claude-slim 2.3.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,18 +20,11 @@ Every Claude Code session auto-loads every skill, memory file, and plugin instru
20
20
 
21
21
  ---
22
22
 
23
- ### The problem, visualized
23
+ ### See it in action
24
24
 
25
- ```
26
- Session start token budget
27
- ┌──────────────────────────────────────────────────┐
28
- │██████████████████████████░░░░░░░░░░░░░░░░░░░░░░░░│ Before claude-slim
29
- │ 12K tokens consumed ↑ your actual work ↑ │
30
- ├──────────────────────────────────────────────────┤
31
- │██████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ After claude-slim
32
- │ 5K ↑ more room for your work ↑ │
33
- └──────────────────────────────────────────────────┘
34
- ```
25
+ <p align="center">
26
+ <img src="docs/demo.gif" alt="claude-slim cleanup: 11,442 tokens of overhead reduced to ~5,800 in 45 seconds" width="900" />
27
+ </p>
35
28
 
36
29
  Where the bloat hides:
37
30
 
@@ -73,6 +66,7 @@ That's slower responses. Hitting your usage cap faster. Paying for context you'r
73
66
  | Duplicates | Same skill registered from multiple sources |
74
67
  | Empty templates | Placeholder skills with no content |
75
68
  | Oversized files | SKILL.md over 10KB |
69
+ | **Unused skills** | **Local skills never invoked in your last N days of sessions (default 60d)** |
76
70
  | Stale memory | Large memory files loaded every session |
77
71
  | Disabled plugins | Installed but disabled plugins still in cache |
78
72
  | Stale projects | Project memory untouched for 90+ days |
@@ -141,21 +135,23 @@ Then just type `/claude-slim` in any session.
141
135
  ## Usage
142
136
 
143
137
  ```bash
144
- /claude-slim # Full pipeline: scan → propose → clean → report
145
- /claude-slim scan # Report only, no changes
146
- /claude-slim scan --json # Machine-readable JSON output
147
- /claude-slim restore # Bring back anything you disabled
138
+ /claude-slim # Full pipeline: scan → propose → clean → report
139
+ /claude-slim scan # Report only, no changes
140
+ /claude-slim scan --json # Machine-readable JSON output
141
+ /claude-slim scan --lookback-days 30 # Treat skills idle for 30+ days as unused
142
+ /claude-slim restore # Bring back anything you disabled
148
143
  ```
149
144
 
150
145
  CLI equivalents:
151
146
 
152
147
  ```bash
153
- npx claude-slim clean # Full pipeline
154
- npx claude-slim clean --dry-run # See what would happen (no changes)
155
- npx claude-slim clean --auto # Non-interactive, Tier 1 only (CI/scripts)
156
- npx claude-slim scan # Report only
157
- npx claude-slim restore # Undo
158
- npx claude-slim report # Show savings from last clean
148
+ npx claude-slim clean # Full pipeline
149
+ npx claude-slim clean --dry-run # See what would happen (no changes)
150
+ npx claude-slim clean --auto # Non-interactive, Tier 1 only (CI/scripts)
151
+ npx claude-slim clean --lookback-days N # Tune the unused-skill detection window
152
+ npx claude-slim scan # Report only
153
+ npx claude-slim restore # Undo
154
+ npx claude-slim report # Show savings from last clean
159
155
  ```
160
156
 
161
157
  ---
@@ -212,6 +208,20 @@ From a real cleanup session:
212
208
 
213
209
  ---
214
210
 
