/mods: every mod in your mods folder, what it does, and an on/off switch for each.

What it does: /mods opens a page listing every mod in your mods folder: whether it's on, its version, what it does, Details (its README) and Turn off / Turn on. /mods again closes it.
How on/off works: the switched-off mods are a list on this computer, ~/.claude/mods-data/hub/off.json. Turn off adds a mod to it and Turn on takes it out. Whenever a mod loads, the engine first asks the mods loaded before it, and the hub refuses any mod on the list, so none of its hooks, commands or tools run.
hooks/hooks.json again, unchanged. The new timestamp makes every session that watches the folder reload that mod at once (a session busy with a reply does it when the reply ends), and the reload asks the hub again. Any other session picks the change up when it starts.git status stays clean and on/off never travels through a commit or git pull.hub comes first in CLAUDE_CODE_PLUGIN_DIRS, and the install scripts put it there. If a switched-off mod loads ahead of the hub, the hub says so in a toast: run the install script again.Notes: the hub can't switch itself off from its page. Without the hub nothing refuses the switched-off mods, so they all load.
Cost: none. No model calls.
hooks/register.tsx 190 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { HubMod } from '../types'
5
6// /mods: every mod in the mods folder, what it does (its README), and an on/off
7// switch for each. A switched-off mod is named in ~/.claude/mods-data/hub/off.json
8// (this computer only) and the hub refuses it when the engine loads it
9// (`plugin.register`), so no mod file changes and git sees nothing. A switch
10// also saves the mod's hooks.json again unchanged: its new timestamp makes every
11// session that watches the folder reload the mod, and the reload asks the hub.
12
13const PANE = 'mods'
14const SELF = 'hub'
15const REFUSAL = 'switched off in /mods'
16// The page's two colors: what's on, and everything quieter.
17const ON = '#22d3ee'
18const GREY = '#9ca3af'
19
20const mods = atom({ plugin: 'hub', key: 'mods' } as const, [])
21const expanded = atom({ plugin: 'hub', key: 'expanded' } as const, null)
22const rootRef = atom({ plugin: 'hub', key: 'root' } as const, '')
23const isOpen = atom({ plugin: 'hub', key: 'isOpen' } as const, false)
24
25const parentOf = (dir: string) => dir.replace(/[\\/]+$/, '').replace(/[\\/][^\\/]+$/, '')
26
27const readJson = async ($: EngineInterface, path: string) => {
28 try {
29 return JSON.parse(await $.fs.read(path)) as Record<string, unknown>
30 } catch {
31 return null
32 }
33}
34
35const dataDir = async ($: EngineInterface) => {
36 // CLAUDE_CONFIG_DIR (set when Claude's folder lives on another drive) wins over ~/.claude
37 const dir = ((await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '.'}/.claude`).replace(/\\/g, '/')
38 return `${dir}/mods-data/hub`
39}
40
41// The mods switched off on this computer, by name.
42const readOff = async ($: EngineInterface) => {
43 const saved = await readJson($, `${await dataDir($)}/off.json`)
44 return Array.isArray(saved?.off) ? saved.off.filter((n): n is string => typeof n === 'string') : []
45}
46
47const writeOff = async ($: EngineInterface, names: string[]) =>
48 $.fs.write(`${await dataDir($)}/off.json`, `${JSON.stringify({ off: [...new Set(names)].sort() }, null, 2)}\n`)
49
50// The engine asks only the mods loaded before a mod whether it may load, so the
51// hub must come first in CLAUDE_CODE_PLUGIN_DIRS (the installers put it there).
52// The folders listed ahead of it, which it can't keep off.
53const normDir = (p: string, isWindows: boolean) => {
54 const path = p.trim().replace(/\\/g, '/').replace(/\/+$/, '')
55 return isWindows ? path.toLowerCase() : path
56}
57
58const foldersAheadOfHub = async ($: EngineInterface) => {
59 const isWindows = /^[A-Za-z]:/.test($.plugin.root)
60 const dirs = ((await $.env.get('CLAUDE_CODE_PLUGIN_DIRS')) ?? '').split(isWindows ? ';' : ':').map(d => normDir(d, isWindows))
61 const at = dirs.indexOf(normDir($.plugin.root, isWindows))
62 return at < 0 ? [] : dirs.slice(0, at).map(d => d.slice(d.lastIndexOf('/') + 1))
63}
64
65const aheadWarning = (names: string) =>
66 `Can't keep ${names} off: loaded before the hub. Run install.ps1 (or install.sh) again to put the hub first, then start a new session.`
67
68const scan = async ($: EngineInterface) => {
69 const root = parentOf($.plugin.root)
70 const entries = await $.fs.list(root)
71 const off = await readOff($)
72 const ahead = await foldersAheadOfHub($)
73 const isWindows = /^[A-Za-z]:/.test(root)
74 const found: HubMod[] = []
75 for (const entry of entries) {
76 const dir = `${root}/${entry.name}`
77 const manifest = await readJson($, `${dir}/.claude-plugin/plugin.json`)
78 if (!manifest) continue
79 const name = typeof manifest.name === 'string' ? manifest.name : entry.name
80 found.push({
81 folder: entry.name,
82 name,
83 description: typeof manifest.description === 'string' ? manifest.description : '',
84 version: typeof manifest.version === 'string' ? manifest.version : '',
85 isOn: !off.includes(name),
86 isAheadOfHub: ahead.includes(isWindows ? entry.name.toLowerCase() : entry.name),
87 readme: (await $.fs.exists(`${dir}/README.md`)) ? await $.fs.read(`${dir}/README.md`) : null,
88 })
89 }
90 found.sort((a, b) => (a.name === SELF ? -1 : b.name === SELF ? 1 : a.name.localeCompare(b.name)))
91 await update($, mods, () => found)
92 await update($, rootRef, () => root)
93 return found
94}
95
96// Does what the pressed button said, even when a page left open elsewhere is behind.
97const toggle = async ($: EngineInterface, mod: HubMod) => {
98 if (mod.name === SELF) return
99 const rest = (await readOff($)).filter(n => n !== mod.name)
100 await writeOff($, mod.isOn ? [...rest, mod.name] : rest)
101 // hooks.json saved again as it is: its new timestamp makes every watching
102 // session reload the mod, and the reload asks plugin.register again.
103 const file = `${await read($, rootRef)}/${mod.folder}/hooks/hooks.json`
104 const text = await $.fs.read(file).catch(() => null)
105 if (text !== null) await $.fs.write(file, text)
106 await scan($)
107 const isStuck = mod.isOn && mod.isAheadOfHub
108 $.ui.toast(isStuck ? aheadWarning(mod.name) : `${mod.name} switched ${mod.isOn ? 'off' : 'on'}.`, { timeoutMs: isStuck ? 12000 : 5000 })
109}
110
111const togglePane = async ($: EngineInterface) => {
112 if ((await $.ui.panes()).some(p => p.id === PANE)) {
113 await $.ui.close({ id: PANE })
114 // A plugin's own close does not reach its own ui.close hook: reset the flag here.
115 await update($, isOpen, () => false)
116 return 'Mods closed.'
117 }
118 const list = await scan($)
119 const opened = await $.ui.open({ id: PANE, title: 'Mods' })
120 if (opened.isPlaced) await update($, isOpen, () => true)
121 return opened.isPlaced ? `Mods: ${list.filter(m => m.isOn).length} of ${list.length} on.` : `Could not open the pane (${opened.reason}).`
122}
123
124export const register: Register = on => {
125 // A switched-off mod never loads: the engine asks the hub about every mod
126 // loaded after it, at session start and at each reload.
127 on('plugin.register', async ($, e, next) => {
128 if (e.name !== SELF && (await readOff($)).includes(e.name)) return { refuse: REFUSAL }
129 return next(e)
130 })
131
132 on('session.start', async ($, e, next) => {
133 await $.command.register({ name: 'mods', description: 'Every mod, what it does, and an on/off switch for each' })
134 const list = await scan($).catch(() => [] as HubMod[])
135 const stuck = list.filter(m => !m.isOn && m.isAheadOfHub)
136 if (stuck.length > 0) $.ui.toast(aheadWarning(stuck.map(m => m.name).join(', ')), { timeoutMs: 12000 })
137 return next(e)
138 })
139
140 on('command.run', { command: 'mods' }, async $ => ({ text: await togglePane($) }))
141
142 on('ui.close', { id: PANE }, async ($, e, next) => {
143 const done = await next(e)
144 await update($, isOpen, () => false)
145 return done
146 })
147
148 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
149 const { Box, Text, Button, Markdown } = $.ui.resolve(e)
150 const list = await read($, mods)
151 const open = await read($, expanded)
152 return (
153 <Box flexDirection="column" gap={1}>
154 <Box flexDirection="row" gap={1}>
155 <Text bold>Mods</Text>
156 <Text color={GREY}>
157 {list.filter(m => m.isOn).length} on · {list.filter(m => !m.isOn).length} off
158 </Text>
159 <Box flexGrow={1} />
160 <Button key="refresh" label="Refresh" plain onPress={() => scan($)} />
161 <Button key="close" label="Close" role="dismiss" onPress={() => togglePane($)} />
162 </Box>
163 {list.map(m => (
164 <Box key={`mod:${m.folder}`} flexDirection="column">
165 <Box flexDirection="row" gap={1}>
166 <Text color={m.isOn ? ON : GREY}>{m.isOn ? '● on ' : '○ off'}</Text>
167 <Text bold>{m.name}</Text>
168 <Text color={GREY}>{m.version}</Text>
169 <Box flexGrow={1} />
170 <Button
171 key={`details:${m.folder}`}
172 label={open === m.folder ? 'Hide details' : 'Details'}
173 plain
174 onPress={() => update($, expanded, cur => (cur === m.folder ? null : m.folder))}
175 />
176 {m.name !== SELF && <Button key={`toggle:${m.folder}`} label={m.isOn ? 'Turn off' : 'Turn on'} onPress={() => toggle($, m)} />}
177 </Box>
178 <Text color={GREY}>{m.description}</Text>
179 {open === m.folder && (
180 <Box flexDirection="column" marginTop={1} paddingLeft={2}>
181 {m.readme ? <Markdown text={m.readme} /> : <Text color={GREY}>No README.md in this mod's folder.</Text>}
182 </Box>
183 )}
184 </Box>
185 ))}
186 </Box>
187 )
188 })
189}
190types/index.d.ts 25 lines1/** One mod in the mods folder, as the /mods page lists it. */
2export type HubMod = {
3 folder: string
4 name: string
5 description: string
6 version: string
7 /** Not on this computer's off list (~/.claude/mods-data/hub/off.json). */
8 isOn: boolean
9 /** Listed before the hub in CLAUDE_CODE_PLUGIN_DIRS: it loads before the hub can refuse it. */
10 isAheadOfHub: boolean
11 readme: string | null
12}
13
14declare module 'claude-code' {
15 interface PluginState {
16 hub: {
17 mods: HubMod[]
18 /** The folder whose details are open on the page; null for none. */
19 expanded: string | null
20 root: string
21 isOpen: boolean
22 }
23 }
24}
25