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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buddy-agent-harness",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Initialize standards-based agent configuration across agent harnesses",
5
5
  "author": {
6
6
  "name": "repobuddy"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buddy-agent-harness",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Initialize standards-based agent configuration across agent harnesses",
5
5
  "author": {
6
6
  "name": "repobuddy"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buddy-agent-harness",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Initialize standards-based agent configuration across agent harnesses",
5
5
  "author": {
6
6
  "name": "repobuddy"
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 both commands on `buddy`, as `buddy agent-harness doctor` and `buddy agent-harness init`.
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 { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, rmSync, statSync, symlinkSync } from "node:fs";
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-Bvnq8zOL.mjs.map
2735
+ //# sourceMappingURL=cli-4ekejn5W.mjs.map