Counts how often each skill is invoked, by you and by Claude, against sessions, prompts and turns, and shows it above the prompt

A Claude Code mod that counts how often each skill is invoked, by you and by Claude, and shows the counts in a band above the prompt:

Counts are set against sessions, prompts and turns, so you can see that across 50 sessions and 5,000 turns a skill was invoked 100 times.
Taken in Claude Code with made-up counts.
No invocations yet, before any skill was used:

/name apart from skills Claude invoked through the Skill tool, in the main conversation or a subagent./100p gives each skill's invocations per 100 prompts./theme recolors the band right away.+N more. /plugin marketplace add JanSuthacheeva/claude-code-mods /plugin install skill-usage@claude-code-mods ``/skill-usage to see the band.For development, link a clone of this repository instead:
ln -s "$PWD/claude-code-mods/skill-usage" ~/.claude/skills/skill-usage
| Column | Meaning |
|---|---|
you | Invocations you typed as /name |
claude | Invocations Claude made through the Skill tool |
total | Both together, the order of the list |
| bar | Your share, then Claude's, scaled to the top skill |
/100p | Invocations per 100 prompts |
last | When the skill was last invoked, by you or by Claude |
/skill-usage shows or hides the band./name, your own command files and plugin skills included. Built-in commands and commands a mod registers, such as /mrs, are not skills and are not counted.The counts are kept in ~/.claude/skill-usage.json:
{
"since": "2026-10-08T04:50:54.943Z",
"sessions": 12,
"prompts": 340,
"turns": 1204,
"skills": {
"commit": {
"user": 12,
"claude": 30,
"preload": 0,
"lastUsed": "2026-10-08T09:12:03.120Z"
}
}
}
Delete the file to start counting afresh.
claude plugin validate .
claude plugin test .hooks/register.tsx 228 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Source, Usage } from '../types'
5import { initialAttribution, sourceOf, typedCommandOf, typedSkillOf } from './attribution'
6import { bandLayout, columnWidths, thousands } from './band'
7import { parseUsage, serializeUsage, withCount, withInvocation } from './usage'
8import type { Counter } from './usage'
9
10const commandName = 'skill-usage'
11const usageFileName = '.claude/skill-usage.json'
12
13const isSessionCounted = atom({ plugin: 'skill-usage', key: 'isSessionCounted' } as const, false)
14const cachedUsage = atom({ plugin: 'skill-usage', key: 'usage' } as const, null)
15const isOpen = atom({ plugin: 'skill-usage', key: 'isOpen' } as const, false)
16
17const attribution = initialAttribution()
18
19let pendingWrites: Promise<void> = Promise.resolve()
20
21// Usage file
22
23async function usagePath($: EngineInterface): Promise<string> {
24 return `${await $.env.get('HOME')}/${usageFileName}`
25}
26
27async function readUsage($: EngineInterface, path: string, now: number): Promise<Usage> {
28 const text = (await $.fs.exists(path)) ? await $.fs.read(path) : null
29 return parseUsage(text, now)
30}
31
32function changeUsage($: EngineInterface, change: (usage: Usage, now: number) => Usage): Promise<void> {
33 pendingWrites = pendingWrites
34 .then(async () => {
35 const path = await usagePath($)
36 const now = await $.clock.now()
37 const usage = change(await readUsage($, path, now), now)
38 await $.fs.write(path, serializeUsage(usage))
39 await update($, cachedUsage, () => usage)
40 })
41 .catch(() => undefined)
42 return pendingWrites
43}
44
45function count($: EngineInterface, counter: Counter): Promise<void> {
46 return changeUsage($, usage => withCount(usage, counter))
47}
48
49function countInvocation($: EngineInterface, skill: string, source: Source): Promise<void> {
50 return changeUsage($, (usage, now) => withInvocation(usage, skill, source, now))
51}
52
53async function countTypedSkill($: EngineInterface, typed: string): Promise<void> {
54 const skill = typedSkillOf(typed, await $.command.list())
55 if (skill !== null) await countInvocation($, skill, 'user')
56}
57
58// Hooks
59
60export const register: Register = on => {
61 on('session.start', async ($, e, next) => {
62 await $.command.register({ name: commandName, description: 'Toggle skill usage stats above the prompt' })
63
64 const isReload = await read($, isSessionCounted)
65 await update($, isSessionCounted, () => true)
66 void changeUsage($, usage => (isReload ? usage : withCount(usage, 'sessions')))
67 return next(e)
68 })
69
70 on('prompt.submit', async ($, e, next) => {
71 if (e.origin.kind === 'composer' || e.origin.kind === 'bridge') {
72 const typed = typedCommandOf(e.text)
73 attribution.typedCommand = typed
74 void count($, 'prompts')
75 if (typed !== null && typed !== commandName) void countTypedSkill($, typed).catch(() => undefined)
76 }
77 return next(e)
78 }).catch(($, e, next) => next(e))
79
80 on('turn.start', async ($, e, next) => {
81 void count($, 'turns')
82 return next(e)
83 })
84
85 on('command.run', { command: commandName }, async $ => {
86 await pendingWrites
87 const isShown = await update($, isOpen, open => !open)
88 return { text: isShown ? 'Skill usage shown above the prompt.' : 'Skill usage hidden.' }
89 }).catch(($, e, next) => next(e))
90
91 on('tool.call', { tool: 'Skill' }, async ($, e, next) => {
92 attribution.pendingSkillCalls += 1
93 attribution.isSkillCallCounted = false
94 try {
95 return await next(e)
96 } finally {
97 attribution.pendingSkillCalls -= 1
98 if (!attribution.isSkillCallCounted) void countInvocation($, e.skill, 'claude')
99 }
100 }).catch(($, e, next) => next(e))
101
102 on('skill.prompt', async ($, e, next) => {
103 const source = sourceOf(attribution, e.skill)
104 if (source === 'user') {
105 attribution.typedCommand = null
106 return next(e)
107 }
108 if (source === 'claude') attribution.isSkillCallCounted = true
109 void countInvocation($, e.skill, source)
110 return next(e)
111 })
112
113 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
114 const usage = await read($, cachedUsage)
115 if (e.props.hasSurvey || usage === null || !(await read($, isOpen))) return next(e)
116
117 const { Box, Text } = $.ui.resolve(e)
118 const { width, nameWidth, barWidth, rows, hiddenCount } = bandLayout(
119 usage,
120 e.props.bodyColumns,
121 e.props.maxRows,
122 await $.clock.now(),
123 )
124
125 const stat = (value: number, label: string): JSX.Element => (
126 <Text>
127 <Text bold>{thousands(value)}</Text>
128 <Text dimColor> {label}</Text>
129 </Text>
130 )
131
132 return (
133 <Box flexDirection="column" width={width} borderStyle="round" borderColor="subtle" paddingX={1}>
134 <Box gap={3}>
135 <Text bold color="claude">
136 Skill usage
137 </Text>
138 {stat(usage.sessions, 'sessions')}
139 {stat(usage.prompts, 'prompts')}
140 {stat(usage.turns, 'turns')}
141 <Text dimColor>since {usage.since.slice(0, 10)}</Text>
142 </Box>
143
144 {rows.length === 0 ? (
145 <Box marginTop={1}>
146 <Text dimColor>No skill invocations yet. Type a /skill or let Claude use one.</Text>
147 </Box>
148 ) : (
149 <Box gap={1} marginTop={1}>
150 <Box width={columnWidths.rank} />
151 <Box width={nameWidth}>
152 <Text dimColor>skill</Text>
153 </Box>
154 <Box width={columnWidths.you} justifyContent="flex-end">
155 <Text color="permission">you</Text>
156 </Box>
157 <Box width={columnWidths.claude} justifyContent="flex-end">
158 <Text color="claude">claude</Text>
159 </Box>
160 <Box width={columnWidths.total} justifyContent="flex-end">
161 <Text dimColor>total</Text>
162 </Box>
163 <Box width={barWidth} />
164 <Box width={columnWidths.rate} justifyContent="flex-end">
165 <Text dimColor>/100p</Text>
166 </Box>
167 <Box width={columnWidths.last} justifyContent="flex-end">
168 <Text dimColor>last</Text>
169 </Box>
170 </Box>
171 )}
172
173 {rows.map(row => (
174 <Box key={row.name} gap={1}>
175 <Box width={columnWidths.rank} justifyContent="flex-end">
176 <Text dimColor>{row.rank}.</Text>
177 </Box>
178 <Box width={nameWidth}>
179 <Text wrap="truncate-end">{row.name}</Text>
180 </Box>
181 <Box width={columnWidths.you} justifyContent="flex-end">
182 <Text color="permission" dimColor={row.user === 0}>
183 {thousands(row.user)}
184 </Text>
185 </Box>
186 <Box width={columnWidths.claude} justifyContent="flex-end">
187 <Text color="claude" dimColor={row.claude === 0}>
188 {thousands(row.claude)}
189 </Text>
190 </Box>
191 <Box width={columnWidths.total} justifyContent="flex-end">
192 <Text bold>{thousands(row.total)}</Text>
193 </Box>
194 <Box width={barWidth}>
195 <Text wrap="truncate">
196 <Text color="permission">{row.bar.you}</Text>
197 <Text color="claude">{row.bar.claude}</Text>
198 <Text color="subtle">{row.bar.track}</Text>
199 </Text>
200 </Box>
201 <Box width={columnWidths.rate} justifyContent="flex-end">
202 <Text dimColor>{row.rate}</Text>
203 </Box>
204 <Box width={columnWidths.last} justifyContent="flex-end">
205 <Text dimColor>{row.lastUsed}</Text>
206 </Box>
207 </Box>
208 ))}
209
210 {rows.length > 0 && (
211 <Box justifyContent="space-between" marginTop={1}>
212 <Text dimColor>{hiddenCount > 0 ? `+${String(hiddenCount)} more` : ''}</Text>
213 <Box gap={2}>
214 <Text dimColor>
215 <Text color="permission">■</Text> you
216 </Text>
217 <Text dimColor>
218 <Text color="claude">■</Text> claude
219 </Text>
220 <Text dimColor>/100p per 100 prompts</Text>
221 </Box>
222 </Box>
223 )}
224 </Box>
225 )
226 })
227}
228hooks/attribution.ts 45 lines1import type { CommandInfo } from 'claude-code'
2
3import type { Source } from '../types'
4
5export interface Attribution {
6 pendingSkillCalls: number
7 isSkillCallCounted: boolean
8 typedCommand: string | null
9}
10
11export function initialAttribution(): Attribution {
12 return { pendingSkillCalls: 0, isSkillCallCounted: false, typedCommand: null }
13}
14
15export function typedCommandOf(text: string): string | null {
16 return /^\/(\S+)/.exec(text.trim())?.[1] ?? null
17}
18
19function baseName(skill: string): string {
20 return skill.replace(/^\//, '').split(':').pop() ?? skill
21}
22
23export function isSameSkill(a: string, b: string): boolean {
24 return baseName(a) === baseName(b)
25}
26
27export function sourceOf(attribution: Attribution, skill: string): Source {
28 if (attribution.pendingSkillCalls > 0) return 'claude'
29 if (attribution.typedCommand !== null && isSameSkill(attribution.typedCommand, skill)) return 'user'
30 return 'preload'
31}
32
33function isSkillCommand(command: CommandInfo): boolean {
34 return command.source === 'user' || (command.source === 'plugin' && command.name.includes(':'))
35}
36
37export function typedSkillOf(typed: string, commands: readonly CommandInfo[]): string | null {
38 const skills = commands.filter(isSkillCommand)
39 const exact = skills.find(command => command.name === typed)
40 if (exact !== undefined) return exact.name
41
42 const sameName = skills.filter(command => isSameSkill(command.name, typed))
43 return sameName.length === 1 ? (sameName[0]?.name ?? null) : null
44}
45hooks/band.ts 103 lines1import type { Usage } from '../types'
2import { rankedSkills } from './usage'
3
4export interface StackedBar {
5 you: string
6 claude: string
7 track: string
8}
9
10export interface BandRow {
11 rank: number
12 name: string
13 user: number
14 claude: number
15 total: number
16 bar: StackedBar
17 rate: string
18 lastUsed: string
19}
20
21export interface BandLayout {
22 width: number
23 nameWidth: number
24 barWidth: number
25 rows: BandRow[]
26 hiddenCount: number
27}
28
29export const columnWidths = { rank: 3, you: 5, claude: 6, total: 6, rate: 6, last: 9 } as const
30
31const minuteMs = 60_000
32const hourMs = 60 * minuteMs
33const dayMs = 24 * hourMs
34const eighths = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
35
36const frameColumns = 4
37const frameRows = 7
38const columnGaps = 7
39const nameWidthRange = { min: 5, max: 26 }
40const barWidthRange = { min: 6, max: 40 }
41
42function clamp(value: number, range: { min: number; max: number }): number {
43 return Math.max(range.min, Math.min(range.max, value))
44}
45
46export function thousands(value: number): string {
47 return value.toLocaleString('en-US')
48}
49
50export function relativeTime(iso: string, now: number): string {
51 const elapsed = now - Date.parse(iso)
52 if (Number.isNaN(elapsed)) return '-'
53 if (elapsed < minuteMs) return 'just now'
54 if (elapsed < hourMs) return `${String(Math.floor(elapsed / minuteMs))}m ago`
55 if (elapsed < dayMs) return `${String(Math.floor(elapsed / hourMs))}h ago`
56 if (elapsed < 30 * dayMs) return `${String(Math.floor(elapsed / dayMs))}d ago`
57 return iso.slice(0, 10)
58}
59
60export function perHundred(count: number, prompts: number): string {
61 return prompts > 0 ? ((count / prompts) * 100).toFixed(1) : '-'
62}
63
64export function stackedBar(user: number, claude: number, max: number, width: number): StackedBar {
65 const scale = max > 0 ? width / max : 0
66 const youCells = Math.round(user * scale)
67 const claudeCells = Math.max(0, (user + claude) * scale - youCells)
68 const fullCells = Math.floor(claudeCells)
69 const minTail = fullCells === 0 ? 1 : 0
70 const tail = claude > 0 ? (eighths[Math.max(minTail, Math.round((claudeCells - fullCells) * 8))] ?? '') : ''
71 const claudeBar = '█'.repeat(fullCells) + tail
72
73 return {
74 you: '█'.repeat(youCells),
75 claude: claudeBar,
76 track: '─'.repeat(Math.max(0, width - youCells - claudeBar.length)),
77 }
78}
79
80export function bandLayout(usage: Usage, bodyColumns: number, maxRows: number, now: number): BandLayout {
81 const ranked = rankedSkills(usage)
82 const shown = ranked.slice(0, Math.max(1, maxRows - frameRows))
83
84 const nameWidth = clamp(Math.max(0, ...shown.map(skill => skill.name.length)), nameWidthRange)
85 const fixedWidth = Object.values(columnWidths).reduce((sum, width) => sum + width, 0) + nameWidth + columnGaps
86 const barWidth = clamp(bodyColumns - frameColumns - fixedWidth, barWidthRange)
87 const width = Math.min(bodyColumns, frameColumns + fixedWidth + barWidth)
88 const max = Math.max(1, ...shown.map(skill => skill.total))
89
90 const rows = shown.map((skill, index) => ({
91 rank: index + 1,
92 name: skill.name,
93 user: skill.user,
94 claude: skill.claude,
95 total: skill.total,
96 bar: stackedBar(skill.user, skill.claude, max, barWidth),
97 rate: perHundred(skill.total, usage.prompts),
98 lastUsed: relativeTime(skill.lastUsed, now),
99 }))
100
101 return { width, nameWidth, barWidth, rows, hiddenCount: ranked.length - shown.length }
102}
103hooks/usage.ts 60 lines1import type { SkillStats, Source, Usage } from '../types'
2
3export type Counter = 'sessions' | 'prompts' | 'turns'
4
5export interface RankedSkill {
6 name: string
7 user: number
8 claude: number
9 total: number
10 lastUsed: string
11}
12
13const emptyStats: SkillStats = { user: 0, claude: 0, preload: 0, lastUsed: '' }
14
15function isRecord(value: unknown): value is Record<string, unknown> {
16 return typeof value === 'object' && value !== null && !Array.isArray(value)
17}
18
19export function emptyUsage(now: number): Usage {
20 return { since: new Date(now).toISOString(), sessions: 0, prompts: 0, turns: 0, skills: {} }
21}
22
23export function parseUsage(text: string | null, now: number): Usage {
24 if (text === null) return emptyUsage(now)
25
26 try {
27 const parsed: unknown = JSON.parse(text)
28 return isRecord(parsed) ? { ...emptyUsage(now), ...(parsed as Partial<Usage>) } : emptyUsage(now)
29 } catch {
30 return emptyUsage(now)
31 }
32}
33
34export function serializeUsage(usage: Usage): string {
35 return `${JSON.stringify(usage, null, 2)}\n`
36}
37
38export function withCount(usage: Usage, counter: Counter): Usage {
39 return { ...usage, [counter]: usage[counter] + 1 }
40}
41
42export function withInvocation(usage: Usage, skill: string, source: Source, now: number): Usage {
43 const stats = usage.skills[skill] ?? emptyStats
44 const counted = { ...stats, [source]: stats[source] + 1, lastUsed: new Date(now).toISOString() }
45 return { ...usage, skills: { ...usage.skills, [skill]: counted } }
46}
47
48export function rankedSkills(usage: Usage): RankedSkill[] {
49 return Object.entries(usage.skills)
50 .map(([name, stats]) => ({
51 name,
52 user: stats.user,
53 claude: stats.claude,
54 total: stats.user + stats.claude,
55 lastUsed: stats.lastUsed,
56 }))
57 .filter(skill => skill.total > 0)
58 .sort((a, b) => b.total - a.total || a.name.localeCompare(b.name))
59}
60types/index.d.ts 23 lines1export type Source = 'user' | 'claude' | 'preload'
2
3export interface SkillStats {
4 user: number
5 claude: number
6 preload: number
7 lastUsed: string
8}
9
10export interface Usage {
11 since: string
12 sessions: number
13 prompts: number
14 turns: number
15 skills: Record<string, SkillStats>
16}
17
18declare module 'claude-code' {
19 interface PluginState {
20 'skill-usage': { isSessionCounted: boolean; usage: Usage | null; isOpen: boolean }
21 }
22}
23