211
+ ## v2.4 — What's new
212
+
213
+ - **Unused-skill detection** — claude-slim now reads your `~/.claude/projects/*/*.jsonl` session transcripts, finds every `Skill` tool invocation in the last 60 days, and flags local skills you've installed but never actually used. Tier 3 (Optional, never auto-selected) so you decide. Configurable lookback via `--lookback-days <n>`. Falls back silently if there's not enough session history (≥3 sessions required) — no false-flagging when the data source is unreliable.
214
+ - **Plugin skills are intentionally out of scope** for this detector. They live inside `~/.claude/plugins/cache/` and are managed by the Claude Code plugin runtime; moving them would partially uninstall the plugin. Use `claude plugin disable <name>` for plugin-level cleanup.
215
+ - **Per-file session-usage cache** at `~/.claude/.skill-usage-cache.json` keyed by mtime. Warm rescans only re-parse session logs that have changed.
216
+ - **Node 20+** is now the engine floor (previously `>=18`, but Node 18 was already dropped from CI in v2.3.0).
217
+
218
+ ## v2.3 — What's new
219
+
220
+ - **Detector registry refactor (v2.3.0)** — Scanner split from a 588-line module into focused detectors under `src/scanner/`. Adding a new heuristic is a one-function addition; see CONTRIBUTING.md. Public API unchanged.
221
+ - **Path-containment guard (v2.2.3)** — Every destructive op refuses any target path that escapes `~/.claude/`. `runCommand` no longer goes through a shell. `temp_cache` cleanup is symlink-safe.
222
+ - **Report sign fix (v2.2.3)** — The breakdown table's Saved column was inverted in earlier 2.2.x; cleanup now shows correct savings per row.
223
+ - **85 tests (was 73)** — New round-trip coverage for path containment, restore guards, breakdown sign, restore-selection dedup, atomic tokenizer flush, and custom detector injection.
224
+
215
225
  ## v2.2 — What's new
216
226
 
217
227
  - **Atomic `stale_project` clean/restore** — Single directory `rename()` instead of per-file loop. No more partial-failure state if the operation is interrupted.
@@ -242,7 +252,7 @@ From a real cleanup session:
242
252
 
243
253
  ## Requirements
244
254
 
245
- - Node.js 18+
255
+ - Node.js 20+
246
256
  - macOS or Linux
247
257
  - Claude Code CLI
248
258
 
