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:
+3
-2
@@ -348,7 +348,8 @@ graph TD
|
|||||||
| `server/lib/scoped-stats.js` | Source-scoped variants of the stats/analytics aggregates, used **only** when a `?sources=` scope filter is active — the unscoped fast paths in `routes/stats.js` / `routes/analytics.js` are untouched when no scope is set |
|
| `server/lib/scoped-stats.js` | Source-scoped variants of the stats/analytics aggregates, used **only** when a `?sources=` scope filter is active — the unscoped fast paths in `routes/stats.js` / `routes/analytics.js` are untouched when no scope is set |
|
||||||
| `lib/cc-discovery.js` | Read-only discovery of every Claude Code config surface for the Config Explorer page. Pure file reads; never writes. Surfaces: skills (`<root>/skills/<name>/SKILL.md`), subagents (`<root>/agents/*.md`), slash commands (`<root>/commands/*.md`), output styles (`<root>/output-styles/*.md`), plugins (`<CLAUDE_HOME>/plugins/installed_plugins.json` joined with `enabledPlugins` in settings + per-plugin `contributes` counts **and** `contributes.items`, which names every skill / subagent / command / output style the plugin ships — each surface read from the conventional install subdirectory *and* from whatever path `plugin.json` declares for that key, with declared paths outside the plugin's own install dir dropped by `isUnder()`, so a plugin that lists its commands explicitly and keeps `commands/` empty no longer reports zero + `plugin.json` metadata), marketplaces (`known_marketplaces.json` enriched with each `marketplace.json`), MCP servers (top-level + per-project from `~/.claude.json`), hooks (across user / project / project-local settings.json), keybindings (`<CLAUDE_HOME>/keybindings.json`), statusline config + `statusline.py` / `statusline-command.sh` content, hook scripts dir (`<CLAUDE_HOME>/hooks/`), settings (with secret-key redaction matching `/token\|secret\|password\|api[_-]?key\|auth/i`), memory (`CLAUDE.md` at user + project **plus** the per-project file-based auto-memory store — every `*.md` under `~/.claude/projects/<slug>/memory/`, returned as `scope:"auto-memory"` items carrying `project`, `name`, `isIndex`, and parsed `frontmatter`, so a `MEMORY.md` index and one file per remembered fact, often 100+, all surface). Path containment via `isUnder()` — every read must resolve under CLAUDE_HOME, project `.claude/`, or be a project CLAUDE.md. 256 KB read cap. Minimal YAML frontmatter parser handles `key: value` + quoted strings + indented continuation lines |
|
| `lib/cc-discovery.js` | Read-only discovery of every Claude Code config surface for the Config Explorer page. Pure file reads; never writes. Surfaces: skills (`<root>/skills/<name>/SKILL.md`), subagents (`<root>/agents/*.md`), slash commands (`<root>/commands/*.md`), output styles (`<root>/output-styles/*.md`), plugins (`<CLAUDE_HOME>/plugins/installed_plugins.json` joined with `enabledPlugins` in settings + per-plugin `contributes` counts **and** `contributes.items`, which names every skill / subagent / command / output style the plugin ships — each surface read from the conventional install subdirectory *and* from whatever path `plugin.json` declares for that key, with declared paths outside the plugin's own install dir dropped by `isUnder()`, so a plugin that lists its commands explicitly and keeps `commands/` empty no longer reports zero + `plugin.json` metadata), marketplaces (`known_marketplaces.json` enriched with each `marketplace.json`), MCP servers (top-level + per-project from `~/.claude.json`), hooks (across user / project / project-local settings.json), keybindings (`<CLAUDE_HOME>/keybindings.json`), statusline config + `statusline.py` / `statusline-command.sh` content, hook scripts dir (`<CLAUDE_HOME>/hooks/`), settings (with secret-key redaction matching `/token\|secret\|password\|api[_-]?key\|auth/i`), memory (`CLAUDE.md` at user + project **plus** the per-project file-based auto-memory store — every `*.md` under `~/.claude/projects/<slug>/memory/`, returned as `scope:"auto-memory"` items carrying `project`, `name`, `isIndex`, and parsed `frontmatter`, so a `MEMORY.md` index and one file per remembered fact, often 100+, all surface). Path containment via `isUnder()` — every read must resolve under CLAUDE_HOME, project `.claude/`, or be a project CLAUDE.md. 256 KB read cap. Minimal YAML frontmatter parser handles `key: value` + quoted strings + indented continuation lines |
|
||||||
| `lib/cc-mutate.js` | Create / overwrite / delete for the **low-risk text-file surfaces only** (skills, subagents, slash commands, output styles, memory — including the per-project file-based auto-memory store, mutated via `scope: "auto-memory"`, `type: "auto-memory"`, `project`, `name`, with its backups landing in `<memory-dir>/.cc-config-backups/auto-memory/`), plus `writeKeybindings()` for the structured `keybindings.json` editor (read-modify-write that preserves top-level metadata, rejects duplicate contexts/keys, and backs up to `<CLAUDE_HOME>/cc-config-backups/keybindings/`). Plugins, MCP, hooks-in-settings, and `settings.json` files are NEVER written from here — they have concurrent-write races with the live Claude Code CLI. Every mutation creates a timestamped backup at `<root>/cc-config-backups/<type>/<base>.<ISO>.bak[.dir]` BEFORE the change — backups land outside the directories Claude Code scans, so a deleted skill cannot resurface as a backup-named one. Writes are atomic: temp file in same dir → fsync → `renameSync`. Tmp removed on every failure path. Skill dirs are backed up whole (preserving bundled assets) before recursive removal. Strict `name` regex (`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`), 256 KB content cap, double-checked path containment via `isUnder()` |
|
| `lib/cc-mutate.js` | Create / overwrite / delete for the **low-risk text-file surfaces only** (skills, subagents, slash commands, output styles, memory — including the per-project file-based auto-memory store, mutated via `scope: "auto-memory"`, `type: "auto-memory"`, `project`, `name`, with its backups landing in `<memory-dir>/.cc-config-backups/auto-memory/`), plus `writeKeybindings()` for the structured `keybindings.json` editor (read-modify-write that preserves top-level metadata, rejects duplicate contexts/keys, and backs up to `<CLAUDE_HOME>/cc-config-backups/keybindings/`). Plugins, MCP, hooks-in-settings, and `settings.json` files are NEVER written from here — they have concurrent-write races with the live Claude Code CLI. Every mutation creates a timestamped backup at `<root>/cc-config-backups/<type>/<base>.<ISO>.bak[.dir]` BEFORE the change — backups land outside the directories Claude Code scans, so a deleted skill cannot resurface as a backup-named one. Writes are atomic: temp file in same dir → fsync → `renameSync`. Tmp removed on every failure path. Skill dirs are backed up whole (preserving bundled assets) before recursive removal. Strict `name` regex (`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`), 256 KB content cap, double-checked path containment via `isUnder()` |
|
||||||
| `routes/cc-config.js` | HTTP surface for the Claude Config Explorer. Read endpoints for every surface (skills, agents, commands, output-styles, plugins, marketplaces, mcp, hooks, hook-scripts, keybindings, statusline, settings, memory, file, overview), plus mutation endpoints (`PUT /file`, `DELETE /file`, and a structured `PUT /keybindings`) that delegate to `cc-mutate.js`, plus a `GET /backups` listing for the recovery modal. After every successful PUT/DELETE the route broadcasts `cc_config_changed` over the WebSocket so any open `/cc-config` tab refetches without polling. All errors return structured `{error: {code, message}}` shapes mapped to 400/404/413/500 statuses |
|
| `routes/cc-config.js` | HTTP surface for the Claude Config Explorer. Read endpoints for every surface (skills, agents, commands, output-styles, plugins, marketplaces, mcp, hooks, hook-scripts, keybindings, statusline, settings, memory, file, overview), plus mutation endpoints (`PUT /file`, `DELETE /file`, and a structured `PUT /keybindings`) that delegate to `cc-mutate.js`, plus a `GET /backups` listing for the recovery modal, plus the auth-profile routes (`GET/POST/DELETE /profiles`, `POST /profiles/activate`) that delegate to `cc-profiles.js`. After every successful PUT/DELETE the route broadcasts `cc_config_changed` over the WebSocket so any open `/cc-config` tab refetches without polling. All errors return structured `{error: {code, message}}` shapes mapped to 400/404/413/500 statuses |
|
||||||
|
| `lib/cc-profiles.js` | Named Claude Code **auth profiles** — swap which Anthropic account (and therefore which quota) the local `claude` CLI runs as. A profile is exactly two identity artifacts: `claudeAiOauth` out of `<CLAUDE_HOME>/.credentials.json`, and `oauthAccount` + `userID` out of the `.claude.json` config file(s) (`<CLAUDE_HOME>.json` and `<CLAUDE_HOME>/.claude.json`, each key-merged in place and only when it already exists, 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 (machine MCP tokens, not account identity). Stored one JSON file per profile under `<CLAUDE_HOME>/agenthub-profiles/` (dir `0700`, files `0600`), holding live OAuth tokens, so **no route ever returns token material** — `listProfiles()` answers identity metadata only. Cannot mint credentials (only `claude auth login` can): a new profile is captured by logging in, then snapshotting. `activateProfile()` re-snapshots the outgoing account first so refreshed tokens 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. Atomic temp + rename at `0600` |
|
||||||
| `lib/cc-watcher.js` | Best-effort `fs.watch` over `~/.claude/` (recursive where the platform / Node version honors it — macOS / Windows always; Linux from Node 20) plus `~/.claude.json`. Coalesces bursts at 500 ms and broadcasts `cc_config_changed` with `{ source: "fs", paths: [...] }` so the Config Explorer picks up changes from external tools (CLI installs a plugin, manual `settings.json` edits, dropping a new skill) without a manual refresh. Started from `server/index.js` after the HTTP server boots; failures are caught and logged so a flaky watcher can't take the server down |
|
| `lib/cc-watcher.js` | Best-effort `fs.watch` over `~/.claude/` (recursive where the platform / Node version honors it — macOS / Windows always; Linux from Node 20) plus `~/.claude.json`. Coalesces bursts at 500 ms and broadcasts `cc_config_changed` with `{ source: "fs", paths: [...] }` so the Config Explorer picks up changes from external tools (CLI installs a plugin, manual `settings.json` edits, dropping a new skill) without a manual refresh. Started from `server/index.js` after the HTTP server boots; failures are caught and logged so a flaky watcher can't take the server down |
|
||||||
| `lib/tmux.js` | Wrapper around tmux CLI for session management. `createSession(sessionName, cwd)` creates a new tmux session in the specified working directory. `sendCommand(sessionName, command)` sends a command into the session. `killSession(sessionName)` terminates the session. `listSessions()` returns all active sessions. Session management is the foundation for the PTY transport layer |
|
| `lib/tmux.js` | Wrapper around tmux CLI for session management. `createSession(sessionName, cwd)` creates a new tmux session in the specified working directory. `sendCommand(sessionName, command)` sends a command into the session. `killSession(sessionName)` terminates the session. `listSessions()` returns all active sessions. Session management is the foundation for the PTY transport layer |
|
||||||
| `lib/pty-run.js` | PTY lifecycle for tmux-backed runs. Manages one tmux session per lane, named `agenthub-lane-<lane.id>`. Exports `startRun()` to create/attach a session and return a `runId` opaque handle; internally uses tmux to manage the pseudoterminal. Spawned `claude` processes run inside the session and fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, the analytics, the Kanban board, and the Workflows page automatically. The PTY frames (terminal input/output deltas) are streamed to the client over `/ws-pty/:runId` (see `server/websocket.js`) at binary frame granularity rather than as JSON envelopes; the client's xterm.js terminal widget renders these raw PTY updates live |
|
| `lib/pty-run.js` | PTY lifecycle for tmux-backed runs. Manages one tmux session per lane, named `agenthub-lane-<lane.id>`. Exports `startRun()` to create/attach a session and return a `runId` opaque handle; internally uses tmux to manage the pseudoterminal. Spawned `claude` processes run inside the session and fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, the analytics, the Kanban board, and the Workflows page automatically. The PTY frames (terminal input/output deltas) are streamed to the client over `/ws-pty/:runId` (see `server/websocket.js`) at binary frame granularity rather than as JSON envelopes; the client's xterm.js terminal widget renders these raw PTY updates live |
|
||||||
@@ -613,7 +614,7 @@ graph LR
|
|||||||
| `/sessions/:id` | SessionDetail | `GET /api/sessions/:id` (agents + events), `GET /api/sessions/:id/stats` (overview tiles, top tools, subagent breakdown, token totals — debounced live-refresh on `new_event`/`agent_*`/`session_updated`), `GET /api/sessions/:id/transcripts` (Conversation tab transcript list), `GET /api/sessions/:id/transcript` (cursor-paginated message stream, including inline `session_event` rename markers; optional `run_id` resolves a workflow inner agent's nested transcript). Probes `/api/run` (and listens for `run_status`) to surface a green "Open in Run page" banner when this session is currently being driven by an in-flight Run handle. When the session sits in the Waiting overlay, renders a yellow waiting-for-input banner naming the `awaiting_reason` (Needs input / Turn done / At prompt / Interrupted) and how long it has waited; every Waiting `StatusBadge` across the app carries the same reason as a hover tooltip, with an inline nested chip on the wider surfaces (Sessions table, session-detail header) — compact card badges (Dashboard) stay tooltip-only |
|
| `/sessions/:id` | SessionDetail | `GET /api/sessions/:id` (agents + events), `GET /api/sessions/:id/stats` (overview tiles, top tools, subagent breakdown, token totals — debounced live-refresh on `new_event`/`agent_*`/`session_updated`), `GET /api/sessions/:id/transcripts` (Conversation tab transcript list), `GET /api/sessions/:id/transcript` (cursor-paginated message stream, including inline `session_event` rename markers; optional `run_id` resolves a workflow inner agent's nested transcript). Probes `/api/run` (and listens for `run_status`) to surface a green "Open in Run page" banner when this session is currently being driven by an in-flight Run handle. When the session sits in the Waiting overlay, renders a yellow waiting-for-input banner naming the `awaiting_reason` (Needs input / Turn done / At prompt / Interrupted) and how long it has waited; every Waiting `StatusBadge` across the app carries the same reason as a hover tooltip, with an inline nested chip on the wider surfaces (Sessions table, session-detail header) — compact card badges (Dashboard) stay tooltip-only |
|
||||||
| `/activity` | ActivityFeed | `GET /api/events?limit=100` — click row to expand inline payload; "Session →" button navigates to `/sessions/:id` |
|
| `/activity` | ActivityFeed | `GET /api/events?limit=100` — click row to expand inline payload; "Session →" button navigates to `/sessions/:id` |
|
||||||
| `/workflows` | Workflows | `GET /api/workflows?status=active\|completed`, `GET /api/workflows/session/:id` + WebSocket auto-refresh (3s debounce) |
|
| `/workflows` | Workflows | `GET /api/workflows?status=active\|completed`, `GET /api/workflows/session/:id` + WebSocket auto-refresh (3s debounce) |
|
||||||
| `/cc-config` | CcConfig | 12-tab Claude Code configuration explorer. Reads via `GET /api/cc-config/{overview,skills,agents,commands,output-styles,plugins,marketplaces,mcp,hooks,hook-scripts,keybindings,statusline,settings,memory}`. Mutations for skills/agents/commands/output-styles/memory — including the per-project file-based auto-memory store (`*.md` under `~/.claude/projects/<slug>/memory/`, grouped by project and searchable in the Memory tab, with clickable `MEMORY.md` index links that scroll to + highlight the matching fact file) — via `PUT /api/cc-config/file` + `DELETE /api/cc-config/file` (timestamped backups, atomic writes). The Keybindings tab additionally offers a structured inline editor that persists via `PUT /api/cc-config/keybindings` (same backup-first, atomic-write guarantees). `GET /api/cc-config/file?path=…` for single-file viewer. `GET /api/cc-config/backups` for the recovery modal. Subscribes to `cc_config_changed` WS messages for live refresh on both dashboard mutations and external file edits picked up by `cc-watcher`. The Settings tab leads with a client-side **Current configuration** summary that resolves the `/config` options (model, verbose, theme, output style, effort, auto-compact, notifications, …) across user / project / project-local scopes, showing defaults when unset. Live / Offline indicator next to the title |
|
| `/cc-config` | CcConfig | 12-tab Claude Code configuration explorer. Reads via `GET /api/cc-config/{overview,skills,agents,commands,output-styles,plugins,marketplaces,mcp,hooks,hook-scripts,keybindings,statusline,settings,memory}`. Mutations for skills/agents/commands/output-styles/memory — including the per-project file-based auto-memory store (`*.md` under `~/.claude/projects/<slug>/memory/`, grouped by project and searchable in the Memory tab, with clickable `MEMORY.md` index links that scroll to + highlight the matching fact file) — via `PUT /api/cc-config/file` + `DELETE /api/cc-config/file` (timestamped backups, atomic writes). The Keybindings tab additionally offers a structured inline editor that persists via `PUT /api/cc-config/keybindings` (same backup-first, atomic-write guarantees). `GET /api/cc-config/file?path=…` for single-file viewer. `GET /api/cc-config/backups` for the recovery modal. A **Switch profile** button left of Backups opens the auth-profile modal (`/api/cc-config/profiles*`): save the logged-in Anthropic account under a name, then switch between accounts — only the login is swapped, so skills / subagents / memory / plugins / marketplaces / settings stay put. It cannot log in for you (`claude auth login` does that) and a switch applies to new sessions only. Subscribes to `cc_config_changed` WS messages for live refresh on both dashboard mutations and external file edits picked up by `cc-watcher`. The Settings tab leads with a client-side **Current configuration** summary that resolves the `/config` options (model, verbose, theme, output style, effort, auto-compact, notifications, …) across user / project / project-local scopes, showing defaults when unset. Live / Offline indicator next to the title |
|
||||||
| `/run` | Workspace | Merged workspace page combining lanes and runs. Attaches to a tmux-backed pseudoterminal tied to a lane: the UI selects a lane, calls `POST /api/run` with that lane's `id`, and receives a `runId` + tmux session name. The Workspace displays a horizontal lane strip at the top, the selected lane's pipeline map, and a real interactive terminal (xterm.js) fed by `/ws-pty/:runId` binary frames below. `TerminalView` re-attaches 1.5 s after any unexpected socket close (server restart included) and stops only on the server's `exit` frame — a dead socket used to leave the pane frozen on its last frame, looking live while swallowing every keystroke and mouse report. It also calls `preventDefault()` on Shift+Tab (keeping xterm's own handling): xterm emits `ESC[Z` for that combo but, unlike plain Tab, never marks the event cancelled, so the browser's default moved focus out of the terminal and the pane saw only the first press — `shift+tab to cycle` (claude's permission mode) looked dead in the browser while working in a real terminal. It also installs a custom wheel handler that returns `false`: without it, xterm falls back on an alt-screen buffer to translating each wheel notch into a cursor-key press (`ESC[A`/`ESC[B`), which a `claude` pane reads as arrow up/down and uses to walk the prompt history instead of scrolling. That handler suppresses only the emulation branch — real mouse reports are emitted by a separate listener xterm registers when the pane's program enables mouse tracking. Its xterm viewport scrollbar is hidden by design (`.xterm-viewport` rule in `client/src/index.css`): tmux repaints the whole pane, so xterm's scrollback is always empty and scrolling happens via mouse reports forwarded to the pane's program, not the DOM. Pre-flight: `GET /api/run/{tmux,binary,cwds,files}` for tmux availability + `claude` binary check + `@`-file autocomplete. Start/resume: `POST /api/run` (requires `laneId`; optionally accepts `prompt` to send immediately); `GET /api/run/:id` (returns handle); `DELETE /api/run/:id` (stops). History: `GET /api/run/history?laneId=<n>` lists only that lane's runs. PTY streaming: `/ws-pty/:runId` delivers raw PTY frames as binary WebSocket frames — no JSON envelope overhead, direct to xterm.js for live rendering; the same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub lanes shell` CLI, other tools), all synced live. Lane self-heal: `GET /api/lanes/:id` auto-corrects `run_id`/`status` if the tmux session has been killed externally. Tier 1 TUI parity: tmux session is a real shell, not headless — supports editors, pagers, interactive subcommands. **The console never writes a lane's stage** — stage moves only through `agenthub stage` commands. Live / Offline indicator next to the title |
|
| `/run` | Workspace | Merged workspace page combining lanes and runs. Attaches to a tmux-backed pseudoterminal tied to a lane: the UI selects a lane, calls `POST /api/run` with that lane's `id`, and receives a `runId` + tmux session name. The Workspace displays a horizontal lane strip at the top, the selected lane's pipeline map, and a real interactive terminal (xterm.js) fed by `/ws-pty/:runId` binary frames below. `TerminalView` re-attaches 1.5 s after any unexpected socket close (server restart included) and stops only on the server's `exit` frame — a dead socket used to leave the pane frozen on its last frame, looking live while swallowing every keystroke and mouse report. It also calls `preventDefault()` on Shift+Tab (keeping xterm's own handling): xterm emits `ESC[Z` for that combo but, unlike plain Tab, never marks the event cancelled, so the browser's default moved focus out of the terminal and the pane saw only the first press — `shift+tab to cycle` (claude's permission mode) looked dead in the browser while working in a real terminal. It also installs a custom wheel handler that returns `false`: without it, xterm falls back on an alt-screen buffer to translating each wheel notch into a cursor-key press (`ESC[A`/`ESC[B`), which a `claude` pane reads as arrow up/down and uses to walk the prompt history instead of scrolling. That handler suppresses only the emulation branch — real mouse reports are emitted by a separate listener xterm registers when the pane's program enables mouse tracking. Its xterm viewport scrollbar is hidden by design (`.xterm-viewport` rule in `client/src/index.css`): tmux repaints the whole pane, so xterm's scrollback is always empty and scrolling happens via mouse reports forwarded to the pane's program, not the DOM. Pre-flight: `GET /api/run/{tmux,binary,cwds,files}` for tmux availability + `claude` binary check + `@`-file autocomplete. Start/resume: `POST /api/run` (requires `laneId`; optionally accepts `prompt` to send immediately); `GET /api/run/:id` (returns handle); `DELETE /api/run/:id` (stops). History: `GET /api/run/history?laneId=<n>` lists only that lane's runs. PTY streaming: `/ws-pty/:runId` delivers raw PTY frames as binary WebSocket frames — no JSON envelope overhead, direct to xterm.js for live rendering; the same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub lanes shell` CLI, other tools), all synced live. Lane self-heal: `GET /api/lanes/:id` auto-corrects `run_id`/`status` if the tmux session has been killed externally. Tier 1 TUI parity: tmux session is a real shell, not headless — supports editors, pagers, interactive subcommands. **The console never writes a lane's stage** — stage moves only through `agenthub stage` commands. Live / Offline indicator next to the title |
|
||||||
| `/settings` | Settings | `GET /api/settings/info`, `GET /api/pricing`, `GET /api/pricing/cost` + `localStorage` for notification prefs. Hosts the **Remote Data Sources** panel (`components/RemoteSources.tsx`) — CRUD + test + sync over `/api/remote-sources`, live status from `remote_source.status` WS messages |
|
| `/settings` | Settings | `GET /api/settings/info`, `GET /api/pricing`, `GET /api/pricing/cost` + `localStorage` for notification prefs. Hosts the **Remote Data Sources** panel (`components/RemoteSources.tsx`) — CRUD + test + sync over `/api/remote-sources`, live status from `remote_source.status` WS messages |
|
||||||
| `/*` | NotFound | None (static 404 page) |
|
| `/*` | NotFound | None (static 404 page) |
|
||||||
|
|||||||
@@ -215,6 +215,29 @@
|
|||||||
"scopeLabel": "Scope",
|
"scopeLabel": "Scope",
|
||||||
"filterAll": "All types"
|
"filterAll": "All types"
|
||||||
},
|
},
|
||||||
|
"profiles": {
|
||||||
|
"title": "Claude Code profiles",
|
||||||
|
"openButton": "Switch profile",
|
||||||
|
"subtitle": "A profile is one Anthropic account - its own quota. Switching swaps only the login (credentials + account identity); skills, subagents, memory, plugins, marketplaces, MCP servers and settings all stay exactly as they are.",
|
||||||
|
"warning": "Close your running Claude Code sessions before switching - a live session rewrites the config on exit and would put the old account back.",
|
||||||
|
"activeLabel": "Logged in now",
|
||||||
|
"noActive": "No Claude account is logged in on this machine.",
|
||||||
|
"unsavedActive": "This account is not saved as a profile yet - save it below, otherwise you cannot switch away without losing its credentials.",
|
||||||
|
"saveLabel": "Save the current account as a profile",
|
||||||
|
"namePlaceholder": "profile name (e.g. work)",
|
||||||
|
"saveButton": "Save",
|
||||||
|
"saveHint": "The dashboard cannot log in for you. To add another account: run `claude auth login` in a terminal, sign in, then come back and save it under a new name.",
|
||||||
|
"saveSuccess": "Saved the current account as profile \"{{name}}\".",
|
||||||
|
"empty": "No profiles saved yet - save the account you are logged in as to get started.",
|
||||||
|
"activeBadge": "active",
|
||||||
|
"switchButton": "Switch to",
|
||||||
|
"switching": "Switching…",
|
||||||
|
"switchSuccess": "Switched to profile \"{{name}}\". Start a new Claude Code session to pick it up.",
|
||||||
|
"deleteButton": "Delete profile",
|
||||||
|
"deleteSuccess": "Deleted profile \"{{name}}\".",
|
||||||
|
"savedAt": "Saved {{time}}",
|
||||||
|
"loginHint": "Add another account by logging in from a terminal first:"
|
||||||
|
},
|
||||||
"explain": {
|
"explain": {
|
||||||
"hooks": {
|
"hooks": {
|
||||||
"title": "Editing hooks writes settings.json",
|
"title": "Editing hooks writes settings.json",
|
||||||
|
|||||||
@@ -210,6 +210,29 @@
|
|||||||
"scopeLabel": "Phạm vi",
|
"scopeLabel": "Phạm vi",
|
||||||
"filterAll": "Tất cả loại"
|
"filterAll": "Tất cả loại"
|
||||||
},
|
},
|
||||||
|
"profiles": {
|
||||||
|
"title": "Profile Claude Code",
|
||||||
|
"openButton": "Đổi profile",
|
||||||
|
"subtitle": "Mỗi profile là một tài khoản Anthropic - quota riêng. Khi đổi, chỉ phần đăng nhập (credentials + danh tính tài khoản) được hoán đổi; skills, subagent, memory, plugin, marketplace, MCP server và settings vẫn giữ nguyên.",
|
||||||
|
"warning": "Hãy đóng các phiên Claude Code đang chạy trước khi đổi - phiên đang chạy sẽ ghi lại config khi thoát và đưa tài khoản cũ quay về.",
|
||||||
|
"activeLabel": "Đang đăng nhập",
|
||||||
|
"noActive": "Chưa có tài khoản Claude nào đăng nhập trên máy này.",
|
||||||
|
"unsavedActive": "Tài khoản này chưa được lưu thành profile - hãy lưu bên dưới, nếu không sẽ không thể đổi sang profile khác mà không mất credentials của nó.",
|
||||||
|
"saveLabel": "Lưu tài khoản hiện tại thành profile",
|
||||||
|
"namePlaceholder": "tên profile (vd: work)",
|
||||||
|
"saveButton": "Lưu",
|
||||||
|
"saveHint": "Dashboard không thể đăng nhập thay bạn. Để thêm tài khoản khác: chạy `claude auth login` trong terminal, đăng nhập, rồi quay lại đây lưu với tên mới.",
|
||||||
|
"saveSuccess": "Đã lưu tài khoản hiện tại thành profile \"{{name}}\".",
|
||||||
|
"empty": "Chưa có profile nào - hãy lưu tài khoản đang đăng nhập để bắt đầu.",
|
||||||
|
"activeBadge": "đang dùng",
|
||||||
|
"switchButton": "Chuyển sang",
|
||||||
|
"switching": "Đang chuyển…",
|
||||||
|
"switchSuccess": "Đã chuyển sang profile \"{{name}}\". Mở phiên Claude Code mới để áp dụng.",
|
||||||
|
"deleteButton": "Xoá profile",
|
||||||
|
"deleteSuccess": "Đã xoá profile \"{{name}}\".",
|
||||||
|
"savedAt": "Lưu lúc {{time}}",
|
||||||
|
"loginHint": "Thêm tài khoản khác bằng cách đăng nhập từ terminal trước:"
|
||||||
|
},
|
||||||
"explain": {
|
"explain": {
|
||||||
"hooks": {
|
"hooks": {
|
||||||
"title": "Sửa hooks tức là ghi vào settings.json",
|
"title": "Sửa hooks tức là ghi vào settings.json",
|
||||||
|
|||||||
@@ -1509,6 +1509,38 @@ export const api = {
|
|||||||
*/
|
*/
|
||||||
backups: (params?: { scope?: "user" | "project"; type?: CcArtifactType }) =>
|
backups: (params?: { scope?: "user" | "project"; type?: CcArtifactType }) =>
|
||||||
requestBackupsHelper(params),
|
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
|
* GET /api/cc-config/plugins/available - every plugin offered by a
|
||||||
* registered marketplace, from `claude plugin list --available --json`.
|
* registered marketplace, from `claude plugin list --available --json`.
|
||||||
@@ -2261,6 +2293,30 @@ export interface CcBackup {
|
|||||||
project?: string; // present for scope === "auto-memory"
|
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
|
/** Scope filter accepted by most `ccConfig` list endpoints; "all" merges
|
||||||
* user + project scope in one response. */
|
* user + project scope in one response. */
|
||||||
export type CcScope = "user" | "project" | "all";
|
export type CcScope = "user" | "project" | "all";
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ import {
|
|||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
UserCog,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import type {
|
import type {
|
||||||
@@ -116,6 +117,8 @@ import type {
|
|||||||
CcPlugin,
|
CcPlugin,
|
||||||
CcPluginItem,
|
CcPluginItem,
|
||||||
CcPluginsResponse,
|
CcPluginsResponse,
|
||||||
|
CcProfile,
|
||||||
|
CcAccountIdentity,
|
||||||
CcScope,
|
CcScope,
|
||||||
CcSettingsSource,
|
CcSettingsSource,
|
||||||
CcStatusline,
|
CcStatusline,
|
||||||
@@ -254,6 +257,7 @@ export function CcConfig() {
|
|||||||
const [confirmDelete, setConfirmDelete] = useState<ConfirmDeleteState>(null);
|
const [confirmDelete, setConfirmDelete] = useState<ConfirmDeleteState>(null);
|
||||||
const [toast, setToast] = useState<Toast>(null);
|
const [toast, setToast] = useState<Toast>(null);
|
||||||
const [backupsOpen, setBackupsOpen] = useState(false);
|
const [backupsOpen, setBackupsOpen] = useState(false);
|
||||||
|
const [profilesOpen, setProfilesOpen] = useState(false);
|
||||||
|
|
||||||
// Auto-dismiss toasts after 5s
|
// Auto-dismiss toasts after 5s
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -492,6 +496,7 @@ export function CcConfig() {
|
|||||||
onScopeChange={setScope}
|
onScopeChange={setScope}
|
||||||
onRefresh={fetchAll}
|
onRefresh={fetchAll}
|
||||||
onOpenBackups={() => setBackupsOpen(true)}
|
onOpenBackups={() => setBackupsOpen(true)}
|
||||||
|
onOpenProfiles={() => setProfilesOpen(true)}
|
||||||
wsConnected={wsConnected}
|
wsConnected={wsConnected}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -565,6 +570,7 @@ export function CcConfig() {
|
|||||||
)}
|
)}
|
||||||
{toast && <ToastNotice toast={toast} onDismiss={() => setToast(null)} />}
|
{toast && <ToastNotice toast={toast} onDismiss={() => setToast(null)} />}
|
||||||
{backupsOpen && <BackupsModal onClose={() => setBackupsOpen(false)} />}
|
{backupsOpen && <BackupsModal onClose={() => setBackupsOpen(false)} />}
|
||||||
|
{profilesOpen && <ProfilesModal onClose={() => setProfilesOpen(false)} onToast={setToast} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -578,6 +584,7 @@ interface HeaderProps {
|
|||||||
onScopeChange: (s: CcScope) => void;
|
onScopeChange: (s: CcScope) => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
onOpenBackups: () => void;
|
onOpenBackups: () => void;
|
||||||
|
onOpenProfiles: () => void;
|
||||||
wsConnected: boolean;
|
wsConnected: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -588,6 +595,7 @@ function Header({
|
|||||||
onScopeChange,
|
onScopeChange,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onOpenBackups,
|
onOpenBackups,
|
||||||
|
onOpenProfiles,
|
||||||
wsConnected,
|
wsConnected,
|
||||||
}: HeaderProps) {
|
}: HeaderProps) {
|
||||||
const { t } = useTranslation("ccConfig");
|
const { t } = useTranslation("ccConfig");
|
||||||
@@ -626,6 +634,13 @@ function Header({
|
|||||||
<div className="flex flex-col items-stretch lg:items-end gap-2 flex-shrink-0">
|
<div className="flex flex-col items-stretch lg:items-end gap-2 flex-shrink-0">
|
||||||
<div className="flex items-center gap-2 justify-end flex-wrap">
|
<div className="flex items-center gap-2 justify-end flex-wrap">
|
||||||
<ScopeToggle value={scope} onChange={onScopeChange} />
|
<ScopeToggle value={scope} onChange={onScopeChange} />
|
||||||
|
<button
|
||||||
|
onClick={onOpenProfiles}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary hover:bg-surface-3 transition-colors"
|
||||||
|
>
|
||||||
|
<UserCog className="w-3.5 h-3.5" />
|
||||||
|
{t("profiles.openButton")}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onOpenBackups}
|
onClick={onOpenBackups}
|
||||||
className="inline-flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary hover:bg-surface-3 transition-colors"
|
className="inline-flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary hover:bg-surface-3 transition-colors"
|
||||||
@@ -4497,6 +4512,240 @@ function CommandSnippet({ command, label }: { command: string; label?: string })
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Auth profiles modal ────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Swaps which Anthropic account the local `claude` CLI runs as. Only the two
|
||||||
|
// identity artifacts move (the OAuth blob + `oauthAccount`), so skills,
|
||||||
|
// subagents, memory, plugins, marketplaces and settings all stay exactly where
|
||||||
|
// they are — that is the whole point of the feature.
|
||||||
|
//
|
||||||
|
// The dashboard cannot log a new account in (only `claude auth login` can mint
|
||||||
|
// tokens), so adding a profile is always "log in, then snapshot what the CLI
|
||||||
|
// wrote". Hence the save-current field rather than an add-account flow.
|
||||||
|
|
||||||
|
function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t: Toast) => void }) {
|
||||||
|
const { t } = useTranslation("ccConfig");
|
||||||
|
const [items, setItems] = useState<CcProfile[] | null>(null);
|
||||||
|
const [active, setActive] = useState<CcAccountIdentity | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
const [newName, setNewName] = useState("");
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const r = await api.ccConfig.profiles();
|
||||||
|
setItems(r.items);
|
||||||
|
setActive(r.active);
|
||||||
|
setError(null);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : "unknown");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
/** Run one profile mutation, then refresh the list. */
|
||||||
|
const run = useCallback(
|
||||||
|
async (key: string, fn: () => Promise<unknown>, success: string) => {
|
||||||
|
setBusy(key);
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
onToast({ kind: "success", message: success });
|
||||||
|
await load();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
onToast({
|
||||||
|
kind: "error",
|
||||||
|
message: err instanceof Error ? err.message : "unknown error",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[load, onToast]
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeSaved = items?.some((p) => p.active) ?? false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/60 z-40 flex items-center justify-center p-4"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative w-full max-w-2xl max-h-[85vh] rounded-xl border border-border bg-surface-1 flex flex-col"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
|
||||||
|
<UserCog className="w-4 h-4 text-fg-muted" />
|
||||||
|
<span className="text-sm font-medium text-fg-primary flex-1">{t("profiles.title")}</span>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3"
|
||||||
|
aria-label={t("common.close")}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-4 py-3 border-b border-border space-y-2">
|
||||||
|
<p className="text-[11px] text-fg-muted leading-relaxed">{t("profiles.subtitle")}</p>
|
||||||
|
<p className="text-[11px] text-status-warning inline-flex items-start gap-1.5 leading-relaxed">
|
||||||
|
<ShieldAlert className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
|
||||||
|
<span>{t("profiles.warning")}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-auto p-4 space-y-3">
|
||||||
|
{error && (
|
||||||
|
<div className="text-sm text-status-danger inline-flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-4 h-4" />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-border bg-surface-2 px-3 py-2.5 space-y-1">
|
||||||
|
<div className="text-[10px] uppercase tracking-wide text-fg-muted">
|
||||||
|
{t("profiles.activeLabel")}
|
||||||
|
</div>
|
||||||
|
{active ? (
|
||||||
|
<>
|
||||||
|
<div className="text-sm text-fg-primary">
|
||||||
|
{active.email || active.displayName || active.accountUuid}
|
||||||
|
</div>
|
||||||
|
<IdentityLine identity={active} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-fg-muted">{t("profiles.noActive")}</div>
|
||||||
|
)}
|
||||||
|
{active && !activeSaved && (
|
||||||
|
<div className="text-[11px] text-status-warning pt-1">
|
||||||
|
{t("profiles.unsavedActive")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-border bg-surface-2 px-3 py-2.5">
|
||||||
|
<div className="text-[10px] uppercase tracking-wide text-fg-muted mb-1.5">
|
||||||
|
{t("profiles.saveLabel")}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
placeholder={t("profiles.namePlaceholder")}
|
||||||
|
className="h-7 flex-1 min-w-0 rounded-md border border-border bg-surface-1 px-2 text-xs text-fg-primary placeholder:text-fg-muted focus:outline-none focus:ring-1 focus:ring-accent/40"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
disabled={!newName.trim() || busy !== null}
|
||||||
|
onClick={() =>
|
||||||
|
void run(
|
||||||
|
"save",
|
||||||
|
() => api.ccConfig.saveProfile(newName.trim()),
|
||||||
|
t("profiles.saveSuccess", { name: newName.trim() })
|
||||||
|
).then(() => setNewName(""))
|
||||||
|
}
|
||||||
|
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Save className="w-3 h-3" />
|
||||||
|
{t("profiles.saveButton")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-fg-muted mt-1.5 leading-relaxed">
|
||||||
|
{t("profiles.saveHint")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{items === null && !error && <SkeletonRows n={3} />}
|
||||||
|
{items !== null && items.length === 0 && (
|
||||||
|
<div className="rounded-lg border border-dashed border-border bg-surface-2 px-4 py-8 text-center text-sm text-fg-muted">
|
||||||
|
{t("profiles.empty")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{items?.map((p) => (
|
||||||
|
<div key={p.name} className="rounded-lg border border-border bg-surface-2 px-3 py-2.5">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="font-mono text-xs text-fg-primary">{p.name}</span>
|
||||||
|
{p.active && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-status-success/10 border border-status-success/20 text-status-success">
|
||||||
|
{t("profiles.activeBadge")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
|
||||||
|
{p.email || p.displayName || p.accountUuid}
|
||||||
|
</span>
|
||||||
|
{!p.active && (
|
||||||
|
<button
|
||||||
|
disabled={busy !== null}
|
||||||
|
onClick={() =>
|
||||||
|
void run(
|
||||||
|
`switch:${p.name}`,
|
||||||
|
() => api.ccConfig.activateProfile(p.name),
|
||||||
|
t("profiles.switchSuccess", { name: p.name })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busy === `switch:${p.name}`
|
||||||
|
? t("profiles.switching")
|
||||||
|
: t("profiles.switchButton")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!p.active && (
|
||||||
|
<button
|
||||||
|
disabled={busy !== null}
|
||||||
|
onClick={() =>
|
||||||
|
void run(
|
||||||
|
`del:${p.name}`,
|
||||||
|
() => api.ccConfig.deleteProfile(p.name),
|
||||||
|
t("profiles.deleteSuccess", { name: p.name })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
title={t("profiles.deleteButton")}
|
||||||
|
aria-label={t("profiles.deleteButton")}
|
||||||
|
className="h-7 w-7 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-status-danger hover:bg-surface-3 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<IdentityLine identity={p} />
|
||||||
|
{p.savedAt && (
|
||||||
|
<div className="text-[10px] text-fg-muted mt-1">
|
||||||
|
{t("profiles.savedAt", { time: new Date(p.savedAt).toLocaleString() })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] text-fg-muted mb-1">{t("profiles.loginHint")}</div>
|
||||||
|
<CommandSnippet command="claude auth login" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Org / seat / plan line shared by the active card and each profile row. */
|
||||||
|
function IdentityLine({ identity }: { identity: CcAccountIdentity }) {
|
||||||
|
const parts = [identity.organization, identity.seatTier, identity.subscriptionType].filter(
|
||||||
|
Boolean
|
||||||
|
);
|
||||||
|
if (parts.length === 0) return null;
|
||||||
|
return <div className="text-[10px] text-fg-muted mt-0.5">{parts.join(" · ")}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Backups modal ──────────────────────────────────────────────────────
|
// ── Backups modal ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
function BackupsModal({ onClose }: { onClose: () => void }) {
|
function BackupsModal({ onClose }: { onClose: () => void }) {
|
||||||
|
|||||||
@@ -795,6 +795,61 @@ exports[`screen snapshots > Claude Config 1`] = `
|
|||||||
Project
|
Project
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
class="inline-flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary hover:bg-surface-3 transition-colors"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-user-cog w-3.5 h-3.5"
|
||||||
|
fill="none"
|
||||||
|
height="24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx="18"
|
||||||
|
cy="15"
|
||||||
|
r="3"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="9"
|
||||||
|
cy="7"
|
||||||
|
r="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M10 15H6a4 4 0 0 0-4 4v2"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="m21.7 16.4-.9-.3"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="m15.2 13.9-.9-.3"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="m16.6 18.7.3-.9"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="m19.1 12.2.3-.9"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="m19.6 18.7-.4-1"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="m16.8 12.3-.4-1"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="m14.3 16.6 1-.4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="m20.7 13.8 1-.4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Switch profile
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="inline-flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary hover:bg-surface-3 transition-colors"
|
class="inline-flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary hover:bg-surface-3 transition-colors"
|
||||||
>
|
>
|
||||||
|
|||||||
+10
@@ -1580,6 +1580,10 @@ GET /api/cc-config/settings
|
|||||||
GET /api/cc-config/memory
|
GET /api/cc-config/memory
|
||||||
GET /api/cc-config/file?path=<absolute-path>
|
GET /api/cc-config/file?path=<absolute-path>
|
||||||
GET /api/cc-config/backups[?scope=&type=]
|
GET /api/cc-config/backups[?scope=&type=]
|
||||||
|
GET /api/cc-config/profiles
|
||||||
|
POST /api/cc-config/profiles Body: { name }
|
||||||
|
POST /api/cc-config/profiles/activate Body: { name }
|
||||||
|
DELETE /api/cc-config/profiles Body: { name }
|
||||||
PUT /api/cc-config/file Body: { scope, type, name?, content }
|
PUT /api/cc-config/file Body: { scope, type, name?, content }
|
||||||
DELETE /api/cc-config/file Body: { scope, type, name? }
|
DELETE /api/cc-config/file Body: { scope, type, name? }
|
||||||
PATCH /api/cc-config/settings Body: { scope, set?: { "dotted.key": value }, unset?: ["dotted.key"] }
|
PATCH /api/cc-config/settings Body: { scope, set?: { "dotted.key": value }, unset?: ["dotted.key"] }
|
||||||
@@ -1602,6 +1606,12 @@ POST /api/cc-config/marketplaces/action Body: { name, action: update|remove }
|
|||||||
|
|
||||||
`PUT /api/cc-config/keybindings` edits `~/.claude/keybindings.json` from a structured list of context groups (`{ groups: [{ context, bindings: [{ key, action }] }] }`). The server backs the file up first (under `<CLAUDE_HOME>/cc-config-backups/keybindings/`), preserves any top-level metadata (`$schema`/`$docs`), and replaces only the `bindings` array; duplicate contexts or duplicate keys within a context return `EBADCONTENT`. `keybindings.json` is replaced wholesale here because the live CLI does not rewrite it mid-session; `settings.json`, which it does, is only ever key-merged (see `PATCH /settings` above).
|
`PUT /api/cc-config/keybindings` edits `~/.claude/keybindings.json` from a structured list of context groups (`{ groups: [{ context, bindings: [{ key, action }] }] }`). The server backs the file up first (under `<CLAUDE_HOME>/cc-config-backups/keybindings/`), preserves any top-level metadata (`$schema`/`$docs`), and replaces only the `bindings` array; duplicate contexts or duplicate keys within a context return `EBADCONTENT`. `keybindings.json` is replaced wholesale here because the live CLI does not rewrite it mid-session; `settings.json`, which it does, is only ever key-merged (see `PATCH /settings` above).
|
||||||
|
|
||||||
|
**Auth profiles (`/profiles*`).** A *profile* is one Anthropic account — one quota — under a name. Switching swaps exactly two identity artifacts and nothing else: `claudeAiOauth` inside `<CLAUDE_HOME>/.credentials.json`, and `oauthAccount` + `userID` inside the `.claude.json` config file(s) (`<CLAUDE_HOME>.json` and `<CLAUDE_HOME>/.claude.json`, each rewritten only if it already exists, key-merged 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` also stays put: those are the machine's MCP server tokens, not account identity.
|
||||||
|
|
||||||
|
Profiles live one JSON file per profile under `<CLAUDE_HOME>/agenthub-profiles/` (dir `0700`, files `0600`) and hold live OAuth tokens, so **no route ever returns token material** — `GET /profiles` answers `{ items, active }` with identity metadata only (`name`, `savedAt`, `active`, `accountUuid`, `email`, `displayName`, `organization`, `seatTier`, `subscriptionType`, `expiresAt`). `name` must match `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,39}$` (it is the filename).
|
||||||
|
|
||||||
|
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 `POST /profiles` to snapshot what the CLI wrote". `POST /profiles/activate` re-snapshots the outgoing account into its own profile first (reported as `savedOutgoing`), so tokens Claude Code refreshed since the last save are not lost, and returns `{ ok, name, savedOutgoing, written }`. Errors: `EBADNAME` (400), `ENOTFOUND` (404 — no such profile), `EBADJSON` (409 — a config file on disk is unparseable; nothing is written), `ENOAUTH` (409 — nobody is logged in to snapshot), `EUNSAVED` (409 — the live account is not saved as a profile, and switching away would discard the only copy of its credentials), `EACTIVE` (409 — refusing to delete the active profile). Mutations broadcast `cc_config_changed` with `type: "profile"`. A switch only takes effect for **new** Claude Code sessions, and a session still running will rewrite the config on exit — close them first.
|
||||||
|
|
||||||
Backup paths look like `<root>/cc-config-backups/<type>/<base>.<ISO>.bak[.dir]` — outside the directories Claude Code scans, so a deleted skill cannot resurface as a backup-named one. The Backups modal in the UI auto-builds `mv` restore commands.
|
Backup paths look like `<root>/cc-config-backups/<type>/<base>.<ISO>.bak[.dir]` — outside the directories Claude Code scans, so a deleted skill cannot resurface as a backup-named one. The Backups modal in the UI auto-builds `mv` restore commands.
|
||||||
|
|
||||||
### Run Claude via tmux+PTY
|
### Run Claude via tmux+PTY
|
||||||
|
|||||||
+261
@@ -2903,6 +2903,103 @@ components:
|
|||||||
description: Backups across the requested scope/type buckets, sorted newest first.
|
description: Backups across the requested scope/type buckets, sorted newest first.
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/CcConfigBackup'
|
$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:
|
RunHandle:
|
||||||
type: object
|
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.
|
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
|
isDir: false
|
||||||
mtime: 1718900000000
|
mtime: 1718900000000
|
||||||
size: 2048
|
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:
|
/api/run:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
|
|||||||
@@ -677,6 +677,10 @@ Reads — and carefully gated mutations for low-risk text-file artifacts — for
|
|||||||
| `GET` | `/api/cc-config/backups[?scope=&type=]` | Listing of all timestamped backups. Also lists `scope:"auto-memory"` backups (each carrying `project`) |
|
| `GET` | `/api/cc-config/backups[?scope=&type=]` | Listing of all timestamped backups. Also lists `scope:"auto-memory"` backups (each carrying `project`) |
|
||||||
| `PUT` | `/api/cc-config/file` | Create or overwrite a text-file artifact (skills/agents/commands/output-styles/memory). Body: `{ scope, type, name?, content }`. Auto-backs-up if file exists. Atomic temp + rename. 256 KB cap. Per-project file-based memory is also editable via `{ scope: "auto-memory", type: "auto-memory", project, name }` — backups land under `<memory-dir>/.cc-config-backups/auto-memory/`, and an invalid project slug returns `EBADPROJECT` |
|
| `PUT` | `/api/cc-config/file` | Create or overwrite a text-file artifact (skills/agents/commands/output-styles/memory). Body: `{ scope, type, name?, content }`. Auto-backs-up if file exists. Atomic temp + rename. 256 KB cap. Per-project file-based memory is also editable via `{ scope: "auto-memory", type: "auto-memory", project, name }` — backups land under `<memory-dir>/.cc-config-backups/auto-memory/`, and an invalid project slug returns `EBADPROJECT` |
|
||||||
| `DELETE` | `/api/cc-config/file` | Backup-then-delete a text-file artifact. Skill dirs are backed up whole before recursive removal |
|
| `DELETE` | `/api/cc-config/file` | Backup-then-delete a text-file artifact. Skill dirs are backed up whole before recursive removal |
|
||||||
|
| `GET` | `/api/cc-config/profiles` | `{ items, active }` — saved auth profiles + the account logged in right now. Identity metadata only, **never token material** |
|
||||||
|
| `POST` | `/api/cc-config/profiles` | Snapshot the logged-in account as a profile. Body: `{ name }`. `ENOAUTH` when nobody is logged in |
|
||||||
|
| `POST` | `/api/cc-config/profiles/activate` | Make a saved profile the live account — swaps `claudeAiOauth` + `oauthAccount`/`userID` and nothing else. Body: `{ name }`. Re-snapshots the outgoing account first (`savedOutgoing`); `EUNSAVED` if it was never saved |
|
||||||
|
| `DELETE` | `/api/cc-config/profiles` | Forget a saved profile. Body: `{ name }`. `EACTIVE` for the active one |
|
||||||
|
|
||||||
### Run a lane's CLI (`/api/run`)
|
### Run a lane's CLI (`/api/run`)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
/**
|
||||||
|
* @file Tests for named Claude Code auth profiles — snapshotting the logged-in
|
||||||
|
* account, switching between accounts, and the guards that keep a switch from
|
||||||
|
* destroying the only copy of a credential.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
|
||||||
|
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-cc-profiles-"));
|
||||||
|
process.env.CLAUDE_HOME = path.join(SUITE_ROOT, ".claude");
|
||||||
|
|
||||||
|
const { describe, it, beforeEach, after } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
|
||||||
|
const profiles = require("../lib/cc-profiles");
|
||||||
|
|
||||||
|
const HOME = process.env.CLAUDE_HOME;
|
||||||
|
const CREDS = path.join(HOME, ".credentials.json");
|
||||||
|
const CONFIG = `${HOME}.json`;
|
||||||
|
|
||||||
|
after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
/** Put one account on disk the way the `claude` CLI would leave it. */
|
||||||
|
function login(uuid, email, { mcp = true, extras = {} } = {}) {
|
||||||
|
fs.mkdirSync(HOME, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
CREDS,
|
||||||
|
JSON.stringify({
|
||||||
|
...(mcp ? { mcpOAuth: { "some:server": { accessToken: "mcp-token" } } } : {}),
|
||||||
|
claudeAiOauth: { accessToken: `at-${uuid}`, refreshToken: `rt-${uuid}`, expiresAt: 111 },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
CONFIG,
|
||||||
|
JSON.stringify({
|
||||||
|
numStartups: 7,
|
||||||
|
projects: { "/tmp/x": { allowedTools: [] } },
|
||||||
|
userID: `uid-${uuid}`,
|
||||||
|
oauthAccount: { accountUuid: uuid, emailAddress: email, organizationName: "Org" },
|
||||||
|
...extras,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fs.rmSync(HOME, { recursive: true, force: true });
|
||||||
|
fs.rmSync(CONFIG, { force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cc-profiles", () => {
|
||||||
|
it("reports the live account and no profiles before anything is saved", () => {
|
||||||
|
login("uuid-a", "a@example.com");
|
||||||
|
const { items, active } = profiles.listProfiles();
|
||||||
|
assert.deepEqual(items, []);
|
||||||
|
assert.equal(active.email, "a@example.com");
|
||||||
|
assert.equal(active.accountUuid, "uuid-a");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves the current account and marks it active in the list", () => {
|
||||||
|
login("uuid-a", "a@example.com");
|
||||||
|
profiles.saveProfile("work");
|
||||||
|
const { items } = profiles.listProfiles();
|
||||||
|
assert.equal(items.length, 1);
|
||||||
|
assert.equal(items[0].name, "work");
|
||||||
|
assert.equal(items[0].email, "a@example.com");
|
||||||
|
assert.equal(items[0].active, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never exposes token material in the list", () => {
|
||||||
|
login("uuid-a", "a@example.com");
|
||||||
|
profiles.saveProfile("work");
|
||||||
|
const json = JSON.stringify(profiles.listProfiles());
|
||||||
|
assert.ok(!json.includes("at-uuid-a"), "access token leaked into the list response");
|
||||||
|
assert.ok(!json.includes("rt-uuid-a"), "refresh token leaked into the list response");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores profiles 0600 in a 0700 dir", () => {
|
||||||
|
login("uuid-a", "a@example.com");
|
||||||
|
profiles.saveProfile("work");
|
||||||
|
const dir = profiles.profilesDir();
|
||||||
|
assert.equal(fs.statSync(dir).mode & 0o777, 0o700);
|
||||||
|
assert.equal(fs.statSync(path.join(dir, "work.json")).mode & 0o777, 0o600);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects names that are not filename-safe", () => {
|
||||||
|
login("uuid-a", "a@example.com");
|
||||||
|
for (const bad of ["../escape", "has/slash", "", "a".repeat(41), ".dotfirst"]) {
|
||||||
|
assert.throws(() => profiles.saveProfile(bad), { code: "EBADNAME" }, `accepted ${bad}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to save when nobody is logged in", () => {
|
||||||
|
fs.mkdirSync(HOME, { recursive: true });
|
||||||
|
assert.throws(() => profiles.saveProfile("work"), { code: "ENOAUTH" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switches the account and keeps everything else in the config", () => {
|
||||||
|
login("uuid-a", "a@example.com");
|
||||||
|
profiles.saveProfile("work");
|
||||||
|
login("uuid-b", "b@example.com");
|
||||||
|
profiles.saveProfile("personal");
|
||||||
|
|
||||||
|
const result = profiles.activateProfile("work");
|
||||||
|
assert.equal(result.savedOutgoing, "personal");
|
||||||
|
|
||||||
|
const creds = JSON.parse(fs.readFileSync(CREDS, "utf8"));
|
||||||
|
assert.equal(creds.claudeAiOauth.accessToken, "at-uuid-a");
|
||||||
|
assert.equal(creds.claudeAiOauth.refreshToken, "rt-uuid-a");
|
||||||
|
// MCP server tokens belong to the machine, not the account.
|
||||||
|
assert.equal(creds.mcpOAuth["some:server"].accessToken, "mcp-token");
|
||||||
|
|
||||||
|
const cfg = JSON.parse(fs.readFileSync(CONFIG, "utf8"));
|
||||||
|
assert.equal(cfg.oauthAccount.accountUuid, "uuid-a");
|
||||||
|
assert.equal(cfg.userID, "uid-uuid-a");
|
||||||
|
// Untouched: everything that is not the account identity.
|
||||||
|
assert.equal(cfg.numStartups, 7);
|
||||||
|
assert.deepEqual(cfg.projects, { "/tmp/x": { allowedTools: [] } });
|
||||||
|
|
||||||
|
const { items } = profiles.listProfiles();
|
||||||
|
assert.deepEqual(
|
||||||
|
items.map((p) => [p.name, p.active]),
|
||||||
|
[
|
||||||
|
["personal", false],
|
||||||
|
["work", true],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-snapshots the outgoing account so refreshed tokens survive a round trip", () => {
|
||||||
|
login("uuid-a", "a@example.com");
|
||||||
|
profiles.saveProfile("work");
|
||||||
|
login("uuid-b", "b@example.com");
|
||||||
|
profiles.saveProfile("personal");
|
||||||
|
profiles.activateProfile("work");
|
||||||
|
|
||||||
|
// Simulate Claude Code refreshing the now-active account's token.
|
||||||
|
const creds = JSON.parse(fs.readFileSync(CREDS, "utf8"));
|
||||||
|
creds.claudeAiOauth.accessToken = "at-refreshed";
|
||||||
|
fs.writeFileSync(CREDS, JSON.stringify(creds));
|
||||||
|
|
||||||
|
profiles.activateProfile("personal");
|
||||||
|
profiles.activateProfile("work");
|
||||||
|
assert.equal(
|
||||||
|
JSON.parse(fs.readFileSync(CREDS, "utf8")).claudeAiOauth.accessToken,
|
||||||
|
"at-refreshed"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to switch away from an account that was never saved", () => {
|
||||||
|
login("uuid-a", "a@example.com");
|
||||||
|
profiles.saveProfile("work");
|
||||||
|
login("uuid-b", "b@example.com"); // logged in, but not snapshotted
|
||||||
|
assert.throws(() => profiles.activateProfile("work"), { code: "EUNSAVED" });
|
||||||
|
// Nothing moved.
|
||||||
|
assert.equal(JSON.parse(fs.readFileSync(CREDS, "utf8")).claudeAiOauth.accessToken, "at-uuid-b");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rewrites a second stale .claude.json so it cannot contradict the switch", () => {
|
||||||
|
login("uuid-a", "a@example.com");
|
||||||
|
profiles.saveProfile("work");
|
||||||
|
login("uuid-b", "b@example.com");
|
||||||
|
profiles.saveProfile("personal");
|
||||||
|
const inner = path.join(HOME, ".claude.json");
|
||||||
|
fs.writeFileSync(
|
||||||
|
inner,
|
||||||
|
JSON.stringify({ oauthAccount: { accountUuid: "uuid-stale" }, firstStartTime: "x" })
|
||||||
|
);
|
||||||
|
|
||||||
|
profiles.activateProfile("work");
|
||||||
|
const cfg = JSON.parse(fs.readFileSync(inner, "utf8"));
|
||||||
|
assert.equal(cfg.oauthAccount.accountUuid, "uuid-a");
|
||||||
|
assert.equal(cfg.firstStartTime, "x");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not create a .claude.json that was not there", () => {
|
||||||
|
login("uuid-a", "a@example.com");
|
||||||
|
profiles.saveProfile("work");
|
||||||
|
login("uuid-b", "b@example.com");
|
||||||
|
profiles.saveProfile("personal");
|
||||||
|
profiles.activateProfile("work");
|
||||||
|
assert.equal(fs.existsSync(path.join(HOME, ".claude.json")), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes an inactive profile and refuses the active one", () => {
|
||||||
|
login("uuid-a", "a@example.com");
|
||||||
|
profiles.saveProfile("work");
|
||||||
|
login("uuid-b", "b@example.com");
|
||||||
|
profiles.saveProfile("personal");
|
||||||
|
|
||||||
|
assert.throws(() => profiles.deleteProfile("personal"), { code: "EACTIVE" });
|
||||||
|
assert.deepEqual(profiles.deleteProfile("work"), { ok: true, name: "work" });
|
||||||
|
assert.throws(() => profiles.deleteProfile("work"), { code: "ENOTFOUND" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips corrupt profile files instead of failing the whole list", () => {
|
||||||
|
login("uuid-a", "a@example.com");
|
||||||
|
profiles.saveProfile("work");
|
||||||
|
fs.writeFileSync(path.join(profiles.profilesDir(), "broken.json"), "{not json");
|
||||||
|
const { items } = profiles.listProfiles();
|
||||||
|
assert.deepEqual(
|
||||||
|
items.map((p) => p.name),
|
||||||
|
["work"]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a corrupt live credentials file rather than guessing", () => {
|
||||||
|
fs.mkdirSync(HOME, { recursive: true });
|
||||||
|
fs.writeFileSync(CREDS, "{nope");
|
||||||
|
assert.throws(() => profiles.listProfiles(), { code: "EBADJSON" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
/**
|
||||||
|
* @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 `<CLAUDE_HOME>/.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
|
||||||
|
* `<CLAUDE_HOME>/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ĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
@@ -4,7 +4,8 @@
|
|||||||
* every read surface (skills, agents, commands, output styles, plugins, MCP
|
* every read surface (skills, agents, commands, output styles, plugins, MCP
|
||||||
* servers, hooks, settings, memory, marketplaces, keybindings, statusline,
|
* servers, hooks, settings, memory, marketplaces, keybindings, statusline,
|
||||||
* hook scripts, single-file body) plus the low-risk mutation surface
|
* 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
|
* Exports `{ tags, schemas, paths }` for deep-merge by
|
||||||
* `server/openapi-extra.js`. All schema names are prefixed with `CcConfig`
|
* `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) ────
|
// ── 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 };
|
module.exports = { tags, schemas, paths };
|
||||||
|
|||||||
@@ -14,6 +14,10 @@
|
|||||||
* Every file write is preceded by a timestamped backup. MCP server config
|
* Every file write is preceded by a timestamped backup. MCP server config
|
||||||
* stays read-only — it is written concurrently by the running CLI.
|
* stays read-only — it is written concurrently by the running CLI.
|
||||||
*
|
*
|
||||||
|
* Also hosts the auth-profile routes (`/profiles*`) that swap which Anthropic
|
||||||
|
* account the local CLI runs as; those carry identity metadata only, never
|
||||||
|
* token material.
|
||||||
|
*
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -21,6 +25,7 @@ const { Router } = require("express");
|
|||||||
const cc = require("../lib/cc-discovery");
|
const cc = require("../lib/cc-discovery");
|
||||||
const ccMutate = require("../lib/cc-mutate");
|
const ccMutate = require("../lib/cc-mutate");
|
||||||
const ccCli = require("../lib/cc-plugin-cli");
|
const ccCli = require("../lib/cc-plugin-cli");
|
||||||
|
const ccProfiles = require("../lib/cc-profiles");
|
||||||
const { broadcast } = require("../websocket");
|
const { broadcast } = require("../websocket");
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -332,6 +337,66 @@ router.patch("/settings", (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Auth profiles ──────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Switch which Anthropic account (= which quota) the local `claude` CLI runs
|
||||||
|
// as, keeping every other config surface in place. Responses carry identity
|
||||||
|
// metadata only — never token material. See cc-profiles.js.
|
||||||
|
|
||||||
|
const PROFILE_ERR_TO_STATUS = {
|
||||||
|
EBADNAME: 400,
|
||||||
|
EBADREQ: 400,
|
||||||
|
ENOTFOUND: 404,
|
||||||
|
EBADJSON: 409,
|
||||||
|
EACTIVE: 409,
|
||||||
|
EUNSAVED: 409,
|
||||||
|
ENOAUTH: 409,
|
||||||
|
};
|
||||||
|
|
||||||
|
function profileError(res, e) {
|
||||||
|
const status = PROFILE_ERR_TO_STATUS[e.code] || 500;
|
||||||
|
return res.status(status).json({ error: { code: e.code || "EINTERNAL", message: e.message } });
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get("/profiles", (_req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(ccProfiles.listProfiles());
|
||||||
|
} catch (e) {
|
||||||
|
return profileError(res, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/profiles", (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = ccProfiles.saveProfile((req.body || {}).name);
|
||||||
|
emitChanged({ action: "save", type: "profile", name: result.name, project: null });
|
||||||
|
res.json(result);
|
||||||
|
} catch (e) {
|
||||||
|
return profileError(res, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/profiles/activate", (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = ccProfiles.activateProfile((req.body || {}).name);
|
||||||
|
emitChanged({ action: "activate", type: "profile", name: result.name, project: null });
|
||||||
|
res.json(result);
|
||||||
|
} catch (e) {
|
||||||
|
return profileError(res, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Identifier travels in the body, not the URL — same reason as DELETE /file.
|
||||||
|
router.delete("/profiles", (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = ccProfiles.deleteProfile((req.body || {}).name);
|
||||||
|
emitChanged({ action: "delete", type: "profile", name: result.name, project: null });
|
||||||
|
res.json(result);
|
||||||
|
} catch (e) {
|
||||||
|
return profileError(res, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.get("/backups", (req, res) => {
|
router.get("/backups", (req, res) => {
|
||||||
const scope =
|
const scope =
|
||||||
req.query.scope === "user" || req.query.scope === "project" ? req.query.scope : undefined;
|
req.query.scope === "user" || req.query.scope === "project" ? req.query.scope : undefined;
|
||||||
|
|||||||
Reference in New Issue
Block a user