SLOPSHOPPER

hub

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

newpanecommandtoast
v0.1.0no licenseupdated 2026-10-25eshin087/claude-code-mods/hub
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · hub
│ ┃ Mods ✕ › fix the failing auth test and add an audit log call │ ┃ Mods 0 on · 0 off Refresh [ Close ] │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ 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 │ ⎿ hub: Mods: 0 of 0 on. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

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

Hub

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.

  • The switch also saves the mod's 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.
  • The switch covers every session on this computer. Nothing is deleted, and no tracked file changes, so git status stays clean and on/off never travels through a commit or git pull.
  • The hub must load first. A mod can only be refused by mods that load before it, so 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.

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