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