Robert's Toolbox: one button that opens your mods and the commands that change Claude Code.

A small collection of Claude Code mods (hooks-module plugins), shared as a plugin marketplace. Use any of them, change them, pass them on: they're MIT-licensed.
claude plugin marketplace add hearsawho25/roberts-mods
claude plugin install <mod>@roberts-mods
Install as many as you like, then run /reload-plugins or start a new session.
| Mod | Command | What it does | |
|---|---|---|---|
roberts-toolbox | /toolbox | One button at the end of the prompt hint line that opens a menu of your mods and the commands that change Claude Code. | |
clean-view | `/simple on\ | off` | Hides tool calls, diffs and command output, and shows one plain-English checklist above the prompt instead. |
prompt-upgrade | /better | Turns a rough prompt into a better one in a pane: up to three quick questions, a rewrite, and a side-by-side review before anything is sent. | |
daily-challenge | /challenge | One small Claude Code exercise a day, with a streak and a year of challenges that never repeat. | |
work-report | /report | Counts the tasks Claude finished for you and estimates the hours and dollars saved, by day, week, month and year. | |
copy-blocks | /copyblock [n] | A Copy button under every code block in Claude's replies, so you get clean text without the frame. | |
relay | /relay, /handoff, /keepwarm | A context and prompt-cache meter above the prompt, one-click handoffs to a fresh session, and cache warming with a cold-cache guard. See relay/README.md. | |
git-ship | /ship | Repo status above the prompt, and a pane whose buttons fill ready-to-run git and gh commands (commit messages and PRs drafted for you). It never runs a write: you press Enter. See git-ship/README.md. | |
mod-doctor | /mod-doctor | Checks that every enabled mod actually loaded. Stays quiet until one didn't, then shows a red row and the reason from claude plugin validate. See mod-doctor/README.md. |
Several of these started as other people's ideas, and they deserve the credit:
The mods themselves were built with Claude Code.
MIT.
hooks/register.tsx 402 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Catalog, ModInfo } from '../types'
5import {
6 NEW_MOD_REQUEST,
7 changeRequest,
8 claudeHome,
9 displayName,
10 fillText,
11 isModHooks,
12 joinPath,
13 parentOf,
14 readInstalled,
15 readManifest,
16 toolsIn,
17 type Tool,
18} from './tools'
19
20const PANE = 'roberts-toolbox'
21const PANE_TITLE = "Robert's Toolbox"
22const BUTTON_LABEL = "Robert's Toolbox"
23const TITLE = "ROBERT'S TOOLBOX"
24/** The callout's width in columns, border included: one compact column above the button. */
25const CALLOUT_WIDTH = 48
26const FAILED = "Robert's Toolbox could not do that just now. Please try again in a moment."
27
28const COLORS = {
29 gold: '#F2C46D',
30 line: '#262B33',
31 muted: '#7D8590',
32 text: '#E6EDF3',
33 green: '#3DD68C',
34 ink: '#0D1117',
35 panel: '#161B22',
36} as const
37
38const catalogAtom = atom({ plugin: 'roberts-toolbox', key: 'catalog' } as const, null)
39const isOpenAtom = atom({ plugin: 'roberts-toolbox', key: 'isOpen' } as const, false)
40
41function quietly(work: Promise<unknown>): void {
42 work.catch(() => undefined)
43}
44
45function fit(text: string, width: number): string {
46 if (width <= 0) return ''
47 if (text.length <= width) return text
48 return width <= 1 ? text.slice(0, width) : `${text.slice(0, width - 1)}…`
49}
50
51/**
52 * Where Claude Code keeps its plugins: CLAUDE_CONFIG_DIR when set, else the `.claude`
53 * folder this mod sits under, else `.claude` in the home folder (a mod installed from
54 * a folder elsewhere, as from a local marketplace).
55 */
56async function findClaudeHome($: EngineInterface): Promise<string | undefined> {
57 const configDir = await $.env.get('CLAUDE_CONFIG_DIR').catch(() => undefined)
58 if (configDir !== undefined && configDir !== '') return configDir
59 const fromRoot = claudeHome($.plugin.root)
60 if (fromRoot !== undefined) return fromRoot
61 const home = (await $.env.get('USERPROFILE').catch(() => undefined)) ?? (await $.env.get('HOME').catch(() => undefined))
62 return home === undefined || home === '' ? undefined : joinPath(home, '.claude')
63}
64
65/** Looks at the mods folder and the session's commands. Never called while drawing. */
66async function scan($: EngineInterface): Promise<Catalog> {
67 const commands = await $.command.list().catch(() => [])
68 const folder = parentOf($.plugin.root)
69 const entries = await $.fs.list(folder).catch(() => [])
70 const commandsOf = (name: string) =>
71 commands.filter(one => one.source === 'plugin' && one.plugin === name).map(one => one.name)
72 const manifestIn = async (modFolder: string) => {
73 const text = await $.fs.read(joinPath(modFolder, '.claude-plugin', 'plugin.json')).catch(() => undefined)
74 return typeof text === 'string' ? readManifest(text) : undefined
75 }
76
77 const mods: ModInfo[] = []
78 const seen = new Set([$.plugin.name])
79 for (const entry of entries) {
80 if (entry.kind !== 'dir' || entry.name.startsWith('.')) continue
81 const modFolder = joinPath(folder, entry.name)
82 const manifest = await manifestIn(modFolder)
83 if (manifest === undefined || seen.has(manifest.name)) continue
84 seen.add(manifest.name)
85 mods.push({ ...manifest, folder: modFolder, commands: commandsOf(manifest.name), kind: 'dev' })
86 }
87
88 // Installed plugins count as mods when their hooks.json loads hooks modules; skill-only plugins do not.
89 const home = await findClaudeHome($)
90 if (home !== undefined) {
91 const installedText = await $.fs.read(joinPath(home, 'plugins', 'installed_plugins.json')).catch(() => undefined)
92 const settingsText = await $.fs.read(joinPath(home, 'settings.json')).catch(() => undefined)
93 const installed = typeof installedText === 'string'
94 ? readInstalled(installedText, typeof settingsText === 'string' ? settingsText : undefined)
95 : []
96 for (const plugin of installed) {
97 if (seen.has(plugin.name)) continue
98 const hooks = await $.fs.read(joinPath(plugin.folder, 'hooks', 'hooks.json')).catch(() => undefined)
99 if (typeof hooks !== 'string' || !isModHooks(hooks)) continue
100 const manifest = await manifestIn(plugin.folder)
101 seen.add(plugin.name)
102 mods.push({
103 name: plugin.name,
104 description: manifest?.description ?? '',
105 folder: plugin.folder,
106 commands: commandsOf(plugin.name),
107 kind: 'installed',
108 marketplace: plugin.marketplace,
109 })
110 }
111 }
112 mods.sort((a, b) => a.name.localeCompare(b.name))
113
114 return { mods, available: commands.map(one => one.name) }
115}
116
117async function refresh($: EngineInterface): Promise<Catalog> {
118 const catalog = await scan($)
119 await update($, catalogAtom, () => catalog)
120 return catalog
121}
122
123async function openToolbox($: EngineInterface): Promise<void> {
124 await refresh($).catch(() => undefined)
125 await $.ui.open({ id: PANE, title: PANE_TITLE })
126}
127
128/** Opens or closes the menu above the prompt, scanning afresh as it opens. */
129async function toggleMenu($: EngineInterface): Promise<void> {
130 const isOpen = await update($, isOpenAtom, was => !was)
131 if (isOpen) await refresh($).catch(() => undefined)
132}
133
134/** Closes the toolbox, wherever it shows, then hands over to what was picked. */
135async function leaveFor($: EngineInterface, then: () => Promise<unknown>): Promise<void> {
136 await update($, isOpenAtom, () => false)
137 await $.ui.close({ id: PANE }).catch(() => undefined)
138 await then()
139}
140
141const runCommand = ($: EngineInterface, command: string) =>
142 leaveFor($, () => $.command.run({ command }))
143
144const fillPrompt = ($: EngineInterface, text: string) =>
145 leaveFor($, () => $.prompt.fill({ text }))
146
147function useTool($: EngineInterface, tool: Tool): Promise<void> {
148 return tool.action === 'run' ? runCommand($, tool.command) : fillPrompt($, fillText(tool))
149}
150
151export const register: Register = on => {
152 on('session.start', async ($, e, next) => {
153 await $.command.register({
154 name: 'toolbox',
155 description: "Open Robert's Toolbox: your mods and the commands that change Claude Code",
156 })
157 await update($, isOpenAtom, () => false)
158 quietly(refresh($))
159
160 return next(e)
161 })
162
163 on('command.run', { command: 'toolbox' }, async $ => {
164 try {
165 quietly(toggleMenu($))
166 return { text: "Opening Robert's Toolbox." }
167 } catch {
168 return { text: FAILED }
169 }
170 }).catch(() => ({ text: FAILED }))
171
172 on('prompt.submit', async ($, e, next) => {
173 quietly(update($, isOpenAtom, () => false))
174 return next(e)
175 })
176
177 /** The button at the right end of the hint line; a click opens or closes the menu above the prompt. */
178 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
179 const engine = await next(e)
180 const { Box, Button, Text } = $.ui.resolve(e)
181 const isOpen = await read($, isOpenAtom)
182 // The engine's own line may not sit under a sized Box (the terminal refuses the tree
183 // and draws its own), so it stays a direct child and an empty spacer pushes the button right.
184 return (
185 <Box flexDirection="row">
186 {engine}
187 <Box key="toolbox-spacer" flexGrow={1} />
188 <Button key="open-toolbox" plain onPress={() => quietly(toggleMenu($))}>
189 <Text color={COLORS.gold} bold={isOpen}>{isOpen ? `${BUTTON_LABEL} ▾` : BUTTON_LABEL}</Text>
190 </Button>
191 </Box>
192 )
193 })
194
195 /**
196 * The menu: a gold-bordered callout at the right of the band above the prompt, over the button.
197 * The band pushes the prompt down rather than covering it, and it is shared, so while the menu
198 * is closed (or a survey holds the band) the others draw.
199 */
200 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
201 if (e.props.hasSurvey || !(await read($, isOpenAtom))) return next(e)
202 const { Box, Button, Text } = $.ui.resolve(e)
203 const catalog = await read($, catalogAtom)
204 const width = Math.max(28, Math.min(CALLOUT_WIDTH, (e.viewport?.columns ?? 80) - 2))
205 const inner = width - 4
206
207 const chip = (key: string, text: string, isMain: boolean, onPress: () => Promise<unknown>) => (
208 <Button key={key} plain onPress={() => quietly(onPress())}>
209 <Text backgroundColor={isMain ? COLORS.gold : COLORS.line} color={isMain ? COLORS.ink : COLORS.text} bold={isMain}>
210 {` ${fit(text, inner - 2)} `}
211 </Text>
212 </Button>
213 )
214 const heading = (key: string, text: string) => (
215 <Box key={key} marginTop={1}>
216 <Text color={COLORS.muted} bold>{fit(text.toUpperCase(), inner)}</Text>
217 </Box>
218 )
219
220 const body = () => {
221 if (catalog === null) return [<Text key="loading" color={COLORS.muted}>{fit('Looking for your mods…', inner)}</Text>]
222
223 const modLine = (mod: ModInfo) => {
224 const hasOpen = mod.commands.length > 0
225 return (
226 <Box key={`mod-${mod.name}`} flexDirection="row" justifyContent="space-between" width={inner}>
227 <Text bold color={COLORS.text}>{fit(displayName(mod.name), inner - (hasOpen ? 16 : 9))}</Text>
228 <Box flexDirection="row" columnGap={1}>
229 {hasOpen && chip(`open-${mod.name}`, 'Open', false, () => runCommand($, mod.commands[0] ?? ''))}
230 {chip(`change-${mod.name}`, 'Change', false, () => fillPrompt($, changeRequest(mod)))}
231 </Box>
232 </Box>
233 )
234 }
235 const own = catalog.mods.filter(mod => mod.kind === 'dev')
236 const installed = catalog.mods.filter(mod => mod.kind === 'installed')
237 const tools = [...toolsIn('claude', catalog), ...toolsIn('plugins', catalog)]
238
239 return [
240 heading('own-label', 'Your mods'),
241 ...(own.length === 0 ? [<Text key="no-mods" color={COLORS.muted}>{fit('None yet.', inner)}</Text>] : own.map(modLine)),
242 <Box key="new-mod-row">{chip('new-mod', 'New mod', true, () => fillPrompt($, NEW_MOD_REQUEST))}</Box>,
243 ...(installed.length > 0 ? [heading('installed-label', 'Installed mods'), ...installed.map(modLine)] : []),
244 ...(tools.length > 0
245 ? [
246 heading('tools-label', 'Shortcuts'),
247 <Box key="tools" flexDirection="row" flexWrap="wrap" columnGap={1} width={inner}>
248 {tools.map(tool => chip(`tool-${tool.key}`, `/${tool.command}`, false, () => useTool($, tool)))}
249 </Box>,
250 ]
251 : []),
252 ]
253 }
254
255 // A spacer pushes the callout to the right end, over the button on the hint line below.
256 return (
257 <Box flexDirection="row">
258 <Box key="menu-spacer" flexGrow={1} />
259 <Box
260 key="menu"
261 flexDirection="column"
262 width={width}
263 borderStyle="round"
264 borderColor={COLORS.gold}
265 paddingX={1}
266 >
267 <Box key="menu-header" flexDirection="row" justifyContent="space-between" width={inner}>
268 <Text bold color={COLORS.gold}>{fit(TITLE, inner - 18)}</Text>
269 <Box flexDirection="row" columnGap={1}>
270 {chip('full-view', 'Full', false, () => leaveFor($, () => openToolbox($)))}
271 {chip('close-menu', 'Close', false, () => update($, isOpenAtom, () => false))}
272 </Box>
273 </Box>
274 {body()}
275 </Box>
276 </Box>
277 )
278 })
279
280 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
281 const { Box, Button, Text } = $.ui.resolve(e)
282 const catalog = await read($, catalogAtom)
283 const cols = Math.max(20, Math.floor(e.props.bodyColumns))
284
285 const headingRow = (label: string) => {
286 const text = fit(label.toUpperCase(), cols)
287 const rest = cols - text.length - 1
288 return (
289 <Box marginTop={1}>
290 <Text wrap="truncate">
291 <Text color={COLORS.muted} bold>{text}</Text>
292 <Text color={COLORS.line}>{rest > 0 ? ` ${'─'.repeat(rest)}` : ''}</Text>
293 </Text>
294 </Box>
295 )
296 }
297
298 const chip = (key: string, label: string, isMain: boolean, onPress: () => Promise<unknown>) => (
299 <Button key={key} plain onPress={() => quietly(onPress())}>
300 <Text backgroundColor={isMain ? COLORS.gold : COLORS.line} color={isMain ? COLORS.ink : COLORS.text} bold={isMain}>
301 {` ${label} `}
302 </Text>
303 </Button>
304 )
305
306 const header = (
307 <Box flexDirection="column" width={cols}>
308 <Box flexDirection="row" justifyContent="space-between" width={cols}>
309 <Text bold color={COLORS.text}>{fit(TITLE, cols)}</Text>
310 {catalog !== null && TITLE.length + 10 <= cols && (
311 <Text color={COLORS.muted}>{`${catalog.mods.length} ${catalog.mods.length === 1 ? 'mod' : 'mods'}`}</Text>
312 )}
313 </Box>
314 <Text wrap="truncate">
315 <Text color={COLORS.gold}>{'━'.repeat(Math.min(TITLE.length, cols))}</Text>
316 <Text color={COLORS.line}>{'─'.repeat(Math.max(0, cols - TITLE.length))}</Text>
317 </Text>
318 </Box>
319 )
320
321 if (catalog === null) {
322 return (
323 <Box flexDirection="column" width={cols}>
324 {header}
325 <Text color={COLORS.muted}>{fit('Looking for your mods…', cols)}</Text>
326 </Box>
327 )
328 }
329
330 const toolRows = (tools: Tool[]) => {
331 const labelWidth = Math.max(...tools.map(tool => tool.command.length + 1)) + 2
332 return tools.map(tool => {
333 const label = `/${tool.command}`
334 const room = cols - labelWidth - 1
335 return (
336 <Button key={`tool-${tool.key}`} plain onPress={() => quietly(useTool($, tool))}>
337 <Text backgroundColor={COLORS.line} color={COLORS.text}>{` ${fit(label, cols - 2)} `.padEnd(Math.min(labelWidth, cols))}</Text>
338 {room >= 8 && <Text color={COLORS.muted}>{` ${fit(tool.description, room)}`}</Text>}
339 </Button>
340 )
341 })
342 }
343
344 const modBlock = (mod: ModInfo) => {
345 const hasOpen = mod.commands.length > 0
346 const buttonsWidth = (hasOpen ? 7 : 0) + 8
347 const isStacked = cols - buttonsWidth - 1 < 12
348 const nameRoom = isStacked ? cols : cols - buttonsWidth - 1
349 const buttons = (
350 <Box flexDirection="row" columnGap={1}>
351 {hasOpen && chip(`open-${mod.name}`, 'Open', false, () => runCommand($, mod.commands[0] ?? ''))}
352 {chip(`change-${mod.name}`, 'Change', false, () => fillPrompt($, changeRequest(mod)))}
353 </Box>
354 )
355 return (
356 <Box key={`mod-${mod.name}`} flexDirection="column" width={cols}>
357 <Box flexDirection="row" justifyContent="space-between" width={cols}>
358 <Text bold color={COLORS.text}>{fit(displayName(mod.name), nameRoom)}</Text>
359 {!isStacked && buttons}
360 </Box>
361 {mod.description !== '' && <Text color={COLORS.muted}>{fit(mod.description, cols)}</Text>}
362 {hasOpen && <Text color={COLORS.muted}>{fit(mod.commands.map(name => `/${name}`).join(' '), cols)}</Text>}
363 {isStacked && buttons}
364 </Box>
365 )
366 }
367
368 const own = catalog.mods.filter(mod => mod.kind === 'dev')
369 const installed = catalog.mods.filter(mod => mod.kind === 'installed')
370
371 const claudeTools = toolsIn('claude', catalog)
372 const pluginTools = toolsIn('plugins', catalog)
373
374 return (
375 <Box flexDirection="column" width={cols}>
376 {header}
377
378 {headingRow('Your mods')}
379 {own.length === 0 && <Text color={COLORS.muted}>{fit('No other mods in your mods folder yet.', cols)}</Text>}
380 {own.map(modBlock)}
381 <Box marginTop={1}>{chip('new-mod', 'New mod', true, () => fillPrompt($, NEW_MOD_REQUEST))}</Box>
382
383 {installed.length > 0 && headingRow('Installed mods')}
384 {installed.map(modBlock)}
385
386 {claudeTools.length > 0 && headingRow('Change Claude Code')}
387 {claudeTools.length > 0 && <Box flexDirection="column">{toolRows(claudeTools)}</Box>}
388
389 {pluginTools.length > 0 && headingRow('Plugin tools')}
390 {pluginTools.length > 0 && <Box flexDirection="column">{toolRows(pluginTools)}</Box>}
391
392 <Box marginTop={1} flexDirection="row" columnGap={2}>
393 <Button key="refresh" plain dimColor onPress={() => quietly(refresh($))}>
394 <Text color={COLORS.muted}>Refresh</Text>
395 </Button>
396 {cols >= 40 && <Text color={COLORS.muted}>{fit('Opens commands as if you typed them.', cols - 10)}</Text>}
397 </Box>
398 </Box>
399 )
400 })
401}
402hooks/tools.ts 141 lines1import type { Catalog, ModInfo } from '../types'
2
3export type Tool = {
4 key: string
5 /** The command without the slash; for a plugin skill, its full `plugin:skill` name. */
6 command: string
7 description: string
8 /** `run` runs the command at once; `fill` puts it in the prompt box for you to finish. */
9 action: 'run' | 'fill'
10 section: 'claude' | 'plugins'
11}
12
13/** The commands that change Claude Code itself, each shown only where this version has it. */
14export const TOOLS: readonly Tool[] = [
15 { key: 'plugin', command: 'plugin', description: 'Install, enable or remove plugins', action: 'run', section: 'claude' },
16 { key: 'reload-plugins', command: 'reload-plugins', description: 'Pick up plugin changes now', action: 'run', section: 'claude' },
17 { key: 'config', command: 'config', description: 'Open settings', action: 'run', section: 'claude' },
18 { key: 'theme', command: 'theme', description: 'Change the theme', action: 'run', section: 'claude' },
19 { key: 'color', command: 'color', description: 'Colour this prompt bar', action: 'run', section: 'claude' },
20 { key: 'statusline', command: 'statusline', description: 'Set up the status line', action: 'fill', section: 'claude' },
21 { key: 'output-style', command: 'output-style', description: 'Switch the response style', action: 'run', section: 'claude' },
22 { key: 'keybindings', command: 'keybindings', description: 'Edit keyboard shortcuts', action: 'run', section: 'claude' },
23 { key: 'hooks', command: 'hooks', description: 'See automatic actions', action: 'run', section: 'claude' },
24 { key: 'permissions', command: 'permissions', description: 'Allow and deny rules', action: 'run', section: 'claude' },
25 { key: 'memory', command: 'memory', description: 'Edit CLAUDE.md instructions', action: 'run', section: 'claude' },
26 { key: 'mcp', command: 'mcp', description: 'Manage connectors', action: 'run', section: 'claude' },
27 { key: 'skills', command: 'skills', description: 'List your skills', action: 'run', section: 'claude' },
28 { key: 'reload-skills', command: 'reload-skills', description: 'Pick up skill changes now', action: 'run', section: 'claude' },
29 { key: 'tui', command: 'tui', description: 'Switch the screen layout', action: 'run', section: 'claude' },
30 { key: 'skin-designer', command: 'skins:skin-designer', description: 'Restyle the transcript', action: 'fill', section: 'plugins' },
31 { key: 'update-config', command: 'update-config', description: 'Change settings by asking', action: 'fill', section: 'plugins' },
32 { key: 'keybindings-help', command: 'keybindings-help', description: 'Rebind keys by asking', action: 'fill', section: 'plugins' },
33 { key: 'revise-claude-md', command: 'claude-md-management:revise-claude-md', description: 'Fold lessons into CLAUDE.md', action: 'fill', section: 'plugins' },
34 { key: 'fewer-prompts', command: 'fewer-permission-prompts', description: 'Cut repeat permission prompts', action: 'fill', section: 'plugins' },
35]
36
37export function isAvailable(tool: Tool, catalog: Catalog): boolean {
38 return catalog.available.includes(tool.command)
39}
40
41export function toolsIn(section: Tool['section'], catalog: Catalog): Tool[] {
42 return TOOLS.filter(tool => tool.section === section && isAvailable(tool, catalog))
43}
44
45/** The folder that holds this mod's folder: where the other mods live. */
46export function parentOf(path: string): string {
47 const trimmed = path.replace(/[\\/]+$/, '')
48 const cut = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
49 return cut <= 0 ? trimmed : trimmed.slice(0, cut)
50}
51
52export function joinPath(folder: string, ...parts: string[]): string {
53 const separator = folder.includes('\\') && !folder.includes('/') ? '\\' : '/'
54 return [folder.replace(/[\\/]+$/, ''), ...parts].join(separator)
55}
56
57/** A mod's folder name as people read it: `work-report` shows as `Work Report`. */
58export function displayName(name: string): string {
59 return name
60 .split(/[-_]+/)
61 .filter(word => word !== '')
62 .map(word => word[0]!.toUpperCase() + word.slice(1))
63 .join(' ')
64}
65
66/** Reads a plugin.json's text into its name and description, or undefined when it is not one. */
67export function readManifest(text: string): { name: string; description: string } | undefined {
68 try {
69 const parsed = JSON.parse(text) as { name?: unknown; description?: unknown }
70 if (typeof parsed.name !== 'string' || parsed.name === '') return undefined
71 return { name: parsed.name, description: typeof parsed.description === 'string' ? parsed.description : '' }
72 } catch {
73 return undefined
74 }
75}
76
77/** The `.claude` folder this mod sits under, where Claude Code keeps its plugins and settings. */
78export function claudeHome(root: string): string | undefined {
79 const parts = root.split(/[\\/]/)
80 const at = parts.lastIndexOf('.claude')
81 if (at < 0) return undefined
82 return parts.slice(0, at + 1).join(root.includes('\\') && !root.includes('/') ? '\\' : '/')
83}
84
85export type InstalledPlugin = { name: string; marketplace: string; folder: string }
86
87/**
88 * Reads installed_plugins.json into the plugins installed from a marketplace,
89 * leaving out any that settings.json's `enabledPlugins` turns off.
90 */
91export function readInstalled(installedText: string, settingsText?: string): InstalledPlugin[] {
92 let enabled: Record<string, unknown> = {}
93 try {
94 const settings = JSON.parse(settingsText ?? '{}') as { enabledPlugins?: unknown }
95 if (typeof settings.enabledPlugins === 'object' && settings.enabledPlugins !== null) {
96 enabled = settings.enabledPlugins as Record<string, unknown>
97 }
98 } catch {
99 // Unreadable settings turn nothing off.
100 }
101 try {
102 const parsed = JSON.parse(installedText) as { plugins?: Record<string, { installPath?: unknown }[]> }
103 const found: InstalledPlugin[] = []
104 for (const [key, installs] of Object.entries(parsed.plugins ?? {})) {
105 const at = key.lastIndexOf('@')
106 const folder = installs?.[0]?.installPath
107 if (at <= 0 || typeof folder !== 'string' || enabled[key] === false) continue
108 found.push({ name: key.slice(0, at), marketplace: key.slice(at + 1), folder })
109 }
110 return found
111 } catch {
112 return []
113 }
114}
115
116/** Whether a hooks.json's text loads hooks modules: what makes a plugin a mod. */
117export function isModHooks(text: string): boolean {
118 try {
119 const parsed = JSON.parse(text) as { modules?: unknown }
120 return Array.isArray(parsed.modules) && parsed.modules.length > 0
121 } catch {
122 return false
123 }
124}
125
126export function changeRequest(mod: ModInfo): string {
127 if (mod.kind === 'installed') {
128 return (
129 `Change my installed ${mod.name} mod (from the ${mod.marketplace ?? 'unknown'} marketplace, its folder is ${mod.folder}). ` +
130 'Updates from the marketplace would overwrite edits there, so make the change as my own copy in my mods folder: '
131 )
132 }
133 return `Change my ${mod.name} mod (its folder is ${mod.folder}): `
134}
135
136export const NEW_MOD_REQUEST = 'Design and make me a new mod: '
137
138export function fillText(tool: Tool): string {
139 return `/${tool.command} `
140}
141types/index.d.ts 30 lines1export type ModInfo = {
2 /** The name in the mod's plugin.json. */
3 name: string
4 description: string
5 /** The mod's folder, absolute. */
6 folder: string
7 /** The slash commands the mod registered, without the slash. */
8 commands: string[]
9 /** `dev`: a mod in your mods folder, changed in place. `installed`: one installed from a marketplace. */
10 kind: 'dev' | 'installed'
11 /** For an installed mod, the marketplace it came from. */
12 marketplace?: string
13}
14
15export type Catalog = {
16 mods: ModInfo[]
17 /** Every command name the session offers right now, without the slash. */
18 available: string[]
19}
20
21declare module 'claude-code' {
22 interface PluginState {
23 'roberts-toolbox': {
24 catalog: Catalog | null
25 /** True from a click on the Robert's Toolbox button until a pick, another click or a prompt. */
26 isOpen: boolean
27 }
28 }
29}
30