@thesmurph/agentlink 0.1.0 → 0.2.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/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0
4
+
5
+ Corrects the path table against the code each harness actually ships, checked
6
+ with `scripts/verify-paths.mjs`. Paths now come from evidence rather than from
7
+ documentation that may describe an older layout.
8
+
9
+ - Qoder and Grok read `.agents/skills` themselves, so they need no link. Both
10
+ were aliases; Qoder's own directory holds settings and repowiki, not skills,
11
+ and Grok's binary states that it scans `.agents/skills/` at every tier.
12
+ - opencode's `.opencode/skills` is confirmed in its shipped binary.
13
+ - Kilo Code's skills path is now reported as unknown. `.claude/skills` came from
14
+ a third-party table and appears nowhere in its 195 MB binary, so agentlink
15
+ stopped writing links there rather than guessing.
16
+ - Factory Droid's home-directory instructions file has no evidence behind it and
17
+ is flagged unverified, though both of its skills paths are confirmed.
18
+ - Claude Code, Codex, Pi, Copilot, Qwen and Mastra Code rows were confirmed as
19
+ they stood.
20
+
21
+ ## 0.1.2
22
+
23
+ - `list` explains what `unverified` means: the vendor's docs do not confirm that
24
+ path, so the link may sit where nothing reads it. It is not an error.
25
+ - An explicit `--harnesses`, `--all` or `--detected` sets the saved selection, so
26
+ sync now reports what it added or dropped instead of changing it quietly.
27
+ `sync --json` gains `selection.added` and `selection.removed`.
28
+ - Colour is written only to a TTY, and `NO_COLOR` is honoured, so piped output
29
+ and CI logs contain no escape codes.
30
+
31
+ ## 0.1.1
32
+
33
+ - `list` shows what is linked rather than only what is installed. A `✓` means
34
+ agentlink is linking that harness; a trailing note appears when a harness is
35
+ installed but not linked, or linked but not installed. `list --json` gains
36
+ `selected` and a top-level `linked` array.
37
+ - Fixed: `list` printed one mark for two different facts, so a deselected
38
+ harness looked selected. The selection itself was always saved correctly.
39
+
3
40
  ## 0.1.0
4
41
 
5
42
  First release.
package/README.md CHANGED
@@ -32,7 +32,7 @@ agentlink init create AGENTS.md + .agents/skills, migrate, link
32
32
  agentlink sync re-link after adding or moving a skill
33
33
  agentlink fix fold stray real copies into .agents, then link
34
34
  agentlink select change which harnesses are linked
35
- agentlink list every harness, where it reads from, whether it is installed
35
+ agentlink list what is linked, where each harness reads from, what is installed
36
36
  agentlink doctor drift, duplicates, invalid skills, broken links
37
37
  agentlink adopt move an existing CLAUDE.md or GEMINI.md into AGENTS.md
38
38
  agentlink unlink remove the links agentlink created
package/dist/cli.js CHANGED
@@ -13,13 +13,20 @@ import { apply, mergeState, plan, pruneStale, readState, unlink, writeState } fr
13
13
  import { resolveScope } from "./scope.js";
14
14
  import { selectMany } from "./ui.js";
