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:
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user