claude-slim 2.7.3 → 2.8.1

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
@@ -3,12 +3,16 @@
3
3
  # claude-slim
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/claude-slim.svg)](https://www.npmjs.com/package/claude-slim)
6
+ [![downloads](https://img.shields.io/npm/dm/claude-slim.svg)](https://www.npmjs.com/package/claude-slim)
6
7
  [![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)
8
+ [![node](https://img.shields.io/node/v/claude-slim.svg)](https://nodejs.org)
7
9
  [![license](https://img.shields.io/npm/l/claude-slim.svg)](./LICENSE)
8
10
 
9
- **You're burning thousands of tokens before you even say "hello."**
11
+ **Your Claude Code session burns thousands of tokens before you even say "hello."**
10
12
 
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.
13
+ 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.
14
+
15
+ 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
16
 
13
17
  ```
14
18
  /claude-slim
@@ -26,17 +30,20 @@ Every Claude Code session auto-loads every skill, memory file, and plugin instru
26
30
  <img src="docs/demo.gif" alt="claude-slim cleanup: 11,442 tokens of overhead reduced to ~5,800 in 45 seconds" width="900" />
27
31
  </p>
28
32
 
29
- Where the bloat hides:
33
+ Where the bloat hides — measured on one real install:
34
+
35
+ | Source | What it costs | |
36
+ |--------|:---:|---|
37
+ | Skill listings | ~10,100 tokens | 256 skills × their `name: description` line |
38
+ | Agent catalog | ~2,250 tokens | `~/.claude/agents/`, 12 agents |
39
+ | CLAUDE.md | ~2,000 tokens | plugin instructions |
40
+ | Deferred tools list | ~1,500 tokens | MCP tool schemas |
41
+ | Slash commands | ~80 tokens | `~/.claude/commands/` |
42
+ | Memory files | **0 – 63,500 tokens** | current project only — varies wildly per project |
30
43
 
31
- | Source | Typical overhead |
32
- |--------|:---:|
33
- | 60+ registered skills | ~3,000 tokens |
34
- | CLAUDE.md (plugin instructions) | ~5,000 tokens |
35
- | Memory files | ~2,500 tokens |
36
- | Deferred tools list | ~1,500 tokens |
37
- | **Total** | **~12,000 tokens** |
44
+ Skill listings are the part people underestimate: each installed skill contributes one `- name: description` line to the system prompt, and those run anywhere from **30 to 509 tokens each**. Sixty terse skills and sixty verbose ones are not the same bill.
38
45
 
39
- That's slower responses. Hitting your usage cap faster. Paying for context you're not using.
46
+ That's slower responses. Hitting your usage cap faster. Paying for context you're not using — on every turn, of every session.
40
47
 
41
48
  ---
42
49
 
@@ -67,6 +74,7 @@ That's slower responses. Hitting your usage cap faster. Paying for context you'r
67
74
  | Empty templates | Placeholder skills with no content |
68
75
  | Oversized files | SKILL.md over 10KB |
69
76
  | **Unused skills** | **Local skills never invoked in your last N days of sessions (default 60d)** |
77
+ | Agents & commands | `~/.claude/agents/` and `~/.claude/commands/` — measured and reported, never modified |
70
78
  | **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
79
  | Stale memory | Large memory files loaded every session |
72
80
  | Disabled plugins | Installed but disabled plugins still in cache |
@@ -173,6 +181,7 @@ npx claude-slim report # Show savings from last clean
173
181
 
174
182
  - **`~/.claude/CLAUDE.md`** — your system instructions, read-only.
175
183
  - **`~/.claude/settings.json`** — MCP server config, hooks, and any other settings. Read-only.
184
+ - **`~/.claude/agents/` and `~/.claude/commands/`** — measured and reported since v2.8, never moved or deleted. There is no restore path for them yet, and a destructive action without its undo isn't worth shipping.
176
185
  - **Plugin internals** (`~/.claude/plugins/config.json`, individual `plugin.json` files) — left alone; use `claude plugin` to manage plugins.
177
186
  - **Git / project sources** — claude-slim only looks inside `~/.claude/`, never at your code.
178
187
  - **Anything outside `~/.claude/`** — a path-containment guard refuses destructive ops anywhere else, even if a tampered manifest asked it to.
@@ -188,9 +197,11 @@ claude-slim scans these locations. No plugin-specific logic — pure filesystem
188
197
  ```
189
198
  ~/.claude/
190
199
  ├── skills/ ← user-installed skills
191
- ├── plugins/cache/ ← plugin skills
200
+ ├── plugins/cache/ ← plugin skills, agents, commands, MCP servers
201
+ ├── agents/ ← user agents (measured, read-only)
202
+ ├── commands/ ← user slash commands (measured, read-only)
192
203
  ├── CLAUDE.md ← system instructions (read-only)
193
- ├── projects/*/memory/ ← auto-memory files
204
+ ├── projects/*/memory/ ← auto-memory files (current project counts toward startup)
194
205
  └── settings.json ← MCP server count (read-only)
195
206
  ```
196
207
 
@@ -209,17 +220,25 @@ From a real cleanup session:
209
220
  | Memory files | 15KB | 2KB | **-87%** |
210
221
  | **Est. token savings** | | **~4,300/session** | |
211
222
 
223
+ ### A note on the numbers
224
+
225
+ claude-slim reports what a session in **this** directory pays. Memory is per-project — Claude Code loads `~/.claude/projects/<slug>/memory/` for the project you're in, not every project on disk — so running `scan` from two different repos will legitimately give you two different totals.
226
+
227
+ Token counts come from [js-tiktoken](https://github.com/nicolo-ribaudo/js-tiktoken) against the actual file contents. The only estimates left are marked with `~`: MCP tool schemas (~8 tokens/tool) and skills whose frontmatter can't be parsed (~30 tokens). Everything else is measured.
228
+
212
229
  ---
213
230
 
214
- ## v2.7.2 — What's new
231
+ ## v2.8.0 — What's new
215
232
 
216
- Correctness patch fixing three HIGH-severity issues from a post-2.7.1 review. No new features; no breaking changes.
233
+ 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.**
217
234
 
218
- - **Friendlier `unused_plugin` cleanup when the `claude` CLI is missing.** Users running claude-slim outside a Claude Code install used to see raw `spawn claude ENOENT` one row per selected plugin. The cleaner now probes the CLI once up front via `isClaudeCliAvailable()`; failing items become `skipped` (not `errored`), and a single grouped warning points at the manual `claude plugin disable <name>` workaround.
219
- - **Nested-skill scanner now covers 3-deep layouts.** The prior 2-level walk silently missed `skills/<org>/<group>/<skill>/SKILL.md` token totals under-reported and `unused_skill` could never flag them. Replaced with a bounded recursive walk (`MAX_SKILL_DEPTH = 3`) that stops descending once a `SKILL.md` is found, so nested docs under a declared skill don't become phantom duplicates.
220
- - **`scan --json` output contract locked in.** New test spies on `console.log` / `console.info` / `console.warn` during the full scan pipeline (both minimal and rich fixtures session logs, plugin cache, memory, CLAUDE.md) and fails if any fires. No stray writes exist today; the invariant is now enforced so a future refactor can't silently break `claude-slim scan --json | jq`.
235
+ - **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.
236
+ - **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.
237
+ - **`~/.claude/agents/` and `~/.claude/commands/` are now scanned.** Previously invisible despite loading into every session12 agents worth ~2,254 tokens on the dev machine. Reported only; never moved or deleted, because there's no restore path for them yet.
238
+ - **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.
239
+ - **Fixed: the token cache grew without bound** — 355 of 776 entries (46%) pointed at deleted files. `flushCache()` now prunes them.
221
240
 
222
- Tests: 190206 (+16).
241
+ Tests: 206241 (+35).
223
242
 
224
243
  For older release notes, see [CHANGELOG.md](CHANGELOG.md).
225
244
 
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;
@@ -11,13 +11,18 @@ function getCachePath() {
11
11
  let cache = { version: 1, entries: {} };
12
12
  let cacheDirty = false;
13
13
  export async function initTokenizer() {
14
- try {
15
- // cl100k_base is closest to Claude's tokenizer (gpt-4o uses o200k_base which undercounts by ~15%)
16
- const { getEncoding } = await import('js-tiktoken');
17
- encoder = getEncoding('cl100k_base');
18
- }
19
- catch {
20
- useFallback = true;
14
+ // Building the cl100k_base encoder parses a large rank table — hundreds of
15
+ // milliseconds, and far more under CPU contention. It is immutable once
16
+ // built, so reuse it across calls. Only the cache is per-init state.
17
+ if (encoder === null && !useFallback) {
18
+ try {
19
+ // cl100k_base is closest to Claude's tokenizer (gpt-4o uses o200k_base which undercounts by ~15%)
20
+ const { getEncoding } = await import('js-tiktoken');
21
+ encoder = getEncoding('cl100k_base');
22
+ }
23
+ catch {
24
+ useFallback = true;
25
+ }
21
26
  }
22
27
  // Reset in-memory state so repeated initTokenizer() calls (e.g. across
23
28
  // test cases) don't bleed cache entries from a prior invocation.
@@ -51,9 +56,35 @@ export function countTokensCached(text, filePath) {
51
56
  cacheDirty = true;
52
57
  return tokens;
53
58
  }
59
+ /**
60
+ * Drop cache entries whose source file no longer exists.
61
+ *
62
+ * Skills get uninstalled, plugins get removed, sessions get rotated — but the
63
+ * entry keyed by that path stayed forever, so the cache grew without bound. A
64
+ * path that is gone can never produce a hit again, which makes existence the
65
+ * safe pruning predicate: it cannot evict an entry that is still reachable.
66
+ * (Measured on a real install before this fix: 355 of 776 entries were dead.)
67
+ */
68
+ async function pruneMissingEntries() {
69
+ const paths = Object.keys(cache.entries);
70
+ const alive = await Promise.all(paths.map(async (p) => {
71
+ try {
72
+ await access(p);
73
+ return true;
74
+ }
75
+ catch {
76
+ return false;
77
+ }
78
+ }));
79
+ for (let i = 0; i < paths.length; i++) {
80
+ if (!alive[i])
81
+ delete cache.entries[paths[i]];
82
+ }
83
+ }
54
84
  export async function flushCache() {
55
85
  if (!cacheDirty)
56
86
  return;
87
+ await pruneMissingEntries();
57
88
  const target = getCachePath();
58
89
  const tmp = target + '.tmp';
59
90
  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.3",
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.1",
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
  }