@jaybeeuu/agent-cortex 1.39.0 → 1.40.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 (41) hide show
  1. package/.agents/skills/style-versioning/SKILL.md +1 -1
  2. package/.github/workflows/ci.yml +47 -14
  3. package/AGENTS.md +19 -5
  4. package/CHANGELOG.md +12 -0
  5. package/README.md +111 -74
  6. package/agents/README.md +12 -11
  7. package/bin/agent-cortex.mjs +23 -10
  8. package/bin/installers/claude.mjs +518 -168
  9. package/bin/installers/copilot.mjs +22 -12
  10. package/bin/installers/pi.mjs +37 -34
  11. package/docs/claude-hooks.md +3 -3
  12. package/extensions/agent-modes/README.md +3 -2
  13. package/extensions/agent-modes/discover.test.ts +30 -7
  14. package/extensions/agent-modes/discover.ts +1 -1
  15. package/extensions/skill-stats/index.test.ts +3 -2
  16. package/lib/cli.mjs +22 -10
  17. package/package.json +4 -4
  18. package/plugin.json +1 -1
  19. package/scripts/build-copilot-agents.mjs +1 -1
  20. package/scripts/check-no-sync.mjs +49 -0
  21. package/scripts/lib/compose-agent.mjs +32 -14
  22. package/skills/engineering/grill-with-docs/SKILL.md +1 -1
  23. package/skills/workflow/hitl-collab/SKILL.md +1 -1
  24. package/test/claude-installer.test.mjs +199 -139
  25. package/test/cli.test.mjs +40 -30
  26. package/test/copilot-installer.test.mjs +92 -82
  27. package/test/helpers/fake-claude.mjs +152 -0
  28. package/test/installer.test.mjs +309 -57
  29. package/test/pi-installer.test.mjs +115 -99
  30. package/token-map.README.md +21 -12
  31. package/token-map.json +7 -6
  32. package/.claude-plugin/marketplace.json +0 -16
  33. package/claude/.claude-plugin/plugin.json +0 -11
  34. package/claude/agents/plan.md +0 -58
  35. package/claude/agents/ralph-plan.md +0 -121
  36. package/claude/agents/ralph.md +0 -193
  37. package/claude/agents/strategy.md +0 -111
  38. package/claude/hooks/scripts/notify.mjs +0 -54
  39. package/claude/hooks.json +0 -38
  40. /package/{claude → claude-extras}/.mcp.json +0 -0
  41. /package/{claude → claude-extras}/scripts/statusline-command.sh +0 -0
