/** * @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` — or, on macOS, * out of the login Keychain item the CLI actually reads there * - `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 os = require("node:os"); const path = require("node:path"); const cp = require("node:child_process"); const { getClaudeHome } = require("./claude-home"); /** * On macOS the `claude` CLI keeps `claudeAiOauth` in the login Keychain, NOT in * `/.credentials.json` — a file there is absent or a stale leftover * from an older build. Reading/writing only the file on a Mac is why a switch * reported success while the CLI kept running as the previous account. */ const KEYCHAIN_SERVICE = "Claude Code-credentials"; const KEYCHAIN_LABEL = `macOS Keychain (${KEYCHAIN_SERVICE})`; /** Off with AGENTHUB_CC_KEYCHAIN=0 — tests, and anyone pinning the file. */ function keychainEnabled() { return process.platform === "darwin" && process.env.AGENTHUB_CC_KEYCHAIN !== "0"; } function keychainArgs(verb) { return [verb, "-s", KEYCHAIN_SERVICE, "-a", os.userInfo().username]; } /** The keychain item's JSON, or null when there is none / it is unreadable. */ function readKeychain() { try { const out = cp.execFileSync( "/usr/bin/security", [...keychainArgs("find-generic-password"), "-w"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], } ); const parsed = JSON.parse(out.trim()); return parsed && typeof parsed === "object" ? parsed : null; } catch { return null; } } function writeKeychain(value) { // ponytail: the secret rides in argv (`security` has no stdin mode), so it is // briefly visible in `ps` on a shared machine. Upgrade path is a native // keychain binding if that ever matters. cp.execFileSync( "/usr/bin/security", [...keychainArgs("add-generic-password"), "-U", "-w", JSON.stringify(value)], { stdio: "ignore" } ); } /** * A profile's `name` is its filename, so the charset is deliberately narrow. * It is an internal identifier: what the user reads and edits is `label`, which * accepts spaces and diacritics precisely because it never touches a path. */ const NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,39}$/; const LABEL_MAX = 60; 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 live credentials blob and where it came from. The Keychain wins on macOS * when it holds an item; everything else (and a Mac whose CLI still uses the * file) falls back to `.credentials.json`. */ function readCredentials() { if (keychainEnabled()) { const kc = readKeychain(); if (kc) return { creds: kc, keychain: true }; } return { creds: readJson(credentialsPath()), keychain: false }; } /** Write credentials back to the store they were read from. Returns what was written. */ function writeCredentials(creds, keychain) { if (!keychain) { writeJson(credentialsPath(), creds); return [credentialsPath()]; } writeKeychain(creds); // A stale file beside the Keychain would still name the old account; keep it // in step when it exists, never create one. if (fs.existsSync(credentialsPath())) { writeJson(credentialsPath(), creds); return [KEYCHAIN_LABEL, credentialsPath()]; } return [KEYCHAIN_LABEL]; } /** The account identity currently on disk. `oauth`/`account` may be null. */ function readActiveAuth() { const { creds, keychain } = readCredentials(); 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, keychain }; } /** 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`); } /** * Validate a user-supplied display label. Anything printable is allowed — * spaces and diacritics included — because a label is never used as a path. * Control characters are not: they would corrupt both the stored JSON line and * the row rendered in the UI. */ function normalizeLabel(label) { const clean = typeof label === "string" ? label.trim() : ""; if (!clean || clean.length > LABEL_MAX || /[\u0000-\u001f\u007f]/.test(clean)) { throw err("EBADLABEL", `label must be 1-${LABEL_MAX} characters and no control characters`); } return clean; } /** Reduce any text to the narrow NAME_RE charset, for use as a filename. */ function slugify(text) { const base = String(text || "") .normalize("NFD") .replace(/[\u0300-\u036f]/g, "") // drop the combining accents NFD split off .replace(/[^a-zA-Z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 32); return NAME_RE.test(base) ? base : "profile"; } /** `base`, or `base-2`, `base-3`… — the first that is not already a file. */ function freeName(base) { let name = base; for (let n = 2; fs.existsSync(path.join(profilesDir(), `${name}.json`)); n += 1) { name = `${base}-${n}`; } return name; } /** * The LIVE credentials when `stored` is the account logged in right now, else * null. * * A saved profile is a copy, and an OAuth refresh token is single use: every * time Claude Code refreshes the account it is logged into, the copy sitting in * the profile file is retired. So for the active account the saved credentials * are the one set guaranteed NOT to work, and `.credentials.json` — which the * CLI keeps current — is the only usable source. Nothing here writes: rotating * the token the live session is holding would log the user out of their own * terminal. * * @param stored a profile as returned by `readProfile` * @returns the live `claudeAiOauth` block, or null when this is not that account */ function liveCredentialsFor(stored) { const live = readActiveAuth(); if (!live.oauth) return null; const mine = identityOf(live).accountUuid; const theirs = identityOf({ oauth: stored.credentials, account: stored.account }).accountUuid; return mine && mine === theirs ? live.oauth : null; } /** 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, // Profiles saved before labels existed fall back to their identifier, so // the UI always has something to show and to put in the rename field. label: typeof stored.label === "string" && stored.label.trim() ? stored.label.trim() : name, savedAt: stored.savedAt || null, active: Boolean(identity.accountUuid) && identity.accountUuid === active.accountUuid, ...identity, }); } // Label first (that is what the UI shows), name as the tiebreaker so two // profiles sharing a label still come back in a stable order. items.sort((a, b) => a.label.localeCompare(b.label) || a.name.localeCompare(b.name)); return { items, active: active.accountUuid || active.email ? active : null }; } /** * Snapshot the currently logged-in account into a profile. * * One account is one profile: saving an account that some other profile * already holds is refused (`EDUPLICATE`) rather than silently making a second * copy, because two profiles of one account both report themselves active and * there is then nothing to switch between. Re-saving into the account's OWN * profile is the refresh path and stays allowed. * * @param name Optional identifier/filename. Omitted, one is derived from the * account, which is what the UI does — the user renames the `label` * afterwards instead of having to invent a filename up front. * @returns `{ ok, name, label, ...identity }` */ function saveProfile(name) { const auth = readActiveAuth(); if (!auth.oauth) { throw err( "ENOAUTH", "no Claude account is logged in on this machine - run `claude auth login` first" ); } const identity = identityOf(auth); const readable = identity.displayName || (identity.email || "").split("@")[0] || "profile"; // An explicit name is both identifier and initial label; a derived one keeps // the readable form as the label and only the filename gets flattened. const explicit = name !== undefined && name !== null; let target; if (explicit) { profileFile(name); // validates the charset, throwing EBADNAME target = name; } else { target = freeName(slugify(readable)); } const clash = listProfiles().items.find( (p) => p.accountUuid && p.accountUuid === identity.accountUuid && p.name !== target ); if (clash) { throw err( "EDUPLICATE", `this account is already saved as "${clash.label}" (${clash.name}) - rename that profile instead of saving a second copy of it` ); } // A re-save must not clobber a label the user has since edited. const existing = readProfile(target); const label = (existing && existing.label) || (explicit ? target : readable); fs.mkdirSync(profilesDir(), { recursive: true, mode: 0o700 }); writeJson(profileFile(target), { name: target, label, savedAt: Date.now(), credentials: auth.oauth, account: auth.account, userID: auth.userID, }); return { ok: true, name: target, label, ...identity }; } /** * Change a profile's display label. The `name` (its filename, and the id every * other route takes) deliberately does not move: renaming is a cosmetic edit, * not a re-keying, so it cannot fail halfway and leave a profile unreachable. * * @param name Profile to rename. * @param label New display label; spaces and diacritics are fine. * @returns `{ ok, name, label }` */ function renameProfile(name, label) { const file = profileFile(name); const stored = readProfile(name); if (!stored) throw err("ENOTFOUND", `no profile named "${name}"`); const clean = normalizeLabel(label); const taken = listProfiles().items.find( (p) => p.name !== name && p.label.toLowerCase() === clean.toLowerCase() ); if (taken) { throw err("ELABELTAKEN", `another profile is already called "${taken.label}"`); } writeJson(file, { ...stored, label: clean }); return { ok: true, name, label: clean }; } /** * 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: live, keychain } = readCredentials(); const creds = live || {}; // 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; written.push(...writeCredentials(creds, keychain)); 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. * * Refused only when it holds the LAST saved copy of the live account: that copy * is the credentials' only home outside the files a switch is about to * overwrite. A redundant copy of the live account is deletable, which is what * makes an accidental duplicate cleanable — with a blanket active-guard, two * profiles of one account would both be undeletable forever. */ function deleteProfile(name) { const file = profileFile(name); if (!readProfile(name)) throw err("ENOTFOUND", `no profile named "${name}"`); const items = listProfiles().items; const entry = items.find((p) => p.name === name); if (entry && entry.active) { const otherCopy = items.some((p) => p.name !== name && p.accountUuid === entry.accountUuid); if (!otherCopy) { throw err( "EACTIVE", "cannot delete the only saved copy of the account that is logged in right now - switch away first" ); } } fs.rmSync(file, { force: true }); return { ok: true, name }; } /** * Overwrite a stored profile wholesale. The one caller is `cc-usage.js`, which * has to write a rotated OAuth token pair straight back: the exchange retires * the refresh token it was given, so a profile whose refreshed credentials are * not persisted is a profile that can no longer be activated. */ function writeProfile(name, value) { writeJson(profileFile(name), value); return { ok: true, name }; } module.exports = { listProfiles, saveProfile, renameProfile, activateProfile, deleteProfile, // Token-bearing reads/writes: for cc-usage.js, never for a route. readProfile, writeProfile, liveCredentialsFor, readActiveAuth, // exported for tests profilesDir, configPaths, };