SLOPSHOPPER

skills-used

A Skills used panel: every skill run in the session as a badge, the ones of the current reply in green, with who started each and its description on hover

paneguardcommandprompt
v0.2.0no licenseupdated 2026-10-16exsec-dev/skills-used
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · skills-used
│ ┃ Skills used ✕ › fix the failing auth test and add an audit log call │ ┃ No skills used yet. │ ⏺ 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 │ │ › /skills-used │ ⎿ skills-used: Skills used panel opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Skills used
No skills used yet.
README

skills-used

A Claude Code mod that shows which skills Claude used in the session.

It opens a Skills used panel with one badge per skill:

  • every skill run in the session, in the order it was first used, with ×N once it ran more than once;
  • a plugin's skill shows the plugin's name dimmed before its own: engineering standup;
  • the skills of the reply Claude is writing right now are green, and turn gray when the reply is done;
  • an icon after the name says who started the skill;
  • hovering a badge opens its description from SKILL.md right under its row, with how often it ran and in which reply it last did.
IconStarted by
personyou, by typing /skill-name
sparklesClaude, on its own
linkClaude, while another skill of the same reply was in use
robota subagent

The list resets on /clear.

Install

At the prompt of a terminal session:

/plugin install skills-used --marketplace exsec-dev/skills-used

Answer y to add the marketplace, then pick a scope (user scope loads it in every session).

From a shell:

claude plugin install skills-used --marketplace exsec-dev/skills-used

In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with exsec-dev/skills-used, then Add plugin.

Use

The panel opens by itself with the first skill Claude uses in a session. If you closed it, /skills-used opens it again.

Limits

  • Skills run before the mod loaded are not shown.
  • Where an organization manages Claude Code, skill events reach only the plugins the organization lists in its managed settings, so a plugin installed by hand may show nothing.
  • The mod uses Claude Code's function hooks, an early-access API that may change between releases.

Credits

