Files
AgentHub/server/routes/cc-config.js
T
nntrivi2001 11dcc04ac4 fix(cc-config): one account is one profile, and rename it in place
Four defects, all from one design mistake: a profile's display name and its
filename were the same string.

- Saving the logged-in account made a SECOND profile of an account another
  profile already held. Both then reported themselves active, so there was
  nothing to switch between. `POST /profiles` now refuses `EDUPLICATE` and
  names the profile that already holds the account. Re-saving into the
  account's own profile stays allowed - that is the token-refresh path.
- Worse, an accidental duplicate could not be cleaned up: two profiles of one
  account are both active, and the delete guard refused any active profile, so
  neither was ever deletable. The guard now blocks only the LAST saved copy of
  the live account, which is the actual credential-loss condition it exists
  for. The trash button had to follow, or the server would allow what the UI
  hid.
- A name could not contain spaces or diacritics, because it had to be
  filename-safe. A profile now carries `name` (internal identifier, still the
  filename, still what every route takes) and `label` (what the user reads and
  edits: 1-60 free-form characters, control characters rejected, stored
  trimmed, unique case-insensitively). A label never touches a path, which is
  what makes "Tài khoản chính" a legal name.
- Naming happened up front in a "save current account as" field. That field is
  gone. The server derives the identifier, the save button lives on the live
  account's card and appears only while it is unsaved, and the label is edited
  by clicking it (Enter saves, Escape cancels).

Renaming rewrites the label in place and deliberately does NOT move `name`, so
it cannot half-fail and leave a profile unreachable. Profiles written before
labels existed report `label` equal to their `name`, so nothing needs migrating.

Adds `PATCH /api/cc-config/profiles`; `POST` now takes an optional `name`.

Verified: server 1431/1431 (11 new), client 459/459, tsc clean. Also exercised
end to end against a COPY of the real profile store on this machine: the
duplicate deleted, the last copy still refused, a Vietnamese label accepted,
and the stored access/refresh tokens and 0600 mode intact after a rename.
2026-09-07 16:29:11 +07:00

423 lines
14 KiB
JavaScript

