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 +8 -0
- package/dist/cleaner.d.ts +2 -2
- package/dist/cleaner.js +69 -24
- package/dist/cli.js +70 -31
- 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 +11 -0
- package/dist/scanner/detectors.js +49 -3
- package/dist/scanner/disabled-plugins.d.ts +7 -0
- package/dist/scanner/disabled-plugins.js +38 -0
- package/dist/scanner/index.js +40 -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 +158 -7
- package/dist/types.d.ts +22 -2
- package/package.json +1 -1
|
@@ -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
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
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
|
+
}
|
package/dist/scanner/index.js
CHANGED
|
@@ -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>;
|