Source 6 files
hooks/register.tsx 301 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { SkillName, Trigger } from '../types'
5import { frontmatterDescription } from './frontmatter'
6import { splitName, usageLine } from './labels'
7import { badgeWidth, toRows } from './rows'
8import { classifyTrigger, GLYPHS, triggerIcon, triggerLabel } from './triggers'
9
10const PANE = 'skills-used'
11const TITLE = 'Skills used'
12const DESCRIPTION_LIMIT = 280
13
14// Badge tints: translucent, so they sit on the panel in both light and dark themes.
15// The border takes the fill's color: the desktop draws a round border, which rounds the badge.
16const TINT = '#9a98902e'
17const TINT_HOVER = '#9a989047'
18const TINT_ACTIVE = '#5fa85f33'
19const TINT_ACTIVE_HOVER = '#5fa85f4d'
20const GREEN = '#7dba7d'
21const MUTED = '#9a9890'
22const HAIRLINE = '#9a989040'
23const CARD = '#9a98901a'
24const ICON_SIZE = 12
25
26// Skills run in this session, in the order they were first used.
27const used = atom({ plugin: 'skills-used', key: 'used' } as const, [] as SkillName[])
28// Skills run in the reply that is being written right now.
29const current = atom({ plugin: 'skills-used', key: 'current' } as const, [] as SkillName[])
30// Each skill's description from its SKILL.md frontmatter.
31const descriptions = atom(
32  { plugin: 'skills-used', key: 'descriptions' } as const,
33  {} as Record<SkillName, string>,
34)
35// Who started each skill the last time it ran.
36const triggers = atom({ plugin: 'skills-used', key: 'triggers' } as const, {} as Record<SkillName, Trigger>)
37// How many times each skill ran this session.
38const counts = atom({ plugin: 'skills-used', key: 'counts' } as const, {} as Record<SkillName, number>)
39// Replies finished so far, and the reply each skill last ran in: "2 replies ago" stays true without a timer.
40const replies = atom({ plugin: 'skills-used', key: 'replies' } as const, 0)
41const lastReply = atom({ plugin: 'skills-used', key: 'lastReply' } as const, {} as Record<SkillName, number>)
42
43// Markers between events of one reply; a reload drops them.
44let manualCommand: string | undefined
45const pendingSkillCalls = new Map<SkillName, string>()
46
47const addOnce = (name: SkillName) => (list: SkillName[]) =>
48  list.includes(name) ? list : [...list, name]
49
50const shorten = (text: string) => {
51  const flat = text.replace(/\s+/g, ' ').trim()
52
53  return flat.length > DESCRIPTION_LIMIT ? `${flat.slice(0, DESCRIPTION_LIMIT).trimEnd()}…` : flat
54}
55
56// Skills used before their description was recorded: read it from the skill's folder beside this one.
57const tried = new Set<SkillName>()
58async function fillMissing($: EngineInterface, list: SkillName[], known: Record<SkillName, string>) {
59  for (const name of list) {
60    if (known[name] !== undefined || tried.has(name) || name.includes(':')) {
61      continue
62    }
63    tried.add(name)
64    try {
65      const description = frontmatterDescription(await $.fs.read(`${$.plugin.root}/../${name}/SKILL.md`))
66      if (description !== undefined) {
67        await update($, descriptions, all => ({ ...all, [name]: shorten(description) }))
68      }
69    } catch {
70      // Not a skill of the user's skills folder: it stays without a description.
71    }
72  }
73}
74
75export const register: Register = on => {
76  on('session.start', async ($, e, next) => {
77    await $.command.register({
78      name: 'skills-used',
79      description: 'Open the Skills used panel',
80    })
81    // An empty panel would only take room: it opens with the first skill, or now if this session has some.
82    if ((await read($, used)).length > 0) {
83      void $.ui.open({ id: PANE, title: TITLE }).catch(() => undefined)
84    }
85
86    return next(e)
87  })
88
89  // A slash command the person typed: a skill run this way is theirs.
90  on('command.run', async ($, e, next) => {
91    manualCommand = e.command.replace(/^\//, '')
92
93    return next(e)
94  })
95
96  // A Skill call, from the main loop or a subagent, is pending until its skill has loaded.
97  on('tool.call', { tool: 'Skill' }, async ($, e, next) => {
98    pendingSkillCalls.set(e.skill, e.agentId ?? 'main')
99    try {
100      return await next(e)
101    } finally {
102      pendingSkillCalls.delete(e.skill)
103    }
104  })
105
106  // Fires for /name typed by the person, the Skill tool, and skills preloaded into a subagent.
107  on('skill.prompt', async ($, e, next) => {
108    const trigger = classifyTrigger(e.skill, manualCommand, pendingSkillCalls.get(e.skill), await read($, current))
109    if (trigger.kind === 'manual') {
110      manualCommand = undefined
111    }
112    const isFirst = (await read($, used)).length === 0
113    const reply = await read($, replies)
114    await update($, triggers, all => ({ ...all, [e.skill]: trigger }))
115    await update($, counts, all => ({ ...all, [e.skill]: (all[e.skill] ?? 0) + 1 }))
116    await update($, lastReply, all => ({ ...all, [e.skill]: reply }))
117    await update($, used, addOnce(e.skill))
118    await update($, current, addOnce(e.skill))
119    if (isFirst) {
120      void $.ui.open({ id: PANE, title: TITLE }).catch(() => undefined)
121    }
122
123    const description = e.frontmatter.description
124    if (typeof description === 'string' && description.trim() !== '') {
125      await update($, descriptions, all => ({ ...all, [e.skill]: shorten(description) }))
126    }
127
128    return next(e)
129  })
130
131  // The main loop's reply is done: nothing is in use any more.
132  on('turn.complete', async ($, e, next) => {
133    if (e.agentId === undefined) {
134      manualCommand = undefined
135      await update($, current, () => [])
136      await update($, replies, n => n + 1)
137    }
138
139    return next(e)
140  })
141
142  on('session.end', async ($, e, next) => {
143    if (e.reason === 'clear') {
144      await update($, used, () => [])
145      await update($, current, () => [])
146      await update($, triggers, () => ({}))
147      await update($, counts, () => ({}))
148      await update($, lastReply, () => ({}))
149      await update($, replies, () => 0)
150    }
151
152    return next(e)
153  })
154
155  on('command.run', { command: 'skills-used' }, async $ => {
156    const opened = await $.ui.open({ id: PANE, title: TITLE })
157
158    return { text: opened.isPlaced ? 'Skills used panel opened.' : 'The panel did not fit on screen.' }
159  })
160
161  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
162    const elements = $.ui.resolve(e)
163    const { Box, Text } = elements
164    const list = await read($, used)
165    const active = await read($, current)
166    const about = await read($, descriptions)
167    const startedBy = await read($, triggers)
168    const runs = await read($, counts)
169    const replyNow = await read($, replies)
170    const lastIn = await read($, lastReply)
171    void fillMissing($, list, about)
172
173    // The trigger's icon: an Svg where the surface draws one, a glyph elsewhere.
174    const icon = (name: SkillName, isActive: boolean) => {
175      const trigger = startedBy[name]
176      if (trigger === undefined) {
177        return null
178      }
179      const color = isActive ? GREEN : MUTED
180      if (e.surface === 'desktop' && 'Svg' in elements) {
181        const { Svg } = elements
182
183        return (
184          <Svg
185            source={triggerIcon(trigger.kind, color)}
186            alt={triggerLabel(trigger)}
187            width={ICON_SIZE}
188            height={ICON_SIZE}
189          />
190        )
191      }
192
193      return (
194        <Text size="footnote" color={color}>
195          {GLYPHS[trigger.kind]}
196        </Text>
197      )
198    }
199
200    if (list.length === 0) {
201      return (
202        <Text size="footnote" dimColor>
203          No skills used yet.
204        </Text>
205      )
206    }
207
208    const scopeOf = (name: SkillName) => `tile-${list.indexOf(name)}`
209
210    const badge = (name: SkillName) => {
211      const isActive = active.includes(name)
212      const fill = isActive ? TINT_ACTIVE : TINT
213      const hoverFill = isActive ? TINT_ACTIVE_HOVER : TINT_HOVER
214      const { plugin, skill } = splitName(name)
215      const count = runs[name] ?? 1
216
217      return (
218        <Box
219          key={name}
220          borderStyle="round"
221          borderColor={fill}
222          backgroundColor={fill}
223          paddingX={1}
224          flexDirection="row"
225          alignItems="center"
226          gap="xs"
227          hover={{ scope: scopeOf(name), borderColor: hoverFill, backgroundColor: hoverFill }}
228        >
229          {plugin !== undefined && (
230            <Text size="caption" color={MUTED}>
231              {plugin}
232            </Text>
233          )}
234          {isActive ? (
235            <Text size="footnote" color={GREEN}>
236              {skill}
237            </Text>
238          ) : (
239            <Text size="footnote" dimColor>
240              {skill}
241            </Text>
242          )}
243          {count > 1 && (
244            <Text size="caption" color={isActive ? GREEN : MUTED}>
245              ×{count}
246            </Text>
247          )}
248          {icon(name, isActive)}
249        </Box>
250      )
251    }
252
253    // The hovered badge's description: hidden until its badge is under the pointer.
254    const card = (name: SkillName) => (
255      <Box
256        key={`about-${name}`}
257        display="none"
258        hover={{ scope: scopeOf(name), display: 'flex' }}
259        flexDirection="column"
260        borderStyle="round"
261        borderColor={HAIRLINE}
262        backgroundColor={CARD}
263        paddingX={1}
264      >
265        <Text size="footnote" weight="medium">
266          {name}
267        </Text>
268        {startedBy[name] !== undefined && (
269          <Text size="caption" dimColor>
270            {triggerLabel(startedBy[name])}
271          </Text>
272        )}
273        {lastIn[name] !== undefined && (
274          <Text size="caption" dimColor>
275            {usageLine(runs[name] ?? 1, active.includes(name) ? 0 : replyNow - lastIn[name])}
276          </Text>
277        )}
278        <Box marginTop="xs">
279          <Text size="footnote" dimColor wrap="wrap">
280            {about[name] ?? 'No description.'}
281          </Text>
282        </Box>
283      </Box>
284    )
285
286    // Each row's cards sit right under it: a description opens beside its badge, and the rows below move down.
287    return (
288      <Box flexDirection="column" gap="xs">
289        {toRows(list, Math.max(20, e.props.bodyColumns), name => badgeWidth(name) + ((runs[name] ?? 1) > 1 ? 3 : 0)).map(row => (
290          <Box key={`row-${row[0]}`} flexDirection="column" gap="xs">
291            <Box flexDirection="row" flexWrap="wrap" gap="xs">
292              {row.map(badge)}
293            </Box>
294            {row.map(card)}
295          </Box>
296        ))}
297      </Box>
298    )
299  })
300}
301
hooks/frontmatter.ts 27 lines
1// The `description` of a SKILL.md frontmatter: inline, quoted, or a `|` / `>` block.
2export const frontmatterDescription = (file: string): string | undefined => {
3  const head = /^---\r?\n([\s\S]*?)\r?\n---/.exec(file)?.[1]
4  if (head === undefined) {
5    return undefined
6  }
7  const lines = head.split(/\r?\n/)
8  const at = lines.findIndex(line => /^description:/.test(line))
9  if (at === -1) {
10    return undefined
11  }
12  const inline = (lines[at] ?? '').slice('description:'.length).trim()
13  if (inline !== '' && !/^[|>][-+]?$/.test(inline)) {
14    return inline.replace(/^(['"])([\s\S]*)\1$/, '$2')
15  }
16  const block: string[] = []
17  for (const line of lines.slice(at + 1)) {
18    if (line.trim() !== '' && !/^\s/.test(line)) {
19      break
20    }
21    block.push(line.trim())
22  }
23  const text = block.join(' ').trim()
24
25  return text === '' ? undefined : text
26}
27
hooks/labels.ts 22 lines
1import type { SkillName } from '../types'
2
3// A plugin skill's name in two parts, so the badge can dim the plugin: `engineering:standup`.
4export const splitName = (name: SkillName): { plugin?: string; skill: string } => {
5  const at = name.lastIndexOf(':')
6
7  return at > 0 && at < name.length - 1 ? { plugin: name.slice(0, at), skill: name.slice(at + 1) } : { skill: name }
8}
9
10// How often a skill ran, and in which reply it last did.
11export const usageLine = (count: number, repliesAgo: number) => {
12  const times = count === 1 ? 'Used once' : `Used ${count} times`
13  const when =
14    repliesAgo <= 0
15      ? 'last in this reply'
16      : repliesAgo === 1
17        ? 'last in the previous reply'
18        : `last ${repliesAgo} replies ago`
19
20  return `${times} · ${when}`
21}
22
hooks/rows.ts 32 lines
1import type { SkillName } from '../types'
2
3// A badge's width in cells, rounded up: one cell per character, plus padding, border, icon and the gap after it.
4export const badgeWidth = (name: SkillName) => name.length + 7
5
6// Badges in rows that fit the panel, so a hovered badge's description can open right under its row.
7// A badge wider than the panel gets a row of its own.
8export const toRows = (
9  names: SkillName[],
10  columns: number,
11  widthOf: (name: SkillName) => number = badgeWidth,
12): SkillName[][] => {
13  const rows: SkillName[][] = []
14  let row: SkillName[] = []
15  let used = 0
16  for (const name of names) {
17    const width = widthOf(name)
18    if (row.length > 0 && used + width > columns) {
19      rows.push(row)
20      row = []
21      used = 0
22    }
23    row.push(name)
24    used += width
25  }
26  if (row.length > 0) {
27    rows.push(row)
28  }
29
30  return rows
31}
32
hooks/triggers.ts 61 lines
1import type { SkillName, Trigger } from '../types'
2
3// Outline icons from Tabler Icons (MIT), 24×24, stroked in the given color.
4const PATHS: Record<Trigger['kind'], string> = {
5  manual:
6    '<path d="M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0"/><path d="M6 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2"/>',
7  model:
8    '<path d="M16 18a2 2 0 0 1 2 2a2 2 0 0 1 2 -2a2 2 0 0 1 -2 -2a2 2 0 0 1 -2 2zm0 -12a2 2 0 0 1 2 2a2 2 0 0 1 2 -2a2 2 0 0 1 -2 -2a2 2 0 0 1 -2 2zm-7 12a6 6 0 0 1 6 -6a6 6 0 0 1 -6 -6a6 6 0 0 1 -6 6a6 6 0 0 1 6 6z"/>',
9  chained:
10    '<path d="M9 15l6 -6"/><path d="M11 6l.463 -.536a5 5 0 0 1 7.071 7.072l-.534 .464"/><path d="M13 18l-.397 .534a5.068 5.068 0 0 1 -7.127 0a4.972 4.972 0 0 1 0 -7.071l.524 -.463"/>',
11  subagent:
12    '<path d="M6 6a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v4a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2z"/><path d="M12 2v2"/><path d="M9 12v9"/><path d="M15 12v9"/><path d="M5 16l4 -2"/><path d="M15 14l4 2"/><path d="M9 18h6"/><path d="M10 8v.01"/><path d="M14 8v.01"/>',
13}
14
15// Stand-ins where a surface draws no Svg.
16export const GLYPHS: Record<Trigger['kind'], string> = {
17  manual: '/',
18  model: '✦',
19  chained: '↳',
20  subagent: '◇',
21}
22
23export const triggerIcon = (kind: Trigger['kind'], color: string) =>
24  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${PATHS[kind]}</svg>`
25
26export const triggerLabel = (trigger: Trigger) => {
27  switch (trigger.kind) {
28    case 'manual':
29      return 'Started by you'
30    case 'model':
31      return 'Started by Claude'
32    case 'chained':
33      return `Chained after ${trigger.after}`
34    case 'subagent':
35      return 'Started in a subagent'
36  }
37}
38
39// Who started a skill: a typed /name, a Skill call of the main loop or a subagent, or a preload.
40// The four kinds follow madolphe/skill-trace (MIT).
41export const classifyTrigger = (
42  skill: SkillName,
43  manualCommand: string | undefined,
44  pendingAgent: string | undefined,
45  activeInReply: SkillName[],
46): Trigger => {
47  if (manualCommand === skill) {
48    return { kind: 'manual' }
49  }
50  if (pendingAgent === undefined) {
51    // No Skill call to answer for it: preloaded into a subagent as it spawned.
52    return { kind: 'subagent' }
53  }
54  if (pendingAgent !== 'main') {
55    return { kind: 'subagent' }
56  }
57  const before = activeInReply.filter(name => name !== skill).at(-1)
58
59  return before === undefined ? { kind: 'model' } : { kind: 'chained', after: before }
60}
61
types/index.d.ts 22 lines
1export type SkillName = string
2
3export type Trigger =
4  | { kind: 'manual' }
5  | { kind: 'model' }
6  | { kind: 'chained'; after: SkillName }
7  | { kind: 'subagent' }
8
9declare module 'claude-code' {
10  interface PluginState {
11    'skills-used': {
12      used: SkillName[]
13      current: SkillName[]
14      descriptions: Record<SkillName, string>
15      triggers: Record<SkillName, Trigger>
16      counts: Record<SkillName, number>
17      replies: number
18      lastReply: Record<SkillName, number>
19    }
20  }
21}
22