claude-slim 2.7.0 → 2.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cleaner.js +22 -2
- package/dist/cli.js +35 -15
- package/dist/scanner/detectors.d.ts +1 -0
- package/dist/scanner/detectors.js +7 -5
- package/dist/scanner/index.js +10 -0
- package/dist/scanner/sessions.js +5 -4
- package/package.json +1 -1
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';
|
|
4
|
+
import { assertInsideClaudeDir, getSkillsDir, getProjectsDir } from './paths.js';
|
|
5
5
|
import { disablePlugin, enablePlugin } 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);
|
|
@@ -186,6 +199,11 @@ export async function restoreItem(entry) {
|
|
|
186
199
|
}
|
|
187
200
|
const disabledDir = getDisabledDir();
|
|
188
201
|
if (legacyEntry.type === 'stale_project') {
|
|
202
|
+
// Type-scoped path guard: stale-project backups must restore under
|
|
203
|
+
// ~/.claude/projects/. Prevents a tampered manifest from redirecting a
|
|
204
|
+
// restore into ~/.claude/skills/ (or elsewhere under ~/.claude/) and
|
|
205
|
+
// clobbering an unrelated asset.
|
|
206
|
+
assertInsideSubtree(legacyEntry.from, getProjectsDir(), 'project memory');
|
|
189
207
|
const backupDir = join(disabledDir, 'memory-backup', legacyEntry.name);
|
|
190
208
|
// Refuse to overwrite user's current state
|
|
191
209
|
if (await pathExists(legacyEntry.from)) {
|
|
@@ -197,6 +215,8 @@ export async function restoreItem(entry) {
|
|
|
197
215
|
await rename(backupDir, legacyEntry.from);
|
|
198
216
|
}
|
|
199
217
|
else {
|
|
218
|
+
// Type-scoped path guard: skill restores must land under ~/.claude/skills/.
|
|
219
|
+
assertInsideSubtree(legacyEntry.from, getSkillsDir(), 'skill');
|
|
200
220
|
// Restore skill directory using the same naming as cleanIssues
|
|
201
221
|
const safeName = legacyEntry.name.replace(/\//g, '--');
|
|
202
222
|
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');
|
|
@@ -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
|
@@ -12,6 +12,7 @@ import { scanSessionUsage } from './sessions.js';
|
|
|
12
12
|
import { classifyIssues } from './detectors.js';
|
|
13
13
|
import { scanPluginSurfaces } from './plugin-surfaces.js';
|
|
14
14
|
import { computePluginBreakdown } from './plugin-breakdown.js';
|
|
15
|
+
import { computePluginCosts } from './plugin-cost.js';
|
|
15
16
|
import { SKILL_PROMPT_OVERHEAD_TOKENS } from './constants.js';
|
|
16
17
|
const DEFAULT_LOOKBACK_DAYS = 60;
|
|
17
18
|
export async function scan(opts = {}) {
|
|
@@ -44,6 +45,14 @@ export async function scan(opts = {}) {
|
|
|
44
45
|
? countTokensCached(claudeMdContent, join(getClaudeDir(), 'CLAUDE.md'))
|
|
45
46
|
: 0;
|
|
46
47
|
const claudeMdSections = claudeMdContent ? parseClaudeMdSections(claudeMdContent) : [];
|
|
48
|
+
// Per-plugin cost map for the unused_plugin detector's savings estimate.
|
|
49
|
+
// Aggregates when multiple surface entries share a pluginName (mirrors the
|
|
50
|
+
// same logic in computePluginBreakdown).
|
|
51
|
+
const pluginCostBreakdowns = computePluginCosts(pluginSurfaces, claudeMdSections);
|
|
52
|
+
const pluginCosts = new Map();
|
|
53
|
+
for (const c of pluginCostBreakdowns) {
|
|
54
|
+
pluginCosts.set(c.pluginName, (pluginCosts.get(c.pluginName) ?? 0) + c.totalEstimatedTokens);
|
|
55
|
+
}
|
|
47
56
|
const issues = classifyIssues({
|
|
48
57
|
localSkills, pluginSkills, brokenSymlinks, memoryFiles,
|
|
49
58
|
tempCaches, staleProjects, disabledPlugins, plugins,
|
|
@@ -57,6 +66,7 @@ export async function scan(opts = {}) {
|
|
|
57
66
|
recentCommands: sessionUsage.commandsInvoked,
|
|
58
67
|
totalUserCallableInvocations: sessionUsage.totalUserCallableInvocations,
|
|
59
68
|
sessionsInWindow: sessionUsage.sessionsInWindow,
|
|
69
|
+
pluginCosts,
|
|
60
70
|
});
|
|
61
71
|
// Compute plugin breakdown (used by PLUGINS table in scan output)
|
|
62
72
|
const pluginBreakdown = computePluginBreakdown({
|
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);
|