claude-slim 2.12.0 → 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.0 — What's new
252
+ ## v2.12.3 — What's new
252
253
 
253
- - **`claude-slim update`** runs the upgrade command for however this copy was installed, after showing it and asking. `--dry-run` to preview, `--yes` to skip the prompt. Plugin installs get the marketplace refresh first, then the qualified `claude-slim@claude-slim` id, and are reminded that a restart is needed. npx and source checkouts run nothing and say why npx already resolves the latest every invocation, and pulling your own repository is not claude-slim's to do.
254
- - **This corrects v2.9.0's reasoning.** That release stopped at detection, arguing updating belonged to the package manager. Half held: claude-slim must not write into a directory `claude plugin` owns. The other half did not invoking the package manager is something this tool already does during cleanup (`claude plugin disable`), so refusing to here was inconsistent. It still writes nothing itself.
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: 374392 (+18), pinning the safety properties — every argv a fixed literal, no shell metacharacters, executable only ever `claude` or `npm`, and a refusal to run unattended with no TTY and no `--yes`.
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,12 +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, projectDirError } from './paths.js';
13
14
  import { checkForUpdate, formatUpdateNotice } from './update-check.js';
14
15
  import { confirmDecision, planUpdate, renderStep, runUpdate } from './update-run.js';
15
16
  import { scanCodex } from './codex/index.js';
16
17
  import { formatCodexSummary } from './codex/report.js';
17
18
  import { classifyCodexIssues } from './codex/detectors.js';
18
- import { resolveSelection, resolveRestoreSelection } from './selection.js';
19
+ import { resolveSelection, resolveRestoreSelection, parseSelection } from './selection.js';
19
20
  const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
20
21
  const { version: PKG_VERSION } = JSON.parse(readFileSync(pkgPath, 'utf-8'));
21
22
  // Parse a non-negative-integer CLI option, keeping explicit 0 distinct from an
@@ -40,10 +41,15 @@ program
40
41
  .description('Scan environment and report issues')
41
42
  .option('--json', 'Output raw JSON')
42
43
  .option('--lookback-days <n>', 'Days of session history for skill-usage analysis', '60')
44
+ .option('--project-dir <path>', 'Directory whose project memory counts toward the total (default: cwd)')
43
45
  .option('--no-codex', 'Skip the ~/.codex scan even if Codex is installed')
44
46
  .action(async (opts) => {
45
47
  await initTokenizer();
46
- const result = await scan({ lookbackDays: parseNonNegativeInt(opts.lookbackDays, 60) });
48
+ const projectDir = resolveProjectDir(opts.projectDir);
49
+ const result = await scan({
50
+ lookbackDays: parseNonNegativeInt(opts.lookbackDays, 60),
51
+ projectDir,
52
+ });
47
53
  // Codex is scanned when present. Reported only — it is never modified, and
48
54
  // unused-skill detection is suppressed there for lack of a usage signal.
49
55
  // commander maps `--no-codex` to `opts.codex === false`, not `opts.noCodex`.
@@ -185,6 +191,7 @@ program
185
191
  .option('--auto', 'Non-interactive: auto-select Tier 1 items only')
186
192
  .option('--sessions-per-day <n>', 'Sessions per day for savings estimate', '2')
187
193
  .option('--lookback-days <n>', 'Days of session history for skill-usage analysis', '60')
194
+ .option('--project-dir <path>', 'Directory whose project memory counts toward the total (default: cwd)')
188
195
  .option('--no-codex', 'Skip ~/.codex entirely')
189
196
  .action(async (opts) => {
190
197
  await runCleanPipeline({
@@ -193,6 +200,7 @@ program
193
200
  sessionsPerDay: parseNonNegativeInt(opts.sessionsPerDay, 2),
194
201
  lookbackDays: parseNonNegativeInt(opts.lookbackDays, 60),
195
202
  codex: opts.codex !== false,
203
+ projectDir: opts.projectDir,
196
204
  });
197
205
  });
198
206
  // --- restore ---
@@ -246,7 +254,8 @@ program
246
254
  return;
247
255
  }
248
256
  console.log('');
249
- const selection = await askUser(' Restore (all / numbers / none): ');
257
+ const selection = await askUser(' Restore (all / numbers / ranges / none): ');
258
+ warnIgnoredSelection(selection, restorable.length);
250
259
  const indices = resolveRestoreSelection(selection, restorable.length);
251
260
  if (indices.length === 0) {
252
261
  console.log('\n Cancelled.\n');
@@ -276,9 +285,17 @@ program
276
285
  .description('Show savings report from last clean')
277
286
  .option('--sessions-per-day <n>', 'Sessions per day for savings estimate', '2')
278
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)')
279
289
  .action(async (opts) => {
280
290
  await initTokenizer();
281
- 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
+ });
282
299
  const allEntries = await readManifest();
283
300
  // Filter to legacy-style entries only (those with tokenCount/name/from fields)
284
301
  const entries = allEntries.filter((e) => !('plugin' in e && 'marketplace' in e));
@@ -350,10 +367,60 @@ program.action(async () => {
350
367
  }
351
368
  await runCleanPipeline({ dryRun: false, auto: false, sessionsPerDay: 2, lookbackDays: 60, codex: true });
352
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
+ }
388
+ /**
389
+ * Resolve which directory's project memory counts toward the startup estimate.
390
+ *
391
+ * Warns when the CLI is running from its own install directory and no explicit
392
+ * directory was given: the slug would resolve to the plugin cache, match no
393
+ * project, and silently zero out every project-memory token. Warning beats
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.
401
+ */
402
+ function resolveProjectDir(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
+ }
411
+ return explicit;
412
+ }
413
+ if (looksLikeToolInstallDir()) {
414
+ console.error(' \x1b[33m!\x1b[0m Running from claude-slim\'s own install directory, so project memory\n' +
415
+ ' cannot be attributed and is reported as 0. Pass --project-dir <path>\n' +
416
+ ' (or run from your project) for an accurate startup total.\n');
417
+ }
418
+ return undefined;
419
+ }
353
420
  // --- shared clean pipeline ---
