claude-slim 2.12.1 → 2.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -4
- package/dist/cleaner.js +28 -0
- package/dist/cli.js +48 -5
- package/dist/paths.d.ts +13 -0
- package/dist/paths.js +25 -0
- package/dist/report.js +29 -2
- package/dist/scanner/fs-walk.d.ts +3 -0
- package/dist/scanner/fs-walk.js +9 -0
- package/dist/scanner/index.d.ts +24 -1
- package/dist/scanner/index.js +144 -2
- package/dist/scanner/plugin-skills.js +49 -4
- package/dist/scanner/plugin-surfaces.js +16 -7
- package/dist/scanner/plugin-versions.d.ts +34 -0
- package/dist/scanner/plugin-versions.js +53 -0
- package/dist/selection.d.ts +13 -0
- package/dist/selection.js +42 -21
- package/dist/types.d.ts +45 -0
- package/package.json +1 -1
- package/skills/claude-slim/SKILL.md +31 -2
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,15 @@ Token counts come from [js-tiktoken](https://github.com/nicolo-ribaudo/js-tiktok
|
|
|
248
249
|
|
|
249
250
|
---
|
|
250
251
|
|
|
251
|
-
## v2.
|
|
252
|
+
## v2.13.0 — What's new
|
|
252
253
|
|
|
253
|
-
- **
|
|
254
|
-
-
|
|
254
|
+
- **Changed: the startup estimate no longer counts disabled plugins.** Their skills are not in the session catalog, so they are not a startup cost — but they were being added to a number labelled "tokens at session start". Verified against a live session rather than assumed: skills from every disabled plugin were absent from the prompt, while every enabled one's were present. Measured here: **12,504 → 9,836**, a 21% correction.
|
|
255
|
+
- **Added `disabledPluginSkillTokens`**, shown under the overhead line and in `--json` — what re-enabling everything would cost. A headline number that drops by a fifth with no explanation reads like a bug.
|
|
256
|
+
- **Fixed: plugin skills are attributed to their plugin, not their marketplace.** One marketplace can host several plugins with different enabled states, so the two had to be told apart.
|
|
255
257
|
|
|
256
|
-
|
|
258
|
+
Only plugins *explicitly reported disabled* are excluded. `claude plugin list` has a third state — `✘ failed to load` — and a plugin in it still loads its skills, so anything unrecognised keeps counting.
|
|
259
|
+
|
|
260
|
+
Tests: 452 → 461 (+9).
|
|
257
261
|
|
|
258
262
|
For older release notes, see [CHANGELOG.md](CHANGELOG.md).
|
|
259
263
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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,20 @@ 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.disabledPluginSkillTokens > 0) {
|
|
255
|
+
// Stated rather than silently omitted: the figure moved out of the total in
|
|
256
|
+
// 2.13.0, and a total that drops with no explanation reads like a bug.
|
|
257
|
+
lines.push(` \x1b[90mexcludes ~${result.disabledPluginSkillTokens.toLocaleString()} tokens of disabled-plugin skills — ` +
|
|
258
|
+
`not loaded, so not a startup cost\x1b[0m`);
|
|
259
|
+
}
|
|
260
|
+
if (result.issues.length > 0) {
|
|
261
|
+
// Stated next to the total it is a fraction of, because the numbers on the
|
|
262
|
+
// issue rows below are body sizes, not startup cost, and adding them up
|
|
263
|
+
// yields a "saving" many times larger than the whole startup budget.
|
|
264
|
+
lines.push(` \x1b[1mRECOVERABLE\x1b[0m: ~${result.recoverableStartupTokens.toLocaleString()} tokens ` +
|
|
265
|
+
`\x1b[90mif every issue below is acted on (startup cost only — the per-issue\n` +
|
|
266
|
+
` figures below are full file sizes, paid when a skill runs, not at startup)\x1b[0m`);
|
|
267
|
+
}
|
|
241
268
|
if (isUsingFallback()) {
|
|
242
269
|
lines.push(` \x1b[33m\u26a0 Using bytes/4 approximation (js-tiktoken unavailable)\x1b[0m`);
|
|
243
270
|
}
|
|
@@ -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>;
|
package/dist/scanner/fs-walk.js
CHANGED
|
@@ -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);
|
package/dist/scanner/index.d.ts
CHANGED
|
@@ -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;
|
package/dist/scanner/index.js
CHANGED
|
@@ -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';
|
|
@@ -89,7 +90,39 @@ export async function scan(opts = {}) {
|
|
|
89
90
|
// measured from each file's frontmatter description rather than assumed
|
|
90
91
|
// (see scanner/skill-listing.ts) — the real spread is 30–500+ tokens apiece.
|
|
91
92
|
const sumListing = (entries) => entries.reduce((sum, e) => sum + e.listingTokens, 0);
|
|
92
|
-
|
|
93
|
+
// A disabled plugin's skills are not in the session catalog, so they cost
|
|
94
|
+
// nothing at startup. Verified against a live session: skills from every
|
|
95
|
+
// disabled plugin here (document-skills, superpowers, telegram, …) were
|
|
96
|
+
// absent from the prompt, while enabled ones were present. Counting them put
|
|
97
|
+
// 3,397 tokens — 27% of the total — into a number labelled "at session start".
|
|
98
|
+
//
|
|
99
|
+
// Only names reported *explicitly disabled* are dropped. `claude plugin list`
|
|
100
|
+
// also emits `failed to load`, which the parser matches as neither enabled nor
|
|
101
|
+
// disabled: railway reports it (a hook clash) and its twelve skills still
|
|
102
|
+
// load. Treating anything unrecognised as disabled would have silently
|
|
103
|
+
// deleted those from the total, so anything not known-disabled still counts.
|
|
104
|
+
// Keyed on `<plugin>@<marketplace>`, not the bare name: the same plugin name
|
|
105
|
+
// can be installed from two marketplaces in different states, and a name-only
|
|
106
|
+
// set would drop the enabled copy along with the disabled one. `pluginName`
|
|
107
|
+
// is the cache directory, which is the marketplace.
|
|
108
|
+
//
|
|
109
|
+
// An identity enabled anywhere is treated as enabled. `claude plugin list`
|
|
110
|
+
// emits one row per scope, so the same identity legitimately appears twice —
|
|
111
|
+
// and counting a live plugin is the safe error to make, not dropping it.
|
|
112
|
+
const pluginIdentity = (plugin, marketplace) => `${plugin}@${marketplace}`;
|
|
113
|
+
const enabledIdentities = new Set(installed.filter((p) => p.enabled).map((p) => pluginIdentity(p.name, p.marketplace)));
|
|
114
|
+
const disabledIdentities = new Set(installed
|
|
115
|
+
.filter((p) => !p.enabled)
|
|
116
|
+
.map((p) => pluginIdentity(p.name, p.marketplace))
|
|
117
|
+
.filter((id) => !enabledIdentities.has(id)));
|
|
118
|
+
const isLoadedAtStartup = (s) => {
|
|
119
|
+
if (s.plugin === undefined || s.pluginName === undefined)
|
|
120
|
+
return true;
|
|
121
|
+
return !disabledIdentities.has(pluginIdentity(s.plugin, s.pluginName));
|
|
122
|
+
};
|
|
123
|
+
const activePluginSkills = pluginSkills.filter(isLoadedAtStartup);
|
|
124
|
+
const disabledPluginSkillTokens = sumListing(pluginSkills.filter((s) => !isLoadedAtStartup(s)));
|
|
125
|
+
const skillListingTokens = sumListing(localSkills) + sumListing(activePluginSkills);
|
|
93
126
|
const agentListingTokens = sumListing(userSurfaces.agents);
|
|
94
127
|
const commandListingTokens = sumListing(userSurfaces.commands);
|
|
95
128
|
// Memory is per-project: a session loads ~/.claude/projects/<slug>/memory/
|
|
@@ -107,6 +140,15 @@ export async function scan(opts = {}) {
|
|
|
107
140
|
commandListingTokens +
|
|
108
141
|
claudeMdTokens +
|
|
109
142
|
currentProjectMemoryTokens;
|
|
143
|
+
const currentProjectKnown = await pathExists(join(getProjectsDir(), currentProjectSlug));
|
|
144
|
+
// Only the skill-listing slice of a plugin's cost is recoverable startup
|
|
145
|
+
// context — see sumRecoverableStartupTokens. Aggregated by plugin name the
|
|
146
|
+
// same way `pluginCosts` is, so the two stay comparable.
|
|
147
|
+
const pluginSkillListingTokens = new Map();
|
|
148
|
+
for (const c of pluginCostBreakdowns) {
|
|
149
|
+
pluginSkillListingTokens.set(c.pluginName, (pluginSkillListingTokens.get(c.pluginName) ?? 0) + c.skillTokens);
|
|
150
|
+
}
|
|
151
|
+
const recoverableStartupTokens = sumRecoverableStartupTokens(issues, [...localSkills, ...pluginSkills], currentProjectSlug, pluginSkillListingTokens);
|
|
110
152
|
return {
|
|
111
153
|
localSkills,
|
|
112
154
|
pluginSkills,
|
|
@@ -124,7 +166,107 @@ export async function scan(opts = {}) {
|
|
|
124
166
|
userAgents: userSurfaces.agents,
|
|
125
167
|
userCommands: userSurfaces.commands,
|
|
126
168
|
currentProjectSlug,
|
|
169
|
+
currentProjectKnown,
|
|
127
170
|
currentProjectMemoryTokens,
|
|
128
171
|
allProjectsMemoryTokens,
|
|
172
|
+
recoverableStartupTokens,
|
|
173
|
+
disabledPluginSkillTokens,
|
|
129
174
|
};
|
|
130
175
|
}
|
|
176
|
+
async function pathExists(p) {
|
|
177
|
+
try {
|
|
178
|
+
await access(p);
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/** Issue types whose cleanup moves a skill directory out of the listing. */
|
|
186
|
+
const SKILL_MOVE_TYPES = new Set([
|
|
187
|
+
'template', 'duplicate', 'skill_dup', 'oversized_skill', 'unused_skill',
|
|
188
|
+
'backup_artifact',
|
|
189
|
+
]);
|
|
190
|
+
/**
|
|
191
|
+
* What acting on every issue would actually save at session start.
|
|
192
|
+
*
|
|
193
|
+
* Two corrections over a naive `sum(issues.tokens)`, both of which inflate:
|
|
194
|
+
*
|
|
195
|
+
* - **Per-path, not per-issue.** The detectors are independent, so one skill
|
|
196
|
+
* routinely earns several findings at once (`duplicate` + `oversized_skill` +
|
|
197
|
+
* `unused_skill`). Removing it once collects the saving once.
|
|
198
|
+
* - **Listing tokens, not body tokens.** `Issue.tokens` is the whole SKILL.md,
|
|
199
|
+
* which is loaded only when the skill runs. Startup pays for the catalog
|
|
200
|
+
* line alone. Conflating the two overstated savings ~80× in practice.
|
|
201
|
+
*
|
|
202
|
+
* Memory issues count only when they belong to the current project — the same
|
|
203
|
+
* per-project rule `totalTokensBefore` follows. Deletions that free disk but no
|
|
204
|
+
* context (`broken_symlink`, `temp_cache`) contribute nothing here by design.
|
|
205
|
+
*
|
|
206
|
+
* Three separate overlaps have to be collapsed, since every one of them inflates:
|
|
207
|
+
* the same skill path, the same plugin across cached versions, and a memory file
|
|
208
|
+
* that its own stale project already accounts for.
|
|
209
|
+
*/
|
|
210
|
+
export function sumRecoverableStartupTokens(issues, skills, currentProjectSlug,
|
|
211
|
+
/** Plugin name → its skill-listing tokens. See the `unused_plugin` branch. */
|
|
212
|
+
pluginSkillListingTokens = new Map()) {
|
|
213
|
+
const listingByPath = new Map(skills.map((s) => [s.path, s.listingTokens]));
|
|
214
|
+
const countedPaths = new Set();
|
|
215
|
+
const countedPlugins = new Set();
|
|
216
|
+
let total = 0;
|
|
217
|
+
// `stale_project` names the slug alone; `oversized_memory` names
|
|
218
|
+
// `<slug>/<file>`. Match both without letting a sibling slug through — plain
|
|
219
|
+
// startsWith would count `-Users-me-app2` as part of `-Users-me-app`.
|
|
220
|
+
const isCurrentProject = (name) => name === currentProjectSlug || name.startsWith(currentProjectSlug + '/');
|
|
221
|
+
// Stale projects first: `stale_project.tokens` is the sum of every memory file
|
|
222
|
+
// in that project, so counting it settles the per-file findings inside it too.
|
|
223
|
+
// Charging both billed an oversized file twice and could claim more than the
|
|
224
|
+
// project's entire memory.
|
|
225
|
+
let currentProjectIsStale = false;
|
|
226
|
+
for (const issue of issues) {
|
|
227
|
+
if (issue.type !== 'stale_project' || !isCurrentProject(issue.name))
|
|
228
|
+
continue;
|
|
229
|
+
if (currentProjectIsStale)
|
|
230
|
+
continue;
|
|
231
|
+
currentProjectIsStale = true;
|
|
232
|
+
total += issue.tokens;
|
|
233
|
+
}
|
|
234
|
+
for (const issue of issues) {
|
|
235
|
+
if (SKILL_MOVE_TYPES.has(issue.type)) {
|
|
236
|
+
if (countedPaths.has(issue.path))
|
|
237
|
+
continue;
|
|
238
|
+
countedPaths.add(issue.path);
|
|
239
|
+
// A skill missing from the listing map costs nothing at startup.
|
|
240
|
+
total += listingByPath.get(issue.path) ?? 0;
|
|
241
|
+
}
|
|
242
|
+
else if (issue.type === 'unused_plugin') {
|
|
243
|
+
// Deliberately NOT `issue.tokens`. That is the plugin's full estimated
|
|
244
|
+
// cost from computePluginCosts — CLAUDE.md section + skills + MCP tools +
|
|
245
|
+
// commands — and two of those parts do not belong in a recovery figure
|
|
246
|
+
// presented against `totalTokensBefore`:
|
|
247
|
+
// - the matched CLAUDE.md section is in the baseline but survives the
|
|
248
|
+
// cleanup, since disabling a plugin does not edit the user's
|
|
249
|
+
// CLAUDE.md (and this tool never modifies it at all);
|
|
250
|
+
// - the MCP-tool and command estimates are genuinely freed, but are not
|
|
251
|
+
// in the baseline, so counting them measures against a total that
|
|
252
|
+
// never included them.
|
|
253
|
+
// The skill listings are the one component that is both. Deduped by name
|
|
254
|
+
// because the surface scan walks version directories, so a plugin with
|
|
255
|
+
// two cached versions raises two findings.
|
|
256
|
+
if (countedPlugins.has(issue.name))
|
|
257
|
+
continue;
|
|
258
|
+
countedPlugins.add(issue.name);
|
|
259
|
+
total += pluginSkillListingTokens.get(issue.name) ?? 0;
|
|
260
|
+
}
|
|
261
|
+
else if (issue.type === 'oversized_memory') {
|
|
262
|
+
// Another project's memory never loads here, so trimming it saves this
|
|
263
|
+
// session nothing.
|
|
264
|
+
if (!isCurrentProject(issue.name))
|
|
265
|
+
continue;
|
|
266
|
+
if (currentProjectIsStale)
|
|
267
|
+
continue; // already inside the stale-project total
|
|
268
|
+
total += issue.tokens;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return total;
|
|
272
|
+
}
|
|
@@ -1,8 +1,46 @@
|
|
|
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
|
+
async function resolveContentRoots(pluginBaseDir) {
|
|
8
|
+
const entries = await safeReaddir(pluginBaseDir);
|
|
9
|
+
// Flat layout: the cache entry holds content directly, with no plugin or
|
|
10
|
+
// version level (`<cache-entry>/skills/<skill>/`). Without this check the
|
|
11
|
+
// loop below reads `skills` as a plugin directory and each skill inside it as
|
|
12
|
+
// a candidate version, then picks exactly one — which is not a miscount but a
|
|
13
|
+
// silent disappearance: the whole entry reported zero skills.
|
|
14
|
+
if (entries.includes('skills'))
|
|
15
|
+
return [{ dir: pluginBaseDir }];
|
|
16
|
+
const roots = [];
|
|
17
|
+
for (const entry of entries) {
|
|
18
|
+
const pluginDir = join(pluginBaseDir, entry);
|
|
19
|
+
if (!(await isDirectory(pluginDir)))
|
|
20
|
+
continue;
|
|
21
|
+
const children = await safeReaddir(pluginDir);
|
|
22
|
+
if (children.includes('skills')) {
|
|
23
|
+
// Content root, not a version container.
|
|
24
|
+
roots.push({ dir: pluginDir, plugin: entry });
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const versions = [];
|
|
28
|
+
for (const child of children) {
|
|
29
|
+
const versionDir = join(pluginDir, child);
|
|
30
|
+
if (!(await isDirectory(versionDir)))
|
|
31
|
+
continue;
|
|
32
|
+
const stats = await safeStat(versionDir);
|
|
33
|
+
versions.push({ version: child, installedAt: stats?.mtimeMs ?? 0, dir: versionDir });
|
|
34
|
+
}
|
|
35
|
+
const active = pickActiveVersion(versions);
|
|
36
|
+
// No subdirectories at all: hand back the plugin dir so the walk behaves
|
|
37
|
+
// exactly as it did before rather than silently dropping the plugin.
|
|
38
|
+
roots.push({ dir: active ? active.dir : pluginDir, plugin: entry });
|
|
39
|
+
}
|
|
40
|
+
// A marketplace with no plugin subdirectories still needs walking — some
|
|
41
|
+
// caches put content directly under the top level.
|
|
42
|
+
return roots.length > 0 ? roots : [{ dir: pluginBaseDir }];
|
|
43
|
+
}
|
|
6
44
|
export async function scanPluginSkills() {
|
|
7
45
|
const skills = [];
|
|
8
46
|
const plugins = [];
|
|
@@ -20,7 +58,11 @@ export async function scanPluginSkills() {
|
|
|
20
58
|
return;
|
|
21
59
|
}
|
|
22
60
|
const pluginSkillNames = [];
|
|
23
|
-
const walkDir = async (dir) => {
|
|
61
|
+
const walkDir = async (dir, plugin) => {
|
|
62
|
+
// Kept generic: a plugin's content root is normally
|
|
63
|
+
// `<plugin>/<version>/`, but the walk also has to reach skills nested
|
|
64
|
+
// deeper. Version selection happens before we get here — see
|
|
65
|
+
// resolveContentRoots below — so this only ever descends one install.
|
|
24
66
|
const entries = await safeReaddir(dir);
|
|
25
67
|
for (const entry of entries) {
|
|
26
68
|
const entryPath = join(dir, entry);
|
|
@@ -44,16 +86,19 @@ export async function scanPluginSkills() {
|
|
|
44
86
|
listingTokens: listingTokensFromContent(skillDir, content),
|
|
45
87
|
source: 'plugin',
|
|
46
88
|
pluginName,
|
|
89
|
+
plugin,
|
|
47
90
|
});
|
|
48
91
|
}
|
|
49
92
|
}
|
|
50
93
|
}
|
|
51
94
|
else {
|
|
52
|
-
await walkDir(entryPath);
|
|
95
|
+
await walkDir(entryPath, plugin);
|
|
53
96
|
}
|
|
54
97
|
}
|
|
55
98
|
};
|
|
56
|
-
await
|
|
99
|
+
for (const root of await resolveContentRoots(pluginDir)) {
|
|
100
|
+
await walkDir(root.dir, root.plugin);
|
|
101
|
+
}
|
|
57
102
|
if (pluginSkillNames.length > 0) {
|
|
58
103
|
plugins.push({
|
|
59
104
|
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
|
-
//
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
+
}
|
package/dist/selection.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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
|
@@ -14,7 +14,20 @@ export interface SkillInfo {
|
|
|
14
14
|
*/
|
|
15
15
|
listingTokens: number;
|
|
16
16
|
source: 'local' | 'plugin';
|
|
17
|
+
/**
|
|
18
|
+
* Cache directory this skill was found under — the *marketplace*, not the
|
|
19
|
+
* plugin. Kept as-is because `plugins[]` and the `disabled_plugin` detector
|
|
20
|
+
* match cache directories by this name (see parseDisabledPlugins).
|
|
21
|
+
*/
|
|
17
22
|
pluginName?: string;
|
|
23
|
+
/**
|
|
24
|
+
* The actual plugin, e.g. `oh-my-claudecode` where `pluginName` is `omc`.
|
|
25
|
+
*
|
|
26
|
+
* Needed because enabled/disabled state is reported per plugin, not per
|
|
27
|
+
* marketplace, and one marketplace can host both. Absent when the cache entry
|
|
28
|
+
* has no plugin level to read it from.
|
|
29
|
+
*/
|
|
30
|
+
plugin?: string;
|
|
18
31
|
}
|
|
19
32
|
export interface BrokenSymlink {
|
|
20
33
|
name: string;
|
|
@@ -81,10 +94,42 @@ export interface ScanResult {
|
|
|
81
94
|
userCommands: UserSurfaceEntry[];
|
|
82
95
|
/** Project slug (cwd with `/` → `-`) whose memory a session here would load. */
|
|
83
96
|
currentProjectSlug: string;
|
|
97
|
+
/**
|
|
98
|
+
* Whether `~/.claude/projects/<currentProjectSlug>/` exists — i.e. whether
|
|
99
|
+
* Claude Code holds any state for this project.
|
|
100
|
+
*
|
|
101
|
+
* When false, `currentProjectMemoryTokens` is 0 because nothing is stored
|
|
102
|
+
* under this slug. That 0 is arithmetically correct either way; what the flag
|
|
103
|
+
* adds is *why*. A directory Claude has never opened genuinely has no memory,
|
|
104
|
+
* while a slug pointing somewhere the user did not mean — the plugin cache, a
|
|
105
|
+
* git worktree — produces the same 0 for a very different reason. Consumers
|
|
106
|
+
* of `--json` had no way to tell a scanned project from a mis-aimed one.
|
|
107
|
+
*/
|
|
108
|
+
currentProjectKnown: boolean;
|
|
84
109
|
/** Memory tokens actually loaded at startup — current project only. */
|
|
85
110
|
currentProjectMemoryTokens: number;
|
|
86
111
|
/** Memory tokens across every project on disk. Not a per-session cost. */
|
|
87
112
|
allProjectsMemoryTokens: number;
|
|
113
|
+
/**
|
|
114
|
+
* Startup tokens that acting on every issue would actually recover.
|
|
115
|
+
*
|
|
116
|
+
* Deliberately NOT `sum(issues.tokens)`. Two things make that sum wrong:
|
|
117
|
+
* 1. Issues are per-finding, not per-path — one skill can be flagged
|
|
118
|
+
* `duplicate` + `oversized_skill` + `unused_skill` and get counted 3×.
|
|
119
|
+
* 2. `Issue.tokens` is the full SKILL.md body, which is only paid when the
|
|
120
|
+
* skill is invoked. Startup pays `listingTokens` — often ~80× smaller.
|
|
121
|
+
* Summing raw issue tokens on a real machine produced 215,535 "savings"
|
|
122
|
+
* against a 13,434-token startup total. This field is the honest number.
|
|
123
|
+
*/
|
|
124
|
+
recoverableStartupTokens: number;
|
|
125
|
+
/**
|
|
126
|
+
* Skill-listing tokens belonging to plugins reported as disabled.
|
|
127
|
+
*
|
|
128
|
+
* Excluded from `totalTokensBefore` because a disabled plugin's skills are
|
|
129
|
+
* not in the session catalog. Reported separately so the number does not just
|
|
130
|
+
* vanish: it is what re-enabling everything would cost.
|
|
131
|
+
*/
|
|
132
|
+
disabledPluginSkillTokens: number;
|
|
88
133
|
}
|
|
89
134
|
export interface ManifestEntry {
|
|
90
135
|
date: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-slim",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.13.0",
|
|
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
|
-
|
|
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
|
|