claude-slim 2.7.2 → 2.8.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
@@ -6,9 +6,11 @@
6
6
  [![CI](https://github.com/iops-leo/claude-slim/actions/workflows/ci.yml/badge.svg)](https://github.com/iops-leo/claude-slim/actions/workflows/ci.yml)
7
7
  [![license](https://img.shields.io/npm/l/claude-slim.svg)](./LICENSE)
8
8
 
9
- **You're burning thousands of tokens before you even say "hello."**
9
+ **Your Claude Code session burns thousands of tokens before you even say "hello."**
10
10
 
11
- Every Claude Code session auto-loads every skill, memory file, and plugin instruction into the system prompt — even the ones you never use. If you run OMC, marketplace plugins, or a custom skill stack, you're paying for context you'll never touch. claude-slim finds and removes that waste.
11
+ Every session auto-loads every skill, agent, slash command, memory file, and plugin instruction into the system prompt — even the ones you never use. If you run OMC, marketplace plugins, or a custom skill stack, you're paying for context you'll never touch on every single turn. claude-slim measures that startup overhead and removes what you don't need.
12
+
13
+ No proxy, no compression, no changes to how Claude Code talks to the API — it reads `~/.claude/`, tells you what each skill and plugin actually costs, and moves the dead weight aside reversibly.
12
14
 
13
15
  ```
14
16
  /claude-slim
@@ -67,6 +69,7 @@ That's slower responses. Hitting your usage cap faster. Paying for context you'r
67
69
  | Empty templates | Placeholder skills with no content |
68
70
  | Oversized files | SKILL.md over 10KB |
69
71
  | **Unused skills** | **Local skills never invoked in your last N days of sessions (default 60d)** |
72
+ | Agents & commands | `~/.claude/agents/` and `~/.claude/commands/` — measured and reported, never modified |
70
73
  | **Unused plugins** | **Plugins whose skill/mcp/cmd were never invoked in your last N days of sessions (default 60d). Tier 3, never auto-selected.** |
71
74
  | Stale memory | Large memory files loaded every session |
72
75
  | Disabled plugins | Installed but disabled plugins still in cache |
@@ -188,9 +191,11 @@ claude-slim scans these locations. No plugin-specific logic — pure filesystem
188
191
  ```
189
192
  ~/.claude/
190
193
  ├── skills/ ← user-installed skills
191
- ├── plugins/cache/ ← plugin skills
194
+ ├── plugins/cache/ ← plugin skills, agents, commands, MCP servers
195
+ ├── agents/ ← user agents (measured, read-only)
196
+ ├── commands/ ← user slash commands (measured, read-only)
192
197
  ├── CLAUDE.md ← system instructions (read-only)
193
- ├── projects/*/memory/ ← auto-memory files
198
+ ├── projects/*/memory/ ← auto-memory files (current project counts toward startup)
194
199
  └── settings.json ← MCP server count (read-only)
195
200
  ```
196
201
 
@@ -211,72 +216,19 @@ From a real cleanup session:
211
216
 
212
217
  ---
213
218
 
214
- ## v2.7.1 — What's new
215
-
216
- Correctness patch cleaning up seven bugs found in post-2.7 review. No new features; no breaking changes.
217
-
218
- - **`unused_plugin` savings always showed 0.** The detector emitted `tokens: 0` for every flagged plugin, so both the dry-run summary and the final report box under-counted savings for what is typically the largest cleanup target. Cost is now threaded through `DetectorContext` and the detector uses the real per-plugin value.
219
- - **`duplicate` detector could disable namespaced local skills.** A `baseName` fallback flagged `org/ship` as a duplicate of a bare plugin `ship`, even though namespaced local skills are addressable independently. Only exact-name matches are flagged now.
220
- - **`stale_project` restore was scoped to all of `~/.claude/`.** A tampered manifest could redirect a project-memory backup into `~/.claude/skills/` and clobber an unrelated asset. Restores are now type-scoped — `stale_project` targets must live under `~/.claude/projects/`, skill restores under `~/.claude/skills/`.
221
- - **Non-interactive `claude-slim clean` refuses to auto-apply.** Prior behavior silently auto-selected Tier 1 in non-TTY without `--auto`/`--dry-run`, surprising users running from scripts. It now prints a warning and exits with status 1; opt in explicitly with `--auto` or `--dry-run`.
222
- - **`--lookback-days 0` / `--sessions-per-day 0` are respected.** `parseInt(x, 10) || N` was silently upgrading explicit `0` to the default. Replaced with a `parseNonNegativeInt` helper.
223
- - **`claude-slim report` recognizes zero-token cleanups.** Runs that only removed `broken_symlink` or `temp_cache` entries were being reported as "no previous cleanup"; every manifest entry now counts.
224
- - **Session parser regex hardened.** `extractCommandsFromTranscript` uses `String.matchAll` instead of a manual `lastIndex`-resetting loop — one fewer footgun for future refactors.
225
-
226
- Tests: 188 → 190 (+2 regression cases for the token-propagation fix).
227
-
228
- ## v2.7 — What's new
229
-
230
- - **Unused-plugin detection** — claude-slim now reads your session transcripts for MCP tool calls (`mcp__plugin_<plugin>_<server>__*`) and slash commands, and flags plugins whose surfaces you've never touched in the last 60 days. Tier 3 (Optional, never auto-selected). When you choose to clean one, `claude plugin disable <name>` runs automatically; `/claude-slim restore` re-enables it.
231
- - **PLUGIN BREAKDOWN table** — the scan report now includes a per-plugin cost breakdown: token estimate (CLAUDE.md section + skills + deferred MCP tools + commands) and usage status (used / unused / agent-only / insufficient data / disabled). In the owner's real env: `oh-my-claudecode` tops the list at ~6,210 tok; three plugins were flagged as unused despite being enabled.
232
- - **Session parser fix** — slash commands in string-form user messages were previously missed (only array-form content was parsed). All slash-command invocations are now captured correctly.
233
- - **+82 tests** for the new modules and the parser regression.
234
-
235
- ## v2.6 — What's new
236
-
237
- - **`claude-slim doctor`** — Checks Node support, `~/.claude/` readability, local skill/plugin cache access, `claude plugin list`, and recent session-log signal quality. Use it when scan results look sparse or unused-skill detection is suppressed.
238
- - **Clearer safety wording** — Skills and project memory are reversible moves into `~/.claude/skills.disabled/`; broken symlink files and failed-install `temp_local_*` caches are permanent cleanup targets and are labeled before selection.
239
- - **Pinned development Node version** — `.nvmrc` and `.node-version` pin contributors to Node 22.12.0, matching the current Vitest/Vite/Rolldown patch-floor requirements.
240
-
241
- ## v2.4 — What's new
242
-
243
- - **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.
244
- - **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.
245
- - **Per-file session-usage cache** at `~/.claude/.skill-usage-cache.json` keyed by mtime. Warm rescans only re-parse session logs that have changed.
246
- - **Node 20+** is now the engine floor (previously `>=18`, but Node 18 was already dropped from CI in v2.3.0).
247
-
248
- ## v2.3 — What's new
249
-
250
- - **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.
251
- - **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.
252
- - **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.
253
- - **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.
219
+ ## v2.8.0 — What's new
254
220
 
255
- ## v2.2 What's new
221
+ Accuracy release. Three reported numbers were wrong; the largest was wrong by an order of magnitude. **If your startup estimate drops sharply after upgrading, the old number was the inaccurate one.**
256
222
 
257
- - **Atomic `stale_project` clean/restore**Single directory `rename()` instead of per-file loop. No more partial-failure state if the operation is interrupted.
258
- - **Clear collision errors** Cleaning a project whose backup already exists (or restoring onto an existing directory) now fails with an actionable message instead of a cryptic OS error.
259
- - **Manifest schema v2** Single JSON file (`manifest.json`) containing only currently-disabled entries. Restore removes the entry entirely, so the manifest stays bounded across many clean/restore cycles.
260
- - **Automatic migration from v1** Existing legacy manifests (`.claude-slim-manifest.jsonl`) are auto-migrated on first read; the original is preserved as `.jsonl.bak` for safety.
261
- - **Crash-safe manifest writes** — Write-to-tmp-then-rename pattern prevents partial-write corruption on power loss or SIGKILL.
262
- - **Expanded test coverage** — 66 tests (was 35). New round-trip tests per issue type: `broken_symlink`, `template`, `duplicate`, `skill_dup`, `oversized_skill`, `temp_cache`, `stale_project`. Plus manifest migration + bounded-growth cycle tests.
223
+ - **Startup estimate no longer sums memory across every project on disk.** Claude Code loads `~/.claude/projects/<slug>/memory/` for the project you're in not the other 40 project directories in your `~/.claude`. The old total scaled with how many projects you'd ever opened: on the dev machine it reported **116,259 tokens where the real per-session cost was 14,399**. Now scoped to the current project, with the cross-project total still shown and labelled as not a per-session cost.
224
+ - **Skill listing cost is measured, not assumed.** Each skill adds a `- <name>: <description>` line to the system prompt. The flat 30-tokens-per-skill estimate stood in for all of them; measured across 68 installed skills the real spread is **30 → 509 tokens (mean 51)**. The per-plugin cost gradient can now tell five terse skills apart from five verbose ones.
225
+ - **`~/.claude/agents/` and `~/.claude/commands/` are now scanned.** Previously invisible despite loading into every session 12 agents worth ~2,254 tokens on the dev machine. Reported only; never moved or deleted, because there's no restore path for them yet.
226
+ - **Fixed: plugin manifests were stuck at 2.7.0 for three releases**, so `claude plugin install` advertised a stale version. CI now fails on version drift.
227
+ - **Fixed: the token cache grew without bound** — 355 of 776 entries (46%) pointed at deleted files. `flushCache()` now prunes them.
263
228
 
264
- ## v2.0 What's new
229
+ Tests: 206 241 (+35).
265
230
 
266
- - **TypeScript CLI** — Rewritten from bash. Faster, more accurate, extensible.
267
- - **Accurate token counting** — [js-tiktoken](https://github.com/nicolo-ribaudo/js-tiktoken) with `cl100k_base` encoding.
268
- - **Savings report box** — Visual before/after with breakdown table and monthly savings estimate.
269
- - **`--dry-run`** — Preview changes without making them.
270
- - **`--json`** — Machine-readable output for automation.
271
- - **Token cache** — Instant repeat scans.
272
- - **Standalone CLI** — `npx claude-slim` works outside Claude Code.
273
- - **`--auto`** — Non-interactive cleanup for CI/scripts (Tier 1 only).
274
- - **Disabled plugin detection** — Finds plugins you disabled but didn't uninstall.
275
- - **Stale project detection** — Flags project memory untouched for 90+ days.
276
- - **CLAUDE.md section breakdown** — See which plugin instructions cost the most tokens.
277
- - **Plugin status** — Shows enabled/disabled status for each plugin.
278
- - **Non-TTY support** — Auto-selects Tier 1 when stdin is piped.
279
- - **Unit tests** — Vitest-based.
231
+ For older release notes, see [CHANGELOG.md](CHANGELOG.md).
280
232
 
281
233
  ---
282
234
 
package/dist/cli.js CHANGED
@@ -201,6 +201,10 @@ program
201
201
  path: e.from,
202
202
  sizeBytes: 0,
203
203
  tokens: e.tokenCount || 0,
204
+ // The file is gone, so its real listing cost is unrecoverable. Fall
205
+ // back to the flat estimate — the same one `totalBefore` above uses,
206
+ // keeping the reconstructed before-state internally consistent.
207
+ listingTokens: SKILL_PROMPT_OVERHEAD_TOKENS,
204
208
  source: 'local',
205
209
  })),
206
210
  ],
package/dist/paths.d.ts CHANGED
@@ -3,6 +3,15 @@ export declare function getSkillsDir(): string;
3
3
  export declare function getPluginsDir(): string;
4
4
  export declare function getProjectsDir(): string;
5
5
  export declare function getDisabledDir(): string;
6
+ /**
7
+ * Claude Code stores per-project state under `~/.claude/projects/<slug>/`,
8
+ * where the slug is the absolute project path with every `/` replaced by `-`
9
+ * (e.g. `/Users/me/app` → `-Users-me-app`).
10
+ *
11
+ * Only the current project's `memory/` is loaded into a session — which is why
12
+ * the startup estimate must not sum memory across every project on disk.
13
+ */
14
+ export declare function getCurrentProjectSlug(cwd?: string): string;
6
15
  export declare function getManifestPath(): string;
7
16
  export declare function getLegacyManifestPath(): string;
8
17
  export declare function assertInsideClaudeDir(p: string): void;
package/dist/paths.js CHANGED
@@ -15,6 +15,17 @@ export function getProjectsDir() {
15
15
  export function getDisabledDir() {
16
16
  return join(getClaudeDir(), 'skills.disabled');
17
17
  }
18
+ /**
19
+ * Claude Code stores per-project state under `~/.claude/projects/<slug>/`,
20
+ * where the slug is the absolute project path with every `/` replaced by `-`
21
+ * (e.g. `/Users/me/app` → `-Users-me-app`).
22
+ *
23
+ * Only the current project's `memory/` is loaded into a session — which is why
24
+ * the startup estimate must not sum memory across every project on disk.
25
+ */
26
+ export function getCurrentProjectSlug(cwd = process.cwd()) {
27
+ return resolve(cwd).replace(/\//g, '-');
28
+ }
18
29
  export function getManifestPath() {
19
30
  return join(getDisabledDir(), 'manifest.json');
20
31
  }
package/dist/report.js CHANGED
@@ -181,7 +181,6 @@ export function formatScanSummary(result) {
181
181
  // --- MEMORY FILES ---
182
182
  lines.push('');
183
183
  const memTotal = result.memoryFiles.reduce((s, m) => s + m.sizeBytes, 0);
184
- const memTokTotal = result.memoryFiles.reduce((s, m) => s + m.tokens, 0);
185
184
  lines.push(`\x1b[1m MEMORY FILES\x1b[0m (${result.memoryFiles.length} files, ${(memTotal / 1024).toFixed(1)}KB)`);
186
185
  const sortedMem = [...result.memoryFiles].sort((a, b) => b.sizeBytes - a.sizeBytes);
187
186
  for (const mem of sortedMem) {
@@ -194,7 +193,16 @@ export function formatScanSummary(result) {
194
193
  project = rest ? '~' + rest : '~';
195
194
  }
196
195
  const label = `${project}/${mem.name}`;
197
- lines.push(` ${label.padEnd(52)} ${kb.padStart(6)}KB ${tok.padStart(7)} tok`);
196
+ // Mark the only project whose memory this session would actually load.
197
+ const active = mem.project === result.currentProjectSlug ? ' \x1b[32m←\x1b[0m' : '';
198
+ lines.push(` ${label.padEnd(52)} ${kb.padStart(6)}KB ${tok.padStart(7)} tok${active}`);
199
+ }
200
+ if (result.memoryFiles.length > 0) {
201
+ lines.push('');
202
+ lines.push(` \x1b[32m←\x1b[0m loaded in this project: ` +
203
+ `${result.currentProjectMemoryTokens.toLocaleString()} tok ` +
204
+ `\x1b[90m(${result.allProjectsMemoryTokens.toLocaleString()} tok across all projects, ` +
205
+ `not a per-session cost)\x1b[0m`);
198
206
  }
199
207
  // --- MCP SERVERS ---
200
208
  lines.push('');
@@ -207,6 +215,26 @@ export function formatScanSummary(result) {
207
215
  else {
208
216
  lines.push(`\x1b[1m MCP SERVERS\x1b[0m: ${result.mcpServers}`);
209
217
  }
218
+ // --- USER AGENTS & COMMANDS ---
219
+ // Read-only in v2.8: these are measured and reported, but never moved or
220
+ // deleted. There is no restore path for ~/.claude/agents yet, and shipping a
221
+ // destructive action without its undo would break the tool's core promise.
222
+ const agentTokens = result.userAgents.reduce((s, a) => s + a.listingTokens, 0);
223
+ const commandTokens = result.userCommands.reduce((s, c) => s + c.listingTokens, 0);
224
+ if (result.userAgents.length > 0 || result.userCommands.length > 0) {
225
+ lines.push('');
226
+ lines.push(`\x1b[1m AGENTS & COMMANDS\x1b[0m ` +
227
+ `(${result.userAgents.length} agents, ${result.userCommands.length} commands)`);
228
+ if (result.userAgents.length > 0) {
229
+ lines.push(` ~/.claude/agents/`.padEnd(56) +
230
+ `${agentTokens.toLocaleString().padStart(7)} tok`);
231
+ }
232
+ if (result.userCommands.length > 0) {
233
+ lines.push(` ~/.claude/commands/`.padEnd(56) +
234
+ `${commandTokens.toLocaleString().padStart(7)} tok`);
235
+ }
236
+ lines.push(` \x1b[90mreported only — not touched by clean\x1b[0m`);
237
+ }
210
238
  // --- SUMMARY ---
211
239
  lines.push('');
212
240
  lines.push(`\x1b[1m ESTIMATED OVERHEAD\x1b[0m: ~${result.totalTokensBefore.toLocaleString()} tokens at session start`);
@@ -5,7 +5,7 @@
5
5
  // src/__tests__/scan-stdout-invariant.test.ts.
6
6
  import { join } from 'node:path';
7
7
  import { countTokensCached } from '../tokenizer.js';
8
- import { getClaudeDir } from '../paths.js';
8
+ import { getClaudeDir, getCurrentProjectSlug } from '../paths.js';
9
9
  import { safeReadFile } from './fs-walk.js';
10
10
  import { scanLocalSkills } from './local-skills.js';
11
11
  import { scanPluginSkills } from './plugin-skills.js';
@@ -18,17 +18,18 @@ import { classifyIssues } from './detectors.js';
18
18
  import { scanPluginSurfaces } from './plugin-surfaces.js';
19
19
  import { computePluginBreakdown } from './plugin-breakdown.js';
20
20
  import { computePluginCosts } from './plugin-cost.js';
21
- import { SKILL_PROMPT_OVERHEAD_TOKENS } from './constants.js';
21
+ import { scanUserSurfaces } from './user-surfaces.js';
22
22
  const DEFAULT_LOOKBACK_DAYS = 60;
23
23
  export async function scan(opts = {}) {
24
24
  const lookbackDays = opts.lookbackDays ?? DEFAULT_LOOKBACK_DAYS;
25
- const [{ skills: localSkills, brokenSymlinks, contents }, { skills: pluginSkills, plugins, tempCaches }, { memoryFiles, staleProjects }, mcp, disabledPlugins, sessionUsage,] = await Promise.all([
25
+ const [{ skills: localSkills, brokenSymlinks, contents }, { skills: pluginSkills, plugins, tempCaches }, { memoryFiles, staleProjects }, mcp, disabledPlugins, sessionUsage, userSurfaces,] = await Promise.all([
26
26
  scanLocalSkills(),
27
27
  scanPluginSkills(),
28
28
  scanMemoryFiles(),
29
29
  scanMcpServers(),
30
30
  getDisabledPlugins(),
31
31
  scanSessionUsage(lookbackDays),
32
+ scanUserSurfaces(),
32
33
  ]);
33
34
  const pluginSurfaces = scanPluginSurfaces();
34
35
  // Annotate plugin status
@@ -84,10 +85,28 @@ export async function scan(opts = {}) {
84
85
  sessionsInWindow: sessionUsage.sessionsInWindow,
85
86
  claudeMdSections,
86
87
  });
87
- // Estimate total tokens at startup
88
- const skillListingTokens = (localSkills.length + pluginSkills.length) * SKILL_PROMPT_OVERHEAD_TOKENS;
89
- const memoryTokens = memoryFiles.reduce((sum, m) => sum + m.tokens, 0);
90
- const totalTokensBefore = skillListingTokens + claudeMdTokens + memoryTokens;
88
+ // Estimate total tokens at startup. Skill/agent/command listing costs are
89
+ // measured from each file's frontmatter description rather than assumed
90
+ // (see scanner/skill-listing.ts) the real spread is 30–500+ tokens apiece.
91
+ const sumListing = (entries) => entries.reduce((sum, e) => sum + e.listingTokens, 0);
92
+ const skillListingTokens = sumListing(localSkills) + sumListing(pluginSkills);
93
+ const agentListingTokens = sumListing(userSurfaces.agents);
94
+ const commandListingTokens = sumListing(userSurfaces.commands);
95
+ // Memory is per-project: a session loads ~/.claude/projects/<slug>/memory/
96
+ // for the project it is running in, and nothing from the other projects on
97
+ // disk. Summing all of them (pre-2.8 behaviour) inflated the startup estimate
98
+ // by a factor of however many projects the user had — 100k+ tokens on a busy
99
+ // machine, for a number labelled "tokens at session start".
100
+ const currentProjectSlug = getCurrentProjectSlug();
101
+ const currentProjectMemoryTokens = memoryFiles
102
+ .filter((m) => m.project === currentProjectSlug)
103
+ .reduce((sum, m) => sum + m.tokens, 0);
104
+ const allProjectsMemoryTokens = memoryFiles.reduce((sum, m) => sum + m.tokens, 0);
105
+ const totalTokensBefore = skillListingTokens +
106
+ agentListingTokens +
107
+ commandListingTokens +
108
+ claudeMdTokens +
109
+ currentProjectMemoryTokens;
91
110
  return {
92
111
  localSkills,
93
112
  pluginSkills,
@@ -102,5 +121,10 @@ export async function scan(opts = {}) {
102
121
  issues,
103
122
  totalTokensBefore,
104
123
  pluginBreakdown,
124
+ userAgents: userSurfaces.agents,
125
+ userCommands: userSurfaces.commands,
126
+ currentProjectSlug,
127
+ currentProjectMemoryTokens,
128
+ allProjectsMemoryTokens,
105
129
  };
106
130
  }
@@ -3,6 +3,7 @@ import { join } from 'node:path';
3
3
  import { countTokensCached } from '../tokenizer.js';
4
4
  import { getSkillsDir } from '../paths.js';
5
5
  import { safeReadFile, safeReaddir, isDirectory, isBrokenSymlink, resolveRealPath, } from './fs-walk.js';
6
+ import { listingTokensFromContent } from './skill-listing.js';
6
7
  export function dedupeBySymlink(candidates) {
7
8
  const seen = new Map();
8
9
  for (const { skill, realMdPath } of candidates) {
@@ -57,6 +58,7 @@ export async function scanLocalSkills() {
57
58
  path: dirPath,
58
59
  sizeBytes: Buffer.byteLength(content),
59
60
  tokens,
61
+ listingTokens: listingTokensFromContent(displayName, content),
60
62
  source: 'local',
61
63
  },
62
64
  realMdPath,
@@ -4,7 +4,7 @@ export interface PluginCostBreakdown {
4
4
  marketplace: string;
5
5
  /** Tokens from the matching CLAUDE.md section (0 if no match). */
6
6
  claudeMdTokens: number;
7
- /** SKILL_PROMPT_OVERHEAD_TOKENS × skills.length */
7
+ /** Measured sum of each skill's `- <name>: <description>` listing line. */
8
8
  skillTokens: number;
9
9
  /** DEFERRED_TOOL_OVERHEAD_TOKENS × MCP_SERVER_TOOLS_AVG × mcpServerKeys.length */
10
10
  mcpToolTokens: number;
@@ -1,4 +1,4 @@
1
- import { SKILL_PROMPT_OVERHEAD_TOKENS, DEFERRED_TOOL_OVERHEAD_TOKENS, COMMAND_OVERHEAD_TOKENS, MCP_SERVER_TOOLS_AVG, } from './constants.js';
1
+ import { DEFERRED_TOOL_OVERHEAD_TOKENS, COMMAND_OVERHEAD_TOKENS, MCP_SERVER_TOOLS_AVG, } from './constants.js';
2
2
  /**
3
3
  * Find a CLAUDE.md section whose name contains the plugin name as a substring
4
4
  * (case-insensitive). Returns the first match or null.
@@ -18,7 +18,7 @@ export function computePluginCosts(surfaces, claudeMdSections) {
18
18
  return surfaces.map((s) => {
19
19
  const matched = matchSection(s.pluginName, claudeMdSections);
20
20
  const claudeMdTokens = matched?.tokens ?? 0;
21
- const skillTokens = SKILL_PROMPT_OVERHEAD_TOKENS * s.skills.length;
21
+ const skillTokens = s.skillListingTokens;
22
22
  const mcpToolTokens = DEFERRED_TOOL_OVERHEAD_TOKENS * MCP_SERVER_TOOLS_AVG * s.mcpServerKeys.length;
23
23
  const commandTokens = COMMAND_OVERHEAD_TOKENS * s.commands.length;
24
24
  const totalEstimatedTokens = claudeMdTokens + skillTokens + mcpToolTokens + commandTokens;
@@ -2,6 +2,7 @@ import { join } from 'node:path';
2
2
  import { countTokensCached } from '../tokenizer.js';
3
3
  import { getPluginsDir } from '../paths.js';
4
4
  import { safeReadFile, safeReaddir, isDirectory, getDirSize } from './fs-walk.js';
5
+ import { listingTokensFromContent } from './skill-listing.js';
5
6
  export async function scanPluginSkills() {
6
7
  const skills = [];
7
8
  const plugins = [];
@@ -40,6 +41,7 @@ export async function scanPluginSkills() {
40
41
  path: skillPath,
41
42
  sizeBytes: Buffer.byteLength(content),
42
43
  tokens: countTokensCached(content, skillMd),
44
+ listingTokens: listingTokensFromContent(skillDir, content),
43
45
  source: 'plugin',
44
46
  pluginName,
45
47
  });
@@ -5,6 +5,7 @@ export interface PluginSurfaces {
5
5
  installDir: string;
6
6
  installedAt: number;
7
7
  skills: string[];
8
+ skillListingTokens: number;
8
9
  mcpServerKeys: string[];
9
10
  mcpToolPrefixes: string[];
10
11
  commands: string[];
@@ -2,6 +2,8 @@ import { join } from 'node:path';
2
2
  import { statSync } from 'node:fs';
3
3
  import { readFileSync, readdirSync } from 'node:fs';
4
4
  import { getPluginsDir } from '../paths.js';
5
+ import { listingTokensFromContent } from './skill-listing.js';
6
+ import { SKILL_PROMPT_OVERHEAD_TOKENS } from './constants.js';
5
7
  function safeReaddir(p) {
6
8
  try {
7
9
  return readdirSync(p);
@@ -49,19 +51,29 @@ function parseMcpServerKeys(installDir) {
49
51
  function scanSkills(installDir) {
50
52
  const skillsDir = join(installDir, 'skills');
51
53
  if (!isDir(skillsDir))
52
- return [];
54
+ return { names: [], listingTokens: 0 };
53
55
  const names = [];
56
+ let listingTokens = 0;
54
57
  for (const entry of safeReaddir(skillsDir)) {
55
58
  const skillDir = join(skillsDir, entry);
56
59
  if (!isDir(skillDir))
57
60
  continue;
58
61
  const upper = join(skillDir, 'SKILL.md');
59
62
  const lower = join(skillDir, 'skill.md');
60
- if (isFile(upper) || isFile(lower)) {
61
- names.push(entry);
63
+ const present = isFile(upper) ? upper : isFile(lower) ? lower : null;
64
+ if (present === null)
65
+ continue;
66
+ names.push(entry);
67
+ let content = '';
68
+ try {
69
+ content = readFileSync(present, 'utf-8');
62
70
  }
71
+ catch { /* unreadable */ }
72
+ listingTokens += content
73
+ ? listingTokensFromContent(entry, content)
74
+ : SKILL_PROMPT_OVERHEAD_TOKENS;
63
75
  }
64
- return names;
76
+ return { names, listingTokens };
65
77
  }
66
78
  function scanCommands(installDir) {
67
79
  const commandsDir = join(installDir, 'commands');
@@ -103,7 +115,7 @@ export function scanPluginSurfaces() {
103
115
  continue;
104
116
  const dirStat = safeStat(installDir);
105
117
  const installedAt = dirStat ? Number(dirStat.mtimeMs) : 0;
106
- const skills = scanSkills(installDir);
118
+ const { names: skills, listingTokens: skillListingTokens } = scanSkills(installDir);
107
119
  const mcpServerKeys = parseMcpServerKeys(installDir);
108
120
  const mcpToolPrefixes = mcpServerKeys.map((key) => `plugin_${pluginName}_${key}`);
109
121
  const commands = scanCommands(installDir);
@@ -116,6 +128,7 @@ export function scanPluginSurfaces() {
116
128
  installDir,
117
129
  installedAt,
118
130
  skills,
131
+ skillListingTokens,
119
132
  mcpServerKeys,
120
133
  mcpToolPrefixes,
121
134
  commands,
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Extract the `description` value from a SKILL.md (or agent .md) frontmatter
3
+ * block. Returns null when there is no frontmatter or no description key —
4
+ * callers fall back to {@link SKILL_PROMPT_OVERHEAD_TOKENS}.
5
+ */
6
+ export declare function parseFrontmatterDescription(content: string): string | null;
7
+ /**
8
+ * Token cost of one entry in the system prompt's skill listing.
9
+ *
10
+ * Falls back to the flat {@link SKILL_PROMPT_OVERHEAD_TOKENS} estimate when the
11
+ * description is missing or unreadable, so a malformed SKILL.md degrades to the
12
+ * pre-2.8 behaviour rather than reporting zero.
13
+ */
14
+ export declare function listingTokens(name: string, description: string | null): number;
15
+ /** Convenience wrapper: parse a raw SKILL.md and return its listing cost. */
16
+ export declare function listingTokensFromContent(name: string, content: string): number;
@@ -0,0 +1,66 @@
1
+ import { countTokens } from '../tokenizer.js';
2
+ import { SKILL_PROMPT_OVERHEAD_TOKENS } from './constants.js';
3
+ // Claude Code renders each available skill into the system prompt as one line:
4
+ // `- <name>: <description>`
5
+ // The description comes from the SKILL.md YAML frontmatter and is reproduced
6
+ // verbatim, so its real length — not a flat per-skill constant — is what the
7
+ // session actually pays for. Measured across 68 installed skills the spread is
8
+ // 30 → 509 tokens (mean 51), which a fixed estimate cannot represent.
9
+ const LISTING_PREFIX = '- ';
10
+ const LISTING_SEPARATOR = ': ';
11
+ // Frontmatter block at the very top of the file: `---\n<yaml>\n---`.
12
+ const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---/;
13
+ // `description:` up to the next top-level key. YAML folded/literal blocks and
14
+ // plain multi-line values both indent their continuation lines, so "next line
15
+ // that starts at column 0 with `key:`" is the terminator.
16
+ //
17
+ // Deliberately NOT /m: with the multiline flag `$` matches end-of-line, which
18
+ // would terminate the capture at the first newline and silently truncate every
19
+ // wrapped description to its first line.
20
+ const DESCRIPTION_PATTERN = /(?:^|\n)description:[ \t]*([\s\S]*?)(?=\r?\n[A-Za-z_][\w-]*:|$)/;
21
+ /**
22
+ * Extract the `description` value from a SKILL.md (or agent .md) frontmatter
23
+ * block. Returns null when there is no frontmatter or no description key —
24
+ * callers fall back to {@link SKILL_PROMPT_OVERHEAD_TOKENS}.
25
+ */
26
+ export function parseFrontmatterDescription(content) {
27
+ const fm = FRONTMATTER_PATTERN.exec(content);
28
+ if (!fm)
29
+ return null;
30
+ const match = DESCRIPTION_PATTERN.exec(fm[1]);
31
+ if (!match)
32
+ return null;
33
+ const raw = match[1]
34
+ // Drop a leading YAML block scalar indicator (`|`, `>`, `|-`, `>-`, …).
35
+ .replace(/^[|>][+-]?\d*[ \t]*\r?\n/, '')
36
+ // Collapse the indentation + wrapping that YAML uses for long values;
37
+ // the system prompt renders them as a single line.
38
+ .split(/\r?\n/)
39
+ .map((line) => line.trim())
40
+ .join(' ')
41
+ .trim();
42
+ if (!raw)
43
+ return null;
44
+ // Strip surrounding quotes if the value was quoted.
45
+ const unquoted = (raw.startsWith('"') && raw.endsWith('"')) ||
46
+ (raw.startsWith("'") && raw.endsWith("'"))
47
+ ? raw.slice(1, -1)
48
+ : raw;
49
+ return unquoted.trim() || null;
50
+ }
51
+ /**
52
+ * Token cost of one entry in the system prompt's skill listing.
53
+ *
54
+ * Falls back to the flat {@link SKILL_PROMPT_OVERHEAD_TOKENS} estimate when the
55
+ * description is missing or unreadable, so a malformed SKILL.md degrades to the
56
+ * pre-2.8 behaviour rather than reporting zero.
57
+ */
58
+ export function listingTokens(name, description) {
59
+ if (description === null)
60
+ return SKILL_PROMPT_OVERHEAD_TOKENS;
61
+ return countTokens(LISTING_PREFIX + name + LISTING_SEPARATOR + description);
62
+ }
63
+ /** Convenience wrapper: parse a raw SKILL.md and return its listing cost. */
64
+ export function listingTokensFromContent(name, content) {
65
+ return listingTokens(name, parseFrontmatterDescription(content));
66
+ }
@@ -0,0 +1,16 @@
1
+ export interface UserSurfaceEntry {
2
+ name: string;
3
+ path: string;
4
+ sizeBytes: number;
5
+ /** Full file body — what the agent/command costs once invoked. */
6
+ tokens: number;
7
+ /** What it costs at startup by merely existing. */
8
+ listingTokens: number;
9
+ }
10
+ export interface UserSurfacesResult {
11
+ agents: UserSurfaceEntry[];
12
+ commands: UserSurfaceEntry[];
13
+ }
14
+ export declare function getUserAgentsDir(): string;
15
+ export declare function getUserCommandsDir(): string;
16
+ export declare function scanUserSurfaces(): Promise<UserSurfacesResult>;
@@ -0,0 +1,59 @@
1
+ import { join } from 'node:path';
2
+ import { getClaudeDir } from '../paths.js';
3
+ import { countTokensCached } from '../tokenizer.js';
4
+ import { safeReadFile, safeReaddir, isBrokenSymlink } from './fs-walk.js';
5
+ import { listingTokens, parseFrontmatterDescription } from './skill-listing.js';
6
+ import { COMMAND_OVERHEAD_TOKENS } from './constants.js';
7
+ export function getUserAgentsDir() {
8
+ return join(getClaudeDir(), 'agents');
9
+ }
10
+ export function getUserCommandsDir() {
11
+ return join(getClaudeDir(), 'commands');
12
+ }
13
+ /** Agents render exactly like skills: `- <name>: <description>`. */
14
+ function agentListingCost(name, content) {
15
+ return listingTokens(name, parseFrontmatterDescription(content));
16
+ }
17
+ /**
18
+ * Commands without frontmatter appear as a bare name in the listing, which is
19
+ * what COMMAND_OVERHEAD_TOKENS already estimates for plugin commands. With a
20
+ * description they cost the same as a skill line.
21
+ */
22
+ function commandListingCost(name, content) {
23
+ const description = parseFrontmatterDescription(content);
24
+ if (description === null)
25
+ return COMMAND_OVERHEAD_TOKENS;
26
+ return listingTokens(name, description);
27
+ }
28
+ async function scanMarkdownDir(dir, listingCost) {
29
+ const entries = await safeReaddir(dir);
30
+ const results = await Promise.all(entries.map(async (entry) => {
31
+ if (!entry.endsWith('.md'))
32
+ return null;
33
+ const path = join(dir, entry);
34
+ // Symlinked agents are common (vendored packs link into ~/.claude/agents).
35
+ // A dangling link contributes nothing to the prompt — skip it rather than
36
+ // counting a phantom entry.
37
+ if (await isBrokenSymlink(path))
38
+ return null;
39
+ const content = await safeReadFile(path);
40
+ if (content === null)
41
+ return null;
42
+ const name = entry.slice(0, -3);
43
+ return {
44
+ name,
45
+ path,
46
+ sizeBytes: Buffer.byteLength(content),
47
+ tokens: countTokensCached(content, path),
48
+ listingTokens: listingCost(name, content),
49
+ };
50
+ }));
51
+ return results.filter((r) => r !== null);
52
+ }
53
+ export async function scanUserSurfaces() {
54
+ const [agents, commands] = await Promise.all([
55
+ scanMarkdownDir(getUserAgentsDir(), agentListingCost),
56
+ scanMarkdownDir(getUserCommandsDir(), commandListingCost),
57
+ ]);
58
+ return { agents, commands };
59
+ }
package/dist/tokenizer.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
2
+ import { readFile, writeFile, mkdir, rename, access } from 'node:fs/promises';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { getClaudeDir } from './paths.js';
5
5
  let encoder = null;
@@ -51,9 +51,35 @@ export function countTokensCached(text, filePath) {
51
51
  cacheDirty = true;
52
52
  return tokens;
53
53
  }
54
+ /**
55
+ * Drop cache entries whose source file no longer exists.
56
+ *
57
+ * Skills get uninstalled, plugins get removed, sessions get rotated — but the
58
+ * entry keyed by that path stayed forever, so the cache grew without bound. A
59
+ * path that is gone can never produce a hit again, which makes existence the
60
+ * safe pruning predicate: it cannot evict an entry that is still reachable.
61
+ * (Measured on a real install before this fix: 355 of 776 entries were dead.)
62
+ */
63
+ async function pruneMissingEntries() {
64
+ const paths = Object.keys(cache.entries);
65
+ const alive = await Promise.all(paths.map(async (p) => {
66
+ try {
67
+ await access(p);
68
+ return true;
69
+ }
70
+ catch {
71
+ return false;
72
+ }
73
+ }));
74
+ for (let i = 0; i < paths.length; i++) {
75
+ if (!alive[i])
76
+ delete cache.entries[paths[i]];
77
+ }
78
+ }
54
79
  export async function flushCache() {
55
80
  if (!cacheDirty)
56
81
  return;
82
+ await pruneMissingEntries();
57
83
  const target = getCachePath();
58
84
  const tmp = target + '.tmp';
59
85
  try {
package/dist/types.d.ts CHANGED
@@ -1,8 +1,17 @@
1
+ import type { UserSurfaceEntry } from './scanner/user-surfaces.js';
2
+ export type { UserSurfaceEntry };
1
3
  export interface SkillInfo {
2
4
  name: string;
3
5
  path: string;
4
6
  sizeBytes: number;
7
+ /** Full SKILL.md body tokens — what the skill costs once invoked. */
5
8
  tokens: number;
9
+ /**
10
+ * Tokens this skill adds to the system prompt just by being installed
11
+ * (its `- <name>: <description>` listing line). Measured from the
12
+ * frontmatter description; see scanner/skill-listing.ts.
13
+ */
14
+ listingTokens: number;
6
15
  source: 'local' | 'plugin';
7
16
  pluginName?: string;
8
17
  }
@@ -63,6 +72,16 @@ export interface ScanResult {
63
72
  issues: Issue[];
64
73
  totalTokensBefore: number;
65
74
  pluginBreakdown: PluginBreakdown[];
75
+ /** ~/.claude/agents/*.md — loaded into the system prompt as the agent catalog. */
76
+ userAgents: UserSurfaceEntry[];
77
+ /** ~/.claude/commands/*.md — loaded as the slash-command listing. */
78
+ userCommands: UserSurfaceEntry[];
79
+ /** Project slug (cwd with `/` → `-`) whose memory a session here would load. */
80
+ currentProjectSlug: string;
81
+ /** Memory tokens actually loaded at startup — current project only. */
82
+ currentProjectMemoryTokens: number;
83
+ /** Memory tokens across every project on disk. Not a per-session cost. */
84
+ allProjectsMemoryTokens: number;
66
85
  }
67
86
  export interface ManifestEntry {
68
87
  date: string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "claude-slim",
3
- "version": "2.7.2",
4
- "description": "Cut Claude Code startup token overhead non-destructive scan, tiered proposals, one-command restore. Finds unused skills, duplicate registrations, stale memory, and heavyweight plugins in ~/.claude/.",
3
+ "version": "2.8.0",
4
+ "description": "Audit and shrink your Claude Code startup context. Measures what every skill, plugin, agent, command, and memory file costs in the system prompt, then reversibly disables the dead weight. Non-destructive scan, tiered proposals, one-command restore no proxy, no compression.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "claude-slim": "./dist/cli.js"
@@ -16,9 +16,10 @@
16
16
  "build": "tsc",
17
17
  "dev": "tsc --watch",
18
18
  "test": "vitest run",
19
+ "check:versions": "node scripts/check-version-sync.mjs",
19
20
  "test:watch": "vitest",
20
21
  "demo": "npm run build && bash scripts/demo/fixture.sh && vhs scripts/demo/demo.tape",
21
- "prepublishOnly": "npm run build"
22
+ "prepublishOnly": "npm run check:versions && npm run build"
22
23
  },
23
24
  "keywords": [
24
25
  "claude",
@@ -35,7 +36,13 @@
35
36
  "plugin",
36
37
  "plugin-cleanup",
37
38
  "cli",
38
- "developer-experience"
39
+ "developer-experience",
40
+ "context-engineering",
41
+ "context-window",
42
+ "agent-skills",
43
+ "claude-code-plugin",
44
+ "usage-limits",
45
+ "llm-tools"
39
46
  ],
40
47
  "author": "iops-leo",
41
48
  "license": "MIT",
@@ -51,13 +58,13 @@
51
58
  "node": ">=20"
52
59
  },
53
60
  "dependencies": {
54
- "commander": "^13.0.0",
61
+ "commander": "^14.0.3",
55
62
  "js-tiktoken": "^1.0.18"
56
63
  },
57
64
  "devDependencies": {
58
- "@types/node": "^22.0.0",
65
+ "@types/node": "^22.20.1",
59
66
  "tmp-promise": "^3.0.3",
60
- "typescript": "^5.7.0",
61
- "vitest": "^4.1.4"
67
+ "typescript": "^5.9.3",
68
+ "vitest": "^4.1.10"
62
69
  }
63
70
  }