6d27c115d8
readMcpServers only ever parses ~/.claude.json (top-level = user scope, projects[<root>] = the panel's "projectScoped" bucket, which the CLI itself calls `local`). The previous user/project scope choice was the exact inverse: `--scope project` writes .mcp.json, which readMcpServers never reads (a successful add would vanish), and every "projectScoped" row was actually `local`, so Remove sent a scope the CLI could never match. - cc-mcp-cli.js: MCP_SCOPES -> ["user", "local"]; drop the local mutationQueue/serialize in favor of cc-plugin-cli.js's exported one, so a plugin install and an MCP add can't spawn `claude` concurrently; drop the String(v) coercion in env/header flags (a wrong-typed value is now a 400, not a written "[object Object]"); assertEmbeddedValue allows empty strings (a legitimate empty stdio arg was previously a 400). - cc-plugin-cli.js: export `serialize` for reuse. - CcConfig.tsx: CcMcpScope narrowed to user/local; the projectScoped bucket's McpCard now sends scope="local"; rows sourced from ~/.claude/settings.json (no CLI scope addresses them) no longer render a Remove button. Cleaned up i18n keys my own earlier change made dead (explain.mcp.addCmd/remove/removeButton) and gave the scope <select> its own short option labels instead of reusing full-sentence section headings.
1219 lines
44 KiB
JavaScript
1219 lines
44 KiB
JavaScript
/**
|
|
* @file cc-config.test.js
|
|
* @description Tests for /api/cc-config — Claude Code configuration explorer.
|
|
* Builds a fake CLAUDE_HOME and project .claude/ in tmpdir, points the
|
|
* server at it, and exercises every surface plus path-containment guards,
|
|
* write/delete with backup, plugin contributions, marketplaces, keybindings,
|
|
* statusline, and hook scripts.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const { describe, it, before, after } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const os = require("node:os");
|
|
const http = require("node:http");
|
|
|
|
// Build the fixture FIRST, set CLAUDE_HOME, then require the server. Order
|
|
// matters: claude-home.js caches the env var on first require.
|
|
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "cc-config-test-"));
|
|
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(path.join(FAKE_HOME, "skills", "demo-skill"), { recursive: true });
|
|
fs.mkdirSync(path.join(FAKE_HOME, "agents"), { recursive: true });
|
|
fs.mkdirSync(path.join(FAKE_HOME, "commands"), { recursive: true });
|
|
fs.mkdirSync(path.join(FAKE_HOME, "plugins"), { recursive: true });
|
|
fs.mkdirSync(path.join(FAKE_PROJECT_CLAUDE, "skills", "proj-skill"), { recursive: true });
|
|
fs.mkdirSync(path.join(FAKE_PROJECT_CLAUDE, "agents"), { recursive: true });
|
|
|
|
fs.writeFileSync(
|
|
path.join(FAKE_HOME, "skills", "demo-skill", "SKILL.md"),
|
|
`---\nname: demo-skill\ndescription: A demo skill for tests\n---\n\nBody text here.`
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(FAKE_HOME, "agents", "demo-agent.md"),
|
|
`---\nname: demo-agent\ntools: Read, Bash\nmodel: sonnet\n---\n\nAgent body.`
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(FAKE_HOME, "commands", "deploy.md"),
|
|
`---\ndescription: ship it\n---\n\nDeploy command body.`
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(FAKE_PROJECT_CLAUDE, "skills", "proj-skill", "SKILL.md"),
|
|
`---\nname: proj-skill\n---\n\nProject skill.`
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(FAKE_PROJECT_CLAUDE, "agents", "proj-agent.md"),
|
|
`---\nname: proj-agent\n---\n\nProject agent.`
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(FAKE_PROJECT_CLAUDE, "settings.local.json"),
|
|
JSON.stringify({
|
|
permissions: { allow: ["Bash(npm:*)"] },
|
|
hooks: {
|
|
Stop: [{ matcher: "*", hooks: [{ type: "command", command: "echo hi" }] }],
|
|
},
|
|
})
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(FAKE_HOME, "settings.json"),
|
|
JSON.stringify({
|
|
model: "opus",
|
|
apiKeyHelper: "should-be-redacted",
|
|
hooks: {
|
|
PreToolUse: [{ matcher: "*", hooks: [{ type: "command", command: "node x.js" }] }],
|
|
},
|
|
})
|
|
);
|
|
// Build a minimal plugin install tree so contributions counter has something to count
|
|
const PLUGIN_INSTALL = path.join(FAKE_HOME, "plugins", "cache", "market", "demo-plugin", "1.0.0");
|
|
fs.mkdirSync(path.join(PLUGIN_INSTALL, ".claude-plugin"), { recursive: true });
|
|
fs.mkdirSync(path.join(PLUGIN_INSTALL, "skills", "plugin-skill"), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(PLUGIN_INSTALL, "skills", "plugin-skill", "SKILL.md"),
|
|
"---\nname: x\n---\nbody"
|
|
);
|
|
fs.mkdirSync(path.join(PLUGIN_INSTALL, "agents"), { recursive: true });
|
|
fs.writeFileSync(path.join(PLUGIN_INSTALL, "agents", "plug-agent.md"), "---\nname: pa\n---\n");
|
|
fs.writeFileSync(
|
|
path.join(PLUGIN_INSTALL, ".claude-plugin", "plugin.json"),
|
|
JSON.stringify({ name: "demo-plugin", description: "Demo", version: "1.0.0" })
|
|
);
|
|
|
|
fs.writeFileSync(
|
|
path.join(FAKE_HOME, "plugins", "installed_plugins.json"),
|
|
JSON.stringify({
|
|
version: 2,
|
|
plugins: {
|
|
"demo-plugin@market": [
|
|
{
|
|
scope: "user",
|
|
installPath: PLUGIN_INSTALL,
|
|
version: "1.0.0",
|
|
installedAt: "2026-01-01T00:00:00Z",
|
|
},
|
|
],
|
|
},
|
|
})
|
|
);
|
|
|
|
// Marketplace fixture
|
|
const MARKETPLACE_DIR = path.join(FAKE_HOME, "plugins", "marketplaces", "demo-mp");
|
|
fs.mkdirSync(path.join(MARKETPLACE_DIR, ".claude-plugin"), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(MARKETPLACE_DIR, ".claude-plugin", "marketplace.json"),
|
|
JSON.stringify({
|
|
name: "demo-mp",
|
|
description: "Demo marketplace",
|
|
owner: { name: "demo" },
|
|
plugins: [{ name: "p1" }, { name: "p2" }, { name: "p3" }],
|
|
})
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(FAKE_HOME, "plugins", "known_marketplaces.json"),
|
|
JSON.stringify({
|
|
"demo-mp": {
|
|
source: { source: "github", repo: "demo/demo" },
|
|
installLocation: MARKETPLACE_DIR,
|
|
lastUpdated: "2026-01-15T00:00:00Z",
|
|
},
|
|
})
|
|
);
|
|
|
|
// Keybindings fixture
|
|
fs.writeFileSync(
|
|
path.join(FAKE_HOME, "keybindings.json"),
|
|
JSON.stringify({
|
|
$schema: "https://www.schemastore.org/x.json",
|
|
bindings: [
|
|
{ context: "Global", bindings: { "ctrl+t": "toggleTodos" } },
|
|
{ context: "Chat", bindings: { escape: "cancel", "ctrl+f": "killAgents" } },
|
|
],
|
|
})
|
|
);
|
|
|
|
// Statusline scripts
|
|
fs.writeFileSync(path.join(FAKE_HOME, "statusline.py"), "# fake statusline\nprint('ok')\n");
|
|
fs.writeFileSync(path.join(FAKE_HOME, "statusline-command.sh"), "#!/bin/sh\necho ok\n");
|
|
|
|
// Hook scripts dir
|
|
fs.mkdirSync(path.join(FAKE_HOME, "hooks"), { recursive: true });
|
|
fs.writeFileSync(path.join(FAKE_HOME, "hooks", "logger.py"), "# fake logger\n");
|
|
fs.writeFileSync(path.join(FAKE_HOME, "hooks", "scanner.py"), "# fake scanner\n");
|
|
|
|
// Mark a plugin as enabled in user settings
|
|
fs.writeFileSync(
|
|
path.join(FAKE_HOME, "settings.json"),
|
|
JSON.stringify({
|
|
model: "opus",
|
|
apiKeyHelper: "should-be-redacted",
|
|
statusLine: { type: "command", command: "sh /tmp/fake-status.sh" },
|
|
enabledPlugins: { "demo-plugin@market": true },
|
|
hooks: {
|
|
PreToolUse: [{ matcher: "*", hooks: [{ type: "command", command: "node x.js" }] }],
|
|
},
|
|
})
|
|
);
|
|
|
|
fs.writeFileSync(path.join(FAKE_PROJECT, "CLAUDE.md"), "# Project memory\nHello.");
|
|
|
|
// Per-project file-based memory store under ~/.claude/projects/<slug>/memory/
|
|
// (MEMORY.md index + one file per remembered fact). notes.txt is a non-md
|
|
// file that must be ignored.
|
|
const FAKE_AUTO_MEM = path.join(FAKE_HOME, "projects", "-Users-test-proj", "memory");
|
|
fs.mkdirSync(FAKE_AUTO_MEM, { recursive: true });
|
|
fs.writeFileSync(path.join(FAKE_AUTO_MEM, "MEMORY.md"), "- [Foo fact](foo.md) — a hook\n");
|
|
fs.writeFileSync(
|
|
path.join(FAKE_AUTO_MEM, "foo.md"),
|
|
"---\nname: foo\n---\nFoo fact body about widgets.\n"
|
|
);
|
|
fs.writeFileSync(path.join(FAKE_AUTO_MEM, "bar.md"), "Bar fact body.\n");
|
|
fs.writeFileSync(path.join(FAKE_AUTO_MEM, "notes.txt"), "ignored non-md\n");
|
|
|
|
process.env.CLAUDE_HOME = FAKE_HOME;
|
|
const TEST_DB = path.join(TMP, "dashboard-test.db");
|
|
process.env.DASHBOARD_DB_PATH = TEST_DB;
|
|
|
|
const { createApp } = require("../index");
|
|
|
|
let server;
|
|
let BASE;
|
|
|
|
function fetchJson(p, opts = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const url = new URL(p, BASE);
|
|
const headers = { ...(opts.headers || {}) };
|
|
let bodyBuf;
|
|
if (opts.body !== undefined) {
|
|
bodyBuf = Buffer.from(JSON.stringify(opts.body));
|
|
headers["Content-Type"] = "application/json";
|
|
headers["Content-Length"] = bodyBuf.length;
|
|
}
|
|
const req = http.request(
|
|
{
|
|
hostname: url.hostname,
|
|
port: url.port,
|
|
path: url.pathname + url.search,
|
|
method: opts.method || "GET",
|
|
headers,
|
|
},
|
|
(res) => {
|
|
const chunks = [];
|
|
res.on("data", (c) => chunks.push(c));
|
|
res.on("end", () => {
|
|
const body = Buffer.concat(chunks).toString("utf8");
|
|
let json;
|
|
try {
|
|
json = JSON.parse(body);
|
|
} catch {
|
|
json = body;
|
|
}
|
|
resolve({ status: res.statusCode, body: json });
|
|
});
|
|
}
|
|
);
|
|
req.on("error", reject);
|
|
if (bodyBuf) req.write(bodyBuf);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
describe("/api/cc-config", () => {
|
|
before(async () => {
|
|
const app = createApp();
|
|
server = http.createServer(app);
|
|
await new Promise((r) => server.listen(0, r));
|
|
const port = server.address().port;
|
|
BASE = `http://127.0.0.1:${port}`;
|
|
});
|
|
|
|
after(async () => {
|
|
await new Promise((r) => server.close(r));
|
|
// On Windows rmSync can hit EPERM when a handle under TMP (fixture files /
|
|
// the OS releasing directory handles) is still held. maxRetries covers
|
|
// transient locks; the try/catch makes the rest best-effort — a leftover
|
|
// temp dir must not fail the suite (the OS reclaims os.tmpdir()).
|
|
try {
|
|
fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
} catch {
|
|
/* best-effort temp cleanup */
|
|
}
|
|
});
|
|
|
|
it("overview reports counts and roots", async () => {
|
|
const { status, body } = await fetchJson(
|
|
`/api/cc-config/overview?cwd=${encodeURIComponent(FAKE_PROJECT)}`
|
|
);
|
|
assert.equal(status, 200);
|
|
assert.equal(body.roots.claudeHome, FAKE_HOME);
|
|
assert.equal(body.roots.projectClaudeDir, FAKE_PROJECT_CLAUDE);
|
|
assert.equal(body.counts.skills.user, 1);
|
|
assert.equal(body.counts.skills.project, 1);
|
|
assert.equal(body.counts.agents.user, 1);
|
|
assert.equal(body.counts.commands.user, 1);
|
|
assert.equal(body.counts.plugins, 1);
|
|
// 1 project CLAUDE.md + 3 auto-memory files (MEMORY.md, foo.md, bar.md);
|
|
// notes.txt is ignored.
|
|
assert.equal(body.counts.memory, 4);
|
|
});
|
|
|
|
it("skills returns user + project items with parsed frontmatter", async () => {
|
|
const { status, body } = await fetchJson(
|
|
`/api/cc-config/skills?cwd=${encodeURIComponent(FAKE_PROJECT)}`
|
|
);
|
|
assert.equal(status, 200);
|
|
assert.ok(Array.isArray(body.items));
|
|
const demo = body.items.find((s) => s.name === "demo-skill");
|
|
assert.equal(demo.scope, "user");
|
|
assert.equal(demo.frontmatter.name, "demo-skill");
|
|
assert.match(demo.preview, /Body text here/);
|
|
const proj = body.items.find((s) => s.name === "proj-skill");
|
|
assert.equal(proj.scope, "project");
|
|
});
|
|
|
|
it("scope=user filters out project items", async () => {
|
|
const { body } = await fetchJson(
|
|
`/api/cc-config/skills?scope=user&cwd=${encodeURIComponent(FAKE_PROJECT)}`
|
|
);
|
|
assert.ok(body.items.every((s) => s.scope === "user"));
|
|
});
|
|
|
|
it("agents parses tools/model frontmatter", async () => {
|
|
const { body } = await fetchJson(
|
|
`/api/cc-config/agents?cwd=${encodeURIComponent(FAKE_PROJECT)}`
|
|
);
|
|
const a = body.items.find((x) => x.name === "demo-agent");
|
|
assert.equal(a.frontmatter.model, "sonnet");
|
|
assert.match(a.frontmatter.tools, /Read/);
|
|
});
|
|
|
|
it("plugins returns installed manifest with contributions and enabled state", async () => {
|
|
const { body } = await fetchJson("/api/cc-config/plugins");
|
|
assert.equal(body.manifestExists, true);
|
|
assert.equal(body.plugins.length, 1);
|
|
const p = body.plugins[0];
|
|
assert.equal(p.name, "demo-plugin");
|
|
assert.equal(p.marketplace, "market");
|
|
assert.equal(p.version, "1.0.0");
|
|
assert.equal(p.enabled, true);
|
|
assert.ok(p.contributes, "contributions surfaced");
|
|
assert.equal(p.contributes.skills, 1);
|
|
assert.equal(p.contributes.agents, 1);
|
|
assert.equal(p.contributes.commands, 0);
|
|
assert.equal(p.contributes.pluginJson.name, "demo-plugin");
|
|
});
|
|
|
|
it("marketplaces returns known marketplaces with plugin counts", async () => {
|
|
const { status, body } = await fetchJson("/api/cc-config/marketplaces");
|
|
assert.equal(status, 200);
|
|
assert.equal(body.knownExists, true);
|
|
assert.equal(body.items.length, 1);
|
|
const m = body.items[0];
|
|
assert.equal(m.name, "demo-mp");
|
|
assert.equal(m.pluginCount, 3);
|
|
assert.equal(m.marketplaceName, "demo-mp");
|
|
assert.equal(m.marketplaceOwner.name, "demo");
|
|
});
|
|
|
|
it("keybindings returns parsed groups", async () => {
|
|
const { body } = await fetchJson("/api/cc-config/keybindings");
|
|
assert.equal(body.exists, true);
|
|
assert.equal(body.groups.length, 2);
|
|
const chat = body.groups.find((g) => g.context === "Chat");
|
|
assert.ok(chat);
|
|
assert.equal(chat.bindings.length, 2);
|
|
const escapeBinding = chat.bindings.find((b) => b.key === "escape");
|
|
assert.equal(escapeBinding.action, "cancel");
|
|
});
|
|
|
|
it("statusline returns config + script content", async () => {
|
|
const { body } = await fetchJson("/api/cc-config/statusline");
|
|
assert.ok(body.config);
|
|
assert.equal(body.config.type, "command");
|
|
assert.equal(body.scripts.length, 2);
|
|
assert.match(body.scripts[0].preview, /fake statusline|fake/);
|
|
});
|
|
|
|
it("hook-scripts lists files inside ~/.claude/hooks/", async () => {
|
|
const { body } = await fetchJson("/api/cc-config/hook-scripts");
|
|
const names = body.items.map((i) => i.name).sort();
|
|
assert.deepEqual(names, ["logger.py", "scanner.py"]);
|
|
});
|
|
|
|
it("overview includes the new counters", async () => {
|
|
const { body } = await fetchJson(
|
|
`/api/cc-config/overview?cwd=${encodeURIComponent(FAKE_PROJECT)}`
|
|
);
|
|
assert.equal(body.counts.marketplaces, 1);
|
|
assert.equal(body.counts.keybindings, 3);
|
|
assert.equal(body.counts.pluginsEnabled, 1);
|
|
assert.equal(body.counts.pluginsDisabled, 0);
|
|
});
|
|
|
|
it("hooks aggregates across user + project + project-local", async () => {
|
|
const { body } = await fetchJson(
|
|
`/api/cc-config/hooks?cwd=${encodeURIComponent(FAKE_PROJECT)}`
|
|
);
|
|
const userSrc = body.items.find((x) => x.scope === "user");
|
|
assert.equal(userSrc.exists, true);
|
|
assert.equal(userSrc.hooks.PreToolUse.length, 1);
|
|
const local = body.items.find((x) => x.scope === "project-local");
|
|
assert.equal(local.exists, true);
|
|
assert.equal(local.hooks.Stop.length, 1);
|
|
});
|
|
|
|
it("settings redacts secret-like keys", async () => {
|
|
const { body } = await fetchJson(
|
|
`/api/cc-config/settings?cwd=${encodeURIComponent(FAKE_PROJECT)}`
|
|
);
|
|
const userSettings = body.items.find((x) => x.scope === "user");
|
|
assert.equal(userSettings.exists, true);
|
|
assert.equal(userSettings.data.apiKeyHelper, "<redacted>");
|
|
assert.equal(userSettings.data.model, "opus");
|
|
});
|
|
|
|
it("memory returns project CLAUDE.md", async () => {
|
|
const { body } = await fetchJson(
|
|
`/api/cc-config/memory?cwd=${encodeURIComponent(FAKE_PROJECT)}`
|
|
);
|
|
const proj = body.items.find((x) => x.scope === "project");
|
|
assert.ok(proj);
|
|
assert.match(proj.preview, /Project memory/);
|
|
});
|
|
|
|
it("memory surfaces per-project auto-memory files (index sorted first)", async () => {
|
|
const { body } = await fetchJson(
|
|
`/api/cc-config/memory?cwd=${encodeURIComponent(FAKE_PROJECT)}`
|
|
);
|
|
const auto = body.items.filter((x) => x.scope === "auto-memory");
|
|
assert.equal(auto.length, 3); // MEMORY.md + foo.md + bar.md; notes.txt ignored
|
|
// Index file (MEMORY.md) sorts before the per-fact files.
|
|
assert.equal(auto[0].name, "MEMORY.md");
|
|
assert.equal(auto[0].isIndex, true);
|
|
assert.equal(auto[0].project, "-Users-test-proj");
|
|
const foo = auto.find((x) => x.name === "foo.md");
|
|
assert.ok(foo);
|
|
assert.equal(foo.isIndex, false);
|
|
assert.equal(foo.frontmatter.name, "foo"); // frontmatter parsed
|
|
assert.match(foo.preview, /Foo fact body/); // preview is the body, sans frontmatter
|
|
assert.doesNotMatch(foo.preview, /name: foo/);
|
|
});
|
|
|
|
it("file endpoint reads auto-memory files (they live under CLAUDE_HOME)", async () => {
|
|
const target = path.join(FAKE_AUTO_MEM, "foo.md");
|
|
const { status, body } = await fetchJson(
|
|
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}&path=${encodeURIComponent(target)}`
|
|
);
|
|
assert.equal(status, 200);
|
|
assert.equal(body.ok, true);
|
|
assert.match(body.text, /Foo fact body/);
|
|
});
|
|
|
|
it("file endpoint reads inside CLAUDE_HOME", async () => {
|
|
const target = path.join(FAKE_HOME, "agents", "demo-agent.md");
|
|
const { status, body } = await fetchJson(
|
|
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}&path=${encodeURIComponent(target)}`
|
|
);
|
|
assert.equal(status, 200);
|
|
assert.equal(body.ok, true);
|
|
assert.match(body.text, /Agent body/);
|
|
});
|
|
|
|
it("file endpoint blocks paths outside allowed roots", async () => {
|
|
const outside = path.join(TMP, "evil.md");
|
|
fs.writeFileSync(outside, "secret");
|
|
const { status, body } = await fetchJson(
|
|
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}&path=${encodeURIComponent(outside)}`
|
|
);
|
|
assert.equal(status, 400);
|
|
assert.equal(body.error.code, "READ_DENIED");
|
|
});
|
|
|
|
it("file endpoint blocks .. traversal", async () => {
|
|
const tricky = path.join(FAKE_HOME, "..", "..", "etc", "passwd");
|
|
const { status } = await fetchJson(
|
|
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}&path=${encodeURIComponent(tricky)}`
|
|
);
|
|
assert.equal(status, 400);
|
|
});
|
|
|
|
it("file endpoint requires a path", async () => {
|
|
const { status, body } = await fetchJson("/api/cc-config/file");
|
|
assert.equal(status, 400);
|
|
assert.equal(body.error.code, "BAD_PATH");
|
|
});
|
|
|
|
// ── Phase 2: write/delete ─────────────────────────────────────────
|
|
|
|
it("PUT /file creates a new agent (no backup, file did not exist)", async () => {
|
|
const { status, body } = await fetchJson(
|
|
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
|
|
{
|
|
method: "PUT",
|
|
body: {
|
|
scope: "user",
|
|
type: "agents",
|
|
name: "fresh-agent",
|
|
content: `---\nname: fresh-agent\n---\n\nFresh body.`,
|
|
},
|
|
}
|
|
);
|
|
assert.equal(status, 200);
|
|
assert.equal(body.ok, true);
|
|
assert.equal(body.created, true);
|
|
assert.equal(body.backupPath, null);
|
|
assert.equal(fs.readFileSync(body.file, "utf8").includes("Fresh body"), true);
|
|
});
|
|
|
|
it("PUT /file overwrites an existing agent and creates a backup", async () => {
|
|
const before = fs.readFileSync(path.join(FAKE_HOME, "agents", "demo-agent.md"), "utf8");
|
|
const { status, body } = await fetchJson(
|
|
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
|
|
{
|
|
method: "PUT",
|
|
body: {
|
|
scope: "user",
|
|
type: "agents",
|
|
name: "demo-agent",
|
|
content: `---\nname: demo-agent\n---\n\nUpdated body.`,
|
|
},
|
|
}
|
|
);
|
|
assert.equal(status, 200);
|
|
assert.equal(body.created, false);
|
|
assert.ok(body.backupPath, "backup path returned");
|
|
assert.equal(fs.readFileSync(body.backupPath, "utf8"), before);
|
|
assert.match(fs.readFileSync(body.file, "utf8"), /Updated body/);
|
|
});
|
|
|
|
it("PUT /file creates a new skill dir with SKILL.md", async () => {
|
|
const { status, body } = await fetchJson(
|
|
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
|
|
{
|
|
method: "PUT",
|
|
body: {
|
|
scope: "user",
|
|
type: "skills",
|
|
name: "brand-new-skill",
|
|
content: `---\nname: brand-new-skill\n---\n\nHello.`,
|
|
},
|
|
}
|
|
);
|
|
assert.equal(status, 200);
|
|
assert.equal(body.created, true);
|
|
assert.ok(fs.existsSync(body.file));
|
|
assert.equal(path.basename(body.file), "SKILL.md");
|
|
});
|
|
|
|
it("PUT /file rejects malicious names (traversal)", async () => {
|
|
const { status, body } = await fetchJson(
|
|
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
|
|
{
|
|
method: "PUT",
|
|
body: {
|
|
scope: "user",
|
|
type: "agents",
|
|
name: "../../etc/passwd",
|
|
content: "evil",
|
|
},
|
|
}
|
|
);
|
|
assert.equal(status, 400);
|
|
assert.equal(body.error.code, "EBADNAME");
|
|
});
|
|
|
|
it("PUT /file rejects unknown type", async () => {
|
|
const { status, body } = await fetchJson(
|
|
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
|
|
{
|
|
method: "PUT",
|
|
body: { scope: "user", type: "plugins", name: "x", content: "y" },
|
|
}
|
|
);
|
|
assert.equal(status, 400);
|
|
assert.equal(body.error.code, "EBADTYPE");
|
|
});
|
|
|
|
it("PUT /file rejects oversize content", async () => {
|
|
const huge = "x".repeat(256 * 1024 + 1);
|
|
const { status, body } = await fetchJson(
|
|
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
|
|
{
|
|
method: "PUT",
|
|
body: { scope: "user", type: "agents", name: "huge", content: huge },
|
|
}
|
|
);
|
|
assert.equal(status, 413);
|
|
assert.equal(body.error.code, "ETOOLARGE");
|
|
});
|
|
|
|
it("PUT /file edits memory CLAUDE.md without a name", async () => {
|
|
// chdir, not `?cwd=`: project-scope writes resolve against the SERVER's
|
|
// cwd on purpose, so a request cannot pick the destination. Passing
|
|
// `?cwd=` here would have written into the repo's own CLAUDE.md.
|
|
const prev = process.cwd();
|
|
process.chdir(FAKE_PROJECT);
|
|
try {
|
|
const { status, body } = await fetchJson("/api/cc-config/file", {
|
|
method: "PUT",
|
|
body: { scope: "project", type: "memory", content: "# new project memory" },
|
|
});
|
|
assert.equal(status, 200);
|
|
assert.ok(body.backupPath, "previous CLAUDE.md should be backed up");
|
|
assert.equal(fs.readFileSync(body.file, "utf8"), "# new project memory");
|
|
assert.equal(body.file, path.join(FAKE_PROJECT, "CLAUDE.md"));
|
|
} finally {
|
|
process.chdir(prev);
|
|
}
|
|
});
|
|
|
|
it("PUT /file ignores ?cwd= — a caller cannot choose where a write lands", async () => {
|
|
// The hole this closes: `?cwd=<anywhere>` with scope "project" dropped a
|
|
// file into any directory on disk. PATCH /settings already refused it;
|
|
// PUT/DELETE did not.
|
|
const victim = path.join(TMP, "victim-project");
|
|
fs.mkdirSync(victim, { recursive: true });
|
|
fs.writeFileSync(path.join(victim, "CLAUDE.md"), "UNTOUCHED");
|
|
|
|
const prev = process.cwd();
|
|
process.chdir(FAKE_PROJECT);
|
|
try {
|
|
const { status, body } = await fetchJson(
|
|
`/api/cc-config/file?cwd=${encodeURIComponent(victim)}`,
|
|
{
|
|
method: "PUT",
|
|
body: { scope: "project", type: "memory", content: "PWNED" },
|
|
}
|
|
);
|
|
assert.equal(status, 200);
|
|
assert.equal(body.file, path.join(FAKE_PROJECT, "CLAUDE.md"), "must ignore the query cwd");
|
|
assert.equal(fs.readFileSync(path.join(victim, "CLAUDE.md"), "utf8"), "UNTOUCHED");
|
|
} finally {
|
|
process.chdir(prev);
|
|
}
|
|
});
|
|
|
|
it("DELETE /file ignores ?cwd= too", async () => {
|
|
const victim = path.join(TMP, "victim-delete");
|
|
fs.mkdirSync(victim, { recursive: true });
|
|
fs.writeFileSync(path.join(victim, "CLAUDE.md"), "UNTOUCHED");
|
|
fs.writeFileSync(path.join(FAKE_PROJECT, "CLAUDE.md"), "# server-cwd memory");
|
|
|
|
const prev = process.cwd();
|
|
process.chdir(FAKE_PROJECT);
|
|
try {
|
|
const { status } = await fetchJson(`/api/cc-config/file?cwd=${encodeURIComponent(victim)}`, {
|
|
method: "DELETE",
|
|
body: { scope: "project", type: "memory" },
|
|
});
|
|
assert.equal(status, 200);
|
|
assert.equal(fs.existsSync(path.join(victim, "CLAUDE.md")), true, "victim must survive");
|
|
assert.equal(fs.existsSync(path.join(FAKE_PROJECT, "CLAUDE.md")), false);
|
|
} finally {
|
|
process.chdir(prev);
|
|
}
|
|
});
|
|
|
|
it("DELETE /file backs up and removes a single-file agent", async () => {
|
|
fs.writeFileSync(path.join(FAKE_HOME, "agents", "to-delete.md"), "bye");
|
|
const { status, body } = await fetchJson(
|
|
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
|
|
{
|
|
method: "DELETE",
|
|
body: { scope: "user", type: "agents", name: "to-delete" },
|
|
}
|
|
);
|
|
assert.equal(status, 200);
|
|
assert.equal(body.ok, true);
|
|
assert.ok(body.backupPath);
|
|
assert.equal(fs.existsSync(path.join(FAKE_HOME, "agents", "to-delete.md")), false);
|
|
assert.equal(fs.readFileSync(body.backupPath, "utf8"), "bye");
|
|
});
|
|
|
|
it("DELETE /file backs up and removes a skill dir (preserves bundled assets in backup)", async () => {
|
|
const skillDir = path.join(FAKE_HOME, "skills", "with-assets");
|
|
fs.mkdirSync(skillDir, { recursive: true });
|
|
fs.writeFileSync(path.join(skillDir, "SKILL.md"), "---\nname: with-assets\n---\nbody");
|
|
fs.writeFileSync(path.join(skillDir, "asset.txt"), "important payload");
|
|
const { status, body } = await fetchJson(
|
|
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
|
|
{
|
|
method: "DELETE",
|
|
body: { scope: "user", type: "skills", name: "with-assets" },
|
|
}
|
|
);
|
|
assert.equal(status, 200);
|
|
assert.ok(body.backupPath);
|
|
assert.equal(fs.existsSync(skillDir), false);
|
|
assert.equal(
|
|
fs.readFileSync(path.join(body.backupPath, "asset.txt"), "utf8"),
|
|
"important payload"
|
|
);
|
|
});
|
|
|
|
it("DELETE /file 404s on a missing item", async () => {
|
|
const { status, body } = await fetchJson(
|
|
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
|
|
{
|
|
method: "DELETE",
|
|
body: { scope: "user", type: "agents", name: "never-existed" },
|
|
}
|
|
);
|
|
assert.equal(status, 404);
|
|
assert.equal(body.error.code, "ENOTFOUND");
|
|
});
|
|
|
|
it("backups endpoint lists everything we just created", async () => {
|
|
const { status, body } = await fetchJson(
|
|
`/api/cc-config/backups?cwd=${encodeURIComponent(FAKE_PROJECT)}`
|
|
);
|
|
assert.equal(status, 200);
|
|
assert.ok(Array.isArray(body.items));
|
|
// We should have at least: demo-agent overwrite + memory overwrite +
|
|
// to-delete + with-assets dir.
|
|
assert.ok(body.items.length >= 4, `expected ≥4 backups, got ${body.items.length}`);
|
|
assert.ok(body.items.every((b) => typeof b.backupPath === "string"));
|
|
});
|
|
|
|
it("write is atomic: tmp file is gone after success", async () => {
|
|
await fetchJson(`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`, {
|
|
method: "PUT",
|
|
body: {
|
|
scope: "user",
|
|
type: "commands",
|
|
name: "atomic-test",
|
|
content: "---\ndescription: atomic\n---\nbody",
|
|
},
|
|
});
|
|
const cmdsDir = path.join(FAKE_HOME, "commands");
|
|
const stragglers = fs
|
|
.readdirSync(cmdsDir)
|
|
.filter((n) => n.startsWith(".atomic-test.md.") && n.endsWith(".tmp"));
|
|
assert.deepEqual(stragglers, []);
|
|
});
|
|
|
|
// ── auto-memory mutations (per-project file-based memory) ───────────────
|
|
const AUTO_SLUG = "-Users-test-proj";
|
|
|
|
it("PUT /file creates a new auto-memory fact file", async () => {
|
|
const { status, body } = await fetchJson(
|
|
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
|
|
{
|
|
method: "PUT",
|
|
body: {
|
|
scope: "auto-memory",
|
|
type: "auto-memory",
|
|
project: AUTO_SLUG,
|
|
name: "new_fact.md",
|
|
content: "---\nname: new-fact\n---\nA freshly written fact.",
|
|
},
|
|
}
|
|
);
|
|
assert.equal(status, 200);
|
|
assert.equal(body.created, true);
|
|
assert.equal(body.backupPath, null); // brand-new file → nothing to back up
|
|
assert.equal(
|
|
fs.readFileSync(path.join(FAKE_AUTO_MEM, "new_fact.md"), "utf8"),
|
|
"---\nname: new-fact\n---\nA freshly written fact."
|
|
);
|
|
});
|
|
|
|
it("PUT /file edits an existing auto-memory file and backs it up", async () => {
|
|
const { status, body } = await fetchJson(
|
|
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
|
|
{
|
|
method: "PUT",
|
|
body: {
|
|
scope: "auto-memory",
|
|
type: "auto-memory",
|
|
project: AUTO_SLUG,
|
|
name: "foo.md",
|
|
content: "edited foo body",
|
|
},
|
|
}
|
|
);
|
|
assert.equal(status, 200);
|
|
assert.ok(body.backupPath, "existing foo.md should be backed up");
|
|
assert.match(body.backupPath, /\.cc-config-backups[\\/]auto-memory[\\/]foo\.md\./);
|
|
assert.equal(fs.readFileSync(path.join(FAKE_AUTO_MEM, "foo.md"), "utf8"), "edited foo body");
|
|
});
|
|
|
|
it("DELETE /file backs up and removes an auto-memory file", async () => {
|
|
const { status, body } = await fetchJson(
|
|
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
|
|
{
|
|
method: "DELETE",
|
|
body: { scope: "auto-memory", type: "auto-memory", project: AUTO_SLUG, name: "bar.md" },
|
|
}
|
|
);
|
|
assert.equal(status, 200);
|
|
assert.ok(body.backupPath);
|
|
assert.equal(fs.existsSync(path.join(FAKE_AUTO_MEM, "bar.md")), false);
|
|
assert.equal(fs.readFileSync(body.backupPath, "utf8"), "Bar fact body.\n");
|
|
});
|
|
|
|
it("backups endpoint includes auto-memory backups (with project)", async () => {
|
|
const { status, body } = await fetchJson(
|
|
`/api/cc-config/backups?cwd=${encodeURIComponent(FAKE_PROJECT)}`
|
|
);
|
|
assert.equal(status, 200);
|
|
const auto = body.items.filter((b) => b.scope === "auto-memory");
|
|
assert.ok(auto.length >= 2, `expected ≥2 auto-memory backups, got ${auto.length}`);
|
|
assert.ok(auto.every((b) => b.project === AUTO_SLUG && b.type === "auto-memory"));
|
|
assert.ok(auto.some((b) => /^foo\.md\./.test(b.name)));
|
|
});
|
|
|
|
it("PUT /file rejects an auto-memory project slug that traverses", async () => {
|
|
const { status, body } = await fetchJson(
|
|
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
|
|
{
|
|
method: "PUT",
|
|
body: {
|
|
scope: "auto-memory",
|
|
type: "auto-memory",
|
|
project: "../../etc",
|
|
name: "x.md",
|
|
content: "evil",
|
|
},
|
|
}
|
|
);
|
|
assert.equal(status, 400);
|
|
assert.equal(body.error.code, "EBADPROJECT");
|
|
});
|
|
|
|
it("PUT /file rejects an auto-memory name without a .md extension", async () => {
|
|
const { status, body } = await fetchJson(
|
|
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
|
|
{
|
|
method: "PUT",
|
|
body: {
|
|
scope: "auto-memory",
|
|
type: "auto-memory",
|
|
project: AUTO_SLUG,
|
|
name: "../escape",
|
|
content: "evil",
|
|
},
|
|
}
|
|
);
|
|
assert.equal(status, 400);
|
|
assert.equal(body.error.code, "EBADNAME");
|
|
});
|
|
|
|
// ── Keybindings structured edit (PUT /keybindings) ─────────────────────
|
|
// These run last so the earlier overview count assertion (keybindings === 3)
|
|
// sees the original fixture before we rewrite the file here.
|
|
|
|
it("PUT /keybindings overwrites, backs up, and preserves top-level metadata", async () => {
|
|
const { status, body } = await fetchJson("/api/cc-config/keybindings", {
|
|
method: "PUT",
|
|
body: {
|
|
groups: [
|
|
{
|
|
context: "Global",
|
|
bindings: [
|
|
{ key: "ctrl+t", action: "toggleTodos" },
|
|
{ key: "ctrl+n", action: "newThing" },
|
|
],
|
|
},
|
|
{ context: "Chat", bindings: [{ key: "escape", action: "cancel" }] },
|
|
],
|
|
},
|
|
});
|
|
assert.equal(status, 200);
|
|
assert.equal(body.ok, true);
|
|
assert.equal(body.created, false);
|
|
assert.ok(body.backupPath, "existing keybindings.json should be backed up");
|
|
|
|
const onDisk = JSON.parse(fs.readFileSync(path.join(FAKE_HOME, "keybindings.json"), "utf8"));
|
|
// $schema from the fixture must survive a structured rewrite.
|
|
assert.equal(onDisk.$schema, "https://www.schemastore.org/x.json");
|
|
const global = onDisk.bindings.find((g) => g.context === "Global");
|
|
assert.equal(global.bindings["ctrl+n"], "newThing");
|
|
assert.equal(Object.keys(onDisk.bindings.find((g) => g.context === "Chat").bindings).length, 1);
|
|
|
|
// Re-reading through the API returns the updated groups.
|
|
const after = await fetchJson("/api/cc-config/keybindings");
|
|
const chat = after.body.groups.find((g) => g.context === "Chat");
|
|
assert.equal(chat.bindings.length, 1);
|
|
});
|
|
|
|
it("PUT /keybindings rejects a duplicate key within one context", async () => {
|
|
const { status, body } = await fetchJson("/api/cc-config/keybindings", {
|
|
method: "PUT",
|
|
body: {
|
|
groups: [
|
|
{
|
|
context: "Global",
|
|
bindings: [
|
|
{ key: "ctrl+t", action: "toggleTodos" },
|
|
{ key: "ctrl+t", action: "somethingElse" },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
});
|
|
assert.equal(status, 400);
|
|
assert.equal(body.error.code, "EBADCONTENT");
|
|
});
|
|
|
|
it("PUT /keybindings rejects a non-array groups payload", async () => {
|
|
const { status, body } = await fetchJson("/api/cc-config/keybindings", {
|
|
method: "PUT",
|
|
body: { groups: "nope" },
|
|
});
|
|
assert.equal(status, 400);
|
|
assert.equal(body.error.code, "EBADREQ");
|
|
});
|
|
|
|
it("PUT /keybindings rejects an empty action", async () => {
|
|
const { status, body } = await fetchJson("/api/cc-config/keybindings", {
|
|
method: "PUT",
|
|
body: { groups: [{ context: "Global", bindings: [{ key: "ctrl+z", action: "" }] }] },
|
|
});
|
|
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;
|
|
}
|
|
});
|
|
|
|
// ── MCP add/remove routes (fake `claude` on PATH) ───────────────────────
|
|
//
|
|
// `printf`, not `echo` — bash's builtin `echo "-e"` swallows a lone `-e`
|
|
// argument as its own flag, and `-e KEY=VALUE` is exactly what mcpAdd
|
|
// emits for a stdio server's env vars.
|
|
const PRINTF_ARGV = 'for a in "$@"; do printf \'%s\\n\' "$a"; done';
|
|
|
|
it("POST /mcp/add builds an http server with a positional url", async () => {
|
|
const restore = stubClaude(PRINTF_ARGV);
|
|
try {
|
|
const { status, body } = await fetchJson("/api/cc-config/mcp/add", {
|
|
method: "POST",
|
|
body: { name: "vercel", scope: "user", transport: "http", url: "https://mcp.vercel.com" },
|
|
});
|
|
assert.equal(status, 200);
|
|
assert.deepEqual(body.stdout.trim().split("\n"), [
|
|
"mcp",
|
|
"add",
|
|
"--scope",
|
|
"user",
|
|
"--transport",
|
|
"http",
|
|
"vercel",
|
|
"https://mcp.vercel.com",
|
|
]);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
it("POST /mcp/add builds a stdio server with args after --", async () => {
|
|
const restore = stubClaude(PRINTF_ARGV);
|
|
try {
|
|
const { status, body } = await fetchJson("/api/cc-config/mcp/add", {
|
|
method: "POST",
|
|
body: { name: "docs", scope: "user", command: "node", args: ["s.js", "--port", "3000"] },
|
|
});
|
|
assert.equal(status, 200);
|
|
assert.deepEqual(body.stdout.trim().split("\n").slice(-5), [
|
|
"--",
|
|
"node",
|
|
"s.js",
|
|
"--port",
|
|
"3000",
|
|
]);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
it("POST /mcp/add rejects a name that reads as a flag with 400", async () => {
|
|
const { status, body } = await fetchJson("/api/cc-config/mcp/add", {
|
|
method: "POST",
|
|
body: { name: "--all", scope: "user", command: "node", args: [] },
|
|
});
|
|
assert.equal(status, 400);
|
|
assert.equal(body.error.code, "EBADARG");
|
|
});
|
|
|
|
it("POST /mcp/remove surfaces a failed CLI run as 422", async () => {
|
|
const restore = stubClaude('echo "No MCP server named \\"nope\\"" >&2\nexit 1');
|
|
try {
|
|
const { status, body } = await fetchJson("/api/cc-config/mcp/remove", {
|
|
method: "POST",
|
|
body: { name: "nope", scope: "user" },
|
|
});
|
|
assert.equal(status, 422);
|
|
assert.equal(body.error.code, "ECLIEXIT");
|
|
assert.match(body.error.message, /No MCP server named/);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
it("POST /mcp/remove runs the CLI with the given scope", async () => {
|
|
const restore = stubClaude(PRINTF_ARGV);
|
|
try {
|
|
const { status, body } = await fetchJson("/api/cc-config/mcp/remove", {
|
|
method: "POST",
|
|
body: { name: "docs", scope: "local" },
|
|
});
|
|
assert.equal(status, 200);
|
|
assert.deepEqual(body.stdout.trim().split("\n"), [
|
|
"mcp",
|
|
"remove",
|
|
"docs",
|
|
"--scope",
|
|
"local",
|
|
]);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
it("POST /mcp/remove rejects the CLI's project scope with 400 — .mcp.json isn't addressable here", async () => {
|
|
const { status, body } = await fetchJson("/api/cc-config/mcp/remove", {
|
|
method: "POST",
|
|
body: { name: "docs", scope: "project" },
|
|
});
|
|
assert.equal(status, 400);
|
|
assert.equal(body.error.code, "EBADARG");
|
|
});
|
|
});
|