Files
pbakaus_impeccable/cli/bin/commands/skills.mjs
T
9c0012d4e1 feat(hooks): package design hook in plugin, install to settings.local.json (#243)
* feat(hooks): package design hook in plugin, install to settings.local.json

Three related changes to how the Impeccable design hook is distributed,
plus an unrelated build fix discovered along the way.

Package the hook in the Claude Code plugin
- The marketplace / `/plugin install` path previously shipped the skill and
  agents but no hook, so those users never got the design detector. The build
  now emits `plugin/hooks/hooks.json` (auto-discovered at the plugin root),
  resolving the script via `${CLAUDE_PLUGIN_ROOT}` so it works wherever Claude
  Code unpacks the plugin instead of assuming a `.claude/skills/` layout.

CLI installs the hook into settings.local.json, not shared settings.json
- `npx impeccable skills install/update` now writes the Claude hook to the
  gitignored `.claude/settings.local.json` (a machine-local install side
  effect) rather than the team-shared `settings.json`, which could otherwise
  be committed and break for teammates without the skill installed.
- Graceful handling (leave-it-never-duplicate): if our hook already lives in
  the shared `settings.json` (a legacy install or a deliberate user move), it
  is honored in place and never duplicated into the local override, which
  would otherwise run the detector twice per edit.
- The skill's `/impeccable hooks on|off` toggle is unaffected: it only writes
  `.impeccable/hook.json`, never the settings files.

Fix universal.zip build failure under archiver v8
- `archiver` was bumped to v8 (now ESM, factory function removed) but
  `scripts/lib/zip.js` still used the old `archiver('zip', ...)` API, so every
  build silently failed to produce `dist/universal.zip` (the skill-release
  artifact). Switched to `new ZipArchive({...})`.

Also folds in a pre-existing local rename of the hook status message
("Scanning design" -> "Checking UI changes") and its regenerated provider
output.

Tests: new coverage for the plugin-packaged hook manifest and the
shared-settings honor-in-place path; existing CLI assertions moved to
settings.local.json. Full suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): detect hook by marker, not file existence (Bugbot)

hookInstalledForProvider treated any existing settings.local.json (or
hooks.json) as proof the hook was installed. Those files commonly hold
unrelated local settings, so the already-installed `skills install` path
would skip repairing a genuinely missing hook that `update` would add.

Detect the Impeccable marker in the file instead of mere existence. Adds a
test for the exact case: a settings.local.json with only permissions still
triggers hook repair and preserves the unrelated content.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(build): fail loud on a broken release zip + cover the zip writer

Close the gap that let the archiver v8 break ship a 0-byte universal.zip
with a green test suite:

- createProviderZip no longer swallows failures. It throws on a missing
  source, an archive with zero entries, or a 0-byte output, and build() now
  exits non-zero on any such rejection. A build that can't produce its release
  artifact fails instead of deploying an empty bundle.
- New tests/zip.test.mjs exercises the real zip writer and round-trips through
  extract-zip (the unpacker the CLI uses): a valid bundle unpacks to the skill
  tree, and the empty/missing-source cases throw. Wired into the core suite so
  it runs in `bun run test`.

Why this matters: the prior CLI e2e tests stub the bundle as a local
directory, so they never built, downloaded, or unzipped a real archive. The
zip writer had no coverage and failed soft, so Dependabot's archiver 7->8
major bump merged green and the deploy shipped an unusable bundle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): scope hook marker scan to the hooks subtree + prune local dupes (Bugbot)

Two follow-ups from Bugbot:

- fileHasImpeccableHookMarker scanned the whole settings file as raw text, so
  an unrelated string (e.g. a permissions allow entry that mentions the hook
  path) could falsely read as an installed hook and block install/repair or
  the shared-settings skip. Now it parses the JSON and scans only the `hooks`
  subtree.
- When the hook is honored in the shared settings.json, copyProviderHooks
  skipped the local write but left a stale hook in settings.local.json from an
  earlier machine-local install, so Claude Code loaded both and ran the
  detector twice per edit. It now prunes the local copy (preserving unrelated
  local settings, dropping the file if only our scaffolding remained).

Adds tests for both: a permissions string mentioning the hook path still
triggers repair, and a shared hook prunes the stale local duplicate while
keeping unrelated permissions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 23:54:46 -07:00

