@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,56 +1,96 @@
1
- // Install-time generator + registrar for the Claude Code plugin. Producing
2
- // claude/ from the canonical sources is the ONLY code path — there is no
3
- // separate build-time generator: the package script `pnpm build:claude` runs
4
- // the same command as the CLI with `--output` (`node bin/agent-cortex.mjs
5
- // install claude --output claude`), so the committed subtree and installer
6
- // output can never diverge:
1
+ // Install-time generator + registrar for the Claude Code plugin. A plain
2
+ // `agent-cortex install claude` MATERIALISES the plugin into a home-scoped
3
+ // directory (~/.agent-cortex/claude) and merges the committed
4
+ // claude/settings.json template into ~/.claude/settings.json; the generated
5
+ // plugin subtree is never committed — only that template, claude-extras/, and
6
+ // the gitignored $HOME target are stores. The materialised plugin is:
7
7
  //
8
- // node bin/agent-cortex.mjs install claude regenerates claude/ in place
9
- // AND registers the plugin with Claude Code via
10
- // the `claude plugin` CLI (marketplace add →
11
- // install → marketplace update → plugin update)
8
+ // <output>/.claude-plugin/plugin.json plugin manifest (version tracks the
9
+ // package — never stale)
10
+ // <output>/agents/<slug>.md composed agents (agents/<name>/
11
+ // agent.md + claude/ harness dirs,
12
+ // {{TOOL:...}}/{{PATH:...}}
13
+ // substituted against token-map.json's
14
+ // claude column) plus agents-native
15
+ // (ralph verbatim)
16
+ // <output>/skills/<name>/ skill dirs COPIED flat (one dir per
17
+ // skill, as Claude's <name>/SKILL.md
18
+ // plugin scan expects) with .md files
19
+ // token-substituted — no symlinks, so
20
+ // literal {{TOOL:...}}/{{PATH:...}}
21
+ // never reaches Claude
22
+ // <output>/hooks.json + hooks/<scripts> from hooks/claude/ (support files
23
+ // referenced via ${CLAUDE_PLUGIN_ROOT})
24
+ // <output>/.mcp.json + scripts/ hand-authored extras copied from
25
+ // claude-extras/ (the canonical
26
+ // store)
27
+ //
28
+ // Default output is ~/.agent-cortex/claude (the home install root
29
+ // ~/.agent-cortex doubles as the marketplace root — the installer writes
30
+ // ~/.agent-cortex/.claude-plugin/marketplace.json exposing ./claude, so
31
+ // `claude plugin marketplace add ~/.agent-cortex` works). `--output <dir>`
32
+ // generates the plugin subtree only — no marketplace manifest, no runtime
33
+ // registration (tests/CI). `--dry-run` plans without writing or spawning.
34
+ //
35
+ // node bin/agent-cortex.mjs install claude
36
+ // materialises into ~/.agent-cortex/claude +
37
+ // marketplace manifest, AND registers the plugin
38
+ // with Claude Code via the `claude plugin` CLI
39
+ // (marketplace add → install → marketplace
40
+ // update → plugin update)
12
41
  // node bin/agent-cortex.mjs install claude --output <dir>
13
- // generates only — no runtime registration
42
+ // generates only — no manifest, no registration
14
43
  // node bin/agent-cortex.mjs install claude --dry-run
15
44
  // plans generation + registration without writing
16
45
  //
17
- // claude/ stays committed (not gitignored): registration points Claude Code's
18
- // marketplace at this checkout (`.claude-plugin/marketplace.json` exposes
19
- // ./claude), so the plugin content is the working tree and a fresh clone
20
- // installs as-is with no generation step. CI regenerates claude/ via
21
- // `--output` and runs `git diff --exit-code claude/...` — byte-for-byte
22
- // validation that the committed output equals installer output, catching stale
23
- // output and hand-edits to generated files alike.
24
- //
25
- // Generated from:
26
- // claude/agents/<slug>.md agents/<name>/agent.md +
27
- // agents/<name>/claude/frontmatter.json +
28
- // agents/<name>/claude/<section>.md ({{SECTION:...}}),
29
- // with {{TOOL:...}}/{{PATH:...}} substituted against
30
- // the claude column of token-map.json. Agents in the
31
- // DEFER set ship instead from agents-native/<name>.md.
32
- // claude/skills/<name> symlinks to the grouped skills/<group>/<name> dirs
33
- // (single source — Claude scans skills one level deep)
34
- // claude/.claude-plugin/plugin.json name/version/description from package.json
35
- // (version tracks the package — never stale)
36
- // claude/hooks.json copied from hooks/claude/hooks.json when present
37
- // claude/hooks/<scripts> copied from hooks/claude/ (support files bundled
38
- // alongside hooks.json, referenced via ${CLAUDE_PLUGIN_ROOT})
39
- //
40
- // The claude/ subtree stays committed and CI drift-checks it (build:claude +
41
- // git diff --exit-code), so a fresh clone remains installable as-is. Hand-authored
42
- // support files under claude/ (claude/.mcp.json, claude/scripts/) are left untouched.
46
+ // Registration is idempotent by state, not by exit code: `claude plugin
47
+ // marketplace list --json` decides add-vs-update and `claude plugin list
48
+ // --json` decides install-vs-update against the materialised version — a
49
+ // re-run is the update path, and a repeat install at the same version is a
50
+ // true no-op. If the `claude` CLI is missing or predates the plugin subcommand
51
+ // (v2+), the installer warns and prints the manual registration commands,
52
+ // exiting non-zero only with --require-register.
43
53
  //
44
54
  // Zero dependencies so it runs on the CI Node and local Node alike.
45
55
 
46
- import { readFileSync, writeFileSync, mkdirSync, rmSync, symlinkSync, statSync, existsSync, readdirSync } from "node:fs";
47
- import { spawnSync } from "node:child_process";
48
- import { join, dirname, relative, resolve } from "node:path";
56
+ import { readFile, writeFile, mkdir, rm, copyFile, stat, readdir, chmod, rename, lstat } from "node:fs/promises";
57
+ import { spawn } from "node:child_process";
58
+ import { join, dirname, basename, resolve } from "node:path";
49
59
  import { fileURLToPath } from "node:url";
50
- import { composeAgent } from "../../scripts/lib/compose-agent.mjs";
60
+ import { homedir } from "node:os";
61
+ import { composeAgent, loadTokenMap, substituteTokens } from "../../scripts/lib/compose-agent.mjs";
62
+ import { isDirectory, isFile } from "../../scripts/lib/fs.mjs";
51
63
 
52
64
  const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
