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.
@@ -40,9 +40,11 @@ const duplicateDetector = {
40
40
  const pluginSkillNames = new Set(pluginSkills.map((s) => s.name));
41
41
  const issues = [];
42
42
  for (const skill of localSkills) {
43
- // Check base name for nested skills (e.g. "org/ship" → "ship")
44
- const baseName = skill.name.includes('/') ? skill.name.split('/').pop() : skill.name;
45
- if (pluginSkillNames.has(baseName)) {
43
+ // Exact-name match only. A prior baseName fallback flagged nested local
44
+ // skills (e.g. `org/ship`) as duplicates of a bare plugin `ship`, but
45
+ // namespaced local skills are addressable independently and are not real
46
+ // duplicates — the fallback risked disabling user content.
47
+ if (pluginSkillNames.has(skill.name)) {
46
48
  issues.push({
47
49
  type: 'duplicate',
48
50
  tier: 2,
@@ -175,6 +177,49 @@ const unusedSkillDetector = {
175
177
  return issues;
176
178
  },
177
179
  };
180
+ const unusedPluginDetector = {
181
+ name: 'unused_plugin',
182
+ detect({ pluginSurfaces, enabledPlugins, recentSkillInvocations, recentMcpPrefixes, recentCommands, totalUserCallableInvocations, sessionsInWindow, lookbackDays, pluginCosts, }) {
183
+ // (a) Global suppression: too few sessions to draw a conclusion
184
+ if (sessionsInWindow < 3)
185
+ return [];
186
+ // (b) Global suppression: no user-callable activity — schema change suspected
187
+ if (totalUserCallableInvocations === 0)
188
+ return [];
189
+ const enabledNames = new Set(enabledPlugins.map((p) => p.name));
190
+ const issues = [];
191
+ for (const ps of pluginSurfaces) {
192
+ // Inner-join: only consider plugins reported as enabled (filters .git noise)
193
+ if (!enabledNames.has(ps.pluginName))
194
+ continue;
195
+ // (c) Per-plugin suppression: no user-callable surface (agent/hook only)
196
+ const userCallableCount = ps.skills.length + ps.mcpToolPrefixes.length + ps.commands.length;
197
+ if (userCallableCount === 0)
198
+ continue;
199
+ // (d) suppression was intended to skip recently-installed plugins by
200
+ // `installedAt` mtime, but dogfooding showed `claude plugin update` resets
201
+ // cache mtime indiscriminately, making install age unreliable. Dropped.
202
+ // Tier 3 (never auto-selected) lets users sanity-check any flagged plugin.
203
+ // Usage check: any skill/mcp/command from this plugin invoked?
204
+ const usedSkill = ps.skills.some((s) => recentSkillInvocations.has(s) ||
205
+ recentSkillInvocations.has(`${ps.pluginName}:${s}`));
206
+ const usedMcp = ps.mcpToolPrefixes.some((p) => recentMcpPrefixes.has(p));
207
+ const usedCmd = ps.commands.some((c) => recentCommands.has(c));
208
+ if (usedSkill || usedMcp || usedCmd)
209
+ continue;
210
+ issues.push({
211
+ type: 'unused_plugin',
212
+ tier: 3,
213
+ name: ps.pluginName,
214
+ marketplace: ps.marketplace,
215
+ detail: `not invoked in ${lookbackDays}d (${ps.marketplace})`,
216
+ tokens: pluginCosts.get(ps.pluginName) ?? 0,
217
+ path: ps.installDir,
218
+ });
219
+ }
220
+ return issues;
221
+ },
222
+ };
178
223
  const disabledPluginDetector = {
179
224
  name: 'disabled_plugin',
180
225
  detect({ plugins, disabledPlugins }) {
@@ -206,6 +251,7 @@ export const detectors = [
206
251
  oversizedMemoryDetector,
207
252
  staleProjectDetector,
208
253
  unusedSkillDetector,
254
+ unusedPluginDetector,
209
255
  disabledPluginDetector,
210
256
  ];
211
257
  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
+ }
@@ -7,9 +7,12 @@ import { scanPluginSkills } from './plugin-skills.js';
7
7
  import { scanMemoryFiles } from './memory.js';
8
8
  import { scanMcpServers } from './mcp.js';
9
9
  import { parseClaudeMdSections } from './claude-md.js';
10
- import { getDisabledPlugins } from './disabled-plugins.js';
10
+ import { getDisabledPlugins, getInstalledPlugins } from './disabled-plugins.js';
11
11
  import { scanSessionUsage } from './sessions.js';
12
12
  import { classifyIssues } from './detectors.js';
13
+ import { scanPluginSurfaces } from './plugin-surfaces.js';
14
+ import { computePluginBreakdown } from './plugin-breakdown.js';
15
+ import { computePluginCosts } from './plugin-cost.js';
13
16
  import { SKILL_PROMPT_OVERHEAD_TOKENS } from './constants.js';
14
17
  const DEFAULT_LOOKBACK_DAYS = 60;
15
18
  export async function scan(opts = {}) {
@@ -22,10 +25,19 @@ export async function scan(opts = {}) {
22
25
  getDisabledPlugins(),
23
26
  scanSessionUsage(lookbackDays),
24
27
  ]);
28
+ const pluginSurfaces = scanPluginSurfaces();
25
29
  // Annotate plugin status
26
30
  for (const plugin of plugins) {
27
31
  plugin.status = disabledPlugins.has(plugin.name) ? 'disabled' : 'enabled';
28
32
  }
33
+ // Build enabled plugin list for unused_plugin detector. `plugins[].name` from
34
+ // existing code is the marketplace name (intentional, for cache-dir matching).
35
+ // For unused_plugin we need the actual plugin name parsed from
36
+ // `claude plugin list` output (the `<plugin>` part of `<plugin>@<marketplace>`).
37
+ const installed = await getInstalledPlugins();
38
+ const enabledPlugins = installed
39
+ .filter((p) => p.enabled)
40
+ .map((p) => ({ name: p.name, marketplace: p.marketplace }));
29
41
  // CLAUDE.md
30
42
  const claudeMdContent = await safeReadFile(join(getClaudeDir(), 'CLAUDE.md'));
31
43
  const claudeMdBytes = claudeMdContent ? Buffer.byteLength(claudeMdContent) : 0;
@@ -33,6 +45,14 @@ export async function scan(opts = {}) {
33
45
  ? countTokensCached(claudeMdContent, join(getClaudeDir(), 'CLAUDE.md'))
34
46
  : 0;
35
47
  const claudeMdSections = claudeMdContent ? parseClaudeMdSections(claudeMdContent) : [];
48
+ // Per-plugin cost map for the unused_plugin detector's savings estimate.
49
+ // Aggregates when multiple surface entries share a pluginName (mirrors the
50
+ // same logic in computePluginBreakdown).
51
+ const pluginCostBreakdowns = computePluginCosts(pluginSurfaces, claudeMdSections);
52
+ const pluginCosts = new Map();
53
+ for (const c of pluginCostBreakdowns) {
54
+ pluginCosts.set(c.pluginName, (pluginCosts.get(c.pluginName) ?? 0) + c.totalEstimatedTokens);
55
+ }
36
56
  const issues = classifyIssues({
37
57
  localSkills, pluginSkills, brokenSymlinks, memoryFiles,
38
58
  tempCaches, staleProjects, disabledPlugins, plugins,
@@ -40,6 +60,24 @@ export async function scan(opts = {}) {
40
60
  recentSkillInvocations: sessionUsage.invokedSkills,
41
61
  sessionDataAvailable: sessionUsage.dataAvailable,
42
62
  lookbackDays,
63
+ pluginSurfaces,
64
+ enabledPlugins,
65
+ recentMcpPrefixes: sessionUsage.mcpPrefixesInvoked,
66
+ recentCommands: sessionUsage.commandsInvoked,
67
+ totalUserCallableInvocations: sessionUsage.totalUserCallableInvocations,
68
+ sessionsInWindow: sessionUsage.sessionsInWindow,
69
+ pluginCosts,
70
+ });
71
+ // Compute plugin breakdown (used by PLUGINS table in scan output)
72
+ const pluginBreakdown = computePluginBreakdown({
73
+ surfaces: pluginSurfaces,
74
+ installedPlugins: installed,
75
+ invokedSkills: sessionUsage.invokedSkills,
76
+ mcpPrefixesInvoked: sessionUsage.mcpPrefixesInvoked,
77
+ commandsInvoked: sessionUsage.commandsInvoked,
78
+ totalUserCallableInvocations: sessionUsage.totalUserCallableInvocations,
79
+ sessionsInWindow: sessionUsage.sessionsInWindow,
80
+ claudeMdSections,
43
81
  });
44
82
  // Estimate total tokens at startup
45
83
  const skillListingTokens = (localSkills.length + pluginSkills.length) * SKILL_PROMPT_OVERHEAD_TOKENS;
@@ -58,5 +96,6 @@ export async function scan(opts = {}) {
58
96
  mcpServerNames: mcp.names,
59
97
  issues,
60
98
  totalTokensBefore,
99
+ pluginBreakdown,
61
100
  };
62
101
  }
@@ -0,0 +1,16 @@
1
+ import type { PluginBreakdown } from '../types.js';
2
+ import type { PluginSurfaces } from './plugin-surfaces.js';
3
+ import type { InstalledPlugin } from './disabled-plugins.js';
4
+ import type { ClaudeMdSection } from './plugin-cost.js';
5
+ export interface PluginBreakdownOptions {
6
+ surfaces: PluginSurfaces[];
7
+ installedPlugins: InstalledPlugin[];
8
+ invokedSkills: Set<string>;
9
+ mcpPrefixesInvoked: Set<string>;
10
+ commandsInvoked: Set<string>;
11
+ totalUserCallableInvocations: number;
12
+ sessionsInWindow: number;
13
+ claudeMdSections: ClaudeMdSection[];
14
+ }
15
+ export declare function computePluginBreakdown(opts: PluginBreakdownOptions): PluginBreakdown[];
16
+ export declare function formatPluginsTable(rows: PluginBreakdown[], totalInstalled: number, totalEnabled: number): string;
@@ -0,0 +1,129 @@
1
+ import { computePluginCosts } from './plugin-cost.js';
2
+ const MIN_SESSIONS = 3;
3
+ function classifyStatus(opts) {
4
+ const { surface, enabled, invokedSkills, mcpPrefixesInvoked, commandsInvoked, totalUserCallableInvocations, sessionsInWindow } = opts;
5
+ if (!enabled)
6
+ return 'disabled';
7
+ if (!surface)
8
+ return 'insufficient data';
9
+ const userCallableSurfaces = surface.skills.length + surface.mcpServerKeys.length + surface.commands.length;
10
+ if (userCallableSurfaces === 0)
11
+ return 'agent-only';
12
+ // Suppression: not enough session data
13
+ if (sessionsInWindow < MIN_SESSIONS || totalUserCallableInvocations === 0) {
14
+ return 'insufficient data';
15
+ }
16
+ // Check if any surface was actually invoked
17
+ const skillUsed = surface.skills.some((s) => {
18
+ // Skills may be invoked as <pluginName>:<skillName> or just <skillName>
19
+ return (invokedSkills.has(`${surface.pluginName}:${s}`) ||
20
+ invokedSkills.has(s));
21
+ });
22
+ const mcpUsed = surface.mcpToolPrefixes.some((p) => mcpPrefixesInvoked.has(p));
23
+ const cmdUsed = surface.commands.some((c) => commandsInvoked.has(c));
24
+ if (skillUsed || mcpUsed || cmdUsed)
25
+ return 'used';
26
+ return 'unused';
27
+ }
28
+ export function computePluginBreakdown(opts) {
29
+ const { surfaces, installedPlugins, invokedSkills, mcpPrefixesInvoked, commandsInvoked, totalUserCallableInvocations, sessionsInWindow, claudeMdSections, } = opts;
30
+ // Build map: pluginName → surface
31
+ const surfaceMap = new Map();
32
+ for (const s of surfaces) {
33
+ // Prefer the most recently installed version (largest installedAt)
34
+ const existing = surfaceMap.get(s.pluginName);
35
+ if (!existing || s.installedAt > existing.installedAt) {
36
+ surfaceMap.set(s.pluginName, s);
37
+ }
38
+ }
39
+ // Build cost map: pluginName → tokens
40
+ const costs = computePluginCosts(surfaces, claudeMdSections);
41
+ const costMap = new Map();
42
+ for (const c of costs) {
43
+ const existing = costMap.get(c.pluginName) ?? 0;
44
+ costMap.set(c.pluginName, existing + c.totalEstimatedTokens);
45
+ }
46
+ // Collect all unique plugin names from both installed list and surfaces.
47
+ // Skip noise from failed plugin installs: cache dirs like `temp_git_*` get
48
+ // walked as if they were marketplaces, surfacing `.git/{hooks,info,...}` as
49
+ // fake "plugins". The temp_cache detector already flags these for cleanup.
50
+ const isNoise = (pluginName, marketplace) => pluginName === '.git' || marketplace.startsWith('temp_git_');
51
+ const allNames = new Set();
52
+ for (const p of installedPlugins) {
53
+ if (!isNoise(p.name, p.marketplace))
54
+ allNames.add(p.name);
55
+ }
56
+ for (const s of surfaces) {
57
+ if (!isNoise(s.pluginName, s.marketplace))
58
+ allNames.add(s.pluginName);
59
+ }
60
+ const rows = [];
61
+ for (const name of allNames) {
62
+ const installed = installedPlugins.find((p) => p.name === name);
63
+ const enabled = installed ? installed.enabled : true; // surfaces not in list are treated as enabled
64
+ const marketplace = installed?.marketplace ?? surfaceMap.get(name)?.marketplace ?? '';
65
+ const surface = surfaceMap.get(name);
66
+ const status = classifyStatus({
67
+ surface,
68
+ enabled,
69
+ invokedSkills,
70
+ mcpPrefixesInvoked,
71
+ commandsInvoked,
72
+ totalUserCallableInvocations,
73
+ sessionsInWindow,
74
+ });
75
+ const lastUsed = status === 'used' ? 'used' : 'never';
76
+ rows.push({
77
+ name,
78
+ marketplace,
79
+ tokens: costMap.get(name) ?? 0,
80
+ skills: surface?.skills.length ?? 0,
81
+ mcp: surface?.mcpServerKeys.length ?? 0,
82
+ commands: surface?.commands.length ?? 0,
83
+ lastUsed,
84
+ status,
85
+ });
86
+ }
87
+ // Sort by tokens descending
88
+ rows.sort((a, b) => b.tokens - a.tokens);
89
+ return rows;
90
+ }
91
+ export function formatPluginsTable(rows, totalInstalled, totalEnabled) {
92
+ const lines = [];
93
+ lines.push('');
94
+ lines.push(`\x1b[1m PLUGIN BREAKDOWN\x1b[0m (${totalInstalled} installed, ${totalEnabled} enabled)`);
95
+ if (rows.length === 0) {
96
+ lines.push(' (none)');
97
+ return lines.join('\n');
98
+ }
99
+ // Column widths: Plugin(30) ~Tokens(10) Skills(7) MCP(5) Cmd(5) Last used(11) Status(20)
100
+ const COL_PLUGIN = 30;
101
+ const COL_TOKENS = 10;
102
+ const COL_SKILLS = 7;
103
+ const COL_MCP = 5;
104
+ const COL_CMD = 5;
105
+ const COL_LAST = 11;
106
+ // Status: flexible (rest of line)
107
+ const rpad = (s, n) => s.padEnd(n);
108
+ const lpad = (s, n) => s.padStart(n);
109
+ const header = ` ${rpad('Plugin', COL_PLUGIN)}` +
110
+ `${lpad('~Tokens', COL_TOKENS)}` +
111
+ `${lpad('Skills', COL_SKILLS)}` +
112
+ `${lpad('MCP', COL_MCP)}` +
113
+ `${lpad('Cmd', COL_CMD)}` +
114
+ ` ${'Last used'.padEnd(COL_LAST)}` +
115
+ `Status`;
116
+ lines.push(header);
117
+ for (const row of rows) {
118
+ const tokStr = row.tokens > 0 ? `~${row.tokens.toLocaleString()}` : '~?';
119
+ const line = ` ${rpad(row.name, COL_PLUGIN)}` +
120
+ `${lpad(tokStr, COL_TOKENS)}` +
121
+ `${lpad(String(row.skills), COL_SKILLS)}` +
122
+ `${lpad(String(row.mcp), COL_MCP)}` +
123
+ `${lpad(String(row.commands), COL_CMD)}` +
124
+ ` ${row.lastUsed.padEnd(COL_LAST)}` +
125
+ `${row.status}`;
126
+ lines.push(line);
127
+ }
128
+ return lines.join('\n');
129
+ }
@@ -0,0 +1,27 @@
1
+ import type { PluginSurfaces } from './plugin-surfaces.js';
2
+ export interface PluginCostBreakdown {
3
+ pluginName: string;
4
+ marketplace: string;
5
+ /** Tokens from the matching CLAUDE.md section (0 if no match). */
6
+ claudeMdTokens: number;
7
+ /** SKILL_PROMPT_OVERHEAD_TOKENS × skills.length */
8
+ skillTokens: number;
9
+ /** DEFERRED_TOOL_OVERHEAD_TOKENS × MCP_SERVER_TOOLS_AVG × mcpServerKeys.length */
10
+ mcpToolTokens: number;
11
+ /** COMMAND_OVERHEAD_TOKENS × commands.length */
12
+ commandTokens: number;
13
+ /** Sum of all fields above. All values are estimates (~). */
14
+ totalEstimatedTokens: number;
15
+ }
16
+ export interface ClaudeMdSection {
17
+ name: string;
18
+ sizeBytes: number;
19
+ tokens: number;
20
+ }
21
+ /**
22
+ * Compute estimated system-prompt token cost for each plugin.
23
+ *
24
+ * All returned token counts are estimates (±20%). Callers should surface
25
+ * them with a `~` prefix in any UI output.
26
+ */
27
+ export declare function computePluginCosts(surfaces: PluginSurfaces[], claudeMdSections: ClaudeMdSection[]): PluginCostBreakdown[];
@@ -0,0 +1,35 @@
1
+ import { SKILL_PROMPT_OVERHEAD_TOKENS, DEFERRED_TOOL_OVERHEAD_TOKENS, COMMAND_OVERHEAD_TOKENS, MCP_SERVER_TOOLS_AVG, } from './constants.js';
2
+ /**
3
+ * Find a CLAUDE.md section whose name contains the plugin name as a substring
4
+ * (case-insensitive). Returns the first match or null.
5
+ * Fuzzy matching is intentionally disabled to avoid false positives.
6
+ */
7
+ function matchSection(pluginName, sections) {
8
+ const lower = pluginName.toLowerCase();
9
+ return sections.find((s) => s.name.toLowerCase().includes(lower)) ?? null;
10
+ }
11
+ /**
12
+ * Compute estimated system-prompt token cost for each plugin.
13
+ *
14
+ * All returned token counts are estimates (±20%). Callers should surface
15
+ * them with a `~` prefix in any UI output.
16
+ */
17
+ export function computePluginCosts(surfaces, claudeMdSections) {
18
+ return surfaces.map((s) => {
19
+ const matched = matchSection(s.pluginName, claudeMdSections);
20
+ const claudeMdTokens = matched?.tokens ?? 0;
21
+ const skillTokens = SKILL_PROMPT_OVERHEAD_TOKENS * s.skills.length;
22
+ const mcpToolTokens = DEFERRED_TOOL_OVERHEAD_TOKENS * MCP_SERVER_TOOLS_AVG * s.mcpServerKeys.length;
23
+ const commandTokens = COMMAND_OVERHEAD_TOKENS * s.commands.length;
24
+ const totalEstimatedTokens = claudeMdTokens + skillTokens + mcpToolTokens + commandTokens;
25
+ return {
26
+ pluginName: s.pluginName,
27
+ marketplace: s.marketplace,
28
+ claudeMdTokens,
29
+ skillTokens,
30
+ mcpToolTokens,
31
+ commandTokens,
32
+ totalEstimatedTokens,
33
+ };
34
+ });
35
+ }
@@ -0,0 +1,14 @@
1
+ export interface PluginSurfaces {
2
+ pluginName: string;
3
+ marketplace: string;
4
+ version: string;
5
+ installDir: string;
6
+ installedAt: number;
7
+ skills: string[];
8
+ mcpServerKeys: string[];
9
+ mcpToolPrefixes: string[];
10
+ commands: string[];
11
+ agentCount: number;
12
+ hookCount: number;
13
+ }
14
+ export declare function scanPluginSurfaces(): PluginSurfaces[];
@@ -0,0 +1,129 @@
1
+ import { join } from 'node:path';
2
+ import { statSync } from 'node:fs';
3
+ import { readFileSync, readdirSync } from 'node:fs';
4
+ import { getPluginsDir } from '../paths.js';
5
+ function safeReaddir(p) {
6
+ try {
7
+ return readdirSync(p);
8
+ }
9
+ catch {
10
+ return [];
11
+ }
12
+ }
13
+ function safeStat(p) {
14
+ try {
15
+ return statSync(p);
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ }
21
+ function isDir(p) {
22
+ const s = safeStat(p);
23
+ return s != null && s.isDirectory();
24
+ }
25
+ function isFile(p) {
26
+ const s = safeStat(p);
27
+ return s != null && s.isFile();
28
+ }
29
+ function parseMcpServerKeys(installDir) {
30
+ const mcpPath = join(installDir, '.mcp.json');
31
+ if (!isFile(mcpPath))
32
+ return [];
33
+ try {
34
+ const raw = readFileSync(mcpPath, 'utf-8');
35
+ const parsed = JSON.parse(raw);
36
+ if (typeof parsed === 'object' &&
37
+ parsed !== null &&
38
+ 'mcpServers' in parsed &&
39
+ typeof parsed.mcpServers === 'object' &&
40
+ parsed.mcpServers !== null) {
41
+ return Object.keys(parsed.mcpServers);
42
+ }
43
+ return [];
44
+ }
45
+ catch {
46
+ return [];
47
+ }
48
+ }
49
+ function scanSkills(installDir) {
50
+ const skillsDir = join(installDir, 'skills');
51
+ if (!isDir(skillsDir))
52
+ return [];
53
+ const names = [];
54
+ for (const entry of safeReaddir(skillsDir)) {
55
+ const skillDir = join(skillsDir, entry);
56
+ if (!isDir(skillDir))
57
+ continue;
58
+ const upper = join(skillDir, 'SKILL.md');
59
+ const lower = join(skillDir, 'skill.md');
60
+ if (isFile(upper) || isFile(lower)) {
61
+ names.push(entry);
62
+ }
63
+ }
64
+ return names;
65
+ }
66
+ function scanCommands(installDir) {
67
+ const commandsDir = join(installDir, 'commands');
68
+ if (!isDir(commandsDir))
69
+ return [];
70
+ const names = [];
71
+ for (const entry of safeReaddir(commandsDir)) {
72
+ if (entry.endsWith('.md') && isFile(join(commandsDir, entry))) {
73
+ names.push(entry.slice(0, -3)); // strip .md
74
+ }
75
+ }
76
+ return names;
77
+ }
78
+ function countFiles(dir) {
79
+ if (!isDir(dir))
80
+ return 0;
81
+ let count = 0;
82
+ for (const entry of safeReaddir(dir)) {
83
+ if (isFile(join(dir, entry)))
84
+ count++;
85
+ }
86
+ return count;
87
+ }
88
+ export function scanPluginSurfaces() {
89
+ const pluginsDir = getPluginsDir();
90
+ const results = [];
91
+ for (const marketplace of safeReaddir(pluginsDir)) {
92
+ const marketplaceDir = join(pluginsDir, marketplace);
93
+ if (!isDir(marketplaceDir))
94
+ continue;
95
+ for (const pluginName of safeReaddir(marketplaceDir)) {
96
+ const pluginBaseDir = join(marketplaceDir, pluginName);
97
+ if (!isDir(pluginBaseDir))
98
+ continue;
99
+ // Each plugin may have version subdirectories
100
+ for (const version of safeReaddir(pluginBaseDir)) {
101
+ const installDir = join(pluginBaseDir, version);
102
+ if (!isDir(installDir))
103
+ continue;
104
+ const dirStat = safeStat(installDir);
105
+ const installedAt = dirStat ? Number(dirStat.mtimeMs) : 0;
106
+ const skills = scanSkills(installDir);
107
+ const mcpServerKeys = parseMcpServerKeys(installDir);
108
+ const mcpToolPrefixes = mcpServerKeys.map((key) => `plugin_${pluginName}_${key}`);
109
+ const commands = scanCommands(installDir);
110
+ const agentCount = countFiles(join(installDir, 'agents'));
111
+ const hookCount = countFiles(join(installDir, 'hooks'));
112
+ results.push({
113
+ pluginName,
114
+ marketplace,
115
+ version,
116
+ installDir,
117
+ installedAt,
118
+ skills,
119
+ mcpServerKeys,
120
+ mcpToolPrefixes,
121
+ commands,
122
+ agentCount,
123
+ hookCount,
124
+ });
125
+ }
126
+ }
127
+ }
128
+ return results;
129
+ }
@@ -3,6 +3,11 @@ export interface SessionScanResult {
3
3
  dataAvailable: boolean;
4
4
  sessionsScanned: number;
5
5
  sessionsInWindow: number;
6
+ mcpPrefixesInvoked: Set<string>;
7
+ commandsInvoked: Set<string>;
8
+ totalUserCallableInvocations: number;
6
9
  }
7
10
  export declare function extractSkillsFromTranscript(content: string): string[];
11
+ export declare function extractMcpPrefixesFromTranscript(content: string): Set<string>;
12
+ export declare function extractCommandsFromTranscript(content: string): Set<string>;
8
13
  export declare function scanSessionUsage(lookbackDays: number): Promise<SessionScanResult>;