SLOPSHOPPER

skill-library

A pane that lists every installed skill as a searchable table, with its skillset, description, how to invoke it and a link when it has one

panecommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · skill-library
│ ┃ Skill library ✕ › fix the failing auth test and add an audit log call │ ┃ 0 skills [ Refresh ] │ ┃ Click a skill to read it. Scroll with the ⏺ Read(src/auth.ts) │ ┃ wheel or arrow keys. ⎿ Read 6 lines │ ┃ Search: : name, skillset or description ⏎ se ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ [ Skill ] [ Skillset ⏺ Bash(bun test) │ ┃ No skills loaded. Press Refresh. ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /library │ ┃ ⎿ skill-library: Skill library opened. Invoke any skill by typing │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Skill library
0 skills [ Refresh ] Click a skill to read it. Scroll with the wheel or arrow keys. Search: : name, skillset or description ⏎ search [ Skill ] [ Skillset ] [ Link ] No skills loaded. Press Refresh.
README

Skill Library (a Claude Code mod)

A pane that shows every skill your Claude Code session has, as a searchable table.

  • Skill: the name. Click it to open its full description, how to invoke it (/name), and its file path.
  • Skillset: the larger set it belongs to (a plugin, or a family such as Google Workspace or HyperFrames), or - if it stands alone.
  • Link: a repo link when the skill ships with a GitHub URL in its metadata or README.
  • A search box filters by name, skillset or description.
  • Click a column heading to sort by it (click again for descending, a third time to reset).
  • The header (count, search, column headings) stays pinned. Scroll the list with the mouse wheel, the arrow, Page Up/Down, Home and End keys, or the Up / Down / Page buttons.

Your own skills are listed first, then plugin skills, then built-ins.

Install

At the prompt of a Claude Code terminal session:

/plugin install skill-library --marketplace PabloPicasserole/skill-library-claude-code-mod

From a shell:

claude plugin install skill-library --marketplace PabloPicasserole/skill-library-claude-code-mod

Answer y to add the marketplace, then pick a scope.

Use

Type /library to open the pane.

Known limits (first version)

  • Descriptions and links only show for skills in your own ~/.claude/skills folder. For plugin and built-in skills the engine only gives a name and source.
  • Skillsets are guessed: plugin skills use the plugin's name; skills named by Matt Pocock's ask-matt router show as "Matt Pocock" (only if you have that skill); a few name prefixes (gws-, hyperframes, ponytail, hairline) are grouped as families. Everything else is shown as standalone. Edit FAMILIES in hooks/register.tsx to add your own.
  • It reads skills from the Windows user profile (USERPROFILE), so it has only been tried on Windows.
  • The row height is tuned to the desktop app. A different screen scale may leave a little blank space or clip a row.
  • Mods and the pane API are new; this has been tested only on the author's machine.

License

MIT

Source 2 files
hooks/register.tsx 310 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { SkillRow } from '../types'
5
6const PANE = 'skill-library'
7const skills = atom({ plugin: 'skill-library', key: 'skills' } as const, [])
8const selected = atom({ plugin: 'skill-library', key: 'selected' } as const, null)
9const query = atom({ plugin: 'skill-library', key: 'query' } as const, '')
10const offset = atom({ plugin: 'skill-library', key: 'offset' } as const, 0)
11const sort = atom({ plugin: 'skill-library', key: 'sort' } as const, { col: 'default', dir: 'asc' })
12
13type Col = 'name' | 'set' | 'link'
14
15// The text a column sorts on; an empty value always sorts last.
16const cell = (s: SkillRow, col: Col): string =>
17  col === 'name' ? s.name : col === 'set' ? s.set : (s.link ?? '')
18
19// Your own skills first, built-ins last.
20const ORDER = ['userSettings', 'projectSettings', 'plugin', 'syncedSkills', 'mcp', 'built-in']
21
22// Name prefixes that mark a family of skills from one author.
23const FAMILIES: Record<string, string> = {
24  gws: 'Google Workspace',
25  hyperframes: 'HyperFrames',
26  ponytail: 'Ponytail',
27  hairline: 'Hairline',
28}
29
30// One line of a SKILL.md's frontmatter, quotes removed.
31const front = (text: string, key: string): string | undefined => {
32  const head = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)?.[1] ?? ''
33  const line = new RegExp(`^${key}:\\s*(.*)$`, 'm').exec(head)?.[1]?.trim()
34
35  return line ? line.replace(/^["']|["']$/g, '') : undefined
36}
37
38const firstUrl = (text: string): string | undefined =>
39  /https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+/.exec(text)?.[0]
40
41const rank = (source: string) => {
42  const i = ORDER.indexOf(source)
43
44  return i < 0 ? ORDER.length : i
45}
46
47async function load($: EngineInterface) {
48  const usage = await $.session.usage({ breakdown: 'summary' })
49  const listed = usage.context.breakdown?.skills?.skillFrontmatter ?? []
50  const home = await $.env.get('USERPROFILE')
51  const base = home ? `${home}/.claude/skills` : undefined
52
53  // Matt Pocock's skills are the ones his router skill points to.
54  const router = base ? await $.fs.read(`${base}/ask-matt/SKILL.md`).catch(() => undefined) : undefined
55  const matt = new Set<string>(
56    typeof router === 'string' ? [...router.matchAll(/`\/([a-z0-9-]+)`/g)].map(m => m[1]) : [],
57  )
58  if (typeof router === 'string') {
59    matt.add('ask-matt')
60  }
61
62  const rows: SkillRow[] = []
63  for (const one of listed) {
64    const prefix = one.name.split(/[-:]/)[0]
65    const set = one.pluginName ?? (matt.has(one.name) ? 'Matt Pocock' : (FAMILIES[prefix] ?? ''))
66    const row: SkillRow = { name: one.name, source: one.source, set: one.source === 'built-in' ? 'built-in' : set }
67
68    if (one.source === 'userSettings' && base) {
69      const path = `${base}/${one.name}/SKILL.md`
70      const text = await $.fs.read(path).catch(() => undefined)
71      if (typeof text === 'string') {
72        row.path = path
73        row.description = front(text, 'description')
74        row.link = front(text, 'homepage') ?? front(text, 'repository') ?? firstUrl(text)
75        if (!row.link) {
76          const readme = await $.fs.read(`${base}/${one.name}/README.md`).catch(() => undefined)
77          row.link = typeof readme === 'string' ? firstUrl(readme) : undefined
78        }
79      }
80    }
81    rows.push(row)
82  }
83  rows.sort(
84    (a, b) =>
85      rank(a.source) - rank(b.source) ||
86      (a.set === '' ? 1 : 0) - (b.set === '' ? 1 : 0) ||
87      a.set.localeCompare(b.set) ||
88      a.name.localeCompare(b.name),
89  )
90  await update($, skills, () => rows)
91}
92
93// The pane draws its own window of rows so the header above them stays put.
94// Sizes are in the engine's cells; a graphical surface draws a button row taller
95// than one cell, so each row is counted as ROW_CELLS.
96const HEADER_CELLS = 12
97const ROW_CELLS = 1.3
98// Cells kept free for an open description when working out where the list ends.
99const CARD_RESERVE = 10
100
101type Order = { col: string; dir: string }
102
103// The skills matching the search, in the order the column heads ask for.
104const arrange = (all: SkillRow[], typed: string, order: Order): SkillRow[] => {
105  const q = typed.trim().toLowerCase()
106  const found = q
107    ? all.filter(s => `${s.name} ${s.set} ${s.description ?? ''}`.toLowerCase().includes(q))
108    : all
109  if (order.col === 'default') {
110    return found
111  }
112
113  return [...found].sort((a, b) => {
114    const x = cell(a, order.col as Col)
115    const y = cell(b, order.col as Col)
116    if (x === '' || y === '') {
117      return (x === '' ? 1 : 0) - (y === '' ? 1 : 0)
118    }
119
120    return (order.dir === 'asc' ? 1 : -1) * x.localeCompare(y)
121  })
122}
123
124// Cells the list may use: the body less the pinned header.
125const spaceFor = (bodyRows: number): number => Math.max(6, Math.floor(bodyRows - HEADER_CELLS))
126
127// The text of an open skill, as lines of at most `width` characters.
128const detail = (s: SkillRow): string[] => [
129  s.description ?? 'No description available for this source.',
130  `${s.set === '' ? 'Standalone skill' : `Part of: ${s.set}`}${s.path ? `  |  ${s.path}` : ''}`,
131]
132
133// Cells an open skill's description takes under its row.
134const cardCells = (s: SkillRow, columns: number): number => {
135  const width = Math.max(20, columns - 2)
136
137  return 2 + detail(s).reduce((n, text) => n + Math.max(1, Math.ceil(text.length / width)), 0) + (s.link ? 1 : 0)
138}
139
140// The furthest the list may scroll: the first row from which the rest still fit.
141const lastOffset = (count: number, space: number, hasCard: boolean): number => {
142  let used = hasCard ? CARD_RESERVE : 0
143  let fit = 0
144  while (fit < count && used + ROW_CELLS <= space) {
145    used += ROW_CELLS
146    fit += 1
147  }
148
149  return Math.max(0, count - Math.max(1, fit))
150}
151
152export const register: Register = on => {
153  on('session.start', async ($, e, next) => {
154    await $.command.register({
155      name: 'library',
156      description: 'Open the skill library pane (browse installed skills)',
157    })
158
159    return next(e)
160  })
161
162  on('command.run', { command: 'library' }, async $ => {
163    await load($)
164    const { isPlaced } = await $.ui.open({ id: PANE, title: 'Skill library' })
165
166    return {
167      text: isPlaced
168        ? 'Skill library opened. Invoke any skill by typing /<skill-name>.'
169        : 'This screen cannot draw mod panes, so the Skill library has nowhere to show.',
170    }
171  })
172
173  // The wheel and the scroll keys move `offset`; the engine's own window stays at 0.
174  on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e) => {
175    const list = arrange(await read($, skills), await read($, query), await read($, sort))
176    const last = lastOffset(list.length, spaceFor(e.bodyRows), (await read($, selected)) !== null)
177    await update($, offset, cur => Math.max(0, Math.min(Math.min(cur, last) + e.by, last)))
178
179    return {}
180  })
181
182  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
183    const { Box, Text, Button, Input, Link } = $.ui.resolve(e)
184    const all = await read($, skills)
185    const pick = await read($, selected)
186    const typed = await read($, query)
187    const order = await read($, sort)
188    const list = arrange(all, typed, order)
189    const space = spaceFor(e.props.scroll.bodyRows)
190    const columns = e.props.bodyColumns
191    // Column widths follow the pane, so the table lines up at any width.
192    const linkW = 7
193    const setW = Math.max(12, Math.min(20, Math.floor(columns * 0.3)))
194    const nameW = Math.max(16, columns - setW - linkW)
195    const top = Math.min(await read($, offset), lastOffset(list.length, space, pick !== null))
196
197    // Take rows from `top` until the space is used; an open description counts too.
198    const shown: SkillRow[] = []
199    let used = 0
200    for (const s of list.slice(top)) {
201      const cost = ROW_CELLS + (s.name === pick ? cardCells(s, columns) : 0)
202      if (shown.length > 0 && used + cost > space) {
203        break
204      }
205      shown.push(s)
206      used += cost
207    }
208
209    // The buttons in the header move the list a row or a page, less one row so the eye keeps its place.
210    const last = lastOffset(list.length, space, pick !== null)
211    const move = (by: number) =>
212      update($, offset, cur => Math.max(0, Math.min(Math.min(cur, last) + by, last)))
213    const step = Math.max(1, shown.length - 1)
214
215    // First click sorts ascending, second descending, third back to the default order.
216    const press = (col: Col) => {
217      void update($, offset, () => 0)
218      void update($, sort, cur =>
219        cur.col !== col
220          ? { col, dir: 'asc' }
221          : cur.dir === 'asc'
222            ? { col, dir: 'desc' }
223            : { col: 'default', dir: 'asc' },
224      )
225    }
226    const head = (col: Col, label: string) =>
227      order.col === col ? `${label} ${order.dir === 'asc' ? '^' : 'v'}` : label
228
229    return (
230      <Box flexDirection="column">
231        <Box>
232          <Text bold>
233            {typed.trim() ? `${list.length} of ${all.length}` : all.length} skills
234            {list.length > shown.length ? ` (${top + 1}-${top + shown.length})` : ''}{' '}
235          </Text>
236          <Button key="refresh" label="Refresh" onPress={() => load($)} />
237        </Box>
238        {list.length > shown.length && (
239          <Box gap={1} marginY={1}>
240            <Button key="up" label="Up" onPress={() => move(-1)} />
241            <Button key="down" label="Down" onPress={() => move(1)} />
242            <Button key="pgup" label="Page up" onPress={() => move(-step)} />
243            <Button key="pgdn" label="Page down" onPress={() => move(step)} />
244          </Box>
245        )}
246        <Text dimColor>Click a skill to read it. Scroll with the wheel or arrow keys.</Text>
247        <Box marginBottom={1}>
248          <Input
249            key="search"
250            label="Search: "
251            placeholder="name, skillset or description"
252            value={typed}
253            submitLabel="search"
254            onInput={value => {
255              void update($, query, () => value)
256              void update($, offset, () => 0)
257            }}
258            onSubmit={() => {}}
259          />
260        </Box>
261        <Box>
262          <Box width={nameW}>
263            <Button key="sort:name" label={head('name', 'Skill')} onPress={() => press('name')} />
264          </Box>
265          <Box width={setW}>
266            <Button key="sort:set" label={head('set', 'Skillset')} onPress={() => press('set')} />
267          </Box>
268          <Box width={linkW}>
269            <Button key="sort:link" label={head('link', 'Link')} onPress={() => press('link')} />
270          </Box>
271        </Box>
272        {list.length === 0 && (
273          <Text dimColor>{all.length === 0 ? 'No skills loaded. Press Refresh.' : 'No match.'}</Text>
274        )}
275        <Box height={space} overflow="hidden">
276          <Box flexDirection="column" flexGrow={1}>
277            {shown.map(s => (
278              <Box key={`row:${s.name}`} flexDirection="column">
279                <Box>
280                  <Box width={nameW}>
281                    <Button
282                      key={`open:${s.name}`}
283                      label={`/${s.name}`}
284                      onPress={() => update($, selected, cur => (cur === s.name ? null : s.name))}
285                    />
286                  </Box>
287                  <Box width={setW}>
288                    <Text dimColor={s.set === ''} wrap="truncate">
289                      {s.set === '' ? '-' : s.set}
290                    </Text>
291                  </Box>
292                  {s.link ? <Link href={s.link} label="repo" /> : <Text dimColor>-</Text>}
293                </Box>
294                {pick === s.name && (
295                  <Box flexDirection="column" paddingLeft={1} paddingY={1}>
296                    <Text bold>Invoke: /{s.name}</Text>
297                    <Text>{detail(s)[0]}</Text>
298                    <Text dimColor>{detail(s)[1]}</Text>
299                    {s.link && <Link href={s.link} />}
300                  </Box>
301                )}
302              </Box>
303            ))}
304          </Box>
305        </Box>
306      </Box>
307    )
308  })
309}
310
types/index.d.ts 23 lines
1export type SkillRow = {
2  name: string
3  source: string
4  /** The skillset it belongs to: a plugin, a family or "Matt Pocock"; empty when standalone. */
5  set: string
6  description?: string
7  path?: string
8  /** A web page for the skill (its repo or readme), when it ships one. */
9  link?: string
10}
11
12declare module 'claude-code' {
13  interface PluginState {
14    'skill-library': {
15      skills: SkillRow[]
16      selected: string | null
17      query: string
18      offset: number
19      sort: { col: string; dir: string }
20    }
21  }
22}
23