Pick which claude-mods you want: install, turn on or off, update, or remove each mod from one panel (/mods), with presets. No need to install everything.

Panels, guardrails and quality-of-life mods for Claude Code: see what fills your context, how fast you burn your plan, and what the agent is running right now, and block the commands you never want run.
<img src="docs/demo-guardrails.gif" alt="guardrails blocking an agent's oc delete all --all before it runs" width="900">
<sub>If a mod saves you from one bad approve, a ⭐ on this repo helps other people find it.</sub>
Claude Code is a black box while it works. You can't see the context window filling up until it compacts away the decision you made an hour ago. You find out you've burned your 5-hour limit when it stops answering. You approve a command, tab away, and come back ten minutes later to find it has been waiting on a second approval the whole time. And most people discover /rewind only after the edit they wanted to undo.
claude-mods is a set of small plugins that open that box. Each mod is a standalone Claude Code plugin built on function hooks, the in-process plugin API: it sees every prompt, tool call and turn as it happens, and it can draw panels, toasts and status lines inside Claude Code itself. Install all eleven or only the ones you want.
They are deliberately boring about tokens. Nine of the eleven never call a model. The two that do (prompt-coach's review and context-keeper's handoff note) say so and are easy to turn off.
| 🧩 mod-manager | One panel (/mods) to install, turn on or off, update, or remove each mod, with presets (Safety only, Essentials, Zero tokens, Everything). Shows when a mod has a newer version. Install this first and pick the rest |
| 🚦 quickbar | One line above the prompt: live context % and a button for every claude-mods panel you have installed. Start here |
| 🧠 context-keeper | What fills your context window, tips to trim it, and checkpoints: a handoff note (goal, decisions, files, TODOs) saved to .claude/checkpoints/ so /clear and /compact stop losing the thread. Archives the raw transcript before every compaction, for zero tokens |
| 📈 usage-meter | 5-hour and 7-day plan-limit bars with reset countdown, burn rate and "full in ~2.3h", session cost, cache hit ratio (and why it's low), tokens-per-turn sparkline (drawn as charts in the desktop app) |
| 🔔 notify | Notification inbox plus native OS notifications (Windows, macOS, Linux) when a long turn ends, a subagent or background task finishes, Claude asks you something, or an approval has been waiting 15s |
| 👀 activity | What Claude is doing this second: thinking, running $ npm test for 0m42s, waiting for YOUR approval, which subagents run, and its todo plan with progress |
| 🛡️ guardrails | Clickable safety rules with presets (Safe defaults, Locked to project, Read-only review): block rm -rf, mass kubectl/oc deletes, terraform destroy, force-push, destructive git, .env and key files, sudo, installs, network; keep Claude inside the project; add your own patterns. Checks scripts the agent wrote before they run, and keeps a weekly log of what it blocked |
| ✍️ prompt-coach | Before a vague prompt is sent, a small fast model suggests a sharper one. You pick Send improved / Send mine / Edit. Never rewrites silently, never touches "yes" or "continue" |
| 🧰 toolbox | Every tool Claude can use (built-in and MCP, grouped by server) in plain language, how often each was used, and suggestions for the project you're in |
| ⌨️ command-hub | The built-in commands you're probably missing, each with when to use it; a searchable list of everything installed; a form to create your own slash command; tips when your prompt matches one ("undo that" → /rewind) |
| 📝 changes | Every file Claude created or edited this session with +/- counts from git, plus one-click why?, summarize all and self-review |
| 🔁 loop-breaker | Notices Claude going in circles (the same command failing 3×, the same code rewritten 3× in a turn), tells you, and tells Claude to stop and rethink |
activity: what Claude is running right now, and the moment it's waiting on you
<img src="docs/demo-activity.gif" alt="activity panel showing thinking, waiting for approval, then completed tool calls" width="900">
context-keeper + usage-meter: what fills the window, a checkpoint saved to disk, plan limits
<img src="docs/demo-context.gif" alt="context panel, writing a checkpoint, then the usage meter" width="900">
prompt-coach: a vague prompt, a suggested rewrite, you choose
<img src="docs/demo-coach.gif" alt="prompt coach suggesting a sharper prompt and sending it" width="900">
command-hub: the built-in commands worth knowing, explained
<img src="docs/demo-commands.gif" alt="command hub listing essential slash commands" width="900">
You ──prompt──▶ ┌──────────────────────────────┐
│ Claude Code engine │
│ │
│ prompt.submit tool.call │
│ turn.start turn.complete│
│ session.compact ui.render │
└──────┬─────────────────▲──────┘
events, in │ │ allow · deny · rewrite
order, live ▼ │ panes · toasts · status
┌──────────────────────────────┐
│ claude-mods (one plugin each)│
│ │
│ guardrails ── may deny ──────┤
│ prompt-coach ─ may ask you ──┤
│ activity · changes · notify │
│ usage-meter · context-keeper │──▶ .claude/checkpoints/
│ toolbox · command-hub ... │──▶ OS notifications
└──────────────────────────────┘
What a mod sees. Each mod registers hooks on engine events. A tool.call hook sits in front of every tool the agent runs (Bash, Edit, MCP tools, subagents) and sees its real arguments before anything executes. That is how activity can show the exact command and guardrails can refuse it.
What it can change. A hook either passes the event on, rewrites it, or answers it itself. Guardrails answers with a denial the model reads ("blocked by the user's guardrails, don't work around it"). Prompt-coach never rewrites on its own: it asks you first, in Claude Code's own question dialog.
What it draws. Panels are ui.render hooks returning a small element tree (Box, Text, Button, Input) that Claude Code draws natively in the terminal and the desktop app. State lives in the engine ($.state), so panels survive a hot reload and redraw only when their data changes.
What it costs. Nothing, for nine of the mods. They read numbers the engine already has ($.session.usage(), $.tool.list(), $.command.list()). The two model calls are opt-out and listed under Honest limits.
Prerequisites: Claude Code 2.1.286 or newer (claude update). Panels dock beside the chat in the desktop app or a terminal ≥ 144 columns wide, and open inline in narrower terminals.
From any terminal:
claude plugin marketplace add mishgoldenberg/claude-mods
claude plugin install mod-manager@claude-mods
(In the Claude Code CLI you can also use /plugin marketplace add mishgoldenberg/claude-mods and the /plugin menu.)
Restart Claude Code. The first session says hello once ("claude-mods ready: /mods to pick your set"). Type /mods: the manager lists every mod with what it does, whether it uses tokens, and whether an update is available. Install, turn on or off, update, or remove each one with a click, or apply a preset:
| Preset | What you get |
|---|---|
| Safety only | guardrails, notify |
| Essentials | guardrails, activity, notify, context-keeper, usage-meter |
| Zero tokens | every mod that never calls a model |
| Everything | all eleven |
Presets turn off what they don't list and never remove anything. Changes apply in your next session.
With quickbar installed, a one-line bar sits above the prompt: live context % and a button for every installed panel. /quickbar (or its ×) hides it; /quickbar again brings it back.
Or just ask Claude: "Install the claude-mods plugins from github.com/mishgoldenberg/claude-mods. Follow its INSTALL-FOR-CLAUDE.md."
The VS Code extension has no /plugin menu, so install from a terminal with the two commands in step 1, then use /mods inside VS Code. Mods need the extension to run Claude Code 2.1.286 or newer.
for m in quickbar context-keeper usage-meter notify activity guardrails prompt-coach toolbox command-hub changes loop-breaker; do
claude plugin install "$m@claude-mods"
done
git clone https://github.com/mishgoldenberg/claude-mods.git
claude --plugin-dir claude-mods/plugins/activity --plugin-dir claude-mods/plugins/guardrails
| Command | Mod | What it does | |||
|---|---|---|---|---|---|
/mods | mod-manager | Install, turn on or off, update, or remove mods; apply a preset | |||
/quickbar | quickbar | Show or hide the launcher above the prompt | |||
/ctx | context-keeper | Open the context panel | |||
/checkpoint | context-keeper | Save a handoff note of this session now | |||
/resume-checkpoint | context-keeper | Load the latest checkpoint into the prompt box (use after /clear) | |||
/meter | usage-meter | Open plan limits, burn rate and cost | |||
/notifications | notify | Open the notification inbox | |||
| `/mute [on\ | off]` | notify | Mute or unmute notifications | ||
/activity | activity | Open the live activity panel | |||
/guard | guardrails | Open the rules panel | |||
| `/guard-preset <safe\ | locked\ | review\ | off>` | guardrails | Apply a preset |
/guard-log [json] | guardrails | What guardrails blocked in the last 7 days | |||
| `/coach [on\ | off]` | prompt-coach | Turn the prompt coach on or off | ||
/tools | toolbox | Open the tools panel and project suggestions | |||
/cmds | command-hub | Open the command hub | |||
/new-command | command-hub | Create your own slash command | |||
/changes | changes | Open the changed-files panel |
Options appear in /config once a mod is installed, or go in ~/.claude/settings.json under pluginConfigs.<mod>.
| Option | Default | Description |
|---|---|---|
warnAt | 70 | Toast a tip when context passes this % |
checkpointAt | 85 | Write a handoff note automatically at this % (0 = off) |
| Option | Default | Description |
|---|---|---|
warnAt | 80 | Toast when a plan window passes this % (a second toast always fires at 95%) |
| Option | Default | Description |
|---|---|---|
minSeconds | 30 | Only notify for turns at least this long |
osNotify | true | Also raise a native OS notification |
approvalWaitSeconds | 15 | Ping when an approval has waited this long (-1 = off) |
| Mod | Option | Default | Description |
|---|---|---|---|
| activity | autoOpen | false | Dock the panel on session start when there is room |
| prompt-coach | enabled | true | Review prompts before sending |
| prompt-coach | model | claude-haiku-4-5 | Reviewer model; small keeps the delay near a second |
| prompt-coach | minChars | 8 | Never review prompts shorter than this |
| loop-breaker | failLimit | 3 | Same failing command this many times in a row |
| loop-breaker | editLimit | 3 | Times the same code is rewritten in one turn (separate edits to different parts of a file never count) |
| Rule | Blocks | In preset |
|---|---|---|
no-rm-rf | rm -rf, rm -fr, Remove-Item -Recurse -Force, rmdir /s | Safe · Locked |
no-mass-delete | kubectl/oc delete --all or -A, delete namespace/project, helm uninstall, terraform destroy | Safe · Locked · Review |
no-force-push | git push --force / -f (--force-with-lease allowed) | Safe · Locked |
no-history-rewrite | git reset --hard, git clean -f, git checkout -- ., branch -D, stash drop, fetch/pull --prune, remote prune | Safe · Locked |
protect-secrets | reading or writing .env*, *.pem, *.key, id_rsa, credentials files | Safe · Locked · Review |
no-sudo | sudo, su -, runas | Safe · Locked · Review |
no-installs | npm i, pip install, cargo add, brew/apt/winget install … | Review |
no-network | WebFetch, WebSearch, curl, wget, Invoke-WebRequest | — |
jail-writes | Write/Edit outside the project folder | — |
jail-all | any file tool outside the project, cd out of it | Locked |
read-only | all edits; shell limited to look-only commands | Review |
Guardrails turns on Safe defaults the first time it loads. /guard-preset off turns everything off.
Whatever the preset, while any rule is on:
Write in this session (and edits after that) is remembered, and when a shell command runs it (bash cleanup.sh, python tools/x.py, ./run, node a.mjs), its code is checked against your enabled rules and patterns first. os.system("rm -rf …") inside a Python file counts.Write/Edit on them, and the obvious shell writes (sed -i, >, tee, cp, mv, rm), are denied with the same "ask the user" message./guard-log prints a summary (/guard-log json for the raw list), and clear wipes it. Commands are cut to 200 characters.Mods run inside Claude Code with your permissions and no sandbox, so this is the right question to ask about any mod, including these. What these ones do:
git diff --numstat (changes), the claude plugin CLI when you open /mods or click in it (mod-manager; only Install, Update and Check for updates go online, through Claude Code's own plugin installer), your OS notification tool (notify), and gh --version to see whether the GitHub CLI exists (toolbox)..claude/checkpoints/ (context-keeper), and a new slash command file when you use the form (command-hub). Settings, and guardrails' 30-day block log, live in Claude Code's own plugin store under ~/.claude. Nothing else.Every mod is a few hundred lines of TypeScript in plugins/<mod>/hooks/register.tsx. Read the ones you install.
rm -rf doesn't block you, except when the text is handed to a shell (bash -c, | sh, powershell -Command), which is checked. For hard guarantees use Claude Code's permission rules and sandboxing; use guardrails to catch the honest mistakes./coach off turns it off.claude-mods/
├── .claude-plugin/
│ └── marketplace.json The marketplace: one entry per mod
├── plugins/
│ ├── activity/
│ │ ├── .claude-plugin/
│ │ │ └── plugin.json Name, description, userConfig options
│ │ ├── hooks/
│ │ │ ├── hooks.json Points at the hooks module
│ │ │ └── register.tsx The mod: hooks, commands, panel
│ │ ├── tests/
│ │ │ └── surfaces.test.tsx The panel draws on every surface (claude plugin test)
│ │ └── types/
│ │ └── index.d.ts Its $.state contract
│ ├── guardrails/ …same shape for every mod
│ └── …
├── docs/
│ └── design.md The shared design spec and kit every mod copies
├── tests/
│ ├── guardrails-rules.test.mjs Every rule against real commands
│ └── design-kit.test.mjs Every mod's kit matches docs/design.md
├── INSTALL-FOR-CLAUDE.md Steps Claude follows when asked to install
└── tsconfig.json Type-checks all mods against the engine API
Add a guardrail rule. Append an entry to RULES in plugins/guardrails/hooks/register.tsx (an id, a title, and a test that returns the offending text) and add cases to tests/guardrails-rules.test.mjs. The panel and presets pick it up automatically.
Add an essential command. One line in ESSENTIALS in plugins/command-hub/hooks/register.tsx. It only shows when that command exists in the user's Claude Code.
Write a new mod. Copy plugins/changes (a small mod with a panel) or plugins/loop-breaker (no UI), rename it everywhere, and add it to marketplace.json. Or ask Claude Code to "make a mod that …": it has a built-in skill for exactly this, with hot reload.
Issues and pull requests are welcome. Start with CONTRIBUTING.md, which has the mod checklist.
npm install
npm run typecheck
npm test
claude plugin validate plugins/<mod>
claude plugin test plugins/<mod>
Every mod follows one design spec, docs/design.md: theme colors only (Claude orange as the single accent), the same pane header, glyphs, hotkeys and empty states, so the set feels like one product in any theme.
Two house rules worth knowing before you write anything, because the validator enforces both:
$ must be top-level function declarations. A closure inside register that receives $ is rejected.$.state key is declared in the mod's types/index.d.ts, under the mod's name.Found a way around guardrails, or another security problem? Please report it privately; see SECURITY.md.
MIT © 2026 Michael Goldenberg
hooks/register.tsx 429 lines1import { atom, read, update } from 'claude-code'
2import type { ElementTable, EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { ModRow, ModStatus } from '../types'
5
6// ── claude-mods kit v1 (docs/design.md): identical in every mod ──
7const TONE = { accent: 'claude', ok: 'success', warn: 'warning', bad: 'error', dim: 'inactive' } as const
8const GLYPH = { on: '●', off: '○', warn: '▲', ok: '✓', fail: '✗' } as const
9type Kit = Pick<ElementTable, 'Box' | 'Text'>
10
11/** Pane header: state glyph, mod name, one-line live status. */
12function header({ Box, Text }: Kit, glyph: string, tone: string, name: string, status: string) {
13 return (
14 <Box gap={1}>
15 <Text color={tone}>{glyph}</Text>
16 <Text bold>{name}</Text>
17 <Text dimColor wrap="truncate-end">{status}</Text>
18 </Box>
19 )
20}
21
22/** A section: a dim label (with optional small controls beside it), then its rows. */
23function section({ Box, Text }: Kit, label: string, rows: RenderChildren, aside?: RenderChildren) {
24 return (
25 <Box flexDirection="column">
26 <Box gap={1}>
27 <Text dimColor>{label}</Text>
28 {aside}
29 </Box>
30 {rows}
31 </Box>
32 )
33}
34
35/** A number right-aligned in a fixed-width cell. */
36function num({ Box, Text }: Kit, value: string, width: number, color?: string) {
37 return (
38 <Box width={width} flexShrink={0} justifyContent="flex-end">
39 <Text color={color}>{value}</Text>
40 </Box>
41 )
42}
43
44/** Empty state: what will show up here, and how to get it. */
45function empty({ Text }: Kit, text: string) {
46 return <Text dimColor>{text}</Text>
47}
48// ── end kit ──
49
50const PANE = 'mod-manager'
51const MARKETPLACE = 'claude-mods'
52const rows = atom({ plugin: 'mod-manager', key: 'rows' } as const, [])
53const busy = atom({ plugin: 'mod-manager', key: 'busy' } as const, null)
54const message = atom({ plugin: 'mod-manager', key: 'message' } as const, '')
55const needsRestart = atom({ plugin: 'mod-manager', key: 'needsRestart' } as const, false)
56const self = atom({ plugin: 'mod-manager', key: 'self' } as const, null as ModRow | null)
57
58/** The mods this manager knows how to describe; anything else in the marketplace is listed with its own description. */
59const CATALOG: { name: string; blurb: string; usesTokens: boolean }[] = [
60 { name: 'guardrails', blurb: 'Safety rules and presets: block rm -rf, mass deletes, force-push, secrets, installs, network; or lock Claude to the project.', usesTokens: false },
61 { name: 'activity', blurb: 'Live panel: what Claude is running right now and what it is waiting on.', usesTokens: false },
62 { name: 'notify', blurb: 'OS notifications when a long task finishes or an approval is waiting.', usesTokens: false },
63 { name: 'context-keeper', blurb: 'What fills your context window, plus checkpoints that survive /compact.', usesTokens: true },
64 { name: 'usage-meter', blurb: '5h/7d plan limits with burn rate and cache hit ratio.', usesTokens: false },
65 { name: 'prompt-coach', blurb: 'Suggests a sharper prompt before sending; you choose which one goes.', usesTokens: true },
66 { name: 'command-hub', blurb: 'The built-in slash commands explained, plus a form to make your own.', usesTokens: false },
67 { name: 'toolbox', blurb: 'Every tool Claude can use, how often it used them, and tips for this project.', usesTokens: false },
68 { name: 'changes', blurb: 'Files Claude changed this session with +/- counts, and one-click why, summary or self-review.', usesTokens: false },
69 { name: 'loop-breaker', blurb: 'Notices Claude going in circles (same failing command, same file patched again) and nudges it to rethink.', usesTokens: false },
70 { name: 'quickbar', blurb: 'A one-line launcher above the prompt: live context % and buttons for every claude-mods panel.', usesTokens: false },
71]
72
73const PRESETS: { id: string; label: string; mods: string[] }[] = [
74 { id: 'safety', label: 'Safety only', mods: ['guardrails', 'notify'] },
75 { id: 'essentials', label: 'Essentials', mods: ['guardrails', 'activity', 'notify', 'context-keeper', 'usage-meter'] },
76 { id: 'zero', label: 'Zero tokens', mods: CATALOG.filter(m => !m.usesTokens).map(m => m.name) },
77 { id: 'all', label: 'Everything', mods: CATALOG.map(m => m.name) },
78]
79
80let cliPath = 'claude'
81
82type Action = 'install' | 'enable' | 'disable' | 'uninstall' | 'update'
83
84type Listing = {
85 installed?: { id: string; version?: string; scope?: string; enabled?: boolean; projectPath?: string }[]
86 available?: { name: string; description?: string; marketplaceName?: string }[]
87}
88
89/** Runs `claude plugin …`; through cmd on Windows so an npm-installed claude.cmd resolves too. */
90async function cli($: EngineInterface, args: string[], timeoutMs = 120000, cwd?: string) {
91 const argv = (await $.env.get('OS')) === 'Windows_NT' ? ['cmd', '/d', '/c', cliPath, ...args] : [cliPath, ...args]
92
93 return $.process.run(argv, cwd === undefined ? { timeoutMs } : { timeoutMs, cwd })
94}
95
96const normPath = (path: string) => path.replace(/[\\/]+/g, '/').replace(/\/$/, '').toLowerCase()
97
98/** True when `dir` is `root` or inside it. */
99const isWithin = (dir: string, root: string) => {
100 const d = normPath(dir)
101 const r = normPath(root)
102
103 return d === r || d.startsWith(`${r}/`)
104}
105
106/** True when version `a` is newer than `b` (dotted numbers; anything else compares as 0). */
107const isNewer = (a: string, b: string) => {
108 const pa = a.split('.').map(n => Number.parseInt(n, 10) || 0)
109 const pb = b.split('.').map(n => Number.parseInt(n, 10) || 0)
110 for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
111 const d = (pa[i] ?? 0) - (pb[i] ?? 0)
112 if (d !== 0) return d > 0
113 }
114
115 return false
116}
117
118/**
119 * The version each mod has in the marketplace, read from the marketplace's local copy, which is what
120 * `claude plugin update` installs. (`plugin list --available` lists only mods that aren't installed.)
121 */
122async function marketplaceVersions($: EngineInterface) {
123 const versions = new Map<string, string>()
124 try {
125 const isWindows = (await $.env.get('OS')) === 'Windows_NT'
126 const home = (isWindows ? await $.env.get('USERPROFILE') : undefined) ?? (await $.env.get('HOME')) ?? ''
127 const configDir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${home}/.claude`
128 const known = JSON.parse(await $.fs.read(`${configDir}/plugins/known_marketplaces.json`)) as Record<string, { installLocation?: string }>
129 const location = known[MARKETPLACE]?.installLocation
130 if (location === undefined) return versions
131 const manifest = JSON.parse(await $.fs.read(`${location}/.claude-plugin/marketplace.json`)) as { plugins?: { name: string; source?: unknown; version?: string }[] }
132 for (const entry of manifest.plugins ?? []) {
133 if (typeof entry.version === 'string') {
134 versions.set(entry.name, entry.version)
135 continue
136 }
137 if (typeof entry.source !== 'string') continue
138 const plugin = JSON.parse(await $.fs.read(`${location}/${entry.source}/.claude-plugin/plugin.json`).catch(() => '{}')) as { version?: unknown }
139 if (typeof plugin.version === 'string') versions.set(entry.name, plugin.version)
140 }
141 } catch (error) {
142 $.ui.log(`mod-manager: could not read marketplace versions: ${String(error)}`, { to: 'debug' })
143 }
144
145 return versions
146}
147
148async function readListing($: EngineInterface, cwd?: string) {
149 const result = await cli($, ['plugin', 'list', '--available', '--json'], 60000, cwd)
150 if (result.exitCode !== 0) throw new Error(result.stderr.trim() || `exit ${result.exitCode}`)
151
152 return JSON.parse(result.stdout) as Listing
153}
154
155/** Reads what is installed and what the marketplace offers, and merges it with the catalog. */
156async function refresh($: EngineInterface) {
157 let listing: Listing
158 // Local and project installs belong to the folder they were made in, and the CLI only sees them
159 // from there. A session in a subfolder asks from the project folder instead.
160 let root: string | undefined
161 try {
162 const here = await $.session.cwd()
163 listing = await readListing($)
164 root = (listing.installed ?? [])
165 .map(p => p.projectPath)
166 .filter((path): path is string => path !== undefined && path !== '' && isWithin(here, path))
167 .sort((a, b) => b.length - a.length)[0]
168 if (root !== undefined && normPath(root) !== normPath(here)) listing = await readListing($, root)
169 } catch (error) {
170 await update($, message, () => `Couldn't run "${cliPath} plugin list" (${String(error).slice(0, 160)}). Set the Claude Code command in /config if claude isn't on your PATH.`)
171 return
172 }
173
174 const mine = (listing.installed ?? []).filter(p => p.id.endsWith(`@${MARKETPLACE}`))
175 // Another project's local install is not this session's; leave it out.
176 const visible = mine.filter(p => p.projectPath === undefined || p.projectPath === '' || (root !== undefined && normPath(p.projectPath) === normPath(root)))
177 const installed = new Map(visible.map(p => [p.id.split('@')[0] ?? '', p]))
178 const offered = new Map((listing.available ?? []).filter(p => p.marketplaceName === MARKETPLACE).map(p => [p.name, p]))
179 if (installed.size === 0 && offered.size === 0) {
180 await update($, message, () => `The ${MARKETPLACE} marketplace isn't added yet. Run: ${cliPath} plugin marketplace add mishgoldenberg/claude-mods`)
181 }
182
183 const latest = await marketplaceVersions($)
184 const newer = (name: string, version: string | undefined) => {
185 const v = latest.get(name)
186
187 return v !== undefined && version !== undefined && isNewer(v, version) ? v : undefined
188 }
189 const manager = installed.get('mod-manager')
190 await update($, self, (): ModRow | null =>
191 manager === undefined
192 ? null
193 : { name: 'mod-manager', blurb: '', usesTokens: false, status: manager.enabled === false ? 'off' : 'on', version: manager.version, latest: newer('mod-manager', manager.version), scope: manager.scope, projectPath: manager.projectPath || undefined },
194 )
195
196 const known = new Set(CATALOG.map(m => m.name))
197 const extra = [...new Set([...installed.keys(), ...offered.keys()])].filter(n => n !== 'mod-manager' && !known.has(n))
198 const next: ModRow[] = [
199 ...CATALOG,
200 ...extra.map(name => ({ name, blurb: offered.get(name)?.description ?? '', usesTokens: false })),
201 ].map(m => {
202 const own = installed.get(m.name)
203 const status: ModStatus = own === undefined ? 'available' : own.enabled === false ? 'off' : 'on'
204
205 return { ...m, status, version: own?.version, latest: newer(m.name, own?.version), scope: own?.scope, projectPath: own?.projectPath || undefined }
206 })
207 await update($, rows, () => next)
208}
209
210/** Installs, enables, disables, updates or removes one mod; returns false when the CLI refused. */
211async function change($: EngineInterface, row: ModRow, action: Action) {
212 const id = `${row.name}@${MARKETPLACE}`
213 const scope = row.scope !== undefined && action !== 'install' ? ['--scope', row.scope] : []
214 const cwd = action === 'install' ? undefined : row.projectPath
215 const result = await cli($, ['plugin', action, id, ...scope], 120000, cwd).catch((error: unknown) => ({ exitCode: 1, stdout: '', stderr: String(error) }))
216 if (result.exitCode !== 0) {
217 await update($, message, () => `${action} ${row.name} failed: ${(result.stderr || result.stdout).trim().split('\n').at(-1)?.slice(0, 200) ?? 'unknown error'}`)
218 return false
219 }
220
221 return true
222}
223
224async function act($: EngineInterface, row: ModRow, action: Action) {
225 if ((await read($, busy)) !== null) return
226 if (action === 'uninstall') {
227 const answer = await $.ui.ask(`Remove ${row.name}? Its settings stay saved; you can install it again any time.`, { header: 'Mods', options: ['Remove', 'Keep it'] }).catch(() => 'Keep it')
228 if (answer !== 'Remove') return
229 }
230 await update($, busy, () => row.name)
231 await update($, message, () => '')
232 try {
233 if (await change($, row, action)) {
234 await update($, needsRestart, () => true)
235 const done = { uninstall: 'removed', install: 'installed', enable: 'turned on', disable: 'turned off', update: `updated to ${row.latest ?? 'the latest version'}` }[action]
236 $.ui.toast(`${GLYPH.ok} ${row.name}: ${done}. Applies in your next session.`)
237 }
238 await refresh($)
239 } finally {
240 await update($, busy, () => null)
241 }
242}
243
244/** Updates every mod that has a newer version in the marketplace. */
245async function updateAll($: EngineInterface) {
246 const outdated = [...(await read($, rows)), ...[await read($, self)].filter((r): r is ModRow => r !== null)].filter(r => r.latest !== undefined)
247 if (outdated.length === 0 || (await read($, busy)) !== null) return
248 await update($, busy, () => `${outdated.length} update${outdated.length === 1 ? '' : 's'}`)
249 await update($, message, () => '')
250 let changed = 0
251 try {
252 for (const row of outdated) if (await change($, row, 'update')) changed++
253 if (changed > 0) {
254 await update($, needsRestart, () => true)
255 $.ui.toast(`${GLYPH.ok} Updated ${changed} mod${changed === 1 ? '' : 's'}. Applies in your next session.`)
256 }
257 await refresh($)
258 } finally {
259 await update($, busy, () => null)
260 }
261}
262
263/** Fetches the marketplace's latest catalog, then re-reads what is installed, so "update available" is current. */
264async function checkForUpdates($: EngineInterface) {
265 if ((await read($, busy)) !== null) return
266 await update($, busy, () => 'checking for updates')
267 try {
268 const result = await cli($, ['plugin', 'marketplace', 'update', MARKETPLACE], 120000).catch((error: unknown) => ({ exitCode: 1, stdout: '', stderr: String(error) }))
269 if (result.exitCode !== 0) {
270 await update($, message, () => `Couldn't check for updates: ${(result.stderr || result.stdout).trim().split('\n').at(-1)?.slice(0, 200) ?? 'unknown error'}`)
271 }
272 await refresh($)
273 } finally {
274 await update($, busy, () => null)
275 }
276}
277
278/** Makes the installed set match a preset: installs or turns on what it lists, turns off the rest (nothing is removed). */
279async function applyPreset($: EngineInterface, presetId: string) {
280 const preset = PRESETS.find(p => p.id === presetId)
281 if (preset === undefined || (await read($, busy)) !== null) return
282 await update($, busy, () => preset.label)
283 await update($, message, () => '')
284 let changed = 0
285 try {
286 for (const row of await read($, rows)) {
287 const wanted = preset.mods.includes(row.name)
288 const action = wanted ? (row.status === 'available' ? 'install' : row.status === 'off' ? 'enable' : null) : row.status === 'on' ? 'disable' : null
289 if (action === null) continue
290 if (await change($, row, action)) changed++
291 }
292 if (changed > 0) {
293 await update($, needsRestart, () => true)
294 $.ui.toast(`${preset.label}: ${changed} change${changed === 1 ? '' : 's'}. Applies in your next session.`)
295 } else {
296 $.ui.toast(`${preset.label} is already what you have.`)
297 }
298 await refresh($)
299 } finally {
300 await update($, busy, () => null)
301 }
302}
303
304export const register: Register = (on, options) => {
305 cliPath = typeof options.cliPath === 'string' && options.cliPath.trim() !== '' ? options.cliPath.trim() : 'claude'
306
307 on('session.start', async ($, e, next) => {
308 await $.command.register({ name: 'mods', description: 'Choose which claude-mods to install and turn on' })
309 // One hello on the very first session after install; never again.
310 if ((await $.store.get('welcomed')) !== true) {
311 await $.store.set('welcomed', true)
312 $.ui.toast('claude-mods ready: /mods to pick your set. Useful? A star on GitHub helps others find it.', { timeoutMs: 10000 })
313 }
314
315 return next(e)
316 })
317
318 on('command.run', { command: 'mods' }, async $ => {
319 await $.ui.open({ id: PANE, title: 'Mods' })
320 // Offline: compares with the marketplace copy Claude Code keeps. Fetching news is the Check for updates button.
321 void refresh($)
322
323 return { text: 'Mod manager opened.' }
324 })
325
326 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
327 const { Box, Text, Button } = $.ui.resolve(e)
328 const kit = { Box, Text }
329 const list = await read($, rows)
330 const me = await read($, self)
331 const working = await read($, busy)
332 const note = await read($, message)
333 const restart = await read($, needsRestart)
334 const onCount = list.filter(r => r.status === 'on').length
335 const updates = [...list, ...(me !== null ? [me] : [])].filter(r => r.latest !== undefined).length
336 const icon: Record<ModStatus, string> = { on: GLYPH.on, off: GLYPH.off, available: GLYPH.off }
337 const color: Record<ModStatus, string> = { on: TONE.accent, off: TONE.dim, available: TONE.dim }
338 const status =
339 working !== null
340 ? `working on ${working}…`
341 : list.length === 0
342 ? 'reading what is installed…'
343 : `${onCount} on of ${list.length}${updates > 0 ? ` · ${updates} update${updates === 1 ? '' : 's'} available` : ''}`
344 const version = (r: ModRow) =>
345 r.version === undefined ? null : r.latest !== undefined ? <Text color={TONE.accent}>{`${r.version} → ${r.latest}`}</Text> : <Text dimColor>{r.version}</Text>
346
347 return (
348 <Box flexDirection="column" gap={1}>
349 <Box flexDirection="column">
350 {header(kit, note !== '' ? GLYPH.fail : updates > 0 ? GLYPH.warn : GLYPH.on, note !== '' ? TONE.bad : updates > 0 ? TONE.warn : TONE.accent, 'mod-manager', status)}
351 {restart && (
352 <Text color={TONE.warn}>
353 {GLYPH.warn} Changes apply in your next session (start a new chat or restart Claude Code).
354 </Text>
355 )}
356 {note !== '' && (
357 <Text color={TONE.bad}>
358 {GLYPH.fail} {note}
359 </Text>
360 )}
361 {list.length === 0 && note === '' && empty(kit, 'Asking Claude Code which mods are installed… this takes a few seconds.')}
362 </Box>
363
364 {updates > 0 && (
365 <Box gap={1}>
366 <Button key="update-all" hotkey="u" variant="primary" label={`Update ${updates === 1 ? '1 mod' : `all ${updates}`}`} onPress={() => void updateAll($)} />
367 <Text dimColor>Takes effect in your next session.</Text>
368 </Box>
369 )}
370
371 {section(
372 kit,
373 "Presets (turns off what isn't listed, removes nothing)",
374 <Box gap={1} flexWrap="wrap">
375 {PRESETS.map((p, i) => (
376 <Button key={`preset-${p.id}`} plain hotkey={String(i + 1)} label={p.label} onPress={() => void applyPreset($, p.id)} />
377 ))}
378 </Box>,
379 )}
380
381 {me !== null && me.latest !== undefined &&
382 section(
383 kit,
384 'This manager',
385 <Box gap={1}>
386 <Text color={TONE.accent}>{GLYPH.on}</Text>
387 <Text bold>mod-manager</Text>
388 {version(me)}
389 <Button key="update-mod-manager" plain label="Update" onPress={() => void act($, me, 'update')} />
390 </Box>,
391 )}
392
393 {list.length > 0 &&
394 section(
395 kit,
396 'Mods',
397 list.map(r => (
398 <Box flexDirection="column">
399 <Box gap={1}>
400 <Text color={color[r.status]}>{icon[r.status]}</Text>
401 <Text bold>{r.name}</Text>
402 {version(r)}
403 {r.usesTokens && <Text color={TONE.warn}>uses tokens</Text>}
404 {r.latest !== undefined && <Button key={`update-${r.name}`} plain label="Update" onPress={() => void act($, r, 'update')} />}
405 {r.status === 'available' && <Button key={`install-${r.name}`} plain label="Install" onPress={() => void act($, r, 'install')} />}
406 {r.status === 'off' && <Button key={`enable-${r.name}`} plain label="Turn on" onPress={() => void act($, r, 'enable')} />}
407 {r.status === 'on' && <Button key={`disable-${r.name}`} plain label="Turn off" onPress={() => void act($, r, 'disable')} />}
408 {r.status !== 'available' && <Button key={`remove-${r.name}`} plain label="Remove" onPress={() => void act($, r, 'uninstall')} />}
409 </Box>
410 {r.blurb !== '' && (
411 <Box paddingLeft={2}>
412 <Text dimColor wrap="truncate-end">
413 {r.blurb}
414 </Text>
415 </Box>
416 )}
417 </Box>
418 )),
419 )}
420
421 <Box gap={1}>
422 <Button key="refresh" hotkey="r" label="Refresh" onPress={() => void refresh($)} />
423 <Button key="check" label="Check for updates" onPress={() => void checkForUpdates($)} />
424 </Box>
425 </Box>
426 )
427 })
428}
429types/index.d.ts 28 lines1export type ModStatus = 'on' | 'off' | 'available'
2
3export type ModRow = {
4 name: string
5 blurb: string
6 usesTokens: boolean
7 status: ModStatus
8 version?: string
9 /** A newer version in the marketplace, when there is one. */
10 latest?: string
11 scope?: string
12 /** The folder a local or project install belongs to; the CLI is run from there. */
13 projectPath?: string
14}
15
16declare module 'claude-code' {
17 interface PluginState {
18 'mod-manager': {
19 rows: ModRow[]
20 busy: string | null
21 message: string
22 needsRestart: boolean
23 /** mod-manager's own install, shown only when it has an update. */
24 self: ModRow | null
25 }
26 }
27}
28