Lists every skill and MCP server in the session with how many times each was used

Shows which skills and MCP servers were used in the current session and how many times.
Above the prompt, the five most used of each:

Each line lists what was used, most used first, with how many times. Skills and MCP are buttons that open the full table.
claude plugin marketplace add vmlinuz82/claude-mods
claude plugin install skill-usage@claude-mods
Skills or MCP above the prompt, or run /skill-usage, for a pane with a table of every skill and MCP server used in the session, most used first./name. Skills preloaded into a subagent's definition aren't counted. An MCP server counts each call to one of its tools.× closes the band until the next skill or MCP use; the pane stays available from /skill-usage.hooks/register.tsx 216 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Catalog, Counts } from '../types'
5
6const PANE = 'skill-usage'
7const PANE_TITLE = 'Skills & MCP usage'
8const BAND_TOP = 5
9
10const catalog = atom({ plugin: 'skill-usage', key: 'catalog' } as const, { skills: [], servers: [], toolServers: {} })
11const counts = atom({ plugin: 'skill-usage', key: 'counts' } as const, { skills: {}, servers: {} })
12const closedAt = atom({ plugin: 'skill-usage', key: 'closedAt' } as const, null)
13
14const totalUses = (used: Counts): number =>
15 [...Object.values(used.skills), ...Object.values(used.servers)].reduce((sum, count) => sum + count, 0)
16
17type Row = { name: string; count: number }
18
19// Every known name, used or not, most used first, then alphabetical
20const toRows = (names: readonly string[], used: Readonly<Record<string, number>>): Row[] =>
21 [...new Set([...names, ...Object.keys(used)])]
22 .map(name => ({ name, count: used[name] ?? 0 }))
23 .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name))
24
25const serverOf = (tool: string, known: Readonly<Record<string, string>>): string =>
26 known[tool] ?? tool.split('__')[1] ?? tool
27
28// MCP servers often connect after the session starts, so this runs again at every turn
29const refresh = async ($: EngineInterface): Promise<void> => {
30 const { context } = await $.session.usage({ breakdown: 'summary' })
31 const breakdown = context.breakdown
32 if (!breakdown) {
33 return
34 }
35
36 const toolServers = Object.fromEntries(breakdown.mcpTools.map(tool => [tool.name, tool.serverName]))
37 const next: Catalog = {
38 skills: (breakdown.skills?.skillFrontmatter ?? []).map(skill => skill.name),
39 servers: [...new Set(Object.values(toolServers))],
40 toolServers,
41 }
42 await update($, catalog, () => next)
43}
44
45const bump = (counted: Readonly<Record<string, number>>, name: string): Record<string, number> => ({
46 ...counted,
47 [name]: (counted[name] ?? 0) + 1,
48})
49
50export const register: Register = on => {
51 on('session.start', async ($, e, next) => {
52 await $.command.register({
53 name: 'skill-usage',
54 description: 'Show every skill and MCP server in this session with how many times each was used',
55 })
56 await refresh($)
57
58 return next(e)
59 })
60
61 on('turn.start', async ($, e, next) => {
62 await refresh($)
63
64 return next(e)
65 }).catch(($, e, next) => next(e))
66
67 on('command.run', { command: 'skill-usage' }, async $ => {
68 await refresh($)
69 await $.ui.open({ id: PANE, title: PANE_TITLE })
70
71 return { text: `${PANE_TITLE} pane opened.` }
72 })
73
74 // `skill.prompt` doesn't fire on this build, so skills are counted where they start: Claude's
75 // Skill tool calls, and `/name` typed by the person (a command run, counted only when it names a
76 // skill). Counted before the call runs, so a failed count never re-runs or blocks the skill
77 on('tool.call', { tool: 'Skill' }, async ($, e, next) => {
78 await update($, counts, (now: Counts) => ({ ...now, skills: bump(now.skills, e.skill) }))
79
80 return next(e)
81 }).catch(($, e, next) => next(e))
82
83 on('command.run', async ($, e, next) => {
84 const { skills } = await read($, catalog)
85 if (skills.includes(e.command)) {
86 await update($, counts, (now: Counts) => ({ ...now, skills: bump(now.skills, e.command) }))
87 }
88
89 return next(e)
90 }).catch(($, e, next) => next(e))
91
92 on('tool.call', async ($, e, next) => {
93 if (!e.tool.startsWith('mcp__')) {
94 return next(e)
95 }
96
97 const { toolServers } = await read($, catalog)
98 const server = serverOf(e.tool, toolServers)
99 await update($, counts, (now: Counts) => ({ ...now, servers: bump(now.servers, server) }))
100
101 return next(e)
102 }).catch(($, e, next) => next(e))
103
104 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
105 if (e.props.hasSurvey) {
106 return next(e)
107 }
108
109 const known = await read($, catalog)
110 const used = await read($, counts)
111 const usedOnly = (rows: readonly Row[]) => rows.filter(row => row.count > 0).slice(0, BAND_TOP)
112 const skills = usedOnly(toRows(known.skills, used.skills))
113 const servers = usedOnly(toRows(known.servers, used.servers))
114 const total = totalUses(used)
115 // Nothing used yet this session, or closed by the person until the next use: no band
116 if ((skills.length === 0 && servers.length === 0) || (await read($, closedAt)) === total) {
117 return next(e)
118 }
119
120 const { Box, Button, Text } = $.ui.resolve(e)
121 const groups = [
122 { key: 'skills', label: 'Skills', rows: skills },
123 { key: 'servers', label: 'MCP', rows: servers },
124 ].filter(group => group.rows.length > 0)
125 // Each group with usage is one row of its most used `name count` items; the label opens the pane
126 const mine = (
127 <Box key="skill-usage" flexDirection="row" justifyContent="space-between" columnGap={2}>
128 <Box key="groups" flexDirection="column" flexShrink={1}>
129 {groups.map(group => (
130 <Box key={group.key} flexDirection="row" flexWrap="wrap" columnGap={2}>
131 <Button
132 key={`open-${group.key}`}
133 label={group.label}
134 plain
135 onPress={() => $.ui.open({ id: PANE, title: PANE_TITLE })}
136 />
137 {group.rows.map(row => (
138 <Text key={row.name}>
139 {row.name} {row.count}
140 </Text>
141 ))}
142 </Box>
143 ))}
144 </Box>
145 <Button
146 key="close"
147 label="×"
148 role="dismiss"
149 plain
150 dimColor
151 onPress={() => update($, closedAt, () => total)}
152 />
153 </Box>
154 )
155
156 // Another mod's band (the task list) stays on top; the engine's own placeholder is left out
157 const below = await next(e)
158 if (below.type === 'engine') {
159 return mine
160 }
161
162 // Two bands stacked: each framed like the prompt box, a row apart, so they read as two
163 return (
164 <Box flexDirection="column" rowGap={1}>
165 <Box key="upper" flexDirection="column" borderStyle="round" borderColor="promptBorder" paddingX={1}>
166 {below}
167 </Box>
168 <Box key="lower" flexDirection="column" borderStyle="round" borderColor="promptBorder" paddingX={1}>
169 {mine}
170 </Box>
171 </Box>
172 )
173 })
174
175 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
176 const { Box, Text } = $.ui.resolve(e)
177 const known = await read($, catalog)
178 const used = await read($, counts)
179 const sections = [
180 { key: 'skills', title: 'Skill', rows: toRows(known.skills, used.skills).filter(row => row.count > 0) },
181 { key: 'servers', title: 'MCP server', rows: toRows(known.servers, used.servers).filter(row => row.count > 0) },
182 ].filter(section => section.rows.length > 0)
183
184 if (sections.length === 0) {
185 return <Text dimColor>No skills or MCP servers used in this session yet.</Text>
186 }
187
188 // One table per section with usage: the name on the left, the count pushed to the right edge,
189 // so the counts line up in the desktop app's proportional font as well as in the terminal
190 return (
191 <Box flexDirection="column">
192 {sections.map(section => (
193 <Box key={section.key} flexDirection="column" marginBottom={1}>
194 <Box key="head" flexDirection="row" justifyContent="space-between" columnGap={2}>
195 <Text key="name" bold>
196 {section.title}
197 </Text>
198 <Text key="count" bold>
199 Uses
200 </Text>
201 </Box>
202 {section.rows.map(row => (
203 <Box key={row.name} flexDirection="row" justifyContent="space-between" columnGap={2}>
204 <Box key="name" flexShrink={1}>
205 <Text wrap="truncate-end">{row.name}</Text>
206 </Box>
207 <Text key="count">{row.count}</Text>
208 </Box>
209 ))}
210 </Box>
211 ))}
212 </Box>
213 )
214 })
215}
216types/index.d.ts 19 lines1export type Catalog = {
2 skills: string[]
3 servers: string[]
4 // MCP tool wire name → server name as /mcp lists it
5 toolServers: Record<string, string>
6}
7
8export type Counts = {
9 skills: Record<string, number>
10 servers: Record<string, number>
11}
12
13declare module 'claude-code' {
14 interface PluginState {
15 // closedAt: the total uses when the band was closed; the band stays hidden until a new use
16 'skill-usage': { catalog: Catalog; counts: Counts; closedAt: number | null }
17 }
18}
19