@@ -0,0 +1,152 @@
1
+ // Shared test helper: a stateful fake `claude` binary for the `claude plugin`
2
+ // CLI. The real CLI is a genuine external system (and its home config must
3
+ // never be touched by tests), so tests drive this fake instead. It records
4
+ // every invocation to `calls.log` (argv, space-joined, one line each) and
5
+ // simulates just enough real behaviour for idempotency assertions:
6
+ //
7
+ // plugin --help → exit 0 (availability probe)
8
+ // plugin marketplace list --json → registered marketplaces as JSON
9
+ // plugin marketplace add <root> → registers <root> (name read from its
10
+ // .claude-plugin/marketplace.json)
11
+ // plugin marketplace update <m> → exit 0
12
+ // plugin list --json → installed plugins as JSON
13
+ // plugin install <id> -y → records id at the version published by
14
+ // the marketplace source's plugin.json
15
+ // plugin update <name> -y → re-reads the version from the source
16
+ //
17
+ // State lives in `state.json` next to the binary, so a test can re-run the CLI
18
+ // against the same fake and observe the update path. `failWhen` makes any
19
+ // invocation whose argv contains the substring exit 1 (probe/command failures).
20
+ //
21
+ // Written async throughout (the fake is a child process and the helper's own
22
+ // I/O is test code) — the repo's check-no-sync lint scans test/ for *Sync.
23
+
24
+ import { chmod, writeFile } from "node:fs/promises";
25
+ import { join } from "node:path";
26
+
27
+ const FAKE_CLAUDE_SCRIPT = [
28
+ "#!/usr/bin/env node",
29
+ "// Stateful fake for the `claude plugin` CLI (test/helpers/fake-claude.mjs).",
30
+ "const fs = require('node:fs/promises');",
31
+ "const path = require('node:path');",
32
+ "",
33
+ "const dir = __dirname;",
34
+ "const stateFile = path.join(dir, 'state.json');",
35
+ "const logFile = path.join(dir, 'calls.log');",
36
+ "const failWhenFile = path.join(dir, 'failWhen.txt');",
37
+ "",
38
+ "(async () => {",
39
+ " const argv = process.argv.slice(2);",
40
+ " const joined = argv.join(' ');",
41
+ " await fs.appendFile(logFile, joined + '\\n');",
42
+ "",
43
+ " const failWhen = await fs.readFile(failWhenFile, 'utf8').catch(() => null);",
44
+ " if (failWhen && joined.includes(failWhen)) process.exit(1);",
45
+ "",
46
+ " if (argv[0] !== 'plugin') process.exit(0);",
47
+ "",
48
+ " const readState = async () => {",
49
+ " try {",
50
+ " return JSON.parse(await fs.readFile(stateFile, 'utf8'));",
51
+ " } catch {",
52
+ " return { markets: [], installed: [] };",
53
+ " }",
54
+ " };",
55
+ " const writeState = (state) => fs.writeFile(stateFile, JSON.stringify(state));",
56
+ "",
57
+ " // Real claude resolves the plugin version from the marketplace source's",
58
+ " // plugin.json; the fake mirrors that by reading <source>/claude/.claude-plugin/plugin.json.",
59
+ " const pluginJsonVersion = async (marketPath) => {",
60
+ " try {",
61
+ " return JSON.parse(await fs.readFile(path.join(marketPath, 'claude', '.claude-plugin', 'plugin.json'), 'utf8')).version;",
62
+ " } catch {",
63
+ " return undefined;",
64
+ " }",
65
+ " };",
66
+ " const marketplaceName = async (source) => {",
67
+ " try {",
68
+ " return JSON.parse(await fs.readFile(path.join(source, '.claude-plugin', 'marketplace.json'), 'utf8')).name;",
69
+ " } catch {",
70
+ " return undefined;",
71
+ " }",
72
+ " };",
73
+ "",
74
+ " const state = await readState();",
75
+ " const [sub, ...rest] = argv.slice(1);",
76
+ "",
77
+ " if (sub === '--help') process.exit(0);",
78
+ "",
79
+ " if (sub === 'marketplace') {",
80
+ " const [op, ...opArgs] = rest;",
81
+ " if (op === 'list') {",
82
+ " console.log(JSON.stringify(state.markets));",
83
+ " process.exit(0);",
84
+ " }",
85
+ " if (op === 'add') {",
86
+ " const source = path.resolve(opArgs[0]);",
87
+ " const name = (await marketplaceName(source)) ?? 'market-' + state.markets.length;",
88
+ " if (!state.markets.some((m) => m.name === name)) state.markets.push({ name, path: source });",
89
+ " await writeState(state);",
90
+ " process.exit(0);",
91
+ " }",
92
+ " if (op === 'update') process.exit(0);",
93
+ " process.exit(0);",
94
+ " }",
95
+ "",
96
+ " if (sub === 'list') {",
97
+ " console.log(JSON.stringify(state.installed));",
98
+ " process.exit(0);",
99
+ " }",
100
+ "",
101
+ " if (sub === 'install') {",
102
+ " const id = rest[0];",
103
+ " if (!state.installed.some((i) => i.id === id)) {",
104
+ " const [name, market] = id.split('@');",
105
+ " const m = state.markets.find((x) => x.name === market);",
106
+ " state.installed.push({ id, version: m ? (await pluginJsonVersion(m.path)) ?? '0.0.0' : '0.0.0' });",
107
+ " await writeState(state);",
108
+ " }",
109
+ " process.exit(0);",
110
+ " }",
111
+ "",
112
+ " if (sub === 'update') {",
113
+ " const name = rest[0];",
114
+ " const entry = state.installed.find((i) => i.id.startsWith(name + '@'));",
115
+ " if (entry) {",
116
+ " const m = state.markets.find((x) => x.name === entry.id.split('@')[1]);",
117
+ " const v = m ? await pluginJsonVersion(m.path) : undefined;",
118
+ " if (v) entry.version = v;",
119
+ " await writeState(state);",
120
+ " }",
121
+ " process.exit(0);",
122
+ " }",
123
+ "",
124
+ " process.exit(0);",
125
+ "})().catch((err) => {",
126
+ " console.error(err);",
127
+ " process.exit(1);",
128
+ "});",
129
+ ].join("\n");
130
+
131
+ /** Create a stateful fake `claude` binary in `dir`.
132
+ * @param {string} dir Directory to hold bin, calls.log, state.json
133
+ * @param {{ failWhen?: string|null }} [options] Invocations whose argv contains
134
+ * failWhen exit 1
135
+ * @returns {Promise<{ bin: string, log: string, state: string }>} */
136
+ export async function makeFakeClaude(dir, { failWhen = null } = {}) {
137
+ const bin = join(dir, "claude");
138
+ const log = join(dir, "calls.log");
139
+ const state = join(dir, "state.json");
140
+ await writeFile(bin, FAKE_CLAUDE_SCRIPT + "\n");
141
+ await chmod(bin, 0o755);
142
+ await writeFile(state, JSON.stringify({ markets: [], installed: [] }));
143
+ if (failWhen) await writeFile(join(dir, "failWhen.txt"), failWhen);
144
+ return { bin, log, state };
145
+ }
146
+
147
+ /** Registration actions from a fake call log: probes and state queries dropped.
148
+ * @param {string[]} lines Raw call-log lines
149
+ * @returns {string[]} Action lines (marketplace add/update, install, update) */
150
+ export function registrationActions(lines) {
151
+ return lines.filter((c) => !c.startsWith("plugin --help") && !c.includes("--json"));
152
+ }
@@ -1,57 +1,100 @@
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
+ } from "node:fs/promises";
9
22
  import { tmpdir } from "node:os";