1105 lines
41 KiB
JavaScript

/**
* `impeccable skills` subcommand
*
* Usage:
* impeccable skills help Show all available skills and commands
* impeccable skills install Install compiled skills from the universal bundle
* impeccable skills link Symlink compiled skills from a local checkout
* impeccable skills update Update skills to latest version
*/
import { execSync } from 'node:child_process';
import { existsSync, readFileSync, readdirSync, statSync, lstatSync, unlinkSync, mkdirSync, writeFileSync, rmSync, renameSync, createWriteStream, realpathSync, symlinkSync, readlinkSync, cpSync } from 'node:fs';
import { join, resolve, dirname, relative, isAbsolute } from 'node:path';
import { createInterface } from 'node:readline';
import { fileURLToPath } from 'node:url';
import { get } from 'node:https';
import { createHash } from 'node:crypto';
import { tmpdir, homedir } from 'node:os';
import extract from 'extract-zip';
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', '.github', '.kiro', '.opencode', '.pi', '.qoder', '.trae', '.trae-cn', '.rovodev'];
const PROVIDER_ALIASES = {
agents: '.agents',
claude: '.claude',
'claude-code': '.claude',
codex: '.agents',
copilot: '.github',
cursor: '.cursor',
gemini: '.gemini',
github: '.github',
kiro: '.kiro',
opencode: '.opencode',
pi: '.pi',
qoder: '.qoder',
'rovo-dev': '.rovodev',
rovodev: '.rovodev',
trae: '.trae',
'trae-cn': '.trae-cn',
};
// 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.
const GLOBAL_HARNESS_HINTS = [
{ home: '.claude', provider: '.claude' },
{ home: '.codex', provider: '.agents' },
{ home: '.cursor', provider: '.cursor' },
{ home: '.gemini', provider: '.gemini' },
{ home: '.kiro', provider: '.kiro' },
{ home: '.opencode', provider: '.opencode' },
{ home: '.qoder', provider: '.qoder' },
{ home: '.rovodev', provider: '.rovodev' },
];
// 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 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' },
],
};
function ask(question) {
const rl = createInterface({ input: process.stdin, output: process.stdout });
return new Promise(r => rl.question(question, ans => { rl.close(); r(ans.trim().toLowerCase()); }));
}
// ─── skills help ──────────────────────────────────────────────────────────────
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 skills install');
console.log(' Link: npx impeccable skills link --source=.impeccable');
console.log(' Update: npx impeccable skills 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 ─────────────────────────────────────────────────────────
/**
* Read the skills version from the impeccable SKILL.md frontmatter.
*/
function getSkillsVersion(root) {
for (const d of PROVIDER_DIRS) {
const skillMd = join(root, d, 'skills', 'impeccable', 'SKILL.md');
if (!existsSync(skillMd)) continue;
const content = readFileSync(skillMd, 'utf-8');
const match = content.match(/^version:\s*(.+)$/m);
if (match) return match[1].trim().replace(/^["']|["']$/g, '');
}
return null;
}
/**
* Hash all SKILL.md files in a directory tree for comparison.
* Returns a sorted string of "name:hash" pairs.
*/
function hashSkillsDir(skillsDir) {
if (!existsSync(skillsDir)) return '';
const entries = [];
for (const name of readdirSync(skillsDir).sort()) {
const skillMd = join(skillsDir, name, 'SKILL.md');
if (!existsSync(skillMd)) continue;
const hash = createHash('sha256').update(readFileSync(skillMd)).digest('hex').slice(0, 12);
entries.push(`${name}:${hash}`);
}
return entries.join(',');
}
/**
* 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 tmpZip = join(tmpdir(), `impeccable-update-${Date.now()}.zip`);
const tmpDir = join(tmpdir(), `impeccable-update-${Date.now()}`);
await downloadFile(`${API_BASE}/api/download/bundle/universal`, tmpZip);
mkdirSync(tmpDir, { recursive: true });
await extract(tmpZip, { dir: tmpDir });
rmSync(tmpZip, { force: true });
return tmpDir;
}
async function copyOrExtractLocalBundle(sourceValue) {
const source = resolve(sourceValue);
if (!existsSync(source)) {
throw new Error(`Local bundle not found: ${source}`);
}
const tmpDir = join(tmpdir(), `impeccable-local-bundle-${process.pid}-${Date.now()}`);
mkdirSync(tmpDir, { recursive: true });
if (statSync(source).isDirectory()) {
cpSync(source, tmpDir, { recursive: true });
return tmpDir;
}
await extract(source, { dir: tmpDir });
return tmpDir;
}
/**
* 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.
*/
function normalizeForHash(content) {
return content
.replace(/\.(claude|cursor|agents|github|gemini|codex|kiro|opencode|pi|qoder|trae|trae-cn|rovodev)\/skills\//g, '.PROVIDER/skills/')
.replace(/^version:\s*.+$/m, 'version: NORMALIZED');
}
/**
* 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) {
const seen = new Map(); // realPath -> { provider, localSkillsDir }
for (const provider of providers) {
const skillsDir = join(root, provider, 'skills');
if (!existsSync(skillsDir)) continue;
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). Normalizes provider-specific
* paths and version fields before comparing.
* Returns true if every bundle skill matches the local copy.
*/
function isUpToDate(root, providers, bundleDir) {
const unique = deduplicateProviders(root, providers);
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 bundleMd = join(bundleSkillsDir, name, 'SKILL.md');
const localMd = join(localSkillsDir, name, 'SKILL.md');
if (!existsSync(bundleMd)) continue;
if (!existsSync(localMd)) return false;
const bundleHash = createHash('sha256').update(normalizeForHash(readFileSync(bundleMd, 'utf-8'))).digest('hex');
const localHash = createHash('sha256').update(normalizeForHash(readFileSync(localMd, 'utf-8'))).digest('hex');
if (bundleHash !== localHash) 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 skills install` to install.');
process.exit(0);
}
const providers = findInstalledProviders(root);
console.log('Checking for updates...\n');
try {
const bundleDir = await downloadAndExtractBundle();
const upToDate = isUpToDate(root, providers, bundleDir);
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 skills 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) {
for (const d of PROVIDER_DIRS) {
const skillsDir = join(root, d, 'skills');
if (!existsSync(skillsDir)) continue;
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 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) {
let migrated = 0;
for (const d of PROVIDER_DIRS) {
const skillsDir = join(root, d, 'skills');
if (!existsSync(skillsDir)) continue;
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;
}
/**
* 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) {
const wanted = providersValue
.split(',')
.map(s => s.trim())
.filter(Boolean)
.map(normalizeProviderName)
.filter(Boolean);
return [...new Set(wanted)];
}
const inProject = PROVIDER_DIRS.filter(d => existsSync(join(root, d)));
if (inProject.length > 0) return inProject;
const home = homedir();
const inferred = [];
for (const { home: h, provider } of GLOBAL_HARNESS_HINTS) {
if (existsSync(join(home, h)) && !inferred.includes(provider)) inferred.push(provider);
}
if (inferred.length > 0) return inferred;
return [...DEFAULT_TARGETS];
}
/**
* 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.
*/
function copyProviderSkills(bundleDir, root, targets) {
let written = 0;
for (const provider of targets) {
const srcDir = join(bundleDir, provider, 'skills');
if (existsSync(srcDir)) {
const localSkillsDir = join(root, provider, 'skills');
// A previous `npx skills` install may have left this provider's skills dir
// as a symlink to another provider's canonical copy. Drop the link so we
// write a real, provider-specific directory instead of writing through it.
try {
if (lstatSync(localSkillsDir).isSymbolicLink()) 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++;
}
}
}
return written;
}
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 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') {
return IMPECCABLE_HOOK_COMMAND_MARKERS.some(marker => value.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;
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)) {
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 } = {}) {
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 fresh = readJsonFile(src, 'Bundled hook manifest');
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)];
}
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);
}
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 providersValue = getFlagValue(flags, '--providers');
const root = findProjectRoot();
const existing = isAlreadyInstalled(root);
if (existing && !force) {
console.log(`Impeccable skills are already installed (found in ${existing}/).`);
const targets = providersValue ? resolveInstallTargets(root, providersValue) : findInstalledProviders(root);
const missingHookTargets = installHooks
? targets.filter(provider => !hookInstalledForProvider(root, provider))
: [];
if (missingHookTargets.length > 0) {
let bundleDir;
try {
bundleDir = await downloadAndExtractBundle();
const hookTargets = copyProviderHooks(bundleDir, root, missingHookTargets);
if (hookTargets.length > 0) console.log(`Installed hooks into: ${hookTargets.join(', ')}`);
} catch (e) {
console.error(`Hook install failed: ${e.message}`);
process.exit(1);
} finally {
if (bundleDir) rmSync(bundleDir, { recursive: true, force: true });
}
}
console.log('Run with --force to reinstall.\n');
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.
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(`Target harness folder(s): ${targets.join(', ')}`);
const ans = await ask(`Install impeccable skills into ${targets.length} folder(s)? (Y/n) `);
if (ans === 'n' || ans === 'no') {
console.log('Aborted. Re-run with --providers=<dirs> to choose explicitly (e.g. --providers=.claude,.cursor).');
process.exit(0);
}
}
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(root);
let written = 0;
let hookTargets = [];
try {
written = copyProviderSkills(bundleDir, root, targets);
hookTargets = installHooks ? copyProviderHooks(bundleDir, root, targets, { force }) : [];
} 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(', ')}`);
if (hookTargets.length > 0) console.log(`Installed hooks into: ${hookTargets.join(', ')}`);
console.log('\nDone! Run /impeccable init in your AI harness 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) {
const found = [];
for (const d of PROVIDER_DIRS) {
const skillsDir = join(root, d, 'skills');
if (!existsSync(skillsDir)) continue;
try {
const entries = readdirSync(skillsDir);
if (entries.some(name => isSkillDir(skillsDir, name))) found.push(d);
} catch {}
}
return found;
}
function findLinkedProviders(root, providers) {
return providers.filter(provider => {
const skillDir = join(root, provider, 'skills', 'impeccable');
try {
return lstatSync(skillDir).isSymbolicLink();
} 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;
}
function downloadFile(url, dest) {
return new Promise((resolve, reject) => {
const file = createWriteStream(dest);
get(url, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
// Follow redirect
get(res.headers.location, (res2) => {
res2.pipe(file);
file.on('finish', () => { file.close(); resolve(); });
}).on('error', reject);
return;
}
if (res.statusCode !== 200) {
reject(new Error(`HTTP ${res.statusCode}`));
return;
}
res.pipe(file);
file.on('finish', () => { file.close(); resolve(); });
}).on('error', reject);
});
}
async function update(flags = []) {
const yes = flags.includes('-y') || flags.includes('--yes');
const force = flags.includes('--force');
const installHooks = !flags.includes('--no-hooks');
// 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 root = findProjectRoot();
const providers = findInstalledProviders(root);
const linkedProviders = findLinkedProviders(root, providers);
const copyProviders = providers.filter(provider => !linkedProviders.includes(provider));
if (providers.length === 0) {
console.log('No impeccable skill folders found in this project.');
console.log('Run `npx impeccable skills install` to install first.');
process.exit(1);
}
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 skills 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)) {
try {
const hookTargets = installHooks ? copyProviderHooks(tmpDir, root, copyProviders, { force }) : [];
rmSync(tmpDir, { recursive: true, force: true });
const v = getSkillsVersion(root);
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);
if (migrated > 0) console.log('Migrated a prefixed install back to /impeccable (the i- prefix is no longer used).');
// Copy from the bundle to each unique provider folder.
// Deduplicate so symlinked dirs (e.g. .claude/skills -> .agents/skills)
// are only written once with the correct provider's content.
const unique = deduplicateProviders(root, copyProviders);
let updated = 0;
for (const { provider, localSkillsDir } of unique) {
const srcDir = join(tmpDir, 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 });
copyDirSync(src, dest);
updated++;
}
}
const hookTargets = installHooks ? copyProviderHooks(tmpDir, root, providers, { force }) : [];
rmSync(tmpDir, { recursive: true, force: true });
const v = getSkillsVersion(root);
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 {
copyProviderHooks,
copyProviderSkills,
expectedHookDests,
linkProviderSkills,
mergeHookManifests,
migrateUnprefixImpeccable,
resolveInstallTargets,
resolveLinkSource,
};
// ─── Router ───────────────────────────────────────────────────────────────────
export async function run(args) {
const sub = args[0];
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 skills --help' for available commands.`);
process.exit(1);
}
}