@jaybeeuu/agent-cortex 1.39.0 → 1.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/.agents/skills/style-versioning/SKILL.md +1 -1
  2. package/.github/workflows/ci.yml +47 -14
  3. package/AGENTS.md +35 -8
  4. package/CHANGELOG.md +60 -0
  5. package/README.md +174 -97
  6. package/agents/README.md +14 -11
  7. package/bin/agent-cortex.mjs +55 -10
  8. package/bin/installers/claude.mjs +674 -174
  9. package/bin/installers/copilot.mjs +13 -18
  10. package/bin/installers/pi.mjs +293 -51
  11. package/claude/settings.json +4 -0
  12. package/{claude → claude-extras}/scripts/statusline-command.sh +32 -4
  13. package/docs/claude-hooks.md +15 -3
  14. package/docs/ideas/ban-content-assertion-tests.md +73 -0
  15. package/docs/ideas/bead-progress-notes-for-session-resumption.md +75 -0
  16. package/docs/ideas/classify-beads-via-deterministic-script-with-agent-fallback.md +70 -0
  17. package/docs/ideas/ctx-alias-version-and-update-commands.md +65 -0
  18. package/docs/ideas/ctx-feedback-command-for-capturing-ideas-from-any-machine.md +67 -0
  19. package/docs/ideas/deployment-beads-for-post-merge-pipelines.md +36 -0
  20. package/docs/ideas/design-and-delivery-as-separate-bead-trees/design-and-delivery-as-separate-bead-trees.md +99 -0
  21. package/docs/ideas/enable-signed-commit-enforcement-changesets-bot-commits-must-be-signed.md +33 -0
  22. package/docs/ideas/extension-manifests.md +55 -14
  23. package/docs/ideas/improve-pi-system-prompt.md +5 -2
  24. package/docs/ideas/map-every-cortex-tool-to-a-real-pi-equivalent.md +95 -0
  25. package/docs/ideas/record-idea-implementation-questions.md +68 -0
  26. package/docs/ideas/self-flushing-session.md +72 -0
  27. package/docs/ideas/state-in-beads-not-md.md +69 -0
  28. package/docs/ideas/task-session-context.local.md +95 -0
  29. package/docs/ideas/test-data-creation-helpers.md +92 -0
  30. package/docs/ideas/test-planning-stage-bead-in-feature-planning.md +68 -0
  31. package/docs/ideas/worktree-housekeeping-for-closed-beads.md +39 -0
  32. package/docs/technical-direction/harness-config.md +161 -0
  33. package/docs/technical-direction/migration-execution-labels.md +60 -23
  34. package/extensions/agent-guardrails/README.md +45 -0
  35. package/extensions/agent-guardrails/guardrails.test.ts +42 -0
  36. package/extensions/agent-guardrails/guardrails.ts +66 -0
  37. package/extensions/agent-guardrails/index.ts +22 -0
  38. package/extensions/agent-guardrails/node_modules/.bin/pi +21 -0
  39. package/extensions/agent-guardrails/node_modules/.bin/tsc +21 -0
  40. package/extensions/agent-guardrails/node_modules/.bin/tsserver +21 -0
  41. package/extensions/agent-guardrails/node_modules/.bin/tsx +21 -0
  42. package/extensions/agent-guardrails/package.json +19 -0
  43. package/extensions/agent-guardrails/tsconfig.json +12 -0
  44. package/extensions/agent-modes/README.md +12 -5
  45. package/extensions/agent-modes/discover.test.ts +30 -7
  46. package/extensions/agent-modes/discover.ts +1 -1
  47. package/extensions/agent-modes/index.test.ts +208 -0
  48. package/extensions/agent-modes/index.ts +23 -6
  49. package/extensions/agent-modes/package.json +1 -1
  50. package/extensions/skill-stats/index.test.ts +3 -2
  51. package/lib/cli.mjs +30 -12
  52. package/lib/pi-packages.mjs +169 -0
  53. package/package.json +8 -4
  54. package/pi/settings.json +23 -12
  55. package/plugin.json +1 -1
  56. package/scripts/build-copilot-agents.mjs +1 -1
  57. package/scripts/check-no-sync.mjs +49 -0
  58. package/scripts/lib/compose-agent.mjs +32 -14
  59. package/scripts/lib/fs.mjs +31 -0
  60. package/skills/engineering/grill-with-docs/SKILL.md +1 -1
  61. package/skills/planning/classify-bead/REFERENCE.md +85 -0
  62. package/skills/planning/classify-bead/SKILL.md +32 -36
  63. package/skills/planning/classify-bead/scripts/classify-bead.mjs +292 -0
  64. package/skills/planning/create-task/SKILL.md +19 -10
  65. package/skills/planning/write-a-prd/SKILL.md +2 -2
  66. package/skills/style/style-code/REFERENCE.md +32 -2
  67. package/skills/style/style-code/SKILL.md +5 -21
  68. package/skills/style/style-tests/SKILL.md +69 -17
  69. package/skills/workflow/hitl-collab/SKILL.md +1 -1
  70. package/skills/workflow/run-pipeline-stage/SKILL.md +13 -1
  71. package/skills/workflow/run-pipeline-stage/prompts/stage-runner.md +12 -0
  72. package/test/classify-bead.test.mjs +277 -0
  73. package/test/claude-installer.test.mjs +199 -139
  74. package/test/cli.test.mjs +95 -41
  75. package/test/copilot-installer.test.mjs +92 -82
  76. package/test/fs.test.mjs +76 -0
  77. package/test/helpers/fake-claude.mjs +152 -0
  78. package/test/installer.test.mjs +542 -57
  79. package/test/pi-installer.test.mjs +457 -102
  80. package/test/pi-packages.test.mjs +295 -0
  81. package/token-map.README.md +23 -12
  82. package/token-map.json +8 -7
  83. package/.claude-plugin/marketplace.json +0 -16
  84. package/claude/.claude-plugin/plugin.json +0 -11
  85. package/claude/agents/plan.md +0 -58
  86. package/claude/agents/ralph-plan.md +0 -121
  87. package/claude/agents/ralph.md +0 -193
  88. package/claude/agents/strategy.md +0 -111
  89. package/claude/hooks/scripts/notify.mjs +0 -54
  90. package/claude/hooks.json +0 -38
  91. /package/{claude → claude-extras}/.mcp.json +0 -0
