mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-16 16:16:32 +03:00
Remove deprecated cleanup migration (#240)
This commit is contained in:
@@ -1,284 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Cleans up deprecated Impeccable skill files, symlinks, and
|
||||
* skills-lock.json entries left over from previous versions.
|
||||
*
|
||||
* Safe to run repeatedly -- it is a no-op when nothing needs cleaning.
|
||||
*
|
||||
* Usage (from the project root):
|
||||
* node {{scripts_path}}/cleanup-deprecated.mjs
|
||||
*
|
||||
* What it does:
|
||||
* 1. Finds every harness-specific skills directory (.claude/skills,
|
||||
* .cursor/skills, .agents/skills, etc.).
|
||||
* 2. For each deprecated skill name (with and without i- prefix),
|
||||
* checks if the directory exists and its SKILL.md mentions
|
||||
* "impeccable" (to avoid deleting unrelated user skills).
|
||||
* 3. Deletes confirmed matches (files, directories, or symlinks).
|
||||
* 4. Removes the corresponding entries from skills-lock.json.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, rmSync, readdirSync, statSync, lstatSync, unlinkSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
// Skills that were renamed, merged, or folded in v2.0, v2.1, and v3.0.
|
||||
const DEPRECATED_NAMES = [
|
||||
// v2.0 renames
|
||||
'frontend-design', // renamed to impeccable
|
||||
'teach-impeccable', // folded into /impeccable init
|
||||
// v2.1 merges
|
||||
'arrange', // renamed to layout
|
||||
'normalize', // merged into polish
|
||||
'onboard', // merged into harden
|
||||
'extract', // merged into /impeccable extract
|
||||
// v3.0 consolidation: all standalone skills -> /impeccable sub-commands
|
||||
'adapt',
|
||||
'animate',
|
||||
'audit',
|
||||
'bolder',
|
||||
'clarify',
|
||||
'colorize',
|
||||
'critique',
|
||||
'delight',
|
||||
'distill',
|
||||
'harden',
|
||||
'layout',
|
||||
'optimize',
|
||||
'overdrive',
|
||||
'polish',
|
||||
'quieter',
|
||||
'shape',
|
||||
'typeset',
|
||||
];
|
||||
|
||||
// All known harness directories that may contain a skills/ subfolder.
|
||||
const HARNESS_DIRS = [
|
||||
'.claude', '.cursor', '.gemini', '.codex', '.agents',
|
||||
'.trae', '.trae-cn', '.pi', '.opencode', '.kiro', '.rovodev',
|
||||
];
|
||||
|
||||
// Per-skill fingerprints for SKILL.md bodies that never mentioned
|
||||
// "impeccable" in their v2.x source. Used as a last-resort match
|
||||
// when no skills-lock.json exists and the word heuristic fails.
|
||||
// The strings are lifted verbatim from the v2.x frontmatter
|
||||
// descriptions, so collisions with hand-written user skills are
|
||||
// vanishingly unlikely.
|
||||
const SKILL_FINGERPRINTS = {
|
||||
harden: 'Make interfaces production-ready: error handling, empty states',
|
||||
optimize: 'Diagnoses and fixes UI performance across loading speed',
|
||||
};
|
||||
|
||||
/**
|
||||
* Walk up from startDir until we find a directory that looks like a
|
||||
* project root (has package.json, .git, or skills-lock.json).
|
||||
*/
|
||||
export function findProjectRoot(startDir = process.cwd()) {
|
||||
let dir = resolve(startDir);
|
||||
const { root } = { root: '/' };
|
||||
while (dir !== root) {
|
||||
if (
|
||||
existsSync(join(dir, 'package.json')) ||
|
||||
existsSync(join(dir, '.git')) ||
|
||||
existsSync(join(dir, 'skills-lock.json'))
|
||||
) {
|
||||
return dir;
|
||||
}
|
||||
const parent = resolve(dir, '..');
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return resolve(startDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load skills-lock.json from the project root, or null if missing/unreadable.
|
||||
*/
|
||||
export function loadLock(projectRoot) {
|
||||
const lockPath = join(projectRoot, 'skills-lock.json');
|
||||
if (!existsSync(lockPath)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(lockPath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a skill directory belongs to Impeccable. Three layered
|
||||
* signals, in order of reliability:
|
||||
* 1. Lock source equals "pbakaus/impeccable" (authoritative).
|
||||
* 2. SKILL.md body contains the word "impeccable".
|
||||
* 3. SKILL.md body contains a per-skill fingerprint (for harden and
|
||||
* optimize, whose v2.x SKILL.md never mentioned the pack name).
|
||||
*/
|
||||
export function isImpeccableSkill(skillDir, { skillName, lock } = {}) {
|
||||
// 1. Authoritative: the lock file claims this skill is ours.
|
||||
if (skillName && lock?.skills?.[skillName]?.source === 'pbakaus/impeccable') {
|
||||
return true;
|
||||
}
|
||||
const skillMd = join(skillDir, 'SKILL.md');
|
||||
if (!existsSync(skillMd)) return false;
|
||||
let content;
|
||||
try {
|
||||
content = readFileSync(skillMd, 'utf-8');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
// 2. Word-level content heuristic.
|
||||
if (/impeccable/i.test(content)) return true;
|
||||
// 3. Per-skill fingerprint for old skills that never mentioned the pack.
|
||||
// Strip the i- prefix so both `harden` and `i-harden` resolve to the
|
||||
// same fingerprint entry.
|
||||
const unprefixed = skillName?.startsWith('i-') ? skillName.slice(2) : skillName;
|
||||
const fingerprint = unprefixed && SKILL_FINGERPRINTS[unprefixed];
|
||||
if (fingerprint && content.includes(fingerprint)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full list of names to check: each deprecated name, plus
|
||||
* its i-prefixed variant.
|
||||
*/
|
||||
export function buildTargetNames() {
|
||||
const names = [];
|
||||
for (const name of DEPRECATED_NAMES) {
|
||||
names.push(name);
|
||||
names.push(`i-${name}`);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find every skills directory across all harness dirs in the project.
|
||||
* Returns absolute paths that exist on disk.
|
||||
*/
|
||||
export function findSkillsDirs(projectRoot) {
|
||||
const dirs = [];
|
||||
for (const harness of HARNESS_DIRS) {
|
||||
const candidate = join(projectRoot, harness, 'skills');
|
||||
if (existsSync(candidate)) {
|
||||
dirs.push(candidate);
|
||||
}
|
||||
}
|
||||
return dirs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove deprecated skill directories/symlinks from all harness dirs.
|
||||
* Reads skills-lock.json so the authoritative "source" field can
|
||||
* drive deletion even when SKILL.md never mentions impeccable.
|
||||
* Returns an array of paths that were deleted.
|
||||
*/
|
||||
export function removeDeprecatedSkills(projectRoot, lock) {
|
||||
if (lock === undefined) lock = loadLock(projectRoot);
|
||||
const targets = buildTargetNames();
|
||||
const skillsDirs = findSkillsDirs(projectRoot);
|
||||
const deleted = [];
|
||||
|
||||
for (const skillsDir of skillsDirs) {
|
||||
for (const name of targets) {
|
||||
const skillPath = join(skillsDir, name);
|
||||
|
||||
// Use lstat to detect symlinks (existsSync follows symlinks and
|
||||
// returns false for dangling ones).
|
||||
let stat;
|
||||
try {
|
||||
stat = lstatSync(skillPath);
|
||||
} catch {
|
||||
continue; // does not exist at all
|
||||
}
|
||||
|
||||
if (stat.isSymbolicLink()) {
|
||||
// Symlink: check the target if it's alive, otherwise treat
|
||||
// dangling symlinks to deprecated names as safe to remove.
|
||||
const targetAlive = existsSync(skillPath);
|
||||
const isMatch = targetAlive
|
||||
? isImpeccableSkill(skillPath, { skillName: name, lock })
|
||||
: true;
|
||||
if (isMatch) {
|
||||
unlinkSync(skillPath);
|
||||
deleted.push(skillPath);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular directory -- verify it belongs to impeccable
|
||||
if (isImpeccableSkill(skillPath, { skillName: name, lock })) {
|
||||
rmSync(skillPath, { recursive: true, force: true });
|
||||
deleted.push(skillPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove deprecated entries from skills-lock.json.
|
||||
* Only removes entries whose source is "pbakaus/impeccable".
|
||||
* Returns the list of removed skill names.
|
||||
*/
|
||||
export function cleanSkillsLock(projectRoot) {
|
||||
const lockPath = join(projectRoot, 'skills-lock.json');
|
||||
if (!existsSync(lockPath)) return [];
|
||||
|
||||
let lock;
|
||||
try {
|
||||
lock = JSON.parse(readFileSync(lockPath, 'utf-8'));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!lock.skills || typeof lock.skills !== 'object') return [];
|
||||
|
||||
const targets = buildTargetNames();
|
||||
const removed = [];
|
||||
|
||||
for (const name of targets) {
|
||||
const entry = lock.skills[name];
|
||||
if (!entry) continue;
|
||||
// Only remove if it belongs to impeccable
|
||||
if (entry.source === 'pbakaus/impeccable') {
|
||||
delete lock.skills[name];
|
||||
removed.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
if (removed.length > 0) {
|
||||
writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full cleanup. Returns a summary object.
|
||||
*
|
||||
* Order matters: read the lock and delete directories first, then
|
||||
* strip lock entries. Otherwise the authoritative signal is gone by
|
||||
* the time directory deletion runs.
|
||||
*/
|
||||
export function cleanup(projectRoot) {
|
||||
const root = projectRoot || findProjectRoot();
|
||||
const lock = loadLock(root);
|
||||
const deletedPaths = removeDeprecatedSkills(root, lock);
|
||||
const removedLockEntries = cleanSkillsLock(root);
|
||||
return { deletedPaths, removedLockEntries, projectRoot: root };
|
||||
}
|
||||
|
||||
// CLI entry point
|
||||
if (process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname)) {
|
||||
const result = cleanup();
|
||||
if (result.deletedPaths.length === 0 && result.removedLockEntries.length === 0) {
|
||||
console.log('No deprecated Impeccable skills found. Nothing to clean up.');
|
||||
} else {
|
||||
if (result.deletedPaths.length > 0) {
|
||||
console.log(`Removed ${result.deletedPaths.length} deprecated skill(s):`);
|
||||
for (const p of result.deletedPaths) console.log(` - ${p}`);
|
||||
}
|
||||
if (result.removedLockEntries.length > 0) {
|
||||
console.log(`Cleaned ${result.removedLockEntries.length} entry/entries from skills-lock.json:`);
|
||||
for (const name of result.removedLockEntries) console.log(` - ${name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Cleans up deprecated Impeccable skill files, symlinks, and
|
||||
* skills-lock.json entries left over from previous versions.
|
||||
*
|
||||
* Safe to run repeatedly -- it is a no-op when nothing needs cleaning.
|
||||
*
|
||||
* Usage (from the project root):
|
||||
* node {{scripts_path}}/cleanup-deprecated.mjs
|
||||
*
|
||||
* What it does:
|
||||
* 1. Finds every harness-specific skills directory (.claude/skills,
|
||||
* .cursor/skills, .agents/skills, etc.).
|
||||
* 2. For each deprecated skill name (with and without i- prefix),
|
||||
* checks if the directory exists and its SKILL.md mentions
|
||||
* "impeccable" (to avoid deleting unrelated user skills).
|
||||
* 3. Deletes confirmed matches (files, directories, or symlinks).
|
||||
* 4. Removes the corresponding entries from skills-lock.json.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, rmSync, readdirSync, statSync, lstatSync, unlinkSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
// Skills that were renamed, merged, or folded in v2.0, v2.1, and v3.0.
|
||||
const DEPRECATED_NAMES = [
|
||||
// v2.0 renames
|
||||
'frontend-design', // renamed to impeccable
|
||||
'teach-impeccable', // folded into /impeccable init
|
||||
// v2.1 merges
|
||||
'arrange', // renamed to layout
|
||||
'normalize', // merged into polish
|
||||
'onboard', // merged into harden
|
||||
'extract', // merged into /impeccable extract
|
||||
// v3.0 consolidation: all standalone skills -> /impeccable sub-commands
|
||||
'adapt',
|
||||
'animate',
|
||||
'audit',
|
||||
'bolder',
|
||||
'clarify',
|
||||
'colorize',
|
||||
'critique',
|
||||
'delight',
|
||||
'distill',
|
||||
'harden',
|
||||
'layout',
|
||||
'optimize',
|
||||
'overdrive',
|
||||
'polish',
|
||||
'quieter',
|
||||
'shape',
|
||||
'typeset',
|
||||
];
|
||||
|
||||
// All known harness directories that may contain a skills/ subfolder.
|
||||
const HARNESS_DIRS = [
|
||||
'.claude', '.cursor', '.gemini', '.codex', '.agents',
|
||||
'.trae', '.trae-cn', '.pi', '.opencode', '.kiro', '.rovodev',
|
||||
];
|
||||
|
||||
// Per-skill fingerprints for SKILL.md bodies that never mentioned
|
||||
// "impeccable" in their v2.x source. Used as a last-resort match
|
||||
// when no skills-lock.json exists and the word heuristic fails.
|
||||
// The strings are lifted verbatim from the v2.x frontmatter
|
||||
// descriptions, so collisions with hand-written user skills are
|
||||
// vanishingly unlikely.
|
||||
const SKILL_FINGERPRINTS = {
|
||||
harden: 'Make interfaces production-ready: error handling, empty states',
|
||||
optimize: 'Diagnoses and fixes UI performance across loading speed',
|
||||
};
|
||||
|
||||
/**
|
||||
* Walk up from startDir until we find a directory that looks like a
|
||||
* project root (has package.json, .git, or skills-lock.json).
|
||||
*/
|
||||
export function findProjectRoot(startDir = process.cwd()) {
|
||||
let dir = resolve(startDir);
|
||||
const { root } = { root: '/' };
|
||||
while (dir !== root) {
|
||||
if (
|
||||
existsSync(join(dir, 'package.json')) ||
|
||||
existsSync(join(dir, '.git')) ||
|
||||
existsSync(join(dir, 'skills-lock.json'))
|
||||
) {
|
||||
return dir;
|
||||
}
|
||||
const parent = resolve(dir, '..');
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return resolve(startDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load skills-lock.json from the project root, or null if missing/unreadable.
|
||||
*/
|
||||
export function loadLock(projectRoot) {
|
||||
const lockPath = join(projectRoot, 'skills-lock.json');
|
||||
if (!existsSync(lockPath)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(lockPath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a skill directory belongs to Impeccable. Three layered
|
||||
* signals, in order of reliability:
|
||||
* 1. Lock source equals "pbakaus/impeccable" (authoritative).
|
||||
* 2. SKILL.md body contains the word "impeccable".
|
||||
* 3. SKILL.md body contains a per-skill fingerprint (for harden and
|
||||
* optimize, whose v2.x SKILL.md never mentioned the pack name).
|
||||
*/
|
||||
export function isImpeccableSkill(skillDir, { skillName, lock } = {}) {
|
||||
// 1. Authoritative: the lock file claims this skill is ours.
|
||||
if (skillName && lock?.skills?.[skillName]?.source === 'pbakaus/impeccable') {
|
||||
return true;
|
||||
}
|
||||
const skillMd = join(skillDir, 'SKILL.md');
|
||||
if (!existsSync(skillMd)) return false;
|
||||
let content;
|
||||
try {
|
||||
content = readFileSync(skillMd, 'utf-8');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
// 2. Word-level content heuristic.
|
||||
if (/impeccable/i.test(content)) return true;
|
||||
// 3. Per-skill fingerprint for old skills that never mentioned the pack.
|
||||
// Strip the i- prefix so both `harden` and `i-harden` resolve to the
|
||||
// same fingerprint entry.
|
||||
const unprefixed = skillName?.startsWith('i-') ? skillName.slice(2) : skillName;
|
||||
const fingerprint = unprefixed && SKILL_FINGERPRINTS[unprefixed];
|
||||
if (fingerprint && content.includes(fingerprint)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full list of names to check: each deprecated name, plus
|
||||
* its i-prefixed variant.
|
||||
*/
|
||||
export function buildTargetNames() {
|
||||
const names = [];
|
||||
for (const name of DEPRECATED_NAMES) {
|
||||
names.push(name);
|
||||
names.push(`i-${name}`);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find every skills directory across all harness dirs in the project.
|
||||
* Returns absolute paths that exist on disk.
|
||||
*/
|
||||
export function findSkillsDirs(projectRoot) {
|
||||
const dirs = [];
|
||||
for (const harness of HARNESS_DIRS) {
|
||||
const candidate = join(projectRoot, harness, 'skills');
|
||||
if (existsSync(candidate)) {
|
||||
dirs.push(candidate);
|
||||
}
|
||||
}
|
||||
return dirs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove deprecated skill directories/symlinks from all harness dirs.
|
||||
* Reads skills-lock.json so the authoritative "source" field can
|
||||
* drive deletion even when SKILL.md never mentions impeccable.
|
||||
* Returns an array of paths that were deleted.
|
||||
*/
|
||||
export function removeDeprecatedSkills(projectRoot, lock) {
|
||||
if (lock === undefined) lock = loadLock(projectRoot);
|
||||
const targets = buildTargetNames();
|
||||
const skillsDirs = findSkillsDirs(projectRoot);
|
||||
const deleted = [];
|
||||
|
||||
for (const skillsDir of skillsDirs) {
|
||||
for (const name of targets) {
|
||||
const skillPath = join(skillsDir, name);
|
||||
|
||||
// Use lstat to detect symlinks (existsSync follows symlinks and
|
||||
// returns false for dangling ones).
|
||||
let stat;
|
||||
try {
|
||||
stat = lstatSync(skillPath);
|
||||
} catch {
|
||||
continue; // does not exist at all
|
||||
}
|
||||
|
||||
if (stat.isSymbolicLink()) {
|
||||
// Symlink: check the target if it's alive, otherwise treat
|
||||
// dangling symlinks to deprecated names as safe to remove.
|
||||
const targetAlive = existsSync(skillPath);
|
||||
const isMatch = targetAlive
|
||||
? isImpeccableSkill(skillPath, { skillName: name, lock })
|
||||
: true;
|
||||
if (isMatch) {
|
||||
unlinkSync(skillPath);
|
||||
deleted.push(skillPath);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular directory -- verify it belongs to impeccable
|
||||
if (isImpeccableSkill(skillPath, { skillName: name, lock })) {
|
||||
rmSync(skillPath, { recursive: true, force: true });
|
||||
deleted.push(skillPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove deprecated entries from skills-lock.json.
|
||||
* Only removes entries whose source is "pbakaus/impeccable".
|
||||
* Returns the list of removed skill names.
|
||||
*/
|
||||
export function cleanSkillsLock(projectRoot) {
|
||||
const lockPath = join(projectRoot, 'skills-lock.json');
|
||||
if (!existsSync(lockPath)) return [];
|
||||
|
||||
let lock;
|
||||
try {
|
||||
lock = JSON.parse(readFileSync(lockPath, 'utf-8'));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!lock.skills || typeof lock.skills !== 'object') return [];
|
||||
|
||||
const targets = buildTargetNames();
|
||||
const removed = [];
|
||||
|
||||
for (const name of targets) {
|
||||
const entry = lock.skills[name];
|
||||
if (!entry) continue;
|
||||
// Only remove if it belongs to impeccable
|
||||
if (entry.source === 'pbakaus/impeccable') {
|
||||
delete lock.skills[name];
|
||||
removed.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
if (removed.length > 0) {
|
||||
writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full cleanup. Returns a summary object.
|
||||
*
|
||||
* Order matters: read the lock and delete directories first, then
|
||||
* strip lock entries. Otherwise the authoritative signal is gone by
|
||||
* the time directory deletion runs.
|
||||
*/
|
||||
export function cleanup(projectRoot) {
|
||||
const root = projectRoot || findProjectRoot();
|
||||
const lock = loadLock(root);
|
||||
const deletedPaths = removeDeprecatedSkills(root, lock);
|
||||
const removedLockEntries = cleanSkillsLock(root);
|
||||
return { deletedPaths, removedLockEntries, projectRoot: root };
|
||||
}
|
||||
|
||||
// CLI entry point
|
||||
if (process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname)) {
|
||||
const result = cleanup();
|
||||
if (result.deletedPaths.length === 0 && result.removedLockEntries.length === 0) {
|
||||
console.log('No deprecated Impeccable skills found. Nothing to clean up.');
|
||||
} else {
|
||||
if (result.deletedPaths.length > 0) {
|
||||
console.log(`Removed ${result.deletedPaths.length} deprecated skill(s):`);
|
||||
for (const p of result.deletedPaths) console.log(` - ${p}`);
|
||||
}
|
||||
if (result.removedLockEntries.length > 0) {
|
||||
console.log(`Cleaned ${result.removedLockEntries.length} entry/entries from skills-lock.json:`);
|
||||
for (const name of result.removedLockEntries) console.log(` - ${name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Cleans up deprecated Impeccable skill files, symlinks, and
|
||||
* skills-lock.json entries left over from previous versions.
|
||||
*
|
||||
* Safe to run repeatedly -- it is a no-op when nothing needs cleaning.
|
||||
*
|
||||
* Usage (from the project root):
|
||||
* node {{scripts_path}}/cleanup-deprecated.mjs
|
||||
*
|
||||
* What it does:
|
||||
* 1. Finds every harness-specific skills directory (.claude/skills,
|
||||
* .cursor/skills, .agents/skills, etc.).
|
||||
* 2. For each deprecated skill name (with and without i- prefix),
|
||||
* checks if the directory exists and its SKILL.md mentions
|
||||
* "impeccable" (to avoid deleting unrelated user skills).
|
||||
* 3. Deletes confirmed matches (files, directories, or symlinks).
|
||||
* 4. Removes the corresponding entries from skills-lock.json.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, rmSync, readdirSync, statSync, lstatSync, unlinkSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
// Skills that were renamed, merged, or folded in v2.0, v2.1, and v3.0.
|
||||
const DEPRECATED_NAMES = [
|
||||
// v2.0 renames
|
||||
'frontend-design', // renamed to impeccable
|
||||
'teach-impeccable', // folded into /impeccable init
|
||||
// v2.1 merges
|
||||
'arrange', // renamed to layout
|
||||
'normalize', // merged into polish
|
||||
'onboard', // merged into harden
|
||||
'extract', // merged into /impeccable extract
|
||||
// v3.0 consolidation: all standalone skills -> /impeccable sub-commands
|
||||
'adapt',
|
||||
'animate',
|
||||
'audit',
|
||||
'bolder',
|
||||
'clarify',
|
||||
'colorize',
|
||||
'critique',
|
||||
'delight',
|
||||
'distill',
|
||||
'harden',
|
||||
'layout',
|
||||
'optimize',
|
||||
'overdrive',
|
||||
'polish',
|
||||
'quieter',
|
||||
'shape',
|
||||
'typeset',
|
||||
];
|
||||
|
||||
// All known harness directories that may contain a skills/ subfolder.
|
||||
const HARNESS_DIRS = [
|
||||
'.claude', '.cursor', '.gemini', '.codex', '.agents',
|
||||
'.trae', '.trae-cn', '.pi', '.opencode', '.kiro', '.rovodev',
|
||||
];
|
||||
|
||||
// Per-skill fingerprints for SKILL.md bodies that never mentioned
|
||||
// "impeccable" in their v2.x source. Used as a last-resort match
|
||||
// when no skills-lock.json exists and the word heuristic fails.
|
||||
// The strings are lifted verbatim from the v2.x frontmatter
|
||||
// descriptions, so collisions with hand-written user skills are
|
||||
// vanishingly unlikely.
|
||||
const SKILL_FINGERPRINTS = {
|
||||
harden: 'Make interfaces production-ready: error handling, empty states',
|
||||
optimize: 'Diagnoses and fixes UI performance across loading speed',
|
||||
};
|
||||
|
||||
/**
|
||||
* Walk up from startDir until we find a directory that looks like a
|
||||
* project root (has package.json, .git, or skills-lock.json).
|
||||
*/
|
||||
export function findProjectRoot(startDir = process.cwd()) {
|
||||
let dir = resolve(startDir);
|
||||
const { root } = { root: '/' };
|
||||
while (dir !== root) {
|
||||
if (
|
||||
existsSync(join(dir, 'package.json')) ||
|
||||
existsSync(join(dir, '.git')) ||
|
||||
existsSync(join(dir, 'skills-lock.json'))
|
||||
) {
|
||||
return dir;
|
||||
}
|
||||
const parent = resolve(dir, '..');
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return resolve(startDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load skills-lock.json from the project root, or null if missing/unreadable.
|
||||
*/
|
||||
export function loadLock(projectRoot) {
|
||||
const lockPath = join(projectRoot, 'skills-lock.json');
|
||||
if (!existsSync(lockPath)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(lockPath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a skill directory belongs to Impeccable. Three layered
|
||||
* signals, in order of reliability:
|
||||
* 1. Lock source equals "pbakaus/impeccable" (authoritative).
|
||||
* 2. SKILL.md body contains the word "impeccable".
|
||||
* 3. SKILL.md body contains a per-skill fingerprint (for harden and
|
||||
* optimize, whose v2.x SKILL.md never mentioned the pack name).
|
||||
*/
|
||||
export function isImpeccableSkill(skillDir, { skillName, lock } = {}) {
|
||||
// 1. Authoritative: the lock file claims this skill is ours.
|
||||
if (skillName && lock?.skills?.[skillName]?.source === 'pbakaus/impeccable') {
|
||||
return true;
|
||||
}
|
||||
const skillMd = join(skillDir, 'SKILL.md');
|
||||
if (!existsSync(skillMd)) return false;
|
||||
let content;
|
||||
try {
|
||||
content = readFileSync(skillMd, 'utf-8');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
// 2. Word-level content heuristic.
|
||||
if (/impeccable/i.test(content)) return true;
|
||||
// 3. Per-skill fingerprint for old skills that never mentioned the pack.
|
||||
// Strip the i- prefix so both `harden` and `i-harden` resolve to the
|
||||
// same fingerprint entry.
|
||||
const unprefixed = skillName?.startsWith('i-') ? skillName.slice(2) : skillName;
|
||||
const fingerprint = unprefixed && SKILL_FINGERPRINTS[unprefixed];
|
||||
if (fingerprint && content.includes(fingerprint)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full list of names to check: each deprecated name, plus
|
||||
* its i-prefixed variant.
|
||||
*/
|
||||
export function buildTargetNames() {
|
||||
const names = [];
|
||||
for (const name of DEPRECATED_NAMES) {
|
||||
names.push(name);
|
||||
names.push(`i-${name}`);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find every skills directory across all harness dirs in the project.
|
||||
* Returns absolute paths that exist on disk.
|
||||
*/
|
||||
export function findSkillsDirs(projectRoot) {
|
||||
const dirs = [];
|
||||
for (const harness of HARNESS_DIRS) {
|
||||
const candidate = join(projectRoot, harness, 'skills');
|
||||
if (existsSync(candidate)) {
|
||||
dirs.push(candidate);
|
||||
}
|
||||
}
|
||||
return dirs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove deprecated skill directories/symlinks from all harness dirs.
|
||||
* Reads skills-lock.json so the authoritative "source" field can
|
||||
* drive deletion even when SKILL.md never mentions impeccable.
|
||||
* Returns an array of paths that were deleted.
|
||||
*/
|
||||
export function removeDeprecatedSkills(projectRoot, lock) {
|
||||
if (lock === undefined) lock = loadLock(projectRoot);
|
||||
const targets = buildTargetNames();
|
||||
const skillsDirs = findSkillsDirs(projectRoot);
|
||||
const deleted = [];
|
||||
|
||||
for (const skillsDir of skillsDirs) {
|
||||
for (const name of targets) {
|
||||
const skillPath = join(skillsDir, name);
|
||||
|
||||
// Use lstat to detect symlinks (existsSync follows symlinks and
|
||||
// returns false for dangling ones).
|
||||
let stat;
|
||||
try {
|
||||
stat = lstatSync(skillPath);
|
||||
} catch {
|
||||
continue; // does not exist at all
|
||||
}
|
||||
|
||||
if (stat.isSymbolicLink()) {
|
||||
// Symlink: check the target if it's alive, otherwise treat
|
||||
// dangling symlinks to deprecated names as safe to remove.
|
||||
const targetAlive = existsSync(skillPath);
|
||||
const isMatch = targetAlive
|
||||
? isImpeccableSkill(skillPath, { skillName: name, lock })
|
||||
: true;
|
||||
if (isMatch) {
|
||||
unlinkSync(skillPath);
|
||||
deleted.push(skillPath);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular directory -- verify it belongs to impeccable
|
||||
if (isImpeccableSkill(skillPath, { skillName: name, lock })) {
|
||||
rmSync(skillPath, { recursive: true, force: true });
|
||||
deleted.push(skillPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove deprecated entries from skills-lock.json.
|
||||
* Only removes entries whose source is "pbakaus/impeccable".
|
||||
* Returns the list of removed skill names.
|
||||
*/
|
||||
export function cleanSkillsLock(projectRoot) {
|
||||
const lockPath = join(projectRoot, 'skills-lock.json');
|
||||
if (!existsSync(lockPath)) return [];
|
||||
|
||||
let lock;
|
||||
try {
|
||||
lock = JSON.parse(readFileSync(lockPath, 'utf-8'));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!lock.skills || typeof lock.skills !== 'object') return [];
|
||||
|
||||
const targets = buildTargetNames();
|
||||
const removed = [];
|
||||
|
||||
for (const name of targets) {
|
||||
const entry = lock.skills[name];
|
||||
if (!entry) continue;
|
||||
// Only remove if it belongs to impeccable
|
||||
if (entry.source === 'pbakaus/impeccable') {
|
||||
delete lock.skills[name];
|
||||
removed.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
if (removed.length > 0) {
|
||||
writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full cleanup. Returns a summary object.
|
||||
*
|
||||
* Order matters: read the lock and delete directories first, then
|
||||
* strip lock entries. Otherwise the authoritative signal is gone by
|
||||
* the time directory deletion runs.
|
||||
*/
|
||||
export function cleanup(projectRoot) {
|
||||
const root = projectRoot || findProjectRoot();
|
||||
const lock = loadLock(root);
|
||||
const deletedPaths = removeDeprecatedSkills(root, lock);
|
||||
const removedLockEntries = cleanSkillsLock(root);
|
||||
return { deletedPaths, removedLockEntries, projectRoot: root };
|
||||
}
|
||||
|
||||
// CLI entry point
|
||||
if (process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname)) {
|
||||
const result = cleanup();
|
||||
if (result.deletedPaths.length === 0 && result.removedLockEntries.length === 0) {
|
||||
console.log('No deprecated Impeccable skills found. Nothing to clean up.');
|
||||
} else {
|
||||
if (result.deletedPaths.length > 0) {
|
||||
console.log(`Removed ${result.deletedPaths.length} deprecated skill(s):`);
|
||||
for (const p of result.deletedPaths) console.log(` - ${p}`);
|
||||
}
|
||||
if (result.removedLockEntries.length > 0) {
|
||||
console.log(`Cleaned ${result.removedLockEntries.length} entry/entries from skills-lock.json:`);
|
||||
for (const name of result.removedLockEntries) console.log(` - ${name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Cleans up deprecated Impeccable skill files, symlinks, and
|
||||
* skills-lock.json entries left over from previous versions.
|
||||
*
|
||||
* Safe to run repeatedly -- it is a no-op when nothing needs cleaning.
|
||||
*
|
||||
* Usage (from the project root):
|
||||
* node {{scripts_path}}/cleanup-deprecated.mjs
|
||||
*
|
||||
* What it does:
|
||||
* 1. Finds every harness-specific skills directory (.claude/skills,
|
||||
* .cursor/skills, .agents/skills, etc.).
|
||||
* 2. For each deprecated skill name (with and without i- prefix),
|
||||
* checks if the directory exists and its SKILL.md mentions
|
||||
* "impeccable" (to avoid deleting unrelated user skills).
|
||||
* 3. Deletes confirmed matches (files, directories, or symlinks).
|
||||
* 4. Removes the corresponding entries from skills-lock.json.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, rmSync, readdirSync, statSync, lstatSync, unlinkSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
// Skills that were renamed, merged, or folded in v2.0, v2.1, and v3.0.
|
||||
const DEPRECATED_NAMES = [
|
||||
// v2.0 renames
|
||||
'frontend-design', // renamed to impeccable
|
||||
'teach-impeccable', // folded into /impeccable init
|
||||
// v2.1 merges
|
||||
'arrange', // renamed to layout
|
||||
'normalize', // merged into polish
|
||||
'onboard', // merged into harden
|
||||
'extract', // merged into /impeccable extract
|
||||
// v3.0 consolidation: all standalone skills -> /impeccable sub-commands
|
||||
'adapt',
|
||||
'animate',
|
||||
'audit',
|
||||
'bolder',
|
||||
'clarify',
|
||||
'colorize',
|
||||
'critique',
|
||||
'delight',
|
||||
'distill',
|
||||
'harden',
|
||||
'layout',
|
||||
'optimize',
|
||||
'overdrive',
|
||||
'polish',
|
||||
'quieter',
|
||||
'shape',
|
||||
'typeset',
|
||||
];
|
||||
|
||||
// All known harness directories that may contain a skills/ subfolder.
|
||||
const HARNESS_DIRS = [
|
||||
'.claude', '.cursor', '.gemini', '.codex', '.agents',
|
||||
'.trae', '.trae-cn', '.pi', '.opencode', '.kiro', '.rovodev',
|
||||
];
|
||||
|
||||
// Per-skill fingerprints for SKILL.md bodies that never mentioned
|
||||
// "impeccable" in their v2.x source. Used as a last-resort match
|
||||
// when no skills-lock.json exists and the word heuristic fails.
|
||||
// The strings are lifted verbatim from the v2.x frontmatter
|
||||
// descriptions, so collisions with hand-written user skills are
|
||||
// vanishingly unlikely.
|
||||
const SKILL_FINGERPRINTS = {
|
||||
harden: 'Make interfaces production-ready: error handling, empty states',
|
||||
optimize: 'Diagnoses and fixes UI performance across loading speed',
|
||||
};
|
||||
|
||||
/**
|
||||
* Walk up from startDir until we find a directory that looks like a
|
||||
* project root (has package.json, .git, or skills-lock.json).
|
||||
*/
|
||||
export function findProjectRoot(startDir = process.cwd()) {
|
||||
let dir = resolve(startDir);
|
||||
const { root } = { root: '/' };
|
||||
while (dir !== root) {
|
||||
if (
|
||||
existsSync(join(dir, 'package.json')) ||
|
||||
existsSync(join(dir, '.git')) ||
|
||||
existsSync(join(dir, 'skills-lock.json'))
|
||||
) {
|
||||
return dir;
|
||||
}
|
||||
const parent = resolve(dir, '..');
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return resolve(startDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load skills-lock.json from the project root, or null if missing/unreadable.
|
||||
*/
|
||||
export function loadLock(projectRoot) {
|
||||
const lockPath = join(projectRoot, 'skills-lock.json');
|
||||
if (!existsSync(lockPath)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(lockPath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a skill directory belongs to Impeccable. Three layered
|
||||
* signals, in order of reliability:
|
||||
* 1. Lock source equals "pbakaus/impeccable" (authoritative).
|
||||
* 2. SKILL.md body contains the word "impeccable".
|
||||
* 3. SKILL.md body contains a per-skill fingerprint (for harden and
|
||||
* optimize, whose v2.x SKILL.md never mentioned the pack name).
|
||||
*/
|
||||
export function isImpeccableSkill(skillDir, { skillName, lock } = {}) {
|
||||
// 1. Authoritative: the lock file claims this skill is ours.
|
||||
if (skillName && lock?.skills?.[skillName]?.source === 'pbakaus/impeccable') {
|
||||
return true;
|
||||
}
|
||||
const skillMd = join(skillDir, 'SKILL.md');
|
||||
if (!existsSync(skillMd)) return false;
|
||||
let content;
|
||||
try {
|
||||
content = readFileSync(skillMd, 'utf-8');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
// 2. Word-level content heuristic.
|
||||
if (/impeccable/i.test(content)) return true;
|
||||
// 3. Per-skill fingerprint for old skills that never mentioned the pack.
|
||||
// Strip the i- prefix so both `harden` and `i-harden` resolve to the
|
||||
// same fingerprint entry.
|
||||
const unprefixed = skillName?.startsWith('i-') ? skillName.slice(2) : skillName;
|
||||
const fingerprint = unprefixed && SKILL_FINGERPRINTS[unprefixed];
|
||||
if (fingerprint && content.includes(fingerprint)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full list of names to check: each deprecated name, plus
|
||||
* its i-prefixed variant.
|
||||
*/
|
||||
export function buildTargetNames() {
|
||||
const names = [];
|
||||
for (const name of DEPRECATED_NAMES) {
|
||||
names.push(name);
|
||||
names.push(`i-${name}`);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find every skills directory across all harness dirs in the project.
|
||||
* Returns absolute paths that exist on disk.
|
||||
*/
|
||||
export function findSkillsDirs(projectRoot) {
|
||||
const dirs = [];
|
||||
for (const harness of HARNESS_DIRS) {
|
||||
const candidate = join(projectRoot, harness, 'skills');
|
||||
if (existsSync(candidate)) {
|
||||
dirs.push(candidate);
|
||||
}
|
||||
}
|
||||
return dirs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove deprecated skill directories/symlinks from all harness dirs.
|
||||
* Reads skills-lock.json so the authoritative "source" field can
|
||||
* drive deletion even when SKILL.md never mentions impeccable.
|
||||
* Returns an array of paths that were deleted.
|
||||
*/
|
||||
export function removeDeprecatedSkills(projectRoot, lock) {
|
||||
if (lock === undefined) lock = loadLock(projectRoot);
|
||||
const targets = buildTargetNames();
|
||||
const skillsDirs = findSkillsDirs(projectRoot);
|
||||
const deleted = [];
|
||||
|
||||
for (const skillsDir of skillsDirs) {
|
||||
for (const name of targets) {
|
||||
const skillPath = join(skillsDir, name);
|
||||
|
||||
// Use lstat to detect symlinks (existsSync follows symlinks and
|
||||
// returns false for dangling ones).
|
||||
let stat;
|
||||
try {
|
||||
stat = lstatSync(skillPath);
|
||||
} catch {
|
||||
continue; // does not exist at all
|
||||
}
|
||||
|
||||
if (stat.isSymbolicLink()) {
|
||||
// Symlink: check the target if it's alive, otherwise treat
|
||||
// dangling symlinks to deprecated names as safe to remove.
|
||||
const targetAlive = existsSync(skillPath);
|
||||
const isMatch = targetAlive
|
||||
? isImpeccableSkill(skillPath, { skillName: name, lock })
|
||||
: true;
|
||||
if (isMatch) {
|
||||
unlinkSync(skillPath);
|
||||
deleted.push(skillPath);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular directory -- verify it belongs to impeccable
|
||||
if (isImpeccableSkill(skillPath, { skillName: name, lock })) {
|
||||
rmSync(skillPath, { recursive: true, force: true });
|
||||
deleted.push(skillPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove deprecated entries from skills-lock.json.
|
||||
* Only removes entries whose source is "pbakaus/impeccable".
|
||||
* Returns the list of removed skill names.
|
||||
*/
|
||||
export function cleanSkillsLock(projectRoot) {
|
||||
const lockPath = join(projectRoot, 'skills-lock.json');
|
||||
if (!existsSync(lockPath)) return [];
|
||||
|
||||
let lock;
|
||||
try {
|
||||
lock = JSON.parse(readFileSync(lockPath, 'utf-8'));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!lock.skills || typeof lock.skills !== 'object') return [];
|
||||
|
||||
const targets = buildTargetNames();
|
||||
const removed = [];
|
||||
|
||||
for (const name of targets) {
|
||||
const entry = lock.skills[name];
|
||||
if (!entry) continue;
|
||||
// Only remove if it belongs to impeccable
|
||||
if (entry.source === 'pbakaus/impeccable') {
|
||||
delete lock.skills[name];
|
||||
removed.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
if (removed.length > 0) {
|
||||
writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full cleanup. Returns a summary object.
|
||||
*
|
||||
* Order matters: read the lock and delete directories first, then
|
||||
* strip lock entries. Otherwise the authoritative signal is gone by
|
||||
* the time directory deletion runs.
|
||||
*/
|
||||
export function cleanup(projectRoot) {
|
||||
const root = projectRoot || findProjectRoot();
|
||||
const lock = loadLock(root);
|
||||
const deletedPaths = removeDeprecatedSkills(root, lock);
|
||||
const removedLockEntries = cleanSkillsLock(root);
|
||||
return { deletedPaths, removedLockEntries, projectRoot: root };
|
||||
}
|
||||
|
||||
// CLI entry point
|
||||
if (process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname)) {
|
||||
const result = cleanup();
|
||||
if (result.deletedPaths.length === 0 && result.removedLockEntries.length === 0) {
|
||||
console.log('No deprecated Impeccable skills found. Nothing to clean up.');
|
||||
} else {
|
||||
if (result.deletedPaths.length > 0) {
|
||||
console.log(`Removed ${result.deletedPaths.length} deprecated skill(s):`);
|
||||
for (const p of result.deletedPaths) console.log(` - ${p}`);
|
||||
}
|
||||
if (result.removedLockEntries.length > 0) {
|
||||
console.log(`Cleaned ${result.removedLockEntries.length} entry/entries from skills-lock.json:`);
|
||||
for (const name of result.removedLockEntries) console.log(` - ${name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Cleans up deprecated Impeccable skill files, symlinks, and
|
||||
* skills-lock.json entries left over from previous versions.
|
||||
*
|
||||
* Safe to run repeatedly -- it is a no-op when nothing needs cleaning.
|
||||
*
|
||||
* Usage (from the project root):
|
||||
* node {{scripts_path}}/cleanup-deprecated.mjs
|
||||
*
|
||||
* What it does:
|
||||
* 1. Finds every harness-specific skills directory (.claude/skills,
|
||||
* .cursor/skills, .agents/skills, etc.).
|
||||
* 2. For each deprecated skill name (with and without i- prefix),
|
||||
* checks if the directory exists and its SKILL.md mentions
|
||||
* "impeccable" (to avoid deleting unrelated user skills).
|
||||
* 3. Deletes confirmed matches (files, directories, or symlinks).
|
||||
* 4. Removes the corresponding entries from skills-lock.json.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, rmSync, readdirSync, statSync, lstatSync, unlinkSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
// Skills that were renamed, merged, or folded in v2.0, v2.1, and v3.0.
|
||||
const DEPRECATED_NAMES = [
|
||||
// v2.0 renames
|
||||
'frontend-design', // renamed to impeccable
|
||||
'teach-impeccable', // folded into /impeccable init
|
||||
// v2.1 merges
|
||||
'arrange', // renamed to layout
|
||||
'normalize', // merged into polish
|
||||
'onboard', // merged into harden
|
||||
'extract', // merged into /impeccable extract
|
||||
// v3.0 consolidation: all standalone skills -> /impeccable sub-commands
|
||||
'adapt',
|
||||
'animate',
|
||||
'audit',
|
||||
'bolder',
|
||||
'clarify',
|
||||
'colorize',
|
||||
'critique',
|
||||
'delight',
|
||||
'distill',
|
||||
'harden',
|
||||
'layout',
|
||||
'optimize',
|
||||
'overdrive',
|
||||
'polish',
|
||||
'quieter',
|
||||
'shape',
|
||||
'typeset',
|
||||
];
|
||||
|
||||
// All known harness directories that may contain a skills/ subfolder.
|
||||
const HARNESS_DIRS = [
|
||||
'.claude', '.cursor', '.gemini', '.codex', '.agents',
|
||||
'.trae', '.trae-cn', '.pi', '.opencode', '.kiro', '.rovodev',
|
||||
];
|
||||
|
||||
// Per-skill fingerprints for SKILL.md bodies that never mentioned
|
||||
// "impeccable" in their v2.x source. Used as a last-resort match
|
||||
// when no skills-lock.json exists and the word heuristic fails.
|
||||
// The strings are lifted verbatim from the v2.x frontmatter
|
||||
// descriptions, so collisions with hand-written user skills are
|
||||
// vanishingly unlikely.
|
||||
const SKILL_FINGERPRINTS = {
|
||||
harden: 'Make interfaces production-ready: error handling, empty states',
|
||||
optimize: 'Diagnoses and fixes UI performance across loading speed',
|
||||
};
|
||||
|
||||
/**
|
||||
* Walk up from startDir until we find a directory that looks like a
|
||||
* project root (has package.json, .git, or skills-lock.json).
|
||||
*/
|
||||
export function findProjectRoot(startDir = process.cwd()) {
|
||||
let dir = resolve(startDir);
|
||||
const { root } = { root: '/' };
|
||||
while (dir !== root) {
|
||||
if (
|
||||
existsSync(join(dir, 'package.json')) ||
|
||||
existsSync(join(dir, '.git')) ||
|
||||
existsSync(join(dir, 'skills-lock.json'))
|
||||
) {
|
||||
return dir;
|
||||
}
|
||||
const parent = resolve(dir, '..');
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return resolve(startDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load skills-lock.json from the project root, or null if missing/unreadable.
|
||||
*/
|
||||
export function loadLock(projectRoot) {
|
||||
const lockPath = join(projectRoot, 'skills-lock.json');
|
||||
if (!existsSync(lockPath)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(lockPath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a skill directory belongs to Impeccable. Three layered
|
||||
* signals, in order of reliability:
|
||||
* 1. Lock source equals "pbakaus/impeccable" (authoritative).
|
||||
* 2. SKILL.md body contains the word "impeccable".
|
||||
* 3. SKILL.md body contains a per-skill fingerprint (for harden and
|
||||
* optimize, whose v2.x SKILL.md never mentioned the pack name).
|
||||
*/
|
||||
export function isImpeccableSkill(skillDir, { skillName, lock } = {}) {
|
||||
// 1. Authoritative: the lock file claims this skill is ours.
|
||||
if (skillName && lock?.skills?.[skillName]?.source === 'pbakaus/impeccable') {
|
||||
return true;
|
||||
}
|
||||
const skillMd = join(skillDir, 'SKILL.md');
|
||||
if (!existsSync(skillMd)) return false;
|
||||
let content;
|
||||
try {
|
||||
content = readFileSync(skillMd, 'utf-8');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
// 2. Word-level content heuristic.
|
||||
if (/impeccable/i.test(content)) return true;
|
||||
// 3. Per-skill fingerprint for old skills that never mentioned the pack.
|
||||
// Strip the i- prefix so both `harden` and `i-harden` resolve to the
|
||||
// same fingerprint entry.
|
||||
const unprefixed = skillName?.startsWith('i-') ? skillName.slice(2) : skillName;
|
||||
const fingerprint = unprefixed && SKILL_FINGERPRINTS[unprefixed];
|
||||
if (fingerprint && content.includes(fingerprint)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full list of names to check: each deprecated name, plus
|
||||
* its i-prefixed variant.
|
||||
*/
|
||||
export function buildTargetNames() {
|
||||
const names = [];
|
||||
for (const name of DEPRECATED_NAMES) {
|
||||
names.push(name);
|
||||
names.push(`i-${name}`);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find every skills directory across all harness dirs in the project.
|
||||
* Returns absolute paths that exist on disk.
|
||||
*/
|
||||
export function findSkillsDirs(projectRoot) {
|
||||
const dirs = [];
|
||||
for (const harness of HARNESS_DIRS) {
|
||||
const candidate = join(projectRoot, harness, 'skills');
|
||||
if (existsSync(candidate)) {
|
||||
dirs.push(candidate);
|
||||
}
|
||||
}
|
||||
return dirs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove deprecated skill directories/symlinks from all harness dirs.
|
||||
* Reads skills-lock.json so the authoritative "source" field can
|
||||
* drive deletion even when SKILL.md never mentions impeccable.
|
||||
* Returns an array of paths that were deleted.
|
||||
*/
|
||||
export function removeDeprecatedSkills(projectRoot, lock) {
|
||||
if (lock === undefined) lock = loadLock(projectRoot);
|
||||
const targets = buildTargetNames();
|
||||
const skillsDirs = findSkillsDirs(projectRoot);
|
||||
const deleted = [];
|
||||
|
||||
for (const skillsDir of skillsDirs) {
|
||||
for (const name of targets) {
|
||||
const skillPath = join(skillsDir, name);
|
||||
|
||||
// Use lstat to detect symlinks (existsSync follows symlinks and
|
||||
// returns false for dangling ones).
|
||||
let stat;
|
||||
try {
|
||||
stat = lstatSync(skillPath);
|
||||
} catch {
|
||||
continue; // does not exist at all
|
||||
}
|
||||
|
||||
if (stat.isSymbolicLink()) {
|
||||
// Symlink: check the target if it's alive, otherwise treat
|
||||
// dangling symlinks to deprecated names as safe to remove.
|
||||
const targetAlive = existsSync(skillPath);
|
||||
const isMatch = targetAlive
|
||||
? isImpeccableSkill(skillPath, { skillName: name, lock })
|
||||
: true;
|
||||
if (isMatch) {
|
||||
unlinkSync(skillPath);
|
||||
deleted.push(skillPath);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular directory -- verify it belongs to impeccable
|
||||
if (isImpeccableSkill(skillPath, { skillName: name, lock })) {
|
||||
rmSync(skillPath, { recursive: true, force: true });
|
||||
deleted.push(skillPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove deprecated entries from skills-lock.json.
|
||||
* Only removes entries whose source is "pbakaus/impeccable".
|
||||
* Returns the list of removed skill names.
|
||||
*/
|
||||
export function cleanSkillsLock(projectRoot) {
|
||||
const lockPath = join(projectRoot, 'skills-lock.json');
|
||||
if (!existsSync(lockPath)) return [];
|
||||
|
||||
let lock;
|
||||
try {
|
||||
lock = JSON.parse(readFileSync(lockPath, 'utf-8'));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!lock.skills || typeof lock.skills !== 'object') return [];
|
||||
|
||||
const targets = buildTargetNames();
|
||||
const removed = [];
|
||||
|
||||
for (const name of targets) {
|
||||
const entry = lock.skills[name];
|
||||
if (!entry) continue;
|
||||
// Only remove if it belongs to impeccable
|
||||
if (entry.source === 'pbakaus/impeccable') {
|
||||
delete lock.skills[name];
|
||||
removed.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
if (removed.length > 0) {
|
||||
writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full cleanup. Returns a summary object.
|
||||
*
|
||||
* Order matters: read the lock and delete directories first, then
|
||||
* strip lock entries. Otherwise the authoritative signal is gone by
|
||||
* the time directory deletion runs.
|
||||
*/
|
||||
export function cleanup(projectRoot) {
|
||||
const root = projectRoot || findProjectRoot();
|
||||
const lock = loadLock(root);
|
||||
const deletedPaths = removeDeprecatedSkills(root, lock);
|
||||
const removedLockEntries = cleanSkillsLock(root);
|
||||
return { deletedPaths, removedLockEntries, projectRoot: root };
|
||||
}
|
||||
|
||||
// CLI entry point
|
||||
if (process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname)) {
|
||||
const result = cleanup();
|
||||
if (result.deletedPaths.length === 0 && result.removedLockEntries.length === 0) {
|
||||
console.log('No deprecated Impeccable skills found. Nothing to clean up.');
|
||||
} else {
|
||||
if (result.deletedPaths.length > 0) {
|
||||
console.log(`Removed ${result.deletedPaths.length} deprecated skill(s):`);
|
||||
for (const p of result.deletedPaths) console.log(` - ${p}`);
|
||||
}
|
||||
if (result.removedLockEntries.length > 0) {
|
||||
console.log(`Cleaned ${result.removedLockEntries.length} entry/entries from skills-lock.json:`);
|
||||
for (const name of result.removedLockEntries) console.log(` - ${name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Cleans up deprecated Impeccable skill files, symlinks, and
|
||||
* skills-lock.json entries left over from previous versions.
|
||||
*
|
||||
* Safe to run repeatedly -- it is a no-op when nothing needs cleaning.
|
||||
*
|
||||
* Usage (from the project root):
|
||||
* node {{scripts_path}}/cleanup-deprecated.mjs
|
||||
*
|
||||
* What it does:
|
||||
* 1. Finds every harness-specific skills directory (.claude/skills,
|
||||
* .cursor/skills, .agents/skills, etc.).
|
||||
* 2. For each deprecated skill name (with and without i- prefix),
|
||||
* checks if the directory exists and its SKILL.md mentions
|
||||
* "impeccable" (to avoid deleting unrelated user skills).
|
||||
* 3. Deletes confirmed matches (files, directories, or symlinks).
|
||||
* 4. Removes the corresponding entries from skills-lock.json.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, rmSync, readdirSync, statSync, lstatSync, unlinkSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
// Skills that were renamed, merged, or folded in v2.0, v2.1, and v3.0.
|
||||
const DEPRECATED_NAMES = [
|
||||
// v2.0 renames
|
||||
'frontend-design', // renamed to impeccable
|
||||
'teach-impeccable', // folded into /impeccable init
|
||||
// v2.1 merges
|
||||
'arrange', // renamed to layout
|
||||
'normalize', // merged into polish
|
||||
'onboard', // merged into harden
|
||||
'extract', // merged into /impeccable extract
|
||||
// v3.0 consolidation: all standalone skills -> /impeccable sub-commands
|
||||
'adapt',
|
||||
'animate',
|
||||
'audit',
|
||||
'bolder',
|
||||
'clarify',
|
||||
'colorize',
|
||||
'critique',
|
||||
'delight',
|
||||
'distill',
|
||||
'harden',
|
||||
'layout',
|
||||
'optimize',
|
||||
'overdrive',
|
||||
'polish',
|
||||
'quieter',
|
||||
'shape',
|
||||
'typeset',
|
||||
];
|
||||
|
||||
// All known harness directories that may contain a skills/ subfolder.
|
||||
const HARNESS_DIRS = [
|
||||
'.claude', '.cursor', '.gemini', '.codex', '.agents',
|
||||
'.trae', '.trae-cn', '.pi', '.opencode', '.kiro', '.rovodev',
|
||||
];
|
||||
|
||||
// Per-skill fingerprints for SKILL.md bodies that never mentioned
|
||||
// "impeccable" in their v2.x source. Used as a last-resort match
|
||||
// when no skills-lock.json exists and the word heuristic fails.
|
||||
// The strings are lifted verbatim from the v2.x frontmatter
|
||||
// descriptions, so collisions with hand-written user skills are
|
||||
// vanishingly unlikely.
|
||||
const SKILL_FINGERPRINTS = {
|
||||
harden: 'Make interfaces production-ready: error handling, empty states',
|
||||
optimize: 'Diagnoses and fixes UI performance across loading speed',
|
||||
};
|
||||
|
||||
/**
|
||||
* Walk up from startDir until we find a directory that looks like a
|
||||
* project root (has package.json, .git, or skills-lock.json).
|
||||
*/
|
||||
export function findProjectRoot(startDir = process.cwd()) {
|
||||
let dir = resolve(startDir);
|
||||
const { root } = { root: '/' };
|
||||
while (dir !== root) {
|
||||
if (
|
||||
existsSync(join(dir, 'package.json')) ||
|
||||
existsSync(join(dir, '.git')) ||
|
||||
existsSync(join(dir, 'skills-lock.json'))
|
||||
) {
|
||||
return dir;
|
||||
}
|
||||
const parent = resolve(dir, '..');
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return resolve(startDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load skills-lock.json from the project root, or null if missing/unreadable.
|
||||
*/
|
||||
export function loadLock(projectRoot) {
|
||||
const lockPath = join(projectRoot, 'skills-lock.json');
|
||||
if (!existsSync(lockPath)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(lockPath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a skill directory belongs to Impeccable. Three layered
|
||||
* signals, in order of reliability:
|
||||
* 1. Lock source equals "pbakaus/impeccable" (authoritative).
|
||||
* 2. SKILL.md body contains the word "impeccable".
|
||||
* 3. SKILL.md body contains a per-skill fingerprint (for harden and
|
||||
* optimize, whose v2.x SKILL.md never mentioned the pack name).
|
||||
*/
|
||||
export function isImpeccableSkill(skillDir, { skillName, lock } = {}) {
|
||||
// 1. Authoritative: the lock file claims this skill is ours.
|
||||
if (skillName && lock?.skills?.[skillName]?.source === 'pbakaus/impeccable') {
|
||||
return true;
|
||||
}
|
||||
const skillMd = join(skillDir, 'SKILL.md');
|
||||
if (!existsSync(skillMd)) return false;
|
||||
let content;
|
||||
try {
|
||||
content = readFileSync(skillMd, 'utf-8');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
// 2. Word-level content heuristic.
|
||||
if (/impeccable/i.test(content)) return true;
|
||||
// 3. Per-skill fingerprint for old skills that never mentioned the pack.
|
||||
// Strip the i- prefix so both `harden` and `i-harden` resolve to the
|
||||
// same fingerprint entry.
|
||||
const unprefixed = skillName?.startsWith('i-') ? skillName.slice(2) : skillName;
|
||||
const fingerprint = unprefixed && SKILL_FINGERPRINTS[unprefixed];
|
||||
if (fingerprint && content.includes(fingerprint)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full list of names to check: each deprecated name, plus
|
||||
* its i-prefixed variant.
|
||||
*/
|
||||
export function buildTargetNames() {
|
||||
const names = [];
|
||||
for (const name of DEPRECATED_NAMES) {
|
||||
names.push(name);
|
||||
names.push(`i-${name}`);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find every skills directory across all harness dirs in the project.
|
||||
* Returns absolute paths that exist on disk.
|
||||
*/
|
||||
export function findSkillsDirs(projectRoot) {
|
||||
const dirs = [];
|
||||
for (const harness of HARNESS_DIRS) {
|
||||
const candidate = join(projectRoot, harness, 'skills');
|
||||
if (existsSync(candidate)) {
|
||||
dirs.push(candidate);
|
||||
}
|
||||
}
|
||||
return dirs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove deprecated skill directories/symlinks from all harness dirs.
|
||||
* Reads skills-lock.json so the authoritative "source" field can
|
||||
* drive deletion even when SKILL.md never mentions impeccable.
|
||||
* Returns an array of paths that were deleted.
|
||||
*/
|
||||
export function removeDeprecatedSkills(projectRoot, lock) {
|
||||
if (lock === undefined) lock = loadLock(projectRoot);
|
||||
const targets = buildTargetNames();
|
||||
const skillsDirs = findSkillsDirs(projectRoot);
|
||||
const deleted = [];
|
||||
|
||||
for (const skillsDir of skillsDirs) {
|
||||
for (const name of targets) {
|
||||
const skillPath = join(skillsDir, name);
|
||||
|
||||
// Use lstat to detect symlinks (existsSync follows symlinks and
|
||||
// returns false for dangling ones).
|
||||
let stat;
|
||||
try {
|
||||
stat = lstatSync(skillPath);
|
||||
} catch {
|
||||
continue; // does not exist at all
|
||||
}
|
||||
|
||||
if (stat.isSymbolicLink()) {
|
||||
// Symlink: check the target if it's alive, otherwise treat
|
||||
// dangling symlinks to deprecated names as safe to remove.
|
||||
const targetAlive = existsSync(skillPath);
|
||||
const isMatch = targetAlive
|
||||
? isImpeccableSkill(skillPath, { skillName: name, lock })
|
||||
: true;
|
||||
if (isMatch) {
|
||||
unlinkSync(skillPath);
|
||||
deleted.push(skillPath);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular directory -- verify it belongs to impeccable
|
||||
if (isImpeccableSkill(skillPath, { skillName: name, lock })) {
|
||||
rmSync(skillPath, { recursive: true, force: true });
|
||||
deleted.push(skillPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove deprecated entries from skills-lock.json.
|
||||
* Only removes entries whose source is "pbakaus/impeccable".
|
||||
* Returns the list of removed skill names.
|
||||
*/
|
||||
export function cleanSkillsLock(projectRoot) {
|
||||
const lockPath = join(projectRoot, 'skills-lock.json');
|
||||
if (!existsSync(lockPath)) return [];
|
||||
|
||||
let lock;
|
||||
try {
|
||||
lock = JSON.parse(readFileSync(lockPath, 'utf-8'));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!lock.skills || typeof lock.skills !== 'object') return [];
|
||||
|
||||
const targets = buildTargetNames();
|
||||
const removed = [];
|
||||
|
||||
for (const name of targets) {
|
||||
const entry = lock.skills[name];
|
||||
if (!entry) continue;
|
||||
// Only remove if it belongs to impeccable
|
||||
if (entry.source === 'pbakaus/impeccable') {
|
||||
delete lock.skills[name];
|
||||
removed.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
if (removed.length > 0) {
|
||||
writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full cleanup. Returns a summary object.
|
||||
*
|
||||
* Order matters: read the lock and delete directories first, then
|
||||
* strip lock entries. Otherwise the authoritative signal is gone by
|
||||
* the time directory deletion runs.
|
||||
*/
|
||||
export function cleanup(projectRoot) {
|
||||
const root = projectRoot || findProjectRoot();
|
||||
const lock = loadLock(root);
|
||||
const deletedPaths = removeDeprecatedSkills(root, lock);
|
||||
const removedLockEntries = cleanSkillsLock(root);
|
||||
return { deletedPaths, removedLockEntries, projectRoot: root };
|
||||
}
|
||||
|
||||
// CLI entry point
|
||||
if (process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname)) {
|
||||
const result = cleanup();
|
||||
if (result.deletedPaths.length === 0 && result.removedLockEntries.length === 0) {
|
||||
console.log('No deprecated Impeccable skills found. Nothing to clean up.');
|
||||
} else {
|
||||
if (result.deletedPaths.length > 0) {
|
||||
console.log(`Removed ${result.deletedPaths.length} deprecated skill(s):`);
|
||||
for (const p of result.deletedPaths) console.log(` - ${p}`);
|
||||
}
|
||||
if (result.removedLockEntries.length > 0) {
|
||||
console.log(`Cleaned ${result.removedLockEntries.length} entry/entries from skills-lock.json:`);
|
||||
for (const name of result.removedLockEntries) console.log(` - ${name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Cleans up deprecated Impeccable skill files, symlinks, and
|
||||
* skills-lock.json entries left over from previous versions.
|
||||
*
|
||||
* Safe to run repeatedly -- it is a no-op when nothing needs cleaning.
|
||||
*
|
||||
* Usage (from the project root):
|
||||
* node {{scripts_path}}/cleanup-deprecated.mjs
|
||||
*
|
||||
* What it does:
|
||||
* 1. Finds every harness-specific skills directory (.claude/skills,
|
||||
* .cursor/skills, .agents/skills, etc.).
|
||||
* 2. For each deprecated skill name (with and without i- prefix),
|
||||
* checks if the directory exists and its SKILL.md mentions
|
||||
* "impeccable" (to avoid deleting unrelated user skills).
|
||||
* 3. Deletes confirmed matches (files, directories, or symlinks).
|
||||
* 4. Removes the corresponding entries from skills-lock.json.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, rmSync, readdirSync, statSync, lstatSync, unlinkSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
// Skills that were renamed, merged, or folded in v2.0, v2.1, and v3.0.
|
||||
const DEPRECATED_NAMES = [
|
||||
// v2.0 renames
|
||||
'frontend-design', // renamed to impeccable
|
||||
'teach-impeccable', // folded into /impeccable init
|
||||
// v2.1 merges
|
||||
'arrange', // renamed to layout
|
||||
'normalize', // merged into polish
|
||||
'onboard', // merged into harden
|
||||
'extract', // merged into /impeccable extract
|
||||
// v3.0 consolidation: all standalone skills -> /impeccable sub-commands
|
||||
'adapt',
|
||||
'animate',
|
||||
'audit',
|
||||
'bolder',
|
||||
'clarify',
|
||||
'colorize',
|
||||
'critique',
|
||||
'delight',
|
||||
'distill',
|
||||
'harden',
|
||||
'layout',
|
||||
'optimize',
|
||||
'overdrive',
|
||||
'polish',
|
||||
'quieter',
|
||||
'shape',
|
||||
'typeset',
|
||||
];
|
||||
|
||||
// All known harness directories that may contain a skills/ subfolder.
|
||||
const HARNESS_DIRS = [
|
||||
'.claude', '.cursor', '.gemini', '.codex', '.agents',
|
||||
'.trae', '.trae-cn', '.pi', '.opencode', '.kiro', '.rovodev',
|
||||
];
|
||||
|
||||
// Per-skill fingerprints for SKILL.md bodies that never mentioned
|
||||
// "impeccable" in their v2.x source. Used as a last-resort match
|
||||
// when no skills-lock.json exists and the word heuristic fails.
|
||||
// The strings are lifted verbatim from the v2.x frontmatter
|
||||
// descriptions, so collisions with hand-written user skills are
|
||||
// vanishingly unlikely.
|
||||
const SKILL_FINGERPRINTS = {
|
||||
harden: 'Make interfaces production-ready: error handling, empty states',
|
||||
optimize: 'Diagnoses and fixes UI performance across loading speed',
|
||||
};
|
||||
|
||||
/**
|
||||
* Walk up from startDir until we find a directory that looks like a
|
||||
* project root (has package.json, .git, or skills-lock.json).
|
||||
*/
|
||||
export function findProjectRoot(startDir = process.cwd()) {
|
||||
let dir = resolve(startDir);
|
||||
const { root } = { root: '/' };
|
||||
while (dir !== root) {
|
||||
if (
|
||||
existsSync(join(dir, 'package.json')) ||
|
||||
existsSync(join(dir, '.git')) ||
|
||||
existsSync(join(dir, 'skills-lock.json'))
|
||||
) {
|
||||
return dir;
|
||||
}
|
||||
const parent = resolve(dir, '..');
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return resolve(startDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load skills-lock.json from the project root, or null if missing/unreadable.
|
||||
*/
|
||||
export function loadLock(projectRoot) {
|
||||
const lockPath = join(projectRoot, 'skills-lock.json');
|
||||
if (!existsSync(lockPath)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(lockPath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a skill directory belongs to Impeccable. Three layered
|
||||
* signals, in order of reliability:
|
||||
* 1. Lock source equals "pbakaus/impeccable" (authoritative).
|
||||
* 2. SKILL.md body contains the word "impeccable".
|
||||
* 3. SKILL.md body contains a per-skill fingerprint (for harden and
|
||||
* optimize, whose v2.x SKILL.md never mentioned the pack name).
|
||||
*/
|
||||
export function isImpeccableSkill(skillDir, { skillName, lock } = {}) {
|
||||
// 1. Authoritative: the lock file claims this skill is ours.
|
||||
if (skillName && lock?.skills?.[skillName]?.source === 'pbakaus/impeccable') {
|
||||
return true;
|
||||
}
|
||||
const skillMd = join(skillDir, 'SKILL.md');
|
||||
if (!existsSync(skillMd)) return false;
|
||||
let content;
|
||||
try {
|
||||
content = readFileSync(skillMd, 'utf-8');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
// 2. Word-level content heuristic.
|
||||
if (/impeccable/i.test(content)) return true;
|
||||
// 3. Per-skill fingerprint for old skills that never mentioned the pack.
|
||||
// Strip the i- prefix so both `harden` and `i-harden` resolve to the
|
||||
// same fingerprint entry.
|
||||
const unprefixed = skillName?.startsWith('i-') ? skillName.slice(2) : skillName;
|
||||
const fingerprint = unprefixed && SKILL_FINGERPRINTS[unprefixed];
|
||||
if (fingerprint && content.includes(fingerprint)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full list of names to check: each deprecated name, plus
|
||||
* its i-prefixed variant.
|
||||
*/
|
||||
export function buildTargetNames() {
|
||||
const names = [];
|
||||
for (const name of DEPRECATED_NAMES) {
|
||||
names.push(name);
|
||||
names.push(`i-${name}`);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find every skills directory across all harness dirs in the project.
|
||||
* Returns absolute paths that exist on disk.
|
||||
*/
|
||||
export function findSkillsDirs(projectRoot) {
|
||||
const dirs = [];
|
||||
for (const harness of HARNESS_DIRS) {
|
||||
const candidate = join(projectRoot, harness, 'skills');
|
||||
if (existsSync(candidate)) {
|
||||
dirs.push(candidate);
|
||||
}
|
||||
}
|
||||
return dirs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove deprecated skill directories/symlinks from all harness dirs.
|
||||
* Reads skills-lock.json so the authoritative "source" field can
|
||||
* drive deletion even when SKILL.md never mentions impeccable.
|
||||
* Returns an array of paths that were deleted.
|
||||
*/
|
||||
export function removeDeprecatedSkills(projectRoot, lock) {
|
||||
if (lock === undefined) lock = loadLock(projectRoot);
|
||||
const targets = buildTargetNames();
|
||||
const skillsDirs = findSkillsDirs(projectRoot);
|
||||
const deleted = [];
|
||||
|
||||
for (const skillsDir of skillsDirs) {
|
||||
for (const name of targets) {
|
||||
const skillPath = join(skillsDir, name);
|
||||
|
||||
// Use lstat to detect symlinks (existsSync follows symlinks and
|
||||
// returns false for dangling ones).
|
||||
let stat;
|
||||
try {
|
||||
stat = lstatSync(skillPath);
|
||||
} catch {
|
||||
continue; // does not exist at all
|
||||
}
|
||||
|
||||
if (stat.isSymbolicLink()) {
|
||||
// Symlink: check the target if it's alive, otherwise treat
|
||||
// dangling symlinks to deprecated names as safe to remove.
|
||||
const targetAlive = existsSync(skillPath);
|
||||
const isMatch = targetAlive
|
||||
? isImpeccableSkill(skillPath, { skillName: name, lock })
|
||||
: true;
|
||||
if (isMatch) {
|
||||
unlinkSync(skillPath);
|
||||
deleted.push(skillPath);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular directory -- verify it belongs to impeccable
|
||||
if (isImpeccableSkill(skillPath, { skillName: name, lock })) {
|
||||
rmSync(skillPath, { recursive: true, force: true });
|
||||
deleted.push(skillPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove deprecated entries from skills-lock.json.
|
||||
* Only removes entries whose source is "pbakaus/impeccable".
|
||||
* Returns the list of removed skill names.
|
||||
*/
|
||||
export function cleanSkillsLock(projectRoot) {
|
||||
const lockPath = join(projectRoot, 'skills-lock.json');
|
||||
if (!existsSync(lockPath)) return [];
|
||||
|
||||
let lock;
|
||||
try {
|
||||
lock = JSON.parse(readFileSync(lockPath, 'utf-8'));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!lock.skills || typeof lock.skills !== 'object') return [];
|
||||
|
||||
const targets = buildTargetNames();
|
||||
const removed = [];
|
||||
|
||||
for (const name of targets) {
|
||||
const entry = lock.skills[name];
|
||||
if (!entry) continue;
|
||||
// Only remove if it belongs to impeccable
|
||||
if (entry.source === 'pbakaus/impeccable') {
|
||||
delete lock.skills[name];
|
||||
removed.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
if (removed.length > 0) {
|
||||
writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full cleanup. Returns a summary object.
|
||||
*
|
||||
* Order matters: read the lock and delete directories first, then
|
||||
* strip lock entries. Otherwise the authoritative signal is gone by
|
||||
* the time directory deletion runs.
|
||||
*/
|
||||
export function cleanup(projectRoot) {
|
||||
const root = projectRoot || findProjectRoot();
|
||||
const lock = loadLock(root);
|
||||
const deletedPaths = removeDeprecatedSkills(root, lock);
|
||||
const removedLockEntries = cleanSkillsLock(root);
|
||||
return { deletedPaths, removedLockEntries, projectRoot: root };
|
||||
}
|
||||
|
||||
// CLI entry point
|
||||
if (process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname)) {
|
||||
const result = cleanup();
|
||||
if (result.deletedPaths.length === 0 && result.removedLockEntries.length === 0) {
|
||||
console.log('No deprecated Impeccable skills found. Nothing to clean up.');
|
||||
} else {
|
||||
if (result.deletedPaths.length > 0) {
|
||||
console.log(`Removed ${result.deletedPaths.length} deprecated skill(s):`);
|
||||
for (const p of result.deletedPaths) console.log(` - ${p}`);
|
||||
}
|
||||
if (result.removedLockEntries.length > 0) {
|
||||
console.log(`Cleaned ${result.removedLockEntries.length} entry/entries from skills-lock.json:`);
|
||||
for (const name of result.removedLockEntries) console.log(` - ${name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Cleans up deprecated Impeccable skill files, symlinks, and
|
||||
* skills-lock.json entries left over from previous versions.
|
||||
*
|
||||
* Safe to run repeatedly -- it is a no-op when nothing needs cleaning.
|
||||
*
|
||||
* Usage (from the project root):
|
||||
* node {{scripts_path}}/cleanup-deprecated.mjs
|
||||
*
|
||||
* What it does:
|
||||
* 1. Finds every harness-specific skills directory (.claude/skills,
|
||||
* .cursor/skills, .agents/skills, etc.).
|
||||
* 2. For each deprecated skill name (with and without i- prefix),
|
||||
* checks if the directory exists and its SKILL.md mentions
|
||||
* "impeccable" (to avoid deleting unrelated user skills).
|
||||
* 3. Deletes confirmed matches (files, directories, or symlinks).
|
||||
* 4. Removes the corresponding entries from skills-lock.json.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, rmSync, readdirSync, statSync, lstatSync, unlinkSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
// Skills that were renamed, merged, or folded in v2.0, v2.1, and v3.0.
|
||||
const DEPRECATED_NAMES = [
|
||||
// v2.0 renames
|
||||
'frontend-design', // renamed to impeccable
|
||||
'teach-impeccable', // folded into /impeccable init
|
||||
// v2.1 merges
|
||||
'arrange', // renamed to layout
|
||||
'normalize', // merged into polish
|
||||
'onboard', // merged into harden
|
||||
'extract', // merged into /impeccable extract
|
||||
// v3.0 consolidation: all standalone skills -> /impeccable sub-commands
|
||||
'adapt',
|
||||
'animate',
|
||||
'audit',
|
||||
'bolder',
|
||||
'clarify',
|
||||
'colorize',
|
||||
'critique',
|
||||
'delight',
|
||||
'distill',
|
||||
'harden',
|
||||
'layout',
|
||||
'optimize',
|
||||
'overdrive',
|
||||
'polish',
|
||||
'quieter',
|
||||
'shape',
|
||||
'typeset',
|
||||
];
|
||||
|
||||
// All known harness directories that may contain a skills/ subfolder.
|
||||
const HARNESS_DIRS = [
|
||||
'.claude', '.cursor', '.gemini', '.codex', '.agents',
|
||||
'.trae', '.trae-cn', '.pi', '.opencode', '.kiro', '.rovodev',
|
||||
];
|
||||
|
||||
// Per-skill fingerprints for SKILL.md bodies that never mentioned
|
||||
// "impeccable" in their v2.x source. Used as a last-resort match
|
||||
// when no skills-lock.json exists and the word heuristic fails.
|
||||
// The strings are lifted verbatim from the v2.x frontmatter
|
||||
// descriptions, so collisions with hand-written user skills are
|
||||
// vanishingly unlikely.
|
||||
const SKILL_FINGERPRINTS = {
|
||||
harden: 'Make interfaces production-ready: error handling, empty states',
|
||||
optimize: 'Diagnoses and fixes UI performance across loading speed',
|
||||
};
|
||||
|
||||
/**
|
||||
* Walk up from startDir until we find a directory that looks like a
|
||||
* project root (has package.json, .git, or skills-lock.json).
|
||||
*/
|
||||
export function findProjectRoot(startDir = process.cwd()) {
|
||||
let dir = resolve(startDir);
|
||||
const { root } = { root: '/' };
|
||||
while (dir !== root) {
|
||||
if (
|
||||
existsSync(join(dir, 'package.json')) ||
|
||||
existsSync(join(dir, '.git')) ||
|
||||
existsSync(join(dir, 'skills-lock.json'))
|
||||
) {
|
||||
return dir;
|
||||
}
|
||||
const parent = resolve(dir, '..');
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return resolve(startDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load skills-lock.json from the project root, or null if missing/unreadable.
|
||||
*/
|
||||
export function loadLock(projectRoot) {
|
||||
const lockPath = join(projectRoot, 'skills-lock.json');
|
||||
if (!existsSync(lockPath)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(lockPath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a skill directory belongs to Impeccable. Three layered
|
||||
* signals, in order of reliability:
|
||||
* 1. Lock source equals "pbakaus/impeccable" (authoritative).
|
||||
* 2. SKILL.md body contains the word "impeccable".
|
||||
* 3. SKILL.md body contains a per-skill fingerprint (for harden and
|
||||
* optimize, whose v2.x SKILL.md never mentioned the pack name).
|
||||
*/
|
||||
export function isImpeccableSkill(skillDir, { skillName, lock } = {}) {
|
||||
// 1. Authoritative: the lock file claims this skill is ours.
|
||||
if (skillName && lock?.skills?.[skillName]?.source === 'pbakaus/impeccable') {
|
||||
return true;
|
||||
}
|
||||
const skillMd = join(skillDir, 'SKILL.md');
|
||||
if (!existsSync(skillMd)) return false;
|
||||
let content;
|
||||
try {
|
||||
content = readFileSync(skillMd, 'utf-8');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
// 2. Word-level content heuristic.
|
||||
if (/impeccable/i.test(content)) return true;
|
||||
// 3. Per-skill fingerprint for old skills that never mentioned the pack.
|
||||
// Strip the i- prefix so both `harden` and `i-harden` resolve to the
|
||||
// same fingerprint entry.
|
||||
const unprefixed = skillName?.startsWith('i-') ? skillName.slice(2) : skillName;
|
||||
const fingerprint = unprefixed && SKILL_FINGERPRINTS[unprefixed];
|
||||
if (fingerprint && content.includes(fingerprint)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full list of names to check: each deprecated name, plus
|
||||
* its i-prefixed variant.
|
||||
*/
|
||||
export function buildTargetNames() {
|
||||
const names = [];
|
||||
for (const name of DEPRECATED_NAMES) {
|
||||
names.push(name);
|
||||
names.push(`i-${name}`);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find every skills directory across all harness dirs in the project.
|
||||
* Returns absolute paths that exist on disk.
|
||||
*/
|
||||
export function findSkillsDirs(projectRoot) {
|
||||
const dirs = [];
|
||||
for (const harness of HARNESS_DIRS) {
|
||||
const candidate = join(projectRoot, harness, 'skills');
|
||||
if (existsSync(candidate)) {
|
||||
dirs.push(candidate);
|
||||
}
|
||||
}
|
||||
return dirs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove deprecated skill directories/symlinks from all harness dirs.
|
||||
* Reads skills-lock.json so the authoritative "source" field can
|
||||
* drive deletion even when SKILL.md never mentions impeccable.
|
||||
* Returns an array of paths that were deleted.
|
||||
*/
|
||||
export function removeDeprecatedSkills(projectRoot, lock) {
|
||||
if (lock === undefined) lock = loadLock(projectRoot);
|
||||
const targets = buildTargetNames();
|
||||
const skillsDirs = findSkillsDirs(projectRoot);
|
||||
const deleted = [];
|
||||
|
||||
for (const skillsDir of skillsDirs) {
|
||||
for (const name of targets) {
|
||||
const skillPath = join(skillsDir, name);
|
||||
|
||||
// Use lstat to detect symlinks (existsSync follows symlinks and
|
||||
// returns false for dangling ones).
|
||||
let stat;
|
||||
try {
|
||||
stat = lstatSync(skillPath);
|
||||
} catch {
|
||||
continue; // does not exist at all
|
||||
}
|
||||
|
||||
if (stat.isSymbolicLink()) {
|
||||
// Symlink: check the target if it's alive, otherwise treat
|
||||
// dangling symlinks to deprecated names as safe to remove.
|
||||
const targetAlive = existsSync(skillPath);
|
||||
const isMatch = targetAlive
|
||||
? isImpeccableSkill(skillPath, { skillName: name, lock })
|
||||
: true;
|
||||
if (isMatch) {
|
||||
unlinkSync(skillPath);
|
||||
deleted.push(skillPath);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular directory -- verify it belongs to impeccable
|
||||
if (isImpeccableSkill(skillPath, { skillName: name, lock })) {
|
||||
rmSync(skillPath, { recursive: true, force: true });
|
||||
deleted.push(skillPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove deprecated entries from skills-lock.json.
|
||||
* Only removes entries whose source is "pbakaus/impeccable".
|
||||
* Returns the list of removed skill names.
|
||||
*/
|
||||
export function cleanSkillsLock(projectRoot) {
|
||||
const lockPath = join(projectRoot, 'skills-lock.json');
|
||||
if (!existsSync(lockPath)) return [];
|
||||
|
||||
let lock;
|
||||
try {
|
||||
lock = JSON.parse(readFileSync(lockPath, 'utf-8'));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!lock.skills || typeof lock.skills !== 'object') return [];
|
||||
|
||||
const targets = buildTargetNames();
|
||||
const removed = [];
|
||||
|
||||
for (const name of targets) {
|
||||
const entry = lock.skills[name];
|
||||
if (!entry) continue;
|
||||
// Only remove if it belongs to impeccable
|
||||
if (entry.source === 'pbakaus/impeccable') {
|
||||
delete lock.skills[name];
|
||||
removed.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
if (removed.length > 0) {
|
||||
writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full cleanup. Returns a summary object.
|
||||
*
|
||||
* Order matters: read the lock and delete directories first, then
|
||||
* strip lock entries. Otherwise the authoritative signal is gone by
|
||||
* the time directory deletion runs.
|
||||
*/
|
||||
export function cleanup(projectRoot) {
|
||||
const root = projectRoot || findProjectRoot();
|
||||
const lock = loadLock(root);
|
||||
const deletedPaths = removeDeprecatedSkills(root, lock);
|
||||
const removedLockEntries = cleanSkillsLock(root);
|
||||
return { deletedPaths, removedLockEntries, projectRoot: root };
|
||||
}
|
||||
|
||||
// CLI entry point
|
||||
if (process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname)) {
|
||||
const result = cleanup();
|
||||
if (result.deletedPaths.length === 0 && result.removedLockEntries.length === 0) {
|
||||
console.log('No deprecated Impeccable skills found. Nothing to clean up.');
|
||||
} else {
|
||||
if (result.deletedPaths.length > 0) {
|
||||
console.log(`Removed ${result.deletedPaths.length} deprecated skill(s):`);
|
||||
for (const p of result.deletedPaths) console.log(` - ${p}`);
|
||||
}
|
||||
if (result.removedLockEntries.length > 0) {
|
||||
console.log(`Cleaned ${result.removedLockEntries.length} entry/entries from skills-lock.json:`);
|
||||
for (const name of result.removedLockEntries) console.log(` - ${name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Cleans up deprecated Impeccable skill files, symlinks, and
|
||||
* skills-lock.json entries left over from previous versions.
|
||||
*
|
||||
* Safe to run repeatedly -- it is a no-op when nothing needs cleaning.
|
||||
*
|
||||
* Usage (from the project root):
|
||||
* node {{scripts_path}}/cleanup-deprecated.mjs
|
||||
*
|
||||
* What it does:
|
||||
* 1. Finds every harness-specific skills directory (.claude/skills,
|
||||
* .cursor/skills, .agents/skills, etc.).
|
||||
* 2. For each deprecated skill name (with and without i- prefix),
|
||||
* checks if the directory exists and its SKILL.md mentions
|
||||
* "impeccable" (to avoid deleting unrelated user skills).
|
||||
* 3. Deletes confirmed matches (files, directories, or symlinks).
|
||||
* 4. Removes the corresponding entries from skills-lock.json.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, rmSync, readdirSync, statSync, lstatSync, unlinkSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
// Skills that were renamed, merged, or folded in v2.0, v2.1, and v3.0.
|
||||
const DEPRECATED_NAMES = [
|
||||
// v2.0 renames
|
||||
'frontend-design', // renamed to impeccable
|
||||
'teach-impeccable', // folded into /impeccable init
|
||||
// v2.1 merges
|
||||
'arrange', // renamed to layout
|
||||
'normalize', // merged into polish
|
||||
'onboard', // merged into harden
|
||||
'extract', // merged into /impeccable extract
|
||||
// v3.0 consolidation: all standalone skills -> /impeccable sub-commands
|
||||
'adapt',
|
||||
'animate',
|
||||
'audit',
|
||||
'bolder',
|
||||
'clarify',
|
||||
'colorize',
|
||||
'critique',
|
||||
'delight',
|
||||
'distill',
|
||||
'harden',
|
||||
'layout',
|
||||
'optimize',
|
||||
'overdrive',
|
||||
'polish',
|
||||
'quieter',
|
||||
'shape',
|
||||
'typeset',
|
||||
];
|
||||
|
||||
// All known harness directories that may contain a skills/ subfolder.
|
||||
const HARNESS_DIRS = [
|
||||
'.claude', '.cursor', '.gemini', '.codex', '.agents',
|
||||
'.trae', '.trae-cn', '.pi', '.opencode', '.kiro', '.rovodev',
|
||||
];
|
||||
|
||||
// Per-skill fingerprints for SKILL.md bodies that never mentioned
|
||||
// "impeccable" in their v2.x source. Used as a last-resort match
|
||||
// when no skills-lock.json exists and the word heuristic fails.
|
||||
// The strings are lifted verbatim from the v2.x frontmatter
|
||||
// descriptions, so collisions with hand-written user skills are
|
||||
// vanishingly unlikely.
|
||||
const SKILL_FINGERPRINTS = {
|
||||
harden: 'Make interfaces production-ready: error handling, empty states',
|
||||
optimize: 'Diagnoses and fixes UI performance across loading speed',
|
||||
};
|
||||
|
||||
/**
|
||||
* Walk up from startDir until we find a directory that looks like a
|
||||
* project root (has package.json, .git, or skills-lock.json).
|
||||
*/
|
||||
export function findProjectRoot(startDir = process.cwd()) {
|
||||
let dir = resolve(startDir);
|
||||
const { root } = { root: '/' };
|
||||
while (dir !== root) {
|
||||
if (
|
||||
existsSync(join(dir, 'package.json')) ||
|
||||
existsSync(join(dir, '.git')) ||
|
||||
existsSync(join(dir, 'skills-lock.json'))
|
||||
) {
|
||||
return dir;
|
||||
}
|
||||
const parent = resolve(dir, '..');
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return resolve(startDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load skills-lock.json from the project root, or null if missing/unreadable.
|
||||
*/
|
||||
export function loadLock(projectRoot) {
|
||||
const lockPath = join(projectRoot, 'skills-lock.json');
|
||||
if (!existsSync(lockPath)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(lockPath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a skill directory belongs to Impeccable. Three layered
|
||||
* signals, in order of reliability:
|
||||
* 1. Lock source equals "pbakaus/impeccable" (authoritative).
|
||||
* 2. SKILL.md body contains the word "impeccable".
|
||||
* 3. SKILL.md body contains a per-skill fingerprint (for harden and
|
||||
* optimize, whose v2.x SKILL.md never mentioned the pack name).
|
||||
*/
|
||||
export function isImpeccableSkill(skillDir, { skillName, lock } = {}) {
|
||||
// 1. Authoritative: the lock file claims this skill is ours.
|
||||
if (skillName && lock?.skills?.[skillName]?.source === 'pbakaus/impeccable') {
|
||||
return true;
|
||||
}
|
||||
const skillMd = join(skillDir, 'SKILL.md');
|
||||
if (!existsSync(skillMd)) return false;
|
||||
let content;
|
||||
try {
|
||||
content = readFileSync(skillMd, 'utf-8');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
// 2. Word-level content heuristic.
|
||||
if (/impeccable/i.test(content)) return true;
|
||||
// 3. Per-skill fingerprint for old skills that never mentioned the pack.
|
||||
// Strip the i- prefix so both `harden` and `i-harden` resolve to the
|
||||
// same fingerprint entry.
|
||||
const unprefixed = skillName?.startsWith('i-') ? skillName.slice(2) : skillName;
|
||||
const fingerprint = unprefixed && SKILL_FINGERPRINTS[unprefixed];
|
||||
if (fingerprint && content.includes(fingerprint)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full list of names to check: each deprecated name, plus
|
||||
* its i-prefixed variant.
|
||||
*/
|
||||
export function buildTargetNames() {
|
||||
const names = [];
|
||||
for (const name of DEPRECATED_NAMES) {
|
||||
names.push(name);
|
||||
names.push(`i-${name}`);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find every skills directory across all harness dirs in the project.
|
||||
* Returns absolute paths that exist on disk.
|
||||
*/
|
||||
export function findSkillsDirs(projectRoot) {
|
||||
const dirs = [];
|
||||
for (const harness of HARNESS_DIRS) {
|
||||
const candidate = join(projectRoot, harness, 'skills');
|
||||
if (existsSync(candidate)) {
|
||||
dirs.push(candidate);
|
||||
}
|
||||
}
|
||||
return dirs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove deprecated skill directories/symlinks from all harness dirs.
|
||||
* Reads skills-lock.json so the authoritative "source" field can
|
||||
* drive deletion even when SKILL.md never mentions impeccable.
|
||||
* Returns an array of paths that were deleted.
|
||||
*/
|
||||
export function removeDeprecatedSkills(projectRoot, lock) {
|
||||
if (lock === undefined) lock = loadLock(projectRoot);
|
||||
const targets = buildTargetNames();
|
||||
const skillsDirs = findSkillsDirs(projectRoot);
|
||||
const deleted = [];
|
||||
|
||||
for (const skillsDir of skillsDirs) {
|
||||
for (const name of targets) {
|
||||
const skillPath = join(skillsDir, name);
|
||||
|
||||
// Use lstat to detect symlinks (existsSync follows symlinks and
|
||||
// returns false for dangling ones).
|
||||
let stat;
|
||||
try {
|
||||
stat = lstatSync(skillPath);
|
||||
} catch {
|
||||
continue; // does not exist at all
|
||||
}
|
||||
|
||||
if (stat.isSymbolicLink()) {
|
||||
// Symlink: check the target if it's alive, otherwise treat
|
||||
// dangling symlinks to deprecated names as safe to remove.
|
||||
const targetAlive = existsSync(skillPath);
|
||||
const isMatch = targetAlive
|
||||
? isImpeccableSkill(skillPath, { skillName: name, lock })
|
||||
: true;
|
||||
if (isMatch) {
|
||||
unlinkSync(skillPath);
|
||||
deleted.push(skillPath);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular directory -- verify it belongs to impeccable
|
||||
if (isImpeccableSkill(skillPath, { skillName: name, lock })) {
|
||||
rmSync(skillPath, { recursive: true, force: true });
|
||||
deleted.push(skillPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove deprecated entries from skills-lock.json.
|
||||
* Only removes entries whose source is "pbakaus/impeccable".
|
||||
* Returns the list of removed skill names.
|
||||
*/
|
||||
export function cleanSkillsLock(projectRoot) {
|
||||
const lockPath = join(projectRoot, 'skills-lock.json');
|
||||
if (!existsSync(lockPath)) return [];
|
||||
|
||||
let lock;
|
||||
try {
|
||||
lock = JSON.parse(readFileSync(lockPath, 'utf-8'));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!lock.skills || typeof lock.skills !== 'object') return [];
|
||||
|
||||
const targets = buildTargetNames();
|
||||
const removed = [];
|
||||
|
||||
for (const name of targets) {
|
||||
const entry = lock.skills[name];
|
||||
if (!entry) continue;
|
||||
// Only remove if it belongs to impeccable
|
||||
if (entry.source === 'pbakaus/impeccable') {
|
||||
delete lock.skills[name];
|
||||
removed.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
if (removed.length > 0) {
|
||||
writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full cleanup. Returns a summary object.
|
||||
*
|
||||
* Order matters: read the lock and delete directories first, then
|
||||
* strip lock entries. Otherwise the authoritative signal is gone by
|
||||
* the time directory deletion runs.
|
||||
*/
|
||||
export function cleanup(projectRoot) {
|
||||
const root = projectRoot || findProjectRoot();
|
||||
const lock = loadLock(root);
|
||||
const deletedPaths = removeDeprecatedSkills(root, lock);
|
||||
const removedLockEntries = cleanSkillsLock(root);
|
||||
return { deletedPaths, removedLockEntries, projectRoot: root };
|
||||
}
|
||||
|
||||
// CLI entry point
|
||||
if (process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname)) {
|
||||
const result = cleanup();
|
||||
if (result.deletedPaths.length === 0 && result.removedLockEntries.length === 0) {
|
||||
console.log('No deprecated Impeccable skills found. Nothing to clean up.');
|
||||
} else {
|
||||
if (result.deletedPaths.length > 0) {
|
||||
console.log(`Removed ${result.deletedPaths.length} deprecated skill(s):`);
|
||||
for (const p of result.deletedPaths) console.log(` - ${p}`);
|
||||
}
|
||||
if (result.removedLockEntries.length > 0) {
|
||||
console.log(`Cleaned ${result.removedLockEntries.length} entry/entries from skills-lock.json:`);
|
||||
for (const name of result.removedLockEntries) console.log(` - ${name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Cleans up deprecated Impeccable skill files, symlinks, and
|
||||
* skills-lock.json entries left over from previous versions.
|
||||
*
|
||||
* Safe to run repeatedly -- it is a no-op when nothing needs cleaning.
|
||||
*
|
||||
* Usage (from the project root):
|
||||
* node {{scripts_path}}/cleanup-deprecated.mjs
|
||||
*
|
||||
* What it does:
|
||||
* 1. Finds every harness-specific skills directory (.claude/skills,
|
||||
* .cursor/skills, .agents/skills, etc.).
|
||||
* 2. For each deprecated skill name (with and without i- prefix),
|
||||
* checks if the directory exists and its SKILL.md mentions
|
||||
* "impeccable" (to avoid deleting unrelated user skills).
|
||||
* 3. Deletes confirmed matches (files, directories, or symlinks).
|
||||
* 4. Removes the corresponding entries from skills-lock.json.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, rmSync, readdirSync, statSync, lstatSync, unlinkSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
// Skills that were renamed, merged, or folded in v2.0, v2.1, and v3.0.
|
||||
const DEPRECATED_NAMES = [
|
||||
// v2.0 renames
|
||||
'frontend-design', // renamed to impeccable
|
||||
'teach-impeccable', // folded into /impeccable init
|
||||
// v2.1 merges
|
||||
'arrange', // renamed to layout
|
||||
'normalize', // merged into polish
|
||||
'onboard', // merged into harden
|
||||
'extract', // merged into /impeccable extract
|
||||
// v3.0 consolidation: all standalone skills -> /impeccable sub-commands
|
||||
'adapt',
|
||||
'animate',
|
||||
'audit',
|
||||
'bolder',
|
||||
'clarify',
|
||||
'colorize',
|
||||
'critique',
|
||||
'delight',
|
||||
'distill',
|
||||
'harden',
|
||||
'layout',
|
||||
'optimize',
|
||||
'overdrive',
|
||||
'polish',
|
||||
'quieter',
|
||||
'shape',
|
||||
'typeset',
|
||||
];
|
||||
|
||||
// All known harness directories that may contain a skills/ subfolder.
|
||||
const HARNESS_DIRS = [
|
||||
'.claude', '.cursor', '.gemini', '.codex', '.agents',
|
||||
'.trae', '.trae-cn', '.pi', '.opencode', '.kiro', '.rovodev',
|
||||
];
|
||||
|
||||
// Per-skill fingerprints for SKILL.md bodies that never mentioned
|
||||
// "impeccable" in their v2.x source. Used as a last-resort match
|
||||
// when no skills-lock.json exists and the word heuristic fails.
|
||||
// The strings are lifted verbatim from the v2.x frontmatter
|
||||
// descriptions, so collisions with hand-written user skills are
|
||||
// vanishingly unlikely.
|
||||
const SKILL_FINGERPRINTS = {
|
||||
harden: 'Make interfaces production-ready: error handling, empty states',
|
||||
optimize: 'Diagnoses and fixes UI performance across loading speed',
|
||||
};
|
||||
|
||||
/**
|
||||
* Walk up from startDir until we find a directory that looks like a
|
||||
* project root (has package.json, .git, or skills-lock.json).
|
||||
*/
|
||||
export function findProjectRoot(startDir = process.cwd()) {
|
||||
let dir = resolve(startDir);
|
||||
const { root } = { root: '/' };
|
||||
while (dir !== root) {
|
||||
if (
|
||||
existsSync(join(dir, 'package.json')) ||
|
||||
existsSync(join(dir, '.git')) ||
|
||||
existsSync(join(dir, 'skills-lock.json'))
|
||||
) {
|
||||
return dir;
|
||||
}
|
||||
const parent = resolve(dir, '..');
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return resolve(startDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load skills-lock.json from the project root, or null if missing/unreadable.
|
||||
*/
|
||||
export function loadLock(projectRoot) {
|
||||
const lockPath = join(projectRoot, 'skills-lock.json');
|
||||
if (!existsSync(lockPath)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(lockPath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a skill directory belongs to Impeccable. Three layered
|
||||
* signals, in order of reliability:
|
||||
* 1. Lock source equals "pbakaus/impeccable" (authoritative).
|
||||
* 2. SKILL.md body contains the word "impeccable".
|
||||
* 3. SKILL.md body contains a per-skill fingerprint (for harden and
|
||||
* optimize, whose v2.x SKILL.md never mentioned the pack name).
|
||||
*/
|
||||
export function isImpeccableSkill(skillDir, { skillName, lock } = {}) {
|
||||
// 1. Authoritative: the lock file claims this skill is ours.
|
||||
if (skillName && lock?.skills?.[skillName]?.source === 'pbakaus/impeccable') {
|
||||
return true;
|
||||
}
|
||||
const skillMd = join(skillDir, 'SKILL.md');
|
||||
if (!existsSync(skillMd)) return false;
|
||||
let content;
|
||||
try {
|
||||
content = readFileSync(skillMd, 'utf-8');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
// 2. Word-level content heuristic.
|
||||
if (/impeccable/i.test(content)) return true;
|
||||
// 3. Per-skill fingerprint for old skills that never mentioned the pack.
|
||||
// Strip the i- prefix so both `harden` and `i-harden` resolve to the
|
||||
// same fingerprint entry.
|
||||
const unprefixed = skillName?.startsWith('i-') ? skillName.slice(2) : skillName;
|
||||
const fingerprint = unprefixed && SKILL_FINGERPRINTS[unprefixed];
|
||||
if (fingerprint && content.includes(fingerprint)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full list of names to check: each deprecated name, plus
|
||||
* its i-prefixed variant.
|
||||
*/
|
||||
export function buildTargetNames() {
|
||||
const names = [];
|
||||
for (const name of DEPRECATED_NAMES) {
|
||||
names.push(name);
|
||||
names.push(`i-${name}`);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find every skills directory across all harness dirs in the project.
|
||||
* Returns absolute paths that exist on disk.
|
||||
*/
|
||||
export function findSkillsDirs(projectRoot) {
|
||||
const dirs = [];
|
||||
for (const harness of HARNESS_DIRS) {
|
||||
const candidate = join(projectRoot, harness, 'skills');
|
||||
if (existsSync(candidate)) {
|
||||
dirs.push(candidate);
|
||||
}
|
||||
}
|
||||
return dirs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove deprecated skill directories/symlinks from all harness dirs.
|
||||
* Reads skills-lock.json so the authoritative "source" field can
|
||||
* drive deletion even when SKILL.md never mentions impeccable.
|
||||
* Returns an array of paths that were deleted.
|
||||
*/
|
||||
export function removeDeprecatedSkills(projectRoot, lock) {
|
||||
if (lock === undefined) lock = loadLock(projectRoot);
|
||||
const targets = buildTargetNames();
|
||||
const skillsDirs = findSkillsDirs(projectRoot);
|
||||
const deleted = [];
|
||||
|
||||
for (const skillsDir of skillsDirs) {
|
||||
for (const name of targets) {
|
||||
const skillPath = join(skillsDir, name);
|
||||
|
||||
// Use lstat to detect symlinks (existsSync follows symlinks and
|
||||
// returns false for dangling ones).
|
||||
let stat;
|
||||
try {
|
||||
stat = lstatSync(skillPath);
|
||||
} catch {
|
||||
continue; // does not exist at all
|
||||
}
|
||||
|
||||
if (stat.isSymbolicLink()) {
|
||||
// Symlink: check the target if it's alive, otherwise treat
|
||||
// dangling symlinks to deprecated names as safe to remove.
|
||||
const targetAlive = existsSync(skillPath);
|
||||
const isMatch = targetAlive
|
||||
? isImpeccableSkill(skillPath, { skillName: name, lock })
|
||||
: true;
|
||||
if (isMatch) {
|
||||
unlinkSync(skillPath);
|
||||
deleted.push(skillPath);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular directory -- verify it belongs to impeccable
|
||||
if (isImpeccableSkill(skillPath, { skillName: name, lock })) {
|
||||
rmSync(skillPath, { recursive: true, force: true });
|
||||
deleted.push(skillPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove deprecated entries from skills-lock.json.
|
||||
* Only removes entries whose source is "pbakaus/impeccable".
|
||||
* Returns the list of removed skill names.
|
||||
*/
|
||||
export function cleanSkillsLock(projectRoot) {
|
||||
const lockPath = join(projectRoot, 'skills-lock.json');
|
||||
if (!existsSync(lockPath)) return [];
|
||||
|
||||
let lock;
|
||||
try {
|
||||
lock = JSON.parse(readFileSync(lockPath, 'utf-8'));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!lock.skills || typeof lock.skills !== 'object') return [];
|
||||
|
||||
const targets = buildTargetNames();
|
||||
const removed = [];
|
||||
|
||||
for (const name of targets) {
|
||||
const entry = lock.skills[name];
|
||||
if (!entry) continue;
|
||||
// Only remove if it belongs to impeccable
|
||||
if (entry.source === 'pbakaus/impeccable') {
|
||||
delete lock.skills[name];
|
||||
removed.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
if (removed.length > 0) {
|
||||
writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full cleanup. Returns a summary object.
|
||||
*
|
||||
* Order matters: read the lock and delete directories first, then
|
||||
* strip lock entries. Otherwise the authoritative signal is gone by
|
||||
* the time directory deletion runs.
|
||||
*/
|
||||
export function cleanup(projectRoot) {
|
||||
const root = projectRoot || findProjectRoot();
|
||||
const lock = loadLock(root);
|
||||
const deletedPaths = removeDeprecatedSkills(root, lock);
|
||||
const removedLockEntries = cleanSkillsLock(root);
|
||||
return { deletedPaths, removedLockEntries, projectRoot: root };
|
||||
}
|
||||
|
||||
// CLI entry point
|
||||
if (process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname)) {
|
||||
const result = cleanup();
|
||||
if (result.deletedPaths.length === 0 && result.removedLockEntries.length === 0) {
|
||||
console.log('No deprecated Impeccable skills found. Nothing to clean up.');
|
||||
} else {
|
||||
if (result.deletedPaths.length > 0) {
|
||||
console.log(`Removed ${result.deletedPaths.length} deprecated skill(s):`);
|
||||
for (const p of result.deletedPaths) console.log(` - ${p}`);
|
||||
}
|
||||
if (result.removedLockEntries.length > 0) {
|
||||
console.log(`Cleaned ${result.removedLockEntries.length} entry/entries from skills-lock.json:`);
|
||||
for (const name of result.removedLockEntries) console.log(` - ${name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Cleans up deprecated Impeccable skill files, symlinks, and
|
||||
* skills-lock.json entries left over from previous versions.
|
||||
*
|
||||
* Safe to run repeatedly -- it is a no-op when nothing needs cleaning.
|
||||
*
|
||||
* Usage (from the project root):
|
||||
* node {{scripts_path}}/cleanup-deprecated.mjs
|
||||
*
|
||||
* What it does:
|
||||
* 1. Finds every harness-specific skills directory (.claude/skills,
|
||||
* .cursor/skills, .agents/skills, etc.).
|
||||
* 2. For each deprecated skill name (with and without i- prefix),
|
||||
* checks if the directory exists and its SKILL.md mentions
|
||||
* "impeccable" (to avoid deleting unrelated user skills).
|
||||
* 3. Deletes confirmed matches (files, directories, or symlinks).
|
||||
* 4. Removes the corresponding entries from skills-lock.json.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, rmSync, readdirSync, statSync, lstatSync, unlinkSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
// Skills that were renamed, merged, or folded in v2.0, v2.1, and v3.0.
|
||||
const DEPRECATED_NAMES = [
|
||||
// v2.0 renames
|
||||
'frontend-design', // renamed to impeccable
|
||||
'teach-impeccable', // folded into /impeccable init
|
||||
// v2.1 merges
|
||||
'arrange', // renamed to layout
|
||||
'normalize', // merged into polish
|
||||
'onboard', // merged into harden
|
||||
'extract', // merged into /impeccable extract
|
||||
// v3.0 consolidation: all standalone skills -> /impeccable sub-commands
|
||||
'adapt',
|
||||
'animate',
|
||||
'audit',
|
||||
'bolder',
|
||||
'clarify',
|
||||
'colorize',
|
||||
'critique',
|
||||
'delight',
|
||||
'distill',
|
||||
'harden',
|
||||
'layout',
|
||||
'optimize',
|
||||
'overdrive',
|
||||
'polish',
|
||||
'quieter',
|
||||
'shape',
|
||||
'typeset',
|
||||
];
|
||||
|
||||
// All known harness directories that may contain a skills/ subfolder.
|
||||
const HARNESS_DIRS = [
|
||||
'.claude', '.cursor', '.gemini', '.codex', '.agents',
|
||||
'.trae', '.trae-cn', '.pi', '.opencode', '.kiro', '.rovodev',
|
||||
];
|
||||
|
||||
// Per-skill fingerprints for SKILL.md bodies that never mentioned
|
||||
// "impeccable" in their v2.x source. Used as a last-resort match
|
||||
// when no skills-lock.json exists and the word heuristic fails.
|
||||
// The strings are lifted verbatim from the v2.x frontmatter
|
||||
// descriptions, so collisions with hand-written user skills are
|
||||
// vanishingly unlikely.
|
||||
const SKILL_FINGERPRINTS = {
|
||||
harden: 'Make interfaces production-ready: error handling, empty states',
|
||||
optimize: 'Diagnoses and fixes UI performance across loading speed',
|
||||
};
|
||||
|
||||
/**
|
||||
* Walk up from startDir until we find a directory that looks like a
|
||||
* project root (has package.json, .git, or skills-lock.json).
|
||||
*/
|
||||
export function findProjectRoot(startDir = process.cwd()) {
|
||||
let dir = resolve(startDir);
|
||||
const { root } = { root: '/' };
|
||||
while (dir !== root) {
|
||||
if (
|
||||
existsSync(join(dir, 'package.json')) ||
|
||||
existsSync(join(dir, '.git')) ||
|
||||
existsSync(join(dir, 'skills-lock.json'))
|
||||
) {
|
||||
return dir;
|
||||
}
|
||||
const parent = resolve(dir, '..');
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return resolve(startDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load skills-lock.json from the project root, or null if missing/unreadable.
|
||||
*/
|
||||
export function loadLock(projectRoot) {
|
||||
const lockPath = join(projectRoot, 'skills-lock.json');
|
||||
if (!existsSync(lockPath)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(lockPath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a skill directory belongs to Impeccable. Three layered
|
||||
* signals, in order of reliability:
|
||||
* 1. Lock source equals "pbakaus/impeccable" (authoritative).
|
||||
* 2. SKILL.md body contains the word "impeccable".
|
||||
* 3. SKILL.md body contains a per-skill fingerprint (for harden and
|
||||
* optimize, whose v2.x SKILL.md never mentioned the pack name).
|
||||
*/
|
||||
export function isImpeccableSkill(skillDir, { skillName, lock } = {}) {
|
||||
// 1. Authoritative: the lock file claims this skill is ours.
|
||||
if (skillName && lock?.skills?.[skillName]?.source === 'pbakaus/impeccable') {
|
||||
return true;
|
||||
}
|
||||
const skillMd = join(skillDir, 'SKILL.md');
|
||||
if (!existsSync(skillMd)) return false;
|
||||
let content;
|
||||
try {
|
||||
content = readFileSync(skillMd, 'utf-8');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
// 2. Word-level content heuristic.
|
||||
if (/impeccable/i.test(content)) return true;
|
||||
// 3. Per-skill fingerprint for old skills that never mentioned the pack.
|
||||
// Strip the i- prefix so both `harden` and `i-harden` resolve to the
|
||||
// same fingerprint entry.
|
||||
const unprefixed = skillName?.startsWith('i-') ? skillName.slice(2) : skillName;
|
||||
const fingerprint = unprefixed && SKILL_FINGERPRINTS[unprefixed];
|
||||
if (fingerprint && content.includes(fingerprint)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full list of names to check: each deprecated name, plus
|
||||
* its i-prefixed variant.
|
||||
*/
|
||||
export function buildTargetNames() {
|
||||
const names = [];
|
||||
for (const name of DEPRECATED_NAMES) {
|
||||
names.push(name);
|
||||
names.push(`i-${name}`);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find every skills directory across all harness dirs in the project.
|
||||
* Returns absolute paths that exist on disk.
|
||||
*/
|
||||
export function findSkillsDirs(projectRoot) {
|
||||
const dirs = [];
|
||||
for (const harness of HARNESS_DIRS) {
|
||||
const candidate = join(projectRoot, harness, 'skills');
|
||||
if (existsSync(candidate)) {
|
||||
dirs.push(candidate);
|
||||
}
|
||||
}
|
||||
return dirs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove deprecated skill directories/symlinks from all harness dirs.
|
||||
* Reads skills-lock.json so the authoritative "source" field can
|
||||
* drive deletion even when SKILL.md never mentions impeccable.
|
||||
* Returns an array of paths that were deleted.
|
||||
*/
|
||||
export function removeDeprecatedSkills(projectRoot, lock) {
|
||||
if (lock === undefined) lock = loadLock(projectRoot);
|
||||
const targets = buildTargetNames();
|
||||
const skillsDirs = findSkillsDirs(projectRoot);
|
||||
const deleted = [];
|
||||
|
||||
for (const skillsDir of skillsDirs) {
|
||||
for (const name of targets) {
|
||||
const skillPath = join(skillsDir, name);
|
||||
|
||||
// Use lstat to detect symlinks (existsSync follows symlinks and
|
||||
// returns false for dangling ones).
|
||||
let stat;
|
||||
try {
|
||||
stat = lstatSync(skillPath);
|
||||
} catch {
|
||||
continue; // does not exist at all
|
||||
}
|
||||
|
||||
if (stat.isSymbolicLink()) {
|
||||
// Symlink: check the target if it's alive, otherwise treat
|
||||
// dangling symlinks to deprecated names as safe to remove.
|
||||
const targetAlive = existsSync(skillPath);
|
||||
const isMatch = targetAlive
|
||||
? isImpeccableSkill(skillPath, { skillName: name, lock })
|
||||
: true;
|
||||
if (isMatch) {
|
||||
unlinkSync(skillPath);
|
||||
deleted.push(skillPath);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular directory -- verify it belongs to impeccable
|
||||
if (isImpeccableSkill(skillPath, { skillName: name, lock })) {
|
||||
rmSync(skillPath, { recursive: true, force: true });
|
||||
deleted.push(skillPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove deprecated entries from skills-lock.json.
|
||||
* Only removes entries whose source is "pbakaus/impeccable".
|
||||
* Returns the list of removed skill names.
|
||||
*/
|
||||
export function cleanSkillsLock(projectRoot) {
|
||||
const lockPath = join(projectRoot, 'skills-lock.json');
|
||||
if (!existsSync(lockPath)) return [];
|
||||
|
||||
let lock;
|
||||
try {
|
||||
lock = JSON.parse(readFileSync(lockPath, 'utf-8'));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!lock.skills || typeof lock.skills !== 'object') return [];
|
||||
|
||||
const targets = buildTargetNames();
|
||||
const removed = [];
|
||||
|
||||
for (const name of targets) {
|
||||
const entry = lock.skills[name];
|
||||
if (!entry) continue;
|
||||
// Only remove if it belongs to impeccable
|
||||
if (entry.source === 'pbakaus/impeccable') {
|
||||
delete lock.skills[name];
|
||||
removed.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
if (removed.length > 0) {
|
||||
writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full cleanup. Returns a summary object.
|
||||
*
|
||||
* Order matters: read the lock and delete directories first, then
|
||||
* strip lock entries. Otherwise the authoritative signal is gone by
|
||||
* the time directory deletion runs.
|
||||
*/
|
||||
export function cleanup(projectRoot) {
|
||||
const root = projectRoot || findProjectRoot();
|
||||
const lock = loadLock(root);
|
||||
const deletedPaths = removeDeprecatedSkills(root, lock);
|
||||
const removedLockEntries = cleanSkillsLock(root);
|
||||
return { deletedPaths, removedLockEntries, projectRoot: root };
|
||||
}
|
||||
|
||||
// CLI entry point
|
||||
if (process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname)) {
|
||||
const result = cleanup();
|
||||
if (result.deletedPaths.length === 0 && result.removedLockEntries.length === 0) {
|
||||
console.log('No deprecated Impeccable skills found. Nothing to clean up.');
|
||||
} else {
|
||||
if (result.deletedPaths.length > 0) {
|
||||
console.log(`Removed ${result.deletedPaths.length} deprecated skill(s):`);
|
||||
for (const p of result.deletedPaths) console.log(` - ${p}`);
|
||||
}
|
||||
if (result.removedLockEntries.length > 0) {
|
||||
console.log(`Cleaned ${result.removedLockEntries.length} entry/entries from skills-lock.json:`);
|
||||
for (const name of result.removedLockEntries) console.log(` - ${name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Cleans up deprecated Impeccable skill files, symlinks, and
|
||||
* skills-lock.json entries left over from previous versions.
|
||||
*
|
||||
* Safe to run repeatedly -- it is a no-op when nothing needs cleaning.
|
||||
*
|
||||
* Usage (from the project root):
|
||||
* node {{scripts_path}}/cleanup-deprecated.mjs
|
||||
*
|
||||
* What it does:
|
||||
* 1. Finds every harness-specific skills directory (.claude/skills,
|
||||
* .cursor/skills, .agents/skills, etc.).
|
||||
* 2. For each deprecated skill name (with and without i- prefix),
|
||||
* checks if the directory exists and its SKILL.md mentions
|
||||
* "impeccable" (to avoid deleting unrelated user skills).
|
||||
* 3. Deletes confirmed matches (files, directories, or symlinks).
|
||||
* 4. Removes the corresponding entries from skills-lock.json.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, rmSync, readdirSync, statSync, lstatSync, unlinkSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
// Skills that were renamed, merged, or folded in v2.0, v2.1, and v3.0.
|
||||
const DEPRECATED_NAMES = [
|
||||
// v2.0 renames
|
||||
'frontend-design', // renamed to impeccable
|
||||
'teach-impeccable', // folded into /impeccable init
|
||||
// v2.1 merges
|
||||
'arrange', // renamed to layout
|
||||
'normalize', // merged into polish
|
||||
'onboard', // merged into harden
|
||||
'extract', // merged into /impeccable extract
|
||||
// v3.0 consolidation: all standalone skills -> /impeccable sub-commands
|
||||
'adapt',
|
||||
'animate',
|
||||
'audit',
|
||||
'bolder',
|
||||
'clarify',
|
||||
'colorize',
|
||||
'critique',
|
||||
'delight',
|
||||
'distill',
|
||||
'harden',
|
||||
'layout',
|
||||
'optimize',
|
||||
'overdrive',
|
||||
'polish',
|
||||
'quieter',
|
||||
'shape',
|
||||
'typeset',
|
||||
];
|
||||
|
||||
// All known harness directories that may contain a skills/ subfolder.
|
||||
const HARNESS_DIRS = [
|
||||
'.claude', '.cursor', '.gemini', '.codex', '.agents',
|
||||
'.trae', '.trae-cn', '.pi', '.opencode', '.kiro', '.rovodev',
|
||||
];
|
||||
|
||||
// Per-skill fingerprints for SKILL.md bodies that never mentioned
|
||||
// "impeccable" in their v2.x source. Used as a last-resort match
|
||||
// when no skills-lock.json exists and the word heuristic fails.
|
||||
// The strings are lifted verbatim from the v2.x frontmatter
|
||||
// descriptions, so collisions with hand-written user skills are
|
||||
// vanishingly unlikely.
|
||||
const SKILL_FINGERPRINTS = {
|
||||
harden: 'Make interfaces production-ready: error handling, empty states',
|
||||
optimize: 'Diagnoses and fixes UI performance across loading speed',
|
||||
};
|
||||
|
||||
/**
|
||||
* Walk up from startDir until we find a directory that looks like a
|
||||
* project root (has package.json, .git, or skills-lock.json).
|
||||
*/
|
||||
export function findProjectRoot(startDir = process.cwd()) {
|
||||
let dir = resolve(startDir);
|
||||
const { root } = { root: '/' };
|
||||
while (dir !== root) {
|
||||
if (
|
||||
existsSync(join(dir, 'package.json')) ||
|
||||
existsSync(join(dir, '.git')) ||
|
||||
existsSync(join(dir, 'skills-lock.json'))
|
||||
) {
|
||||
return dir;
|
||||
}
|
||||
const parent = resolve(dir, '..');
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return resolve(startDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load skills-lock.json from the project root, or null if missing/unreadable.
|
||||
*/
|
||||
export function loadLock(projectRoot) {
|
||||
const lockPath = join(projectRoot, 'skills-lock.json');
|
||||
if (!existsSync(lockPath)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(lockPath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a skill directory belongs to Impeccable. Three layered
|
||||
* signals, in order of reliability:
|
||||
* 1. Lock source equals "pbakaus/impeccable" (authoritative).
|
||||
* 2. SKILL.md body contains the word "impeccable".
|
||||
* 3. SKILL.md body contains a per-skill fingerprint (for harden and
|
||||
* optimize, whose v2.x SKILL.md never mentioned the pack name).
|
||||
*/
|
||||
export function isImpeccableSkill(skillDir, { skillName, lock } = {}) {
|
||||
// 1. Authoritative: the lock file claims this skill is ours.
|
||||
if (skillName && lock?.skills?.[skillName]?.source === 'pbakaus/impeccable') {
|
||||
return true;
|
||||
}
|
||||
const skillMd = join(skillDir, 'SKILL.md');
|
||||
if (!existsSync(skillMd)) return false;
|
||||
let content;
|
||||
try {
|
||||
content = readFileSync(skillMd, 'utf-8');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
// 2. Word-level content heuristic.
|
||||
if (/impeccable/i.test(content)) return true;
|
||||
// 3. Per-skill fingerprint for old skills that never mentioned the pack.
|
||||
// Strip the i- prefix so both `harden` and `i-harden` resolve to the
|
||||
// same fingerprint entry.
|
||||
const unprefixed = skillName?.startsWith('i-') ? skillName.slice(2) : skillName;
|
||||
const fingerprint = unprefixed && SKILL_FINGERPRINTS[unprefixed];
|
||||
if (fingerprint && content.includes(fingerprint)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full list of names to check: each deprecated name, plus
|
||||
* its i-prefixed variant.
|
||||
*/
|
||||
export function buildTargetNames() {
|
||||
const names = [];
|
||||
for (const name of DEPRECATED_NAMES) {
|
||||
names.push(name);
|
||||
names.push(`i-${name}`);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find every skills directory across all harness dirs in the project.
|
||||
* Returns absolute paths that exist on disk.
|
||||
*/
|
||||
export function findSkillsDirs(projectRoot) {
|
||||
const dirs = [];
|
||||
for (const harness of HARNESS_DIRS) {
|
||||
const candidate = join(projectRoot, harness, 'skills');
|
||||
if (existsSync(candidate)) {
|
||||
dirs.push(candidate);
|
||||
}
|
||||
}
|
||||
return dirs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove deprecated skill directories/symlinks from all harness dirs.
|
||||
* Reads skills-lock.json so the authoritative "source" field can
|
||||
* drive deletion even when SKILL.md never mentions impeccable.
|
||||
* Returns an array of paths that were deleted.
|
||||
*/
|
||||
export function removeDeprecatedSkills(projectRoot, lock) {
|
||||
if (lock === undefined) lock = loadLock(projectRoot);
|
||||
const targets = buildTargetNames();
|
||||
const skillsDirs = findSkillsDirs(projectRoot);
|
||||
const deleted = [];
|
||||
|
||||
for (const skillsDir of skillsDirs) {
|
||||
for (const name of targets) {
|
||||
const skillPath = join(skillsDir, name);
|
||||
|
||||
// Use lstat to detect symlinks (existsSync follows symlinks and
|
||||
// returns false for dangling ones).
|
||||
let stat;
|
||||
try {
|
||||
stat = lstatSync(skillPath);
|
||||
} catch {
|
||||
continue; // does not exist at all
|
||||
}
|
||||
|
||||
if (stat.isSymbolicLink()) {
|
||||
// Symlink: check the target if it's alive, otherwise treat
|
||||
// dangling symlinks to deprecated names as safe to remove.
|
||||
const targetAlive = existsSync(skillPath);
|
||||
const isMatch = targetAlive
|
||||
? isImpeccableSkill(skillPath, { skillName: name, lock })
|
||||
: true;
|
||||
if (isMatch) {
|
||||
unlinkSync(skillPath);
|
||||
deleted.push(skillPath);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular directory -- verify it belongs to impeccable
|
||||
if (isImpeccableSkill(skillPath, { skillName: name, lock })) {
|
||||
rmSync(skillPath, { recursive: true, force: true });
|
||||
deleted.push(skillPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove deprecated entries from skills-lock.json.
|
||||
* Only removes entries whose source is "pbakaus/impeccable".
|
||||
* Returns the list of removed skill names.
|
||||
*/
|
||||
export function cleanSkillsLock(projectRoot) {
|
||||
const lockPath = join(projectRoot, 'skills-lock.json');
|
||||
if (!existsSync(lockPath)) return [];
|
||||
|
||||
let lock;
|
||||
try {
|
||||
lock = JSON.parse(readFileSync(lockPath, 'utf-8'));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!lock.skills || typeof lock.skills !== 'object') return [];
|
||||
|
||||
const targets = buildTargetNames();
|
||||
const removed = [];
|
||||
|
||||
for (const name of targets) {
|
||||
const entry = lock.skills[name];
|
||||
if (!entry) continue;
|
||||
// Only remove if it belongs to impeccable
|
||||
if (entry.source === 'pbakaus/impeccable') {
|
||||
delete lock.skills[name];
|
||||
removed.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
if (removed.length > 0) {
|
||||
writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full cleanup. Returns a summary object.
|
||||
*
|
||||
* Order matters: read the lock and delete directories first, then
|
||||
* strip lock entries. Otherwise the authoritative signal is gone by
|
||||
* the time directory deletion runs.
|
||||
*/
|
||||
export function cleanup(projectRoot) {
|
||||
const root = projectRoot || findProjectRoot();
|
||||
const lock = loadLock(root);
|
||||
const deletedPaths = removeDeprecatedSkills(root, lock);
|
||||
const removedLockEntries = cleanSkillsLock(root);
|
||||
return { deletedPaths, removedLockEntries, projectRoot: root };
|
||||
}
|
||||
|
||||
// CLI entry point
|
||||
if (process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname)) {
|
||||
const result = cleanup();
|
||||
if (result.deletedPaths.length === 0 && result.removedLockEntries.length === 0) {
|
||||
console.log('No deprecated Impeccable skills found. Nothing to clean up.');
|
||||
} else {
|
||||
if (result.deletedPaths.length > 0) {
|
||||
console.log(`Removed ${result.deletedPaths.length} deprecated skill(s):`);
|
||||
for (const p of result.deletedPaths) console.log(` - ${p}`);
|
||||
}
|
||||
if (result.removedLockEntries.length > 0) {
|
||||
console.log(`Cleaned ${result.removedLockEntries.length} entry/entries from skills-lock.json:`);
|
||||
for (const name of result.removedLockEntries) console.log(` - ${name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,5 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Skill bootstrap: skip the post-update-cleanup step
|
||||
|
||||
If you load the Impeccable skill from `.agents/skills/impeccable/SKILL.md` (or any sibling harness directory under this repo), it begins with a `<post-update-cleanup>` block that runs `scripts/cleanup-deprecated.mjs` and then self-deletes. **Do not run it here, and do not delete the block.** This repo is the source of truth for the skill; cleanup-deprecated assumes a consumer project and would touch legitimate source files. The block is regenerated from `skill/SKILL.src.md` whenever `bun run build:release` syncs tracked harness output, so any deletion churns back. Skip it silently and proceed with the task.
|
||||
|
||||
## Project Structure & Module Organization
|
||||
|
||||
`skill/` is the source of truth for the Impeccable skill: `SKILL.src.md`, `reference/`, `scripts/`, and `agents/`. Build logic lives in `scripts/`, with provider configs in `scripts/lib/transformers/`. The CLI and anti-pattern detector live in `cli/`, the browser extension in `extension/`, the Astro website in `site/`, Cloudflare Pages Functions in `functions/`, and regression coverage in `tests/` with fixtures under `tests/fixtures/`. `dist/` and `build/` are generated and gitignored. The root harness folders (`.agents/`, `.claude/`, `.cursor/`, etc.) and `plugin/` are generated distribution artifacts that are tracked for direct repo installs, not hand-authored source.
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
# Project Instructions for Claude
|
||||
|
||||
## Skill bootstrap: skip the post-update-cleanup step
|
||||
|
||||
The `<post-update-cleanup>` block at the top of `.claude/skills/impeccable/SKILL.md` runs `scripts/cleanup-deprecated.mjs` and then self-deletes. **Do not run it in this repo, and do not delete the block.** This repo is the source of truth for the Impeccable skill; the cleanup script scans for "leftover" skill files to remove and its judgment doesn't fit a repo where the skill lives in `skill/`. The block is also regenerated from `skill/SKILL.src.md` whenever `bun run build:release` syncs tracked harness output, so any deletion churns back on the next sync. Skip the step silently and proceed.
|
||||
|
||||
Same rule for AGENTS.md and every other harness-specific instruction file: treat post-update-cleanup as a no-op in this repo.
|
||||
|
||||
## Architecture (v3.0+)
|
||||
|
||||
There is **one** user-invocable skill, `impeccable`, with **23 commands** underneath it. Users type `/impeccable polish`, `/impeccable audit`, etc. The skill is defined in `skill/`:
|
||||
@@ -15,7 +9,6 @@ There is **one** user-invocable skill, `impeccable`, with **23 commands** undern
|
||||
- `reference/brand.md` and `reference/product.md` — the two register references. SKILL.md's Setup section selects one based on the task cue, the surface in focus, or the `register` field in PRODUCT.md (first match wins).
|
||||
- `scripts/command-metadata.json` — single source of truth for each command's description, argument hint, and (eventually) category. Both the build and `pin.mjs` read from this.
|
||||
- `scripts/pin.mjs` — creates/removes lightweight redirect shims so users can have `/audit` as a standalone shortcut that delegates to `/impeccable audit`.
|
||||
- `scripts/cleanup-deprecated.mjs` — runs once after an update to remove leftover files from renamed/merged commands.
|
||||
|
||||
**Do not add standalone skills** unless there's a strong reason. The consolidation was deliberate: the `/` menu pollution problem is real and gets worse as users install more plugins.
|
||||
|
||||
|
||||
@@ -316,8 +316,8 @@ function migrateUnprefixImpeccable(root) {
|
||||
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).
|
||||
// A prefixed impeccable skill is `<prefix>impeccable`, not the canonical
|
||||
// `impeccable` and not an unrelated legacy skill name.
|
||||
if (name === 'impeccable' || name === 'teach-impeccable') continue;
|
||||
if (!name.endsWith('-impeccable')) continue;
|
||||
if (!isRealSkillDir(skillsDir, name)) continue;
|
||||
@@ -609,18 +609,6 @@ async function install(flags) {
|
||||
}
|
||||
console.log(`Installed impeccable into: ${targets.join(', ')}`);
|
||||
|
||||
// Clean up deprecated skills from previous versions
|
||||
try {
|
||||
const { cleanup } = await import('../../../skill/scripts/cleanup-deprecated.mjs');
|
||||
const result = cleanup(root);
|
||||
const total = result.deletedPaths.length + result.removedLockEntries.length;
|
||||
if (total > 0) {
|
||||
console.log(`Cleaned up ${total} deprecated skill(s) from previous versions.`);
|
||||
}
|
||||
} catch {
|
||||
// Cleanup script not available -- skip
|
||||
}
|
||||
|
||||
console.log('\nDone! Run /impeccable init in your AI harness to set up design context.\n');
|
||||
}
|
||||
|
||||
@@ -705,19 +693,6 @@ function downloadFile(url, dest) {
|
||||
async function update(flags = []) {
|
||||
const yes = flags.includes('-y') || flags.includes('--yes');
|
||||
|
||||
// Clean up deprecated skills from previous versions.
|
||||
try {
|
||||
const { cleanup } = await import('../../../skill/scripts/cleanup-deprecated.mjs');
|
||||
const root = findProjectRoot();
|
||||
const result = cleanup(root);
|
||||
const total = result.deletedPaths.length + result.removedLockEntries.length;
|
||||
if (total > 0) {
|
||||
console.log(`Cleaned up ${total} deprecated skill(s) from previous versions.\n`);
|
||||
}
|
||||
} catch {
|
||||
// Cleanup script not available (e.g. running from npm package) -- skip
|
||||
}
|
||||
|
||||
// Download the latest skills directly from impeccable.style.
|
||||
// We skip `npx skills update` because it has a known upstream bug
|
||||
// (vercel-labs/skills#775) where it can't find the lock file.
|
||||
@@ -797,14 +772,6 @@ async function update(flags = []) {
|
||||
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
|
||||
// Run cleanup to remove deprecated stubs from the fresh download
|
||||
try {
|
||||
const { cleanup: postCleanup } = await import('../../../skill/scripts/cleanup-deprecated.mjs');
|
||||
postCleanup(root);
|
||||
} catch {
|
||||
// Not available -- skip
|
||||
}
|
||||
|
||||
const v = getSkillsVersion(root);
|
||||
console.log(`Updated ${updated} skill(s)${v ? ` to v${v}` : ''}.`);
|
||||
console.log('Done!\n');
|
||||
|
||||
@@ -1,284 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Cleans up deprecated Impeccable skill files, symlinks, and
|
||||
* skills-lock.json entries left over from previous versions.
|
||||
*
|
||||
* Safe to run repeatedly -- it is a no-op when nothing needs cleaning.
|
||||
*
|
||||
* Usage (from the project root):
|
||||
* node {{scripts_path}}/cleanup-deprecated.mjs
|
||||
*
|
||||
* What it does:
|
||||
* 1. Finds every harness-specific skills directory (.claude/skills,
|
||||
* .cursor/skills, .agents/skills, etc.).
|
||||
* 2. For each deprecated skill name (with and without i- prefix),
|
||||
* checks if the directory exists and its SKILL.md mentions
|
||||
* "impeccable" (to avoid deleting unrelated user skills).
|
||||
* 3. Deletes confirmed matches (files, directories, or symlinks).
|
||||
* 4. Removes the corresponding entries from skills-lock.json.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, rmSync, readdirSync, statSync, lstatSync, unlinkSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
// Skills that were renamed, merged, or folded in v2.0, v2.1, and v3.0.
|
||||
const DEPRECATED_NAMES = [
|
||||
// v2.0 renames
|
||||
'frontend-design', // renamed to impeccable
|
||||
'teach-impeccable', // folded into /impeccable init
|
||||
// v2.1 merges
|
||||
'arrange', // renamed to layout
|
||||
'normalize', // merged into polish
|
||||
'onboard', // merged into harden
|
||||
'extract', // merged into /impeccable extract
|
||||
// v3.0 consolidation: all standalone skills -> /impeccable sub-commands
|
||||
'adapt',
|
||||
'animate',
|
||||
'audit',
|
||||
'bolder',
|
||||
'clarify',
|
||||
'colorize',
|
||||
'critique',
|
||||
'delight',
|
||||
'distill',
|
||||
'harden',
|
||||
'layout',
|
||||
'optimize',
|
||||
'overdrive',
|
||||
'polish',
|
||||
'quieter',
|
||||
'shape',
|
||||
'typeset',
|
||||
];
|
||||
|
||||
// All known harness directories that may contain a skills/ subfolder.
|
||||
const HARNESS_DIRS = [
|
||||
'.claude', '.cursor', '.gemini', '.codex', '.agents',
|
||||
'.trae', '.trae-cn', '.pi', '.opencode', '.kiro', '.rovodev',
|
||||
];
|
||||
|
||||
// Per-skill fingerprints for SKILL.md bodies that never mentioned
|
||||
// "impeccable" in their v2.x source. Used as a last-resort match
|
||||
// when no skills-lock.json exists and the word heuristic fails.
|
||||
// The strings are lifted verbatim from the v2.x frontmatter
|
||||
// descriptions, so collisions with hand-written user skills are
|
||||
// vanishingly unlikely.
|
||||
const SKILL_FINGERPRINTS = {
|
||||
harden: 'Make interfaces production-ready: error handling, empty states',
|
||||
optimize: 'Diagnoses and fixes UI performance across loading speed',
|
||||
};
|
||||
|
||||
/**
|
||||
* Walk up from startDir until we find a directory that looks like a
|
||||
* project root (has package.json, .git, or skills-lock.json).
|
||||
*/
|
||||
export function findProjectRoot(startDir = process.cwd()) {
|
||||
let dir = resolve(startDir);
|
||||
const { root } = { root: '/' };
|
||||
while (dir !== root) {
|
||||
if (
|
||||
existsSync(join(dir, 'package.json')) ||
|
||||
existsSync(join(dir, '.git')) ||
|
||||
existsSync(join(dir, 'skills-lock.json'))
|
||||
) {
|
||||
return dir;
|
||||
}
|
||||
const parent = resolve(dir, '..');
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return resolve(startDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load skills-lock.json from the project root, or null if missing/unreadable.
|
||||
*/
|
||||
export function loadLock(projectRoot) {
|
||||
const lockPath = join(projectRoot, 'skills-lock.json');
|
||||
if (!existsSync(lockPath)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(lockPath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a skill directory belongs to Impeccable. Three layered
|
||||
* signals, in order of reliability:
|
||||
* 1. Lock source equals "pbakaus/impeccable" (authoritative).
|
||||
* 2. SKILL.md body contains the word "impeccable".
|
||||
* 3. SKILL.md body contains a per-skill fingerprint (for harden and
|
||||
* optimize, whose v2.x SKILL.md never mentioned the pack name).
|
||||
*/
|
||||
export function isImpeccableSkill(skillDir, { skillName, lock } = {}) {
|
||||
// 1. Authoritative: the lock file claims this skill is ours.
|
||||
if (skillName && lock?.skills?.[skillName]?.source === 'pbakaus/impeccable') {
|
||||
return true;
|
||||
}
|
||||
const skillMd = join(skillDir, 'SKILL.md');
|
||||
if (!existsSync(skillMd)) return false;
|
||||
let content;
|
||||
try {
|
||||
content = readFileSync(skillMd, 'utf-8');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
// 2. Word-level content heuristic.
|
||||
if (/impeccable/i.test(content)) return true;
|
||||
// 3. Per-skill fingerprint for old skills that never mentioned the pack.
|
||||
// Strip the i- prefix so both `harden` and `i-harden` resolve to the
|
||||
// same fingerprint entry.
|
||||
const unprefixed = skillName?.startsWith('i-') ? skillName.slice(2) : skillName;
|
||||
const fingerprint = unprefixed && SKILL_FINGERPRINTS[unprefixed];
|
||||
if (fingerprint && content.includes(fingerprint)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full list of names to check: each deprecated name, plus
|
||||
* its i-prefixed variant.
|
||||
*/
|
||||
export function buildTargetNames() {
|
||||
const names = [];
|
||||
for (const name of DEPRECATED_NAMES) {
|
||||
names.push(name);
|
||||
names.push(`i-${name}`);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find every skills directory across all harness dirs in the project.
|
||||
* Returns absolute paths that exist on disk.
|
||||
*/
|
||||
export function findSkillsDirs(projectRoot) {
|
||||
const dirs = [];
|
||||
for (const harness of HARNESS_DIRS) {
|
||||
const candidate = join(projectRoot, harness, 'skills');
|
||||
if (existsSync(candidate)) {
|
||||
dirs.push(candidate);
|
||||
}
|
||||
}
|
||||
return dirs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove deprecated skill directories/symlinks from all harness dirs.
|
||||
* Reads skills-lock.json so the authoritative "source" field can
|
||||
* drive deletion even when SKILL.md never mentions impeccable.
|
||||
* Returns an array of paths that were deleted.
|
||||
*/
|
||||
export function removeDeprecatedSkills(projectRoot, lock) {
|
||||
if (lock === undefined) lock = loadLock(projectRoot);
|
||||
const targets = buildTargetNames();
|
||||
const skillsDirs = findSkillsDirs(projectRoot);
|
||||
const deleted = [];
|
||||
|
||||
for (const skillsDir of skillsDirs) {
|
||||
for (const name of targets) {
|
||||
const skillPath = join(skillsDir, name);
|
||||
|
||||
// Use lstat to detect symlinks (existsSync follows symlinks and
|
||||
// returns false for dangling ones).
|
||||
let stat;
|
||||
try {
|
||||
stat = lstatSync(skillPath);
|
||||
} catch {
|
||||
continue; // does not exist at all
|
||||
}
|
||||
|
||||
if (stat.isSymbolicLink()) {
|
||||
// Symlink: check the target if it's alive, otherwise treat
|
||||
// dangling symlinks to deprecated names as safe to remove.
|
||||
const targetAlive = existsSync(skillPath);
|
||||
const isMatch = targetAlive
|
||||
? isImpeccableSkill(skillPath, { skillName: name, lock })
|
||||
: true;
|
||||
if (isMatch) {
|
||||
unlinkSync(skillPath);
|
||||
deleted.push(skillPath);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular directory -- verify it belongs to impeccable
|
||||
if (isImpeccableSkill(skillPath, { skillName: name, lock })) {
|
||||
rmSync(skillPath, { recursive: true, force: true });
|
||||
deleted.push(skillPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove deprecated entries from skills-lock.json.
|
||||
* Only removes entries whose source is "pbakaus/impeccable".
|
||||
* Returns the list of removed skill names.
|
||||
*/
|
||||
export function cleanSkillsLock(projectRoot) {
|
||||
const lockPath = join(projectRoot, 'skills-lock.json');
|
||||
if (!existsSync(lockPath)) return [];
|
||||
|
||||
let lock;
|
||||
try {
|
||||
lock = JSON.parse(readFileSync(lockPath, 'utf-8'));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!lock.skills || typeof lock.skills !== 'object') return [];
|
||||
|
||||
const targets = buildTargetNames();
|
||||
const removed = [];
|
||||
|
||||
for (const name of targets) {
|
||||
const entry = lock.skills[name];
|
||||
if (!entry) continue;
|
||||
// Only remove if it belongs to impeccable
|
||||
if (entry.source === 'pbakaus/impeccable') {
|
||||
delete lock.skills[name];
|
||||
removed.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
if (removed.length > 0) {
|
||||
writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full cleanup. Returns a summary object.
|
||||
*
|
||||
* Order matters: read the lock and delete directories first, then
|
||||
* strip lock entries. Otherwise the authoritative signal is gone by
|
||||
* the time directory deletion runs.
|
||||
*/
|
||||
export function cleanup(projectRoot) {
|
||||
const root = projectRoot || findProjectRoot();
|
||||
const lock = loadLock(root);
|
||||
const deletedPaths = removeDeprecatedSkills(root, lock);
|
||||
const removedLockEntries = cleanSkillsLock(root);
|
||||
return { deletedPaths, removedLockEntries, projectRoot: root };
|
||||
}
|
||||
|
||||
// CLI entry point
|
||||
if (process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname)) {
|
||||
const result = cleanup();
|
||||
if (result.deletedPaths.length === 0 && result.removedLockEntries.length === 0) {
|
||||
console.log('No deprecated Impeccable skills found. Nothing to clean up.');
|
||||
} else {
|
||||
if (result.deletedPaths.length > 0) {
|
||||
console.log(`Removed ${result.deletedPaths.length} deprecated skill(s):`);
|
||||
for (const p of result.deletedPaths) console.log(` - ${p}`);
|
||||
}
|
||||
if (result.removedLockEntries.length > 0) {
|
||||
console.log(`Cleaned ${result.removedLockEntries.length} entry/entries from skills-lock.json:`);
|
||||
for (const name of result.removedLockEntries) console.log(` - ${name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -658,24 +658,6 @@ async function build() {
|
||||
}
|
||||
}
|
||||
|
||||
// Remove deprecated skill stubs from local harness dirs. They exist
|
||||
// in dist/ so the cleanup script can redirect users, but they should
|
||||
// not clutter the repo's own skill directories.
|
||||
const deprecatedLocalSkills = [
|
||||
'frontend-design', 'teach-impeccable',
|
||||
'arrange', 'normalize', 'onboard', 'extract',
|
||||
// v3.0 consolidation: standalone skills -> /impeccable sub-commands
|
||||
'adapt', 'animate', 'audit', 'bolder', 'clarify', 'colorize',
|
||||
'critique', 'delight', 'distill', 'harden', 'layout', 'optimize',
|
||||
'overdrive', 'polish', 'quieter', 'shape', 'typeset',
|
||||
];
|
||||
for (const { configDir } of syncConfigs) {
|
||||
for (const name of deprecatedLocalSkills) {
|
||||
const p = path.join(ROOT_DIR, configDir, 'skills', name);
|
||||
if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`📋 Synced skills to: ${syncConfigs.map(p => p.configDir).join(', ')}`);
|
||||
|
||||
// Build the Claude Code plugin subtree at ./plugin/.
|
||||
|
||||
@@ -25,11 +25,11 @@ export const SUITES = {
|
||||
triggers: [
|
||||
...COMMON_INFRA_PATTERNS,
|
||||
/^scripts\/(?!benchmark-detector|build-browser-detector|build-extension)/,
|
||||
/^skill\/(SKILL\.src\.md|agents\/|reference\/|scripts\/(cleanup-deprecated|context|context-signals|critique-storage|design-parser|impeccable-paths|is-generated))/,
|
||||
/^skill\/(SKILL\.src\.md|agents\/|reference\/|scripts\/(context|context-signals|critique-storage|design-parser|impeccable-paths|is-generated))/,
|
||||
/^site\/(pages|content|components|layouts)\//,
|
||||
/^README(\.npm)?\.md$/,
|
||||
/^cli\/bin\//,
|
||||
/^tests\/(build|cleanup-deprecated|context|context-signals|critique-storage|design-parser|docs-integrity|impeccable-paths|skills-cli|test-suites|windows-path-fix)\.test\.(js|mjs)$/,
|
||||
/^tests\/(build|context|context-signals|critique-storage|design-parser|docs-integrity|impeccable-paths|skills-cli|test-suites|windows-path-fix)\.test\.(js|mjs)$/,
|
||||
/^tests\/lib\//,
|
||||
],
|
||||
commands: [
|
||||
@@ -51,7 +51,6 @@ export const SUITES = {
|
||||
runner: 'node',
|
||||
files: [
|
||||
'tests/ci-test-plan.test.mjs',
|
||||
'tests/cleanup-deprecated.test.mjs',
|
||||
'tests/context.test.mjs',
|
||||
'tests/context-signals.test.mjs',
|
||||
'tests/critique-storage.test.mjs',
|
||||
|
||||
@@ -1,284 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Cleans up deprecated Impeccable skill files, symlinks, and
|
||||
* skills-lock.json entries left over from previous versions.
|
||||
*
|
||||
* Safe to run repeatedly -- it is a no-op when nothing needs cleaning.
|
||||
*
|
||||
* Usage (from the project root):
|
||||
* node {{scripts_path}}/cleanup-deprecated.mjs
|
||||
*
|
||||
* What it does:
|
||||
* 1. Finds every harness-specific skills directory (.claude/skills,
|
||||
* .cursor/skills, .agents/skills, etc.).
|
||||
* 2. For each deprecated skill name (with and without i- prefix),
|
||||
* checks if the directory exists and its SKILL.md mentions
|
||||
* "impeccable" (to avoid deleting unrelated user skills).
|
||||
* 3. Deletes confirmed matches (files, directories, or symlinks).
|
||||
* 4. Removes the corresponding entries from skills-lock.json.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, rmSync, readdirSync, statSync, lstatSync, unlinkSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
// Skills that were renamed, merged, or folded in v2.0, v2.1, and v3.0.
|
||||
const DEPRECATED_NAMES = [
|
||||
// v2.0 renames
|
||||
'frontend-design', // renamed to impeccable
|
||||
'teach-impeccable', // folded into /impeccable init
|
||||
// v2.1 merges
|
||||
'arrange', // renamed to layout
|
||||
'normalize', // merged into polish
|
||||
'onboard', // merged into harden
|
||||
'extract', // merged into /impeccable extract
|
||||
// v3.0 consolidation: all standalone skills -> /impeccable sub-commands
|
||||
'adapt',
|
||||
'animate',
|
||||
'audit',
|
||||
'bolder',
|
||||
'clarify',
|
||||
'colorize',
|
||||
'critique',
|
||||
'delight',
|
||||
'distill',
|
||||
'harden',
|
||||
'layout',
|
||||
'optimize',
|
||||
'overdrive',
|
||||
'polish',
|
||||
'quieter',
|
||||
'shape',
|
||||
'typeset',
|
||||
];
|
||||
|
||||
// All known harness directories that may contain a skills/ subfolder.
|
||||
const HARNESS_DIRS = [
|
||||
'.claude', '.cursor', '.gemini', '.codex', '.agents',
|
||||
'.trae', '.trae-cn', '.pi', '.opencode', '.kiro', '.rovodev',
|
||||
];
|
||||
|
||||
// Per-skill fingerprints for SKILL.md bodies that never mentioned
|
||||
// "impeccable" in their v2.x source. Used as a last-resort match
|
||||
// when no skills-lock.json exists and the word heuristic fails.
|
||||
// The strings are lifted verbatim from the v2.x frontmatter
|
||||
// descriptions, so collisions with hand-written user skills are
|
||||
// vanishingly unlikely.
|
||||
const SKILL_FINGERPRINTS = {
|
||||
harden: 'Make interfaces production-ready: error handling, empty states',
|
||||
optimize: 'Diagnoses and fixes UI performance across loading speed',
|
||||
};
|
||||
|
||||
/**
|
||||
* Walk up from startDir until we find a directory that looks like a
|
||||
* project root (has package.json, .git, or skills-lock.json).
|
||||
*/
|
||||
export function findProjectRoot(startDir = process.cwd()) {
|
||||
let dir = resolve(startDir);
|
||||
const { root } = { root: '/' };
|
||||
while (dir !== root) {
|
||||
if (
|
||||
existsSync(join(dir, 'package.json')) ||
|
||||
existsSync(join(dir, '.git')) ||
|
||||
existsSync(join(dir, 'skills-lock.json'))
|
||||
) {
|
||||
return dir;
|
||||
}
|
||||
const parent = resolve(dir, '..');
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return resolve(startDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load skills-lock.json from the project root, or null if missing/unreadable.
|
||||
*/
|
||||
export function loadLock(projectRoot) {
|
||||
const lockPath = join(projectRoot, 'skills-lock.json');
|
||||
if (!existsSync(lockPath)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(lockPath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a skill directory belongs to Impeccable. Three layered
|
||||
* signals, in order of reliability:
|
||||
* 1. Lock source equals "pbakaus/impeccable" (authoritative).
|
||||
* 2. SKILL.md body contains the word "impeccable".
|
||||
* 3. SKILL.md body contains a per-skill fingerprint (for harden and
|
||||
* optimize, whose v2.x SKILL.md never mentioned the pack name).
|
||||
*/
|
||||
export function isImpeccableSkill(skillDir, { skillName, lock } = {}) {
|
||||
// 1. Authoritative: the lock file claims this skill is ours.
|
||||
if (skillName && lock?.skills?.[skillName]?.source === 'pbakaus/impeccable') {
|
||||
return true;
|
||||
}
|
||||
const skillMd = join(skillDir, 'SKILL.md');
|
||||
if (!existsSync(skillMd)) return false;
|
||||
let content;
|
||||
try {
|
||||
content = readFileSync(skillMd, 'utf-8');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
// 2. Word-level content heuristic.
|
||||
if (/impeccable/i.test(content)) return true;
|
||||
// 3. Per-skill fingerprint for old skills that never mentioned the pack.
|
||||
// Strip the i- prefix so both `harden` and `i-harden` resolve to the
|
||||
// same fingerprint entry.
|
||||
const unprefixed = skillName?.startsWith('i-') ? skillName.slice(2) : skillName;
|
||||
const fingerprint = unprefixed && SKILL_FINGERPRINTS[unprefixed];
|
||||
if (fingerprint && content.includes(fingerprint)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full list of names to check: each deprecated name, plus
|
||||
* its i-prefixed variant.
|
||||
*/
|
||||
export function buildTargetNames() {
|
||||
const names = [];
|
||||
for (const name of DEPRECATED_NAMES) {
|
||||
names.push(name);
|
||||
names.push(`i-${name}`);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find every skills directory across all harness dirs in the project.
|
||||
* Returns absolute paths that exist on disk.
|
||||
*/
|
||||
export function findSkillsDirs(projectRoot) {
|
||||
const dirs = [];
|
||||
for (const harness of HARNESS_DIRS) {
|
||||
const candidate = join(projectRoot, harness, 'skills');
|
||||
if (existsSync(candidate)) {
|
||||
dirs.push(candidate);
|
||||
}
|
||||
}
|
||||
return dirs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove deprecated skill directories/symlinks from all harness dirs.
|
||||
* Reads skills-lock.json so the authoritative "source" field can
|
||||
* drive deletion even when SKILL.md never mentions impeccable.
|
||||
* Returns an array of paths that were deleted.
|
||||
*/
|
||||
export function removeDeprecatedSkills(projectRoot, lock) {
|
||||
if (lock === undefined) lock = loadLock(projectRoot);
|
||||
const targets = buildTargetNames();
|
||||
const skillsDirs = findSkillsDirs(projectRoot);
|
||||
const deleted = [];
|
||||
|
||||
for (const skillsDir of skillsDirs) {
|
||||
for (const name of targets) {
|
||||
const skillPath = join(skillsDir, name);
|
||||
|
||||
// Use lstat to detect symlinks (existsSync follows symlinks and
|
||||
// returns false for dangling ones).
|
||||
let stat;
|
||||
try {
|
||||
stat = lstatSync(skillPath);
|
||||
} catch {
|
||||
continue; // does not exist at all
|
||||
}
|
||||
|
||||
if (stat.isSymbolicLink()) {
|
||||
// Symlink: check the target if it's alive, otherwise treat
|
||||
// dangling symlinks to deprecated names as safe to remove.
|
||||
const targetAlive = existsSync(skillPath);
|
||||
const isMatch = targetAlive
|
||||
? isImpeccableSkill(skillPath, { skillName: name, lock })
|
||||
: true;
|
||||
if (isMatch) {
|
||||
unlinkSync(skillPath);
|
||||
deleted.push(skillPath);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular directory -- verify it belongs to impeccable
|
||||
if (isImpeccableSkill(skillPath, { skillName: name, lock })) {
|
||||
rmSync(skillPath, { recursive: true, force: true });
|
||||
deleted.push(skillPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove deprecated entries from skills-lock.json.
|
||||
* Only removes entries whose source is "pbakaus/impeccable".
|
||||
* Returns the list of removed skill names.
|
||||
*/
|
||||
export function cleanSkillsLock(projectRoot) {
|
||||
const lockPath = join(projectRoot, 'skills-lock.json');
|
||||
if (!existsSync(lockPath)) return [];
|
||||
|
||||
let lock;
|
||||
try {
|
||||
lock = JSON.parse(readFileSync(lockPath, 'utf-8'));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!lock.skills || typeof lock.skills !== 'object') return [];
|
||||
|
||||
const targets = buildTargetNames();
|
||||
const removed = [];
|
||||
|
||||
for (const name of targets) {
|
||||
const entry = lock.skills[name];
|
||||
if (!entry) continue;
|
||||
// Only remove if it belongs to impeccable
|
||||
if (entry.source === 'pbakaus/impeccable') {
|
||||
delete lock.skills[name];
|
||||
removed.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
if (removed.length > 0) {
|
||||
writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full cleanup. Returns a summary object.
|
||||
*
|
||||
* Order matters: read the lock and delete directories first, then
|
||||
* strip lock entries. Otherwise the authoritative signal is gone by
|
||||
* the time directory deletion runs.
|
||||
*/
|
||||
export function cleanup(projectRoot) {
|
||||
const root = projectRoot || findProjectRoot();
|
||||
const lock = loadLock(root);
|
||||
const deletedPaths = removeDeprecatedSkills(root, lock);
|
||||
const removedLockEntries = cleanSkillsLock(root);
|
||||
return { deletedPaths, removedLockEntries, projectRoot: root };
|
||||
}
|
||||
|
||||
// CLI entry point
|
||||
if (process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname)) {
|
||||
const result = cleanup();
|
||||
if (result.deletedPaths.length === 0 && result.removedLockEntries.length === 0) {
|
||||
console.log('No deprecated Impeccable skills found. Nothing to clean up.');
|
||||
} else {
|
||||
if (result.deletedPaths.length > 0) {
|
||||
console.log(`Removed ${result.deletedPaths.length} deprecated skill(s):`);
|
||||
for (const p of result.deletedPaths) console.log(` - ${p}`);
|
||||
}
|
||||
if (result.removedLockEntries.length > 0) {
|
||||
console.log(`Cleaned ${result.removedLockEntries.length} entry/entries from skills-lock.json:`);
|
||||
for (const name of result.removedLockEntries) console.log(` - ${name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,344 +0,0 @@
|
||||
import { describe, it, beforeEach, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync, writeFileSync, readFileSync, existsSync, symlinkSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
import {
|
||||
findProjectRoot,
|
||||
isImpeccableSkill,
|
||||
buildTargetNames,
|
||||
findSkillsDirs,
|
||||
removeDeprecatedSkills,
|
||||
cleanSkillsLock,
|
||||
cleanup,
|
||||
loadLock,
|
||||
} from '../skill/scripts/cleanup-deprecated.mjs';
|
||||
|
||||
function makeTmpDir() {
|
||||
return mkdtempSync(join(tmpdir(), 'impeccable-cleanup-test-'));
|
||||
}
|
||||
|
||||
function writeSkill(root, harness, name, content) {
|
||||
const dir = join(root, harness, 'skills', name);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, 'SKILL.md'), content, 'utf-8');
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe('cleanup-deprecated', () => {
|
||||
let tmp;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = makeTmpDir();
|
||||
// Mark as project root
|
||||
writeFileSync(join(tmp, 'package.json'), '{}', 'utf-8');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('findProjectRoot', () => {
|
||||
it('finds directory with package.json', () => {
|
||||
const sub = join(tmp, 'a', 'b', 'c');
|
||||
mkdirSync(sub, { recursive: true });
|
||||
assert.equal(findProjectRoot(sub), tmp);
|
||||
});
|
||||
|
||||
it('finds directory with skills-lock.json', () => {
|
||||
const root2 = makeTmpDir();
|
||||
writeFileSync(join(root2, 'skills-lock.json'), '{}', 'utf-8');
|
||||
assert.equal(findProjectRoot(root2), root2);
|
||||
rmSync(root2, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('isImpeccableSkill', () => {
|
||||
it('returns true when SKILL.md mentions impeccable', () => {
|
||||
const dir = writeSkill(tmp, '.claude', 'arrange', 'Invoke /impeccable first.');
|
||||
assert.equal(isImpeccableSkill(dir), true);
|
||||
});
|
||||
|
||||
it('returns false when SKILL.md does not mention impeccable', () => {
|
||||
const dir = writeSkill(tmp, '.claude', 'arrange', 'This is my custom arrange skill.');
|
||||
assert.equal(isImpeccableSkill(dir), false);
|
||||
});
|
||||
|
||||
it('returns false for non-existent directory', () => {
|
||||
assert.equal(isImpeccableSkill(join(tmp, 'nope')), false);
|
||||
});
|
||||
|
||||
it('returns true when lock source says pbakaus/impeccable, even if SKILL.md never mentions it', () => {
|
||||
const dir = writeSkill(tmp, '.claude', 'harden', 'A custom skill with no pack mention.');
|
||||
const lock = {
|
||||
skills: { harden: { source: 'pbakaus/impeccable' } },
|
||||
};
|
||||
assert.equal(isImpeccableSkill(dir, { skillName: 'harden', lock }), true);
|
||||
});
|
||||
|
||||
it('returns false when lock source is a different pack', () => {
|
||||
const dir = writeSkill(tmp, '.claude', 'harden', 'A custom skill with no pack mention.');
|
||||
const lock = {
|
||||
skills: { harden: { source: 'someone-else/pack' } },
|
||||
};
|
||||
assert.equal(isImpeccableSkill(dir, { skillName: 'harden', lock }), false);
|
||||
});
|
||||
|
||||
it('falls back to SKILL.md content when no lock entry exists', () => {
|
||||
const dir = writeSkill(tmp, '.claude', 'harden', 'Invoke /impeccable to harden.');
|
||||
const lock = { skills: {} };
|
||||
assert.equal(isImpeccableSkill(dir, { skillName: 'harden', lock }), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadLock', () => {
|
||||
it('returns null when skills-lock.json is missing', () => {
|
||||
assert.equal(loadLock(tmp), null);
|
||||
});
|
||||
|
||||
it('parses skills-lock.json when present', () => {
|
||||
const lock = { version: 1, skills: { arrange: { source: 'pbakaus/impeccable' } } };
|
||||
writeFileSync(join(tmp, 'skills-lock.json'), JSON.stringify(lock), 'utf-8');
|
||||
assert.deepEqual(loadLock(tmp), lock);
|
||||
});
|
||||
|
||||
it('returns null on malformed JSON', () => {
|
||||
writeFileSync(join(tmp, 'skills-lock.json'), '{not json', 'utf-8');
|
||||
assert.equal(loadLock(tmp), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildTargetNames', () => {
|
||||
it('includes both unprefixed and i-prefixed names', () => {
|
||||
const names = buildTargetNames();
|
||||
assert.ok(names.includes('arrange'));
|
||||
assert.ok(names.includes('i-arrange'));
|
||||
assert.ok(names.includes('frontend-design'));
|
||||
assert.ok(names.includes('i-frontend-design'));
|
||||
assert.equal(names.length, 46); // 23 deprecated * 2
|
||||
});
|
||||
});
|
||||
|
||||
describe('findSkillsDirs', () => {
|
||||
it('finds existing harness skill directories', () => {
|
||||
mkdirSync(join(tmp, '.claude', 'skills'), { recursive: true });
|
||||
mkdirSync(join(tmp, '.agents', 'skills'), { recursive: true });
|
||||
const dirs = findSkillsDirs(tmp);
|
||||
assert.equal(dirs.length, 2);
|
||||
});
|
||||
|
||||
it('ignores non-existent harness directories', () => {
|
||||
const dirs = findSkillsDirs(tmp);
|
||||
assert.equal(dirs.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeDeprecatedSkills', () => {
|
||||
it('deletes impeccable-owned deprecated skill directories', () => {
|
||||
writeSkill(tmp, '.claude', 'arrange', 'Invoke /impeccable first.');
|
||||
writeSkill(tmp, '.claude', 'normalize', 'Run impeccable teach.');
|
||||
const deleted = removeDeprecatedSkills(tmp);
|
||||
assert.equal(deleted.length, 2);
|
||||
assert.equal(existsSync(join(tmp, '.claude', 'skills', 'arrange')), false);
|
||||
assert.equal(existsSync(join(tmp, '.claude', 'skills', 'normalize')), false);
|
||||
});
|
||||
|
||||
it('does NOT delete skills that do not mention impeccable', () => {
|
||||
writeSkill(tmp, '.claude', 'arrange', 'My custom layout organizer.');
|
||||
const deleted = removeDeprecatedSkills(tmp);
|
||||
assert.equal(deleted.length, 0);
|
||||
assert.equal(existsSync(join(tmp, '.claude', 'skills', 'arrange')), true);
|
||||
});
|
||||
|
||||
it('deletes i-prefixed variants', () => {
|
||||
writeSkill(tmp, '.cursor', 'i-normalize', 'Invoke /impeccable first.');
|
||||
const deleted = removeDeprecatedSkills(tmp);
|
||||
assert.equal(deleted.length, 1);
|
||||
assert.equal(existsSync(join(tmp, '.cursor', 'skills', 'i-normalize')), false);
|
||||
});
|
||||
|
||||
it('cleans across multiple harness directories', () => {
|
||||
writeSkill(tmp, '.claude', 'onboard', 'Run impeccable teach first.');
|
||||
writeSkill(tmp, '.agents', 'onboard', 'Run impeccable teach first.');
|
||||
writeSkill(tmp, '.cursor', 'onboard', 'Run impeccable teach first.');
|
||||
const deleted = removeDeprecatedSkills(tmp);
|
||||
assert.equal(deleted.length, 3);
|
||||
});
|
||||
|
||||
it('leaves non-deprecated skills alone', () => {
|
||||
writeSkill(tmp, '.claude', 'my-custom-skill', 'Invoke /impeccable first.');
|
||||
writeSkill(tmp, '.claude', 'arrange', 'Invoke /impeccable first.');
|
||||
const deleted = removeDeprecatedSkills(tmp);
|
||||
assert.equal(deleted.length, 1); // only arrange
|
||||
assert.equal(existsSync(join(tmp, '.claude', 'skills', 'my-custom-skill')), true);
|
||||
});
|
||||
|
||||
it('deletes a stock v2.x harden skill with no lock file via the fingerprint fallback', () => {
|
||||
// Reproduces the no-lock-file install path: user installed via
|
||||
// submodule or manual copy, so skills-lock.json never existed. The
|
||||
// v2.x harden SKILL.md never contained the word "impeccable", but
|
||||
// does contain the distinctive description fingerprint.
|
||||
const body = [
|
||||
'---',
|
||||
'name: harden',
|
||||
'description: "Make interfaces production-ready: error handling, empty states, onboarding flows, i18n, text overflow, and edge case management."',
|
||||
'---',
|
||||
'',
|
||||
'Strengthen interfaces against edge cases.',
|
||||
].join('\n');
|
||||
writeSkill(tmp, '.claude', 'harden', body);
|
||||
const deleted = removeDeprecatedSkills(tmp);
|
||||
assert.equal(deleted.length, 1);
|
||||
assert.equal(existsSync(join(tmp, '.claude', 'skills', 'harden')), false);
|
||||
});
|
||||
|
||||
it('deletes a stock v2.x optimize skill with no lock file via the fingerprint fallback', () => {
|
||||
const body = [
|
||||
'---',
|
||||
'name: optimize',
|
||||
'description: "Diagnoses and fixes UI performance across loading speed, rendering, animations, images, and bundle size."',
|
||||
'---',
|
||||
'',
|
||||
'Identify and fix performance issues.',
|
||||
].join('\n');
|
||||
writeSkill(tmp, '.claude', 'optimize', body);
|
||||
const deleted = removeDeprecatedSkills(tmp);
|
||||
assert.equal(deleted.length, 1);
|
||||
});
|
||||
|
||||
it('does NOT delete a user-written harden skill that lacks both the pack word and the fingerprint', () => {
|
||||
writeSkill(tmp, '.claude', 'harden', 'My custom skill for hardening cookies against CSRF.');
|
||||
const deleted = removeDeprecatedSkills(tmp);
|
||||
assert.equal(deleted.length, 0);
|
||||
assert.equal(existsSync(join(tmp, '.claude', 'skills', 'harden')), true);
|
||||
});
|
||||
|
||||
it('deletes skills whose SKILL.md never mentions impeccable when the lock claims them', () => {
|
||||
// Reproduces the "orphan dir" bug: the old SKILL.md bodies described
|
||||
// each skill on its own merits and never said the word "impeccable",
|
||||
// so the content heuristic returned false. The lock source is the
|
||||
// authoritative signal.
|
||||
writeSkill(tmp, '.claude', 'harden', '# Harden\n\nA custom skill with zero pack-name mentions.');
|
||||
const lock = {
|
||||
version: 1,
|
||||
skills: { harden: { source: 'pbakaus/impeccable', sourceType: 'github', computedHash: 'x' } },
|
||||
};
|
||||
writeFileSync(join(tmp, 'skills-lock.json'), JSON.stringify(lock), 'utf-8');
|
||||
const deleted = removeDeprecatedSkills(tmp);
|
||||
assert.equal(deleted.length, 1);
|
||||
assert.equal(existsSync(join(tmp, '.claude', 'skills', 'harden')), false);
|
||||
});
|
||||
|
||||
it('does NOT delete a same-named skill owned by a different pack', () => {
|
||||
writeSkill(tmp, '.claude', 'extract', 'Some user-written extract skill.');
|
||||
const lock = {
|
||||
version: 1,
|
||||
skills: { extract: { source: 'someone-else/pack' } },
|
||||
};
|
||||
writeFileSync(join(tmp, 'skills-lock.json'), JSON.stringify(lock), 'utf-8');
|
||||
const deleted = removeDeprecatedSkills(tmp);
|
||||
assert.equal(deleted.length, 0);
|
||||
assert.equal(existsSync(join(tmp, '.claude', 'skills', 'extract')), true);
|
||||
});
|
||||
|
||||
it('handles symlinks to deprecated skills', () => {
|
||||
// Create the canonical skill in .agents
|
||||
const canonical = writeSkill(tmp, '.agents', 'extract', 'Use impeccable extract.');
|
||||
// Create a symlink in .claude
|
||||
mkdirSync(join(tmp, '.claude', 'skills'), { recursive: true });
|
||||
symlinkSync(canonical, join(tmp, '.claude', 'skills', 'extract'));
|
||||
const deleted = removeDeprecatedSkills(tmp);
|
||||
assert.equal(deleted.length, 2); // both canonical and symlink
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanSkillsLock', () => {
|
||||
it('removes impeccable-owned deprecated entries', () => {
|
||||
const lock = {
|
||||
version: 1,
|
||||
skills: {
|
||||
arrange: { source: 'pbakaus/impeccable', sourceType: 'github', computedHash: 'abc' },
|
||||
impeccable: { source: 'pbakaus/impeccable', sourceType: 'github', computedHash: 'def' },
|
||||
'resolve-reviews': { source: 'pbakaus/agent-reviews', sourceType: 'github', computedHash: 'ghi' },
|
||||
},
|
||||
};
|
||||
writeFileSync(join(tmp, 'skills-lock.json'), JSON.stringify(lock), 'utf-8');
|
||||
const removed = cleanSkillsLock(tmp);
|
||||
assert.deepEqual(removed, ['arrange']);
|
||||
const updated = JSON.parse(readFileSync(join(tmp, 'skills-lock.json'), 'utf-8'));
|
||||
assert.equal(updated.skills.arrange, undefined);
|
||||
assert.ok(updated.skills.impeccable); // not deprecated
|
||||
assert.ok(updated.skills['resolve-reviews']); // different source
|
||||
});
|
||||
|
||||
it('does NOT remove entries from other sources', () => {
|
||||
const lock = {
|
||||
version: 1,
|
||||
skills: {
|
||||
extract: { source: 'some-other/package', sourceType: 'github', computedHash: 'xyz' },
|
||||
},
|
||||
};
|
||||
writeFileSync(join(tmp, 'skills-lock.json'), JSON.stringify(lock), 'utf-8');
|
||||
const removed = cleanSkillsLock(tmp);
|
||||
assert.equal(removed.length, 0);
|
||||
});
|
||||
|
||||
it('handles missing skills-lock.json gracefully', () => {
|
||||
const removed = cleanSkillsLock(tmp);
|
||||
assert.equal(removed.length, 0);
|
||||
});
|
||||
|
||||
it('removes i-prefixed entries', () => {
|
||||
const lock = {
|
||||
version: 1,
|
||||
skills: {
|
||||
'i-arrange': { source: 'pbakaus/impeccable', sourceType: 'github', computedHash: 'abc' },
|
||||
'i-normalize': { source: 'pbakaus/impeccable', sourceType: 'github', computedHash: 'def' },
|
||||
},
|
||||
};
|
||||
writeFileSync(join(tmp, 'skills-lock.json'), JSON.stringify(lock), 'utf-8');
|
||||
const removed = cleanSkillsLock(tmp);
|
||||
assert.equal(removed.length, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanup (integration)', () => {
|
||||
it('cleans both files and lock entries in one pass', () => {
|
||||
// Set up deprecated skills in two harness dirs
|
||||
writeSkill(tmp, '.claude', 'arrange', 'Invoke /impeccable.');
|
||||
writeSkill(tmp, '.agents', 'arrange', 'Invoke /impeccable.');
|
||||
writeSkill(tmp, '.claude', 'extract', 'Run impeccable extract.');
|
||||
|
||||
// Set up lock file
|
||||
const lock = {
|
||||
version: 1,
|
||||
skills: {
|
||||
arrange: { source: 'pbakaus/impeccable', sourceType: 'github', computedHash: 'a' },
|
||||
extract: { source: 'pbakaus/impeccable', sourceType: 'github', computedHash: 'b' },
|
||||
impeccable: { source: 'pbakaus/impeccable', sourceType: 'github', computedHash: 'c' },
|
||||
},
|
||||
};
|
||||
writeFileSync(join(tmp, 'skills-lock.json'), JSON.stringify(lock), 'utf-8');
|
||||
|
||||
const result = cleanup(tmp);
|
||||
assert.equal(result.deletedPaths.length, 3);
|
||||
assert.equal(result.removedLockEntries.length, 2); // arrange + extract
|
||||
assert.equal(existsSync(join(tmp, '.claude', 'skills', 'arrange')), false);
|
||||
assert.equal(existsSync(join(tmp, '.agents', 'skills', 'arrange')), false);
|
||||
|
||||
const updated = JSON.parse(readFileSync(join(tmp, 'skills-lock.json'), 'utf-8'));
|
||||
assert.ok(updated.skills.impeccable); // not deprecated
|
||||
assert.equal(updated.skills.arrange, undefined);
|
||||
assert.equal(updated.skills.extract, undefined);
|
||||
});
|
||||
|
||||
it('is a no-op when nothing needs cleaning', () => {
|
||||
writeSkill(tmp, '.claude', 'my-custom-skill', 'Invoke /impeccable.');
|
||||
const result = cleanup(tmp);
|
||||
assert.equal(result.deletedPaths.length, 0);
|
||||
assert.equal(result.removedLockEntries.length, 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -299,7 +299,7 @@ describe('skills: unprefix migration', () => {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('leaves the legacy teach-impeccable name alone (cleanup owns that)', () => {
|
||||
test('leaves unrelated legacy skill names alone', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-mig-legacy-'));
|
||||
createFakeSkills(tmp, ['teach-impeccable'], ['.claude']);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user