Remove the i- command prefix from the CLI

The `i-` prefix install option was a holdover from the multi-skill era.
With a single `impeccable` skill it only ever renamed that one skill to
`i-impeccable`, while the install message wrongly advertised `/i-audit`
style commands that never existed, and the unscoped rename could clobber
unrelated third-party skills in the same harness folder.

- Drop `--prefix=`, the interactive prompt, and all prefix machinery
  (renameSkillsWithPrefix, prefixSkillContent, detectPrefix, undoPrefix,
  prefixedCommandHint, isImpeccableSkillName).
- Add migrateUnprefixImpeccable: install --force and update rename any old
  `<prefix>impeccable` back to canonical `impeccable` before the fresh copy
  lands, scoped by name so foreign `i-*` skills are left untouched.
- Fix FAQ + editorial that wrongly described pinned commands as `i-`
  prefixed (pins are bare `skills/<command>/` dirs).
- Tests now exercise the real exported migration, not a reimplementation.
- CLI 2.3.1 -> 2.3.2 with a changelog entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-05-29 18:33:16 -07:00
co-authored by Claude Opus 4.8
parent e10cff397b
commit 824c434500
6 changed files with 151 additions and 435 deletions
+46 -173
View File
@@ -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 `<prefix>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}<command> (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) {
+1 -1
View File
@@ -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": [
+1 -1
View File
@@ -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
+8
View File
@@ -71,6 +71,14 @@ import '../styles/changelog-faq-kinpaku.css';
</ul>
</article>
<article id="cli-v2.3.2" class="cf-entry">
<header class="cf-entry-head"><span class="cf-version">CLI v2.3.2</span><span class="cf-date">May 29, 2026</span></header>
<ul class="cf-items">
<li><strong>The <code>i-</code> command prefix is gone.</strong> Opting into a prefix at install time was a holdover from when every command was its own skill. With a single <code>impeccable</code> skill it only ever renamed that one skill to <code>i-impeccable</code>, while the install message wrongly promised <code>/i-audit</code> 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.</li>
<li><strong>Existing prefixed installs heal themselves.</strong> <code>skills install</code> and <code>skills update</code> now rename any old <code>i-impeccable</code> (or custom-prefixed) skill back to the canonical <code>impeccable</code>, scoped by name so a third-party skill that happens to start with <code>i-</code> is left untouched. Want a short top-level command? <code>/impeccable pin audit</code> still makes <code>/audit</code> a standalone shortcut.</li>
</ul>
</article>
<article id="cli-v2.3.1" class="cf-entry">
<header class="cf-entry-head"><span class="cf-version">CLI v2.3.1</span><span class="cf-date">May 28, 2026</span></header>
<ul class="cf-items">
+2 -2
View File
@@ -29,7 +29,7 @@ import '../styles/changelog-faq-kinpaku.css';
<details id="update" class="cf-faq-item">
<summary class="cf-faq-question">How do I update to the latest version?</summary>
<div class="cf-faq-answer">
<p>Run <code>npx impeccable skills update</code> from your project root. It downloads the latest skills, cleans up deprecated files, and preserves any prefix you use. Not sure you're behind? <code>npx impeccable skills check</code> compares what you have installed against the latest release first.</p>
<p>Run <code>npx impeccable skills update</code> from your project root. It downloads the latest skills and cleans up deprecated files. Not sure you're behind? <code>npx impeccable skills check</code> compares what you have installed against the latest release first.</p>
<ul>
<li><strong>Reinstall:</strong> <code>npx impeccable skills install --force</code> installs fresh.</li>
<li><strong>Claude Code plugin:</strong> Open <code>/plugin</code> in Claude Code.</li>
@@ -51,7 +51,7 @@ import '../styles/changelog-faq-kinpaku.css';
<li><code>/impeccable pin audit</code> &rarr; <code>/audit</code> works again</li>
<li><code>/impeccable pin live</code> &rarr; <code>/live</code> works again</li>
</ul>
<p>To remove: <code>/impeccable unpin critique</code>. To see your current pins, check your harness skills directory (<code>.claude/skills/</code>, <code>.cursor/skills/</code>, etc.) for directories prefixed with <code>i-</code>.</p>
<p>To remove: <code>/impeccable unpin critique</code>. To see your current pins, check your harness skills directory (<code>.claude/skills/</code>, <code>.cursor/skills/</code>, etc.) for directories named after the command you pinned, like <code>.claude/skills/critique/</code>.</p>
</div>
</details>
+93 -258
View File
@@ -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 `<prefix>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 `<prefix>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);
});