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

Marketplace of Claude Code mods, MIT licensed. One folder per mod under mods/.
| Mod | What it does |
|---|---|
| baton | Measures real context use per model request, stops subagents that run too long or too big, hands remaining work to a fresh session. |
| relay | Role 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-meter | Band above the prompt: prompt-cache countdown, cache hit rate of the last request, context fill against auto-compact, and a Compact button. |
/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.
mods/<name>/ with .claude-plugin/plugin.json, hooks/hooks.json, hooks/register.ts and a README..claude-plugin/marketplace.json.claude plugin validate mods/<name> and claude plugin test mods/<name>.hooks/register.tsx 154 lines1import 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}
154types/index.d.ts 18 lines1/** 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