cleanup-deprecated.mjs

  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}