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
+261
View File
@@ -2903,6 +2903,103 @@ components:
description: Backups across the requested scope/type buckets, sorted newest first.
items:
$ref: '#/components/schemas/CcConfigBackup'
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
RunHandle:
type: object
description: Live, in-memory view of a spawned `claude` subprocess. Returned by GET /api/run (in `items`), POST /api/run (201), and GET /api/run/{id}. Backed by the spawner's handle map, which reaps each handle 5 minutes after the process exits — after that the run is only visible via GET /api/run/history.
@@ -8090,6 +8187,170 @@ paths:
isDir: false
mtime: 1718900000000
size: 2048
/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).
The 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'
/api/run:
get:
tags: