SLOPSHOPPER

roberts-toolbox

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

newpanebandspinnercommandprompt
v0.1.0MITupdated 2026-10-14hearsawho25/roberts-mods/roberts-toolbox
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · roberts-toolbox
│ ┃ roberts-toolbox ✕ › fix the failing auth test and add an audit log call │ ┃ ROBERT'S TOOLBOX │ ┃ ━━━━━━━━━━━━━━━━──────────────────────────── ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ YOUR MODS ────────────────────────────────── ⏺ Update(src/auth.ts) │ ┃ No other mods in your mods folder yet. ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ new-mod ⎿ 3 pass, 1 fail │ ┃ │ ┃ refresh Opens commands as if you typed them ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /toolbox │ ⎿ roberts-toolbox: Opening Robert's Toolbox. │ │ ╭──────────────────────────────────────────────╮ │ ROBERT'S TOOLBOX full-view close-menu │ │ │ │ YOUR MODS │ │ None yet. │ │ new-mod │ ╰──────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ⟨Claude Code's own drawing⟩open-toolbox

Draws

Band
╭──────────────────────────────────────────────╮ │ ROBERT'S TOOLBOX full-view close-menu │ │ │ │ YOUR MODS │ │ None yet. │ │ new-mod │ ╰──────────────────────────────────────────────╯
Pane · roberts-toolbox
ROBERT'S TOOLBOX 0 mods ━━━━━━━━━━━━━━━━──────────────────────────────────────── YOUR MODS ────────────────────────────────────────────── No other mods in your mods folder yet. new-mod refresh Opens commands as if you typed them.
Prompt hint
⟨Claude Code's own drawing⟩open-toolbox
README

roberts-mods

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.

Install

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.

The mods

ModCommandWhat it does
roberts-toolbox/toolboxOne 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/betterTurns 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/challengeOne small Claude Code exercise a day, with a streak and a year of challenges that never repeat.
work-report/reportCounts 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, /keepwarmA 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/shipRepo 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-doctorChecks 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.

Credits

Several of these started as other people's ideas, and they deserve the credit:

  • Clean View, Prompt Upgrade, Daily Challenge and Work Report were built from prompts by Tristen O'Brien (AI Made Easy). The ideas are Tristen's. These are my builds of them.
  • Robert's Toolbox is based on a toolbox Tristen O'Brien showed in a video (tristenobrien.ai).
  • Relay's price table is adapted from cache-tax by Karan Bansal (MIT License).

The mods themselves were built with Claude Code.

License

MIT.

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