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