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', '.codex', '.agents', '.kiro', '.opencode', '.pi', '.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|gemini|codex|kiro|opencode|pi|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    execSync(`npx skills add pbakaus/impeccable${yes ? ' -y' : ''}`, { stdio: 'inherit' });
340  } catch (e) {
341    process.exit(e.status ?? 1);
342  }
343
344  // Ask about prefixing (skip in CI mode unless --prefix= is set)
345  let prefix = '';
346  if (prefixFlag) {
347    prefix = prefixFlag.split('=')[1] || 'i-';
348  } else if (!yes) {
349    console.log();
350    const wantPrefix = await ask('Prefix commands to avoid conflicts? e.g. /i-audit instead of /audit (y/N) ');
351    if (wantPrefix === 'y' || wantPrefix === 'yes') {
352      const custom = await ask('Prefix (default: i-): ');
353      prefix = custom || 'i-';
354    }
355  }
356
357  if (prefix) {
358    const count = renameSkillsWithPrefix(root, prefix);
359    if (count > 0) {
360      console.log(`\nRenamed ${count} skills with "${prefix}" prefix.`);
361      console.log(`Commands are now available as /${prefix}<command> (e.g. /${prefix}audit).`);
362    }
363  }
364
365  // Clean up deprecated skills from previous versions
366  try {
367    const { cleanup } = await import('../../source/skills/impeccable/scripts/cleanup-deprecated.mjs');
368    const result = cleanup(root);
369    const total = result.deletedPaths.length + result.removedLockEntries.length;
370    if (total > 0) {
371      console.log(`Cleaned up ${total} deprecated skill(s) from previous versions.`);
372    }
373  } catch {
374    // Cleanup script not available -- skip
375  }
376
377  console.log(`\nDone! Run /${prefix}impeccable teach in your AI harness to set up design context.\n`);
378}
379
380/** Detect prefix by looking for the 'impeccable' skill (or legacy 'teach-impeccable') */
381function detectPrefix(root) {
382  for (const d of PROVIDER_DIRS) {
383    const skillsDir = join(root, d, 'skills');
384    if (!existsSync(skillsDir)) continue;
385    for (const name of readdirSync(skillsDir)) {
386      if (name === 'impeccable') return '';
387      if (name.endsWith('-impeccable') && name !== 'teach-impeccable') return name.slice(0, -'impeccable'.length);
388      // Legacy fallback
389      if (name === 'teach-impeccable') return '';
390      if (name.endsWith('-teach-impeccable')) return name.slice(0, -'teach-impeccable'.length);
391    }
392  }
393  return '';
394}
395
396/** Undo prefixing: rename folders back and strip prefix from SKILL.md content */
397function undoPrefix(root, prefix) {
398  if (!prefix) return;
399  // Collect the unprefixed names (strip our prefix)
400  let allPrefixedNames = [];
401  for (const d of PROVIDER_DIRS) {
402    const skillsDir = join(root, d, 'skills');
403    if (!existsSync(skillsDir)) continue;
404    allPrefixedNames = readdirSync(skillsDir).filter(n => n.startsWith(prefix) && isRealSkillDir(skillsDir, n));
405    if (allPrefixedNames.length > 0) break;
406  }
407  const unprefixedNames = allPrefixedNames.map(n => n.slice(prefix.length));
408
409  for (const d of PROVIDER_DIRS) {
410    const skillsDir = join(root, d, 'skills');
411    if (!existsSync(skillsDir)) continue;
412    for (const name of readdirSync(skillsDir)) {
413      if (!name.startsWith(prefix)) continue;
414      const unprefixed = name.slice(prefix.length);
415      const src = join(skillsDir, name);
416      const dest = join(skillsDir, unprefixed);
417
418      if (lstatSync(src).isSymbolicLink()) {
419        const target = readlinkSync(src);
420        const newTarget = target.replace(`/${name}`, `/${unprefixed}`);
421        unlinkSync(src);
422        symlinkSync(newTarget, dest);
423      } else {
424        renameSync(src, dest);
425        // Strip prefix from SKILL.md content
426        const skillMd = join(dest, 'SKILL.md');
427        if (existsSync(skillMd)) {
428          let content = readFileSync(skillMd, 'utf8');
429          // Reverse the prefixing: replace prefixed names with unprefixed
430          content = content.replace(new RegExp(`^name:\\s*${escapeRegex(prefix)}`, 'm'), 'name: ');
431          const sorted = [...allPrefixedNames].sort((a, b) => b.length - a.length);
432          for (const pName of sorted) {
433            const uName = pName.slice(prefix.length);
434            content = content.replace(new RegExp(`/${escapeRegex(pName)}(?=[^a-zA-Z0-9_-]|$)`, 'g'), `/${uName}`);
435            content = content.replace(new RegExp(`(the) ${escapeRegex(pName)} skill`, 'gi'), `$1 ${uName} skill`);
436          }
437          writeFileSync(skillMd, content);
438        }
439      }
440    }
441  }
442}
443
444// ─── skills update ────────────────────────────────────────────────────────────
445
446function findProjectRoot() {
447  let dir = process.cwd();
448  while (dir !== dirname(dir)) {
449    if (existsSync(join(dir, '.git'))) return dir;
450    dir = dirname(dir);
451  }
452  return process.cwd();
453}
454
455function findInstalledProviders(root) {
456  const found = [];
457  for (const d of PROVIDER_DIRS) {
458    const skillsDir = join(root, d, 'skills');
459    if (!existsSync(skillsDir)) continue;
460    try {
461      const entries = readdirSync(skillsDir);
462      if (entries.some(name => isSkillDir(skillsDir, name))) found.push(d);
463    } catch {}
464  }
465  return found;
466}
467
468function getModifiedSkillFiles(root, providerDirs) {
469  // Use git to check if any skill files have local modifications
470  const modified = [];
471  try {
472    const status = execSync('git status --porcelain', { cwd: root, encoding: 'utf8' });
473    for (const line of status.split('\n')) {
474      if (!line.trim()) continue;
475      const file = line.substring(3);
476      for (const d of providerDirs) {
477        if (file.startsWith(`${d}/skills/`)) {
478          const flag = line.substring(0, 2).trim();
479          modified.push({ file, flag });
480        }
481      }
482    }
483  } catch {
484    // Not a git repo or git not available
485  }
486  return modified;
487}
488
489function downloadFile(url, dest) {
490  return new Promise((resolve, reject) => {
491    const file = createWriteStream(dest);
492    get(url, (res) => {
493      if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
494        // Follow redirect
495        get(res.headers.location, (res2) => {
496          res2.pipe(file);
497          file.on('finish', () => { file.close(); resolve(); });
498        }).on('error', reject);
499        return;
500      }
501      if (res.statusCode !== 200) {
502        reject(new Error(`HTTP ${res.statusCode}`));
503        return;
504      }
505      res.pipe(file);
506      file.on('finish', () => { file.close(); resolve(); });
507    }).on('error', reject);
508  });
509}
510
511async function update(flags = []) {
512  const yes = flags.includes('-y') || flags.includes('--yes');
513
514  // Clean up deprecated skills from previous versions.
515  try {
516    const { cleanup } = await import('../../source/skills/impeccable/scripts/cleanup-deprecated.mjs');
517    const root = findProjectRoot();
518    const result = cleanup(root);
519    const total = result.deletedPaths.length + result.removedLockEntries.length;
520    if (total > 0) {
521      console.log(`Cleaned up ${total} deprecated skill(s) from previous versions.\n`);
522    }
523  } catch {
524    // Cleanup script not available (e.g. running from npm package) -- skip
525  }
526
527  // Download the latest skills directly from impeccable.style.
528  // We skip `npx skills update` because it has a known upstream bug
529  // (vercel-labs/skills#775) where it can't find the lock file.
530  const root = findProjectRoot();
531  const providers = findInstalledProviders(root);
532
533  if (providers.length === 0) {
534    console.log('No impeccable skill folders found in this project.');
535    console.log('Run `npx impeccable skills install` to install first.');
536    process.exit(1);
537  }
538
539  console.log('Checking for updates...');
540
541  let tmpDir;
542  try {
543    tmpDir = await downloadAndExtractBundle();
544  } catch (e) {
545    console.error(`Download failed: ${e.message}`);
546    process.exit(1);
547  }
548
549  // Compare local vs remote -- skip if already up to date
550  if (isUpToDate(root, providers, tmpDir)) {
551    rmSync(tmpDir, { recursive: true, force: true });
552    const v = getSkillsVersion(root);
553    console.log(`Skills are up to date${v ? ` (v${v})` : ''}. Nothing to do.`);
554    process.exit(0);
555  }
556
557  console.log(`Found skills in: ${providers.join(', ')}`);
558
559  if (!yes) {
560    const ans = await ask(`Update skills in ${providers.length} provider folder(s)? (Y/n) `);
561    if (ans === 'n' || ans === 'no') {
562      rmSync(tmpDir, { recursive: true, force: true });
563      console.log('Aborted.');
564      process.exit(0);
565    }
566  }
567
568  try {
569
570    // Copy from the bundle to each unique provider folder.
571    // Deduplicate so symlinked dirs (e.g. .claude/skills -> .agents/skills)
572    // are only written once with the correct provider's content.
573    const unique = deduplicateProviders(root, providers);
574    let updated = 0;
575    for (const { provider, localSkillsDir } of unique) {
576      const srcDir = join(tmpDir, provider, 'skills');
577      if (!existsSync(srcDir)) continue;
578
579      const skills = readdirSync(srcDir, { withFileTypes: true });
580      for (const skill of skills) {
581        if (!skill.isDirectory()) continue;
582        const src = join(srcDir, skill.name);
583        const dest = join(localSkillsDir, skill.name);
584        if (existsSync(dest)) rmSync(dest, { recursive: true });
585        copyDirSync(src, dest);
586        updated++;
587      }
588    }
589
590    rmSync(tmpDir, { recursive: true, force: true });
591
592    // Re-apply prefix if detected
593    const prefix = detectPrefix(root);
594    if (prefix) {
595      const count = renameSkillsWithPrefix(root, prefix);
596      if (count > 0) console.log(`Re-applied "${prefix}" prefix to ${count} skills.`);
597    }
598
599    // Run cleanup to remove deprecated stubs from the fresh download
600    try {
601      const { cleanup: postCleanup } = await import('../../source/skills/impeccable/scripts/cleanup-deprecated.mjs');
602      postCleanup(root);
603    } catch {
604      // Not available -- skip
605    }
606
607    const v = getSkillsVersion(root);
608    console.log(`Updated ${updated} skill(s)${v ? ` to v${v}` : ''}.`);
609    console.log('Done!\n');
610  } catch (e) {
611    console.error(`Update failed: ${e.message}`);
612    if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
613    process.exit(1);
614  }
615}
616
617function copyDirSync(src, dest) {
618  mkdirSync(dest, { recursive: true });
619  for (const entry of readdirSync(src, { withFileTypes: true })) {
620    const s = join(src, entry.name);
621    const d = join(dest, entry.name);
622    if (entry.isDirectory()) {
623      copyDirSync(s, d);
624    } else {
625      writeFileSync(d, readFileSync(s));
626    }
627  }
628}
629
630// ─── Router ───────────────────────────────────────────────────────────────────
631
632export async function run(args) {
633  const sub = args[0];
634
635  if (!sub || sub === 'help' || sub === '--help' || sub === '-h') {
636    await showHelp();
637  } else if (sub === 'install') {
638    await install(args.slice(1));
639  } else if (sub === 'update') {
640    await update(args.slice(1));
641  } else if (sub === 'check') {
642    await check();
643  } else {
644    console.error(`Unknown skills command: ${sub}`);
645    console.error(`Run 'impeccable skills --help' for available commands.`);
646    process.exit(1);
647  }
648}