feat(cc-config): switch between Claude Code accounts, keeping the workspace

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.
This commit is contained in:
2026-09-07 15:13:38 +07:00
parent 17d56d0e01
commit 4bc77ded17
13 changed files with 1496 additions and 3 deletions
+56
View File
@@ -1509,6 +1509,38 @@ export const api = {
*/
backups: (params?: { scope?: "user" | "project"; type?: CcArtifactType }) =>
requestBackupsHelper(params),
/**
* GET /api/cc-config/profiles - saved auth profiles + the live account.
* @returns `{ items, active }` — {@link CcProfile}s and the currently
* logged-in {@link CcAccountIdentity} (null when nobody is logged in).
*/
profiles: () =>
request<{ items: CcProfile[]; active: CcAccountIdentity | null }>("/cc-config/profiles"),
/**
* POST /api/cc-config/profiles - snapshot the logged-in account under
* `name`, overwriting a profile of that name.
*/
saveProfile: (name: string) =>
request<{ ok: true; name: string } & CcAccountIdentity>("/cc-config/profiles", {
method: "POST",
body: JSON.stringify({ name }),
}),
/**
* POST /api/cc-config/profiles/activate - make a saved profile the account
* the local `claude` CLI runs as. The outgoing account is re-snapshotted
* into its own profile first (`savedOutgoing`).
*/
activateProfile: (name: string) =>
request<{ ok: true; name: string; savedOutgoing: string | null; written: string[] }>(
"/cc-config/profiles/activate",
{ method: "POST", body: JSON.stringify({ name }) }
),
/** DELETE /api/cc-config/profiles - forget a saved profile (never the active one). */
deleteProfile: (name: string) =>
request<{ ok: true; name: string }>("/cc-config/profiles", {
method: "DELETE",
body: JSON.stringify({ name }),
}),
/**
* GET /api/cc-config/plugins/available - every plugin offered by a
* registered marketplace, from `claude plugin list --available --json`.
@@ -2261,6 +2293,30 @@ export interface CcBackup {
project?: string; // present for scope === "auto-memory"
}
/** Identity of one Anthropic account, as reported by the auth-profile
* endpoints. Deliberately token-free — the server never sends credentials to
* the browser, only enough to tell the accounts apart. */
export interface CcAccountIdentity {
accountUuid: string | null;
email: string | null;
displayName: string | null;
organization: string | null;
seatTier: string | null;
subscriptionType: string | null;
/** Access-token expiry, epoch ms; a switch still works past it (the refresh
* token is stored too). */
expiresAt: number | null;
}
/** One saved auth profile from GET /api/cc-config/profiles. */
export interface CcProfile extends CcAccountIdentity {
name: string;
/** When the profile was last snapshotted, epoch ms. */
savedAt: number | null;
/** True when this profile's account is the one logged in right now. */
active: boolean;
}
/** Scope filter accepted by most `ccConfig` list endpoints; "all" merges
* user + project scope in one response. */
export type CcScope = "user" | "project" | "all";