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';
+
+
+
+ - Existing installs now pick up script-only skill fixes.
impeccable update compares the complete bundled skill tree instead of just SKILL.md, so fixes in bundled scripts are detected. Running impeccable install on an already-installed project also refreshes stale skill files instead of stopping at "already installed."
+ - Codex installs report the skill version. The
.agents and .codex builds now include the same version frontmatter as the other harnesses, so check/update output can show the installed skill version consistently.
+
+
+
diff --git a/tests/lib/transformers/providers.test.js b/tests/lib/transformers/providers.test.js
index 684d5874c..3906de04d 100644
--- a/tests/lib/transformers/providers.test.js
+++ b/tests/lib/transformers/providers.test.js
@@ -65,6 +65,13 @@ for (const [key, config] of Object.entries(PROVIDERS)) {
expect(fs.existsSync(refPath)).toBe(true);
});
+ test('should emit skillsVersion in generated skill frontmatter', () => {
+ const skills = [{ name: 'test', description: 'Test', body: 'Body' }];
+ transform(skills, TEST_DIR, { skillsVersion: '1.2.3-test' });
+ const parsed = parseFrontmatter(fs.readFileSync(skillPath(config, 'test'), 'utf-8'));
+ expect(parsed.frontmatter.version).toBe('1.2.3-test');
+ });
+
// Field-specific tests based on provider config
if (config.frontmatterFields.includes('user-invocable')) {
test('should emit user-invocable for user-invocable skills', () => {
diff --git a/tests/skills-cli.test.js b/tests/skills-cli.test.js
index a898d380c..e412e3247 100644
--- a/tests/skills-cli.test.js
+++ b/tests/skills-cli.test.js
@@ -139,6 +139,7 @@ describe('skills install: already-installed detection', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-'));
execSync('git init', { cwd: tmp });
createFakeSkills(tmp);
+ const bundleRoot = createFakeUniversalBundle(tmp, ['.claude']);
// Seed the canonical hook target so the already-installed path sees the hook
// wired up and doesn't try to repair it (which would need the bundle).
writeFileSync(join(tmp, '.claude', 'settings.local.json'), JSON.stringify({
@@ -147,12 +148,38 @@ describe('skills install: already-installed detection', () => {
] }] },
}));
- const output = run('skills install -y', { cwd: tmp });
+ const output = run('skills install -y', {
+ cwd: tmp,
+ env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot },
+ });
expect(output).toContain('already installed');
rmSync(tmp, { recursive: true, force: true });
}, 15000);
+ test('already-installed projects keep working when the update check is offline', () => {
+ const tmp = mkdtempSync(join(tmpdir(), 'imp-test-offline-installed-'));
+ execSync('git init', { cwd: tmp });
+ createFakeSkills(tmp, ['impeccable'], ['.claude']);
+ writeFileSync(join(tmp, '.claude', 'settings.local.json'), JSON.stringify({
+ hooks: { PostToolUse: [{ matcher: 'Edit|Write|MultiEdit', hooks: [
+ { type: 'command', command: 'node ".claude/skills/impeccable/scripts/hook.mjs"' },
+ ] }] },
+ }));
+
+ const output = run('skills install -y --providers=claude', {
+ cwd: tmp,
+ env: { ...process.env, IMPECCABLE_BUNDLE_PATH: join(tmp, 'missing-bundle') },
+ });
+
+ expect(output).toContain('already installed');
+ expect(output).toContain('Could not check for skill updates');
+ expect(output).toContain('Existing skills were left unchanged.');
+ expect(existsSync(join(tmp, '.claude', 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
+
+ rmSync(tmp, { recursive: true, force: true });
+ }, 15000);
+
test('detects prefixed i-impeccable', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-'));
execSync('git init', { cwd: tmp });
@@ -160,6 +187,7 @@ describe('skills install: already-installed detection', () => {
const skillDir = join(tmp, '.cursor', 'skills', 'i-impeccable');
mkdirSync(skillDir, { recursive: true });
writeFileSync(join(skillDir, 'SKILL.md'), '---\nname: i-impeccable\n---\n');
+ const bundleRoot = createFakeUniversalBundle(tmp, ['.cursor']);
// Seed the hook so the already-installed path sees it wired up and doesn't
// try to repair it (which would need the bundle).
writeFileSync(join(tmp, '.cursor', 'hooks.json'), JSON.stringify({
@@ -167,7 +195,10 @@ describe('skills install: already-installed detection', () => {
hooks: { preToolUse: [{ command: 'node ".cursor/skills/impeccable/scripts/hook-before-edit.mjs"' }] },
}));
- const output = run('skills install -y', { cwd: tmp });
+ const output = run('skills install -y', {
+ cwd: tmp,
+ env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot },
+ });
expect(output).toContain('already installed');
rmSync(tmp, { recursive: true, force: true });
@@ -343,6 +374,36 @@ describe('skills link: submodule installs', () => {
rmSync(tmp, { recursive: true, force: true });
}, 15000);
+ test('plain install leaves linked installs on the submodule path', () => {
+ const tmp = mkdtempSync(join(tmpdir(), 'imp-test-link-install-'));
+ execSync('git init', { cwd: tmp });
+ createFakeLinkSource(tmp);
+ const bundleRoot = createFakeUniversalBundle(tmp, ['.claude', '.cursor']);
+ run('skills link --source=.impeccable --providers=claude -y', { cwd: tmp });
+
+ const linkedDest = join(tmp, '.claude', 'skills', 'impeccable');
+ const before = readlinkSync(linkedDest);
+ const copiedDest = join(tmp, '.cursor', 'skills', 'impeccable');
+ mkdirSync(join(copiedDest, 'scripts'), { recursive: true });
+ writeFileSync(join(copiedDest, 'SKILL.md'), '---\nname: impeccable\nstale: true\n---\nOld content.\n');
+ writeFileSync(join(copiedDest, 'scripts', 'context.mjs'), 'console.log("old broken script");\n');
+
+ const output = run('skills install -y --providers=claude,cursor --no-hooks', {
+ cwd: tmp,
+ env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot },
+ });
+
+ expect(output).toContain('Linked skills found in: .claude');
+ expect(output).toContain('Continuing with copied installs in: .cursor');
+ expect(output).toContain('Updated');
+ expect(readlinkSync(linkedDest)).toBe(before);
+ expect(lstatSync(linkedDest).isSymbolicLink()).toBe(true);
+ expect(readFileSync(join(copiedDest, 'SKILL.md'), 'utf8')).toContain('version: 9.9.9-local');
+ expect(readFileSync(join(copiedDest, 'scripts', 'context.mjs'), 'utf8')).toBe('console.log("local bundle context");\n');
+
+ rmSync(tmp, { recursive: true, force: true });
+ }, 15000);
+
test('deduplicates providers that share one skills directory', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-link-shared-'));
execSync('git init', { cwd: tmp });
@@ -869,6 +930,81 @@ describe('skills install/update: local universal bundle e2e', () => {
rmSync(tmp, { recursive: true, force: true });
}, 15000);
+ test('skills update refreshes script-only bundle changes when SKILL.md is unchanged', () => {
+ const tmp = mkdtempSync(join(tmpdir(), 'imp-test-script-only-update-'));
+ execSync('git init', { cwd: tmp });
+ const bundleRoot = createFakeUniversalBundle(tmp, ['.claude']);
+
+ run('skills install -y --providers=claude --no-hooks', {
+ cwd: tmp,
+ env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot },
+ });
+
+ const scriptPath = join(tmp, '.claude', 'skills', 'impeccable', 'scripts', 'context.mjs');
+ writeFileSync(scriptPath, 'console.log("old broken script");\n');
+
+ const output = run('skills update -y --no-hooks', {
+ cwd: tmp,
+ env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot },
+ });
+
+ expect(output).toContain('Updated');
+ expect(readFileSync(scriptPath, 'utf8')).toBe('console.log("local bundle context");\n');
+
+ rmSync(tmp, { recursive: true, force: true });
+ }, 15000);
+
+ test('plain install refreshes an already-installed stale skill', () => {
+ const tmp = mkdtempSync(join(tmpdir(), 'imp-test-existing-install-refresh-'));
+ execSync('git init', { cwd: tmp });
+ const bundleRoot = createFakeUniversalBundle(tmp, ['.claude']);
+
+ const skillDir = join(tmp, '.claude', 'skills', 'impeccable');
+ mkdirSync(join(skillDir, 'scripts'), { recursive: true });
+ writeFileSync(
+ join(skillDir, 'SKILL.md'),
+ readFileSync(join(bundleRoot, '.claude', 'skills', 'impeccable', 'SKILL.md'), 'utf8')
+ );
+ writeFileSync(join(skillDir, 'scripts', 'context.mjs'), 'console.log("old broken script");\n');
+
+ const output = run('skills install -y --providers=claude --no-hooks', {
+ cwd: tmp,
+ env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot },
+ });
+
+ expect(output).toContain('already installed');
+ expect(output).toContain('Updated');
+ expect(readFileSync(join(skillDir, 'scripts', 'context.mjs'), 'utf8')).toBe('console.log("local bundle context");\n');
+
+ rmSync(tmp, { recursive: true, force: true });
+ }, 15000);
+
+ test('plain install only refreshes selected copied providers', () => {
+ const tmp = mkdtempSync(join(tmpdir(), 'imp-test-existing-install-scope-'));
+ execSync('git init', { cwd: tmp });
+ const bundleRoot = createFakeUniversalBundle(tmp, ['.claude', '.cursor']);
+
+ for (const provider of ['.claude', '.cursor']) {
+ const skillDir = join(tmp, provider, 'skills', 'impeccable');
+ mkdirSync(join(skillDir, 'scripts'), { recursive: true });
+ writeFileSync(join(skillDir, 'SKILL.md'), `---\nname: impeccable\nstale: ${provider}\n---\nOld content.\n`);
+ writeFileSync(join(skillDir, 'scripts', 'context.mjs'), `console.log("old ${provider} script");\n`);
+ }
+
+ const output = run('skills install -y --providers=claude --no-hooks', {
+ cwd: tmp,
+ env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot },
+ });
+
+ expect(output).toContain('already installed');
+ expect(output).toContain('Updated');
+ expect(readFileSync(join(tmp, '.claude', 'skills', 'impeccable', 'SKILL.md'), 'utf8')).toContain('version: 9.9.9-local');
+ expect(readFileSync(join(tmp, '.cursor', 'skills', 'impeccable', 'SKILL.md'), 'utf8')).toContain('stale: .cursor');
+ expect(readFileSync(join(tmp, '.cursor', 'skills', 'impeccable', 'scripts', 'context.mjs'), 'utf8')).toBe('console.log("old .cursor script");\n');
+
+ rmSync(tmp, { recursive: true, force: true });
+ }, 15000);
+
test('skills update --no-hooks refreshes skills without touching malformed hook manifests', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-update-no-hooks-'));
execSync('git init', { cwd: tmp });