SLOPSHOPPER

mod-switch

A mods button in the prompt footer opens a popover of every installed mod with switches that enable or disable them at user scope.

newbandspinnertoastprocesstimer
v0.3.1MITupdated 2026-10-16hal-9/claude-mods/mods/mod-switch
A shopper browsing a rack in a slop shop
README

claude-mods

Marketplace of Claude Code mods, MIT licensed. One folder per mod under mods/.

ModWhat it does
batonMeasures real context use per model request, stops subagents that run too long or too big, hands remaining work to a fresh session.
relayRole buttons above the prompt: review or adversarially review the working tree with Codex, hand a task to Codex, or verify with a second Claude model; findings become fix prompts.
cache-meterBand above the prompt: prompt-cache countdown, cache hit rate of the last request, context fill against auto-compact, and a Compact button.

Install a mod

/plugin install baton --marketplace hal-9/claude-mods

Answer y to add the marketplace, pick the user scope. Repeat with another mod's name for the next one.

Add a mod

  1. Put it in mods/<name>/ with .claude-plugin/plugin.json, hooks/hooks.json, hooks/register.ts and a README.
  2. Add it to .claude-plugin/marketplace.json.
  3. claude plugin validate mods/<name> and claude plugin test mods/<name>.
Source 2 files
hooks/register.tsx 154 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import type { IsOpen, Switch } from '../types'
4
5// Your mods are the user-scope plugins that ship a hooks module, from any marketplace; on/off is their enabledPlugins entry.
6const SELF = 'mod-switch@claude-mods'
7const IS_OPEN = { plugin: 'mod-switch', key: 'isOpen' } as const
8const SWITCHES = { plugin: 'mod-switch', key: 'switches' } as const
9const NEEDS_NEW_SESSION = { plugin: 'mod-switch', key: 'needsNewSession' } as const
10
11type $ = EngineInterface
12type Mod = { id: string; name: string; marketplace: string; isOn: boolean; isPending: boolean; isSwitched: boolean }
13type Installed = { plugins: Record<string, { scope: string; installPath: string }[]> }
14
15async function home($: $) {
16  return (await $.env.get('HOME')) ?? ''
17}
18
19async function getSwitches($: $): Promise<Record<string, Switch>> {
20  return (await $.state.get(SWITCHES)).value ?? {}
21}
22
23async function setSwitch($: $, id: string, value: Switch | undefined) {
24  const { [id]: _, ...rest } = await getSwitches($)
25  await $.state.set(SWITCHES, value ? { ...rest, [id]: value } : rest)
26}
27
28async function isMod($: $, installPath: string) {
29  try {
30    const hooks = JSON.parse(await $.fs.read(`${installPath}/hooks/hooks.json`)) as { modules?: unknown[] }
31    return (hooks.modules?.length ?? 0) > 0
32  } catch {
33    return false
34  }
35}
36
37async function listMods($: $): Promise<Mod[]> {
38  const installed = JSON.parse(await $.fs.read(`${await home($)}/.claude/plugins/installed_plugins.json`)) as Installed
39  const enabled = ((await $.settings.read({ source: 'user' })).enabledPlugins ?? {}) as Record<string, boolean>
40  const switches = await getSwitches($)
41  const mods: Mod[] = []
42  for (const [id, installs] of Object.entries(installed.plugins)) {
43    const install = installs.find(i => i.scope === 'user')
44    if (id === SELF || !install || !(await isMod($, install.installPath))) continue
45    const [name = id, marketplace = ''] = id.split('@')
46    const pressed = switches[id]
47    mods.push({
48      id,
49      name,
50      marketplace,
51      isOn: pressed?.isOn ?? enabled[id] === true,
52      isPending: pressed?.isPending ?? false,
53      isSwitched: pressed !== undefined && !pressed.isPending,
54    })
55  }
56  return mods.sort((a, b) => a.name.localeCompare(b.name))
57}
58
59async function getOpen($: $): Promise<IsOpen> {
60  return (await $.state.get(IS_OPEN)).value ?? false
61}
62
63async function setOpen($: $, value: IsOpen) {
64  await $.state.set(IS_OPEN, value)
65}
66
67async function getNeedsNewSession($: $): Promise<boolean> {
68  return (await $.state.get(NEEDS_NEW_SESSION)).value ?? false
69}
70
71// The press only flips the switch, so it settles and redraws at once; the CLI runs in a timer's own dispatch.
72async function toggle($: $, mod: Mod) {
73  if (mod.isPending) return
74  const isOn = !mod.isOn
75  await setSwitch($, mod.id, { isOn, isPending: true })
76  $.clock.after(0, () => void apply($, mod, isOn))
77}
78
79async function apply($: $, mod: Mod, isOn: boolean) {
80  // The desktop app's PATH may lack ~/.local/bin, so the CLI is named by its full path.
81  const cli = `${await home($)}/.local/bin/claude`
82  const { exitCode, stderr } = await $.process.run([cli, 'plugin', isOn ? 'enable' : 'disable', mod.id, '--scope', 'user'])
83  if (exitCode !== 0) {
84    await setSwitch($, mod.id, undefined)
85    $.ui.toast(`${mod.name}: could not switch ${isOn ? 'on' : 'off'}: ${stderr.trim()}`)
86    return
87  }
88  await setSwitch($, mod.id, { isOn, isPending: false })
89
90  // Only the terminal can /reload-plugins; the desktop app refuses it over its remote connection, so the change lands next session.
91  if ((await $.session.surface()) === 'terminal') {
92    void $.command.run({ command: 'reload-plugins' }).catch(() => undefined)
93    $.ui.toast(`${mod.name} ${isOn ? 'on' : 'off'}`)
94  } else {
95    await $.state.set(NEEDS_NEW_SESSION, true)
96  }
97}
98
99export const register: Register = on => {
100  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
101    const { Box, Button, Text } = $.ui.resolve(e)
102    const modes = e.props.modes.join(' & ')
103    const open = await getOpen($)
104    // Another mod's footer (cache-meter) rides along; the engine's own is redrawn as its mode labels.
105    const below = await next(e)
106
107    return (
108      <Box gap="sm" alignItems="center">
109        {below.type !== 'engine' ? below : modes && <Text dimColor>{modes}</Text>}
110        <Button key="toggle-menu" plain dimColor={!open} onPress={() => setOpen($, !open)}>
111          <Text color={open ? 'claude' : undefined}>⚙ mods {open ? '▾' : '▴'}</Text>
112        </Button>
113      </Box>
114    )
115  })
116
117  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
118    if (e.props.hasSurvey || !(await getOpen($))) return next(e)
119
120    const { Box, Button, Text } = $.ui.resolve(e)
121    const mods = await listMods($)
122    const needsNewSession = await getNeedsNewSession($)
123    const onCount = mods.filter(mod => mod.isOn).length
124    const nameWidth = Math.max(0, ...mods.map(mod => mod.name.length))
125
126    return (
127      <Box flexDirection="column" borderStyle="round" borderColor="claude" paddingX={1}>
128        <Box justifyContent="space-between">
129          <Text bold>Mods <Text dimColor>· {onCount} of {mods.length} on</Text></Text>
130          <Button key="close" plain dimColor role="dismiss" onPress={() => setOpen($, false)}>✕</Button>
131        </Box>
132        {mods.length === 0 && <Text dimColor>No installed plugin ships a hooks module.</Text>}
133        {mods.map(mod => (
134          <Box key={mod.id}>
135            <Button key={`toggle-${mod.name}`} plain onPress={() => toggle($, mod)}>
136              <Text color={mod.isPending ? 'warning' : mod.isOn ? 'success' : undefined} dimColor={!mod.isOn && !mod.isPending}>
137                {mod.isPending ? '[…]' : mod.isOn ? '[■]' : '[ ]'}{' '}
138              </Text>
139              <Text bold={mod.isOn} dimColor={!mod.isOn}>{mod.name.padEnd(nameWidth)}</Text>
140            </Button>
141            <Text dimColor>  {mod.marketplace}</Text>
142            {needsNewSession && mod.isSwitched && <Text color="warning">  ↻</Text>}
143          </Box>
144        ))}
145        {needsNewSession && (
146          <Box marginTop={1}>
147            <Text color="warning">↻ takes effect when you start a new session</Text>
148          </Box>
149        )}
150      </Box>
151    )
152  })
153}
154
types/index.d.ts 18 lines
1/** Whether the mods popover above the prompt is open. */
2export type IsOpen = boolean
3
4/** A switch the person pressed: the state asked for, and whether the CLI is still running. */
5export type Switch = { isOn: boolean; isPending: boolean }
6
7declare module 'claude-code' {
8  interface PluginState {
9    'mod-switch': {
10      isOpen: IsOpen
11      /** Pressed switches by plugin id (name@marketplace); they win over the settings snapshot, which only refreshes on a reload. */
12      switches: Record<string, Switch>
13      /** True once a switch landed but this session could not reload plugins. */
14      needsNewSession: boolean
15    }
16  }
17}
18