buddy-agent-harness 0.2.0 → 0.3.1

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.
@@ -0,0 +1,686 @@
1
+ import { command, z } from "clibuilder";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join, posix, relative, sep } from "node:path";
4
+ import { encode } from "@toon-format/toon";
5
+ import { cpSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, rmSync, statSync, symlinkSync } from "node:fs";
6
+ import { execFileSync } from "node:child_process";
7
+ //#region src/command-output/command-output.ts
8
+ const formats = [
9
+ "toon",
10
+ "json",
11
+ "text"
12
+ ];
13
+ /** Rejects anything but the supported formats so an unknown value never falls back silently. */
14
+ function parseFormat(value) {
15
+ if (!formats.includes(value)) throw new Error("--format must be toon, json, or text.");
16
+ return value;
17
+ }
18
+ /** The single stdout boundary: internal logic stays on plain objects, encoding happens here. */
19
+ function writeResult(value, format) {
20
+ process.stdout.write(`${encodeResult(value, format)}\n`);
21
+ }
22
+ function encodeResult(value, format) {
23
+ if (format === "json") return JSON.stringify(value);
24
+ return format === "text" ? renderText(value) : encode(value);
25
+ }
26
+ function isRecord(value) {
27
+ return typeof value === "object" && value !== null && !Array.isArray(value);
28
+ }
29
+ function cell(value) {
30
+ return value === void 0 ? "" : String(value);
31
+ }
32
+ /** A header row plus one row per record, every column padded to its widest cell. */
33
+ function table(rows) {
34
+ const columns = [...new Set(rows.flatMap((row) => Object.keys(row)))];
35
+ const widths = columns.map((column) => Math.max(column.length, ...rows.map((row) => cell(row[column]).length)));
36
+ const line = (values) => ` ${values.map((value, index) => value.padEnd(widths[index])).join(" ")}`.trimEnd();
37
+ return [line(columns), ...rows.map((row) => line(columns.map((column) => cell(row[column]))))];
38
+ }
39
+ /**
40
+ * The same result rendered for a person rather than for an agent: scalars as `key: value`, records
41
+ * as an aligned table, and everything else as a bulleted list. TOON stays the default because it is
42
+ * what an agent parses; this is for reading over someone's shoulder.
43
+ */
44
+ function renderText(value) {
45
+ const blocks = Object.entries(value).map(([key, item]) => {
46
+ if (!Array.isArray(item)) return [`${key}: ${isRecord(item) ? JSON.stringify(item) : String(item)}`];
47
+ if (!item.length) return [`${key}: (none)`];
48
+ const body = item.every(isRecord) ? table(item) : item.map((entry) => ` - ${String(entry)}`);
49
+ return [`${key}:`, ...body];
50
+ });
51
+ return blocks.flatMap((block, index) => {
52
+ const previous = blocks[index - 1];
53
+ return previous && (previous.length > 1 || block.length > 1) ? ["", ...block] : block;
54
+ }).join("\n");
55
+ }
56
+ /** AXI §10: the executable's absolute path, with the user's home directory collapsed to `~`. */
57
+ function binPath(home, executable) {
58
+ if (!executable) return "buddy-agent-harness";
59
+ return home && executable.startsWith(home + sep) ? `~${executable.slice(home.length)}` : executable;
60
+ }
61
+ //#endregion
62
+ //#region src/harness-registry/harness-registry.ts
63
+ /**
64
+ * Codex, Cursor, Copilot CLI, and Devin Desktop read `.agents/skills` directly, so they need no
65
+ * projection. Only Claude Code and Gemini CLI read solely their own directory.
66
+ *
67
+ * `windsurf` is the former name of Devin Desktop, rebranded 2026-06-02. It is retained as a
68
+ * deprecated alias: Devin still scans the legacy `.windsurf/skills` path, so its projection keeps
69
+ * working, but new repositories should enable `devin-desktop` and have nothing written for them.
70
+ *
71
+ * See `.research/agentic-configuration-standards/` for the per-harness sources.
72
+ */
73
+ const harnessRegistry = [
74
+ {
75
+ name: "claude-code",
76
+ detect: ".claude",
77
+ skillsDirectory: ".claude/skills"
78
+ },
79
+ {
80
+ name: "cursor",
81
+ detect: ".cursor"
82
+ },
83
+ {
84
+ name: "codex",
85
+ detect: ".codex"
86
+ },
87
+ {
88
+ name: "copilot-cli",
89
+ detect: ".github/skills"
90
+ },
91
+ {
92
+ name: "gemini-cli",
93
+ detect: ".gemini",
94
+ skillsDirectory: ".gemini/skills"
95
+ },
96
+ {
97
+ name: "devin-desktop",
98
+ detect: ".devin"
99
+ },
100
+ {
101
+ name: "windsurf",
102
+ detect: ".windsurf",
103
+ skillsDirectory: ".windsurf/skills",
104
+ deprecated: "devin-desktop"
105
+ }
106
+ ];
107
+ /** Enabled whether or not the repository already contains their directories. */
108
+ const defaultHarnesses = ["claude-code", "cursor"];
109
+ function directoryExists(path) {
110
+ try {
111
+ return lstatSync(path).isDirectory();
112
+ } catch {
113
+ return false;
114
+ }
115
+ }
116
+ /** The default harnesses, plus the preferred ones, plus every harness whose directory is present. */
117
+ function selectHarnesses(root, preferred) {
118
+ const enabled = /* @__PURE__ */ new Set([...defaultHarnesses, ...preferred]);
119
+ return harnessRegistry.filter((harness) => enabled.has(harness.name) || directoryExists(join(root, harness.detect)));
120
+ }
121
+ //#endregion
122
+ //#region src/diagnose-bridges/directory-files.ts
123
+ /**
124
+ * Every file below `directory`, as paths relative to it, POSIX-separated and sorted. A directory
125
+ * that does not exist reads as empty: `doctor` never creates one to look at it.
126
+ */
127
+ function filesUnder(directory) {
128
+ try {
129
+ return readdirSync(directory, {
130
+ recursive: true,
131
+ withFileTypes: true
132
+ }).filter((entry) => entry.isFile()).map((entry) => relative(directory, join(entry.parentPath, entry.name)).split(sep).join(posix.sep)).sort();
133
+ } catch {
134
+ return [];
135
+ }
136
+ }
137
+ //#endregion
138
+ //#region src/diagnose-bridges/doctor-guidance.ts
139
+ /** How the command names itself. A skill may run without the binary on PATH, so it uses `npx`. */
140
+ const commandInvocation = "buddy-agent-harness";
141
+ const skillInvocation = "npx -y buddy-agent-harness";
142
+ /** How the skill hands a repair back to `init`. */
143
+ const initSkillInvocation = "/buddy-agent-harness:init";
144
+ const doctorRepairs = [
145
+ {
146
+ problem: "no-canonical",
147
+ detail: "the canonical skill directory does not exist, so no bridge can resolve",
148
+ repair: (_path, cli) => `${cli} init`,
149
+ skillRepair: () => `run \`${initSkillInvocation}\`, which creates \`.agents/skills\` and the bridges`
150
+ },
151
+ {
152
+ problem: "missing",
153
+ detail: "no bridge at this path — the harness sees zero project skills",
154
+ repair: (_path, cli) => `${cli} init`,
155
+ skillRepair: () => `run \`${initSkillInvocation}\``
156
+ },
157
+ {
158
+ problem: "degraded",
159
+ detail: "expected a directory but found a regular file — checkout without core.symlinks",
160
+ repair: (_path, cli) => `${cli} init --copy --force`,
161
+ skillRepair: () => `run \`${initSkillInvocation} --copy --force\``
162
+ },
163
+ {
164
+ problem: "stale",
165
+ detail: "symlink does not resolve to .agents/skills",
166
+ repair: (_path, cli) => `${cli} init --force`,
167
+ skillRepair: () => `run \`${initSkillInvocation} --force\``
168
+ },
169
+ {
170
+ problem: "diverged-bridge",
171
+ detail: "only the bridge changed since the two last agreed — an agent wrote through the copy",
172
+ repair: (path, cli) => `replace .agents/skills with ${path} to keep the newer edit and then run ${cli} init --force`,
173
+ skillRepair: (path) => `replace .agents/skills with ${path} to keep the newer edit, then run \`${initSkillInvocation} --force\``
174
+ },
175
+ {
176
+ problem: "diverged-canonical",
177
+ detail: "only .agents/skills changed since the two last agreed — the copy is stale",
178
+ repair: (_path, cli) => `${cli} init --copy --force`,
179
+ skillRepair: () => `run \`${initSkillInvocation} --copy --force\``
180
+ },
181
+ {
182
+ problem: "diverged-both",
183
+ detail: "both sides changed since they last agreed — rebuilding would discard one of them",
184
+ repair: (path) => `git diff --no-index .agents/skills ${path} and reconcile by hand`,
185
+ skillRepair: (path) => `run \`git diff --no-index .agents/skills ${path}\` and reconcile by hand`
186
+ },
187
+ {
188
+ problem: "diverged-unknown",
189
+ detail: "contents differ and no commit where they agreed was found — which side moved is unknown",
190
+ repair: (path) => `git diff --no-index .agents/skills ${path} and reconcile by hand`,
191
+ skillRepair: (path) => `run \`git diff --no-index .agents/skills ${path}\` and reconcile by hand`
192
+ },
193
+ {
194
+ problem: "unpinned-copy",
195
+ detail: "tracked copy without the skip-worktree bit — the tree is dirty with content that must not be committed",
196
+ repair: (path) => `git ls-files -z ${path} | xargs -0 git update-index --skip-worktree`,
197
+ skillRepair: (path) => `run \`git ls-files -z ${path} | xargs -0 git update-index --skip-worktree\``
198
+ }
199
+ ];
200
+ const repairsByProblem = new Map(doctorRepairs.map((entry) => [entry.problem, entry]));
201
+ function repairFor(problem) {
202
+ return repairsByProblem.get(problem);
203
+ }
204
+ const doctorSkill = {
205
+ name: "doctor",
206
+ description: "Use this skill when a repository loads no project skills, when skills are missing after a clone, or when checking whether the agent configuration bridges into .claude/skills and the other harness directories still resolve."
207
+ };
208
+ /** Written into the generated skill so a reader knows not to edit it in place. */
209
+ const generatedSkillWarning = "<!-- Generated from src/diagnose-bridges/doctor-guidance.ts by scripts/generate-doctor-skill.ts. Do not edit by hand. -->";
210
+ /** The shipped `doctor` skill, rendered from the same table the command prints. */
211
+ function renderDoctorSkill() {
212
+ const cell = (value) => value.replaceAll("|", "\\|");
213
+ const rows = doctorRepairs.map((entry) => `| \`${entry.problem}\` | ${entry.detail} | ${cell(entry.skillRepair("<path>"))} |`).join("\n");
214
+ return `---
215
+ name: ${doctorSkill.name}
216
+ description: ${doctorSkill.description}
217
+ ---
218
+
219
+ ${generatedSkillWarning}
220
+
221
+ # Harness Doctor
222
+
223
+ \`.agents/skills\` is the canonical skill directory. Harnesses that cannot read it — Claude Code and Gemini CLI — get a bridge pointing at it, normally a directory symlink. A bridge that stops resolving is silent: the harness finds nothing and loads zero project skills, with no warning anywhere.
224
+
225
+ Diagnose it:
226
+
227
+ \`\`\`sh
228
+ ${skillInvocation} doctor
229
+ \`\`\`
230
+
231
+ The command is read-only. It never repairs anything, so it is safe to run at any point, including from a session-start hook.
232
+
233
+ ## Reading the report
234
+
235
+ \`bridges\` lists every bridge \`init\` would create for this repository, each with a \`status\` of \`ok\`, \`missing\`, \`degraded\`, \`stale\`, or \`diverged\`. \`findings\` explains each problem and \`help\` names its repair. Apply the repair from the table below, then re-run \`doctor\`.
236
+
237
+ \`help\` names the \`init\` command for a person at a shell. Do not run it yourself. Rebuilding a bridge can move skills a user wrote, and that judgment belongs to the \`init\` skill — hand the repair to \`${initSkillInvocation}\` instead.
238
+
239
+ When every bridge resolves, \`findings\` says so outright rather than being empty.
240
+
241
+ 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\`.
242
+
243
+ ## Findings and their repairs
244
+
245
+ | Finding | What it means | Repair |
246
+ | --- | --- | --- |
247
+ ${rows}
248
+
249
+ Substitute the reported bridge path for \`<path>\`.
250
+
251
+ ## The Windows case
252
+
253
+ The common failure is \`degraded\`. Creating a symlink on Windows needs a privilege most accounts do not have, and Git for Windows gates it separately with \`core.symlinks\`, which its installer leaves off. With \`core.symlinks=false\` git does not error — it writes the symlink out as a regular file whose contents are the target path. \`${initSkillInvocation} --copy --force\` rebuilds the bridges as real directories on that machine.
254
+
255
+ A copy is a snapshot rather than a live projection, and an agent that edits a skill through it writes into the copy instead of into \`.agents/skills\`. That is what the \`diverged\` findings catch.
256
+
257
+ ## Rules
258
+
259
+ - 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.
260
+ - Edit skills at \`.agents/skills/<name>/SKILL.md\`. Editing through a bridge is only safe when that bridge is a symlink.
261
+ - Do not add bridges to \`.gitignore\`. An untracked bridge swallows a real edit silently.
262
+ `;
263
+ }
264
+ //#endregion
265
+ //#region src/diagnose-bridges/git-bridge-state.ts
266
+ function git(root, args, input) {
267
+ try {
268
+ return execFileSync("git", args, {
269
+ cwd: root,
270
+ encoding: "utf8",
271
+ ...input === void 0 ? {} : { input },
272
+ stdio: [
273
+ "pipe",
274
+ "pipe",
275
+ "pipe"
276
+ ]
277
+ });
278
+ } catch {
279
+ return;
280
+ }
281
+ }
282
+ function lines(output) {
283
+ return (output ?? "").split("\n").filter(Boolean);
284
+ }
285
+ /**
286
+ * Reads git state for the two sides of a bridge. Every method degrades to "cannot tell" rather than
287
+ * throwing, so `doctor` still reports on a tarball, a worktree without git, or a repository with no
288
+ * commits.
289
+ */
290
+ var GitBridgeState = class {
291
+ root;
292
+ /** Path of `root` relative to the repository root, `''` at the top, `undefined` outside a repository. */
293
+ prefix;
294
+ constructor(root) {
295
+ this.root = root;
296
+ this.prefix = git(root, ["rev-parse", "--show-prefix"])?.trim();
297
+ }
298
+ /**
299
+ * `init --copy` marks a bridge skip-worktree so a machine-local copy over a tracked symlink stops
300
+ * showing as a pending change. The bit is a hint that some checkouts clear, so it is verified
301
+ * rather than assumed.
302
+ */
303
+ trackingOf(path) {
304
+ if (this.prefix === void 0) return "untracked";
305
+ const entries = lines(git(this.root, [
306
+ "ls-files",
307
+ "-v",
308
+ "--",
309
+ path
310
+ ]));
311
+ if (!entries.length) return "untracked";
312
+ return entries.every((entry) => entry.startsWith("S")) ? "skip-worktree" : "tracked";
313
+ }
314
+ /**
315
+ * Names the side that moved by finding the newest commit whose two trees agreed, then asking which
316
+ * of the working directories still matches it.
317
+ */
318
+ directionOf(bridge, canonical) {
319
+ if (this.prefix === void 0) return "unknown";
320
+ const base = this.lastAgreedTree(bridge, canonical);
321
+ if (base === void 0) return "unknown";
322
+ const bridgeMoved = !this.matchesTree(bridge, base);
323
+ const canonicalMoved = !this.matchesTree(canonical, base);
324
+ if (bridgeMoved && canonicalMoved) return "both";
325
+ if (bridgeMoved) return "bridge";
326
+ return "canonical";
327
+ }
328
+ /** The tree of the newest commit where both paths held identical content. */
329
+ lastAgreedTree(bridge, canonical) {
330
+ for (const commit of lines(git(this.root, [
331
+ "rev-list",
332
+ "-n",
333
+ "200",
334
+ "HEAD",
335
+ "--",
336
+ bridge,
337
+ canonical
338
+ ]))) {
339
+ const bridgeObject = git(this.root, [
340
+ "rev-parse",
341
+ "-q",
342
+ "--verify",
343
+ `${commit}:${this.prefix}${bridge}`
344
+ ])?.trim();
345
+ if (!bridgeObject) continue;
346
+ if (bridgeObject === git(this.root, [
347
+ "rev-parse",
348
+ "-q",
349
+ "--verify",
350
+ `${commit}:${this.prefix}${canonical}`
351
+ ])?.trim()) return bridgeObject;
352
+ }
353
+ }
354
+ /**
355
+ * Compares a working directory against a git tree by object id. `hash-object` is used rather than
356
+ * hashing in-process so the comparison follows whatever object format the repository uses.
357
+ */
358
+ matchesTree(directory, tree) {
359
+ const expected = new Map(lines(git(this.root, [
360
+ "ls-tree",
361
+ "-r",
362
+ tree
363
+ ])).map((entry) => {
364
+ const [meta, path] = entry.split(" ");
365
+ return [path, meta.split(" ")[2]];
366
+ }));
367
+ const files = filesUnder(join(this.root, directory));
368
+ if (files.length !== expected.size || files.some((file) => !expected.has(file))) return false;
369
+ const hashed = lines(git(this.root, ["hash-object", "--stdin-paths"], files.map((file) => join(directory, file)).join("\n")));
370
+ return hashed.length === files.length && files.every((file, index) => expected.get(file) === hashed[index]);
371
+ }
372
+ };
373
+ //#endregion
374
+ //#region src/diagnose-bridges/diagnose-bridges.ts
375
+ const canonicalPath = ".agents/skills";
376
+ function pathState(target) {
377
+ try {
378
+ const stats = lstatSync(target);
379
+ if (stats.isSymbolicLink()) return "symlink";
380
+ return stats.isDirectory() ? "directory" : "file";
381
+ } catch {
382
+ return "missing";
383
+ }
384
+ }
385
+ function isDirectory(path) {
386
+ try {
387
+ return statSync(path).isDirectory();
388
+ } catch {
389
+ return false;
390
+ }
391
+ }
392
+ /** The same test `init` uses to decide a projection is already correct, plus that it still resolves. */
393
+ function resolvesToCanonical(target, canonical) {
394
+ return readlinkSync(target) === relative(dirname(target), canonical) && isDirectory(target);
395
+ }
396
+ function sameContent(left, right) {
397
+ const leftFiles = filesUnder(left);
398
+ const rightFiles = filesUnder(right);
399
+ return leftFiles.length === rightFiles.length && leftFiles.every((file, index) => rightFiles[index] === file && readFileSync(join(left, file)).equals(readFileSync(join(right, file))));
400
+ }
401
+ function inspect(target, path, canonical, git) {
402
+ switch (pathState(target)) {
403
+ case "missing": return {
404
+ kind: "none",
405
+ status: "missing",
406
+ problem: "missing"
407
+ };
408
+ case "file": return {
409
+ kind: "file",
410
+ status: "degraded",
411
+ problem: "degraded"
412
+ };
413
+ case "symlink": return resolvesToCanonical(target, canonical) ? {
414
+ kind: "symlink",
415
+ status: "ok"
416
+ } : {
417
+ kind: "symlink",
418
+ status: "stale",
419
+ problem: "stale"
420
+ };
421
+ default:
422
+ if (!sameContent(target, canonical)) {
423
+ const direction = git.directionOf(path, canonicalPath);
424
+ return {
425
+ kind: "copy",
426
+ status: "diverged",
427
+ problem: `diverged-${direction}`,
428
+ direction
429
+ };
430
+ }
431
+ return git.trackingOf(path) === "tracked" ? {
432
+ kind: "copy",
433
+ status: "ok",
434
+ problem: "unpinned-copy"
435
+ } : {
436
+ kind: "copy",
437
+ status: "ok"
438
+ };
439
+ }
440
+ }
441
+ /**
442
+ * Reports whether every bridge `init` would create for this repository still resolves into
443
+ * `.agents/skills`. Read-only: nothing is created, moved, or repaired, so the caller decides what
444
+ * to run from the repair each finding carries.
445
+ */
446
+ function diagnoseBridges({ root, harnesses: preferred = [], cli }) {
447
+ const canonical = join(root, ".agents", "skills");
448
+ const git = new GitBridgeState(root);
449
+ const bridged = selectHarnesses(root, preferred).filter((harness) => Boolean(harness.skillsDirectory));
450
+ const bridges = [];
451
+ const divergence = [];
452
+ const findings = [];
453
+ if (!isDirectory(canonical)) {
454
+ const { detail, repair } = repairFor("no-canonical");
455
+ findings.push({
456
+ path: canonicalPath,
457
+ detail,
458
+ repair: repair(canonicalPath, cli)
459
+ });
460
+ }
461
+ for (const harness of bridged) {
462
+ const path = harness.skillsDirectory;
463
+ const inspection = inspect(join(root, path), path, canonical, git);
464
+ bridges.push({
465
+ harness: harness.name,
466
+ path,
467
+ kind: inspection.kind,
468
+ status: inspection.status
469
+ });
470
+ if (inspection.direction) divergence.push({
471
+ path,
472
+ direction: inspection.direction
473
+ });
474
+ if (inspection.problem) {
475
+ const { detail, repair } = repairFor(inspection.problem);
476
+ findings.push({
477
+ path,
478
+ detail,
479
+ repair: repair(path, cli)
480
+ });
481
+ }
482
+ }
483
+ return {
484
+ bridges,
485
+ divergence,
486
+ findings
487
+ };
488
+ }
489
+ //#endregion
490
+ //#region src/diagnose-bridges/doctor.command.ts
491
+ /**
492
+ * AXI §5: the healthy answer states the zero with context, so an agent does not re-run with other
493
+ * flags to confirm that an empty section really meant "nothing wrong".
494
+ */
495
+ function buildReport(bin, result) {
496
+ if (!result.findings.length) {
497
+ const count = result.bridges.length;
498
+ return {
499
+ bin,
500
+ bridges: result.bridges,
501
+ findings: count === 1 ? "0 problems found — the 1 bridge resolves" : `0 problems found — all ${count} bridges resolve`
502
+ };
503
+ }
504
+ return {
505
+ bin,
506
+ bridges: result.bridges,
507
+ ...result.divergence.length ? { divergence: result.divergence } : {},
508
+ findings: result.findings.map(({ path, detail }) => ({
509
+ path,
510
+ detail
511
+ })),
512
+ help: [...new Set(result.findings.map((finding) => `Run \`${finding.repair}\``))]
513
+ };
514
+ }
515
+ const doctorCommand = command({
516
+ name: "doctor",
517
+ description: "Report whether the harness skill bridges in this repository still resolve. Read-only.",
518
+ options: {
519
+ root: {
520
+ description: "Repository or package directory. Defaults to the current directory.",
521
+ type: z.optional(z.string())
522
+ },
523
+ harness: {
524
+ description: "Comma-separated harnesses to check in addition to Claude Code and Cursor, e.g. codex,gemini-cli.",
525
+ type: z.optional(z.string())
526
+ },
527
+ format: {
528
+ description: "Output format: toon (default), json, or text for a human-readable report.",
529
+ type: z.optional(z.string()),
530
+ default: "toon"
531
+ }
532
+ },
533
+ run(args) {
534
+ try {
535
+ const format = parseFormat(args.format);
536
+ const requested = args.harness?.split(",").map((name) => name.trim()).filter(Boolean);
537
+ const unsupported = requested?.filter((name) => !harnessRegistry.some((harness) => harness.name === name));
538
+ if (unsupported?.length) throw new Error(`Unsupported harness: ${unsupported.join(", ")}. Supported: ${harnessRegistry.map((harness) => harness.name).join(", ")}.`);
539
+ const result = diagnoseBridges({
540
+ root: args.root ?? process.cwd(),
541
+ ...requested?.length ? { harnesses: requested } : {},
542
+ cli: commandInvocation
543
+ });
544
+ writeResult(buildReport(binPath(homedir(), process.argv[1]), result), format);
545
+ } catch (error) {
546
+ process.stderr.write(`error: ${error instanceof Error ? error.message : "Harness diagnosis failed."}\n`);
547
+ process.exitCode = 1;
548
+ }
549
+ }
550
+ });
551
+ //#endregion
552
+ //#region src/skill-projection/skill-projection.ts
553
+ function skillNames(skillsDirectory) {
554
+ return readdirSync(skillsDirectory, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
555
+ }
556
+ function pathOccupied(path) {
557
+ try {
558
+ lstatSync(path);
559
+ return true;
560
+ } catch {
561
+ return false;
562
+ }
563
+ }
564
+ function isCorrectLink(target, source) {
565
+ return lstatSync(target).isSymbolicLink() && readlinkSync(target) === relative(dirname(target), source);
566
+ }
567
+ /** Creates the canonical directory when absent, so a fresh repository reports zero skills. */
568
+ function countSkills(canonicalSkills) {
569
+ mkdirSync(canonicalSkills, { recursive: true });
570
+ return skillNames(canonicalSkills).length;
571
+ }
572
+ /**
573
+ * Points every harness that cannot read `.agents/skills` at the canonical directory. Symlinking is
574
+ * preferred; a platform that refuses the link falls back to a copy. Returns the projected harnesses.
575
+ */
576
+ function projectSkills({ root, canonicalSkills, harnesses, copy, force }) {
577
+ const projections = harnesses.filter((harness) => harness.skillsDirectory).map((harness) => ({
578
+ harness,
579
+ target: join(root, harness.skillsDirectory)
580
+ }));
581
+ const conflicts = projections.filter(({ target }) => pathOccupied(target) && !isCorrectLink(target, canonicalSkills)).map(({ target }) => target);
582
+ if (conflicts.length && !force) throw new Error(`Refusing to replace existing skill targets:\n${conflicts.map((target) => `- ${target}`).join("\n")}`);
583
+ for (const { target } of projections) {
584
+ mkdirSync(dirname(target), { recursive: true });
585
+ if (pathOccupied(target) && isCorrectLink(target, canonicalSkills)) continue;
586
+ if (pathOccupied(target)) rmSync(target, {
587
+ recursive: true,
588
+ force: true
589
+ });
590
+ if (copy) {
591
+ cpSync(canonicalSkills, target, { recursive: true });
592
+ continue;
593
+ }
594
+ try {
595
+ symlinkSync(relative(dirname(target), canonicalSkills), target, "junction");
596
+ } catch (error) {
597
+ if (pathOccupied(target)) throw error;
598
+ cpSync(canonicalSkills, target, { recursive: true });
599
+ }
600
+ }
601
+ return projections.map(({ harness }) => harness.name);
602
+ }
603
+ //#endregion
604
+ //#region src/initialize-harnesses/initialize-harnesses.ts
605
+ function initializeHarnesses({ root, harnesses: preferred = [], copy = false, force = false }) {
606
+ const canonicalSkills = join(root, ".agents", "skills");
607
+ const skills = countSkills(canonicalSkills);
608
+ const harnesses = selectHarnesses(root, preferred);
609
+ const linked = projectSkills({
610
+ root,
611
+ canonicalSkills,
612
+ harnesses,
613
+ copy,
614
+ force
615
+ });
616
+ return {
617
+ root,
618
+ harnesses: harnesses.map((harness) => harness.name),
619
+ native: harnesses.filter((harness) => !harness.skillsDirectory).map((harness) => harness.name),
620
+ linked,
621
+ deprecated: harnesses.filter((harness) => harness.deprecated).map((harness) => ({
622
+ name: harness.name,
623
+ replacedBy: harness.deprecated
624
+ })),
625
+ skills,
626
+ copied: copy
627
+ };
628
+ }
629
+ //#endregion
630
+ //#region src/initialize-harnesses/init.command.ts
631
+ const initCommand = command({
632
+ name: "init",
633
+ description: "Set up canonical agent skills for the harnesses already enabled in a repository.",
634
+ options: {
635
+ root: {
636
+ description: "Repository or package directory. Defaults to the current directory.",
637
+ type: z.optional(z.string())
638
+ },
639
+ harness: {
640
+ description: "Comma-separated harnesses to enable in addition to Claude Code and Cursor, e.g. codex,gemini-cli.",
641
+ type: z.optional(z.string())
642
+ },
643
+ copy: {
644
+ description: "Copy skills instead of linking them.",
645
+ type: z.optional(z.boolean())
646
+ },
647
+ force: {
648
+ description: "Replace conflicting target skill directories.",
649
+ type: z.optional(z.boolean())
650
+ },
651
+ format: {
652
+ description: "Output format: toon (default), json, or text for a human-readable report.",
653
+ type: z.optional(z.string()),
654
+ default: "toon"
655
+ }
656
+ },
657
+ run(args) {
658
+ try {
659
+ const format = parseFormat(args.format);
660
+ const requested = args.harness?.split(",").map((name) => name.trim()).filter(Boolean);
661
+ const unsupported = requested?.filter((name) => !harnessRegistry.some((harness) => harness.name === name));
662
+ if (unsupported?.length) throw new Error(`Unsupported harness: ${unsupported.join(", ")}. Supported: ${harnessRegistry.map((harness) => harness.name).join(", ")}.`);
663
+ writeResult(initializeHarnesses({
664
+ root: args.root ?? process.cwd(),
665
+ ...requested?.length ? { harnesses: requested } : {},
666
+ ...args.copy === void 0 ? {} : { copy: args.copy },
667
+ ...args.force === void 0 ? {} : { force: args.force }
668
+ }), format);
669
+ } catch (error) {
670
+ process.stderr.write(`error: ${error instanceof Error ? error.message : "Harness initialization failed."}\n`);
671
+ process.exitCode = 1;
672
+ }
673
+ }
674
+ });
675
+ const harnessCommand = command({
676
+ name: "agent-harness",
677
+ description: "Commands for configuring agent harness compatibility.",
678
+ commands: [initCommand, doctorCommand]
679
+ });
680
+ function activate({ addCommand }) {
681
+ addCommand(harnessCommand);
682
+ }
683
+ //#endregion
684
+ export { doctorCommand as a, doctorSkill as c, initializeHarnesses as i, renderDoctorSkill as l, harnessCommand as n, diagnoseBridges as o, initCommand as r, doctorRepairs as s, activate as t, harnessRegistry as u };
685
+
686
+ //# sourceMappingURL=init.command-BN_w5DRZ.mjs.map