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…

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.
$CLAUDE_MODS_HOME, else ~/claude-mods. Every folder there with a .claude-plugin/plugin.json is one row.~/.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.env.CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json.$.command.list()).| Command | What it does |
|---|---|
/mods off | Turns the manager off: closes the pane, also in new sessions |
/mods on | Turns it back on and opens the pane |
/mods | Rescans and opens the pane; closes it while it is shown |
/mods list | Prints the same overview as text |
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.
| Key | Does | |
|---|---|---|
1-9 | Picks that mod (marked ›) | |
t | Turns the picked mod off or on via its own `/<command> off\ | on` (remembered in new sessions). Only for mods loaded in this session |
s | Runs its command with no arguments: opens or closes its pane, or (no pane) toggles it | |
l | Load 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 | |
r | Rescans |
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".
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.
/plugin install mods --marketplace B314-N03/plugin-marketplace
Answer y to add the marketplace, then pick the user scope.
/plugin uninstall mods, or /mods off to keep it installed but closed.
hooks/register.tsx 250 lines1import { 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}
250hooks/logic.ts 131 lines1import 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'
131types/index.d.ts 25 lines1export 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