buddy-agent-harness 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/README.md +9 -1
- package/bin/buddy-agent-harness.mjs +8 -3
- package/dist/cli-4ekejn5W.mjs +2735 -0
- package/dist/cli-4ekejn5W.mjs.map +1 -0
- package/dist/cli.d.mts +12 -2
- package/dist/cli.d.mts.map +1 -1
- package/dist/cli.mjs +2 -20
- package/dist/index.d.mts +389 -54
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -2
- package/package.json +6 -5
- package/plugin.json +1 -1
- package/skills/doctor/SKILL.md +20 -34
- package/skills/doctor/references/bridges.md +23 -0
- package/skills/doctor/references/configuration.md +12 -0
- package/skills/doctor/references/harnesses/claude-code.md +39 -0
- package/skills/doctor/references/harnesses/codex.md +37 -0
- package/skills/doctor/references/harnesses/copilot-cli.md +39 -0
- package/skills/doctor/references/harnesses/cursor.md +40 -0
- package/skills/doctor/references/harnesses/devin-desktop.md +20 -0
- package/skills/doctor/references/harnesses/gemini-cli.md +38 -0
- package/skills/doctor/references/harnesses/windsurf.md +27 -0
- package/skills/doctor/references/instructions.md +14 -0
- package/skills/doctor/references/mcp.md +31 -0
- package/skills/doctor/references/nonstandard.md +27 -0
- package/skills/doctor/scripts/doctor.mjs +7 -3
- package/skills/enhance/SKILL.md +4 -0
- package/skills/enhance/references/delegation.md +3 -1
- package/skills/init/README.md +1 -1
- package/skills/init/SKILL.md +19 -8
- package/skills/init/references/detection.md +5 -3
- package/skills/init/references/harnesses/cursor.md +9 -2
- package/skills/init/scripts/doctor.mjs +14 -0
- package/skills/init/scripts/init.mjs +7 -3
- package/skills/repair/README.md +21 -0
- package/skills/repair/SKILL.md +74 -0
- package/skills/repair/references/classes.md +49 -0
- package/skills/repair/scripts/doctor.mjs +14 -0
- package/src/cli.ts +42 -7
- package/src/command-output/command-output.ts +1 -4
- package/src/dep-plugins/dep-plugins.command.ts +168 -0
- package/src/dep-plugins/dep-plugins.ts +131 -0
- package/src/dep-plugins/generate.ts +115 -0
- package/src/dep-plugins/harness-plugins.ts +232 -0
- package/src/dep-plugins/reconcile.ts +132 -0
- package/src/dep-plugins/resolve.ts +117 -0
- package/src/diagnose-bridges/diagnose-bridges.ts +21 -13
- package/src/diagnose-bridges/diagnose-instructions.ts +7 -2
- package/src/diagnose-bridges/doctor-guidance.ts +636 -90
- package/src/diagnose-bridges/doctor.command.ts +55 -28
- package/src/diagnose-bridges/git-bridge-state.ts +30 -0
- package/src/diagnose-bridges/locator.ts +36 -0
- package/src/diagnose-configuration/diagnose-configuration.ts +99 -0
- package/src/diagnose-mcp/diagnose-mcp.ts +135 -0
- package/src/diagnose-mcp/mcp-baseline.ts +122 -0
- package/src/diagnose-mcp/mcp-model.ts +88 -0
- package/src/diagnose-mcp/mcp-secrets.ts +101 -0
- package/src/diagnose-mcp/mcp-sources.ts +131 -0
- package/src/diagnose-nonstandard/diagnose-nonstandard.ts +109 -0
- package/src/harness-registry/harness-registry.ts +97 -4
- package/src/harness-registry/mcp-config.ts +37 -0
- package/src/harness-registry/nonstandard-artifact.ts +39 -0
- package/src/index.ts +18 -2
- package/src/initialize-harnesses/init.command.ts +13 -18
- package/src/initialize-harnesses/initialize-harnesses.ts +6 -3
- package/src/is-record/is-record.ts +12 -0
- package/src/skill-projection/skill-projection.ts +102 -16
- package/dist/cli.mjs.map +0 -1
- package/dist/init.command-zbntulIq.mjs +0 -1016
- package/dist/init.command-zbntulIq.mjs.map +0 -1
|
@@ -0,0 +1,2735 @@
|
|
|
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
|
|
2733
|
+
export { harnessRegistry as _, initializeHarnesses as a, diagnoseBridges as c, goldenSetPath as d, bridgeRepairs as f, renderDoctorSkill as g, instructionRepairs as h, initCommand as i, diagnoseInstructions as l, doctorSkill as m, activate as n, buildDoctorReport as o, doctorRepairs as p, harnessCommand as r, doctorCommand as s, run as t, diagnoseMcp as u };
|
|
2734
|
+
|
|
2735
|
+
//# sourceMappingURL=cli-4ekejn5W.mjs.map
|