SLOPSHOPPER

mods-panel

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

newpanecommandtoastprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mods-panel
│ ┃ Mods ✕ › fix the failing auth test and add an audit log call │ ┃ Installed mods (0) │ ┃ No mods with commands are installed. ⏺ 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 │ │ › /mods │ ⎿ mods-panel: Mods panel opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Mods
Installed mods (0) No mods with commands are installed.
README

mods-panel

A side panel listing every installed mod that adds commands. Pick a mod to see its commands, then pick a command:

  • No arguments → it runs right away.
  • Takes arguments (or it is unknown whether it does) → /<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.

Uninstalling a mod

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.

How it decides

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.

Source 3 files
hooks/register.tsx 164 lines
1import { 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}
164
hooks/mods.ts 77 lines
1import 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}
77
types/index.d.ts 17 lines
1/** 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