claude-slim 2.7.0 → 2.7.2
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 +14 -0
- package/dist/cleaner.d.ts +6 -0
- package/dist/cleaner.js +46 -4
- package/dist/cli.js +41 -15
- package/dist/plugin-runtime.d.ts +15 -0
- package/dist/plugin-runtime.js +41 -0
- package/dist/scanner/detectors.d.ts +1 -0
- package/dist/scanner/detectors.js +7 -5
- package/dist/scanner/index.js +15 -0
- package/dist/scanner/local-skills.js +33 -39
- package/dist/scanner/sessions.js +5 -4
- package/package.json +12 -4
package/README.md
CHANGED
|
@@ -211,6 +211,20 @@ From a real cleanup session:
|
|
|
211
211
|
|
|
212
212
|
---
|
|
213
213
|
|
|
214
|
+
## v2.7.1 — What's new
|
|
215
|
+
|
|
216
|
+
Correctness patch cleaning up seven bugs found in post-2.7 review. No new features; no breaking changes.
|
|
217
|
+
|
|
218
|
+
- **`unused_plugin` savings always showed 0.** The detector emitted `tokens: 0` for every flagged plugin, so both the dry-run summary and the final report box under-counted savings for what is typically the largest cleanup target. Cost is now threaded through `DetectorContext` and the detector uses the real per-plugin value.
|
|
219
|
+
- **`duplicate` detector could disable namespaced local skills.** A `baseName` fallback flagged `org/ship` as a duplicate of a bare plugin `ship`, even though namespaced local skills are addressable independently. Only exact-name matches are flagged now.
|
|
220
|
+
- **`stale_project` restore was scoped to all of `~/.claude/`.** A tampered manifest could redirect a project-memory backup into `~/.claude/skills/` and clobber an unrelated asset. Restores are now type-scoped — `stale_project` targets must live under `~/.claude/projects/`, skill restores under `~/.claude/skills/`.
|
|
221
|
+
- **Non-interactive `claude-slim clean` refuses to auto-apply.** Prior behavior silently auto-selected Tier 1 in non-TTY without `--auto`/`--dry-run`, surprising users running from scripts. It now prints a warning and exits with status 1; opt in explicitly with `--auto` or `--dry-run`.
|
|
222
|
+
- **`--lookback-days 0` / `--sessions-per-day 0` are respected.** `parseInt(x, 10) || N` was silently upgrading explicit `0` to the default. Replaced with a `parseNonNegativeInt` helper.
|
|
223
|
+
- **`claude-slim report` recognizes zero-token cleanups.** Runs that only removed `broken_symlink` or `temp_cache` entries were being reported as "no previous cleanup"; every manifest entry now counts.
|
|
224
|
+
- **Session parser regex hardened.** `extractCommandsFromTranscript` uses `String.matchAll` instead of a manual `lastIndex`-resetting loop — one fewer footgun for future refactors.
|
|
225
|
+
|
|
226
|
+
Tests: 188 → 190 (+2 regression cases for the token-propagation fix).
|
|
227
|
+
|
|
214
228
|
## v2.7 — What's new
|
|
215
229
|
|
|
216
230
|
- **Unused-plugin detection** — claude-slim now reads your session transcripts for MCP tool calls (`mcp__plugin_<plugin>_<server>__*`) and slash commands, and flags plugins whose surfaces you've never touched in the last 60 days. Tier 3 (Optional, never auto-selected). When you choose to clean one, `claude plugin disable <name>` runs automatically; `/claude-slim restore` re-enables it.
|
package/dist/cleaner.d.ts
CHANGED
|
@@ -6,6 +6,12 @@ export interface CleanResult {
|
|
|
6
6
|
name: string;
|
|
7
7
|
error: string;
|
|
8
8
|
}>;
|
|
9
|
+
/**
|
|
10
|
+
* Populated when one or more `unused_plugin` items were requested but the
|
|
11
|
+
* `claude` CLI is missing from PATH. The CLI surfaces this once at the top of
|
|
12
|
+
* the error block instead of N repeat rows.
|
|
13
|
+
*/
|
|
14
|
+
claudeCliMissing?: boolean;
|
|
9
15
|
}
|
|
10
16
|
export declare function cleanIssues(issues: Issue[]): Promise<CleanResult>;
|
|
11
17
|
export declare function restoreItem(entry: ManifestEntry | DisabledPluginEntry): Promise<void>;
|
package/dist/cleaner.js
CHANGED
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
import { rename, readdir, rmdir, rm, unlink, lstat, mkdir } from 'node:fs/promises';
|
|
2
|
-
import { join, dirname } from 'node:path';
|
|
2
|
+
import { join, dirname, resolve, sep } from 'node:path';
|
|
3
3
|
import { appendManifest, ensureDisabledDir, getDisabledDir, removeEntry, recordDisabledPlugin, removeDisabledPlugin } from './manifest.js';
|
|
4
|
-
import { assertInsideClaudeDir, getSkillsDir } from './paths.js';
|
|
5
|
-
import { disablePlugin, enablePlugin } from './plugin-runtime.js';
|
|
4
|
+
import { assertInsideClaudeDir, getSkillsDir, getProjectsDir } from './paths.js';
|
|
5
|
+
import { disablePlugin, enablePlugin, isClaudeCliAvailable, ClaudeCliMissingError } from './plugin-runtime.js';
|
|
6
|
+
// Restrict a restore target to a specific subtree of ~/.claude/. Complements
|
|
7
|
+
// assertInsideClaudeDir: a tampered manifest could still name a legal
|
|
8
|
+
// ~/.claude/ path that belongs to a different type of asset (e.g. redirect a
|
|
9
|
+
// stale-project restore into ~/.claude/skills/ to clobber a skill). By pinning
|
|
10
|
+
// each restore type to its own subtree we close that gap.
|
|
11
|
+
function assertInsideSubtree(p, subtreeRoot, label) {
|
|
12
|
+
const resolvedTarget = resolve(p);
|
|
13
|
+
const resolvedRoot = resolve(subtreeRoot);
|
|
14
|
+
if (resolvedTarget !== resolvedRoot &&
|
|
15
|
+
!resolvedTarget.startsWith(resolvedRoot + sep)) {
|
|
16
|
+
throw new Error(`Refusing to restore ${label} outside ${subtreeRoot}: ${p}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
6
19
|
async function pathExists(p) {
|
|
7
20
|
try {
|
|
8
21
|
await lstat(p);
|
|
@@ -32,6 +45,14 @@ export async function cleanIssues(issues) {
|
|
|
32
45
|
const moved = [];
|
|
33
46
|
const skipped = [];
|
|
34
47
|
const errors = [];
|
|
48
|
+
// Pre-check: if any unused_plugin items are selected, verify `claude` CLI is
|
|
49
|
+
// reachable before we start. Failing fast with a single friendly message
|
|
50
|
+
// beats N raw ENOENTs mid-run. Skip the probe if no plugin items were picked.
|
|
51
|
+
const wantsPluginOps = issues.some((i) => i.type === 'unused_plugin');
|
|
52
|
+
let claudeCliMissing = false;
|
|
53
|
+
if (wantsPluginOps && !(await isClaudeCliAvailable())) {
|
|
54
|
+
claudeCliMissing = true;
|
|
55
|
+
}
|
|
35
56
|
for (const issue of issues) {
|
|
36
57
|
try {
|
|
37
58
|
// unused_plugin and report-only types don't touch filesystem paths directly
|
|
@@ -119,6 +140,12 @@ export async function cleanIssues(issues) {
|
|
|
119
140
|
moved.push(entry);
|
|
120
141
|
}
|
|
121
142
|
else if (issue.type === 'unused_plugin') {
|
|
143
|
+
// Pre-check flagged `claude` missing — skip silently; the CLI prints
|
|
144
|
+
// one grouped message after cleanIssues returns.
|
|
145
|
+
if (claudeCliMissing) {
|
|
146
|
+
skipped.push(issue.name);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
122
149
|
await disablePlugin(issue.name);
|
|
123
150
|
// Record in manifest; if that fails, roll back the disable
|
|
124
151
|
try {
|
|
@@ -137,13 +164,21 @@ export async function cleanIssues(issues) {
|
|
|
137
164
|
}
|
|
138
165
|
}
|
|
139
166
|
catch (err) {
|
|
167
|
+
// Race window: `claude` was on PATH at pre-check but gone by the time we
|
|
168
|
+
// shelled out. Convert to the same grouped skip path instead of a raw
|
|
169
|
+
// ENOENT row.
|
|
170
|
+
if (err instanceof ClaudeCliMissingError) {
|
|
171
|
+
claudeCliMissing = true;
|
|
172
|
+
skipped.push(issue.name);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
140
175
|
const message = err instanceof Error ? err.message : String(err);
|
|
141
176
|
errors.push({ name: issue.name, error: message });
|
|
142
177
|
}
|
|
143
178
|
}
|
|
144
179
|
// Clean empty directories in skills/
|
|
145
180
|
await cleanEmptyDirs(getSkillsDir());
|
|
146
|
-
return { moved, skipped, errors };
|
|
181
|
+
return { moved, skipped, errors, claudeCliMissing };
|
|
147
182
|
}
|
|
148
183
|
async function cleanEmptyDirs(dir) {
|
|
149
184
|
try {
|
|
@@ -186,6 +221,11 @@ export async function restoreItem(entry) {
|
|
|
186
221
|
}
|
|
187
222
|
const disabledDir = getDisabledDir();
|
|
188
223
|
if (legacyEntry.type === 'stale_project') {
|
|
224
|
+
// Type-scoped path guard: stale-project backups must restore under
|
|
225
|
+
// ~/.claude/projects/. Prevents a tampered manifest from redirecting a
|
|
226
|
+
// restore into ~/.claude/skills/ (or elsewhere under ~/.claude/) and
|
|
227
|
+
// clobbering an unrelated asset.
|
|
228
|
+
assertInsideSubtree(legacyEntry.from, getProjectsDir(), 'project memory');
|
|
189
229
|
const backupDir = join(disabledDir, 'memory-backup', legacyEntry.name);
|
|
190
230
|
// Refuse to overwrite user's current state
|
|
191
231
|
if (await pathExists(legacyEntry.from)) {
|
|
@@ -197,6 +237,8 @@ export async function restoreItem(entry) {
|
|
|
197
237
|
await rename(backupDir, legacyEntry.from);
|
|
198
238
|
}
|
|
199
239
|
else {
|
|
240
|
+
// Type-scoped path guard: skill restores must land under ~/.claude/skills/.
|
|
241
|
+
assertInsideSubtree(legacyEntry.from, getSkillsDir(), 'skill');
|
|
200
242
|
// Restore skill directory using the same naming as cleanIssues
|
|
201
243
|
const safeName = legacyEntry.name.replace(/\//g, '--');
|
|
202
244
|
const src = join(disabledDir, safeName);
|
package/dist/cli.js
CHANGED
|
@@ -13,6 +13,17 @@ import { collectDoctorReport, formatDoctorReport } from './doctor.js';
|
|
|
13
13
|
import { resolveSelection, resolveRestoreSelection } from './selection.js';
|
|
14
14
|
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
|
|
15
15
|
const { version: PKG_VERSION } = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
16
|
+
// Parse a non-negative-integer CLI option, keeping explicit 0 distinct from an
|
|
17
|
+
// unset/invalid value. `parseInt(x, 10) || N` was swallowing legitimate 0
|
|
18
|
+
// (e.g. `--lookback-days 0` was silently upgraded to 60).
|
|
19
|
+
function parseNonNegativeInt(raw, fallback) {
|
|
20
|
+
if (typeof raw !== 'string')
|
|
21
|
+
return fallback;
|
|
22
|
+
const n = Number.parseInt(raw, 10);
|
|
23
|
+
if (!Number.isFinite(n) || n < 0)
|
|
24
|
+
return fallback;
|
|
25
|
+
return n;
|
|
26
|
+
}
|
|
16
27
|
const program = new Command();
|
|
17
28
|
program
|
|
18
29
|
.name('claude-slim')
|
|
@@ -26,7 +37,7 @@ program
|
|
|
26
37
|
.option('--lookback-days <n>', 'Days of session history for skill-usage analysis', '60')
|
|
27
38
|
.action(async (opts) => {
|
|
28
39
|
await initTokenizer();
|
|
29
|
-
const result = await scan({ lookbackDays:
|
|
40
|
+
const result = await scan({ lookbackDays: parseNonNegativeInt(opts.lookbackDays, 60) });
|
|
30
41
|
await flushCache();
|
|
31
42
|
if (opts.json) {
|
|
32
43
|
console.log(JSON.stringify(result, null, 2));
|
|
@@ -43,7 +54,7 @@ program
|
|
|
43
54
|
.option('--lookback-days <n>', 'Days of session history for skill-usage analysis', '60')
|
|
44
55
|
.action(async (opts) => {
|
|
45
56
|
const report = await collectDoctorReport({
|
|
46
|
-
lookbackDays:
|
|
57
|
+
lookbackDays: parseNonNegativeInt(opts.lookbackDays, 60),
|
|
47
58
|
});
|
|
48
59
|
if (opts.json) {
|
|
49
60
|
console.log(JSON.stringify(report, null, 2));
|
|
@@ -64,8 +75,8 @@ program
|
|
|
64
75
|
await runCleanPipeline({
|
|
65
76
|
dryRun: !!opts.dryRun,
|
|
66
77
|
auto: !!opts.auto,
|
|
67
|
-
sessionsPerDay:
|
|
68
|
-
lookbackDays:
|
|
78
|
+
sessionsPerDay: parseNonNegativeInt(opts.sessionsPerDay, 2),
|
|
79
|
+
lookbackDays: parseNonNegativeInt(opts.lookbackDays, 60),
|
|
69
80
|
});
|
|
70
81
|
});
|
|
71
82
|
// --- restore ---
|
|
@@ -151,17 +162,21 @@ program
|
|
|
151
162
|
.option('--lookback-days <n>', 'Days of session history for skill-usage analysis', '60')
|
|
152
163
|
.action(async (opts) => {
|
|
153
164
|
await initTokenizer();
|
|
154
|
-
const result = await scan({ lookbackDays:
|
|
165
|
+
const result = await scan({ lookbackDays: parseNonNegativeInt(opts.lookbackDays, 60) });
|
|
155
166
|
const allEntries = await readManifest();
|
|
156
167
|
// Filter to legacy-style entries only (those with tokenCount/name/from fields)
|
|
157
168
|
const entries = allEntries.filter((e) => !('plugin' in e && 'marketplace' in e));
|
|
158
|
-
|
|
169
|
+
// Any prior manifest entry counts as a cleanup receipt. Filtering on
|
|
170
|
+
// `tokenCount > 0` previously hid runs that only removed zero-token items
|
|
171
|
+
// (broken_symlink / temp_cache), making `report` say "no previous cleanup"
|
|
172
|
+
// even after real work.
|
|
173
|
+
const movedEntries = entries;
|
|
159
174
|
if (movedEntries.length === 0) {
|
|
160
175
|
console.log('\n No previous cleanup found. Run `claude-slim clean` first.\n');
|
|
161
176
|
await flushCache();
|
|
162
177
|
return;
|
|
163
178
|
}
|
|
164
|
-
const sessionsPerDay =
|
|
179
|
+
const sessionsPerDay = parseNonNegativeInt(opts.sessionsPerDay, 2);
|
|
165
180
|
// Reconstruct "before" state: current + what was removed.
|
|
166
181
|
// Only skill-type entries contributed to the per-skill prompt overhead
|
|
167
182
|
// (stale_project restores memory tokens separately; broken_symlink/
|
|
@@ -222,15 +237,20 @@ async function runCleanPipeline(opts) {
|
|
|
222
237
|
const selection = await askUser(' Your choice: ');
|
|
223
238
|
selectedIssues = resolveSelection(selection, result.issues);
|
|
224
239
|
}
|
|
225
|
-
else {
|
|
226
|
-
//
|
|
240
|
+
else if (opts.auto) {
|
|
241
|
+
// Explicit non-interactive mode: select Tier 1 only.
|
|
227
242
|
selectedIssues = result.issues.filter((i) => i.tier === 1);
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
243
|
+
console.log(` \x1b[36m→ Auto mode: selecting ${selectedIssues.length} Tier 1 item(s)\x1b[0m\n`);
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
// Non-TTY without --auto/--dry-run: refuse rather than silently mutating
|
|
247
|
+
// the filesystem. Prior behavior auto-selected Tier 1, which surprised
|
|
248
|
+
// users who ran the CLI from scripts/nohup expecting a no-op.
|
|
249
|
+
console.log('\n \x1b[33m⚠ Non-interactive shell detected.\x1b[0m ' +
|
|
250
|
+
'Re-run with \x1b[1m--auto\x1b[0m (apply Tier 1) or \x1b[1m--dry-run\x1b[0m (preview only).\n');
|
|
251
|
+
await flushCache();
|
|
252
|
+
process.exitCode = 1;
|
|
253
|
+
return;
|
|
234
254
|
}
|
|
235
255
|
if (selectedIssues.length === 0) {
|
|
236
256
|
console.log('\n Cancelled. No changes made.\n');
|
|
@@ -255,6 +275,12 @@ async function runCleanPipeline(opts) {
|
|
|
255
275
|
console.log('');
|
|
256
276
|
console.log(formatReportBox(reportData));
|
|
257
277
|
console.log('');
|
|
278
|
+
if (cleanResult.claudeCliMissing) {
|
|
279
|
+
const skippedPluginCount = selectedIssues.filter((i) => i.type === 'unused_plugin').length;
|
|
280
|
+
console.log(` \x1b[33m⚠ \`claude\` CLI not found on PATH — skipped ${skippedPluginCount} plugin(s).\x1b[0m`);
|
|
281
|
+
console.log(' Install Claude Code and re-run to disable unused plugins,');
|
|
282
|
+
console.log(' or disable them manually via `claude plugin disable <name>`.\n');
|
|
283
|
+
}
|
|
258
284
|
if (cleanResult.errors.length > 0) {
|
|
259
285
|
console.log(' \x1b[31mErrors:\x1b[0m');
|
|
260
286
|
for (const err of cleanResult.errors) {
|
package/dist/plugin-runtime.d.ts
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sentinel error class raised when the `claude` CLI is not on PATH.
|
|
3
|
+
* Callers (cleaner.ts) treat this as a "skip, don't error" condition so users
|
|
4
|
+
* running claude-slim outside a Claude Code install don't see a raw ENOENT
|
|
5
|
+
* mid-cleanup.
|
|
6
|
+
*/
|
|
7
|
+
export declare class ClaudeCliMissingError extends Error {
|
|
8
|
+
constructor();
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Best-effort probe: is `claude` on PATH and answering `--version`?
|
|
12
|
+
* Never throws — a false result short-circuits `unused_plugin` cleanup with a
|
|
13
|
+
* friendly message rather than surfacing spawn ENOENT to end users.
|
|
14
|
+
*/
|
|
15
|
+
export declare function isClaudeCliAvailable(): Promise<boolean>;
|
|
1
16
|
/** Validates plugin name then shells out to `claude plugin disable <name>`. */
|
|
2
17
|
export declare function disablePlugin(name: string): Promise<void>;
|
|
3
18
|
/** Validates plugin name then shells out to `claude plugin enable <name>`. */
|
package/dist/plugin-runtime.js
CHANGED
|
@@ -1,14 +1,43 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
2
|
const PLUGIN_NAME_RE = /^[a-zA-Z0-9_-]+$/;
|
|
3
|
+
/**
|
|
4
|
+
* Sentinel error class raised when the `claude` CLI is not on PATH.
|
|
5
|
+
* Callers (cleaner.ts) treat this as a "skip, don't error" condition so users
|
|
6
|
+
* running claude-slim outside a Claude Code install don't see a raw ENOENT
|
|
7
|
+
* mid-cleanup.
|
|
8
|
+
*/
|
|
9
|
+
export class ClaudeCliMissingError extends Error {
|
|
10
|
+
constructor() {
|
|
11
|
+
super("`claude` CLI not found on PATH. Install Claude Code (https://claude.com/product/claude-code) " +
|
|
12
|
+
'to enable/disable plugins, or run `claude-slim clean` without the unused-plugin items selected.');
|
|
13
|
+
this.name = 'ClaudeCliMissingError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
3
16
|
function validateName(name) {
|
|
4
17
|
if (!PLUGIN_NAME_RE.test(name)) {
|
|
5
18
|
throw new Error(`Refusing to operate on suspicious plugin name: ${name}`);
|
|
6
19
|
}
|
|
7
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Detects the classic "binary missing from PATH" shape of Node's execFile error.
|
|
23
|
+
* spawn ENOENT surfaces as an Error with { code: 'ENOENT', syscall: 'spawn claude' }.
|
|
24
|
+
* The exact-match on `spawn claude` avoids false positives for hypothetical
|
|
25
|
+
* sibling binaries (e.g. `claude-code`) that might be spawned by future code.
|
|
26
|
+
*/
|
|
27
|
+
function isClaudeMissing(err) {
|
|
28
|
+
if (!(err instanceof Error))
|
|
29
|
+
return false;
|
|
30
|
+
const e = err;
|
|
31
|
+
return e.code === 'ENOENT' && e.syscall === 'spawn claude';
|
|
32
|
+
}
|
|
8
33
|
function runPluginCommand(subcommand, name) {
|
|
9
34
|
return new Promise((resolve, reject) => {
|
|
10
35
|
execFile('claude', ['plugin', subcommand, name], { timeout: 30000 }, (err, _stdout, stderr) => {
|
|
11
36
|
if (err) {
|
|
37
|
+
if (isClaudeMissing(err)) {
|
|
38
|
+
reject(new ClaudeCliMissingError());
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
12
41
|
const detail = stderr?.trim() ? `: ${stderr.trim()}` : '';
|
|
13
42
|
reject(new Error(`${err.message}${detail}`));
|
|
14
43
|
return;
|
|
@@ -21,6 +50,18 @@ function runPluginCommand(subcommand, name) {
|
|
|
21
50
|
});
|
|
22
51
|
});
|
|
23
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Best-effort probe: is `claude` on PATH and answering `--version`?
|
|
55
|
+
* Never throws — a false result short-circuits `unused_plugin` cleanup with a
|
|
56
|
+
* friendly message rather than surfacing spawn ENOENT to end users.
|
|
57
|
+
*/
|
|
58
|
+
export function isClaudeCliAvailable() {
|
|
59
|
+
return new Promise((resolve) => {
|
|
60
|
+
execFile('claude', ['--version'], { timeout: 5000 }, (err) => {
|
|
61
|
+
resolve(!err);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
}
|
|
24
65
|
/** Validates plugin name then shells out to `claude plugin disable <name>`. */
|
|
25
66
|
export async function disablePlugin(name) {
|
|
26
67
|
validateName(name);
|
|
@@ -40,9 +40,11 @@ const duplicateDetector = {
|
|
|
40
40
|
const pluginSkillNames = new Set(pluginSkills.map((s) => s.name));
|
|
41
41
|
const issues = [];
|
|
42
42
|
for (const skill of localSkills) {
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
// Exact-name match only. A prior baseName fallback flagged nested local
|
|
44
|
+
// skills (e.g. `org/ship`) as duplicates of a bare plugin `ship`, but
|
|
45
|
+
// namespaced local skills are addressable independently and are not real
|
|
46
|
+
// duplicates — the fallback risked disabling user content.
|
|
47
|
+
if (pluginSkillNames.has(skill.name)) {
|
|
46
48
|
issues.push({
|
|
47
49
|
type: 'duplicate',
|
|
48
50
|
tier: 2,
|
|
@@ -177,7 +179,7 @@ const unusedSkillDetector = {
|
|
|
177
179
|
};
|
|
178
180
|
const unusedPluginDetector = {
|
|
179
181
|
name: 'unused_plugin',
|
|
180
|
-
detect({ pluginSurfaces, enabledPlugins, recentSkillInvocations, recentMcpPrefixes, recentCommands, totalUserCallableInvocations, sessionsInWindow, lookbackDays, }) {
|
|
182
|
+
detect({ pluginSurfaces, enabledPlugins, recentSkillInvocations, recentMcpPrefixes, recentCommands, totalUserCallableInvocations, sessionsInWindow, lookbackDays, pluginCosts, }) {
|
|
181
183
|
// (a) Global suppression: too few sessions to draw a conclusion
|
|
182
184
|
if (sessionsInWindow < 3)
|
|
183
185
|
return [];
|
|
@@ -211,7 +213,7 @@ const unusedPluginDetector = {
|
|
|
211
213
|
name: ps.pluginName,
|
|
212
214
|
marketplace: ps.marketplace,
|
|
213
215
|
detail: `not invoked in ${lookbackDays}d (${ps.marketplace})`,
|
|
214
|
-
tokens: 0,
|
|
216
|
+
tokens: pluginCosts.get(ps.pluginName) ?? 0,
|
|
215
217
|
path: ps.installDir,
|
|
216
218
|
});
|
|
217
219
|
}
|
package/dist/scanner/index.js
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
// INVARIANT: nothing in the scanner (this file or anything under scanner/**)
|
|
2
|
+
// may write to stdout. The CLI pipes stdout of `scan --json` to jq/other
|
|
3
|
+
// tools; a stray console.log would silently corrupt machine-readable output.
|
|
4
|
+
// Route diagnostics through console.error. Enforced by
|
|
5
|
+
// src/__tests__/scan-stdout-invariant.test.ts.
|
|
1
6
|
import { join } from 'node:path';
|
|
2
7
|
import { countTokensCached } from '../tokenizer.js';
|
|
3
8
|
import { getClaudeDir } from '../paths.js';
|
|
@@ -12,6 +17,7 @@ import { scanSessionUsage } from './sessions.js';
|
|
|
12
17
|
import { classifyIssues } from './detectors.js';
|
|
13
18
|
import { scanPluginSurfaces } from './plugin-surfaces.js';
|
|
14
19
|
import { computePluginBreakdown } from './plugin-breakdown.js';
|
|
20
|
+
import { computePluginCosts } from './plugin-cost.js';
|
|
15
21
|
import { SKILL_PROMPT_OVERHEAD_TOKENS } from './constants.js';
|
|
16
22
|
const DEFAULT_LOOKBACK_DAYS = 60;
|
|
17
23
|
export async function scan(opts = {}) {
|
|
@@ -44,6 +50,14 @@ export async function scan(opts = {}) {
|
|
|
44
50
|
? countTokensCached(claudeMdContent, join(getClaudeDir(), 'CLAUDE.md'))
|
|
45
51
|
: 0;
|
|
46
52
|
const claudeMdSections = claudeMdContent ? parseClaudeMdSections(claudeMdContent) : [];
|
|
53
|
+
// Per-plugin cost map for the unused_plugin detector's savings estimate.
|
|
54
|
+
// Aggregates when multiple surface entries share a pluginName (mirrors the
|
|
55
|
+
// same logic in computePluginBreakdown).
|
|
56
|
+
const pluginCostBreakdowns = computePluginCosts(pluginSurfaces, claudeMdSections);
|
|
57
|
+
const pluginCosts = new Map();
|
|
58
|
+
for (const c of pluginCostBreakdowns) {
|
|
59
|
+
pluginCosts.set(c.pluginName, (pluginCosts.get(c.pluginName) ?? 0) + c.totalEstimatedTokens);
|
|
60
|
+
}
|
|
47
61
|
const issues = classifyIssues({
|
|
48
62
|
localSkills, pluginSkills, brokenSymlinks, memoryFiles,
|
|
49
63
|
tempCaches, staleProjects, disabledPlugins, plugins,
|
|
@@ -57,6 +71,7 @@ export async function scan(opts = {}) {
|
|
|
57
71
|
recentCommands: sessionUsage.commandsInvoked,
|
|
58
72
|
totalUserCallableInvocations: sessionUsage.totalUserCallableInvocations,
|
|
59
73
|
sessionsInWindow: sessionUsage.sessionsInWindow,
|
|
74
|
+
pluginCosts,
|
|
60
75
|
});
|
|
61
76
|
// Compute plugin breakdown (used by PLUGINS table in scan output)
|
|
62
77
|
const pluginBreakdown = computePluginBreakdown({
|
|
@@ -20,24 +20,30 @@ export function dedupeBySymlink(candidates) {
|
|
|
20
20
|
}
|
|
21
21
|
return Array.from(seen.values());
|
|
22
22
|
}
|
|
23
|
+
// Max depth for the nested-skill walk. Depth 1 = ~/.claude/skills/<a>/SKILL.md,
|
|
24
|
+
// depth 2 = ~/.claude/skills/<a>/<b>/SKILL.md, etc. Depth 3 covers the deepest
|
|
25
|
+
// layouts seen in the wild (e.g. plugin-namespaced groups like
|
|
26
|
+
// skills/<org>/<group>/<skill>/SKILL.md) while keeping the walk finite.
|
|
27
|
+
// If a directory contains a SKILL.md we stop descending — nested SKILL.md
|
|
28
|
+
// files under an already-declared skill would just create phantom duplicates.
|
|
29
|
+
const MAX_SKILL_DEPTH = 3;
|
|
23
30
|
export async function scanLocalSkills() {
|
|
24
31
|
const skillsDir = getSkillsDir();
|
|
25
32
|
const candidates = [];
|
|
26
33
|
const brokenSymlinks = [];
|
|
27
34
|
const contents = new Map();
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const dirPath = join(skillsDir, entry);
|
|
31
|
-
if (!(await isDirectory(dirPath)))
|
|
35
|
+
async function visit(dirPath, nameParts, depth) {
|
|
36
|
+
if (depth > MAX_SKILL_DEPTH)
|
|
32
37
|
return;
|
|
33
38
|
const skillMd = join(dirPath, 'SKILL.md');
|
|
39
|
+
const displayName = nameParts.join('/');
|
|
34
40
|
if (await isBrokenSymlink(skillMd)) {
|
|
35
41
|
let target = 'unknown';
|
|
36
42
|
try {
|
|
37
43
|
target = await readlink(skillMd);
|
|
38
44
|
}
|
|
39
45
|
catch { /* */ }
|
|
40
|
-
brokenSymlinks.push({ name:
|
|
46
|
+
brokenSymlinks.push({ name: displayName, path: skillMd, target });
|
|
41
47
|
return;
|
|
42
48
|
}
|
|
43
49
|
const content = await safeReadFile(skillMd);
|
|
@@ -47,7 +53,7 @@ export async function scanLocalSkills() {
|
|
|
47
53
|
const realMdPath = await resolveRealPath(skillMd);
|
|
48
54
|
candidates.push({
|
|
49
55
|
skill: {
|
|
50
|
-
name:
|
|
56
|
+
name: displayName,
|
|
51
57
|
path: dirPath,
|
|
52
58
|
sizeBytes: Buffer.byteLength(content),
|
|
53
59
|
tokens,
|
|
@@ -55,43 +61,31 @@ export async function scanLocalSkills() {
|
|
|
55
61
|
},
|
|
56
62
|
realMdPath,
|
|
57
63
|
});
|
|
64
|
+
// Stop descending: nested SKILL.md files under a declared skill are
|
|
65
|
+
// documentation/examples, not addressable skills.
|
|
66
|
+
return;
|
|
58
67
|
}
|
|
59
|
-
|
|
68
|
+
if (depth === MAX_SKILL_DEPTH)
|
|
69
|
+
return;
|
|
60
70
|
const subEntries = await safeReaddir(dirPath);
|
|
61
|
-
|
|
71
|
+
await Promise.all(subEntries.map(async (sub) => {
|
|
62
72
|
const subDir = join(dirPath, sub);
|
|
73
|
+
// isDirectory() uses stat(), which follows symlinks — intentional so
|
|
74
|
+
// users can symlink shared skills into ~/.claude/skills/. A cycle
|
|
75
|
+
// through symlinks would be bounded by MAX_SKILL_DEPTH, not by us
|
|
76
|
+
// detecting the loop directly.
|
|
63
77
|
if (!(await isDirectory(subDir)))
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const subContent = await safeReadFile(subSkillMd);
|
|
76
|
-
if (subContent !== null) {
|
|
77
|
-
const name = `${entry}/${sub}`;
|
|
78
|
-
contents.set(subSkillMd, subContent);
|
|
79
|
-
const tokens = countTokensCached(subContent, subSkillMd);
|
|
80
|
-
const realMdPath = await resolveRealPath(subSkillMd);
|
|
81
|
-
candidates.push({
|
|
82
|
-
skill: {
|
|
83
|
-
name,
|
|
84
|
-
path: subDir,
|
|
85
|
-
sizeBytes: Buffer.byteLength(subContent),
|
|
86
|
-
tokens,
|
|
87
|
-
source: 'local',
|
|
88
|
-
},
|
|
89
|
-
realMdPath,
|
|
90
|
-
});
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
});
|
|
94
|
-
await Promise.all(scanPromises);
|
|
78
|
+
return;
|
|
79
|
+
await visit(subDir, [...nameParts, sub], depth + 1);
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
82
|
+
const topEntries = await safeReaddir(skillsDir);
|
|
83
|
+
await Promise.all(topEntries.map(async (entry) => {
|
|
84
|
+
const dirPath = join(skillsDir, entry);
|
|
85
|
+
if (!(await isDirectory(dirPath)))
|
|
86
|
+
return;
|
|
87
|
+
await visit(dirPath, [entry], 1);
|
|
88
|
+
}));
|
|
95
89
|
const skills = dedupeBySymlink(candidates);
|
|
96
90
|
return { skills, brokenSymlinks, contents };
|
|
97
91
|
}
|
package/dist/scanner/sessions.js
CHANGED
|
@@ -130,9 +130,12 @@ export function extractMcpPrefixesFromTranscript(content) {
|
|
|
130
130
|
//
|
|
131
131
|
// Only `type === "user"` / `role === "user"` messages are examined to avoid
|
|
132
132
|
// false positives from assistant text that may reference command names.
|
|
133
|
+
// Regex pattern held as a plain string — each call site constructs a fresh
|
|
134
|
+
// RegExp so there is no shared `lastIndex` state to reset. `String.matchAll`
|
|
135
|
+
// then wraps that RegExp in its own iterator, further insulating the loop.
|
|
136
|
+
const COMMAND_TAG_PATTERN = /<command-name>([^<]+)<\/command-name>/g;
|
|
133
137
|
export function extractCommandsFromTranscript(content) {
|
|
134
138
|
const commands = new Set();
|
|
135
|
-
const TAG_RE = /<command-name>([^<]+)<\/command-name>/g;
|
|
136
139
|
const lines = content.split('\n');
|
|
137
140
|
for (const line of lines) {
|
|
138
141
|
if (!line)
|
|
@@ -170,9 +173,7 @@ export function extractCommandsFromTranscript(content) {
|
|
|
170
173
|
}
|
|
171
174
|
}
|
|
172
175
|
for (const text of texts) {
|
|
173
|
-
|
|
174
|
-
let match;
|
|
175
|
-
while ((match = TAG_RE.exec(text)) !== null) {
|
|
176
|
+
for (const match of text.matchAll(COMMAND_TAG_PATTERN)) {
|
|
176
177
|
// Strip leading slash from the command value (e.g. "/clear" → "clear")
|
|
177
178
|
const raw = match[1].trim();
|
|
178
179
|
commands.add(raw.startsWith('/') ? raw.slice(1) : raw);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-slim",
|
|
3
|
-
"version": "2.7.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "2.7.2",
|
|
4
|
+
"description": "Cut Claude Code startup token overhead — non-destructive scan, tiered proposals, one-command restore. Finds unused skills, duplicate registrations, stale memory, and heavyweight plugins in ~/.claude/.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"claude-slim": "./dist/cli.js"
|
|
@@ -23,11 +23,19 @@
|
|
|
23
23
|
"keywords": [
|
|
24
24
|
"claude",
|
|
25
25
|
"claude-code",
|
|
26
|
+
"anthropic",
|
|
26
27
|
"token",
|
|
27
|
-
"optimization",
|
|
28
|
+
"token-optimization",
|
|
29
|
+
"context-bloat",
|
|
30
|
+
"prompt-optimization",
|
|
31
|
+
"system-prompt",
|
|
28
32
|
"cleanup",
|
|
29
33
|
"skills",
|
|
30
|
-
"
|
|
34
|
+
"skill-management",
|
|
35
|
+
"plugin",
|
|
36
|
+
"plugin-cleanup",
|
|
37
|
+
"cli",
|
|
38
|
+
"developer-experience"
|
|
31
39
|
],
|
|
32
40
|
"author": "iops-leo",
|
|
33
41
|
"license": "MIT",
|