mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-19 17:46:36 +03:00
[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
This commit is contained in:
+112
-58
@@ -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
@@ -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": [
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user