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:
@@ -4,7 +4,8 @@
|
||||
* every read surface (skills, agents, commands, output styles, plugins, MCP
|
||||
* servers, hooks, settings, memory, marketplaces, keybindings, statusline,
|
||||
* hook scripts, single-file body) plus the low-risk mutation surface
|
||||
* (PUT/DELETE /file) and the backups listing.
|
||||
* (PUT/DELETE /file), the backups listing, and the auth-profile surface
|
||||
* (`/profiles*` — swap which Anthropic account the local CLI runs as).
|
||||
*
|
||||
* Exports `{ tags, schemas, paths }` for deep-merge by
|
||||
* `server/openapi-extra.js`. All schema names are prefixed with `CcConfig`
|
||||
@@ -1183,6 +1184,95 @@ const schemas = {
|
||||
},
|
||||
},
|
||||
},
|
||||
CcConfigAccountIdentity: {
|
||||
type: "object",
|
||||
description:
|
||||
"Identity of one Anthropic account. Deliberately token-free - the auth-profile endpoints never return credential material, only enough to tell accounts apart.",
|
||||
properties: {
|
||||
accountUuid: {
|
||||
type: "string",
|
||||
nullable: true,
|
||||
example: "c9b05d52-391c-449b-a6b1-e161edea7d8a",
|
||||
},
|
||||
email: { type: "string", nullable: true, example: "you@example.com" },
|
||||
displayName: { type: "string", nullable: true, example: "Vĩ" },
|
||||
organization: { type: "string", nullable: true, example: "Smartgift" },
|
||||
seatTier: { type: "string", nullable: true, example: "team_standard" },
|
||||
subscriptionType: { type: "string", nullable: true, example: "max" },
|
||||
expiresAt: {
|
||||
type: "integer",
|
||||
nullable: true,
|
||||
description:
|
||||
"Access-token expiry, epoch ms. A switch still works past it - the refresh token is stored too.",
|
||||
example: 1788776815474,
|
||||
},
|
||||
},
|
||||
},
|
||||
CcConfigProfile: {
|
||||
allOf: [
|
||||
{ $ref: "#/components/schemas/CcConfigAccountIdentity" },
|
||||
{
|
||||
type: "object",
|
||||
required: ["name", "active"],
|
||||
properties: {
|
||||
name: {
|
||||
type: "string",
|
||||
description:
|
||||
"Profile name; also its filename under `<CLAUDE_HOME>/agenthub-profiles/`.",
|
||||
pattern: "^[a-zA-Z0-9][a-zA-Z0-9._-]{0,39}$",
|
||||
example: "work",
|
||||
},
|
||||
savedAt: {
|
||||
type: "integer",
|
||||
nullable: true,
|
||||
description: "When the profile was last snapshotted, epoch ms.",
|
||||
example: 1788743353379,
|
||||
},
|
||||
active: {
|
||||
type: "boolean",
|
||||
description: "True when this profile's account is the one logged in right now.",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
CcConfigProfilesResponse: {
|
||||
type: "object",
|
||||
required: ["items", "active"],
|
||||
properties: {
|
||||
items: {
|
||||
type: "array",
|
||||
description: "Saved profiles, sorted by name.",
|
||||
items: { $ref: "#/components/schemas/CcConfigProfile" },
|
||||
},
|
||||
active: {
|
||||
nullable: true,
|
||||
description: "The account logged in right now, or null when nobody is.",
|
||||
allOf: [{ $ref: "#/components/schemas/CcConfigAccountIdentity" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
CcConfigProfileActivateResult: {
|
||||
type: "object",
|
||||
required: ["ok", "name", "savedOutgoing", "written"],
|
||||
properties: {
|
||||
ok: { type: "boolean", example: true },
|
||||
name: { type: "string", example: "work" },
|
||||
savedOutgoing: {
|
||||
type: "string",
|
||||
nullable: true,
|
||||
description:
|
||||
"Profile the outgoing account was re-snapshotted into (so tokens Claude Code refreshed since its last save are not lost), or null when the target was already active.",
|
||||
example: "personal",
|
||||
},
|
||||
written: {
|
||||
type: "array",
|
||||
description: "Absolute paths rewritten by the switch.",
|
||||
items: { type: "string" },
|
||||
example: ["/Users/son/.claude/.credentials.json", "/Users/son/.claude.json"],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ── Reusable parameters (inlined per-operation to match base style) ────
|
||||
@@ -2074,6 +2164,189 @@ const paths = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/cc-config/profiles": {
|
||||
get: {
|
||||
tags: ["CcConfig"],
|
||||
summary: "List Claude Code auth profiles",
|
||||
description:
|
||||
"Read-only. A *profile* is one Anthropic account - one quota - saved under a name, so the local `claude` CLI can be switched between accounts while every other config surface stays put. Profiles live one JSON file per profile under `<CLAUDE_HOME>/agenthub-profiles/` (dir 0700, files 0600) and hold live OAuth tokens, so this endpoint returns identity metadata ONLY - never token material.",
|
||||
operationId: "ccConfigGetProfiles",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Saved profiles plus the account logged in right now.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/CcConfigProfilesResponse" },
|
||||
},
|
||||
},
|
||||
},
|
||||
409: {
|
||||
description: "`EBADJSON` - a credentials/config file on disk is not valid JSON.",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ["CcConfig"],
|
||||
summary: "Save the logged-in account as a profile",
|
||||
description:
|
||||
"Snapshots the account currently logged in (its `claudeAiOauth` blob plus `oauthAccount`/`userID`) into a profile, overwriting a profile of the same name. The dashboard CANNOT log an account in - only `claude auth login` mints tokens - so adding another account is always: log in from a terminal, then call this to capture what the CLI wrote.",
|
||||
operationId: "ccConfigSaveProfile",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["name"],
|
||||
properties: {
|
||||
name: {
|
||||
type: "string",
|
||||
pattern: "^[a-zA-Z0-9][a-zA-Z0-9._-]{0,39}$",
|
||||
description: "Profile name; also its filename.",
|
||||
example: "work",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "The saved profile's identity.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
allOf: [
|
||||
{ $ref: "#/components/schemas/CcConfigAccountIdentity" },
|
||||
{
|
||||
type: "object",
|
||||
properties: { ok: { type: "boolean" }, name: { type: "string" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: "`EBADNAME` - the name is not filename-safe.",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||
},
|
||||
},
|
||||
409: {
|
||||
description:
|
||||
"`ENOAUTH` - no Claude account is logged in to snapshot; `EBADJSON` - a config file is unparseable.",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["CcConfig"],
|
||||
summary: "Forget a saved auth profile",
|
||||
description:
|
||||
"Deletes the stored profile file. The identifier travels in the body, not the URL (same reason as `DELETE /file`). Refuses the ACTIVE profile: it holds the live account's only credential copy outside the files a switch is about to overwrite.",
|
||||
operationId: "ccConfigDeleteProfile",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["name"],
|
||||
properties: { name: { type: "string", example: "work" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Profile deleted.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: { ok: { type: "boolean" }, name: { type: "string" } },
|
||||
},
|
||||
example: { ok: true, name: "work" },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: "`EBADNAME` - the name is not filename-safe.",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "`ENOTFOUND` - no profile by that name.",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||
},
|
||||
},
|
||||
409: {
|
||||
description: "`EACTIVE` - refusing to delete the profile that is currently active.",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/cc-config/profiles/activate": {
|
||||
post: {
|
||||
tags: ["CcConfig"],
|
||||
summary: "Switch the local CLI to another Anthropic account",
|
||||
description:
|
||||
"Makes a saved profile the live account. Swaps EXACTLY two identity artifacts and nothing else: `claudeAiOauth` inside `<CLAUDE_HOME>/.credentials.json`, and `oauthAccount` + `userID` inside the `.claude.json` config file(s) (key-merged in place, and only where the file already exists, so `projects`/`numStartups`/everything else survives). Skills, subagents, commands, memory, plugins, marketplaces, MCP servers and settings are never touched - that is what makes a switch keep the whole workspace and change only the quota. `mcpOAuth` stays put too (machine MCP tokens, not account identity).\n\nThe outgoing account is re-snapshotted into its own profile FIRST, so tokens Claude Code refreshed since its last save are not lost. A switch takes effect for NEW Claude Code sessions only, and a session still running will rewrite the config on exit - close them first.",
|
||||
operationId: "ccConfigActivateProfile",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["name"],
|
||||
properties: { name: { type: "string", example: "work" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "The switch completed; `written` lists the rewritten paths.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/CcConfigProfileActivateResult" },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: "`EBADNAME` - the name is not filename-safe.",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "`ENOTFOUND` - no profile by that name.",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||
},
|
||||
},
|
||||
409: {
|
||||
description:
|
||||
"`EUNSAVED` - the account logged in right now is not saved as a profile, and switching away would discard the only copy of its credentials; `EBADJSON` - a config file is unparseable, nothing was written.",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = { tags, schemas, paths };
|
||||
|
||||
Reference in New Issue
Block a user