package/dist/cleaner.js CHANGED
@@ -51,7 +51,8 @@ export async function cleanIssues(issues) {
51
51
  else if (issue.type === 'template' ||
52
52
  issue.type === 'duplicate' ||
53
53
  issue.type === 'skill_dup' ||
54
- issue.type === 'oversized_skill') {
54
+ issue.type === 'oversized_skill' ||
55
+ issue.type === 'unused_skill') {
55
56
  // Move skill directory to disabled — use name (not basename) to avoid namespace collisions
56
57
  const safeName = issue.name.replace(/\//g, '--');
57
58
  const dest = join(disabledDir, safeName);
package/dist/cli.js CHANGED
@@ -22,9 +22,10 @@ program
22
22
  .command('scan')
23
23
  .description('Scan environment and report issues')
24
24
  .option('--json', 'Output raw JSON')
25
+ .option('--lookback-days <n>', 'Days of session history for skill-usage analysis', '60')
25
26
  .action(async (opts) => {
26
27
  await initTokenizer();
27
- const result = await scan();
28
+ const result = await scan({ lookbackDays: parseInt(opts.lookbackDays, 10) || 60 });
28
29
  await flushCache();
29
30
  if (opts.json) {
30
31
  console.log(JSON.stringify(result, null, 2));
@@ -40,11 +41,13 @@ program
40
41
  .option('--dry-run', 'Show what would happen without making changes')
41
42
  .option('--auto', 'Non-interactive: auto-select Tier 1 items only')
42
43
  .option('--sessions-per-day <n>', 'Sessions per day for savings estimate', '2')
44
+ .option('--lookback-days <n>', 'Days of session history for skill-usage analysis', '60')
43
45
  .action(async (opts) => {
44
46
  await runCleanPipeline({
45
47
  dryRun: !!opts.dryRun,
46
48
  auto: !!opts.auto,
47
49
  sessionsPerDay: parseInt(opts.sessionsPerDay, 10) || 2,
50
+ lookbackDays: parseInt(opts.lookbackDays, 10) || 60,
48
51
  });
49
52
  });
50
53
  // --- restore ---
@@ -110,9 +113,10 @@ program
110
113
  .command('report')
111
114
  .description('Show savings report from last clean')
112
115
  .option('--sessions-per-day <n>', 'Sessions per day for savings estimate', '2')
116
+ .option('--lookback-days <n>', 'Days of session history for skill-usage analysis', '60')
113
117
  .action(async (opts) => {
114
118
  await initTokenizer();
115
- const result = await scan();
119
+ const result = await scan({ lookbackDays: parseInt(opts.lookbackDays, 10) || 60 });
116
120
  const entries = await readManifest();
117
121
  const movedEntries = entries.filter((e) => e.tokenCount && e.tokenCount > 0);
118
122
  if (movedEntries.length === 0) {
@@ -125,7 +129,9 @@ program
125
129
  // Only skill-type entries contributed to the per-skill prompt overhead
126
130
  // (stale_project restores memory tokens separately; broken_symlink/
127
131
  // temp_cache never counted toward totalTokensBefore).
128
- const SKILL_TYPES = new Set(['template', 'duplicate', 'skill_dup', 'oversized_skill']);
132
+ const SKILL_TYPES = new Set([
133
+ 'template', 'duplicate', 'skill_dup', 'oversized_skill', 'unused_skill',
134
+ ]);
129
135
  const removedSkillEntries = movedEntries.filter((e) => SKILL_TYPES.has(e.type));
130
136
  const removedMemoryTokens = movedEntries
131
137
  .filter((e) => e.type === 'stale_project')
@@ -155,12 +161,12 @@ program
155
161
  });
156
162
  // --- default (no subcommand) → run clean ---
157
163
  program.action(async () => {
158
- await runCleanPipeline({ dryRun: false, auto: false, sessionsPerDay: 2 });
164
+ await runCleanPipeline({ dryRun: false, auto: false, sessionsPerDay: 2, lookbackDays: 60 });
159
165
  });
160
166
  // --- shared clean pipeline ---
161
167
  async function runCleanPipeline(opts) {
162
168
  await initTokenizer();
163
- const result = await scan();
169
+ const result = await scan({ lookbackDays: opts.lookbackDays });
164
170
  if (result.issues.length === 0) {
165
171
  console.log('\n \x1b[32mAlready slim!\x1b[0m No issues found.\n');
166
172
  await flushCache();
@@ -207,7 +213,7 @@ async function runCleanPipeline(opts) {
207
213
  console.log('');
208
214
  const cleanResult = await cleanIssues(selectedIssues);
209
215
  // Re-scan after cleanup for accurate breakdown
210
- const afterResult = await scan();
216
+ const afterResult = await scan({ lookbackDays: opts.lookbackDays });
211
217
  const reportData = calculateReport(result, afterResult, cleanResult.moved, opts.sessionsPerDay);
212
218
  console.log('');
213
219
  console.log(formatReportBox(reportData));
@@ -11,6 +11,9 @@ export interface DetectorContext {
11
11
  disabledPlugins: Set<string>;
12
12
  plugins: PluginInfo[];
13
13
  contents: Map<string, string>;
14
+ recentSkillInvocations: Set<string>;
15
+ sessionDataAvailable: boolean;
16
+ lookbackDays: number;
14
17
  }
15
18
  export interface Detector {
16
19
  name: string;
@@ -144,6 +144,37 @@ const staleProjectDetector = {
144
144
  });
145
145
  },
146
146
  };
147
+ const unusedSkillDetector = {
148
+ name: 'unused_skill',
149
+ detect({ localSkills, recentSkillInvocations, sessionDataAvailable, lookbackDays, }) {
150
+ // Suppress entirely when the data source is unreliable — better no signal
151
+ // than a wrong one that flags every skill as unused.
152
+ if (!sessionDataAvailable)
153
+ return [];
154
+ const issues = [];
155
+ for (const skill of localSkills) {
156
+ // Direct hit: invocation set contains the skill name as-is.
157
+ if (recentSkillInvocations.has(skill.name))
158
+ continue;
159
+ // Nested skill (e.g. "org/ship"): also check the bare leaf name, which
160
+ // is how it would appear in a Skill tool_use input.
161
+ if (skill.name.includes('/')) {
162
+ const leaf = skill.name.split('/').pop();
163
+ if (recentSkillInvocations.has(leaf))
164
+ continue;
165
+ }
166
+ issues.push({
167
+ type: 'unused_skill',
168
+ tier: 3,
169
+ name: skill.name,
170
+ detail: `not invoked in ${lookbackDays}d`,
171
+ tokens: skill.tokens,
172
+ path: skill.path,
173
+ });
174
+ }
175
+ return issues;
176
+ },
177
+ };
147
178
  const disabledPluginDetector = {
148
179
  name: 'disabled_plugin',
149
180
  detect({ plugins, disabledPlugins }) {
@@ -174,6 +205,7 @@ export const detectors = [
174
205
  tempCacheDetector,
175
206
  oversizedMemoryDetector,
176
207
  staleProjectDetector,
208
+ unusedSkillDetector,
177
209
  disabledPluginDetector,
178
210
  ];
179
211
  export function classifyIssues(ctx, registry = detectors) {
@@ -1,2 +1,5 @@
1
1
  import type { ScanResult } from '../types.js';
2
- export declare function scan(): Promise<ScanResult>;
2
+ export interface ScanOptions {
3
+ lookbackDays?: number;
4
+ }
5
+ export declare function scan(opts?: ScanOptions): Promise<ScanResult>;
@@ -8,15 +8,19 @@ import { scanMemoryFiles } from './memory.js';
8
8
  import { scanMcpServers } from './mcp.js';
9
9
  import { parseClaudeMdSections } from './claude-md.js';
10
10
  import { getDisabledPlugins } from './disabled-plugins.js';
11
+ import { scanSessionUsage } from './sessions.js';
11
12
  import { classifyIssues } from './detectors.js';
12
13
  import { SKILL_PROMPT_OVERHEAD_TOKENS } from './constants.js';
13
- export async function scan() {
14
- const [{ skills: localSkills, brokenSymlinks, contents }, { skills: pluginSkills, plugins, tempCaches }, { memoryFiles, staleProjects }, mcp, disabledPlugins,] = await Promise.all([
14
+ const DEFAULT_LOOKBACK_DAYS = 60;
15
+ export async function scan(opts = {}) {
16
+ const lookbackDays = opts.lookbackDays ?? DEFAULT_LOOKBACK_DAYS;
17
+ const [{ skills: localSkills, brokenSymlinks, contents }, { skills: pluginSkills, plugins, tempCaches }, { memoryFiles, staleProjects }, mcp, disabledPlugins, sessionUsage,] = await Promise.all([
15
18
  scanLocalSkills(),
16
19
  scanPluginSkills(),
17
20
  scanMemoryFiles(),
18
21
  scanMcpServers(),
19
22
  getDisabledPlugins(),
23
+ scanSessionUsage(lookbackDays),
20
24
  ]);
21
25
  // Annotate plugin status
22
26
  for (const plugin of plugins) {
@@ -33,6 +37,9 @@ export async function scan() {
33
37
  localSkills, pluginSkills, brokenSymlinks, memoryFiles,
34
38
  tempCaches, staleProjects, disabledPlugins, plugins,
35
39
  contents,
40
+ recentSkillInvocations: sessionUsage.invokedSkills,
41
+ sessionDataAvailable: sessionUsage.dataAvailable,
42
+ lookbackDays,
36
43
  });
37
44
  // Estimate total tokens at startup
38
45
  const skillListingTokens = (localSkills.length + pluginSkills.length) * SKILL_PROMPT_OVERHEAD_TOKENS;
@@ -0,0 +1,8 @@
1
+ export interface SessionScanResult {
2
+ invokedSkills: Set<string>;
3
+ dataAvailable: boolean;
4
+ sessionsScanned: number;
5
+ sessionsInWindow: number;
6
+ }
7
+ export declare function extractSkillsFromTranscript(content: string): string[];
8
+ export declare function scanSessionUsage(lookbackDays: number): Promise<SessionScanResult>;
@@ -0,0 +1,150 @@
1
+ import { readFile, readdir, stat, writeFile, mkdir, rename } from 'node:fs/promises';
2
+ import { join, dirname } from 'node:path';
3
+ import { getClaudeDir } from '../paths.js';
4
+ const CACHE_VERSION = 1;
5
+ // Below this many sessions in the lookback window we suppress unused-skill
6
+ // classification — too little signal, "unused" would be misleading.
7
+ const MIN_SESSIONS_FOR_DATA_AVAILABLE = 3;
8
+ function getCachePath() {
9
+ return join(getClaudeDir(), '.skill-usage-cache.json');
10
+ }
11
+ async function loadCache() {
12
+ try {
13
+ const raw = await readFile(getCachePath(), 'utf-8');
14
+ const parsed = JSON.parse(raw);
15
+ if (parsed.version !== CACHE_VERSION)
16
+ return { version: CACHE_VERSION, entries: {} };
17
+ return parsed;
18
+ }
19
+ catch {
20
+ return { version: CACHE_VERSION, entries: {} };
21
+ }
22
+ }
23
+ async function saveCache(cache) {
24
+ const target = getCachePath();
25
+ const tmp = target + '.tmp';
26
+ try {
27
+ await mkdir(dirname(target), { recursive: true });
28
+ // Atomic write — same pattern as token cache. A crash mid-flush leaves the
29
+ // prior cache intact rather than a torn JSON file.
30
+ await writeFile(tmp, JSON.stringify(cache));
31
+ await rename(tmp, target);
32
+ }
33
+ catch {
34
+ // Non-critical: missing cache just means a slower next scan.
35
+ }
36
+ }
37
+ // Extract Skill-tool invocations from a single JSONL session log.
38
+ // Each line in `~/.claude/projects/<slug>/<sessionId>.jsonl` is a JSON event;
39
+ // we look for `message.content[]` entries shaped
40
+ // { type: 'tool_use', name: 'Skill', input: { skill: '<id>' } }
41
+ // and collect the `skill` strings (e.g. 'superpowers:brainstorming').
42
+ //
43
+ // Schema-defensive: any line/field that does not match is silently skipped,
44
+ // so a partial schema change degrades gracefully rather than throwing.
45
+ export function extractSkillsFromTranscript(content) {
46
+ const skills = [];
47
+ const lines = content.split('\n');
48
+ for (const line of lines) {
49
+ if (!line)
50
+ continue;
51
+ let obj;
52
+ try {
53
+ obj = JSON.parse(line);
54
+ }
55
+ catch {
56
+ continue;
57
+ }
58
+ if (typeof obj !== 'object' || obj === null)
59
+ continue;
60
+ const message = obj.message;
61
+ if (typeof message !== 'object' || message === null)
62
+ continue;
63
+ const msgContent = message.content;
64
+ if (!Array.isArray(msgContent))
65
+ continue;
66
+ for (const c of msgContent) {
67
+ if (typeof c !== 'object' || c === null)
68
+ continue;
69
+ const rec = c;
70
+ if (rec.type !== 'tool_use' || rec.name !== 'Skill')
71
+ continue;
72
+ const input = rec.input;
73
+ if (typeof input !== 'object' || input === null)
74
+ continue;
75
+ const skill = input.skill;
76
+ if (typeof skill === 'string')
77
+ skills.push(skill);
78
+ }
79
+ }
80
+ return skills;
81
+ }
82
+ // Walk every `~/.claude/projects/<slug>/*.jsonl` whose mtime falls inside the
83
+ // lookback window. Per-file results are cached by mtime, so warm scans only
84
+ // re-read files that have changed.
85
+ export async function scanSessionUsage(lookbackDays) {
86
+ const projectsDir = join(getClaudeDir(), 'projects');
87
+ const cutoffMs = Date.now() - lookbackDays * 24 * 60 * 60 * 1000;
88
+ const cache = await loadCache();
89
+ // Pruned cache: only entries seen this scan survive. Keeps the file bounded
90
+ // across many scans even as old session logs get rotated/deleted.
91
+ const newEntries = {};
92
+ const invokedSkills = new Set();
93
+ let sessionsScanned = 0;
94
+ let sessionsInWindow = 0;
95
+ let projectDirs = [];
96
+ try {
97
+ projectDirs = await readdir(projectsDir);
98
+ }
99
+ catch {
100
+ return { invokedSkills, dataAvailable: false, sessionsScanned: 0, sessionsInWindow: 0 };
101
+ }
102
+ for (const projectName of projectDirs) {
103
+ const projectPath = join(projectsDir, projectName);
104
+ let entries = [];
105
+ try {
106
+ entries = await readdir(projectPath);
107
+ }
108
+ catch {
109
+ continue;
110
+ }
111
+ for (const entry of entries) {
112
+ if (!entry.endsWith('.jsonl'))
113
+ continue;
114
+ const filePath = join(projectPath, entry);
115
+ let mtimeMs;
116
+ try {
117
+ mtimeMs = (await stat(filePath)).mtimeMs;
118
+ }
119
+ catch {
120
+ continue;
121
+ }
122
+ sessionsScanned++;
123
+ if (mtimeMs < cutoffMs)
124
+ continue;
125
+ sessionsInWindow++;
126
+ // Cache hit: reuse the parsed skill list, no I/O on the file body.
127
+ const cached = cache.entries[filePath];
128
+ if (cached && cached.mtimeMs === mtimeMs) {
129
+ for (const s of cached.skills)
130
+ invokedSkills.add(s);
131
+ newEntries[filePath] = cached;
132
+ continue;
133
+ }
134
+ let content;
135
+ try {
136
+ content = await readFile(filePath, 'utf-8');
137
+ }
138
+ catch {
139
+ continue;
140
+ }
141
+ const skills = extractSkillsFromTranscript(content);
142
+ for (const s of skills)
143
+ invokedSkills.add(s);
144
+ newEntries[filePath] = { mtimeMs, skills: Array.from(new Set(skills)) };
145
+ }
146
+ }
147
+ const dataAvailable = sessionsInWindow >= MIN_SESSIONS_FOR_DATA_AVAILABLE && invokedSkills.size > 0;
148
+ await saveCache({ version: CACHE_VERSION, entries: newEntries });
149
+ return { invokedSkills, dataAvailable, sessionsScanned, sessionsInWindow };
150
+ }
package/dist/types.d.ts CHANGED
@@ -25,7 +25,7 @@ export interface PluginInfo {
25
25
  status?: 'enabled' | 'disabled';
26
26
  }
27
27
  export type IssueTier = 1 | 2 | 3;
28
- export type IssueType = 'broken_symlink' | 'template' | 'skill_dup' | 'duplicate' | 'oversized_memory' | 'oversized_skill' | 'disabled_plugin' | 'stale_project' | 'temp_cache';
28
+ export type IssueType = 'broken_symlink' | 'template' | 'skill_dup' | 'duplicate' | 'oversized_memory' | 'oversized_skill' | 'unused_skill' | 'disabled_plugin' | 'stale_project' | 'temp_cache';
29
29
  export interface Issue {
30
30
  type: IssueType;
31
31
  tier: IssueTier;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-slim",
3
- "version": "2.3.0",
3
+ "version": "2.5.0",
4
4
  "description": "Analyze and reduce Claude Code token overhead",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,6 +17,7 @@
17
17
  "dev": "tsc --watch",
18
18
  "test": "vitest run",
19
19
  "test:watch": "vitest",
20
+ "demo": "npm run build && bash scripts/demo/fixture.sh && vhs scripts/demo/demo.tape",
20
21
  "prepublishOnly": "npm run build"
21
22
  },
22
23
  "keywords": [
@@ -39,7 +40,7 @@
39
40
  "url": "https://github.com/iops-leo/claude-slim/issues"
40
41
  },
41
42
  "engines": {
42
- "node": ">=18"
43
+ "node": ">=20"
43
44
  },
44
45
  "dependencies": {
45
46
  "commander": "^13.0.0",