diff --git a/cli/bin/commands/skills.mjs b/cli/bin/commands/skills.mjs index d234d93c1..671b4ee5a 100644 --- a/cli/bin/commands/skills.mjs +++ b/cli/bin/commands/skills.mjs @@ -8,7 +8,7 @@ */ import { execSync } from 'node:child_process'; -import { existsSync, readFileSync, readdirSync, statSync, lstatSync, symlinkSync, readlinkSync, unlinkSync, mkdirSync, writeFileSync, rmSync, renameSync, createWriteStream, realpathSync } from 'node:fs'; +import { existsSync, readFileSync, readdirSync, statSync, lstatSync, unlinkSync, mkdirSync, writeFileSync, rmSync, renameSync, createWriteStream, realpathSync } from 'node:fs'; import { join, resolve, dirname } from 'node:path'; import { createInterface } from 'node:readline'; import { fileURLToPath } from 'node:url'; @@ -237,31 +237,6 @@ function isAlreadyInstalled(root) { return null; } -function escapeRegex(str) { - return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -function prefixSkillContent(content, prefix, allSkillNames) { - // Prefix the name in frontmatter - let result = content.replace(/^name:\s*(.+)$/m, (_, name) => `name: ${prefix}${name.trim()}`); - - // Prefix cross-references: /skillname -> /prefix-skillname - const sorted = [...allSkillNames].sort((a, b) => b.length - a.length); - for (const name of sorted) { - // Command invocations: /skillname - result = result.replace( - new RegExp(`/(?=${escapeRegex(name)}(?:[^a-zA-Z0-9_-]|$))`, 'g'), - `/${prefix}` - ); - // Prose references: "the skillname skill" - result = result.replace( - new RegExp(`(the) ${escapeRegex(name)} skill`, 'gi'), - (_, article) => `${article} ${prefix}${name} skill` - ); - } - return result; -} - function isSkillDir(skillsDir, name) { // Skill entries can be real directories or symlinks to directories (npx skills uses symlinks) const full = join(skillsDir, name); @@ -279,63 +254,40 @@ function isRealSkillDir(skillsDir, name) { } catch { return false; } } -function renameSkillsWithPrefix(root, prefix) { - // First pass: collect all skill names across all providers (use first provider found) - let allSkillNames = []; +/** + * One-way migration for installs from the era when the CLI offered a command + * prefix (default `i-`), renaming the skill to e.g. `i-impeccable`. The prefix + * only earned its keep when every command was its own skill; with a single + * `impeccable` skill it does nothing, so it is no longer offered. Rename any + * prefixed impeccable skill back to the canonical `impeccable` (the fresh + * install/update content lands there next) so users aren't left with a stale, + * orphaned `i-impeccable` alongside the new one. Scoped to the impeccable skill + * by name -- never touches third-party skills that happen to start with `i-`. + * Returns the number of skills migrated. + */ +function migrateUnprefixImpeccable(root) { + let migrated = 0; for (const d of PROVIDER_DIRS) { const skillsDir = join(root, d, 'skills'); if (!existsSync(skillsDir)) continue; - const entries = readdirSync(skillsDir); - allSkillNames = entries.filter(name => isSkillDir(skillsDir, name)); - if (allSkillNames.length > 0) break; + let entries; + try { entries = readdirSync(skillsDir); } catch { continue; } + for (const name of entries) { + // A prefixed impeccable skill is `impeccable` -- not the canonical + // `impeccable`, and not the legacy `teach-impeccable` (cleanup handles that). + if (name === 'impeccable' || name === 'teach-impeccable') continue; + if (!name.endsWith('-impeccable')) continue; + if (!isRealSkillDir(skillsDir, name)) continue; + + const dest = join(skillsDir, 'impeccable'); + try { + rmSync(dest, { recursive: true, force: true }); + renameSync(join(skillsDir, name), dest); + migrated++; + } catch {} + } } - - // Second pass: rename real dirs and update their content - let count = 0; - for (const d of PROVIDER_DIRS) { - const skillsDir = join(root, d, 'skills'); - if (!existsSync(skillsDir)) continue; - try { - const entries = readdirSync(skillsDir); - for (const name of entries) { - if (name.startsWith(prefix)) continue; - if (!isRealSkillDir(skillsDir, name)) continue; - - const src = join(skillsDir, name); - const dest = join(skillsDir, prefix + name); - - renameSync(src, dest); - - // Prefix frontmatter name + all cross-references in SKILL.md - let content = readFileSync(join(dest, 'SKILL.md'), 'utf8'); - content = prefixSkillContent(content, prefix, allSkillNames); - writeFileSync(join(dest, 'SKILL.md'), content); - count++; - } - } catch {} - } - - // Third pass: fix symlinks that now point to renamed targets (npx skills uses these) - for (const d of PROVIDER_DIRS) { - const skillsDir = join(root, d, 'skills'); - if (!existsSync(skillsDir)) continue; - try { - const entries = readdirSync(skillsDir); - for (const name of entries) { - if (name.startsWith(prefix)) continue; - const full = join(skillsDir, name); - try { - if (!lstatSync(full).isSymbolicLink()) continue; - const target = readlinkSync(full); - const newTarget = target.replace(new RegExp(`/${escapeRegex(name)}$`), `/${prefix}${name}`); - unlinkSync(full); - symlinkSync(newTarget, join(skillsDir, prefix + name)); - } catch {} - } - } catch {} - } - - return count; + return migrated; } /** @@ -401,7 +353,6 @@ function copyProviderSkills(bundleDir, root, targets) { async function install(flags) { const force = flags.includes('--force'); const yes = flags.includes('-y') || flags.includes('--yes'); - const prefixFlag = flags.find(f => f.startsWith('--prefix=')); const providersFlag = flags.find(f => f.startsWith('--providers=')); const root = findProjectRoot(); const existing = isAlreadyInstalled(root); @@ -442,6 +393,10 @@ async function install(flags) { process.exit(1); } + // Retire any old `i-`-prefixed install so the fresh copy lands on the + // canonical `impeccable` dir instead of orphaning the prefixed one. + migrateUnprefixImpeccable(root); + let written = 0; try { written = copyProviderSkills(bundleDir, root, targets); @@ -458,27 +413,6 @@ async function install(flags) { } console.log(`Installed impeccable into: ${targets.join(', ')}`); - // Ask about prefixing (skip in CI mode unless --prefix= is set) - let prefix = ''; - if (prefixFlag) { - prefix = prefixFlag.split('=')[1] || 'i-'; - } else if (!yes) { - console.log(); - const wantPrefix = await ask('Prefix commands to avoid conflicts? e.g. /i-audit instead of /audit (y/N) '); - if (wantPrefix === 'y' || wantPrefix === 'yes') { - const custom = await ask('Prefix (default: i-): '); - prefix = custom || 'i-'; - } - } - - if (prefix) { - const count = renameSkillsWithPrefix(root, prefix); - if (count > 0) { - console.log(`\nRenamed ${count} skills with "${prefix}" prefix.`); - console.log(`Commands are now available as /${prefix} (e.g. /${prefix}audit).`); - } - } - // Clean up deprecated skills from previous versions try { const { cleanup } = await import('../../../skill/scripts/cleanup-deprecated.mjs'); @@ -491,71 +425,7 @@ async function install(flags) { // Cleanup script not available -- skip } - console.log(`\nDone! Run /${prefix}impeccable init in your AI harness to set up design context.\n`); -} - -/** Detect prefix by looking for the 'impeccable' skill (or legacy 'teach-impeccable') */ -function detectPrefix(root) { - for (const d of PROVIDER_DIRS) { - const skillsDir = join(root, d, 'skills'); - if (!existsSync(skillsDir)) continue; - for (const name of readdirSync(skillsDir)) { - if (name === 'impeccable') return ''; - if (name.endsWith('-impeccable') && name !== 'teach-impeccable') return name.slice(0, -'impeccable'.length); - // Legacy fallback - if (name === 'teach-impeccable') return ''; - if (name.endsWith('-teach-impeccable')) return name.slice(0, -'teach-impeccable'.length); - } - } - return ''; -} - -/** Undo prefixing: rename folders back and strip prefix from SKILL.md content */ -function undoPrefix(root, prefix) { - if (!prefix) return; - // Collect the unprefixed names (strip our prefix) - let allPrefixedNames = []; - for (const d of PROVIDER_DIRS) { - const skillsDir = join(root, d, 'skills'); - if (!existsSync(skillsDir)) continue; - allPrefixedNames = readdirSync(skillsDir).filter(n => n.startsWith(prefix) && isRealSkillDir(skillsDir, n)); - if (allPrefixedNames.length > 0) break; - } - const unprefixedNames = allPrefixedNames.map(n => n.slice(prefix.length)); - - for (const d of PROVIDER_DIRS) { - const skillsDir = join(root, d, 'skills'); - if (!existsSync(skillsDir)) continue; - for (const name of readdirSync(skillsDir)) { - if (!name.startsWith(prefix)) continue; - const unprefixed = name.slice(prefix.length); - const src = join(skillsDir, name); - const dest = join(skillsDir, unprefixed); - - if (lstatSync(src).isSymbolicLink()) { - const target = readlinkSync(src); - const newTarget = target.replace(`/${name}`, `/${unprefixed}`); - unlinkSync(src); - symlinkSync(newTarget, dest); - } else { - renameSync(src, dest); - // Strip prefix from SKILL.md content - const skillMd = join(dest, 'SKILL.md'); - if (existsSync(skillMd)) { - let content = readFileSync(skillMd, 'utf8'); - // Reverse the prefixing: replace prefixed names with unprefixed - content = content.replace(new RegExp(`^name:\\s*${escapeRegex(prefix)}`, 'm'), 'name: '); - const sorted = [...allPrefixedNames].sort((a, b) => b.length - a.length); - for (const pName of sorted) { - const uName = pName.slice(prefix.length); - content = content.replace(new RegExp(`/${escapeRegex(pName)}(?=[^a-zA-Z0-9_-]|$)`, 'g'), `/${uName}`); - content = content.replace(new RegExp(`(the) ${escapeRegex(pName)} skill`, 'gi'), `$1 ${uName} skill`); - } - writeFileSync(skillMd, content); - } - } - } - } + console.log('\nDone! Run /impeccable init in your AI harness to set up design context.\n'); } // ─── skills update ──────────────────────────────────────────────────────────── @@ -684,6 +554,11 @@ async function update(flags = []) { try { + // Retire any old `i-`-prefixed install up front so the refresh lands on the + // canonical `impeccable` dir rather than orphaning the prefixed copy. + 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. @@ -706,13 +581,6 @@ async function update(flags = []) { rmSync(tmpDir, { recursive: true, force: true }); - // Re-apply prefix if detected - const prefix = detectPrefix(root); - if (prefix) { - const count = renameSkillsWithPrefix(root, prefix); - if (count > 0) console.log(`Re-applied "${prefix}" prefix to ${count} skills.`); - } - // Run cleanup to remove deprecated stubs from the fresh download try { const { cleanup: postCleanup } = await import('../../../skill/scripts/cleanup-deprecated.mjs'); @@ -744,6 +612,11 @@ function copyDirSync(src, dest) { } } +// ─── Test surface ─────────────────────────────────────────────────────────── +// Exported so the test suite exercises the real implementation rather than a +// reimplementation in a helper script (which is how bugs slip through). +export { migrateUnprefixImpeccable }; + // ─── Router ─────────────────────────────────────────────────────────────────── export async function run(args) { diff --git a/package.json b/package.json index 215045428..089eac795 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impeccable", - "version": "2.3.1", + "version": "2.3.2", "author": "Paul Bakaus", "description": "Design skills, commands, and anti-pattern detection for AI coding agents", "keywords": [ diff --git a/site/content/skills/impeccable.md b/site/content/skills/impeccable.md index f11a5b1c5..1e7dbb6b1 100644 --- a/site/content/skills/impeccable.md +++ b/site/content/skills/impeccable.md @@ -75,7 +75,7 @@ Useful pins to try: - `/impeccable pin live` for the browser iteration flow - `/impeccable pin critique` for design review -To remove: `/impeccable unpin critique`. Pins live as directories prefixed with `i-` in your harness skills folder (`.claude/skills/i-critique/`, `.cursor/skills/i-critique/`, etc.), so you can also delete them manually. +To remove: `/impeccable unpin critique`. Pins live as directories named after the command in your harness skills folder (`.claude/skills/critique/`, `.cursor/skills/critique/`, etc.), so you can also delete them manually. ## Pitfalls diff --git a/site/pages/changelog.astro b/site/pages/changelog.astro index 1adc5d6a8..fbd3de17b 100644 --- a/site/pages/changelog.astro +++ b/site/pages/changelog.astro @@ -71,6 +71,14 @@ import '../styles/changelog-faq-kinpaku.css'; +
+
CLI v2.3.2May 29, 2026
+
    +
  • The i- command prefix is gone. Opting into a prefix at install time was a holdover from when every command was its own skill. With a single impeccable skill it only ever renamed that one skill to i-impeccable, while the install message wrongly promised /i-audit style commands that never existed, and the rename could clobber unrelated third-party skills in the same harness folder. The flag and the prompt are removed.
  • +
  • Existing prefixed installs heal themselves. skills install and skills update now rename any old i-impeccable (or custom-prefixed) skill back to the canonical impeccable, scoped by name so a third-party skill that happens to start with i- is left untouched. Want a short top-level command? /impeccable pin audit still makes /audit a standalone shortcut.
  • +
+
+
CLI v2.3.1May 28, 2026
    diff --git a/site/pages/faq.astro b/site/pages/faq.astro index 43fda8831..b565a08d2 100644 --- a/site/pages/faq.astro +++ b/site/pages/faq.astro @@ -29,7 +29,7 @@ import '../styles/changelog-faq-kinpaku.css';
    How do I update to the latest version?
    -

    Run npx impeccable skills update from your project root. It downloads the latest skills, cleans up deprecated files, and preserves any prefix you use. Not sure you're behind? npx impeccable skills check compares what you have installed against the latest release first.

    +

    Run npx impeccable skills update from your project root. It downloads the latest skills and cleans up deprecated files. Not sure you're behind? npx impeccable skills check compares what you have installed against the latest release first.

    • Reinstall: npx impeccable skills install --force installs fresh.
    • Claude Code plugin: Open /plugin in Claude Code.
    • @@ -51,7 +51,7 @@ import '../styles/changelog-faq-kinpaku.css';
    • /impeccable pin audit/audit works again
    • /impeccable pin live/live works again
    -

    To remove: /impeccable unpin critique. To see your current pins, check your harness skills directory (.claude/skills/, .cursor/skills/, etc.) for directories prefixed with i-.

    +

    To remove: /impeccable unpin critique. To see your current pins, check your harness skills directory (.claude/skills/, .cursor/skills/, etc.) for directories named after the command you pinned, like .claude/skills/critique/.

    diff --git a/tests/skills-cli.test.js b/tests/skills-cli.test.js index de91f0613..41309813e 100644 --- a/tests/skills-cli.test.js +++ b/tests/skills-cli.test.js @@ -3,7 +3,7 @@ * * Creates real temp directories, runs the CLI, and verifies results. * - * Pure blocks (already-installed detection, prefix rename/round-trip) run in the + * Pure blocks (already-installed detection, unprefix migration) run in the * default `bun run test`. Network blocks that download the universal bundle use * `describeNet` and run only under `bun run test:cli-e2e` (IMPECCABLE_CLI_E2E=1), * skipping gracefully when impeccable.style is unreachable. @@ -13,6 +13,7 @@ import { execSync } from 'child_process'; import { mkdtempSync, existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync, rmSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; +import { migrateUnprefixImpeccable } from '../cli/bin/commands/skills.mjs'; const CLI = join(import.meta.dir, '..', 'cli', 'bin', 'cli.js'); @@ -43,6 +44,25 @@ function createFakeSkills(root, skills = ['audit', 'polish', 'impeccable'], prov } } +/** Write one fake skill dir with a SKILL.md naming itself. */ +function writeSkill(root, provider, name) { + const dir = join(root, provider, 'skills', name); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'SKILL.md'), `---\nname: ${name}\n---\nRun /${name}.\n`); +} + +/** + * Simulate an install from the era when the CLI offered a command prefix: the + * skill lives at `impeccable`. Optionally drop in a third-party skill + * (one that even starts with the same prefix) that migration must NOT touch. + */ +function createPrefixedInstall(root, { prefix = 'i-', providers = ['.claude'], foreign = null } = {}) { + for (const provider of providers) { + writeSkill(root, provider, `${prefix}impeccable`); + if (foreign) writeSkill(root, provider, foreign); + } +} + // ─── Already-installed detection ───────────────────────────────────────────── // Network e2e blocks (real bundle downloads from impeccable.style) run only @@ -86,103 +106,79 @@ describe('skills install: already-installed detection', () => { }, 15000); }); -// ─── Prefix rename (real filesystem) ───────────────────────────────────────── +// ─── Unprefix migration (real implementation, real filesystem) ─────────────── +// +// The CLI no longer offers a command prefix (the `i-` rename only made sense +// when each command was its own skill). migrateUnprefixImpeccable retires any +// old `impeccable` install back to the canonical `impeccable`, so an +// update lands fresh content there instead of orphaning the prefixed copy. +// These call the EXPORTED function -- not a reimplementation -- so a regression +// in the real code fails the suite. -describe('skills install: prefix rename', () => { - let tmp; +describe('skills: unprefix migration', () => { + test('renames i-impeccable back to impeccable across every provider', () => { + const tmp = mkdtempSync(join(tmpdir(), 'imp-test-mig-')); + createPrefixedInstall(tmp, { prefix: 'i-', providers: ['.claude', '.cursor'] }); - beforeAll(() => { - tmp = mkdtempSync(join(tmpdir(), 'imp-test-pfx-')); - createFakeSkills(tmp, ['audit', 'polish', 'impeccable'], ['.claude', '.cursor']); + const migrated = migrateUnprefixImpeccable(tmp); + expect(migrated).toBe(2); // one skill x two providers + + for (const provider of ['.claude', '.cursor']) { + const skills = readdirSync(join(tmp, provider, 'skills')); + expect(skills).toContain('impeccable'); + expect(skills).not.toContain('i-impeccable'); + } + + rmSync(tmp, { recursive: true, force: true }); }); - afterAll(() => { - if (tmp) rmSync(tmp, { recursive: true, force: true }); + test('migrates a custom prefix too (x-impeccable)', () => { + const tmp = mkdtempSync(join(tmpdir(), 'imp-test-mig-x-')); + createPrefixedInstall(tmp, { prefix: 'x-' }); + + expect(migrateUnprefixImpeccable(tmp)).toBe(1); + expect(readdirSync(join(tmp, '.claude', 'skills'))).toContain('impeccable'); + + rmSync(tmp, { recursive: true, force: true }); }); - test('renames folders with prefix', () => { - // Write a helper script that imports and runs renameSkillsWithPrefix - const helperScript = join(tmp, '_test_rename.mjs'); - writeFileSync(helperScript, ` -import { existsSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; + test('REGRESSION: never touches third-party skills, even ones starting with i-', () => { + const tmp = mkdtempSync(join(tmpdir(), 'imp-test-mig-scope-')); + // A foreign skill that shares the i- prefix but is NOT impeccable. + createPrefixedInstall(tmp, { prefix: 'i-', foreign: 'i-cool-skill' }); -function escapeRegex(str) { - return str.replace(/[.*+?^$\{\}()|[\\]\\\\]/g, '\\\\$&'); -} + const migrated = migrateUnprefixImpeccable(tmp); + expect(migrated).toBe(1); // only i-impeccable -function prefixSkillContent(content, prefix, allSkillNames) { - let result = content.replace(/^name:\\s*(.+)$/m, (_, name) => 'name: ' + prefix + name.trim()); - const sorted = [...allSkillNames].sort((a, b) => b.length - a.length); - for (const name of sorted) { - result = result.replace( - new RegExp('/' + '(?=' + escapeRegex(name) + '(?:[^a-zA-Z0-9_-]|$))', 'g'), - '/' + prefix - ); - result = result.replace( - new RegExp('(the) ' + escapeRegex(name) + ' skill', 'gi'), - (_, article) => article + ' ' + prefix + name + ' skill' - ); - } - return result; -} - -const DIRS = ['.claude', '.cursor']; -const root = process.argv[2]; -const prefix = process.argv[3]; - -let allNames = []; -for (const d of DIRS) { - const dir = join(root, d, 'skills'); - if (!existsSync(dir)) continue; - allNames = readdirSync(dir, { withFileTypes: true }).filter(e => e.isDirectory()).map(e => e.name); - if (allNames.length > 0) break; -} - -let count = 0; -for (const d of DIRS) { - const dir = join(root, d, 'skills'); - if (!existsSync(dir)) continue; - for (const entry of readdirSync(dir, { withFileTypes: true })) { - if (!entry.isDirectory() || entry.name.startsWith(prefix)) continue; - const skillMd = join(dir, entry.name, 'SKILL.md'); - if (!existsSync(skillMd)) continue; - renameSync(join(dir, entry.name), join(dir, prefix + entry.name)); - let content = readFileSync(join(dir, prefix + entry.name, 'SKILL.md'), 'utf8'); - content = prefixSkillContent(content, prefix, allNames); - writeFileSync(join(dir, prefix + entry.name, 'SKILL.md'), content); - count++; - } -} -console.log(JSON.stringify({ count })); - `); - - const output = JSON.parse(execSync(`node ${helperScript} ${tmp} i-`, { encoding: 'utf8' })); - expect(output.count).toBe(6); // 3 skills x 2 providers - - // Verify folders renamed const skills = readdirSync(join(tmp, '.claude', 'skills')); - expect(skills).toContain('i-audit'); - expect(skills).toContain('i-polish'); - expect(skills).toContain('i-impeccable'); - expect(skills).not.toContain('audit'); - expect(skills).not.toContain('polish'); - }, 15000); + expect(skills).toContain('impeccable'); + expect(skills).toContain('i-cool-skill'); // untouched, NOT renamed to cool-skill + expect(skills).not.toContain('cool-skill'); - test('prefixed SKILL.md has correct name and cross-references', () => { - const content = readFileSync(join(tmp, '.claude', 'skills', 'i-audit', 'SKILL.md'), 'utf8'); - expect(content).toContain('name: i-audit'); - expect(content).toContain('/i-audit'); - expect(content).toContain('/i-polish'); - expect(content).toContain('the i-impeccable skill'); - // Original unprefixed references should be gone - expect(content).not.toMatch(/\/audit(?=[^a-zA-Z0-9_-]|$)/); + const foreign = readFileSync(join(tmp, '.claude', 'skills', 'i-cool-skill', 'SKILL.md'), 'utf8'); + expect(foreign).toContain('name: i-cool-skill'); + + rmSync(tmp, { recursive: true, force: true }); }); - test('also prefixed in second provider', () => { - const skills = readdirSync(join(tmp, '.cursor', 'skills')); - expect(skills).toContain('i-audit'); - expect(skills).toContain('i-impeccable'); + test('leaves a clean impeccable install alone (no-op)', () => { + const tmp = mkdtempSync(join(tmpdir(), 'imp-test-mig-clean-')); + createFakeSkills(tmp, ['impeccable'], ['.claude']); + + expect(migrateUnprefixImpeccable(tmp)).toBe(0); + expect(readdirSync(join(tmp, '.claude', 'skills'))).toContain('impeccable'); + + rmSync(tmp, { recursive: true, force: true }); + }); + + test('leaves the legacy teach-impeccable name alone (cleanup owns that)', () => { + const tmp = mkdtempSync(join(tmpdir(), 'imp-test-mig-legacy-')); + createFakeSkills(tmp, ['teach-impeccable'], ['.claude']); + + expect(migrateUnprefixImpeccable(tmp)).toBe(0); + expect(readdirSync(join(tmp, '.claude', 'skills'))).toContain('teach-impeccable'); + + rmSync(tmp, { recursive: true, force: true }); }); }); @@ -223,162 +219,6 @@ describeNet('skills update: refreshes from the universal bundle', () => { }); }); -// ─── Prefix round-trip: detect, undo, re-apply ────────────────────────────── - -describe('prefix round-trip: detect, undo, re-apply', () => { - let tmp; - - beforeAll(() => { - tmp = mkdtempSync(join(tmpdir(), 'imp-test-roundtrip-')); - // Create prefixed skills to simulate post-install state - for (const skill of ['audit', 'polish', 'impeccable']) { - const skillDir = join(tmp, '.claude', 'skills', 'i-' + skill); - mkdirSync(skillDir, { recursive: true }); - writeFileSync(join(skillDir, 'SKILL.md'), [ - '---', - `name: i-${skill}`, - 'user-invocable: true', - '---', - '', - 'Run /i-audit first, then /i-polish to finish.', - 'Use the i-impeccable skill for setup.', - ].join('\n')); - } - }); - - afterAll(() => { - if (tmp) rmSync(tmp, { recursive: true, force: true }); - }); - - test('detectPrefix finds the prefix from skill names', () => { - const script = join(tmp, '_detect.mjs'); - writeFileSync(script, ` -import { existsSync, readdirSync } from 'node:fs'; -import { join } from 'node:path'; -const DIRS = ['.claude', '.cursor', '.agents']; -const root = ${JSON.stringify(tmp)}; -for (const d of DIRS) { - const dir = join(root, d, 'skills'); - if (!existsSync(dir)) continue; - for (const name of readdirSync(dir)) { - if (name === 'impeccable') { console.log(''); process.exit(); } - if (name.endsWith('-impeccable')) { console.log(name.slice(0, -'impeccable'.length)); process.exit(); } - } -} -console.log(''); - `); - const output = execSync(`node ${script}`, { encoding: 'utf8' }).trim(); - expect(output).toBe('i-'); - }); - - test('undo removes prefix from folders and content', () => { - // Write undo helper script - const script = join(tmp, '_undo.mjs'); - writeFileSync(script, ` -import { existsSync, readdirSync, readFileSync, lstatSync, readlinkSync, unlinkSync, renameSync, writeFileSync, symlinkSync } from 'node:fs'; -import { join } from 'node:path'; - -function escapeRegex(str) { return str.replace(/[.*+?^$\{\\}()|[\\]\\\\]/g, '\\\\$&'); } - -const root = ${JSON.stringify(tmp)}; -const prefix = 'i-'; -const skillsDir = join(root, '.claude', 'skills'); -const entries = readdirSync(skillsDir); -const prefixedNames = entries.filter(n => n.startsWith(prefix)); - -for (const name of entries) { - if (!name.startsWith(prefix)) continue; - const unprefixed = name.slice(prefix.length); - const src = join(skillsDir, name); - const dest = join(skillsDir, unprefixed); - - if (lstatSync(src).isSymbolicLink()) { - const target = readlinkSync(src); - unlinkSync(src); - symlinkSync(target.replace('/' + name, '/' + unprefixed), dest); - } else { - renameSync(src, dest); - const skillMd = join(dest, 'SKILL.md'); - if (existsSync(skillMd)) { - let content = readFileSync(skillMd, 'utf8'); - content = content.replace(new RegExp('^name:\\\\s*' + escapeRegex(prefix), 'm'), 'name: '); - const sorted = [...prefixedNames].sort((a, b) => b.length - a.length); - for (const pName of sorted) { - const uName = pName.slice(prefix.length); - content = content.replace(new RegExp('/' + escapeRegex(pName) + '(?=[^a-zA-Z0-9_-]|$)', 'g'), '/' + uName); - content = content.replace(new RegExp('(the) ' + escapeRegex(pName) + ' skill', 'gi'), '$1 ' + uName + ' skill'); - } - writeFileSync(skillMd, content); - } - } -} -console.log(JSON.stringify(readdirSync(skillsDir))); - `); - const output = JSON.parse(execSync(`node ${script}`, { encoding: 'utf8' })); - expect(output).toContain('audit'); - expect(output).toContain('polish'); - expect(output).toContain('impeccable'); - expect(output).not.toContain('i-audit'); - - // Verify content was un-prefixed - const content = readFileSync(join(tmp, '.claude', 'skills', 'audit', 'SKILL.md'), 'utf8'); - expect(content).toContain('name: audit'); - expect(content).toContain('/audit'); - expect(content).toContain('/polish'); - expect(content).toContain('the impeccable skill'); - expect(content).not.toContain('/i-audit'); - expect(content).not.toContain('i-impeccable'); - }); - - test('re-applying prefix restores original state', () => { - // Now re-apply using the same helper from the rename test - const script = join(tmp, '_reprefix.mjs'); - writeFileSync(script, ` -import { existsSync, readdirSync, readFileSync, statSync, lstatSync, renameSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; - -function escapeRegex(str) { return str.replace(/[.*+?^$\{\\}()|[\\]\\\\]/g, '\\\\$&'); } - -const root = ${JSON.stringify(tmp)}; -const prefix = 'i-'; -const skillsDir = join(root, '.claude', 'skills'); -const allNames = readdirSync(skillsDir).filter(n => { - const full = join(skillsDir, n); - try { return statSync(full).isDirectory() && existsSync(join(full, 'SKILL.md')); } catch { return false; } -}); - -for (const name of allNames) { - if (name.startsWith(prefix)) continue; - const src = join(skillsDir, name); - const dest = join(skillsDir, prefix + name); - const ls = lstatSync(src); - if (ls.isSymbolicLink() || !ls.isDirectory()) continue; - renameSync(src, dest); - let content = readFileSync(join(dest, 'SKILL.md'), 'utf8'); - content = content.replace(/^name:\\s*(.+)$/m, (_, n) => 'name: ' + prefix + n.trim()); - const sorted = [...allNames].sort((a, b) => b.length - a.length); - for (const n of sorted) { - content = content.replace(new RegExp('/' + '(?=' + escapeRegex(n) + '(?:[^a-zA-Z0-9_-]|$))', 'g'), '/' + prefix); - content = content.replace(new RegExp('(the) ' + escapeRegex(n) + ' skill', 'gi'), (_, art) => art + ' ' + prefix + n + ' skill'); - } - writeFileSync(join(dest, 'SKILL.md'), content); -} -console.log(JSON.stringify(readdirSync(skillsDir))); - `); - const output = JSON.parse(execSync(`node ${script}`, { encoding: 'utf8' })); - expect(output).toContain('i-audit'); - expect(output).toContain('i-polish'); - expect(output).toContain('i-impeccable'); - expect(output).not.toContain('audit'); - - // Verify content was re-prefixed - const content = readFileSync(join(tmp, '.claude', 'skills', 'i-audit', 'SKILL.md'), 'utf8'); - expect(content).toContain('name: i-audit'); - expect(content).toContain('/i-polish'); - expect(content).toContain('the i-impeccable skill'); - }); -}); - // ─── Full install e2e (downloads the universal bundle) ─────────────────────── describeNet('skills install: full e2e (universal bundle download)', () => { @@ -404,22 +244,17 @@ describeNet('skills install: full e2e (universal bundle download)', () => { expect(hasSkills).toBe(true); }, 90000); - test('install with --prefix= renames all skills', () => { - const output = run('skills install -y --force --prefix=x-', { cwd: tmp }); + test('--force reinstall over an old prefixed install lands on canonical impeccable', () => { + // Seed a stale prefixed install, then reinstall. The migration should + // retire i-impeccable so we are left with the canonical name only. + const prefixed = join(tmp, '.claude', 'skills', 'i-impeccable'); + mkdirSync(prefixed, { recursive: true }); + writeFileSync(join(prefixed, 'SKILL.md'), '---\nname: i-impeccable\n---\n'); - // Find the provider that has skills - let found = false; - for (const d of ['.claude', '.cursor', '.gemini', '.agents', '.kiro']) { - const dir = join(tmp, d, 'skills'); - if (!existsSync(dir)) continue; - const skills = readdirSync(dir); - if (skills.length === 0) continue; - found = true; - const prefixed = skills.filter(s => s.startsWith('x-')); - // All skills should be prefixed - expect(prefixed.length).toBe(skills.length); - break; - } - expect(found).toBe(true); + run('skills install -y --force', { cwd: tmp }); + + const skills = readdirSync(join(tmp, '.claude', 'skills')); + expect(skills).toContain('impeccable'); + expect(skills).not.toContain('i-impeccable'); }, 90000); });