skills.mjs

  1/**
  2 * `impeccable skills` subcommand
  3 *
  4 * Usage:
  5 *   impeccable skills help      Show all available skills and commands
  6 *   impeccable skills install   Install skills via npx skills add
  7 *   impeccable skills update    Update skills to latest version
  8 */
  9
 10import { execSync } from 'node:child_process';
 11import { existsSync, readFileSync, readdirSync, statSync, lstatSync, symlinkSync, readlinkSync, unlinkSync, mkdirSync, writeFileSync, rmSync, renameSync, createWriteStream, realpathSync } from 'node:fs';
 12import { join, resolve, dirname } from 'node:path';
 13import { createInterface } from 'node:readline';
 14import { fileURLToPath } from 'node:url';
 15import { get } from 'node:https';
 16import { createHash } from 'node:crypto';
 17import { tmpdir } from 'node:os';
 18
 19const __dirname = dirname(fileURLToPath(import.meta.url));
 20const API_BASE = 'https://impeccable.style';
 21
 22// Provider folder names in project roots
 23const PROVIDER_DIRS = ['.claude', '.cursor', '.gemini', '.agents', '.github', '.kiro', '.opencode', '.pi', '.qoder', '.trae', '.trae-cn'];
 24
 25function ask(question) {
 26  const rl = createInterface({ input: process.stdin, output: process.stdout });
 27  return new Promise(r => rl.question(question, ans => { rl.close(); r(ans.trim().toLowerCase()); }));
 28}
 29
 30// ─── skills help ──────────────────────────────────────────────────────────────
 31
 32async function showHelp() {
 33  let commands;
 34  try {
 35    const res = await fetch(`${API_BASE}/api/commands`);
 36    commands = await res.json();
 37  } catch {
 38    console.error('Could not fetch command list from impeccable.style. Check your network connection.');
 39    process.exit(1);
 40  }
 41
 42  const pad = (s, n) => s + ' '.repeat(Math.max(0, n - s.length));
 43
 44  console.log('\n  Impeccable Skills & Commands\n');
 45  console.log('  Install:  npx impeccable skills install');
 46  console.log('  Update:   npx impeccable skills update');
 47  console.log('  Docs:     https://impeccable.style/cheatsheet\n');
 48  console.log(`  ${pad('Command', 22)} Description`);
 49  console.log(`  ${'-'.repeat(22)} ${'-'.repeat(52)}`);
 50
 51  for (const cmd of commands.sort((a, b) => a.id.localeCompare(b.id))) {
 52    // Trim description to fit terminal
 53    const desc = cmd.description.length > 72
 54      ? cmd.description.substring(0, 69) + '...'
 55      : cmd.description;
 56    console.log(`  ${pad('/' + cmd.id, 22)} ${desc}`);
 57  }
 58  console.log(`\n  ${commands.length} commands available. Run /<command> in your AI harness.\n`);
 59}
 60
 61// ─── version helpers ─────────────────────────────────────────────────────────
 62
 63/**
 64 * Read the skills version from the impeccable SKILL.md frontmatter.
 65 */
 66function getSkillsVersion(root) {
 67  for (const d of PROVIDER_DIRS) {
 68    const skillMd = join(root, d, 'skills', 'impeccable', 'SKILL.md');
 69    if (!existsSync(skillMd)) continue;
 70    const content = readFileSync(skillMd, 'utf-8');
 71    const match = content.match(/^version:\s*(.+)$/m);
 72    if (match) return match[1].trim().replace(/^["']|["']$/g, '');
 73  }
 74  return null;
 75}
 76
 77/**
 78 * Hash all SKILL.md files in a directory tree for comparison.
 79 * Returns a sorted string of "name:hash" pairs.
 80 */
 81function hashSkillsDir(skillsDir) {
 82  if (!existsSync(skillsDir)) return '';
 83  const entries = [];
 84  for (const name of readdirSync(skillsDir).sort()) {
 85    const skillMd = join(skillsDir, name, 'SKILL.md');
 86    if (!existsSync(skillMd)) continue;
 87    const hash = createHash('sha256').update(readFileSync(skillMd)).digest('hex').slice(0, 12);
 88    entries.push(`${name}:${hash}`);
 89  }
 90  return entries.join(',');
 91}
 92
 93/**
 94 * Download the universal bundle to a temp dir and return its path.
 95 * Caller is responsible for cleanup.
 96 */
 97async function downloadAndExtractBundle() {
 98  const tmpZip = join(tmpdir(), `impeccable-update-${Date.now()}.zip`);
 99  const tmpDir = join(tmpdir(), `impeccable-update-${Date.now()}`);
100  await downloadFile(`${API_BASE}/api/download/bundle/universal`, tmpZip);
101  mkdirSync(tmpDir, { recursive: true });
102  execSync(`unzip -qo "${tmpZip}" -d "${tmpDir}"`, { encoding: 'utf8' });
103  rmSync(tmpZip, { force: true });
104  return tmpDir;
105}
106
107/**
108 * Normalize a SKILL.md's content for comparison by stripping
109 * provider-specific paths. Different install methods (npx skills add
110 * vs our bundle) resolve {{scripts_path}} to different provider dirs
111 * (e.g. .agents vs .claude), so we strip those differences.
112 */
113function normalizeForHash(content) {
114  return content
115    .replace(/\.(claude|cursor|agents|github|gemini|codex|kiro|opencode|pi|qoder|trae|trae-cn|rovodev)\/skills\//g, '.PROVIDER/skills/')
116    .replace(/^version:\s*.+$/m, 'version: NORMALIZED');
117}
118
119/**
120 * Deduplicate providers by resolved path. When .claude/skills is a
121 * symlink to ../.agents/skills, both resolve to the same directory.
122 * Returns an array of { provider, localSkillsDir } with one entry
123 * per unique real path. The first provider that maps to a real path
124 * wins (so the bundle uses that provider's build).
125 */
126function deduplicateProviders(root, providers) {
127  const seen = new Map(); // realPath -> { provider, localSkillsDir }
128  for (const provider of providers) {
129    const skillsDir = join(root, provider, 'skills');
130    if (!existsSync(skillsDir)) continue;
131    const real = realpathSync(skillsDir);
132    if (!seen.has(real)) {
133      seen.set(real, { provider, localSkillsDir: skillsDir });
134    }
135  }
136  return [...seen.values()];
137}
138
139/**
140 * Compare local skills against a downloaded bundle.
141 * Only checks skills that exist in the bundle (ignores user's custom
142 * skills that aren't part of impeccable). Deduplicates providers that
143 * share the same real path (symlinks). Normalizes provider-specific
144 * paths and version fields before comparing.
145 * Returns true if every bundle skill matches the local copy.
146 */
147function isUpToDate(root, providers, bundleDir) {
148  const unique = deduplicateProviders(root, providers);
149  if (unique.length === 0) return false;
150
151  for (const { provider, localSkillsDir } of unique) {
152    const bundleSkillsDir = join(bundleDir, provider, 'skills');
153    if (!existsSync(bundleSkillsDir)) continue;
154
155    for (const name of readdirSync(bundleSkillsDir)) {
156      const bundleMd = join(bundleSkillsDir, name, 'SKILL.md');
157      const localMd = join(localSkillsDir, name, 'SKILL.md');
158      if (!existsSync(bundleMd)) continue;
159      if (!existsSync(localMd)) return false;
160
161      const bundleHash = createHash('sha256').update(normalizeForHash(readFileSync(bundleMd, 'utf-8'))).digest('hex');
162      const localHash = createHash('sha256').update(normalizeForHash(readFileSync(localMd, 'utf-8'))).digest('hex');
163      if (bundleHash !== localHash) return false;
164    }
165  }
166  return true;
167}
168
169// ─── skills check ────────────────────────────────────────────────────────────
170
171async function check() {
172  const root = findProjectRoot();
173  const installed = isAlreadyInstalled(root);
174
175  if (!installed) {
176    console.log('Impeccable is not installed in this project.');
177    console.log('Run `npx impeccable skills install` to install.');
178    process.exit(0);
179  }
180
181  const providers = findInstalledProviders(root);
182
183  console.log('Checking for updates...\n');
184  try {
185    const bundleDir = await downloadAndExtractBundle();
186    const upToDate = isUpToDate(root, providers, bundleDir);
187    rmSync(bundleDir, { recursive: true, force: true });
188
189    if (upToDate) {
190      const v = getSkillsVersion(root);
191      console.log(`Skills are up to date${v ? ` (v${v})` : ''}.`);
192    } else {
193      console.log('Updates available.');
194      console.log('Run `npx impeccable skills update` to update.');
195    }
196  } catch (e) {
197    console.error(`Could not check for updates: ${e.message}`);
198    process.exit(1);
199  }
200}
201
202// ─── skills install ───────────────────────────────────────────────────────────
203
204// Check if impeccable skills are already present in any provider folder
205function isAlreadyInstalled(root) {
206  for (const d of PROVIDER_DIRS) {
207    const skillsDir = join(root, d, 'skills');
208    if (!existsSync(skillsDir)) continue;
209    try {
210      const entries = readdirSync(skillsDir);
211      // Look for 'impeccable' skill (or prefixed variant, or legacy 'teach-impeccable')
212      if (entries.some(e =>
213        e === 'impeccable' || e.endsWith('-impeccable') ||
214        e === 'teach-impeccable' || e.endsWith('-teach-impeccable')
215      )) {
216        return d;
217      }
218    } catch {}
219  }
220  return null;
221}
222
223function escapeRegex(str) {
224  return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
225}
226
227function prefixSkillContent(content, prefix, allSkillNames) {
228  // Prefix the name in frontmatter
229  let result = content.replace(/^name:\s*(.+)$/m, (_, name) => `name: ${prefix}${name.trim()}`);
230
231  // Prefix cross-references: /skillname -> /prefix-skillname
232  const sorted = [...allSkillNames].sort((a, b) => b.length - a.length);
233  for (const name of sorted) {
234    // Command invocations: /skillname
235    result = result.replace(
236      new RegExp(`/(?=${escapeRegex(name)}(?:[^a-zA-Z0-9_-]|$))`, 'g'),
237      `/${prefix}`
238    );
239    // Prose references: "the skillname skill"
240    result = result.replace(
241      new RegExp(`(the) ${escapeRegex(name)} skill`, 'gi'),
242      (_, article) => `${article} ${prefix}${name} skill`
243    );
244  }
245  return result;
246}
247
248function isSkillDir(skillsDir, name) {
249  // Skill entries can be real directories or symlinks to directories (npx skills uses symlinks)
250  const full = join(skillsDir, name);
251  try {
252    return statSync(full).isDirectory() && existsSync(join(full, 'SKILL.md'));
253  } catch { return false; }
254}
255
256function isRealSkillDir(skillsDir, name) {
257  // Only real directories, not symlinks -- renaming the real dir renames the symlink targets too
258  const full = join(skillsDir, name);
259  try {
260    const lstat = lstatSync(full);
261    return lstat.isDirectory() && !lstat.isSymbolicLink() && existsSync(join(full, 'SKILL.md'));
262  } catch { return false; }
263}
264
265function renameSkillsWithPrefix(root, prefix) {
266  // First pass: collect all skill names across all providers (use first provider found)
267  let allSkillNames = [];
268  for (const d of PROVIDER_DIRS) {
269    const skillsDir = join(root, d, 'skills');
270    if (!existsSync(skillsDir)) continue;
271    const entries = readdirSync(skillsDir);
272    allSkillNames = entries.filter(name => isSkillDir(skillsDir, name));
273    if (allSkillNames.length > 0) break;
274  }
275
276  // Second pass: rename real dirs and update their content
277  let count = 0;
278  for (const d of PROVIDER_DIRS) {
279    const skillsDir = join(root, d, 'skills');
280    if (!existsSync(skillsDir)) continue;
281    try {
282      const entries = readdirSync(skillsDir);
283      for (const name of entries) {
284        if (name.startsWith(prefix)) continue;
285        if (!isRealSkillDir(skillsDir, name)) continue;
286
287        const src = join(skillsDir, name);
288        const dest = join(skillsDir, prefix + name);
289
290        renameSync(src, dest);
291
292        // Prefix frontmatter name + all cross-references in SKILL.md
293        let content = readFileSync(join(dest, 'SKILL.md'), 'utf8');
294        content = prefixSkillContent(content, prefix, allSkillNames);
295        writeFileSync(join(dest, 'SKILL.md'), content);
296        count++;
297      }
298    } catch {}
299  }
300
301  // Third pass: fix symlinks that now point to renamed targets (npx skills uses these)
302  for (const d of PROVIDER_DIRS) {
303    const skillsDir = join(root, d, 'skills');
304    if (!existsSync(skillsDir)) continue;
305    try {
306      const entries = readdirSync(skillsDir);
307      for (const name of entries) {
308        if (name.startsWith(prefix)) continue;
309        const full = join(skillsDir, name);
310        try {
311          if (!lstatSync(full).isSymbolicLink()) continue;
312          const target = readlinkSync(full);
313          const newTarget = target.replace(new RegExp(`/${escapeRegex(name)}$`), `/${prefix}${name}`);
314          unlinkSync(full);
315          symlinkSync(newTarget, join(skillsDir, prefix + name));
316        } catch {}
317      }
318    } catch {}
319  }
320
321  return count;
322}
323
324async function install(flags) {
325  const force = flags.includes('--force');
326  const yes = flags.includes('-y') || flags.includes('--yes');
327  const prefixFlag = flags.find(f => f.startsWith('--prefix='));
328  const root = findProjectRoot();
329  const existing = isAlreadyInstalled(root);
330
331  if (existing && !force) {
332    console.log(`Impeccable skills are already installed (found in ${existing}/).`);
333    console.log('Run with --force to reinstall.\n');
334    process.exit(0);
335  }
336
337  console.log('Installing impeccable skills via npx skills...\n');
338  try {
339    // --copy forces npx skills to install each provider's variant separately
340    // instead of symlinking .claude/skills/ to .agents/skills/. The two
341    // directories have meaningfully different per-provider content (frontmatter,
342    // command prefix, paths), and the default symlink also fails silently when
343    // .claude/ doesn't exist yet or on Windows without elevated privileges (#140).
344    execSync(`npx skills add pbakaus/impeccable --copy${yes ? ' -y' : ''}`, { stdio: 'inherit' });
345  } catch (e) {
346    process.exit(e.status ?? 1);
347  }
348
349  // Ask about prefixing (skip in CI mode unless --prefix= is set)
350  let prefix = '';
351  if (prefixFlag) {
352    prefix = prefixFlag.split('=')[1] || 'i-';
353  } else if (!yes) {
354    console.log();
355    const wantPrefix = await ask('Prefix commands to avoid conflicts? e.g. /i-audit instead of /audit (y/N) ');
356    if (wantPrefix === 'y' || wantPrefix === 'yes') {
357      const custom = await ask('Prefix (default: i-): ');
358      prefix = custom || 'i-';
359    }
360  }
361
362  if (prefix) {
363    const count = renameSkillsWithPrefix(root, prefix);
364    if (count > 0) {
365      console.log(`\nRenamed ${count} skills with "${prefix}" prefix.`);
366      console.log(`Commands are now available as /${prefix}<command> (e.g. /${prefix}audit).`);
367    }
368  }
369
370  // Clean up deprecated skills from previous versions
371  try {
372    const { cleanup } = await import('../../../skill/scripts/cleanup-deprecated.mjs');
373    const result = cleanup(root);
374    const total = result.deletedPaths.length + result.removedLockEntries.length;
375    if (total > 0) {
376      console.log(`Cleaned up ${total} deprecated skill(s) from previous versions.`);
377    }
378  } catch {
379    // Cleanup script not available -- skip
380  }
381
382  console.log(`\nDone! Run /${prefix}impeccable teach in your AI harness to set up design context.\n`);
383}
384
385/** Detect prefix by looking for the 'impeccable' skill (or legacy 'teach-impeccable') */
386function detectPrefix(root) {
387  for (const d of PROVIDER_DIRS) {
388    const skillsDir = join(root, d, 'skills');
389    if (!existsSync(skillsDir)) continue;
390    for (const name of readdirSync(skillsDir)) {
391      if (name === 'impeccable') return '';
392      if (name.endsWith('-impeccable') && name !== 'teach-impeccable') return name.slice(0, -'impeccable'.length);
393      // Legacy fallback
394      if (name === 'teach-impeccable') return '';
395      if (name.endsWith('-teach-impeccable')) return name.slice(0, -'teach-impeccable'.length);
396    }
397  }
398  return '';
399}
400
401/** Undo prefixing: rename folders back and strip prefix from SKILL.md content */
402function undoPrefix(root, prefix) {
403  if (!prefix) return;
404  // Collect the unprefixed names (strip our prefix)
405  let allPrefixedNames = [];
406  for (const d of PROVIDER_DIRS) {
407    const skillsDir = join(root, d, 'skills');
408    if (!existsSync(skillsDir)) continue;
409    allPrefixedNames = readdirSync(skillsDir).filter(n => n.startsWith(prefix) && isRealSkillDir(skillsDir, n));
410    if (allPrefixedNames.length > 0) break;
411  }
412  const unprefixedNames = allPrefixedNames.map(n => n.slice(prefix.length));
413
414  for (const d of PROVIDER_DIRS) {
415    const skillsDir = join(root, d, 'skills');
416    if (!existsSync(skillsDir)) continue;
417    for (const name of readdirSync(skillsDir)) {
418      if (!name.startsWith(prefix)) continue;
419      const unprefixed = name.slice(prefix.length);
420      const src = join(skillsDir, name);
421      const dest = join(skillsDir, unprefixed);
422
423      if (lstatSync(src).isSymbolicLink()) {
424        const target = readlinkSync(src);
425        const newTarget = target.replace(`/${name}`, `/${unprefixed}`);
426        unlinkSync(src);
427        symlinkSync(newTarget, dest);
428      } else {
429        renameSync(src, dest);
430        // Strip prefix from SKILL.md content
431        const skillMd = join(dest, 'SKILL.md');
432        if (existsSync(skillMd)) {
433          let content = readFileSync(skillMd, 'utf8');
434          // Reverse the prefixing: replace prefixed names with unprefixed
435          content = content.replace(new RegExp(`^name:\\s*${escapeRegex(prefix)}`, 'm'), 'name: ');
436          const sorted = [...allPrefixedNames].sort((a, b) => b.length - a.length);
437          for (const pName of sorted) {
438            const uName = pName.slice(prefix.length);
439            content = content.replace(new RegExp(`/${escapeRegex(pName)}(?=[^a-zA-Z0-9_-]|$)`, 'g'), `/${uName}`);
440            content = content.replace(new RegExp(`(the) ${escapeRegex(pName)} skill`, 'gi'), `$1 ${uName} skill`);
441          }
442          writeFileSync(skillMd, content);
443        }
444      }
445    }
446  }
447}
448
449// ─── skills update ────────────────────────────────────────────────────────────
450
451function findProjectRoot() {
452  let dir = process.cwd();
453  while (dir !== dirname(dir)) {
454    if (existsSync(join(dir, '.git'))) return dir;
455    dir = dirname(dir);
456  }
457  return process.cwd();
458}
459
460function findInstalledProviders(root) {
461  const found = [];
462  for (const d of PROVIDER_DIRS) {
463    const skillsDir = join(root, d, 'skills');
464    if (!existsSync(skillsDir)) continue;
465    try {
466      const entries = readdirSync(skillsDir);
467      if (entries.some(name => isSkillDir(skillsDir, name))) found.push(d);
468    } catch {}
469  }
470  return found;
471}
472
473function getModifiedSkillFiles(root, providerDirs) {
474  // Use git to check if any skill files have local modifications
475  const modified = [];
476  try {
477    const status = execSync('git status --porcelain', { cwd: root, encoding: 'utf8' });
478    for (const line of status.split('\n')) {
479      if (!line.trim()) continue;
480      const file = line.substring(3);
481      for (const d of providerDirs) {
482        if (file.startsWith(`${d}/skills/`)) {
483          const flag = line.substring(0, 2).trim();
484          modified.push({ file, flag });
485        }
486      }
487    }
488  } catch {
489    // Not a git repo or git not available
490  }
491  return modified;
492}
493
494function downloadFile(url, dest) {
495  return new Promise((resolve, reject) => {
496    const file = createWriteStream(dest);
497    get(url, (res) => {
498      if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
499        // Follow redirect
500        get(res.headers.location, (res2) => {
501          res2.pipe(file);
502          file.on('finish', () => { file.close(); resolve(); });
503        }).on('error', reject);
504        return;
505      }
506      if (res.statusCode !== 200) {
507        reject(new Error(`HTTP ${res.statusCode}`));
508        return;
509      }
510      res.pipe(file);
511      file.on('finish', () => { file.close(); resolve(); });
512    }).on('error', reject);
513  });
514}
515
516async function update(flags = []) {
517  const yes = flags.includes('-y') || flags.includes('--yes');
518
519  // Clean up deprecated skills from previous versions.
520  try {
521    const { cleanup } = await import('../../../skill/scripts/cleanup-deprecated.mjs');
522    const root = findProjectRoot();
523    const result = cleanup(root);
524    const total = result.deletedPaths.length + result.removedLockEntries.length;
525    if (total > 0) {
526      console.log(`Cleaned up ${total} deprecated skill(s) from previous versions.\n`);
527    }
528  } catch {
529    // Cleanup script not available (e.g. running from npm package) -- skip
530  }
531
532  // Download the latest skills directly from impeccable.style.
533  // We skip `npx skills update` because it has a known upstream bug
534  // (vercel-labs/skills#775) where it can't find the lock file.
535  const root = findProjectRoot();
536  const providers = findInstalledProviders(root);
537
538  if (providers.length === 0) {
539    console.log('No impeccable skill folders found in this project.');
540    console.log('Run `npx impeccable skills install` to install first.');
541    process.exit(1);
542  }
543
544  console.log('Checking for updates...');
545
546  let tmpDir;
547  try {
548    tmpDir = await downloadAndExtractBundle();
549  } catch (e) {
550    console.error(`Download failed: ${e.message}`);
551    process.exit(1);
552  }
553
554  // Compare local vs remote -- skip if already up to date
555  if (isUpToDate(root, providers, tmpDir)) {
556    rmSync(tmpDir, { recursive: true, force: true });
557    const v = getSkillsVersion(root);
558    console.log(`Skills are up to date${v ? ` (v${v})` : ''}. Nothing to do.`);
559    process.exit(0);
560  }
561
562  console.log(`Found skills in: ${providers.join(', ')}`);
563
564  if (!yes) {
565    const ans = await ask(`Update skills in ${providers.length} provider folder(s)? (Y/n) `);
566    if (ans === 'n' || ans === 'no') {
567      rmSync(tmpDir, { recursive: true, force: true });
568      console.log('Aborted.');
569      process.exit(0);
570    }
571  }
572
573  try {
574
575    // Copy from the bundle to each unique provider folder.
576    // Deduplicate so symlinked dirs (e.g. .claude/skills -> .agents/skills)
577    // are only written once with the correct provider's content.
578    const unique = deduplicateProviders(root, providers);
579    let updated = 0;
580    for (const { provider, localSkillsDir } of unique) {
581      const srcDir = join(tmpDir, provider, 'skills');
582      if (!existsSync(srcDir)) continue;
583
584      const skills = readdirSync(srcDir, { withFileTypes: true });
585      for (const skill of skills) {
586        if (!skill.isDirectory()) continue;
587        const src = join(srcDir, skill.name);
588        const dest = join(localSkillsDir, skill.name);
589        if (existsSync(dest)) rmSync(dest, { recursive: true });
590        copyDirSync(src, dest);
591        updated++;
592      }
593    }
594
595    rmSync(tmpDir, { recursive: true, force: true });
596
597    // Re-apply prefix if detected
598    const prefix = detectPrefix(root);
599    if (prefix) {
600      const count = renameSkillsWithPrefix(root, prefix);
601      if (count > 0) console.log(`Re-applied "${prefix}" prefix to ${count} skills.`);
602    }
603
604    // Run cleanup to remove deprecated stubs from the fresh download
605    try {
606      const { cleanup: postCleanup } = await import('../../../skill/scripts/cleanup-deprecated.mjs');
607      postCleanup(root);
608    } catch {
609      // Not available -- skip
610    }
611
612    const v = getSkillsVersion(root);
613    console.log(`Updated ${updated} skill(s)${v ? ` to v${v}` : ''}.`);
614    console.log('Done!\n');
615  } catch (e) {
616    console.error(`Update failed: ${e.message}`);
617    if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
618    process.exit(1);
619  }
620}
621
622function copyDirSync(src, dest) {
623  mkdirSync(dest, { recursive: true });
624  for (const entry of readdirSync(src, { withFileTypes: true })) {
625    const s = join(src, entry.name);
626    const d = join(dest, entry.name);
627    if (entry.isDirectory()) {
628      copyDirSync(s, d);
629    } else {
630      writeFileSync(d, readFileSync(s));
631    }
632  }
633}
634
635// ─── Router ───────────────────────────────────────────────────────────────────
636
637export async function run(args) {
638  const sub = args[0];
639
640  if (!sub || sub === 'help' || sub === '--help' || sub === '-h') {
641    await showHelp();
642  } else if (sub === 'install') {
643    await install(args.slice(1));
644  } else if (sub === 'update') {
645    await update(args.slice(1));
646  } else if (sub === 'check') {
647    await check();
648  } else {
649    console.error(`Unknown skills command: ${sub}`);
650    console.error(`Run 'impeccable skills --help' for available commands.`);
651    process.exit(1);
652  }
653}