diff --git a/cli/bin/commands/skills.mjs b/cli/bin/commands/skills.mjs index 7bb1558f9..e1e20f137 100644 --- a/cli/bin/commands/skills.mjs +++ b/cli/bin/commands/skills.mjs @@ -421,19 +421,20 @@ function getSkillsVersion(root) { } /** - * Hash all SKILL.md files in a directory tree for comparison. - * Returns a sorted string of "name:hash" pairs. + * Return every file in a directory tree, sorted and relative to the tree root. */ -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}`); +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 entries.join(','); + return files; } /** @@ -507,11 +508,18 @@ async function copyOrExtractLocalBundle(sourceValue) { * 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|github|gemini|codex|kiro|opencode|pi|qoder|trae|trae-cn|rovodev)\/skills\//g, '.PROVIDER/skills/') - .replace(/^version:\s*.+$/m, 'version: NORMALIZED'); + .replace(/\.(claude|cursor|agents|github|gemini|codex|kiro|opencode|pi|qoder|trae|trae-cn|rovodev)\/skills\//g, '.PROVIDER/skills/'); +} + +function hashSkillFile(filePath) { + return createHash('sha256') + .update(normalizeForHash(readFileSync(filePath, 'utf-8'))) + .digest('hex'); } /** @@ -536,10 +544,10 @@ function deduplicateProviders(root, providers) { /** * 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. + * 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) { @@ -551,14 +559,21 @@ function isUpToDate(root, providers, bundleDir) { if (!existsSync(bundleSkillsDir)) continue; for (const name of readdirSync(bundleSkillsDir)) { - const bundleMd = join(bundleSkillsDir, name, 'SKILL.md'); - const localMd = join(localSkillsDir, name, 'SKILL.md'); + const bundleSkillDir = join(bundleSkillsDir, name); + const localSkillDir = join(localSkillsDir, name); + const bundleMd = join(bundleSkillDir, 'SKILL.md'); if (!existsSync(bundleMd)) continue; - if (!existsSync(localMd)) return false; + if (!existsSync(localSkillDir)) 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; + 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; + } } } return true; @@ -1019,6 +1034,26 @@ function copyProviderSkills(bundleDir, root, targets) { return written; } +function refreshProviderSkills(bundleDir, root, providers) { + const unique = deduplicateProviders(root, providers); + 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; @@ -1455,25 +1490,62 @@ async function install(flags) { if (existing && !force) { console.log(`Impeccable skills are already installed (found in ${existing}/).`); const installedTargets = findInstalledProviders(installRoot); - const hookTargets = targets.filter(provider => installedTargets.includes(provider)); + const selectedInstalledTargets = targets.filter(provider => installedTargets.includes(provider)); + const linkedTargets = findLinkedProviders(installRoot, selectedInstalledTargets); + const copyTargets = selectedInstalledTargets.filter(provider => !linkedTargets.includes(provider)); + const hookTargets = selectedInstalledTargets; const wantHooks = installHooks && await decideHookInstall(hookRoot, hookTargets, { yes }); - const missingHookTargets = wantHooks - ? hookTargets.filter(provider => !hookInstalledForProvider(hookRoot, provider)) - : []; - if (missingHookTargets.length > 0) { - let bundleDir; - try { - bundleDir = await downloadAndExtractBundle(); - const writtenHookTargets = copyProviderHooks(bundleDir, hookRoot, missingHookTargets, { skillRoot: installRoot }); - if (writtenHookTargets.length > 0) console.log(`Installed hooks into: ${writtenHookTargets.join(', ')}`); - } catch (e) { - console.error(`Hook install failed: ${e.message}`); - process.exit(1); - } finally { - if (bundleDir) rmSync(bundleDir, { recursive: true, force: true }); + 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) { + try { + bundleDir = await downloadAndExtractBundle(); + } catch (e) { + if (missingHookTargets.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)) { + migrateUnprefixImpeccable(installRoot); + updated = refreshProviderSkills(bundleDir, installRoot, copyTargets); + const v = getSkillsVersion(installRoot); + console.log(`Updated ${updated} skill(s)${v ? ` to v${v}` : ''}.`); + } + + 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) { + const v = getSkillsVersion(installRoot); + 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 }); } - console.log('Run with --force to reinstall.\n'); process.exit(0); } @@ -1675,25 +1747,7 @@ async function update(flags = []) { 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 updated = refreshProviderSkills(tmpDir, root, copyProviders); const wantHooks = installHooks && await decideHookInstall(root, providers, { yes }); const hookTargets = wantHooks ? copyProviderHooks(tmpDir, root, providers, { force }) : []; diff --git a/package.json b/package.json index 3b260d324..c5c65de3c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impeccable", - "version": "3.0.2", + "version": "3.0.3", "author": "Paul Bakaus", "description": "Design skills, commands, and anti-pattern detection for AI coding agents", "keywords": [ diff --git a/scripts/lib/transformers/providers.js b/scripts/lib/transformers/providers.js index 6c26cbafa..cd42c4f72 100644 --- a/scripts/lib/transformers/providers.js +++ b/scripts/lib/transformers/providers.js @@ -44,7 +44,6 @@ export const PROVIDERS = { configDir: '.codex', displayName: 'Codex', frontmatterFields: [], - includeVersion: false, writeOpenAIMetadata: true, // No agentFormat: the Codex subagent ships nested inside the skill's own // agents/ folder (see CODEX_SKILL_PROVIDERS in factory.js), which Codex @@ -60,7 +59,6 @@ export const PROVIDERS = { displayName: 'Codex Repo Skills', placeholderProvider: 'codex', frontmatterFields: [], - includeVersion: false, writeOpenAIMetadata: true, }, github: { diff --git a/site/pages/changelog.astro b/site/pages/changelog.astro index 7bf3a04fc..015087239 100644 --- a/site/pages/changelog.astro +++ b/site/pages/changelog.astro @@ -103,6 +103,14 @@ import '../styles/changelog-faq-kinpaku.css'; +
+
CLI v3.0.3June 17, 2026
+ +
+
CLI v3.0.2June 16, 2026