mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 06:06:37 +03:00
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:
co-authored by
Claude Opus 4.8
parent
e10cff397b
commit
824c434500
+46
-173
@@ -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
@@ -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": [
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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> → <code>/audit</code> works again</li>
|
||||
<li><code>/impeccable pin live</code> → <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
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user