A side panel listing your Claude Mods and Skills, how often each is used, which model each runs on, and what each does.

A Claude Code mod that adds a side panel listing your mods and skills, with:
m / s, r to refresh)x to collapse allOpen it any time with /mods-skills, or with the 🧩 Skills and 🛠 Mods buttons just above the chat box, which open the panel on that list.
Claude Code must be installed. Run the install line in a terminal session (claude), not in the desktop app's Code tab.
From this folder on your computer:
/plugin install mods-skills-panel --marketplace /full/path/to/mods-skills-panel
From GitHub (after pushing this folder to a repository of your own):
/plugin install mods-skills-panel --marketplace <your-github-name>/<repo-name>
Answer y to add the marketplace, then press Enter to install for your user. Once installed for your user, it also loads in the desktop app's Code tab.
claude --plugin-dir /full/path/to/mods-skills-panel
claude plugin validate /full/path/to/mods-skills-panel
claude plugin test /full/path/to/mods-skills-panelhooks/register.tsx 395 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ModEntry, SkillEntry, View } from '../types'
5
6const NAME = 'mods-skills-panel'
7const PANE = 'mods-skills'
8const TITLE = 'Mods & Skills'
9
10// Model choices a row cycles through. "default" means: leave the session's model alone.
11const MODELS = ['default', 'opus', 'sonnet', 'haiku'] as const
12const MODEL_LABEL: Record<string, string> = {
13 default: 'Default',
14 opus: 'Opus',
15 sonnet: 'Sonnet',
16 haiku: 'Haiku',
17}
18
19// Colors are theme keys, so they follow the person's light or dark theme.
20const MODEL_COLOR: Record<string, string> = {
21 opus: 'claude',
22 sonnet: 'suggestion',
23 haiku: 'success',
24}
25
26// Where an item came from: its card border, name and tag share this color.
27const SOURCE_STYLE: Record<string, { label: string; color: string }> = {
28 'in development': { label: 'in development', color: 'warning' },
29 installed: { label: 'installed', color: 'success' },
30 loaded: { label: 'loaded', color: 'suggestion' },
31 plugin: { label: 'plugin', color: 'merged' },
32 userSettings: { label: 'personal', color: 'success' },
33 projectSettings: { label: 'project', color: 'planMode' },
34 'built-in': { label: 'built-in', color: 'suggestion' },
35 bundled: { label: 'built-in', color: 'suggestion' },
36 mcp: { label: 'MCP', color: 'ide' },
37 syncedSkills: { label: 'synced', color: 'autoAccept' },
38}
39const sourceStyle = (kind: string) => SOURCE_STYLE[kind] ?? { label: kind, color: 'subtle' }
40
41// Usage reads at a glance: unused is faded, used is accented, heavily used stands out.
42const usageColor = (used: number) => (used === 0 ? 'inactive' : used < 10 ? 'text' : 'claude')
43
44const view = atom({ plugin: 'mods-skills-panel', key: 'view' } as const, 'mods' as View)
45const mods = atom({ plugin: 'mods-skills-panel', key: 'mods' } as const, [] as ModEntry[])
46const skills = atom({ plugin: 'mods-skills-panel', key: 'skills' } as const, [] as SkillEntry[])
47const usage = atom({ plugin: 'mods-skills-panel', key: 'usage' } as const, {} as Record<string, number>)
48const models = atom({ plugin: 'mods-skills-panel', key: 'models' } as const, {} as Record<string, string>)
49const note = atom({ plugin: 'mods-skills-panel', key: 'note' } as const, '')
50// Rows showing their details; every other row is one numbered line.
51const expanded = atom({ plugin: 'mods-skills-panel', key: 'expanded' } as const, [] as string[])
52
53const modKey = (name: string) => `mod:${name}`
54const skillKey = (name: string) => `skill:${name}`
55
56// Plugins seen joining the chain, kept for the session's refreshes.
57const seenPlugins = new Map<string, { version?: string; provenance: string; hooks: string[] }>()
58
59async function bump($: EngineInterface, key: string) {
60 const next = await update($, usage, all => ({ ...all, [key]: (all[key] ?? 0) + 1 }))
61 await $.store.set('usage', next).catch(() => undefined)
62}
63
64async function readManifest($: EngineInterface, dir: string) {
65 try {
66 const text = await $.fs.read(`${dir}/.claude-plugin/plugin.json`)
67 const json = JSON.parse(typeof text === 'string' ? text : '') as {
68 name?: string
69 version?: string
70 description?: string
71 }
72 return json.name ? json : undefined
73 } catch {
74 return undefined
75 }
76}
77
78// Finds plugin folders (ones holding .claude-plugin/plugin.json) up to `depth` levels down.
79async function findPluginDirs($: EngineInterface, root: string, depth: number): Promise<string[]> {
80 if (depth < 0 || !(await $.fs.exists(root).catch(() => false))) return []
81 if (await $.fs.exists(`${root}/.claude-plugin/plugin.json`).catch(() => false)) return [root]
82 const entries = await $.fs.list(root).catch(() => [])
83 const found: string[] = []
84 for (const entry of entries) {
85 if (entry.kind !== 'dir' || entry.name.startsWith('.') || entry.name === 'node_modules') continue
86 found.push(...(await findPluginDirs($, `${root}/${entry.name}`, depth - 1)))
87 }
88 return found
89}
90
91async function refresh($: EngineInterface) {
92 const commands = await $.command.list().catch(() => [])
93 const describe = new Map(commands.map(c => [c.name, c.description]))
94
95 // Mods: plugins on disk, plugins seen loading, and plugins that own slash commands.
96 const byName = new Map<string, ModEntry>()
97 const add = (name: string, patch: Partial<ModEntry>) => {
98 const had = byName.get(name) ?? { name, description: '', source: 'loaded', commands: [], hooks: [] }
99 byName.set(name, {
100 ...had,
101 ...Object.fromEntries(Object.entries(patch).filter(([, v]) => v !== undefined && v !== '')),
102 commands: [...new Set([...had.commands, ...(patch.commands ?? [])])],
103 hooks: [...new Set([...had.hooks, ...(patch.hooks ?? [])])],
104 })
105 }
106
107 const home = (await $.env.get('HOME').catch(() => undefined)) ?? '/root'
108 const devRoot = $.plugin.root.slice(0, $.plugin.root.lastIndexOf('/'))
109 const roots: [string, string, number][] = [
110 [devRoot, 'in development', 1],
111 [`${home}/.claude/plugins`, 'installed', 5],
112 ]
113 for (const [root, source, depth] of roots) {
114 for (const dir of await findPluginDirs($, root, depth)) {
115 const manifest = await readManifest($, dir)
116 if (manifest?.name) {
117 add(manifest.name, { version: manifest.version, description: manifest.description, source })
118 }
119 }
120 }
121 for (const [name, info] of seenPlugins) {
122 if (info.provenance.endsWith('@builtin')) continue
123 add(name, { version: info.version, hooks: info.hooks })
124 }
125 for (const c of commands) {
126 if (c.source === 'plugin' && c.plugin) add(c.plugin, { commands: [c.name] })
127 }
128 for (const mod of byName.values()) {
129 if (!mod.description) mod.description = 'No description in its manifest.'
130 }
131
132 // Skills: the listing the model is given, described from the slash-command list.
133 const measured = await $.session.usage({ breakdown: 'summary' }).catch(() => undefined)
134 const listed = measured?.context.breakdown?.skills?.skillFrontmatter ?? []
135 const skillList: SkillEntry[] = listed.map(s => {
136 const short = s.name.includes(':') ? s.name.slice(s.name.indexOf(':') + 1) : s.name
137 return {
138 name: s.name,
139 source: s.pluginName ? `plugin ${s.pluginName}` : s.source,
140 plugin: s.pluginName,
141 description: describe.get(s.name) ?? describe.get(short) ?? 'No description listed.',
142 }
143 })
144
145 const sortByName = <T extends { name: string }>(list: T[]) =>
146 [...list].sort((a, b) => a.name.localeCompare(b.name))
147 await update($, mods, () => sortByName([...byName.values()]))
148 await update($, skills, () => sortByName(skillList))
149 const time = new Date(await $.clock.now()).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
150 await update($, note, () => `Updated ${time}`)
151}
152
153async function toggle($: EngineInterface, key: string) {
154 await update($, expanded, open => (open.includes(key) ? open.filter(k => k !== key) : [...open, key]))
155}
156
157async function cycleModel($: EngineInterface, key: string) {
158 const all = await read($, models)
159 const current = all[key] ?? 'default'
160 const nextModel: string = MODELS[(MODELS.indexOf(current as (typeof MODELS)[number]) + 1) % MODELS.length] ?? 'default'
161 const saved = await update($, models, list => {
162 const copy = { ...list }
163 if (nextModel === 'default') delete copy[key]
164 else copy[key] = nextModel
165 return copy
166 })
167 await $.store.set('models', saved).catch(() => undefined)
168}
169
170export const register: Register = on => {
171 // The model a skill asked for, applied to the main loop's requests until its turn ends.
172 let turnModel: string | undefined
173
174 on('plugin.register', (_$, e, next) => {
175 seenPlugins.set(e.name, { version: e.version, provenance: e.provenance, hooks: [...e.uses.events] })
176 return next(e)
177 }).catch((_$, e, next) => next(e))
178
179 on('session.start', async ($, e, next) => {
180 const result = await next(e)
181 const savedUsage = (await $.store.get('usage').catch(() => undefined)) as Record<string, number> | undefined
182 const savedModels = (await $.store.get('models').catch(() => undefined)) as Record<string, string> | undefined
183 if (savedUsage) await update($, usage, () => savedUsage)
184 if (savedModels) await update($, models, () => savedModels)
185 seenPlugins.set(NAME, { provenance: `${NAME}@inline`, hooks: [] })
186 await $.command.register({
187 name: 'mods-skills',
188 description: 'Open the Mods & Skills panel',
189 })
190 void refresh($).catch(() => undefined)
191 void $.ui.open({ id: PANE, title: TITLE })
192 return result
193 })
194
195 on('command.run', { command: 'mods-skills' }, async $ => {
196 await refresh($).catch(() => undefined)
197 await $.ui.open({ id: PANE, title: TITLE })
198 return { text: 'Mods & Skills panel opened.' }
199 })
200
201 // Usage: a slash command that belongs to a mod counts as one use of that mod.
202 on('command.run', async ($, e, next) => {
203 if (e.command !== 'mods-skills') {
204 const owner = (await read($, mods)).find(m => m.commands.includes(e.command))
205 if (owner) await bump($, modKey(owner.name))
206 }
207 return next(e)
208 }).catch((_$, e, next) => next(e))
209
210 // Usage and model for skills: every expansion (/name, the Skill tool, a preload).
211 on('skill.prompt', async ($, e, next) => {
212 await bump($, skillKey(e.skill))
213 const assigned = (await read($, models))[skillKey(e.skill)]
214 if (assigned) turnModel = assigned
215 return next(e)
216 })
217
218 on('turn.step', async function* ($, e, next) {
219 if (turnModel && !e.agentId && e.model !== turnModel) {
220 return yield* next({ ...e, model: turnModel })
221 }
222 return yield* next(e)
223 })
224
225 on('turn.complete', (_$, e, next) => {
226 if (!e.agentId) turnModel = undefined
227 return next(e)
228 })
229
230 // Model and usage for mods: a completion a mod asks for runs on its assigned model.
231 on('model.complete', async ($, e, next) => {
232 const origin = next.origin.plugin
233 if (origin === NAME || origin === 'engine') return next(e)
234 const known = (await read($, mods)).some(m => m.name === origin)
235 if (!known) return next(e)
236 await bump($, modKey(origin))
237 const assigned = (await read($, models))[modKey(origin)]
238 return next(assigned ? { ...e, model: assigned } : e)
239 }).catch((_$, e, next) => next(e))
240
241 // Skills / Mods buttons above the prompt, each opening the panel on that list.
242 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
243 const rest = await next(e)
244 if (e.props.hasSurvey) return rest
245
246 const { Box, Button, Text } = $.ui.resolve(e)
247 const show = async (list: View) => {
248 await update($, view, () => list)
249 await refresh($).catch(() => undefined)
250 await $.ui.open({ id: PANE, title: TITLE })
251 }
252
253 return (
254 <Box>
255 <Button key="show-skills" label="🧩 Skills" onPress={() => show('skills')} />
256 <Text> </Text>
257 <Button key="show-mods" label="🛠 Mods" onPress={() => show('mods')} />
258 <Text> </Text>
259 {rest}
260 </Box>
261 )
262 })
263
264 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
265 const { Box, Text, Button } = $.ui.resolve(e)
266 const current = await read($, view)
267 const modList = await read($, mods)
268 const skillList = await read($, skills)
269 const counts = await read($, usage)
270 const assigned = await read($, models)
271 const status = await read($, note)
272 const openKeys = await read($, expanded)
273
274 const rows =
275 current === 'mods'
276 ? modList.map(m => ({
277 key: modKey(m.name),
278 name: m.name,
279 style: sourceStyle(m.source),
280 meta: m.version ? `v${m.version}` : '',
281 description: m.description,
282 commands: m.commands.map(c => `/${c}`).join(', '),
283 }))
284 : skillList.map(s => ({
285 key: skillKey(s.name),
286 name: s.name,
287 style: sourceStyle(s.plugin ? 'plugin' : s.source),
288 meta: s.plugin ?? '',
289 description: s.description,
290 commands: '',
291 }))
292
293 const numberWidth = String(Math.max(1, rows.length)).length
294
295 // A legend of the sources on screen, so each color is named once.
296 const legend = [...new Map(rows.map(r => [r.style.label, r.style])).values()]
297
298 const empty =
299 current === 'mods'
300 ? 'No mods found yet. Press Refresh after installing one.'
301 : 'No skills listed in this session yet. Press Refresh.'
302
303 return (
304 <Box flexDirection="column" gap={1}>
305 <Box flexDirection="row" gap={1} flexWrap="wrap">
306 <Button
307 key="tab-mods"
308 label={`Mods (${modList.length})`}
309 variant={current === 'mods' ? 'primary' : 'secondary'}
310 hotkey="m"
311 onPress={() => update($, view, () => 'mods')}
312 />
313 <Button
314 key="tab-skills"
315 label={`Skills (${skillList.length})`}
316 variant={current === 'skills' ? 'primary' : 'secondary'}
317 hotkey="s"
318 onPress={() => update($, view, () => 'skills')}
319 />
320 <Button key="refresh" label="Refresh" hotkey="r" dimColor onPress={() => void refresh($)} />
321 </Box>
322 {status !== '' && <Text dimColor>{status}</Text>}
323 {legend.length > 0 && (
324 <Box flexDirection="row" gap={2} flexWrap="wrap">
325 {legend.map(s => (
326 <Text key={`legend-${s.label}`} color={s.color}>
327 ● {s.label}
328 </Text>
329 ))}
330 </Box>
331 )}
332 {rows.length === 0 && <Text dimColor>{empty}</Text>}
333 {openKeys.length > 0 && (
334 <Button key="collapse-all" label="Collapse all" hotkey="x" dimColor onPress={() => update($, expanded, () => [])} />
335 )}
336 <Box flexDirection="column">
337 {rows.map((row, i) => {
338 const isOpen = openKeys.includes(row.key)
339 const model = assigned[row.key] ?? 'default'
340 const used = counts[row.key] ?? 0
341 const number = `${String(i + 1).padStart(numberWidth, ' ')}.`
342 return (
343 <Box key={row.key} flexDirection="column">
344 {/* The line: number, arrow and name; a click opens or closes the details. */}
345 <Button key={`row-${row.key}`} plain onPress={() => toggle($, row.key)}>
346 <Text dimColor>{number} </Text>
347 <Text color={row.style.color}>{isOpen ? '▾' : '▸'} </Text>
348 <Text bold color={row.style.color}>
349 {row.name}
350 </Text>
351 {model !== 'default' && <Text color={MODEL_COLOR[model] ?? 'text'}> ●</Text>}
352 </Button>
353 {isOpen && (
354 <Box
355 flexDirection="column"
356 borderStyle="round"
357 borderColor={row.style.color}
358 paddingX={1}
359 marginLeft={numberWidth + 2}
360 marginBottom={1}
361 >
362 <Box flexDirection="row" gap={1} flexWrap="wrap">
363 <Text color={row.style.color}>● {row.style.label}</Text>
364 {row.meta !== '' && <Text dimColor>{row.meta}</Text>}
365 </Box>
366 <Text wrap="wrap">{row.description}</Text>
367 {row.commands !== '' && (
368 <Text wrap="wrap">
369 <Text dimColor>Commands: </Text>
370 <Text color="permission">{row.commands}</Text>
371 </Text>
372 )}
373 <Box flexDirection="row" gap={1} flexWrap="wrap" alignItems="center">
374 <Text color={usageColor(used)} bold={used >= 10}>
375 {used === 1 ? 'Used 1 time' : `Used ${used} times`}
376 </Text>
377 {model !== 'default' && <Text color={MODEL_COLOR[model] ?? 'text'}>●</Text>}
378 <Button
379 key={`model-${row.key}`}
380 label={`Model: ${MODEL_LABEL[model] ?? model}`}
381 variant={model === 'default' ? 'secondary' : 'primary'}
382 onPress={() => cycleModel($, row.key)}
383 />
384 </Box>
385 </Box>
386 )}
387 </Box>
388 )
389 })}
390 </Box>
391 </Box>
392 )
393 })
394}
395types/index.d.ts 32 lines1export type View = 'mods' | 'skills'
2
3export type ModEntry = {
4 name: string
5 version?: string
6 description: string
7 source: string
8 commands: string[]
9 hooks: string[]
10}
11
12export type SkillEntry = {
13 name: string
14 source: string
15 plugin?: string
16 description: string
17}
18
19declare module 'claude-code' {
20 interface PluginState {
21 'mods-skills-panel': {
22 view: View
23 mods: ModEntry[]
24 skills: SkillEntry[]
25 usage: Record<string, number>
26 models: Record<string, string>
27 note: string
28 expanded: string[]
29 }
30 }
31}
32