SLOPSHOPPER

mods

Mod manager: /mods opens (or closes) a keyboard-driven pane listing every mod (what it does, its commands, on/off, loads at startup) with buttons to switch…

newpanecommand
★ 1v0.2.0no licenseupdated 2026-10-22B314-N03/plugin-marketplace/plugins/mods
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mods
│ ┃ Mods ✕ › fix the failing auth test and add an audit log call │ ┃ 0 mods · /Users/dev/claude-mods · 1-9 pick … │ ┃ No mods found. ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ r: refresh ⏺ 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: 0 mods. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Mods
0 mods · /Users/dev/claude-mods · 1-9 pick · ctrl+x tab ref… No mods found. r: refresh
README

mods

A pane that lists every mod you have: what it does, its slash commands, whether it's on, and whether it loads in new sessions. Each mod has buttons to switch it.

Where it looks

  • Mods home: $CLAUDE_MODS_HOME, else ~/claude-mods. Every folder there with a .claude-plugin/plugin.json is one row.
  • Session folders: ~/.claude/dev-mods/<session>/<mod>. A mod found only there (not yet promoted) is listed as "session folder only". Session copies of a home mod are hidden.
  • Startup list: env.CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json.
  • Loaded now: the slash commands registered in this session ($.command.list()).

Commands

CommandWhat it does
/mods offTurns the manager off: closes the pane, also in new sessions
/mods onTurns it back on and opens the pane
/modsRescans and opens the pane; closes it while it is shown
/mods listPrints the same overview as text

Using the pane (keyboard)

The pane opens with the keyboard focus and takes it back after t runs a mod's command. If the keys ever end up in the prompt (you typed, or s opened another pane), ctrl+x tab gives them back to the pane. Esc closes it.

KeyDoes
1-9Picks that mod (marked ›)
tTurns the picked mod off or on via its own `/<command> off\on` (remembered in new sessions). Only for mods loaded in this session
sRuns its command with no arguments: opens or closes its pane, or (no pane) toggles it
lLoad at start / don't: adds or removes its folder in CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json, nothing else touched; applies to new sessions
rRescans

In the fullscreen layout each key's label can also be clicked.

On/off is what the manager last saw. It records every /<command> on|off, whether you typed it or pressed a button. A mod nobody has switched since the manager was installed shows "loaded".

What it runs

Nothing in the background: it reads files and the command list only when the pane opens, after a button press, or on Refresh. A button that switches a mod at start writes ~/.claude/settings.json.

Install

/plugin install mods --marketplace B314-N03/plugin-marketplace

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

Remove it

/plugin uninstall mods, or /mods off to keep it installed but closed.

