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}