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

A pane that shows every skill your Claude Code session has, as a searchable table.
/name), and its file path.- if it stands alone.repo link when the skill ships with a GitHub URL in its metadata or README.Your own skills are listed first, then plugin skills, then built-ins.
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.
Type /library to open the pane.
~/.claude/skills folder. For plugin and built-in skills the engine only gives a name and source.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.USERPROFILE), so it has only been tried on Windows.MIT
hooks/register.tsx 310 lines1import { 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}
310types/index.d.ts 23 lines1export 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