/**
* @file cc-config.js
* @description HTTP surface for inspecting and (carefully) mutating Claude
* Code configuration: skills, subagents, slash commands, output styles,
* plugins, marketplaces, MCP servers, hooks, settings, memory, keybindings,
* statusline, hook scripts. Powers the Claude Config Explorer dashboard
* page.
*
* Read paths cover every surface. Write paths cover: low-risk text-file
* artifacts (skills, agents, commands, output styles, memory, per-project
* auto-memory), the structured keybindings.json editor, settings.json layers
* via a per-key merge (`PATCH /settings`, which is also how hooks are edited),
* and plugin/marketplace management delegated to the `claude plugin …` CLI.
* 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>
*/
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();
function emitChanged(payload) {
try {
broadcast("cc_config_changed", { source: "dashboard", ...payload });
} catch {
/* websocket may not be initialised in unit tests */
}
}
// Map mutate-error codes → HTTP status. Anything unmapped is a 500.
const ERR_TO_STATUS = {
EBADTYPE: 400,
EBADSCOPE: 400,
EBADNAME: 400,
EBADPROJECT: 400,
EBADCONTENT: 400,
ETOOLARGE: 413,
EOUTOFROOT: 400,
ENOTFOUND: 404,
EREDACTED: 400,
// The file on disk is unusable as-is; the caller must fix it by hand first.
EBADJSON: 409,
};
function mutateError(res, err) {
const status = ERR_TO_STATUS[err.code] || 500;
return res
.status(status)
.json({ error: { code: err.code || "EINTERNAL", message: err.message } });
}
function scopeOf(req) {
const s = String(req.query.scope || "all");
return s === "user" || s === "project" ? s : "all";
}
function cwdOf(req) {
// The dashboard server's own cwd is the natural "project" — but allow
// override via ?cwd= so the user can inspect another working dir without
// restarting the server.
const c = typeof req.query.cwd === "string" && req.query.cwd ? req.query.cwd : null;
return c || process.cwd();
}
router.get("/overview", (req, res) => {
res.json(cc.readOverview({ cwd: cwdOf(req) }));
});
router.get("/skills", (req, res) => {
res.json({ items: cc.readSkills({ scope: scopeOf(req), cwd: cwdOf(req) }) });
});
router.get("/agents", (req, res) => {
res.json({ items: cc.readAgents({ scope: scopeOf(req), cwd: cwdOf(req) }) });
});
router.get("/commands", (req, res) => {
res.json({ items: cc.readCommands({ scope: scopeOf(req), cwd: cwdOf(req) }) });
});
router.get("/output-styles", (req, res) => {
res.json({ items: cc.readOutputStyles({ scope: scopeOf(req), cwd: cwdOf(req) }) });
});
router.get("/plugins", (_req, res) => {
res.json(cc.readPlugins());
});
router.get("/mcp", (req, res) => {
res.json(cc.readMcpServers({ cwd: cwdOf(req) }));
});
router.get("/hooks", (req, res) => {
res.json({ items: cc.readHooks({ cwd: cwdOf(req) }) });
});
router.get("/settings", (req, res) => {
res.json({ items: cc.readSettings({ cwd: cwdOf(req) }) });
});
router.get("/memory", (req, res) => {
res.json({ items: cc.readMemory({ cwd: cwdOf(req) }) });
});
router.get("/marketplaces", (_req, res) => {
res.json(cc.readMarketplaces());
});
router.get("/keybindings", (_req, res) => {
res.json(cc.readKeybindings());
});
// PUT /api/cc-config/keybindings — structured overwrite of the user's
// ~/.claude/keybindings.json. Body: { groups: [{ context, bindings: [{ key,
// action }] }] }. Backs the file up first and preserves top-level metadata.
router.put("/keybindings", (req, res) => {
const { groups } = req.body || {};
if (!Array.isArray(groups)) {
return res
.status(400)
.json({ error: { code: "EBADREQ", message: "groups array is required" } });
}
try {
const result = ccMutate.writeKeybindings({ groups });
emitChanged({ action: "write", type: "keybindings", name: null, project: null });
res.json(result);
} catch (err) {
return mutateError(res, err);
}
});
router.get("/statusline", (_req, res) => {
res.json(cc.readStatusline());
});
router.get("/hook-scripts", (_req, res) => {
res.json(cc.readHookScripts());
});
// GET /api/cc-config/file?path=/abs/path — return body of a single file.
// Path must resolve under CLAUDE_HOME, project .claude/, or be project CLAUDE.md.
router.get("/file", (req, res) => {
const p = req.query.path;
if (typeof p !== "string" || !p) {
return res.status(400).json({ error: { code: "BAD_PATH", message: "path is required" } });
}
const result = cc.readFileSafe(p, { cwd: cwdOf(req) });
if (result.error)
return res.status(400).json({ error: { code: "READ_DENIED", message: result.error } });
res.json(result);
});
// ── Phase-2 mutation endpoints ─────────────────────────────────────────
//
// PUT /api/cc-config/file — create or overwrite. Body: { scope, type, name?, content }
// DELETE /api/cc-config/file — delete. Body: { scope, type, name? }
// GET /api/cc-config/backups[?scope=&type=] — list backups
//
// Plugins, MCP, hooks-in-settings, and settings.json files are intentionally
// not mutable here. See cc-mutate.js for the rationale.
router.put("/file", (req, res) => {
const { scope, type, name, content, project } = req.body || {};
if (typeof scope !== "string" || typeof type !== "string") {
return res
.status(400)
.json({ error: { code: "EBADREQ", message: "scope and type are required" } });
}
try {
const result = ccMutate.writeArtifact({ scope, type, name, content, project, cwd: cwdOf(req) });
emitChanged({ action: "write", scope, type, name: name || null, project: project || null });
res.json(result);
} catch (err) {
return mutateError(res, err);
}
});
router.delete("/file", (req, res) => {
const { scope, type, name, project } = req.body || {};
if (typeof scope !== "string" || typeof type !== "string") {
return res
.status(400)
.json({ error: { code: "EBADREQ", message: "scope and type are required" } });
}
try {
const result = ccMutate.deleteArtifact({ scope, type, name, project, cwd: cwdOf(req) });
emitChanged({ action: "delete", scope, type, name: name || null, project: project || null });
res.json(result);
} catch (err) {
return mutateError(res, err);
}
});
// ── Plugins / marketplaces (via the `claude` CLI) ──────────────────────
//
// These shell out to `claude plugin …` with an argv array — never a shell
// string — because the CLI owns the manifest + cache tree that plugin state
// lives in. Identifiers travel in the body, not the URL, so a key like
// `agenthub@sg` needs no encoding (same reason `DELETE /file` takes a body).
const CLI_ERR_TO_STATUS = {
EBADARG: 400,
ECLINOTFOUND: 503,
ETIMEOUT: 504,
ECLIFAILED: 502,
};
function cliError(res, err) {
const status = CLI_ERR_TO_STATUS[err.code] || 500;
return res
.status(status)
.json({ error: { code: err.code || "EINTERNAL", message: err.message } });
}
/** Map a finished CLI run onto a response; a non-zero exit is a 422, not a 500. */
function cliResult(res, result, changed) {
if (!result.ok) {
return res.status(422).json({
error: {
code: "ECLIEXIT",
message: ccCli.cliMessage(result) || `claude exited with code ${result.code}`,
},
code: result.code,
stdout: result.stdout,
stderr: result.stderr,
});
}
emitChanged(changed);
res.json({ ok: true, stdout: result.stdout, stderr: result.stderr });
}
router.get("/plugins/available", async (_req, res) => {
try {
res.json(await ccCli.listAvailable());
} catch (err) {
return cliError(res, err);
}
});
router.post("/plugins/install", async (req, res) => {
const { spec, scope } = req.body || {};
try {
const result = await ccCli.pluginInstall({ spec, scope });
return cliResult(res, result, { action: "install", type: "plugin", name: spec });
} catch (err) {
return cliError(res, err);
}
});
const PLUGIN_ACTIONS = {
enable: ccCli.pluginEnable,
disable: ccCli.pluginDisable,
update: ccCli.pluginUpdate,
uninstall: ccCli.pluginUninstall,
};
router.post("/plugins/action", async (req, res) => {
const { key, action } = req.body || {};
const run = PLUGIN_ACTIONS[action];
if (!run) {
return res.status(400).json({
error: {
code: "EBADACTION",
message: `action must be one of ${Object.keys(PLUGIN_ACTIONS).join(", ")}`,
},
});
}
try {
const result = await run({ spec: key });
return cliResult(res, result, { action, type: "plugin", name: key });
} catch (err) {
return cliError(res, err);
}
});
router.post("/marketplaces/add", async (req, res) => {
const { source, scope } = req.body || {};
try {
const result = await ccCli.marketplaceAdd({ source, scope });
return cliResult(res, result, { action: "add", type: "marketplace", name: source });
} catch (err) {
return cliError(res, err);
}
});
const MARKETPLACE_ACTIONS = {
remove: ccCli.marketplaceRemove,
update: ccCli.marketplaceUpdate,
};
router.post("/marketplaces/action", async (req, res) => {
const { name, action } = req.body || {};
const run = MARKETPLACE_ACTIONS[action];
if (!run) {
return res.status(400).json({
error: {
code: "EBADACTION",
message: `action must be one of ${Object.keys(MARKETPLACE_ACTIONS).join(", ")}`,
},
});
}
try {
const result = await run({ name });
return cliResult(res, result, { action, type: "marketplace", name });
} catch (err) {
return cliError(res, err);
}
});
// PATCH /api/cc-config/settings — merge individual keys into one settings
// layer. Body: { scope: "user"|"project"|"project-local", set?: {dotted: value},
// unset?: ["dotted"] }. Hooks are edited through this same route as
// `set: { "hooks.PreToolUse": [...] }` in settings.json's own nested shape.
//
// The project layers resolve against the SERVER's cwd, never `?cwd=`: a write
// target chosen by the caller would let any request create a settings.json
// anywhere on disk. Reads keep the override; writes do not.
router.patch("/settings", (req, res) => {
const { scope, set, unset } = req.body || {};
try {
const result = ccMutate.patchSettings({ scope, cwd: process.cwd(), set, unset });
emitChanged({ action: "patch", type: "settings", name: scope, project: null });
res.json(result);
} catch (err) {
return mutateError(res, err);
}
});
// ── 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,
EBADLABEL: 400,
EBADREQ: 400,
ENOTFOUND: 404,
EBADJSON: 409,
EACTIVE: 409,
EUNSAVED: 409,
ENOAUTH: 409,
EDUPLICATE: 409,
ELABELTAKEN: 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);
}
});
// Rename is a label edit; the profile's `name` identifier never moves.
router.patch("/profiles", (req, res) => {
try {
const body = req.body || {};
const result = ccProfiles.renameProfile(body.name, body.label);
emitChanged({ action: "rename", 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;
const type = typeof req.query.type === "string" ? req.query.type : undefined;
res.json({ items: ccMutate.listBackups({ scope, type, cwd: cwdOf(req) }) });
});
module.exports = router;