@thesmurph/agentlink 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js ADDED
@@ -0,0 +1,570 @@
1
+ #!/usr/bin/env node
2
+ import { mkdirSync } from "node:fs";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { fileURLToPath } from "node:url";
5
+ import path from "node:path";
6
+ import { adoptInstructions, ensureClause, initConvention } from "./convention.js";
7
+ import { detectAll } from "./detect.js";
8
+ import { diagnose, readSkills } from "./doctor.js";
9
+ import { applyFixes, planFixes } from "./fix.js";
10
+ import { endpointVerified, HARNESSES, resolveHarnessList } from "./harnesses.js";
11
+ import { isIgnoreMode, readIgnoreBlock, removeIgnoreBlock, updateGitignore } from "./ignore.js";
12
+ import { apply, mergeState, plan, pruneStale, readState, unlink, writeState } from "./link.js";
13
+ import { resolveScope } from "./scope.js";
14
+ import { selectMany } from "./ui.js";
15
+ const ESC = String.fromCharCode(27);
16
+ const BOLD = `${ESC}[1m`;
17
+ const DIM = `${ESC}[2m`;
18
+ const RED = `${ESC}[31m`;
19
+ const GREEN = `${ESC}[32m`;
20
+ const YELLOW = `${ESC}[33m`;
21
+ const CYAN = `${ESC}[36m`;
22
+ const RESET = `${ESC}[0m`;
23
+ const COMMANDS = ["init", "sync", "select", "fix", "list", "doctor", "adopt", "unlink", "help", "version"];
24
+ function parseArgs(argv) {
25
+ const options = {
26
+ command: "sync",
27
+ scope: "project",
28
+ all: false,
29
+ detected: false,
30
+ dryRun: false,
31
+ yes: false,
32
+ force: false,
33
+ clause: true,
34
+ json: false,
35
+ verbose: false,
36
+ };
37
+ const positional = [];
38
+ for (const arg of argv) {
39
+ if (arg === "-g" || arg === "--global")
40
+ options.scope = "global";
41
+ else if (arg === "--all")
42
+ options.all = true;
43
+ else if (arg === "--detected")
44
+ options.detected = true;
45
+ else if (arg === "--dry-run" || arg === "-n")
46
+ options.dryRun = true;
47
+ else if (arg === "--yes" || arg === "-y")
48
+ options.yes = true;
49
+ else if (arg === "--force")
50
+ options.force = true;
51
+ else if (arg === "--no-clause")
52
+ options.clause = false;
53
+ else if (arg === "--json")
54
+ options.json = true;
55
+ else if (arg === "--verbose" || arg === "-v")
56
+ options.verbose = true;
57
+ else if (arg === "--version" || arg === "-V")
58
+ options.command = "version";
59
+ else if (arg === "--help" || arg === "-h")
60
+ options.command = "help";
61
+ else if (arg.startsWith("--ignore=")) {
62
+ const value = arg.slice("--ignore=".length);
63
+ if (!isIgnoreMode(value))
64
+ fail(`--ignore must be one of skills, all, none (got \`${value}\`)`);
65
+ options.ignore = value;
66
+ }
67
+ else if (arg === "--ignore")
68
+ options.ignore = "skills";
69
+ else if (arg.startsWith("--harnesses="))
70
+ options.harnessIds = split(arg.slice("--harnesses=".length));
71
+ else if (arg === "--harnesses")
72
+ options.harnessIds = [];
73
+ else if (arg.startsWith("-"))
74
+ fail(`unknown option \`${arg}\``, true);
75
+ else
76
+ positional.push(arg);
77
+ }
78
+ const [first] = positional;
79
+ if (first) {
80
+ if (COMMANDS.includes(first))
81
+ options.command = first;
82
+ else
83
+ fail(`unknown command \`${first}\` — try \`agentlink help\``);
84
+ }
85
+ if (positional.length > 1)
86
+ options.harnessIds = split(positional.slice(1).join(","));
87
+ return options;
88
+ }
89
+ function unique(values) {
90
+ return [...new Set(values)].sort();
91
+ }
92
+ function split(value) {
93
+ return value.split(/[,\s]+/).filter(Boolean);
94
+ }
95
+ function fail(message, usage = false) {
96
+ process.stderr.write(`${RED}error${RESET} ${message}\n`);
97
+ if (usage)
98
+ process.stderr.write(`${DIM}run \`agentlink help\`${RESET}\n`);
99
+ process.exit(1);
100
+ }
101
+ async function main() {
102
+ const options = parseArgs(process.argv.slice(2));
103
+ if (options.command === "help")
104
+ return help();
105
+ if (options.command === "version")
106
+ return version();
107
+ const paths = resolveScope(options.scope, process.cwd());
108
+ switch (options.command) {
109
+ case "init":
110
+ return runInit(paths, options);
111
+ case "select":
112
+ return runSelect(paths, options);
113
+ case "sync":
114
+ return runSync(paths, options);
115
+ case "fix":
116
+ return runFix(paths, options);
117
+ case "list":
118
+ return runList(paths, options);
119
+ case "doctor":
120
+ return runDoctor(paths, options);
121
+ case "adopt":
122
+ return runAdopt(paths, options);
123
+ case "unlink":
124
+ return runUnlink(paths, options);
125
+ default:
126
+ return help();
127
+ }
128
+ }
129
+ // --- commands ---------------------------------------------------------------
130
+ async function runInit(paths, options) {
131
+ const chosen = await chooseHarnesses(paths, options, { prompt: true });
132
+ // A repository that already has real instructions should have them adopted
133
+ // rather than shadowed by a fresh stub.
134
+ const adoption = adoptInstructions(paths, { dryRun: options.dryRun });
135
+ if (adoption.performed && !options.json) {
136
+ step("adopt", `${path.basename(adoption.from ?? "")} → AGENTS.md`);
137
+ }
138
+ else if (adoption.needsInvert && !options.json) {
139
+ process.stdout.write(` ${YELLOW}!${RESET} ${adoption.reason}\n`);
140
+ }
141
+ const result = initConvention(paths, { dryRun: options.dryRun });
142
+ if (!options.dryRun)
143
+ mkdirSync(paths.skills, { recursive: true });
144
+ if (!options.json) {
145
+ const where = paths.scope === "global" ? "~" : ".";
146
+ step(result.createdFile ? "create" : "keep", `${where}/AGENTS.md`);
147
+ step(result.createdSkillsDir ? "create" : "keep", `${where}/.agents/skills/`);
148
+ }
149
+ const fixes = migrateDuplicates(paths, chosen, options);
150
+ await syncLinks(paths, chosen, options, { adoption, fixes });
151
+ }
152
+ async function runFix(paths, options) {
153
+ const chosen = await chooseHarnesses(paths, options, { prompt: false });
154
+ if (!existsSync(paths.instructions)) {
155
+ if (!options.json) {
156
+ process.stdout.write(`${DIM}no AGENTS.md here yet — run \`agentlink init\` first${RESET}\n`);
157
+ }
158
+ return;
159
+ }
160
+ const fixes = migrateDuplicates(paths, chosen, options);
161
+ await syncLinks(paths, chosen, options, { fixes });
162
+ }
163
+ async function runSelect(paths, options) {
164
+ const chosen = await chooseHarnesses(paths, options, { prompt: true });
165
+ if (!options.json) {
166
+ process.stdout.write(`${DIM}selected:${RESET} ${chosen.map((h) => h.id).join(", ") || "(none)"}\n`);
167
+ }
168
+ await syncLinks(paths, chosen, options, {});
169
+ }
170
+ async function runSync(paths, options) {
171
+ const chosen = await chooseHarnesses(paths, options, { prompt: false });
172
+ await syncLinks(paths, chosen, options, {});
173
+ }
174
+ /** Resolve real copies sitting where a symlink belongs, without guessing. */
175
+ function migrateDuplicates(paths, chosen, options) {
176
+ const actions = planFixes(paths, chosen);
177
+ if (actions.length === 0)
178
+ return [];
179
+ const results = applyFixes(paths, actions, { dryRun: options.dryRun, force: options.force });
180
+ if (options.json)
181
+ return results;
182
+ for (const result of results) {
183
+ const dry = options.dryRun ? ` ${DIM}(dry run)${RESET}` : "";
184
+ if (result.kind === "move" || result.kind === "remove-identical") {
185
+ if (!result.performed && !options.dryRun) {
186
+ // Refused or failed: say so rather than reporting a clean run.
187
+ process.stdout.write(` ${YELLOW}!${RESET} ${result.target} ${DIM}${result.detail ?? "not migrated"}${RESET}\n`);
188
+ continue;
189
+ }
190
+ if (result.kind === "move") {
191
+ step(options.dryRun ? "would" : "move", `${result.target} → ${result.canonical}${dry}`);
192
+ }
193
+ else {
194
+ step(options.dryRun ? "would" : "cleanup", `${result.target} ${DIM}identical to the canonical copy${RESET}${dry}`);
195
+ }
196
+ }
197
+ else if (result.kind === "conflict") {
198
+ const command = result.skill ? "diff -r" : "diff";
199
+ process.stdout.write(` ${YELLOW}!${RESET} conflict ${result.target} ${DIM}${result.detail ?? ""}${RESET}\n` +
200
+ ` ${DIM}fix: ${command} ${result.target} ${result.canonical}, merge by hand, then \`agentlink fix\`${RESET}\n` +
201
+ ` ${DIM}or \`agentlink fix --force\` to favour the canonical copy${RESET}\n`);
202
+ }
203
+ }
204
+ return results;
205
+ }
206
+ async function syncLinks(paths, chosen, options, context) {
207
+ const previous = readState(paths);
208
+ const desiredClause = options.clause && existsSync(paths.instructions);
209
+ if (!options.dryRun && !existsSync(paths.skills) && existsSync(paths.instructions)) {
210
+ mkdirSync(paths.skills, { recursive: true });
211
+ }
212
+ const clauseResult = desiredClause ? ensureClause(paths, { dryRun: options.dryRun }) : undefined;
213
+ const linkPlan = plan(paths, chosen);
214
+ const results = apply(paths, linkPlan, { dryRun: options.dryRun });
215
+ const pruned = pruneStale(paths, linkPlan, previous, { dryRun: options.dryRun });
216
+ const mode = options.ignore ?? (isIgnoreMode(previous.ignore) ? previous.ignore : "skills");
217
+ const ignoreResult = updateGitignore(paths, mode, {
218
+ skillDirs: unique([
219
+ ...linkPlan.ops.filter((op) => op.kind === "skill").map((op) => path.posix.dirname(op.rel)),
220
+ ...linkPlan.aliases.map((alias) => alias.rel),
221
+ ]),
222
+ instructionFiles: unique(linkPlan.ops.filter((op) => op.kind === "instructions").map((op) => op.rel)),
223
+ }, { dryRun: options.dryRun });
224
+ if (!options.dryRun) {
225
+ const state = mergeState(paths, previous, results, chosen.map((h) => h.id), {
226
+ ignore: mode,
227
+ plannedRels: linkPlan.ops.map((op) => op.rel),
228
+ });
229
+ writeState(paths, state);
230
+ }
231
+ const conflicts = (context.fixes ?? []).filter((fix) => fix.kind === "conflict" && !fix.performed);
232
+ const blocked = results.filter((result) => result.state === "skipped");
233
+ // Anything skipped here is real content standing where a link belongs, or a
234
+ // path we could not even inspect. Either way it needs a human decision, so the
235
+ // run reports failure. Links that are merely premature (no AGENTS.md yet) are
236
+ // plan shims, not results, and stay quiet.
237
+ const unresolved = blocked.length > 0 || conflicts.length > 0;
238
+ if (options.json) {
239
+ process.stdout.write(`${JSON.stringify({
240
+ scope: paths.scope,
241
+ root: paths.root,
242
+ dryRun: options.dryRun,
243
+ harnesses: chosen.map((h) => h.id),
244
+ adoption: context.adoption ?? null,
245
+ fixes: (context.fixes ?? []).map((fix) => ({
246
+ target: fix.target,
247
+ canonical: fix.canonical,
248
+ kind: fix.kind,
249
+ performed: fix.performed,
250
+ })),
251
+ links: results.map((result) => ({
252
+ path: result.op.rel,
253
+ state: result.state,
254
+ harnesses: result.op.harnessIds,
255
+ detail: result.detail,
256
+ })),
257
+ skipped: linkPlan.skips.map((skip) => ({ path: skip.rel, reason: skip.reason, harnesses: skip.harnessIds })),
258
+ pruned,
259
+ native: linkPlan.native,
260
+ unknown: linkPlan.unknown,
261
+ aliases: linkPlan.aliases,
262
+ skills: linkPlan.skillsFound,
263
+ conflicts: conflicts.map((fix) => fix.target),
264
+ ignore: {
265
+ mode,
266
+ file: ignoreResult.skipped ? null : ignoreResult.file,
267
+ entries: ignoreResult.entries,
268
+ status: ignoreResult.status,
269
+ },
270
+ }, null, 2)}\n`);
271
+ if (unresolved)
272
+ process.exit(1);
273
+ return;
274
+ }
275
+ const where = paths.scope === "global" ? "~" : path.basename(paths.root);
276
+ process.stdout.write(`\n${BOLD}agentlink${RESET} ${DIM}${where} · ${chosen.length} harness${chosen.length === 1 ? "" : "es"}${options.dryRun ? " · dry run" : ""}${RESET}\n`);
277
+ for (const result of results) {
278
+ const symbol = result.state === "skipped"
279
+ ? `${YELLOW}!${RESET}`
280
+ : result.state === "unchanged"
281
+ ? `${DIM}=${RESET}`
282
+ : `${GREEN}+${RESET}`;
283
+ const detail = result.detail ? ` ${DIM}${result.detail}${RESET}` : "";
284
+ const verb = options.dryRun && result.state !== "skipped" ? "would link" : result.state;
285
+ process.stdout.write(` ${symbol} ${result.op.rel} ${DIM}${verb}${RESET}${detail}\n`);
286
+ }
287
+ for (const gone of pruned) {
288
+ process.stdout.write(` ${DIM}- ${gone} removed (no longer wanted)${RESET}\n`);
289
+ }
290
+ for (const skip of linkPlan.skips) {
291
+ process.stdout.write(` ${DIM}· ${skip.rel} ${skip.reason}${RESET}\n`);
292
+ }
293
+ if (clauseResult?.action === "malformed") {
294
+ process.stdout.write(` ${YELLOW}!${RESET} ${path.basename(paths.instructions)} has an unterminated agentlink clause — fix it by hand\n`);
295
+ }
296
+ else if (clauseResult?.changed) {
297
+ step("clause", `${path.basename(paths.instructions)} ${DIM}(${clauseResult.action})${RESET}`);
298
+ }
299
+ if (ignoreResult.status === "malformed") {
300
+ process.stdout.write(` ${YELLOW}!${RESET} .gitignore has an unmatched agentlink marker — remove that line and re-run\n`);
301
+ }
302
+ else if (!ignoreResult.skipped) {
303
+ const count = ignoreResult.entries.length;
304
+ const suffix = options.dryRun ? ", would update" : "";
305
+ step("ignore", ignoreResult.status === "updated"
306
+ ? `.gitignore ${DIM}(${count} entr${count === 1 ? "y" : "ies"}${suffix})${RESET}`
307
+ : `.gitignore ${DIM}(already covers ${count} path${count === 1 ? "" : "s"})${RESET}`);
308
+ }
309
+ const created = results.filter((r) => r.state === "linked" || r.state === "relinked").length;
310
+ const unchanged = results.filter((r) => r.state === "unchanged").length;
311
+ const blockedCount = blocked.length + linkPlan.skips.length;
312
+ process.stdout.write(`\n ${created} ${options.dryRun ? "to link" : "linked"}${unchanged ? `, ${unchanged} already correct` : ""}${pruned.length ? `, ${pruned.length} pruned` : ""}${blockedCount ? `, ${blockedCount} blocked` : ""}, ${linkPlan.native.length} native (nothing to do)\n`);
313
+ for (const alias of linkPlan.aliases) {
314
+ process.stdout.write(` ${DIM}· ${alias.rel} → .agents/skills (already linked)${RESET}\n`);
315
+ }
316
+ if (blocked.length > 0) {
317
+ process.stdout.write(` ${DIM}run \`agentlink doctor\` for what to do about the blocked links${RESET}\n`);
318
+ }
319
+ if (!existsSync(paths.instructions)) {
320
+ process.stdout.write(` ${DIM}no AGENTS.md yet — \`agentlink init\` creates it${RESET}\n`);
321
+ }
322
+ else if (linkPlan.skillsFound.length === 0) {
323
+ process.stdout.write(` ${DIM}no skills yet — add one at .agents/skills/<name>/SKILL.md${RESET}\n`);
324
+ }
325
+ if (unresolved)
326
+ process.exit(1);
327
+ }
328
+ async function runList(paths, options) {
329
+ const rows = detectAll().map(({ harness, installed, reasons }) => ({
330
+ id: harness.id,
331
+ label: harness.label,
332
+ installed,
333
+ reasons,
334
+ instructions: describe(harness.instructions[paths.scope]),
335
+ skills: describe(harness.skills[paths.scope]),
336
+ instructionsVerified: endpointVerified(harness.instructions[paths.scope]),
337
+ skillsVerified: endpointVerified(harness.skills[paths.scope]),
338
+ source: harness.source,
339
+ }));
340
+ if (options.json) {
341
+ process.stdout.write(`${JSON.stringify({ scope: paths.scope, harnesses: rows }, null, 2)}\n`);
342
+ return;
343
+ }
344
+ const width = Math.max(...rows.map((row) => row.label.length));
345
+ process.stdout.write(`\n${BOLD}harnesses${RESET} ${DIM}· scope: ${paths.scope}${RESET}\n\n`);
346
+ for (const row of rows) {
347
+ const mark = row.installed ? `${GREEN}●${RESET}` : `${DIM}○${RESET}`;
348
+ const flag = !row.instructionsVerified || !row.skillsVerified ? ` ${YELLOW}unverified${RESET}` : "";
349
+ process.stdout.write(` ${mark} ${row.label.padEnd(width)} ${DIM}instructions${RESET} ${row.instructions} ${DIM}skills${RESET} ${row.skills}${flag}\n`);
350
+ }
351
+ process.stdout.write(`\n ${GREEN}●${RESET} present on this machine ${DIM}native = harness reads AGENTS.md / .agents/skills itself${RESET}\n` +
352
+ ` ${DIM}agentlink list --json prints the source URL for every row${RESET}\n`);
353
+ }
354
+ function describe(endpoint) {
355
+ if (endpoint.native)
356
+ return `${CYAN}native${RESET}`;
357
+ if (!endpoint.alias)
358
+ return `${DIM}unknown${RESET}`;
359
+ return endpoint.alias;
360
+ }
361
+ function runDoctor(paths, options) {
362
+ const state = readState(paths);
363
+ // An explicit selection wins, so a CI runner with no harnesses installed can
364
+ // still check the set a repository declares. Otherwise fall back to what was
365
+ // recorded, then to what is present on this machine.
366
+ let chosen;
367
+ if (options.harnessIds && options.harnessIds.length > 0) {
368
+ const { found, unknown } = resolveHarnessList(options.harnessIds.join(","));
369
+ if (unknown.length)
370
+ fail(`unknown harness${unknown.length > 1 ? "es" : ""}: ${unknown.join(", ")}`);
371
+ chosen = dedupe(found);
372
+ }
373
+ else if (state.harnesses.length > 0) {
374
+ chosen = HARNESSES.filter((h) => state.harnesses.includes(h.id));
375
+ }
376
+ else {
377
+ chosen = detectAll()
378
+ .filter((detection) => detection.installed)
379
+ .map((detection) => detection.harness);
380
+ }
381
+ const findings = diagnose({ paths, harnesses: chosen });
382
+ const errors = findings.filter((f) => f.severity === "error").length;
383
+ const warns = findings.filter((f) => f.severity === "warn").length;
384
+ if (options.json) {
385
+ process.stdout.write(`${JSON.stringify({
386
+ scope: paths.scope,
387
+ root: paths.root,
388
+ harnesses: chosen.map((h) => h.id),
389
+ skills: readSkills(paths).map((skill) => skill.name),
390
+ findings,
391
+ errors,
392
+ warnings: warns,
393
+ }, null, 2)}\n`);
394
+ process.exit(errors > 0 ? 1 : 0);
395
+ }
396
+ const where = paths.scope === "global" ? "~" : paths.root;
397
+ process.stdout.write(`\n${BOLD}agentlink doctor${RESET} ${DIM}${where}${RESET}\n\n`);
398
+ if (findings.length === 0) {
399
+ process.stdout.write(` ${GREEN}✓${RESET} convention intact${chosen.length ? `, ${chosen.length} harness${chosen.length === 1 ? "" : "es"} selected` : ""}\n\n`);
400
+ return;
401
+ }
402
+ for (const severity of ["error", "warn", "info"]) {
403
+ for (const finding of findings.filter((f) => f.severity === severity)) {
404
+ const symbol = severity === "error" ? `${RED}✗${RESET}` : severity === "warn" ? `${YELLOW}!${RESET}` : `${DIM}·${RESET}`;
405
+ process.stdout.write(` ${symbol} ${finding.message}\n`);
406
+ if (finding.fix)
407
+ process.stdout.write(` ${DIM}fix: ${finding.fix}${RESET}\n`);
408
+ }
409
+ }
410
+ process.stdout.write(`\n ${errors} error${errors === 1 ? "" : "s"}, ${warns} warning${warns === 1 ? "" : "s"}\n\n`);
411
+ process.exit(errors > 0 ? 1 : 0);
412
+ }
413
+ function runAdopt(paths, options) {
414
+ const result = adoptInstructions(paths, { dryRun: options.dryRun });
415
+ if (options.json) {
416
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
417
+ if (result.needsInvert)
418
+ process.exit(1);
419
+ return;
420
+ }
421
+ if (result.performed) {
422
+ step("adopt", `${path.basename(result.from ?? "")} → AGENTS.md`);
423
+ process.stdout.write(` ${DIM}now run \`agentlink sync\` to link it back into every harness${RESET}\n`);
424
+ return;
425
+ }
426
+ if (result.from) {
427
+ process.stdout.write(`${YELLOW}!${RESET} would rename ${path.basename(result.from)} → AGENTS.md ${DIM}(dry run; then run \`agentlink sync\`)${RESET}\n`);
428
+ return;
429
+ }
430
+ process.stdout.write(`${DIM}nothing to adopt: ${result.reason}${RESET}\n`);
431
+ if (result.needsInvert || result.reason?.includes("merge") || result.reason?.includes("several"))
432
+ process.exit(1);
433
+ }
434
+ function runUnlink(paths, options) {
435
+ const result = unlink(paths, { dryRun: options.dryRun });
436
+ const ignoreChanged = removeIgnoreBlock(paths, { dryRun: options.dryRun });
437
+ if (options.json) {
438
+ process.stdout.write(`${JSON.stringify({ ...result, ignoreBlockRemoved: ignoreChanged }, null, 2)}\n`);
439
+ return;
440
+ }
441
+ for (const removed of result.removed)
442
+ step("unlink", removed);
443
+ for (const keep of result.kept) {
444
+ process.stdout.write(` ${YELLOW}!${RESET} kept ${keep.path} ${DIM}(${keep.reason})${RESET}\n`);
445
+ }
446
+ if (ignoreChanged)
447
+ step("ignore", `.gitignore ${DIM}(block removed)${RESET}`);
448
+ if (result.removed.length === 0 && result.kept.length === 0) {
449
+ process.stdout.write(`${DIM}nothing to unlink${RESET}\n`);
450
+ }
451
+ }
452
+ // --- selection --------------------------------------------------------------
453
+ async function chooseHarnesses(paths, options, behaviour) {
454
+ const state = readState(paths);
455
+ if (options.harnessIds && options.harnessIds.length > 0) {
456
+ const { found, unknown } = resolveHarnessList(options.harnessIds.join(","));
457
+ if (unknown.length)
458
+ fail(`unknown harness${unknown.length > 1 ? "es" : ""}: ${unknown.join(", ")}`);
459
+ return dedupe(found);
460
+ }
461
+ if (options.all)
462
+ return HARNESSES;
463
+ if (options.detected)
464
+ return detectedHarnesses();
465
+ // Asking for a specific command or an explicit selection is the only reason to
466
+ // reuse a saved selection; init and select are about choosing.
467
+ if (!behaviour.prompt && state.harnesses.length > 0) {
468
+ const fromState = HARNESSES.filter((h) => state.harnesses.includes(h.id));
469
+ if (fromState.length > 0)
470
+ return fromState;
471
+ }
472
+ // Never prompt when the caller cannot answer: --yes, --json, or no TTY.
473
+ const canPrompt = !options.yes && !options.json && process.stdin.isTTY === true && process.stdout.isTTY === true;
474
+ if (behaviour.prompt && canPrompt)
475
+ return promptForHarnesses();
476
+ const fallback = detectedHarnesses();
477
+ if (!options.json) {
478
+ const names = fallback.map((h) => h.id).join(", ") || "none";
479
+ process.stdout.write(`${DIM}harnesses present on this machine: ${names}${canPrompt ? "" : " (not prompting)"}${RESET}\n`);
480
+ if (!canPrompt) {
481
+ process.stdout.write(`${DIM}pass --harnesses, --all or --detected to choose explicitly${RESET}\n`);
482
+ }
483
+ }
484
+ return fallback;
485
+ }
486
+ async function promptForHarnesses() {
487
+ const detections = detectAll();
488
+ const choices = detections.map(({ harness, installed, reasons }) => ({
489
+ id: harness.id,
490
+ label: harness.label,
491
+ hint: installed ? reasons.join(" · ") : "not detected",
492
+ checked: installed,
493
+ group: installed ? "detected" : "other",
494
+ }));
495
+ const picked = await selectMany(choices, {
496
+ title: "Which harnesses should read this project's AGENTS.md and skills?",
497
+ help: "space toggle · a all · i installed only · enter confirm · esc cancel",
498
+ });
499
+ if (picked === null) {
500
+ process.stdout.write(`${DIM}cancelled${RESET}\n`);
501
+ process.exit(0);
502
+ }
503
+ return dedupe(detections.filter((d) => picked.includes(d.harness.id)).map((d) => d.harness));
504
+ }
505
+ function detectedHarnesses() {
506
+ return dedupe(detectAll().filter((d) => d.installed).map((d) => d.harness));
507
+ }
508
+ function dedupe(harnesses) {
509
+ return HARNESSES.filter((h) => harnesses.some((candidate) => candidate.id === h.id));
510
+ }
511
+ function step(verb, message) {
512
+ process.stdout.write(` ${GREEN}✓${RESET} ${DIM}${verb.padEnd(7)}${RESET}${message}\n`);
513
+ }
514
+ function version() {
515
+ try {
516
+ const file = fileURLToPath(new URL("../package.json", import.meta.url));
517
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
518
+ process.stdout.write(`${parsed.version ?? "unknown"}\n`);
519
+ }
520
+ catch {
521
+ process.stdout.write("unknown\n");
522
+ }
523
+ }
524
+ function help() {
525
+ process.stdout.write(`
526
+ ${BOLD}agentlink${RESET} ${DIM}— one source of truth for agent instructions and skills${RESET}
527
+
528
+ ${BOLD}usage${RESET}
529
+ agentlink pick harnesses (first run), then link
530
+ agentlink init create AGENTS.md + .agents/skills, migrate, link
531
+ agentlink sync re-link after adding or moving a skill
532
+ agentlink fix fold stray real copies into .agents, then link
533
+ agentlink select change which harnesses are linked
534
+ agentlink list show every harness and where it reads from
535
+ agentlink doctor report drift, duplicates and broken links
536
+ agentlink doctor --harnesses a,b check a declared set (useful in CI)
537
+ agentlink adopt move an existing CLAUDE.md/GEMINI.md into AGENTS.md
538
+ agentlink unlink remove the links agentlink created
539
+
540
+ ${BOLD}options${RESET}
541
+ -g, --global act on ~ instead of the current repository
542
+ --harnesses a,b skip the picker
543
+ --all every harness in the table
544
+ --detected only harnesses found on this machine
545
+ -n, --dry-run show what would change
546
+ -y, --yes never prompt
547
+ --force on a conflict, keep the canonical copy
548
+ --no-clause leave AGENTS.md untouched
549
+ --ignore=skills|all|none
550
+ what to list in .gitignore (default: skills)
551
+ --json machine-readable output
552
+ -v, --verbose more detail
553
+
554
+ ${BOLD}the convention${RESET}
555
+ AGENTS.md instructions. Everything else points here.
556
+ .agents/skills/<name>/SKILL.md skills, one directory each.
557
+ ${DIM}Files like CLAUDE.md and .claude/skills/ are symlinks into the above.${RESET}
558
+ ${DIM}Instructions aliases are committed; skill links go in .gitignore.${RESET}
559
+
560
+ ${BOLD}more${RESET}
561
+ agentlink help this text
562
+ agentlink --version print the version
563
+ `);
564
+ }
565
+ main().catch((error) => {
566
+ const message = error instanceof Error ? error.message : String(error);
567
+ process.stderr.write(`${RED}error${RESET} ${message}\n`);
568
+ process.exit(1);
569
+ });
570
+ export { readIgnoreBlock };