claude-slim 2.6.1 → 2.7.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 -0
- package/dist/cleaner.d.ts +2 -2
- package/dist/cleaner.js +47 -22
- package/dist/cli.js +37 -18
- 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 +10 -0
- package/dist/scanner/detectors.js +44 -0
- package/dist/scanner/disabled-plugins.d.ts +7 -0
- package/dist/scanner/disabled-plugins.js +38 -0
- package/dist/scanner/index.js +30 -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 +157 -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,8 @@
|
|
|
1
1
|
import { rename, readdir, rmdir, rm, unlink, lstat, mkdir } from 'node:fs/promises';
|
|
2
2
|
import { join, dirname } from 'node:path';
|
|
3
|
-
import { appendManifest, ensureDisabledDir, getDisabledDir, removeEntry } from './manifest.js';
|
|
3
|
+
import { appendManifest, ensureDisabledDir, getDisabledDir, removeEntry, recordDisabledPlugin, removeDisabledPlugin } from './manifest.js';
|
|
4
4
|
import { assertInsideClaudeDir, getSkillsDir } from './paths.js';
|
|
5
|
+
import { disablePlugin, enablePlugin } from './plugin-runtime.js';
|
|
5
6
|
async function pathExists(p) {
|
|
6
7
|
try {
|
|
7
8
|
await lstat(p);
|
|
@@ -33,7 +34,10 @@ export async function cleanIssues(issues) {
|
|
|
33
34
|
const errors = [];
|
|
34
35
|
for (const issue of issues) {
|
|
35
36
|
try {
|
|
36
|
-
|
|
37
|
+
// unused_plugin and report-only types don't touch filesystem paths directly
|
|
38
|
+
if (issue.type !== 'unused_plugin' && issue.type !== 'oversized_memory' && issue.type !== 'disabled_plugin') {
|
|
39
|
+
assertInsideClaudeDir(issue.path);
|
|
40
|
+
}
|
|
37
41
|
if (issue.type === 'broken_symlink') {
|
|
38
42
|
await unlink(issue.path);
|
|
39
43
|
const entry = {
|
|
@@ -114,6 +118,19 @@ export async function cleanIssues(issues) {
|
|
|
114
118
|
await recordOrRollback(entry, () => rename(backupDir, issue.path));
|
|
115
119
|
moved.push(entry);
|
|
116
120
|
}
|
|
121
|
+
else if (issue.type === 'unused_plugin') {
|
|
122
|
+
await disablePlugin(issue.name);
|
|
123
|
+
// Record in manifest; if that fails, roll back the disable
|
|
124
|
+
try {
|
|
125
|
+
await recordDisabledPlugin(issue.name, issue.marketplace ?? 'unknown');
|
|
126
|
+
}
|
|
127
|
+
catch (e) {
|
|
128
|
+
await enablePlugin(issue.name).catch(() => { });
|
|
129
|
+
throw e;
|
|
130
|
+
}
|
|
131
|
+
// unused_plugin doesn't contribute to moved (no ManifestEntry shape), track skipped
|
|
132
|
+
skipped.push(issue.name);
|
|
133
|
+
}
|
|
117
134
|
else if (issue.type === 'oversized_memory' || issue.type === 'disabled_plugin') {
|
|
118
135
|
// Report only — user manages these manually
|
|
119
136
|
skipped.push(issue.name);
|
|
@@ -149,42 +166,50 @@ async function cleanEmptyDirs(dir) {
|
|
|
149
166
|
catch { /* skip */ }
|
|
150
167
|
}
|
|
151
168
|
export async function restoreItem(entry) {
|
|
152
|
-
|
|
153
|
-
if (entry
|
|
154
|
-
|
|
169
|
+
// Handle new-style disabled_plugin entries (plugin + marketplace shape)
|
|
170
|
+
if ('plugin' in entry && 'marketplace' in entry) {
|
|
171
|
+
const pluginEntry = entry;
|
|
172
|
+
await enablePlugin(pluginEntry.plugin);
|
|
173
|
+
await removeDisabledPlugin(pluginEntry.plugin, pluginEntry.marketplace);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const legacyEntry = entry;
|
|
177
|
+
assertInsideClaudeDir(legacyEntry.from);
|
|
178
|
+
if (legacyEntry.type === 'broken_symlink') {
|
|
179
|
+
throw new Error(`Broken symlinks cannot be restored (${legacyEntry.name})`);
|
|
155
180
|
}
|
|
156
|
-
if (
|
|
157
|
-
throw new Error(`Plugins must be reinstalled: claude plugin install ${
|
|
181
|
+
if (legacyEntry.type === 'disabled_plugin') {
|
|
182
|
+
throw new Error(`Plugins must be reinstalled: claude plugin install ${legacyEntry.name}`);
|
|
158
183
|
}
|
|
159
|
-
if (
|
|
160
|
-
throw new Error(`Temp caches were deleted and cannot be restored (${
|
|
184
|
+
if (legacyEntry.type === 'temp_cache') {
|
|
185
|
+
throw new Error(`Temp caches were deleted and cannot be restored (${legacyEntry.name})`);
|
|
161
186
|
}
|
|
162
187
|
const disabledDir = getDisabledDir();
|
|
163
|
-
if (
|
|
164
|
-
const backupDir = join(disabledDir, 'memory-backup',
|
|
188
|
+
if (legacyEntry.type === 'stale_project') {
|
|
189
|
+
const backupDir = join(disabledDir, 'memory-backup', legacyEntry.name);
|
|
165
190
|
// Refuse to overwrite user's current state
|
|
166
|
-
if (await pathExists(
|
|
167
|
-
throw new Error(`Cannot restore: ${
|
|
191
|
+
if (await pathExists(legacyEntry.from)) {
|
|
192
|
+
throw new Error(`Cannot restore: ${legacyEntry.from} already exists. ` +
|
|
168
193
|
`Remove or rename it first.`);
|
|
169
194
|
}
|
|
170
|
-
await mkdir(dirname(
|
|
195
|
+
await mkdir(dirname(legacyEntry.from), { recursive: true });
|
|
171
196
|
// Atomic directory rename — requires same FS (guaranteed since both paths are under ~/.claude/)
|
|
172
|
-
await rename(backupDir,
|
|
197
|
+
await rename(backupDir, legacyEntry.from);
|
|
173
198
|
}
|
|
174
199
|
else {
|
|
175
200
|
// Restore skill directory using the same naming as cleanIssues
|
|
176
|
-
const safeName =
|
|
201
|
+
const safeName = legacyEntry.name.replace(/\//g, '--');
|
|
177
202
|
const src = join(disabledDir, safeName);
|
|
178
203
|
if (!(await pathExists(src))) {
|
|
179
|
-
throw new Error(`Backup not found for "${
|
|
204
|
+
throw new Error(`Backup not found for "${legacyEntry.name}" at ${src}. ` +
|
|
180
205
|
`It may have been manually removed.`);
|
|
181
206
|
}
|
|
182
|
-
if (await pathExists(
|
|
183
|
-
throw new Error(`Cannot restore: ${
|
|
207
|
+
if (await pathExists(legacyEntry.from)) {
|
|
208
|
+
throw new Error(`Cannot restore: ${legacyEntry.from} already exists. ` +
|
|
184
209
|
`Remove or rename it first.`);
|
|
185
210
|
}
|
|
186
|
-
await mkdir(dirname(
|
|
187
|
-
await rename(src,
|
|
211
|
+
await mkdir(dirname(legacyEntry.from), { recursive: true });
|
|
212
|
+
await rename(src, legacyEntry.from);
|
|
188
213
|
}
|
|
189
|
-
await removeEntry(
|
|
214
|
+
await removeEntry(legacyEntry.name);
|
|
190
215
|
}
|
package/dist/cli.js
CHANGED
|
@@ -73,12 +73,17 @@ program
|
|
|
73
73
|
.command('restore')
|
|
74
74
|
.description('Restore previously disabled items')
|
|
75
75
|
.action(async () => {
|
|
76
|
-
const
|
|
76
|
+
const allEntries = await readManifest();
|
|
77
|
+
// Separate plugin entries (DisabledPluginEntry shape) from legacy ManifestEntry
|
|
78
|
+
const pluginEntries = allEntries.filter((e) => 'plugin' in e && 'marketplace' in e);
|
|
79
|
+
const legacyEntries = allEntries.filter((e) => !('plugin' in e && 'marketplace' in e));
|
|
77
80
|
// v2 manifest contains only currently-disabled entries (restored ones are removed)
|
|
78
|
-
const disabled =
|
|
81
|
+
const disabled = legacyEntries.filter((e) => e.type !== 'broken_symlink');
|
|
79
82
|
const NON_RESTORABLE = new Set(['temp_cache', 'disabled_plugin']);
|
|
80
|
-
const
|
|
83
|
+
const restorableLegacy = disabled.filter((e) => !NON_RESTORABLE.has(e.type));
|
|
81
84
|
const infoOnly = disabled.filter((e) => NON_RESTORABLE.has(e.type));
|
|
85
|
+
// Combine restorable: legacy skill/project entries + plugin entries
|
|
86
|
+
const restorable = [...restorableLegacy, ...pluginEntries];
|
|
82
87
|
if (restorable.length === 0 && infoOnly.length === 0) {
|
|
83
88
|
console.log('\n Nothing to restore.\n');
|
|
84
89
|
return;
|
|
@@ -87,8 +92,16 @@ program
|
|
|
87
92
|
console.log('\n Restorable items:\n');
|
|
88
93
|
for (let i = 0; i < restorable.length; i++) {
|
|
89
94
|
const e = restorable[i];
|
|
90
|
-
|
|
91
|
-
|
|
95
|
+
if ('plugin' in e && 'marketplace' in e) {
|
|
96
|
+
const pe = e;
|
|
97
|
+
const date = new Date(pe.disabledAt).toLocaleDateString();
|
|
98
|
+
console.log(` ${i + 1}. [plugin] ${pe.plugin} @ ${pe.marketplace} (disabled ${date})`);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
const le = e;
|
|
102
|
+
const date = new Date(le.date).toLocaleDateString();
|
|
103
|
+
console.log(` ${i + 1}. ${le.name} (${le.type}, disabled ${date})`);
|
|
104
|
+
}
|
|
92
105
|
}
|
|
93
106
|
}
|
|
94
107
|
if (infoOnly.length > 0) {
|
|
@@ -98,7 +111,7 @@ program
|
|
|
98
111
|
const hint = e.type === 'disabled_plugin'
|
|
99
112
|
? `reinstall: claude plugin install ${e.name}`
|
|
100
113
|
: 'deleted, cannot restore';
|
|
101
|
-
console.log(` \x1b[90m
|
|
114
|
+
console.log(` \x1b[90m• ${e.name} (${date}) — ${hint}\x1b[0m`);
|
|
102
115
|
}
|
|
103
116
|
}
|
|
104
117
|
if (restorable.length === 0) {
|
|
@@ -114,14 +127,18 @@ program
|
|
|
114
127
|
}
|
|
115
128
|
let restored = 0;
|
|
116
129
|
for (const idx of indices) {
|
|
130
|
+
const e = restorable[idx];
|
|
131
|
+
const label = 'plugin' in e && 'marketplace' in e
|
|
132
|
+
? e.plugin
|
|
133
|
+
: e.name;
|
|
117
134
|
try {
|
|
118
|
-
await restoreItem(
|
|
119
|
-
console.log(` \x1b[32m
|
|
135
|
+
await restoreItem(e);
|
|
136
|
+
console.log(` \x1b[32m✓\x1b[0m Restored: ${label}`);
|
|
120
137
|
restored++;
|
|
121
138
|
}
|
|
122
139
|
catch (err) {
|
|
123
140
|
const msg = err instanceof Error ? err.message : String(err);
|
|
124
|
-
console.log(` \x1b[31m
|
|
141
|
+
console.log(` \x1b[31m✗\x1b[0m ${label}: ${msg}`);
|
|
125
142
|
}
|
|
126
143
|
}
|
|
127
144
|
console.log(`\n Restored ${restored} item(s).\n`);
|
|
@@ -135,7 +152,9 @@ program
|
|
|
135
152
|
.action(async (opts) => {
|
|
136
153
|
await initTokenizer();
|
|
137
154
|
const result = await scan({ lookbackDays: parseInt(opts.lookbackDays, 10) || 60 });
|
|
138
|
-
const
|
|
155
|
+
const allEntries = await readManifest();
|
|
156
|
+
// Filter to legacy-style entries only (those with tokenCount/name/from fields)
|
|
157
|
+
const entries = allEntries.filter((e) => !('plugin' in e && 'marketplace' in e));
|
|
139
158
|
const movedEntries = entries.filter((e) => e.tokenCount && e.tokenCount > 0);
|
|
140
159
|
if (movedEntries.length === 0) {
|
|
141
160
|
console.log('\n No previous cleanup found. Run `claude-slim clean` first.\n');
|
|
@@ -195,10 +214,10 @@ async function runCleanPipeline(opts) {
|
|
|
195
214
|
let selectedIssues;
|
|
196
215
|
if (isInteractive) {
|
|
197
216
|
console.log(' Actions:');
|
|
198
|
-
console.log(' Enter
|
|
199
|
-
console.log(' 1,3,5
|
|
200
|
-
console.log(' all
|
|
201
|
-
console.log(' none
|
|
217
|
+
console.log(' Enter → accept pre-selected (Tier 1 only)');
|
|
218
|
+
console.log(' 1,3,5 → select specific items');
|
|
219
|
+
console.log(' all → select everything');
|
|
220
|
+
console.log(' none → cancel');
|
|
202
221
|
console.log('');
|
|
203
222
|
const selection = await askUser(' Your choice: ');
|
|
204
223
|
selectedIssues = resolveSelection(selection, result.issues);
|
|
@@ -207,10 +226,10 @@ async function runCleanPipeline(opts) {
|
|
|
207
226
|
// Auto mode or non-TTY: select Tier 1 only
|
|
208
227
|
selectedIssues = result.issues.filter((i) => i.tier === 1);
|
|
209
228
|
if (!opts.auto) {
|
|
210
|
-
console.log(' \x1b[33m
|
|
229
|
+
console.log(' \x1b[33m⚠ Non-interactive mode detected, auto-selecting Tier 1\x1b[0m\n');
|
|
211
230
|
}
|
|
212
231
|
else {
|
|
213
|
-
console.log(` \x1b[36m
|
|
232
|
+
console.log(` \x1b[36m→ Auto mode: selecting ${selectedIssues.length} Tier 1 item(s)\x1b[0m\n`);
|
|
214
233
|
}
|
|
215
234
|
}
|
|
216
235
|
if (selectedIssues.length === 0) {
|
|
@@ -221,7 +240,7 @@ async function runCleanPipeline(opts) {
|
|
|
221
240
|
if (opts.dryRun) {
|
|
222
241
|
console.log('\n \x1b[33m[DRY RUN]\x1b[0m Would disable:');
|
|
223
242
|
for (const issue of selectedIssues) {
|
|
224
|
-
console.log(`
|
|
243
|
+
console.log(` • ${issue.name} (${issue.type})`);
|
|
225
244
|
}
|
|
226
245
|
console.log(`\n Estimated token savings: ~${selectedIssues.reduce((s, i) => s + i.tokens, 0).toLocaleString()}`);
|
|
227
246
|
console.log('');
|
|
@@ -239,7 +258,7 @@ async function runCleanPipeline(opts) {
|
|
|
239
258
|
if (cleanResult.errors.length > 0) {
|
|
240
259
|
console.log(' \x1b[31mErrors:\x1b[0m');
|
|
241
260
|
for (const err of cleanResult.errors) {
|
|
242
|
-
console.log(`
|
|
261
|
+
console.log(` • ${err.name}: ${err.error}`);
|
|
243
262
|
}
|
|
244
263
|
console.log('');
|
|
245
264
|
}
|
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,15 @@ 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;
|
|
17
27
|
}
|
|
18
28
|
export interface Detector {
|
|
19
29
|
name: string;
|
|
@@ -175,6 +175,49 @@ const unusedSkillDetector = {
|
|
|
175
175
|
return issues;
|
|
176
176
|
},
|
|
177
177
|
};
|
|
178
|
+
const unusedPluginDetector = {
|
|
179
|
+
name: 'unused_plugin',
|
|
180
|
+
detect({ pluginSurfaces, enabledPlugins, recentSkillInvocations, recentMcpPrefixes, recentCommands, totalUserCallableInvocations, sessionsInWindow, lookbackDays, }) {
|
|
181
|
+
// (a) Global suppression: too few sessions to draw a conclusion
|
|
182
|
+
if (sessionsInWindow < 3)
|
|
183
|
+
return [];
|
|
184
|
+
// (b) Global suppression: no user-callable activity — schema change suspected
|
|
185
|
+
if (totalUserCallableInvocations === 0)
|
|
186
|
+
return [];
|
|
187
|
+
const enabledNames = new Set(enabledPlugins.map((p) => p.name));
|
|
188
|
+
const issues = [];
|
|
189
|
+
for (const ps of pluginSurfaces) {
|
|
190
|
+
// Inner-join: only consider plugins reported as enabled (filters .git noise)
|
|
191
|
+
if (!enabledNames.has(ps.pluginName))
|
|
192
|
+
continue;
|
|
193
|
+
// (c) Per-plugin suppression: no user-callable surface (agent/hook only)
|
|
194
|
+
const userCallableCount = ps.skills.length + ps.mcpToolPrefixes.length + ps.commands.length;
|
|
195
|
+
if (userCallableCount === 0)
|
|
196
|
+
continue;
|
|
197
|
+
// (d) suppression was intended to skip recently-installed plugins by
|
|
198
|
+
// `installedAt` mtime, but dogfooding showed `claude plugin update` resets
|
|
199
|
+
// cache mtime indiscriminately, making install age unreliable. Dropped.
|
|
200
|
+
// Tier 3 (never auto-selected) lets users sanity-check any flagged plugin.
|
|
201
|
+
// Usage check: any skill/mcp/command from this plugin invoked?
|
|
202
|
+
const usedSkill = ps.skills.some((s) => recentSkillInvocations.has(s) ||
|
|
203
|
+
recentSkillInvocations.has(`${ps.pluginName}:${s}`));
|
|
204
|
+
const usedMcp = ps.mcpToolPrefixes.some((p) => recentMcpPrefixes.has(p));
|
|
205
|
+
const usedCmd = ps.commands.some((c) => recentCommands.has(c));
|
|
206
|
+
if (usedSkill || usedMcp || usedCmd)
|
|
207
|
+
continue;
|
|
208
|
+
issues.push({
|
|
209
|
+
type: 'unused_plugin',
|
|
210
|
+
tier: 3,
|
|
211
|
+
name: ps.pluginName,
|
|
212
|
+
marketplace: ps.marketplace,
|
|
213
|
+
detail: `not invoked in ${lookbackDays}d (${ps.marketplace})`,
|
|
214
|
+
tokens: 0,
|
|
215
|
+
path: ps.installDir,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
return issues;
|
|
219
|
+
},
|
|
220
|
+
};
|
|
178
221
|
const disabledPluginDetector = {
|
|
179
222
|
name: 'disabled_plugin',
|
|
180
223
|
detect({ plugins, disabledPlugins }) {
|
|
@@ -206,6 +249,7 @@ export const detectors = [
|
|
|
206
249
|
oversizedMemoryDetector,
|
|
207
250
|
staleProjectDetector,
|
|
208
251
|
unusedSkillDetector,
|
|
252
|
+
unusedPluginDetector,
|
|
209
253
|
disabledPluginDetector,
|
|
210
254
|
];
|
|
211
255
|
export function classifyIssues(ctx, registry = detectors) {
|
|
@@ -1,2 +1,9 @@
|
|
|
1
1
|
export declare function parseDisabledPlugins(output: string): Set<string>;
|
|
2
2
|
export declare function getDisabledPlugins(): Promise<Set<string>>;
|
|
3
|
+
export interface InstalledPlugin {
|
|
4
|
+
name: string;
|
|
5
|
+
marketplace: string;
|
|
6
|
+
enabled: boolean;
|
|
7
|
+
}
|
|
8
|
+
export declare function parseInstalledPlugins(output: string): InstalledPlugin[];
|
|
9
|
+
export declare function getInstalledPlugins(): Promise<InstalledPlugin[]>;
|
|
@@ -24,3 +24,41 @@ export function parseDisabledPlugins(output) {
|
|
|
24
24
|
export async function getDisabledPlugins() {
|
|
25
25
|
return parseDisabledPlugins(await runCommand('claude', ['plugin', 'list']));
|
|
26
26
|
}
|
|
27
|
+
// Parse `claude plugin list` output into per-plugin entries. Unlike
|
|
28
|
+
// parseDisabledPlugins (which collapses to marketplace name for cache-dir
|
|
29
|
+
// matching), this preserves the full <plugin>@<marketplace> pair so callers
|
|
30
|
+
// can match against plugin surface scanning by exact plugin name.
|
|
31
|
+
export function parseInstalledPlugins(output) {
|
|
32
|
+
const plugins = [];
|
|
33
|
+
if (!output)
|
|
34
|
+
return plugins;
|
|
35
|
+
let current = null;
|
|
36
|
+
for (const line of output.split('\n')) {
|
|
37
|
+
const trimmed = line.trim();
|
|
38
|
+
if (trimmed.startsWith('❯')) {
|
|
39
|
+
const full = trimmed.split('❯')[1]?.trim() || '';
|
|
40
|
+
const at = full.indexOf('@');
|
|
41
|
+
if (at > 0) {
|
|
42
|
+
current = { name: full.slice(0, at), marketplace: full.slice(at + 1) };
|
|
43
|
+
}
|
|
44
|
+
else if (full) {
|
|
45
|
+
current = { name: full, marketplace: full };
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
current = null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
else if (current && trimmed.toLowerCase().includes('disabled')) {
|
|
52
|
+
plugins.push({ ...current, enabled: false });
|
|
53
|
+
current = null;
|
|
54
|
+
}
|
|
55
|
+
else if (current && trimmed.toLowerCase().includes('enabled')) {
|
|
56
|
+
plugins.push({ ...current, enabled: true });
|
|
57
|
+
current = null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return plugins;
|
|
61
|
+
}
|
|
62
|
+
export async function getInstalledPlugins() {
|
|
63
|
+
return parseInstalledPlugins(await runCommand('claude', ['plugin', 'list']));
|
|
64
|
+
}
|