Source 3 files
hooks/register.tsx 250 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ModRow, ModState } from '../types'
5import {
6  buildRows,
7  clampIndex,
8  dirsFromSettings,
9  listText,
10  parseManifest,
11  parseStateArg,
12  paneAction,
13  paneRows,
14  parseToggle,
15  pickHotkey,
16  SELF,
17  startupLabel,
18  stateLabel,
19  switchCommand,
20  trimSlash,
21  withPluginDir,
22} from './logic'
23import type { FoundMod } from './logic'
24
25const PANE = 'mods'
26const OFF_KEY = 'isOff'
27const STATES_KEY = 'states'
28const rows = atom({ plugin: 'mods', key: 'rows' } as const, [])
29const modsHome = atom({ plugin: 'mods', key: 'modsHome' } as const, '')
30const notice = atom({ plugin: 'mods', key: 'notice' } as const, '')
31const isOff = atom({ plugin: 'mods', key: 'isOff' } as const, false)
32const selected = atom({ plugin: 'mods', key: 'selected' } as const, 0)
33
34async function paths($: EngineInterface) {
35  const home = (await $.env.get('HOME')) ?? ''
36  const custom = await $.env.get('CLAUDE_MODS_HOME')
37  return {
38    modsHome: trimSlash(custom || `${home}/claude-mods`),
39    settings: `${home}/.claude/settings.json`,
40    devMods: `${home}/.claude/dev-mods`,
41  }
42}
43
44async function childDirs($: EngineInterface, dir: string) {
45  const entries = await $.fs.list(dir).catch(() => [])
46  return entries.filter(entry => entry.kind === 'dir' && !entry.name.startsWith('.')).map(entry => `${dir}/${entry.name}`)
47}
48
49async function readMod($: EngineInterface, folder: string): Promise<FoundMod | null> {
50  const text = await $.fs.read(`${folder}/.claude-plugin/plugin.json`).catch(() => null)
51  const manifest = typeof text === 'string' ? parseManifest(text) : null
52  if (manifest === null) return null
53  const hasReadme = await $.fs.exists(`${folder}/README.md`).catch(() => false)
54  return { manifest, folder, hasReadme }
55}
56
57async function readMods($: EngineInterface, folders: string[]) {
58  const found = await Promise.all(folders.map(folder => readMod($, folder)))
59  return found.filter((mod): mod is FoundMod => mod !== null)
60}
61
62async function readStates($: EngineInterface): Promise<Record<string, ModState>> {
63  const stored = await $.store.get(STATES_KEY)
64  return typeof stored === 'object' && stored !== null ? (stored as Record<string, ModState>) : {}
65}
66
67async function scan($: EngineInterface) {
68  const where = await paths($)
69  const sessions = await childDirs($, where.devMods)
70  const strayFolders = (await Promise.all(sessions.map(session => childDirs($, session)))).flat()
71  const settingsText = await $.fs.read(where.settings).catch(() => '')
72  const commands = (await $.command.list())
73    .filter(command => command.source === 'plugin' && command.plugin !== undefined)
74    .map(command => ({ name: command.name, plugin: command.plugin! }))
75  const next = buildRows({
76    homeMods: await readMods($, await childDirs($, where.modsHome)),
77    strayMods: await readMods($, strayFolders),
78    startupDirs: dirsFromSettings(typeof settingsText === 'string' ? settingsText : ''),
79    commands,
80    states: await readStates($),
81  })
82  await update($, rows, () => next)
83  await update($, modsHome, () => where.modsHome)
84  return next
85}
86
87async function recordState($: EngineInterface, name: string, state: ModState) {
88  await $.store.set(STATES_KEY, { ...(await readStates($)), [name]: state })
89}
90
91async function say($: EngineInterface, text: string) {
92  await update($, notice, () => text)
93}
94
95/** Opens the pane with the keyboard, or takes the keys back for an open one: a command it ran hands them to the prompt. */
96async function openPane($: EngineInterface, count: number) {
97  return $.ui.open({ id: PANE, title: 'Mods', focus: true, closeOnEscape: true, rows: paneRows(count) })
98}
99
100async function switchLive($: EngineInterface, row: ModRow, state: ModState) {
101  const command = switchCommand(row)
102  if (command === undefined) return say($, `${row.name} is not loaded in this session.`)
103  await $.command.run({ command, args: state })
104  await recordState($, row.name, state)
105  await say($, `${row.name} ${state} (via /${command}; remembered in new sessions).`)
106  const found = await scan($)
107  await openPane($, found.length)
108}
109
110async function showMod($: EngineInterface, row: ModRow) {
111  const command = switchCommand(row)
112  if (command === undefined) return say($, `${row.name} is not loaded in this session.`)
113  await $.command.run({ command, args: '' })
114}
115
116async function switchStartup($: EngineInterface, row: ModRow, shouldLoad: boolean) {
117  const where = await paths($)
118  try {
119    const text = await $.fs.read(where.settings).catch(() => '')
120    await $.fs.write(where.settings, withPluginDir(typeof text === 'string' ? text : '', row.folder, shouldLoad))
121    await say($, `${row.name} ${shouldLoad ? 'will load' : 'will no longer load'} in new sessions.`)
122  } catch (error) {
123    await say($, `Could not update ${where.settings}: ${String(error)}`)
124  }
125  await scan($)
126}
127
128async function setOff($: EngineInterface, value: boolean) {
129  await $.store.set(OFF_KEY, value)
130  await update($, isOff, () => value)
131  if (value) await $.ui.close({ id: PANE })
132}
133
134export const register: Register = on => {
135  on('session.start', async ($, e, next) => {
136    await $.command.register({
137      name: 'mods',
138      description: 'Mod manager. /mods opens the pane, /mods list prints them, /mods off|on switches this manager',
139      argumentHint: '[list|on|off]',
140    })
141    const wasOff = (await $.store.get(OFF_KEY)) === true
142    await update($, isOff, () => wasOff)
143    return next(e)
144  })
145
146  on('command.run', { command: 'mods' }, async ($, e) => {
147    const toggle = parseToggle(e.args)
148    if (toggle === 'invalid') return { text: 'Use /mods, /mods list, /mods on or /mods off.' }
149    if (toggle === 'off') {
150      await setOff($, true)
151      return { text: 'mods off: pane closed (also in new sessions). /mods on to bring it back.' }
152    }
153    if (toggle === 'on') await setOff($, false)
154    if (await read($, isOff)) return { text: 'mods is off. /mods on to turn it back on.' }
155    if (toggle === 'show' && paneAction(await $.ui.panes(), PANE) === 'close') {
156      await $.ui.close({ id: PANE })
157      return { text: 'Mods pane closed. /mods opens it again.' }
158    }
159    const found = await scan($)
160    if (toggle === 'list') return { text: listText(found, await read($, modsHome)) }
161    await say($, '')
162    await openPane($, found.length)
163    return { text: toggle === 'on' ? 'mods on.' : `${found.length} mods.` }
164  })
165
166  // Observer: remembers on/off whenever a mod's command is run with on|off, by whoever.
167  on('command.run', async ($, e, next) => {
168    const ran = await next(e)
169    const state = parseStateArg(e.args)
170    if (state === null || e.command === 'mods') return ran
171    const owner = (await $.command.list()).find(command => command.name === e.command)?.plugin
172    if (owner !== undefined && owner !== SELF) await recordState($, owner, state)
173    return ran
174  })
175
176  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
177    const { Box, Button, Text } = $.ui.resolve(e)
178    const list = await read($, rows)
179    const message = await read($, notice)
180    const index = clampIndex(await read($, selected), list.length)
181    const current = list[index]
182    const canAct = current !== undefined && current.name !== SELF
183    const isLiveOff = current?.state === 'off'
184
185    return (
186      <Box flexDirection="column">
187        <Text wrap="truncate">
188          <Text bold>{list.length} mods</Text>
189          <Text dimColor> · {await read($, modsHome)} · 1-9 pick · ctrl+x tab refocuses · esc closes</Text>
190        </Text>
191        {message !== '' && <Text color="suggestion">{message}</Text>}
192        {list.length === 0 && <Text dimColor>No mods found.</Text>}
193        {list.map((row, at) => {
194          const isPicked = at === index
195          const color = !row.isLoaded ? undefined : row.state === 'off' ? 'warning' : 'success'
196          return (
197            <Box flexDirection="column">
198              <Box gap={1}>
199                <Text color={isPicked ? 'suggestion' : undefined}>{isPicked ? '›' : ' '}</Text>
200                <Text color={color} dimColor={!row.isLoaded}>
201                  ●
202                </Text>
203                <Button
204                  key={`pick-${row.name}`}
205                  plain
206                  hotkey={pickHotkey(at)}
207                  label={row.name}
208                  onPress={() => void update($, selected, () => at)}
209                />
210                <Text dimColor wrap="truncate">
211                  {row.commands.map(name => `/${name}`).join(' ')} · {stateLabel(row)} · {startupLabel(row)}
212                </Text>
213              </Box>
214              <Text dimColor wrap="truncate-end">
215                {'    '}
216                {row.description || 'no description'}
217              </Text>
218            </Box>
219          )
220        })}
221        <Box gap={2} marginTop={1}>
222          {canAct && current.isLoaded && (
223            <Button
224              key="live"
225              plain
226              hotkey="t"
227              label={isLiveOff ? `turn ${current.name} on` : `turn ${current.name} off`}
228              onPress={() => void switchLive($, current, isLiveOff ? 'on' : 'off')}
229            />
230          )}
231          {canAct && current.isLoaded && (
232            <Button key="show" plain hotkey="s" label="pane" onPress={() => void showMod($, current)} />
233          )}
234          {canAct && (
235            <Button
236              key="startup"
237              plain
238              hotkey="l"
239              label={current.isAtStartup ? "don't load at start" : 'load at start'}
240              onPress={() => void switchStartup($, current, !current.isAtStartup)}
241            />
242          )}
243          {current?.name === SELF && <Text dimColor>this pane · /mods off turns it off</Text>}
244          <Button key="refresh" plain hotkey="r" label="refresh" dimColor onPress={() => void scan($)} />
245        </Box>
246      </Box>
247    )
248  })
249}
250
hooks/logic.ts 131 lines
1import type { ModRow, ModState } from '../types'
2
3export const SELF = 'mods'
4
5export type Manifest = { name: string; description: string; version: string }
6export type FoundMod = { manifest: Manifest; folder: string; hasReadme: boolean }
7export type PluginCommand = { name: string; plugin: string }
8
9const asText = (value: unknown) => (typeof value === 'string' ? value : '')
10
11export const parseManifest = (text: string): Manifest | null => {
12  try {
13    const json = JSON.parse(text)
14    const name = asText(json?.name)
15    if (name === '') return null
16    return { name, description: asText(json.description), version: asText(json.version) }
17  } catch {
18    return null
19  }
20}
21
22export const trimSlash = (path: string) => path.replace(/\/+$/, '')
23
24export const splitDirs = (value: string | undefined) =>
25  (value ?? '')
26    .split(':')
27    .map(dir => trimSlash(dir.trim()))
28    .filter(dir => dir !== '')
29
30export const dirsFromSettings = (text: string): string[] => {
31  try {
32    return splitDirs(asText(JSON.parse(text)?.env?.CLAUDE_CODE_PLUGIN_DIRS))
33  } catch {
34    return []
35  }
36}
37
38/** Adds or removes `dir` in env.CLAUDE_CODE_PLUGIN_DIRS, leaving every other setting as it was. Throws on unreadable settings. */
39export const withPluginDir = (text: string, dir: string, shouldLoad: boolean): string => {
40  const settings = text.trim() === '' ? {} : JSON.parse(text)
41  if (typeof settings !== 'object' || settings === null || Array.isArray(settings)) {
42    throw new Error('settings.json is not a JSON object')
43  }
44  const env = typeof settings.env === 'object' && settings.env !== null ? { ...settings.env } : {}
45  const target = trimSlash(dir)
46  const others = splitDirs(asText(env.CLAUDE_CODE_PLUGIN_DIRS)).filter(entry => entry !== target)
47  const dirs = shouldLoad ? [...others, target] : others
48  if (dirs.length === 0) delete env.CLAUDE_CODE_PLUGIN_DIRS
49  else env.CLAUDE_CODE_PLUGIN_DIRS = dirs.join(':')
50  return `${JSON.stringify({ ...settings, env }, null, 2)}\n`
51}
52
53export const buildRows = (input: {
54  homeMods: readonly FoundMod[]
55  strayMods: readonly FoundMod[]
56  startupDirs: readonly string[]
57  commands: readonly PluginCommand[]
58  states: Readonly<Record<string, ModState>>
59}): ModRow[] => {
60  const homeNames = new Set(input.homeMods.map(mod => mod.manifest.name))
61  const strays = input.strayMods.filter(
62    (mod, index, all) =>
63      !homeNames.has(mod.manifest.name) && all.findIndex(other => other.manifest.name === mod.manifest.name) === index,
64  )
65  const toRow = (mod: FoundMod, isHome: boolean): ModRow => {
66    const commands = input.commands.filter(command => command.plugin === mod.manifest.name).map(command => command.name)
67    return {
68      ...mod.manifest,
69      folder: mod.folder,
70      hasReadme: mod.hasReadme,
71      isHome,
72      isLoaded: commands.length > 0,
73      isAtStartup: input.startupDirs.includes(trimSlash(mod.folder)),
74      commands,
75      state: input.states[mod.manifest.name] ?? 'unknown',
76    }
77  }
78  const byName = (a: ModRow, b: ModRow) => a.name.localeCompare(b.name)
79  return [...input.homeMods.map(mod => toRow(mod, true)).sort(byName), ...strays.map(mod => toRow(mod, false)).sort(byName)]
80}
81
82/** The command that switches a mod: the one named after it, else its first. */
83export const switchCommand = (row: Pick<ModRow, 'name' | 'commands'>) =>
84  row.commands.includes(row.name) ? row.name : row.commands[0]
85
86/** on/off when a command's arguments switch it, else null. */
87export const parseStateArg = (args: string): ModState | null => {
88  const first = args.trim().toLowerCase().split(/\s+/)[0]
89  return first === 'on' || first === 'off' ? first : null
90}
91
92export const stateLabel = (row: ModRow) => {
93  if (!row.isLoaded) return 'not loaded'
94  return row.state === 'unknown' ? 'loaded' : row.state
95}
96
97export const startupLabel = (row: ModRow) => {
98  if (!row.isHome) return 'session folder only'
99  return row.isAtStartup ? 'loads at start' : 'not loaded at start'
100}
101
102export const listText = (rows: readonly ModRow[], modsHome: string) => {
103  if (rows.length === 0) return `No mods found in ${modsHome} or session folders.`
104  const lines = rows.map(row => {
105    const commands = row.commands.length > 0 ? ` (${row.commands.map(name => `/${name}`).join(' ')})` : ''
106    return `- ${row.name}${commands}: ${stateLabel(row)}, ${startupLabel(row)}\n  ${row.description || 'no description'}\n  ${row.folder}`
107  })
108  return [`Mods in ${modsHome}:`, ...lines].join('\n')
109}
110
111/** The digit that picks the mod at `index` in the pane: 1-9, then none. */
112export const pickHotkey = (index: number) => (index < 9 ? String(index + 1) : undefined)
113
114/** Keeps the picked row inside the list; the first row when nothing is left to pick. */
115export const clampIndex = (index: number, length: number) => Math.max(0, Math.min(index, length - 1))
116
117/** Body rows the pane asks for: a header, two lines per mod, the action bar. */
118export const paneRows = (count: number) => Math.min(40, 6 + count * 2)
119
120export type Toggle = 'on' | 'off' | 'show' | 'list' | 'invalid'
121
122export const parseToggle = (args: string): Toggle => {
123  const arg = args.trim().toLowerCase()
124  if (arg === '') return 'show'
125  return arg === 'on' || arg === 'off' || arg === 'list' ? arg : 'invalid'
126}
127
128/** What a bare `/<mod>` does to its pane: closes it while the person is looking at it, else opens it (or brings it to the front). */
129export const paneAction = (panes: readonly { id: string; isShown: boolean }[], id: string): 'open' | 'close' =>
130  panes.some(pane => pane.id === id && pane.isShown) ? 'close' : 'open'
131
types/index.d.ts 25 lines
1export type ModState = 'on' | 'off'
2
3export type ModRow = {
4  name: string
5  description: string
6  version: string
7  folder: string
8  /** Lives in the mods home (else a session-only dev folder). */
9  isHome: boolean
10  /** Has commands registered in this session. */
11  isLoaded: boolean
12  /** Listed in CLAUDE_CODE_PLUGIN_DIRS of the user settings. */
13  isAtStartup: boolean
14  commands: string[]
15  /** Last on/off seen through its command; unknown when never seen. */
16  state: ModState | 'unknown'
17  hasReadme: boolean
18}
19
20declare module 'claude-code' {
21  interface PluginState {
22    mods: { rows: ModRow[]; modsHome: string; notice: string; isOff: boolean; selected: number }
23  }
24}
25