claude-slim 2.12.1 → 2.12.3

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
@@ -148,6 +148,7 @@ Then just type `/claude-slim` in any session.
148
148
  /claude-slim scan # Report only, no changes
149
149
  /claude-slim scan --json # Machine-readable JSON output
150
150
  /claude-slim scan --lookback-days 30 # Treat skills idle for 30+ days as unused
151
+ /claude-slim scan --project-dir PATH # Count PATH's project memory (default: cwd)
151
152
  /claude-slim doctor # Check scanner prerequisites and data fidelity
152
153
  /claude-slim check-update # Is a newer version published?
153
154
  /claude-slim restore # Bring back anything you disabled
@@ -248,12 +249,13 @@ Token counts come from [js-tiktoken](https://github.com/nicolo-ribaudo/js-tiktok
248
249
 
249
250
  ---
250
251
 
251
- ## v2.12.1 — What's new
252
+ ## v2.12.3 — What's new
252
253
 
253
- - **Fixed: the `/claude-slim` skill reported project memory as zero.** It invoked the CLI with `cd "${CLAUDE_PLUGIN_ROOT}"`, making `cwd` the plugin cache directory; the project slug resolved there, matched nothing, and every project-memory token silently left the startup total**108,570** on the machine where this surfaced. `SKILL.md` no longer `cd`s. This hit the tool's primary entry point, and quietly: a zero reads as a clean result.
254
- - **`--project-dir <path>`** for callers that cannot run from the project directory, plus a warning when the CLI notices it is running from its own install without one.
254
+ - **Fixed: the startup total counted a plugin's skills once per cached version.** `claude plugin update` leaves the old version behind as a symlink to the new one (`4.9.1 -> 4.15.4`), and the scan followed both as if they were separate installs inflating the one number this tool exists to report. Both scanners now resolve the version a session actually loads. Measured here: plugin skill entries 148 107, startup total 13,435 → **12,504**.
255
+ - **Fixed: every per-plugin cost was doubled when two versions were cached.** The breakdown showed one version's skill count beside two versions' tokens. `oh-my-claudecode` read as ~6,074 tokens against 41 skills; it is ~3,037.
256
+ - **Corrected: v2.12.2 reported this issue as "1,944 tokens, 14.5%". It is 931 tokens, 6.9%.** That estimate mistook two distinct plugins shipping identical skill names (`document-skills` and `example-skills` share all 16) for duplicates.
255
257
 
256
- Tests: 392401 (+9).
258
+ Tests: 445452 (+7).
257
259
 
258
260
  For older release notes, see [CHANGELOG.md](CHANGELOG.md).
259
261
 
package/dist/cleaner.js CHANGED
@@ -3,6 +3,16 @@ import { join, dirname, resolve, sep } from 'node:path';
3
3
  import { appendManifest, ensureDisabledDir, removeEntry, recordDisabledPlugin, removeDisabledPlugin } from './manifest.js';
4
4
  import { assertInsideAgentRoot, getAgentRoot, getAgentDisabledDir, getSkillsDir, getProjectsDir } from './paths.js';
5
5
  import { disablePlugin, enablePlugin, isClaudeCliAvailable, ClaudeCliMissingError } from './plugin-runtime.js';
6
+ /**
7
+ * Issue types whose cleanup consumes a filesystem path — the ones where two
8
+ * findings on the same path cannot both be executed. Types absent here are
9
+ * either report-only (`oversized_memory`, `disabled_plugin`) or addressed by
10
+ * name through the `claude` CLI rather than by path (`unused_plugin`).
11
+ */
12
+ const MOVES_A_PATH = new Set([
13
+ 'template', 'duplicate', 'skill_dup', 'oversized_skill', 'unused_skill',
14
+ 'backup_artifact', 'broken_symlink', 'temp_cache', 'stale_project',
15
+ ]);
6
16
  // Restrict a restore target to a specific subtree of ~/.claude/. Complements
7
17
  // assertInsideClaudeDir: a tampered manifest could still name a legal
8
18
  // ~/.claude/ path that belongs to a different type of asset (e.g. redirect a
@@ -51,7 +61,25 @@ export async function cleanIssues(issues) {
51
61
  if (wantsPluginOps && !(await isClaudeCliAvailable())) {
52
62
  claudeCliMissing = true;
53
63
  }
64
+ // The detectors run independently, so one skill routinely earns several
65
+ // findings at once — `skillify` shows up as duplicate + oversized_skill +
66
+ // unused_skill. Selecting "all" then tried to rename the same directory three
67
+ // times: the first succeeded and the rest surfaced raw ENOENT rows, telling
68
+ // the user a cleanup had failed when it had in fact worked. Collapse them
69
+ // here, keeping the first (issues arrive tier-ordered, so that is the
70
+ // most-confident finding for the path).
71
+ const deduped = [];
72
+ const claimedPaths = new Set();
54
73
  for (const issue of issues) {
74
+ if (MOVES_A_PATH.has(issue.type)) {
75
+ const key = `${issue.agent ?? 'claude'}:${issue.path}`;
76
+ if (claimedPaths.has(key))
77
+ continue;
78
+ claimedPaths.add(key);
79
+ }
80
+ deduped.push(issue);
81
+ }
82
+ for (const issue of deduped) {
55
83
  // Scope every path decision to the issue's own agent, so a Codex issue can
56
84
  // never resolve into ~/.claude/ (or the reverse).
57
85
  const agent = issue.agent ?? 'claude';
package/dist/cli.js CHANGED
@@ -10,13 +10,13 @@ import { cleanIssues, restoreItem } from './cleaner.js';
10
10
  import { readManifest } from './manifest.js';
11
11
  import { formatScanSummary, formatReportBox, calculateReport, } from './report.js';
12
12
  import { collectDoctorReport, formatDoctorReport } from './doctor.js';
13
- import { looksLikeToolInstallDir } from './paths.js';
13
+ import { looksLikeToolInstallDir, projectDirError } from './paths.js';
14
14
  import { checkForUpdate, formatUpdateNotice } from './update-check.js';
15
15
  import { confirmDecision, planUpdate, renderStep, runUpdate } from './update-run.js';
16
16
  import { scanCodex } from './codex/index.js';
17
17
  import { formatCodexSummary } from './codex/report.js';
18
18
  import { classifyCodexIssues } from './codex/detectors.js';
19
- import { resolveSelection, resolveRestoreSelection } from './selection.js';
19
+ import { resolveSelection, resolveRestoreSelection, parseSelection } from './selection.js';
20
20
  const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
21
21
  const { version: PKG_VERSION } = JSON.parse(readFileSync(pkgPath, 'utf-8'));
22
22
  // Parse a non-negative-integer CLI option, keeping explicit 0 distinct from an
@@ -254,7 +254,8 @@ program
254
254
  return;
255
255
  }
256
256
  console.log('');
257
- const selection = await askUser(' Restore (all / numbers / none): ');
257
+ const selection = await askUser(' Restore (all / numbers / ranges / none): ');
258
+ warnIgnoredSelection(selection, restorable.length);
258
259
  const indices = resolveRestoreSelection(selection, restorable.length);
259
260
  if (indices.length === 0) {
260
261
  console.log('\n Cancelled.\n');
@@ -284,9 +285,17 @@ program
284
285
  .description('Show savings report from last clean')
285
286
  .option('--sessions-per-day <n>', 'Sessions per day for savings estimate', '2')
286
287
  .option('--lookback-days <n>', 'Days of session history for skill-usage analysis', '60')
288
+ .option('--project-dir <path>', 'Directory whose project memory counts toward the total (default: cwd)')
287
289
  .action(async (opts) => {
288
290
  await initTokenizer();
289
- const result = await scan({ lookbackDays: parseNonNegativeInt(opts.lookbackDays, 60) });
291
+ // Must resolve the same way `scan` and `clean` do. Skipping this left
292
+ // `report` as the one command that would neither honour --project-dir nor
293
+ // warn from an install directory, so its before/after pair could be
294
+ // computed against a different project than the clean it is reporting on.
295
+ const result = await scan({
296
+ lookbackDays: parseNonNegativeInt(opts.lookbackDays, 60),
297
+ projectDir: resolveProjectDir(opts.projectDir),
298
+ });
290
299
  const allEntries = await readManifest();
291
300
  // Filter to legacy-style entries only (those with tokenCount/name/from fields)
292
301
  const entries = allEntries.filter((e) => !('plugin' in e && 'marketplace' in e));
@@ -358,6 +367,24 @@ program.action(async () => {
358
367
  }
359
368
  await runCleanPipeline({ dryRun: false, auto: false, sessionsPerDay: 2, lookbackDays: 60, codex: true });
360
369
  });
370
+ /**
371
+ * Name the fragments of a selection that were thrown away.
372
+ *
373
+ * Silence here is dangerous in one direction only: the user believes they
374
+ * selected more than they did, sees a short confirmation, and assumes the rest
375
+ * is coming. Keywords (`all`, `none`, empty) are handled by the resolvers and
376
+ * are not fragments, so they are not reported as invalid.
377
+ */
378
+ function warnIgnoredSelection(input, count) {
379
+ const trimmed = input.trim().toLowerCase();
380
+ if (trimmed === '' || ['all', 'a', 'none', 'n', 'enter'].includes(trimmed))
381
+ return;
382
+ const { invalid } = parseSelection(trimmed, count);
383
+ if (invalid.length === 0)
384
+ return;
385
+ console.log(` \x1b[33m!\x1b[0m Ignored ${invalid.length} unrecognised entr${invalid.length === 1 ? 'y' : 'ies'}: ` +
386
+ `${invalid.join(', ')} \x1b[90m(valid: 1-${count}, e.g. "3", "5-12")\x1b[0m`);
387
+ }
361
388
  /**
362
389
  * Resolve which directory's project memory counts toward the startup estimate.
363
390
  *
@@ -365,10 +392,24 @@ program.action(async () => {
365
392
  * directory was given: the slug would resolve to the plugin cache, match no
366
393
  * project, and silently zero out every project-memory token. Warning beats
367
394
  * guessing — we cannot know which project the user meant.
395
+ *
396
+ * A `--project-dir` that does not exist is a hard error rather than a warning.
397
+ * The slug is derived from the path string, so a typo happily resolves to a
398
+ * slug that matches nothing and reports 0 — reintroducing, through the very
399
+ * flag added to prevent it, the silent zero of v2.12.1. An explicit flag with a
400
+ * bad value is unambiguously a mistake, so fail instead of guessing.
368
401
  */
369
402
  function resolveProjectDir(explicit) {
370
- if (explicit)
403
+ if (explicit) {
404
+ const err = projectDirError(explicit);
405
+ if (err) {
406
+ // Exit rather than fall back to cwd: continuing would print a full,
407
+ // confident report built on the wrong project.
408
+ console.error(`error: ${err}`);
409
+ process.exit(1);
410
+ }
371
411
  return explicit;
412
+ }
372
413
  if (looksLikeToolInstallDir()) {
373
414
  console.error(' \x1b[33m!\x1b[0m Running from claude-slim\'s own install directory, so project memory\n' +
374
415
  ' cannot be attributed and is reported as 0. Pass --project-dir <path>\n' +
@@ -400,10 +441,12 @@ async function runCleanPipeline(opts) {
400
441
  console.log(' Actions:');
401
442
  console.log(' Enter → accept pre-selected (Tier 1 only)');
402
443
  console.log(' 1,3,5 → select specific items');
444
+ console.log(' 1-9 → select a range');
403
445
  console.log(' all → select everything');
404
446
  console.log(' none → cancel');
405
447
  console.log('');
406
448
  const selection = await askUser(' Your choice: ');
449
+ warnIgnoredSelection(selection, result.issues.length);
407
450
  selectedIssues = resolveSelection(selection, result.issues);
408
451
  }
409
452
  else if (opts.auto) {
package/dist/paths.d.ts CHANGED
@@ -23,6 +23,19 @@ export declare function getCurrentProjectSlug(cwd?: string): string;
23
23
  * `--project-dir` instead of quietly reporting zero.
24
24
  */
25
25
  export declare function looksLikeToolInstallDir(cwd?: string): boolean;
26
+ /**
27
+ * Why an explicit `--project-dir` is unusable, or null when it is fine.
28
+ *
29
+ * The slug is a pure string transform of the path, so a typo resolves to a
30
+ * perfectly well-formed slug that matches no project on disk and reports zero
31
+ * project memory. That is the identical silent zero v2.12.1 was cut to fix,
32
+ * reintroduced through the flag added to fix it. A non-existent explicit path
33
+ * is unambiguously a mistake — worth an error, not a guess.
34
+ *
35
+ * A directory that exists but holds no memory is NOT an error: that is a real,
36
+ * correctly-measured zero.
37
+ */
38
+ export declare function projectDirError(p: string): string | null;
26
39
  export declare function getManifestPath(): string;
27
40
  export declare function getLegacyManifestPath(): string;
28
41
  /** The agents claude-slim is allowed to touch. Adding one widens what every
package/dist/paths.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { homedir } from 'node:os';
2
+ import { statSync } from 'node:fs';
2
3
  import { join, resolve, sep } from 'node:path';
3
4
  export function getClaudeDir() {
4
5
  return join(homedir(), '.claude');
@@ -42,6 +43,30 @@ export function looksLikeToolInstallDir(cwd = process.cwd()) {
42
43
  p.includes('/_npx/') ||
43
44
  /\/node_modules\/claude-slim(\/|$)/.test(p));
44
45
  }
46
+ /**
47
+ * Why an explicit `--project-dir` is unusable, or null when it is fine.
48
+ *
49
+ * The slug is a pure string transform of the path, so a typo resolves to a
50
+ * perfectly well-formed slug that matches no project on disk and reports zero
51
+ * project memory. That is the identical silent zero v2.12.1 was cut to fix,
52
+ * reintroduced through the flag added to fix it. A non-existent explicit path
53
+ * is unambiguously a mistake — worth an error, not a guess.
54
+ *
55
+ * A directory that exists but holds no memory is NOT an error: that is a real,
56
+ * correctly-measured zero.
57
+ */
58
+ export function projectDirError(p) {
59
+ let st;
60
+ try {
61
+ st = statSync(p);
62
+ }
63
+ catch {
64
+ return `--project-dir is not an existing directory: ${p}`;
65
+ }
66
+ if (!st.isDirectory())
67
+ return `--project-dir is not a directory: ${p}`;
68
+ return null;
69
+ }
45
70
  export function getManifestPath() {
46
71
  return join(getDisabledDir(), 'manifest.json');
47
72
  }
package/dist/report.js CHANGED
@@ -74,8 +74,11 @@ export function formatReportBox(data) {
74
74
  const visible = s.replace(/\x1b\[[0-9;]*m/g, '');
75
75
  return s + ' '.repeat(Math.max(0, W - 2 - visible.length));
76
76
  };
77
- const top = '\u256d' + '\u2500'.repeat(W) + '\u256e';
78
- const bot = '\u2570' + '\u2500'.repeat(W) + '\u256f';
77
+ // W is the total line width, so the run between the corners is W - 2 \u2014 the
78
+ // same interior width `pad()` and `blank` use. Repeating W here made every
79
+ // border overhang its own body by two columns.
80
+ const top = '\u256d' + '\u2500'.repeat(W - 2) + '\u256e';
81
+ const bot = '\u2570' + '\u2500'.repeat(W - 2) + '\u256f';
79
82
  const blank = '\u2502' + ' '.repeat(W - 2) + '\u2502';
80
83
  lines.push(top);
81
84
  lines.push(`\u2502${pad(' claude-slim report')}\u2502`);
@@ -203,6 +206,16 @@ export function formatScanSummary(result) {
203
206
  `${result.currentProjectMemoryTokens.toLocaleString()} tok ` +
204
207
  `\x1b[90m(${result.allProjectsMemoryTokens.toLocaleString()} tok across all projects, ` +
205
208
  `not a per-session cost)\x1b[0m`);
209
+ // Zero reads as "clean", so name the reason for it. Deliberately does not
210
+ // claim the memory is unattributed: a directory Claude has simply never
211
+ // opened has no memory, and that 0 is correct. What is worth surfacing is
212
+ // that no project state backs this path, so if the user meant a different
213
+ // project, this total is answering the wrong question.
214
+ if (!result.currentProjectKnown) {
215
+ lines.push(` \x1b[33m!\x1b[0m No Claude project state for \x1b[90m${result.currentProjectSlug}\x1b[0m — ` +
216
+ `nothing to attribute here.\n` +
217
+ ` \x1b[90mIf you meant a different project, pass --project-dir <path>.\x1b[0m`);
218
+ }
206
219
  }
207
220
  // --- MCP SERVERS ---
208
221
  lines.push('');
@@ -238,6 +251,14 @@ export function formatScanSummary(result) {
238
251
  // --- SUMMARY ---
239
252
  lines.push('');
240
253
  lines.push(`\x1b[1m ESTIMATED OVERHEAD\x1b[0m: ~${result.totalTokensBefore.toLocaleString()} tokens at session start`);
254
+ if (result.issues.length > 0) {
255
+ // Stated next to the total it is a fraction of, because the numbers on the
256
+ // issue rows below are body sizes, not startup cost, and adding them up
257
+ // yields a "saving" many times larger than the whole startup budget.
258
+ lines.push(` \x1b[1mRECOVERABLE\x1b[0m: ~${result.recoverableStartupTokens.toLocaleString()} tokens ` +
259
+ `\x1b[90mif every issue below is acted on (startup cost only — the per-issue\n` +
260
+ ` figures below are full file sizes, paid when a skill runs, not at startup)\x1b[0m`);
261
+ }
241
262
  if (isUsingFallback()) {
242
263
  lines.push(` \x1b[33m\u26a0 Using bytes/4 approximation (js-tiktoken unavailable)\x1b[0m`);
243
264
  }
@@ -1,6 +1,9 @@
1
+ import type { Stats } from 'node:fs';
1
2
  export declare function safeReadFile(p: string): Promise<string | null>;
2
3
  export declare function safeReaddir(p: string): Promise<string[]>;
3
4
  export declare function isDirectory(p: string): Promise<boolean>;
5
+ /** Stats `p`, or null when it cannot be read. Follows symlinks, like isDirectory. */
6
+ export declare function safeStat(p: string): Promise<Stats | null>;
4
7
  export declare function isBrokenSymlink(p: string): Promise<boolean>;
5
8
  export declare function resolveRealPath(p: string): Promise<string>;
6
9
  export declare function getDirSize(dir: string): Promise<number>;
@@ -24,6 +24,15 @@ export async function isDirectory(p) {
24
24
  return false;
25
25
  }
26
26
  }
27
+ /** Stats `p`, or null when it cannot be read. Follows symlinks, like isDirectory. */
28
+ export async function safeStat(p) {
29
+ try {
30
+ return await stat(p);
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
27
36
  export async function isBrokenSymlink(p) {
28
37
  try {
29
38
  const lstats = await lstat(p);
@@ -1,4 +1,4 @@
1
- import type { ScanResult } from '../types.js';
1
+ import type { Issue, ScanResult, SkillInfo } from '../types.js';
2
2
  export interface ScanOptions {
3
3
  lookbackDays?: number;
4
4
  /**
@@ -12,3 +12,26 @@ export interface ScanOptions {
12
12
  projectDir?: string;
13
13
  }
14
14
  export declare function scan(opts?: ScanOptions): Promise<ScanResult>;
15
+ /**
16
+ * What acting on every issue would actually save at session start.
17
+ *
18
+ * Two corrections over a naive `sum(issues.tokens)`, both of which inflate:
19
+ *
20
+ * - **Per-path, not per-issue.** The detectors are independent, so one skill
21
+ * routinely earns several findings at once (`duplicate` + `oversized_skill` +
22
+ * `unused_skill`). Removing it once collects the saving once.
23
+ * - **Listing tokens, not body tokens.** `Issue.tokens` is the whole SKILL.md,
24
+ * which is loaded only when the skill runs. Startup pays for the catalog
25
+ * line alone. Conflating the two overstated savings ~80× in practice.
26
+ *
27
+ * Memory issues count only when they belong to the current project — the same
28
+ * per-project rule `totalTokensBefore` follows. Deletions that free disk but no
29
+ * context (`broken_symlink`, `temp_cache`) contribute nothing here by design.
30
+ *
31
+ * Three separate overlaps have to be collapsed, since every one of them inflates:
32
+ * the same skill path, the same plugin across cached versions, and a memory file
33
+ * that its own stale project already accounts for.
34
+ */
35
+ export declare function sumRecoverableStartupTokens(issues: Issue[], skills: SkillInfo[], currentProjectSlug: string,
36
+ /** Plugin name → its skill-listing tokens. See the `unused_plugin` branch. */
37
+ pluginSkillListingTokens?: Map<string, number>): number;
@@ -4,8 +4,9 @@
4
4
  // Route diagnostics through console.error. Enforced by
5
5
  // src/__tests__/scan-stdout-invariant.test.ts.
6
6
  import { join } from 'node:path';
7
+ import { access } from 'node:fs/promises';
7
8
  import { countTokensCached } from '../tokenizer.js';
8
- import { getClaudeDir, getCurrentProjectSlug } from '../paths.js';
9
+ import { getClaudeDir, getCurrentProjectSlug, getProjectsDir } from '../paths.js';
9
10
  import { safeReadFile } from './fs-walk.js';
10
11
  import { scanLocalSkills } from './local-skills.js';
11
12
  import { scanPluginSkills } from './plugin-skills.js';
@@ -107,6 +108,15 @@ export async function scan(opts = {}) {
107
108
  commandListingTokens +
108
109
  claudeMdTokens +
109
110
  currentProjectMemoryTokens;
111
+ const currentProjectKnown = await pathExists(join(getProjectsDir(), currentProjectSlug));
112
+ // Only the skill-listing slice of a plugin's cost is recoverable startup
113
+ // context — see sumRecoverableStartupTokens. Aggregated by plugin name the
114
+ // same way `pluginCosts` is, so the two stay comparable.
115
+ const pluginSkillListingTokens = new Map();
116
+ for (const c of pluginCostBreakdowns) {
117
+ pluginSkillListingTokens.set(c.pluginName, (pluginSkillListingTokens.get(c.pluginName) ?? 0) + c.skillTokens);
118
+ }
119
+ const recoverableStartupTokens = sumRecoverableStartupTokens(issues, [...localSkills, ...pluginSkills], currentProjectSlug, pluginSkillListingTokens);
110
120
  return {
111
121
  localSkills,
112
122
  pluginSkills,
@@ -124,7 +134,106 @@ export async function scan(opts = {}) {
124
134
  userAgents: userSurfaces.agents,
125
135
  userCommands: userSurfaces.commands,
126
136
  currentProjectSlug,
137
+ currentProjectKnown,
127
138
  currentProjectMemoryTokens,
128
139
  allProjectsMemoryTokens,
140
+ recoverableStartupTokens,
129
141
  };
130
142
  }
143
+ async function pathExists(p) {
144
+ try {
145
+ await access(p);
146
+ return true;
147
+ }
148
+ catch {
149
+ return false;
150
+ }
151
+ }
152
+ /** Issue types whose cleanup moves a skill directory out of the listing. */
153
+ const SKILL_MOVE_TYPES = new Set([
154
+ 'template', 'duplicate', 'skill_dup', 'oversized_skill', 'unused_skill',
155
+ 'backup_artifact',
156
+ ]);
157
+ /**
158
+ * What acting on every issue would actually save at session start.
159
+ *
160
+ * Two corrections over a naive `sum(issues.tokens)`, both of which inflate:
161
+ *
162
+ * - **Per-path, not per-issue.** The detectors are independent, so one skill
163
+ * routinely earns several findings at once (`duplicate` + `oversized_skill` +
164
+ * `unused_skill`). Removing it once collects the saving once.
165
+ * - **Listing tokens, not body tokens.** `Issue.tokens` is the whole SKILL.md,
166
+ * which is loaded only when the skill runs. Startup pays for the catalog
167
+ * line alone. Conflating the two overstated savings ~80× in practice.
168
+ *
169
+ * Memory issues count only when they belong to the current project — the same
170
+ * per-project rule `totalTokensBefore` follows. Deletions that free disk but no
171
+ * context (`broken_symlink`, `temp_cache`) contribute nothing here by design.
172
+ *
173
+ * Three separate overlaps have to be collapsed, since every one of them inflates:
174
+ * the same skill path, the same plugin across cached versions, and a memory file
175
+ * that its own stale project already accounts for.
176
+ */
177
+ export function sumRecoverableStartupTokens(issues, skills, currentProjectSlug,
178
+ /** Plugin name → its skill-listing tokens. See the `unused_plugin` branch. */
179
+ pluginSkillListingTokens = new Map()) {
180
+ const listingByPath = new Map(skills.map((s) => [s.path, s.listingTokens]));
181
+ const countedPaths = new Set();
182
+ const countedPlugins = new Set();
183
+ let total = 0;
184
+ // `stale_project` names the slug alone; `oversized_memory` names
185
+ // `<slug>/<file>`. Match both without letting a sibling slug through — plain
186
+ // startsWith would count `-Users-me-app2` as part of `-Users-me-app`.
187
+ const isCurrentProject = (name) => name === currentProjectSlug || name.startsWith(currentProjectSlug + '/');
188
+ // Stale projects first: `stale_project.tokens` is the sum of every memory file
189
+ // in that project, so counting it settles the per-file findings inside it too.
190
+ // Charging both billed an oversized file twice and could claim more than the
191
+ // project's entire memory.
192
+ let currentProjectIsStale = false;
193
+ for (const issue of issues) {
194
+ if (issue.type !== 'stale_project' || !isCurrentProject(issue.name))
195
+ continue;
196
+ if (currentProjectIsStale)
197
+ continue;
198
+ currentProjectIsStale = true;
199
+ total += issue.tokens;
200
+ }
201
+ for (const issue of issues) {
202
+ if (SKILL_MOVE_TYPES.has(issue.type)) {
203
+ if (countedPaths.has(issue.path))
204
+ continue;
205
+ countedPaths.add(issue.path);
206
+ // A skill missing from the listing map costs nothing at startup.
207
+ total += listingByPath.get(issue.path) ?? 0;
208
+ }
209
+ else if (issue.type === 'unused_plugin') {
210
+ // Deliberately NOT `issue.tokens`. That is the plugin's full estimated
211
+ // cost from computePluginCosts — CLAUDE.md section + skills + MCP tools +
212
+ // commands — and two of those parts do not belong in a recovery figure
213
+ // presented against `totalTokensBefore`:
214
+ // - the matched CLAUDE.md section is in the baseline but survives the
215
+ // cleanup, since disabling a plugin does not edit the user's
216
+ // CLAUDE.md (and this tool never modifies it at all);
217
+ // - the MCP-tool and command estimates are genuinely freed, but are not
218
+ // in the baseline, so counting them measures against a total that
219
+ // never included them.
220
+ // The skill listings are the one component that is both. Deduped by name
221
+ // because the surface scan walks version directories, so a plugin with
222
+ // two cached versions raises two findings.
223
+ if (countedPlugins.has(issue.name))
224
+ continue;
225
+ countedPlugins.add(issue.name);
226
+ total += pluginSkillListingTokens.get(issue.name) ?? 0;
227
+ }
228
+ else if (issue.type === 'oversized_memory') {
229
+ // Another project's memory never loads here, so trimming it saves this
230
+ // session nothing.
231
+ if (!isCurrentProject(issue.name))
232
+ continue;
233
+ if (currentProjectIsStale)
234
+ continue; // already inside the stale-project total
235
+ total += issue.tokens;
236
+ }
237
+ }
238
+ return total;
239
+ }
@@ -1,8 +1,57 @@
1
1
  import { join } from 'node:path';
2
2
  import { countTokensCached } from '../tokenizer.js';
3
3
  import { getPluginsDir } from '../paths.js';
4
- import { safeReadFile, safeReaddir, isDirectory, getDirSize } from './fs-walk.js';
4
+ import { safeReadFile, safeReaddir, isDirectory, getDirSize, safeStat } from './fs-walk.js';
5
+ import { pickActiveVersion } from './plugin-versions.js';
5
6
  import { listingTokensFromContent } from './skill-listing.js';
7
+ /**
8
+ * The directories under a marketplace whose skills a session would load — one
9
+ * per plugin, never two versions of the same one.
10
+ *
11
+ * The cache is laid out `<marketplace>/<plugin>/<version>/`. A plugin whose
12
+ * content sits directly under `<plugin>/` is returned as-is; otherwise its
13
+ * children are versions and only the active one counts. That distinction is
14
+ * drawn by looking for a `skills` directory rather than by pattern-matching
15
+ * version names, so a plugin that ships no skills is simply walked as before
16
+ * and contributes nothing either way.
17
+ */
18
+ async function resolveContentRoots(pluginBaseDir) {
19
+ const entries = await safeReaddir(pluginBaseDir);
20
+ // Flat layout: the cache entry holds content directly, with no plugin or
21
+ // version level (`<cache-entry>/skills/<skill>/`). Without this check the
22
+ // loop below reads `skills` as a plugin directory and each skill inside it as
23
+ // a candidate version, then picks exactly one — which is not a miscount but a
24
+ // silent disappearance: the whole entry reported zero skills.
25
+ if (entries.includes('skills'))
26
+ return [pluginBaseDir];
27
+ const roots = [];
28
+ for (const entry of entries) {
29
+ const pluginDir = join(pluginBaseDir, entry);
30
+ if (!(await isDirectory(pluginDir)))
31
+ continue;
32
+ const children = await safeReaddir(pluginDir);
33
+ if (children.includes('skills')) {
34
+ // Content root, not a version container.
35
+ roots.push(pluginDir);
36
+ continue;
37
+ }
38
+ const versions = [];
39
+ for (const child of children) {
40
+ const versionDir = join(pluginDir, child);
41
+ if (!(await isDirectory(versionDir)))
42
+ continue;
43
+ const stats = await safeStat(versionDir);
44
+ versions.push({ version: child, installedAt: stats?.mtimeMs ?? 0, dir: versionDir });
45
+ }
46
+ const active = pickActiveVersion(versions);
47
+ // No subdirectories at all: hand back the plugin dir so the walk behaves
48
+ // exactly as it did before rather than silently dropping the plugin.
49
+ roots.push(active ? active.dir : pluginDir);
50
+ }
51
+ // A marketplace with no plugin subdirectories still needs walking — some
52
+ // caches put content directly under the top level.
53
+ return roots.length > 0 ? roots : [pluginBaseDir];
54
+ }
6
55
  export async function scanPluginSkills() {
7
56
  const skills = [];
8
57
  const plugins = [];
@@ -21,6 +70,10 @@ export async function scanPluginSkills() {
21
70
  }
22
71
  const pluginSkillNames = [];
23
72
  const walkDir = async (dir) => {
73
+ // Kept generic: a plugin's content root is normally
74
+ // `<plugin>/<version>/`, but the walk also has to reach skills nested
75
+ // deeper. Version selection happens before we get here — see
76
+ // resolveContentRoots below — so this only ever descends one install.
24
77
  const entries = await safeReaddir(dir);
25
78
  for (const entry of entries) {
26
79
  const entryPath = join(dir, entry);
@@ -53,7 +106,9 @@ export async function scanPluginSkills() {
53
106
  }
54
107
  }
55
108
  };
56
- await walkDir(pluginDir);
109
+ for (const root of await resolveContentRoots(pluginDir)) {
110
+ await walkDir(root);
111
+ }
57
112
  if (pluginSkillNames.length > 0) {
58
113
  plugins.push({
59
114
  name: pluginName,
@@ -2,6 +2,7 @@ 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 { pickActiveVersion } from './plugin-versions.js';
5
6
  import { listingTokensFromContent } from './skill-listing.js';
6
7
  import { SKILL_PROMPT_OVERHEAD_TOKENS } from './constants.js';
7
8
  function safeReaddir(p) {
@@ -108,13 +109,21 @@ export function scanPluginSurfaces() {
108
109
  const pluginBaseDir = join(marketplaceDir, pluginName);
109
110
  if (!isDir(pluginBaseDir))
110
111
  continue;
111
- // Each plugin may have version subdirectories
112
- for (const version of safeReaddir(pluginBaseDir)) {
113
- const installDir = join(pluginBaseDir, version);
114
- if (!isDir(installDir))
115
- continue;
116
- const dirStat = safeStat(installDir);
117
- const installedAt = dirStat ? Number(dirStat.mtimeMs) : 0;
112
+ // A plugin may have several version subdirectories, but a session loads
113
+ // exactly one. Emitting a surface per version made every per-plugin cost
114
+ // a multiple of the truth, because computePluginCosts sums across
115
+ // surfaces while computePluginBreakdown picks a single one — the two
116
+ // disagreed, and the summed figure is what reached the user.
117
+ const versions = safeReaddir(pluginBaseDir)
118
+ .map((version) => ({ version, installDir: join(pluginBaseDir, version) }))
119
+ .filter((v) => isDir(v.installDir))
120
+ .map((v) => {
121
+ const dirStat = safeStat(v.installDir);
122
+ return { ...v, installedAt: dirStat ? Number(dirStat.mtimeMs) : 0 };
123
+ });
124
+ const active = pickActiveVersion(versions);
125
+ if (active) {
126
+ const { version, installDir, installedAt } = active;
118
127
  const { names: skills, listingTokens: skillListingTokens } = scanSkills(installDir);
119
128
  const mcpServerKeys = parseMcpServerKeys(installDir);
120
129
  const mcpToolPrefixes = mcpServerKeys.map((key) => `plugin_${pluginName}_${key}`);
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Which cached version of a plugin a session actually loads.
3
+ *
4
+ * `~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/` can hold more than
5
+ * one version directory. `claude plugin update` leaves the old one behind as a
6
+ * symlink to the new one (`4.9.1 -> 4.15.4`), and `stat` follows it, so a naive
7
+ * walk sees two complete installs and counts every skill twice. Only one is ever
8
+ * loaded, so counting both inflated `totalTokensBefore` — the number the whole
9
+ * tool is built to report — by 14.5% on the machine where this was measured.
10
+ */
11
+ export interface VersionedDir {
12
+ /** Directory name, normally a semver string. */
13
+ version: string;
14
+ /** Directory mtime in ms, the same signal plugin-surfaces calls `installedAt`. */
15
+ installedAt: number;
16
+ }
17
+ /**
18
+ * Compare two version directory names, newest last (sort-compatible).
19
+ *
20
+ * Numeric segment by segment, so `4.15.4` beats `4.9.1` — the lexicographic
21
+ * comparison that a plain string sort would do gets that backwards. Non-numeric
22
+ * segments fall back to string order, which keeps prereleases deterministic
23
+ * without pretending to implement full semver precedence.
24
+ */
25
+ export declare function compareVersions(a: string, b: string): number;
26
+ /**
27
+ * Pick the version a session would load, or null when there are none.
28
+ *
29
+ * Newest mtime wins, matching how `computePluginBreakdown` already chooses among
30
+ * surfaces. An update-symlink and its target report the *same* mtime — `stat`
31
+ * resolves the link — so the tie-break carries the real weight here: highest
32
+ * version. Without it the choice would depend on readdir order.
33
+ */
34
+ export declare function pickActiveVersion<T extends VersionedDir>(candidates: T[]): T | null;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Which cached version of a plugin a session actually loads.
3
+ *
4
+ * `~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/` can hold more than
5
+ * one version directory. `claude plugin update` leaves the old one behind as a
6
+ * symlink to the new one (`4.9.1 -> 4.15.4`), and `stat` follows it, so a naive
7
+ * walk sees two complete installs and counts every skill twice. Only one is ever
8
+ * loaded, so counting both inflated `totalTokensBefore` — the number the whole
9
+ * tool is built to report — by 14.5% on the machine where this was measured.
10
+ */
11
+ /**
12
+ * Compare two version directory names, newest last (sort-compatible).
13
+ *
14
+ * Numeric segment by segment, so `4.15.4` beats `4.9.1` — the lexicographic
15
+ * comparison that a plain string sort would do gets that backwards. Non-numeric
16
+ * segments fall back to string order, which keeps prereleases deterministic
17
+ * without pretending to implement full semver precedence.
18
+ */
19
+ export function compareVersions(a, b) {
20
+ const pa = a.split(/[.\-+]/);
21
+ const pb = b.split(/[.\-+]/);
22
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
23
+ const sa = pa[i] ?? '';
24
+ const sb = pb[i] ?? '';
25
+ const na = Number(sa);
26
+ const nb = Number(sb);
27
+ if (Number.isInteger(na) && Number.isInteger(nb) && sa !== '' && sb !== '') {
28
+ if (na !== nb)
29
+ return na - nb;
30
+ }
31
+ else if (sa !== sb) {
32
+ return sa < sb ? -1 : 1;
33
+ }
34
+ }
35
+ return 0;
36
+ }
37
+ /**
38
+ * Pick the version a session would load, or null when there are none.
39
+ *
40
+ * Newest mtime wins, matching how `computePluginBreakdown` already chooses among
41
+ * surfaces. An update-symlink and its target report the *same* mtime — `stat`
42
+ * resolves the link — so the tie-break carries the real weight here: highest
43
+ * version. Without it the choice would depend on readdir order.
44
+ */
45
+ export function pickActiveVersion(candidates) {
46
+ if (candidates.length === 0)
47
+ return null;
48
+ return candidates.reduce((best, c) => {
49
+ if (c.installedAt !== best.installedAt)
50
+ return c.installedAt > best.installedAt ? c : best;
51
+ return compareVersions(c.version, best.version) > 0 ? c : best;
52
+ });
53
+ }
@@ -1,3 +1,16 @@
1
1
  import type { Issue } from './types.js';
2
+ /**
3
+ * Parsed selection input, including whatever could not be understood.
4
+ *
5
+ * Unparseable fragments used to be dropped in silence. On a list that routinely
6
+ * runs to 70+ numbered items, the natural way to pick a span is `1-20` — and
7
+ * `parseInt('1-20')` is `1`, so the user asked for twenty items, got one, and
8
+ * was told nothing. Surfacing `invalid` lets the caller say what it ignored.
9
+ */
10
+ export interface ParsedSelection {
11
+ indices: number[];
12
+ invalid: string[];
13
+ }
14
+ export declare function parseSelection(input: string, count: number): ParsedSelection;
2
15
  export declare function resolveSelection(input: string, issues: Issue[]): Issue[];
3
16
  export declare function resolveRestoreSelection(input: string, count: number): number[];
package/dist/selection.js CHANGED
@@ -1,3 +1,42 @@
1
+ /** Expand a `N` or `N-M` fragment into 1-based indices, or null if malformed. */
2
+ function parseFragment(part, count) {
3
+ const range = /^(\d+)\s*-\s*(\d+)$/.exec(part);
4
+ if (range) {
5
+ const lo = parseInt(range[1], 10);
6
+ const hi = parseInt(range[2], 10);
7
+ if (lo < 1 || hi < 1 || lo > count || hi > count || lo > hi)
8
+ return null;
9
+ return Array.from({ length: hi - lo + 1 }, (_, k) => lo + k);
10
+ }
11
+ if (!/^\d+$/.test(part))
12
+ return null;
13
+ const num = parseInt(part, 10);
14
+ if (num < 1 || num > count)
15
+ return null;
16
+ return [num];
17
+ }
18
+ export function parseSelection(input, count) {
19
+ const indices = [];
20
+ const invalid = [];
21
+ const seen = new Set();
22
+ for (const raw of input.trim().toLowerCase().split(',')) {
23
+ const part = raw.trim();
24
+ if (part === '')
25
+ continue;
26
+ const nums = parseFragment(part, count);
27
+ if (nums === null) {
28
+ invalid.push(part);
29
+ continue;
30
+ }
31
+ for (const num of nums) {
32
+ if (seen.has(num))
33
+ continue;
34
+ seen.add(num);
35
+ indices.push(num);
36
+ }
37
+ }
38
+ return { indices, invalid };
39
+ }
1
40
  export function resolveSelection(input, issues) {
2
41
  const trimmed = input.trim().toLowerCase();
3
42
  if (trimmed === 'none' || trimmed === 'n')
@@ -8,17 +47,7 @@ export function resolveSelection(input, issues) {
8
47
  // Default: tier 1 only
9
48
  return issues.filter((i) => i.tier === 1);
10
49
  }
11
- // Parse comma-separated numbers select exactly those items
12
- const result = [];
13
- const seen = new Set();
14
- for (const part of trimmed.split(',')) {
15
- const num = parseInt(part.trim(), 10);
16
- if (!isNaN(num) && num >= 1 && num <= issues.length && !seen.has(num)) {
17
- seen.add(num);
18
- result.push(issues[num - 1]);
19
- }
20
- }
21
- return result;
50
+ return parseSelection(trimmed, issues.length).indices.map((n) => issues[n - 1]);
22
51
  }
23
52
  export function resolveRestoreSelection(input, count) {
24
53
  const trimmed = input.trim().toLowerCase();
@@ -27,14 +56,6 @@ export function resolveRestoreSelection(input, count) {
27
56
  if (trimmed === 'all' || trimmed === 'a') {
28
57
  return Array.from({ length: count }, (_, i) => i);
29
58
  }
30
- const indices = [];
31
- const seen = new Set();
32
- for (const part of trimmed.split(',')) {
33
- const num = parseInt(part.trim(), 10);
34
- if (!isNaN(num) && num >= 1 && num <= count && !seen.has(num)) {
35
- seen.add(num);
36
- indices.push(num - 1);
37
- }
38
- }
39
- return indices;
59
+ // 0-based, unlike resolveSelection — restore indexes into the manifest array.
60
+ return parseSelection(trimmed, count).indices.map((n) => n - 1);
40
61
  }
package/dist/types.d.ts CHANGED
@@ -81,10 +81,34 @@ export interface ScanResult {
81
81
  userCommands: UserSurfaceEntry[];
82
82
  /** Project slug (cwd with `/` → `-`) whose memory a session here would load. */
83
83
  currentProjectSlug: string;
84
+ /**
85
+ * Whether `~/.claude/projects/<currentProjectSlug>/` exists — i.e. whether
86
+ * Claude Code holds any state for this project.
87
+ *
88
+ * When false, `currentProjectMemoryTokens` is 0 because nothing is stored
89
+ * under this slug. That 0 is arithmetically correct either way; what the flag
90
+ * adds is *why*. A directory Claude has never opened genuinely has no memory,
91
+ * while a slug pointing somewhere the user did not mean — the plugin cache, a
92
+ * git worktree — produces the same 0 for a very different reason. Consumers
93
+ * of `--json` had no way to tell a scanned project from a mis-aimed one.
94
+ */
95
+ currentProjectKnown: boolean;
84
96
  /** Memory tokens actually loaded at startup — current project only. */
85
97
  currentProjectMemoryTokens: number;
86
98
  /** Memory tokens across every project on disk. Not a per-session cost. */
87
99
  allProjectsMemoryTokens: number;
100
+ /**
101
+ * Startup tokens that acting on every issue would actually recover.
102
+ *
103
+ * Deliberately NOT `sum(issues.tokens)`. Two things make that sum wrong:
104
+ * 1. Issues are per-finding, not per-path — one skill can be flagged
105
+ * `duplicate` + `oversized_skill` + `unused_skill` and get counted 3×.
106
+ * 2. `Issue.tokens` is the full SKILL.md body, which is only paid when the
107
+ * skill is invoked. Startup pays `listingTokens` — often ~80× smaller.
108
+ * Summing raw issue tokens on a real machine produced 215,535 "savings"
109
+ * against a 13,434-token startup total. This field is the honest number.
110
+ */
111
+ recoverableStartupTokens: number;
88
112
  }
89
113
  export interface ManifestEntry {
90
114
  date: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-slim",
3
- "version": "2.12.1",
3
+ "version": "2.12.3",
4
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": {
@@ -72,6 +72,24 @@ After getting the scan JSON, YOU must interpret and present results to the user.
72
72
 
73
73
  > **Templates below are shown in English for readability. Always translate headers, labels, and prompts into the user's detected language when rendering.**
74
74
 
75
+ ### 2-0. Two numbers that are not interchangeable
76
+
77
+ The JSON reports token counts in two different units, and mixing them produces
78
+ savings figures larger than the entire thing being saved:
79
+
80
+ - **`listingTokens`** (and `totalTokensBefore`, `recoverableStartupTokens`) —
81
+ what a session pays at startup for the catalog line.
82
+ - **`tokens`** on a skill or an issue — the whole SKILL.md body, paid only when
83
+ that skill is actually invoked.
84
+
85
+ `Issue.tokens` is the second kind. **Never sum it and call the result savings.**
86
+ On a real machine that sum was 215,535 against a 13,434-token startup total —
87
+ 16× the whole budget. Use `recoverableStartupTokens`, which the CLI already
88
+ computes with duplicates collapsed and the right unit.
89
+
90
+ Quote body tokens only when explaining what one skill costs *per invocation*,
91
+ and label them that way.
92
+
75
93
  ### 2-1. Environment Snapshot Table
76
94
 
77
95
  Show a summary table:
@@ -82,7 +100,15 @@ Show a summary table:
82
100
  | Plugins | N (M skills) | ~X tok |
83
101
  | CLAUDE.md | XKB | X tok |
84
102
  | Memory files | N (XKB) | ~X tok |
85
- | **Session startup overhead** | | **~X tok** |
103
+ | **Session startup overhead** | | **~X tok** (`totalTokensBefore`) |
104
+
105
+ **Check `currentProjectKnown` before presenting memory numbers.** When it is
106
+ `false`, Claude Code holds no state for that slug. The `0` is correct, but it
107
+ may be answering the wrong question — the slug can point at a plugin cache or a
108
+ git worktree rather than the project the user meant. Say that no project state
109
+ backs this path and offer `--project-dir <path>`, instead of presenting the 0 as
110
+ a clean result. Do not assert the memory was lost: a directory Claude has never
111
+ opened really does have none.
86
112
 
87
113
  ### 2-2. Plugin Detail Table
88
114
 
@@ -123,7 +149,10 @@ End with a numbered action list, ordered by impact:
123
149
  2. What to consider
124
150
  3. What to leave alone and why
125
151
 
126
- Show estimated total token savings if all recommended actions are taken.
152
+ For total savings, quote **`recoverableStartupTokens`** and state it against
153
+ `totalTokensBefore` ("~2,700 of ~13,400 startup tokens"). Do not add up the
154
+ per-issue numbers — see 2-0. A skill flagged three times is one cleanup, and
155
+ its body size is not a startup cost.
127
156
 
128
157
  If subcommand is `scan`, stop here. Ask a localized equivalent of "Proceed with cleanup?" only for the full pipeline.
129
158