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 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
- assertInsideClaudeDir(issue.path);
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
- assertInsideClaudeDir(entry.from);
153
- if (entry.type === 'broken_symlink') {
154
- throw new Error(`Broken symlinks cannot be restored (${entry.name})`);
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 (entry.type === 'disabled_plugin') {
157
- throw new Error(`Plugins must be reinstalled: claude plugin install ${entry.name}`);
194
+ if (legacyEntry.type === 'disabled_plugin') {
195
+ throw new Error(`Plugins must be reinstalled: claude plugin install ${legacyEntry.name}`);
158
196
  }
159
- if (entry.type === 'temp_cache') {
160
- throw new Error(`Temp caches were deleted and cannot be restored (${entry.name})`);
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 (entry.type === 'stale_project') {
164
- const backupDir = join(disabledDir, 'memory-backup', entry.name);
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(entry.from)) {
167
- throw new Error(`Cannot restore: ${entry.from} already exists. ` +
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(entry.from), { recursive: true });
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, entry.from);
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 = entry.name.replace(/\//g, '--');
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 "${entry.name}" at ${src}. ` +
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(entry.from)) {
183
- throw new Error(`Cannot restore: ${entry.from} already exists. ` +
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(entry.from), { recursive: true });
187
- await rename(src, entry.from);
231
+ await mkdir(dirname(legacyEntry.from), { recursive: true });
232
+ await rename(src, legacyEntry.from);
188
233
  }
189
- await removeEntry(entry.name);
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: parseInt(opts.lookbackDays, 10) || 60 });
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: parseInt(opts.lookbackDays, 10) || 60,
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: parseInt(opts.sessionsPerDay, 10) || 2,
68
- lookbackDays: parseInt(opts.lookbackDays, 10) || 60,
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 entries = await readManifest();
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 = entries.filter((e) => e.type !== 'broken_symlink');
92
+ const disabled = legacyEntries.filter((e) => e.type !== 'broken_symlink');
79
93
  const NON_RESTORABLE = new Set(['temp_cache', 'disabled_plugin']);
80
- const restorable = disabled.filter((e) => !NON_RESTORABLE.has(e.type));
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
- const date = new Date(e.date).toLocaleDateString();
91
- console.log(` ${i + 1}. ${e.name} (${e.type}, disabled ${date})`);
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\u2022 ${e.name} (${date}) — ${hint}\x1b[0m`);
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(restorable[idx]);
119
- console.log(` \x1b[32m\u2713\x1b[0m Restored: ${restorable[idx].name}`);
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\u2717\x1b[0m ${restorable[idx].name}: ${msg}`);
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: parseInt(opts.lookbackDays, 10) || 60 });
138
- const entries = await readManifest();
139
- const movedEntries = entries.filter((e) => e.tokenCount && e.tokenCount > 0);
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 = parseInt(opts.sessionsPerDay, 10) || 2;
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 \u2192 accept pre-selected (Tier 1 only)');
199
- console.log(' 1,3,5 \u2192 select specific items');
200
- console.log(' all \u2192 select everything');
201
- console.log(' none \u2192 cancel');
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
- // Auto mode or non-TTY: select Tier 1 only
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
- if (!opts.auto) {
210
- console.log(' \x1b[33m\u26a0 Non-interactive mode detected, auto-selecting Tier 1\x1b[0m\n');
211
- }
212
- else {
213
- console.log(` \x1b[36m\u2192 Auto mode: selecting ${selectedIssues.length} Tier 1 item(s)\x1b[0m\n`);
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(` \u2022 ${issue.name} (${issue.type})`);
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(` \u2022 ${err.name}: ${err.error}`);
281
+ console.log(` ${err.name}: ${err.error}`);
243
282
  }
244
283
  console.log('');
245
284
  }
@@ -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: ManifestEntry): Promise<void>;
7
+ export declare function addEntry(entry: AnyManifestEntry): Promise<void>;
8
8
  export declare function removeEntry(name: string): Promise<ManifestEntry | null>;
9
- export declare function readManifest(): Promise<ManifestEntry[]>;
10
- export declare function appendManifest(entry: ManifestEntry): Promise<void>;
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;