@@ -1,57 +1,101 @@
1
1
  // Integration tests for `agent-cortex install claude`: the install-time
2
- // generator for the Claude Code plugin subtree. All tests install into
3
- // mkdtemp dirs so the real repo claude/ and ~/.claude are never touched.
2
+ // generator that materialises the Claude Code plugin. The default target is a
3
+ // home-scoped directory (~/.agent-cortex/claude, overridable via HOME for
4
+ // tests); all tests install into mkdtemp dirs — the real ~/.agent-cortex and
5
+ // the repo's canonical sources (agents/, skills/, claude-extras/, hooks/claude/) are never touched.
4
6
 
5
7
  import { describe, it } from "node:test";
6
8
  import assert from "node:assert/strict";
7
9
  import { execFile } from "node:child_process";
8
- import { mkdtempSync, readFileSync, readdirSync, existsSync, lstatSync, readlinkSync, realpathSync } from "node:fs";
10
+ import {
11
+ mkdtemp,
12
+ mkdir,
13
+ writeFile,
14
+ chmod,
15
+ rm,
16
+ readFile,
17
+ readdir,
18
+ stat,
19
+ lstat,
20
+ readlink,
21
+ symlink,
22
+ } from "node:fs/promises";
9
23
  import { tmpdir } from "node:os";
10
24
  import { fileURLToPath } from "node:url";
11
25
  import { dirname, join, relative } from "node:path";
26
+ import { installClaude } from "../bin/installers/claude.mjs";
27
+ import { makeFakeClaude, registrationActions } from "./helpers/fake-claude.mjs";
12
28
 
13
29
  const __dirname = dirname(fileURLToPath(import.meta.url));
14
30
  const ROOT = join(__dirname, "..");
15
31
  const CLI_PATH = join(ROOT, "bin", "agent-cortex.mjs");
16
32
 
