/** * @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ĩ */ 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//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, ""); 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"); }); });