15
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`;
16
+ // Colour only when something is reading it: a TTY, and not opted out. Piped
17
+ // output and CI logs stay free of escape codes.
18
+ const useColor = process.env.NO_COLOR === undefined &&
19
+ process.env.FORCE_COLOR !== "0" &&
20
+ (process.env.FORCE_COLOR !== undefined || process.stdout.isTTY === true);
21
+ const paint = (code) => (useColor ? `${ESC}[${code}m` : "");
22
+ const BOLD = paint("1");
23
+ const DIM = paint("2");
24
+ const RED = paint("31");
25
+ const GREEN = paint("32");
26
+ const YELLOW = paint("33");
27
+ const CYAN = paint("36");
28
+ const RESET = paint("0");
29
+ ;
23
30
  const COMMANDS = ["init", "sync", "select", "fix", "list", "doctor", "adopt", "unlink", "help", "version"];
24
31
  function parseArgs(argv) {
25
32
  const options = {
@@ -258,6 +265,11 @@ async function syncLinks(paths, chosen, options, context) {
258
265
  pruned,
259
266
  native: linkPlan.native,
260
267
  unknown: linkPlan.unknown,
268
+ selection: {
269
+ harnesses: chosen.map((h) => h.id),
270
+ added: chosen.map((h) => h.id).filter((id) => !previous.harnesses.includes(id)),
271
+ removed: previous.harnesses.filter((id) => !chosen.some((h) => h.id === id)),
272
+ },
261
273
  aliases: linkPlan.aliases,
262
274
  skills: linkPlan.skillsFound,
263
275
  conflicts: conflicts.map((fix) => fix.target),
@@ -274,6 +286,11 @@ async function syncLinks(paths, chosen, options, context) {
274
286
  }
275
287
  const where = paths.scope === "global" ? "~" : path.basename(paths.root);
276
288
  process.stdout.write(`\n${BOLD}agentlink${RESET} ${DIM}${where} · ${chosen.length} harness${chosen.length === 1 ? "" : "es"}${options.dryRun ? " · dry run" : ""}${RESET}\n`);
289
+ // An explicit --harnesses/--all/--detected changes the saved selection, so
290
+ // say which harnesses that added or dropped rather than letting it pass.
291
+ const selection = selectionChange(previous.harnesses, chosen);
292
+ if (selection)
293
+ step("select", selection);
277
294
  for (const result of results) {
278
295
  const symbol = result.state === "skipped"
279
296
  ? `${YELLOW}!${RESET}`
@@ -326,9 +343,12 @@ async function syncLinks(paths, chosen, options, context) {
326
343
  process.exit(1);
327
344
  }
328
345
  async function runList(paths, options) {
346
+ const state = readState(paths);
347
+ const selected = new Set(state.harnesses);
329
348
  const rows = detectAll().map(({ harness, installed, reasons }) => ({
330
349
  id: harness.id,
331
350
  label: harness.label,
351
+ selected: selected.has(harness.id),
332
352
  installed,
333
353
  reasons,
334
354
  instructions: describe(harness.instructions[paths.scope]),
@@ -338,18 +358,28 @@ async function runList(paths, options) {
338
358
  source: harness.source,
339
359
  }));
340
360
  if (options.json) {
341
- process.stdout.write(`${JSON.stringify({ scope: paths.scope, harnesses: rows }, null, 2)}\n`);
361
+ process.stdout.write(`${JSON.stringify({ scope: paths.scope, root: paths.root, linked: [...selected], harnesses: rows }, null, 2)}\n`);
342
362
  return;
343
363
  }
344
364
  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`);
365
+ const installedCount = rows.filter((row) => row.installed).length;
366
+ process.stdout.write(`\n${BOLD}harnesses${RESET} ${DIM}· scope: ${paths.scope} · ${selected.size} linked · ${installedCount} installed here${RESET}\n\n`);
346
367
  for (const row of rows) {
347
- const mark = row.installed ? `${GREEN}●${RESET}` : `${DIM}○${RESET}`;
368
+ // The leading mark is the tool's own state: what is actually linked.
369
+ // Availability is only called out when it is surprising.
370
+ const mark = row.selected ? `${GREEN}✓${RESET}` : `${DIM}·${RESET}`;
371
+ const note = row.selected && !row.installed
372
+ ? ` ${YELLOW}selected, not installed${RESET}`
373
+ : !row.selected && row.installed
374
+ ? ` ${DIM}installed, not linked${RESET}`
375
+ : "";
348
376
  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`);
377
+ process.stdout.write(` ${mark} ${row.label.padEnd(width)} ${DIM}instructions${RESET} ${row.instructions} ${DIM}skills${RESET} ${row.skills}${flag}${note}\n`);
350
378
  }
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`);
379
+ process.stdout.write(`\n ${GREEN}✓${RESET} linked ${DIM}·${RESET} not linked ${DIM}change with \`agentlink select\`${RESET}\n` +
380
+ ` ${DIM}native = harness reads AGENTS.md / .agents/skills itself${RESET}\n` +
381
+ ` ${YELLOW}unverified${RESET} ${DIM}= the vendor's docs do not confirm that path, so the link may sit where nothing reads it.${RESET}\n` +
382
+ ` ${DIM}It is not an error. \`list --json\` prints the source URL for every endpoint.${RESET}\n`);
353
383
  }