17
- function runCli(args) {
33
+ /** True when a path exists (stat succeeds). */
34
+ async function pathExists(p) {
35
+ try {
36
+ await stat(p);
37
+ return true;
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ /** Run the CLI; optionally pass a custom env (e.g. a fake HOME). */
44
+ function runCli(args, env = {}) {
18
45
  return new Promise((resolve) => {
19
- execFile(process.execPath, [CLI_PATH, ...args], (error, stdout, stderr) => {
20
- resolve({ exitCode: error ? error.code ?? 1 : 0, stdout, stderr });
21
- });
46
+ execFile(
47
+ process.execPath,
48
+ [CLI_PATH, ...args],
49
+ { env: { ...process.env, ...env } },
50
+ (error, stdout, stderr) => {
51
+ resolve({ exitCode: error ? error.code ?? 1 : 0, stdout, stderr });
52
+ },
53
+ );
22
54
  });
23
55
  }
24
56
 
25
- function makeTmp() {
26
- return mkdtempSync(join(tmpdir(), "agent-cortex-installer-"));
57
+ async function makeTmp() {
58
+ return await mkdtemp(join(tmpdir(), "agent-cortex-installer-"));
27
59
  }
28
60
 
29
- /** Deterministic snapshot of a generated tree: relative paths, kind, and for
30
- * files/symlinks their byte content / link target. */
31
- function snapshot(dir) {
61
+
62
+ /** Deterministic snapshot of a generated tree: kind, and for files/symlinks
63
+ * their byte content / link target. */
64
+ async function snapshot(dir) {
32
65
  const entries = [];
33
- const walk = (p) => {
34
- for (const e of readdirSync(p, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
66
+ const walk = async (p) => {
67
+ for (const e of (await readdir(p, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
35
68
  const full = join(p, e.name);
36
69
  const rel = relative(dir, full);
37
- if (e.isSymbolicLink()) entries.push([rel, "link", readlinkSync(full)]);
70
+ if (e.isSymbolicLink()) entries.push([rel, "link", await readlink(full)]);
38
71
  else if (e.isDirectory()) {
39
72
  entries.push([rel, "dir"]);
40
- walk(full);
41
- } else entries.push([rel, "file", readFileSync(full, "utf-8")]);
73
+ await walk(full);
74
+ } else entries.push([rel, "file", await readFile(full, "utf-8")]);
42
75
  }
43
76
  };
44
- walk(dir);
77
+ await walk(dir);
45
78
  return entries;
46
79
  }
47
80
 
81
+ async function listFiles(dir) {
82
+ if (!(await pathExists(dir))) return [];
83
+ const out = [];
84
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
85
+ const p = join(dir, entry.name);
86
+ if (entry.isDirectory()) out.push(...(await listFiles(p)).map((f) => join(entry.name, f)));
87
+ else out.push(entry.name);
88
+ }
89
+ return out;
90
+ }
91
+
48
92
  describe("install claude", () => {
49
93
  it("composes agents from the composable sources (claude names, no tokens)", async () => {
50
- const out = makeTmp();
94
+ const out = await makeTmp();
51
95
  const { exitCode } = await runCli(["install", "claude", "--output", out]);
52
96
  assert.equal(exitCode, 0);
53
97
 
54
- const plan = readFileSync(join(out, "agents", "plan.md"), "utf-8");
98
+ const plan = await readFile(join(out, "agents", "plan.md"), "utf-8");
55
99
  assert.match(plan, /^---\n# GENERATED from agents\/plan\//);
56
100
  // provenance names the shared installer (no build-time script), so the
57
101
  // committed subtree can only come from the install-time generator.
@@ -60,46 +104,127 @@ describe("install claude", () => {
60
104
  assert.match(plan, /\nname: plan\n/);
61
105
  assert.doesNotMatch(plan, /\{\{(TOOL|PATH|SECTION):/);
62
106
 
63
- const ralphPlan = readFileSync(join(out, "agents", "ralph-plan.md"), "utf-8");
107
+ const ralphPlan = await readFile(join(out, "agents", "ralph-plan.md"), "utf-8");
64
108
  assert.match(ralphPlan, /^---\n# GENERATED from agents\/ralph-plan\//);
65
109
  assert.match(ralphPlan, /\nname: ralph-plan\n/);
66
110
  });
67
111
 
68
112
  it("copies native agents verbatim (ralph stays native, no composed counterpart)", async () => {
69
- const out = makeTmp();
113
+ const out = await makeTmp();
70
114
  await runCli(["install", "claude", "--output", out]);
71
- const native = readFileSync(join(ROOT, "agents-native", "ralph.md"), "utf-8");
72
- assert.equal(readFileSync(join(out, "agents", "ralph.md"), "utf-8"), native);
115
+ const native = await readFile(join(ROOT, "agents-native", "ralph.md"), "utf-8");
116
+ assert.equal(await readFile(join(out, "agents", "ralph.md"), "utf-8"), native);
73
117
  });
74
118
 
75
- it("symlinks flattened skills and excludes ralph-coupled skills", async () => {
76
- const out = makeTmp();
77
- await runCli(["install", "claude", "--output", out]);
119
+ it("copies skill dirs flat with substituted tokens, no symlinks, excluding ralph-coupled skills", async () => {
120
+ const out = await makeTmp();
121
+ const { exitCode } = await runCli(["install", "claude", "--output", out]);
122
+ assert.equal(exitCode, 0);
123
+
78
124
  const skills = join(out, "skills");
79
- const names = readdirSync(skills);
125
+ const names = await readdir(skills);
80
126
  assert.ok(names.includes("tdd"));
81
127
  assert.ok(names.includes("plan"));
82
128
  assert.ok(!names.includes("ralph"));
83
129
  assert.ok(!names.includes("run-pipeline-stage"));
84
- assert.ok(lstatSync(join(skills, "tdd")).isSymbolicLink());
85
- assert.equal(realpathSync(join(skills, "tdd")), realpathSync(join(ROOT, "skills", "engineering", "tdd")));
130
+
131
+ // skills are real directories (copies), not symlinks
132
+ assert.ok((await stat(join(skills, "tdd"))).isDirectory());
133
+ assert.ok(await pathExists(join(skills, "tdd", "SKILL.md")));
134
+ assert.ok(!(await lstat(join(skills, "plan"))).isSymbolicLink());
135
+
136
+ // no symlinks anywhere in the materialised tree
137
+ for (const e of await snapshot(out)) {
138
+ assert.notEqual(e[1], "link", `unexpected symlink in tree: ${e[0]}`);
139
+ }
140
+
141
+ // tokens substituted against the claude column
142
+ const techDir = await readFile(join(skills, "technical-direction", "SKILL.md"), "utf-8");
143
+ assert.ok(techDir.includes("using Grep, Read, and Glob"), "rg/view/glob mapped to Grep/Read/Glob");
144
+ assert.ok(techDir.includes("via WebFetch"), "web_fetch mapped to WebFetch");
145
+ assert.doesNotMatch(techDir, /\{\{TOOL:/);
146
+
147
+ // null-mapped tool dropped from prose (claude has no read_agent step)
148
+ const design = await readFile(join(skills, "design-an-interface", "SKILL.md"), "utf-8");
149
+ assert.ok(design.includes("with Task,"), "task mapped to Task");
150
+ assert.doesNotMatch(design, /\{\{TOOL:|read_agent/);
151
+ });
152
+
153
+ it("leaves no literal tokens in any installed skill markdown", async () => {
154
+ const out = await makeTmp();
155
+ await runCli(["install", "claude", "--output", out]);
156
+ const files = (await listFiles(join(out, "skills"))).filter((f) => f.endsWith(".md"));
157
+ assert.ok(files.length > 0);
158
+ for (const f of files) {
159
+ const content = await readFile(join(out, "skills", f), "utf-8");
160
+ assert.doesNotMatch(content, /\{\{TOOL:|\{\{PATH:/, `no literal tokens in skill ${f}`);
161
+ }
162
+ });
163
+
164
+ it("copies hand-authored plugin extras (.mcp.json, scripts/) from the canonical claude-extras/ dir", async () => {
165
+ const out = await makeTmp();
166
+ await runCli(["install", "claude", "--output", out]);
167
+
168
+ const mcp = await readFile(join(ROOT, "claude-extras", ".mcp.json"), "utf-8");
169
+ assert.equal(await readFile(join(out, ".mcp.json"), "utf-8"), mcp);
170
+
171
+ const statusline = await readFile(join(ROOT, "claude-extras", "scripts", "statusline-command.sh"), "utf-8");
172
+ assert.equal(await readFile(join(out, "scripts", "statusline-command.sh"), "utf-8"), statusline);
173
+ });
174
+
175
+ it("copies hand-authored extras from claude-extras/ with executable bits preserved", async () => {
176
+ // claude-extras/ is the canonical store for .mcp.json and scripts/ (the
177
+ // committed claude/ subtree is retired); the installer ships them into the
178
+ // output, keeping the executable bit, and never writes into the store.
179
+ const root = await makeTmp();
180
+ const out = join(root, "out");
181
+ const extras = join(root, "claude-extras");
182
+ await writeFile(
183
+ join(root, "package.json"),
184
+ JSON.stringify({ name: "agent-cortex", version: "1.2.3", license: "MIT" }),
185
+ );
186
+ await writeFile(
187
+ join(root, "token-map.json"),
188
+ JSON.stringify({ tools: {}, paths: { plugin_root: { claude: "PLUGIN_ROOT" } } }),
189
+ );
190
+ await mkdir(join(root, "agents"));
191
+ await mkdir(join(root, "skills"));
192
+ await mkdir(join(extras, "scripts"), { recursive: true });
193
+ await writeFile(join(extras, ".mcp.json"), JSON.stringify({ mcpServers: {} }, null, 2) + "\n");
194
+ await writeFile(join(extras, "scripts", "statusline-command.sh"), "#!/bin/sh\necho status\n");
195
+ await chmod(join(extras, "scripts", "statusline-command.sh"), 0o755);
196
+
197
+ const result = await installClaude({ root, output: out });
198
+
199
+ assert.deepEqual(result.handAuthored, [".mcp.json", "scripts/statusline-command.sh"]);
200
+ assert.equal(
201
+ await readFile(join(out, ".mcp.json"), "utf-8"),
202
+ JSON.stringify({ mcpServers: {} }, null, 2) + "\n",
203
+ );
204
+ assert.equal(
205
+ (await stat(join(out, "scripts", "statusline-command.sh"))).mode & 0o111,
206
+ 0o111,
207
+ "executable bit survives the copy",
208
+ );
209
+ // the source store is never written to
210
+ assert.deepStrictEqual((await readdir(extras)).sort(), [".mcp.json", "scripts"]);
86
211
  });
87
212
 
88
213
  it("generates plugin.json tracking the package version and referencing hooks.json", async () => {
89
- const out = makeTmp();
214
+ const out = await makeTmp();
90
215
  await runCli(["install", "claude", "--output", out]);
91
- const pkgVersion = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf-8")).version;
92
- const plugin = JSON.parse(readFileSync(join(out, ".claude-plugin", "plugin.json"), "utf-8"));
216
+ const pkgVersion = JSON.parse(await readFile(join(ROOT, "package.json"), "utf-8")).version;
217
+ const plugin = JSON.parse(await readFile(join(out, ".claude-plugin", "plugin.json"), "utf-8"));
93
218
  assert.equal(plugin.name, "agent-cortex");
94
219
  assert.equal(plugin.version, pkgVersion);
95
220
  assert.equal(plugin.hooks, "./hooks.json");
96
221
  });
97
222
 
98
223
  it("generates hooks.json from the canonical hooks/claude/ source", async () => {
99
- const out = makeTmp();
224
+ const out = await makeTmp();
100
225
  await runCli(["install", "claude", "--output", out]);
101
- const source = JSON.parse(readFileSync(join(ROOT, "hooks", "claude", "hooks.json"), "utf-8"));
102
- const installed = JSON.parse(readFileSync(join(out, "hooks.json"), "utf-8"));
226
+ const source = JSON.parse(await readFile(join(ROOT, "hooks", "claude", "hooks.json"), "utf-8"));
227
+ const installed = JSON.parse(await readFile(join(out, "hooks.json"), "utf-8"));
103
228
  assert.deepStrictEqual(installed, source);
104
229
  assert.ok(source.hooks.SessionStart);
105
230
  });
@@ -108,9 +233,9 @@ describe("install claude", () => {
108
233
  // The Notification matcher is an exact-string list over `notification_type` —
109
234
  // there are no success/error types, so that matcher would never fire. Guard
110
235
  // against it recurring (see docs/claude-hooks.md for the documented type list).
111
- const out = makeTmp();
236
+ const out = await makeTmp();
112
237
  await runCli(["install", "claude", "--output", out]);
113
- const installed = JSON.parse(readFileSync(join(out, "hooks.json"), "utf-8"));
238
+ const installed = JSON.parse(await readFile(join(out, "hooks.json"), "utf-8"));
114
239
  const matcher = installed.hooks.Notification.flatMap((g) => g.hooks).length
115
240
  ? installed.hooks.Notification[0].matcher
116
241
  : "";
@@ -135,15 +260,15 @@ describe("install claude", () => {
135
260
  }
136
261
  });
137
262
 
138
- it("bundles hook support scripts into claude/hooks/ and references them via the plugin root", async () => {
139
- const out = makeTmp();
263
+ it("bundles hook support scripts into hooks/ and references them via the plugin root", async () => {
264
+ const out = await makeTmp();
140
265
  await runCli(["install", "claude", "--output", out]);
141
266
 
142
- const canonical = readFileSync(join(ROOT, "hooks", "claude", "scripts", "notify.mjs"), "utf-8");
143
- const bundled = readFileSync(join(out, "hooks", "scripts", "notify.mjs"), "utf-8");
267
+ const canonical = await readFile(join(ROOT, "hooks", "claude", "scripts", "notify.mjs"), "utf-8");
268
+ const bundled = await readFile(join(out, "hooks", "scripts", "notify.mjs"), "utf-8");
144
269
  assert.equal(bundled, canonical);
145
270
 
146
- const installed = JSON.parse(readFileSync(join(out, "hooks.json"), "utf-8"));
271
+ const installed = JSON.parse(await readFile(join(out, "hooks.json"), "utf-8"));
147
272
  const notify = installed.hooks.Notification.flatMap((g) => g.hooks)
148
273
  .filter((h) => h.command?.includes("notify.mjs"));
149
274
  assert.ok(notify.length === 1, "hooks.json should reference the notification script");
@@ -151,29 +276,389 @@ describe("install claude", () => {
151
276
  });
152
277
 
153
278
  it("is deterministic across repeated installs into the same output", async () => {
154
- const out = makeTmp();
279
+ const out = await makeTmp();
155
280
  await runCli(["install", "claude", "--output", out]);
156
- const first = snapshot(out);
281
+ const first = await snapshot(out);
157
282
  await runCli(["install", "claude", "--output", out]);
158
- assert.deepStrictEqual(snapshot(out), first);
283
+ assert.deepStrictEqual(await snapshot(out), first);
159
284
  });
160
285
 
161
- it("--dry-run reports the plan without writing anything", async () => {
162
- const out = makeTmp();
286
+ it("--dry-run reports the plan (copied skills, output target) without writing anything", async () => {
287
+ const out = await makeTmp();
163
288
  const { exitCode, stdout } = await runCli(["install", "claude", "--output", out, "--dry-run"]);
164
289
  assert.equal(exitCode, 0);
165
290
  assert.ok(/would/i.test(stdout));
166
- assert.equal(existsSync(join(out, "agents")), false);
167
- assert.equal(existsSync(join(out, "skills")), false);
168
- assert.equal(existsSync(join(out, ".claude-plugin")), false);
291
+ assert.ok(/copied/i.test(stdout), "dry-run should describe skills as copied, not symlinked");
292
+ assert.ok(stdout.includes(out), "dry-run should name the output target");
293
+ assert.equal(await pathExists(join(out, "agents")), false);
294
+ assert.equal(await pathExists(join(out, "skills")), false);
295
+ assert.equal(await pathExists(join(out, ".claude-plugin")), false);
169
296
  });
170
297
 
171
- it("leaves the committed claude/ subtree untouched when --output is given", async () => {
172
- const out = makeTmp();
173
- const beforeSkills = readdirSync(join(ROOT, "claude", "skills")).sort();
174
- const beforePlugin = readFileSync(join(ROOT, "claude", ".claude-plugin", "plugin.json"), "utf-8");
298
+ it("leaves the canonical sources untouched (no generated claude/ output)", async () => {
299
+ const out = await makeTmp();
300
+ const beforeExtras = (await readdir(join(ROOT, "claude-extras"))).sort();
301
+ const beforeExtrasScripts = (await readdir(join(ROOT, "claude-extras", "scripts"))).sort();
175
302
  await runCli(["install", "claude", "--output", out]);
176
- assert.deepStrictEqual(readdirSync(join(ROOT, "claude", "skills")).sort(), beforeSkills);
177
- assert.equal(readFileSync(join(ROOT, "claude", ".claude-plugin", "plugin.json"), "utf-8"), beforePlugin);
303
+ // the committed claude/ store carries only the settings template — the
304
+ // generated plugin subtree (agents/, skills/, .claude-plugin/, hooks.json)
305
+ // is never committed, and installs never write into the canonical sources
306
+ assert.deepStrictEqual((await readdir(join(ROOT, "claude"))).sort(), ["settings.json"]);
307
+ assert.deepStrictEqual((await readdir(join(ROOT, "claude-extras"))).sort(), beforeExtras);
308
+ assert.deepStrictEqual((await readdir(join(ROOT, "claude-extras", "scripts"))).sort(), beforeExtrasScripts);
309
+ });
310
+
311
+ it("plain install materialises into ~/.agent-cortex/claude with a marketplace manifest and drives the claude plugin CLI", async () => {
312
+ const fakeHome = await makeTmp();
313
+ const fakeBin = await makeTmp();
314
+ const fake = await makeFakeClaude(fakeBin);
315
+ const env = { HOME: fakeHome, PATH: `${fakeBin}:${process.env.PATH}` };
316
+
317
+ const { exitCode, stdout } = await runCli(["install", "claude"], env);
318
+ assert.equal(exitCode, 0);
319
+
320
+ const pluginDir = join(fakeHome, ".agent-cortex", "claude");
321
+ // plugin materialised into the home install root
322
+ assert.ok(await pathExists(join(pluginDir, "skills", "tdd", "SKILL.md")), "skill copied into home plugin");
323
+ assert.ok((await stat(join(pluginDir, "skills", "tdd"))).isDirectory(), "home skills are copies, not symlinks");
324
+ // hand-authored extras shipped into the home plugin
325
+ assert.equal(
326
+ await readFile(join(pluginDir, ".mcp.json"), "utf-8"),
327
+ await readFile(join(ROOT, "claude-extras", ".mcp.json"), "utf-8"),
328
+ );
329
+
330
+ // marketplace manifest at the home root exposes ./claude
331
+ const marketPath = join(fakeHome, ".agent-cortex", ".claude-plugin", "marketplace.json");
332
+ const market = JSON.parse(await readFile(marketPath, "utf-8"));
333
+ assert.equal(market.name, "jaybeeuu");
334
+ assert.deepStrictEqual(market.plugins, [
335
+ {
336
+ name: "agent-cortex",
337
+ source: "./claude",
338
+ description: "Personal Claude Code plugin with custom agents and skills",
339
+ },
340
+ ]);
341
+
342
+ // registration drove the claude plugin CLI against the home install root:
343
+ // fresh state → marketplace add + plugin install (state queries and the
344
+ // availability probe are not registration actions).
345
+ const calls = (await readFile(fake.log, "utf-8")).trim().split("\n");
346
+ assert.deepEqual(registrationActions(calls), [
347
+ `plugin marketplace add ${join(fakeHome, ".agent-cortex")}`,
348
+ "plugin install agent-cortex@jaybeeuu -y",
349
+ ]);
350
+ assert.ok(stdout.includes("Registering plugin"), "CLI reports registration");
351
+
352
+ // the default install also materialises ~/.claude/settings.json from the
353
+ // committed template, owning only the two declared keys
354
+ const settings = JSON.parse(await readFile(join(fakeHome, ".claude", "settings.json"), "utf-8"));
355
+ assert.deepEqual(Object.keys(settings).sort(), ["enabledPlugins", "extraKnownMarketplaces"]);
356
+ });
357
+
358
+ it("re-running a plain install is the update path: no marketplace re-add, no double-install", async () => {
359
+ const fakeHome = await makeTmp();
360
+ const fakeBin = await makeTmp();
361
+ const fake = await makeFakeClaude(fakeBin);
362
+ const env = { HOME: fakeHome, PATH: `${fakeBin}:${process.env.PATH}` };
363
+ const allCalls = async () => (await readFile(fake.log, "utf-8")).trim().split("\n").filter(Boolean);
364
+
365
+ const first = await runCli(["install", "claude"], env);
366
+ assert.equal(first.exitCode, 0);
367
+ assert.deepEqual(registrationActions(await allCalls()), [
368
+ `plugin marketplace add ${join(fakeHome, ".agent-cortex")}`,
369
+ "plugin install agent-cortex@jaybeeuu -y",
370
+ ]);
371
+ const run1End = (await allCalls()).length;
372
+
373
+ // Second run: marketplace registered, plugin at the materialised version →
374
+ // marketplace update only — the documented no-op update path.
375
+ const second = await runCli(["install", "claude"], env);
376
+ assert.equal(second.exitCode, 0);
377
+ assert.deepEqual(registrationActions((await allCalls()).slice(run1End)), [
378
+ "plugin marketplace update jaybeeuu",
379
+ ]);
380
+
381
+ // Across both runs the marketplace was added exactly once and the plugin
382
+ // installed exactly once (idempotent — no re-add, no double-install).
383
+ const all = registrationActions(await allCalls());
384
+ assert.equal(all.filter((c) => c.startsWith("plugin marketplace add")).length, 1);
385
+ assert.equal(all.filter((c) => c === "plugin install agent-cortex@jaybeeuu -y").length, 1);
386
+ });
387
+
388
+ it("warns with manual commands when the claude CLI is missing; --require-register fails the run", async () => {
389
+ const fakeHome = await makeTmp();
390
+ const env = { HOME: fakeHome, PATH: "/nonexistent" };
391
+
392
+ // Default: warn-only — the plugin is materialised, exit 0, manual
393
+ // registration commands printed for the user to run.
394
+ const warned = await runCli(["install", "claude"], env);
395
+ assert.equal(warned.exitCode, 0, "missing claude CLI is warn-only by default");
396
+ assert.match(warned.stderr, /NOT registered/);
397
+ assert.match(warned.stderr, /claude plugin marketplace add /);
398
+ assert.match(warned.stderr, /claude plugin install agent-cortex@jaybeeuu -y/);
399
+ // The plugin itself is still materialised into the fake home.
400
+ assert.ok(await pathExists(join(fakeHome, ".agent-cortex", "claude", "skills", "tdd", "SKILL.md")));
401
+
402
+ // --require-register escalates the same failure to a non-zero exit.
403
+ const required = await runCli(["install", "claude", "--require-register"], env);
404
+ assert.equal(required.exitCode, 1);
405
+ assert.match(required.stderr, /require-register/);
406
+ });
407
+
408
+ it("plain --dry-run prints the home target and copied-skill plan without writing", async () => {
409
+ const fakeHome = await makeTmp();
410
+ const fakeBin = await makeTmp();
411
+ await makeFakeClaude(fakeBin);
412
+ const env = { HOME: fakeHome, PATH: `${fakeBin}:${process.env.PATH}` };
413
+
414
+ const { exitCode, stdout } = await runCli(["install", "claude", "--dry-run"], env);
415
+ assert.equal(exitCode, 0);
416
+ assert.ok(stdout.includes(join(fakeHome, ".agent-cortex", "claude")), "dry-run names the home target");
417
+ assert.ok(stdout.includes(join(fakeHome, ".claude", "settings.json")), "dry-run names the settings target");
418
+ assert.ok(/copied/i.test(stdout), "dry-run describes copied skills");
419
+ assert.equal(await pathExists(join(fakeHome, ".agent-cortex")), false, "nothing written in dry-run");
420
+ assert.equal(await pathExists(join(fakeHome, ".claude")), false, "no settings written in dry-run");
421
+ });
422
+
423
+ it("--output stays generate-only and never touches ~/.claude/settings.json", async () => {
424
+ const fakeHome = await makeTmp();
425
+ const out = await makeTmp();
426
+ try {
427
+ const { exitCode } = await runCli(["install", "claude", "--output", out], { HOME: fakeHome });
428
+ assert.equal(exitCode, 0);
429
+ assert.ok(await pathExists(join(out, ".claude-plugin", "plugin.json")), "plugin subtree still generated");
430
+ assert.equal(await pathExists(join(fakeHome, ".claude")), false, "no user settings written by --output");
431
+ } finally {
432
+ await rm(fakeHome, { recursive: true, force: true });
433
+ await rm(out, { recursive: true, force: true });
434
+ }
435
+ });
436
+
437
+ it("rejects token-map.json versions newer than the implemented contract", async () => {
438
+ const fixture = await makeTmp();
439
+ try {
440
+ await mkdir(join(fixture, "agents"), { recursive: true });
441
+ await writeFile(
442
+ join(fixture, "token-map.json"),
443
+ JSON.stringify({ name: "fixture", version: 99, tools: {}, paths: {} }),
444
+ );
445
+ await assert.rejects(
446
+ installClaude({ root: fixture, output: join(fixture, "out") }),
447
+ /version 99/,
448
+ );
449
+ assert.equal(await pathExists(join(fixture, "out")), false, "nothing written when the contract is rejected");
450
+ } finally {
451
+ await rm(fixture, { recursive: true, force: true });
452
+ }
453
+ });
454
+ });
455
+
456
+ // ─── installClaude — ~/.claude/settings.json ─────────────────────────────────
457
+
458
+ /** A claude/settings.json template carrying the two CLI-owned keys. */
459
+ function settingsTemplate(overrides = {}) {
460
+ return { enabledPlugins: {}, extraKnownMarketplaces: {}, ...overrides };
461
+ }
462
+
463
+ /** A realistic personal ~/.claude/settings.json (keys the installer must never own). */
464
+ function personalSettings(overrides = {}) {
465
+ return {
466
+ permissions: { allow: ["Bash(pnpm test:*)"] },
467
+ hooks: { SessionStart: [{ hooks: [{ type: "command", command: "echo personal" }] }] },
468
+ env: { PERSONAL_FLAG: "1" },
469
+ statusLine: { type: "command", command: "~/.claude/statusline.sh" },
470
+ ...overrides,
471
+ };
472
+ }
473
+
474
+ /** Minimal package root with a claude/settings.json template, enough for
475
+ * installClaude to materialise the plugin subtree into a throwaway output. */
476
+ async function makeSettingsFixture({ template = settingsTemplate(), settings } = {}) {
477
+ const root = await makeTmp();
478
+ await writeFile(
479
+ join(root, "package.json"),
480
+ JSON.stringify({ name: "agent-cortex", version: "1.2.3", license: "MIT" }),
481
+ );
482
+ await writeFile(
483
+ join(root, "token-map.json"),
484
+ JSON.stringify({ tools: {}, paths: { plugin_root: { claude: "PLUGIN_ROOT" } } }),
485
+ );
486
+ await mkdir(join(root, "agents"));
487
+ await mkdir(join(root, "skills"));
488
+ if (template !== null) {
489
+ await mkdir(join(root, "claude"), { recursive: true });
490
+ await writeFile(join(root, "claude", "settings.json"), JSON.stringify(template, null, 2) + "\n");
491
+ }
492
+ const output = join(root, "out");
493
+ const settingsPath = join(root, "claude-home", "settings.json");
494
+ if (settings !== undefined) {
495
+ await mkdir(dirname(settingsPath), { recursive: true });
496
+ await writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n");
497
+ }
498
+ return { root, output, settingsPath, cleanup: () => rm(root, { recursive: true, force: true }) };
499
+ }
500
+
501
+ describe("install claude — settings.json", () => {
502
+ it("materialises a real settings.json carrying the CLI-owned keys from the committed template", async () => {
503
+ const fx = await makeSettingsFixture({
504
+ template: settingsTemplate({
505
+ enabledPlugins: { "context7@claude-plugins-official": true },
506
+ extraKnownMarketplaces: {
507
+ "claude-plugins-official": { source: { source: "github", repo: "anthropics/claude-plugins" } },
508
+ },
509
+ }),
510
+ });
511
+ try {
512
+ const result = await installClaude({ root: fx.root, output: fx.output, settingsPath: fx.settingsPath });
513
+
514
+ assert.equal(result.settings.path, fx.settingsPath);
515
+ assert.equal(result.settings.action, "written");
516
+ assert.equal((await lstat(fx.settingsPath)).isSymbolicLink(), false, "settings.json is a real file");
517
+ const settings = JSON.parse(await readFile(fx.settingsPath, "utf-8"));
518
+ assert.deepEqual(settings.enabledPlugins, { "context7@claude-plugins-official": true });
519
+ assert.deepEqual(settings.extraKnownMarketplaces, {
520
+ "claude-plugins-official": { source: { source: "github", repo: "anthropics/claude-plugins" } },
521
+ });
522
+ assert.deepEqual(
523
+ Object.keys(settings).sort(),
524
+ ["enabledPlugins", "extraKnownMarketplaces"],
525
+ "only the two CLI-owned keys are written",
526
+ );
527
+ } finally {
528
+ await fx.cleanup();
529
+ }
530
+ });
531
+
532
+ it("preserves personal and unknown keys on re-install (merge-keep-personal)", async () => {
533
+ const fx = await makeSettingsFixture({
534
+ template: settingsTemplate({
535
+ theme: { name: "dark", accent: "blue" },
536
+ enabledPlugins: { "team@official": true },
537
+ }),
538
+ settings: personalSettings({
539
+ theme: { name: "light" },
540
+ personalOnly: { nested: true },
541
+ }),
542
+ });
543
+ try {
544
+ await installClaude({ root: fx.root, output: fx.output, settingsPath: fx.settingsPath });
545
+ const settings = JSON.parse(await readFile(fx.settingsPath, "utf-8"));
546
+
547
+ assert.deepEqual(settings.permissions, { allow: ["Bash(pnpm test:*)"] });
548
+ assert.deepEqual(settings.hooks, { SessionStart: [{ hooks: [{ type: "command", command: "echo personal" }] }] });
549
+ assert.deepEqual(settings.env, { PERSONAL_FLAG: "1" });
550
+ assert.deepEqual(settings.statusLine, { type: "command", command: "~/.claude/statusline.sh" });
551
+ assert.deepEqual(settings.personalOnly, { nested: true }, "unknown key preserved");
552
+ assert.equal(settings.theme.name, "light", "personal value wins over the template default");
553
+ assert.equal(settings.theme.accent, "blue", "template default fills the unset nested key");
554
+ } finally {
555
+ await fx.cleanup();
556
+ }
557
+ });
558
+
559
+ it("owns only enabledPlugins + extraKnownMarketplaces — personal entries under them are replaced by the template", async () => {
560
+ const fx = await makeSettingsFixture({
561
+ template: settingsTemplate({
562
+ enabledPlugins: { "team@official": true },
563
+ extraKnownMarketplaces: {
564
+ official: { source: { source: "github", repo: "anthropics/plugins" } },
565
+ },
566
+ }),
567
+ settings: personalSettings({
568
+ enabledPlugins: { "personal@mine": true },
569
+ extraKnownMarketplaces: { mine: { source: { source: "github", repo: "me/mine" } } },
570
+ }),
571
+ });
572
+ try {
573
+ await installClaude({ root: fx.root, output: fx.output, settingsPath: fx.settingsPath });
574
+ const settings = JSON.parse(await readFile(fx.settingsPath, "utf-8"));
575
+
576
+ assert.deepEqual(settings.enabledPlugins, { "team@official": true }, "template owns enabledPlugins");
577
+ assert.deepEqual(
578
+ settings.extraKnownMarketplaces,
579
+ { official: { source: { source: "github", repo: "anthropics/plugins" } } },
580
+ "template owns extraKnownMarketplaces",
581
+ );
582
+ assert.ok(!("personal@mine" in settings.enabledPlugins));
583
+ } finally {
584
+ await fx.cleanup();
585
+ }
586
+ });
587
+
588
+ it("dry-run plans the settings write without touching the filesystem", async () => {
589
+ const fx = await makeSettingsFixture({
590
+ template: settingsTemplate({ enabledPlugins: { "team@official": true } }),
591
+ });
592
+ try {
593
+ const result = await installClaude({
594
+ root: fx.root,
595
+ output: fx.output,
596
+ settingsPath: fx.settingsPath,
597
+ dryRun: true,
598
+ });
599
+
600
+ assert.equal(result.settings.action, "would-write");
601
+ assert.equal(await pathExists(fx.settingsPath), false, "nothing written in dry-run");
602
+ } finally {
603
+ await fx.cleanup();
604
+ }
605
+ });
606
+
607
+ it("re-running is idempotent — the second install leaves settings.json unchanged", async () => {
608
+ const fx = await makeSettingsFixture({
609
+ template: settingsTemplate({ enabledPlugins: { "team@official": true } }),
610
+ });
611
+ try {
612
+ await installClaude({ root: fx.root, output: fx.output, settingsPath: fx.settingsPath });
613
+ const first = await readFile(fx.settingsPath, "utf-8");
614
+
615
+ const second = await installClaude({ root: fx.root, output: fx.output, settingsPath: fx.settingsPath });
616
+ assert.equal(second.settings.action, "unchanged");
617
+ assert.equal(await readFile(fx.settingsPath, "utf-8"), first);
618
+ } finally {
619
+ await fx.cleanup();
620
+ }
621
+ });
622
+
623
+ it("replaces a legacy symlinked settings.json with a real file, never writing through the template", async () => {
624
+ const fx = await makeSettingsFixture({
625
+ template: settingsTemplate({ enabledPlugins: { "team@official": true } }),
626
+ });
627
+ try {
628
+ const templatePath = join(fx.root, "claude", "settings.json");
629
+ const templateBefore = await readFile(templatePath, "utf-8");
630
+ await mkdir(dirname(fx.settingsPath), { recursive: true });
631
+ await symlink(templatePath, fx.settingsPath);
632
+
633
+ const result = await installClaude({ root: fx.root, output: fx.output, settingsPath: fx.settingsPath });
634
+
635
+ assert.equal(result.settings.action, "removed-symlink");
636
+ assert.equal(result.settings.removedSymlink, true);
637
+ assert.equal((await lstat(fx.settingsPath)).isSymbolicLink(), false, "symlink replaced by a real file");
638
+ const settings = JSON.parse(await readFile(fx.settingsPath, "utf-8"));
639
+ assert.deepEqual(settings.enabledPlugins, { "team@official": true });
640
+ assert.equal(await readFile(templatePath, "utf-8"), templateBefore, "template never written through");
641
+ } finally {
642
+ await fx.cleanup();
643
+ }
644
+ });
645
+
646
+ it("warns and skips when the package ships no claude/settings.json template", async () => {
647
+ const fx = await makeSettingsFixture({ template: null });
648
+ try {
649
+ const warnings = [];
650
+ const result = await installClaude({
651
+ root: fx.root,
652
+ output: fx.output,
653
+ settingsPath: fx.settingsPath,
654
+ warn: (m) => warnings.push(m),
655
+ });
656
+
657
+ assert.equal(result.settings, null);
658
+ assert.ok(warnings.some((w) => w.includes("claude/settings.json")), "missing template is warned about");
659
+ assert.equal(await pathExists(fx.settingsPath), false, "nothing written without a template");
660
+ } finally {
661
+ await fx.cleanup();
662
+ }
178
663
  });
179
664
  });