Files
pbakaus_impeccable/cli/bin/commands/skills.mjs
T
Paul BakausandGitHub a264199177 Fix skill subcommand help handling (#708)
Ensure install, link, update, and check render static help before entering any operational path. Covers top-level and legacy routing for both -h and --help.

AI-assisted implementation under maintainer direction.
2026-09-02 11:38:23 -04:00

2605 lines
104 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* `impeccable skills` subcommand
*
* Usage:
* impeccable help Show all available skills and commands
* impeccable install Install compiled skills from the universal bundle
* impeccable link Symlink compiled skills from a local checkout
* impeccable update Update skills to latest version
*/
import { execSync } from 'node:child_process';
import { existsSync, readFileSync, readdirSync, statSync, accessSync, constants, lstatSync, unlinkSync, mkdirSync, mkdtempSync, writeFileSync, rmSync, rmdirSync, renameSync, createWriteStream, realpathSync, symlinkSync, readlinkSync, cpSync, copyFileSync } from 'node:fs';
import { join, resolve, dirname, relative, isAbsolute, sep, delimiter } from 'node:path';
import { createInterface, emitKeypressEvents } from 'node:readline';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { fileURLToPath } from 'node:url';
import { createHash } from 'node:crypto';
import { tmpdir, homedir } from 'node:os';
import { unzipSync } from 'fflate';
import { getHookConsent, setHookConsent } from '../../lib/impeccable-config.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const API_BASE = 'https://impeccable.style';
// Provider folder names in project roots
const PROVIDER_DIRS = ['.claude', '.cursor', '.gemini', '.agents', '.agent', '.github', '.grok', '.hermes', '.kiro', '.opencode', '.pi', '.qoder', '.trae', '.trae-cn', '.rovodev', '.vibe', '.veto'];
const PROVIDER_ALIASES = {
agent: '.agent',
agents: '.agents',
antigravity: '.agent',
claude: '.claude',
'claude-code': '.claude',
codex: '.agents',
copilot: '.github',
cursor: '.cursor',
gemini: '.gemini',
github: '.github',
grok: '.grok',
'grok-build': '.grok',
hermes: '.hermes',
xai: '.grok',
kiro: '.kiro',
opencode: '.opencode',
pi: '.pi',
qoder: '.qoder',
'rovo-dev': '.rovodev',
rovodev: '.rovodev',
trae: '.trae',
'trae-cn': '.trae-cn',
vibe: '.vibe',
veto: '.veto',
};
const PROVIDER_DISPLAY = {
'.agent': { name: 'Antigravity', input: 'antigravity' },
'.agents': { name: 'Codex CLI', input: 'codex' },
'.claude': { name: 'Claude Code', input: 'claude' },
'.cursor': { name: 'Cursor', input: 'cursor' },
'.gemini': { name: 'Gemini CLI', input: 'gemini' },
'.github': { name: 'GitHub Copilot', input: 'github' },
'.grok': { name: 'Grok Build', input: 'grok' },
'.hermes': { name: 'Hermes Agent', input: 'hermes' },
'.kiro': { name: 'Kiro', input: 'kiro' },
'.opencode': { name: 'OpenCode', input: 'opencode' },
'.pi': { name: 'Pi Coding Agent', input: 'pi' },
'.qoder': { name: 'Qoder', input: 'qoder' },
'.rovodev': { name: 'Rovo Dev', input: 'rovo-dev' },
'.trae': { name: 'Trae', input: 'trae' },
'.trae-cn': { name: 'Trae CN', input: 'trae-cn' },
'.vibe': { name: 'Mistral Vibe', input: 'vibe' },
'.veto': { name: 'Veto', input: 'veto' },
};
const PROVIDER_INPUT_ORDER = ['antigravity', 'claude', 'codex', 'cursor', 'gemini', 'github', 'grok', 'hermes', 'kiro', 'opencode', 'pi', 'qoder', 'trae', 'trae-cn', 'rovo-dev', 'vibe', 'veto'];
// OpenCode reads global skills from its config directory, not ~/.opencode:
// $OPENCODE_CONFIG_DIR, else $XDG_CONFIG_HOME/opencode, else
// ~/.config/opencode. Writing to ~/.opencode/skills produced an install
// `opencode debug skill` never listed. See issue #406.
function opencodeGlobalConfigDir(home) {
if (process.env.OPENCODE_CONFIG_DIR) return process.env.OPENCODE_CONFIG_DIR;
if (process.env.XDG_CONFIG_HOME) return join(process.env.XDG_CONFIG_HOME, 'opencode');
return join(home, '.config', 'opencode');
}
// Hermes reads skills from `$HERMES_HOME/skills/`, where $HERMES_HOME defaults
// to `~/.hermes` but is also set to a profile path (e.g.
// `~/.hermes/profiles/forge`) when a non-default profile is active. Reading
// the env var matters here at install time: writing to `~/.hermes/skills/`
// from a profile-scoped Hermes invocation would land in the wrong profile
// (the same cross-profile data-corruption class that the active_profile
// fallback warning in hermes_constants.py exists to detect). Used by
// HOME_SKILLS_DIR_OVERRIDES['.hermes'] only; GLOBAL_HARNESS_HINTS reads the
// fixed `~/.hermes` location so detection doesn't leak the developer's real
// HERMES_HOME into test output (test isolation).
//
// Ignore $HERMES_HOME when it doesn't sit under `home` (the caller-supplied
// home dir, which tests inject via HOME=/tmp/...). Without this guard, an
// inherited $HERMES_HOME=/home/<dev>/.hermes from the developer's shell leaks
// into test output even when the test sets HOME=/tmp/imp-home-xxx: tests
// expect ~/.hermes to live under their tmp home, not under the dev's real
// home. The check uses `resolve()` on both sides so a symlinked test home
// (e.g. /tmp -> /private/tmp on macOS) still compares correctly.
function hermesGlobalHome(home) {
const envHome = process.env.HERMES_HOME;
if (envHome) {
try {
const resolvedEnv = resolve(envHome);
const resolvedHome = resolve(home);
// Honor HERMES_HOME only when it lives under the active home (real
// ~/.hermes or ~/.hermes/profiles/<name>). Cross-home inheritance is
// treated as not-set, so a test running under HOME=/tmp/... doesn't
// pick up the developer's real ~/.hermes.
if (resolvedEnv === resolvedHome || resolvedEnv.startsWith(resolvedHome + sep)) {
return resolvedEnv;
}
} catch {
// fall through to default
}
}
return join(home, '.hermes');
}
// Providers whose GLOBAL (home) skills dir is not `<provider>/skills`,
// as a function of the home dir. Pi discovers global skills from
// ~/.pi/agent/skills/ (issue #327); OpenCode from its config dir (issue
// #406); Hermes from $HERMES_HOME. Project scope stays `<provider>/skills`
// for all three.
const HOME_SKILLS_DIR_OVERRIDES = {
'.agent': (home) => join(home, '.gemini', 'config', 'skills'),
'.hermes': (home) => join(hermesGlobalHome(home), 'skills'),
'.pi': (home) => join(home, '.pi', 'agent', 'skills'),
'.opencode': (home) => join(opencodeGlobalConfigDir(home), 'skills'),
};
// When a project has no harness folder yet, infer the target from globally
// installed harnesses (~/.claude, ~/.codex, ...). Codex reads skills from
// .agents/skills, so ~/.codex maps to the .agents bundle variant.
//
// Hermes auto-detection uses the fixed `~/.hermes` location only. When a
// non-default Hermes profile is active (HERMES_HOME points to a profile path),
// the user is expected to be inside a Hermes invocation and can pass
// --providers=hermes explicitly. Auto-detection from a non-default HERMES_HOME
// would also defeat test isolation (tests inject HOME; HERMES_HOME leaks from
// the parent process and would surface the developer's real ~/.hermes in
// detection output). The install path honors $HERMES_HOME; detection does not.
const GLOBAL_HARNESS_HINTS = [
{ home: '.agent', provider: '.agent' },
// Antigravity nests under ~/.gemini/ too, so any of these also trips the
// .gemini hint above (harmless double-detection — both get pre-selected).
{ home: '.gemini/antigravity', provider: '.agent' },
{ home: '.gemini/antigravity-cli', provider: '.agent' },
{ home: '.gemini/antigravity-ide', provider: '.agent' },
{ home: '.claude', provider: '.claude' },
{ home: '.codex', provider: '.agents' },
{ home: '.cursor', provider: '.cursor' },
{ home: '.gemini', provider: '.gemini' },
{ home: '.grok', provider: '.grok' },
{ home: '.hermes', provider: '.hermes' },
{ home: '.kiro', provider: '.kiro' },
{ home: '.opencode', provider: '.opencode' },
// OpenCode's real global config dir (issue #406); the ~/.opencode entry
// above keeps recognizing machines that only have the legacy dir.
{ resolve: opencodeGlobalConfigDir, provider: '.opencode' },
{ home: '.pi', provider: '.pi' },
{ home: '.qoder', provider: '.qoder' },
{ home: '.rovodev', provider: '.rovodev' },
{ home: '.vibe', provider: '.vibe' },
// Veto is a CLI harness whose managed skill directory is ~/.veto/skills.
// Require its managed state directory as well as the executable so an
// unrelated `veto` binary on PATH does not change project install defaults.
{ command: 'veto', provider: '.veto' },
];
// Last-resort default when nothing is detected: Claude Code + the universal
// (.agents, also Codex) folder, which covers the most common setups.
const DEFAULT_TARGETS = ['.claude', '.agents'];
const IGNORED_SKILL_DIR_NAMES = new Set([
'codex-primary-runtime',
]);
const IMPECCABLE_HOOK_COMMAND_MARKERS = [
'skills/impeccable/scripts/hook-probe.mjs',
'skills/impeccable/scripts/hook.mjs',
'skills/impeccable/scripts/hook-before-edit.mjs',
'skills/impeccable/scripts/hook-after-edit.mjs',
'skills/impeccable/scripts/hook-stop.mjs',
];
const PROVIDER_HOOK_ARTIFACTS = {
'.claude': [
// The hook is a machine-local install side effect, so it lands in the
// gitignored `.claude/settings.local.json` rather than the team-shared
// `settings.json`. The bundle still ships the manifest as `settings.json`
// (the `rel` source), but we write it to `destRel`. A hook the user moved
// into `settings.json` is honored in place; see copyProviderHooks.
{ sourceProvider: '.claude', rel: 'settings.json', destProvider: '.claude', destRel: 'settings.local.json' },
],
'.cursor': [
{ sourceProvider: '.cursor', rel: 'hooks.json', destProvider: '.cursor' },
],
// Codex reads skills from `.agents/skills`, but project hooks from
// `.codex/hooks.json`, so the `.agents` install target owns this sidecar.
'.agents': [
{ sourceProvider: '.codex', rel: 'hooks.json', destProvider: '.codex' },
],
// GitHub Copilot reads repo-level hooks from `.github/hooks/*.json`. Unlike
// Claude, this is a team-shared, committed file (not a machine-local override),
// so source and dest are the same path.
'.github': [
{ sourceProvider: '.github', rel: 'hooks/impeccable.json', destProvider: '.github' },
],
// Grok Build discovers project hooks from `.grok/hooks/*.json`. Team-shared
// by default (commit them if the whole team uses Grok); folder trust is still
// required via `/hooks-trust` or `--trust` before they run.
'.grok': [
{ sourceProvider: '.grok', rel: 'hooks/impeccable.json', destProvider: '.grok' },
],
};
function userProviderSkillsDir(home, provider) {
const override = HOME_SKILLS_DIR_OVERRIDES[provider];
if (override) return override(home);
return join(home, provider, 'skills');
}
// Compare via realpath: the project root comes from process.cwd() (symlinks
// resolved) while homedir() reflects $HOME verbatim, so a home dir reached
// through a symlink (e.g. /tmp -> /private/tmp) would fail a string compare.
function isHomeDir(root) {
if (root === homedir()) return true;
try {
return realpathSync(root) === realpathSync(homedir());
} catch {
return false;
}
}
// Every layout a provider's installed skills can live in under `root`.
// `scope` narrows the answer when the caller knows which install it is
// acting on: 'user' means the provider's global layout, 'project' means
// `<provider>/skills`. Without a scope (update/check, where installs of
// either kind may live under `root`) both layouts are candidates when
// `root` is the home dir, since an overridden provider (Pi) keeps its
// global skills elsewhere while a repo rooted at ~ still uses the project
// layout. Scoping matters for the same reason: a project-scope install in
// a home-rooted repo must not be conflated with an existing global one.
function providerSkillsDirCandidates(root, provider, scope) {
if (scope === 'user') return [userProviderSkillsDir(root, provider)];
const dirs = [join(root, provider, 'skills')];
if (scope !== 'project' && HOME_SKILLS_DIR_OVERRIDES[provider] && isHomeDir(root)) {
dirs.unshift(userProviderSkillsDir(root, provider));
}
return dirs;
}
function existingSkillsDirs(root, provider, scope) {
return providerSkillsDirCandidates(root, provider, scope).filter(existsSync);
}
let pipedAnswers = null;
class PromptAbortError extends Error {
constructor() {
super('Aborted.');
this.name = 'PromptAbortError';
this.code = 'IMPECCABLE_PROMPT_ABORT';
}
}
function isPromptAbortError(error) {
return error?.code === 'IMPECCABLE_PROMPT_ABORT';
}
function canStyleTerminal() {
return Boolean(process.stdout.isTTY && process.env.NO_COLOR === undefined && process.env.TERM !== 'dumb');
}
function ansi(open, close, value) {
const text = String(value);
return canStyleTerminal() ? `${open}${text}${close}` : text;
}
const ui = {
accent: value => ansi('\x1b[36m', '\x1b[0m', value),
bold: value => ansi('\x1b[1m', '\x1b[22m', value),
dim: value => ansi('\x1b[2m', '\x1b[22m', value),
good: value => ansi('\x1b[32m', '\x1b[0m', value),
};
function ask(question) {
if (!process.stdin.isTTY) {
process.stdout.write(question);
if (!pipedAnswers) {
let input = '';
try {
input = readFileSync(0, 'utf-8');
} catch {}
pipedAnswers = input.split(/\r?\n/);
}
return Promise.resolve(String(pipedAnswers.shift() || '').trim().toLowerCase());
}
const rl = createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve, reject) => {
rl.once('SIGINT', () => {
rl.close();
reject(new PromptAbortError());
});
rl.question(question, ans => {
rl.close();
resolve(ans.trim().toLowerCase());
});
});
}
function isInteractivePrompt() {
return Boolean(process.stdin.isTTY && process.stdout.isTTY && typeof process.stdin.setRawMode === 'function');
}
function promptKeypressSession(renderInitial, handleKey) {
const input = process.stdin;
const output = process.stdout;
const wasRaw = Boolean(input.isRaw);
let lastLineCount = 0;
let done = false;
emitKeypressEvents(input);
return new Promise((resolve, reject) => {
function cleanup() {
if (done) return;
done = true;
input.off('keypress', onKeypress);
if (typeof input.setRawMode === 'function') input.setRawMode(wasRaw);
output.write('\x1b[?25h');
input.pause();
}
function render(lines) {
const nextLines = Array.isArray(lines) ? lines : String(lines).split('\n');
if (lastLineCount > 0) output.write(`\x1b[${lastLineCount}A`);
const lineCount = Math.max(lastLineCount, nextLines.length);
for (let index = 0; index < lineCount; index++) {
const line = nextLines[index] || '';
output.write(`\x1b[2K\r${line}\n`);
}
lastLineCount = lineCount;
}
function finish(value) {
cleanup();
resolve(value);
}
function abort() {
cleanup();
reject(new PromptAbortError());
}
function onKeypress(str, key = {}) {
if (key.ctrl && key.name === 'c') {
abort();
return;
}
const next = handleKey(str, key);
if (!next) return;
if (next.abort) {
abort();
return;
}
if (next.done) {
render(next.lines);
finish(next.value);
return;
}
render(next.lines);
}
input.on('keypress', onKeypress);
input.setRawMode(true);
input.resume();
output.write('\x1b[?25l');
render(renderInitial());
});
}
function clampIndex(index, length) {
if (length <= 0) return 0;
if (index < 0) return length - 1;
if (index >= length) return 0;
return index;
}
function visibleWindow(cursor, total, maxVisible) {
const visible = Math.max(1, Math.min(total, maxVisible));
let start = Math.max(0, cursor - visible + 1);
if (cursor < start) start = cursor;
start = Math.min(start, Math.max(0, total - visible));
return { start, end: start + visible };
}
async function promptRadio(message, options, { initialIndex = 0 } = {}) {
let cursor = clampIndex(initialIndex, options.length);
const render = () => [
`${ui.accent('◆')} ${ui.bold(message)}`,
'',
...options.map((option, index) => {
const active = index === cursor;
const pointer = active ? ui.accent('') : ' ';
const mark = active ? ui.good('●') : ui.dim('○');
const label = active ? ui.bold(option.label) : option.label;
const hint = option.hint ? ` ${ui.dim(option.hint)}` : '';
return ` ${pointer} ${mark} ${label}${hint}`;
}),
'',
` ${ui.dim('↑/↓ move, enter confirm')}`,
];
return promptKeypressSession(render, (_str, key = {}) => {
if (key.name === 'up' || key.name === 'k') cursor = clampIndex(cursor - 1, options.length);
if (key.name === 'down' || key.name === 'j') cursor = clampIndex(cursor + 1, options.length);
if (key.name === 'return' || key.name === 'enter') {
return { done: true, value: options[cursor].value, lines: render() };
}
return { lines: render() };
});
}
async function promptCheckbox(message, options, { selectedValues = [] } = {}) {
const selected = new Set(selectedValues);
let cursor = 0;
let error = '';
let query = '';
const maxVisible = Math.max(5, Math.min(options.length, (process.stdout.rows || 24) - 9, 10));
function filteredOptions() {
const needle = query.trim().toLowerCase();
if (!needle) return options;
return options.filter(option => option.searchText.toLowerCase().includes(needle));
}
function selectedSummary() {
const selectedOptions = options.filter(option => selected.has(option.value));
if (selectedOptions.length === 0) return ui.dim('none');
const labels = selectedOptions.map(option => option.label);
if (labels.length <= 4) return labels.join(', ');
return `${labels.slice(0, 4).join(', ')} ${ui.dim(`+${labels.length - 4} more`)}`;
}
const render = () => {
const filtered = filteredOptions();
cursor = clampIndex(cursor, filtered.length);
const { start, end } = visibleWindow(cursor, filtered.length, maxVisible);
const lines = [
`${ui.accent('◆')} ${ui.bold(message)}`,
'',
` Search: ${query || ui.dim('type to filter')}`,
` ${ui.dim('↑/↓ move, space select, enter confirm')}`,
'',
];
if (filtered.length === 0) {
lines.push(` ${ui.dim('No matches')}`);
} else if (filtered.length > maxVisible) {
lines.push(` ${ui.dim(`Showing ${start + 1}-${end} of ${filtered.length}`)}`);
}
if (filtered.length > 0) {
for (let index = start; index < end; index++) {
const option = filtered[index];
const active = index === cursor;
const pointer = active ? ui.accent('') : ' ';
const mark = selected.has(option.value) ? ui.good('●') : ui.dim('○');
const label = active ? ui.bold(option.label) : option.label;
const hint = option.hint ? ` ${ui.dim(option.hint)}` : '';
lines.push(` ${pointer} ${mark} ${label}${hint}`);
}
}
lines.push('');
lines.push(` Selected: ${selectedSummary()}`);
if (error) lines.push(` ${error}`);
return lines;
};
return promptKeypressSession(render, (str, key = {}) => {
const filtered = filteredOptions();
if (key.name === 'up') cursor = clampIndex(cursor - 1, filtered.length);
if (key.name === 'down') cursor = clampIndex(cursor + 1, filtered.length);
if (key.name === 'space' || str === ' ') {
const option = filtered[cursor];
if (option) {
if (selected.has(option.value)) selected.delete(option.value);
else selected.add(option.value);
error = '';
}
}
if (key.name === 'backspace' || key.name === 'delete') {
query = query.slice(0, -1);
cursor = 0;
error = '';
}
if (key.ctrl && key.name === 'u') {
query = '';
cursor = 0;
error = '';
}
if (str && str.length === 1 && str >= '!' && !key.ctrl && !key.meta) {
query += str;
cursor = 0;
error = '';
}
if (key.name === 'return' || key.name === 'enter') {
if (selected.size === 0) {
error = ui.dim('Choose at least one harness.');
return { lines: render() };
}
return {
done: true,
value: options.filter(option => selected.has(option.value)).map(option => option.value),
lines: render(),
};
}
return { lines: render() };
});
}
// ─── skills help ──────────────────────────────────────────────────────────────
const SUBCOMMAND_HELP = {
install: `Usage: impeccable install [options]
Install compiled Impeccable skills into project or user-level harness folders.
Options:
-y, --yes Accept detected defaults without prompting
--providers=<names> Comma-separated harnesses to install
--scope=<scope> Install scope: project or global
--project Install into the current project
--user, --global Install at the user level
--no-hooks Install skills without provider hook manifests
--force Replace an existing installation
-h, --help Show this help message`,
link: `Usage: impeccable link [options]
Link Impeccable skills from a local checkout or submodule.
Options:
--source=<path> Source checkout (default: .impeccable)
--providers=<names> Comma-separated harnesses to link
-y, --yes Accept detected defaults without prompting
--force Replace existing skill folders with links
-h, --help Show this help message`,
update: `Usage: impeccable update [options]
Update an existing Impeccable skill installation.
Options:
-y, --yes Accept detected defaults without prompting
--scope=<scope> Update scope: project or global
--project Update the current project installation
--user, --global Update the user-level installation
--no-hooks Update skills without changing hook manifests
--force Replace installed skill files
-h, --help Show this help message`,
check: `Usage: impeccable check [options]
Check whether installed Impeccable skills are up to date.
Options:
-h, --help Show this help message`,
};
function showSubcommandHelp(subcommand) {
console.log(SUBCOMMAND_HELP[subcommand]);
}
async function showHelp() {
let commands;
try {
const res = await fetch(`${API_BASE}/api/commands`);
commands = await res.json();
} catch {
console.error('Could not fetch command list from impeccable.style. Check your network connection.');
process.exit(1);
}
const pad = (s, n) => s + ' '.repeat(Math.max(0, n - s.length));
console.log('\n Impeccable Skills & Commands\n');
console.log(' Install: npx impeccable install');
console.log(' Link: npx impeccable link --source=.impeccable');
console.log(' Update: npx impeccable update');
console.log(' Docs: https://impeccable.style/cheatsheet\n');
console.log(` ${pad('Command', 22)} Description`);
console.log(` ${'-'.repeat(22)} ${'-'.repeat(52)}`);
for (const cmd of commands.sort((a, b) => a.id.localeCompare(b.id))) {
// Trim description to fit terminal
const desc = cmd.description.length > 72
? cmd.description.substring(0, 69) + '...'
: cmd.description;
console.log(` ${pad('/' + cmd.id, 22)} ${desc}`);
}
console.log(`\n ${commands.length} commands available. Run /<command> in your AI harness.\n`);
}
// ─── version helpers ─────────────────────────────────────────────────────────
function parseSkillFrontmatterVersion(content) {
const match = String(content).match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---(?:[ \t]*\r?\n|[ \t]*$)/);
if (!match) return null;
let metadataVersion = null;
let topLevelVersion = null;
let inMetadata = false;
let metadataIndent = null;
for (const line of match[1].split(/\r?\n/)) {
if (!line.trim() || line.trimStart().startsWith('#')) continue;
const indentText = line.match(/^[ \t]*/)[0];
const indent = indentText.replace(/\t/g, ' ').length;
if (indent === 0) {
inMetadata = /^metadata:\s*(?:#.*)?$/.test(line);
metadataIndent = null;
const version = line.match(/^version:\s*(.+?)\s*$/);
if (version) topLevelVersion = version[1];
continue;
}
if (!inMetadata) continue;
if (metadataIndent === null) metadataIndent = indent;
if (indent !== metadataIndent) continue;
const version = line.trim().match(/^version:\s*(.+?)\s*$/);
if (version) metadataVersion = version[1];
}
const value = metadataVersion || topLevelVersion;
return value ? value.trim().replace(/^(["'])(.*)\1$/, '$2') : null;
}
/**
* Read the skills version from the impeccable SKILL.md frontmatter.
*/
function getSkillsVersion(root, scope) {
for (const d of PROVIDER_DIRS) {
for (const skillsDir of providerSkillsDirCandidates(root, d, scope)) {
const skillMd = join(skillsDir, 'impeccable', 'SKILL.md');
if (!existsSync(skillMd)) continue;
const content = readFileSync(skillMd, 'utf-8');
const version = parseSkillFrontmatterVersion(content);
if (version) return version;
}
}
return null;
}
/**
* Return every file in a directory tree, sorted and relative to the tree root.
*/
function listSkillTreeFiles(root, dir = root) {
if (!existsSync(dir)) return [];
const files = [];
for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...listSkillTreeFiles(root, full));
} else if (entry.isFile()) {
files.push(relative(root, full).split(sep).join('/'));
}
}
return files;
}
/**
* Extract every entry of a zip archive into `targetDir`.
*
* This replaces `extract-zip`, whose `yauzl`/`fd-slicer` read stack stalls on
* Node v24.16.0 / v26.1.0+ (nodejs/node#63487): `pause()`/`resume()` became
* no-ops on destroyed streams, so extraction stops after a handful of entries,
* its promise never settles, and -- because nothing else keeps the event loop
* alive -- the CLI exits 0 with no error, silently installing nothing.
*
* `fflate` decompresses from an in-memory buffer and never touches the fs
* stream path, so it is immune to that regression on every Node version. It is
* pure JS with zero dependencies, which keeps the Windows fix from #198 intact
* (no `unzip` binary required). We write the entries to disk ourselves, which
* lets us guard against zip-slip (`../` entries escaping `targetDir`).
*/
async function extractZip(zipPath, targetDir) {
const entries = unzipSync(readFileSync(zipPath));
const root = resolve(targetDir);
for (const [entryPath, bytes] of Object.entries(entries)) {
// Directory entries arrive as zero-length names ending in `/`; the files
// beneath them create their parents via mkdirSync below.
if (entryPath.endsWith('/')) continue;
const dest = resolve(root, entryPath);
if (dest !== root && !dest.startsWith(root + sep)) {
throw new Error(`Refusing to extract entry outside target dir: ${entryPath}`);
}
mkdirSync(dirname(dest), { recursive: true });
writeFileSync(dest, bytes);
}
}
/**
* Download the universal bundle to a temp dir and return its path.
* Caller is responsible for cleanup.
*/
async function downloadAndExtractBundle() {
const localBundle = process.env.IMPECCABLE_BUNDLE_PATH;
if (localBundle) return copyOrExtractLocalBundle(localBundle);
const staging = mkdtempSync(join(tmpdir(), 'impeccable-update-'));
const tmpZip = join(staging, 'bundle.zip');
try {
await downloadFile(`${API_BASE}/api/download/bundle/universal`, tmpZip);
await extractZip(tmpZip, staging);
rmSync(tmpZip, { force: true });
return staging;
} catch (e) {
rmSync(staging, { recursive: true, force: true });
throw e;
}
}
async function copyOrExtractLocalBundle(sourceValue) {
const source = resolve(sourceValue);
if (!existsSync(source)) {
throw new Error(`Local bundle not found: ${source}`);
}
const staging = mkdtempSync(join(tmpdir(), 'impeccable-local-bundle-'));
try {
if (statSync(source).isDirectory()) {
cpSync(source, staging, { recursive: true });
} else {
await extractZip(source, staging);
}
return staging;
} catch (e) {
rmSync(staging, { recursive: true, force: true });
throw e;
}
}
/**
* Normalize a SKILL.md's content for comparison by stripping
* provider-specific paths. Different install methods (npx skills add
* vs our bundle) resolve {{scripts_path}} to different provider dirs
* (e.g. .agents vs .claude), so we strip those differences.
* Version fields intentionally remain part of the comparison so metadata-only
* releases still refresh installed files.
*/
function normalizeForHash(content) {
return content
.replace(/\.(claude|cursor|agents|agent|github|gemini|codex|grok|hermes|kiro|opencode|pi|qoder|trae|trae-cn|rovodev|vibe|veto)\/skills\//g, '.PROVIDER/skills/');
}
function hashSkillFile(filePath) {
return createHash('sha256')
.update(normalizeForHash(readFileSync(filePath, 'utf-8')))
.digest('hex');
}
/**
* Deduplicate providers by resolved path. When .claude/skills is a
* symlink to ../.agents/skills, both resolve to the same directory.
* Returns an array of { provider, localSkillsDir } with one entry
* per unique real path. The first provider that maps to a real path
* wins (so the bundle uses that provider's build).
*/
function deduplicateProviders(root, providers, scope) {
const seen = new Map(); // realPath -> { provider, localSkillsDir }
for (const provider of providers) {
// A provider can hold real installs in more than one layout (a home-rooted
// repo may carry both ~/.pi/agent/skills and ~/.pi/skills). Keep each as
// its own entry so update/check touch every tree, not just the first.
for (const skillsDir of existingSkillsDirs(root, provider, scope)) {
const real = realpathSync(skillsDir);
if (!seen.has(real)) {
seen.set(real, { provider, localSkillsDir: skillsDir });
}
}
}
return [...seen.values()];
}
/**
* Compare local skills against a downloaded bundle.
* Only checks skills that exist in the bundle (ignores user's custom skills
* that aren't part of impeccable). Deduplicates providers that share the same
* real path (symlinks). Compares the full bundled skill tree, not just
* SKILL.md, so script-only fixes and removed files are detected.
* Returns true if every bundle skill matches the local copy.
*/
function isUpToDate(root, providers, bundleDir, scope, agentScope = scope) {
const unique = deduplicateProviders(root, providers, scope);
if (unique.length === 0) return false;
for (const { provider, localSkillsDir } of unique) {
const bundleSkillsDir = join(bundleDir, provider, 'skills');
if (!existsSync(bundleSkillsDir)) continue;
for (const name of readdirSync(bundleSkillsDir)) {
const bundleSkillDir = join(bundleSkillsDir, name);
const localSkillDir = join(localSkillsDir, name);
const bundleMd = join(bundleSkillDir, 'SKILL.md');
if (!existsSync(bundleMd)) continue;
if (!existsSync(localSkillDir)) return false;
const bundleFiles = listSkillTreeFiles(bundleSkillDir);
const localFiles = listSkillTreeFiles(localSkillDir);
if (bundleFiles.join('\n') !== localFiles.join('\n')) return false;
for (const relPath of bundleFiles) {
const bundleHash = hashSkillFile(join(bundleSkillDir, ...relPath.split('/')));
const localHash = hashSkillFile(join(localSkillDir, ...relPath.split('/')));
if (bundleHash !== localHash) return false;
}
}
// Provider command artifacts (e.g. OpenCode's commands/impeccable.md) are
// part of "current" too: an install whose skills match but whose bridge is
// missing or drifted must refresh, otherwise reinstall/update report
// success while the slash command stays absent (#474 backfill). Only
// bundle-shipped files are checked, so pinned or user commands never
// affect freshness. The commands dir sits next to the matched skills dir
// (project <root>/.opencode, user <config>, home-dir global override), so
// deriving it from localSkillsDir stays correct for every layout
// copyProviderCommands can write.
const bundleCommandsDir = join(bundleDir, provider, 'commands');
if (existsSync(bundleCommandsDir)) {
const localCommandsDir = join(dirname(localSkillsDir), 'commands');
for (const entry of readdirSync(bundleCommandsDir)) {
const bundleFile = join(bundleCommandsDir, entry);
if (!statSync(bundleFile).isFile()) continue;
const localFile = join(localCommandsDir, entry);
if (!existsSync(localFile)) return false;
if (hashSkillFile(bundleFile) !== hashSkillFile(localFile)) return false;
}
}
if (!providerAgentsUpToDate(bundleDir, root, provider, agentScope)) return false;
}
return true;
}
// ─── skills check ────────────────────────────────────────────────────────────
async function check() {
const root = findProjectRoot();
const installed = isAlreadyInstalled(root);
if (!installed) {
console.log('Impeccable is not installed in this project.');
console.log('Run `npx impeccable install` to install.');
process.exit(0);
}
const providers = findInstalledProviders(root);
console.log('Checking for updates...\n');
try {
const bundleDir = await downloadAndExtractBundle();
const agentScope = isHomeDir(root) ? 'user' : undefined;
const upToDate = isUpToDate(root, providers, bundleDir, undefined, agentScope);
rmSync(bundleDir, { recursive: true, force: true });
if (upToDate) {
const v = getSkillsVersion(root);
console.log(`Skills are up to date${v ? ` (v${v})` : ''}.`);
} else {
console.log('Updates available.');
console.log('Run `npx impeccable update` to update.');
}
} catch (e) {
console.error(`Could not check for updates: ${e.message}`);
process.exit(1);
}
}
// ─── skills install ───────────────────────────────────────────────────────────
// Check if impeccable skills are already present in any provider folder
function isAlreadyInstalled(root, scope) {
for (const d of PROVIDER_DIRS) {
for (const skillsDir of existingSkillsDirs(root, d, scope)) {
try {
const entries = readdirSync(skillsDir);
// Look for 'impeccable' skill (or prefixed variant, or legacy 'teach-impeccable')
if (entries.some(e =>
e === 'impeccable' || e.endsWith('-impeccable') ||
e === 'teach-impeccable' || e.endsWith('-teach-impeccable')
)) {
return d;
}
} catch {}
}
}
return null;
}
function isSkillDir(skillsDir, name) {
// Skill entries can be real directories or symlinks to directories (npx skills uses symlinks)
const full = join(skillsDir, name);
try {
return statSync(full).isDirectory() && existsSync(join(full, 'SKILL.md'));
} catch { return false; }
}
function hasRealSkillEntries(skillsDir) {
if (!existsSync(skillsDir)) return false;
let entries;
try { entries = readdirSync(skillsDir); } catch { return false; }
return entries.some(name =>
!name.startsWith('.') &&
!IGNORED_SKILL_DIR_NAMES.has(name) &&
isSkillDir(skillsDir, name)
);
}
function isRealSkillDir(skillsDir, name) {
// Only real directories, not symlinks -- renaming the real dir renames the symlink targets too
const full = join(skillsDir, name);
try {
const lstat = lstatSync(full);
return lstat.isDirectory() && !lstat.isSymbolicLink() && existsSync(join(full, 'SKILL.md'));
} catch { return false; }
}
/**
* One-way migration for installs from the era when the CLI offered a command
* prefix (default `i-`), renaming the skill to e.g. `i-impeccable`. The prefix
* only earned its keep when every command was its own skill; with a single
* `impeccable` skill it does nothing, so it is no longer offered. Rename any
* prefixed impeccable skill back to the canonical `impeccable` (the fresh
* install/update content lands there next) so users aren't left with a stale,
* orphaned `i-impeccable` alongside the new one. Scoped to the impeccable skill
* by name -- never touches third-party skills that happen to start with `i-`.
* Returns the number of skills migrated.
*/
function migrateUnprefixImpeccable(root, scope) {
let migrated = 0;
for (const d of PROVIDER_DIRS) {
for (const skillsDir of existingSkillsDirs(root, d, scope)) {
let entries;
try { entries = readdirSync(skillsDir); } catch { continue; }
for (const name of entries) {
// A prefixed impeccable skill is `<prefix>impeccable`, not the canonical
// `impeccable` and not an unrelated legacy skill name.
if (name === 'impeccable' || name === 'teach-impeccable') continue;
if (!name.endsWith('-impeccable')) continue;
if (!isRealSkillDir(skillsDir, name)) continue;
const dest = join(skillsDir, 'impeccable');
try {
rmSync(dest, { recursive: true, force: true });
renameSync(join(skillsDir, name), dest);
migrated++;
} catch {}
}
}
}
return migrated;
}
function getFlagValue(flags, name) {
const prefix = `${name}=`;
const inline = flags.find(f => f.startsWith(prefix));
if (inline) return inline.slice(prefix.length);
const index = flags.indexOf(name);
if (index !== -1 && flags[index + 1] && !flags[index + 1].startsWith('-')) {
return flags[index + 1];
}
return null;
}
function normalizeProviderName(value) {
const raw = String(value || '').trim();
if (!raw) return null;
if (PROVIDER_DIRS.includes(raw)) return raw;
const key = raw.replace(/^\./, '').toLowerCase();
return PROVIDER_ALIASES[key] || null;
}
function parseProviderList(value) {
const providers = [];
const invalid = [];
for (const raw of String(value || '').split(',').map(s => s.trim()).filter(Boolean)) {
const provider = normalizeProviderName(raw);
if (!provider) {
invalid.push(raw);
continue;
}
if (!providers.includes(provider)) providers.push(provider);
}
return { providers, invalid };
}
function providerInputName(provider) {
return PROVIDER_DISPLAY[provider]?.input || provider.replace(/^\./, '');
}
function providerDisplayName(provider) {
return PROVIDER_DISPLAY[provider]?.name || provider;
}
function formatProviderList(providers) {
return providers.map(providerInputName).join(', ');
}
function providerPromptOptions() {
return PROVIDER_INPUT_ORDER.map(input => {
const provider = normalizeProviderName(input);
const label = providerDisplayName(provider);
const hint = `(${provider}/skills)`;
return {
value: provider,
label,
hint,
searchText: `${label} ${input} ${provider} ${hint}`,
};
});
}
function formatPathForDisplay(path, home = homedir()) {
if (path === home) return '~';
if (path.startsWith(`${home}/`)) return `~/${path.slice(home.length + 1)}`;
return path;
}
function uniquePaths(paths) {
return [...new Set(paths)];
}
function userSkillProbePaths(home, harnessDir, provider) {
return uniquePaths([
userProviderSkillsDir(home, provider),
join(home, harnessDir, 'skills'),
]);
}
function commandOnPath(command) {
const candidates = process.platform === 'win32'
? [`${command}.exe`, `${command}.cmd`, `${command}.bat`]
: [command];
for (const directory of String(process.env.PATH || '').split(delimiter)) {
if (!directory) continue;
for (const candidate of candidates) {
const path = resolve(directory, candidate);
try {
if (statSync(path).isFile()) {
accessSync(path, constants.X_OK);
return path;
}
} catch {}
}
}
return null;
}
function collectInstallDetections(root, home = homedir()) {
const detections = [];
for (const provider of PROVIDER_DIRS) {
const foundPath = join(root, provider);
if (!existsSync(foundPath)) continue;
detections.push({
provider,
scope: 'project',
foundPath,
installRoot: root,
installPath: join(root, provider, 'skills'),
hasRealSkills: hasRealSkillEntries(join(root, provider, 'skills')),
reason: 'project harness folder',
});
}
for (const hint of GLOBAL_HARNESS_HINTS) {
const { provider } = hint;
// A hint is either a fixed dir under home or a resolver for harnesses
// whose location depends on the environment (OpenCode's config dir).
const foundPath = hint.command
? commandOnPath(hint.command)
: hint.resolve ? hint.resolve(home) : join(home, hint.home);
if (!foundPath || (!hint.command && !existsSync(foundPath))) continue;
if (hint.command && !existsSync(join(home, '.veto'))) continue;
const skillProbePaths = hint.command
? [userProviderSkillsDir(home, provider)]
: hint.resolve
? uniquePaths([userProviderSkillsDir(home, provider), join(foundPath, 'skills')])
: userSkillProbePaths(home, hint.home, provider);
detections.push({
provider,
scope: 'user',
foundPath,
installRoot: home,
installPath: userProviderSkillsDir(home, provider),
skillProbePaths,
hasRealSkills: skillProbePaths.some(hasRealSkillEntries),
reason: hint.command ? 'CLI on PATH' : 'user harness folder',
});
}
return detections;
}
function uniqueProviders(detections) {
const providers = [];
for (const detection of detections) {
if (!providers.includes(detection.provider)) providers.push(detection.provider);
}
return providers;
}
function defaultDetectedProviders(detections) {
const projectProviders = uniqueProviders(detections.filter(d => d.scope === 'project'));
if (projectProviders.length > 0) return projectProviders;
return uniqueProviders(detections.filter(d => d.scope === 'user'));
}
/**
* Decide which provider folders to install into.
* 1. An explicit --providers=.claude,.cursor list wins.
* 2. Otherwise, harness folders already present in the project.
* 3. Otherwise, infer from globally installed harnesses (~/.claude, ~/.codex).
* 4. Otherwise, a sensible default (.claude + .agents).
*/
function resolveInstallTargets(root, providersValue) {
if (providersValue) {
return parseProviderList(providersValue).providers;
}
const detected = defaultDetectedProviders(collectInstallDetections(root));
if (detected.length > 0) return detected;
return [...DEFAULT_TARGETS];
}
function normalizeInstallScope(value) {
const key = String(value || '').trim().toLowerCase();
if (['u', 'user', 'home', 'global'].includes(key)) return 'user';
if (['p', 'project', 'local', 'repo'].includes(key)) return 'project';
return null;
}
function getInstallScopeValue(flags) {
if (flags.includes('--user') || flags.includes('--home') || flags.includes('--global')) return 'user';
if (flags.includes('--project') || flags.includes('--local')) return 'project';
return getFlagValue(flags, '--scope') || getFlagValue(flags, '--install-scope');
}
function defaultInstallScope(detections, providers) {
const selected = new Set(providers);
if (detections.some(d => selected.has(d.provider) && d.scope === 'project')) return 'project';
if (detections.some(d => selected.has(d.provider) && d.scope === 'user' && d.hasRealSkills)) return 'user';
return 'project';
}
function installRootForScope(scope, projectRoot) {
return scope === 'user' ? homedir() : projectRoot;
}
function printInstallIntro() {
if (!isInteractivePrompt()) return;
console.log(`${ui.accent(ui.bold('impeccable'))} ${ui.dim('install')}`);
console.log('');
}
function formatInstallDetectionLines(projectRoot, detections, home = homedir(), { styled = false } = {}) {
if (detections.length === 0) {
const message = `No harnesses detected under ${formatPathForDisplay(projectRoot, home)} or ${formatPathForDisplay(home, home)}.`;
return styled
? [`${ui.accent('◇')} ${ui.bold('Detected harnesses')}`, ` ${ui.dim(message)}`]
: [message];
}
const names = detections.map(d => providerDisplayName(d.provider));
const paths = detections.map(d => formatPathForDisplay(d.foundPath, home));
const nameWidth = Math.max(...names.map(name => name.length));
const heading = styled ? `${ui.accent('◇')} ${ui.bold('Detected harnesses')}` : 'Detected harnesses:';
return [
heading,
...detections.map((detection, index) => {
const rawName = names[index].padEnd(nameWidth);
const rawFoundPath = paths[index];
const name = styled ? ui.bold(rawName) : rawName;
const foundPath = styled ? ui.dim(rawFoundPath) : rawFoundPath;
return ` ${name} ${foundPath}`;
}),
];
}
function printInstallDetections(projectRoot, detections) {
for (const line of formatInstallDetectionLines(projectRoot, detections, homedir(), { styled: isInteractivePrompt() })) console.log(line);
console.log('');
}
async function promptForProviders(defaultProviders = []) {
if (isInteractivePrompt()) {
return promptCheckbox('Select harnesses', providerPromptOptions(), { selectedValues: defaultProviders });
}
const choices = PROVIDER_INPUT_ORDER.join(', ');
const suffix = defaultProviders.length > 0
? ` [blank keeps ${formatProviderList(defaultProviders)}]`
: '';
while (true) {
const answer = await ask(`Select harnesses (comma-separated: ${choices})${suffix}: `);
if (!answer && defaultProviders.length > 0) return [...defaultProviders];
const { providers, invalid } = parseProviderList(answer);
if (invalid.length > 0) {
console.log(`Unknown provider(s): ${invalid.join(', ')}`);
continue;
}
if (providers.length > 0) return providers;
console.log('Choose at least one provider.');
}
}
async function promptDetectedInstallMode(detectedProviders) {
if (isInteractivePrompt()) {
return promptRadio('Install for detected harnesses only, or add more?', [
{ value: 'detected', label: 'Detected only', hint: `(${formatProviderList(detectedProviders)})` },
{ value: 'add', label: 'Customize...' },
]);
}
while (true) {
const answer = await ask(`Install target: [1] Detected only (${formatProviderList(detectedProviders)}) [2] Customize [1]: `);
if (!answer || ['1', 'detected', 'detected only', 'only', 'd'].includes(answer)) return 'detected';
if (['2', 'customize', 'customise', 'add', 'add more', 'more', 'a', 'n', 'no'].includes(answer)) return 'add';
console.log('Choose 1 for detected only, or 2 to customize.');
}
}
async function chooseInstallProviders(projectRoot, providersValue, { yes } = {}) {
const detections = collectInstallDetections(projectRoot);
if (providersValue) {
const { providers, invalid } = parseProviderList(providersValue);
if (invalid.length > 0) {
throw new Error(`Unknown provider(s): ${invalid.join(', ')}`);
}
return { targets: providers, detections, explicit: true };
}
if (yes) {
return { targets: resolveInstallTargets(projectRoot, null), detections, explicit: false };
}
printInstallDetections(projectRoot, detections);
const detectedProviders = defaultDetectedProviders(detections);
if (detectedProviders.length === 0) {
return { targets: await promptForProviders(), detections, explicit: false };
}
const mode = await promptDetectedInstallMode(detectedProviders);
if (mode === 'add') {
return { targets: await promptForProviders(detectedProviders), detections, explicit: false };
}
return { targets: detectedProviders, detections, explicit: false };
}
async function chooseInstallScope(projectRoot, targets, detections, { yes, scopeValue } = {}) {
const explicitScope = normalizeInstallScope(scopeValue);
if (scopeValue && !explicitScope) {
throw new Error(`Unknown install scope: ${scopeValue}. Use --scope=project or --scope=global.`);
}
if (explicitScope) return explicitScope;
// Preserve the old scripted behavior: `-y` installs into the current project
// unless the caller explicitly opts into `--scope=global`.
if (yes) return 'project';
const fallback = defaultInstallScope(detections, targets);
if (isInteractivePrompt()) {
return promptRadio('Install location', [
{ value: 'project', label: 'Project', hint: `(${formatPathForDisplay(projectRoot)})` },
{ value: 'user', label: 'Global', hint: `(${formatPathForDisplay(homedir())})` },
], { initialIndex: fallback === 'user' ? 1 : 0 });
}
const answer = await ask(`Install location: project (${formatPathForDisplay(projectRoot)}) or global (${formatPathForDisplay(homedir())})? [${fallback === 'user' ? 'global' : fallback}] `);
if (!answer) return fallback;
const scope = normalizeInstallScope(answer);
if (!scope) {
console.log(`Unknown install location "${answer}", using ${fallback}.`);
return fallback;
}
return scope;
}
async function chooseInstallPlan(projectRoot, flags, { yes } = {}) {
const providersValue = getFlagValue(flags, '--providers');
const scopeValue = getInstallScopeValue(flags);
const { targets, detections, explicit } = await chooseInstallProviders(projectRoot, providersValue, { yes });
if (targets.length === 0) {
throw new Error('Could not determine a target harness folder.');
}
const scope = await chooseInstallScope(projectRoot, targets, detections, { yes, scopeValue });
const installRoot = installRootForScope(scope, projectRoot);
return { targets, scope, installRoot, hookRoot: projectRoot, detections, explicit };
}
/**
* Whether `localSkillsDir` is a symlink that points at ANOTHER in-project
* provider's skills dir (e.g. `.claude/skills -> ../.agents/skills`, the shape a
* prior `npx skills` install can leave behind). Only these get dropped so each
* provider can receive its own compiled variant. A symlink to anywhere else -
* notably a user's external shared skills dir (`~/.claude/skills ->
* ~/.config/agents/skills`) - is preserved and written through. See issue #295.
*/
function isInProjectProviderLink(localSkillsDir, root, provider) {
let target;
try {
if (!lstatSync(localSkillsDir).isSymbolicLink()) return false;
target = readlinkSync(localSkillsDir);
} catch {
return false; // not a symlink, or unreadable
}
// Resolve the link's TARGET lexically against the link's own directory. We
// deliberately do NOT realpathSync the target:
// * it lets a not-yet-created in-project target still match, so a dangling
// `.claude/skills -> ../.agents/skills` is still dropped;
// * it compares the ACTUAL target, not a shared realpath, so two providers
// pointing at the SAME external dir are never misread as in-project.
const resolvedTarget = resolve(dirname(localSkillsDir), target);
for (const other of PROVIDER_DIRS) {
if (other === provider) continue;
if (resolvedTarget === join(root, other, 'skills')) return true;
}
return false;
}
/**
* Copy each target provider's compiled skill variant from an extracted bundle
* into the project. Writes real directories (copy, never symlink) so every
* harness keeps the build that was compiled for it. Returns skills written.
* `scope: 'user'` writes to the provider's global skills layout (see
* HOME_SKILLS_DIR_OVERRIDES); anything else writes `<provider>/skills`.
*/
function copyProviderSkills(bundleDir, root, targets, { scope } = {}) {
let written = 0;
for (const provider of targets) {
const srcDir = join(bundleDir, provider, 'skills');
if (existsSync(srcDir)) {
const localSkillsDir = scope === 'user'
? userProviderSkillsDir(root, provider)
: join(root, provider, 'skills');
// A previous `npx skills` install may have left this provider's skills dir
// as a symlink to ANOTHER in-project provider's canonical copy. Drop only
// that link so we write a real, provider-specific directory. A user's
// external shared-skills symlink (e.g. ~/.claude/skills ->
// ~/.config/agents/skills) is preserved and written through. See #295.
try {
if (isInProjectProviderLink(localSkillsDir, root, provider)) unlinkSync(localSkillsDir);
} catch {}
for (const skill of readdirSync(srcDir, { withFileTypes: true })) {
if (!skill.isDirectory()) continue;
const src = join(srcDir, skill.name);
const dest = join(localSkillsDir, skill.name);
rmSync(dest, { recursive: true, force: true });
copyDirSync(src, dest);
written++;
}
// A pre-#406 global OpenCode install lived at ~/.opencode/skills, a
// location OpenCode never reads. Now that the real copy sits in the
// config dir, drop exactly the skills just written from the stranded
// location; sibling skills and everything else in ~/.opencode stay.
// Guards (both flagged in review): a symlinked skills dir is shared
// storage whose target must not be emptied through the link, the
// just-written dir must be compared by realpath rather than string,
// and a home-rooted repo makes `.opencode/skills` a live
// project-scope install rather than a stranded global one.
if (scope === 'user' && provider === '.opencode') {
const legacyDir = join(root, '.opencode', 'skills');
let migratable = false;
try {
migratable = existsSync(legacyDir)
&& !lstatSync(legacyDir).isSymbolicLink()
&& realpathSync(legacyDir) !== realpathSync(localSkillsDir)
&& !existsSync(join(root, '.git'));
} catch { migratable = false; }
if (migratable) {
for (const skill of readdirSync(srcDir, { withFileTypes: true })) {
if (!skill.isDirectory()) continue;
rmSync(join(legacyDir, skill.name), { recursive: true, force: true });
}
try { rmdirSync(legacyDir); } catch { /* not empty: siblings stay */ }
}
}
}
}
return written;
}
/**
* Copy each target provider's compiled command variant from an extracted
* bundle into the project or global config dir. OpenCode 1.18.10 discovers
* custom commands from `{command,commands}/**.md` under any active config
* dir, so the install mirrors `copyProviderSkills`: project scope writes
* `<root>/<configDir>/commands/`, user scope writes
* `opencodeGlobalConfigDir(home)/commands` with the same
* `OPENCODE_CONFIG_DIR` → `$XDG_CONFIG_HOME/opencode` → `~/.config/opencode`
* precedence PR #417 established for skills.
*
* Migration guard: a pre-#406 global OpenCode install at
* `~/.opencode/commands/` is not scanned by OpenCode. After a global
* install, the commands just written are removed from the stranded
* legacy copy, sibling commands stay put, symlinked legacy dirs are
* skipped (deleting through a symlink would empty the real target), and
* a home-rooted git repo (`<configDir>/commands/` IS a project install)
* is left alone. Symmetric to `copyProviderSkills` at
* `skills.mjs:1168-1186`.
*/
// Local commands dir for a provider. Project installs land at
// <root>/<configDir>/commands; user-scope OpenCode installs must target the
// config dir OpenCode actually scans (OPENCODE_CONFIG_DIR → XDG → ~/.config).
function providerCommandsDir(root, providerEntry, scope) {
return scope === 'user'
? join(opencodeGlobalConfigDir(root), 'commands')
: join(root, providerEntry.replace(/^\./, '.'), 'commands');
}
function copyProviderCommands(bundleDir, root, targets, { scope } = {}) {
let written = 0;
for (const target of targets) {
const providerEntry = PROVIDER_DIRS.includes(`.${target}`)
? `.${target}`
: target;
const srcDir = join(bundleDir, providerEntry, 'commands');
if (!existsSync(srcDir)) continue;
const localCommandsDir = providerCommandsDir(root, providerEntry, scope);
mkdirSync(localCommandsDir, { recursive: true });
for (const entry of readdirSync(srcDir)) {
const src = join(srcDir, entry);
if (!statSync(src).isFile()) continue;
const dest = join(localCommandsDir, entry);
rmSync(dest, { recursive: true, force: true });
copyFileSync(src, dest);
written++;
}
if (scope === 'user' && providerEntry === '.opencode') {
const legacyDir = join(root, '.opencode', 'commands');
let migratable = false;
try {
migratable = existsSync(legacyDir)
&& !lstatSync(legacyDir).isSymbolicLink()
&& realpathSync(legacyDir) !== realpathSync(localCommandsDir)
&& !existsSync(join(root, '.git'));
} catch { migratable = false; }
if (migratable) {
for (const entry of readdirSync(srcDir)) {
const src = join(srcDir, entry);
if (!statSync(src).isFile()) continue;
rmSync(join(legacyDir, entry), { recursive: true, force: true });
}
try { rmdirSync(legacyDir); } catch { /* not empty: siblings stay */ }
}
}
}
return written;
}
// Native subagent definitions that ship in the bundle next to a provider's
// skills. Claude Code's live at `.claude/agents/impeccable-*.md`; project
// agents take precedence over user agents. GitHub Copilot's live at
// `.github/agents/impeccable-*.agent.md`:
// project installs commit them at `<repo>/.github/agents/`, user-level
// installs go to `~/.copilot/agents/` (Copilot's user-scope dir, NOT
// `~/.github/`). On a name conflict Copilot lets the user-level file shadow
// the project one, so both paths overwrite existing impeccable-* copies and a
// project install reports any same-named user-level agents that would shadow
// it. Cursor's live at `.cursor/agents/impeccable-*.md`, user scope
// `~/.cursor/agents/`; project agents take precedence there, so no shadow
// warning is needed.
const PROVIDER_AGENT_ARTIFACTS = {
'.claude': {
ext: '.md',
userDir: home => join(home, '.claude', 'agents'),
userShadowsProject: false,
},
'.github': {
ext: '.agent.md',
userDir: home => join(home, '.copilot', 'agents'),
userShadowsProject: true,
},
'.cursor': {
ext: '.md',
userDir: home => join(home, '.cursor', 'agents'),
userShadowsProject: false,
},
};
function providerAgentsUpToDate(bundleDir, root, provider, scope) {
const artifact = PROVIDER_AGENT_ARTIFACTS[provider];
if (!artifact) return true;
const srcDir = join(bundleDir, provider, 'agents');
if (!existsSync(srcDir)) return true;
const destDir = scope === 'user'
? artifact.userDir(root)
: join(root, provider, 'agents');
const agentFiles = readdirSync(srcDir).filter(name => name.endsWith(artifact.ext));
return agentFiles.every(name => {
const localPath = join(destDir, name);
return existsSync(localPath)
&& hashSkillFile(join(srcDir, name)) === hashSkillFile(localPath);
});
}
function copyProviderAgents(bundleDir, root, providers, { scope, home = homedir() } = {}) {
const targets = Array.isArray(providers) ? providers : [providers];
const results = [];
for (const provider of targets) {
const artifact = PROVIDER_AGENT_ARTIFACTS[provider];
if (!artifact) continue;
const srcDir = join(bundleDir, provider, 'agents');
if (!existsSync(srcDir)) continue;
const agentFiles = readdirSync(srcDir).filter(name => name.endsWith(artifact.ext));
if (agentFiles.length === 0) continue;
const destDir = scope === 'user'
? artifact.userDir(root)
: join(root, provider, 'agents');
mkdirSync(destDir, { recursive: true });
for (const name of agentFiles) {
writeFileSync(join(destDir, name), readFileSync(join(srcDir, name)));
}
// A project install can be shadowed by same-named agents in the user-level
// dir; surface them so the freshly installed project agents actually apply.
const userDir = artifact.userDir(home);
const shadowed = artifact.userShadowsProject && scope !== 'user'
? agentFiles.filter(name => existsSync(join(userDir, name)))
: [];
results.push({ provider, written: agentFiles.length, destDir, userDir, shadowed });
}
return results;
}
function reportProviderAgents(results) {
for (const result of results || []) {
if (result.written === 0) continue;
console.log(`Installed ${providerDisplayName(result.provider)} agents into: ${formatPathForDisplay(result.destDir)}`);
if (result.shadowed.length > 0) {
console.warn(`Warning: user-level agents in ${formatPathForDisplay(result.userDir)} shadow the project copies just installed: ${result.shadowed.join(', ')}.`);
console.warn('Run `npx impeccable update --user` to refresh them, or remove them so the project agents apply.');
}
}
}
function refreshProviderSkills(bundleDir, root, providers, scope) {
const unique = deduplicateProviders(root, providers, scope);
let updated = 0;
for (const { provider, localSkillsDir } of unique) {
const srcDir = join(bundleDir, provider, 'skills');
if (!existsSync(srcDir)) continue;
const skills = readdirSync(srcDir, { withFileTypes: true });
for (const skill of skills) {
if (!skill.isDirectory()) continue;
const src = join(srcDir, skill.name);
const dest = join(localSkillsDir, skill.name);
if (existsSync(dest)) rmSync(dest, { recursive: true, force: true });
copyDirSync(src, dest);
updated++;
}
}
return updated;
}
function hookArtifactsForProvider(bundleDir, root, provider) {
return (PROVIDER_HOOK_ARTIFACTS[provider] || []).map(({ sourceProvider, rel, destProvider, destRel }) => {
const writeRel = destRel || rel;
const artifact = {
src: join(bundleDir, sourceProvider, rel),
dest: join(root, destProvider, writeRel),
};
// When the write target is a local override (e.g. settings.local.json), the
// team-shared sibling (settings.json) is where a legacy install or a
// deliberate user move would put our hook. Track it so we never duplicate.
if (writeRel !== rel) {
artifact.sharedDest = join(root, destProvider, rel);
}
return artifact;
});
}
// The project-relative hook command path for a provider, used for project-scope
// installs (skillRoot === root). Derived rather than copied from the bundle: the
// Codex bundle ships a `.codex/skills/...` command (correct for a `.codex`-
// directory install), but the CLI lays Codex's skill down at `.agents/skills/`,
// so preserving the bundle token would point the hook at a nonexistent file and
// silently no-op it. Claude keeps its ${CLAUDE_PROJECT_DIR} token so a manifest
// read from a nested cwd (or copied into settings.local.json) still resolves.
function hookScriptRelPathForProvider(provider) {
const script = provider === '.cursor' ? 'hook-before-edit.mjs' : 'hook.mjs';
const rel = `${provider}/skills/impeccable/scripts/${script}`;
return provider === '.claude' ? '${CLAUDE_PROJECT_DIR}/' + rel : rel;
}
function hookScriptPathForProvider(skillRoot, provider) {
// `.github` is intentionally absent: its hook manifest (`.github/hooks/
// impeccable.json`) is a committed, team-shared file that the Copilot cloud
// agent and every teammate read, so the command must stay portable
// (`$(git rev-parse --show-toplevel)/.github/skills/...`). Rewriting it to a
// machine-local absolute skillRoot path would break those. GitHub skills are
// project-scoped (not a home-provider), so the project-relative path resolves.
if (provider === '.cursor') {
return join(skillRoot, provider, 'skills', 'impeccable', 'scripts', 'hook-before-edit.mjs');
}
if (provider === '.claude' || provider === '.agents' || provider === '.grok') {
return join(skillRoot, provider, 'skills', 'impeccable', 'scripts', 'hook.mjs');
}
return null;
}
// Wrap a `node "PATH"` hook command so a missing skill file is a silent no-op
// (exit 0) instead of a Node module-resolution crash. hook.mjs promises to
// "never break a turn. Always exit 0.", but that only holds once Node can load
// the file; a stale/missing path crashes before any of that logic runs. The
// `[ ! -f X ] || node X` form (NOT `... || true`) preserves Node's own exit
// code when the file exists, so Claude's exit-2 blocking signal still reaches
// the agent. POSIX-shell form, consistent with the project's other hook
// commands (e.g. the GitHub manifest's `$(git rev-parse ...)`).
//
// On Windows that guard is a hard failure, not a degraded one (issue #452).
// Codex runs hook commands through COMSPEC (`cmd.exe /C`), where `[` is not a
// command: the guard errors noisily and `||` then runs node even when the file
// is missing, trading the silent no-op for a MODULE_NOT_FOUND crash. Two
// remedies, by provider:
//
// * Codex manifests support a `commandWindows` sibling that Codex 0.146.0+
// selects on Windows (`command_windows.unwrap_or(command)` in its hook
// discovery). rewriteHookCommandsForSkillRoot adds it with a cmd.exe
// `if exist` guard (form contributed and Windows-tested by @PatrickSys in
// issue #452; `exit /b` forwards node's errorlevel), so the same
// .codex/hooks.json is correct on every OS no matter where it was
// written, and `command` stays the plain POSIX guard.
// * Claude and Cursor manifests have no per-platform field, so a Windows
// install moves the existence check into node itself: a `node -e` wrapper
// that exits 0 when the target is missing and otherwise re-spawns node on
// it with inherited stdio, forwarding the hook's exit code. Cursor's
// hooks.json is committable and can be consumed on a teammate's POSIX
// machine, so the wrapper has to hold there too: it uses only characters
// that survive PowerShell, cmd.exe (issue #445: shims re-parse through
// `cmd /C`, which claims < > | & ^ % !), and sh double-quoting alike,
// with single quotes for the inner string literals.
const WIN32_HOOK_GUARD_SCRIPT = "const p=process.argv[1];const f=require('fs');if(f.existsSync(p)){const r=require('child_process').spawnSync(process.execPath,[p],{stdio:'inherit'});process.exit(r.status===null?1:r.status);}";
// POSIX single-quote escaping. JSON.stringify is not shell quoting: inside
// double quotes /bin/sh still expands $(...), backticks, and ${}, and this
// string is baked into a hook manifest the harness re-executes on every edit,
// so an install path embedding $(...) would run it repeatedly (issue #476).
// Windows command forms keep double quotes: cmd.exe treats ' as a literal
// character and performs no command substitution.
function shSingleQuote(value) {
return `'${String(value).replace(/'/g, `'\\''`)}'`;
}
function windowsHookCommand(quotedPath) {
return `if exist ${quotedPath} (node ${quotedPath} & exit /b)`;
}
// `quotedPath` carries one pre-quoted form per target shell: { posix, win32 }.
function guardHookCommand(quotedPath, provider) {
// `.agents` (Codex) keeps the POSIX form unconditionally: its Windows
// consumers read the commandWindows sibling instead.
if (provider !== '.agents' && process.platform === 'win32') {
return `node -e "${WIN32_HOOK_GUARD_SCRIPT}" ${quotedPath.win32}`;
}
return `[ ! -f ${quotedPath.posix} ] || node ${quotedPath.posix}`;
}
// Transform bundled hook commands for the actual install target:
// * absolute — rewrite the (marker) command to the resolved absolute skill
// path. Required when the manifest is a user/global file (~/.claude/
// settings.local.json) that fires in EVERY project, so ${CLAUDE_PROJECT_DIR}
// would resolve per-project to dirs without a skill copy (issue #399); also
// when a project hook points at a skill installed elsewhere (--scope=global).
// * otherwise — keep the bundle's own ${CLAUDE_PROJECT_DIR}-relative path,
// which correctly resolves for a project-scoped install.
// Either way the command goes through guardHookCommand (POSIX shell guard, or
// the shell-agnostic node -e guard when installing on Windows), and Codex hook
// entries additionally get a `commandWindows` sibling for cmd.exe.
function rewriteHookCommandsForSkillRoot(value, provider, { skillRoot, absolute }) {
const hookScript = hookScriptPathForProvider(skillRoot, provider);
// Providers we don't own a `node "PATH"` command hook for (.github) carry
// their own portable command forms; leave them untouched.
if (!hookScript) return value;
// Project-scope installs derive the provider's own project-relative path
// rather than trusting the bundle token, which for Codex points at
// `.codex/skills/...` while the CLI installs the skill at `.agents/skills/`.
// The absolute path comes from the install root (project dir or $HOME), so
// its POSIX form gets real single-quote escaping (issue #476). The relative
// form is a per-provider constant and stays double-quoted, because Claude's
// ${CLAUDE_PROJECT_DIR} token must keep expanding at hook time.
const relPath = hookScriptRelPathForProvider(provider);
const quotedPath = absolute
? { posix: shSingleQuote(hookScript), win32: JSON.stringify(hookScript) }
: { posix: JSON.stringify(relPath), win32: JSON.stringify(relPath) };
if (typeof value === 'string') {
if (!valueHasImpeccableHookMarker(value)) return value;
return guardHookCommand(quotedPath, provider);
}
if (Array.isArray(value)) {
return value.map(item => rewriteHookCommandsForSkillRoot(item, provider, { skillRoot, absolute }));
}
if (value && typeof value === 'object') {
const next = {};
for (const [key, child] of Object.entries(value)) {
next[key] = rewriteHookCommandsForSkillRoot(child, provider, { skillRoot, absolute });
}
if (provider === '.agents' && typeof value.command === 'string' && valueHasImpeccableHookMarker(value.command)) {
next.commandWindows = windowsHookCommand(quotedPath.win32);
}
return next;
}
return value;
}
// The file paths the CLI writes hook manifests to (the local override target,
// e.g. settings.local.json — not the shared sibling).
function expectedHookDests(root, providers) {
const targets = Array.isArray(providers) ? providers : [providers];
return targets.flatMap(provider =>
(PROVIDER_HOOK_ARTIFACTS[provider] || []).map(({ rel, destProvider, destRel }) =>
join(root, destProvider, destRel || rel))
);
}
// Whether a hook manifest file actually wires up the Impeccable hook. We parse
// the JSON and scan only the `hooks` subtree (via valueHasImpeccableHookMarker),
// not the raw file text: an unrelated string elsewhere — e.g. a permissions
// allow entry that happens to mention the hook path — must not read as a hook.
function fileHasImpeccableHookMarker(file) {
if (!existsSync(file)) return false;
let parsed;
try {
parsed = JSON.parse(readFileSync(file, 'utf-8'));
} catch {
return false;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
if (!parsed.hooks || typeof parsed.hooks !== 'object') return false;
return valueHasImpeccableHookMarker(parsed.hooks);
}
// Whether our hook is already wired up for a provider, used to decide if the
// already-installed fast path should top up a missing hook. We look for the
// Impeccable marker — not mere file existence — because the target files
// (settings.local.json, hooks.json) commonly hold unrelated local settings; an
// existence check would falsely report "installed" and skip repairing a missing
// hook that `update` would otherwise add. For Claude we also honor our hook
// living in the shared settings.json sibling (a legacy install or user move).
function hookInstalledForProvider(root, provider) {
const artifacts = PROVIDER_HOOK_ARTIFACTS[provider] || [];
if (artifacts.length === 0) return true;
return artifacts.every(({ destProvider, rel, destRel }) => {
const writeRel = destRel || rel;
if (fileHasImpeccableHookMarker(join(root, destProvider, writeRel))) return true;
if (writeRel !== rel && fileHasImpeccableHookMarker(join(root, destProvider, rel))) return true;
return false;
});
}
function valueHasImpeccableHookMarker(value) {
if (typeof value === 'string') {
const normalized = value.replace(/\\/g, '/');
return IMPECCABLE_HOOK_COMMAND_MARKERS.some(marker => normalized.includes(marker));
}
if (Array.isArray(value)) return value.some(valueHasImpeccableHookMarker);
if (value && typeof value === 'object') {
return Object.values(value).some(valueHasImpeccableHookMarker);
}
return false;
}
function stripImpeccableHookEntry(entry) {
if (!entry || typeof entry !== 'object') return entry;
// `command`/`args`: Claude/Codex/Cursor. `bash`/`powershell`: GitHub Copilot's
// flat entry shape, where the marker lives under the shell-command keys.
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)
|| valueHasImpeccableHookMarker(entry.bash) || valueHasImpeccableHookMarker(entry.powershell)) {
return null;
}
if (!Array.isArray(entry.hooks)) return entry;
const strippedHooks = entry.hooks
.map(stripImpeccableHookEntry)
.filter(Boolean);
if (strippedHooks.length === 0 && entry.hooks.some(valueHasImpeccableHookMarker)) {
return null;
}
return { ...entry, hooks: strippedHooks };
}
function stripImpeccableHookEntries(entries) {
if (!Array.isArray(entries)) return [];
return entries
.map(stripImpeccableHookEntry)
.filter(Boolean);
}
// Remove our hook from a manifest file, preserving any unrelated content. Used
// when the hook is honored in the shared settings.json so a stale machine-local
// copy doesn't make the detector run twice. Drops the file if nothing but our
// hook scaffolding remains. Returns true if it changed anything.
function pruneImpeccableHookFromManifest(manifestPath) {
if (!fileHasImpeccableHookMarker(manifestPath)) return false;
let parsed;
try {
parsed = JSON.parse(readFileSync(manifestPath, 'utf-8'));
} catch {
return false;
}
const existingHooks = parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks)
? parsed.hooks
: {};
const cleanedHooks = {};
for (const [event, entries] of Object.entries(existingHooks)) {
const kept = stripImpeccableHookEntries(entries);
if (kept.length > 0) cleanedHooks[event] = kept;
}
const next = { ...parsed };
if (Object.keys(cleanedHooks).length > 0) {
next.hooks = cleanedHooks;
} else {
// Our hook was the only thing here; drop the hook-manifest scaffolding too.
delete next.hooks;
delete next.description;
delete next.version;
}
if (Object.keys(next).length === 0) {
rmSync(manifestPath, { force: true });
} else {
writeFileSync(manifestPath, `${JSON.stringify(next, null, 2)}\n`);
}
return true;
}
function mergeHookManifests(existing, fresh) {
const existingObject = existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : {};
const freshObject = fresh && typeof fresh === 'object' && !Array.isArray(fresh) ? fresh : {};
const existingHooks = existingObject.hooks && typeof existingObject.hooks === 'object' && !Array.isArray(existingObject.hooks)
? existingObject.hooks
: {};
const freshHooks = freshObject.hooks && typeof freshObject.hooks === 'object' && !Array.isArray(freshObject.hooks)
? freshObject.hooks
: {};
const merged = { ...existingObject, hooks: {} };
if (freshObject.version !== undefined) merged.version = freshObject.version;
if (freshObject.description !== undefined) merged.description = freshObject.description;
const hookEvents = new Set([...Object.keys(existingHooks), ...Object.keys(freshHooks)]);
for (const event of hookEvents) {
const preserved = stripImpeccableHookEntries(existingHooks[event]);
const added = Array.isArray(freshHooks[event]) ? freshHooks[event] : [];
const mergedEntries = [...preserved, ...added];
if (mergedEntries.length > 0) merged.hooks[event] = mergedEntries;
}
return merged;
}
function readJsonFile(filePath, description) {
try {
return JSON.parse(readFileSync(filePath, 'utf-8'));
} catch (e) {
throw new Error(`${description} is not valid JSON: ${filePath}. ${e.message}`);
}
}
function copyProviderHooks(bundleDir, root, providers, { force = false, skillRoot = root } = {}) {
const targets = Array.isArray(providers) ? providers : [providers];
const written = [];
for (const provider of targets) {
for (const { src, dest, sharedDest } of hookArtifactsForProvider(bundleDir, root, provider)) {
if (!existsSync(src)) continue;
// Leave-it-never-duplicate: our hook already lives in the team-shared
// settings.json (a legacy install or a deliberate user move). Honor it in
// place and skip the local write — but first strip any stale copy from the
// local override, or Claude Code would load both and run the detector
// twice per edit.
if (sharedDest && fileHasImpeccableHookMarker(sharedDest)) {
pruneImpeccableHookFromManifest(dest);
continue;
}
const freshManifest = readJsonFile(src, 'Bundled hook manifest');
// Rewrite to an absolute skill path when the skill lives elsewhere than
// this manifest's root (--scope=global project hook) OR when the manifest
// itself is a user/global file. A global settings file fires in every
// project, so ${CLAUDE_PROJECT_DIR} there crashes Node wherever no local
// skill copy exists (issue #399); the resolved absolute path is correct
// for the one global skill it targets.
const absolute = skillRoot !== root || isHomeDir(root);
const fresh = rewriteHookCommandsForSkillRoot(freshManifest, provider, { skillRoot, absolute });
let next = fresh;
if (existsSync(dest)) {
try {
const existing = JSON.parse(readFileSync(dest, 'utf-8'));
next = mergeHookManifests(existing, fresh);
} catch {
if (!force) {
throw new Error(`Existing hook manifest is not valid JSON: ${dest}. Re-run with --force to replace it.`);
}
writeFileSync(`${dest}.bak`, readFileSync(dest));
next = fresh;
}
}
mkdirSync(dirname(dest), { recursive: true });
writeFileSync(dest, `${JSON.stringify(next, null, 2)}\n`);
written.push(provider);
}
}
return [...new Set(written)];
}
const HOOK_EXPLAINER = [
'',
'Impeccable can install a design hook for this project. In Claude/Codex it',
'checks UI files after edits; in Cursor it checks proposed writes before they',
'land and can block writes with detector findings. It feeds results back to',
'your agent so design slop gets caught as you build. Change it later with',
'/impeccable hooks on|off.',
'',
].join('\n');
// Decide whether to install the design hook. Prompts once (default yes) the
// first time, records the answer in .impeccable/config.local.json, and never
// re-asks: a recorded decision or an already-installed hook short-circuits, and
// non-interactive runs keep the historical install-by-default behavior.
async function decideHookInstall(root, targets, { yes } = {}) {
if (targets.length === 0) return false;
const consent = getHookConsent(root);
if (consent === 'declined') return false;
if (consent === 'accepted') return true;
// Existing hook users (hook already wired up) are never nagged.
if (targets.length > 0 && targets.every(provider => hookInstalledForProvider(root, provider))) {
return true;
}
// Undecided and not yet installed. Non-interactive (-y or no TTY) keeps the
// historical default-on behavior without recording a (re-promptable) decision.
if (yes || !process.stdin.isTTY) return true;
process.stdout.write(HOOK_EXPLAINER);
const ans = await ask('Install the design hook? (Y/n) ');
const accepted = !(ans === 'n' || ans === 'no');
setHookConsent(root, accepted ? 'accepted' : 'declined');
return accepted;
}
function resolveLinkSource(sourceValue, root) {
const sourcePath = sourceValue || '.impeccable';
const checkoutRoot = isAbsolute(sourcePath) ? sourcePath : resolve(root, sourcePath);
const universalRoot = join(checkoutRoot, 'dist', 'universal');
if (existsSync(universalRoot)) {
return { checkoutRoot, bundleRoot: universalRoot };
}
if (PROVIDER_DIRS.some(provider => existsSync(join(checkoutRoot, provider, 'skills')))) {
return { checkoutRoot, bundleRoot: checkoutRoot };
}
throw new Error(`Could not find compiled skills in ${sourcePath}. Expected dist/universal/ or provider skill folders.`);
}
function pathExistsOrLink(path) {
try {
lstatSync(path);
return true;
} catch {
return false;
}
}
function isSymlinkTo(dest, expectedSource) {
try {
if (!lstatSync(dest).isSymbolicLink()) return false;
const target = readlinkSync(dest);
const resolvedTarget = resolve(dirname(dest), target);
return realpathSync(resolvedTarget) === realpathSync(expectedSource);
} catch {
return false;
}
}
function resolveUniqueLinkTargets(root, targets) {
const seen = new Set();
const unique = [];
for (const provider of targets) {
const localSkillsDir = join(root, provider, 'skills');
mkdirSync(localSkillsDir, { recursive: true });
const real = realpathSync(localSkillsDir);
if (seen.has(real)) continue;
seen.add(real);
unique.push({ provider, localSkillsDir });
}
return unique;
}
function linkProviderSkills(bundleRoot, root, targets, { force = false } = {}) {
let linked = 0;
let already = 0;
let skipped = 0;
for (const { provider, localSkillsDir } of resolveUniqueLinkTargets(root, targets)) {
const srcDir = join(bundleRoot, provider, 'skills');
if (!existsSync(srcDir)) continue;
for (const skill of readdirSync(srcDir, { withFileTypes: true })) {
if (!skill.isDirectory()) continue;
const src = join(srcDir, skill.name);
const dest = join(localSkillsDir, skill.name);
if (pathExistsOrLink(dest)) {
if (isSymlinkTo(dest, src)) {
already++;
continue;
}
if (!force) {
console.warn(`Skipped existing ${provider}/skills/${skill.name}. Use --force to replace it with a link.`);
skipped++;
continue;
}
rmSync(dest, { recursive: true, force: true });
}
const target = relative(dirname(dest), src) || '.';
symlinkSync(target, dest, 'dir');
linked++;
}
}
return { linked, already, skipped };
}
async function link(flags) {
const force = flags.includes('--force');
const yes = flags.includes('-y') || flags.includes('--yes');
const sourceValue = getFlagValue(flags, '--source');
const providersValue = getFlagValue(flags, '--providers');
const root = findProjectRoot();
let source;
try {
source = resolveLinkSource(sourceValue, root);
} catch (e) {
console.error(e.message);
process.exit(1);
}
const targets = resolveInstallTargets(root, providersValue);
if (targets.length === 0) {
console.error('Could not determine a target harness folder.');
console.error('Pass one explicitly, e.g. --providers=claude,cursor');
process.exit(1);
}
if (!yes) {
console.log(`Source checkout: ${source.checkoutRoot}`);
console.log(`Target harness folder(s): ${targets.join(', ')}`);
const ans = await ask(`Link impeccable skills into ${targets.length} folder(s)? (Y/n) `);
if (ans === 'n' || ans === 'no') {
console.log('Aborted. Re-run with --providers=<names> to choose explicitly (e.g. --providers=claude,cursor).');
process.exit(0);
}
}
const result = linkProviderSkills(source.bundleRoot, root, targets, { force });
if (result.linked === 0 && result.already === 0) {
if (result.skipped > 0) {
console.error('Nothing was linked because matching skill folders already exist.');
console.error('Existing skills were left untouched. Re-run with --force to replace them with links.');
} else {
console.error(`Nothing was linked: ${source.bundleRoot} had no variants for ${targets.join(', ')}.`);
}
process.exit(1);
}
// Linked installs are excluded from install/update refreshes (overwriting a
// symlink would destroy the link), so this is the only path that can deliver
// the OpenCode command bridge to them. A copy, not a symlink: the bridge is
// static and OpenCode scans the real commands dir. No-ops when the source
// checkout has no built commands (e.g. dist/ not built yet).
copyProviderCommands(source.bundleRoot, root, targets, { scope: 'project' });
const parts = [];
if (result.linked > 0) parts.push(`${result.linked} linked`);
if (result.already > 0) parts.push(`${result.already} already linked`);
if (result.skipped > 0) parts.push(`${result.skipped} skipped`);
console.log(`Linked impeccable into: ${targets.join(', ')} (${parts.join(', ')}).`);
console.log('Update with `git submodule update --remote` from your project root, then rerun this command if new skills are added.\n');
}
async function install(flags) {
const force = flags.includes('--force');
const yes = flags.includes('-y') || flags.includes('--yes');
const installHooks = !flags.includes('--no-hooks');
const projectRoot = findProjectRoot();
if (!yes) printInstallIntro();
let plan;
try {
plan = await chooseInstallPlan(projectRoot, flags, { yes });
} catch (e) {
if (isPromptAbortError(e)) throw e;
console.error(e.message);
console.error('Pass providers explicitly, e.g. --providers=claude,cursor');
process.exit(1);
}
const { targets, installRoot, hookRoot, scope, explicit } = plan;
const existing = isAlreadyInstalled(installRoot, scope);
const installedTargets = existing ? findInstalledProviders(installRoot, scope) : [];
// An explicit --providers list is a per-target request: a selected provider
// with no install yet gets a fresh install instead of tripping the global
// "already installed" early exit (issue #500). When every selected provider
// is missing, skip the update branch entirely and take the fresh-install path.
const missingSelectedTargets = (existing && !force && explicit)
? targets.filter(provider => !installedTargets.includes(provider))
: [];
if (existing && !force && missingSelectedTargets.length < targets.length) {
console.log(`Impeccable skills are already installed (found in ${existing}/).`);
const selectedInstalledTargets = targets.filter(provider => installedTargets.includes(provider));
const linkedTargets = findLinkedProviders(installRoot, selectedInstalledTargets, scope);
const copyTargets = selectedInstalledTargets.filter(provider => !linkedTargets.includes(provider));
const hookTargets = [...selectedInstalledTargets, ...missingSelectedTargets];
const wantHooks = installHooks && await decideHookInstall(hookRoot, hookTargets, { yes });
let bundleDir;
try {
if (linkedTargets.length > 0) {
console.log(`Linked skills found in: ${linkedTargets.join(', ')}`);
console.log('Update the source checkout with `git submodule update --remote`, then rerun `npx impeccable link --source=.impeccable` if new skills are added.');
if (copyTargets.length > 0) console.log(`Continuing with copied installs in: ${copyTargets.join(', ')}\n`);
}
let updated = 0;
const missingHookTargets = wantHooks
? hookTargets.filter(provider => !hookInstalledForProvider(hookRoot, provider))
: [];
let updateCheckSkipped = false;
if (copyTargets.length > 0 || missingHookTargets.length > 0 || missingSelectedTargets.length > 0) {
try {
bundleDir = await downloadAndExtractBundle();
} catch (e) {
if (missingHookTargets.length > 0 || missingSelectedTargets.length > 0) throw e;
updateCheckSkipped = true;
console.log(`Could not check for skill updates: ${e.message}`);
}
}
if (!updateCheckSkipped && copyTargets.length > 0 && !isUpToDate(installRoot, copyTargets, bundleDir, scope)) {
migrateUnprefixImpeccable(installRoot, scope);
updated = refreshProviderSkills(bundleDir, installRoot, copyTargets, scope);
reportProviderAgents(copyProviderAgents(bundleDir, installRoot, copyTargets, { scope }));
copyProviderCommands(bundleDir, installRoot, copyTargets, { scope });
const v = getSkillsVersion(installRoot, scope);
console.log(`Updated ${updated} skill(s)${v ? ` to v${v}` : ''}.`);
}
let freshWritten = 0;
if (!updateCheckSkipped && missingSelectedTargets.length > 0) {
freshWritten = copyProviderSkills(bundleDir, installRoot, missingSelectedTargets, { scope });
if (freshWritten === 0) {
console.error(`Nothing was installed: the bundle had no variants for ${missingSelectedTargets.join(', ')}.`);
process.exit(1);
}
console.log(`Installed impeccable into: ${missingSelectedTargets.join(', ')} (${scope === 'user' ? 'global' : 'project'})`);
reportProviderAgents(copyProviderAgents(bundleDir, installRoot, missingSelectedTargets, { scope }));
}
const writtenHookTargets = missingHookTargets.length > 0
? copyProviderHooks(bundleDir, hookRoot, missingHookTargets, { skillRoot: installRoot })
: [];
if (writtenHookTargets.length > 0) console.log(`Installed hooks into: ${writtenHookTargets.join(', ')}`);
if (updateCheckSkipped) {
console.log('Existing skills were left unchanged.');
console.log('Run with --force to reinstall.\n');
} else if (updated === 0 && writtenHookTargets.length === 0 && freshWritten === 0) {
const v = getSkillsVersion(installRoot, scope);
console.log(`Skills are up to date${v ? ` (v${v})` : ''}.`);
console.log('Run with --force to reinstall.\n');
} else {
console.log('Done!\n');
}
} catch (e) {
console.error(`Install check failed: ${e.message}`);
process.exit(1);
} finally {
if (bundleDir) rmSync(bundleDir, { recursive: true, force: true });
}
process.exit(0);
}
// Decide which harness folders to install into, then copy each harness's own
// compiled variant from the universal bundle. We deliberately do NOT shell out
// to `npx skills add`: its name-based discovery can install the uncompiled
// source, and its symlink default points every harness at one shared variant.
// Copying per-provider variants is the only correct install for this skill.
if (targets.length === 0) {
console.error('Could not determine a target harness folder.');
console.error('Pass one explicitly, e.g. --providers=.claude,.cursor');
process.exit(1);
}
const wantHooks = installHooks && await decideHookInstall(hookRoot, targets, { yes });
console.log('\nDownloading impeccable skills...');
let bundleDir;
try {
bundleDir = await downloadAndExtractBundle();
} catch (e) {
console.error(`Download failed: ${e.message}`);
process.exit(1);
}
// Retire any old `i-`-prefixed install so the fresh copy lands on the
// canonical `impeccable` dir instead of orphaning the prefixed one.
migrateUnprefixImpeccable(installRoot, scope);
let written = 0;
let hookTargets = [];
let agentResults = [];
try {
written = copyProviderSkills(bundleDir, installRoot, targets, { scope });
agentResults = copyProviderAgents(bundleDir, installRoot, targets, { scope });
copyProviderCommands(bundleDir, installRoot, targets, { scope });
hookTargets = wantHooks ? copyProviderHooks(bundleDir, hookRoot, targets, { force, skillRoot: installRoot }) : [];
} catch (e) {
rmSync(bundleDir, { recursive: true, force: true });
console.error(`Install failed: ${e.message}`);
process.exit(1);
}
rmSync(bundleDir, { recursive: true, force: true });
if (written === 0) {
console.error(`Nothing was installed: the bundle had no variants for ${targets.join(', ')}.`);
process.exit(1);
}
console.log(`Installed impeccable into: ${targets.join(', ')} (${scope === 'user' ? 'global' : 'project'})`);
reportProviderAgents(agentResults);
if (hookTargets.length > 0) console.log(`Installed hooks into: ${hookTargets.join(', ')}`);
console.log('\nDone! Now type /impeccable init in your AI coding agent\'s chat (not in this terminal) to set up design context.\n');
}
// ─── skills update ────────────────────────────────────────────────────────────
function findProjectRoot() {
let dir = process.cwd();
while (dir !== dirname(dir)) {
if (existsSync(join(dir, '.git'))) return dir;
dir = dirname(dir);
}
return process.cwd();
}
function findInstalledProviders(root, scope) {
const found = [];
for (const d of PROVIDER_DIRS) {
for (const skillsDir of existingSkillsDirs(root, d, scope)) {
try {
const entries = readdirSync(skillsDir);
if (entries.some(name => isSkillDir(skillsDir, name))) {
found.push(d);
break;
}
} catch {}
}
}
return found;
}
// Like findInstalledProviders, but only counts a provider whose skills dir
// actually holds the IMPECCABLE skill (canonical, prefixed, or legacy teach-).
// `update` uses this so it never mistakes a repo that vendors OTHER first-party
// skills under .claude/skills for an impeccable install and drops a copy in
// (issue #399, part 2).
function findImpeccableProviders(root, scope) {
const found = [];
for (const d of PROVIDER_DIRS) {
for (const skillsDir of existingSkillsDirs(root, d, scope)) {
let entries;
try { entries = readdirSync(skillsDir); } catch { continue; }
if (entries.some(e =>
e === 'impeccable' || e.endsWith('-impeccable') ||
e === 'teach-impeccable' || e.endsWith('-teach-impeccable')
)) {
found.push(d);
break;
}
}
}
return found;
}
// Resolve which install `skills update` should refresh: project-level (the CWD's
// git root) or user-level (~/.claude etc.). Returns a plain descriptor; the
// caller handles the interactive both-exist prompt via `ambiguous`.
function resolveUpdateTarget({ projectRoot, home, explicitScope }) {
// A home-rooted repo (a dotfiles checkout at $HOME) overlaps project and user
// installs under one root; keep the historical unscoped scan so overlapping
// layouts (e.g. Pi's ~/.pi/skills and ~/.pi/agent/skills) both refresh.
const homeRooted = isHomeDir(projectRoot);
if (homeRooted && !explicitScope) {
const providers = findInstalledProviders(home);
return providers.length
? { root: home, scope: undefined, agentScope: 'user', providers, scopeLabel: 'user level' }
: null;
}
const projectProviders = homeRooted ? [] : findImpeccableProviders(projectRoot, 'project');
const userProviders = findImpeccableProviders(home, 'user');
if (explicitScope === 'user') {
return userProviders.length
? { root: home, scope: 'user', providers: userProviders, scopeLabel: 'user level' }
: null;
}
if (explicitScope === 'project') {
return projectProviders.length
? { root: projectRoot, scope: 'project', providers: projectProviders, scopeLabel: 'this project' }
: null;
}
if (projectProviders.length && userProviders.length) {
return { ambiguous: true, projectRoot, home, projectProviders, userProviders };
}
if (projectProviders.length) {
return { root: projectRoot, scope: 'project', providers: projectProviders, scopeLabel: 'this project' };
}
if (userProviders.length) {
return { root: home, scope: 'user', providers: userProviders, scopeLabel: 'user level' };
}
return null;
}
function findLinkedProviders(root, providers, scope) {
return providers.filter(provider => {
for (const skillsDir of providerSkillsDirCandidates(root, provider, scope)) {
const skillDir = join(skillsDir, 'impeccable');
try {
if (lstatSync(skillDir).isSymbolicLink()) return true;
} catch {}
}
return false;
});
}
function getModifiedSkillFiles(root, providerDirs) {
// Use git to check if any skill files have local modifications
const modified = [];
try {
const status = execSync('git status --porcelain', { cwd: root, encoding: 'utf8' });
for (const line of status.split('\n')) {
if (!line.trim()) continue;
const file = line.substring(3);
for (const d of providerDirs) {
if (file.startsWith(`${d}/skills/`)) {
const flag = line.substring(0, 2).trim();
modified.push({ file, flag });
}
}
}
} catch {
// Not a git repo or git not available
}
return modified;
}
async function downloadFile(url, dest, { fetchImpl = globalThis.fetch } = {}) {
let current = url;
let hopsLeft = 5;
while (true) {
const parsed = new URL(current);
if (parsed.protocol !== 'https:') {
throw new Error('Refusing non-HTTPS URL');
}
const res = await fetchImpl(current, { redirect: 'manual' });
if (res.status >= 300 && res.status < 400) {
const location = res.headers.get('location');
if (!location) throw new Error(`HTTP ${res.status}`);
if (hopsLeft <= 0) throw new Error('Too many redirects');
hopsLeft -= 1;
current = new URL(location, current).href;
continue;
}
if (res.status !== 200) {
throw new Error(`HTTP ${res.status}`);
}
if (!res.body) throw new Error('Empty response body');
try {
await pipeline(Readable.fromWeb(res.body), createWriteStream(dest, { flags: 'wx' }));
} catch (e) {
if (e.code !== 'EEXIST') rmSync(dest, { force: true });
throw e;
}
return;
}
}
async function update(flags = []) {
const yes = flags.includes('-y') || flags.includes('--yes');
const force = flags.includes('--force');
const installHooks = !flags.includes('--no-hooks');
const scopeValue = getInstallScopeValue(flags);
const explicitScope = normalizeInstallScope(scopeValue);
if (scopeValue && !explicitScope) {
console.error(`Unknown update scope: ${scopeValue}. Use --project or --user.`);
process.exit(1);
}
// Download the latest skills directly from impeccable.style.
// We skip `npx skills update` because it has a known upstream bug
// (vercel-labs/skills#775) where it can't find the lock file.
const projectRoot = findProjectRoot();
const home = homedir();
let target = resolveUpdateTarget({ projectRoot, home, explicitScope });
if (!target) {
if (explicitScope) {
const where = explicitScope === 'user' ? `user level (${formatPathForDisplay(home)})` : `this project (${projectRoot})`;
console.log(`No impeccable skill folders found at the ${where}.`);
} else {
console.log('No impeccable skill folders found in this project or at the user level.');
}
console.log('Run `npx impeccable install` to install first.');
process.exit(1);
}
// Both a project and a user-level install exist and no scope was given. Never
// silently pick (issue #399, part 2): prompt when interactive, else default to
// the project and say how to target the other.
if (target.ambiguous) {
console.log('Impeccable is installed both here and at the user level:');
console.log(` project ${projectRoot} (${target.projectProviders.join(', ')})`);
console.log(` user level ${formatPathForDisplay(home)} (${target.userProviders.join(', ')})`);
let pickUser = false;
if (!yes && process.stdin.isTTY) {
const ans = await ask('Update which? [project]/user: ');
pickUser = ['user', 'u', 'global', 'home'].includes(ans);
} else {
console.log('Defaulting to the project. Re-run with --user to update the user-level install instead.');
}
target = pickUser
? { root: home, scope: 'user', providers: target.userProviders, scopeLabel: 'user level' }
: { root: projectRoot, scope: 'project', providers: target.projectProviders, scopeLabel: 'this project' };
}
const { root, scope, agentScope = scope } = target;
console.log(`Updating the ${target.scopeLabel} install: ${formatPathForDisplay(root)} (${target.providers.join(', ')})`);
const providers = target.providers;
const linkedProviders = findLinkedProviders(root, providers, scope);
const copyProviders = providers.filter(provider => !linkedProviders.includes(provider));
if (linkedProviders.length > 0) {
console.log(`Linked skills found in: ${linkedProviders.join(', ')}`);
console.log('Update the source checkout with `git submodule update --remote`, then rerun `npx impeccable link --source=.impeccable` if new skills are added.');
if (copyProviders.length === 0) process.exit(0);
console.log(`Continuing with copied installs in: ${copyProviders.join(', ')}\n`);
}
console.log('Checking for updates...');
let tmpDir;
try {
tmpDir = await downloadAndExtractBundle();
} catch (e) {
console.error(`Download failed: ${e.message}`);
process.exit(1);
}
// Compare local vs remote -- skip if already up to date
if (isUpToDate(root, copyProviders, tmpDir, scope, agentScope)) {
try {
const wantHooks = installHooks && await decideHookInstall(root, copyProviders, { yes });
const hookTargets = wantHooks ? copyProviderHooks(tmpDir, root, copyProviders, { force }) : [];
rmSync(tmpDir, { recursive: true, force: true });
const v = getSkillsVersion(root, scope);
console.log(`Skills are up to date${v ? ` (v${v})` : ''}.`);
if (hookTargets.length > 0) console.log(`Installed hooks into: ${hookTargets.join(', ')}`);
console.log('Nothing else to do.');
process.exit(0);
} catch (e) {
rmSync(tmpDir, { recursive: true, force: true });
console.error(`Update failed: ${e.message}`);
process.exit(1);
}
}
console.log(`Found skills in: ${copyProviders.join(', ')}`);
if (!yes) {
const ans = await ask(`Update skills in ${copyProviders.length} provider folder(s)? (Y/n) `);
if (ans === 'n' || ans === 'no') {
rmSync(tmpDir, { recursive: true, force: true });
console.log('Aborted.');
process.exit(0);
}
}
try {
// Retire any old `i-`-prefixed install up front so the refresh lands on the
// canonical `impeccable` dir rather than orphaning the prefixed copy.
const migrated = migrateUnprefixImpeccable(root, scope);
if (migrated > 0) console.log('Migrated a prefixed install back to /impeccable (the i- prefix is no longer used).');
const updated = refreshProviderSkills(tmpDir, root, copyProviders, scope);
reportProviderAgents(copyProviderAgents(tmpDir, root, copyProviders, { scope: agentScope }));
copyProviderCommands(tmpDir, root, copyProviders, { scope });
const wantHooks = installHooks && await decideHookInstall(root, providers, { yes });
const hookTargets = wantHooks ? copyProviderHooks(tmpDir, root, providers, { force }) : [];
rmSync(tmpDir, { recursive: true, force: true });
const v = getSkillsVersion(root, scope);
console.log(`Updated ${updated} skill(s)${v ? ` to v${v}` : ''}.`);
if (hookTargets.length > 0) console.log(`Installed hooks into: ${hookTargets.join(', ')}`);
console.log('Done!\n');
} catch (e) {
console.error(`Update failed: ${e.message}`);
if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
process.exit(1);
}
}
function copyDirSync(src, dest) {
mkdirSync(dest, { recursive: true });
for (const entry of readdirSync(src, { withFileTypes: true })) {
const s = join(src, entry.name);
const d = join(dest, entry.name);
if (entry.isDirectory()) {
copyDirSync(s, d);
} else {
writeFileSync(d, readFileSync(s));
}
}
}
// ─── Test surface ───────────────────────────────────────────────────────────
// Exported so the test suite exercises the real implementation rather than a
// reimplementation in a helper script (which is how bugs slip through).
export {
collectInstallDetections,
copyProviderAgents,
copyProviderCommands,
copyProviderHooks,
copyProviderSkills,
decideHookInstall,
downloadAndExtractBundle,
downloadFile,
expectedHookDests,
extractZip,
formatInstallDetectionLines,
getSkillsVersion,
isUpToDate,
hermesGlobalHome,
HOME_SKILLS_DIR_OVERRIDES,
linkProviderSkills,
mergeHookManifests,
migrateUnprefixImpeccable,
opencodeGlobalConfigDir,
resolveInstallTargets,
resolveLinkSource,
};
// ─── Router ───────────────────────────────────────────────────────────────────
export async function run(args) {
const sub = args[0];
if (SUBCOMMAND_HELP[sub] && args.slice(1).some(arg => arg === '--help' || arg === '-h')) {
showSubcommandHelp(sub);
return;
}
if (!sub || sub === 'help' || sub === '--help' || sub === '-h') {
await showHelp();
} else if (sub === 'install') {
await install(args.slice(1));
} else if (sub === 'link') {
await link(args.slice(1));
} else if (sub === 'update') {
await update(args.slice(1));
} else if (sub === 'check') {
await check();
} else {
console.error(`Unknown skills command: ${sub}`);
console.error(`Run 'impeccable --help' for available commands.`);
process.exit(1);
}
}