buddy-agent-harness 0.8.0 → 0.9.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/dist/index.mjs CHANGED
@@ -1,2 +1,2735 @@
1
- import { _ as harnessRegistry, a as initializeHarnesses, c as diagnoseBridges, d as goldenSetPath, f as bridgeRepairs, g as renderDoctorSkill, h as instructionRepairs, i as initCommand, l as diagnoseInstructions, m as doctorSkill, n as activate, o as buildDoctorReport, p as doctorRepairs, r as harnessCommand, s as doctorCommand, t as run, u as diagnoseMcp } from "./cli-4ekejn5W.mjs";
1
+ import { createRequire } from "node:module";
2
+ import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
3
+ import { cli, command, exitCodes, z } from "clibuilder";
4
+ import { homedir, platform } from "node:os";
5
+ import { dirname, join, posix, relative, resolve, sep } from "node:path";
6
+ import { encode } from "@toon-format/toon";
7
+ import { parse } from "smol-toml";
8
+ import { execFileSync } from "node:child_process";
9
+ //#region src/is-record/is-record.ts
10
+ /**
11
+ * Whether a value is a record: an object with named keys, and not an array.
12
+ *
13
+ * Everything this package reads is a file someone else wrote — a golden MCP set, a harness config,
14
+ * a projection record — so every parse lands on `unknown` and every reader narrows it here before
15
+ * asking for a key. Four modules had their own copy of the same three clauses; a predicate that
16
+ * decides what counts as a parsed object is one fact, and a copy of it that drifts changes what a
17
+ * file is read to say.
18
+ */
19
+ function isRecord(value) {
20
+ return typeof value === "object" && value !== null && !Array.isArray(value);
21
+ }
22
+ //#endregion
23
+ //#region src/command-output/command-output.ts
24
+ const formats = [
25
+ "toon",
26
+ "json",
27
+ "text"
28
+ ];
29
+ /** Rejects anything but the supported formats so an unknown value never falls back silently. */
30
+ function parseFormat(value) {
31
+ if (!formats.includes(value)) throw new Error("--format must be toon, json, or text.");
32
+ return value;
33
+ }
34
+ /** The single stdout boundary: internal logic stays on plain objects, encoding happens here. */
35
+ function writeResult(value, format) {
36
+ process.stdout.write(`${encodeResult(value, format)}\n`);
37
+ }
38
+ function encodeResult(value, format) {
39
+ if (format === "json") return JSON.stringify(value);
40
+ return format === "text" ? renderText(value) : encode(value);
41
+ }
42
+ function cell(value) {
43
+ return value === void 0 ? "" : String(value);
44
+ }
45
+ /** A header row plus one row per record, every column padded to its widest cell. */
46
+ function table(rows) {
47
+ const columns = [...new Set(rows.flatMap((row) => Object.keys(row)))];
48
+ const widths = columns.map((column) => Math.max(column.length, ...rows.map((row) => cell(row[column]).length)));
49
+ const line = (values) => ` ${values.map((value, index) => value.padEnd(widths[index])).join(" ")}`.trimEnd();
50
+ return [line(columns), ...rows.map((row) => line(columns.map((column) => cell(row[column]))))];
51
+ }
52
+ /**
53
+ * The same result rendered for a person rather than for an agent: scalars as `key: value`, records
54
+ * as an aligned table, and everything else as a bulleted list. TOON stays the default because it is
55
+ * what an agent parses; this is for reading over someone's shoulder.
56
+ */
57
+ function renderText(value) {
58
+ const blocks = Object.entries(value).map(([key, item]) => {
59
+ if (!Array.isArray(item)) return [`${key}: ${isRecord(item) ? JSON.stringify(item) : String(item)}`];
60
+ if (!item.length) return [`${key}: (none)`];
61
+ const body = item.every(isRecord) ? table(item) : item.map((entry) => ` - ${String(entry)}`);
62
+ return [`${key}:`, ...body];
63
+ });
64
+ return blocks.flatMap((block, index) => {
65
+ const previous = blocks[index - 1];
66
+ return previous && (previous.length > 1 || block.length > 1) ? ["", ...block] : block;
67
+ }).join("\n");
68
+ }
69
+ /** AXI §10: the executable's absolute path, with the user's home directory collapsed to `~`. */
70
+ function binPath(home, executable) {
71
+ if (!executable) return "buddy-agent-harness";
72
+ return home && executable.startsWith(home + sep) ? `~${executable.slice(home.length)}` : executable;
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
687
+ //#region src/diagnose-bridges/directory-files.ts
688
+ /**
689
+ * Every file below `directory`, as paths relative to it, POSIX-separated and sorted. A directory
690
+ * that does not exist reads as empty: `doctor` never creates one to look at it.
691
+ */
692
+ function filesUnder(directory) {
693
+ try {
694
+ return readdirSync(directory, {
695
+ recursive: true,
696
+ withFileTypes: true
697
+ }).filter((entry) => entry.isFile()).map((entry) => relative(directory, join(entry.parentPath, entry.name)).split(sep).join(posix.sep)).sort();
698
+ } catch {
699
+ return [];
700
+ }
701
+ }
702
+ //#endregion
703
+ //#region src/harness-registry/harness-registry.ts
704
+ /**
705
+ * The two scopes disagree in general, and a single answer per harness could only record one of them:
706
+ * Copilot CLI is `.github/skills` in a repository and `~/.copilot` for the user. Claude Code is the
707
+ * only harness that needs a skills projection, and it needs one at both scopes. Codex, Cursor,
708
+ * Copilot CLI, Gemini CLI, and Devin Desktop read `.agents/skills` themselves and are never
709
+ * projected into.
710
+ *
711
+ * Gemini CLI carried a `.gemini/skills` projection until E-GEM-02: it reads the `.agents/skills`
712
+ * alias at project scope too, where that alias takes precedence over `.gemini/skills`. It still
713
+ * needs an instruction bridge, which is a separate axis and unaffected.
714
+ *
715
+ * Instruction bridges are recorded at project scope only. The user-scope equivalents exist, but
716
+ * nothing writes or reads them yet: `init` works inside a repository, and so does `doctor`.
717
+ *
718
+ * `windsurf` is the former name of Devin Desktop, rebranded 2026-06-02. It is retained as a
719
+ * deprecated alias: Devin still scans the legacy `.windsurf/skills` path, so its projection keeps
720
+ * working, but new repositories should enable `devin-desktop` and have nothing written for them.
721
+ *
722
+ * See `.research/agentic-configuration-standards/` for the per-harness sources.
723
+ */
724
+ const harnessRegistry = [
725
+ {
726
+ name: "claude-code",
727
+ project: {
728
+ detect: ".claude",
729
+ skillsDirectory: ".claude/skills",
730
+ instructionBridge: {
731
+ kind: "import",
732
+ path: "CLAUDE.md"
733
+ },
734
+ mcpConfig: {
735
+ path: ".mcp.json",
736
+ key: "mcpServers",
737
+ format: "json"
738
+ },
739
+ nonstandard: [
740
+ {
741
+ path: ".claude/commands",
742
+ shape: "directory",
743
+ kind: "command"
744
+ },
745
+ {
746
+ path: ".claude/rules",
747
+ shape: "directory",
748
+ kind: "rule"
749
+ },
750
+ {
751
+ path: ".claude/agents",
752
+ shape: "directory",
753
+ kind: "subagent"
754
+ }
755
+ ]
756
+ },
757
+ user: {
758
+ detect: ".claude",
759
+ skillsDirectory: ".claude/skills"
760
+ }
761
+ },
762
+ {
763
+ name: "cursor",
764
+ project: {
765
+ detect: ".cursor",
766
+ mcpConfig: {
767
+ path: ".cursor/mcp.json",
768
+ key: "mcpServers",
769
+ format: "json"
770
+ },
771
+ nonstandard: [
772
+ {
773
+ path: ".cursorrules",
774
+ shape: "file",
775
+ kind: "instructions"
776
+ },
777
+ {
778
+ path: ".cursor/rules",
779
+ shape: "directory",
780
+ kind: "rule"
781
+ },
782
+ {
783
+ path: ".cursor/commands",
784
+ shape: "directory",
785
+ kind: "command"
786
+ },
787
+ {
788
+ path: ".cursor/skills",
789
+ shape: "directory",
790
+ kind: "skill"
791
+ }
792
+ ]
793
+ },
794
+ user: { detect: ".cursor" }
795
+ },
796
+ {
797
+ name: "codex",
798
+ project: {
799
+ detect: ".codex",
800
+ mcpConfig: {
801
+ path: ".codex/config.toml",
802
+ key: "mcp_servers",
803
+ format: "toml"
804
+ },
805
+ nonstandard: [{
806
+ path: ".codex/skills",
807
+ shape: "directory",
808
+ kind: "skill"
809
+ }]
810
+ },
811
+ user: { detect: ".codex" }
812
+ },
813
+ {
814
+ name: "copilot-cli",
815
+ project: {
816
+ detect: ".github/skills",
817
+ nonstandard: [
818
+ {
819
+ path: ".github/copilot-instructions.md",
820
+ shape: "file",
821
+ kind: "instructions"
822
+ },
823
+ {
824
+ path: ".github/instructions",
825
+ shape: "directory",
826
+ kind: "instructions"
827
+ },
828
+ {
829
+ path: ".github/skills",
830
+ shape: "directory",
831
+ kind: "skill"
832
+ }
833
+ ]
834
+ },
835
+ user: { detect: ".copilot" }
836
+ },
837
+ {
838
+ name: "gemini-cli",
839
+ project: {
840
+ detect: ".gemini",
841
+ instructionBridge: {
842
+ kind: "settings-entry",
843
+ path: ".gemini/settings.json",
844
+ key: "context.fileName"
845
+ },
846
+ mcpConfig: {
847
+ path: ".gemini/settings.json",
848
+ key: "mcpServers",
849
+ format: "json",
850
+ shared: true
851
+ },
852
+ nonstandard: [{
853
+ path: "GEMINI.md",
854
+ shape: "file",
855
+ kind: "instructions"
856
+ }, {
857
+ path: ".gemini/skills",
858
+ shape: "directory",
859
+ kind: "skill"
860
+ }]
861
+ },
862
+ user: { detect: ".gemini" }
863
+ },
864
+ {
865
+ name: "devin-desktop",
866
+ project: { detect: ".devin" }
867
+ },
868
+ {
869
+ name: "windsurf",
870
+ project: {
871
+ detect: ".windsurf",
872
+ skillsDirectory: ".windsurf/skills",
873
+ nonstandard: [{
874
+ path: ".windsurfrules",
875
+ shape: "file",
876
+ kind: "instructions"
877
+ }, {
878
+ path: ".windsurf/rules",
879
+ shape: "directory",
880
+ kind: "rule"
881
+ }]
882
+ },
883
+ deprecated: "devin-desktop"
884
+ }
885
+ ];
886
+ const harnessNames = new Set(harnessRegistry.map((harness) => harness.name));
887
+ function isHarnessName(value) {
888
+ return harnessNames.has(value);
889
+ }
890
+ /**
891
+ * The `--harness` option, as names this registry knows. Both commands take the option and neither
892
+ * owns what a name means, so the parse lives beside the list it validates against: a harness added
893
+ * to the registry is accepted by both commands with nothing else to change, and the rejection reads
894
+ * the same either way.
895
+ *
896
+ * An unknown name throws rather than being dropped. A caller that asked for a harness by name and
897
+ * got a report about the two default ones has been told the wrong thing about their repository.
898
+ */
899
+ function parseHarnesses(value) {
900
+ const requested = (value ?? "").split(",").map((name) => name.trim()).filter(Boolean);
901
+ const unsupported = requested.filter((name) => !isHarnessName(name));
902
+ if (unsupported.length) throw new Error(`Unsupported harness: ${unsupported.join(", ")}. Supported: ${harnessRegistry.map((harness) => harness.name).join(", ")}.`);
903
+ return requested.filter(isHarnessName);
904
+ }
905
+ /** Enabled whether or not the repository already contains their directories. */
906
+ const defaultHarnesses = ["claude-code", "cursor"];
907
+ function directoryExists(path) {
908
+ try {
909
+ return lstatSync(path).isDirectory();
910
+ } catch {
911
+ return false;
912
+ }
913
+ }
914
+ /** The default harnesses, plus the preferred ones, plus every harness whose directory is present. */
915
+ function selectHarnesses(root, preferred) {
916
+ const enabled = /* @__PURE__ */ new Set([...defaultHarnesses, ...preferred]);
917
+ return harnessRegistry.filter((harness) => enabled.has(harness.name) || directoryExists(join(root, harness.project.detect)));
918
+ }
919
+ //#endregion
920
+ //#region src/diagnose-bridges/locator.ts
921
+ /** The locator as one string: the finding's `path`, and what a repair names. */
922
+ function locatorText({ file, server, field, position }) {
923
+ if (position) return `${file}#L${position.line}:${position.column}`;
924
+ if (server === void 0) return file;
925
+ return `${file}#servers.${server}${field === void 0 ? "" : `.${field}`}`;
926
+ }
927
+ //#endregion
928
+ //#region src/diagnose-bridges/doctor-guidance.ts
929
+ /**
930
+ * A table as the list `doctor` reports, in the order it was written. The keys of a
931
+ * `Record<P, RepairRow>` are `P` by construction, which is what the assertion says; nothing here
932
+ * claims a row exists.
933
+ */
934
+ function repairsOf(table) {
935
+ return Object.entries(table).map(([problem, row]) => ({
936
+ problem,
937
+ ...row
938
+ }));
939
+ }
940
+ /** How the command names itself. */
941
+ const commandInvocation = "buddy-agent-harness";
942
+ /**
943
+ * How a skill invokes the command. A skill may run without the binary on PATH, so it goes through
944
+ * `npx`, pinned to the caret range of the version that generated the skill.
945
+ *
946
+ * The pin is what makes the shipped guidance honest. A skill states the findings and flags of the
947
+ * version it was generated from, so an unpinned `npx` — which resolves whatever is latest — can
948
+ * hand an agent a table that does not describe the CLI it just ran. The caret keeps patches
949
+ * flowing and stops at the next breaking line.
950
+ */
951
+ const skillInvocation = (version) => `npx -y ${commandInvocation}@^${version}`;
952
+ /**
953
+ * The launcher a skill ships, named for the subcommand it runs so a stack trace or a process list
954
+ * says which one it was. One script per command, beside the `SKILL.md` that documents it.
955
+ */
956
+ const launcherFor = (subcommand) => `scripts/${subcommand}.mjs`;
957
+ /**
958
+ * How a skill names its launcher: the path as the skill sees it, resolved by the agent against the
959
+ * directory it read the `SKILL.md` from.
960
+ *
961
+ * `node` stays in front. The launcher ships without an executable bit, and its shebang does nothing
962
+ * on Windows, so naming the file alone would not run it.
963
+ */
964
+ const launcherInvocation = (subcommand) => `node ${launcherFor(subcommand)}`;
965
+ /**
966
+ * How the skill hands a repair to `repair`. Bridge and instruction repairs go to `init`, which
967
+ * writes both kinds of bridge in the first place; configuration that is present and wrong goes
968
+ * here, because no `init` flag corrects a file the user already wrote.
969
+ */
970
+ const repairSkillInvocation = "/buddy-agent-harness:repair";
971
+ /** How the skill hands a repair back to `init`. */
972
+ const initSkillInvocation = "/buddy-agent-harness:init";
973
+ /** Where the user authors the golden MCP server set, named in every MCP repair that points at it. */
974
+ const goldenSet = ".agents/buddy-agent-harness/mcp.toml";
975
+ /** The skills bridges, reported in `bridges`. */
976
+ const bridgeTable = {
977
+ "no-canonical": {
978
+ detail: "the canonical skill directory does not exist, so no bridge can resolve",
979
+ repair: (_at, cli) => ({
980
+ command: `${cli} init`,
981
+ instruction: `run \`${cli} init\` to create .agents/skills and the bridges into it`
982
+ }),
983
+ skillRepair: () => `run \`${initSkillInvocation}\`, which creates \`.agents/skills\` and the bridges`
984
+ },
985
+ missing: {
986
+ detail: "no bridge at this path — the harness sees zero project skills",
987
+ repair: ({ file }, cli) => ({
988
+ command: `${cli} init`,
989
+ instruction: `run \`${cli} init\` to create the bridge at ${file}`
990
+ }),
991
+ skillRepair: () => `run \`${initSkillInvocation}\``
992
+ },
993
+ degraded: {
994
+ detail: "expected a directory but found a regular file — checkout without core.symlinks",
995
+ repair: ({ file }, cli) => ({
996
+ command: `${cli} init --copy --force ${file}`,
997
+ instruction: `run \`${cli} init --copy --force ${file}\` to rebuild ${file} as a real directory`
998
+ }),
999
+ skillRepair: ({ file }) => `run \`${initSkillInvocation} --copy --force ${file}\``
1000
+ },
1001
+ stale: {
1002
+ detail: "symlink does not resolve to .agents/skills",
1003
+ repair: ({ file }, cli) => ({
1004
+ command: `${cli} init --force ${file}`,
1005
+ instruction: `run \`${cli} init --force ${file}\` to repoint ${file} at .agents/skills`
1006
+ }),
1007
+ skillRepair: ({ file }) => `run \`${initSkillInvocation} --force ${file}\``
1008
+ },
1009
+ "diverged-bridge": {
1010
+ detail: "only the bridge changed since the two last agreed — an agent wrote through the copy",
1011
+ repair: ({ file }, cli) => ({
1012
+ command: "",
1013
+ instruction: `replace .agents/skills with ${file} to keep the newer edit, then run \`${cli} init --force ${file}\``
1014
+ }),
1015
+ skillRepair: ({ file }) => `replace .agents/skills with ${file} to keep the newer edit, then run \`${initSkillInvocation} --force ${file}\``
1016
+ },
1017
+ "diverged-canonical": {
1018
+ detail: "only .agents/skills changed since the two last agreed — the copy is stale",
1019
+ repair: ({ file }, cli) => ({
1020
+ command: `${cli} init --copy --force ${file}`,
1021
+ instruction: `run \`${cli} init --copy --force ${file}\` to rebuild ${file} from the newer .agents/skills`
1022
+ }),
1023
+ skillRepair: ({ file }) => `run \`${initSkillInvocation} --copy --force ${file}\``
1024
+ },
1025
+ "diverged-both": {
1026
+ detail: "both sides changed since they last agreed — rebuilding would discard one of them",
1027
+ repair: ({ file }) => ({
1028
+ command: "",
1029
+ instruction: `reconcile .agents/skills with ${file} by hand — rebuilding would discard one of them; \`git diff --no-index .agents/skills ${file}\` shows what differs`
1030
+ }),
1031
+ skillRepair: ({ file }) => `run \`git diff --no-index .agents/skills ${file}\` and reconcile by hand`
1032
+ },
1033
+ "diverged-unknown": {
1034
+ detail: "contents differ and no commit where they agreed was found — which side moved is unknown",
1035
+ repair: ({ file }) => ({
1036
+ command: "",
1037
+ instruction: `reconcile .agents/skills with ${file} by hand — which side moved is unknown; \`git diff --no-index .agents/skills ${file}\` shows what differs`
1038
+ }),
1039
+ skillRepair: ({ file }) => `run \`git diff --no-index .agents/skills ${file}\` and reconcile by hand`
1040
+ },
1041
+ "unpinned-copy": {
1042
+ detail: "tracked copy without the skip-worktree bit — the tree is dirty with content that must not be committed",
1043
+ repair: ({ file }) => ({
1044
+ command: `git ls-files -z ${file} | xargs -0 git update-index --skip-worktree`,
1045
+ instruction: `run \`git ls-files -z ${file} | xargs -0 git update-index --skip-worktree\` to restore the skip-worktree bit`
1046
+ }),
1047
+ skillRepair: ({ file }) => `run \`git ls-files -z ${file} | xargs -0 git update-index --skip-worktree\``
1048
+ }
1049
+ };
1050
+ const bridgeRepairs = repairsOf(bridgeTable);
1051
+ /**
1052
+ * The instruction bridges, reported in `instructions`. Every repair is the `init` skill: these are
1053
+ * files a person wrote, or files carrying content beside the bridge, and deciding what to preserve
1054
+ * while restoring the bridge is judgment no flag carries. `repair` therefore names the skill in
1055
+ * both places rather than pretending a shell command exists.
1056
+ */
1057
+ const instructionTable = {
1058
+ "no-instructions": {
1059
+ detail: "no AGENTS.md at the repository root, so every instruction bridge points at nothing",
1060
+ repair: () => ({
1061
+ command: "",
1062
+ instruction: `hand this to \`${initSkillInvocation}\`, which derives AGENTS.md and the bridges to it`
1063
+ }),
1064
+ skillRepair: () => `run \`${initSkillInvocation}\`, which derives AGENTS.md and the bridges to it`
1065
+ },
1066
+ "instructions-missing": {
1067
+ detail: "no instruction bridge at this path — the harness reads none of AGENTS.md",
1068
+ repair: ({ file }) => ({
1069
+ command: "",
1070
+ instruction: `hand ${file} to \`${initSkillInvocation}\`, which writes the bridge into it`
1071
+ }),
1072
+ skillRepair: () => `run \`${initSkillInvocation}\``
1073
+ },
1074
+ "instructions-unbridged": {
1075
+ detail: "the file is present but names AGENTS.md nowhere — the harness reads none of it",
1076
+ repair: ({ file }) => ({
1077
+ command: "",
1078
+ instruction: `hand ${file} to \`${initSkillInvocation}\`, which adds the bridge without discarding what the file already says`
1079
+ }),
1080
+ skillRepair: () => `run \`${initSkillInvocation}\`, which adds the bridge without discarding what the file already says`
1081
+ },
1082
+ "instructions-unreadable": {
1083
+ detail: "the settings file does not parse, so the harness reads none of it",
1084
+ repair: ({ file }) => ({
1085
+ command: "",
1086
+ instruction: `fix the JSON in ${file} by hand, then hand it to \`${initSkillInvocation}\``
1087
+ }),
1088
+ skillRepair: () => `fix the JSON by hand, then run \`${initSkillInvocation}\``
1089
+ }
1090
+ };
1091
+ const instructionRepairs = repairsOf(instructionTable);
1092
+ /**
1093
+ * Configuration that is present and wrong. Detected here like everything else, but repaired by the
1094
+ * `repair` skill rather than by `init` — which is why it is a section of its own.
1095
+ */
1096
+ const configurationTable = {
1097
+ "deprecated-harness": {
1098
+ detail: "a projection under a harness name that has been superseded — the replacement reads .agents/skills natively and needs no projection at all",
1099
+ repair: ({ file }) => ({
1100
+ command: "",
1101
+ instruction: `remove ${file} and enable the harness that replaced it — \`${repairSkillInvocation}\` offers the correction`
1102
+ }),
1103
+ skillRepair: () => `run \`${repairSkillInvocation}\``
1104
+ },
1105
+ "ignored-bridge": {
1106
+ detail: "a .gitignore rule matches this bridge — an untracked bridge swallows a real edit silently",
1107
+ repair: ({ file }) => ({
1108
+ command: "",
1109
+ instruction: `narrow or remove the .gitignore rule matching ${file} — \`${repairSkillInvocation}\` offers the correction`
1110
+ }),
1111
+ skillRepair: () => `run \`${repairSkillInvocation}\``
1112
+ },
1113
+ "unread-local-override": {
1114
+ detail: "no harness reads this filename, so everything in it is invisible to every agent",
1115
+ repair: ({ file }) => ({
1116
+ command: "",
1117
+ instruction: `move ${file} to CLAUDE.local.md, or consolidate it into AGENTS.md — \`${repairSkillInvocation}\` offers the correction`
1118
+ }),
1119
+ skillRepair: () => `run \`${repairSkillInvocation}\``
1120
+ },
1121
+ "unloadable-skill": {
1122
+ detail: "frontmatter that does not parse, or no description — either one makes a harness skip the skill outright",
1123
+ repair: ({ file }) => ({
1124
+ command: "",
1125
+ instruction: `quote the description in ${file}, or add one — \`${repairSkillInvocation}\` offers the correction`
1126
+ }),
1127
+ skillRepair: () => `run \`${repairSkillInvocation}\``
1128
+ }
1129
+ };
1130
+ repairsOf(configurationTable);
1131
+ /**
1132
+ * The golden MCP server set against the harness copies of it.
1133
+ *
1134
+ * Every repair names a **locator** rather than a file — `.cursor/mcp.json#servers.linear.command` —
1135
+ * because a file holding twenty servers is not an address. The locator never carries a value: not
1136
+ * a whole one, and not a prefix. `doctor` is safe to run from a session-start hook, so a value it
1137
+ * echoes lands in agent context on every session and from there into transcripts, and `sk-ab…`
1138
+ * leaks into exactly the same place the whole string would.
1139
+ */
1140
+ const mcpTable = {
1141
+ "mcp-golden-unreadable": {
1142
+ detail: "the golden MCP set does not parse — the locator gives the line and column, and nothing else can be said about it",
1143
+ repair: (at) => ({
1144
+ command: "",
1145
+ instruction: `fix the TOML at ${locatorText(at)}`
1146
+ }),
1147
+ skillRepair: (at) => `fix the TOML at ${locatorText(at)} by hand — the reported line and column are all that can be quoted, because the parser's own message repeats the offending line and that line is the one holding the credential`
1148
+ },
1149
+ "mcp-target-unreadable": {
1150
+ detail: "this harness config does not parse, so the harness starts none of its servers and nothing in it can be compared",
1151
+ repair: (at) => ({
1152
+ command: "",
1153
+ instruction: `fix the syntax of ${locatorText(at)}`
1154
+ }),
1155
+ skillRepair: (at) => `fix the syntax of ${locatorText(at)} by hand`
1156
+ },
1157
+ "mcp-unprojected": {
1158
+ detail: "the golden set declares this server and the harness config does not carry it",
1159
+ repair: ({ file, server }) => ({
1160
+ command: "",
1161
+ instruction: `add the server ${server} to ${file}, or drop it from the golden set`
1162
+ }),
1163
+ skillRepair: (at) => `add the server at ${locatorText(at)} to that file from its golden entry, or drop it from the golden set`
1164
+ },
1165
+ "mcp-undeclared": {
1166
+ detail: "the harness config carries this server and the golden set does not declare it",
1167
+ repair: ({ file, server }) => ({
1168
+ command: "",
1169
+ instruction: `add the server ${server} to ${goldenSet}, or drop it from ${file}`
1170
+ }),
1171
+ skillRepair: (at) => `copy the server at ${locatorText(at)} into ${goldenSet}, refusing any literal credential it carries, or drop it from ${at.file}`
1172
+ },
1173
+ "mcp-diverged-target": {
1174
+ detail: "only the harness config changed since the two last agreed — the edit was made through the copy",
1175
+ repair: (at) => ({
1176
+ command: "",
1177
+ instruction: `reconcile the value at ${locatorText(at)} back into ${goldenSet}`
1178
+ }),
1179
+ skillRepair: (at) => `reconcile the value at ${locatorText(at)} back into ${goldenSet}, field by field`
1180
+ },
1181
+ "mcp-diverged-golden": {
1182
+ detail: "only the golden set changed since the two last agreed — the harness copy is stale",
1183
+ repair: (at) => ({
1184
+ command: "",
1185
+ instruction: `update ${locatorText(at)} from the golden entry`
1186
+ }),
1187
+ skillRepair: (at) => `update ${locatorText(at)} from the golden entry`
1188
+ },
1189
+ "mcp-diverged-both": {
1190
+ detail: "both sides changed since they last agreed — merging either way would discard the other",
1191
+ repair: (at) => ({
1192
+ command: "",
1193
+ instruction: `reconcile ${locatorText(at)} against ${goldenSet} by hand`
1194
+ }),
1195
+ skillRepair: (at) => `reconcile ${locatorText(at)} against ${goldenSet} by hand — never merge a three-way conflict automatically`
1196
+ },
1197
+ "mcp-diverged-unknown": {
1198
+ detail: "the two disagree and no baseline says which side moved — neither history nor a last-projected record covers this server",
1199
+ repair: (at) => ({
1200
+ command: "",
1201
+ instruction: `compare ${locatorText(at)} against ${goldenSet} by hand`
1202
+ }),
1203
+ skillRepair: (at) => `compare ${locatorText(at)} against ${goldenSet} by hand`
1204
+ },
1205
+ "mcp-literal-secret": {
1206
+ detail: "a credential-bearing field holds a literal rather than a reference to an environment variable",
1207
+ repair: (at) => ({
1208
+ command: "",
1209
+ instruction: `move the value at ${locatorText(at)} into an environment variable and reference it`
1210
+ }),
1211
+ skillRepair: (at) => `move the value at ${locatorText(at)} into an environment variable and reference it — read the value from the file, never from this report, and never repeat it back`
1212
+ },
1213
+ "mcp-committed-secret": {
1214
+ detail: "a credential-bearing field holds a literal in a git-tracked file — the credential is committed, and moving it does not un-commit it",
1215
+ repair: (at) => ({
1216
+ command: "",
1217
+ instruction: `rotate the credential behind ${locatorText(at)}, then reference it from an environment variable`
1218
+ }),
1219
+ skillRepair: (at) => `rotate the credential behind ${locatorText(at)} at its issuer, then reference it from an environment variable — it is in the repository's history, so moving it is not enough`
1220
+ }
1221
+ };
1222
+ repairsOf(mcpTable);
1223
+ /**
1224
+ * Configuration only one harness can read. Every repair here is a **conversion**, and every one of
1225
+ * them is the `init` skill's: consolidating what a repository already has is exactly its remit, and
1226
+ * the content is the user's, so it is offered and never written on sight. `repair` owns none of
1227
+ * them — it corrects configuration that is wrong, and none of this is wrong.
1228
+ *
1229
+ * `nonstandard-subagent` is the exception that names no owner. There is no cross-harness subagent
1230
+ * format to convert to, so the honest report is that the gap exists, not a repair that cannot run.
1231
+ */
1232
+ const nonstandardTable = {
1233
+ "nonstandard-instructions": {
1234
+ detail: "instruction content only one harness reads — AGENTS.md carries the same prose to all of them",
1235
+ repair: ({ file }) => ({
1236
+ command: "",
1237
+ instruction: `consolidate ${file} into AGENTS.md and leave a generated bridge in its place — \`${initSkillInvocation}\` offers the consolidation`
1238
+ }),
1239
+ skillRepair: ({ file }) => `hand ${file} to \`${initSkillInvocation}\`, which consolidates it into AGENTS.md`
1240
+ },
1241
+ "nonstandard-rule": {
1242
+ detail: "a path-scoped rule only one harness reads — a skill reaches every harness where the scoping is incidental",
1243
+ repair: ({ file }) => ({
1244
+ command: "",
1245
+ instruction: `convert ${file} into a skill under .agents/skills where its guidance is not tied to the paths it names — \`${initSkillInvocation}\` offers the conversion; keep it where the path scoping is the point`
1246
+ }),
1247
+ skillRepair: ({ file }) => `hand ${file} to \`${initSkillInvocation}\` to convert into a skill, unless the path scoping is load-bearing`
1248
+ },
1249
+ "nonstandard-command": {
1250
+ detail: "a harness command file — a skill is the portable form, and this harness reads skills too",
1251
+ repair: ({ file }) => ({
1252
+ command: "",
1253
+ instruction: `move ${file} to .agents/skills/<name>/SKILL.md — \`${initSkillInvocation}\` offers the move`
1254
+ }),
1255
+ skillRepair: ({ file }) => `hand ${file} to \`${initSkillInvocation}\`, which moves it to .agents/skills`
1256
+ },
1257
+ "nonstandard-skill": {
1258
+ detail: "a skill under a harness directory rather than the canonical one, where only that harness finds it",
1259
+ repair: ({ file }) => ({
1260
+ command: "",
1261
+ instruction: `move ${file} to .agents/skills and project it back where the harness needs one — \`${initSkillInvocation}\` offers the move`
1262
+ }),
1263
+ skillRepair: ({ file }) => `hand ${file} to \`${initSkillInvocation}\`, which moves it to .agents/skills`
1264
+ },
1265
+ "nonstandard-subagent": {
1266
+ detail: "a subagent definition with no cross-harness form — nothing outside this harness can read it, and no canonical form exists yet",
1267
+ repair: ({ file }) => ({
1268
+ command: "",
1269
+ instruction: `leave ${file} where it is and record that it reaches one harness only — no portable form exists to convert it to`
1270
+ }),
1271
+ skillRepair: ({ file }) => `leave ${file} in place — no portable form exists yet, so this is work for a person rather than a skill`
1272
+ }
1273
+ };
1274
+ repairsOf(nonstandardTable);
1275
+ /**
1276
+ * All five sections, in the order `doctor` reports them. Annotated over the whole union rather than
1277
+ * inferred, so a problem added to any of the four fails here too if its section was left alone.
1278
+ */
1279
+ const doctorTable = {
1280
+ ...bridgeTable,
1281
+ ...instructionTable,
1282
+ ...configurationTable,
1283
+ ...mcpTable,
1284
+ ...nonstandardTable
1285
+ };
1286
+ const doctorRepairs = repairsOf(doctorTable);
1287
+ /** The repair for one problem. Total by construction: the table is keyed by the union. */
1288
+ function repairFor(problem) {
1289
+ return {
1290
+ problem,
1291
+ ...doctorTable[problem]
1292
+ };
1293
+ }
1294
+ const doctorSkill = {
1295
+ name: "doctor",
1296
+ description: "Use this skill when a repository loads no project skills, when skills are missing after a clone, when a harness appears to be ignoring AGENTS.md, or when checking whether the agent configuration bridges into .claude/skills, CLAUDE.md, and the other harness files still resolve."
1297
+ };
1298
+ /** Written into the generated skill so a reader knows not to edit it in place. */
1299
+ const generatedSkillWarning = "<!-- Generated from src/diagnose-bridges/doctor-guidance.ts by scripts/generate-skills.ts. Do not edit by hand. -->";
1300
+ /**
1301
+ * The shipped `doctor` skill, rendered from the same table the command prints. `version` is the
1302
+ * package version the skill ships with; it pins the `npx` invocation so the table and the CLI that
1303
+ * produced it stay on the same breaking line.
1304
+ *
1305
+ * The finding tables are NOT here. They live in the reference pages below, and this file carries
1306
+ * only what every reader needs: how to run it, how to read the report, and where to go next.
1307
+ */
1308
+ function renderDoctorSkill(version) {
1309
+ return `---
1310
+ name: ${doctorSkill.name}
1311
+ description: ${doctorSkill.description}
1312
+ ---
1313
+
1314
+ ${generatedSkillWarning}
1315
+
1316
+ # Harness Doctor
1317
+
1318
+ A repository keeps one canonical configuration: \`.agents/skills\` for its skills and \`AGENTS.md\` for its instructions. Harnesses that cannot read those get bridges pointing at them: Claude Code needs both, and Gemini CLI needs the instruction bridge only — it reads \`.agents/skills\` itself. A bridge that stops resolving is silent: the harness finds nothing and loads zero project skills, with no warning anywhere. An instruction bridge fails the same way and costs more, because the harness then reads none of the repository's instructions at all.
1319
+
1320
+ Diagnose it:
1321
+
1322
+ \`\`\`sh
1323
+ ${launcherInvocation("doctor")}
1324
+ \`\`\`
1325
+
1326
+ That path is relative to this skill's own directory. The launcher runs the CLI that shipped beside it against the current working directory, so nothing is downloaded. Fall back to \`${skillInvocation(version)} doctor\` when the launcher cannot be resolved or run, which is the case when the plugin was installed from git rather than npm and its dependencies were never installed.
1327
+
1328
+ The command is read-only. It never repairs anything, so it is safe to run at any point, including from a session-start hook.
1329
+
1330
+ ## Reading the report
1331
+
1332
+ \`bridges\` lists every skills bridge \`init\` would create for this repository, each with a \`status\` of \`ok\`, \`missing\`, \`degraded\`, \`stale\`, or \`diverged\`.
1333
+
1334
+ \`instructions\` lists every instruction bridge into \`AGENTS.md\`, with a \`status\` of \`ok\`, \`missing\`, \`unbridged\`, or \`unreadable\`. They are a separate section because nothing about them is shared: a different \`kind\`, a different status vocabulary, and a repair that is never a command.
1335
+
1336
+ \`findings\` explains each problem and carries more than the two sections above: the configuration, MCP, and non-standard findings have no section of their own, because they are about files rather than about bridges. \`help\` carries each repair, one row per distinct repair, with two columns:
1337
+
1338
+ - \`command\` — a shell invocation that, run exactly as given, **completes** the repair.
1339
+ - \`instruction\` — the same repair in the imperative, always present and complete on its own.
1340
+
1341
+ \`command\` is empty whenever no single invocation does the job, and that emptiness is the signal: act on \`instruction\` and do not assemble a command out of it. A runnable invocation quoted *inside* an \`instruction\` is not the repair either — \`diverged-both\` names \`git diff --no-index\` because the diff shows you what differs, not because running it reconciles anything. Apply the repair, then re-run \`doctor\`.
1342
+
1343
+ Nothing in \`help\` is wrapped. An earlier version prefixed every repair with \`Run\`, which read as an instruction to paste prose into a shell.
1344
+
1345
+ Do not run an \`init\` command yourself. Rebuilding a skills bridge can move skills a user wrote, and rewriting an instruction file touches prose a person authored — both are the \`init\` skill's judgment, so hand the repair to \`${initSkillInvocation}\` instead. Every such repair carries an empty \`command\`: a skill invocation has no shell equivalent at all.
1346
+
1347
+ When every bridge resolves, \`findings\` says so outright rather than being empty.
1348
+
1349
+ The default output is TOON, which is what you parse. Add \`--format text\` when you need to show the same report to a person, or \`--format json\`.
1350
+
1351
+ ## Where the detail is
1352
+
1353
+ Every \`problem\` name routes to exactly one page. Load the page for the finding in front of you and leave the rest unread — the tables are long, and reading four families to act on one is what this split exists to stop.
1354
+
1355
+ | Page | Load it for |
1356
+ | --- | --- |
1357
+ | \`references/bridges.md\` | any \`bridges\` row that is not \`ok\` |
1358
+ | \`references/instructions.md\` | any \`instructions\` row that is not \`ok\` |
1359
+ | \`references/configuration.md\` | a finding about the configuration around the bridges rather than a bridge |
1360
+ | \`references/mcp.md\` | any finding whose path is an MCP locator — **always** before acting on a credential finding |
1361
+ | \`references/nonstandard.md\` | a finding about configuration only one harness can read |
1362
+ | \`references/harnesses/<name>.md\` | the paths and files one named harness uses |
1363
+
1364
+ ## Rules
1365
+
1366
+ - Never repair a \`diverged-both\` or \`diverged-unknown\` bridge by re-running \`init\`. Rebuilding overwrites whichever side holds the newer edit. Reconcile the two directories first.
1367
+ - Edit skills at \`.agents/skills/<name>/SKILL.md\`. Editing through a bridge is only safe when that bridge is a symlink.
1368
+ - Do not add bridges to \`.gitignore\`. An untracked bridge swallows a real edit silently.
1369
+ - Never repeat a value from a file an \`mcp-literal-secret\` or \`mcp-committed-secret\` finding points at. The report withheld it on purpose, and quoting it back puts it in the transcript anyway.
1370
+ - Write instructions in \`AGENTS.md\`, never in \`CLAUDE.md\`. A bridge file holds the import and any harness-specific notes; content written there reaches one harness and drifts from the canonical file.
1371
+ `;
1372
+ }
1373
+ //#endregion
1374
+ //#region src/diagnose-configuration/diagnose-configuration.ts
1375
+ const canonicalSkills = ".agents/skills";
1376
+ const localOverride = "AGENTS.local.md";
1377
+ /**
1378
+ * The two frontmatter faults that make a harness skip a skill outright, per
1379
+ * `skills/init/references/frontmatter.md`: YAML that does not parse, and a missing `description`.
1380
+ * A `name` that mismatches the directory is a warning and still loads, so it is not reported here.
1381
+ *
1382
+ * The unquoted colon is the documented cause of the first, and is checked directly rather than by
1383
+ * parsing YAML: the package ships no parser, and a targeted check names the actual fault.
1384
+ */
1385
+ function frontmatterFault(body) {
1386
+ const block = /^---\n([\s\S]*?)\n---/.exec(body);
1387
+ if (!block) return "unparseable";
1388
+ const value = /^description:[ \t]*(.*)$/m.exec(block[1])?.[1]?.trim();
1389
+ if (!value) return "no-description";
1390
+ if (!/^(['"]).*\1$/.test(value) && /:\s/.test(value)) return "unparseable";
1391
+ }
1392
+ /**
1393
+ * Unlike `diagnoseBridges`, this takes no harness preference. Every check here requires the
1394
+ * projection to exist on disk, and a projection cannot exist without its harness's detection
1395
+ * directory — which selects that harness anyway. A preference could therefore never add a finding,
1396
+ * so accepting one would be surface that does nothing.
1397
+ */
1398
+ function diagnoseConfiguration({ root, git, cli }) {
1399
+ const findings = [];
1400
+ const add = (path, problem) => {
1401
+ const { detail, repair } = repairFor(problem);
1402
+ findings.push({
1403
+ path,
1404
+ problem,
1405
+ detail,
1406
+ repair: repair({ file: path }, cli)
1407
+ });
1408
+ };
1409
+ const harnesses = selectHarnesses(root, []);
1410
+ for (const harness of harnesses) {
1411
+ const projection = harness.project.skillsDirectory;
1412
+ if (harness.deprecated && projection && existsSync(join(root, projection))) add(projection, "deprecated-harness");
1413
+ }
1414
+ for (const harness of harnesses) {
1415
+ const path = harness.project.skillsDirectory;
1416
+ if (path && existsSync(join(root, path)) && git.isIgnored(path)) add(path, "ignored-bridge");
1417
+ }
1418
+ if (existsSync(join(root, localOverride))) add(localOverride, "unread-local-override");
1419
+ for (const file of filesUnder(join(root, canonicalSkills)).filter((file) => file.endsWith("SKILL.md"))) if (frontmatterFault(readFileSync(join(root, canonicalSkills, file), "utf8"))) add(`${canonicalSkills}/${file}`, "unloadable-skill");
1420
+ return findings;
1421
+ }
1422
+ //#endregion
1423
+ //#region src/diagnose-bridges/json-with-comments.ts
1424
+ /**
1425
+ * `.gemini/settings.json` may legally carry comments — the Gemini CLI loader strips them before
1426
+ * parsing. A plain `JSON.parse` of a valid settings file therefore throws, and `doctor` would report
1427
+ * a working bridge as unreadable. Comments are removed before parsing for that reason.
1428
+ *
1429
+ * Only comments. A trailing comma stays a parse error, because nothing documents it as accepted.
1430
+ */
1431
+ function stripJsonComments(source) {
1432
+ let output = "";
1433
+ let index = 0;
1434
+ let inString = false;
1435
+ let escaped = false;
1436
+ while (index < source.length) {
1437
+ const character = source[index];
1438
+ if (inString) {
1439
+ output += character;
1440
+ if (escaped) escaped = false;
1441
+ else if (character === "\\") escaped = true;
1442
+ else if (character === "\"") inString = false;
1443
+ index += 1;
1444
+ continue;
1445
+ }
1446
+ if (character === "\"") {
1447
+ inString = true;
1448
+ output += character;
1449
+ index += 1;
1450
+ continue;
1451
+ }
1452
+ if (character === "/" && source[index + 1] === "/") {
1453
+ while (index < source.length && source[index] !== "\n") index += 1;
1454
+ continue;
1455
+ }
1456
+ if (character === "/" && source[index + 1] === "*") {
1457
+ index += 2;
1458
+ while (index < source.length && !(source[index] === "*" && source[index + 1] === "/")) index += 1;
1459
+ index += 2;
1460
+ continue;
1461
+ }
1462
+ output += character;
1463
+ index += 1;
1464
+ }
1465
+ return output;
1466
+ }
1467
+ /** The parsed value, or `undefined` when the file is not JSON even once its comments are gone. */
1468
+ function parseJsonWithComments(source) {
1469
+ try {
1470
+ return JSON.parse(stripJsonComments(source));
1471
+ } catch {
1472
+ return;
1473
+ }
1474
+ }
1475
+ //#endregion
1476
+ //#region src/diagnose-mcp/mcp-model.ts
1477
+ /** Every field of the model, in the order a finding reports them. */
1478
+ const mcpFields = [
1479
+ "transport",
1480
+ "command",
1481
+ "args",
1482
+ "env",
1483
+ "url",
1484
+ "headers",
1485
+ "description",
1486
+ "enabled",
1487
+ "timeout",
1488
+ "source"
1489
+ ];
1490
+ /** The two fields holding one value per name rather than a single value. */
1491
+ const mapFields = /* @__PURE__ */ new Set(["env", "headers"]);
1492
+ /**
1493
+ * Whether two values of the same field agree. Arrays are ordered — `args` is a command line, and
1494
+ * reordering it changes what runs. Maps are not, so they are compared by their sorted entries.
1495
+ */
1496
+ function sameValue(field, left, right) {
1497
+ if (mapFields.has(field) && isRecord(left) && isRecord(right)) {
1498
+ const keys = Object.keys(left);
1499
+ return keys.length === Object.keys(right).length && keys.every((key) => left[key] === right[key]);
1500
+ }
1501
+ return JSON.stringify(left) === JSON.stringify(right);
1502
+ }
1503
+ /**
1504
+ * The fields on which a target disagrees with the golden set.
1505
+ *
1506
+ * Asymmetric by design, and this is the rule that keeps a golden set from accumulating noise. A
1507
+ * field the golden set leaves unset is never a difference, however the target fills it: a host
1508
+ * restating its own default and a user's deliberate edit are indistinguishable in that position,
1509
+ * and pulling both back would grow the golden set on every round-trip. The golden set speaks only
1510
+ * about what it says.
1511
+ *
1512
+ * `env` and `headers` are compared per name for the same reason — a variable only the target sets
1513
+ * is that target's business, and one the golden set names must match.
1514
+ */
1515
+ function divergingFields(golden, target) {
1516
+ return mcpFields.filter((field) => {
1517
+ const declared = golden[field];
1518
+ if (declared === void 0) return false;
1519
+ if (mapFields.has(field) && isRecord(declared) && isRecord(target[field])) {
1520
+ const observed = target[field];
1521
+ return Object.keys(declared).some((key) => declared[key] !== observed[key]);
1522
+ }
1523
+ return !sameValue(field, declared, target[field]);
1524
+ });
1525
+ }
1526
+ /** Whether one field of two models agrees, for naming the side that moved. */
1527
+ function sameField(field, left, right) {
1528
+ return sameValue(field, left?.[field], right?.[field]);
1529
+ }
1530
+ //#endregion
1531
+ //#region src/diagnose-mcp/mcp-sources.ts
1532
+ /** Where the golden set lives, and why it is namespaced rather than sitting at `.agents/mcp.json`. */
1533
+ const goldenSetPath = ".agents/buddy-agent-harness/mcp.toml";
1534
+ /** The table the golden set keeps its servers under. */
1535
+ const goldenKey = "servers";
1536
+ function stringMap(value) {
1537
+ if (!isRecord(value)) return void 0;
1538
+ const entries = Object.entries(value).filter(([, item]) => typeof item === "string");
1539
+ return entries.length ? Object.fromEntries(entries) : void 0;
1540
+ }
1541
+ const transports = /* @__PURE__ */ new Set([
1542
+ "stdio",
1543
+ "http",
1544
+ "sse"
1545
+ ]);
1546
+ /**
1547
+ * The transport a host entry describes. Hosts spell it `type` (Claude Code, VS Code) or
1548
+ * `transport`, and several state it nowhere and leave it implied by which of `url` and `command`
1549
+ * is present. Inferring it is what lets a `url` entry in one file compare equal to a `type: http`
1550
+ * entry in another.
1551
+ */
1552
+ function transportOf(entry) {
1553
+ const declared = entry["type"] ?? entry["transport"];
1554
+ if (typeof declared === "string" && transports.has(declared)) return declared;
1555
+ if (typeof entry["url"] === "string") return "http";
1556
+ if (typeof entry["command"] === "string") return "stdio";
1557
+ }
1558
+ /**
1559
+ * One host entry, or one golden entry, in the canonical model. The two formats carry the same
1560
+ * field names — the divergence between hosts is the file, the top-level key, and the serialization,
1561
+ * not the entry — so one reader serves both directions of the comparison.
1562
+ *
1563
+ * A field whose value is the wrong type is dropped rather than carried through. A `timeout` that
1564
+ * is a string is not a timeout, and letting it into the model would report a divergence whose real
1565
+ * cause is a typo the finding does not name.
1566
+ */
1567
+ function serverFrom(entry) {
1568
+ const args = Array.isArray(entry["args"]) && entry["args"].every((item) => typeof item === "string");
1569
+ const transport = transportOf(entry);
1570
+ const env = stringMap(entry["env"]);
1571
+ const headers = stringMap(entry["headers"]);
1572
+ return {
1573
+ ...transport ? { transport } : {},
1574
+ ...typeof entry["command"] === "string" ? { command: entry["command"] } : {},
1575
+ ...args ? { args: entry["args"] } : {},
1576
+ ...env ? { env } : {},
1577
+ ...typeof entry["url"] === "string" ? { url: entry["url"] } : {},
1578
+ ...headers ? { headers } : {},
1579
+ ...typeof entry["description"] === "string" ? { description: entry["description"] } : {},
1580
+ ...typeof entry["enabled"] === "boolean" ? { enabled: entry["enabled"] } : {},
1581
+ ...typeof entry["timeout"] === "number" ? { timeout: entry["timeout"] } : {},
1582
+ ...typeof entry["source"] === "string" ? { source: entry["source"] } : {}
1583
+ };
1584
+ }
1585
+ function serversUnder(document, key) {
1586
+ const table = isRecord(document) ? document[key] : void 0;
1587
+ if (!isRecord(table)) return /* @__PURE__ */ new Map();
1588
+ return new Map(Object.entries(table).filter(([, entry]) => isRecord(entry)).map(([name, entry]) => [name, serverFrom(entry)]));
1589
+ }
1590
+ /**
1591
+ * The position out of a thrown parse error, and nothing else out of it.
1592
+ *
1593
+ * `smol-toml` reports `line` and `column` on the error alongside a `codeblock` quoting the source,
1594
+ * and its `message` embeds that same code block. Both are unusable: in a file of MCP configuration
1595
+ * the line a parser failed on is exactly the line holding the credential. Only the two numbers are
1596
+ * read, and anything thrown that does not carry them is reported without a position rather than by
1597
+ * reaching for its message.
1598
+ *
1599
+ * Exported because that restriction is the point of the function rather than an implementation
1600
+ * detail of it, and it is verified directly.
1601
+ */
1602
+ function positionOf(error) {
1603
+ if (!isRecord(error)) return void 0;
1604
+ const { line, column } = error;
1605
+ return typeof line === "number" && typeof column === "number" ? {
1606
+ line,
1607
+ column
1608
+ } : void 0;
1609
+ }
1610
+ /** The golden set, parsed. Absent is the common case and is not a fault. */
1611
+ function parseGoldenSet(source) {
1612
+ if (source === void 0) return { kind: "absent" };
1613
+ try {
1614
+ return {
1615
+ kind: "servers",
1616
+ servers: serversUnder(parse(source), goldenKey)
1617
+ };
1618
+ } catch (error) {
1619
+ return {
1620
+ kind: "unreadable",
1621
+ position: positionOf(error)
1622
+ };
1623
+ }
1624
+ }
1625
+ /**
1626
+ * One harness's MCP configuration, parsed. The file is read for its own key and nothing else:
1627
+ * `.gemini/settings.json` also carries the instruction bridge, and a reader that treated the whole
1628
+ * file as MCP would report the rest of a user's settings as servers it does not recognize.
1629
+ *
1630
+ * JSON is parsed with comments stripped, because Gemini CLI's loader strips them too and a
1631
+ * settings file may legally carry them.
1632
+ */
1633
+ function parseTarget(config, source) {
1634
+ if (source === void 0) return { kind: "absent" };
1635
+ if (config.format === "toml") try {
1636
+ return {
1637
+ kind: "servers",
1638
+ servers: serversUnder(parse(source), config.key)
1639
+ };
1640
+ } catch {
1641
+ return { kind: "unreadable" };
1642
+ }
1643
+ const document = parseJsonWithComments(source);
1644
+ if (document === void 0) return { kind: "unreadable" };
1645
+ return {
1646
+ kind: "servers",
1647
+ servers: serversUnder(document, config.key)
1648
+ };
1649
+ }
1650
+ //#endregion
1651
+ //#region src/diagnose-mcp/mcp-baseline.ts
1652
+ /** Where the record of what was last written to each target lives, beside the golden set it came from. */
1653
+ const projectionRecordPath = ".agents/buddy-agent-harness/mcp.projected.json";
1654
+ /**
1655
+ * The last-projected record: per target path, per server name, the model that was written there.
1656
+ *
1657
+ * It exists because git can only answer for a file it can see. Harnesses write their MCP
1658
+ * configuration themselves, at user scope, into files that are often untracked or ignored outright,
1659
+ * and for those "which side moved since they last agreed" has no answer in history. A record
1660
+ * written at projection time has one, exactly, because it *is* what was projected.
1661
+ *
1662
+ * A malformed record answers nothing rather than throwing. It is a cache of an answer, not the
1663
+ * answer, and a diagnosis that dies because its cache is corrupt is worse than one that says
1664
+ * `unknown`.
1665
+ */
1666
+ function parseProjectionRecord(source) {
1667
+ const document = source === void 0 ? void 0 : parseJsonWithComments(source);
1668
+ const targets = isRecord(document) ? document["targets"] : void 0;
1669
+ if (!isRecord(targets)) return /* @__PURE__ */ new Map();
1670
+ return new Map(Object.entries(targets).filter(([, servers]) => isRecord(servers)).map(([path, servers]) => [path, new Map(Object.entries(servers).filter(([, server]) => isRecord(server)).map(([name, server]) => [name, server]))]));
1671
+ }
1672
+ /**
1673
+ * Which side moved, for one server and one field.
1674
+ *
1675
+ * The record is asked first, because it records exactly what was written and therefore answers
1676
+ * exactly. Where it holds nothing, a **tracked** target falls back to git: the newest commit where
1677
+ * the golden set and the target agreed on that field, then which working tree still matches it.
1678
+ * Where neither can answer, the direction is `unknown` and the finding says so — naming a side on a
1679
+ * guess would send a reconcile at the wrong file.
1680
+ */
1681
+ var McpBaseline = class {
1682
+ options;
1683
+ constructor(options) {
1684
+ this.options = options;
1685
+ }
1686
+ /**
1687
+ * One parse per commit per file, and one commit walk per target.
1688
+ *
1689
+ * `lastAgreed` runs per (config, server, field) that diverges without a projection record, and
1690
+ * every one of those walks reads the same two files at the same commits. Unmemoized, three
1691
+ * targets against five servers with two diverged fields each over fifty commits of history is
1692
+ * three thousand `git show` calls and as many parses — on the command the `doctor` skill says is
1693
+ * cheap enough for a session-start hook. One instance serves one diagnosis, so the memo lives and
1694
+ * dies with it and can never answer for a working tree that has moved on.
1695
+ */
1696
+ parsed = /* @__PURE__ */ new Map();
1697
+ walked = /* @__PURE__ */ new Map();
1698
+ directionOf(config, name, field, golden, target) {
1699
+ const base = this.baseFor(config, name, field);
1700
+ if (base === void 0) return "unknown";
1701
+ const goldenMoved = !sameField(field, golden, base);
1702
+ const targetMoved = !sameField(field, target, base);
1703
+ if (goldenMoved && targetMoved) return "both";
1704
+ return targetMoved ? "target" : "golden";
1705
+ }
1706
+ baseFor(config, name, field) {
1707
+ const recorded = this.options.record.get(config.path)?.get(name);
1708
+ if (recorded) return recorded;
1709
+ return this.lastAgreed(config, name, field);
1710
+ }
1711
+ /**
1712
+ * The newest committed state where both files described this field the same way. Read at model
1713
+ * granularity rather than by comparing blobs: the two files never share bytes, and a commit that
1714
+ * reformatted one of them did not change what it says.
1715
+ */
1716
+ lastAgreed(config, name, field) {
1717
+ for (const commit of this.commitsTouching(config)) {
1718
+ const golden = this.parseAt(commit, goldenSetPath, parseGoldenSet);
1719
+ const target = this.parseAt(commit, config.path, (source) => parseTarget(config, source));
1720
+ if (golden.kind !== "servers" || target.kind !== "servers") continue;
1721
+ const left = golden.servers.get(name);
1722
+ const right = target.servers.get(name);
1723
+ if (left && right && sameField(field, left, right)) return left;
1724
+ }
1725
+ }
1726
+ commitsTouching(config) {
1727
+ const walked = this.walked.get(config.path);
1728
+ if (walked) return walked;
1729
+ const commits = this.options.git.commitsTouching([goldenSetPath, config.path]);
1730
+ this.walked.set(config.path, commits);
1731
+ return commits;
1732
+ }
1733
+ parseAt(commit, path, parse) {
1734
+ const key = `${commit}\u0000${path}`;
1735
+ const parsed = this.parsed.get(key);
1736
+ if (parsed) return parsed;
1737
+ const result = parse(this.options.git.contentAt(commit, path));
1738
+ this.parsed.set(key, result);
1739
+ return result;
1740
+ }
1741
+ };
1742
+ //#endregion
1743
+ //#region src/diagnose-mcp/mcp-secrets.ts
1744
+ /**
1745
+ * Finding a literal credential in MCP configuration, without ever handling one.
1746
+ *
1747
+ * `doctor` is safe to run from a session-start hook, so its output lands in agent context on every
1748
+ * session and from there into transcripts. A value it echoes is amplified far past the file it came
1749
+ * from. Every function here answers **where**, never **what**: the return is a field path, and the
1750
+ * value never leaves this module.
1751
+ *
1752
+ * That is also why the redaction lives here rather than in a formatter. `doctor` renders three ways
1753
+ * — TOON, `--format text`, and `--format json` — and trusting each of them to mask is three chances
1754
+ * to drift. A value that never enters the finding cannot be printed by any of them.
1755
+ */
1756
+ /**
1757
+ * Words that mark a field as credential-bearing wherever they appear in its name. Each is one that
1758
+ * effectively never turns up inside an innocent word, so a bare substring test is safe and catches
1759
+ * the run-together spellings a segment test would miss — `MYTOKEN`, `githubsecret`.
1760
+ */
1761
+ const credentialWord = /token|secret|password|passwd|credential/i;
1762
+ /**
1763
+ * Words that mark a field only when they are a **segment** of its name. `key` and `auth` are the
1764
+ * two that matter, and both are common inside words that carry nothing — `MONKEY`, `AUTHOR`,
1765
+ * `KEYSTONE_URL`. Matching them as substrings reports those, and a scanner that cries wolf on a
1766
+ * hook-run command is one people stop reading.
1767
+ */
1768
+ const credentialSegment = /* @__PURE__ */ new Set([
1769
+ "key",
1770
+ "apikey",
1771
+ "auth",
1772
+ "authorization",
1773
+ "bearer"
1774
+ ]);
1775
+ /** A field name split the two ways these are written: `API_KEY` and `apiKey` alike. */
1776
+ function segmentsOf(key) {
1777
+ return key.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[^A-Za-z0-9]+/).filter(Boolean).map((segment) => segment.toLowerCase());
1778
+ }
1779
+ /**
1780
+ * A value that **names** a secret instead of holding one, in the forms the supported hosts
1781
+ * document: `${VAR}`, `$VAR`, and the `${env:VAR}` / `${input:id}` variants.
1782
+ *
1783
+ * Classification is by shape, not by content. An entropy test on the value would be a guess about
1784
+ * a string this module has already decided not to look at, and it would fail in both directions —
1785
+ * a short password is low-entropy and a base64 configuration blob is high.
1786
+ */
1787
+ const reference = /\$\{[^}]+\}|\$[A-Za-z_][A-Za-z0-9_]*/;
1788
+ /**
1789
+ * A credential-bearing field passes when it carries a reference anywhere in it, because the common
1790
+ * legitimate form is a template rather than a bare variable — `Bearer ${LINEAR_TOKEN}` is the
1791
+ * documented way to write an authorization header, and requiring the whole value to be a reference
1792
+ * would report every correct one.
1793
+ */
1794
+ function holdsLiteral(value) {
1795
+ return !reference.test(value);
1796
+ }
1797
+ function credentialKey(key) {
1798
+ return credentialWord.test(key) || segmentsOf(key).some((segment) => credentialSegment.has(segment));
1799
+ }
1800
+ /**
1801
+ * Whether a URL carries a credential in itself. Two shapes: a password in the userinfo component,
1802
+ * and a credential-named query parameter holding a literal. Neither value is read past the test.
1803
+ *
1804
+ * A URL that does not parse is not reported. It is malformed rather than leaky, and guessing at its
1805
+ * structure with a regular expression is how a scanner starts matching the thing it must not touch.
1806
+ */
1807
+ function urlHoldsCredential(value) {
1808
+ let url;
1809
+ try {
1810
+ url = new URL(value);
1811
+ } catch {
1812
+ return false;
1813
+ }
1814
+ if (url.password) return true;
1815
+ for (const [key, parameter] of url.searchParams) if (credentialKey(key) && holdsLiteral(parameter)) return true;
1816
+ return false;
1817
+ }
1818
+ function literalsIn(field, values) {
1819
+ return Object.entries(values ?? {}).filter(([key, value]) => credentialKey(key) && holdsLiteral(value)).map(([key]) => `${field}.${key}`);
1820
+ }
1821
+ /**
1822
+ * Every field of one server holding a literal credential, as a path within that server.
1823
+ *
1824
+ * The path is the whole answer. It is never a truncated value: `sk-ab…` is a leak into the same
1825
+ * transcript and buys a reader nothing the field name does not already give them.
1826
+ */
1827
+ function credentialFields(server) {
1828
+ return [
1829
+ ...literalsIn("env", server.env),
1830
+ ...literalsIn("headers", server.headers),
1831
+ ...server.url && urlHoldsCredential(server.url) ? ["url"] : []
1832
+ ];
1833
+ }
1834
+ //#endregion
1835
+ //#region src/diagnose-mcp/diagnose-mcp.ts
1836
+ function read(root, path) {
1837
+ const absolute = join(root, path);
1838
+ return existsSync(absolute) ? readFileSync(absolute, "utf8") : void 0;
1839
+ }
1840
+ /**
1841
+ * The distinct MCP files the enabled harnesses read. Two harnesses may name the same file.
1842
+ *
1843
+ * No harness preference is accepted, for the same reason the configuration half accepts none: every
1844
+ * harness documenting an MCP file is already selected without one. Claude Code and Cursor are
1845
+ * selected unconditionally; Codex and Gemini CLI keep their file inside their own detection
1846
+ * directory, so it cannot exist without that directory selecting them. A preference could never add
1847
+ * a finding.
1848
+ */
1849
+ function targetsOf(root) {
1850
+ const seen = /* @__PURE__ */ new Set();
1851
+ return selectHarnesses(root, []).map((harness) => harness.project.mcpConfig).filter((config) => config !== void 0).filter((config) => !seen.has(config.path) && seen.add(config.path));
1852
+ }
1853
+ const divergence = {
1854
+ target: "mcp-diverged-target",
1855
+ golden: "mcp-diverged-golden",
1856
+ both: "mcp-diverged-both",
1857
+ unknown: "mcp-diverged-unknown"
1858
+ };
1859
+ function diagnoseMcp({ root, git, cli }) {
1860
+ const findings = [];
1861
+ const add = (at, problem) => {
1862
+ const { detail, repair } = repairFor(problem);
1863
+ findings.push({
1864
+ path: locatorText(at),
1865
+ problem,
1866
+ detail,
1867
+ repair: repair(at, cli)
1868
+ });
1869
+ };
1870
+ /**
1871
+ * Credentials are reported for every file that holds servers, the golden set included — a user
1872
+ * pastes a token into whichever file is open, and the golden set is a file. Severity splits on
1873
+ * tracking, because a literal in a tracked file is already a committed credential and moving it
1874
+ * to an environment variable does not un-commit it.
1875
+ */
1876
+ const reportSecrets = (path, servers) => {
1877
+ const committed = git.trackingOf(path) !== "untracked";
1878
+ for (const [name, server] of servers) for (const field of credentialFields(server)) add({
1879
+ file: path,
1880
+ server: name,
1881
+ field
1882
+ }, committed ? "mcp-committed-secret" : "mcp-literal-secret");
1883
+ };
1884
+ const targets = /* @__PURE__ */ new Map();
1885
+ const configs = [];
1886
+ for (const config of targetsOf(root)) {
1887
+ const parsed = parseTarget(config, read(root, config.path));
1888
+ if (parsed.kind === "unreadable") {
1889
+ add({ file: config.path }, "mcp-target-unreadable");
1890
+ continue;
1891
+ }
1892
+ if (parsed.kind === "absent") continue;
1893
+ reportSecrets(config.path, parsed.servers);
1894
+ targets.set(config.path, parsed.servers);
1895
+ configs.push(config);
1896
+ }
1897
+ const golden = parseGoldenSet(read(root, goldenSetPath));
1898
+ if (golden.kind === "unreadable") {
1899
+ add({
1900
+ file: goldenSetPath,
1901
+ position: golden.position
1902
+ }, "mcp-golden-unreadable");
1903
+ return findings;
1904
+ }
1905
+ if (golden.kind === "absent") return findings;
1906
+ reportSecrets(goldenSetPath, golden.servers);
1907
+ const baseline = new McpBaseline({
1908
+ git,
1909
+ record: parseProjectionRecord(read(root, projectionRecordPath))
1910
+ });
1911
+ for (const config of configs) {
1912
+ const servers = targets.get(config.path);
1913
+ for (const [name, declared] of golden.servers) {
1914
+ const carried = servers.get(name);
1915
+ if (!carried) {
1916
+ add({
1917
+ file: config.path,
1918
+ server: name
1919
+ }, "mcp-unprojected");
1920
+ continue;
1921
+ }
1922
+ for (const field of divergingFields(declared, carried)) add({
1923
+ file: config.path,
1924
+ server: name,
1925
+ field
1926
+ }, divergence[baseline.directionOf(config, name, field, declared, carried)]);
1927
+ }
1928
+ for (const name of servers.keys()) if (!golden.servers.has(name)) add({
1929
+ file: config.path,
1930
+ server: name
1931
+ }, "mcp-undeclared");
1932
+ }
1933
+ return findings;
1934
+ }
1935
+ //#endregion
1936
+ //#region src/diagnose-nonstandard/diagnose-nonstandard.ts
1937
+ const problemOf = {
1938
+ instructions: "nonstandard-instructions",
1939
+ rule: "nonstandard-rule",
1940
+ command: "nonstandard-command",
1941
+ skill: "nonstandard-skill",
1942
+ subagent: "nonstandard-subagent"
1943
+ };
1944
+ /**
1945
+ * A `.mdc` rule splits on whether its scoping is load-bearing. `globs:` with a value binds the rule
1946
+ * to paths, and `AGENTS.md` has no equivalent — it scopes by directory nesting only — so that one
1947
+ * converts to a skill, or stays. Without it the rule is always-on prose, which `AGENTS.md` holds
1948
+ * verbatim. Read from frontmatter rather than assumed, because the two need different conversions
1949
+ * and guessing wrong sends prose to the wrong destination.
1950
+ */
1951
+ function scopedByGlobs(body) {
1952
+ const block = /^---\n([\s\S]*?)\n---/.exec(body);
1953
+ if (!block) return false;
1954
+ const value = /^globs:[ \t]*(.*)$/m.exec(block[1])?.[1]?.trim();
1955
+ return value !== void 0 && value.length > 0 && value !== "[]";
1956
+ }
1957
+ /** Every skill directory below `directory`, named by its `SKILL.md`. */
1958
+ function skillFiles(directory) {
1959
+ return filesUnder(directory).filter((file) => file.endsWith("SKILL.md"));
1960
+ }
1961
+ /**
1962
+ * A harness directory that is a symlink is a projection someone already made, not configuration
1963
+ * authored here — reporting it would tell a repository to convert what it already converted.
1964
+ */
1965
+ function authoredHere(path) {
1966
+ try {
1967
+ return !lstatSync(path).isSymbolicLink();
1968
+ } catch {
1969
+ return false;
1970
+ }
1971
+ }
1972
+ function artifactFiles(root, artifact) {
1973
+ const absolute = join(root, artifact.path);
1974
+ if (!authoredHere(absolute)) return [];
1975
+ if (artifact.shape === "file") return [artifact.path];
1976
+ return (artifact.kind === "skill" ? skillFiles(absolute) : filesUnder(absolute)).map((file) => posix.join(artifact.path, file));
1977
+ }
1978
+ /**
1979
+ * Unlike the other detectors this takes no harness preference and no enabled set. An artifact is
1980
+ * reachable by one harness whether or not that harness is enabled here — a `.cursorrules` in a
1981
+ * repository nobody opens in Cursor is still instruction content `AGENTS.md` does not carry — so
1982
+ * filtering by the enabled set would hide exactly the drift worth converting.
1983
+ */
1984
+ function diagnoseNonstandard({ root, cli }) {
1985
+ const findings = [];
1986
+ for (const harness of harnessRegistry) for (const artifact of harness.project.nonstandard ?? []) for (const path of artifactFiles(root, artifact)) {
1987
+ const kind = artifact.kind === "rule" && path.endsWith(".mdc") && !scopedByGlobs(readFileSync(join(root, path), "utf8")) ? "instructions" : artifact.kind;
1988
+ const problem = problemOf[kind];
1989
+ const { detail, repair } = repairFor(problem);
1990
+ findings.push({
1991
+ path,
1992
+ problem,
1993
+ detail,
1994
+ repair: repair({ file: path }, cli)
1995
+ });
1996
+ }
1997
+ return findings.sort((left, right) => left.path.localeCompare(right.path));
1998
+ }
1999
+ //#endregion
2000
+ //#region src/skill-projection/skill-projection.ts
2001
+ function skillNames(skillsDirectory) {
2002
+ return readdirSync(skillsDirectory, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
2003
+ }
2004
+ function pathOccupied(path) {
2005
+ try {
2006
+ lstatSync(path);
2007
+ return true;
2008
+ } catch {
2009
+ return false;
2010
+ }
2011
+ }
2012
+ /**
2013
+ * Whether `target` is a symbolic link that resolves to `canonical`. Resolved paths are compared
2014
+ * rather than link text, so a link a user wrote as an absolute path, one this tool wrote as a
2015
+ * Windows junction, and one reached through a symbolic link in a parent directory all read as the
2016
+ * correct bridge. A link that no longer resolves is not.
2017
+ */
2018
+ function linksTo(target, canonical) {
2019
+ try {
2020
+ if (!lstatSync(target).isSymbolicLink()) return false;
2021
+ return realpathSync(target) === realpathSync(canonical);
2022
+ } catch {
2023
+ return false;
2024
+ }
2025
+ }
2026
+ /** Creates the canonical directory when absent, so a fresh repository reports zero skills. */
2027
+ function countSkills(canonicalSkills) {
2028
+ mkdirSync(canonicalSkills, { recursive: true });
2029
+ return skillNames(canonicalSkills).length;
2030
+ }
2031
+ /**
2032
+ * A junction is the only link Windows grants an unprivileged process, and Node resolves a relative
2033
+ * junction target against the process directory rather than the link's own, so Windows is given the
2034
+ * absolute path. Every other platform keeps the link relative, so it survives the repository moving.
2035
+ */
2036
+ function linkTarget(target, canonicalSkills) {
2037
+ return platform() === "win32" ? resolve(canonicalSkills) : relative(dirname(target), canonicalSkills);
2038
+ }
2039
+ /**
2040
+ * Translates what the option parser hands back into a selection. A valueless `--force` arrives as
2041
+ * the literal string `true` — that is how the parser encodes a flag given no value — and means
2042
+ * every conflicting target, which is what keeps an existing `init --force` doing what it always did.
2043
+ * Anything else is a comma-separated list of targets.
2044
+ */
2045
+ function parseForce(value) {
2046
+ if (value === "true") return true;
2047
+ if (value === "false") return false;
2048
+ return value.split(",").map((target) => target.trim()).filter((target) => target.length > 0);
2049
+ }
2050
+ /**
2051
+ * Targets are matched against what the caller typed, resolved against the repository root, so the
2052
+ * repo-relative form the conflict message prints is the form that selects it back — and an absolute
2053
+ * path naming the same directory selects it too.
2054
+ */
2055
+ function namesTarget(root, target, given) {
2056
+ return resolve(root, given) === resolve(target);
2057
+ }
2058
+ /**
2059
+ * Points every harness that cannot read `.agents/skills` at the canonical directory. Symlinking is
2060
+ * preferred; a platform that refuses the link falls back to a copy.
2061
+ */
2062
+ function projectSkills({ root, canonicalSkills, harnesses, copy, force }) {
2063
+ const projections = harnesses.filter((harness) => harness.project.skillsDirectory).map((harness) => ({
2064
+ harness,
2065
+ target: join(root, harness.project.skillsDirectory)
2066
+ }));
2067
+ const named = Array.isArray(force) ? force : [];
2068
+ const unknown = named.filter((given) => !projections.some(({ target }) => namesTarget(root, target, given)));
2069
+ if (unknown.length) throw new Error(`No skill target matches:\n${unknown.map((given) => `- ${given}`).join("\n")}\nTargets for the enabled harnesses:\n${projections.map(({ target }) => `- ${relative(root, target)}`).join("\n")}`);
2070
+ const forces = (target) => force === true || named.some((given) => namesTarget(root, target, given));
2071
+ const blocked = projections.filter(({ target }) => pathOccupied(target) && !linksTo(target, canonicalSkills)).filter(({ target }) => !forces(target));
2072
+ if (blocked.length && named.length === 0) throw new Error(`Refusing to replace existing skill targets:\n${blocked.map(({ target }) => `- ${relative(root, target)}`).join("\n")}`);
2073
+ const skipped = new Set(blocked.map(({ harness }) => harness.name));
2074
+ for (const { harness, target } of projections) {
2075
+ if (skipped.has(harness.name)) continue;
2076
+ mkdirSync(dirname(target), { recursive: true });
2077
+ if (linksTo(target, canonicalSkills)) continue;
2078
+ if (pathOccupied(target)) rmSync(target, {
2079
+ recursive: true,
2080
+ force: true
2081
+ });
2082
+ if (copy) {
2083
+ cpSync(canonicalSkills, target, { recursive: true });
2084
+ continue;
2085
+ }
2086
+ try {
2087
+ symlinkSync(linkTarget(target, canonicalSkills), target, platform() === "win32" ? "junction" : void 0);
2088
+ } catch (error) {
2089
+ if (pathOccupied(target)) throw error;
2090
+ cpSync(canonicalSkills, target, { recursive: true });
2091
+ }
2092
+ }
2093
+ return {
2094
+ linked: projections.filter(({ harness }) => !skipped.has(harness.name)).map(({ harness }) => harness.name),
2095
+ skipped: projections.filter(({ harness }) => skipped.has(harness.name)).map(({ harness }) => harness.name)
2096
+ };
2097
+ }
2098
+ //#endregion
2099
+ //#region src/diagnose-bridges/agents-files.ts
2100
+ /** Directories that never hold instructions this repository is responsible for bridging. */
2101
+ const pruned = /* @__PURE__ */ new Set(["node_modules"]);
2102
+ /**
2103
+ * Every directory below `root` holding an `AGENTS.md`, repository-relative and POSIX-separated,
2104
+ * with the root itself as `''`. Sorted, root first.
2105
+ *
2106
+ * Dot-directories are pruned along with `node_modules`. That is not only a speed measure:
2107
+ * `.agents/AGENTS.md` is canonical shared instructions rather than a subtree-scoped file, and
2108
+ * bridging it would claim a scope it does not have.
2109
+ */
2110
+ function agentsFileDirectories(root) {
2111
+ const found = [];
2112
+ const walk = (relative) => {
2113
+ if (existsSync(join(root, relative, "AGENTS.md"))) found.push(relative);
2114
+ let entries;
2115
+ try {
2116
+ entries = readdirSync(join(root, relative), { withFileTypes: true });
2117
+ } catch {
2118
+ return;
2119
+ }
2120
+ for (const entry of entries) {
2121
+ if (!entry.isDirectory() || entry.name.startsWith(".") || pruned.has(entry.name)) continue;
2122
+ walk(relative ? posix.join(relative, entry.name) : entry.name);
2123
+ }
2124
+ };
2125
+ walk("");
2126
+ return found.sort();
2127
+ }
2128
+ //#endregion
2129
+ //#region src/diagnose-bridges/diagnose-instructions.ts
2130
+ const canonicalInstructions = "AGENTS.md";
2131
+ /**
2132
+ * An import bridges the `AGENTS.md` beside it and nothing else, so the check is per directory
2133
+ * holding one rather than per harness. A line of its own is enough: the harness reference allows
2134
+ * Claude-specific notes below the import, and a file that carries the import plus notes still
2135
+ * bridges.
2136
+ */
2137
+ function inspectImport(target) {
2138
+ let stats;
2139
+ try {
2140
+ stats = lstatSync(target);
2141
+ } catch {
2142
+ return {
2143
+ kind: "none",
2144
+ status: "missing",
2145
+ problem: "instructions-missing"
2146
+ };
2147
+ }
2148
+ if (stats.isSymbolicLink()) return readlinkSync(target) === "AGENTS.md" ? {
2149
+ kind: "symlink",
2150
+ status: "ok"
2151
+ } : {
2152
+ kind: "symlink",
2153
+ status: "unbridged",
2154
+ problem: "instructions-unbridged"
2155
+ };
2156
+ return readFileSync(target, "utf8").split("\n").some((line) => line.trim() === `@AGENTS.md`) ? {
2157
+ kind: "import",
2158
+ status: "ok"
2159
+ } : {
2160
+ kind: "file",
2161
+ status: "unbridged",
2162
+ problem: "instructions-unbridged"
2163
+ };
2164
+ }
2165
+ /** Reads a dotted key out of parsed JSON without asserting anything about the rest of the file. */
2166
+ function valueAt(settings, key) {
2167
+ return key.split(".").reduce((value, segment) => {
2168
+ if (typeof value !== "object" || value === null) return void 0;
2169
+ return value[segment];
2170
+ }, settings);
2171
+ }
2172
+ function inspectSettingsEntry(target, key) {
2173
+ let source;
2174
+ try {
2175
+ source = readFileSync(target, "utf8");
2176
+ } catch {
2177
+ return {
2178
+ kind: "none",
2179
+ status: "missing",
2180
+ problem: "instructions-missing"
2181
+ };
2182
+ }
2183
+ const settings = parseJsonWithComments(source);
2184
+ if (settings === void 0) return {
2185
+ kind: "file",
2186
+ status: "unreadable",
2187
+ problem: "instructions-unreadable"
2188
+ };
2189
+ const entry = valueAt(settings, key);
2190
+ return Array.isArray(entry) && entry.includes("AGENTS.md") ? {
2191
+ kind: "settings-entry",
2192
+ status: "ok"
2193
+ } : {
2194
+ kind: "file",
2195
+ status: "unbridged",
2196
+ problem: "instructions-unbridged"
2197
+ };
2198
+ }
2199
+ /** Every path an instruction bridge of this kind belongs at, in report order. */
2200
+ function pathsFor(root, bridge) {
2201
+ if (bridge.kind !== "import") return [bridge.path];
2202
+ return agentsFileDirectories(root).map((directory) => directory ? posix.join(directory, bridge.path) : bridge.path);
2203
+ }
2204
+ function inspect$1(target, bridge) {
2205
+ return bridge.kind === "import" ? inspectImport(target) : inspectSettingsEntry(target, bridge.key);
2206
+ }
2207
+ /**
2208
+ * Reports whether every enabled harness can still read `AGENTS.md`.
2209
+ *
2210
+ * Losing one of these is quieter than losing a skills bridge and costs more: a harness with no
2211
+ * instruction bridge reads none of the repository's instructions, and says nothing about it. The
2212
+ * checks are read-only, and no repair is a command — rewriting an instruction file is the `init`
2213
+ * skill's judgment.
2214
+ */
2215
+ function diagnoseInstructions(root, harnesses, cli) {
2216
+ const bridged = harnesses.map((harness) => ({
2217
+ name: harness.name,
2218
+ bridge: harness.project.instructionBridge
2219
+ })).filter((harness) => Boolean(harness.bridge));
2220
+ const instructions = [];
2221
+ const findings = [];
2222
+ if (!bridged.length) return {
2223
+ instructions,
2224
+ findings
2225
+ };
2226
+ if (!agentsFileDirectories(root).includes("")) {
2227
+ const { detail, repair } = repairFor("no-instructions");
2228
+ findings.push({
2229
+ path: canonicalInstructions,
2230
+ problem: "no-instructions",
2231
+ detail,
2232
+ repair: repair({ file: canonicalInstructions }, cli)
2233
+ });
2234
+ }
2235
+ for (const { name, bridge } of bridged) for (const path of pathsFor(root, bridge)) {
2236
+ const inspection = inspect$1(join(root, path), bridge);
2237
+ instructions.push({
2238
+ harness: name,
2239
+ path,
2240
+ kind: inspection.kind,
2241
+ status: inspection.status
2242
+ });
2243
+ if (inspection.problem) {
2244
+ const { detail, repair } = repairFor(inspection.problem);
2245
+ findings.push({
2246
+ path,
2247
+ problem: inspection.problem,
2248
+ detail,
2249
+ repair: repair({ file: path }, cli)
2250
+ });
2251
+ }
2252
+ }
2253
+ return {
2254
+ instructions,
2255
+ findings
2256
+ };
2257
+ }
2258
+ //#endregion
2259
+ //#region src/diagnose-bridges/git-bridge-state.ts
2260
+ function git(root, args, input) {
2261
+ try {
2262
+ return execFileSync("git", args, {
2263
+ cwd: root,
2264
+ encoding: "utf8",
2265
+ ...input === void 0 ? {} : { input },
2266
+ stdio: [
2267
+ "pipe",
2268
+ "pipe",
2269
+ "pipe"
2270
+ ]
2271
+ });
2272
+ } catch {
2273
+ return;
2274
+ }
2275
+ }
2276
+ function lines(output) {
2277
+ return (output ?? "").split("\n").filter(Boolean);
2278
+ }
2279
+ /**
2280
+ * Reads git state for the two sides of a bridge. Every method degrades to "cannot tell" rather than
2281
+ * throwing, so `doctor` still reports on a tarball, a worktree without git, or a repository with no
2282
+ * commits.
2283
+ */
2284
+ var GitBridgeState = class {
2285
+ root;
2286
+ /** Path of `root` relative to the repository root, `''` at the top, `undefined` outside a repository. */
2287
+ prefix;
2288
+ constructor(root) {
2289
+ this.root = root;
2290
+ this.prefix = git(root, ["rev-parse", "--show-prefix"])?.trim();
2291
+ }
2292
+ /**
2293
+ * `init --copy` marks a bridge skip-worktree so a machine-local copy over a tracked symlink stops
2294
+ * showing as a pending change. The bit is a hint that some checkouts clear, so it is verified
2295
+ * rather than assumed.
2296
+ */
2297
+ trackingOf(path) {
2298
+ if (this.prefix === void 0) return "untracked";
2299
+ const entries = lines(git(this.root, [
2300
+ "ls-files",
2301
+ "-v",
2302
+ "--",
2303
+ path
2304
+ ]));
2305
+ if (!entries.length) return "untracked";
2306
+ return entries.every((entry) => entry.startsWith("S")) ? "skip-worktree" : "tracked";
2307
+ }
2308
+ /**
2309
+ * Whether a `.gitignore` rule matches this path. Asked of git rather than matched against the
2310
+ * file, so a rule on a parent directory — the common way a bridge gets ignored — is caught.
2311
+ * Outside a repository nothing is ignored, which is the same "cannot tell" degradation the rest
2312
+ * of this class uses.
2313
+ */
2314
+ isIgnored(path) {
2315
+ if (this.prefix === void 0) return false;
2316
+ return git(this.root, [
2317
+ "check-ignore",
2318
+ "-q",
2319
+ "--",
2320
+ path
2321
+ ]) !== void 0;
2322
+ }
2323
+ /**
2324
+ * The newest commits that touched any of these paths, newest first. Empty outside a repository,
2325
+ * which is the same "cannot tell" degradation as everything else here.
2326
+ *
2327
+ * Bounded like `lastAgreedTree` is: a baseline that is not in the recent history of the two files
2328
+ * is not a baseline anyone can act on, and walking a whole repository to prove its absence costs
2329
+ * more than the answer is worth.
2330
+ */
2331
+ commitsTouching(paths) {
2332
+ if (this.prefix === void 0) return [];
2333
+ return lines(git(this.root, [
2334
+ "rev-list",
2335
+ "-n",
2336
+ "200",
2337
+ "HEAD",
2338
+ "--",
2339
+ ...paths
2340
+ ]));
2341
+ }
2342
+ /** One file's content at one commit, or `undefined` when it did not exist there. */
2343
+ contentAt(commit, path) {
2344
+ if (this.prefix === void 0) return void 0;
2345
+ return git(this.root, ["show", `${commit}:${this.prefix}${path}`]);
2346
+ }
2347
+ /**
2348
+ * Names the side that moved by finding the newest commit whose two trees agreed, then asking which
2349
+ * of the working directories still matches it.
2350
+ */
2351
+ directionOf(bridge, canonical) {
2352
+ if (this.prefix === void 0) return "unknown";
2353
+ const base = this.lastAgreedTree(bridge, canonical);
2354
+ if (base === void 0) return "unknown";
2355
+ const bridgeMoved = !this.matchesTree(bridge, base);
2356
+ const canonicalMoved = !this.matchesTree(canonical, base);
2357
+ if (bridgeMoved && canonicalMoved) return "both";
2358
+ if (bridgeMoved) return "bridge";
2359
+ return "canonical";
2360
+ }
2361
+ /** The tree of the newest commit where both paths held identical content. */
2362
+ lastAgreedTree(bridge, canonical) {
2363
+ for (const commit of lines(git(this.root, [
2364
+ "rev-list",
2365
+ "-n",
2366
+ "200",
2367
+ "HEAD",
2368
+ "--",
2369
+ bridge,
2370
+ canonical
2371
+ ]))) {
2372
+ const bridgeObject = git(this.root, [
2373
+ "rev-parse",
2374
+ "-q",
2375
+ "--verify",
2376
+ `${commit}:${this.prefix}${bridge}`
2377
+ ])?.trim();
2378
+ if (!bridgeObject) continue;
2379
+ if (bridgeObject === git(this.root, [
2380
+ "rev-parse",
2381
+ "-q",
2382
+ "--verify",
2383
+ `${commit}:${this.prefix}${canonical}`
2384
+ ])?.trim()) return bridgeObject;
2385
+ }
2386
+ }
2387
+ /**
2388
+ * Compares a working directory against a git tree by object id. `hash-object` is used rather than
2389
+ * hashing in-process so the comparison follows whatever object format the repository uses.
2390
+ */
2391
+ matchesTree(directory, tree) {
2392
+ const expected = new Map(lines(git(this.root, [
2393
+ "ls-tree",
2394
+ "-r",
2395
+ tree
2396
+ ])).map((entry) => {
2397
+ const [meta, path] = entry.split(" ");
2398
+ return [path, meta.split(" ")[2]];
2399
+ }));
2400
+ const files = filesUnder(join(this.root, directory));
2401
+ if (files.length !== expected.size || files.some((file) => !expected.has(file))) return false;
2402
+ const hashed = lines(git(this.root, ["hash-object", "--stdin-paths"], files.map((file) => join(directory, file)).join("\n")));
2403
+ return hashed.length === files.length && files.every((file, index) => expected.get(file) === hashed[index]);
2404
+ }
2405
+ };
2406
+ //#endregion
2407
+ //#region src/diagnose-bridges/diagnose-bridges.ts
2408
+ const canonicalPath = ".agents/skills";
2409
+ function pathState(target) {
2410
+ try {
2411
+ const stats = lstatSync(target);
2412
+ if (stats.isSymbolicLink()) return "symlink";
2413
+ return stats.isDirectory() ? "directory" : "file";
2414
+ } catch {
2415
+ return "missing";
2416
+ }
2417
+ }
2418
+ function isDirectory(path) {
2419
+ try {
2420
+ return statSync(path).isDirectory();
2421
+ } catch {
2422
+ return false;
2423
+ }
2424
+ }
2425
+ function sameContent(left, right) {
2426
+ const leftFiles = filesUnder(left);
2427
+ const rightFiles = filesUnder(right);
2428
+ return leftFiles.length === rightFiles.length && leftFiles.every((file, index) => rightFiles[index] === file && readFileSync(join(left, file)).equals(readFileSync(join(right, file))));
2429
+ }
2430
+ function inspect(target, path, canonical, git) {
2431
+ switch (pathState(target)) {
2432
+ case "missing": return {
2433
+ kind: "none",
2434
+ status: "missing",
2435
+ problem: "missing"
2436
+ };
2437
+ case "file": return {
2438
+ kind: "file",
2439
+ status: "degraded",
2440
+ problem: "degraded"
2441
+ };
2442
+ case "symlink": return linksTo(target, canonical) ? {
2443
+ kind: "symlink",
2444
+ status: "ok"
2445
+ } : {
2446
+ kind: "symlink",
2447
+ status: "stale",
2448
+ problem: "stale"
2449
+ };
2450
+ default:
2451
+ if (!sameContent(target, canonical)) {
2452
+ const direction = git.directionOf(path, canonicalPath);
2453
+ return {
2454
+ kind: "copy",
2455
+ status: "diverged",
2456
+ problem: `diverged-${direction}`,
2457
+ direction
2458
+ };
2459
+ }
2460
+ return git.trackingOf(path) === "tracked" ? {
2461
+ kind: "copy",
2462
+ status: "ok",
2463
+ problem: "unpinned-copy"
2464
+ } : {
2465
+ kind: "copy",
2466
+ status: "ok"
2467
+ };
2468
+ }
2469
+ }
2470
+ /**
2471
+ * Reports whether every bridge this repository needs still resolves: the skills bridges into
2472
+ * `.agents/skills`, and the instruction bridges into `AGENTS.md`. Read-only: nothing is created,
2473
+ * moved, or repaired, so the caller decides what to run from the repair each finding carries.
2474
+ */
2475
+ function diagnoseBridges({ root, harnesses: preferred = [], cli }) {
2476
+ const canonical = join(root, ".agents", "skills");
2477
+ const git = new GitBridgeState(root);
2478
+ const selected = selectHarnesses(root, preferred);
2479
+ const bridged = selected.map((harness) => ({
2480
+ name: harness.name,
2481
+ skillsDirectory: harness.project.skillsDirectory
2482
+ })).filter((harness) => Boolean(harness.skillsDirectory));
2483
+ const bridges = [];
2484
+ const divergence = [];
2485
+ const findings = [];
2486
+ if (!isDirectory(canonical)) {
2487
+ const { detail, repair } = repairFor("no-canonical");
2488
+ findings.push({
2489
+ path: canonicalPath,
2490
+ problem: "no-canonical",
2491
+ detail,
2492
+ repair: repair({ file: canonicalPath }, cli)
2493
+ });
2494
+ }
2495
+ for (const harness of bridged) {
2496
+ const path = harness.skillsDirectory;
2497
+ const inspection = inspect(join(root, path), path, canonical, git);
2498
+ bridges.push({
2499
+ harness: harness.name,
2500
+ path,
2501
+ kind: inspection.kind,
2502
+ status: inspection.status
2503
+ });
2504
+ if (inspection.direction) divergence.push({
2505
+ path,
2506
+ direction: inspection.direction
2507
+ });
2508
+ if (inspection.problem) {
2509
+ const { detail, repair } = repairFor(inspection.problem);
2510
+ findings.push({
2511
+ path,
2512
+ problem: inspection.problem,
2513
+ detail,
2514
+ repair: repair({ file: path }, cli)
2515
+ });
2516
+ }
2517
+ }
2518
+ const instructions = diagnoseInstructions(root, selected, cli);
2519
+ findings.push(...instructions.findings);
2520
+ return {
2521
+ bridges,
2522
+ instructions: instructions.instructions,
2523
+ divergence,
2524
+ findings
2525
+ };
2526
+ }
2527
+ //#endregion
2528
+ //#region src/diagnose-bridges/doctor.command.ts
2529
+ /**
2530
+ * AXI §5: the healthy answer states the zero with context, so an agent does not re-run with other
2531
+ * flags to confirm that an empty section really meant "nothing wrong".
2532
+ */
2533
+ function buildDoctorReport(bin, result, configuration = []) {
2534
+ const findings = [...result.findings, ...configuration];
2535
+ if (!findings.length) {
2536
+ const count = result.bridges.length + result.instructions.length;
2537
+ const bridges = count === 1 ? "the 1 bridge resolves" : `all ${count} bridges resolve`;
2538
+ return {
2539
+ bin,
2540
+ bridges: result.bridges,
2541
+ instructions: result.instructions,
2542
+ findings: `0 problems found — ${bridges} and the configuration around them is current`
2543
+ };
2544
+ }
2545
+ return {
2546
+ bin,
2547
+ bridges: result.bridges,
2548
+ instructions: result.instructions,
2549
+ ...result.divergence.length ? { divergence: result.divergence } : {},
2550
+ findings: findings.map(({ path, problem, detail }) => ({
2551
+ path,
2552
+ problem,
2553
+ detail
2554
+ })),
2555
+ help: [...new Map(findings.map((finding) => [`${finding.repair.command}\u0000${finding.repair.instruction}`, finding.repair])).values()]
2556
+ };
2557
+ }
2558
+ const doctorCommand = command({
2559
+ name: "doctor",
2560
+ description: "Report whether this repository's harness bridges still resolve — skills into .agents/skills, instructions into AGENTS.md. Read-only.",
2561
+ options: {
2562
+ root: {
2563
+ description: "Repository or package directory. Defaults to the current directory.",
2564
+ type: z.optional(z.string())
2565
+ },
2566
+ harness: {
2567
+ description: "Comma-separated harnesses to check in addition to Claude Code and Cursor, e.g. codex,gemini-cli.",
2568
+ type: z.optional(z.string())
2569
+ },
2570
+ format: {
2571
+ description: "Output format: toon (default), json, or text for a human-readable report.",
2572
+ type: z.optional(z.string()),
2573
+ default: "toon"
2574
+ }
2575
+ },
2576
+ run(args) {
2577
+ try {
2578
+ const format = parseFormat(args.format);
2579
+ const harnesses = parseHarnesses(args.harness);
2580
+ const root = args.root ?? process.cwd();
2581
+ const result = diagnoseBridges({
2582
+ root,
2583
+ ...harnesses.length ? { harnesses } : {},
2584
+ cli: commandInvocation
2585
+ });
2586
+ const git = new GitBridgeState(root);
2587
+ const configuration = [
2588
+ ...diagnoseConfiguration({
2589
+ root,
2590
+ git,
2591
+ cli: commandInvocation
2592
+ }),
2593
+ ...diagnoseMcp({
2594
+ root,
2595
+ git,
2596
+ cli: commandInvocation
2597
+ }),
2598
+ ...diagnoseNonstandard({
2599
+ root,
2600
+ cli: commandInvocation
2601
+ })
2602
+ ];
2603
+ writeResult(buildDoctorReport(binPath(homedir(), process.argv[1]), result, configuration), format);
2604
+ return exitCodes.success;
2605
+ } catch (error) {
2606
+ process.stderr.write(`error: ${error instanceof Error ? error.message : "Harness diagnosis failed."}\n`);
2607
+ return exitCodes.error;
2608
+ }
2609
+ }
2610
+ });
2611
+ //#endregion
2612
+ //#region src/initialize-harnesses/initialize-harnesses.ts
2613
+ function initializeHarnesses({ root, harnesses: preferred = [], copy = false, force = false }) {
2614
+ const canonicalSkills = join(root, ".agents", "skills");
2615
+ const skills = countSkills(canonicalSkills);
2616
+ const harnesses = selectHarnesses(root, preferred);
2617
+ const { linked, skipped } = projectSkills({
2618
+ root,
2619
+ canonicalSkills,
2620
+ harnesses,
2621
+ copy,
2622
+ force
2623
+ });
2624
+ return {
2625
+ root,
2626
+ harnesses: harnesses.map((harness) => harness.name),
2627
+ native: harnesses.filter((harness) => !harness.project.skillsDirectory).map((harness) => harness.name),
2628
+ linked,
2629
+ skipped,
2630
+ deprecated: harnesses.filter((harness) => harness.deprecated).map((harness) => ({
2631
+ name: harness.name,
2632
+ replacedBy: harness.deprecated
2633
+ })),
2634
+ skills,
2635
+ copied: copy
2636
+ };
2637
+ }
2638
+ //#endregion
2639
+ //#region src/initialize-harnesses/init.command.ts
2640
+ const initCommand = command({
2641
+ name: "init",
2642
+ description: "Set up canonical agent skills for the harnesses already enabled in a repository.",
2643
+ options: {
2644
+ root: {
2645
+ description: "Repository or package directory. Defaults to the current directory.",
2646
+ type: z.optional(z.string())
2647
+ },
2648
+ harness: {
2649
+ description: "Comma-separated harnesses to enable in addition to Claude Code and Cursor, e.g. codex,gemini-cli.",
2650
+ type: z.optional(z.string())
2651
+ },
2652
+ copy: {
2653
+ description: "Copy skills instead of linking them.",
2654
+ type: z.optional(z.boolean())
2655
+ },
2656
+ force: {
2657
+ description: "Replace conflicting target skill directories. Names the targets to replace, comma-separated (e.g. .claude/skills); given alone it replaces every conflicting target.",
2658
+ type: z.optional(z.string())
2659
+ },
2660
+ format: {
2661
+ description: "Output format: toon (default), json, or text for a human-readable report.",
2662
+ type: z.optional(z.string()),
2663
+ default: "toon"
2664
+ }
2665
+ },
2666
+ run(args) {
2667
+ try {
2668
+ const format = parseFormat(args.format);
2669
+ const harnesses = parseHarnesses(args.harness);
2670
+ writeResult(initializeHarnesses({
2671
+ root: args.root ?? process.cwd(),
2672
+ ...harnesses.length ? { harnesses } : {},
2673
+ ...args.copy === void 0 ? {} : { copy: args.copy },
2674
+ ...args.force === void 0 ? {} : { force: parseForce(args.force) }
2675
+ }), format);
2676
+ return exitCodes.success;
2677
+ } catch (error) {
2678
+ process.stderr.write(`error: ${error instanceof Error ? error.message : "Harness initialization failed."}\n`);
2679
+ return exitCodes.error;
2680
+ }
2681
+ }
2682
+ });
2683
+ const harnessCommand = command({
2684
+ name: "agent-harness",
2685
+ description: "Commands for configuring agent harness compatibility.",
2686
+ commands: [initCommand, doctorCommand]
2687
+ });
2688
+ function activate({ addCommand }) {
2689
+ addCommand(harnessCommand);
2690
+ }
2691
+ //#endregion
2692
+ //#region src/cli.ts
2693
+ /**
2694
+ * Read from the manifest rather than restated here: a version written into this call is a second
2695
+ * home for a number that already has one, and it stayed at `0.1.0` for five minor releases.
2696
+ *
2697
+ * `../package.json` resolves from `src/cli.ts` and from the bundled `dist/cli.mjs` alike — both sit
2698
+ * one directory under the package root.
2699
+ */
2700
+ const version = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
2701
+ /**
2702
+ * A factory rather than a module-level constant: `cli()` builds state, and state built at import
2703
+ * time is shared by every later call in the process.
2704
+ */
2705
+ function app() {
2706
+ return cli({
2707
+ name: "buddy-agent-harness",
2708
+ version,
2709
+ description: "Initialize agent harness skill compatibility in consumer repositories."
2710
+ }).command(initCommand).command(doctorCommand).command(depPluginsCommand);
2711
+ }
2712
+ /**
2713
+ * The application boundary: argv in, exit code out, nothing read from or written to the process.
2714
+ *
2715
+ * The exit code is returned rather than written so that a caller which is not the process — a skill
2716
+ * launcher, a test — can run a command and learn how it went. `bin/buddy-agent-harness.mjs` is the
2717
+ * only place that turns the returned code back into a process outcome.
2718
+ *
2719
+ * One exit path this return cannot carry: `clibuilder` answers an unknown option or an unknown
2720
+ * command by printing help and writing `process.exitCode` itself, returning nothing. `run` reports
2721
+ * `0` there, which is why `bin` and the launchers apply the returned code only when it is non-zero.
2722
+ */
2723
+ async function run(argv) {
2724
+ try {
2725
+ const code = await app().parse(argv);
2726
+ return typeof code === "number" ? code : exitCodes.success;
2727
+ } catch (error) {
2728
+ process.stderr.write(`error: ${error instanceof Error ? error.message : "Invalid command."}\n`);
2729
+ return exitCodes.usage;
2730
+ }
2731
+ }
2732
+ //#endregion
2
2733
  export { activate, bridgeRepairs, buildDoctorReport, diagnoseBridges, diagnoseInstructions, diagnoseMcp, doctorCommand, doctorRepairs, doctorSkill, goldenSetPath, harnessCommand, harnessRegistry, initCommand, initializeHarnesses, instructionRepairs, renderDoctorSkill, run };
2734
+
2735
+ //# sourceMappingURL=index.mjs.map