/** * @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), * plugin/marketplace management delegated to the `claude plugin …` CLI, and * MCP server add/remove delegated to the `claude mcp …` CLI. Every file * write is preceded by a timestamped backup; the CLI-delegated writes are * not files this server touches directly, so there is nothing to back up — * the CLI owns `~/.claude.json`. * * 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ĩ */ const { Router } = require("express"); const path = require("path"); const cc = require("../lib/cc-discovery"); const ccMutate = require("../lib/cc-mutate"); const ccCli = require("../lib/cc-plugin-cli"); const ccMcpCli = require("../lib/cc-mcp-cli"); const ccProfiles = require("../lib/cc-profiles"); const ccUsage = require("../lib/cc-usage"); const projectMemory = require("../lib/project-memory"); const { sameOriginGuard } = require("../lib/same-origin"); const { broadcast } = require("../websocket"); const { reloadOpenSessions } = require("../lib/cc-reload"); const { startUpdateAll, readUpdateAll, isRunning } = require("../lib/cc-update-all"); const { detectPluginInstall } = require("../lib/update-check"); const scope = require("../lib/scope"); /** Mirrors cc-plugin-cli's own spec rule — ids from the CLI's JSON still reach * an argv in the detached batch worker, so they are filtered before they go. */ const PLUGIN_SPEC_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*(@[A-Za-z0-9][A-Za-z0-9._-]*)?$/; const router = Router(); // Server mode: a non-admin's ?cwd= only names one of their own workspaces. router.use(scope.ownCwdQuery); 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, EBADDIR: 400, EBADTOOL: 400, // The project already has a memory file; creating would clobber it. EEXISTS: 409, 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(); } /** * `{scope, cwd}` for the two lists a member may read (the run form's `/` * autocomplete). Server mode keeps a member's project items to their own * workspaces: another folder answers 403 EFOLDER, and with no `?cwd=` there is * no project at all (never the dashboard's own working directory). Returns null * once it has answered; `none` = nothing to list. */ function listOpts(req, res) { const opts = { scope: scopeOf(req), cwd: cwdOf(req) }; if (scope.memberId(req) == null) return opts; const asked = typeof req.query.cwd === "string" && req.query.cwd; if (!asked) return opts.scope === "project" ? { none: true } : { ...opts, scope: "user" }; if (!path.isAbsolute(asked) || !scope.insideOwnWorkspace(req, path.resolve(asked))) { res.status(403).json({ error: { code: "EFOLDER", message: "that folder is not one of your workspaces" }, }); return null; } return opts; } router.get("/overview", (req, res) => { res.json(cc.readOverview({ cwd: cwdOf(req) })); }); router.get("/skills", (req, res) => { const opts = listOpts(req, res); if (opts) res.json({ items: opts.none ? [] : cc.readSkills(opts) }); }); router.get("/agents", (req, res) => { res.json({ items: cc.readAgents({ scope: scopeOf(req), cwd: cwdOf(req) }) }); }); router.get("/commands", (req, res) => { const opts = listOpts(req, res); if (opts) res.json({ items: opts.none ? [] : cc.readCommands(opts) }); }); 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 { // process.cwd(), never ?cwd= — the same rule PATCH /settings already // follows: a caller-chosen write target lets any request drop a file // anywhere on disk. Reads keep the override; writes do not. const result = ccMutate.writeArtifact({ scope, type, name, content, project, cwd: process.cwd(), }); 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 { // Pinned for the same reason as PUT above. const result = ccMutate.deleteArtifact({ scope, type, name, project, cwd: process.cwd() }); emitChanged({ action: "delete", scope, type, name: name || null, project: project || null }); res.json(result); } catch (err) { return mutateError(res, err); } }); /** * POST /api/cc-config/project-memory — create `CLAUDE.md` in a project the * dashboard has never run in. Body: `{ dir, content }`. * * The one write here that honours a caller-supplied directory, which is the * whole point: you cannot bootstrap a NEW project's memory from a writer * pinned to the server's own cwd. It stays narrow — see project-memory.js — * and sits behind the same loopback-origin guard the process-spawning routes * use, so a random web page cannot plant instructions in a project directory. */ router.post("/project-memory", sameOriginGuard, (req, res) => { const { dir, content } = req.body || {}; try { const result = projectMemory.createProjectMemory({ tool: "claude", dir, content }); emitChanged({ action: "write", scope: "project", type: "memory", name: null }); res.json({ ...result, created: true }); } 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, extra) { 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, ...extra }); } 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, reload } = 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 }); // "Update one, reload that one" — the counterpart to the batch, which // reloads once at the end. Only on success, and only when asked: an // enable/disable already needs a reload but the caller decides, and a // reload after a FAILED update would tell the session to activate nothing. let reloaded; if (reload && result.ok) { try { reloaded = await reloadOpenSessions(); } catch { /* a reload that cannot be delivered must not fail the update itself */ } } return cliResult(res, result, { action, type: "plugin", name: key }, { reloaded }); } catch (err) { return cliError(res, err); } }); /** * Type `/reload-plugins` + `/reload-skills` into the live Claude Code lane * panes. Not a config mutation — it changes nothing on disk, it just tells * sessions to re-read what is already there — so it neither queues on the CLI * mutation chain nor emits `cc_config_changed`. */ router.post("/reload", async (req, res) => { const { kinds } = req.body || {}; if (kinds !== undefined && !Array.isArray(kinds)) { return res.status(400).json({ error: { code: "EBADARG", message: "kinds must be an array of 'plugins' | 'skills'" }, }); } try { res.json({ ok: true, ...(await reloadOpenSessions({ kinds })) }); } catch (err) { res.status(500).json({ error: { code: "ERELOAD", message: err.message || String(err) }, }); } }); // ── Update all ───────────────────────────────────────────────────────── // // Runs detached (see server/lib/cc-update-all.js): the batch ends with AgentHub // itself when this server runs from the plugin cache, and that step stops the // process. The response is therefore only an acknowledgement — progress is // polled from GET below, which reads the worker's on-disk journal. router.get("/plugins/update-all", (_req, res) => { res.json({ batch: readUpdateAll() }); }); router.post("/plugins/update-all", async (req, res) => { if (isRunning()) { return res.status(409).json({ error: { code: "EBUSY", message: "an update-all batch is already running" }, batch: readUpdateAll(), }); } let installed; try { installed = (await ccCli.listAvailable()).installed || []; } catch (err) { return cliError(res, err); } // Ids come from the CLI's own JSON, never from the request body, but they // reach an argv in a detached process — so they are filtered here too. const plugins = installed.map((p) => String(p.id || "")).filter((id) => PLUGIN_SPEC_RE.test(id)); // Which plugin, if any, is the one this dashboard is running from. Derived // from our own path — never from the request — so no caller can nominate a // different plugin as "self" and get a bootstrap run against it. const selfRoot = path.resolve(__dirname, "..", ".."); const detected = detectPluginInstall(selfRoot); const selfPlugin = detected ? { marketplace: detected.marketplace, plugin: detected.plugin } : null; const plan = { plugins, selfPlugin, selfRoot: selfPlugin ? selfRoot : null, reload: req.body?.reload !== false, }; try { const started = startUpdateAll(plan); res.json({ ok: true, ...started, planned: plugins.length, self_plugin: selfPlugin }); } catch (err) { res.status(500).json({ error: { code: "EUPDATEALL", message: err.message || String(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); } }); // ── MCP servers (via the `claude` CLI) ───────────────────────────────── // // Same reasoning as the plugin/marketplace routes above: `~/.claude.json` is // rewritten concurrently by the running CLI, so add/remove shell out to // `claude mcp …` rather than editing the file. Reads are unaffected — GET // /mcp above stays on `readMcpServers`; the client refetches it after either // call below succeeds. router.post("/mcp/add", async (req, res) => { const { name, scope, transport, command, args, url, headers, env } = req.body || {}; try { const result = await ccMcpCli.mcpAdd({ name, scope, transport, command, args, url, headers, env, }); return cliResult(res, result, { action: "add", type: "mcp", name }); } catch (err) { return cliError(res, err); } }); router.post("/mcp/remove", async (req, res) => { const { name, scope } = req.body || {}; try { const result = await ccMcpCli.mcpRemove({ name, scope }); return cliResult(res, result, { action: "remove", type: "mcp", 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, EMISMATCH: 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 } }); } // The live token's owner (asked of Anthropic, cached per token) decides who is // logged in — `.claude.json` can name another account. Null offline: then the // config is all there is, as before. router.get("/profiles", async (_req, res) => { try { res.json(ccProfiles.listProfiles({ owner: await ccUsage.liveOwner() })); } catch (e) { return profileError(res, e); } }); router.post("/profiles", async (req, res) => { try { const owner = await ccUsage.liveOwner(); const result = ccProfiles.saveProfile((req.body || {}).name, { owner }); 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", async (req, res) => { try { const owner = await ccUsage.liveOwner(); const result = ccProfiles.activateProfile((req.body || {}).name, { owner }); emitChanged({ action: "activate", type: "profile", name: result.name, project: null }); res.json(result); } catch (e) { return profileError(res, e); } }); /** * POST /profiles/usage — how much of this profile's account quota is spent. * * A POST, and behind the same-origin guard, because it is not a read: a saved * profile's access token has almost always expired, so answering means * refreshing it, and the refresh rotates the stored credentials on disk (see * `cc-usage.js`). That is also why nothing calls this on a timer — the UI asks * per profile, when the user asks. * * The response is two percentages and two reset timestamps. No token material * crosses this boundary, in either direction: the name is all the caller sends. */ router.post("/profiles/usage", sameOriginGuard, async (req, res) => { const name = (req.body || {}).name; try { res.json(await ccUsage.profileUsage(name)); } catch (e) { const status = { ENOTFOUND: 404, EBADNAME: 400, ENOREFRESH: 409, ENOAUTH: 409, ESTALE: 409, ELIVEEXPIRED: 409, EUNAUTHORIZED: 401, }[e.code] || 502; res.status(status).json({ error: { code: e.code || "EUPSTREAM", message: e.message } }); } }); // 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;