Compare commits

...
Author SHA1 Message Date
github-actions[bot] b86f2cc353 Sync generated provider output 2026-06-17 02:51:20 +00:00
Paul BakausandGitHub 8b0c895703 [codex] Fix CLI skill update detection (#257)
* Fix CLI skill update detection

* Preserve linked skills during install refresh

* Keep existing installs working offline

* Respect provider scope during install refresh
2026-06-16 19:50:40 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1268f10b76 chore(deps): bump actions/cache from 4 to 5 in the github-actions group (#249)
Bumps the github-actions group with 1 update: [actions/cache](https://github.com/actions/cache).


Updates `actions/cache` from 4 to 5
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-16 06:56:23 -07:00
8 changed files with 275 additions and 71 deletions
+1
View File
@@ -1,6 +1,7 @@
--- ---
name: impeccable name: impeccable
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks. description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
version: 3.7.1
--- ---
Designs and iterates production-grade frontend interfaces. Real working code, committed design choices, exceptional craft. Designs and iterates production-grade frontend interfaces. Real working code, committed design choices, exceptional craft.
+8 -8
View File
@@ -164,7 +164,7 @@ jobs:
bun-version: latest bun-version: latest
- name: Cache fixture npm downloads - name: Cache fixture npm downloads
uses: actions/cache@v4 uses: actions/cache@v5
with: with:
path: ~/.npm path: ~/.npm
key: ${{ runner.os }}-fixture-npm-${{ hashFiles('tests/framework-fixtures/**/files/package.json') }} key: ${{ runner.os }}-fixture-npm-${{ hashFiles('tests/framework-fixtures/**/files/package.json') }}
@@ -172,7 +172,7 @@ jobs:
${{ runner.os }}-fixture-npm- ${{ runner.os }}-fixture-npm-
- name: Cache Playwright Chromium - name: Cache Playwright Chromium
uses: actions/cache@v4 uses: actions/cache@v5
with: with:
path: ~/.cache/ms-playwright path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('package.json', 'bun.lock') }} key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('package.json', 'bun.lock') }}
@@ -238,7 +238,7 @@ jobs:
bun-version: latest bun-version: latest
- name: Cache fixture npm downloads - name: Cache fixture npm downloads
uses: actions/cache@v4 uses: actions/cache@v5
with: with:
path: ~/.npm path: ~/.npm
key: ${{ runner.os }}-fixture-npm-${{ hashFiles('tests/framework-fixtures/**/files/package.json') }} key: ${{ runner.os }}-fixture-npm-${{ hashFiles('tests/framework-fixtures/**/files/package.json') }}
@@ -246,7 +246,7 @@ jobs:
${{ runner.os }}-fixture-npm- ${{ runner.os }}-fixture-npm-
- name: Cache Playwright Chromium - name: Cache Playwright Chromium
uses: actions/cache@v4 uses: actions/cache@v5
with: with:
path: ~/.cache/ms-playwright path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('package.json', 'bun.lock') }} key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('package.json', 'bun.lock') }}
@@ -307,7 +307,7 @@ jobs:
- name: Cache fixture npm downloads - name: Cache fixture npm downloads
if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }} if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }}
uses: actions/cache@v4 uses: actions/cache@v5
with: with:
path: ~/.npm path: ~/.npm
key: ${{ runner.os }}-fixture-npm-${{ hashFiles('tests/framework-fixtures/**/files/package.json') }} key: ${{ runner.os }}-fixture-npm-${{ hashFiles('tests/framework-fixtures/**/files/package.json') }}
@@ -316,7 +316,7 @@ jobs:
- name: Cache Playwright Chromium - name: Cache Playwright Chromium
if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }} if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }}
uses: actions/cache@v4 uses: actions/cache@v5
with: with:
path: ~/.cache/ms-playwright path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('package.json', 'bun.lock') }} key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('package.json', 'bun.lock') }}
@@ -371,7 +371,7 @@ jobs:
- name: Cache fixture npm downloads - name: Cache fixture npm downloads
if: ${{ env.DEEPSEEK_API_KEY != '' }} if: ${{ env.DEEPSEEK_API_KEY != '' }}
uses: actions/cache@v4 uses: actions/cache@v5
with: with:
path: ~/.npm path: ~/.npm
key: ${{ runner.os }}-fixture-npm-${{ hashFiles('tests/framework-fixtures/**/files/package.json') }} key: ${{ runner.os }}-fixture-npm-${{ hashFiles('tests/framework-fixtures/**/files/package.json') }}
@@ -380,7 +380,7 @@ jobs:
- name: Cache Playwright Chromium - name: Cache Playwright Chromium
if: ${{ env.DEEPSEEK_API_KEY != '' }} if: ${{ env.DEEPSEEK_API_KEY != '' }}
uses: actions/cache@v4 uses: actions/cache@v5
with: with:
path: ~/.cache/ms-playwright path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('package.json', 'bun.lock') }} key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('package.json', 'bun.lock') }}
+112 -58
View File
@@ -421,19 +421,20 @@ function getSkillsVersion(root) {
} }
/** /**
* Hash all SKILL.md files in a directory tree for comparison. * Return every file in a directory tree, sorted and relative to the tree root.
* Returns a sorted string of "name:hash" pairs.
*/ */
function hashSkillsDir(skillsDir) { function listSkillTreeFiles(root, dir = root) {
if (!existsSync(skillsDir)) return ''; if (!existsSync(dir)) return [];
const entries = []; const files = [];
for (const name of readdirSync(skillsDir).sort()) { for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
const skillMd = join(skillsDir, name, 'SKILL.md'); const full = join(dir, entry.name);
if (!existsSync(skillMd)) continue; if (entry.isDirectory()) {
const hash = createHash('sha256').update(readFileSync(skillMd)).digest('hex').slice(0, 12); files.push(...listSkillTreeFiles(root, full));
entries.push(`${name}:${hash}`); } 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 * provider-specific paths. Different install methods (npx skills add
* vs our bundle) resolve {{scripts_path}} to different provider dirs * vs our bundle) resolve {{scripts_path}} to different provider dirs
* (e.g. .agents vs .claude), so we strip those differences. * (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) { function normalizeForHash(content) {
return content return content
.replace(/\.(claude|cursor|agents|github|gemini|codex|kiro|opencode|pi|qoder|trae|trae-cn|rovodev)\/skills\//g, '.PROVIDER/skills/') .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'); }
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. * Compare local skills against a downloaded bundle.
* Only checks skills that exist in the bundle (ignores user's custom * Only checks skills that exist in the bundle (ignores user's custom skills
* skills that aren't part of impeccable). Deduplicates providers that * that aren't part of impeccable). Deduplicates providers that share the same
* share the same real path (symlinks). Normalizes provider-specific * real path (symlinks). Compares the full bundled skill tree, not just
* paths and version fields before comparing. * SKILL.md, so script-only fixes and removed files are detected.
* Returns true if every bundle skill matches the local copy. * Returns true if every bundle skill matches the local copy.
*/ */
function isUpToDate(root, providers, bundleDir) { function isUpToDate(root, providers, bundleDir) {
@@ -551,14 +559,21 @@ function isUpToDate(root, providers, bundleDir) {
if (!existsSync(bundleSkillsDir)) continue; if (!existsSync(bundleSkillsDir)) continue;
for (const name of readdirSync(bundleSkillsDir)) { for (const name of readdirSync(bundleSkillsDir)) {
const bundleMd = join(bundleSkillsDir, name, 'SKILL.md'); const bundleSkillDir = join(bundleSkillsDir, name);
const localMd = join(localSkillsDir, name, 'SKILL.md'); const localSkillDir = join(localSkillsDir, name);
const bundleMd = join(bundleSkillDir, 'SKILL.md');
if (!existsSync(bundleMd)) continue; 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 bundleFiles = listSkillTreeFiles(bundleSkillDir);
const localHash = createHash('sha256').update(normalizeForHash(readFileSync(localMd, 'utf-8'))).digest('hex'); const localFiles = listSkillTreeFiles(localSkillDir);
if (bundleHash !== localHash) return false; 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; return true;
@@ -1019,6 +1034,26 @@ function copyProviderSkills(bundleDir, root, targets) {
return written; 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) { function hookArtifactsForProvider(bundleDir, root, provider) {
return (PROVIDER_HOOK_ARTIFACTS[provider] || []).map(({ sourceProvider, rel, destProvider, destRel }) => { return (PROVIDER_HOOK_ARTIFACTS[provider] || []).map(({ sourceProvider, rel, destProvider, destRel }) => {
const writeRel = destRel || rel; const writeRel = destRel || rel;
@@ -1455,25 +1490,62 @@ async function install(flags) {
if (existing && !force) { if (existing && !force) {
console.log(`Impeccable skills are already installed (found in ${existing}/).`); console.log(`Impeccable skills are already installed (found in ${existing}/).`);
const installedTargets = findInstalledProviders(installRoot); 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 wantHooks = installHooks && await decideHookInstall(hookRoot, hookTargets, { yes });
const missingHookTargets = wantHooks let bundleDir;
? hookTargets.filter(provider => !hookInstalledForProvider(hookRoot, provider)) try {
: []; if (linkedTargets.length > 0) {
if (missingHookTargets.length > 0) { console.log(`Linked skills found in: ${linkedTargets.join(', ')}`);
let bundleDir; console.log('Update the source checkout with `git submodule update --remote`, then rerun `npx impeccable link --source=.impeccable` if new skills are added.');
try { if (copyTargets.length > 0) console.log(`Continuing with copied installs in: ${copyTargets.join(', ')}\n`);
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 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); process.exit(0);
} }
@@ -1675,25 +1747,7 @@ async function update(flags = []) {
const migrated = migrateUnprefixImpeccable(root); const migrated = migrateUnprefixImpeccable(root);
if (migrated > 0) console.log('Migrated a prefixed install back to /impeccable (the i- prefix is no longer used).'); 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. const updated = refreshProviderSkills(tmpDir, root, copyProviders);
// 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 wantHooks = installHooks && await decideHookInstall(root, providers, { yes }); const wantHooks = installHooks && await decideHookInstall(root, providers, { yes });
const hookTargets = wantHooks ? copyProviderHooks(tmpDir, root, providers, { force }) : []; const hookTargets = wantHooks ? copyProviderHooks(tmpDir, root, providers, { force }) : [];
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "impeccable", "name": "impeccable",
"version": "3.0.2", "version": "3.0.3",
"author": "Paul Bakaus", "author": "Paul Bakaus",
"description": "Design skills, commands, and anti-pattern detection for AI coding agents", "description": "Design skills, commands, and anti-pattern detection for AI coding agents",
"keywords": [ "keywords": [
-2
View File
@@ -44,7 +44,6 @@ export const PROVIDERS = {
configDir: '.codex', configDir: '.codex',
displayName: 'Codex', displayName: 'Codex',
frontmatterFields: [], frontmatterFields: [],
includeVersion: false,
writeOpenAIMetadata: true, writeOpenAIMetadata: true,
// No agentFormat: the Codex subagent ships nested inside the skill's own // No agentFormat: the Codex subagent ships nested inside the skill's own
// agents/ folder (see CODEX_SKILL_PROVIDERS in factory.js), which Codex // agents/ folder (see CODEX_SKILL_PROVIDERS in factory.js), which Codex
@@ -60,7 +59,6 @@ export const PROVIDERS = {
displayName: 'Codex Repo Skills', displayName: 'Codex Repo Skills',
placeholderProvider: 'codex', placeholderProvider: 'codex',
frontmatterFields: [], frontmatterFields: [],
includeVersion: false,
writeOpenAIMetadata: true, writeOpenAIMetadata: true,
}, },
github: { github: {
+8
View File
@@ -103,6 +103,14 @@ import '../styles/changelog-faq-kinpaku.css';
</ul> </ul>
</article> </article>
<article id="cli-v3.0.3" class="cf-entry">
<header class="cf-entry-head"><span class="cf-version">CLI v3.0.3</span><span class="cf-date">June 17, 2026</span></header>
<ul class="cf-items">
<li><strong>Existing installs now pick up script-only skill fixes.</strong> <code>impeccable update</code> compares the complete bundled skill tree instead of just <code>SKILL.md</code>, so fixes in bundled scripts are detected. Running <code>impeccable install</code> on an already-installed project also refreshes stale skill files instead of stopping at "already installed."</li>
<li><strong>Codex installs report the skill version.</strong> The <code>.agents</code> and <code>.codex</code> builds now include the same <code>version</code> frontmatter as the other harnesses, so check/update output can show the installed skill version consistently.</li>
</ul>
</article>
<article id="cli-v3.0.2" class="cf-entry"> <article id="cli-v3.0.2" class="cf-entry">
<header class="cf-entry-head"><span class="cf-version">CLI v3.0.2</span><span class="cf-date">June 16, 2026</span></header> <header class="cf-entry-head"><span class="cf-version">CLI v3.0.2</span><span class="cf-date">June 16, 2026</span></header>
<ul class="cf-items"> <ul class="cf-items">
+7
View File
@@ -65,6 +65,13 @@ for (const [key, config] of Object.entries(PROVIDERS)) {
expect(fs.existsSync(refPath)).toBe(true); 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 // Field-specific tests based on provider config
if (config.frontmatterFields.includes('user-invocable')) { if (config.frontmatterFields.includes('user-invocable')) {
test('should emit user-invocable for user-invocable skills', () => { test('should emit user-invocable for user-invocable skills', () => {
+138 -2
View File
@@ -139,6 +139,7 @@ describe('skills install: already-installed detection', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-')); const tmp = mkdtempSync(join(tmpdir(), 'imp-test-'));
execSync('git init', { cwd: tmp }); execSync('git init', { cwd: tmp });
createFakeSkills(tmp); createFakeSkills(tmp);
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude']);
// Seed the canonical hook target so the already-installed path sees the hook // 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). // wired up and doesn't try to repair it (which would need the bundle).
writeFileSync(join(tmp, '.claude', 'settings.local.json'), JSON.stringify({ 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'); expect(output).toContain('already installed');
rmSync(tmp, { recursive: true, force: true }); rmSync(tmp, { recursive: true, force: true });
}, 15000); }, 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', () => { test('detects prefixed i-impeccable', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-')); const tmp = mkdtempSync(join(tmpdir(), 'imp-test-'));
execSync('git init', { cwd: tmp }); execSync('git init', { cwd: tmp });
@@ -160,6 +187,7 @@ describe('skills install: already-installed detection', () => {
const skillDir = join(tmp, '.cursor', 'skills', 'i-impeccable'); const skillDir = join(tmp, '.cursor', 'skills', 'i-impeccable');
mkdirSync(skillDir, { recursive: true }); mkdirSync(skillDir, { recursive: true });
writeFileSync(join(skillDir, 'SKILL.md'), '---\nname: i-impeccable\n---\n'); 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 // Seed the hook so the already-installed path sees it wired up and doesn't
// try to repair it (which would need the bundle). // try to repair it (which would need the bundle).
writeFileSync(join(tmp, '.cursor', 'hooks.json'), JSON.stringify({ 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"' }] }, 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'); expect(output).toContain('already installed');
rmSync(tmp, { recursive: true, force: true }); rmSync(tmp, { recursive: true, force: true });
@@ -343,6 +374,36 @@ describe('skills link: submodule installs', () => {
rmSync(tmp, { recursive: true, force: true }); rmSync(tmp, { recursive: true, force: true });
}, 15000); }, 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', () => { test('deduplicates providers that share one skills directory', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-link-shared-')); const tmp = mkdtempSync(join(tmpdir(), 'imp-test-link-shared-'));
execSync('git init', { cwd: tmp }); execSync('git init', { cwd: tmp });
@@ -869,6 +930,81 @@ describe('skills install/update: local universal bundle e2e', () => {
rmSync(tmp, { recursive: true, force: true }); rmSync(tmp, { recursive: true, force: true });
}, 15000); }, 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', () => { test('skills update --no-hooks refreshes skills without touching malformed hook manifests', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-update-no-hooks-')); const tmp = mkdtempSync(join(tmpdir(), 'imp-test-update-no-hooks-'));
execSync('git init', { cwd: tmp }); execSync('git init', { cwd: tmp });