claude-slim 2.6.1 → 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/README.md +8 -0
- package/dist/cleaner.d.ts +2 -2
- package/dist/cleaner.js +69 -24
- package/dist/cli.js +70 -31
- package/dist/manifest.d.ts +7 -4
- package/dist/manifest.js +26 -1
- package/dist/plugin-runtime.d.ts +4 -0
- package/dist/plugin-runtime.js +33 -0
- package/dist/report.d.ts +8 -1
- package/dist/report.js +29 -1
- package/dist/scanner/constants.d.ts +3 -0
- package/dist/scanner/constants.js +8 -0
- package/dist/scanner/detectors.d.ts +11 -0
- package/dist/scanner/detectors.js +49 -3
- package/dist/scanner/disabled-plugins.d.ts +7 -0
- package/dist/scanner/disabled-plugins.js +38 -0
- package/dist/scanner/index.js +40 -1
- package/dist/scanner/plugin-breakdown.d.ts +16 -0
- package/dist/scanner/plugin-breakdown.js +129 -0
- package/dist/scanner/plugin-cost.d.ts +27 -0
- package/dist/scanner/plugin-cost.js +35 -0
- package/dist/scanner/plugin-surfaces.d.ts +14 -0
- package/dist/scanner/plugin-surfaces.js +129 -0
- package/dist/scanner/sessions.d.ts +5 -0
- package/dist/scanner/sessions.js +158 -7
- package/dist/types.d.ts +22 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -67,6 +67,7 @@ That's slower responses. Hitting your usage cap faster. Paying for context you'r
|
|
|
67
67
|
| Empty templates | Placeholder skills with no content |
|
|
68
68
|
| Oversized files | SKILL.md over 10KB |
|
|
69
69
|
| **Unused skills** | **Local skills never invoked in your last N days of sessions (default 60d)** |
|
|
70
|
+
| **Unused plugins** | **Plugins whose skill/mcp/cmd were never invoked in your last N days of sessions (default 60d). Tier 3, never auto-selected.** |
|
|
70
71
|
| Stale memory | Large memory files loaded every session |
|
|
71
72
|
| Disabled plugins | Installed but disabled plugins still in cache |
|
|
72
73
|
| Stale projects | Project memory untouched for 90+ days |
|
|
@@ -210,6 +211,13 @@ From a real cleanup session:
|
|
|
210
211
|
|
|
211
212
|
---
|
|
212
213
|
|
|
214
|
+
## v2.7 — What's new
|
|
215
|
+
|
|
216
|
+
- **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.
|
|
217
|
+
- **PLUGIN BREAKDOWN table** — the scan report now includes a per-plugin cost breakdown: token estimate (CLAUDE.md section + skills + deferred MCP tools + commands) and usage status (used / unused / agent-only / insufficient data / disabled). In the owner's real env: `oh-my-claudecode` tops the list at ~6,210 tok; three plugins were flagged as unused despite being enabled.
|
|
218
|
+
- **Session parser fix** — slash commands in string-form user messages were previously missed (only array-form content was parsed). All slash-command invocations are now captured correctly.
|
|
219
|
+
- **+82 tests** for the new modules and the parser regression.
|
|
220
|
+
|
|
213
221
|
## v2.6 — What's new
|
|
214
222
|
|
|
215
223
|
- **`claude-slim doctor`** — Checks Node support, `~/.claude/` readability, local skill/plugin cache access, `claude plugin list`, and recent session-log signal quality. Use it when scan results look sparse or unused-skill detection is suppressed.
|
package/dist/cleaner.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Issue, ManifestEntry } from './types.js';
|
|
1
|
+
import type { Issue, ManifestEntry, DisabledPluginEntry } from './types.js';
|
|
2
2
|
export interface CleanResult {
|
|
3
3
|
moved: ManifestEntry[];
|
|
4
4
|
skipped: string[];
|
|
@@ -8,4 +8,4 @@ export interface CleanResult {
|
|
|
8
8
|
}>;
|
|
9
9
|
}
|
|
10
10
|
export declare function cleanIssues(issues: Issue[]): Promise<CleanResult>;
|
|
11
|
-
export declare function restoreItem(entry: ManifestEntry): Promise<void>;
|
|
11
|
+
export declare function restoreItem(entry: ManifestEntry | DisabledPluginEntry): Promise<void>;
|
package/dist/cleaner.js
CHANGED
|
@@ -1,7 +1,21 @@
|
|
|
1
1
|
import { rename, readdir, rmdir, rm, unlink, lstat, mkdir } from 'node:fs/promises';
|
|
2
|
-
import { join, dirname } from 'node:path';
|
|
3
|
-
import { appendManifest, ensureDisabledDir, getDisabledDir, removeEntry } from './manifest.js';
|
|
4
|
-
import { assertInsideClaudeDir, getSkillsDir } from './paths.js';
|
|
2
|
+
import { join, dirname, resolve, sep } from 'node:path';
|
|
3
|
+
import { appendManifest, ensureDisabledDir, getDisabledDir, removeEntry, recordDisabledPlugin, removeDisabledPlugin } from './manifest.js';
|
|
4
|
+
import { assertInsideClaudeDir, getSkillsDir, getProjectsDir } from './paths.js';
|
|
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
|
+
}
|
|
5
19
|
async function pathExists(p) {
|
|
6
20
|
try {
|
|
7
21
|
await lstat(p);
|
|
@@ -33,7 +47,10 @@ export async function cleanIssues(issues) {
|
|
|
33
47
|
const errors = [];
|
|
34
48
|
for (const issue of issues) {
|
|
35
49
|
try {
|
|
36
|
-
|
|
50
|
+
// unused_plugin and report-only types don't touch filesystem paths directly
|
|
51
|
+
if (issue.type !== 'unused_plugin' && issue.type !== 'oversized_memory' && issue.type !== 'disabled_plugin') {
|
|
52
|
+
assertInsideClaudeDir(issue.path);
|
|
53
|
+
}
|
|
37
54
|
if (issue.type === 'broken_symlink') {
|
|
38
55
|
await unlink(issue.path);
|
|
39
56
|
const entry = {
|
|
@@ -114,6 +131,19 @@ export async function cleanIssues(issues) {
|
|
|
114
131
|
await recordOrRollback(entry, () => rename(backupDir, issue.path));
|
|
115
132
|
moved.push(entry);
|
|
116
133
|
}
|
|
134
|
+
else if (issue.type === 'unused_plugin') {
|
|
135
|
+
await disablePlugin(issue.name);
|
|
136
|
+
// Record in manifest; if that fails, roll back the disable
|
|
137
|
+
try {
|
|
138
|
+
await recordDisabledPlugin(issue.name, issue.marketplace ?? 'unknown');
|
|
139
|
+
}
|
|
140
|
+
catch (e) {
|
|
141
|
+
await enablePlugin(issue.name).catch(() => { });
|
|
142
|
+
throw e;
|
|
143
|
+
}
|
|
144
|
+
// unused_plugin doesn't contribute to moved (no ManifestEntry shape), track skipped
|
|
145
|
+
skipped.push(issue.name);
|
|
146
|
+
}
|
|
117
147
|
else if (issue.type === 'oversized_memory' || issue.type === 'disabled_plugin') {
|
|
118
148
|
// Report only — user manages these manually
|
|
119
149
|
skipped.push(issue.name);
|
|
@@ -149,42 +179,57 @@ async function cleanEmptyDirs(dir) {
|
|
|
149
179
|
catch { /* skip */ }
|
|
150
180
|
}
|
|
151
181
|
export async function restoreItem(entry) {
|
|
152
|
-
|
|
153
|
-
if (entry
|
|
154
|
-
|
|
182
|
+
// Handle new-style disabled_plugin entries (plugin + marketplace shape)
|
|
183
|
+
if ('plugin' in entry && 'marketplace' in entry) {
|
|
184
|
+
const pluginEntry = entry;
|
|
185
|
+
await enablePlugin(pluginEntry.plugin);
|
|
186
|
+
await removeDisabledPlugin(pluginEntry.plugin, pluginEntry.marketplace);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const legacyEntry = entry;
|
|
190
|
+
assertInsideClaudeDir(legacyEntry.from);
|
|
191
|
+
if (legacyEntry.type === 'broken_symlink') {
|
|
192
|
+
throw new Error(`Broken symlinks cannot be restored (${legacyEntry.name})`);
|
|
155
193
|
}
|
|
156
|
-
if (
|
|
157
|
-
throw new Error(`Plugins must be reinstalled: claude plugin install ${
|
|
194
|
+
if (legacyEntry.type === 'disabled_plugin') {
|
|
195
|
+
throw new Error(`Plugins must be reinstalled: claude plugin install ${legacyEntry.name}`);
|
|
158
196
|
}
|
|
159
|
-
if (
|
|
160
|
-
throw new Error(`Temp caches were deleted and cannot be restored (${
|
|
197
|
+
if (legacyEntry.type === 'temp_cache') {
|
|
198
|
+
throw new Error(`Temp caches were deleted and cannot be restored (${legacyEntry.name})`);
|
|
161
199
|
}
|
|
162
200
|
const disabledDir = getDisabledDir();
|
|
163
|
-
if (
|
|
164
|
-
|
|
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');
|
|
207
|
+
const backupDir = join(disabledDir, 'memory-backup', legacyEntry.name);
|
|
165
208
|
// Refuse to overwrite user's current state
|
|
166
|
-
if (await pathExists(
|
|
167
|
-
throw new Error(`Cannot restore: ${
|
|
209
|
+
if (await pathExists(legacyEntry.from)) {
|
|
210
|
+
throw new Error(`Cannot restore: ${legacyEntry.from} already exists. ` +
|
|
168
211
|
`Remove or rename it first.`);
|
|
169
212
|
}
|
|
170
|
-
await mkdir(dirname(
|
|
213
|
+
await mkdir(dirname(legacyEntry.from), { recursive: true });
|
|
171
214
|
// Atomic directory rename — requires same FS (guaranteed since both paths are under ~/.claude/)
|
|
172
|
-
await rename(backupDir,
|
|
215
|
+
await rename(backupDir, legacyEntry.from);
|
|
173
216
|
}
|
|
174
217
|
else {
|
|
218
|
+
// Type-scoped path guard: skill restores must land under ~/.claude/skills/.
|
|
219
|
+
assertInsideSubtree(legacyEntry.from, getSkillsDir(), 'skill');
|
|
175
220
|
// Restore skill directory using the same naming as cleanIssues
|
|
176
|
-
const safeName =
|
|
221
|
+
const safeName = legacyEntry.name.replace(/\//g, '--');
|
|
177
222
|
const src = join(disabledDir, safeName);
|
|
178
223
|
if (!(await pathExists(src))) {
|
|
179
|
-
throw new Error(`Backup not found for "${
|
|
224
|
+
throw new Error(`Backup not found for "${legacyEntry.name}" at ${src}. ` +
|
|
180
225
|
`It may have been manually removed.`);
|
|
181
226
|
}
|
|
182
|
-
if (await pathExists(
|
|
183
|
-
throw new Error(`Cannot restore: ${
|
|
227
|
+
if (await pathExists(legacyEntry.from)) {
|
|
228
|
+
throw new Error(`Cannot restore: ${legacyEntry.from} already exists. ` +
|
|
184
229
|
`Remove or rename it first.`);
|
|
185
230
|
}
|
|
186
|
-
await mkdir(dirname(
|
|
187
|
-
await rename(src,
|
|
231
|
+
await mkdir(dirname(legacyEntry.from), { recursive: true });
|
|
232
|
+
await rename(src, legacyEntry.from);
|
|
188
233
|
}
|
|
189
|
-
await removeEntry(
|
|
234
|
+
await removeEntry(legacyEntry.name);
|
|
190
235
|
}
|
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 ---
|
|
@@ -73,12 +84,17 @@ program
|
|
|
73
84
|
.command('restore')
|
|
74
85
|
.description('Restore previously disabled items')
|
|
75
86
|
.action(async () => {
|
|
76
|
-
const
|
|
87
|
+
const allEntries = await readManifest();
|
|
88
|
+
// Separate plugin entries (DisabledPluginEntry shape) from legacy ManifestEntry
|
|
89
|
+
const pluginEntries = allEntries.filter((e) => 'plugin' in e && 'marketplace' in e);
|
|
90
|
+
const legacyEntries = allEntries.filter((e) => !('plugin' in e && 'marketplace' in e));
|
|
77
91
|
// v2 manifest contains only currently-disabled entries (restored ones are removed)
|
|
78
|
-
const disabled =
|
|
92
|
+
const disabled = legacyEntries.filter((e) => e.type !== 'broken_symlink');
|
|
79
93
|
const NON_RESTORABLE = new Set(['temp_cache', 'disabled_plugin']);
|
|
80
|
-
const
|
|
94
|
+
const restorableLegacy = disabled.filter((e) => !NON_RESTORABLE.has(e.type));
|
|
81
95
|
const infoOnly = disabled.filter((e) => NON_RESTORABLE.has(e.type));
|
|
96
|
+
// Combine restorable: legacy skill/project entries + plugin entries
|
|
97
|
+
const restorable = [...restorableLegacy, ...pluginEntries];
|
|
82
98
|
if (restorable.length === 0 && infoOnly.length === 0) {
|
|
83
99
|
console.log('\n Nothing to restore.\n');
|
|
84
100
|
return;
|
|
@@ -87,8 +103,16 @@ program
|
|
|
87
103
|
console.log('\n Restorable items:\n');
|
|
88
104
|
for (let i = 0; i < restorable.length; i++) {
|
|
89
105
|
const e = restorable[i];
|
|
90
|
-
|
|
91
|
-
|
|
106
|
+
if ('plugin' in e && 'marketplace' in e) {
|
|
107
|
+
const pe = e;
|
|
108
|
+
const date = new Date(pe.disabledAt).toLocaleDateString();
|
|
109
|
+
console.log(` ${i + 1}. [plugin] ${pe.plugin} @ ${pe.marketplace} (disabled ${date})`);
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
const le = e;
|
|
113
|
+
const date = new Date(le.date).toLocaleDateString();
|
|
114
|
+
console.log(` ${i + 1}. ${le.name} (${le.type}, disabled ${date})`);
|
|
115
|
+
}
|
|
92
116
|
}
|
|
93
117
|
}
|
|
94
118
|
if (infoOnly.length > 0) {
|
|
@@ -98,7 +122,7 @@ program
|
|
|
98
122
|
const hint = e.type === 'disabled_plugin'
|
|
99
123
|
? `reinstall: claude plugin install ${e.name}`
|
|
100
124
|
: 'deleted, cannot restore';
|
|
101
|
-
console.log(` \x1b[90m
|
|
125
|
+
console.log(` \x1b[90m• ${e.name} (${date}) — ${hint}\x1b[0m`);
|
|
102
126
|
}
|
|
103
127
|
}
|
|
104
128
|
if (restorable.length === 0) {
|
|
@@ -114,14 +138,18 @@ program
|
|
|
114
138
|
}
|
|
115
139
|
let restored = 0;
|
|
116
140
|
for (const idx of indices) {
|
|
141
|
+
const e = restorable[idx];
|
|
142
|
+
const label = 'plugin' in e && 'marketplace' in e
|
|
143
|
+
? e.plugin
|
|
144
|
+
: e.name;
|
|
117
145
|
try {
|
|
118
|
-
await restoreItem(
|
|
119
|
-
console.log(` \x1b[32m
|
|
146
|
+
await restoreItem(e);
|
|
147
|
+
console.log(` \x1b[32m✓\x1b[0m Restored: ${label}`);
|
|
120
148
|
restored++;
|
|
121
149
|
}
|
|
122
150
|
catch (err) {
|
|
123
151
|
const msg = err instanceof Error ? err.message : String(err);
|
|
124
|
-
console.log(` \x1b[31m
|
|
152
|
+
console.log(` \x1b[31m✗\x1b[0m ${label}: ${msg}`);
|
|
125
153
|
}
|
|
126
154
|
}
|
|
127
155
|
console.log(`\n Restored ${restored} item(s).\n`);
|
|
@@ -134,15 +162,21 @@ program
|
|
|
134
162
|
.option('--lookback-days <n>', 'Days of session history for skill-usage analysis', '60')
|
|
135
163
|
.action(async (opts) => {
|
|
136
164
|
await initTokenizer();
|
|
137
|
-
const result = await scan({ lookbackDays:
|
|
138
|
-
const
|
|
139
|
-
|
|
165
|
+
const result = await scan({ lookbackDays: parseNonNegativeInt(opts.lookbackDays, 60) });
|
|
166
|
+
const allEntries = await readManifest();
|
|
167
|
+
// Filter to legacy-style entries only (those with tokenCount/name/from fields)
|
|
168
|
+
const entries = allEntries.filter((e) => !('plugin' in e && 'marketplace' in e));
|
|
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;
|
|
140
174
|
if (movedEntries.length === 0) {
|
|
141
175
|
console.log('\n No previous cleanup found. Run `claude-slim clean` first.\n');
|
|
142
176
|
await flushCache();
|
|
143
177
|
return;
|
|
144
178
|
}
|
|
145
|
-
const sessionsPerDay =
|
|
179
|
+
const sessionsPerDay = parseNonNegativeInt(opts.sessionsPerDay, 2);
|
|
146
180
|
// Reconstruct "before" state: current + what was removed.
|
|
147
181
|
// Only skill-type entries contributed to the per-skill prompt overhead
|
|
148
182
|
// (stale_project restores memory tokens separately; broken_symlink/
|
|
@@ -195,23 +229,28 @@ async function runCleanPipeline(opts) {
|
|
|
195
229
|
let selectedIssues;
|
|
196
230
|
if (isInteractive) {
|
|
197
231
|
console.log(' Actions:');
|
|
198
|
-
console.log(' Enter
|
|
199
|
-
console.log(' 1,3,5
|
|
200
|
-
console.log(' all
|
|
201
|
-
console.log(' none
|
|
232
|
+
console.log(' Enter → accept pre-selected (Tier 1 only)');
|
|
233
|
+
console.log(' 1,3,5 → select specific items');
|
|
234
|
+
console.log(' all → select everything');
|
|
235
|
+
console.log(' none → cancel');
|
|
202
236
|
console.log('');
|
|
203
237
|
const selection = await askUser(' Your choice: ');
|
|
204
238
|
selectedIssues = resolveSelection(selection, result.issues);
|
|
205
239
|
}
|
|
206
|
-
else {
|
|
207
|
-
//
|
|
240
|
+
else if (opts.auto) {
|
|
241
|
+
// Explicit non-interactive mode: select Tier 1 only.
|
|
208
242
|
selectedIssues = result.issues.filter((i) => i.tier === 1);
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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;
|
|
215
254
|
}
|
|
216
255
|
if (selectedIssues.length === 0) {
|
|
217
256
|
console.log('\n Cancelled. No changes made.\n');
|
|
@@ -221,7 +260,7 @@ async function runCleanPipeline(opts) {
|
|
|
221
260
|
if (opts.dryRun) {
|
|
222
261
|
console.log('\n \x1b[33m[DRY RUN]\x1b[0m Would disable:');
|
|
223
262
|
for (const issue of selectedIssues) {
|
|
224
|
-
console.log(`
|
|
263
|
+
console.log(` • ${issue.name} (${issue.type})`);
|
|
225
264
|
}
|
|
226
265
|
console.log(`\n Estimated token savings: ~${selectedIssues.reduce((s, i) => s + i.tokens, 0).toLocaleString()}`);
|
|
227
266
|
console.log('');
|
|
@@ -239,7 +278,7 @@ async function runCleanPipeline(opts) {
|
|
|
239
278
|
if (cleanResult.errors.length > 0) {
|
|
240
279
|
console.log(' \x1b[31mErrors:\x1b[0m');
|
|
241
280
|
for (const err of cleanResult.errors) {
|
|
242
|
-
console.log(`
|
|
281
|
+
console.log(` • ${err.name}: ${err.error}`);
|
|
243
282
|
}
|
|
244
283
|
console.log('');
|
|
245
284
|
}
|
package/dist/manifest.d.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import type { Manifest, ManifestEntry } from './types.js';
|
|
1
|
+
import type { Manifest, ManifestEntry, AnyManifestEntry, DisabledPluginEntry } from './types.js';
|
|
2
2
|
export declare function getDisabledDir(): string;
|
|
3
3
|
export declare function ensureDisabledDir(): Promise<void>;
|
|
4
4
|
export declare function migrateLegacyIfNeeded(): Promise<void>;
|
|
5
5
|
export declare function readManifestV2(): Promise<Manifest>;
|
|
6
6
|
export declare function writeManifestV2(manifest: Manifest): Promise<void>;
|
|
7
|
-
export declare function addEntry(entry:
|
|
7
|
+
export declare function addEntry(entry: AnyManifestEntry): Promise<void>;
|
|
8
8
|
export declare function removeEntry(name: string): Promise<ManifestEntry | null>;
|
|
9
|
-
export declare function
|
|
10
|
-
export declare function
|
|
9
|
+
export declare function recordDisabledPlugin(plugin: string, marketplace: string): Promise<void>;
|
|
10
|
+
export declare function findDisabledPlugin(plugin: string, marketplace: string): Promise<DisabledPluginEntry | undefined>;
|
|
11
|
+
export declare function removeDisabledPlugin(plugin: string, marketplace: string): Promise<boolean>;
|
|
12
|
+
export declare function readManifest(): Promise<AnyManifestEntry[]>;
|
|
13
|
+
export declare function appendManifest(entry: AnyManifestEntry): Promise<void>;
|
package/dist/manifest.js
CHANGED
|
@@ -15,6 +15,9 @@ async function pathExists(p) {
|
|
|
15
15
|
return false;
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
|
+
function isDisabledPluginEntry(e) {
|
|
19
|
+
return e.type === 'disabled_plugin' && 'plugin' in e && 'marketplace' in e;
|
|
20
|
+
}
|
|
18
21
|
function parseJsonl(content) {
|
|
19
22
|
const entries = [];
|
|
20
23
|
for (const line of content.split('\n')) {
|
|
@@ -108,13 +111,35 @@ export async function addEntry(entry) {
|
|
|
108
111
|
}
|
|
109
112
|
export async function removeEntry(name) {
|
|
110
113
|
const m = await readManifestV2();
|
|
111
|
-
const idx = m.entries.findIndex((e) => e.name === name);
|
|
114
|
+
const idx = m.entries.findIndex((e) => !isDisabledPluginEntry(e) && e.name === name);
|
|
112
115
|
if (idx === -1)
|
|
113
116
|
return null;
|
|
114
117
|
const [removed] = m.entries.splice(idx, 1);
|
|
115
118
|
await writeManifestV2(m);
|
|
116
119
|
return removed;
|
|
117
120
|
}
|
|
121
|
+
export async function recordDisabledPlugin(plugin, marketplace) {
|
|
122
|
+
const entry = {
|
|
123
|
+
type: 'disabled_plugin',
|
|
124
|
+
plugin,
|
|
125
|
+
marketplace,
|
|
126
|
+
disabledAt: new Date().toISOString(),
|
|
127
|
+
};
|
|
128
|
+
await addEntry(entry);
|
|
129
|
+
}
|
|
130
|
+
export async function findDisabledPlugin(plugin, marketplace) {
|
|
131
|
+
const m = await readManifestV2();
|
|
132
|
+
return m.entries.find((e) => isDisabledPluginEntry(e) && e.plugin === plugin && e.marketplace === marketplace);
|
|
133
|
+
}
|
|
134
|
+
export async function removeDisabledPlugin(plugin, marketplace) {
|
|
135
|
+
const m = await readManifestV2();
|
|
136
|
+
const idx = m.entries.findIndex((e) => isDisabledPluginEntry(e) && e.plugin === plugin && e.marketplace === marketplace);
|
|
137
|
+
if (idx === -1)
|
|
138
|
+
return false;
|
|
139
|
+
m.entries.splice(idx, 1);
|
|
140
|
+
await writeManifestV2(m);
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
118
143
|
// --- Legacy-compatible API (still used by cleaner/cli pending Task 8) ---
|
|
119
144
|
export async function readManifest() {
|
|
120
145
|
const m = await readManifestV2();
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Validates plugin name then shells out to `claude plugin disable <name>`. */
|
|
2
|
+
export declare function disablePlugin(name: string): Promise<void>;
|
|
3
|
+
/** Validates plugin name then shells out to `claude plugin enable <name>`. */
|
|
4
|
+
export declare function enablePlugin(name: string): Promise<void>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
const PLUGIN_NAME_RE = /^[a-zA-Z0-9_-]+$/;
|
|
3
|
+
function validateName(name) {
|
|
4
|
+
if (!PLUGIN_NAME_RE.test(name)) {
|
|
5
|
+
throw new Error(`Refusing to operate on suspicious plugin name: ${name}`);
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
function runPluginCommand(subcommand, name) {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
execFile('claude', ['plugin', subcommand, name], { timeout: 30000 }, (err, _stdout, stderr) => {
|
|
11
|
+
if (err) {
|
|
12
|
+
const detail = stderr?.trim() ? `: ${stderr.trim()}` : '';
|
|
13
|
+
reject(new Error(`${err.message}${detail}`));
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
if (stderr?.trim()) {
|
|
17
|
+
reject(new Error(stderr.trim()));
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
resolve();
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
/** Validates plugin name then shells out to `claude plugin disable <name>`. */
|
|
25
|
+
export async function disablePlugin(name) {
|
|
26
|
+
validateName(name);
|
|
27
|
+
return runPluginCommand('disable', name);
|
|
28
|
+
}
|
|
29
|
+
/** Validates plugin name then shells out to `claude plugin enable <name>`. */
|
|
30
|
+
export async function enablePlugin(name) {
|
|
31
|
+
validateName(name);
|
|
32
|
+
return runPluginCommand('enable', name);
|
|
33
|
+
}
|
package/dist/report.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { ScanResult, ManifestEntry } from './types.js';
|
|
1
|
+
import type { ScanResult, ManifestEntry, PluginBreakdown } from './types.js';
|
|
2
|
+
import { formatPluginsTable } from './scanner/plugin-breakdown.js';
|
|
2
3
|
export interface BreakdownRow {
|
|
3
4
|
label: string;
|
|
4
5
|
before: string;
|
|
@@ -17,7 +18,13 @@ export interface ReportData {
|
|
|
17
18
|
monthlySavings: number;
|
|
18
19
|
sessionsPerDay: number;
|
|
19
20
|
breakdown: BreakdownRow[];
|
|
21
|
+
unusedPlugins: {
|
|
22
|
+
count: number;
|
|
23
|
+
tokens: number;
|
|
24
|
+
};
|
|
20
25
|
}
|
|
21
26
|
export declare function calculateReport(scanBefore: ScanResult, scanAfter: ScanResult, movedEntries: ManifestEntry[], sessionsPerDay?: number): ReportData;
|
|
22
27
|
export declare function formatReportBox(data: ReportData): string;
|
|
23
28
|
export declare function formatScanSummary(result: ScanResult): string;
|
|
29
|
+
export { formatPluginsTable };
|
|
30
|
+
export type { PluginBreakdown };
|
package/dist/report.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { homedir } from 'node:os';
|
|
2
2
|
import { isUsingFallback } from './tokenizer.js';
|
|
3
|
+
import { formatPluginsTable } from './scanner/plugin-breakdown.js';
|
|
3
4
|
// Claude Code encodes /Users/leo.new/foo as -Users-leo-new-foo
|
|
4
5
|
const HOME_PREFIX = homedir().replace(/\//g, '-').replace(/\./g, '-');
|
|
5
6
|
const SESSIONS_PER_DAY_DEFAULT = 2;
|
|
@@ -49,6 +50,11 @@ export function calculateReport(scanBefore, scanAfter, movedEntries, sessionsPer
|
|
|
49
50
|
saved: `~${(before - after).toLocaleString()}`,
|
|
50
51
|
},
|
|
51
52
|
];
|
|
53
|
+
const unusedPluginIssues = scanAfter.issues.filter((i) => i.type === 'unused_plugin');
|
|
54
|
+
const unusedPlugins = {
|
|
55
|
+
count: unusedPluginIssues.length,
|
|
56
|
+
tokens: unusedPluginIssues.reduce((s, i) => s + (i.tokens || 0), 0),
|
|
57
|
+
};
|
|
52
58
|
return {
|
|
53
59
|
before,
|
|
54
60
|
after,
|
|
@@ -58,6 +64,7 @@ export function calculateReport(scanBefore, scanAfter, movedEntries, sessionsPer
|
|
|
58
64
|
monthlySavings,
|
|
59
65
|
sessionsPerDay,
|
|
60
66
|
breakdown,
|
|
67
|
+
unusedPlugins,
|
|
61
68
|
};
|
|
62
69
|
}
|
|
63
70
|
export function formatReportBox(data) {
|
|
@@ -69,7 +76,7 @@ export function formatReportBox(data) {
|
|
|
69
76
|
};
|
|
70
77
|
const top = '\u256d' + '\u2500'.repeat(W) + '\u256e';
|
|
71
78
|
const bot = '\u2570' + '\u2500'.repeat(W) + '\u256f';
|
|
72
|
-
const blank = '\u2502' + ' '.repeat(W) + '\u2502';
|
|
79
|
+
const blank = '\u2502' + ' '.repeat(W - 2) + '\u2502';
|
|
73
80
|
lines.push(top);
|
|
74
81
|
lines.push(`\u2502${pad(' claude-slim report')}\u2502`);
|
|
75
82
|
lines.push(blank);
|
|
@@ -94,6 +101,19 @@ export function formatReportBox(data) {
|
|
|
94
101
|
lines.push(blank);
|
|
95
102
|
lines.push(`\u2502${pad(' \u26a0 Token counts are approximations (bytes/4)')}\u2502`);
|
|
96
103
|
}
|
|
104
|
+
if (data.unusedPlugins.count > 0) {
|
|
105
|
+
const n = data.unusedPlugins.count;
|
|
106
|
+
const pluginWord = n === 1 ? 'unused plugin' : 'unused plugins';
|
|
107
|
+
let hintText;
|
|
108
|
+
if (data.unusedPlugins.tokens > 0) {
|
|
109
|
+
hintText = ` ! ${n} ${pluginWord} (~${data.unusedPlugins.tokens.toLocaleString()} tok).`;
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
hintText = ` ! ${n} ${pluginWord}. Run: claude-slim`;
|
|
113
|
+
}
|
|
114
|
+
lines.push(blank);
|
|
115
|
+
lines.push(`\u2502${pad(hintText)}\u2502`);
|
|
116
|
+
}
|
|
97
117
|
lines.push(bot);
|
|
98
118
|
// Breakdown table
|
|
99
119
|
if (data.breakdown.length > 0) {
|
|
@@ -216,6 +236,14 @@ export function formatScanSummary(result) {
|
|
|
216
236
|
lines.push(` ${selected} ${i + 1}. \x1b[${color}m[${tierLabel}]\x1b[0m ${issue.type}: ${issue.name}${detail}${tokStr}${permanent}`);
|
|
217
237
|
}
|
|
218
238
|
}
|
|
239
|
+
// --- PLUGINS BREAKDOWN ---
|
|
240
|
+
if (result.pluginBreakdown && result.pluginBreakdown.length > 0) {
|
|
241
|
+
const totalInstalled = result.pluginBreakdown.length;
|
|
242
|
+
const totalEnabled = result.pluginBreakdown.filter((p) => p.status !== 'disabled').length;
|
|
243
|
+
lines.push(formatPluginsTable(result.pluginBreakdown, totalInstalled, totalEnabled));
|
|
244
|
+
}
|
|
219
245
|
lines.push('');
|
|
220
246
|
return lines.join('\n');
|
|
221
247
|
}
|
|
248
|
+
// Re-export for external callers (e.g. tests)
|
|
249
|
+
export { formatPluginsTable };
|
|
@@ -2,3 +2,6 @@ export declare const STALE_DAYS = 90;
|
|
|
2
2
|
export declare const OVERSIZED_SKILL_BYTES = 10240;
|
|
3
3
|
export declare const OVERSIZED_MEMORY_BYTES = 5120;
|
|
4
4
|
export declare const SKILL_PROMPT_OVERHEAD_TOKENS = 30;
|
|
5
|
+
export declare const DEFERRED_TOOL_OVERHEAD_TOKENS = 8;
|
|
6
|
+
export declare const COMMAND_OVERHEAD_TOKENS = 10;
|
|
7
|
+
export declare const MCP_SERVER_TOOLS_AVG = 10;
|
|
@@ -2,3 +2,11 @@ export const STALE_DAYS = 90;
|
|
|
2
2
|
export const OVERSIZED_SKILL_BYTES = 10240;
|
|
3
3
|
export const OVERSIZED_MEMORY_BYTES = 5120;
|
|
4
4
|
export const SKILL_PROMPT_OVERHEAD_TOKENS = 30;
|
|
5
|
+
// Calibrated from owner system prompt sample:
|
|
6
|
+
// Deferred tools list ~1500 tokens / ~209 MCP tools ≈ 7.2 tok/tool → rounded up to 8.
|
|
7
|
+
export const DEFERRED_TOOL_OVERHEAD_TOKENS = 8;
|
|
8
|
+
// Estimated from slash-command list format in system prompt: ~10 tok/command.
|
|
9
|
+
export const COMMAND_OVERHEAD_TOKENS = 10;
|
|
10
|
+
// Average tools per MCP server (used when per-server tool count is unknown).
|
|
11
|
+
// Most plugin MCP servers expose 5–15 tools; 10 is a reasonable midpoint.
|
|
12
|
+
export const MCP_SERVER_TOOLS_AVG = 10;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { SkillInfo, BrokenSymlink, MemoryFile, PluginInfo, Issue } from '../types.js';
|
|
2
2
|
import type { TempCache } from './plugin-skills.js';
|
|
3
3
|
import type { StaleProject } from './memory.js';
|
|
4
|
+
import type { PluginSurfaces } from './plugin-surfaces.js';
|
|
4
5
|
export interface DetectorContext {
|
|
5
6
|
localSkills: SkillInfo[];
|
|
6
7
|
pluginSkills: SkillInfo[];
|
|
@@ -14,6 +15,16 @@ export interface DetectorContext {
|
|
|
14
15
|
recentSkillInvocations: Set<string>;
|
|
15
16
|
sessionDataAvailable: boolean;
|
|
16
17
|
lookbackDays: number;
|
|
18
|
+
pluginSurfaces: PluginSurfaces[];
|
|
19
|
+
enabledPlugins: Array<{
|
|
20
|
+
name: string;
|
|
21
|
+
marketplace: string;
|
|
22
|
+
}>;
|
|
23
|
+
recentMcpPrefixes: Set<string>;
|
|
24
|
+
recentCommands: Set<string>;
|
|
25
|
+
totalUserCallableInvocations: number;
|
|
26
|
+
sessionsInWindow: number;
|
|
27
|
+
pluginCosts: Map<string, number>;
|
|
17
28
|
}
|
|
18
29
|
export interface Detector {
|
|
19
30
|
name: string;
|