buddy-agent-harness 0.7.0 → 0.8.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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/README.md +9 -1
- package/dist/{cli-Bvnq8zOL.mjs → cli-4ekejn5W.mjs} +616 -3
- package/dist/cli-4ekejn5W.mjs.map +1 -0
- package/dist/cli.d.mts +1 -2
- package/dist/cli.d.mts.map +1 -1
- package/dist/cli.mjs +1 -1
- package/dist/index.d.mts +17 -17
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/package.json +4 -4
- package/plugin.json +1 -1
- package/skills/doctor/SKILL.md +1 -1
- package/skills/enhance/references/delegation.md +3 -1
- package/skills/init/SKILL.md +1 -1
- package/src/cli.ts +2 -0
- package/src/dep-plugins/dep-plugins.command.ts +168 -0
- package/src/dep-plugins/dep-plugins.ts +131 -0
- package/src/dep-plugins/generate.ts +115 -0
- package/src/dep-plugins/harness-plugins.ts +232 -0
- package/src/dep-plugins/reconcile.ts +132 -0
- package/src/dep-plugins/resolve.ts +117 -0
- package/dist/cli-Bvnq8zOL.mjs.map +0 -1
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ In Claude Code, add the [cyberplace](https://github.com/cyberuni/cyberplace) mar
|
|
|
18
18
|
|
|
19
19
|
## CLI
|
|
20
20
|
|
|
21
|
-
`init` runs behind the `init` skill, which sorts the configuration you already wrote before the command links anything. Install the package alongside `repobuddy` to mount
|
|
21
|
+
`init` runs behind the `init` skill, which sorts the configuration you already wrote before the command links anything. Install the package alongside `repobuddy` to mount every command on `buddy`, as `buddy agent-harness doctor`, `buddy agent-harness init`, and `buddy agent-harness dep-plugins`.
|
|
22
22
|
|
|
23
23
|
The repository root's `AGENTS.md` and `.agents/` tree are canonical: `.agents/AGENTS.md` holds shared behavior, `.agents/skills/**/SKILL.md` holds capabilities, and separately named files hold tool settings. The active harness is enabled by default; explicit user preferences add others. The command preserves user-authored configuration and projects only supported mappings; it records nothing on disk.
|
|
24
24
|
|
|
@@ -30,4 +30,12 @@ The repository root's `AGENTS.md` and `.agents/` tree are canonical: `.agents/AG
|
|
|
30
30
|
npx -y buddy-agent-harness doctor
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
+
`dep-plugins` derives a marketplace catalog for plugins shipped by this repository's own dependencies, so a harness can install them — reaching a whole plugin (agents, commands, hooks, MCP servers) rather than only its skills:
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
npx -y buddy-agent-harness dep-plugins
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
It writes the catalog, then reports what each detected harness needs to match it and the command that does it. It never touches harness state itself. Claude Code and Codex install from this catalog; Copilot CLI rejects an npm marketplace source, and Cursor exposes no plugin subcommand from a terminal.
|
|
40
|
+
|
|
33
41
|
It never repairs anything and always exits `0`; each finding names the `init` command that fixes it.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
|
|
2
3
|
import { cli, command, exitCodes, z } from "clibuilder";
|
|
3
4
|
import { homedir, platform } from "node:os";
|
|
4
5
|
import { dirname, join, posix, relative, resolve, sep } from "node:path";
|
|
@@ -71,6 +72,618 @@ function binPath(home, executable) {
|
|
|
71
72
|
return home && executable.startsWith(home + sep) ? `~${executable.slice(home.length)}` : executable;
|
|
72
73
|
}
|
|
73
74
|
//#endregion
|
|
75
|
+
//#region src/dep-plugins/dep-plugins.ts
|
|
76
|
+
/**
|
|
77
|
+
* Marketplace names generic enough that two repositories are likely to pick the same one. The
|
|
78
|
+
* collision is silent and costly: registering a second marketplace under a name already in use
|
|
79
|
+
* replaces the first, and the install that follows reports "already installed" and does nothing —
|
|
80
|
+
* so the repository silently runs the other one's plugin versions.
|
|
81
|
+
*/
|
|
82
|
+
const GENERIC_NAMES = /* @__PURE__ */ new Set([
|
|
83
|
+
"deps",
|
|
84
|
+
"dependencies",
|
|
85
|
+
"local",
|
|
86
|
+
"plugins",
|
|
87
|
+
"marketplace",
|
|
88
|
+
"plugin",
|
|
89
|
+
"default"
|
|
90
|
+
]);
|
|
91
|
+
function isGenericName(name) {
|
|
92
|
+
return GENERIC_NAMES.has(name.toLowerCase());
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* A marketplace name derived from the consuming package's own name, so two repositories do not
|
|
96
|
+
* collide by default. A scope is folded into the name rather than dropped (`@acme/web` → `acme-web`)
|
|
97
|
+
* because the unscoped half is the part most likely to repeat across organizations.
|
|
98
|
+
*/
|
|
99
|
+
function marketplaceName(packageName) {
|
|
100
|
+
return packageName.replace(/^@/, "").replace(/\//g, "-").replace(/[^a-zA-Z0-9-_.]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") || "agent-plugins";
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Dependency names a repository declares, both runtime and development.
|
|
104
|
+
*
|
|
105
|
+
* Development dependencies are included on purpose: a plugin that supplies a project's testing or
|
|
106
|
+
* release workflow is a development dependency, and excluding them would drop most of the cases this
|
|
107
|
+
* exists for. Transitive dependencies are never included — see `resolve.ts`.
|
|
108
|
+
*/
|
|
109
|
+
function declaredDependencies(manifest) {
|
|
110
|
+
if (!manifest || typeof manifest !== "object") return [];
|
|
111
|
+
const record = manifest;
|
|
112
|
+
const names = /* @__PURE__ */ new Set();
|
|
113
|
+
for (const field of ["dependencies", "devDependencies"]) {
|
|
114
|
+
const group = record[field];
|
|
115
|
+
if (!group || typeof group !== "object") continue;
|
|
116
|
+
for (const name of Object.keys(group)) names.add(name);
|
|
117
|
+
}
|
|
118
|
+
return [...names].sort();
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* The catalog is derived in full on every run and never merged into what is already on disk. That is
|
|
122
|
+
* what makes the three cases a consumer cares about — a plugin added, a plugin removed, a version
|
|
123
|
+
* moved — fall out of regeneration instead of needing reconciliation logic of their own.
|
|
124
|
+
*
|
|
125
|
+
* Entries are sorted by name so that a regeneration whose inputs did not change produces a
|
|
126
|
+
* byte-identical file, which is what lets a check mode compare rather than interpret.
|
|
127
|
+
*/
|
|
128
|
+
function buildCatalog({ name, owner, plugins }) {
|
|
129
|
+
const entries = [...plugins].sort((a, b) => a.plugin.localeCompare(b.plugin)).map(({ plugin, package: pkg, version, description }) => {
|
|
130
|
+
const source = {
|
|
131
|
+
source: "npm",
|
|
132
|
+
package: pkg,
|
|
133
|
+
version
|
|
134
|
+
};
|
|
135
|
+
return description ? {
|
|
136
|
+
name: plugin,
|
|
137
|
+
source,
|
|
138
|
+
description
|
|
139
|
+
} : {
|
|
140
|
+
name: plugin,
|
|
141
|
+
source
|
|
142
|
+
};
|
|
143
|
+
});
|
|
144
|
+
return {
|
|
145
|
+
name,
|
|
146
|
+
owner: { name: owner },
|
|
147
|
+
description: "Plugins provided by this repository’s dependencies. Generated — do not edit.",
|
|
148
|
+
plugins: entries
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/** Serialized the way the repository formats JSON, with the trailing newline a text file needs. */
|
|
152
|
+
function serializeCatalog(catalog) {
|
|
153
|
+
return `${JSON.stringify(catalog, null, " ")}\n`;
|
|
154
|
+
}
|
|
155
|
+
//#endregion
|
|
156
|
+
//#region src/dep-plugins/resolve.ts
|
|
157
|
+
/**
|
|
158
|
+
* Locating a declared dependency on disk, and reading the plugin manifest it may ship.
|
|
159
|
+
*
|
|
160
|
+
* Resolution rather than scanning. Walking `node_modules` finds transitive dependencies nobody
|
|
161
|
+
* vetted, and a skill or plugin is instruction text that steers an agent — an unvetted transitive
|
|
162
|
+
* dependency is a supply-chain surface, not a convenience. Asking the module resolver where a
|
|
163
|
+
* *declared* dependency lives answers the question that was actually asked, and it is correct under
|
|
164
|
+
* npm, pnpm, yarn with the node-modules linker, and bun alike, because all four put a real directory
|
|
165
|
+
* on disk. Only Yarn PnP does not, and it is reported rather than guessed at.
|
|
166
|
+
*/
|
|
167
|
+
function readJson$1(path) {
|
|
168
|
+
try {
|
|
169
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
170
|
+
} catch {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Every ancestor `node_modules/<pkg>` from `startDir` upward. The fallback path only — see
|
|
176
|
+
* {@link packageDir}.
|
|
177
|
+
*/
|
|
178
|
+
function ancestorCandidates(startDir, pkg) {
|
|
179
|
+
const segments = pkg.split("/");
|
|
180
|
+
const candidates = [];
|
|
181
|
+
let dir = startDir;
|
|
182
|
+
for (;;) {
|
|
183
|
+
candidates.push(join(dir, "node_modules", ...segments));
|
|
184
|
+
const parent = dirname(dir);
|
|
185
|
+
if (parent === dir) break;
|
|
186
|
+
dir = parent;
|
|
187
|
+
}
|
|
188
|
+
return candidates;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* The directory a declared dependency was installed into, or `undefined`.
|
|
192
|
+
*
|
|
193
|
+
* Two mechanisms, in order, because neither alone is sufficient:
|
|
194
|
+
*
|
|
195
|
+
* 1. **`require.resolve('<pkg>/package.json')`, anchored at the declaring manifest.** The anchor
|
|
196
|
+
* matters: under pnpm's isolated store only the package that declares a dependency can resolve it,
|
|
197
|
+
* so resolving from a workspace root would miss what a workspace member declared.
|
|
198
|
+
* 2. **A walk up `node_modules`.** Roughly one package in seven declares an `exports` map without a
|
|
199
|
+
* `./package.json` entry, and those refuse the first mechanism with `ERR_PACKAGE_PATH_NOT_EXPORTED`
|
|
200
|
+
* however plainly they are installed. Resolving a deeper file instead is not an escape — the same
|
|
201
|
+
* map blocks that too.
|
|
202
|
+
*/
|
|
203
|
+
function packageDir(pkg, fromManifest) {
|
|
204
|
+
try {
|
|
205
|
+
const require = createRequire(fromManifest);
|
|
206
|
+
return dirname(require.resolve(`${pkg}/package.json`));
|
|
207
|
+
} catch {}
|
|
208
|
+
for (const candidate of ancestorCandidates(dirname(fromManifest), pkg)) if (existsSync(join(candidate, "package.json"))) return candidate;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Reads the plugin a dependency ships, if it ships one.
|
|
212
|
+
*
|
|
213
|
+
* The version comes from the dependency's own `package.json` rather than from a lockfile. It is the
|
|
214
|
+
* same number — the version actually installed — and reading it needs no knowledge of which of the
|
|
215
|
+
* four lockfile formats this repository uses.
|
|
216
|
+
*
|
|
217
|
+
* A manifest at the package root is what the Agent Plugins Specification pins, and it is the form a
|
|
218
|
+
* harness accepts as a marketplace source without any vendor-specific manifest alongside it.
|
|
219
|
+
*/
|
|
220
|
+
function readDepPlugin(pkg, fromManifest) {
|
|
221
|
+
const dir = packageDir(pkg, fromManifest);
|
|
222
|
+
if (!dir) return {
|
|
223
|
+
ok: false,
|
|
224
|
+
package: pkg,
|
|
225
|
+
reason: "unresolved"
|
|
226
|
+
};
|
|
227
|
+
const manifest = readJson$1(join(dir, "plugin.json"));
|
|
228
|
+
if (!manifest || typeof manifest !== "object") return {
|
|
229
|
+
ok: false,
|
|
230
|
+
package: pkg,
|
|
231
|
+
reason: "no-manifest"
|
|
232
|
+
};
|
|
233
|
+
const record = manifest;
|
|
234
|
+
const name = typeof record["name"] === "string" ? record["name"] : void 0;
|
|
235
|
+
if (!name) return {
|
|
236
|
+
ok: false,
|
|
237
|
+
package: pkg,
|
|
238
|
+
reason: "unnamed"
|
|
239
|
+
};
|
|
240
|
+
const installed = readJson$1(join(dir, "package.json"));
|
|
241
|
+
const version = typeof installed?.version === "string" ? installed.version : void 0;
|
|
242
|
+
if (!version) return {
|
|
243
|
+
ok: false,
|
|
244
|
+
package: pkg,
|
|
245
|
+
reason: "no-manifest"
|
|
246
|
+
};
|
|
247
|
+
const description = typeof record["description"] === "string" ? record["description"] : void 0;
|
|
248
|
+
return {
|
|
249
|
+
ok: true,
|
|
250
|
+
plugin: description ? {
|
|
251
|
+
package: pkg,
|
|
252
|
+
plugin: name,
|
|
253
|
+
version,
|
|
254
|
+
description
|
|
255
|
+
} : {
|
|
256
|
+
package: pkg,
|
|
257
|
+
plugin: name,
|
|
258
|
+
version
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* True when the process is running under Yarn's Plug'n'Play resolver, where packages live inside zip
|
|
264
|
+
* archives rather than as directories. A marketplace source must be a real directory and an npm
|
|
265
|
+
* source must be installable, so a PnP project needs `nodeLinker: node-modules` or the affected
|
|
266
|
+
* dependencies unplugged. Detected rather than worked around, so the report can say so.
|
|
267
|
+
*/
|
|
268
|
+
function isPnp() {
|
|
269
|
+
return Boolean(process.versions["pnp"]);
|
|
270
|
+
}
|
|
271
|
+
//#endregion
|
|
272
|
+
//#region src/dep-plugins/generate.ts
|
|
273
|
+
/**
|
|
274
|
+
* Deriving a repository's dependency-plugin catalog, and deciding whether what is on disk matches.
|
|
275
|
+
*/
|
|
276
|
+
/**
|
|
277
|
+
* The catalog's home, relative to the repository root.
|
|
278
|
+
*
|
|
279
|
+
* Under this tool's own directory, beside the other files it owns there — the same place as
|
|
280
|
+
* `mcp.toml` and `mcp.projected.json`, and the same shape as `.agents/repobuddy/` and
|
|
281
|
+
* `.agents/cyberlegion/`. A directory named for what it holds rather than for who writes it would be
|
|
282
|
+
* a second convention in a tree that already has one.
|
|
283
|
+
*
|
|
284
|
+
* Not the repository root's `.claude-plugin/`, which is where a repository publishes its *own*
|
|
285
|
+
* plugins for its *own* consumers. Merging the two would leak every dependency-provided plugin into
|
|
286
|
+
* what a consumer sees on adding the published marketplace, and no harness has a notion of a private
|
|
287
|
+
* catalog entry to prevent that. Being a separate directory also makes this one a marketplace root
|
|
288
|
+
* in its own right, which is what lets its entries be resolved without reaching outside it.
|
|
289
|
+
*/
|
|
290
|
+
const CATALOG_DIR = join(".agents", "buddy-agent-harness");
|
|
291
|
+
const CATALOG_PATH = join(CATALOG_DIR, ".claude-plugin", "marketplace.json");
|
|
292
|
+
function readManifest(path) {
|
|
293
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Derives the catalog from the repository's declared dependencies.
|
|
297
|
+
*
|
|
298
|
+
* Derived in full every time. A plugin newly added to `package.json` appears, one removed disappears,
|
|
299
|
+
* and a version that moved follows — all three without a line of reconciliation logic, because there
|
|
300
|
+
* is nothing to reconcile against. That is the same rule the vendor manifests already follow: the
|
|
301
|
+
* artifact is derived, never authored.
|
|
302
|
+
*/
|
|
303
|
+
function deriveCatalog(root) {
|
|
304
|
+
const manifestPath = join(root, "package.json");
|
|
305
|
+
const manifest = readManifest(manifestPath);
|
|
306
|
+
const plugins = [];
|
|
307
|
+
const unresolved = [];
|
|
308
|
+
for (const pkg of declaredDependencies(manifest)) {
|
|
309
|
+
const outcome = readDepPlugin(pkg, manifestPath);
|
|
310
|
+
if (outcome.ok) plugins.push(outcome.plugin);
|
|
311
|
+
else if (outcome.reason === "unresolved") unresolved.push({
|
|
312
|
+
package: pkg,
|
|
313
|
+
reason: outcome.reason
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
const owner = typeof manifest["name"] === "string" ? manifest["name"] : "unknown";
|
|
317
|
+
const name = marketplaceName(owner);
|
|
318
|
+
return {
|
|
319
|
+
catalog: buildCatalog({
|
|
320
|
+
name,
|
|
321
|
+
owner,
|
|
322
|
+
plugins
|
|
323
|
+
}),
|
|
324
|
+
plugins,
|
|
325
|
+
unresolved,
|
|
326
|
+
genericName: isGenericName(name),
|
|
327
|
+
pnp: isPnp()
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* What writing the catalog would do, without touching the disk.
|
|
332
|
+
*
|
|
333
|
+
* Comparing the serialized form rather than the parsed one is deliberate: a byte comparison is what
|
|
334
|
+
* lets a check mode be a comparison instead of an interpretation, and `buildCatalog` sorts its
|
|
335
|
+
* entries so that unchanged inputs serialize identically.
|
|
336
|
+
*/
|
|
337
|
+
function catalogStatus(root, catalog) {
|
|
338
|
+
let current;
|
|
339
|
+
try {
|
|
340
|
+
current = readFileSync(join(root, CATALOG_PATH), "utf8");
|
|
341
|
+
} catch {
|
|
342
|
+
current = void 0;
|
|
343
|
+
}
|
|
344
|
+
if (current === serializeCatalog(catalog)) return "unchanged";
|
|
345
|
+
return current === void 0 ? "created" : "updated";
|
|
346
|
+
}
|
|
347
|
+
/** Writes the catalog, reporting whether it moved. A write that would change nothing writes nothing. */
|
|
348
|
+
function writeCatalog(root, catalog) {
|
|
349
|
+
const outcome = catalogStatus(root, catalog);
|
|
350
|
+
if (outcome === "unchanged") return outcome;
|
|
351
|
+
const path = join(root, CATALOG_PATH);
|
|
352
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
353
|
+
writeFileSync(path, serializeCatalog(catalog));
|
|
354
|
+
return outcome;
|
|
355
|
+
}
|
|
356
|
+
//#endregion
|
|
357
|
+
//#region src/dep-plugins/harness-plugins.ts
|
|
358
|
+
/**
|
|
359
|
+
* What each harness does with a marketplace catalog, and how to read what it currently has.
|
|
360
|
+
*
|
|
361
|
+
* The runtimes diverge more than they look, and every difference below was established by running
|
|
362
|
+
* the shipped CLI rather than by reading a document:
|
|
363
|
+
*
|
|
364
|
+
* - **Claude Code** refreshes with `plugin update`; re-running `plugin install` is a no-op that
|
|
365
|
+
* reports "already installed" and leaves the stale copy in place. Every verb defaults to user
|
|
366
|
+
* scope, so each one has to name `--scope project` explicitly.
|
|
367
|
+
* - **Codex** has no update verb and no scope. Re-running `plugin add` re-reads the catalog and
|
|
368
|
+
* replaces the cached copy, so the same command installs and refreshes. It records no version in
|
|
369
|
+
* its config — the installed version is only in the cache directory's name.
|
|
370
|
+
* - **Copilot CLI** accepts a path source but **rejects an `npm` source**, so it cannot consume this
|
|
371
|
+
* catalog at all. See {@link COPILOT_LIMITATION}.
|
|
372
|
+
* - **Cursor** exposes no plugin subcommand from a terminal.
|
|
373
|
+
*
|
|
374
|
+
* A consumer should not have to know any of that, which is why it is here rather than in a document.
|
|
375
|
+
*/
|
|
376
|
+
/**
|
|
377
|
+
* Why Copilot CLI is absent from the table. Recorded rather than dropped, so the reason travels with
|
|
378
|
+
* the code: `copilot plugin marketplace add` rejects this catalog with
|
|
379
|
+
* `Invalid marketplace.json: plugins.0.source: Invalid input`, while the same catalog with a
|
|
380
|
+
* `./path` source is accepted. Supporting it needs a second, path-sourced catalog — and a path source
|
|
381
|
+
* cannot escape its marketplace root, which is the constraint the `npm` source was chosen to avoid.
|
|
382
|
+
*/
|
|
383
|
+
const COPILOT_LIMITATION = "Copilot CLI rejects an npm marketplace source, so it cannot install from this catalog. It needs a path-sourced catalog, which cannot reach node_modules from a subdirectory.";
|
|
384
|
+
function readJson(path) {
|
|
385
|
+
try {
|
|
386
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
387
|
+
} catch {
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
function directories(path) {
|
|
392
|
+
try {
|
|
393
|
+
return readdirSync(path, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
394
|
+
} catch {
|
|
395
|
+
return [];
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* `CLAUDE_CONFIG_DIR` wins where set, which is also what keeps a test off a developer's real
|
|
400
|
+
* configuration.
|
|
401
|
+
*/
|
|
402
|
+
function claudeConfigDir(home, env = process.env) {
|
|
403
|
+
return env["CLAUDE_CONFIG_DIR"] || join(home, ".claude");
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Reads what Claude Code has installed.
|
|
407
|
+
*
|
|
408
|
+
* The file keys each plugin by `<plugin>@<marketplace>` and holds an *array* per key, because the
|
|
409
|
+
* same plugin can be installed at more than one scope and, at project scope, once per project.
|
|
410
|
+
*/
|
|
411
|
+
function readClaudeState(configDir) {
|
|
412
|
+
const installedFile = readJson(join(configDir, "plugins", "installed_plugins.json"));
|
|
413
|
+
const installed = [];
|
|
414
|
+
for (const [id, entries] of Object.entries(installedFile?.plugins ?? {})) {
|
|
415
|
+
if (!Array.isArray(entries)) continue;
|
|
416
|
+
for (const entry of entries) {
|
|
417
|
+
if (!entry || typeof entry !== "object") continue;
|
|
418
|
+
const record = entry;
|
|
419
|
+
const version = typeof record["version"] === "string" ? record["version"] : void 0;
|
|
420
|
+
const scope = typeof record["scope"] === "string" ? record["scope"] : void 0;
|
|
421
|
+
if (!version || !scope) continue;
|
|
422
|
+
const projectPath = typeof record["projectPath"] === "string" ? record["projectPath"] : void 0;
|
|
423
|
+
installed.push(projectPath ? {
|
|
424
|
+
id,
|
|
425
|
+
version,
|
|
426
|
+
scope,
|
|
427
|
+
projectPath
|
|
428
|
+
} : {
|
|
429
|
+
id,
|
|
430
|
+
version,
|
|
431
|
+
scope
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
const knownFile = readJson(join(configDir, "plugins", "known_marketplaces.json"));
|
|
436
|
+
const marketplaces = [];
|
|
437
|
+
for (const [name, value] of Object.entries(knownFile ?? {})) marketplaces.push({
|
|
438
|
+
name,
|
|
439
|
+
path: typeof value?.source?.path === "string" ? value.source.path : ""
|
|
440
|
+
});
|
|
441
|
+
return {
|
|
442
|
+
installed,
|
|
443
|
+
marketplaces
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
const claudeCode = {
|
|
447
|
+
name: "claude-code",
|
|
448
|
+
scoped: true,
|
|
449
|
+
present: (home, env = process.env) => existsSync(claudeConfigDir(home, env)),
|
|
450
|
+
state: (home, env = process.env) => readClaudeState(claudeConfigDir(home, env)),
|
|
451
|
+
register: (catalogDir) => `claude plugin marketplace add ./${catalogDir} --scope project`,
|
|
452
|
+
install: (id) => `claude plugin install ${id} --scope project`,
|
|
453
|
+
/**
|
|
454
|
+
* Not `install`: re-running that on an already-installed plugin changes nothing and says so, which
|
|
455
|
+
* reads as success. `--scope project` is required rather than cosmetic — every verb defaults to
|
|
456
|
+
* user scope, so an update omitting it fails with "not installed at scope user" against a plugin
|
|
457
|
+
* that is plainly installed.
|
|
458
|
+
*/
|
|
459
|
+
update: (id) => `claude plugin update ${id} --scope project`,
|
|
460
|
+
uninstall: (id) => `claude plugin uninstall ${id} --scope project`,
|
|
461
|
+
refreshMarketplace: (marketplace) => `claude plugin marketplace update ${marketplace}`
|
|
462
|
+
};
|
|
463
|
+
function codexHome(home, env = process.env) {
|
|
464
|
+
return env["CODEX_HOME"] || join(home, ".codex");
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Reads what Codex has installed.
|
|
468
|
+
*
|
|
469
|
+
* The installed version comes from the cache layout, `plugins/cache/<marketplace>/<plugin>/<version>`,
|
|
470
|
+
* because Codex's `config.toml` records only that a plugin is enabled — never which version.
|
|
471
|
+
*
|
|
472
|
+
* Registered marketplaces are read from `config.toml` by scanning for its `[marketplaces.<name>]`
|
|
473
|
+
* tables. A deliberately narrow read rather than a TOML parser: only two keys are needed, and the
|
|
474
|
+
* cost of being wrong is a report that suggests re-registering, which is harmless to re-run.
|
|
475
|
+
*/
|
|
476
|
+
function readCodexState(home) {
|
|
477
|
+
const cacheRoot = join(home, "plugins", "cache");
|
|
478
|
+
const installed = [];
|
|
479
|
+
for (const marketplace of directories(cacheRoot)) for (const plugin of directories(join(cacheRoot, marketplace))) for (const version of directories(join(cacheRoot, marketplace, plugin))) installed.push({
|
|
480
|
+
id: `${plugin}@${marketplace}`,
|
|
481
|
+
version,
|
|
482
|
+
scope: "user"
|
|
483
|
+
});
|
|
484
|
+
const marketplaces = [];
|
|
485
|
+
let config = "";
|
|
486
|
+
try {
|
|
487
|
+
config = readFileSync(join(home, "config.toml"), "utf8");
|
|
488
|
+
} catch {
|
|
489
|
+
config = "";
|
|
490
|
+
}
|
|
491
|
+
let current;
|
|
492
|
+
for (const line of config.split("\n")) {
|
|
493
|
+
const header = /^\s*\[marketplaces\.(?:"([^"]+)"|([^\]]+))\]\s*$/.exec(line);
|
|
494
|
+
if (header) {
|
|
495
|
+
current = {
|
|
496
|
+
name: header[1] ?? header[2],
|
|
497
|
+
path: ""
|
|
498
|
+
};
|
|
499
|
+
marketplaces.push(current);
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
if (/^\s*\[/.test(line)) {
|
|
503
|
+
current = void 0;
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
const source = current ? /^\s*source\s*=\s*"([^"]*)"\s*$/.exec(line) : void 0;
|
|
507
|
+
if (source?.[1] && current) current.path = source[1];
|
|
508
|
+
}
|
|
509
|
+
return {
|
|
510
|
+
installed,
|
|
511
|
+
marketplaces
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
const runtimes = [claudeCode, {
|
|
515
|
+
name: "codex",
|
|
516
|
+
/**
|
|
517
|
+
* Codex installs into `CODEX_HOME` with no notion of scope, so one install is shared by every
|
|
518
|
+
* repository on the machine. Two repositories whose catalogs share a marketplace name cannot be
|
|
519
|
+
* told apart here, which is what makes the marketplace name derived from the package name matter.
|
|
520
|
+
*/
|
|
521
|
+
scoped: false,
|
|
522
|
+
present: (home, env = process.env) => existsSync(codexHome(home, env)),
|
|
523
|
+
state: (home, env = process.env) => readCodexState(codexHome(home, env)),
|
|
524
|
+
register: (catalogDir) => `codex plugin marketplace add ./${catalogDir}`,
|
|
525
|
+
install: (id) => `codex plugin add ${id}`,
|
|
526
|
+
/** No update verb: re-running `add` re-reads the catalog and replaces the cached copy. */
|
|
527
|
+
update: (id) => `codex plugin add ${id}`,
|
|
528
|
+
uninstall: (id) => `codex plugin remove ${id}`,
|
|
529
|
+
/** `marketplace upgrade` refreshes git snapshots only; `add` already re-reads a local catalog. */
|
|
530
|
+
refreshMarketplace: () => ""
|
|
531
|
+
}];
|
|
532
|
+
//#endregion
|
|
533
|
+
//#region src/dep-plugins/reconcile.ts
|
|
534
|
+
/**
|
|
535
|
+
* Installs belonging to this repository and this catalog.
|
|
536
|
+
*
|
|
537
|
+
* On a scoped runtime a user-scoped install is deliberately excluded rather than counted: it is
|
|
538
|
+
* shared with every other repository on the machine, so treating it as satisfying this repository's
|
|
539
|
+
* catalog would let one project's pin silently stand in for another's.
|
|
540
|
+
*/
|
|
541
|
+
function ours(installed, marketplace, root, scoped) {
|
|
542
|
+
const suffix = `@${marketplace}`;
|
|
543
|
+
return installed.filter((entry) => entry.id.endsWith(suffix) && (!scoped || entry.projectPath === root));
|
|
544
|
+
}
|
|
545
|
+
function reconcile({ catalog, catalogDir, root, installed, marketplaces, scoped = true }) {
|
|
546
|
+
const registration = marketplaces.find((entry) => entry.name === catalog.name);
|
|
547
|
+
const actions = [];
|
|
548
|
+
const expected = catalogDir.split(/[\\/]/).filter(Boolean);
|
|
549
|
+
const pointsHere = registration ? expected.every((segment) => registration.path.includes(segment)) : false;
|
|
550
|
+
const collision = registration && !pointsHere ? registration.path : void 0;
|
|
551
|
+
if (!registration) actions.push({
|
|
552
|
+
kind: "register",
|
|
553
|
+
subject: catalog.name,
|
|
554
|
+
detail: "marketplace is not registered with this harness"
|
|
555
|
+
});
|
|
556
|
+
const mine = ours(installed, catalog.name, root, scoped);
|
|
557
|
+
const byId = new Map(mine.map((entry) => [entry.id, entry]));
|
|
558
|
+
for (const entry of catalog.plugins) {
|
|
559
|
+
const id = `${entry.name}@${catalog.name}`;
|
|
560
|
+
const current = byId.get(id);
|
|
561
|
+
if (!current) {
|
|
562
|
+
actions.push({
|
|
563
|
+
kind: "install",
|
|
564
|
+
subject: id,
|
|
565
|
+
detail: `not installed; catalog pins ${entry.source.version}`
|
|
566
|
+
});
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
if (current.version !== entry.source.version) actions.push({
|
|
570
|
+
kind: "update",
|
|
571
|
+
subject: id,
|
|
572
|
+
detail: `installed ${current.version}, catalog pins ${entry.source.version}`
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
const wanted = new Set(catalog.plugins.map((entry) => `${entry.name}@${catalog.name}`));
|
|
576
|
+
for (const entry of mine) if (!wanted.has(entry.id)) actions.push({
|
|
577
|
+
kind: "uninstall",
|
|
578
|
+
subject: entry.id,
|
|
579
|
+
detail: "installed but no longer in the catalog; it will report a load failure until removed"
|
|
580
|
+
});
|
|
581
|
+
return {
|
|
582
|
+
actions,
|
|
583
|
+
...collision ? { collision } : {},
|
|
584
|
+
unregistered: !registration
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
//#endregion
|
|
588
|
+
//#region src/dep-plugins/dep-plugins.command.ts
|
|
589
|
+
/** The command that performs one action, so a reader is not left to look the verb up per runtime. */
|
|
590
|
+
function commandFor(runtime, action, marketplace) {
|
|
591
|
+
switch (action.kind) {
|
|
592
|
+
case "register": return runtime.register(CATALOG_DIR);
|
|
593
|
+
case "install": return runtime.install(action.subject);
|
|
594
|
+
case "update": {
|
|
595
|
+
const refresh = runtime.refreshMarketplace(marketplace);
|
|
596
|
+
const update = runtime.update(action.subject);
|
|
597
|
+
return refresh ? `${refresh} && ${update}` : update;
|
|
598
|
+
}
|
|
599
|
+
case "uninstall": return runtime.uninstall(action.subject);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* Notes name the conditions a caller cannot see from the entry list and would otherwise discover as a
|
|
604
|
+
* failure later — a dependency that is declared but absent, a marketplace name two repositories are
|
|
605
|
+
* likely to share, a resolver under which no dependency has a directory at all.
|
|
606
|
+
*/
|
|
607
|
+
function notesFor(derived) {
|
|
608
|
+
const notes = [];
|
|
609
|
+
if (derived.pnp) notes.push("Yarn Plug’n’Play is active: dependencies live inside zip archives, so no harness can install from them. Set nodeLinker: node-modules, or unplug the plugin dependencies.");
|
|
610
|
+
if (derived.genericName) notes.push(`Marketplace name "${derived.catalog.name}" is generic. Registering a second marketplace under a name already in use replaces the first silently, and the install that follows reports "already installed" — the repository then runs the other one's versions.`);
|
|
611
|
+
for (const { package: pkg } of derived.unresolved) notes.push(`Declared dependency "${pkg}" is not installed; install dependencies before generating.`);
|
|
612
|
+
return notes;
|
|
613
|
+
}
|
|
614
|
+
const depPluginsCommand = command({
|
|
615
|
+
name: "dep-plugins",
|
|
616
|
+
description: "Derive the marketplace catalog for plugins shipped by this repository’s dependencies, so a harness can install them. Writes the catalog; installing is the harness’s own job.",
|
|
617
|
+
options: {
|
|
618
|
+
root: {
|
|
619
|
+
description: "Repository directory. Defaults to the current directory.",
|
|
620
|
+
type: z.optional(z.string())
|
|
621
|
+
},
|
|
622
|
+
check: {
|
|
623
|
+
description: "Report whether the catalog is current without writing it. For CI and postinstall.",
|
|
624
|
+
type: z.optional(z.boolean())
|
|
625
|
+
},
|
|
626
|
+
format: {
|
|
627
|
+
description: "Output format: toon (default), json, or text for a human-readable report.",
|
|
628
|
+
type: z.optional(z.string()),
|
|
629
|
+
default: "toon"
|
|
630
|
+
}
|
|
631
|
+
},
|
|
632
|
+
run(args) {
|
|
633
|
+
try {
|
|
634
|
+
const format = parseFormat(args.format);
|
|
635
|
+
const root = args.root ?? process.cwd();
|
|
636
|
+
const derived = deriveCatalog(root);
|
|
637
|
+
const outcome = args.check ? catalogStatus(root, derived.catalog) === "unchanged" ? "current" : "would-change" : writeCatalog(root, derived.catalog);
|
|
638
|
+
const home = homedir();
|
|
639
|
+
const notes = notesFor(derived);
|
|
640
|
+
const detected = runtimes.filter((candidate) => candidate.present(home));
|
|
641
|
+
const harness = [];
|
|
642
|
+
const actions = [];
|
|
643
|
+
for (const runtime of detected) {
|
|
644
|
+
const state = runtime.state(home);
|
|
645
|
+
const delta = reconcile({
|
|
646
|
+
catalog: derived.catalog,
|
|
647
|
+
catalogDir: CATALOG_DIR,
|
|
648
|
+
root: resolve(root),
|
|
649
|
+
installed: state.installed,
|
|
650
|
+
marketplaces: state.marketplaces,
|
|
651
|
+
scoped: runtime.scoped
|
|
652
|
+
});
|
|
653
|
+
if (delta.collision) notes.push(`${runtime.name}: marketplace "${derived.catalog.name}" is already registered against ${delta.collision}. Registering over it replaces the other repository's silently, and the install that follows reports "already installed" — rename this repository's marketplace instead.`);
|
|
654
|
+
harness.push({
|
|
655
|
+
runtime: runtime.name,
|
|
656
|
+
status: delta.actions.length ? `${delta.actions.length} to apply` : "in sync"
|
|
657
|
+
});
|
|
658
|
+
for (const action of delta.actions) actions.push({
|
|
659
|
+
runtime: runtime.name,
|
|
660
|
+
do: commandFor(runtime, action, derived.catalog.name),
|
|
661
|
+
subject: action.subject,
|
|
662
|
+
why: action.detail
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
if (derived.plugins.length) notes.push(COPILOT_LIMITATION);
|
|
666
|
+
writeResult({
|
|
667
|
+
catalog: CATALOG_PATH,
|
|
668
|
+
marketplace: derived.catalog.name,
|
|
669
|
+
outcome,
|
|
670
|
+
plugins: derived.plugins.length ? derived.plugins.map(({ plugin, package: pkg, version }) => ({
|
|
671
|
+
name: plugin,
|
|
672
|
+
package: pkg,
|
|
673
|
+
version
|
|
674
|
+
})) : "0 dependencies ship a plugin — nothing to install",
|
|
675
|
+
harness: harness.length ? harness : "no supported harness detected on this machine",
|
|
676
|
+
actions: actions.length ? actions : "0 actions — every detected harness matches the catalog",
|
|
677
|
+
...notes.length ? { notes } : {}
|
|
678
|
+
}, format);
|
|
679
|
+
return outcome === "would-change" ? exitCodes.error : exitCodes.success;
|
|
680
|
+
} catch (error) {
|
|
681
|
+
process.stderr.write(`error: ${error instanceof Error ? error.message : "Catalog generation failed."}\n`);
|
|
682
|
+
return exitCodes.error;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
});
|
|
686
|
+
//#endregion
|
|
74
687
|
//#region src/diagnose-bridges/directory-files.ts
|
|
75
688
|
/**
|
|
76
689
|
* Every file below `directory`, as paths relative to it, POSIX-separated and sorted. A directory
|
|
@@ -2094,7 +2707,7 @@ function app() {
|
|
|
2094
2707
|
name: "buddy-agent-harness",
|
|
2095
2708
|
version,
|
|
2096
2709
|
description: "Initialize agent harness skill compatibility in consumer repositories."
|
|
2097
|
-
}).command(initCommand).command(doctorCommand);
|
|
2710
|
+
}).command(initCommand).command(doctorCommand).command(depPluginsCommand);
|
|
2098
2711
|
}
|
|
2099
2712
|
/**
|
|
2100
2713
|
* The application boundary: argv in, exit code out, nothing read from or written to the process.
|
|
@@ -2119,4 +2732,4 @@ async function run(argv) {
|
|
|
2119
2732
|
//#endregion
|
|
2120
2733
|
export { harnessRegistry as _, initializeHarnesses as a, diagnoseBridges as c, goldenSetPath as d, bridgeRepairs as f, renderDoctorSkill as g, instructionRepairs as h, initCommand as i, diagnoseInstructions as l, doctorSkill as m, activate as n, buildDoctorReport as o, doctorRepairs as p, harnessCommand as r, doctorCommand as s, run as t, diagnoseMcp as u };
|
|
2121
2734
|
|
|
2122
|
-
//# sourceMappingURL=cli-
|
|
2735
|
+
//# sourceMappingURL=cli-4ekejn5W.mjs.map
|