claude-slim 2.6.0 → 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 +14 -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/dist/scanner/index.js
CHANGED
|
@@ -7,9 +7,11 @@ 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';
|
|
13
15
|
import { SKILL_PROMPT_OVERHEAD_TOKENS } from './constants.js';
|
|
14
16
|
const DEFAULT_LOOKBACK_DAYS = 60;
|
|
15
17
|
export async function scan(opts = {}) {
|
|
@@ -22,10 +24,19 @@ export async function scan(opts = {}) {
|
|
|
22
24
|
getDisabledPlugins(),
|
|
23
25
|
scanSessionUsage(lookbackDays),
|
|
24
26
|
]);
|
|
27
|
+
const pluginSurfaces = scanPluginSurfaces();
|
|
25
28
|
// Annotate plugin status
|
|
26
29
|
for (const plugin of plugins) {
|
|
27
30
|
plugin.status = disabledPlugins.has(plugin.name) ? 'disabled' : 'enabled';
|
|
28
31
|
}
|
|
32
|
+
// Build enabled plugin list for unused_plugin detector. `plugins[].name` from
|
|
33
|
+
// existing code is the marketplace name (intentional, for cache-dir matching).
|
|
34
|
+
// For unused_plugin we need the actual plugin name parsed from
|
|
35
|
+
// `claude plugin list` output (the `<plugin>` part of `<plugin>@<marketplace>`).
|
|
36
|
+
const installed = await getInstalledPlugins();
|
|
37
|
+
const enabledPlugins = installed
|
|
38
|
+
.filter((p) => p.enabled)
|
|
39
|
+
.map((p) => ({ name: p.name, marketplace: p.marketplace }));
|
|
29
40
|
// CLAUDE.md
|
|
30
41
|
const claudeMdContent = await safeReadFile(join(getClaudeDir(), 'CLAUDE.md'));
|
|
31
42
|
const claudeMdBytes = claudeMdContent ? Buffer.byteLength(claudeMdContent) : 0;
|
|
@@ -40,6 +51,23 @@ export async function scan(opts = {}) {
|
|
|
40
51
|
recentSkillInvocations: sessionUsage.invokedSkills,
|
|
41
52
|
sessionDataAvailable: sessionUsage.dataAvailable,
|
|
42
53
|
lookbackDays,
|
|
54
|
+
pluginSurfaces,
|
|
55
|
+
enabledPlugins,
|
|
56
|
+
recentMcpPrefixes: sessionUsage.mcpPrefixesInvoked,
|
|
57
|
+
recentCommands: sessionUsage.commandsInvoked,
|
|
58
|
+
totalUserCallableInvocations: sessionUsage.totalUserCallableInvocations,
|
|
59
|
+
sessionsInWindow: sessionUsage.sessionsInWindow,
|
|
60
|
+
});
|
|
61
|
+
// Compute plugin breakdown (used by PLUGINS table in scan output)
|
|
62
|
+
const pluginBreakdown = computePluginBreakdown({
|
|
63
|
+
surfaces: pluginSurfaces,
|
|
64
|
+
installedPlugins: installed,
|
|
65
|
+
invokedSkills: sessionUsage.invokedSkills,
|
|
66
|
+
mcpPrefixesInvoked: sessionUsage.mcpPrefixesInvoked,
|
|
67
|
+
commandsInvoked: sessionUsage.commandsInvoked,
|
|
68
|
+
totalUserCallableInvocations: sessionUsage.totalUserCallableInvocations,
|
|
69
|
+
sessionsInWindow: sessionUsage.sessionsInWindow,
|
|
70
|
+
claudeMdSections,
|
|
43
71
|
});
|
|
44
72
|
// Estimate total tokens at startup
|
|
45
73
|
const skillListingTokens = (localSkills.length + pluginSkills.length) * SKILL_PROMPT_OVERHEAD_TOKENS;
|
|
@@ -58,5 +86,6 @@ export async function scan(opts = {}) {
|
|
|
58
86
|
mcpServerNames: mcp.names,
|
|
59
87
|
issues,
|
|
60
88
|
totalTokensBefore,
|
|
89
|
+
pluginBreakdown,
|
|
61
90
|
};
|
|
62
91
|
}
|
|
@@ -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>;
|
package/dist/scanner/sessions.js
CHANGED
|
@@ -79,6 +79,116 @@ export function extractSkillsFromTranscript(content) {
|
|
|
79
79
|
}
|
|
80
80
|
return skills;
|
|
81
81
|
}
|
|
82
|
+
// Extract MCP server prefixes from tool_use events whose name matches
|
|
83
|
+
// `mcp__<prefix>__<tool>`. Returns the set of unique prefixes seen.
|
|
84
|
+
//
|
|
85
|
+
// Example: `mcp__plugin_oh-my-claudecode_t__lsp_diagnostics` → `plugin_oh-my-claudecode_t`
|
|
86
|
+
export function extractMcpPrefixesFromTranscript(content) {
|
|
87
|
+
const prefixes = new Set();
|
|
88
|
+
const lines = content.split('\n');
|
|
89
|
+
for (const line of lines) {
|
|
90
|
+
if (!line)
|
|
91
|
+
continue;
|
|
92
|
+
let obj;
|
|
93
|
+
try {
|
|
94
|
+
obj = JSON.parse(line);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (typeof obj !== 'object' || obj === null)
|
|
100
|
+
continue;
|
|
101
|
+
const message = obj.message;
|
|
102
|
+
if (typeof message !== 'object' || message === null)
|
|
103
|
+
continue;
|
|
104
|
+
const msgContent = message.content;
|
|
105
|
+
if (!Array.isArray(msgContent))
|
|
106
|
+
continue;
|
|
107
|
+
for (const c of msgContent) {
|
|
108
|
+
if (typeof c !== 'object' || c === null)
|
|
109
|
+
continue;
|
|
110
|
+
const rec = c;
|
|
111
|
+
if (rec.type !== 'tool_use')
|
|
112
|
+
continue;
|
|
113
|
+
const name = rec.name;
|
|
114
|
+
if (typeof name !== 'string' || !name.startsWith('mcp__'))
|
|
115
|
+
continue;
|
|
116
|
+
// Must have at least two __ separators: mcp__<prefix>__<tool>
|
|
117
|
+
const after = name.slice('mcp__'.length);
|
|
118
|
+
const sep = after.indexOf('__');
|
|
119
|
+
if (sep === -1)
|
|
120
|
+
continue;
|
|
121
|
+
prefixes.add(after.slice(0, sep));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return prefixes;
|
|
125
|
+
}
|
|
126
|
+
// Extract slash command names from user messages containing the
|
|
127
|
+
// `<command-name>/foo</command-name>` tag that Claude Code injects when a
|
|
128
|
+
// user runs a slash command. The leading slash is stripped so callers receive
|
|
129
|
+
// plain names like "clear" or "grill-me".
|
|
130
|
+
//
|
|
131
|
+
// Only `type === "user"` / `role === "user"` messages are examined to avoid
|
|
132
|
+
// false positives from assistant text that may reference command names.
|
|
133
|
+
export function extractCommandsFromTranscript(content) {
|
|
134
|
+
const commands = new Set();
|
|
135
|
+
const TAG_RE = /<command-name>([^<]+)<\/command-name>/g;
|
|
136
|
+
const lines = content.split('\n');
|
|
137
|
+
for (const line of lines) {
|
|
138
|
+
if (!line)
|
|
139
|
+
continue;
|
|
140
|
+
let obj;
|
|
141
|
+
try {
|
|
142
|
+
obj = JSON.parse(line);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (typeof obj !== 'object' || obj === null)
|
|
148
|
+
continue;
|
|
149
|
+
const rec = obj;
|
|
150
|
+
// Only inspect user-role messages
|
|
151
|
+
const message = rec.message;
|
|
152
|
+
if (typeof message !== 'object' || message === null)
|
|
153
|
+
continue;
|
|
154
|
+
const msgRec = message;
|
|
155
|
+
if (msgRec.role !== 'user')
|
|
156
|
+
continue;
|
|
157
|
+
const msgContent = msgRec.content;
|
|
158
|
+
// User content may be a plain string or an array of content blocks.
|
|
159
|
+
const texts = [];
|
|
160
|
+
if (typeof msgContent === 'string') {
|
|
161
|
+
texts.push(msgContent);
|
|
162
|
+
}
|
|
163
|
+
else if (Array.isArray(msgContent)) {
|
|
164
|
+
for (const c of msgContent) {
|
|
165
|
+
if (typeof c !== 'object' || c === null)
|
|
166
|
+
continue;
|
|
167
|
+
const text = c.text;
|
|
168
|
+
if (typeof text === 'string')
|
|
169
|
+
texts.push(text);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
for (const text of texts) {
|
|
173
|
+
TAG_RE.lastIndex = 0;
|
|
174
|
+
let match;
|
|
175
|
+
while ((match = TAG_RE.exec(text)) !== null) {
|
|
176
|
+
// Strip leading slash from the command value (e.g. "/clear" → "clear")
|
|
177
|
+
const raw = match[1].trim();
|
|
178
|
+
commands.add(raw.startsWith('/') ? raw.slice(1) : raw);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return commands;
|
|
183
|
+
}
|
|
184
|
+
// Parse all signals from a single JSONL transcript.
|
|
185
|
+
function parseTranscript(content) {
|
|
186
|
+
const skills = extractSkillsFromTranscript(content);
|
|
187
|
+
const mcpPrefixes = Array.from(extractMcpPrefixesFromTranscript(content));
|
|
188
|
+
const commands = Array.from(extractCommandsFromTranscript(content));
|
|
189
|
+
const invocationCount = skills.length + mcpPrefixes.length + commands.length;
|
|
190
|
+
return { skills, mcpPrefixes, commands, invocationCount };
|
|
191
|
+
}
|
|
82
192
|
// Walk every `~/.claude/projects/<slug>/*.jsonl` whose mtime falls inside the
|
|
83
193
|
// lookback window. Per-file results are cached by mtime, so warm scans only
|
|
84
194
|
// re-read files that have changed.
|
|
@@ -90,6 +200,9 @@ export async function scanSessionUsage(lookbackDays) {
|
|
|
90
200
|
// across many scans even as old session logs get rotated/deleted.
|
|
91
201
|
const newEntries = {};
|
|
92
202
|
const invokedSkills = new Set();
|
|
203
|
+
const mcpPrefixesInvoked = new Set();
|
|
204
|
+
const commandsInvoked = new Set();
|
|
205
|
+
let totalUserCallableInvocations = 0;
|
|
93
206
|
let sessionsScanned = 0;
|
|
94
207
|
let sessionsInWindow = 0;
|
|
95
208
|
let projectDirs = [];
|
|
@@ -97,7 +210,15 @@ export async function scanSessionUsage(lookbackDays) {
|
|
|
97
210
|
projectDirs = await readdir(projectsDir);
|
|
98
211
|
}
|
|
99
212
|
catch {
|
|
100
|
-
return {
|
|
213
|
+
return {
|
|
214
|
+
invokedSkills,
|
|
215
|
+
dataAvailable: false,
|
|
216
|
+
sessionsScanned: 0,
|
|
217
|
+
sessionsInWindow: 0,
|
|
218
|
+
mcpPrefixesInvoked,
|
|
219
|
+
commandsInvoked,
|
|
220
|
+
totalUserCallableInvocations: 0,
|
|
221
|
+
};
|
|
101
222
|
}
|
|
102
223
|
for (const projectName of projectDirs) {
|
|
103
224
|
const projectPath = join(projectsDir, projectName);
|
|
@@ -123,11 +244,21 @@ export async function scanSessionUsage(lookbackDays) {
|
|
|
123
244
|
if (mtimeMs < cutoffMs)
|
|
124
245
|
continue;
|
|
125
246
|
sessionsInWindow++;
|
|
126
|
-
// Cache hit: reuse the parsed
|
|
247
|
+
// Cache hit: reuse the parsed result only if mtime matches AND the entry
|
|
248
|
+
// has the v2.6 extension fields (mcpPrefixes / commands). Entries from
|
|
249
|
+
// older cache files lack these fields → force a re-parse.
|
|
127
250
|
const cached = cache.entries[filePath];
|
|
128
|
-
if (cached &&
|
|
251
|
+
if (cached &&
|
|
252
|
+
cached.mtimeMs === mtimeMs &&
|
|
253
|
+
Array.isArray(cached.mcpPrefixes) &&
|
|
254
|
+
Array.isArray(cached.commands)) {
|
|
129
255
|
for (const s of cached.skills)
|
|
130
256
|
invokedSkills.add(s);
|
|
257
|
+
for (const p of cached.mcpPrefixes)
|
|
258
|
+
mcpPrefixesInvoked.add(p);
|
|
259
|
+
for (const cmd of cached.commands)
|
|
260
|
+
commandsInvoked.add(cmd);
|
|
261
|
+
totalUserCallableInvocations += cached.invocationCount ?? 0;
|
|
131
262
|
newEntries[filePath] = cached;
|
|
132
263
|
continue;
|
|
133
264
|
}
|
|
@@ -138,13 +269,32 @@ export async function scanSessionUsage(lookbackDays) {
|
|
|
138
269
|
catch {
|
|
139
270
|
continue;
|
|
140
271
|
}
|
|
141
|
-
const
|
|
142
|
-
for (const s of skills)
|
|
272
|
+
const parsed = parseTranscript(content);
|
|
273
|
+
for (const s of parsed.skills)
|
|
143
274
|
invokedSkills.add(s);
|
|
144
|
-
|
|
275
|
+
for (const p of parsed.mcpPrefixes)
|
|
276
|
+
mcpPrefixesInvoked.add(p);
|
|
277
|
+
for (const cmd of parsed.commands)
|
|
278
|
+
commandsInvoked.add(cmd);
|
|
279
|
+
totalUserCallableInvocations += parsed.invocationCount;
|
|
280
|
+
newEntries[filePath] = {
|
|
281
|
+
mtimeMs,
|
|
282
|
+
skills: Array.from(new Set(parsed.skills)),
|
|
283
|
+
mcpPrefixes: Array.from(new Set(parsed.mcpPrefixes)),
|
|
284
|
+
commands: Array.from(new Set(parsed.commands)),
|
|
285
|
+
invocationCount: parsed.invocationCount,
|
|
286
|
+
};
|
|
145
287
|
}
|
|
146
288
|
}
|
|
147
289
|
const dataAvailable = sessionsInWindow >= MIN_SESSIONS_FOR_DATA_AVAILABLE && invokedSkills.size > 0;
|
|
148
290
|
await saveCache({ version: CACHE_VERSION, entries: newEntries });
|
|
149
|
-
return {
|
|
291
|
+
return {
|
|
292
|
+
invokedSkills,
|
|
293
|
+
dataAvailable,
|
|
294
|
+
sessionsScanned,
|
|
295
|
+
sessionsInWindow,
|
|
296
|
+
mcpPrefixesInvoked,
|
|
297
|
+
commandsInvoked,
|
|
298
|
+
totalUserCallableInvocations,
|
|
299
|
+
};
|
|
150
300
|
}
|