/** * @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 `/.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 * `/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ĩ */ 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, };