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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,741 @@
|
||||
/**
|
||||
* @file cc-mutate-settings.test.js
|
||||
* @description Comprehensive tests for cc-mutate.js patchSettings — the per-key
|
||||
* merge for settings.json layers. Tests deep merge, unset, prototype pollution
|
||||
* guards, redaction marker handling (including nested), backup atomicity,
|
||||
* large files, invalid JSON, array/null rejection, and key path validation.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after, beforeEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
|
||||
// Build fixture BEFORE requiring modules that read CLAUDE_HOME
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "cc-mutate-settings-"));
|
||||
const FAKE_HOME = path.join(TMP, "home", ".claude");
|
||||
const FAKE_PROJECT = path.join(TMP, "project");
|
||||
const FAKE_PROJECT_CLAUDE = path.join(FAKE_PROJECT, ".claude");
|
||||
|
||||
fs.mkdirSync(FAKE_HOME, { recursive: true });
|
||||
fs.mkdirSync(FAKE_PROJECT_CLAUDE, { recursive: true });
|
||||
|
||||
// Now set env and require
|
||||
process.env.CLAUDE_HOME = FAKE_HOME;
|
||||
const ccMutate = require("../lib/cc-mutate");
|
||||
|
||||
after(() => {
|
||||
fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 5 });
|
||||
});
|
||||
|
||||
describe("patchSettings — happy path", () => {
|
||||
beforeEach(() => {
|
||||
fs.rmSync(FAKE_HOME, { recursive: true, force: true });
|
||||
fs.rmSync(FAKE_PROJECT_CLAUDE, { recursive: true, force: true });
|
||||
fs.mkdirSync(FAKE_HOME, { recursive: true });
|
||||
fs.mkdirSync(FAKE_PROJECT_CLAUDE, { recursive: true });
|
||||
});
|
||||
|
||||
it("creates a new settings.json with a single key", () => {
|
||||
const result = ccMutate.patchSettings({
|
||||
scope: "user",
|
||||
set: { model: "opus" },
|
||||
});
|
||||
assert(result.ok === undefined); // Returns { path, backupPath, created, changed }
|
||||
assert.equal(result.created, true);
|
||||
assert.equal(result.backupPath, null);
|
||||
assert.deepEqual(result.changed, ["model"]);
|
||||
|
||||
const content = JSON.parse(fs.readFileSync(result.path, "utf8"));
|
||||
assert.equal(content.model, "opus");
|
||||
});
|
||||
|
||||
it("merges into an existing settings.json without clobbering untouched keys", () => {
|
||||
const file = path.join(FAKE_HOME, "settings.json");
|
||||
fs.writeFileSync(file, JSON.stringify({ model: "opus", persist: "keep-me" }));
|
||||
|
||||
const result = ccMutate.patchSettings({
|
||||
scope: "user",
|
||||
set: { apiKey: "secret" },
|
||||
});
|
||||
assert.equal(result.created, false);
|
||||
assert.notEqual(result.backupPath, null);
|
||||
|
||||
const content = JSON.parse(fs.readFileSync(result.path, "utf8"));
|
||||
assert.equal(content.model, "opus");
|
||||
assert.equal(content.persist, "keep-me");
|
||||
assert.equal(content.apiKey, "secret");
|
||||
});
|
||||
|
||||
it("unsets a top-level key", () => {
|
||||
const file = path.join(FAKE_HOME, "settings.json");
|
||||
fs.writeFileSync(file, JSON.stringify({ model: "opus", temp: "remove" }));
|
||||
|
||||
const result = ccMutate.patchSettings({
|
||||
scope: "user",
|
||||
unset: ["temp"],
|
||||
});
|
||||
assert.deepEqual(result.changed, ["temp"]);
|
||||
|
||||
const content = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
assert.equal(content.model, "opus");
|
||||
assert.equal(content.temp, undefined);
|
||||
});
|
||||
|
||||
it("unsets a key that does not exist (no error, not listed)", () => {
|
||||
const file = path.join(FAKE_HOME, "settings.json");
|
||||
fs.writeFileSync(file, JSON.stringify({ model: "opus" }));
|
||||
|
||||
const result = ccMutate.patchSettings({
|
||||
scope: "user",
|
||||
unset: ["nonexistent"],
|
||||
});
|
||||
assert.deepEqual(result.changed, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("patchSettings — deep merge", () => {
|
||||
beforeEach(() => {
|
||||
fs.rmSync(FAKE_HOME, { recursive: true, force: true });
|
||||
fs.mkdirSync(FAKE_HOME, { recursive: true });
|
||||
});
|
||||
|
||||
it("merges nested objects without clobbering siblings", () => {
|
||||
const file = path.join(FAKE_HOME, "settings.json");
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
JSON.stringify({
|
||||
permissions: { allow: ["Bash"], deny: ["network"] },
|
||||
other: "data",
|
||||
})
|
||||
);
|
||||
|
||||
const result = ccMutate.patchSettings({
|
||||
scope: "user",
|
||||
set: { "permissions.allow": ["Bash", "Python"] },
|
||||
});
|
||||
|
||||
const content = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
assert.deepEqual(content.permissions.allow, ["Bash", "Python"]);
|
||||
assert.deepEqual(content.permissions.deny, ["network"]);
|
||||
assert.equal(content.other, "data");
|
||||
});
|
||||
|
||||
it("creates deeply nested paths in a fresh file", () => {
|
||||
const result = ccMutate.patchSettings({
|
||||
scope: "user",
|
||||
set: { "a.b.c.d": 42 },
|
||||
});
|
||||
|
||||
const content = JSON.parse(fs.readFileSync(result.path, "utf8"));
|
||||
assert.equal(content.a.b.c.d, 42);
|
||||
});
|
||||
|
||||
it("overwrites a non-object intermediate value during nested set", () => {
|
||||
const file = path.join(FAKE_HOME, "settings.json");
|
||||
fs.writeFileSync(file, JSON.stringify({ config: "string-value" }));
|
||||
|
||||
ccMutate.patchSettings({
|
||||
scope: "user",
|
||||
set: { "config.nested.key": "new-value" },
|
||||
});
|
||||
|
||||
const content = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
assert.equal(content.config.nested.key, "new-value");
|
||||
});
|
||||
|
||||
it("unsets a nested key without touching siblings", () => {
|
||||
const file = path.join(FAKE_HOME, "settings.json");
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
JSON.stringify({
|
||||
hooks: {
|
||||
PreToolUse: [{ command: "x" }],
|
||||
PostToolUse: [{ command: "y" }],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const result = ccMutate.patchSettings({
|
||||
scope: "user",
|
||||
unset: ["hooks.PreToolUse"],
|
||||
});
|
||||
assert.deepEqual(result.changed, ["hooks.PreToolUse"]);
|
||||
|
||||
const content = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
assert.equal(content.hooks.PreToolUse, undefined);
|
||||
assert.deepEqual(content.hooks.PostToolUse, [{ command: "y" }]);
|
||||
});
|
||||
|
||||
it("fails to unset a nested key whose parent is not an object", () => {
|
||||
const file = path.join(FAKE_HOME, "settings.json");
|
||||
fs.writeFileSync(file, JSON.stringify({ config: "string" }));
|
||||
|
||||
const result = ccMutate.patchSettings({
|
||||
scope: "user",
|
||||
unset: ["config.nested.key"],
|
||||
});
|
||||
// unsetDeep returns false, so the key is not listed in changed
|
||||
assert.deepEqual(result.changed, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("patchSettings — redaction marker", () => {
|
||||
beforeEach(() => {
|
||||
fs.rmSync(FAKE_HOME, { recursive: true, force: true });
|
||||
fs.mkdirSync(FAKE_HOME, { recursive: true });
|
||||
});
|
||||
|
||||
it("rejects a value containing <redacted> at top level", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
ccMutate.patchSettings({
|
||||
scope: "user",
|
||||
set: { apiKey: "<redacted>" },
|
||||
}),
|
||||
(err) => {
|
||||
assert.equal(err.code, "EREDACTED");
|
||||
assert.match(err.message, /placeholder/);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a value containing <redacted> at nested position", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
ccMutate.patchSettings({
|
||||
scope: "user",
|
||||
set: { "auth.token": "<redacted>" },
|
||||
}),
|
||||
(err) => {
|
||||
assert.equal(err.code, "EREDACTED");
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects <redacted> inside an object", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
ccMutate.patchSettings({
|
||||
scope: "user",
|
||||
set: { config: { secret: "<redacted>", other: "ok" } },
|
||||
}),
|
||||
(err) => {
|
||||
assert.equal(err.code, "EREDACTED");
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects <redacted> inside an array", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
ccMutate.patchSettings({
|
||||
scope: "user",
|
||||
set: { items: ["a", "<redacted>", "b"] },
|
||||
}),
|
||||
(err) => {
|
||||
assert.equal(err.code, "EREDACTED");
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts a value that merely contains the string redacted (no angle brackets)", () => {
|
||||
const result = ccMutate.patchSettings({
|
||||
scope: "user",
|
||||
set: { notes: "this value is redacted in the UI" },
|
||||
});
|
||||
assert.deepEqual(result.changed, ["notes"]);
|
||||
|
||||
const content = JSON.parse(fs.readFileSync(result.path, "utf8"));
|
||||
assert.equal(content.notes, "this value is redacted in the UI");
|
||||
});
|
||||
});
|
||||
|
||||
describe("patchSettings — key path validation", () => {
|
||||
beforeEach(() => {
|
||||
fs.rmSync(FAKE_HOME, { recursive: true, force: true });
|
||||
fs.mkdirSync(FAKE_HOME, { recursive: true });
|
||||
});
|
||||
|
||||
it("accepts alphanumeric keys with dots for nesting", () => {
|
||||
const result = ccMutate.patchSettings({
|
||||
scope: "user",
|
||||
set: { "my_key.sub-key": "value" },
|
||||
});
|
||||
assert.deepEqual(result.changed, ["my_key.sub-key"]);
|
||||
});
|
||||
|
||||
it("accepts $ in key paths (for $schema, $docs)", () => {
|
||||
const result = ccMutate.patchSettings({
|
||||
scope: "user",
|
||||
set: { $schema: "http://example.com" },
|
||||
});
|
||||
assert.deepEqual(result.changed, ["$schema"]);
|
||||
});
|
||||
|
||||
it("rejects __proto__", () => {
|
||||
// Built via JSON.parse, not an object literal: a literal `__proto__:` key
|
||||
// sets the prototype instead of an own property, so it would never reach
|
||||
// the validator. An HTTP body arrives parsed, i.e. as an own key.
|
||||
const set = JSON.parse('{"__proto__": {"polluted": true}}');
|
||||
assert.throws(
|
||||
() =>
|
||||
ccMutate.patchSettings({
|
||||
scope: "user",
|
||||
set,
|
||||
}),
|
||||
(err) => {
|
||||
assert.equal(err.code, "EBADNAME");
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects prototype", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
ccMutate.patchSettings({
|
||||
scope: "user",
|
||||
set: { prototype: "bad" },
|
||||
}),
|
||||
(err) => {
|
||||
assert.equal(err.code, "EBADNAME");
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects constructor", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
ccMutate.patchSettings({
|
||||
scope: "user",
|
||||
set: { constructor: {} },
|
||||
}),
|
||||
(err) => {
|
||||
assert.equal(err.code, "EBADNAME");
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects __proto__ in a nested path", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
ccMutate.patchSettings({
|
||||
scope: "user",
|
||||
set: { "config.__proto__.polluted": true },
|
||||
}),
|
||||
(err) => {
|
||||
assert.equal(err.code, "EBADNAME");
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects empty key paths", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
ccMutate.patchSettings({
|
||||
scope: "user",
|
||||
set: { "": "bad" },
|
||||
}),
|
||||
(err) => {
|
||||
assert.equal(err.code, "EBADNAME");
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects keys with invalid characters", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
ccMutate.patchSettings({
|
||||
scope: "user",
|
||||
set: { "bad@key": "value" },
|
||||
}),
|
||||
(err) => {
|
||||
assert.equal(err.code, "EBADNAME");
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects empty segments in dotted paths", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
ccMutate.patchSettings({
|
||||
scope: "user",
|
||||
set: { "config..key": "bad" },
|
||||
}),
|
||||
(err) => {
|
||||
assert.equal(err.code, "EBADNAME");
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("patchSettings — scopes", () => {
|
||||
beforeEach(() => {
|
||||
fs.rmSync(FAKE_HOME, { recursive: true, force: true });
|
||||
fs.rmSync(FAKE_PROJECT_CLAUDE, { recursive: true, force: true });
|
||||
fs.mkdirSync(FAKE_HOME, { recursive: true });
|
||||
fs.mkdirSync(FAKE_PROJECT_CLAUDE, { recursive: true });
|
||||
});
|
||||
|
||||
it("writes to user scope (~/.claude/settings.json)", () => {
|
||||
const result = ccMutate.patchSettings({
|
||||
scope: "user",
|
||||
set: { model: "sonnet" },
|
||||
});
|
||||
assert(result.path.includes(".claude/settings.json"));
|
||||
assert(fs.existsSync(result.path));
|
||||
});
|
||||
|
||||
it("writes to project scope (./.claude/settings.json)", () => {
|
||||
const result = ccMutate.patchSettings({
|
||||
scope: "project",
|
||||
cwd: FAKE_PROJECT,
|
||||
set: { model: "haiku" },
|
||||
});
|
||||
assert(result.path.includes(".claude/settings.json"));
|
||||
const content = JSON.parse(fs.readFileSync(result.path, "utf8"));
|
||||
assert.equal(content.model, "haiku");
|
||||
});
|
||||
|
||||
it("writes to project-local scope (./.claude/settings.local.json)", () => {
|
||||
const result = ccMutate.patchSettings({
|
||||
scope: "project-local",
|
||||
cwd: FAKE_PROJECT,
|
||||
set: { permissions: { allow: ["Bash"] } },
|
||||
});
|
||||
assert(result.path.includes("settings.local.json"));
|
||||
const content = JSON.parse(fs.readFileSync(result.path, "utf8"));
|
||||
assert.deepEqual(content.permissions.allow, ["Bash"]);
|
||||
});
|
||||
|
||||
it("rejects unknown scope", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
ccMutate.patchSettings({
|
||||
scope: "unknown",
|
||||
set: { key: "value" },
|
||||
}),
|
||||
(err) => {
|
||||
assert.equal(err.code, "EBADSCOPE");
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("patchSettings — file format validation", () => {
|
||||
beforeEach(() => {
|
||||
fs.rmSync(FAKE_HOME, { recursive: true, force: true });
|
||||
fs.mkdirSync(FAKE_HOME, { recursive: true });
|
||||
});
|
||||
|
||||
it("rejects a settings file that is not valid JSON", () => {
|
||||
const file = path.join(FAKE_HOME, "settings.json");
|
||||
fs.writeFileSync(file, "not json at all {{{");
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
ccMutate.patchSettings({
|
||||
scope: "user",
|
||||
set: { key: "value" },
|
||||
}),
|
||||
(err) => {
|
||||
assert.equal(err.code, "EBADJSON");
|
||||
assert.match(err.message, /not valid JSON/);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a settings file that parses to an array", () => {
|
||||
const file = path.join(FAKE_HOME, "settings.json");
|
||||
fs.writeFileSync(file, JSON.stringify(["a", "b", "c"]));
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
ccMutate.patchSettings({
|
||||
scope: "user",
|
||||
set: { key: "value" },
|
||||
}),
|
||||
(err) => {
|
||||
assert.equal(err.code, "EBADJSON");
|
||||
assert.match(err.message, /does not contain a JSON object/);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a settings file that parses to null", () => {
|
||||
const file = path.join(FAKE_HOME, "settings.json");
|
||||
fs.writeFileSync(file, "null");
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
ccMutate.patchSettings({
|
||||
scope: "user",
|
||||
set: { key: "value" },
|
||||
}),
|
||||
(err) => {
|
||||
assert.equal(err.code, "EBADJSON");
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a settings file that parses to a plain string", () => {
|
||||
const file = path.join(FAKE_HOME, "settings.json");
|
||||
fs.writeFileSync(file, '"not-an-object"');
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
ccMutate.patchSettings({
|
||||
scope: "user",
|
||||
set: { key: "value" },
|
||||
}),
|
||||
(err) => {
|
||||
assert.equal(err.code, "EBADJSON");
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("allows empty files to be treated as {}", () => {
|
||||
const file = path.join(FAKE_HOME, "settings.json");
|
||||
fs.writeFileSync(file, "");
|
||||
|
||||
const result = ccMutate.patchSettings({
|
||||
scope: "user",
|
||||
set: { key: "value" },
|
||||
});
|
||||
const content = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
assert.equal(content.key, "value");
|
||||
});
|
||||
|
||||
it("allows files with only whitespace to be treated as {}", () => {
|
||||
const file = path.join(FAKE_HOME, "settings.json");
|
||||
fs.writeFileSync(file, " \n \t ");
|
||||
|
||||
const result = ccMutate.patchSettings({
|
||||
scope: "user",
|
||||
set: { key: "value" },
|
||||
});
|
||||
const content = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
assert.equal(content.key, "value");
|
||||
});
|
||||
});
|
||||
|
||||
describe("patchSettings — size limits", () => {
|
||||
beforeEach(() => {
|
||||
fs.rmSync(FAKE_HOME, { recursive: true, force: true });
|
||||
fs.mkdirSync(FAKE_HOME, { recursive: true });
|
||||
});
|
||||
|
||||
it("rejects a settings file larger than MAX_FILE_BYTES", () => {
|
||||
const file = path.join(FAKE_HOME, "settings.json");
|
||||
// The MAX_FILE_BYTES is typically 1MB; create a string close to that
|
||||
// We don't have direct access to MAX_FILE_BYTES here, so read it from the discovery module
|
||||
const discovery = require("../lib/cc-discovery");
|
||||
const largeString = "x".repeat(discovery.MAX_FILE_BYTES + 100);
|
||||
fs.writeFileSync(file, JSON.stringify({ data: largeString }));
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
ccMutate.patchSettings({
|
||||
scope: "user",
|
||||
set: { key: "value" },
|
||||
}),
|
||||
(err) => {
|
||||
assert.equal(err.code, "ETOOLARGE");
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a write that would produce output exceeding MAX_FILE_BYTES", () => {
|
||||
const discovery = require("../lib/cc-discovery");
|
||||
const nearLimit = Math.floor(discovery.MAX_FILE_BYTES / 2);
|
||||
const largeValue = "y".repeat(nearLimit);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
ccMutate.patchSettings({
|
||||
scope: "user",
|
||||
set: { a: largeValue, b: largeValue },
|
||||
}),
|
||||
(err) => {
|
||||
assert.equal(err.code, "ETOOLARGE");
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("patchSettings — backup and atomicity", () => {
|
||||
beforeEach(() => {
|
||||
fs.rmSync(FAKE_HOME, { recursive: true, force: true });
|
||||
fs.mkdirSync(FAKE_HOME, { recursive: true });
|
||||
});
|
||||
|
||||
it("creates a backup of an existing file before writing", () => {
|
||||
const file = path.join(FAKE_HOME, "settings.json");
|
||||
const original = { model: "opus", data: "original" };
|
||||
fs.writeFileSync(file, JSON.stringify(original));
|
||||
|
||||
const result = ccMutate.patchSettings({
|
||||
scope: "user",
|
||||
set: { model: "sonnet" },
|
||||
});
|
||||
|
||||
assert.notEqual(result.backupPath, null);
|
||||
assert(fs.existsSync(result.backupPath));
|
||||
|
||||
const backup = JSON.parse(fs.readFileSync(result.backupPath, "utf8"));
|
||||
assert.deepEqual(backup, original);
|
||||
});
|
||||
|
||||
it("returns null backupPath for a fresh file", () => {
|
||||
const result = ccMutate.patchSettings({
|
||||
scope: "user",
|
||||
set: { key: "value" },
|
||||
});
|
||||
assert.equal(result.backupPath, null);
|
||||
});
|
||||
|
||||
it("backup path includes the settings file name and a timestamp", () => {
|
||||
const file = path.join(FAKE_HOME, "settings.json");
|
||||
fs.writeFileSync(file, JSON.stringify({ existing: true }));
|
||||
|
||||
const result = ccMutate.patchSettings({
|
||||
scope: "user",
|
||||
set: { new: "key" },
|
||||
});
|
||||
|
||||
assert(result.backupPath.includes("settings.json"));
|
||||
assert(result.backupPath.includes(".bak"));
|
||||
});
|
||||
|
||||
it("backup is stored under .cc-config-backups/settings/", () => {
|
||||
const file = path.join(FAKE_HOME, "settings.json");
|
||||
fs.writeFileSync(file, JSON.stringify({ existing: true }));
|
||||
|
||||
const result = ccMutate.patchSettings({
|
||||
scope: "user",
|
||||
set: { new: "key" },
|
||||
});
|
||||
|
||||
assert(result.backupPath.includes(".cc-config-backups/settings/"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("patchSettings — edge cases", () => {
|
||||
beforeEach(() => {
|
||||
fs.rmSync(FAKE_HOME, { recursive: true, force: true });
|
||||
fs.mkdirSync(FAKE_HOME, { recursive: true });
|
||||
});
|
||||
|
||||
it("rejects when both set and unset are empty", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
ccMutate.patchSettings({
|
||||
scope: "user",
|
||||
set: {},
|
||||
unset: [],
|
||||
}),
|
||||
(err) => {
|
||||
assert.equal(err.code, "EBADCONTENT");
|
||||
assert.match(err.message, /nothing to change/);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects when set is not an object", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
ccMutate.patchSettings({
|
||||
scope: "user",
|
||||
set: "not an object",
|
||||
}),
|
||||
(err) => {
|
||||
assert.equal(err.code, "EBADCONTENT");
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects when set is an array", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
ccMutate.patchSettings({
|
||||
scope: "user",
|
||||
set: ["a", "b"],
|
||||
}),
|
||||
(err) => {
|
||||
assert.equal(err.code, "EBADCONTENT");
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects when unset is not an array", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
ccMutate.patchSettings({
|
||||
scope: "user",
|
||||
unset: "not an array",
|
||||
}),
|
||||
(err) => {
|
||||
assert.equal(err.code, "EBADCONTENT");
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts various JSON types as values", () => {
|
||||
const result = ccMutate.patchSettings({
|
||||
scope: "user",
|
||||
set: {
|
||||
string: "value",
|
||||
number: 42,
|
||||
boolean: true,
|
||||
null_value: null,
|
||||
array: [1, 2, 3],
|
||||
object: { nested: "data" },
|
||||
},
|
||||
});
|
||||
|
||||
const content = JSON.parse(fs.readFileSync(result.path, "utf8"));
|
||||
assert.equal(content.string, "value");
|
||||
assert.equal(content.number, 42);
|
||||
assert.equal(content.boolean, true);
|
||||
assert.equal(content.null_value, null);
|
||||
assert.deepEqual(content.array, [1, 2, 3]);
|
||||
assert.deepEqual(content.object, { nested: "data" });
|
||||
});
|
||||
|
||||
it("lists only keys that were actually changed in the response", () => {
|
||||
const file = path.join(FAKE_HOME, "settings.json");
|
||||
fs.writeFileSync(file, JSON.stringify({ unchanged: "kept", changed: "old" }));
|
||||
|
||||
const result = ccMutate.patchSettings({
|
||||
scope: "user",
|
||||
set: { changed: "new" },
|
||||
});
|
||||
|
||||
assert.deepEqual(result.changed, ["changed"]);
|
||||
assert(!result.changed.includes("unchanged"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* @file Tests for server/lib/cc-plugin-cli.js — the `claude plugin …` wrapper
|
||||
* that backs plugin/marketplace management from the dashboard. Uses a real
|
||||
* fake `claude` script on PATH (the fixture style server/__tests__/gh.test.js
|
||||
* uses) rather than mocking child_process, and pins the exact argv each
|
||||
* operation builds — that argv IS the security boundary.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
const { describe, it, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-ccplugin-"));
|
||||
const cli = require("../lib/cc-plugin-cli");
|
||||
|
||||
/**
|
||||
* Put a fake `claude` on PATH. The default body echoes the argv, one element
|
||||
* per line, so a test can assert the exact array that was spawned.
|
||||
*/
|
||||
function stubClaude(scriptBody = 'for a in "$@"; do echo "$a"; done') {
|
||||
const bin = path.join(ROOT, `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;
|
||||
};
|
||||
}
|
||||
|
||||
/** PATH with no `claude` on it at all. */
|
||||
function stubMissingClaude() {
|
||||
const empty = path.join(ROOT, `empty-${process.hrtime.bigint()}`);
|
||||
fs.mkdirSync(empty, { recursive: true });
|
||||
const originalPath = process.env.PATH;
|
||||
process.env.PATH = empty;
|
||||
return () => {
|
||||
process.env.PATH = originalPath;
|
||||
};
|
||||
}
|
||||
|
||||
const argvOf = (result) => result.stdout.trim().split("\n");
|
||||
|
||||
after(() => fs.rmSync(ROOT, { recursive: true, force: true, maxRetries: 5 }));
|
||||
|
||||
describe("cc-plugin-cli argument validation", () => {
|
||||
it("accepts owner/repo, http(s) URLs and absolute paths as a source", () => {
|
||||
assert.equal(cli.assertSource("anthropics/claude-plugins"), "anthropics/claude-plugins");
|
||||
assert.equal(cli.assertSource("https://github.com/a/b"), "https://github.com/a/b");
|
||||
assert.equal(cli.assertSource("/opt/marketplace"), "/opt/marketplace");
|
||||
});
|
||||
|
||||
it("rejects sources that are neither repo, URL nor absolute path", () => {
|
||||
for (const bad of ["./relative", "git@github.com:a/b.git", "file:///tmp/x", "a/b/c/d e"]) {
|
||||
assert.throws(() => cli.assertSource(bad), /source must be/, `accepted ${bad}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a source that could become a flag or escape upward", () => {
|
||||
assert.throws(() => cli.assertSource("--scope"), /may not start with/);
|
||||
assert.throws(() => cli.assertSource("/opt/../etc"), /'\.\.'/);
|
||||
});
|
||||
|
||||
it("rejects control characters and empty values", () => {
|
||||
assert.throws(() => cli.assertSource("a/b\nrm -rf /"), /control characters/);
|
||||
assert.throws(() => cli.assertSource(""), /required/);
|
||||
assert.throws(() => cli.assertSource(null), /required/);
|
||||
});
|
||||
|
||||
it("accepts <name> and <name>@<marketplace> plugin specs only", () => {
|
||||
assert.equal(cli.assertPluginSpec("ccam"), "ccam");
|
||||
assert.equal(cli.assertPluginSpec("ccam@sg"), "ccam@sg");
|
||||
for (const bad of ["ccam@bad/repo", "../ccam", "ccam ccam", "@sg", "-ccam"]) {
|
||||
assert.throws(() => cli.assertPluginSpec(bad), undefined, `accepted ${bad}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects marketplace names that are not plain identifiers", () => {
|
||||
assert.equal(cli.assertMarketplaceName("sg"), "sg");
|
||||
assert.throws(() => cli.assertMarketplaceName("sg/other"), /valid identifier/);
|
||||
});
|
||||
|
||||
it("defaults the CLI scope to user and rejects unknown scopes", () => {
|
||||
assert.equal(cli.assertCliScope(undefined), "user");
|
||||
assert.equal(cli.assertCliScope("local"), "local");
|
||||
// `project-local` is the dashboard's settings vocabulary, not the CLI's.
|
||||
assert.throws(() => cli.assertCliScope("project-local"), /scope must be one of/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cc-plugin-cli argv construction", () => {
|
||||
it("builds the marketplace add argv with an explicit scope", async () => {
|
||||
const restore = stubClaude();
|
||||
try {
|
||||
const result = await cli.marketplaceAdd({ source: "owner/repo" });
|
||||
assert.deepEqual(argvOf(result), [
|
||||
"plugin",
|
||||
"marketplace",
|
||||
"add",
|
||||
"owner/repo",
|
||||
"--scope",
|
||||
"user",
|
||||
]);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("passes -y on install so a non-TTY run never waits for a prompt", async () => {
|
||||
const restore = stubClaude();
|
||||
try {
|
||||
const result = await cli.pluginInstall({ spec: "demo@tmpmk", scope: "project" });
|
||||
assert.deepEqual(argvOf(result), [
|
||||
"plugin",
|
||||
"install",
|
||||
"demo@tmpmk",
|
||||
"--scope",
|
||||
"project",
|
||||
"-y",
|
||||
]);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("builds uninstall / enable / disable / update argvs", async () => {
|
||||
const restore = stubClaude();
|
||||
try {
|
||||
assert.deepEqual(argvOf(await cli.pluginUninstall({ spec: "demo@m" })), [
|
||||
"plugin",
|
||||
"uninstall",
|
||||
"demo@m",
|
||||
]);
|
||||
assert.deepEqual(argvOf(await cli.pluginEnable({ spec: "demo@m" })), [
|
||||
"plugin",
|
||||
"enable",
|
||||
"demo@m",
|
||||
]);
|
||||
assert.deepEqual(argvOf(await cli.pluginDisable({ spec: "demo@m" })), [
|
||||
"plugin",
|
||||
"disable",
|
||||
"demo@m",
|
||||
]);
|
||||
assert.deepEqual(argvOf(await cli.pluginUpdate({ spec: "demo@m" })), [
|
||||
"plugin",
|
||||
"update",
|
||||
"demo@m",
|
||||
]);
|
||||
assert.deepEqual(argvOf(await cli.marketplaceRemove({ name: "m" })), [
|
||||
"plugin",
|
||||
"marketplace",
|
||||
"remove",
|
||||
"m",
|
||||
]);
|
||||
assert.deepEqual(argvOf(await cli.marketplaceUpdate({ name: "m" })), [
|
||||
"plugin",
|
||||
"marketplace",
|
||||
"update",
|
||||
"m",
|
||||
]);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("never lets a source reach a shell", async () => {
|
||||
// If the value were interpolated into a shell string this would create the
|
||||
// file; through execFile's argv array it stays one literal argument.
|
||||
const canary = path.join(ROOT, "pwned");
|
||||
const restore = stubClaude();
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => cli.marketplaceAdd({ source: `owner/repo; touch ${canary}` }),
|
||||
/source must be/
|
||||
);
|
||||
assert.equal(fs.existsSync(canary), false);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("cc-plugin-cli run outcomes", () => {
|
||||
it("reports a non-zero exit as ok:false instead of throwing", async () => {
|
||||
const restore = stubClaude('echo "✘ Failed to install plugin \\"nope\\"" >&2\nexit 1');
|
||||
try {
|
||||
const result = await cli.pluginInstall({ spec: "nope@m" });
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.code, 1);
|
||||
assert.match(cli.cliMessage(result), /Failed to install plugin/);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("throws a clear error when the claude CLI is not on PATH", async () => {
|
||||
const restore = stubMissingClaude();
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => cli.marketplaceUpdate({ name: "m" }),
|
||||
(err) => {
|
||||
assert.equal(err.code, "ECLINOTFOUND");
|
||||
assert.match(err.message, /not found on PATH/);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("parses `plugin list --available --json`", async () => {
|
||||
const payload = JSON.stringify({
|
||||
installed: [{ id: "demo@m" }],
|
||||
available: [{ pluginId: "other@m", name: "other" }],
|
||||
});
|
||||
const restore = stubClaude(`cat <<'JSON'\n${payload}\nJSON`);
|
||||
try {
|
||||
const listed = await cli.listAvailable();
|
||||
assert.equal(listed.installed.length, 1);
|
||||
assert.equal(listed.available[0].pluginId, "other@m");
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces unparseable list output as an error", async () => {
|
||||
const restore = stubClaude("echo 'not json'");
|
||||
try {
|
||||
await assert.rejects(() => cli.listAvailable(), /could not parse/);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("serializes mutating calls so two clicks cannot interleave", async () => {
|
||||
// Each run appends its marker around a sleep; interleaved runs would
|
||||
// produce "a1 b1 a2 b2" rather than two complete pairs.
|
||||
const log = path.join(ROOT, `order-${process.hrtime.bigint()}.txt`);
|
||||
const restore = stubClaude(`echo "start $3" >> ${log}\nsleep 0.2\necho "end $3" >> ${log}`);
|
||||
try {
|
||||
await Promise.all([
|
||||
cli.marketplaceUpdate({ name: "first" }),
|
||||
cli.marketplaceUpdate({ name: "second" }),
|
||||
]);
|
||||
const lines = fs.readFileSync(log, "utf8").trim().split("\n");
|
||||
assert.equal(lines[0].replace("start ", ""), lines[1].replace("end ", ""));
|
||||
assert.equal(lines[2].replace("start ", ""), lines[3].replace("end ", ""));
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
+184
-3
@@ -5,10 +5,17 @@
|
||||
* skills, subagents, slash commands, output styles, CLAUDE.md memory, and
|
||||
* per-project file-based memory (~/.claude/projects/<slug>/memory/*.md).
|
||||
*
|
||||
* settings.json layers (user / project / project-local) are also writable, but
|
||||
* ONLY through `patchSettings`'s per-key merge — never as a whole-blob
|
||||
* overwrite. Hooks are edited through that same path (`hooks.<Event>`).
|
||||
*
|
||||
* Hard constraints (do not relax without a follow-up review):
|
||||
* - Plugins, MCP servers, hooks-in-settings, and settings.json files are
|
||||
* NEVER touched here. Those have concurrent-write races with the live
|
||||
* Claude Code CLI and need different handling.
|
||||
* - Plugins and MCP servers are NEVER touched here. Plugin state is written
|
||||
* by the `claude` CLI (see cc-plugin-cli.js); MCP config stays read-only.
|
||||
* - A settings write re-reads the file unredacted and merges the named keys
|
||||
* only. Writing back what `readSettings` returned would replace a real
|
||||
* secret with the literal `<redacted>`, so any payload carrying that
|
||||
* marker is refused.
|
||||
* - Every write/delete creates a timestamped backup BEFORE the mutation.
|
||||
* Backups land under <root>/cc-config-backups/<type>/, well outside the
|
||||
* directories Claude Code scans, so a deleted skill cannot reappear as
|
||||
@@ -439,6 +446,179 @@ function writeKeybindings(args = {}) {
|
||||
return { ok: true, file, target: file, backupPath, created: !existedBefore };
|
||||
}
|
||||
|
||||
// ── settings.json (key-merge only) ─────────────────────────────────────
|
||||
//
|
||||
// The three settings layers Claude Code reads. `project-local` is the
|
||||
// dashboard's name for `settings.local.json`; the plugin CLI calls the same
|
||||
// layer `local` (see cc-plugin-cli.js) — the two vocabularies are separate on
|
||||
// purpose and never share a validator.
|
||||
const SETTINGS_SCOPES = {
|
||||
user: (opts) => path.join(getClaudeHome(), "settings.json"),
|
||||
project: (opts) => path.join(getProjectClaudeDir(opts.cwd), "settings.json"),
|
||||
"project-local": (opts) => path.join(getProjectClaudeDir(opts.cwd), "settings.local.json"),
|
||||
};
|
||||
|
||||
/** A single dotted-path segment. `$` is allowed for `$schema`-style metadata. */
|
||||
const SETTINGS_SEGMENT_RE = /^[A-Za-z0-9_$][A-Za-z0-9._$-]*$/;
|
||||
const FORBIDDEN_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
|
||||
|
||||
function settingsFileFor(scope, opts) {
|
||||
const resolver = SETTINGS_SCOPES[scope];
|
||||
if (!resolver) throw makeError("EBADSCOPE", `unknown settings scope: ${scope}`);
|
||||
return resolver(opts);
|
||||
}
|
||||
|
||||
function settingsBackupRoot(scope, opts) {
|
||||
// Beside the file being edited, in the dotted dir Claude Code never scans —
|
||||
// same convention memory backups use.
|
||||
return path.join(path.dirname(settingsFileFor(scope, opts)), ".cc-config-backups", "settings");
|
||||
}
|
||||
|
||||
/** Split `"permissions.allow"` into validated segments. */
|
||||
function parseSettingsPath(dotted) {
|
||||
if (typeof dotted !== "string" || !dotted.trim()) {
|
||||
throw makeError("EBADNAME", "settings key path must be a non-empty string");
|
||||
}
|
||||
const parts = dotted.split(".");
|
||||
for (const seg of parts) {
|
||||
if (!seg || !SETTINGS_SEGMENT_RE.test(seg) || FORBIDDEN_SEGMENTS.has(seg)) {
|
||||
throw makeError("EBADNAME", `invalid settings key segment: ${seg}`);
|
||||
}
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard against the redaction trap: `readSettings` replaces secret-looking
|
||||
* string values with the literal `<redacted>` before the UI ever sees them, so
|
||||
* echoing a value read from the UI back into the file would overwrite a real
|
||||
* credential with that placeholder. Any payload carrying the marker is
|
||||
* refused outright rather than partially applied.
|
||||
*/
|
||||
function assertNoRedactionMarker(value) {
|
||||
let json;
|
||||
try {
|
||||
json = JSON.stringify(value);
|
||||
} catch {
|
||||
throw makeError("EBADCONTENT", "settings value is not JSON-serializable");
|
||||
}
|
||||
if (json === undefined) throw makeError("EBADCONTENT", "settings value is not JSON-serializable");
|
||||
if (json.includes("<redacted>")) {
|
||||
throw makeError(
|
||||
"EREDACTED",
|
||||
"value contains the <redacted> placeholder — edit that key in the file directly instead"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function setDeep(obj, parts, value) {
|
||||
let node = obj;
|
||||
for (let i = 0; i < parts.length - 1; i += 1) {
|
||||
const key = parts[i];
|
||||
const next = node[key];
|
||||
if (!next || typeof next !== "object" || Array.isArray(next)) node[key] = {};
|
||||
node = node[key];
|
||||
}
|
||||
node[parts[parts.length - 1]] = value;
|
||||
}
|
||||
|
||||
function unsetDeep(obj, parts) {
|
||||
let node = obj;
|
||||
for (let i = 0; i < parts.length - 1; i += 1) {
|
||||
const next = node[parts[i]];
|
||||
if (!next || typeof next !== "object") return false;
|
||||
node = next;
|
||||
}
|
||||
const last = parts[parts.length - 1];
|
||||
if (!Object.prototype.hasOwnProperty.call(node, last)) return false;
|
||||
delete node[last];
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge individual keys into a settings.json layer. This is the ONLY write
|
||||
* path to a settings file, and it is deliberately a per-key merge: the file is
|
||||
* re-read (unredacted) here, so keys the dashboard never showed — including
|
||||
* secret-valued ones — survive byte-for-byte.
|
||||
*
|
||||
* A file that exists but does not parse as JSON is an error, never a
|
||||
* clobber: overwriting a hand-edited file the CLI still owns would lose data.
|
||||
*
|
||||
* @param {{scope:string, cwd?:string, set?:Record<string,unknown>, unset?:string[]}} args
|
||||
* @returns {{path:string, backupPath:string|null, created:boolean, changed:string[]}}
|
||||
*/
|
||||
function patchSettings(args) {
|
||||
const { scope, cwd, set, unset } = args;
|
||||
const opts = { cwd };
|
||||
const file = settingsFileFor(scope, opts);
|
||||
|
||||
const setEntries =
|
||||
set && typeof set === "object" && !Array.isArray(set) ? Object.entries(set) : [];
|
||||
const unsetPaths = Array.isArray(unset) ? unset : [];
|
||||
if (setEntries.length === 0 && unsetPaths.length === 0) {
|
||||
throw makeError("EBADCONTENT", "nothing to change: provide `set` and/or `unset`");
|
||||
}
|
||||
|
||||
// Validate everything before touching disk — a partial apply is worse than
|
||||
// a rejected request.
|
||||
const parsedSet = setEntries.map(([key, value]) => {
|
||||
assertNoRedactionMarker(value);
|
||||
return { parts: parseSettingsPath(key), key, value };
|
||||
});
|
||||
const parsedUnset = unsetPaths.map((key) => ({ parts: parseSettingsPath(key), key }));
|
||||
|
||||
let existed = false;
|
||||
let data = {};
|
||||
let raw = null;
|
||||
try {
|
||||
raw = fs.readFileSync(file, "utf8");
|
||||
existed = true;
|
||||
} catch {
|
||||
/* fresh file */
|
||||
}
|
||||
if (existed) {
|
||||
if (Buffer.byteLength(raw, "utf8") > MAX_FILE_BYTES) {
|
||||
throw makeError("ETOOLARGE", "settings file is too large to edit safely");
|
||||
}
|
||||
try {
|
||||
data = raw.trim() ? JSON.parse(raw) : {};
|
||||
} catch {
|
||||
throw makeError("EBADJSON", `${file} is not valid JSON — fix it by hand before editing here`);
|
||||
}
|
||||
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
||||
throw makeError("EBADJSON", `${file} does not contain a JSON object`);
|
||||
}
|
||||
}
|
||||
|
||||
const changed = [];
|
||||
for (const { parts, key, value } of parsedSet) {
|
||||
setDeep(data, parts, value);
|
||||
changed.push(key);
|
||||
}
|
||||
for (const { parts, key } of parsedUnset) {
|
||||
if (unsetDeep(data, parts)) changed.push(key);
|
||||
}
|
||||
|
||||
const output = `${JSON.stringify(data, null, 2)}\n`;
|
||||
// The same ceiling the read path enforces: a value pasted into the editor
|
||||
// must not produce a file the explorer can no longer open. Checked BEFORE
|
||||
// the backup, so a rejected write leaves no artifacts at all.
|
||||
if (Buffer.byteLength(output, "utf8") > MAX_FILE_BYTES) {
|
||||
throw makeError("ETOOLARGE", "the resulting settings file would be too large");
|
||||
}
|
||||
|
||||
let backupPath = null;
|
||||
if (existed) {
|
||||
const root = settingsBackupRoot(scope, opts);
|
||||
fs.mkdirSync(root, { recursive: true });
|
||||
backupPath = path.join(root, `${path.basename(file)}.${timestamp()}.bak`);
|
||||
fs.copyFileSync(file, backupPath);
|
||||
}
|
||||
|
||||
atomicWriteFile(file, output);
|
||||
return { path: file, backupPath, created: !existed, changed };
|
||||
}
|
||||
|
||||
/**
|
||||
* List backups for either all types or a specific (scope, type) bucket.
|
||||
* Returns [{ scope, type, name, backupPath, mtime, size }].
|
||||
@@ -528,6 +708,7 @@ module.exports = {
|
||||
writeArtifact,
|
||||
deleteArtifact,
|
||||
writeKeybindings,
|
||||
patchSettings,
|
||||
listBackups,
|
||||
resolveTarget, // exported for tests
|
||||
TYPES,
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* @file cc-plugin-cli.js
|
||||
* @description Drives the official `claude plugin …` CLI so the dashboard can
|
||||
* add/remove marketplaces and install/uninstall/enable/disable/update plugins
|
||||
* without the user opening a terminal.
|
||||
*
|
||||
* Why the CLI and not direct JSON edits: `installed_plugins.json`,
|
||||
* `known_marketplaces.json` and the plugin cache tree are written together by
|
||||
* the CLI (it clones the marketplace repo, resolves versions, prunes the
|
||||
* cache). Reproducing that here would drift the moment the CLI's schema moves.
|
||||
*
|
||||
* Hard constraints (do not relax without a follow-up review):
|
||||
* - `execFile` with an explicit argv array. NEVER a shell string — the
|
||||
* `source`/`spec` values come straight from a user-typed field.
|
||||
* - Every argument is validated BEFORE the spawn: no leading `-` (a value
|
||||
* can never become a flag), no control characters, and a shape allowlist
|
||||
* per argument kind.
|
||||
* - Mutating calls are serialized in-process; two dashboard clicks must not
|
||||
* interleave writes to the same manifest.
|
||||
* - `CLAUDE_CONFIG_DIR` is pinned to the same CLAUDE_HOME the dashboard
|
||||
* reads, so the CLI writes where `cc-discovery` looks.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { execFile } = require("node:child_process");
|
||||
const path = require("node:path");
|
||||
const { getClaudeHome } = require("./claude-home");
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 120_000;
|
||||
const MAX_OUTPUT_BYTES = 1024 * 1024;
|
||||
|
||||
/** `owner/repo` — the GitHub shorthand `claude plugin marketplace add` accepts. */
|
||||
// Both halves must START with an alphanumeric, so `./relative` is not mistaken
|
||||
// for `owner/repo`.
|
||||
const REPO_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
||||
/** `name` or `name@marketplace`. No slashes: a plugin spec is never a path. */
|
||||
const PLUGIN_SPEC_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*(@[A-Za-z0-9][A-Za-z0-9._-]*)?$/;
|
||||
/** A marketplace's registered name (what `known_marketplaces.json` is keyed by). */
|
||||
const MARKETPLACE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
||||
/** Install scopes the CLI understands — note `local`, not `project-local`. */
|
||||
const CLI_SCOPES = ["user", "project", "local"];
|
||||
|
||||
function makeError(code, message) {
|
||||
const err = new Error(message);
|
||||
err.code = code;
|
||||
return err;
|
||||
}
|
||||
|
||||
/**
|
||||
* Baseline check applied to every argv element we interpolate. A value that
|
||||
* starts with `-` would be parsed as a flag by the CLI even inside an argv
|
||||
* array, so it is rejected rather than escaped.
|
||||
*/
|
||||
function assertArg(value, label) {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw makeError("EBADARG", `${label} is required`);
|
||||
}
|
||||
if (value.length > 512) throw makeError("EBADARG", `${label} is too long`);
|
||||
// eslint-disable-next-line no-control-regex
|
||||
if (/[\u0000-\u001f\u007f]/.test(value)) {
|
||||
throw makeError("EBADARG", `${label} contains control characters`);
|
||||
}
|
||||
if (value.startsWith("-")) throw makeError("EBADARG", `${label} may not start with "-"`);
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* A marketplace source is one of: `owner/repo`, an http(s) URL, or an absolute
|
||||
* path on this machine. Anything else (an ssh remote, a `file://` URL, a
|
||||
* relative path) is refused — the set the UI advertises is the set we accept.
|
||||
*/
|
||||
function assertSource(value) {
|
||||
assertArg(value, "source");
|
||||
const v = value.trim();
|
||||
if (v.includes("..")) throw makeError("EBADARG", "source may not contain '..'");
|
||||
if (/^https?:\/\/\S+$/i.test(v)) return v;
|
||||
if (path.isAbsolute(v)) return v;
|
||||
if (REPO_RE.test(v)) return v;
|
||||
throw makeError("EBADARG", "source must be owner/repo, an http(s) URL, or an absolute path");
|
||||
}
|
||||
|
||||
function assertPluginSpec(value) {
|
||||
assertArg(value, "plugin");
|
||||
if (!PLUGIN_SPEC_RE.test(value)) {
|
||||
throw makeError("EBADARG", "plugin must be <name> or <name>@<marketplace>");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertMarketplaceName(value) {
|
||||
assertArg(value, "marketplace");
|
||||
if (!MARKETPLACE_NAME_RE.test(value)) {
|
||||
throw makeError("EBADARG", "marketplace name is not a valid identifier");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertCliScope(value) {
|
||||
if (value === undefined || value === null || value === "") return "user";
|
||||
if (!CLI_SCOPES.includes(value)) {
|
||||
throw makeError("EBADARG", `scope must be one of ${CLI_SCOPES.join(", ")}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// One in-process queue for every mutating CLI call. The dashboard is a single
|
||||
// Node process, so a promise chain is the whole locking story — no file lock,
|
||||
// no cross-process coordination. It does NOT protect against a human running
|
||||
// `claude plugin …` in a terminal at the same moment; the CLI owns that race.
|
||||
let mutationQueue = Promise.resolve();
|
||||
|
||||
function serialize(fn) {
|
||||
const run = mutationQueue.then(fn, fn);
|
||||
mutationQueue = run.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
);
|
||||
return run;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `claude <args…>` and resolve with the outcome. A non-zero exit is a
|
||||
* normal result (`ok:false`), not a rejection — callers map it to a response
|
||||
* body. Only a spawn failure (binary missing) rejects.
|
||||
*
|
||||
* @param {string[]} args argv AFTER the binary name; every element pre-validated.
|
||||
* @param {{timeoutMs?:number}} [opts]
|
||||
* @returns {Promise<{ok:boolean, code:number|null, stdout:string, stderr:string, args:string[]}>}
|
||||
*/
|
||||
function runClaude(args, opts = {}) {
|
||||
const timeout = opts.timeoutMs || DEFAULT_TIMEOUT_MS;
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = execFile(
|
||||
"claude",
|
||||
args,
|
||||
{
|
||||
timeout,
|
||||
maxBuffer: MAX_OUTPUT_BYTES,
|
||||
// Pin the CLI to the same config dir cc-discovery reads, so a write
|
||||
// made here is a write the dashboard can see.
|
||||
env: { ...process.env, CLAUDE_CONFIG_DIR: getClaudeHome() },
|
||||
},
|
||||
(err, stdout, stderr) => {
|
||||
const out = String(stdout || "");
|
||||
const errOut = String(stderr || "");
|
||||
if (err && (err.code === "ENOENT" || err.code === "EACCES")) {
|
||||
return reject(
|
||||
makeError(
|
||||
"ECLINOTFOUND",
|
||||
"the `claude` CLI was not found on PATH — install Claude Code or add it to the dashboard's PATH"
|
||||
)
|
||||
);
|
||||
}
|
||||
if (err && err.killed) {
|
||||
return reject(makeError("ETIMEOUT", `claude ${args[0]} timed out after ${timeout}ms`));
|
||||
}
|
||||
const code = err ? (typeof err.code === "number" ? err.code : 1) : 0;
|
||||
resolve({ ok: !err, code, stdout: out, stderr: errOut, args });
|
||||
}
|
||||
);
|
||||
// Every command here is non-interactive. Closing stdin turns a would-be
|
||||
// prompt into an immediate CLI error instead of a hang until `timeout`.
|
||||
try {
|
||||
child.stdin?.end();
|
||||
} catch {
|
||||
/* the child may already have exited */
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Operations ─────────────────────────────────────────────────────────
|
||||
// Each validates first, then queues. Validation errors throw synchronously
|
||||
// from the returned promise, which is what the routes turn into a 400.
|
||||
|
||||
async function marketplaceAdd({ source, scope }) {
|
||||
const src = assertSource(source);
|
||||
const sc = assertCliScope(scope);
|
||||
return serialize(() => runClaude(["plugin", "marketplace", "add", src, "--scope", sc]));
|
||||
}
|
||||
|
||||
async function marketplaceRemove({ name }) {
|
||||
const n = assertMarketplaceName(name);
|
||||
return serialize(() => runClaude(["plugin", "marketplace", "remove", n]));
|
||||
}
|
||||
|
||||
async function marketplaceUpdate({ name }) {
|
||||
const n = assertMarketplaceName(name);
|
||||
return serialize(() => runClaude(["plugin", "marketplace", "update", n]));
|
||||
}
|
||||
|
||||
async function pluginInstall({ spec, scope }) {
|
||||
const s = assertPluginSpec(spec);
|
||||
const sc = assertCliScope(scope);
|
||||
// `-y` accepts the marketplace-declared install command without a prompt;
|
||||
// required because stdin is not a TTY here.
|
||||
return serialize(() => runClaude(["plugin", "install", s, "--scope", sc, "-y"]));
|
||||
}
|
||||
|
||||
async function pluginUninstall({ spec }) {
|
||||
const s = assertPluginSpec(spec);
|
||||
return serialize(() => runClaude(["plugin", "uninstall", s]));
|
||||
}
|
||||
|
||||
async function pluginEnable({ spec }) {
|
||||
const s = assertPluginSpec(spec);
|
||||
return serialize(() => runClaude(["plugin", "enable", s]));
|
||||
}
|
||||
|
||||
async function pluginDisable({ spec }) {
|
||||
const s = assertPluginSpec(spec);
|
||||
return serialize(() => runClaude(["plugin", "disable", s]));
|
||||
}
|
||||
|
||||
async function pluginUpdate({ spec }) {
|
||||
const s = assertPluginSpec(spec);
|
||||
return serialize(() => runClaude(["plugin", "update", s]));
|
||||
}
|
||||
|
||||
/**
|
||||
* `claude plugin list --available --json` — every plugin offered by a
|
||||
* registered marketplace, plus what is already installed. Read-only, so it
|
||||
* skips the mutation queue.
|
||||
*
|
||||
* @returns {Promise<{installed:object[], available:object[]}>}
|
||||
*/
|
||||
async function listAvailable() {
|
||||
const res = await runClaude(["plugin", "list", "--available", "--json"], { timeoutMs: 30_000 });
|
||||
if (!res.ok) {
|
||||
throw makeError("ECLIFAILED", cliMessage(res) || "claude plugin list failed");
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(res.stdout);
|
||||
} catch {
|
||||
throw makeError("ECLIFAILED", "could not parse `claude plugin list --json` output");
|
||||
}
|
||||
return {
|
||||
installed: Array.isArray(parsed.installed) ? parsed.installed : [],
|
||||
available: Array.isArray(parsed.available) ? parsed.available : [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort human message from a failed run. The CLI prints its errors with
|
||||
* a `✘` marker on either stream; fall back to the last non-empty line.
|
||||
*/
|
||||
function cliMessage(res) {
|
||||
const text = `${res.stderr || ""}\n${res.stdout || ""}`;
|
||||
const lines = text
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
const marked = lines.find((l) => l.includes("✘"));
|
||||
const picked = marked || lines[lines.length - 1] || "";
|
||||
return picked.replace(/^✘\s*/, "").slice(0, 1000);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
runClaude,
|
||||
cliMessage,
|
||||
listAvailable,
|
||||
marketplaceAdd,
|
||||
marketplaceRemove,
|
||||
marketplaceUpdate,
|
||||
pluginInstall,
|
||||
pluginUninstall,
|
||||
pluginEnable,
|
||||
pluginDisable,
|
||||
pluginUpdate,
|
||||
// exported for tests
|
||||
assertSource,
|
||||
assertPluginSpec,
|
||||
assertMarketplaceName,
|
||||
assertCliScope,
|
||||
PLUGIN_SPEC_RE,
|
||||
};
|
||||
+146
-6
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user