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
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.
+8 -8
View File
@@ -164,7 +164,7 @@ jobs:
bun-version: latest
- name: Cache fixture npm downloads
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/.npm
key: ${{ runner.os }}-fixture-npm-${{ hashFiles('tests/framework-fixtures/**/files/package.json') }}
@@ -172,7 +172,7 @@ jobs:
${{ runner.os }}-fixture-npm-
- name: Cache Playwright Chromium
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('package.json', 'bun.lock') }}
@@ -238,7 +238,7 @@ jobs:
bun-version: latest
- name: Cache fixture npm downloads
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/.npm
key: ${{ runner.os }}-fixture-npm-${{ hashFiles('tests/framework-fixtures/**/files/package.json') }}
@@ -246,7 +246,7 @@ jobs:
${{ runner.os }}-fixture-npm-
- name: Cache Playwright Chromium
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('package.json', 'bun.lock') }}
@@ -307,7 +307,7 @@ jobs:
- name: Cache fixture npm downloads
if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }}
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/.npm
key: ${{ runner.os }}-fixture-npm-${{ hashFiles('tests/framework-fixtures/**/files/package.json') }}
@@ -316,7 +316,7 @@ jobs:
- name: Cache Playwright Chromium
if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }}
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('package.json', 'bun.lock') }}
@@ -371,7 +371,7 @@ jobs:
- name: Cache fixture npm downloads
if: ${{ env.DEEPSEEK_API_KEY != '' }}
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/.npm
key: ${{ runner.os }}-fixture-npm-${{ hashFiles('tests/framework-fixtures/**/files/package.json') }}
@@ -380,7 +380,7 @@ jobs:
- name: Cache Playwright Chromium
if: ${{ env.DEEPSEEK_API_KEY != '' }}
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/.cache/ms-playwright
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.
* 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 }) : [];
+1 -1
View File
@@ -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": [
-2
View File
@@ -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: {
+8
View File
@@ -103,6 +103,14 @@ import '../styles/changelog-faq-kinpaku.css';
</ul>
</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">
<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">
+7
View File
@@ -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', () => {
+138 -2
View File
@@ -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 });