354
421
  async function runCleanPipeline(opts) {
355
422
  await initTokenizer();
356
- const result = await scan({ lookbackDays: opts.lookbackDays });
423
+ const result = await scan({ lookbackDays: opts.lookbackDays, projectDir: resolveProjectDir(opts.projectDir) });
357
424
  // Codex issues join the same tiered list. They carry `agent: 'codex'`, which
358
425
  // is what keeps the cleaner's path guard pointed at ~/.codex/.
359
426
  const codexContents = new Map();
@@ -374,10 +441,12 @@ async function runCleanPipeline(opts) {
374
441
  console.log(' Actions:');
375
442
  console.log(' Enter → accept pre-selected (Tier 1 only)');
376
443
  console.log(' 1,3,5 → select specific items');
444
+ console.log(' 1-9 → select a range');
377
445
  console.log(' all → select everything');
378
446
  console.log(' none → cancel');
379
447
  console.log('');
380
448
  const selection = await askUser(' Your choice: ');
449
+ warnIgnoredSelection(selection, result.issues.length);
381
450
  selectedIssues = resolveSelection(selection, result.issues);
382
451
  }
383
452
  else if (opts.auto) {
package/dist/paths.d.ts CHANGED
@@ -12,6 +12,30 @@ export declare function getDisabledDir(): string;
12
12
  * the startup estimate must not sum memory across every project on disk.
13
13
  */
14
14
  export declare function getCurrentProjectSlug(cwd?: string): string;
15
+ /**
16
+ * True when `cwd` sits inside claude-slim's own install rather than a project.
17
+ *
18
+ * The `/claude-slim` skill invokes the CLI with `cd "${CLAUDE_PLUGIN_ROOT}"`,
19
+ * which makes `process.cwd()` the plugin cache directory. The project slug then
20
+ * resolves to that path, no memory matches it, and the startup estimate silently
21
+ * drops every project-memory token — 108,570 of them on the machine where this
22
+ * was found. Detecting it lets the caller fail loudly or be told to pass
23
+ * `--project-dir` instead of quietly reporting zero.
24
+ */
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;
15
39
  export declare function getManifestPath(): string;
16
40
  export declare function getLegacyManifestPath(): string;
17
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');
@@ -26,6 +27,46 @@ export function getDisabledDir() {
26
27
  export function getCurrentProjectSlug(cwd = process.cwd()) {
27
28
  return resolve(cwd).replace(/\//g, '-');
28
29
  }
30
+ /**
31
+ * True when `cwd` sits inside claude-slim's own install rather than a project.
32
+ *
33
+ * The `/claude-slim` skill invokes the CLI with `cd "${CLAUDE_PLUGIN_ROOT}"`,
34
+ * which makes `process.cwd()` the plugin cache directory. The project slug then
35
+ * resolves to that path, no memory matches it, and the startup estimate silently
36
+ * drops every project-memory token — 108,570 of them on the machine where this
37
+ * was found. Detecting it lets the caller fail loudly or be told to pass
38
+ * `--project-dir` instead of quietly reporting zero.
39
+ */
40
+ export function looksLikeToolInstallDir(cwd = process.cwd()) {
41
+ const p = resolve(cwd).replace(/\\/g, '/');
42
+ return (p.includes('/.claude/plugins/') ||
43
+ p.includes('/_npx/') ||
44
+ /\/node_modules\/claude-slim(\/|$)/.test(p));
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
+ }
29
70
  export function getManifestPath() {
30
71
  return join(getDisabledDir(), 'manifest.json');
31
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,5 +1,37 @@
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
+ /**
5
+ * Directory whose project memory counts toward the startup estimate.
6
+ *
7
+ * Defaults to `process.cwd()`, which is wrong whenever the CLI is launched
8
+ * from its own install directory — the `/claude-slim` skill does exactly that
9
+ * via `cd "${CLAUDE_PLUGIN_ROOT}"`, and every project-memory token silently
10
+ * dropped out of the total as a result.
11
+ */
12
+ projectDir?: string;
4
13
  }
5
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';
@@ -97,7 +98,7 @@ export async function scan(opts = {}) {
97
98
  // disk. Summing all of them (pre-2.8 behaviour) inflated the startup estimate
98
99
  // by a factor of however many projects the user had — 100k+ tokens on a busy
99
100
  // machine, for a number labelled "tokens at session start".
100
- const currentProjectSlug = getCurrentProjectSlug();
101
+ const currentProjectSlug = getCurrentProjectSlug(opts.projectDir);
101
102
  const currentProjectMemoryTokens = memoryFiles
102
103
  .filter((m) => m.project === currentProjectSlug)
103
104
  .reduce((sum, m) => sum + m.tokens, 0);
@@ -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.0",
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": {
@@ -23,7 +23,7 @@ Analyze the user's Claude Code environment for token waste and perform non-destr
23
23
  An outdated claude-slim does not merely lack features — it reports **wrong numbers**. Versions before 2.8.0 summed memory across every project on disk and inflated the startup estimate roughly 8×. Presenting those figures as fact is worse than not running at all, so check first:
24
24
 
25
25
  ```bash
26
- cd "${CLAUDE_PLUGIN_ROOT}" && node dist/cli.js check-update --json
26
+ node "${CLAUDE_PLUGIN_ROOT}/dist/cli.js" check-update --json
27
27
  ```
28
28
 
29
29
  The check is cached for 24h and fails open — if it errors, times out, or returns `"latest": null`, **proceed silently**. Never block the user because a version lookup failed.
@@ -50,13 +50,13 @@ If `"outdated": false`, say nothing and continue to Phase 1.
50
50
  Run the CLI to collect environment data:
51
51
 
52
52
  ```bash
53
- cd "${CLAUDE_PLUGIN_ROOT}" && node dist/cli.js scan --json
53
+ node "${CLAUDE_PLUGIN_ROOT}/dist/cli.js" scan --json
54
54
  ```
55
55
 
56
56
  If `CLAUDE_PLUGIN_ROOT` is not set:
57
57
  ```bash
58
58
  PLUGIN_DIR=$(find ~/.claude/plugins -path "*/claude-slim/dist/cli.js" -type f 2>/dev/null | head -1 | xargs dirname | xargs dirname)
59
- cd "$PLUGIN_DIR" && node dist/cli.js scan --json
59
+ node "$PLUGIN_DIR/dist/cli.js" scan --json
60
60
  ```
61
61
 
62
62
  If `node` is not available, fall back to the legacy bash scanner:
@@ -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
 
@@ -134,18 +163,18 @@ If subcommand is `scan`, stop here. Ask a localized equivalent of "Proceed with
134
163
  Run the interactive clean command:
135
164
 
136
165
  ```bash
137
- cd "${CLAUDE_PLUGIN_ROOT}" && node dist/cli.js clean
166
+ node "${CLAUDE_PLUGIN_ROOT}/dist/cli.js" clean
138
167
  ```
139
168
 
140
169
  Or with dry-run:
141
170
  ```bash
142
- cd "${CLAUDE_PLUGIN_ROOT}" && node dist/cli.js clean --dry-run
171
+ node "${CLAUDE_PLUGIN_ROOT}/dist/cli.js" clean --dry-run
143
172
  ```
144
173
 
145
174
  After cleanup, re-run scan to get updated numbers, then show the savings report:
146
175
 
147
176
  ```bash
148
- cd "${CLAUDE_PLUGIN_ROOT}" && node dist/cli.js report
177
+ node "${CLAUDE_PLUGIN_ROOT}/dist/cli.js" report
149
178
  ```
150
179
 
151
180
  Present the report box AND the before/after breakdown table to the user.
@@ -157,7 +186,7 @@ Present the report box AND the before/after breakdown table to the user.
157
186
  When `/claude-slim restore` is invoked:
158
187
 
159
188
  ```bash
160
- cd "${CLAUDE_PLUGIN_ROOT}" && node dist/cli.js restore
189
+ node "${CLAUDE_PLUGIN_ROOT}/dist/cli.js" restore
161
190
  ```
162
191
 
163
192
  ## Doctor
@@ -165,7 +194,7 @@ cd "${CLAUDE_PLUGIN_ROOT}" && node dist/cli.js restore
165
194
  When `/claude-slim doctor` is invoked:
166
195
 
167
196
  ```bash
168
- cd "${CLAUDE_PLUGIN_ROOT}" && node dist/cli.js doctor
197
+ node "${CLAUDE_PLUGIN_ROOT}/dist/cli.js" doctor
169
198
  ```
170
199
 
171
200
  Explain warnings in the user's language. Pay special attention to session-log warnings because they explain why unused-skill detection may be suppressed.