10
23
  import { fileURLToPath } from "node:url";
11
24
  import { dirname, join, relative } from "node:path";
25
+ import { installClaude } from "../bin/installers/claude.mjs";
26
+ import { makeFakeClaude, registrationActions } from "./helpers/fake-claude.mjs";
12
27
 
13
28
  const __dirname = dirname(fileURLToPath(import.meta.url));
14
29
  const ROOT = join(__dirname, "..");
15
30
  const CLI_PATH = join(ROOT, "bin", "agent-cortex.mjs");
16
31
 
17
- function runCli(args) {
32
+ /** True when a path exists (stat succeeds). */
33
+ async function pathExists(p) {
34
+ try {
35
+ await stat(p);
36
+ return true;
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
41
+
42
+ /** Run the CLI; optionally pass a custom env (e.g. a fake HOME). */
43
+ function runCli(args, env = {}) {
18
44
  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
- });
45
+ execFile(
46
+ process.execPath,
47
+ [CLI_PATH, ...args],
48
+ { env: { ...process.env, ...env } },
49
+ (error, stdout, stderr) => {
50
+ resolve({ exitCode: error ? error.code ?? 1 : 0, stdout, stderr });
51
+ },
52
+ );
22
53
  });
23
54
  }
24
55
 
25
- function makeTmp() {
26
- return mkdtempSync(join(tmpdir(), "agent-cortex-installer-"));
56
+ async function makeTmp() {
57
+ return await mkdtemp(join(tmpdir(), "agent-cortex-installer-"));
27
58
  }
28
59
 
29
- /** Deterministic snapshot of a generated tree: relative paths, kind, and for
30
- * files/symlinks their byte content / link target. */
31
- function snapshot(dir) {
60
+
61
+ /** Deterministic snapshot of a generated tree: kind, and for files/symlinks
62
+ * their byte content / link target. */
63
+ async function snapshot(dir) {
32
64
  const entries = [];
33
- const walk = (p) => {
34
- for (const e of readdirSync(p, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
65
+ const walk = async (p) => {
66
+ for (const e of (await readdir(p, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
35
67
  const full = join(p, e.name);
36
68
  const rel = relative(dir, full);
37
- if (e.isSymbolicLink()) entries.push([rel, "link", readlinkSync(full)]);
69
+ if (e.isSymbolicLink()) entries.push([rel, "link", await readlink(full)]);
38
70
  else if (e.isDirectory()) {
39
71
  entries.push([rel, "dir"]);
40
- walk(full);
41
- } else entries.push([rel, "file", readFileSync(full, "utf-8")]);
72
+ await walk(full);
73
+ } else entries.push([rel, "file", await readFile(full, "utf-8")]);
42
74
  }
43
75
  };
44
- walk(dir);
76
+ await walk(dir);
45
77
  return entries;
46
78
  }
47
79
 
80
+ async function listFiles(dir) {
81
+ if (!(await pathExists(dir))) return [];
82
+ const out = [];
83
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
84
+ const p = join(dir, entry.name);
85
+ if (entry.isDirectory()) out.push(...(await listFiles(p)).map((f) => join(entry.name, f)));
86
+ else out.push(entry.name);
87
+ }
88
+ return out;
89
+ }
90
+
48
91
  describe("install claude", () => {
49
92
  it("composes agents from the composable sources (claude names, no tokens)", async () => {
50
- const out = makeTmp();
93
+ const out = await makeTmp();
51
94
  const { exitCode } = await runCli(["install", "claude", "--output", out]);
52
95
  assert.equal(exitCode, 0);
53
96
 
54
- const plan = readFileSync(join(out, "agents", "plan.md"), "utf-8");
97
+ const plan = await readFile(join(out, "agents", "plan.md"), "utf-8");
55
98
  assert.match(plan, /^---\n# GENERATED from agents\/plan\//);
56
99
  // provenance names the shared installer (no build-time script), so the
57
100
  // committed subtree can only come from the install-time generator.
@@ -60,46 +103,127 @@ describe("install claude", () => {
60
103
  assert.match(plan, /\nname: plan\n/);
61
104
  assert.doesNotMatch(plan, /\{\{(TOOL|PATH|SECTION):/);
62
105
 
63
- const ralphPlan = readFileSync(join(out, "agents", "ralph-plan.md"), "utf-8");
106
+ const ralphPlan = await readFile(join(out, "agents", "ralph-plan.md"), "utf-8");
64
107
  assert.match(ralphPlan, /^---\n# GENERATED from agents\/ralph-plan\//);
65
108
  assert.match(ralphPlan, /\nname: ralph-plan\n/);
66
109
  });
67
110
 
68
111
  it("copies native agents verbatim (ralph stays native, no composed counterpart)", async () => {
69
- const out = makeTmp();
112
+ const out = await makeTmp();
70
113
  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);
114
+ const native = await readFile(join(ROOT, "agents-native", "ralph.md"), "utf-8");
115
+ assert.equal(await readFile(join(out, "agents", "ralph.md"), "utf-8"), native);
73
116
  });
74
117
 
75
- it("symlinks flattened skills and excludes ralph-coupled skills", async () => {
76
- const out = makeTmp();
77
- await runCli(["install", "claude", "--output", out]);
118
+ it("copies skill dirs flat with substituted tokens, no symlinks, excluding ralph-coupled skills", async () => {
119
+ const out = await makeTmp();
120
+ const { exitCode } = await runCli(["install", "claude", "--output", out]);
121
+ assert.equal(exitCode, 0);
122
+
78
123
  const skills = join(out, "skills");
79
- const names = readdirSync(skills);
124
+ const names = await readdir(skills);
80
125
  assert.ok(names.includes("tdd"));
81
126
  assert.ok(names.includes("plan"));
82
127
  assert.ok(!names.includes("ralph"));
83
128
  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")));
129
+
130
+ // skills are real directories (copies), not symlinks
131
+ assert.ok((await stat(join(skills, "tdd"))).isDirectory());
132
+ assert.ok(await pathExists(join(skills, "tdd", "SKILL.md")));
133
+ assert.ok(!(await lstat(join(skills, "plan"))).isSymbolicLink());
134
+
135
+ // no symlinks anywhere in the materialised tree
136
+ for (const e of await snapshot(out)) {
137
+ assert.notEqual(e[1], "link", `unexpected symlink in tree: ${e[0]}`);
138
+ }
139
+
140
+ // tokens substituted against the claude column
141
+ const techDir = await readFile(join(skills, "technical-direction", "SKILL.md"), "utf-8");
142
+ assert.ok(techDir.includes("using Grep, Read, and Glob"), "rg/view/glob mapped to Grep/Read/Glob");
143
+ assert.ok(techDir.includes("via WebFetch"), "web_fetch mapped to WebFetch");
144
+ assert.doesNotMatch(techDir, /\{\{TOOL:/);
145
+
146
+ // null-mapped tool dropped from prose (claude has no read_agent step)
147
+ const design = await readFile(join(skills, "design-an-interface", "SKILL.md"), "utf-8");
148
+ assert.ok(design.includes("with Task,"), "task mapped to Task");
149
+ assert.doesNotMatch(design, /\{\{TOOL:|read_agent/);
150
+ });
151
+
152
+ it("leaves no literal tokens in any installed skill markdown", async () => {
153
+ const out = await makeTmp();
154
+ await runCli(["install", "claude", "--output", out]);
155
+ const files = (await listFiles(join(out, "skills"))).filter((f) => f.endsWith(".md"));
156
+ assert.ok(files.length > 0);
157
+ for (const f of files) {
158
+ const content = await readFile(join(out, "skills", f), "utf-8");
159
+ assert.doesNotMatch(content, /\{\{TOOL:|\{\{PATH:/, `no literal tokens in skill ${f}`);
160
+ }
161
+ });
162
+
163
+ it("copies hand-authored plugin extras (.mcp.json, scripts/) from the canonical claude-extras/ dir", async () => {
164
+ const out = await makeTmp();
165
+ await runCli(["install", "claude", "--output", out]);
166
+
167
+ const mcp = await readFile(join(ROOT, "claude-extras", ".mcp.json"), "utf-8");
168
+ assert.equal(await readFile(join(out, ".mcp.json"), "utf-8"), mcp);
169
+
170
+ const statusline = await readFile(join(ROOT, "claude-extras", "scripts", "statusline-command.sh"), "utf-8");
171
+ assert.equal(await readFile(join(out, "scripts", "statusline-command.sh"), "utf-8"), statusline);
172
+ });
173
+
174
+ it("copies hand-authored extras from claude-extras/ with executable bits preserved", async () => {
175
+ // claude-extras/ is the canonical store for .mcp.json and scripts/ (the
176
+ // committed claude/ subtree is retired); the installer ships them into the
177
+ // output, keeping the executable bit, and never writes into the store.
178
+ const root = await makeTmp();
179
+ const out = join(root, "out");
180
+ const extras = join(root, "claude-extras");
181
+ await writeFile(
182
+ join(root, "package.json"),
183
+ JSON.stringify({ name: "agent-cortex", version: "1.2.3", license: "MIT" }),
184
+ );
185
+ await writeFile(
186
+ join(root, "token-map.json"),
187
+ JSON.stringify({ tools: {}, paths: { plugin_root: { claude: "PLUGIN_ROOT" } } }),
188
+ );
189
+ await mkdir(join(root, "agents"));
190
+ await mkdir(join(root, "skills"));
191
+ await mkdir(join(extras, "scripts"), { recursive: true });
192
+ await writeFile(join(extras, ".mcp.json"), JSON.stringify({ mcpServers: {} }, null, 2) + "\n");
193
+ await writeFile(join(extras, "scripts", "statusline-command.sh"), "#!/bin/sh\necho status\n");
194
+ await chmod(join(extras, "scripts", "statusline-command.sh"), 0o755);
195
+
196
+ const result = await installClaude({ root, output: out });
197
+
198
+ assert.deepEqual(result.handAuthored, [".mcp.json", "scripts/statusline-command.sh"]);
199
+ assert.equal(
200
+ await readFile(join(out, ".mcp.json"), "utf-8"),
201
+ JSON.stringify({ mcpServers: {} }, null, 2) + "\n",
202
+ );
203
+ assert.equal(
204
+ (await stat(join(out, "scripts", "statusline-command.sh"))).mode & 0o111,
205
+ 0o111,
206
+ "executable bit survives the copy",
207
+ );
208
+ // the source store is never written to
209
+ assert.deepStrictEqual((await readdir(extras)).sort(), [".mcp.json", "scripts"]);
86
210
  });
87
211
 
88
212
  it("generates plugin.json tracking the package version and referencing hooks.json", async () => {
89
- const out = makeTmp();
213
+ const out = await makeTmp();
90
214
  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"));
215
+ const pkgVersion = JSON.parse(await readFile(join(ROOT, "package.json"), "utf-8")).version;
216
+ const plugin = JSON.parse(await readFile(join(out, ".claude-plugin", "plugin.json"), "utf-8"));
93
217
  assert.equal(plugin.name, "agent-cortex");
94
218
  assert.equal(plugin.version, pkgVersion);
95
219
  assert.equal(plugin.hooks, "./hooks.json");
96
220
  });
97
221
 
98
222
  it("generates hooks.json from the canonical hooks/claude/ source", async () => {
99
- const out = makeTmp();
223
+ const out = await makeTmp();
100
224
  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"));
225
+ const source = JSON.parse(await readFile(join(ROOT, "hooks", "claude", "hooks.json"), "utf-8"));
226
+ const installed = JSON.parse(await readFile(join(out, "hooks.json"), "utf-8"));
103
227
  assert.deepStrictEqual(installed, source);
104
228
  assert.ok(source.hooks.SessionStart);
105
229
  });
@@ -108,9 +232,9 @@ describe("install claude", () => {
108
232
  // The Notification matcher is an exact-string list over `notification_type` —
109
233
  // there are no success/error types, so that matcher would never fire. Guard
110
234
  // against it recurring (see docs/claude-hooks.md for the documented type list).
111
- const out = makeTmp();
235
+ const out = await makeTmp();
112
236
  await runCli(["install", "claude", "--output", out]);
113
- const installed = JSON.parse(readFileSync(join(out, "hooks.json"), "utf-8"));
237
+ const installed = JSON.parse(await readFile(join(out, "hooks.json"), "utf-8"));
114
238
  const matcher = installed.hooks.Notification.flatMap((g) => g.hooks).length
115
239
  ? installed.hooks.Notification[0].matcher
116
240
  : "";
@@ -135,15 +259,15 @@ describe("install claude", () => {
135
259
  }
136
260
  });
137
261
 
138
- it("bundles hook support scripts into claude/hooks/ and references them via the plugin root", async () => {
139
- const out = makeTmp();
262
+ it("bundles hook support scripts into hooks/ and references them via the plugin root", async () => {
263
+ const out = await makeTmp();
140
264
  await runCli(["install", "claude", "--output", out]);
141
265
 
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");
266
+ const canonical = await readFile(join(ROOT, "hooks", "claude", "scripts", "notify.mjs"), "utf-8");
267
+ const bundled = await readFile(join(out, "hooks", "scripts", "notify.mjs"), "utf-8");
144
268
  assert.equal(bundled, canonical);
145
269
 
146
- const installed = JSON.parse(readFileSync(join(out, "hooks.json"), "utf-8"));
270
+ const installed = JSON.parse(await readFile(join(out, "hooks.json"), "utf-8"));
147
271
  const notify = installed.hooks.Notification.flatMap((g) => g.hooks)
148
272
  .filter((h) => h.command?.includes("notify.mjs"));
149
273
  assert.ok(notify.length === 1, "hooks.json should reference the notification script");
@@ -151,29 +275,157 @@ describe("install claude", () => {
151
275
  });
152
276
 
153
277
  it("is deterministic across repeated installs into the same output", async () => {
154
- const out = makeTmp();
278
+ const out = await makeTmp();
155
279
  await runCli(["install", "claude", "--output", out]);
156
- const first = snapshot(out);
280
+ const first = await snapshot(out);
157
281
  await runCli(["install", "claude", "--output", out]);
158
- assert.deepStrictEqual(snapshot(out), first);
282
+ assert.deepStrictEqual(await snapshot(out), first);
159
283
  });
160
284
 
161
- it("--dry-run reports the plan without writing anything", async () => {
162
- const out = makeTmp();
285
+ it("--dry-run reports the plan (copied skills, output target) without writing anything", async () => {
286
+ const out = await makeTmp();
163
287
  const { exitCode, stdout } = await runCli(["install", "claude", "--output", out, "--dry-run"]);
164
288
  assert.equal(exitCode, 0);
165
289
  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);
290
+ assert.ok(/copied/i.test(stdout), "dry-run should describe skills as copied, not symlinked");
291
+ assert.ok(stdout.includes(out), "dry-run should name the output target");
292
+ assert.equal(await pathExists(join(out, "agents")), false);
293
+ assert.equal(await pathExists(join(out, "skills")), false);
294
+ assert.equal(await pathExists(join(out, ".claude-plugin")), false);
169
295
  });
170
296
 
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");
297
+ it("leaves the canonical sources untouched (no committed claude/ output)", async () => {
298
+ const out = await makeTmp();
299
+ const beforeExtras = (await readdir(join(ROOT, "claude-extras"))).sort();
300
+ const beforeExtrasScripts = (await readdir(join(ROOT, "claude-extras", "scripts"))).sort();
175
301
  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);
302
+ // no committed claude/ output remains, and installs never write into the
303
+ // canonical sources the plugin materialises from
304
+ assert.equal(await pathExists(join(ROOT, "claude")), false);
305
+ assert.deepStrictEqual((await readdir(join(ROOT, "claude-extras"))).sort(), beforeExtras);
306
+ assert.deepStrictEqual((await readdir(join(ROOT, "claude-extras", "scripts"))).sort(), beforeExtrasScripts);
307
+ });
308
+
309
+ it("plain install materialises into ~/.agent-cortex/claude with a marketplace manifest and drives the claude plugin CLI", async () => {
310
+ const fakeHome = await makeTmp();
311
+ const fakeBin = await makeTmp();
312
+ const fake = await makeFakeClaude(fakeBin);
313
+ const env = { HOME: fakeHome, PATH: `${fakeBin}:${process.env.PATH}` };
314
+
315
+ const { exitCode, stdout } = await runCli(["install", "claude"], env);
316
+ assert.equal(exitCode, 0);
317
+
318
+ const pluginDir = join(fakeHome, ".agent-cortex", "claude");
319
+ // plugin materialised into the home install root
320
+ assert.ok(await pathExists(join(pluginDir, "skills", "tdd", "SKILL.md")), "skill copied into home plugin");
321
+ assert.ok((await stat(join(pluginDir, "skills", "tdd"))).isDirectory(), "home skills are copies, not symlinks");
322
+ // hand-authored extras shipped into the home plugin
323
+ assert.equal(
324
+ await readFile(join(pluginDir, ".mcp.json"), "utf-8"),
325
+ await readFile(join(ROOT, "claude-extras", ".mcp.json"), "utf-8"),
326
+ );
327
+
328
+ // marketplace manifest at the home root exposes ./claude
329
+ const marketPath = join(fakeHome, ".agent-cortex", ".claude-plugin", "marketplace.json");
330
+ const market = JSON.parse(await readFile(marketPath, "utf-8"));
331
+ assert.equal(market.name, "jaybeeuu");
332
+ assert.deepStrictEqual(market.plugins, [
333
+ {
334
+ name: "agent-cortex",
335
+ source: "./claude",
336
+ description: "Personal Claude Code plugin with custom agents and skills",
337
+ },
338
+ ]);
339
+
340
+ // registration drove the claude plugin CLI against the home install root:
341
+ // fresh state → marketplace add + plugin install (state queries and the
342
+ // availability probe are not registration actions).
343
+ const calls = (await readFile(fake.log, "utf-8")).trim().split("\n");
344
+ assert.deepEqual(registrationActions(calls), [
345
+ `plugin marketplace add ${join(fakeHome, ".agent-cortex")}`,
346
+ "plugin install agent-cortex@jaybeeuu -y",
347
+ ]);
348
+ assert.ok(stdout.includes("Registering plugin"), "CLI reports registration");
349
+ });
350
+
351
+ it("re-running a plain install is the update path: no marketplace re-add, no double-install", async () => {
352
+ const fakeHome = await makeTmp();
353
+ const fakeBin = await makeTmp();
354
+ const fake = await makeFakeClaude(fakeBin);
355
+ const env = { HOME: fakeHome, PATH: `${fakeBin}:${process.env.PATH}` };
356
+ const allCalls = async () => (await readFile(fake.log, "utf-8")).trim().split("\n").filter(Boolean);
357
+
358
+ const first = await runCli(["install", "claude"], env);
359
+ assert.equal(first.exitCode, 0);
360
+ assert.deepEqual(registrationActions(await allCalls()), [
361
+ `plugin marketplace add ${join(fakeHome, ".agent-cortex")}`,
362
+ "plugin install agent-cortex@jaybeeuu -y",
363
+ ]);
364
+ const run1End = (await allCalls()).length;
365
+
366
+ // Second run: marketplace registered, plugin at the materialised version →
367
+ // marketplace update only — the documented no-op update path.
368
+ const second = await runCli(["install", "claude"], env);
369
+ assert.equal(second.exitCode, 0);
370
+ assert.deepEqual(registrationActions((await allCalls()).slice(run1End)), [
371
+ "plugin marketplace update jaybeeuu",
372
+ ]);
373
+
374
+ // Across both runs the marketplace was added exactly once and the plugin
375
+ // installed exactly once (idempotent — no re-add, no double-install).
376
+ const all = registrationActions(await allCalls());
377
+ assert.equal(all.filter((c) => c.startsWith("plugin marketplace add")).length, 1);
378
+ assert.equal(all.filter((c) => c === "plugin install agent-cortex@jaybeeuu -y").length, 1);
379
+ });
380
+
381
+ it("warns with manual commands when the claude CLI is missing; --require-register fails the run", async () => {
382
+ const fakeHome = await makeTmp();
383
+ const env = { HOME: fakeHome, PATH: "/nonexistent" };
384
+
385
+ // Default: warn-only — the plugin is materialised, exit 0, manual
386
+ // registration commands printed for the user to run.
387
+ const warned = await runCli(["install", "claude"], env);
388
+ assert.equal(warned.exitCode, 0, "missing claude CLI is warn-only by default");
389
+ assert.match(warned.stderr, /NOT registered/);
390
+ assert.match(warned.stderr, /claude plugin marketplace add /);
391
+ assert.match(warned.stderr, /claude plugin install agent-cortex@jaybeeuu -y/);
392
+ // The plugin itself is still materialised into the fake home.
393
+ assert.ok(await pathExists(join(fakeHome, ".agent-cortex", "claude", "skills", "tdd", "SKILL.md")));
394
+
395
+ // --require-register escalates the same failure to a non-zero exit.
396
+ const required = await runCli(["install", "claude", "--require-register"], env);
397
+ assert.equal(required.exitCode, 1);
398
+ assert.match(required.stderr, /require-register/);
399
+ });
400
+
401
+ it("plain --dry-run prints the home target and copied-skill plan without writing", async () => {
402
+ const fakeHome = await makeTmp();
403
+ const fakeBin = await makeTmp();
404
+ await makeFakeClaude(fakeBin);
405
+ const env = { HOME: fakeHome, PATH: `${fakeBin}:${process.env.PATH}` };
406
+
407
+ const { exitCode, stdout } = await runCli(["install", "claude", "--dry-run"], env);
408
+ assert.equal(exitCode, 0);
409
+ assert.ok(stdout.includes(join(fakeHome, ".agent-cortex", "claude")), "dry-run names the home target");
410
+ assert.ok(/copied/i.test(stdout), "dry-run describes copied skills");
411
+ assert.equal(await pathExists(join(fakeHome, ".agent-cortex")), false, "nothing written in dry-run");
412
+ });
413
+
414
+ it("rejects token-map.json versions newer than the implemented contract", async () => {
415
+ const fixture = await makeTmp();
416
+ try {
417
+ await mkdir(join(fixture, "agents"), { recursive: true });
418
+ await writeFile(
419
+ join(fixture, "token-map.json"),
420
+ JSON.stringify({ name: "fixture", version: 99, tools: {}, paths: {} }),
421
+ );
422
+ await assert.rejects(
423
+ installClaude({ root: fixture, output: join(fixture, "out") }),
424
+ /version 99/,
425
+ );
426
+ assert.equal(await pathExists(join(fixture, "out")), false, "nothing written when the contract is rejected");
427
+ } finally {
428
+ await rm(fixture, { recursive: true, force: true });
429
+ }
178
430
  });
179
431
  });