@jaybeeuu/agent-cortex 1.39.0 → 1.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/style-versioning/SKILL.md +1 -1
- package/.github/workflows/ci.yml +47 -14
- package/AGENTS.md +19 -5
- package/CHANGELOG.md +12 -0
- package/README.md +111 -74
- package/agents/README.md +12 -11
- package/bin/agent-cortex.mjs +23 -10
- package/bin/installers/claude.mjs +518 -168
- package/bin/installers/copilot.mjs +22 -12
- package/bin/installers/pi.mjs +37 -34
- package/docs/claude-hooks.md +3 -3
- package/extensions/agent-modes/README.md +3 -2
- package/extensions/agent-modes/discover.test.ts +30 -7
- package/extensions/agent-modes/discover.ts +1 -1
- package/extensions/skill-stats/index.test.ts +3 -2
- package/lib/cli.mjs +22 -10
- package/package.json +4 -4
- package/plugin.json +1 -1
- package/scripts/build-copilot-agents.mjs +1 -1
- package/scripts/check-no-sync.mjs +49 -0
- package/scripts/lib/compose-agent.mjs +32 -14
- package/skills/engineering/grill-with-docs/SKILL.md +1 -1
- package/skills/workflow/hitl-collab/SKILL.md +1 -1
- package/test/claude-installer.test.mjs +199 -139
- package/test/cli.test.mjs +40 -30
- package/test/copilot-installer.test.mjs +92 -82
- package/test/helpers/fake-claude.mjs +152 -0
- package/test/installer.test.mjs +309 -57
- package/test/pi-installer.test.mjs +115 -99
- package/token-map.README.md +21 -12
- package/token-map.json +7 -6
- package/.claude-plugin/marketplace.json +0 -16
- package/claude/.claude-plugin/plugin.json +0 -11
- package/claude/agents/plan.md +0 -58
- package/claude/agents/ralph-plan.md +0 -121
- package/claude/agents/ralph.md +0 -193
- package/claude/agents/strategy.md +0 -111
- package/claude/hooks/scripts/notify.mjs +0 -54
- package/claude/hooks.json +0 -38
- /package/{claude → claude-extras}/.mcp.json +0 -0
- /package/{claude → claude-extras}/scripts/statusline-command.sh +0 -0
|
@@ -1,56 +1,82 @@
|
|
|
1
|
-
// Install-time generator + registrar for the Claude Code plugin.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// the
|
|
5
|
-
//
|
|
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); 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:
|
|
7
6
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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)
|
|
26
|
+
//
|
|
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.
|
|
33
|
+
//
|
|
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)
|
|
12
40
|
// node bin/agent-cortex.mjs install claude --output <dir>
|
|
13
|
-
// generates only — no
|
|
41
|
+
// generates only — no manifest, no registration
|
|
14
42
|
// node bin/agent-cortex.mjs install claude --dry-run
|
|
15
43
|
// plans generation + registration without writing
|
|
16
44
|
//
|
|
17
|
-
//
|
|
18
|
-
// marketplace
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
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.
|
|
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.
|
|
43
52
|
//
|
|
44
53
|
// Zero dependencies so it runs on the CI Node and local Node alike.
|
|
45
54
|
|
|
46
|
-
import {
|
|
47
|
-
import {
|
|
48
|
-
import { join, dirname,
|
|
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";
|
|
49
58
|
import { fileURLToPath } from "node:url";
|
|
50
|
-
import {
|
|
59
|
+
import { homedir } from "node:os";
|
|
60
|
+
import { composeAgent, loadTokenMap, substituteTokens } from "../../scripts/lib/compose-agent.mjs";
|
|
51
61
|
|
|
52
62
|
const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
|
|
53
|
-
const
|
|
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}`);
|
|
54
80
|
|
|
55
81
|
// Agents NOT transformed from the composable sources. `ralph` is authored natively for Claude
|
|
56
82
|
// instead (agents-native/ralph.md) — its event-driven orchestration can't be produced by
|
|
@@ -58,14 +84,22 @@ const DEFAULT_ROOT = join(MODULE_DIR, "..", "..");
|
|
|
58
84
|
const DEFER = new Set(["ralph"]);
|
|
59
85
|
|
|
60
86
|
// Ralph-coupled Copilot skills that don't apply to the lean Claude Ralph (and carry dead
|
|
61
|
-
// ~/.copilot orchestration paths). Not
|
|
87
|
+
// ~/.copilot orchestration paths). Not copied into the Claude plugin.
|
|
62
88
|
const SKILL_EXCLUDE = new Set(["ralph", "run-pipeline-stage"]);
|
|
63
89
|
|
|
64
90
|
const NAME_PREFIX = "agent-cortex:";
|
|
65
91
|
|
|
66
|
-
function isFile(p) {
|
|
92
|
+
async function isFile(p) {
|
|
67
93
|
try {
|
|
68
|
-
return
|
|
94
|
+
return (await stat(p)).isFile();
|
|
95
|
+
} catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function isDirectory(p) {
|
|
101
|
+
try {
|
|
102
|
+
return (await stat(p)).isDirectory();
|
|
69
103
|
} catch {
|
|
70
104
|
return false;
|
|
71
105
|
}
|
|
@@ -76,22 +110,24 @@ function byName(a, b) {
|
|
|
76
110
|
}
|
|
77
111
|
|
|
78
112
|
/** Compose agents from agents/<name>/; DEFERed agents come from agents-native/ instead. */
|
|
79
|
-
function buildAgents(root) {
|
|
113
|
+
async function buildAgents(root) {
|
|
80
114
|
const files = [];
|
|
81
115
|
const skipped = [];
|
|
82
|
-
const entries =
|
|
116
|
+
const entries = (await readdir(join(root, "agents"), { withFileTypes: true })).sort((a, b) =>
|
|
117
|
+
byName(a.name, b.name),
|
|
118
|
+
);
|
|
83
119
|
|
|
84
120
|
for (const entry of entries) {
|
|
85
121
|
if (!entry.isDirectory()) continue;
|
|
86
122
|
const dir = join(root, "agents", entry.name);
|
|
87
|
-
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;
|
|
88
124
|
|
|
89
125
|
if (DEFER.has(entry.name)) {
|
|
90
126
|
skipped.push(entry.name);
|
|
91
127
|
continue;
|
|
92
128
|
}
|
|
93
129
|
|
|
94
|
-
const fm = composeAgent(root, entry.name,
|
|
130
|
+
const fm = await composeAgent(root, entry.name, CLAUDE);
|
|
95
131
|
const slug = fm.name.startsWith(NAME_PREFIX) ? fm.name.slice(NAME_PREFIX.length) : fm.name;
|
|
96
132
|
const header =
|
|
97
133
|
`---\n` +
|
|
@@ -105,40 +141,78 @@ function buildAgents(root) {
|
|
|
105
141
|
return { files, skipped };
|
|
106
142
|
}
|
|
107
143
|
|
|
108
|
-
/** Copy hand-authored Claude-native agents into
|
|
109
|
-
function buildNatives(root, nativeSrc) {
|
|
144
|
+
/** Copy hand-authored Claude-native agents into agents/ verbatim. */
|
|
145
|
+
async function buildNatives(root, nativeSrc) {
|
|
110
146
|
const files = [];
|
|
111
|
-
if (!
|
|
112
|
-
|
|
113
|
-
|
|
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") });
|
|
114
151
|
}
|
|
115
152
|
return files;
|
|
116
153
|
}
|
|
117
154
|
|
|
118
|
-
/**
|
|
119
|
-
|
|
120
|
-
|
|
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 }) {
|
|
121
163
|
const seen = new Map();
|
|
122
|
-
|
|
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)) {
|
|
123
171
|
const groupDir = join(root, "skills", group);
|
|
124
|
-
if (!
|
|
125
|
-
for (const name of
|
|
172
|
+
if (!(await isDirectory(groupDir))) continue;
|
|
173
|
+
for (const name of (await readdir(groupDir)).sort(byName)) {
|
|
126
174
|
const skillDir = join(groupDir, name);
|
|
127
|
-
if (!isFile(join(skillDir, "SKILL.md"))) continue;
|
|
175
|
+
if (!(await isFile(join(skillDir, "SKILL.md")))) continue;
|
|
128
176
|
if (SKILL_EXCLUDE.has(name)) continue;
|
|
129
177
|
if (seen.has(name)) {
|
|
130
178
|
throw new Error(`duplicate skill name "${name}" (${seen.get(name)} and ${group}) — names must be unique when flattened`);
|
|
131
179
|
}
|
|
132
180
|
seen.set(name, group);
|
|
133
|
-
|
|
181
|
+
names.push(name);
|
|
182
|
+
const stats = await copyTree(skillDir, join(skillOut, name), transform, dryRun);
|
|
183
|
+
md += stats.md;
|
|
184
|
+
files += stats.files;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
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);
|
|
134
208
|
}
|
|
135
209
|
}
|
|
136
|
-
return
|
|
210
|
+
return { md, files };
|
|
137
211
|
}
|
|
138
212
|
|
|
139
213
|
/** plugin.json manifest; version tracks the package so it can never go stale. */
|
|
140
|
-
function buildPluginJson(root) {
|
|
141
|
-
const pkg = JSON.parse(
|
|
214
|
+
async function buildPluginJson(root) {
|
|
215
|
+
const pkg = JSON.parse(await readFile(join(root, "package.json"), "utf-8"));
|
|
142
216
|
return (
|
|
143
217
|
JSON.stringify(
|
|
144
218
|
{
|
|
@@ -156,55 +230,159 @@ function buildPluginJson(root) {
|
|
|
156
230
|
);
|
|
157
231
|
}
|
|
158
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
|
+
|
|
159
256
|
/**
|
|
160
257
|
* Hook support files (hooks/claude/ except hooks.json) bundled into
|
|
161
|
-
*
|
|
162
|
-
* hooks.json itself is special-cased to the plugin root (
|
|
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).
|
|
163
260
|
*/
|
|
164
|
-
function buildHookFiles(root) {
|
|
261
|
+
async function buildHookFiles(root) {
|
|
165
262
|
const files = [];
|
|
166
263
|
const srcDir = join(root, "hooks", "claude");
|
|
167
|
-
if (!
|
|
168
|
-
const walk = (dir, rel) => {
|
|
169
|
-
|
|
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) {
|
|
170
269
|
const full = join(dir, entry.name);
|
|
171
|
-
if (entry.isDirectory()) walk(full, join(rel, entry.name));
|
|
270
|
+
if (entry.isDirectory()) await walk(full, join(rel, entry.name));
|
|
172
271
|
else if (entry.name !== "hooks.json")
|
|
173
|
-
files.push({ rel: join(rel, entry.name), content:
|
|
272
|
+
files.push({ rel: join(rel, entry.name), content: await readFile(full, "utf-8") });
|
|
174
273
|
}
|
|
175
274
|
};
|
|
176
|
-
walk(srcDir, "");
|
|
275
|
+
await walk(srcDir, "");
|
|
177
276
|
return files;
|
|
178
277
|
}
|
|
179
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
|
+
|
|
180
304
|
/**
|
|
181
305
|
* Generate the Claude Code plugin subtree.
|
|
182
306
|
*
|
|
183
307
|
* @param {object} options
|
|
184
|
-
* @param {string} [options.root]
|
|
185
|
-
*
|
|
186
|
-
* @param {string} [options.output]
|
|
187
|
-
*
|
|
188
|
-
*
|
|
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 }}
|
|
189
322
|
*/
|
|
190
|
-
export function installClaude({ root =
|
|
191
|
-
const
|
|
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);
|
|
353
|
+
|
|
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
|
+
}
|
|
192
362
|
|
|
193
|
-
const agents = buildAgents(root);
|
|
194
|
-
const natives = buildNatives(root, join(root, "agents-native"));
|
|
195
|
-
const
|
|
196
|
-
const
|
|
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);
|
|
197
368
|
const hooksSrc = join(root, "hooks", "claude", "hooks.json");
|
|
198
|
-
const hooksJson = isFile(hooksSrc) ?
|
|
199
|
-
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;
|
|
200
372
|
|
|
201
373
|
const summary = {
|
|
202
374
|
output: out,
|
|
375
|
+
marketplaceRoot,
|
|
376
|
+
manifestPath,
|
|
377
|
+
marketplaceManifest,
|
|
378
|
+
pluginVersion: JSON.parse(pluginJson).version,
|
|
203
379
|
agents: agents.files.map((f) => f.file.replace(/\.md$/, "")),
|
|
204
380
|
natives: natives.map((f) => f.file.replace(/\.md$/, "")),
|
|
205
|
-
skills: skills.
|
|
381
|
+
skills: { names: skills.names, md: skills.md, files: skills.files, dir: skillOut },
|
|
206
382
|
hooks: hooksJson !== null,
|
|
207
383
|
hookFiles: hookFiles.map((f) => f.rel),
|
|
384
|
+
handAuthored: handAuthored.map((f) => f.rel),
|
|
385
|
+
warnings,
|
|
208
386
|
dryRun,
|
|
209
387
|
};
|
|
210
388
|
|
|
@@ -214,160 +392,332 @@ export function installClaude({ root = DEFAULT_ROOT, output, dryRun = false } =
|
|
|
214
392
|
console.log(`DRY-RUN — would generate into ${out}, no files written:`);
|
|
215
393
|
console.log(` agents: ${summary.agents.join(", ")}`);
|
|
216
394
|
if (summary.natives.length) console.log(` native agents: ${summary.natives.join(", ")}`);
|
|
217
|
-
console.log(` skills: ${summary.skills.length}
|
|
395
|
+
console.log(` skills: ${summary.skills.names.length} copied → ${skillOut}/<name>/ (token-substituted)`);
|
|
218
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
|
+
}
|
|
219
400
|
if (summary.hooks) console.log(" hooks.json (from hooks/claude/hooks.json)");
|
|
220
401
|
if (summary.hookFiles.length)
|
|
221
|
-
console.log(` hook scripts: ${summary.hookFiles.join(", ")} (bundled into
|
|
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/)`);
|
|
222
405
|
return summary;
|
|
223
406
|
}
|
|
224
407
|
|
|
225
|
-
//
|
|
226
|
-
// under output are left untouched.
|
|
408
|
+
// Write phase — the cleanup before buildSkills already cleared stale children.
|
|
227
409
|
const agentOut = join(out, "agents");
|
|
228
|
-
|
|
229
|
-
mkdirSync(agentOut, { recursive: true });
|
|
410
|
+
await mkdir(agentOut, { recursive: true });
|
|
230
411
|
for (const { file, content } of [...agents.files, ...natives]) {
|
|
231
|
-
|
|
412
|
+
await writeFile(join(agentOut, file), content);
|
|
232
413
|
}
|
|
233
414
|
console.log(`Generated ${summary.agents.length} Claude agent(s): ${summary.agents.join(", ")}`);
|
|
234
415
|
if (summary.natives.length) console.log(`Copied ${summary.natives.length} native Claude agent(s): ${summary.natives.join(", ")}`);
|
|
235
416
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
symlinkSync(relative(skillOut, target), join(skillOut, name));
|
|
241
|
-
}
|
|
242
|
-
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
|
+
);
|
|
243
421
|
|
|
244
|
-
|
|
245
|
-
|
|
422
|
+
await mkdir(join(out, ".claude-plugin"), { recursive: true });
|
|
423
|
+
await writeFile(join(out, ".claude-plugin", "plugin.json"), pluginJson);
|
|
246
424
|
console.log(`Wrote ${join(out, ".claude-plugin", "plugin.json")}`);
|
|
247
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
|
+
|
|
248
433
|
if (summary.hooks) {
|
|
249
|
-
|
|
434
|
+
await writeFile(join(out, "hooks.json"), hooksJson);
|
|
250
435
|
console.log(`Wrote ${join(out, "hooks.json")} (from ${hooksSrc})`);
|
|
251
436
|
|
|
252
437
|
if (hookFiles.length > 0) {
|
|
253
438
|
const hookOut = join(out, "hooks");
|
|
254
|
-
|
|
255
|
-
mkdirSync(hookOut, { recursive: true });
|
|
439
|
+
await mkdir(hookOut, { recursive: true });
|
|
256
440
|
for (const { rel, content } of hookFiles) {
|
|
257
441
|
const dest = join(hookOut, rel);
|
|
258
|
-
|
|
259
|
-
|
|
442
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
443
|
+
await writeFile(dest, content);
|
|
260
444
|
}
|
|
261
445
|
console.log(`Bundled ${hookFiles.length} hook support file(s) into ${hookOut}`);
|
|
262
446
|
}
|
|
263
447
|
} else {
|
|
264
|
-
console.log(`Skipped hooks.json — ${hooksSrc} not found
|
|
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(", ")}`);
|
|
265
459
|
}
|
|
266
460
|
|
|
267
461
|
return summary;
|
|
268
462
|
}
|
|
269
|
-
|
|
270
463
|
// ─── Claude Code registration ────────────────────────────────────────────────
|
|
271
464
|
|
|
272
465
|
// The `claude` CLI on PATH. Overridable in tests via the claudeBin option.
|
|
273
466
|
const DEFAULT_CLAUDE_BIN = "claude";
|
|
274
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
|
+
|
|
275
481
|
/**
|
|
276
|
-
* Read the marketplace manifest that exposes
|
|
277
|
-
* (
|
|
278
|
-
* names that `claude plugin` commands address. Registration is only possible
|
|
279
|
-
* from a checkout carrying the manifest.
|
|
482
|
+
* Read the marketplace manifest that exposes the plugin at install root
|
|
483
|
+
* (<root>/.claude-plugin/marketplace.json).
|
|
280
484
|
*/
|
|
281
|
-
function readMarketplaceManifest(root) {
|
|
485
|
+
async function readMarketplaceManifest(root) {
|
|
282
486
|
const manifestPath = join(root, ".claude-plugin", "marketplace.json");
|
|
283
487
|
let manifest;
|
|
284
488
|
try {
|
|
285
|
-
manifest = JSON.parse(
|
|
489
|
+
manifest = JSON.parse(await readFile(manifestPath, "utf-8"));
|
|
286
490
|
} catch (err) {
|
|
287
491
|
throw new Error(
|
|
288
492
|
`no Claude marketplace manifest at ${manifestPath} (${err.message}) — ` +
|
|
289
|
-
`"agent-cortex install claude" registers the plugin by pointing Claude Code at
|
|
493
|
+
`"agent-cortex install claude" registers the plugin by pointing Claude Code at the install root`,
|
|
290
494
|
);
|
|
291
495
|
}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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;
|
|
298
513
|
}
|
|
299
|
-
return { marketplace, plugin };
|
|
300
514
|
}
|
|
301
515
|
|
|
302
|
-
/** Run one claude plugin command, inheriting its output;
|
|
516
|
+
/** Run one claude plugin command, inheriting its output; rejects on failure. */
|
|
303
517
|
function runClaudeCommand(claudeBin, args) {
|
|
304
518
|
const label = `${claudeBin} ${args.join(" ")}`;
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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;
|
|
308
587
|
}
|
|
309
|
-
|
|
310
|
-
|
|
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;
|
|
311
604
|
}
|
|
312
605
|
}
|
|
313
606
|
|
|
314
607
|
/**
|
|
315
|
-
* Register the
|
|
316
|
-
*
|
|
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)
|
|
317
620
|
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
*
|
|
321
|
-
*
|
|
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).
|
|
322
627
|
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
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.
|
|
326
633
|
*
|
|
327
634
|
* @param {object} options
|
|
328
|
-
* @param {string} [options.root]
|
|
329
|
-
*
|
|
330
|
-
* @param {string} [options.claudeBin]
|
|
331
|
-
* @param {boolean} [options.dryRun]
|
|
332
|
-
* @
|
|
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[] }}
|
|
333
645
|
*/
|
|
334
|
-
export function registerClaude({
|
|
335
|
-
|
|
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");
|
|
336
658
|
const pluginId = `${plugin}@${marketplace}`;
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
["plugin", "install", pluginId, "-y"],
|
|
340
|
-
["plugin", "marketplace", "update", marketplace],
|
|
341
|
-
["plugin", "update", plugin, "-y"],
|
|
342
|
-
];
|
|
659
|
+
const version = pluginVersion ?? (await readMaterialisedPluginVersion(root, manifestObj));
|
|
660
|
+
const home = resolve(root);
|
|
343
661
|
|
|
344
|
-
const summary = { marketplace, plugin, commands, dryRun };
|
|
662
|
+
const summary = { marketplace, plugin, commands: [], dryRun, registered: true, skipped: [] };
|
|
345
663
|
console.log(`Registering plugin "${pluginId}" with Claude Code (via ${claudeBin})…`);
|
|
346
664
|
|
|
665
|
+
const manualCommands = [
|
|
666
|
+
`${claudeBin} plugin marketplace add ${home}`,
|
|
667
|
+
`${claudeBin} plugin install ${pluginId} -y`,
|
|
668
|
+
];
|
|
669
|
+
|
|
347
670
|
if (dryRun) {
|
|
348
|
-
|
|
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)`);
|
|
349
675
|
console.log("(dry-run — no Claude Code state written)");
|
|
350
676
|
return summary;
|
|
351
677
|
}
|
|
352
678
|
|
|
353
|
-
//
|
|
354
|
-
//
|
|
355
|
-
const probe =
|
|
356
|
-
if (probe.
|
|
357
|
-
|
|
358
|
-
`
|
|
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"),
|
|
359
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;
|
|
360
695
|
}
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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]);
|
|
706
|
+
}
|
|
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"]);
|
|
366
716
|
}
|
|
367
717
|
|
|
368
|
-
for (const args of commands) {
|
|
718
|
+
for (const args of summary.commands) {
|
|
369
719
|
console.log(` ${claudeBin} ${args.join(" ")}`);
|
|
370
|
-
runClaudeCommand(claudeBin, args);
|
|
720
|
+
await runClaudeCommand(claudeBin, args);
|
|
371
721
|
}
|
|
372
722
|
console.log(
|
|
373
723
|
`Installed — verify with "claude plugin list" / "claude plugin details ${plugin}" (restart Claude Code to apply).`,
|