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
+236
View File
@@ -822,4 +822,240 @@ describe("/api/cc-config", () => {
assert.equal(status, 400);
assert.equal(body.error.code, "EBADCONTENT");
});
// ── settings key-merge (PATCH /settings) ───────────────────────────────
// Also last: these mutate the fixture's user settings.json.
const userSettings = () =>
JSON.parse(fs.readFileSync(path.join(FAKE_HOME, "settings.json"), "utf8"));
it("PATCH /settings merges one key and leaves every other key intact", async () => {
const before = userSettings();
const { status, body } = await fetchJson("/api/cc-config/settings", {
method: "PATCH",
body: { scope: "user", set: { verbose: true } },
});
assert.equal(status, 200);
assert.deepEqual(body.changed, ["verbose"]);
assert.ok(body.backupPath, "an existing settings.json must be backed up first");
assert.ok(fs.existsSync(body.backupPath));
const after = userSettings();
assert.equal(after.verbose, true);
assert.equal(after.model, before.model);
// The redacted-in-the-API secret survives untouched on disk.
assert.equal(after.apiKeyHelper, "should-be-redacted");
assert.deepEqual(after.enabledPlugins, before.enabledPlugins);
});
it("PATCH /settings writes a nested hooks event without disturbing others", async () => {
const { status } = await fetchJson("/api/cc-config/settings", {
method: "PATCH",
body: {
scope: "user",
set: {
"hooks.Stop": [{ matcher: "*", hooks: [{ type: "command", command: "echo bye" }] }],
},
},
});
assert.equal(status, 200);
const after = userSettings();
assert.equal(after.hooks.Stop[0].hooks[0].command, "echo bye");
// PreToolUse came from the fixture and must still be there.
assert.equal(after.hooks.PreToolUse[0].hooks[0].command, "node x.js");
// And the hooks read endpoint now flattens the new event.
const { body } = await fetchJson("/api/cc-config/hooks");
const user = body.items.find((s) => s.scope === "user");
assert.equal(user.hooks.Stop[0].command, "echo bye");
});
it("PATCH /settings unsets a key", async () => {
const { status, body } = await fetchJson("/api/cc-config/settings", {
method: "PATCH",
body: { scope: "user", unset: ["hooks.Stop"] },
});
assert.equal(status, 200);
assert.deepEqual(body.changed, ["hooks.Stop"]);
assert.equal(userSettings().hooks.Stop, undefined);
assert.ok(userSettings().hooks.PreToolUse, "sibling events survive an unset");
});
it("PATCH /settings refuses a payload carrying the <redacted> placeholder", async () => {
const { status, body } = await fetchJson("/api/cc-config/settings", {
method: "PATCH",
body: { scope: "user", set: { apiKeyHelper: "<redacted>" } },
});
assert.equal(status, 400);
assert.equal(body.error.code, "EREDACTED");
assert.equal(userSettings().apiKeyHelper, "should-be-redacted");
});
it("PATCH /settings rejects prototype-polluting and malformed key paths", async () => {
for (const key of ["__proto__.polluted", "a..b", "a b", ""]) {
const { status, body } = await fetchJson("/api/cc-config/settings", {
method: "PATCH",
body: { scope: "user", set: { [key]: 1 } },
});
assert.equal(status, 400, `accepted ${JSON.stringify(key)}`);
assert.equal(body.error.code, "EBADNAME");
}
assert.equal({}.polluted, undefined);
});
it("PATCH /settings rejects an unknown scope and an empty change set", async () => {
const bad = await fetchJson("/api/cc-config/settings", {
method: "PATCH",
body: { scope: "nope", set: { verbose: true } },
});
assert.equal(bad.status, 400);
assert.equal(bad.body.error.code, "EBADSCOPE");
const empty = await fetchJson("/api/cc-config/settings", {
method: "PATCH",
body: { scope: "user" },
});
assert.equal(empty.status, 400);
assert.equal(empty.body.error.code, "EBADCONTENT");
});
it("PATCH /settings refuses to clobber a settings file that is not valid JSON", async () => {
const file = path.join(FAKE_HOME, "settings.json");
const good = fs.readFileSync(file, "utf8");
fs.writeFileSync(file, "{ not json ");
try {
const { status, body } = await fetchJson("/api/cc-config/settings", {
method: "PATCH",
body: { scope: "user", set: { verbose: false } },
});
assert.equal(status, 409);
assert.equal(body.error.code, "EBADJSON");
assert.equal(fs.readFileSync(file, "utf8"), "{ not json ", "the file must be left alone");
} finally {
fs.writeFileSync(file, good);
}
});
// ── plugin / marketplace routes (fake `claude` on PATH) ────────────────
function stubClaude(scriptBody) {
const bin = path.join(TMP, `bin-${process.hrtime.bigint()}`);
fs.mkdirSync(bin, { recursive: true });
const file = path.join(bin, "claude");
fs.writeFileSync(file, `#!/usr/bin/env bash\n${scriptBody}\n`);
fs.chmodSync(file, 0o755);
const originalPath = process.env.PATH;
process.env.PATH = `${bin}${path.delimiter}${originalPath}`;
return () => {
process.env.PATH = originalPath;
};
}
const ECHO_ARGV = 'for a in "$@"; do echo "$a"; done';
it("POST /marketplaces/add runs the CLI with the pasted source", async () => {
const restore = stubClaude(ECHO_ARGV);
try {
const { status, body } = await fetchJson("/api/cc-config/marketplaces/add", {
method: "POST",
body: { source: "owner/repo" },
});
assert.equal(status, 200);
assert.equal(body.ok, true);
assert.deepEqual(body.stdout.trim().split("\n"), [
"plugin",
"marketplace",
"add",
"owner/repo",
"--scope",
"user",
]);
} finally {
restore();
}
});
it("POST /marketplaces/add rejects a source that is not a repo, URL or path", async () => {
const { status, body } = await fetchJson("/api/cc-config/marketplaces/add", {
method: "POST",
body: { source: "; rm -rf /" },
});
assert.equal(status, 400);
assert.equal(body.error.code, "EBADARG");
});
it("POST /plugins/install surfaces a failed CLI run as 422", async () => {
const restore = stubClaude('echo "✘ Plugin \\"nope\\" not found" >&2\nexit 1');
try {
const { status, body } = await fetchJson("/api/cc-config/plugins/install", {
method: "POST",
body: { spec: "nope@market" },
});
assert.equal(status, 422);
assert.equal(body.error.code, "ECLIEXIT");
assert.match(body.error.message, /not found/);
} finally {
restore();
}
});
it("POST /plugins/action validates the action name", async () => {
const { status, body } = await fetchJson("/api/cc-config/plugins/action", {
method: "POST",
body: { key: "demo-plugin@market", action: "purge" },
});
assert.equal(status, 400);
assert.equal(body.error.code, "EBADACTION");
});
it("POST /plugins/action uninstalls through the CLI", async () => {
const restore = stubClaude(ECHO_ARGV);
try {
const { status, body } = await fetchJson("/api/cc-config/plugins/action", {
method: "POST",
body: { key: "demo-plugin@market", action: "uninstall" },
});
assert.equal(status, 200);
assert.deepEqual(body.stdout.trim().split("\n"), [
"plugin",
"uninstall",
"demo-plugin@market",
]);
} finally {
restore();
}
});
it("GET /plugins/available returns the CLI's JSON inventory", async () => {
const payload = JSON.stringify({
installed: [{ id: "demo-plugin@market" }],
available: [{ pluginId: "other@market", name: "other", marketplaceName: "market" }],
});
const restore = stubClaude(`cat <<'JSON'\n${payload}\nJSON`);
try {
const { status, body } = await fetchJson("/api/cc-config/plugins/available");
assert.equal(status, 200);
assert.equal(body.available[0].pluginId, "other@market");
assert.equal(body.installed.length, 1);
} finally {
restore();
}
});
it("reports a missing `claude` binary as 503 rather than a crash", async () => {
const empty = path.join(TMP, `empty-${process.hrtime.bigint()}`);
fs.mkdirSync(empty, { recursive: true });
const originalPath = process.env.PATH;
process.env.PATH = empty;
try {
const { status, body } = await fetchJson("/api/cc-config/marketplaces/action", {
method: "POST",
body: { name: "demo-mp", action: "update" },
});
assert.equal(status, 503);
assert.equal(body.error.code, "ECLINOTFOUND");
} finally {
process.env.PATH = originalPath;
}
});
});