A side panel listing your installed mods and their commands: pick one to run it, or to fill the prompt when it takes arguments, or uninstall a mod: /mods

A side panel listing every installed mod that adds commands. Pick a mod to see its commands, then pick a command:
/<command> is put in the prompt bar and the panel closes, so you can type the rest and press Enter.The panel only opens when you ask for it:
/mods # the list of mods
/mods claude-dj # straight to one mod's commands
Keys: 1–9 pick a row, b goes back to the mod list, Esc closes the panel.
On a mod's page, press u (Uninstall mod), then y to confirm or n to cancel. The panel runs claude plugin uninstall <mod>@<marketplace> --scope <scope> for each install of that mod it finds in claude plugin list --json, in whatever scope it was installed. The mod keeps running until the session restarts (or /reload-plugins). A mod loaded with --plugin-dir isn't installed from a marketplace, so there's nothing to uninstall and the panel tells you so.
The panel learns each command's argument hint from the engine's command.describe event, the same hint the typeahead shows dim after a command's name (/vol <0-100>). A command with a hint, or one the engine never described, fills the prompt; a command described without a hint runs. Labels show … for the unknown case.
Mods that only add hooks, panes or tools, with no slash commands, are not listed.
hooks/register.tsx 164 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { commandLabel, findInstalls, groupMods, hotkey, needsArgs, promptText, uninstallArgv } from './mods'
5import type { ModCommand } from './mods'
6
7const PANE = 'mods-panel'
8const SELF = 'mods-panel'
9const selected = atom({ plugin: 'mods-panel', key: 'selected' } as const, null)
10const hints = atom({ plugin: 'mods-panel', key: 'hints' } as const, {})
11const confirming = atom({ plugin: 'mods-panel', key: 'confirming' } as const, null)
12const uninstalling = atom({ plugin: 'mods-panel', key: 'uninstalling' } as const, null)
13
14export const register: Register = on => {
15 on('session.start', async ($, e, next) => {
16 await $.command.register({
17 name: 'mods',
18 description: 'Show installed mods and their commands in a side panel',
19 argumentHint: '[mod]',
20 })
21 // Have the engine list every command again, so the describe hook below
22 // learns which ones take arguments.
23 $.ui.invalidate('command.describe')
24
25 return next(e)
26 })
27
28 on('command.describe', async ($, e, next) => {
29 const result = await next(e)
30 const hint = result.argumentHint?.trim() || null
31 const known = await read($, hints)
32 if (known[e.command] !== hint) {
33 await update($, hints, all => ({ ...all, [e.command]: hint }))
34 }
35
36 return result
37 })
38
39 on('command.run', { command: 'mods' }, async ($, e) => {
40 const wanted = e.args.trim()
41 await update($, selected, () => (wanted === '' ? null : wanted))
42 await update($, confirming, () => null)
43 await $.ui.open({ id: PANE, title: 'Mods', focus: true, closeOnEscape: true })
44
45 return { text: wanted === '' ? 'Mods panel opened.' : `Mods panel opened on ${wanted}.` }
46 })
47
48 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
49 const { Box, Text, Button } = $.ui.resolve(e)
50 const mods = groupMods(await $.command.list(), await read($, hints), SELF)
51 const current = await read($, selected)
52 const mod = current === null ? undefined : mods.find(m => m.name === current)
53 const asking = await read($, confirming)
54 const busy = await read($, uninstalling)
55
56 const pick = async (command: ModCommand) => {
57 if (needsArgs(command)) {
58 const { isFilled } = await $.prompt.fill({ text: promptText(command) })
59 if (isFilled) {
60 await $.ui.close({ id: PANE })
61 } else {
62 $.ui.toast(`Could not fill the prompt; type ${promptText(command)}yourself`)
63 }
64
65 return
66 }
67 $.ui.toast(`Running /${command.name}`)
68 $.command.run({ command: command.name }).catch((err: unknown) => {
69 $.ui.toast(`/${command.name} failed: ${err instanceof Error ? err.message : String(err)}`)
70 })
71 }
72
73 const uninstall = async (name: string) => {
74 await update($, confirming, () => null)
75 await update($, uninstalling, () => name)
76 try {
77 const list = await $.process.run(['claude', 'plugin', 'list', '--json'])
78 const installs = findInstalls(list.stdout, name)
79 if (installs.length === 0) {
80 $.ui.toast(`${name} is not installed from a marketplace (loaded with --plugin-dir?); nothing to uninstall`)
81 return
82 }
83 const failed: string[] = []
84 for (const install of installs) {
85 const { exitCode, stderr, stdout } = await $.process.run(uninstallArgv(install))
86 if (exitCode !== 0) failed.push(`${install.id}: ${(stderr || stdout).trim() || `exit ${exitCode}`}`)
87 }
88 if (failed.length > 0) {
89 $.ui.toast(`Could not uninstall ${failed.join('; ')}`)
90 return
91 }
92 await update($, selected, () => null)
93 $.ui.toast(`Uninstalled ${name}; it stays loaded until the session restarts`)
94 } catch (err: unknown) {
95 $.ui.toast(`Uninstalling ${name} failed: ${err instanceof Error ? err.message : String(err)}`)
96 } finally {
97 await update($, uninstalling, () => null)
98 }
99 }
100
101 if (mod === undefined) {
102 return (
103 <Box flexDirection="column">
104 <Text bold>Installed mods ({mods.length})</Text>
105 {current !== null && <Text dimColor>No mod named {current} has commands.</Text>}
106 {mods.length === 0 && <Text dimColor>No mods with commands are installed.</Text>}
107 {mods.map((m, i) => (
108 <Button
109 key={`mod:${m.name}`}
110 label={`${m.name} (${m.commands.length})`}
111 hotkey={hotkey(i)}
112 plain
113 onPress={() => update($, selected, () => m.name)}
114 />
115 ))}
116 </Box>
117 )
118 }
119
120 return (
121 <Box flexDirection="column">
122 <Box>
123 <Button
124 key="back"
125 label="< Mods"
126 hotkey="b"
127 plain
128 onPress={async () => {
129 await update($, confirming, () => null)
130 await update($, selected, () => null)
131 }}
132 />
133 <Text bold> {mod.name}</Text>
134 </Box>
135 {busy === mod.name ? (
136 <Text dimColor>Uninstalling {mod.name}…</Text>
137 ) : asking === mod.name ? (
138 <Box>
139 <Text>Uninstall {mod.name}? </Text>
140 <Button key="yes" label="Yes, uninstall" hotkey="y" variant="primary" onPress={() => uninstall(mod.name)} />
141 <Button key="no" label="Cancel" hotkey="n" plain onPress={() => update($, confirming, () => null)} />
142 </Box>
143 ) : (
144 <Button key="uninstall" label="Uninstall mod" hotkey="u" plain onPress={() => update($, confirming, () => mod.name)} />
145 )}
146 <Text dimColor>Runs at once, or fills the prompt when it takes arguments.</Text>
147 {mod.commands.map((c, i) => (
148 <Box key={`row:${c.name}`} flexDirection="column">
149 <Button
150 key={`cmd:${c.name}`}
151 label={commandLabel(c)}
152 hotkey={hotkey(i)}
153 plain
154 variant={needsArgs(c) ? 'secondary' : 'primary'}
155 onPress={() => pick(c)}
156 />
157 {c.description !== '' && <Text dimColor>{` ${c.description}`}</Text>}
158 </Box>
159 ))}
160 </Box>
161 )
162 })
163}
164hooks/mods.ts 77 lines1import type { CommandInfo } from 'claude-code'
2
3import type { Hints } from '../types'
4
5export type ModCommand = { name: string; description: string; hint: string | null | undefined }
6export type Mod = { name: string; commands: ModCommand[] }
7
8/** The plugin commands grouped per mod, mods and commands sorted by name. */
9export function groupMods(commands: readonly CommandInfo[], hints: Hints, exclude?: string): Mod[] {
10 const byMod = new Map<string, ModCommand[]>()
11
12 for (const c of commands) {
13 if (c.source !== 'plugin') continue
14 const mod = c.plugin ?? (c.name.includes(':') ? c.name.split(':')[0]! : 'other')
15 if (mod === exclude) continue
16 const list = byMod.get(mod) ?? []
17 list.push({ name: c.name, description: c.description, hint: hints[c.name] })
18 byMod.set(mod, list)
19 }
20
21 return [...byMod]
22 .map(([name, list]) => ({ name, commands: list.sort((a, b) => a.name.localeCompare(b.name)) }))
23 .sort((a, b) => a.name.localeCompare(b.name))
24}
25
26/**
27 * Whether picking the command fills the prompt instead of running it: it
28 * declares an argument hint, or the engine never told us whether it takes any.
29 */
30export function needsArgs(command: ModCommand): boolean {
31 return command.hint !== null
32}
33
34/** What goes in the prompt box for a command that takes arguments. */
35export function promptText(command: ModCommand): string {
36 return `/${command.name} `
37}
38
39/** The button label: the command, then its hint, or an ellipsis when unknown. */
40export function commandLabel(command: ModCommand): string {
41 if (typeof command.hint === 'string') return `/${command.name} ${command.hint}`
42
43 return command.hint === null ? `/${command.name}` : `/${command.name} …`
44}
45
46/** One digit hotkey for each of the first nine rows. */
47export function hotkey(index: number): string | undefined {
48 return index < 9 ? String(index + 1) : undefined
49}
50
51/** One entry of `claude plugin list --json`, the fields the uninstall needs. */
52export type Install = { id: string; scope: string }
53
54/**
55 * The installs of the mod named `mod` in `claude plugin list --json` output:
56 * every entry whose id is `<mod>@<marketplace>`, in any scope.
57 */
58export function findInstalls(listJson: string, mod: string): Install[] {
59 let entries: unknown
60 try {
61 entries = JSON.parse(listJson)
62 } catch {
63 return []
64 }
65 if (!Array.isArray(entries)) return []
66
67 return entries
68 .filter((p): p is Install => typeof p?.id === 'string' && typeof p?.scope === 'string')
69 .filter(p => p.id.slice(0, p.id.lastIndexOf('@')) === mod)
70 .map(p => ({ id: p.id, scope: p.scope }))
71}
72
73/** The command that uninstalls one install from its own scope. */
74export function uninstallArgv(install: Install): string[] {
75 return ['claude', 'plugin', 'uninstall', install.id, '--scope', install.scope]
76}
77types/index.d.ts 17 lines1/** Each command's argument hint as `command.describe` reported it; null when it takes none. */
2export type Hints = Record<string, string | null>
3
4declare module 'claude-code' {
5 interface PluginState {
6 'mods-panel': {
7 /** The mod whose commands the panel shows; null shows the list of mods. */
8 selected: string | null
9 hints: Hints
10 /** The mod whose uninstall is waiting on a yes; null when none is. */
11 confirming: string | null
12 /** The mod being uninstalled right now; null when none is. */
13 uninstalling: string | null
14 }
15 }
16}
17