Browse Source

refactor(agent-sync): enhance command-line interface and improve help documentation

softword 1 month ago
parent
commit
c5daeca56c
2 changed files with 245 additions and 146 deletions
  1. 28 48
      README.md
  2. 217 98
      bin/agent-sync.mjs

+ 28 - 48
README.md

@@ -2,54 +2,17 @@
 
 Skills for the [Pi Coding Agent](https://www.npmjs.com/package/@earendil-works/pi-coding-agent).
 
-## Installation with `npx skills`
-
-The [skills](https://github.com/vercel-labs/skills) CLI clones the repo, automatically detects pi,
-and installs the skills you choose.
-
-```bash
-npx skills add https://gogs.gvoci.com/softword/oh-my-skills.git
-```
-
-Useful variants:
-
-```bash
-# List available skills without installing anything
-npx skills add https://gogs.gvoci.com/softword/oh-my-skills.git -l
-
-# Install only some skills
-npx skills add https://gogs.gvoci.com/softword/oh-my-skills.git -s commit -s coding-standard-rules
-
-# Install globally (all projects) instead of the current project
-npx skills add https://gogs.gvoci.com/softword/oh-my-skills.git -g
-
-# Install ALL skills without confirmations
-npx skills add https://gogs.gvoci.com/softword/oh-my-skills.git --all
-
-# Copy files instead of creating symlinks
-npx skills add https://gogs.gvoci.com/softword/oh-my-skills.git --copy
-```
-
-> ⚠️ The repository is private: Git access to `gogs.gvoci.com` must be configured
-> (saved credentials or SSH key), otherwise the clone fails.
-
-Updates and removal:
-
-```bash
-npx skills update        # update installed skills (project)
-npx skills update -g     # update only globally installed skills
-npx skills remove        # remove skills (guided)
-npx skills remove -g     # remove globally installed skills
-npx skills ls            # list installed skills
-```
-
-## Install everything with `agent-sync`
+## Installation
 
 `agent-sync` copies the skills and the agent prompts into the canonical `~/.agents/`
 directory and links them into every AI agent detected on the machine
 (Claude Code, pi, Codex, OpenCode, Cursor, Gemini, Trae, Windsurf, Aider Desk).
-On Windows it uses junctions for skill directories and degrades gracefully
-(hardlink, then copy) for agent files; on macOS/Linux it uses relative symlinks.
+On Windows it uses junctions for skill directories; for agent files it creates
+a symlink, or a hardlink when symlinks are unavailable (it never copies: if no
+link mechanism works the sync aborts with an error); on macOS/Linux it uses
+relative symlinks. Selection is explicit: only what a flag lists is synced,
+a selection flag without a value covers its whole dimension, and without any
+selection flag `agent-sync` prints its help.
 
 ```bash
 # npm >= 12 blocks git dependencies by default (EALLOWGIT): allow it per invocation
@@ -57,16 +20,33 @@ npm_config_allow_git=all npx git+https://gogs.gvoci.com/softword/oh-my-skills.gi
 
 # or install the command globally (persistent `agent-sync` in PATH)
 npm_config_allow_git=all npm install -g git+https://gogs.gvoci.com/softword/oh-my-skills.git
-agent-sync
+
+# discover what is bundled and which agents are detected on the machine
+agent-sync --list
+
+# sync everything into every detected agent
+agent-sync --all
+
+# sync only some skills, only into some agents
+agent-sync --skills commit,coding-standard-rules --targets claude,pi
+
+# a flag without a value covers the whole dimension: every skill into pi
+agent-sync --skills --targets pi
+
+# sync only agent prompts (canonical copy, links, pi manifests)
+agent-sync --agents gv-dev,gv-reviewer
 
 # dry-run: report drift without writing anything (exit 1 when out of sync)
-agent-sync --check
+agent-sync --all --check
 ```
 
 On cmd.exe run `set npm_config_allow_git=all` first; on PowerShell use
 `$env:npm_config_allow_git='all'`. To pick up new commits, reinstall with the same
 `npm install -g` command: the content is embedded in the package, so tool and
- content update together.
+content update together.
+
+> ⚠️ The repository is private: Git access to `gogs.gvoci.com` must be configured
+> (saved credentials or SSH key), otherwise the install fails.
 
 ## Skill catalog
 
@@ -74,6 +54,7 @@ On cmd.exe run `set npm_config_allow_git=all` first; on PowerShell use
 |---|---|
 | `add-phpengine-plugin` | Create or modify PHP plugins for the phpengine backend, following the official recipe |
 | `ascii-preview-generator` | ASCII visual previews of interfaces, layouts, and UI components |
+| `brainstorming` | Orchestrator: brainstorming, inspection, planning, delegation, and review |
 | `codebase-analysis` | Analyze and document the architecture of an existing codebase without modifying it |
 | `code-simplifier` | Simplify and refactor code while preserving its behavior |
 | `coding-standard-rules` | Code quality rules applied to every write/edit task |
@@ -81,7 +62,6 @@ On cmd.exe run `set npm_config_allow_git=all` first; on PowerShell use
 | `create-docs` | Generate structured project documentation (AGENTS.md, specs/) |
 | `format-report-fix` | Standard format for reporting fixes with concrete impact |
 | `frontend-mobile-rules` | Mobile-first frontend rules: MUI, viewport, and iOS safe-area |
-| `brainstorming` | Orchestrator: brainstorming, inspection, planning, delegation, and review |
 | `refactor-rename` | Safe renaming of identifiers across files and folders (script included, with dry-run) |
 | `review-fix-loop` | Repeated review→fix loop until zero violations |
 | `session-handoff` | Writes a HANDOFF.md to hand the work off to another agent or session |

+ 217 - 98
bin/agent-sync.mjs

@@ -2,20 +2,23 @@
 // agent-sync — installs the bundled skills and agent prompts into the canonical
 // user-level directory (~/.agents) and links them into every detected AI agent's
 // own directory (same architecture as the vercel-labs/skills CLI).
-// Usage: agent-sync [--check]
-//   --check : dry-run, reports drift without writing anything (exit 1 on drift)
+// Selection is explicit (--all, --skills, --agents, --targets): without a
+// selection flag the tool prints its help. See HELP for the full surface.
 
 import { createHash } from 'node:crypto';
 import {
-  copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, readdirSync,
-  readFileSync, realpathSync, rmSync, statSync, symlinkSync, linkSync, writeFileSync,
+  cpSync, existsSync, lstatSync, mkdirSync, readdirSync,
+  readFileSync, realpathSync, rmSync, statSync, symlinkSync, linkSync,
 } from 'node:fs';
 import { homedir, platform } from 'node:os';
-import { basename, dirname, join, relative, sep } from 'node:path';
+import { basename, dirname, join, relative } from 'node:path';
 import { fileURLToPath } from 'node:url';
 
+const EXIT_OK = 0;
+const EXIT_FAILURE = 1;
+const EXIT_USAGE = 2;
+
 const IS_WINDOWS = platform() === 'win32';
-const DRY_RUN = process.argv.slice(2).includes('--check');
 
 const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
 const SOURCE_SKILLS_DIR = join(PACKAGE_ROOT, 'skills');
@@ -24,6 +27,27 @@ const SOURCE_AGENTS_DIR = join(PACKAGE_ROOT, 'agents');
 const CANONICAL_SKILLS_DIR = join(homedir(), '.agents', 'skills');
 const CANONICAL_AGENTS_DIR = join(homedir(), '.agents', 'agents');
 
+const HELP = `agent-sync — installs bundled skills and agent prompts into the
+canonical ~/.agents directory and links them into the AI agents detected
+on the machine.
+
+Usage:
+  agent-sync --all [--check] [--targets [<names>]]
+  agent-sync --skills [<names>] [--agents [<names>]] [--check] [--targets [<names>]]
+  agent-sync --list
+  agent-sync --help
+
+A selection flag without a value covers its whole dimension: --skills alone
+selects every bundled skill, --agents alone every prompt, --targets alone
+every detected agent. With a comma-separated value only the listed names are
+used. Flags combine: --skills commit --targets claude,pi syncs just the
+commit skill into Claude Code and pi. Without any selection flag agent-sync
+prints this help.
+
+  --check   dry-run, reports drift without writing anything (exit 1 on drift)
+
+Exit codes: ${EXIT_OK} ok or help, ${EXIT_FAILURE} drift (dry-run) or failure, ${EXIT_USAGE} usage error.`;
+
 // Resolves the Claude Code config dir, honoring CLAUDE_CONFIG_DIR when set.
 function claudeBase() {
   return process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude');
@@ -51,6 +75,99 @@ const AGENT_REGISTRY = [
   { name: 'aider-desk', base: homeDir('.aider-desk'),        skillsSub: 'skills' },
 ];
 
+// Prints a usage error and exits with the usage exit code.
+function fail(message) {
+  console.error(`${message}\nRun 'agent-sync --help' for usage.`);
+  process.exit(EXIT_USAGE);
+}
+
+// Returns the names of the bundled skills (directories under skills/).
+function bundledSkillNames() {
+  return readdirSync(SOURCE_SKILLS_DIR, { withFileTypes: true })
+    .filter((entry) => entry.isDirectory())
+    .map((entry) => entry.name);
+}
+
+// Returns the file names of the bundled agent prompts (*.md under agents/).
+function bundledAgentFiles() {
+  return readdirSync(SOURCE_AGENTS_DIR).filter((name) => name.endsWith('.md'));
+}
+
+// Dimension flags hold three states: null (flag absent), true (whole
+// dimension) or an array of names. A repeated flag accumulates names, and a
+// valueless occurrence widens the dimension to everything.
+function mergeDimension(current, value) {
+  if (current === true || value === null) return true;
+  return [...(current ?? []), ...value];
+}
+
+const DIMENSION_FLAGS = { '--skills': 'skills', '--agents': 'agents', '--targets': 'targets' };
+
+// Parses the command line into a run plan (syntax only: names are validated
+// later against the bundled content). Selection flags are --all, --skills,
+// --agents and --targets; every other flag is a modifier.
+function parseArgs(argv) {
+  const plan = { help: false, list: false, dryRun: false, all: false, skills: null, agents: null, targets: null };
+  const optionalList = (index, flag) => {
+    const value = argv[index + 1];
+    if (!value || value.startsWith('--')) return null;
+    const names = value.split(',').map((name) => name.trim()).filter(Boolean);
+    if (names.length === 0) fail(`error: ${flag} received an empty list of names`);
+    return names;
+  };
+  for (let i = 0; i < argv.length; i += 1) {
+    const arg = argv[i];
+    if (arg === '--help' || arg === '-h') plan.help = true;
+    else if (arg === '--list') plan.list = true;
+    else if (arg === '--check') plan.dryRun = true;
+    else if (arg === '--all') plan.all = true;
+    else if (DIMENSION_FLAGS[arg]) {
+      const key = DIMENSION_FLAGS[arg];
+      const value = optionalList(i, arg);
+      plan[key] = mergeDimension(plan[key], value);
+      if (value !== null) i += 1;
+    }
+    else fail(`error: unknown argument '${arg}'`);
+  }
+  return plan;
+}
+
+// Validates the requested names against the bundled content and the agent
+// registry, then resolves the effective selection. A dimension set to true
+// expands to everything; prompt names are given without extension (gv-dev)
+// and mapped to their bundled files (gv-dev.md).
+function resolveSelection(plan) {
+  const skillNames = bundledSkillNames();
+  const agentFiles = bundledAgentFiles();
+  const registryNames = AGENT_REGISTRY.map((agent) => agent.name);
+  const pickSkill = (name) => {
+    if (!skillNames.includes(name)) fail(`error: unknown skill '${name}' (available: ${skillNames.join(', ')})`);
+    return name;
+  };
+  const pickAgent = (name) => {
+    const file = agentFiles.find((candidate) => basename(candidate, '.md') === name);
+    if (!file) fail(`error: unknown agent '${name}' (available: ${agentFiles.map((f) => basename(f, '.md')).join(', ')})`);
+    return file;
+  };
+  const pickTarget = (name) => {
+    if (!registryNames.includes(name)) fail(`error: unknown target '${name}' (available: ${registryNames.join(', ')})`);
+    return name;
+  };
+  return {
+    skills: plan.all || plan.skills === true ? skillNames : (plan.skills ?? []).map(pickSkill),
+    agents: plan.all || plan.agents === true ? agentFiles : (plan.agents ?? []).map(pickAgent),
+    targets: plan.targets === true || !plan.targets ? null : plan.targets.map(pickTarget),
+  };
+}
+
+// Prints the bundled catalog plus the AI agents detected on this machine.
+function printCatalog() {
+  const detected = AGENT_REGISTRY.filter((agent) => existsSync(agent.base())).map((agent) => agent.name);
+  console.log(`skills:  ${bundledSkillNames().join(', ')}`);
+  console.log(`agents:  ${bundledAgentFiles().map((file) => basename(file, '.md')).join(', ')}`);
+  console.log(`targets: ${AGENT_REGISTRY.map((agent) => `${agent.name}${detected.includes(agent.name) ? '*' : ''}`).join(', ')}  (* = detected)`);
+}
+
 // Returns the SHA-256 hex digest of a file, or null when the file does not exist.
 function fileHash(filePath) {
   if (!existsSync(filePath)) return null;
@@ -91,37 +208,52 @@ function pointsTo(linkPath, targetPath) {
   }
 }
 
-// Copies bundled skills and agents into the canonical directory, replacing
-// entries whose content differs. Returns the list of performed actions.
-function syncCanonical() {
+// Ensures the canonical agents directory exists as a real directory, so the
+// individual .md links have a place to live. An earlier version linked the
+// whole folder with a junction: such a link to our own source is unwound
+// here (removing the junction never touches the source files).
+function ensureCanonicalAgentsDir() {
+  const current = lstatOrNull(CANONICAL_AGENTS_DIR);
+  if (!current) {
+    if (DRY_RUN) return { label: 'agents', status: 'link' };
+    mkdirSync(CANONICAL_AGENTS_DIR, { recursive: true });
+    return { label: 'agents', status: 'added' };
+  }
+  if (current.isSymbolicLink()) {
+    if (!pointsTo(CANONICAL_AGENTS_DIR, SOURCE_AGENTS_DIR)) return { label: 'agents', status: 'skipped' };
+    if (DRY_RUN) return { label: 'agents', status: 'relink' };
+    rmSync(CANONICAL_AGENTS_DIR, { recursive: true, force: true });
+    mkdirSync(CANONICAL_AGENTS_DIR, { recursive: true });
+    return { label: 'agents', status: 'relinked' };
+  }
+  return current.isDirectory() ? { label: 'agents', status: 'up-to-date' } : { label: 'agents', status: 'skipped' };
+}
+
+// Copies the selected skills into the canonical directory and links the agent
+// prompt files individually (no copies, ever). Returns the list of actions.
+function syncCanonical(skills, agentFiles) {
   const actions = [];
-  for (const entry of readdirSync(SOURCE_SKILLS_DIR, { withFileTypes: true })) {
-    if (!entry.isDirectory()) continue;
-    const source = join(SOURCE_SKILLS_DIR, entry.name);
-    const dest = join(CANONICAL_SKILLS_DIR, entry.name);
+  for (const name of skills) {
+    const source = join(SOURCE_SKILLS_DIR, name);
+    const dest = join(CANONICAL_SKILLS_DIR, name);
     const status = dirHash(source) === dirHash(dest) ? 'up-to-date' : existsSync(dest) ? 'updated' : 'added';
     if (status !== 'up-to-date' && !DRY_RUN) {
       rmSync(dest, { recursive: true, force: true });
       cpSync(source, dest, { recursive: true });
     }
-    actions.push({ label: `skills/${entry.name}`, status });
+    actions.push({ label: `skills/${name}`, status });
   }
-  for (const file of readdirSync(SOURCE_AGENTS_DIR).filter((name) => name.endsWith('.md'))) {
-    const source = join(SOURCE_AGENTS_DIR, file);
-    const dest = join(CANONICAL_AGENTS_DIR, file);
-    const status = fileHash(source) === fileHash(dest) ? 'up-to-date' : existsSync(dest) ? 'updated' : 'added';
-    if (status !== 'up-to-date' && !DRY_RUN) {
-      copyFileSync(source, dest);
-    }
-    actions.push({ label: `agents/${file}`, status });
+  for (const file of agentFiles) {
+    actions.push({ label: `agents/${file}`, status: ensureLink(join(CANONICAL_AGENTS_DIR, file), join(SOURCE_AGENTS_DIR, file), { convertForeign: true }) });
   }
   return actions;
 }
 
-// Detects installed agents by probing their config directory.
-function detectAgents() {
+// Detects installed agents by probing their config directory, optionally
+// restricted to the requested target names.
+function detectAgents(targetNames) {
   return AGENT_REGISTRY
-    .filter((agent) => existsSync(agent.base()))
+    .filter((agent) => (!targetNames || targetNames.includes(agent.name)) && existsSync(agent.base()))
     .map((agent) => ({ ...agent, baseDir: agent.base() }));
 }
 
@@ -129,19 +261,28 @@ function detectAgents() {
 // a link already pointing at the target counts as up-to-date, anything else is
 // foreign and skipped. New links use the best mechanism available: relative
 // symlink (unix), junction for directories (windows, no privileges), then
-// hardlink and copy as windows file fallbacks.
-function ensureLink(linkPath, targetPath) {
+// hardlink as the windows file fallback. Copies are never created: when no
+// link mechanism works the sync aborts with an explicit error. With
+// convertForeign an existing regular file (leftover from old copy-based
+// installs) is replaced by a proper link instead of being kept as a copy.
+function ensureLink(linkPath, targetPath, { convertForeign = false } = {}) {
   const current = lstatOrNull(linkPath);
   if (current) {
     if (current.isSymbolicLink() && pointsTo(linkPath, targetPath)) return 'up-to-date';
-    // Regular files produced by this tool's own windows fallbacks (hardlink,
-    // copy) converge to up-to-date when their content matches the target.
-    // Missing or non-file targets degrade to 'skipped': in dry-run the
-    // canonical target may not be installed yet.
-    if (current.isFile() && lstatOrNull(targetPath)?.isFile() && fileHash(linkPath) === fileHash(targetPath)) {
-      return 'up-to-date';
+    if (convertForeign && current.isFile()) {
+      if (DRY_RUN) return 'relink';
+      rmSync(linkPath, { force: true });
+    } else {
+      // Regular files left by this tool's own windows fallbacks (hardlink, or
+      // copies from versions that still had a copy fallback) converge to
+      // up-to-date when their content matches the target.
+      // Missing or non-file targets degrade to 'skipped': in dry-run the
+      // canonical target may not be installed yet.
+      if (current.isFile() && lstatOrNull(targetPath)?.isFile() && fileHash(linkPath) === fileHash(targetPath)) {
+        return 'up-to-date';
+      }
+      return 'skipped';
     }
-    return 'skipped';
   }
   if (DRY_RUN) return 'link';
   mkdirSync(dirname(linkPath), { recursive: true });
@@ -158,99 +299,74 @@ function ensureLink(linkPath, targetPath) {
     symlinkSync(relative(dirname(linkPath), targetPath), linkPath, 'file');
     return 'linked';
   } catch {
-    // Windows symlinks need Developer Mode: degrade to hardlink (same volume),
-    // then to a plain copy so installation always succeeds.
+    // Windows symlinks need Developer Mode: degrade to a hardlink (same
+    // volume). A copy is never a fallback: failing loudly keeps the "links
+    // only" guarantee instead of silently diverging content.
     try {
       linkSync(targetPath, linkPath);
       return 'hardlinked';
-    } catch {
-      copyFileSync(targetPath, linkPath);
-      return 'copied';
+    } catch (error) {
+      throw new Error(`cannot link '${linkPath}' without a copy (symlink and hardlink both failed: ${error.message})`);
     }
   }
 }
 
-// Links every bundled skill and agent prompt (source package names only, so
-// third-party content in the canonical directory is never propagated) into one
-// detected agent's directories.
-function linkForAgent(agent) {
+// Links the selected skills (source package names only, so third-party
+// content in the canonical directory is never propagated) into one detected
+// agent's directories. Agent prompts are deliberately not linked here: they
+// are managed only inside the canonical ~/.agents/agents directory.
+function linkForAgent(agent, skills) {
   const actions = [];
-  for (const name of readdirSync(SOURCE_SKILLS_DIR, { withFileTypes: true })
-    .filter((entry) => entry.isDirectory())
-    .map((entry) => entry.name)) {
+  for (const name of skills) {
     actions.push({
       label: `${agent.name}:skills/${name}`,
       status: ensureLink(join(agent.baseDir, agent.skillsSub, name), join(CANONICAL_SKILLS_DIR, name)),
     });
   }
-  if (agent.agentsSub && agent.name !== 'pi') {
-    for (const file of readdirSync(SOURCE_AGENTS_DIR).filter((name) => name.endsWith('.md'))) {
-      actions.push({
-        label: `${agent.name}:agents/${file}`,
-        status: ensureLink(join(agent.baseDir, agent.agentsSub, file), join(CANONICAL_AGENTS_DIR, file)),
-      });
-    }
-  }
-  return actions;
-}
-
-// Creates a minimal pi manifest for each bundled agent prompt, only when absent:
-// existing manifests hold per-machine config (model, thinking, tools) that must
-// never be overwritten. Only package-sourced agents are considered, so
-// third-party prompts in the canonical directory never receive a manifest.
-function writePiManifests(piAgent) {
-  const piAgentsDir = join(piAgent.baseDir, piAgent.agentsSub);
-  const actions = [];
-  for (const file of readdirSync(SOURCE_AGENTS_DIR).filter((name) => name.endsWith('.md'))) {
-    const manifestPath = join(piAgentsDir, file);
-    if (lstatOrNull(manifestPath)) {
-      actions.push({ label: `pi-manifest:${file}`, status: 'up-to-date' });
-      continue;
-    }
-    if (!DRY_RUN) {
-      const promptPath = join(CANONICAL_AGENTS_DIR, file).split(sep).join('/');
-      const heading = readFileSync(join(SOURCE_AGENTS_DIR, file), 'utf8')
-        .split('\n')
-        .find((line) => line.startsWith('# '));
-      const description = heading ? heading.replace(/^#+\s*/, '').trim() : 'Installed by agent-sync';
-      mkdirSync(piAgentsDir, { recursive: true });
-      writeFileSync(manifestPath, `---\nname: ${JSON.stringify(basename(file, '.md'))}\ndescription: ${JSON.stringify(description)}\nprompt: ${JSON.stringify(promptPath)}\n---\n`);
-    }
-    actions.push({ label: `pi-manifest:${file}`, status: 'manifest' });
-  }
   return actions;
 }
 
 // Orchestrates the sync and prints a per-entry report with a final summary.
 function main() {
-  const args = process.argv.slice(2);
-  if (args.some((arg) => arg !== '--check')) {
-    console.error('Usage: agent-sync [--check]');
-    process.exit(2);
+  if (PLAN.help) {
+    console.log(HELP);
+    process.exit(EXIT_OK);
   }
   if (!existsSync(SOURCE_SKILLS_DIR) || !existsSync(SOURCE_AGENTS_DIR)) {
     console.error(`error: bundled content not found under ${PACKAGE_ROOT}`);
-    process.exit(1);
+    process.exit(EXIT_FAILURE);
+  }
+  const hasContent = PLAN.all || PLAN.skills || PLAN.agents;
+  if (PLAN.list && (hasContent || PLAN.targets)) fail('error: --list cannot be combined with selection flags');
+  if (PLAN.all && (PLAN.skills || PLAN.agents)) fail('error: --all cannot be combined with --skills or --agents');
+  if (PLAN.targets && !hasContent) fail('error: --targets requires --all, --skills or --agents');
+  if (PLAN.list) {
+    printCatalog();
+    process.exit(EXIT_OK);
+  }
+  if (!hasContent) {
+    console.log(HELP);
+    process.exit(EXIT_OK);
+  }
+  const { skills, agents: agentFiles, targets } = resolveSelection(PLAN);
+
+  const detected = detectAgents(targets);
+  if (targets && detected.length === 0) {
+    fail(`error: none of the requested targets is installed (${targets.join(', ')})`);
   }
   if (!DRY_RUN) {
     mkdirSync(CANONICAL_SKILLS_DIR, { recursive: true });
-    mkdirSync(CANONICAL_AGENTS_DIR, { recursive: true });
   }
 
-  const report = [...syncCanonical()];
-  const agents = detectAgents();
-  for (const agent of agents) {
-    report.push(...linkForAgent(agent));
-  }
-  const piAgent = agents.find((agent) => agent.name === 'pi');
-  if (piAgent) {
-    report.push(...writePiManifests(piAgent));
+  const report = [ensureCanonicalAgentsDir(), ...syncCanonical(skills, agentFiles)];
+  for (const agent of detected) {
+    report.push(...linkForAgent(agent, skills));
   }
 
   for (const { label, status } of report) {
     console.log(`${status.padEnd(12)} ${label}`);
   }
-  console.log(`\nagents detected: ${agents.map((agent) => agent.name).join(', ') || 'none'}`);
+  console.log(`\nagents detected: ${detected.map((agent) => agent.name).join(', ') || 'none'}`);
   const drift = report.filter((entry) => entry.status !== 'up-to-date' && entry.status !== 'skipped').length;
   if (drift === 0) {
     console.log('Everything up to date.');
@@ -258,13 +374,16 @@ function main() {
     console.log(DRY_RUN ? `${drift} entr${drift === 1 ? 'y' : 'ies'} out of sync (dry-run, nothing written).` : `${drift} entr${drift === 1 ? 'y' : 'ies'} synced.`);
   }
   if (DRY_RUN && drift > 0) {
-    process.exit(1);
+    process.exit(EXIT_FAILURE);
   }
 }
 
+const PLAN = parseArgs(process.argv.slice(2));
+const DRY_RUN = PLAN.dryRun;
+
 try {
   main();
 } catch (error) {
   console.error(`error: ${error.message}`);
-  process.exit(1);
+  process.exit(EXIT_FAILURE);
 }