1#!/usr/bin/env node
2/**
3 * Cleans up deprecated Impeccable skill files, symlinks, and
4 * skills-lock.json entries left over from previous versions.
5 *
6 * Safe to run repeatedly -- it is a no-op when nothing needs cleaning.
7 *
8 * Usage (from the project root):
9 * node {{scripts_path}}/cleanup-deprecated.mjs
10 *
11 * What it does:
12 * 1. Finds every harness-specific skills directory (.claude/skills,
13 * .cursor/skills, .agents/skills, etc.).
14 * 2. For each deprecated skill name (with and without i- prefix),
15 * checks if the directory exists and its SKILL.md mentions
16 * "impeccable" (to avoid deleting unrelated user skills).
17 * 3. Deletes confirmed matches (files, directories, or symlinks).
18 * 4. Removes the corresponding entries from skills-lock.json.
19 */
20
21import { existsSync, readFileSync, writeFileSync, rmSync, readdirSync, statSync, lstatSync, unlinkSync } from 'node:fs';
22import { join, resolve } from 'node:path';
23
24// Skills that were renamed, merged, or folded in v2.0, v2.1, and v3.0.
25const DEPRECATED_NAMES = [
26 // v2.0 renames
27 'frontend-design', // renamed to impeccable
28 'teach-impeccable', // folded into /impeccable teach
29 // v2.1 merges
30 'arrange', // renamed to layout
31 'normalize', // merged into polish
32 'onboard', // merged into harden
33 'extract', // merged into /impeccable extract
34 // v3.0 consolidation: all standalone skills -> /impeccable sub-commands
35 'adapt',
36 'animate',
37 'audit',
38 'bolder',
39 'clarify',
40 'colorize',
41 'critique',
42 'delight',
43 'distill',
44 'harden',
45 'layout',
46 'optimize',
47 'overdrive',
48 'polish',
49 'quieter',
50 'shape',
51 'typeset',
52];
53
54// All known harness directories that may contain a skills/ subfolder.
55const HARNESS_DIRS = [
56 '.claude', '.cursor', '.gemini', '.codex', '.agents',
57 '.trae', '.trae-cn', '.pi', '.opencode', '.kiro', '.rovodev',
58];
59
60// Per-skill fingerprints for SKILL.md bodies that never mentioned
61// "impeccable" in their v2.x source. Used as a last-resort match
62// when no skills-lock.json exists and the word heuristic fails.
63// The strings are lifted verbatim from the v2.x frontmatter
64// descriptions, so collisions with hand-written user skills are
65// vanishingly unlikely.
66const SKILL_FINGERPRINTS = {
67 harden: 'Make interfaces production-ready: error handling, empty states',
68 optimize: 'Diagnoses and fixes UI performance across loading speed',
69};
70
71/**
72 * Walk up from startDir until we find a directory that looks like a
73 * project root (has package.json, .git, or skills-lock.json).
74 */
75export function findProjectRoot(startDir = process.cwd()) {
76 let dir = resolve(startDir);
77 const { root } = { root: '/' };
78 while (dir !== root) {
79 if (
80 existsSync(join(dir, 'package.json')) ||
81 existsSync(join(dir, '.git')) ||
82 existsSync(join(dir, 'skills-lock.json'))
83 ) {
84 return dir;
85 }
86 const parent = resolve(dir, '..');
87 if (parent === dir) break;
88 dir = parent;
89 }
90 return resolve(startDir);
91}
92
93/**
94 * Load skills-lock.json from the project root, or null if missing/unreadable.
95 */
96export function loadLock(projectRoot) {
97 const lockPath = join(projectRoot, 'skills-lock.json');
98 if (!existsSync(lockPath)) return null;
99 try {
100 return JSON.parse(readFileSync(lockPath, 'utf-8'));
101 } catch {
102 return null;
103 }
104}
105
106/**
107 * Check whether a skill directory belongs to Impeccable. Three layered
108 * signals, in order of reliability:
109 * 1. Lock source equals "pbakaus/impeccable" (authoritative).
110 * 2. SKILL.md body contains the word "impeccable".
111 * 3. SKILL.md body contains a per-skill fingerprint (for harden and
112 * optimize, whose v2.x SKILL.md never mentioned the pack name).
113 */
114export function isImpeccableSkill(skillDir, { skillName, lock } = {}) {
115 // 1. Authoritative: the lock file claims this skill is ours.
116 if (skillName && lock?.skills?.[skillName]?.source === 'pbakaus/impeccable') {
117 return true;
118 }
119 const skillMd = join(skillDir, 'SKILL.md');
120 if (!existsSync(skillMd)) return false;
121 let content;
122 try {
123 content = readFileSync(skillMd, 'utf-8');
124 } catch {
125 return false;
126 }
127 // 2. Word-level content heuristic.
128 if (/impeccable/i.test(content)) return true;
129 // 3. Per-skill fingerprint for old skills that never mentioned the pack.
130 // Strip the i- prefix so both `harden` and `i-harden` resolve to the
131 // same fingerprint entry.
132 const unprefixed = skillName?.startsWith('i-') ? skillName.slice(2) : skillName;
133 const fingerprint = unprefixed && SKILL_FINGERPRINTS[unprefixed];
134 if (fingerprint && content.includes(fingerprint)) return true;
135 return false;
136}
137
138/**
139 * Build the full list of names to check: each deprecated name, plus
140 * its i-prefixed variant.
141 */
142export function buildTargetNames() {
143 const names = [];
144 for (const name of DEPRECATED_NAMES) {
145 names.push(name);
146 names.push(`i-${name}`);
147 }
148 return names;
149}
150
151/**
152 * Find every skills directory across all harness dirs in the project.
153 * Returns absolute paths that exist on disk.
154 */
155export function findSkillsDirs(projectRoot) {
156 const dirs = [];
157 for (const harness of HARNESS_DIRS) {
158 const candidate = join(projectRoot, harness, 'skills');
159 if (existsSync(candidate)) {
160 dirs.push(candidate);
161 }
162 }
163 return dirs;
164}
165
166/**
167 * Remove deprecated skill directories/symlinks from all harness dirs.
168 * Reads skills-lock.json so the authoritative "source" field can
169 * drive deletion even when SKILL.md never mentions impeccable.
170 * Returns an array of paths that were deleted.
171 */
172export function removeDeprecatedSkills(projectRoot, lock) {
173 if (lock === undefined) lock = loadLock(projectRoot);
174 const targets = buildTargetNames();
175 const skillsDirs = findSkillsDirs(projectRoot);
176 const deleted = [];
177
178 for (const skillsDir of skillsDirs) {
179 for (const name of targets) {
180 const skillPath = join(skillsDir, name);
181
182 // Use lstat to detect symlinks (existsSync follows symlinks and
183 // returns false for dangling ones).
184 let stat;
185 try {
186 stat = lstatSync(skillPath);
187 } catch {
188 continue; // does not exist at all
189 }
190
191 if (stat.isSymbolicLink()) {
192 // Symlink: check the target if it's alive, otherwise treat
193 // dangling symlinks to deprecated names as safe to remove.
194 const targetAlive = existsSync(skillPath);
195 const isMatch = targetAlive
196 ? isImpeccableSkill(skillPath, { skillName: name, lock })
197 : true;
198 if (isMatch) {
199 unlinkSync(skillPath);
200 deleted.push(skillPath);
201 }
202 continue;
203 }
204
205 // Regular directory -- verify it belongs to impeccable
206 if (isImpeccableSkill(skillPath, { skillName: name, lock })) {
207 rmSync(skillPath, { recursive: true, force: true });
208 deleted.push(skillPath);
209 }
210 }
211 }
212
213 return deleted;
214}
215
216/**
217 * Remove deprecated entries from skills-lock.json.
218 * Only removes entries whose source is "pbakaus/impeccable".
219 * Returns the list of removed skill names.
220 */
221export function cleanSkillsLock(projectRoot) {
222 const lockPath = join(projectRoot, 'skills-lock.json');
223 if (!existsSync(lockPath)) return [];
224
225 let lock;
226 try {
227 lock = JSON.parse(readFileSync(lockPath, 'utf-8'));
228 } catch {
229 return [];
230 }
231
232 if (!lock.skills || typeof lock.skills !== 'object') return [];
233
234 const targets = buildTargetNames();
235 const removed = [];
236
237 for (const name of targets) {
238 const entry = lock.skills[name];
239 if (!entry) continue;
240 // Only remove if it belongs to impeccable
241 if (entry.source === 'pbakaus/impeccable') {
242 delete lock.skills[name];
243 removed.push(name);
244 }
245 }
246
247 if (removed.length > 0) {
248 writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n', 'utf-8');
249 }
250
251 return removed;
252}
253
254/**
255 * Run the full cleanup. Returns a summary object.
256 *
257 * Order matters: read the lock and delete directories first, then
258 * strip lock entries. Otherwise the authoritative signal is gone by
259 * the time directory deletion runs.
260 */
261export function cleanup(projectRoot) {
262 const root = projectRoot || findProjectRoot();
263 const lock = loadLock(root);
264 const deletedPaths = removeDeprecatedSkills(root, lock);
265 const removedLockEntries = cleanSkillsLock(root);
266 return { deletedPaths, removedLockEntries, projectRoot: root };
267}
268
269// CLI entry point
270if (process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname)) {
271 const result = cleanup();
272 if (result.deletedPaths.length === 0 && result.removedLockEntries.length === 0) {
273 console.log('No deprecated Impeccable skills found. Nothing to clean up.');
274 } else {
275 if (result.deletedPaths.length > 0) {
276 console.log(`Removed ${result.deletedPaths.length} deprecated skill(s):`);
277 for (const p of result.deletedPaths) console.log(` - ${p}`);
278 }
279 if (result.removedLockEntries.length > 0) {
280 console.log(`Cleaned ${result.removedLockEntries.length} entry/entries from skills-lock.json:`);
281 for (const name of result.removedLockEntries) console.log(` - ${name}`);
282 }
283 }
284}