Last active
February 26, 2026 16:38
-
-
Save petekp/78e9188dc1da2b01663e0942fd703dbe to your computer and use it in GitHub Desktop.
Skill Auditor — finds duplicate SKILL.md files across all locations and interactively prompts for cleanup
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env node | |
| /** | |
| * Skill Audit Tool | |
| * | |
| * Auto-discovers ALL SKILL.md files across your system — Claude Code, | |
| * Codex, Copilot, Cursor, Gemini, OpenCode, Goose, Continue, Factory, | |
| * OpenClaw, OpenHands, .agents, project repos, and more. | |
| * | |
| * Identifies duplicates by name and content similarity, | |
| * and interactively prompts for deletion. | |
| * | |
| * Usage: | |
| * node skill-audit.mjs Interactive mode (prompts for each dupe) | |
| * node skill-audit.mjs --report Report-only mode (no deletions, full overview) | |
| * node skill-audit.mjs --skip-managed Skip cache/marketplace dupes in interactive mode | |
| */ | |
| import { readdir, readFile, stat, readlink, rm, lstat, realpath } from "node:fs/promises"; | |
| import { join, resolve, dirname, basename, relative } from "node:path"; | |
| import { homedir } from "node:os"; | |
| import { createInterface } from "node:readline"; | |
| const HOME = homedir(); | |
| const args = new Set(process.argv.slice(2)); | |
| const REPORT_ONLY = args.has("--report"); | |
| const SKIP_MANAGED = args.has("--skip-managed"); | |
| // ─── Auto-discovery: build scan roots dynamically ─── | |
| async function discoverScanRoots() { | |
| const roots = new Set(); | |
| // Known Claude Code paths | |
| const claudeRoots = [ | |
| join(HOME, ".claude/skills"), | |
| join(HOME, ".claude/plugins/local"), | |
| join(HOME, ".claude/plugins/cache"), | |
| join(HOME, ".claude/plugins/marketplaces"), | |
| ]; | |
| // Global .agents directory | |
| const agentsRoots = [ | |
| join(HOME, ".agents/skills"), | |
| join(HOME, ".agents"), | |
| ]; | |
| // Other agent tool config directories that may contain skills | |
| const agentToolDirs = [ | |
| ".codex/skills", | |
| ".config/agents/skills", | |
| ".config/crush/skills", | |
| ".config/goose/skills", | |
| ".config/opencode/skills", | |
| ".config/opencode/skill", | |
| ".continue/skills", | |
| ".copilot/skills", | |
| ".cursor/skills-cursor", | |
| ".factory/skills", | |
| ".gemini/skills", | |
| ".gemini/antigravity/skills", | |
| ".openclaw/skills", | |
| ".openhands/skills", | |
| ]; | |
| // Known development repos | |
| const devRepos = [ | |
| join(HOME, "Code/agent-skills/skills"), | |
| join(HOME, "Code/openclaw/skills"), | |
| ]; | |
| // Other known locations | |
| const otherRoots = [ | |
| join(HOME, ".local/share/shaping-skills"), | |
| ]; | |
| // Add all known paths | |
| for (const p of [...claudeRoots, ...agentsRoots, ...devRepos, ...otherRoots]) { | |
| roots.add(p); | |
| } | |
| for (const rel of agentToolDirs) { | |
| roots.add(join(HOME, rel)); | |
| } | |
| // Auto-discover: scan $HOME for any dotdir containing a "skills" subdirectory | |
| try { | |
| const homeEntries = await readdir(HOME, { withFileTypes: true }); | |
| for (const entry of homeEntries) { | |
| if (!entry.name.startsWith(".")) continue; | |
| if (entry.name === ".Trash" || entry.name === ".npm" || entry.name === ".nvm") continue; | |
| const dotdir = join(HOME, entry.name); | |
| const skillsDir = join(dotdir, "skills"); | |
| try { | |
| const s = await stat(skillsDir); | |
| if (s.isDirectory()) roots.add(skillsDir); | |
| } catch {} | |
| } | |
| } catch {} | |
| // Auto-discover: scan Code/ for project-level .agents directories | |
| const codeDir = join(HOME, "Code"); | |
| try { | |
| const codeEntries = await readdir(codeDir, { withFileTypes: true }); | |
| for (const entry of codeEntries) { | |
| if (!entry.isDirectory()) continue; | |
| const projectAgents = join(codeDir, entry.name, ".agents/skills"); | |
| try { | |
| const s = await stat(projectAgents); | |
| if (s.isDirectory()) roots.add(projectAgents); | |
| } catch {} | |
| // Also check nested (monorepo) projects one level deeper | |
| try { | |
| const subEntries = await readdir(join(codeDir, entry.name), { withFileTypes: true }); | |
| for (const sub of subEntries) { | |
| if (!sub.isDirectory()) continue; | |
| const nested = join(codeDir, entry.name, sub.name, ".agents/skills"); | |
| try { | |
| const s = await stat(nested); | |
| if (s.isDirectory()) roots.add(nested); | |
| } catch {} | |
| } | |
| } catch {} | |
| } | |
| } catch {} | |
| // Filter to only roots that actually exist | |
| const validRoots = []; | |
| for (const root of roots) { | |
| try { | |
| await stat(root); | |
| validRoots.push(root); | |
| } catch {} | |
| } | |
| return validRoots; | |
| } | |
| // ─── ANSI colors ─────────────────────────────── | |
| const c = { | |
| reset: "\x1b[0m", | |
| bold: "\x1b[1m", | |
| dim: "\x1b[2m", | |
| red: "\x1b[31m", | |
| green: "\x1b[32m", | |
| yellow: "\x1b[33m", | |
| blue: "\x1b[34m", | |
| magenta: "\x1b[35m", | |
| cyan: "\x1b[36m", | |
| gray: "\x1b[90m", | |
| white: "\x1b[37m", | |
| }; | |
| // ─── Location classification ─────────────────── | |
| function classifyLocation(filepath) { | |
| const rel = relative(HOME, filepath); | |
| // Claude Code | |
| if (rel.startsWith(".claude/plugins/cache/")) return "claude-cache"; | |
| if (rel.startsWith(".claude/plugins/local/")) return "claude-local-plugin"; | |
| if (rel.startsWith(".claude/plugins/marketplaces/")) return "claude-marketplace"; | |
| if (rel.startsWith(".claude/skills") || rel.startsWith("Code/claude-code-setup/skills")) | |
| return "claude-active"; | |
| // Global .agents | |
| if (rel.startsWith(".agents/")) return "agents-global"; | |
| // Other agent tools (dotdirs) | |
| if (rel.startsWith(".codex/")) return "codex"; | |
| if (rel.startsWith(".config/agents/")) return "config-agents"; | |
| if (rel.startsWith(".config/crush/")) return "crush"; | |
| if (rel.startsWith(".config/goose/")) return "goose"; | |
| if (rel.startsWith(".config/opencode/")) return "opencode"; | |
| if (rel.startsWith(".continue/")) return "continue"; | |
| if (rel.startsWith(".copilot/")) return "copilot"; | |
| if (rel.startsWith(".cursor/")) return "cursor"; | |
| if (rel.startsWith(".factory/")) return "factory"; | |
| if (rel.startsWith(".gemini/")) return "gemini"; | |
| if (rel.startsWith(".openclaw/")) return "openclaw"; | |
| if (rel.startsWith(".openhands/")) return "openhands"; | |
| // Dev repos | |
| if (rel.startsWith("Code/agent-skills/")) return "agent-skills-repo"; | |
| if (rel.startsWith("Code/openclaw/")) return "openclaw-repo"; | |
| if (rel.startsWith("Code/")) return "project-repo"; | |
| // Other | |
| if (rel.startsWith(".local/share/shaping-skills")) return "shaping"; | |
| return "other"; | |
| } | |
| function locationLabel(loc) { | |
| const labels = { | |
| "claude-active": `${c.green}CLAUDE ACTIVE${c.reset}`, | |
| "claude-cache": `${c.gray}CLAUDE CACHE${c.reset}`, | |
| "claude-local-plugin": `${c.cyan}CLAUDE LOCAL PLUGIN${c.reset}`, | |
| "claude-marketplace": `${c.blue}CLAUDE MARKETPLACE${c.reset}`, | |
| "agents-global": `${c.yellow}~/.agents${c.reset}`, | |
| codex: `${c.magenta}CODEX${c.reset}`, | |
| "config-agents": `${c.magenta}.config/agents${c.reset}`, | |
| crush: `${c.magenta}CRUSH${c.reset}`, | |
| goose: `${c.magenta}GOOSE${c.reset}`, | |
| opencode: `${c.magenta}OPENCODE${c.reset}`, | |
| continue: `${c.magenta}CONTINUE${c.reset}`, | |
| copilot: `${c.magenta}COPILOT${c.reset}`, | |
| cursor: `${c.magenta}CURSOR${c.reset}`, | |
| factory: `${c.magenta}FACTORY${c.reset}`, | |
| gemini: `${c.magenta}GEMINI${c.reset}`, | |
| openclaw: `${c.magenta}OPENCLAW${c.reset}`, | |
| openhands: `${c.magenta}OPENHANDS${c.reset}`, | |
| "agent-skills-repo": `${c.blue}AGENT-SKILLS REPO${c.reset}`, | |
| "openclaw-repo": `${c.blue}OPENCLAW REPO${c.reset}`, | |
| "project-repo": `${c.blue}PROJECT REPO${c.reset}`, | |
| shaping: `${c.yellow}SHAPING${c.reset}`, | |
| other: `${c.dim}OTHER${c.reset}`, | |
| }; | |
| return labels[loc] || `${c.dim}${loc}${c.reset}`; | |
| } | |
| // Priority: lower = more authoritative (keep first) | |
| function locationPriority(loc) { | |
| const priorities = { | |
| "claude-active": 0, | |
| "claude-local-plugin": 1, | |
| "agents-global": 2, | |
| "agent-skills-repo": 3, | |
| "openclaw-repo": 4, | |
| "project-repo": 5, | |
| shaping: 6, | |
| codex: 10, | |
| "config-agents": 10, | |
| copilot: 10, | |
| cursor: 10, | |
| gemini: 10, | |
| opencode: 10, | |
| continue: 10, | |
| goose: 10, | |
| crush: 10, | |
| factory: 10, | |
| openclaw: 10, | |
| openhands: 10, | |
| "claude-marketplace": 15, | |
| "claude-cache": 20, | |
| other: 25, | |
| }; | |
| return priorities[loc] ?? 99; | |
| } | |
| function isManaged(location) { | |
| return location === "claude-cache" || location === "claude-marketplace"; | |
| } | |
| function isAgentToolCopy(location) { | |
| return locationPriority(location) === 10; | |
| } | |
| // ─── File discovery ──────────────────────────── | |
| async function findSkillFiles(root) { | |
| const results = []; | |
| async function walk(dir, depth = 0) { | |
| if (depth > 8) return; | |
| let entries; | |
| try { | |
| entries = await readdir(dir, { withFileTypes: true }); | |
| } catch { | |
| return; | |
| } | |
| for (const entry of entries) { | |
| const full = join(dir, entry.name); | |
| if (entry.name === "node_modules" || entry.name === ".git") continue; | |
| if (entry.isDirectory() || entry.isSymbolicLink()) { | |
| const skillPath = join(full, "SKILL.md"); | |
| try { | |
| await stat(skillPath); | |
| results.push(skillPath); | |
| } catch {} | |
| try { | |
| await walk(full, depth + 1); | |
| } catch {} | |
| } | |
| } | |
| } | |
| try { | |
| await stat(root); | |
| await walk(root); | |
| } catch {} | |
| return results; | |
| } | |
| // ─── Content analysis ────────────────────────── | |
| function extractFrontmatter(content) { | |
| const match = content.match(/^---\n([\s\S]*?)\n---/); | |
| if (!match) return {}; | |
| const fm = {}; | |
| for (const line of match[1].split("\n")) { | |
| const kv = line.match(/^(\w[\w-]*):\s*(.+)/); | |
| if (kv) fm[kv[1]] = kv[2].replace(/^["']|["']$/g, ""); | |
| } | |
| return fm; | |
| } | |
| function normalizeContent(content) { | |
| return content | |
| .replace(/^---[\s\S]*?---\n?/, "") | |
| .replace(/\s+/g, " ") | |
| .trim() | |
| .toLowerCase(); | |
| } | |
| function trigramSimilarity(a, b) { | |
| if (!a || !b) return 0; | |
| const sa = a.slice(0, 2000); | |
| const sb = b.slice(0, 2000); | |
| const trigramsA = new Set(); | |
| const trigramsB = new Set(); | |
| for (let i = 0; i <= sa.length - 3; i++) trigramsA.add(sa.slice(i, i + 3)); | |
| for (let i = 0; i <= sb.length - 3; i++) trigramsB.add(sb.slice(i, i + 3)); | |
| let intersection = 0; | |
| for (const t of trigramsA) if (trigramsB.has(t)) intersection++; | |
| const union = trigramsA.size + trigramsB.size - intersection; | |
| return union === 0 ? 0 : intersection / union; | |
| } | |
| async function resolveSymlink(filepath) { | |
| try { | |
| const s = await lstat(filepath); | |
| if (s.isSymbolicLink()) { | |
| const target = await readlink(filepath); | |
| return resolve(dirname(filepath), target); | |
| } | |
| } catch {} | |
| return null; | |
| } | |
| function shortPath(p) { | |
| return relative(HOME, p).replace(/\/SKILL\.md$/, ""); | |
| } | |
| function sortByPriority(group) { | |
| return [...group].sort((a, b) => { | |
| const diff = locationPriority(a.location) - locationPriority(b.location); | |
| if (diff !== 0) return diff; | |
| return b.size - a.size; | |
| }); | |
| } | |
| // ─── Main ────────────────────────────────────── | |
| async function main() { | |
| console.log(`\n${c.bold}🔍 Skill Audit Tool${c.reset}`); | |
| if (REPORT_ONLY) console.log(`${c.dim}Mode: report only (no deletions)${c.reset}`); | |
| if (SKIP_MANAGED) console.log(`${c.dim}Mode: skipping cache/marketplace dupes${c.reset}`); | |
| console.log(`${c.dim}Auto-discovering skill locations...${c.reset}\n`); | |
| // 1. Discover scan roots | |
| const scanRoots = await discoverScanRoots(); | |
| console.log(`${c.bold}Scanning ${c.cyan}${scanRoots.length}${c.reset}${c.bold} locations:${c.reset}`); | |
| for (const root of scanRoots.sort()) { | |
| console.log(` ${c.dim}${relative(HOME, root)}${c.reset}`); | |
| } | |
| console.log(); | |
| // 2. Find all SKILL.md files | |
| const pathSet = new Set(); | |
| for (const root of scanRoots) { | |
| const found = await findSkillFiles(root); | |
| for (const f of found) { | |
| // Deduplicate by real path to avoid counting symlinks to the same file | |
| try { | |
| const real = await realpath(f); | |
| if (!pathSet.has(real)) { | |
| pathSet.add(real); | |
| } | |
| } catch { | |
| pathSet.add(f); | |
| } | |
| } | |
| } | |
| // But we want to keep all paths (including symlink origins) for display purposes | |
| // Re-scan but track which real paths we've already loaded content for | |
| const allPaths = []; | |
| for (const root of scanRoots) { | |
| const found = await findSkillFiles(root); | |
| allPaths.push(...found); | |
| } | |
| const skills = []; | |
| for (const p of allPaths) { | |
| let real; | |
| try { | |
| real = await realpath(p); | |
| } catch { | |
| real = p; | |
| } | |
| // Allow same real path to appear if from different logical locations | |
| // (e.g., a symlink in .claude/skills and the actual file in .agents) | |
| const dir = dirname(p); | |
| const skillName = basename(dir); | |
| const location = classifyLocation(p); | |
| let content = ""; | |
| try { | |
| content = await readFile(p, "utf-8"); | |
| } catch { | |
| continue; | |
| } | |
| const frontmatter = extractFrontmatter(content); | |
| const normalized = normalizeContent(content); | |
| const symTarget = await resolveSymlink(dir); | |
| skills.push({ | |
| path: p, | |
| realPath: real, | |
| dir, | |
| skillName, | |
| fmName: frontmatter.name || skillName, | |
| description: (frontmatter.description || "").slice(0, 120), | |
| location, | |
| normalized, | |
| symTarget, | |
| size: content.length, | |
| }); | |
| } | |
| // Deduplicate entries with identical real paths AND identical locations | |
| const uniqueSkills = []; | |
| const seen = new Set(); | |
| for (const s of skills) { | |
| const key = `${s.realPath}::${s.location}`; | |
| if (!seen.has(key)) { | |
| seen.add(key); | |
| uniqueSkills.push(s); | |
| } | |
| } | |
| console.log( | |
| `${c.bold}Found ${c.cyan}${uniqueSkills.length}${c.reset}${c.bold} SKILL.md files${c.reset}\n` | |
| ); | |
| // Location summary | |
| const locationCounts = {}; | |
| for (const s of uniqueSkills) { | |
| locationCounts[s.location] = (locationCounts[s.location] || 0) + 1; | |
| } | |
| console.log(`${c.bold}Skills by location:${c.reset}`); | |
| for (const [loc, count] of Object.entries(locationCounts).sort((a, b) => b[1] - a[1])) { | |
| console.log(` ${locationLabel(loc)} ${c.bold}${count}${c.reset}`); | |
| } | |
| console.log(); | |
| // 3. Group by name | |
| const byName = new Map(); | |
| for (const s of uniqueSkills) { | |
| const key = s.fmName || s.skillName; | |
| if (!byName.has(key)) byName.set(key, []); | |
| byName.get(key).push(s); | |
| } | |
| // 4. Name-based duplicates | |
| const nameDupes = []; | |
| for (const [name, group] of byName) { | |
| if (group.length > 1) nameDupes.push({ name, skills: group }); | |
| } | |
| // Categorize duplicate groups | |
| const actionableDupes = []; // has non-managed, non-agent-tool copies | |
| const agentToolDupes = []; // dupes spread across agent tools | |
| const managedOnlyDupes = []; // only claude cache/marketplace | |
| for (const dupe of nameDupes) { | |
| const sorted = sortByPriority(dupe.skills); | |
| const extras = sorted.slice(1); | |
| const hasAgentTool = extras.some((s) => isAgentToolCopy(s.location)); | |
| const hasSource = extras.some((s) => !isManaged(s.location) && !isAgentToolCopy(s.location)); | |
| if (hasSource) { | |
| actionableDupes.push({ ...dupe, sorted }); | |
| } else if (hasAgentTool) { | |
| agentToolDupes.push({ ...dupe, sorted }); | |
| } else { | |
| managedOnlyDupes.push({ ...dupe, sorted }); | |
| } | |
| } | |
| // 5. Content-similar skills (different names) | |
| const contentDupes = []; | |
| const checked = new Set(); | |
| const uniqueNames = [...byName.keys()]; | |
| for (let i = 0; i < uniqueNames.length; i++) { | |
| for (let j = i + 1; j < uniqueNames.length; j++) { | |
| const nameA = uniqueNames[i]; | |
| const nameB = uniqueNames[j]; | |
| const a = byName.get(nameA)[0]; | |
| const b = byName.get(nameB)[0]; | |
| const sim = trigramSimilarity(a.normalized, b.normalized); | |
| if (sim > 0.65) { | |
| const key = [nameA, nameB].sort().join("|"); | |
| if (!checked.has(key)) { | |
| checked.add(key); | |
| contentDupes.push({ nameA, nameB, similarity: sim, skillA: a, skillB: b }); | |
| } | |
| } | |
| } | |
| } | |
| // ─── Report Mode ───────────────────────────── | |
| if (REPORT_ONLY) { | |
| printReportMode(actionableDupes, agentToolDupes, managedOnlyDupes, contentDupes, byName, uniqueSkills); | |
| return; | |
| } | |
| // ─── Interactive Mode ──────────────────────── | |
| const totalDupeGroups = actionableDupes.length + agentToolDupes.length + managedOnlyDupes.length; | |
| if (totalDupeGroups === 0 && contentDupes.length === 0) { | |
| console.log(`${c.green}✓ No duplicates found!${c.reset}`); | |
| return; | |
| } | |
| const rl = createInterface({ input: process.stdin, output: process.stdout }); | |
| const ask = (q) => new Promise((res) => rl.question(q, res)); | |
| let deleted = 0; | |
| let skipped = 0; | |
| let quit = false; | |
| // Phase 1: Actionable dupes (source-level conflicts) | |
| if (actionableDupes.length > 0) { | |
| console.log( | |
| `${c.bold}${c.red}═══ Source-Level Duplicates (${actionableDupes.length} groups) ═══${c.reset}` | |
| ); | |
| console.log( | |
| `${c.dim}Same skill exists in multiple source/development locations${c.reset}\n` | |
| ); | |
| for (const { name, sorted } of actionableDupes.sort((a, b) => b.sorted.length - a.sorted.length)) { | |
| if (quit) break; | |
| ({ deleted, skipped, quit } = await handleDupeGroup(name, sorted, ask, deleted, skipped, quit)); | |
| } | |
| } | |
| // Phase 2: Agent tool spread (same skill copied to codex, copilot, gemini, etc.) | |
| if (!quit && agentToolDupes.length > 0) { | |
| console.log( | |
| `\n${c.bold}${c.magenta}═══ Agent Tool Copies (${agentToolDupes.length} groups) ═══${c.reset}` | |
| ); | |
| console.log( | |
| `${c.dim}Skills copied across Codex, Copilot, Gemini, OpenCode, etc.${c.reset}\n` | |
| ); | |
| const answer = await ask( | |
| ` ${c.yellow}Review agent-tool copies? [y/N/q] ${c.reset}` | |
| ); | |
| if (answer.toLowerCase() === "q") { | |
| quit = true; | |
| } else if (answer.toLowerCase() === "y") { | |
| for (const { name, sorted } of agentToolDupes.sort((a, b) => b.sorted.length - a.sorted.length)) { | |
| if (quit) break; | |
| ({ deleted, skipped, quit } = await handleDupeGroup(name, sorted, ask, deleted, skipped, quit)); | |
| } | |
| } else { | |
| const total = agentToolDupes.reduce((sum, d) => sum + d.sorted.length - 1, 0); | |
| skipped += total; | |
| console.log(` ${c.dim}Skipped ${total} agent-tool copies${c.reset}\n`); | |
| } | |
| } | |
| // Phase 3: Managed (Claude cache/marketplace) | |
| if (!quit && managedOnlyDupes.length > 0 && !SKIP_MANAGED) { | |
| console.log( | |
| `\n${c.bold}${c.gray}═══ Claude Cache/Marketplace (${managedOnlyDupes.length} groups) ═══${c.reset}` | |
| ); | |
| console.log( | |
| `${c.dim}Plugin system managed copies (usually safe to skip)${c.reset}\n` | |
| ); | |
| const answer = await ask( | |
| ` ${c.yellow}Review managed copies? [y/N/q] ${c.reset}` | |
| ); | |
| if (answer.toLowerCase() === "q") { | |
| quit = true; | |
| } else if (answer.toLowerCase() !== "y") { | |
| const total = managedOnlyDupes.reduce((sum, d) => sum + d.sorted.length - 1, 0); | |
| skipped += total; | |
| console.log(` ${c.dim}Skipped ${total} managed copies${c.reset}\n`); | |
| } else { | |
| for (const { name, sorted } of managedOnlyDupes.sort((a, b) => b.sorted.length - a.sorted.length)) { | |
| if (quit) break; | |
| console.log(`\n${c.bold}${c.cyan}● "${name}"${c.reset} — ${sorted.length} copies`); | |
| for (let i = 0; i < sorted.length; i++) { | |
| const s = sorted[i]; | |
| const marker = i === 0 ? `${c.green} ★ KEEP${c.reset}` : ""; | |
| console.log( | |
| ` ${i + 1}) [${locationLabel(s.location)}] ${c.dim}${shortPath(s.path)}${c.reset}${marker}` | |
| ); | |
| } | |
| const answer = await ask(` Delete all copies except #1? [y/N/s/q] `); | |
| if (answer.toLowerCase() === "q") { quit = true; break; } | |
| if (answer.toLowerCase() === "y") { | |
| for (let i = 1; i < sorted.length; i++) { | |
| try { | |
| await rm(sorted[i].dir, { recursive: true }); | |
| console.log(` ${c.red}✗ Deleted${c.reset} ${shortPath(sorted[i].path)}`); | |
| deleted++; | |
| } catch (err) { | |
| console.log(` ${c.red}Error:${c.reset} ${err.message}`); | |
| } | |
| } | |
| } else { | |
| skipped += sorted.length - 1; | |
| } | |
| } | |
| } | |
| } | |
| // Phase 4: Content-similar pairs | |
| if (!quit && contentDupes.length > 0) { | |
| console.log( | |
| `\n${c.bold}${c.cyan}═══ Content-Similar Skills (${contentDupes.length} pairs) ═══${c.reset}\n` | |
| ); | |
| for (const { nameA, nameB, similarity, skillA, skillB } of contentDupes.sort( | |
| (a, b) => b.similarity - a.similarity | |
| )) { | |
| if (quit) break; | |
| const pct = (similarity * 100).toFixed(0); | |
| console.log( | |
| `${c.bold}●${c.reset} ${c.bold}"${nameA}"${c.reset} ↔ ${c.bold}"${nameB}"${c.reset} ${c.yellow}${pct}% similar${c.reset}` | |
| ); | |
| console.log( | |
| ` A: [${locationLabel(skillA.location)}] ${c.dim}${shortPath(skillA.path)}${c.reset} (${(skillA.size / 1024).toFixed(1)}KB)` | |
| ); | |
| console.log( | |
| ` B: [${locationLabel(skillB.location)}] ${c.dim}${shortPath(skillB.path)}${c.reset} (${(skillB.size / 1024).toFixed(1)}KB)` | |
| ); | |
| if (skillA.description) console.log(` ${c.dim}A: ${skillA.description}${c.reset}`); | |
| if (skillB.description) console.log(` ${c.dim}B: ${skillB.description}${c.reset}`); | |
| const answer = await ask(`\n Delete one? [a/b/N/q] `); | |
| if (answer.toLowerCase() === "q") { quit = true; break; } | |
| const target = answer.toLowerCase() === "a" ? skillA : answer.toLowerCase() === "b" ? skillB : null; | |
| if (target) { | |
| try { | |
| await rm(target.dir, { recursive: true }); | |
| console.log(` ${c.red}✗ Deleted${c.reset} ${shortPath(target.path)}`); | |
| deleted++; | |
| } catch (err) { | |
| console.log(` ${c.red}Error:${c.reset} ${err.message}`); | |
| } | |
| } else { | |
| skipped++; | |
| } | |
| console.log(); | |
| } | |
| } | |
| rl.close(); | |
| printSummary(deleted, skipped); | |
| } | |
| // ─── Shared interactive handler for a dupe group ── | |
| async function handleDupeGroup(name, sorted, ask, deleted, skipped, quit) { | |
| console.log(`${c.bold}${c.cyan}● "${name}"${c.reset} — ${sorted.length} copies\n`); | |
| for (let i = 0; i < sorted.length; i++) { | |
| const s = sorted[i]; | |
| const loc = locationLabel(s.location); | |
| const sym = s.symTarget ? ` ${c.dim}→ ${relative(HOME, s.symTarget)}${c.reset}` : ""; | |
| const sizeKb = (s.size / 1024).toFixed(1); | |
| const marker = i === 0 ? `${c.green} ★ KEEP${c.reset}` : ""; | |
| console.log( | |
| ` ${c.bold}${i + 1})${c.reset} [${loc}] ${c.dim}${shortPath(s.path)}${c.reset} (${sizeKb}KB)${sym}${marker}` | |
| ); | |
| if (s.description) console.log(` ${c.dim}${s.description}${c.reset}`); | |
| } | |
| console.log(); | |
| for (let i = 1; i < sorted.length; i++) { | |
| if (quit) break; | |
| const s = sorted[i]; | |
| if (SKIP_MANAGED && isManaged(s.location)) { | |
| skipped++; | |
| continue; | |
| } | |
| const contentMatch = trigramSimilarity(sorted[0].normalized, s.normalized); | |
| const matchLabel = | |
| contentMatch > 0.95 | |
| ? `${c.green}IDENTICAL${c.reset}` | |
| : contentMatch > 0.7 | |
| ? `${c.yellow}SIMILAR ${(contentMatch * 100).toFixed(0)}%${c.reset}` | |
| : `${c.red}DIFFERENT ${(contentMatch * 100).toFixed(0)}%${c.reset}`; | |
| const answer = await ask( | |
| ` Delete ${c.bold}${shortPath(s.path)}${c.reset} [${locationLabel(s.location)}] [${matchLabel}]? [y/N/s(kip group)/q] ` | |
| ); | |
| if (answer.toLowerCase() === "q") { | |
| quit = true; | |
| break; | |
| } | |
| if (answer.toLowerCase() === "s") { | |
| skipped += sorted.length - i; | |
| console.log(` ${c.dim}Skipped rest of group${c.reset}`); | |
| break; | |
| } | |
| if (answer.toLowerCase() === "y") { | |
| try { | |
| await rm(s.dir, { recursive: true }); | |
| console.log(` ${c.red}✗ Deleted${c.reset} ${shortPath(s.path)}`); | |
| deleted++; | |
| } catch (err) { | |
| console.log(` ${c.red}Error:${c.reset} ${err.message}`); | |
| } | |
| } else { | |
| skipped++; | |
| } | |
| } | |
| console.log(); | |
| return { deleted, skipped, quit }; | |
| } | |
| // ─── Report mode output ─────────────────────── | |
| function printReportMode(actionableDupes, agentToolDupes, managedOnlyDupes, contentDupes, byName, uniqueSkills) { | |
| if (actionableDupes.length > 0) { | |
| console.log( | |
| `${c.bold}${c.red}═══ Source-Level Duplicates (${actionableDupes.length} groups) ═══${c.reset}` | |
| ); | |
| console.log( | |
| `${c.dim}Same skill in multiple source/development locations${c.reset}\n` | |
| ); | |
| for (const { name, sorted } of actionableDupes.sort((a, b) => b.sorted.length - a.sorted.length)) { | |
| console.log(`${c.bold}${c.cyan}● "${name}"${c.reset} — ${sorted.length} copies`); | |
| for (let i = 0; i < sorted.length; i++) { | |
| const s = sorted[i]; | |
| const sym = s.symTarget ? ` ${c.dim}→ ${relative(HOME, s.symTarget)}${c.reset}` : ""; | |
| const marker = i === 0 ? `${c.green} ★ KEEP${c.reset}` : ""; | |
| console.log( | |
| ` ${i + 1}) [${locationLabel(s.location)}] ${c.dim}${shortPath(s.path)}${c.reset}${sym}${marker}` | |
| ); | |
| } | |
| console.log(); | |
| } | |
| } | |
| if (agentToolDupes.length > 0) { | |
| console.log( | |
| `${c.bold}${c.magenta}═══ Agent Tool Copies (${agentToolDupes.length} groups) ═══${c.reset}` | |
| ); | |
| console.log( | |
| `${c.dim}Skills spread across Codex, Copilot, Gemini, OpenCode, etc.${c.reset}\n` | |
| ); | |
| for (const { name, sorted } of agentToolDupes.sort((a, b) => b.sorted.length - a.sorted.length)) { | |
| const locs = [...new Set(sorted.map((s) => s.location))].map(locationLabel).join(", "); | |
| console.log(` ${c.dim}${name}${c.reset} (${sorted.length} copies: ${locs})`); | |
| } | |
| console.log(); | |
| } | |
| if (managedOnlyDupes.length > 0) { | |
| console.log( | |
| `${c.bold}${c.gray}═══ Claude Cache/Marketplace (${managedOnlyDupes.length} groups) ═══${c.reset}` | |
| ); | |
| console.log( | |
| `${c.dim}Plugin system managed copies${c.reset}\n` | |
| ); | |
| for (const { name, sorted } of managedOnlyDupes.sort((a, b) => b.sorted.length - a.sorted.length)) { | |
| const locs = [...new Set(sorted.map((s) => s.location))].map(locationLabel).join(", "); | |
| console.log(` ${c.dim}${name}${c.reset} (${sorted.length} copies: ${locs})`); | |
| } | |
| console.log(); | |
| } | |
| if (contentDupes.length > 0) { | |
| console.log( | |
| `${c.bold}${c.cyan}═══ Content-Similar Skills (${contentDupes.length} pairs) ═══${c.reset}\n` | |
| ); | |
| for (const { nameA, nameB, similarity, skillA, skillB } of contentDupes.sort( | |
| (a, b) => b.similarity - a.similarity | |
| )) { | |
| const pct = (similarity * 100).toFixed(0); | |
| console.log( | |
| ` ${c.bold}"${nameA}"${c.reset} ↔ ${c.bold}"${nameB}"${c.reset} ${c.yellow}${pct}%${c.reset}` | |
| ); | |
| console.log( | |
| ` [${locationLabel(skillA.location)}] ${c.dim}${shortPath(skillA.path)}${c.reset}` | |
| ); | |
| console.log( | |
| ` [${locationLabel(skillB.location)}] ${c.dim}${shortPath(skillB.path)}${c.reset}` | |
| ); | |
| } | |
| console.log(); | |
| } | |
| const uniqueCount = byName.size; | |
| const dupeCount = uniqueSkills.length - uniqueCount; | |
| const agentToolTotal = agentToolDupes.reduce((sum, d) => sum + d.sorted.length - 1, 0); | |
| console.log(`${c.bold}═══ Totals ═══${c.reset}`); | |
| console.log(` Unique skill names: ${c.bold}${uniqueCount}${c.reset}`); | |
| console.log(` Total copies on disk: ${c.bold}${uniqueSkills.length}${c.reset}`); | |
| console.log(` Duplicate copies: ${c.bold}${dupeCount}${c.reset}`); | |
| console.log(` Source-level conflicts: ${c.bold}${c.red}${actionableDupes.length}${c.reset}`); | |
| console.log(` Agent tool copies: ${c.bold}${c.magenta}${agentToolTotal}${c.reset} (across ${agentToolDupes.length} skills)`); | |
| console.log(` Claude managed dupes: ${c.bold}${managedOnlyDupes.length}${c.reset}`); | |
| console.log(` Content-similar pairs: ${c.bold}${c.cyan}${contentDupes.length}${c.reset}`); | |
| console.log(); | |
| } | |
| function printSummary(deleted, skipped) { | |
| console.log(`\n${c.bold}═══ Summary ═══${c.reset}`); | |
| console.log(` ${c.red}Deleted:${c.reset} ${deleted}`); | |
| console.log(` ${c.dim}Skipped:${c.reset} ${skipped}`); | |
| console.log(); | |
| } | |
| main().catch((err) => { | |
| console.error(`${c.red}Fatal error:${c.reset}`, err); | |
| process.exit(1); | |
| }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment