57dc91585d
Internal SmartGift build of a Claude Code monitoring dashboard. Lanes: a durable unit of parallel agent work, one per working directory, tracked across session restarts. Managed lanes are git worktrees the dashboard provisions and can reset or remove behind a three-check destroy guard and a counted preflight; adopted lanes are directories you already own and are never destroyable. Pipelines: a lane moves through pipeline stages. A stage the agent declares with evidence renders green; a stage inferred from the tool-event stream renders dashed amber and never counts as done. Detection is forward-only within a 30-minute window, and never writes the declared stage. Workspace: one page at /run with a lane grid, the selected lane's pipeline, and a full Claude console behind a disclosure.
826 lines
30 KiB
JavaScript
826 lines
30 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 () => {
|
|
const { status, body } = await fetchJson(
|
|
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
|
|
{
|
|
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");
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|