SLOPSHOPPER

skill-usage

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

newbandguardcommandprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · skill-usage
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /skill-usage ⎿ skill-usage: Skill usage shown above the prompt. ╭─────────────────────────────────────────────────────────────────────────────────────────╮ │ Skill usage 0 sessions 0 prompts 1 turns since 2025-10-09 │ │ │ │ No skill invocations yet. Type a /skill or let Claude use one. │ ╰─────────────────────────────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭─────────────────────────────────────────────────────────────────────────────────────────╮ │ Skill usage 0 sessions 0 prompts 1 turns since 2025-10-09 │ │ │ │ No skill invocations yet. Type a /skill or let Claude use one. │ ╰─────────────────────────────────────────────────────────────────────────────────────────╯
README

skill-usage

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:

Seven skills ranked by invocations, each with a bar split into your share and Claude's

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.

Screenshots

Taken in Claude Code with made-up counts.

No invocations yet, before any skill was used:

The band says no skill invocations yet

Features

  • You or Claude: tells skills you typed as /name apart from skills Claude invoked through the Skill tool, in the main conversation or a subagent.
  • Counts in context: sessions, prompts and turns are counted alongside, and /100p gives each skill's invocations per 100 prompts.
  • Ranked at a glance: skills are ordered by total invocations, with a bar split into your share and Claude's, and when each was last used.
  • Follows your Claude Code theme: all colors come from the theme, and switching it with /theme recolors the band right away.
  • Shared across sessions: one small JSON file holds the counts for every session and project, and parallel sessions never overwrite each other's counts.
  • Light on resources: reads and writes one local file only, and costs no tokens.
  • Fits the terminal: the bar and name columns shrink to the terminal width, and skills beyond the band's height are summed up as +N more.

Requirements

  • Claude Code with function-hook plugins (mods), tested on 2.1.293. The mod API is in early access and may change between releases.

Install

  1. In Claude Code, add the marketplace and install the mod: `` /plugin marketplace add JanSuthacheeva/claude-code-mods /plugin install skill-usage@claude-code-mods ``
  2. Restart Claude Code. Counting starts right away; run /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

The band

ColumnMeaning
youInvocations you typed as /name
claudeInvocations Claude made through the Skill tool
totalBoth together, the order of the list
barYour share, then Claude's, scaled to the top skill
/100pInvocations per 100 prompts
lastWhen the skill was last invoked, by you or by Claude

Commands

  • /skill-usage shows or hides the band.

What it counts

  • you: a skill you invoked by typing /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.
  • claude: a skill Claude invoked through the Skill tool, in the main conversation or a subagent.
  • preload: a skill whose prompt was expanded without either, such as one preloaded into a subagent. Kept in the file, left out of the band.
  • sessions: each Claude Code session the mod ran in, once, however often it reloads.
  • prompts: the messages you sent, at the terminal or through Remote Control.
  • turns: the model turns, including the ones that continue without a new prompt.

Storage

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.

Development

claude plugin validate .
claude plugin test .
Source 5 files
hooks/register.tsx 228 lines
1import { 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}
228
hooks/attribution.ts 45 lines
1import 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}
45
hooks/band.ts 103 lines
1import 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}
103
hooks/usage.ts 60 lines
1import 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}
60
types/index.d.ts 23 lines
1export 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