2ade30f670
The Claude writer's containment was lexical only: a symlink at ~/.claude/<type>/<name>, or at any ancestor of it, is a string inside the root while the write lands wherever it points. The backup destination had the same exposure, and delete copies through the link before unlinking it. PUT/DELETE /file also honoured ?cwd=, so a project-scope write could be aimed at any directory on disk. PATCH /settings already refused that, with the reasoning in a comment; the two other write routes never adopted it. The guard moves into config-write.js so both writers run one copy.
434 lines
14 KiB
JavaScript
434 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 {
|
|
// 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);
|
|
}
|
|
});
|
|
|
|
// ── 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;
|