feat(cc-config): manage plugins, marketplaces, hooks and settings from the dashboard

The Claude Config page was read-only for these four surfaces. It can now:

- register a marketplace from a pasted GitHub `owner/repo`, http(s) URL or
  absolute path, and install / enable / disable / update / uninstall plugins,
  all delegated to the `claude plugin …` CLI so its registry stays consistent;
- edit hooks and settings.json keys in the user, project and project-local
  layers.

Server:

- `server/lib/cc-plugin-cli.js` wraps the CLI with `execFile` and an explicit
  argv array — never a shell string. Every argument is validated before the
  spawn (shape allowlist per kind, no control characters, no leading `-`, no
  `..`), `CLAUDE_CONFIG_DIR` is pinned to the dashboard's CLAUDE_HOME, stdin is
  closed so a prompt errors instead of hanging, and mutating calls are
  serialized in-process.
- `cc-mutate.patchSettings` merges individual dotted keys into one settings
  layer. It re-reads the file unredacted, so keys the UI never displayed —
  including secret-valued ones — survive byte-for-byte. A payload carrying the
  `<redacted>` placeholder is refused (EREDACTED), as are `__proto__` /
  `prototype` / `constructor` segments; a settings file that does not parse is
  reported (EBADJSON) rather than clobbered. Backup first, then atomic write.
- Six additive routes on /api/cc-config; a non-zero CLI exit is a 422 carrying
  the CLI's own message, a missing binary a 503.

Client: install/add bars, per-card plugin and marketplace actions behind a
two-click confirm, and edit modes for hooks and settings that send only the
keys that changed. New i18n keys in en + vi; the explainer banners that said
these surfaces were read-only now describe what the writes actually do.

Tests: 61 new server tests (CLI argv construction and validation, the settings
key-merge, and the six routes against a fake `claude` on PATH).
This commit is contained in:
2026-08-25 15:58:03 +07:00
parent 03a178a718
commit 008ebf1c5c
12 changed files with 3054 additions and 118 deletions
+146 -6
View File
@@ -6,12 +6,13 @@
* statusline, hook scripts. Powers the Claude Config Explorer dashboard
* page.
*
* Read paths cover every surface. Write paths exist only for low-risk
* text-file artifacts (skills, agents, commands, output styles, memory, and
* per-project auto-memory files) plus the structured keybindings.json editor,
* and always create a timestamped backup before mutating. Plugins, MCP servers,
* and the live settings.json files stay read-only because they are written
* concurrently by the running Claude Code CLI.
* 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.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -19,6 +20,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 { broadcast } = require("../websocket");
const router = Router();
@@ -41,6 +43,9 @@ const ERR_TO_STATUS = {
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) {
@@ -192,6 +197,141 @@ router.delete("/file", (req, res) => {
}
});
// ── 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
// `ccam@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);
}
});
router.get("/backups", (req, res) => {
const scope =
req.query.scope === "user" || req.query.scope === "project" ? req.query.scope : undefined;