354
384
  function describe(endpoint) {
355
385
  if (endpoint.native)
@@ -511,6 +541,17 @@ function dedupe(harnesses) {
511
541
  function step(verb, message) {
512
542
  process.stdout.write(` ${GREEN}✓${RESET} ${DIM}${verb.padEnd(7)}${RESET}${message}\n`);
513
543
  }
544
+ /** Describe a change to the saved selection, or null when nothing changed. */
545
+ function selectionChange(before, chosen) {
546
+ const after = chosen.map((harness) => harness.id);
547
+ const added = after.filter((id) => !before.includes(id));
548
+ const removed = before.filter((id) => !after.includes(id));
549
+ if (added.length === 0 && removed.length === 0)
550
+ return null;
551
+ // The first run has nothing to compare against; the list above already says it.
552
+ const parts = [added.length ? `+${added.join(" ")}` : "", removed.length ? `-${removed.join(" ")}` : ""];
553
+ return `${after.length} harness${after.length === 1 ? "" : "es"}${before.length ? ` (was ${before.length})` : ""}: ${parts.filter(Boolean).join(" ")}`;
554
+ }
514
555
  function version() {
515
556
  try {
516
557
  const file = fileURLToPath(new URL("../package.json", import.meta.url));
package/dist/harnesses.js CHANGED
@@ -27,6 +27,7 @@ export const HARNESSES = [
27
27
  label: "Claude Code",
28
28
  bins: ["claude"],
29
29
  configRoot: ".claude",
30
+ npmPackage: "@anthropic-ai/claude-code",
30
31
  // Claude Code reads CLAUDE.md, not AGENTS.md, and never scans .agents/skills.
31
32
  instructions: scopes({ native: false, alias: "CLAUDE.md", note: "Claude Code has no AGENTS.md fallback" }, { native: false, alias: ".claude/CLAUDE.md" }),
32
33
  skills: scopes({ native: false, alias: ".claude/skills" }, { native: false, alias: ".claude/skills" }),
@@ -37,6 +38,7 @@ export const HARNESSES = [
37
38
  label: "OpenAI Codex CLI",
38
39
  bins: ["codex"],
39
40
  configRoot: ".codex",
41
+ npmPackage: "@openai/codex",
40
42
  // Codex reads AGENTS.md in the repo and ~/.codex/AGENTS.md globally.
41
43
  instructions: scopes({ native: true }, { native: false, alias: ".codex/AGENTS.md" }),
42
44
  // Codex's user-level skills directory is $HOME/.agents/skills: native both ways.
@@ -48,6 +50,7 @@ export const HARNESSES = [
48
50
  label: "Pi",
49
51
  bins: ["pi"],
50
52
  configRoot: ".pi",
53
+ npmPackage: "@earendil-works/pi-coding-agent",
51
54
  instructions: scopes({ native: true }, { native: false, alias: ".pi/agent/AGENTS.md" }),
52
55
  // Pi reads ~/.agents/skills and .agents/skills directly.
53
56
  skills: scopes(NATIVE, NATIVE),
@@ -58,6 +61,7 @@ export const HARNESSES = [
58
61
  label: "Oh My Pi (omp)",
59
62
  bins: ["omp"],
60
63
  configRoot: ".omp",
64
+ npmPackage: "@oh-my-pi/pi-coding-agent",
61
65
  instructions: scopes({ native: true, note: "standalone AGENTS.md via the agents-md provider" }, { native: false, alias: ".omp/agent/AGENTS.md" }),
62
66
  // omp scans its own .omp/skills trees; it does not read .agents/skills.
63
67
  skills: scopes({ native: false, alias: ".omp/skills" }, { native: false, alias: ".omp/agent/skills" }),
@@ -68,6 +72,7 @@ export const HARNESSES = [
68
72
  label: "GitHub Copilot CLI",
69
73
  bins: ["copilot"],
70
74
  configRoot: ".copilot",
75
+ npmPackage: "@github/copilot",
71
76
  // Copilot CLI reads AGENTS.md everywhere, but its *user* instructions live
72
77
  // in a differently named file.
73
78
  instructions: scopes({ native: true }, { native: false, alias: ".copilot/copilot-instructions.md" }),
@@ -88,8 +93,11 @@ export const HARNESSES = [
88
93
  label: "opencode",
89
94
  bins: ["opencode"],
90
95
  configRoot: ".config/opencode",
96
+ npmPackage: "opencode-ai",
91
97
  instructions: scopes({ native: true }, { native: false, alias: ".config/opencode/AGENTS.md" }),
92
- skills: scopes({ native: false, alias: ".opencode/skills", verified: false }, { native: false, alias: ".config/opencode/skills", verified: false }),
98
+ // .opencode/skills confirmed in the shipped binary; the home-directory
99
+ // equivalent is not a literal anywhere in it.
100
+ skills: scopes({ native: false, alias: ".opencode/skills" }, { native: false, alias: ".config/opencode/skills", verified: false }),
93
101
  source: "https://opencode.ai/docs/rules/",
94
102
  },
95
103
  {
@@ -97,6 +105,7 @@ export const HARNESSES = [
97
105
  label: "Qwen Code",
98
106
  bins: ["qwen"],
99
107
  configRoot: ".qwen",
108
+ npmPackage: "@qwen-code/qwen-code",
100
109
  instructions: scopes({ native: true, note: "QWEN.md is the legacy name" }, { native: false, alias: ".qwen/AGENTS.md", note: "not documented by Qwen", verified: false }),
101
110
  skills: scopes({ native: false, alias: ".qwen/skills" }, { native: false, alias: ".qwen/skills" }),
102
111
  source: "https://qwenlm.github.io/qwen-code-docs/en/users/features/skills/",
@@ -115,9 +124,13 @@ export const HARNESSES = [
115
124
  label: "Kilo Code",
116
125
  bins: ["kilo"],
117
126
  configRoot: ".config/kilo",
127
+ npmPackage: "@kilocode/cli",
118
128
  instructions: scopes({ native: true }, { native: false, alias: ".config/kilo/AGENTS.md", note: "not documented by Kilo" }),
119
- // Kilo Code reads Claude Code's skills directory for compatibility.
120
- skills: scopes({ native: false, alias: ".claude/skills", note: "shares Claude Code's directory", verified: false }, { native: false, alias: ".config/kilo/skills", note: "unconfirmed", verified: false }),
129
+ // No skills path survived inspection of the shipped binary: its config
130
+ // directory holds command/, themes/ and config files, and .claude/skills
131
+ // (a third-party claim) appears nowhere in 195 MB. Reported as unknown
132
+ // rather than linked into a directory nothing reads.
133
+ skills: scopes({ native: false, note: "unresolved: not found in the shipped binary" }, { native: false, note: "unresolved: not found in the shipped binary" }),
121
134
  source: "https://github.com/intellectronica/ruler#skills-support-experimental",
122
135
  },
123
136
  {
@@ -125,7 +138,8 @@ export const HARNESSES = [
125
138
  label: "Factory Droid",
126
139
  bins: ["droid"],
127
140
  configRoot: ".factory",
128
- instructions: scopes({ native: true, note: "AGENTS.md may also live in the home directory" }, { native: false, alias: ".factory/AGENTS.md" }),
141
+ npmPackage: "@factory/cli",
142
+ instructions: scopes({ native: true, note: "AGENTS.md may also live in the home directory" }, { native: false, alias: ".factory/AGENTS.md", note: "not found in the shipped binary", verified: false }),
129
143
  skills: scopes({ native: false, alias: ".factory/skills" }, { native: false, alias: ".factory/skills" }),
130
144
  source: "https://docs.factory.ai/harness/skills",
131
145
  },
@@ -143,6 +157,7 @@ export const HARNESSES = [
143
157
  label: "Mastra Code",
144
158
  bins: ["mastracode"],
145
159
  configRoot: ".mastracode",
160
+ npmPackage: "mastracode",
146
161
  instructions: scopes({ native: true }, { native: false, alias: ".mastracode/AGENTS.md", note: "not documented by Mastra", verified: false }),
147
162
  // Mastra Code lists .agents/skills as a project source and Agent Skills
148
163
  // spec compatibility, so both scopes are native.
@@ -155,7 +170,9 @@ export const HARNESSES = [
155
170
  bins: ["grok"],
156
171
  configRoot: ".grok",
157
172
  instructions: scopes({ native: true, note: "reads AGENTS.md, CLAUDE.md, AGENT.md" }, { native: false, alias: ".grok/AGENTS.md", note: "not documented by Grok", verified: false }),
158
- skills: scopes({ native: false, alias: ".grok/skills" }, { native: false, alias: ".grok/skills" }),
173
+ // The binary's embedded documentation: "Grok also scans `.agents/skills/`
174
+ // (and `commands/`) at each tier (alongside `.grok/`)".
175
+ skills: scopes(NATIVE, NATIVE),
159
176
  source: "https://docs.x.ai/docs/grok-cli/skills",
160
177
  },
161
178
  {
@@ -163,8 +180,11 @@ export const HARNESSES = [
163
180
  label: "Qoder CLI",
164
181
  bins: ["qodercli", "qoder"],
165
182
  configRoot: ".qoder",
183
+ npmPackage: "@qoder-ai/qodercli",
166
184
  instructions: scopes({ native: true, note: "configurable via context.fileName" }, { native: false, alias: ".qoder/AGENTS.md", note: "not documented by Qoder", verified: false }),
167
- skills: scopes({ native: false, alias: ".qoder/skills" }, { native: false, alias: ".qoder/skills" }),
185
+ // Confirmed in the shipped bundle: .agents/skills is present, .qoder/skills
186
+ // is not. Its own config directory holds settings and repowiki, not skills.
187
+ skills: scopes(NATIVE, NATIVE),
168
188
  source: "https://docs.qoder.com/cli/Skills",
169
189
  },
170
190
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thesmurph/agentlink",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "One source of truth for agent instructions and skills, symlinked into every coding-agent harness.",
5
5
  "keywords": [
6
6
  "agents.md",
package/src/cli.ts CHANGED
@@ -14,13 +14,20 @@ import { resolveScope, type Scope, type ScopePaths } from "./scope.js";
14
14
  import { selectMany, type Choice } from "./ui.js";
15
15
 
16
16
  const ESC = String.fromCharCode(27);
17
- const BOLD = `${ESC}[1m`;
18
- const DIM = `${ESC}[2m`;
19
- const RED = `${ESC}[31m`;
20
- const GREEN = `${ESC}[32m`;
21
- const YELLOW = `${ESC}[33m`;
22
- const CYAN = `${ESC}[36m`;
23
- const RESET = `${ESC}[0m`;
17
+ // Colour only when something is reading it: a TTY, and not opted out. Piped
18
+ // output and CI logs stay free of escape codes.
19
+ const useColor =
20
+ process.env.NO_COLOR === undefined &&
21
+ process.env.FORCE_COLOR !== "0" &&
22
+ (process.env.FORCE_COLOR !== undefined || process.stdout.isTTY === true);
23
+ const paint = (code: string): string => (useColor ? `${ESC}[${code}m` : "");
24
+ const BOLD = paint("1");
25
+ const DIM = paint("2");
26
+ const RED = paint("31");
27
+ const GREEN = paint("32");
28
+ const YELLOW = paint("33");
29
+ const CYAN = paint("36");
30
+ const RESET = paint("0");;
24
31
 
25
32
  interface Options {
26
33
  command: string;
@@ -298,6 +305,11 @@ async function syncLinks(
298
305
  pruned,
299
306
  native: linkPlan.native,
300
307
  unknown: linkPlan.unknown,
308
+ selection: {
309
+ harnesses: chosen.map((h) => h.id),
310
+ added: chosen.map((h) => h.id).filter((id) => !previous.harnesses.includes(id)),
311
+ removed: previous.harnesses.filter((id) => !chosen.some((h) => h.id === id)),
312
+ },
301
313
  aliases: linkPlan.aliases,
302
314
  skills: linkPlan.skillsFound,
303
315
  conflicts: conflicts.map((fix) => fix.target),
@@ -321,6 +333,11 @@ async function syncLinks(
321
333
  `\n${BOLD}agentlink${RESET} ${DIM}${where} · ${chosen.length} harness${chosen.length === 1 ? "" : "es"}${options.dryRun ? " · dry run" : ""}${RESET}\n`,
322
334
  );
323
335
 
336
+ // An explicit --harnesses/--all/--detected changes the saved selection, so
337
+ // say which harnesses that added or dropped rather than letting it pass.
338
+ const selection = selectionChange(previous.harnesses, chosen);
339
+ if (selection) step("select", selection);
340
+
324
341
  for (const result of results) {
325
342
  const symbol =
326
343
  result.state === "skipped"
@@ -388,9 +405,12 @@ async function syncLinks(
388
405
  }
389
406
 
390
407
  async function runList(paths: ScopePaths, options: Options): Promise<void> {
408
+ const state = readState(paths);
409
+ const selected = new Set(state.harnesses);
391
410
  const rows = detectAll().map(({ harness, installed, reasons }) => ({
392
411
  id: harness.id,
393
412
  label: harness.label,
413
+ selected: selected.has(harness.id),
394
414
  installed,
395
415
  reasons,
396
416
  instructions: describe(harness.instructions[paths.scope]),
@@ -401,25 +421,37 @@ async function runList(paths: ScopePaths, options: Options): Promise<void> {
401
421
  }));
402
422
 
403
423
  if (options.json) {
404
- process.stdout.write(`${JSON.stringify({ scope: paths.scope, harnesses: rows }, null, 2)}\n`);
424
+ process.stdout.write(
425
+ `${JSON.stringify({ scope: paths.scope, root: paths.root, linked: [...selected], harnesses: rows }, null, 2)}\n`,
426
+ );
405
427
  return;
406
428
  }
407
429
 
408
430
  const width = Math.max(...rows.map((row) => row.label.length));
431
+ const installedCount = rows.filter((row) => row.installed).length;
409
432
  process.stdout.write(
410
- `\n${BOLD}harnesses${RESET} ${DIM}· scope: ${paths.scope}${RESET}\n\n`,
433
+ `\n${BOLD}harnesses${RESET} ${DIM}· scope: ${paths.scope} · ${selected.size} linked · ${installedCount} installed here${RESET}\n\n`,
411
434
  );
412
435
  for (const row of rows) {
413
- const mark = row.installed ? `${GREEN}●${RESET}` : `${DIM}○${RESET}`;
414
- const flag =
415
- !row.instructionsVerified || !row.skillsVerified ? ` ${YELLOW}unverified${RESET}` : "";
436
+ // The leading mark is the tool's own state: what is actually linked.
437
+ // Availability is only called out when it is surprising.
438
+ const mark = row.selected ? `${GREEN}✓${RESET}` : `${DIM}·${RESET}`;
439
+ const note =
440
+ row.selected && !row.installed
441
+ ? ` ${YELLOW}selected, not installed${RESET}`
442
+ : !row.selected && row.installed
443
+ ? ` ${DIM}installed, not linked${RESET}`
444
+ : "";
445
+ const flag = !row.instructionsVerified || !row.skillsVerified ? ` ${YELLOW}unverified${RESET}` : "";
416
446
  process.stdout.write(
417
- ` ${mark} ${row.label.padEnd(width)} ${DIM}instructions${RESET} ${row.instructions} ${DIM}skills${RESET} ${row.skills}${flag}\n`,
447
+ ` ${mark} ${row.label.padEnd(width)} ${DIM}instructions${RESET} ${row.instructions} ${DIM}skills${RESET} ${row.skills}${flag}${note}\n`,
418
448
  );
419
449
  }
420
450
  process.stdout.write(
421
- `\n ${GREEN}●${RESET} present on this machine ${DIM}native = harness reads AGENTS.md / .agents/skills itself${RESET}\n` +
422
- ` ${DIM}agentlink list --json prints the source URL for every row${RESET}\n`,
451
+ `\n ${GREEN}✓${RESET} linked ${DIM}·${RESET} not linked ${DIM}change with \`agentlink select\`${RESET}\n` +
452
+ ` ${DIM}native = harness reads AGENTS.md / .agents/skills itself${RESET}\n` +
453
+ ` ${YELLOW}unverified${RESET} ${DIM}= the vendor's docs do not confirm that path, so the link may sit where nothing reads it.${RESET}\n` +
454
+ ` ${DIM}It is not an error. \`list --json\` prints the source URL for every endpoint.${RESET}\n`,
423
455
  );
424
456
  }
425
457
 
@@ -604,6 +636,19 @@ function step(verb: string, message: string): void {
604
636
  process.stdout.write(` ${GREEN}✓${RESET} ${DIM}${verb.padEnd(7)}${RESET}${message}\n`);
605
637
  }
606
638
 
639
+ /** Describe a change to the saved selection, or null when nothing changed. */
640
+ function selectionChange(before: string[], chosen: Harness[]): string | null {
641
+ const after = chosen.map((harness) => harness.id);
642
+ const added = after.filter((id) => !before.includes(id));
643
+ const removed = before.filter((id) => !after.includes(id));
644
+ if (added.length === 0 && removed.length === 0) return null;
645
+ // The first run has nothing to compare against; the list above already says it.
646
+ const parts = [added.length ? `+${added.join(" ")}` : "", removed.length ? `-${removed.join(" ")}` : ""];
647
+ return `${after.length} harness${after.length === 1 ? "" : "es"}${
648
+ before.length ? ` (was ${before.length})` : ""
649
+ }: ${parts.filter(Boolean).join(" ")}`;
650
+ }
651
+
607
652
  function version(): void {
608
653
  try {
609
654
  const file = fileURLToPath(new URL("../package.json", import.meta.url));
package/src/harnesses.ts CHANGED
@@ -38,6 +38,13 @@ export interface Harness {
38
38
  bins: string[];
39
39
  /** Config directory relative to $HOME (from `herdr integration status`). */
40
40
  configRoot: string;
41
+ /**
42
+ * The vendor's own npm package, where one exists. Used to check this table
43
+ * against the code that actually ships; third-party packages with similar
44
+ * names are deliberately absent, since inspecting the wrong artifact is how a
45
+ * wrong path gets into a table.
46
+ */
47
+ npmPackage?: string;
41
48
  instructions: Record<Scope, Endpoint>;
42
49
  skills: Record<Scope, Endpoint>;
43
50
  /** Primary documentation these paths were read from. */
@@ -83,6 +90,7 @@ export const HARNESSES: Harness[] = [
83
90
  label: "Claude Code",
84
91
  bins: ["claude"],
85
92
  configRoot: ".claude",
93
+ npmPackage: "@anthropic-ai/claude-code",
86
94
  // Claude Code reads CLAUDE.md, not AGENTS.md, and never scans .agents/skills.
87
95
  instructions: scopes(
88
96
  { native: false, alias: "CLAUDE.md", note: "Claude Code has no AGENTS.md fallback" },
@@ -99,6 +107,7 @@ export const HARNESSES: Harness[] = [
99
107
  label: "OpenAI Codex CLI",
100
108
  bins: ["codex"],
101
109
  configRoot: ".codex",
110
+ npmPackage: "@openai/codex",
102
111
  // Codex reads AGENTS.md in the repo and ~/.codex/AGENTS.md globally.
103
112
  instructions: scopes(
104
113
  { native: true },
@@ -113,6 +122,7 @@ export const HARNESSES: Harness[] = [
113
122
  label: "Pi",
114
123
  bins: ["pi"],
115
124
  configRoot: ".pi",
125
+ npmPackage: "@earendil-works/pi-coding-agent",
116
126
  instructions: scopes(
117
127
  { native: true },
118
128
  { native: false, alias: ".pi/agent/AGENTS.md" },
@@ -126,6 +136,7 @@ export const HARNESSES: Harness[] = [
126
136
  label: "Oh My Pi (omp)",
127
137
  bins: ["omp"],
128
138
  configRoot: ".omp",
139
+ npmPackage: "@oh-my-pi/pi-coding-agent",
129
140
  instructions: scopes(
130
141
  { native: true, note: "standalone AGENTS.md via the agents-md provider" },
131
142
  { native: false, alias: ".omp/agent/AGENTS.md" },
@@ -142,6 +153,7 @@ export const HARNESSES: Harness[] = [
142
153
  label: "GitHub Copilot CLI",
143
154
  bins: ["copilot"],
144
155
  configRoot: ".copilot",
156
+ npmPackage: "@github/copilot",
145
157
  // Copilot CLI reads AGENTS.md everywhere, but its *user* instructions live
146
158
  // in a differently named file.
147
159
  instructions: scopes(
@@ -171,12 +183,15 @@ export const HARNESSES: Harness[] = [
171
183
  label: "opencode",
172
184
  bins: ["opencode"],
173
185
  configRoot: ".config/opencode",
186
+ npmPackage: "opencode-ai",
174
187
  instructions: scopes(
175
188
  { native: true },
176
189
  { native: false, alias: ".config/opencode/AGENTS.md" },
177
190
  ),
191
+ // .opencode/skills confirmed in the shipped binary; the home-directory
192
+ // equivalent is not a literal anywhere in it.
178
193
  skills: scopes(
179
- { native: false, alias: ".opencode/skills", verified: false },
194
+ { native: false, alias: ".opencode/skills" },
180
195
  { native: false, alias: ".config/opencode/skills", verified: false },
181
196
  ),
182
197
  source: "https://opencode.ai/docs/rules/",
@@ -186,6 +201,7 @@ export const HARNESSES: Harness[] = [
186
201
  label: "Qwen Code",
187
202
  bins: ["qwen"],
188
203
  configRoot: ".qwen",
204
+ npmPackage: "@qwen-code/qwen-code",
189
205
  instructions: scopes(
190
206
  { native: true, note: "QWEN.md is the legacy name" },
191
207
  { native: false, alias: ".qwen/AGENTS.md", note: "not documented by Qwen", verified: false },
@@ -216,14 +232,18 @@ export const HARNESSES: Harness[] = [
216
232
  label: "Kilo Code",
217
233
  bins: ["kilo"],
218
234
  configRoot: ".config/kilo",
235
+ npmPackage: "@kilocode/cli",
219
236
  instructions: scopes(
220
237
  { native: true },
221
238
  { native: false, alias: ".config/kilo/AGENTS.md", note: "not documented by Kilo" },
222
239
  ),
223
- // Kilo Code reads Claude Code's skills directory for compatibility.
240
+ // No skills path survived inspection of the shipped binary: its config
241
+ // directory holds command/, themes/ and config files, and .claude/skills
242
+ // (a third-party claim) appears nowhere in 195 MB. Reported as unknown
243
+ // rather than linked into a directory nothing reads.
224
244
  skills: scopes(
225
- { native: false, alias: ".claude/skills", note: "shares Claude Code's directory", verified: false },
226
- { native: false, alias: ".config/kilo/skills", note: "unconfirmed", verified: false },
245
+ { native: false, note: "unresolved: not found in the shipped binary" },
246
+ { native: false, note: "unresolved: not found in the shipped binary" },
227
247
  ),
228
248
  source: "https://github.com/intellectronica/ruler#skills-support-experimental",
229
249
  },
@@ -232,9 +252,10 @@ export const HARNESSES: Harness[] = [
232
252
  label: "Factory Droid",
233
253
  bins: ["droid"],
234
254
  configRoot: ".factory",
255
+ npmPackage: "@factory/cli",
235
256
  instructions: scopes(
236
257
  { native: true, note: "AGENTS.md may also live in the home directory" },
237
- { native: false, alias: ".factory/AGENTS.md" },
258
+ { native: false, alias: ".factory/AGENTS.md", note: "not found in the shipped binary", verified: false },
238
259
  ),
239
260
  skills: scopes(
240
261
  { native: false, alias: ".factory/skills" },
@@ -262,6 +283,7 @@ export const HARNESSES: Harness[] = [
262
283
  label: "Mastra Code",
263
284
  bins: ["mastracode"],
264
285
  configRoot: ".mastracode",
286
+ npmPackage: "mastracode",
265
287
  instructions: scopes(
266
288
  { native: true },
267
289
  { native: false, alias: ".mastracode/AGENTS.md", note: "not documented by Mastra", verified: false },
@@ -280,10 +302,9 @@ export const HARNESSES: Harness[] = [
280
302
  { native: true, note: "reads AGENTS.md, CLAUDE.md, AGENT.md" },
281
303
  { native: false, alias: ".grok/AGENTS.md", note: "not documented by Grok", verified: false },
282
304
  ),
283
- skills: scopes(
284
- { native: false, alias: ".grok/skills" },
285
- { native: false, alias: ".grok/skills" },
286
- ),
305
+ // The binary's embedded documentation: "Grok also scans `.agents/skills/`
306
+ // (and `commands/`) at each tier (alongside `.grok/`)".
307
+ skills: scopes(NATIVE, NATIVE),
287
308
  source: "https://docs.x.ai/docs/grok-cli/skills",
288
309
  },
289
310
  {
@@ -291,14 +312,14 @@ export const HARNESSES: Harness[] = [
291
312
  label: "Qoder CLI",
292
313
  bins: ["qodercli", "qoder"],
293
314
  configRoot: ".qoder",
315
+ npmPackage: "@qoder-ai/qodercli",
294
316
  instructions: scopes(
295
317
  { native: true, note: "configurable via context.fileName" },
296
318
  { native: false, alias: ".qoder/AGENTS.md", note: "not documented by Qoder", verified: false },
297
319
  ),
298
- skills: scopes(
299
- { native: false, alias: ".qoder/skills" },
300
- { native: false, alias: ".qoder/skills" },
301
- ),
320
+ // Confirmed in the shipped bundle: .agents/skills is present, .qoder/skills
321
+ // is not. Its own config directory holds settings and repowiki, not skills.
322
+ skills: scopes(NATIVE, NATIVE),
302
323
  source: "https://docs.qoder.com/cli/Skills",
303
324
  },
304
325
  {