53
- const DEFAULT_ROOT = join(MODULE_DIR, "..", "..");
65
+ const PACKAGE_ROOT = join(MODULE_DIR, "..", "..");
66
+
67
+ // Home-scoped plugin install root. The parent dir (~/.agent-cortex) is the
68
+ // marketplace root: it carries .claude-plugin/marketplace.json exposing
69
+ // ./claude, so `claude plugin marketplace add ~/.agent-cortex` points Claude
70
+ // Code at the materialised plugin. Overridable in tests via HOME (node's
71
+ // os.homedir() honours $HOME on POSIX).
72
+ const DEFAULT_OUTPUT = join(homedir(), ".agent-cortex", "claude");
73
+
74
+ // Claude Code's user settings file: the default install merges the committed
75
+ // claude/settings.json template into it. `--output` leaves it alone; an
76
+ // explicit settingsPath is the programmatic (test/CI) seam that requests it.
77
+ const DEFAULT_SETTINGS_PATH = join(homedir(), ".claude", "settings.json");
78
+
79
+ // The committed user-settings template.
80
+ const SETTINGS_TEMPLATE = "claude/settings.json";
81
+
82
+ // The only keys the installer owns outright: everything else in the live file
83
+ // is personal config the merge must preserve.
84
+ const SETTINGS_OWNED_KEYS = ["enabledPlugins", "extraKnownMarketplaces"];
85
+
86
+ // The token-map contract version this installer implements (token-map.json
87
+ // "version" field). A higher map version is rejected: the contract must be
88
+ // extended before this installer can trust it.
89
+ const CONTRACT_VERSION = 1;
90
+
91
+ const CLAUDE = "claude";
92
+
93
+ const DEFAULT_WARN = (msg) => console.warn(`[claude-installer] ${msg}`);
54
94
 
55
95
  // Agents NOT transformed from the composable sources. `ralph` is authored natively for Claude
56
96
  // instead (agents-native/ralph.md) — its event-driven orchestration can't be produced by
@@ -58,40 +98,34 @@ const DEFAULT_ROOT = join(MODULE_DIR, "..", "..");
58
98
  const DEFER = new Set(["ralph"]);
59
99
 
60
100
  // Ralph-coupled Copilot skills that don't apply to the lean Claude Ralph (and carry dead
61
- // ~/.copilot orchestration paths). Not symlinked into the Claude plugin.
101
+ // ~/.copilot orchestration paths). Not copied into the Claude plugin.
62
102
  const SKILL_EXCLUDE = new Set(["ralph", "run-pipeline-stage"]);
63
103
 
64
104
  const NAME_PREFIX = "agent-cortex:";
65
105
 
66
- function isFile(p) {
67
- try {
68
- return statSync(p).isFile();
69
- } catch {
70
- return false;
71
- }
72
- }
73
-
74
106
  function byName(a, b) {
75
107
  return a.localeCompare(b);
76
108
  }
77
109
 
78
110
  /** Compose agents from agents/<name>/; DEFERed agents come from agents-native/ instead. */
