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).
 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/`
 `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
 directory and links them into every AI agent detected on the machine
 (Claude Code, pi, Codex, OpenCode, Cursor, Gemini, Trae, Windsurf, Aider Desk).
 (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
 ```bash
 # npm >= 12 blocks git dependencies by default (EALLOWGIT): allow it per invocation
 # 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)
 # 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
 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)
 # 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
 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
 `$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
 `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
 ## 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 |
 | `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 |
 | `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 |
 | `codebase-analysis` | Analyze and document the architecture of an existing codebase without modifying it |
 | `code-simplifier` | Simplify and refactor code while preserving its behavior |
 | `code-simplifier` | Simplify and refactor code while preserving its behavior |
 | `coding-standard-rules` | Code quality rules applied to every write/edit task |
 | `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/) |
 | `create-docs` | Generate structured project documentation (AGENTS.md, specs/) |
 | `format-report-fix` | Standard format for reporting fixes with concrete impact |
 | `format-report-fix` | Standard format for reporting fixes with concrete impact |
 | `frontend-mobile-rules` | Mobile-first frontend rules: MUI, viewport, and iOS safe-area |
 | `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) |
 | `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 |
 | `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 |
 | `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
 // 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
 // user-level directory (~/.agents) and links them into every detected AI agent's
 // own directory (same architecture as the vercel-labs/skills CLI).
 // 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 { createHash } from 'node:crypto';
 import {
 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';
 } from 'node:fs';
 import { homedir, platform } from 'node:os';
 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';
 import { fileURLToPath } from 'node:url';
 
 
+const EXIT_OK = 0;
+const EXIT_FAILURE = 1;
+const EXIT_USAGE = 2;
+
 const IS_WINDOWS = platform() === 'win32';
 const IS_WINDOWS = platform() === 'win32';
-const DRY_RUN = process.argv.slice(2).includes('--check');
 
 
 const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
 const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
 const SOURCE_SKILLS_DIR = join(PACKAGE_ROOT, 'skills');
 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_SKILLS_DIR = join(homedir(), '.agents', 'skills');
 const CANONICAL_AGENTS_DIR = join(homedir(), '.agents', 'agents');
 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.
 // Resolves the Claude Code config dir, honoring CLAUDE_CONFIG_DIR when set.
 function claudeBase() {
 function claudeBase() {
   return process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude');
   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' },
   { 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.
 // Returns the SHA-256 hex digest of a file, or null when the file does not exist.
 function fileHash(filePath) {
 function fileHash(filePath) {
   if (!existsSync(filePath)) return null;
   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 = [];
   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';
     const status = dirHash(source) === dirHash(dest) ? 'up-to-date' : existsSync(dest) ? 'updated' : 'added';
     if (status !== 'up-to-date' && !DRY_RUN) {
     if (status !== 'up-to-date' && !DRY_RUN) {
       rmSync(dest, { recursive: true, force: true });
       rmSync(dest, { recursive: true, force: true });
       cpSync(source, dest, { recursive: 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;
   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
   return AGENT_REGISTRY
-    .filter((agent) => existsSync(agent.base()))
+    .filter((agent) => (!targetNames || targetNames.includes(agent.name)) && existsSync(agent.base()))
     .map((agent) => ({ ...agent, baseDir: 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
 // 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
 // foreign and skipped. New links use the best mechanism available: relative
 // symlink (unix), junction for directories (windows, no privileges), then
 // 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);
   const current = lstatOrNull(linkPath);
   if (current) {
   if (current) {
     if (current.isSymbolicLink() && pointsTo(linkPath, targetPath)) return 'up-to-date';
     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';
   if (DRY_RUN) return 'link';
   mkdirSync(dirname(linkPath), { recursive: true });
   mkdirSync(dirname(linkPath), { recursive: true });
@@ -158,99 +299,74 @@ function ensureLink(linkPath, targetPath) {
     symlinkSync(relative(dirname(linkPath), targetPath), linkPath, 'file');
     symlinkSync(relative(dirname(linkPath), targetPath), linkPath, 'file');
     return 'linked';
     return 'linked';
   } catch {
   } 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 {
     try {
       linkSync(targetPath, linkPath);
       linkSync(targetPath, linkPath);
       return 'hardlinked';
       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 = [];
   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({
     actions.push({
       label: `${agent.name}:skills/${name}`,
       label: `${agent.name}:skills/${name}`,
       status: ensureLink(join(agent.baseDir, agent.skillsSub, name), join(CANONICAL_SKILLS_DIR, 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;
   return actions;
 }
 }
 
 
 // Orchestrates the sync and prints a per-entry report with a final summary.
 // Orchestrates the sync and prints a per-entry report with a final summary.
 function main() {
 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)) {
   if (!existsSync(SOURCE_SKILLS_DIR) || !existsSync(SOURCE_AGENTS_DIR)) {
     console.error(`error: bundled content not found under ${PACKAGE_ROOT}`);
     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) {
   if (!DRY_RUN) {
     mkdirSync(CANONICAL_SKILLS_DIR, { recursive: true });
     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) {
   for (const { label, status } of report) {
     console.log(`${status.padEnd(12)} ${label}`);
     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;
   const drift = report.filter((entry) => entry.status !== 'up-to-date' && entry.status !== 'skipped').length;
   if (drift === 0) {
   if (drift === 0) {
     console.log('Everything up to date.');
     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.`);
     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) {
   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 {
 try {
   main();
   main();
 } catch (error) {
 } catch (error) {
   console.error(`error: ${error.message}`);
   console.error(`error: ${error.message}`);
-  process.exit(1);
+  process.exit(EXIT_FAILURE);
 }
 }