SLOPSHOPPER

mod-hub

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

newpanebandcommandtoasttimer
v0.1.0no licenseupdated 2026-10-09eshin087/claude-code-mods/mod-hub
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mod-hub
│ ┃ Mods ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ Mods 0 on · 0 off Refresh [ Close ] │ mod-hub │ │ ┃ /plugins ⏺ Read(src/auth.ts) │ Review: Mods, Plan, Coach and PRs are open │ │ ⎿ Read 6 lines │ as tabs; next tasks are on their way. │ │ ⏺ 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 │ ⎿ mod-hub: Mod Hub: 0 of 0 mods on. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Mods
Mods 0 on · 0 off Refresh [ Close ] /plugins
README

Mod Hub

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…
  • While Claude is idle with a plan under way: a solid bar, %, step and time left (these never get cut off), then the "now" line, trimmed with "…" when space runs out.
  • While Claude works: nothing here; Mission Control's working row carries the progress.
  • When the plan finishes: ✓ done 6m53s until your next prompt.
  • No plan: nothing above the prompt at all.

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.

  • The switch also saves the mod's 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.
  • The switch covers every session on this computer. Nothing is deleted.
  • The hub must load first. A mod can only be refused by mods that load before it, so 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.

  • Older hubs switched a mod off by pointing its 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.
  • Leftover 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.

Source 2 files
hooks/register.tsx 359 lines
1import { 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}
359
types/index.d.ts 30 lines
1export 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