79
- function buildAgents(root) {
111
+ async function buildAgents(root) {
80
112
  const files = [];
81
113
  const skipped = [];
82
- const entries = readdirSync(join(root, "agents"), { withFileTypes: true }).sort((a, b) => byName(a.name, b.name));
114
+ const entries = (await readdir(join(root, "agents"), { withFileTypes: true })).sort((a, b) =>
115
+ byName(a.name, b.name),
116
+ );
83
117
 
84
118
  for (const entry of entries) {
85
119
  if (!entry.isDirectory()) continue;
86
120
  const dir = join(root, "agents", entry.name);
87
- if (!isFile(join(dir, "agent.md")) || !isFile(join(dir, "claude", "frontmatter.json"))) continue;
121
+ if (!(await isFile(join(dir, "agent.md"))) || !(await isFile(join(dir, "claude", "frontmatter.json")))) continue;
88
122
 
89
123
  if (DEFER.has(entry.name)) {
90
124
  skipped.push(entry.name);
91
125
  continue;
92
126
  }
93
127
 
94
- const fm = composeAgent(root, entry.name, "claude");
128
+ const fm = await composeAgent(root, entry.name, CLAUDE);
95
129
  const slug = fm.name.startsWith(NAME_PREFIX) ? fm.name.slice(NAME_PREFIX.length) : fm.name;
96
130
  const header =
97
131
  `---\n` +
@@ -105,40 +139,78 @@ function buildAgents(root) {
105
139
  return { files, skipped };
106
140
  }
107
141
 
108
- /** Copy hand-authored Claude-native agents into claude/agents/ verbatim. */
109
- function buildNatives(root, nativeSrc) {
142
+ /** Copy hand-authored Claude-native agents into agents/ verbatim. */
143
+ async function buildNatives(root, nativeSrc) {
110
144
  const files = [];
111
- if (!existsSync(nativeSrc)) return files;
112
- for (const file of readdirSync(nativeSrc).filter((f) => f.endsWith(".md")).sort(byName)) {
113
- files.push({ file, content: readFileSync(join(nativeSrc, file), "utf-8") });
145
+ if (!(await isDirectory(nativeSrc))) return files;
146
+ const names = (await readdir(nativeSrc)).filter((f) => f.endsWith(".md")).sort(byName);
147
+ for (const file of names) {
148
+ files.push({ file, content: await readFile(join(nativeSrc, file), "utf-8") });
114
149
  }
115
150
  return files;
116
151
  }
117
152
 
118
- /** Flatten grouped skills (skills/<group>/<name>/) into claude/skills/ as relative symlinks. */
119
- function buildSkills(root) {
120
- const links = [];
153
+ /**
154
+ * Copy the grouped source skills (skills/<group>/<name>/) into the plugin FLAT
155
+ * (skills/<name>/ — Claude's plugin scan expects <name>/SKILL.md directly
156
+ * under skills/), token-substituting .md files against token-map.json's claude
157
+ * column (the same materialisation as bin/installers/pi.mjs: no symlinks, so
158
+ * literal {{TOOL:...}}/{{PATH:...}} never survives; non-.md files verbatim).
159
+ */
160
+ async function buildSkills(root, skillOut, { dryRun, pluginRoot, tokenMap, warn }) {
121
161
  const seen = new Map();
122
- for (const group of readdirSync(join(root, "skills")).sort(byName)) {
162
+ const transform = (content) =>
163
+ substituteTokens(content, CLAUDE, tokenMap, { dropNullTools: true, pluginRoot, resolveRelativePaths: true, warn });
164
+
165
+ const names = [];
166
+ let md = 0;
167
+ let files = 0;
168
+ for (const group of (await readdir(join(root, "skills"))).sort(byName)) {
123
169
  const groupDir = join(root, "skills", group);
124
- if (!statSync(groupDir).isDirectory()) continue;
125
- for (const name of readdirSync(groupDir).sort(byName)) {
170
+ if (!(await isDirectory(groupDir))) continue;
171
+ for (const name of (await readdir(groupDir)).sort(byName)) {
126
172
  const skillDir = join(groupDir, name);
127
- if (!isFile(join(skillDir, "SKILL.md"))) continue;
173
+ if (!(await isFile(join(skillDir, "SKILL.md")))) continue;
128
174
  if (SKILL_EXCLUDE.has(name)) continue;
129
175
  if (seen.has(name)) {
130
176
  throw new Error(`duplicate skill name "${name}" (${seen.get(name)} and ${group}) — names must be unique when flattened`);
131
177
  }
132
178
  seen.set(name, group);
133
- links.push({ name, target: skillDir });
179
+ names.push(name);
180
+ const stats = await copyTree(skillDir, join(skillOut, name), transform, dryRun);
181
+ md += stats.md;
182
+ files += stats.files;
134
183
  }
135
184
  }
136
- return links;
185
+ return { names, md, files, dir: skillOut };
186
+ }
187
+
188
+ /** Recursively copy a tree; transform .md file contents, copy everything else verbatim. */
189
+ async function copyTree(src, dest, transform, dryRun) {
190
+ let md = 0;
191
+ let files = 0;
192
+ for (const entry of await readdir(src, { withFileTypes: true })) {
193
+ const from = join(src, entry.name);
194
+ const to = join(dest, entry.name);
195
+ if (entry.isDirectory()) {
196
+ const stats = await copyTree(from, to, transform, dryRun);
197
+ md += stats.md;
198
+ files += stats.files;
199
+ } else if (entry.isFile()) {
200
+ files += 1;
201
+ if (entry.name.endsWith(".md")) md += 1;
202
+ if (dryRun) continue;
203
+ await mkdir(dest, { recursive: true });
204
+ if (entry.name.endsWith(".md")) await writeFile(to, transform(await readFile(from, "utf-8")));
205
+ else await copyFile(from, to);
206
+ }
207
+ }
208
+ return { md, files };
137
209
  }
138
210
 
139
211
  /** plugin.json manifest; version tracks the package so it can never go stale. */
140
- function buildPluginJson(root) {
141
- const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf-8"));
212
+ async function buildPluginJson(root) {
213
+ const pkg = JSON.parse(await readFile(join(root, "package.json"), "utf-8"));
142
214
  return (
143
215
  JSON.stringify(
144
216
  {
@@ -156,55 +228,309 @@ function buildPluginJson(root) {
156
228
  );
157
229
  }
158
230
 
231
+ /**
232
+ * Marketplace manifest exposing the plugin at <marketplaceRoot>/<basename>.
233
+ * Lives at `<marketplaceRoot>/.claude-plugin/marketplace.json` (the home
234
+ * install root ~/.agent-cortex doubles as the marketplace root), so `claude
235
+ * plugin marketplace add ~/.agent-cortex` resolves ./claude relative to the
236
+ * manifest's directory.
237
+ */
238
+ async function buildMarketplaceManifest(root, outputName) {
239
+ const pkg = JSON.parse(await readFile(join(root, "package.json"), "utf-8"));
240
+ return {
241
+ name: "jaybeeuu",
242
+ owner: { name: "jaybeeuu" },
243
+ metadata: { description: "jaybeeuu's personal Claude Code plugins." },
244
+ plugins: [
245
+ {
246
+ name: pkg.name.replace(/^@[^/]+\//, ""),
247
+ source: `./${outputName}`,
248
+ description: "Personal Claude Code plugin with custom agents and skills",
249
+ },
250
+ ],
251
+ };
252
+ }
253
+
159
254
  /**
160
255
  * Hook support files (hooks/claude/ except hooks.json) bundled into
161
- * claude/hooks/ so hook commands can reference them via ${CLAUDE_PLUGIN_ROOT}.
162
- * hooks.json itself is special-cased to the plugin root (claude/hooks.json).
256
+ * hooks/ so hook commands can reference them via ${CLAUDE_PLUGIN_ROOT}.
257
+ * hooks.json itself is special-cased to the plugin root (hooks.json).
163
258
  */
164
- function buildHookFiles(root) {
259
+ async function buildHookFiles(root) {
165
260
  const files = [];
166
261
  const srcDir = join(root, "hooks", "claude");
167
- if (!existsSync(srcDir)) return files;
168
- const walk = (dir, rel) => {
169
- for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => byName(a.name, b.name))) {
262
+ if (!(await isDirectory(srcDir))) return files;
263
+ const walk = async (dir, rel) => {
264
+ const entries = await readdir(dir, { withFileTypes: true });
265
+ entries.sort((a, b) => byName(a.name, b.name));
266
+ for (const entry of entries) {
170
267
  const full = join(dir, entry.name);
171
- if (entry.isDirectory()) walk(full, join(rel, entry.name));
268
+ if (entry.isDirectory()) await walk(full, join(rel, entry.name));
172
269
  else if (entry.name !== "hooks.json")
173
- files.push({ rel: join(rel, entry.name), content: readFileSync(full, "utf-8") });
270
+ files.push({ rel: join(rel, entry.name), content: await readFile(full, "utf-8") });
174
271
  }
175
272
  };
176
- walk(srcDir, "");
273
+ await walk(srcDir, "");
177
274
  return files;
178
275
  }
179
276
 
277
+ /**
278
+ * Hand-authored extras the plugin needs that the generators don't produce:
279
+ * the repo's claude-extras/ dir is the canonical store for .mcp.json and
280
+ * scripts/, and the installer ships them to the output. Content is captured
281
+ * at read time (not path-referenced) so the write-phase cleanup of the
282
+ * output dir can never destroy the sources (outputs normally go to
283
+ * ~/.agent-cortex/claude or a tmp dir, never into claude-extras/).
284
+ */
285
+ async function buildHandAuthored(root) {
286
+ const items = [];
287
+ const srcExtras = join(root, "claude-extras");
288
+ if (await isFile(join(srcExtras, ".mcp.json"))) {
289
+ const mcp = join(srcExtras, ".mcp.json");
290
+ items.push({ rel: ".mcp.json", content: await readFile(mcp), mode: (await stat(mcp)).mode & 0o777 });
291
+ }
292
+ const scriptsDir = join(srcExtras, "scripts");
293
+ if (await isDirectory(scriptsDir)) {
294
+ for (const file of (await readdir(scriptsDir)).sort(byName)) {
295
+ const src = join(scriptsDir, file);
296
+ if (await isFile(src)) items.push({ rel: join("scripts", file), content: await readFile(src), mode: (await stat(src)).mode & 0o777 });
297
+ }
298
+ }
299
+ return items;
300
+ }
301
+
302
+ // ─── Claude user settings ────────────────────────────────────────────────────
303
+
304
+ /**
305
+ * Materialise ~/.claude/settings.json from the committed claude/settings.json
306
+ * template with merge-keep-personal semantics (the same contract as the pi
307
+ * installer's installSettings):
308
+ *
309
+ * - template keys fill in defaults the live file lacks; every value already
310
+ * present in the live file wins (deeply), and unknown keys are preserved,
311
+ * so personal config survives re-install;
312
+ * - `enabledPlugins` and `extraKnownMarketplaces` are CLI-owned: they are
313
+ * rewritten from the template outright. `permissions`, `hooks`, `env`,
314
+ * `statusLine` and every other key are never touched.
315
+ */
316
+ async function installSettings({ root, target, dryRun, warn }) {
317
+ const template = await readTemplateJson(join(root, SETTINGS_TEMPLATE), warn);
318
+ if (template === null) return null;
319
+
320
+ const merged = mergeSettings(template, await readJsonObject(target, warn));
321
+ for (const key of SETTINGS_OWNED_KEYS) {
322
+ if (key in template) merged[key] = template[key];
323
+ else delete merged[key];
324
+ }
325
+
326
+ const content = `${JSON.stringify(merged, null, 2)}\n`;
327
+ return writeManagedJson({ target, content, dryRun });
328
+ }
329
+
330
+ /**
331
+ * Merge the committed template over the live file. Template keys fill in
332
+ * defaults, live values win (deeply), and keys the template does not know about
333
+ * are preserved — re-installing never discards personal or Claude-written config.
334
+ */
335
+ function mergeSettings(template, existing) {
336
+ const merged = {};
337
+ for (const key of Object.keys(template)) {
338
+ const templateValue = template[key];
339
+ const existingValue = existing[key];
340
+ if (isPlainObject(templateValue) && isPlainObject(existingValue)) {
341
+ merged[key] = mergeSettings(templateValue, existingValue);
342
+ } else {
343
+ merged[key] = key in existing ? existingValue : templateValue;
344
+ }
345
+ }
346
+ for (const key of Object.keys(existing)) {
347
+ if (!(key in merged)) merged[key] = existing[key];
348
+ }
349
+ return merged;
350
+ }
351
+
352
+ /**
353
+ * Write the managed settings file. A legacy symlink is replaced with a real
354
+ * file by writing a sibling temp file and renaming it over the target — rename
355
+ * replaces the link itself, so the committed template is never written through.
356
+ * Returns the action taken so the CLI can report the plan (dry-run included).
357
+ */
358
+ async function writeManagedJson({ target, content, dryRun }) {
359
+ const symlink = await isSymlink(target);
360
+ const current = await readFileMaybe(target);
361
+
362
+ if (dryRun) {
363
+ return {
364
+ path: target,
365
+ action: symlink ? "would-remove-symlink" : current === content ? "unchanged" : "would-write",
366
+ removedSymlink: symlink,
367
+ };
368
+ }
369
+ if (!symlink && current === content) {
370
+ return { path: target, action: "unchanged", removedSymlink: false };
371
+ }
372
+
373
+ await mkdir(dirname(target), { recursive: true });
374
+ const tmp = join(dirname(target), `.${basename(target)}.${process.pid}.tmp`);
375
+ try {
376
+ await writeFile(tmp, content);
377
+ await rename(tmp, target);
378
+ } catch (err) {
379
+ await rm(tmp, { force: true });
380
+ throw err;
381
+ }
382
+ return { path: target, action: symlink ? "removed-symlink" : "written", removedSymlink: symlink };
383
+ }
384
+
385
+ async function readTemplateJson(filePath, warn) {
386
+ const raw = await readFileMaybe(filePath);
387
+ if (raw === null) {
388
+ warn(`no ${filePath} template in the package — skipping`);
389
+ return null;
390
+ }
391
+ const parsed = tryParseJsonObject(raw);
392
+ if (parsed === null) throw new Error(`${filePath} is not a JSON object`);
393
+ return parsed;
394
+ }
395
+
396
+ async function readJsonObject(filePath, warn) {
397
+ const raw = await readFileMaybe(filePath);
398
+ if (raw === null) return {};
399
+ const parsed = tryParseJsonObject(raw);
400
+ if (parsed === null) {
401
+ warn(`${filePath} is not a JSON object — ignoring its contents`);
402
+ return {};
403
+ }
404
+ return parsed;
405
+ }
406
+
407
+ function tryParseJsonObject(raw) {
408
+ try {
409
+ const parsed = JSON.parse(raw);
410
+ return isPlainObject(parsed) ? parsed : null;
411
+ } catch {
412
+ return null;
413
+ }
414
+ }
415
+
416
+ function isPlainObject(value) {
417
+ return typeof value === "object" && value !== null && !Array.isArray(value);
418
+ }
419
+
420
+ async function readFileMaybe(filePath) {
421
+ try {
422
+ return await readFile(filePath, "utf-8");
423
+ } catch (err) {
424
+ if (err.code === "ENOENT") return null;
425
+ throw err;
426
+ }
427
+ }
428
+
429
+ async function isSymlink(filePath) {
430
+ try {
431
+ return (await lstat(filePath)).isSymbolicLink();
432
+ } catch {
433
+ return false;
434
+ }
435
+ }
436
+
180
437
  /**
181
438
  * Generate the Claude Code plugin subtree.
182
439
  *
183
440
  * @param {object} options
184
- * @param {string} [options.root] Package root (contains agents/, skills/, token-map.json, …).
185
- * Defaults to the repo containing this module.
186
- * @param {string} [options.output] Target directory (default: <root>/claude).
187
- * @param {boolean} [options.dryRun] Plan and report without writing anything.
188
- * @returns {{ output: string, agents: string[], natives: string[], skills: string[], hooks: boolean, dryRun: boolean }}
441
+ * @param {string} [options.root] Package root (contains agents/, skills/, token-map.json, …).
442
+ * Defaults to the repo containing this module.
443
+ * @param {string} [options.output] Plugin target dir. Defaults to ~/.agent-cortex/claude;
444
+ * the default install additionally writes the marketplace
445
+ * manifest at ~/.agent-cortex/.claude-plugin/marketplace.json.
446
+ * @param {string} [options.pluginRoot] Override plugin_root used for {{PATH:...}} resolution
447
+ * (default: token-map.json's claude value)
448
+ * @param {boolean} [options.dryRun] Plan and report without writing anything.
449
+ * @param {string} [options.settingsPath] Claude user-settings target
450
+ * (default ~/.claude/settings.json). Passing it
451
+ * explicitly materialises settings even with
452
+ * --output — the test/CI seam.
453
+ * @param {(msg: string) => void} [options.warn] Warning sink, also collected in `warnings`.
454
+ * @returns {{ output: string, marketplaceRoot: string|null, manifestPath: string|null,
455
+ * marketplaceManifest: object|null, agents: string[], natives: string[],
456
+ * skills: {names: string[], md: number, files: number, dir: string},
457
+ * hooks: boolean, hookFiles: string[], handAuthored: string[],
458
+ * settings: {path: string, action: string, removedSymlink: boolean}|null,
459
+ * warnings: string[], dryRun: boolean }}
189
460
  */
190
- export function installClaude({ root = DEFAULT_ROOT, output, dryRun = false } = {}) {
191
- const out = output ?? join(root, "claude");
461
+ export async function installClaude({ root = PACKAGE_ROOT, output, dryRun = false, pluginRoot, settingsPath, warn = DEFAULT_WARN } = {}) {
462
+ const tokenMap = await loadTokenMap(root);
463
+ if (typeof tokenMap.version === "number" && tokenMap.version > CONTRACT_VERSION) {
464
+ throw new Error(
465
+ `token-map.json version ${tokenMap.version} is newer than the contract version ${CONTRACT_VERSION} this installer implements — upgrade agent-cortex`,
466
+ );
467
+ }
192
468
 
193
- const agents = buildAgents(root);
194
- const natives = buildNatives(root, join(root, "agents-native"));
195
- const skills = buildSkills(root);
196
- const pluginJson = buildPluginJson(root);
469
+ const warnings = [];
470
+ const warnSink = (msg) => {
471
+ warnings.push(msg);
472
+ warn(msg);
473
+ };
474
+
475
+ const out = output ?? DEFAULT_OUTPUT;
476
+ // The marketplace manifest is part of the home install (default target) only:
477
+ // --output is generate-only (tests/CI), matching its no-registration semantics.
478
+ const isDefaultInstall = output === undefined;
479
+ const marketplaceRoot = isDefaultInstall ? dirname(out) : null;
480
+ const manifestPath = isDefaultInstall ? join(marketplaceRoot, ".claude-plugin", "marketplace.json") : null;
481
+
482
+ const rootPlugin = pluginRoot ?? tokenMap.paths.plugin_root?.[CLAUDE];
483
+
484
+ // Read hand-authored extras from the canonical claude-extras/ dir BEFORE
485
+ // the cleanup below (defensive: the output is normally ~/.agent-cortex/claude
486
+ // or a tmp dir, never claude-extras/, but clearing the output first would
487
+ // still destroy sources if they ever collided). They are re-copied fresh in
488
+ // the write phase. In dry-run nothing is cleaned or written.
489
+ const cleanable = ["agents", "skills", ".claude-plugin", "hooks", "scripts", "hooks.json", ".mcp.json"];
490
+ const handAuthored = await buildHandAuthored(root);
491
+
492
+ // Regenerate the plugin children (removes stale output from earlier flows,
493
+ // e.g. old symlinked skills). Must happen BEFORE the build helpers write.
494
+ if (!dryRun) {
495
+ for (const child of cleanable) {
496
+ await rm(join(out, child), { recursive: true, force: true });
497
+ }
498
+ await mkdir(out, { recursive: true });
499
+ }
500
+
501
+ // The default install owns ~/.claude/settings.json; --output is the
502
+ // generate-only form and leaves the user's Claude config alone. An explicit
503
+ // settingsPath requests the write regardless (the test/CI seam).
504
+ const settingsTarget = settingsPath ?? DEFAULT_SETTINGS_PATH;
505
+ const settings =
506
+ isDefaultInstall || settingsPath !== undefined
507
+ ? await installSettings({ root, target: settingsTarget, dryRun, warn: warnSink })
508
+ : null;
509
+
510
+ const agents = await buildAgents(root);
511
+ const natives = await buildNatives(root, join(root, "agents-native"));
512
+ const skillOut = join(out, "skills");
513
+ const skills = await buildSkills(root, skillOut, { dryRun, pluginRoot: rootPlugin, tokenMap, warn: warnSink });
514
+ const pluginJson = await buildPluginJson(root);
197
515
  const hooksSrc = join(root, "hooks", "claude", "hooks.json");
198
- const hooksJson = isFile(hooksSrc) ? readFileSync(hooksSrc, "utf-8") : null;
199
- const hookFiles = buildHookFiles(root);
516
+ const hooksJson = (await isFile(hooksSrc)) ? await readFile(hooksSrc, "utf-8") : null;
517
+ const hookFiles = await buildHookFiles(root);
518
+ const marketplaceManifest = isDefaultInstall ? await buildMarketplaceManifest(root, basename(out)) : null;
200
519
 
201
520
  const summary = {
202
521
  output: out,
522
+ marketplaceRoot,
523
+ manifestPath,
524
+ marketplaceManifest,
525
+ pluginVersion: JSON.parse(pluginJson).version,
203
526
  agents: agents.files.map((f) => f.file.replace(/\.md$/, "")),
204
527
  natives: natives.map((f) => f.file.replace(/\.md$/, "")),
205
- skills: skills.map((l) => l.name),
528
+ skills: { names: skills.names, md: skills.md, files: skills.files, dir: skillOut },
206
529
  hooks: hooksJson !== null,
207
530
  hookFiles: hookFiles.map((f) => f.rel),
531
+ handAuthored: handAuthored.map((f) => f.rel),
532
+ settings,
533
+ warnings,
208
534
  dryRun,
209
535
  };
210
536
 
@@ -214,160 +540,334 @@ export function installClaude({ root = DEFAULT_ROOT, output, dryRun = false } =
214
540
  console.log(`DRY-RUN — would generate into ${out}, no files written:`);
215
541
  console.log(` agents: ${summary.agents.join(", ")}`);
216
542
  if (summary.natives.length) console.log(` native agents: ${summary.natives.join(", ")}`);
217
- console.log(` skills: ${summary.skills.length} symlink(s)`);
543
+ console.log(` skills: ${summary.skills.names.length} copied → ${skillOut}/<name>/ (token-substituted)`);
218
544
  console.log(` .claude-plugin/plugin.json (version ${JSON.parse(pluginJson).version})`);
545
+ if (isDefaultInstall && manifestPath) {
546
+ console.log(` marketplace manifest → ${manifestPath} (plugin agent-cortex @ ./${basename(out)})`);
547
+ }
219
548
  if (summary.hooks) console.log(" hooks.json (from hooks/claude/hooks.json)");
220
549
  if (summary.hookFiles.length)
221
- console.log(` hook scripts: ${summary.hookFiles.join(", ")} (bundled into claude/hooks/)`);
550
+ console.log(` hook scripts: ${summary.hookFiles.join(", ")} (bundled into ${join(out, "hooks")}/)`);
551
+ if (summary.handAuthored.length)
552
+ console.log(` hand-authored files: ${summary.handAuthored.join(", ")} (copied from repo claude-extras/)`);
553
+ if (summary.settings) console.log(` settings → ${summary.settings.path} (${summary.settings.action})`);
222
554
  return summary;
223
555
  }
224
556
 
225
- // Generated subsets only — hand-authored support files (.mcp.json, scripts/)
226
- // under output are left untouched.
557
+ // Write phase — the cleanup before buildSkills already cleared stale children.
558
+ if (settings) console.log(`Materialised Claude settings → ${settings.path} (${settings.action})`);
227
559
  const agentOut = join(out, "agents");
228
- rmSync(agentOut, { recursive: true, force: true });
229
- mkdirSync(agentOut, { recursive: true });
560
+ await mkdir(agentOut, { recursive: true });
230
561
  for (const { file, content } of [...agents.files, ...natives]) {
231
- writeFileSync(join(agentOut, file), content);
562
+ await writeFile(join(agentOut, file), content);
232
563
  }
233
564
  console.log(`Generated ${summary.agents.length} Claude agent(s): ${summary.agents.join(", ")}`);
234
565
  if (summary.natives.length) console.log(`Copied ${summary.natives.length} native Claude agent(s): ${summary.natives.join(", ")}`);
235
566
 
236
- const skillOut = join(out, "skills");
237
- rmSync(skillOut, { recursive: true, force: true });
238
- mkdirSync(skillOut, { recursive: true });
239
- for (const { name, target } of skills) {
240
- symlinkSync(relative(skillOut, target), join(skillOut, name));
241
- }
242
- console.log(`Linked ${summary.skills.length} skill(s) into ${skillOut}`);
567
+ await mkdir(skillOut, { recursive: true });
568
+ console.log(
569
+ `Copied ${summary.skills.names.length} skill(s) → ${skillOut}/<name>/ (token-substituted, no symlinks)`,
570
+ );
243
571
 
244
- mkdirSync(join(out, ".claude-plugin"), { recursive: true });
245
- writeFileSync(join(out, ".claude-plugin", "plugin.json"), pluginJson);
572
+ await mkdir(join(out, ".claude-plugin"), { recursive: true });
573
+ await writeFile(join(out, ".claude-plugin", "plugin.json"), pluginJson);
246
574
  console.log(`Wrote ${join(out, ".claude-plugin", "plugin.json")}`);
247
575
 
576
+ if (isDefaultInstall && manifestPath && marketplaceManifest) {
577
+ const manifestDir = dirname(manifestPath);
578
+ await mkdir(manifestDir, { recursive: true });
579
+ await writeFile(manifestPath, JSON.stringify(marketplaceManifest, null, 2) + "\n");
580
+ console.log(`Wrote ${manifestPath} (plugin agent-cortex @ ./${basename(out)})`);
581
+ }
582
+
248
583
  if (summary.hooks) {
249
- writeFileSync(join(out, "hooks.json"), hooksJson);
584
+ await writeFile(join(out, "hooks.json"), hooksJson);
250
585
  console.log(`Wrote ${join(out, "hooks.json")} (from ${hooksSrc})`);
251
586
 
252
587
  if (hookFiles.length > 0) {
253
588
  const hookOut = join(out, "hooks");
254
- rmSync(hookOut, { recursive: true, force: true });
255
- mkdirSync(hookOut, { recursive: true });
589
+ await mkdir(hookOut, { recursive: true });
256
590
  for (const { rel, content } of hookFiles) {
257
591
  const dest = join(hookOut, rel);
258
- mkdirSync(dirname(dest), { recursive: true });
259
- writeFileSync(dest, content);
592
+ await mkdir(dirname(dest), { recursive: true });
593
+ await writeFile(dest, content);
260
594
  }
261
595
  console.log(`Bundled ${hookFiles.length} hook support file(s) into ${hookOut}`);
262
596
  }
263
597
  } else {
264
- console.log(`Skipped hooks.json — ${hooksSrc} not found (existing file left untouched)`);
598
+ console.log(`Skipped hooks.json — ${hooksSrc} not found`);
599
+ }
600
+
601
+ if (handAuthored.length > 0) {
602
+ for (const { rel, content, mode } of handAuthored) {
603
+ const dest = join(out, rel);
604
+ await mkdir(dirname(dest), { recursive: true });
605
+ await writeFile(dest, content);
606
+ if (mode !== undefined) await chmod(dest, mode);
607
+ }
608
+ console.log(`Copied hand-authored file(s): ${summary.handAuthored.join(", ")}`);
265
609
  }
266
610
 
267
611
  return summary;
268
612
  }
269
-
270
613
  // ─── Claude Code registration ────────────────────────────────────────────────
271
614
 
272
615
  // The `claude` CLI on PATH. Overridable in tests via the claudeBin option.
273
616
  const DEFAULT_CLAUDE_BIN = "claude";
274
617
 
618
+ /** Validate a marketplace manifest object; returns the marketplace and plugin
619
+ * names that `claude plugin` commands address. */
620
+ function parseMarketplaceManifest(manifest, label) {
621
+ const marketplace = manifest.name;
622
+ const plugin = manifest.plugins?.[0]?.name;
623
+ if (typeof marketplace !== "string" || typeof plugin !== "string") {
624
+ throw new Error(
625
+ `unparsable marketplace manifest (${label}): expected { name, plugins: [{ name }] }`,
626
+ );
627
+ }
628
+ return { marketplace, plugin };
629
+ }
630
+
275
631
  /**
276
- * Read the marketplace manifest that exposes this checkout's claude/ subtree
277
- * (`.claude-plugin/marketplace.json`); returns the marketplace and plugin
278
- * names that `claude plugin` commands address. Registration is only possible
279
- * from a checkout carrying the manifest.
632
+ * Read the marketplace manifest that exposes the plugin at install root
633
+ * (<root>/.claude-plugin/marketplace.json).
280
634
  */
281
- function readMarketplaceManifest(root) {
635
+ async function readMarketplaceManifest(root) {
282
636
  const manifestPath = join(root, ".claude-plugin", "marketplace.json");
283
637
  let manifest;
284
638
  try {
285
- manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
639
+ manifest = JSON.parse(await readFile(manifestPath, "utf-8"));
286
640
  } catch (err) {
287
641
  throw new Error(
288
642
  `no Claude marketplace manifest at ${manifestPath} (${err.message}) — ` +
289
- `"agent-cortex install claude" registers the plugin by pointing Claude Code at this checkout`,
643
+ `"agent-cortex install claude" registers the plugin by pointing Claude Code at the install root`,
290
644
  );
291
645
  }
292
- const marketplace = manifest.name;
293
- const plugin = manifest.plugins?.[0]?.name;
294
- if (typeof marketplace !== "string" || typeof plugin !== "string") {
295
- throw new Error(
296
- `unparsable marketplace manifest at ${manifestPath}: expected { name, plugins: [{ name }] }`,
297
- );
646
+ return { manifest, path: manifestPath };
647
+ }
648
+
649
+ /**
650
+ * Version of the materialised plugin (<root>/<source>/.claude-plugin/plugin.json,
651
+ * where <source> is the marketplace manifest's plugin source) — the signature a
652
+ * directory-source marketplace exposes to `claude plugin`. Read when the version
653
+ * is not passed explicitly; undefined when it cannot be determined.
654
+ */
655
+ async function readMaterialisedPluginVersion(root, manifest) {
656
+ const source = manifest.plugins?.[0]?.source;
657
+ if (typeof source !== "string") return undefined;
658
+ const pluginDir = source.startsWith(".") ? join(root, source) : resolve(source);
659
+ try {
660
+ return JSON.parse(await readFile(join(pluginDir, ".claude-plugin", "plugin.json"), "utf-8")).version;
661
+ } catch {
662
+ return undefined;
298
663
  }
299
- return { marketplace, plugin };
300
664
  }
301
665
 
302
- /** Run one claude plugin command, inheriting its output; throws on failure. */
666
+ /** Run one claude plugin command, inheriting its output; rejects on failure. */
303
667
  function runClaudeCommand(claudeBin, args) {
304
668
  const label = `${claudeBin} ${args.join(" ")}`;
305
- const result = spawnSync(claudeBin, args, { stdio: "inherit" });
306
- if (result.error) {
307
- throw new Error(`failed to run "${label}": ${result.error.message}`);
669
+ return new Promise((resolve, reject) => {
670
+ const child = spawn(claudeBin, args, { stdio: "inherit" });
671
+ child.on("error", (err) => reject(new Error(`failed to run "${label}": ${err.message}`)));
672
+ child.on("exit", (code, signal) => {
673
+ if (code === 0) resolve();
674
+ else if (code !== null) reject(new Error(`"${label}" exited ${code}`));
675
+ else reject(new Error(`"${label}" was terminated by ${signal}`));
676
+ });
677
+ });
678
+ }
679
+
680
+ /**
681
+ * Probe whether `claudeBin` supports the plugin CLI by running
682
+ * `claude plugin --help` with output suppressed. Resolves `{ ok: true }` when
683
+ * it exits 0, otherwise `{ ok: false, message }` naming the failure (missing
684
+ * binary vs missing subcommand) so registration can give targeted guidance.
685
+ */
686
+ function probePluginSupport(claudeBin) {
687
+ return new Promise((resolve) => {
688
+ const child = spawn(claudeBin, ["plugin", "--help"], { stdio: "ignore" });
689
+ child.on("error", (err) =>
690
+ resolve({
691
+ ok: false,
692
+ message: `cannot run "${claudeBin}" (${err.message}) — is Claude Code installed and on PATH?`,
693
+ }),
694
+ );
695
+ child.on("exit", (code) => {
696
+ if (code === 0) resolve({ ok: true });
697
+ else
698
+ resolve({
699
+ ok: false,
700
+ message:
701
+ `"${claudeBin}" lacks the "plugin" subcommand — its Claude Code build predates plugin support; ` +
702
+ `update to a build with the plugin CLI (v2+), e.g. "pnpm add -g @anthropic-ai/claude-code@latest", and re-run`,
703
+ });
704
+ });
705
+ });
706
+ }
707
+
708
+ /** Run a claude plugin command capturing stdout; null when the command fails. */
709
+ function queryClaudeCommand(claudeBin, args) {
710
+ const label = `${claudeBin} ${args.join(" ")}`;
711
+ return new Promise((resolve, reject) => {
712
+ const child = spawn(claudeBin, args, { stdio: ["ignore", "pipe", "ignore"] });
713
+ let stdout = "";
714
+ child.stdout.setEncoding("utf8");
715
+ child.stdout.on("data", (chunk) => (stdout += chunk));
716
+ child.on("error", (err) => reject(new Error(`failed to run "${label}": ${err.message}`)));
717
+ child.on("exit", (code) => {
718
+ if (code !== 0) resolve(null);
719
+ else resolve(stdout);
720
+ });
721
+ });
722
+ }
723
+
724
+ /**
725
+ * Whether `claude plugin marketplace list` already registers `marketplace`.
726
+ * Unparseable output counts as not-registered: the fallback `marketplace add`
727
+ * is itself idempotent (the CLI reports "already on disk", exit 0).
728
+ */
729
+ async function marketplaceRegistered(claudeBin, marketplace) {
730
+ const out = await queryClaudeCommand(claudeBin, ["plugin", "marketplace", "list", "--json"]);
731
+ if (out == null) return false;
732
+ try {
733
+ const entries = JSON.parse(out.trim());
734
+ return Array.isArray(entries) && entries.some((entry) => entry?.name === marketplace);
735
+ } catch {
736
+ return false;
308
737
  }
309
- if (result.status !== 0) {
310
- throw new Error(`"${label}" exited ${result.status}`);
738
+ }
739
+
740
+ /**
741
+ * Installed version of `pluginId` per `claude plugin list --json`; null when
742
+ * not installed or the output is unreadable (the caller falls back to the
743
+ * idempotent `plugin install`).
744
+ */
745
+ async function installedPluginVersion(claudeBin, pluginId) {
746
+ const out = await queryClaudeCommand(claudeBin, ["plugin", "list", "--json"]);
747
+ if (out == null) return null;
748
+ try {
749
+ const entries = JSON.parse(out.trim());
750
+ const entry = Array.isArray(entries) ? entries.find((e) => e?.id === pluginId) : undefined;
751
+ return typeof entry?.version === "string" ? entry.version : null;
752
+ } catch {
753
+ return null;
311
754
  }
312
755
  }
313
756
 
314
757
  /**
315
- * Register the freshly generated claude/ subtree with Claude Code by driving
316
- * the `claude plugin` CLI against this checkout as the marketplace source:
758
+ * Register the materialised plugin with Claude Code by driving the `claude
759
+ * plugin` CLI against the home install root as the marketplace source.
760
+ * Idempotent by STATE, not by exit code (the CLI exits 0 on re-add /
761
+ * re-install, saying "already on disk"/"already installed"):
317
762
  *
318
- * 1. `claude plugin marketplace add <root>` (idempotent: already on disk)
319
- * 2. `claude plugin install <plugin>@<market> -y` (idempotent: already installed)
320
- * 3. `claude plugin marketplace update <market>` (re-validate the directory source)
321
- * 4. `claude plugin update <plugin> -y` (refresh the cache on version bumps)
763
+ * `claude plugin marketplace list --json` → add the marketplace when
764
+ * missing, otherwise `marketplace update` refreshes the directory source
765
+ * (a re-run is the documented UPDATE path)
766
+ * `claude plugin list --json` → `plugin install` when not
767
+ * installed, `plugin update` when the materialised version is newer,
768
+ * nothing when the installed version already matches (a repeat install is
769
+ * a true no-op)
322
770
  *
323
- * Requires a Claude Code binary whose `plugin` subcommand exists (v2+); older
324
- * CLIs treat the args as a chat prompt, so support is probed first. In
325
- * --dry-run mode nothing is spawned — the commands are reported as a plan.
771
+ * Both reads degrade gracefully: unparseable output just runs the idempotent
772
+ * add/install (whose non-zero exits surface as genuine failures). The manifest
773
+ * and the materialised version come from <root>/.claude-plugin/marketplace.json
774
+ * and the plugin source's plugin.json unless passed explicitly via
775
+ * `manifest`/`pluginVersion` (installClaude hands both through so --dry-run
776
+ * never needs files on disk).
777
+ *
778
+ * When the `claude` CLI is unusable (missing on PATH, or a build predating the
779
+ * plugin subcommand), the installer warns loudly and prints the exact commands
780
+ * to complete registration manually, exiting non-zero only when
781
+ * `requireRegister` is set — the default is warn-only because the plugin is
782
+ * already materialised by then; the file side of the install succeeded.
326
783
  *
327
784
  * @param {object} options
328
- * @param {string} [options.root] Checkout root carrying .claude-plugin/marketplace.json
329
- * (defaults to the repo containing this module)
330
- * @param {string} [options.claudeBin] The `claude` executable (default: `claude` from PATH)
331
- * @param {boolean} [options.dryRun] Plan and report without running or writing
332
- * @returns {{ marketplace: string, plugin: string, commands: string[][], dryRun: boolean }}
785
+ * @param {string} [options.root] Marketplace root carrying .claude-plugin/…
786
+ * (defaults to the repo containing this module)
787
+ * @param {string} [options.claudeBin] The `claude` executable (default: `claude` from PATH)
788
+ * @param {boolean} [options.dryRun] Plan and report without running or writing
789
+ * @param {object} [options.manifest] Pre-parsed marketplace manifest (skips the file read)
790
+ * @param {string} [options.pluginVersion] Materialised plugin version (default: read from plugin.json)
791
+ * @param {boolean} [options.requireRegister] Exit non-zero when registration cannot run
792
+ * @param {(msg: string) => void} [options.warn] Warning sink (default: console.warn)
793
+ * @returns {{ marketplace: string, plugin: string, commands: string[][],
794
+ * dryRun: boolean, registered: boolean, skipped: string[] }}
333
795
  */
334
- export function registerClaude({ root = DEFAULT_ROOT, claudeBin = DEFAULT_CLAUDE_BIN, dryRun = false } = {}) {
335
- const { marketplace, plugin } = readMarketplaceManifest(root);
796
+ export async function registerClaude({
797
+ root = PACKAGE_ROOT,
798
+ claudeBin = DEFAULT_CLAUDE_BIN,
799
+ dryRun = false,
800
+ manifest,
801
+ pluginVersion,
802
+ requireRegister = false,
803
+ warn = DEFAULT_WARN,
804
+ } = {}) {
805
+ const { manifest: manifestObj, path: manifestPath } =
806
+ manifest !== undefined ? { manifest, path: null } : await readMarketplaceManifest(root);
807
+ const { marketplace, plugin } = parseMarketplaceManifest(manifestObj, manifestPath ?? "generated manifest");
336
808
  const pluginId = `${plugin}@${marketplace}`;
337
- const commands = [
338
- ["plugin", "marketplace", "add", resolve(root)],
339
- ["plugin", "install", pluginId, "-y"],
340
- ["plugin", "marketplace", "update", marketplace],
341
- ["plugin", "update", plugin, "-y"],
342
- ];
809
+ const version = pluginVersion ?? (await readMaterialisedPluginVersion(root, manifestObj));
810
+ const home = resolve(root);
343
811
 
344
- const summary = { marketplace, plugin, commands, dryRun };
812
+ const summary = { marketplace, plugin, commands: [], dryRun, registered: true, skipped: [] };
345
813
  console.log(`Registering plugin "${pluginId}" with Claude Code (via ${claudeBin})…`);
346
814
 
815
+ const manualCommands = [
816
+ `${claudeBin} plugin marketplace add ${home}`,
817
+ `${claudeBin} plugin install ${pluginId} -y`,
818
+ ];
819
+
347
820
  if (dryRun) {
348
- for (const args of commands) console.log(` would run: ${claudeBin} ${args.join(" ")}`);
821
+ console.log(" would query: claude plugin marketplace list --json (add vs marketplace update by state)");
822
+ console.log(" would query: claude plugin list --json (install vs plugin update by version)");
823
+ console.log(` would run: ${manualCommands[0]} (or "${claudeBin} plugin marketplace update ${marketplace}" when already registered)`);
824
+ console.log(` would run: ${manualCommands[1]} (or "${claudeBin} plugin update ${plugin}" when a newer version is materialised)`);
349
825
  console.log("(dry-run — no Claude Code state written)");
350
826
  return summary;
351
827
  }
352
828
 
353
- // Older Claude Code builds treat `claude plugin …` as a chat prompt; reject
354
- // them up-front instead of letting the registration appear to succeed.
355
- const probe = spawnSync(claudeBin, ["plugin", "--help"], { stdio: "ignore" });
356
- if (probe.error) {
357
- throw new Error(
358
- `cannot run "${claudeBin}" (${probe.error.message}) — is Claude Code installed and on PATH?`,
829
+ // Reject unusable CLIs up-front instead of letting registration appear to
830
+ // succeed: older Claude Code builds treat `claude plugin …` as a chat prompt.
831
+ const probe = await probePluginSupport(claudeBin);
832
+ if (!probe.ok) {
833
+ warn(
834
+ `Claude Code CLI "${claudeBin}" ${probe.message} — the plugin is materialised but NOT registered with Claude Code. ` +
835
+ `Complete registration manually with:\n` +
836
+ manualCommands.map((c) => ` ${c}`).join("\n"),
359
837
  );
838
+ if (requireRegister) {
839
+ throw new Error(
840
+ `registration required (--require-register) but the Claude Code CLI cannot drive it — run the commands above and re-run`,
841
+ );
842
+ }
843
+ summary.registered = false;
844
+ return summary;
360
845
  }
361
- if (probe.status !== 0) {
362
- throw new Error(
363
- `"${claudeBin}" lacks the "plugin" subcommand — its Claude Code build predates plugin support; ` +
364
- `update to a build with the plugin CLI (v2+), e.g. "pnpm add -g @anthropic-ai/claude-code@latest", and re-run`,
365
- );
846
+
847
+ // Decide the sequence from the live state; unreadable state falls back to
848
+ // the idempotent add/install (which the CLI itself short-circuits).
849
+ const marketPresent = await marketplaceRegistered(claudeBin, marketplace);
850
+ const installed = await installedPluginVersion(claudeBin, pluginId);
851
+
852
+ if (marketPresent) {
853
+ summary.commands.push(["plugin", "marketplace", "update", marketplace]);
854
+ } else {
855
+ summary.commands.push(["plugin", "marketplace", "add", home]);
856
+ }
857
+
858
+ if (installed == null) {
859
+ summary.commands.push(["plugin", "install", pluginId, "-y"]);
860
+ } else if (version != null && installed === version) {
861
+ summary.skipped.push(`plugin install ${pluginId} — already installed at ${installed}`);
862
+ summary.skipped.push(`plugin update ${plugin} — installed version matches the materialised version`);
863
+ } else {
864
+ summary.skipped.push(`plugin install ${pluginId} — already installed (updating ${installed} → ${version ?? "materialised"})`);
865
+ summary.commands.push(["plugin", "update", plugin, "-y"]);
366
866
  }
367
867
 
368
- for (const args of commands) {
868
+ for (const args of summary.commands) {
369
869
  console.log(` ${claudeBin} ${args.join(" ")}`);
370
- runClaudeCommand(claudeBin, args);
870
+ await runClaudeCommand(claudeBin, args);
371
871
  }
372
872
  console.log(
373
873
  `Installed — verify with "claude plugin list" / "claude plugin details ${plugin}" (restart Claude Code to apply).`,