4bc77ded17
Adds a "Switch profile" button left of Backups on /settings?tool=claude-code. A *profile* is one Anthropic account - one quota - saved under a name, so the local `claude` CLI can be pointed at a different account without disturbing anything else. A switch swaps exactly two identity artifacts and nothing more: `claudeAiOauth` inside `<CLAUDE_HOME>/.credentials.json`, and `oauthAccount` + `userID` inside the `.claude.json` config file(s). Those files are key-merged in place, and only where they already exist, so `projects`/`numStartups`/everything else survives. Skills, subagents, commands, memory, plugins, marketplaces, MCP servers and settings are never read or written here - that is what makes a switch keep the whole workspace and change only the account. `mcpOAuth` stays put too: those are the machine's MCP server tokens, not account identity. Profiles are one JSON file each under `<CLAUDE_HOME>/agenthub-profiles/` (dir 0700, files 0600, atomic temp+rename) and hold live OAuth tokens, so no route ever returns token material - `GET /profiles` answers identity metadata only. The dashboard cannot log an account in; only `claude auth login` mints tokens. Adding a profile is therefore always "log in from a terminal, then snapshot what the CLI wrote". `activateProfile` re-snapshots the outgoing account first so tokens Claude Code refreshed since its last save are not lost, and refuses (`EUNSAVED`) to switch away from an account that was never saved - that would discard the only copy of its credentials. A switch applies to new sessions only, since a running session rewrites the config on exit. Routes: GET/POST/DELETE /api/cc-config/profiles, POST /profiles/activate. Verified: server 1420/1420 (14 new, incl. token-free responses and 0600/0700 permissions), client 459/459.
260 lines
8.5 KiB
JavaScript
260 lines
8.5 KiB
JavaScript
/**
|
|
* @file cc-profiles.js
|
|
* @description Named Claude Code auth profiles — swap which Anthropic account
|
|
* (and therefore which quota) the local `claude` CLI runs as, without touching
|
|
* anything else in the Claude home.
|
|
*
|
|
* A profile is exactly the two identity artifacts and nothing more:
|
|
* - `claudeAiOauth` out of `<CLAUDE_HOME>/.credentials.json`
|
|
* - `oauthAccount` + `userID` out of the `.claude.json` config file(s)
|
|
* Skills, subagents, commands, memory, plugins, marketplaces, MCP servers,
|
|
* settings and session history all live elsewhere under CLAUDE_HOME and are
|
|
* deliberately never read or written here — that is what makes a switch keep
|
|
* the whole workspace and change only the account.
|
|
*
|
|
* Profiles are stored one JSON file per profile under
|
|
* `<CLAUDE_HOME>/agenthub-profiles/` (dir 0700, files 0600). They hold live OAuth
|
|
* tokens, so **no route ever returns token material** — `listProfiles` returns
|
|
* identity metadata only. This module cannot mint credentials: a new profile is
|
|
* captured by logging in with `claude auth login` and snapshotting what the CLI
|
|
* wrote.
|
|
*
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
|
|
const { getClaudeHome } = require("./claude-home");
|
|
|
|
/** Profile names double as filenames, so the charset is deliberately narrow. */
|
|
const NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,39}$/;
|
|
|
|
function err(code, message) {
|
|
const e = new Error(message);
|
|
e.code = code;
|
|
return e;
|
|
}
|
|
|
|
function profilesDir() {
|
|
return path.join(getClaudeHome(), "agenthub-profiles");
|
|
}
|
|
|
|
function credentialsPath() {
|
|
return path.join(getClaudeHome(), ".credentials.json");
|
|
}
|
|
|
|
/**
|
|
* Every `.claude.json` that could carry the account identity. Claude Code has
|
|
* shipped both locations (`~/.claude.json` beside the home, and one inside it);
|
|
* a stale copy left behind with the OLD account's `oauthAccount` is exactly the
|
|
* kind of contradiction that makes a switch look like it half-worked, so both
|
|
* are rewritten when they exist.
|
|
*/
|
|
function configPaths() {
|
|
const home = getClaudeHome();
|
|
return [`${home}.json`, path.join(home, ".claude.json")];
|
|
}
|
|
|
|
function readJson(file) {
|
|
let raw;
|
|
try {
|
|
raw = fs.readFileSync(file, "utf8");
|
|
} catch {
|
|
return null;
|
|
}
|
|
try {
|
|
return JSON.parse(raw);
|
|
} catch {
|
|
throw err("EBADJSON", `${file} is not valid JSON - fix it by hand first`);
|
|
}
|
|
}
|
|
|
|
/** Write JSON at 0600 through a temp file, so a crash never truncates the original. */
|
|
function writeJson(file, value) {
|
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
const tmp = `${file}.agenthub-tmp`;
|
|
fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
fs.renameSync(tmp, file);
|
|
}
|
|
|
|
/** The account identity currently on disk. `oauth`/`account` may be null. */
|
|
function readActiveAuth() {
|
|
const creds = readJson(credentialsPath());
|
|
let account = null;
|
|
let userID = null;
|
|
for (const file of configPaths()) {
|
|
const cfg = readJson(file);
|
|
if (cfg && cfg.oauthAccount) {
|
|
account = cfg.oauthAccount;
|
|
userID = typeof cfg.userID === "string" ? cfg.userID : null;
|
|
break;
|
|
}
|
|
}
|
|
return { oauth: (creds && creds.claudeAiOauth) || null, account, userID };
|
|
}
|
|
|
|
/** Token-free description of an account, safe to send to the browser. */
|
|
function identityOf({ oauth, account }) {
|
|
return {
|
|
accountUuid: (account && account.accountUuid) || null,
|
|
email: (account && account.emailAddress) || null,
|
|
displayName: (account && account.displayName) || null,
|
|
organization: (account && account.organizationName) || null,
|
|
seatTier: (account && account.seatTier) || null,
|
|
subscriptionType: (oauth && oauth.subscriptionType) || null,
|
|
/** When the stored access token expires; a switch still works after that
|
|
* (the refresh token is stored too), it just refreshes on first use. */
|
|
expiresAt: (oauth && oauth.expiresAt) || null,
|
|
};
|
|
}
|
|
|
|
function profileFile(name) {
|
|
if (typeof name !== "string" || !NAME_RE.test(name)) {
|
|
throw err("EBADNAME", "name must be 1-40 chars of letters, digits, dot, dash or underscore");
|
|
}
|
|
return path.join(profilesDir(), `${name}.json`);
|
|
}
|
|
|
|
/** Read one stored profile, or null when it does not exist. */
|
|
function readProfile(name) {
|
|
const stored = readJson(profileFile(name));
|
|
return stored && typeof stored === "object" ? stored : null;
|
|
}
|
|
|
|
/**
|
|
* Every stored profile plus the account that is live right now.
|
|
* @returns `{ items, active }` — `items[].active` marks the one whose
|
|
* `accountUuid` matches the live config. Tokens are never included.
|
|
*/
|
|
function listProfiles() {
|
|
const active = identityOf(readActiveAuth());
|
|
let names = [];
|
|
try {
|
|
names = fs
|
|
.readdirSync(profilesDir())
|
|
.filter((f) => f.endsWith(".json"))
|
|
.map((f) => f.slice(0, -5));
|
|
} catch {
|
|
/* no profiles saved yet */
|
|
}
|
|
const items = [];
|
|
for (const name of names) {
|
|
if (!NAME_RE.test(name)) continue;
|
|
let stored;
|
|
try {
|
|
stored = readProfile(name);
|
|
} catch {
|
|
continue; // a corrupt profile file is skipped, not fatal for the list
|
|
}
|
|
if (!stored) continue;
|
|
const identity = identityOf({ oauth: stored.credentials, account: stored.account });
|
|
items.push({
|
|
name,
|
|
savedAt: stored.savedAt || null,
|
|
active: Boolean(identity.accountUuid) && identity.accountUuid === active.accountUuid,
|
|
...identity,
|
|
});
|
|
}
|
|
items.sort((a, b) => a.name.localeCompare(b.name));
|
|
return { items, active: active.accountUuid || active.email ? active : null };
|
|
}
|
|
|
|
/**
|
|
* Snapshot the currently logged-in account into a profile, overwriting any
|
|
* profile of the same name.
|
|
* @param name Profile name (also its filename).
|
|
* @returns `{ ok, name, ...identity }`
|
|
*/
|
|
function saveProfile(name) {
|
|
const file = profileFile(name);
|
|
const auth = readActiveAuth();
|
|
if (!auth.oauth) {
|
|
throw err(
|
|
"ENOAUTH",
|
|
"no Claude account is logged in on this machine - run `claude auth login` first"
|
|
);
|
|
}
|
|
fs.mkdirSync(profilesDir(), { recursive: true, mode: 0o700 });
|
|
writeJson(file, {
|
|
name,
|
|
savedAt: Date.now(),
|
|
credentials: auth.oauth,
|
|
account: auth.account,
|
|
userID: auth.userID,
|
|
});
|
|
return { ok: true, name, ...identityOf(auth) };
|
|
}
|
|
|
|
/**
|
|
* Make a stored profile the live account.
|
|
*
|
|
* The outgoing account is snapshotted back into whichever profile it came from
|
|
* first, so its refreshed tokens are not lost — and a switch away from an
|
|
* account that was never saved is refused rather than silently discarding the
|
|
* only copy of its credentials.
|
|
*
|
|
* @param name Profile to activate.
|
|
* @returns `{ ok, name, savedOutgoing, written }`
|
|
*/
|
|
function activateProfile(name) {
|
|
const target = readProfile(name);
|
|
if (!target || !target.credentials) throw err("ENOTFOUND", `no profile named "${name}"`);
|
|
|
|
const current = readActiveAuth();
|
|
let savedOutgoing = null;
|
|
if (current.oauth) {
|
|
const mine = listProfiles().items.find((p) => p.active);
|
|
if (!mine) {
|
|
throw err(
|
|
"EUNSAVED",
|
|
"the account logged in right now is not saved as a profile - save it first, or its credentials are lost on switch"
|
|
);
|
|
}
|
|
if (mine.name !== name) savedOutgoing = saveProfile(mine.name).name;
|
|
}
|
|
|
|
const written = [];
|
|
const creds = readJson(credentialsPath()) || {};
|
|
// Only the Claude account slot moves; `mcpOAuth` holds this machine's MCP
|
|
// server tokens, which are not part of the account identity.
|
|
creds.claudeAiOauth = target.credentials;
|
|
writeJson(credentialsPath(), creds);
|
|
written.push(credentialsPath());
|
|
|
|
for (const file of configPaths()) {
|
|
const cfg = readJson(file);
|
|
if (!cfg) continue;
|
|
if (target.account) cfg.oauthAccount = target.account;
|
|
else delete cfg.oauthAccount;
|
|
if (target.userID) cfg.userID = target.userID;
|
|
writeJson(file, cfg);
|
|
written.push(file);
|
|
}
|
|
|
|
return { ok: true, name, savedOutgoing, written };
|
|
}
|
|
|
|
/** Delete a stored profile. Refuses the active one — that is the live account's
|
|
* only credential copy outside the files it is about to be swapped out of. */
|
|
function deleteProfile(name) {
|
|
const file = profileFile(name);
|
|
if (!readProfile(name)) throw err("ENOTFOUND", `no profile named "${name}"`);
|
|
const entry = listProfiles().items.find((p) => p.name === name);
|
|
if (entry && entry.active) {
|
|
throw err("EACTIVE", "cannot delete the profile that is currently active - switch away first");
|
|
}
|
|
fs.rmSync(file, { force: true });
|
|
return { ok: true, name };
|
|
}
|
|
|
|
module.exports = {
|
|
listProfiles,
|
|
saveProfile,
|
|
activateProfile,
|
|
deleteProfile,
|
|
// exported for tests
|
|
profilesDir,
|
|
configPaths,
|
|
};
|