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 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
- assertInsideClaudeDir(issue.path);
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
- assertInsideClaudeDir(entry.from);
153
- if (entry.type === 'broken_symlink') {
154
- throw new Error(`Broken symlinks cannot be restored (${entry.name})`);
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 (entry.type === 'disabled_plugin') {
157
- throw new Error(`Plugins must be reinstalled: claude plugin install ${entry.name}`);
181
+ if (legacyEntry.type === 'disabled_plugin') {
182
+ throw new Error(`Plugins must be reinstalled: claude plugin install ${legacyEntry.name}`);
158
183
  }
159
- if (entry.type === 'temp_cache') {
160
- throw new Error(`Temp caches were deleted and cannot be restored (${entry.name})`);
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 (entry.type === 'stale_project') {
164
- const backupDir = join(disabledDir, 'memory-backup', entry.name);
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(entry.from)) {
167
- throw new Error(`Cannot restore: ${entry.from} already exists. ` +
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(entry.from), { recursive: true });
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, entry.from);
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 = entry.name.replace(/\//g, '--');
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 "${entry.name}" at ${src}. ` +
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(entry.from)) {
183
- throw new Error(`Cannot restore: ${entry.from} already exists. ` +
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(entry.from), { recursive: true });
187
- await rename(src, entry.from);
211
+ await mkdir(dirname(legacyEntry.from), { recursive: true });
212
+ await rename(src, legacyEntry.from);
188
213
  }
189
- await removeEntry(entry.name);
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 entries = await readManifest();
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 = entries.filter((e) => e.type !== 'broken_symlink');
81
+ const disabled = legacyEntries.filter((e) => e.type !== 'broken_symlink');
79
82
  const NON_RESTORABLE = new Set(['temp_cache', 'disabled_plugin']);
80
- const restorable = disabled.filter((e) => !NON_RESTORABLE.has(e.type));
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
- const date = new Date(e.date).toLocaleDateString();
91
- console.log(` ${i + 1}. ${e.name} (${e.type}, disabled ${date})`);
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\u2022 ${e.name} (${date}) — ${hint}\x1b[0m`);
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(restorable[idx]);
119
- console.log(` \x1b[32m\u2713\x1b[0m Restored: ${restorable[idx].name}`);
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\u2717\x1b[0m ${restorable[idx].name}: ${msg}`);
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 entries = await readManifest();
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 \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');
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\u26a0 Non-interactive mode detected, auto-selecting Tier 1\x1b[0m\n');
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\u2192 Auto mode: selecting ${selectedIssues.length} Tier 1 item(s)\x1b[0m\n`);
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(` \u2022 ${issue.name} (${issue.type})`);
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(` \u2022 ${err.name}: ${err.error}`);
261
+ console.log(` ${err.name}: ${err.error}`);
243
262
  }
244
263
  console.log('');
245
264
  }
@@ -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,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
+ }