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

What it does: one page listing every mod in your mods folder, what each does in detail, and an on/off switch.
The dock: one slim line just above the prompt, only when there's something to say:
◎ ━━━━━━──── 46% · 3/5 · ~8m wiring the tile streamer…
✓ done 6m53s until your next prompt.There are no tab buttons or hover cards: the engine can only draw a card inside the band above the prompt, which made the app's panel there grow. Each panel opens with its slash command instead: /mission, /coach, /prs, /next, /mods. Mission Control's working row also has a plan › link.
Review everything at once: /mods-review opens Mods, Plan, Coach and PRs together (as tabs) and asks for next-task suggestions when there are none.
The hub page: type /mods. A pane opens with every mod, its version, a one-line summary, Details (this page) and Turn off / Turn on.
How on/off works: the switched-off mods are a list on this computer, ~/.claude/mods-data/mod-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 (plugin.register), and the hub refuses any mod on the list, so none of its hooks, commands or tools run. The engine's log names each one: refused by mod-hub: switched off in /mods.
hooks/hooks.json again, byte for byte. 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.mod-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 (older installs listed folders alphabetically), the hub says so in a toast: run the install script again.On/off and git: switching changes no tracked file, so git status stays clean and on/off never travels through a commit or git pull: each computer keeps its own list. claude plugin test always runs a mod's real code, whether it's switched off or not.
hooks.json at a stub (hooks/off.tsx). The hub moves any mod it finds like that onto the list and points hooks.json back at register.tsx by itself.off.tsx files are git-ignored and safe to delete.Removing a mod: delete its folder (and commit that, so other computers lose it with their next git pull). At the next session start the hub sees the folder is gone and takes it out of CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json (backed up to settings.json.bak first) and out of off.json, then says so in a toast. Only folders in this mods folder are touched; nothing else in your settings changes.
Cost: none. No model calls.
Data saved: ~/.claude/mods-data/mod-hub/off.json, the mods switched off on this computer. It also edits CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json, only to take out a mod folder that is gone.
Notes: the hub can't switch itself off from the page. To remove it, delete or rename its folder. Without the hub nothing refuses the switched-off mods, so they all load.
hooks/register.tsx 359 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { HubMod } from '../types'
5
6// Two jobs. The hub: every mod in the mods folder, its README, and an on/off
7// switch. A switched-off mod is named in ~/.claude/mods-data/mod-hub/off.json
8// (this computer only) and the hub refuses it at `plugin.register`, so no mod
9// file changes and git sees nothing.
10// The dock: one slim line above the prompt, only when there is something to
11// say: the plan's progress while Claude is idle (the working row carries it
12// while Claude works), then a done chip until the next prompt. Panels open with
13// their own slash commands; /mods-review opens them all through `signal`, a
14// value each mod hooks, so it lands at once.
15
16const PANE = 'mods'
17const SELF = 'mod-hub'
18const ON = `{ "modules": ["./register.tsx"] }\n`
19// What the old switch wrote into a switched-off mod's hooks.json; moved to off.json on sight.
20const LEGACY_OFF = './off.tsx'
21const REFUSAL = 'switched off in /mods'
22
23const mods = atom({ plugin: 'mod-hub', key: 'mods' } as const, [])
24const expanded = atom({ plugin: 'mod-hub', key: 'expanded' } as const, null)
25const rootRef = atom({ plugin: 'mod-hub', key: 'root' } as const, '')
26const signal = atom({ plugin: 'mod-hub', key: 'signal' } as const, null)
27const isOpen = atom({ plugin: 'mod-hub', key: 'isOpen' } as const, false)
28
29// Mission Control's summary, which the dock shows (its owner writes it).
30const MISSION = { plugin: 'mission-control', key: 'summary' } as const
31// The dock's color: the plan's, as on the working row.
32const PLAN = '#22d3ee'
33const TRACK = '#3f3f46'
34
35const fmt = (ms: number) => {
36 const s = Math.max(0, Math.round(ms / 1000))
37 if (s < 60) return `${s}s`
38 const m = Math.floor(s / 60)
39 return m < 60 ? `${m}m${String(s % 60).padStart(2, '0')}s` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
40}
41
42const parentOf = (dir: string) => dir.replace(/[\\/]+$/, '').replace(/[\\/][^\\/]+$/, '')
43
44const readJson = async ($: EngineInterface, path: string) => {
45 try {
46 return JSON.parse(await $.fs.read(path)) as Record<string, unknown>
47 } catch {
48 return null
49 }
50}
51
52const dataDir = async ($: EngineInterface) => {
53 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '.'
54 return `${home.replace(/\\/g, '/')}/.claude/mods-data/mod-hub`
55}
56
57// The mods switched off on this computer, by name.
58const readOff = async ($: EngineInterface) => {
59 const saved = await readJson($, `${await dataDir($)}/off.json`)
60 return Array.isArray(saved?.off) ? saved.off.filter((n): n is string => typeof n === 'string') : []
61}
62
63const writeOff = async ($: EngineInterface, names: string[]) =>
64 $.fs.write(`${await dataDir($)}/off.json`, `${JSON.stringify({ off: [...new Set(names)].sort() }, null, 2)}\n`)
65
66// The engine asks only the mods loaded before a mod whether it may load, so the
67// hub must come first in CLAUDE_CODE_PLUGIN_DIRS (the installers put it there).
68// The folders listed ahead of it, which it cannot keep off.
69const normDir = (p: string, isWindows: boolean) => {
70 const path = p.trim().replace(/\\/g, '/').replace(/\/+$/, '')
71 return isWindows ? path.toLowerCase() : path
72}
73
74const foldersAheadOfHub = async ($: EngineInterface) => {
75 const isWindows = /^[A-Za-z]:/.test($.plugin.root)
76 const dirs = ((await $.env.get('CLAUDE_CODE_PLUGIN_DIRS')) ?? '').split(isWindows ? ';' : ':').map(d => normDir(d, isWindows))
77 const at = dirs.indexOf(normDir($.plugin.root, isWindows))
78 return at < 0 ? [] : dirs.slice(0, at).map(d => d.slice(d.lastIndexOf('/') + 1))
79}
80
81// A mod folder that is gone (deleted here, or by a git pull) comes out of
82// CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json and out of off.json, so
83// new sessions stop trying to load it. Only folders beside the hub are touched;
84// settings.json is backed up to settings.json.bak first, as the installers do.
85const pruneGone = async ($: EngineInterface) => {
86 const isWindows = /^[A-Za-z]:/.test($.plugin.root)
87 const sep = isWindows ? ';' : ':'
88 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '.'
89 const file = `${home.replace(/\\/g, '/')}/.claude/settings.json`
90 const text = await $.fs.read(file).catch(() => null)
91 if (text === null) return []
92 let settings: { env?: Record<string, unknown> }
93 try {
94 settings = JSON.parse(text)
95 } catch {
96 return []
97 }
98 const value = settings.env?.CLAUDE_CODE_PLUGIN_DIRS
99 if (typeof value !== 'string') return []
100 const base = normDir(parentOf($.plugin.root), isWindows)
101 const dirs = value.split(sep).filter(d => d.trim() !== '')
102 const gone: string[] = []
103 for (const dir of dirs) {
104 if (parentOf(normDir(dir, isWindows)) !== base) continue
105 if (!(await $.fs.exists(`${dir.trim()}/.claude-plugin/plugin.json`))) gone.push(dir)
106 }
107 if (gone.length === 0) return []
108 await $.fs.write(`${file}.bak`, text)
109 settings.env!.CLAUDE_CODE_PLUGIN_DIRS = dirs.filter(d => !gone.includes(d)).join(sep)
110 await $.fs.write(file, `${JSON.stringify(settings, null, 2)}\n`)
111 const names = gone.map(d => normDir(d, false).split('/').pop()!)
112 const off = await readOff($)
113 if (off.some(n => names.includes(n))) await writeOff($, off.filter(n => !names.includes(n)))
114 return names
115}
116
117const aheadWarning = (names: string) =>
118 `Can't keep ${names} off: loaded before mod-hub. Run install.ps1 (or install.sh) again to put mod-hub first, then start a new session.`
119
120const scan = async ($: EngineInterface) => {
121 const root = parentOf($.plugin.root)
122 const entries = await $.fs.list(root)
123 const off = await readOff($)
124 const ahead = await foldersAheadOfHub($)
125 const isWindows = /^[A-Za-z]:/.test(root)
126 const found: HubMod[] = []
127 const legacy: { name: string; file: string }[] = []
128 for (const entry of entries) {
129 const dir = `${root}/${entry.name}`
130 const manifest = await readJson($, `${dir}/.claude-plugin/plugin.json`)
131 if (!manifest) continue
132 const name = typeof manifest.name === 'string' ? manifest.name : entry.name
133 const hooks = await readJson($, `${dir}/hooks/hooks.json`)
134 const modules = Array.isArray(hooks?.modules) ? (hooks.modules as unknown[]) : []
135 if (modules.includes(LEGACY_OFF) && name !== SELF) legacy.push({ name, file: `${dir}/hooks/hooks.json` })
136 const readme = (await $.fs.exists(`${dir}/README.md`)) ? await $.fs.read(`${dir}/README.md`) : null
137 found.push({
138 folder: entry.name,
139 name,
140 description: typeof manifest.description === 'string' ? manifest.description : '',
141 version: typeof manifest.version === 'string' ? manifest.version : '',
142 isOn: !off.includes(name) && !legacy.some(l => l.name === name),
143 isAheadOfHub: ahead.includes(isWindows ? entry.name.toLowerCase() : entry.name),
144 readme,
145 })
146 }
147 // A mod the old switch turned off (its hooks.json names the off.tsx stub):
148 // the list first, so the reload the hooks.json write sets off refuses it.
149 if (legacy.length > 0) {
150 await writeOff($, [...off, ...legacy.map(l => l.name)])
151 for (const l of legacy) await $.fs.write(l.file, ON)
152 }
153 found.sort((a, b) => (a.name === SELF ? -1 : b.name === SELF ? 1 : a.name.localeCompare(b.name)))
154 await update($, mods, () => found)
155 await update($, rootRef, () => root)
156 return found
157}
158
159// Does what the pressed button said, even when a page left open elsewhere is behind.
160const toggle = async ($: EngineInterface, mod: HubMod) => {
161 if (mod.name === SELF) return
162 const rest = (await readOff($)).filter(n => n !== mod.name)
163 await writeOff($, mod.isOn ? [...rest, mod.name] : rest)
164 // hooks.json rewritten as it is: its new timestamp makes every watching
165 // session reload the mod, and the reload asks plugin.register again.
166 const file = `${await read($, rootRef)}/${mod.folder}/hooks/hooks.json`
167 const text = await $.fs.read(file).catch(() => null)
168 if (text !== null) await $.fs.write(file, text)
169 await scan($)
170 const isStuck = mod.isOn && mod.isAheadOfHub
171 $.ui.toast(isStuck ? aheadWarning(mod.name) : `${mod.name} switched ${mod.isOn ? 'off' : 'on'}.`, { timeoutMs: isStuck ? 12000 : 5000 })
172}
173
174const toggleHub = async ($: EngineInterface) => {
175 if ((await $.ui.panes()).some(p => p.id === PANE)) {
176 await $.ui.close({ id: PANE })
177 // A plugin's own close does not reach its own ui.close hook: reset the flag here.
178 await update($, isOpen, () => false)
179 return 'Mod Hub closed.'
180 }
181 const list = await scan($)
182 const opened = await $.ui.open({ id: PANE, title: 'Mods' })
183 if (opened.isPlaced) await update($, isOpen, () => true)
184 return opened.isPlaced ? `Mod Hub: ${list.filter(m => m.isOn).length} of ${list.length} mods on.` : `Could not open the pane (${opened.reason}).`
185}
186
187// Every mod shows itself: the panels open as tabs, the next-task card opens.
188const review = async ($: EngineInterface) => {
189 await update($, signal, s => ({ seq: (s?.seq ?? 0) + 1, target: '*', action: 'review' as const }))
190 if (!(await $.ui.panes()).some(p => p.id === PANE)) await toggleHub($)
191 $.ui.toast('Review: Mods, Plan, Coach and PRs are open as tabs; next tasks are on their way.', { timeoutMs: 8000 })
192}
193
194// A review queued from outside (mods-data/mod-hub/review.json with { "pending": true })
195// runs once, a few seconds after the mods load, so every mod is listening.
196const reviewIfQueued = async ($: EngineInterface) => {
197 const file = `${await dataDir($)}/review.json`
198 if (!(await $.fs.exists(file))) return
199 try {
200 const queued = JSON.parse(await $.fs.read(file)) as { pending?: boolean; session?: string }
201 if (queued.pending !== true) return
202 // A review meant for one session is left for that session.
203 if (queued.session && queued.session !== (await $.session.id())) return
204 } catch {
205 return
206 }
207 await $.fs.write(file, JSON.stringify({ pending: false }))
208 $.clock.after(3000, () => void review($).catch(() => undefined))
209}
210
211// A solid rounded bar (track, then fill up to pct) as SVG, for surfaces that draw it.
212const svgBar = (pct: number, width: number, color: string) => {
213 const fill = Math.max(0, Math.min(width, Math.round(pct * width)))
214 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="8" viewBox="0 0 ${width} 8">` +
215 `<rect y="1.5" width="${width}" height="5" rx="2.5" fill="${TRACK}"/>` +
216 (fill > 0 ? `<rect y="1.5" width="${Math.max(fill, 5)}" height="5" rx="2.5" fill="${color}"/>` : '') +
217 '</svg>'
218}
219
220const isModOn = (list: HubMod[], name: string) => list.length === 0 || list.some(m => m.name === name && m.isOn)
221
222export const register: Register = on => {
223 // A switched-off mod never loads: the engine asks the hub about every mod
224 // loaded after it, at session start and at each reload.
225 on('plugin.register', async ($, e, next) => {
226 if (e.name !== SELF && (await readOff($)).includes(e.name)) return { refuse: REFUSAL }
227 return next(e)
228 })
229
230 on('session.start', async ($, e, next) => {
231 await $.command.register({ name: 'mods', description: 'Mod Hub: what each mod does, and switch mods on or off (toggles)' })
232 await $.command.register({ name: 'mods-review', description: 'Open every mod panel at once (Mods, Plan, Coach, PRs, next tasks)' })
233 const list = await scan($).catch(() => [] as HubMod[])
234 const stuck = list.filter(m => !m.isOn && m.isAheadOfHub)
235 if (stuck.length > 0) $.ui.toast(aheadWarning(stuck.map(m => m.name).join(', ')), { timeoutMs: 12000 })
236 const gone = await pruneGone($).catch(() => [] as string[])
237 if (gone.length > 0) $.ui.toast(`Removed ${gone.join(', ')} from your mods: its folder is gone.`, { timeoutMs: 8000 })
238 await reviewIfQueued($).catch(() => undefined)
239 return next(e)
240 })
241
242 on('command.run', { command: 'mods' }, async $ => ({ text: await toggleHub($) }))
243
244 on('command.run', { command: 'mods-review' }, async $ => {
245 await review($)
246 return { text: 'Review mode: every mod panel is open.' }
247 })
248
249 on('ui.close', { id: PANE }, async ($, e, next) => {
250 const done = await next(e)
251 await update($, isOpen, () => false)
252 return done
253 })
254
255 // The dock: the plan's progress while Claude is idle, a done chip once the
256 // plan is finished, nothing otherwise. No Box above `below` may carry
257 // display, overflow, position, a size (width, height, min...) or an offset
258 // (top, left...): the engine refuses a tree that puts its own node under one,
259 // and draws none of the band.
260 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
261 if (e.props.hasSurvey) return next(e)
262 const list = await read($, mods)
263 const mission = isModOn(list, 'mission-control') ? (await $.state.get(MISSION)).value ?? null : null
264 // While a turn runs, Mission Control's working row carries the progress.
265 if (!mission || mission.total === 0 || (!mission.isFinished && e.props.isWorking)) return next(e)
266 const below = await next(e)
267 const elements = $.ui.resolve(e)
268 const { Box, Text } = elements
269 const SvgEl = 'Svg' in elements && e.surface !== 'terminal' ? elements.Svg : undefined
270 // A bar: solid SVG where the surface draws SVG, characters on the terminal.
271 const meter = (fraction: number, cells: number) => {
272 if (SvgEl) {
273 return <SvgEl source={svgBar(fraction, cells * 7, PLAN)} alt={`${Math.round(fraction * 100)}%`} width={cells * 7} height={8} />
274 }
275 const full = Math.max(0, Math.min(cells, Math.round(fraction * cells)))
276 return (
277 <Box flexDirection="row">
278 <Text color={PLAN}>{'━'.repeat(full)}</Text>
279 <Text color={TRACK}>{'━'.repeat(cells - full)}</Text>
280 </Box>
281 )
282 }
283 return (
284 <Box flexDirection="column">
285 {below}
286 <Box key="dock:row" flexDirection="row" gap={1} flexWrap="nowrap" alignItems="center" overflow="hidden">
287 {mission.isFinished ? (
288 <Box key="dock:done">
289 <Text color="success">{`✓ done ${fmt(mission.elapsedMs)}`}</Text>
290 </Box>
291 ) : (
292 <Box key="dock:progress" flexDirection="row" gap={1} flexShrink={0} alignItems="center">
293 <Text color={PLAN}>◎</Text>
294 {meter(mission.pct, 10)}
295 <Text bold>{`${Math.round(mission.pct * 100)}%`}</Text>
296 <Text dimColor>{`· ${mission.stepNo}/${mission.total}${mission.left ? ` · ${mission.left}` : ''}`}</Text>
297 </Box>
298 )}
299 {!mission.isFinished && mission.now !== '' && (
300 <Box key="dock:now" flexShrink={1} minWidth={0}>
301 <Text dimColor wrap="truncate-end">
302 {mission.now}
303 </Text>
304 </Box>
305 )}
306 </Box>
307 </Box>
308 )
309 })
310
311 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
312 const { Box, Text, Button, Markdown } = $.ui.resolve(e)
313 const list = await read($, mods)
314 const open = await read($, expanded)
315 const root = await read($, rootRef)
316 return (
317 <Box flexDirection="column" gap={1}>
318 <Box flexDirection="column">
319 <Box flexDirection="row" gap={1}>
320 <Text bold>Mods</Text>
321 <Text dimColor>
322 {list.filter(m => m.isOn).length} on · {list.filter(m => !m.isOn).length} off
323 </Text>
324 <Button key="refresh" label="Refresh" plain onPress={() => scan($)} />
325 <Button key="close" label="Close" role="dismiss" onPress={() => toggleHub($)} />
326 </Box>
327 <Text dimColor wrap="truncate-middle">
328 {root}
329 </Text>
330 </Box>
331 {list.map(m => (
332 <Box flexDirection="column">
333 <Box flexDirection="row" gap={1}>
334 <Text color={m.isOn ? 'success' : 'error'}>{m.isOn ? '● ON ' : '○ OFF'}</Text>
335 <Text bold>{m.name}</Text>
336 <Text dimColor>{m.version}</Text>
337 <Button
338 key={`details:${m.folder}`}
339 label={open === m.folder ? 'Hide details' : 'Details'}
340 plain
341 onPress={() => update($, expanded, cur => (cur === m.folder ? null : m.folder))}
342 />
343 {m.name !== SELF && (
344 <Button key={`toggle:${m.folder}`} label={m.isOn ? 'Turn off' : 'Turn on'} onPress={() => toggle($, m)} />
345 )}
346 </Box>
347 <Text dimColor={!m.isOn}>{m.description}</Text>
348 {open === m.folder && (
349 <Box flexDirection="column" marginTop={1} paddingLeft={2}>
350 {m.readme ? <Markdown text={m.readme} /> : <Text dimColor>No README.md in this mod's folder.</Text>}
351 </Box>
352 )}
353 </Box>
354 ))}
355 </Box>
356 )
357 })
358}
359types/index.d.ts 30 lines1export type HubMod = {
2 folder: string
3 name: string
4 description: string
5 version: string
6 /** Not on this computer's off list (~/.claude/mods-data/mod-hub/off.json). */
7 isOn: boolean
8 /** Listed before mod-hub in CLAUDE_CODE_PLUGIN_DIRS: it loads before the hub can refuse it. */
9 isAheadOfHub: boolean
10 readme: string | null
11}
12
13/**
14 * A press for another mod: `target` is the mod's name; `seq` makes each press a new write.
15 * `action: 'review'` (target `*`, from /mods-review) asks every mod to show its panel at once.
16 */
17export type HubSignal = { seq: number; target: string; action?: 'toggle' | 'review' }
18
19declare module 'claude-code' {
20 interface PluginState {
21 'mod-hub': {
22 mods: HubMod[]
23 expanded: string | null
24 root: string
25 signal: HubSignal | null
26 isOpen: boolean
27 }
28 }
29}
30