Mod manager for Claude Code: audits every mod and every update before it runs, keeps your own changes across updates, and shows what each mod's code can really…

Loadout is a mod manager for Claude Code: install, update and manage your plugins and mods without running code nobody reviewed. Loadout puts an audit gate in front of every mod: a new mod, or a new version of one you already trust, stays blocked until Claude reviews its code and you approve it. It tells you when updates are out, audits them before they install, and keeps the changes you made yourself. All of it from one inventory panel inside Claude Code.
<img src="docs/inventory.svg" alt="Loadout inventory panel in Claude Code: a Legendary card in orange, an Epic card in purple, a Rare card in blue and a Poor (off) card in grey" width="600">
/plugin install loadout --marketplace Br4inergy/claude-loadout
Claude Code mods are code that runs on your machine with your permissions. They can run commands, write files, reach the network and send prompts to the model. An update can change any of that overnight. Loadout makes sure you know what you are running before it runs.
A mod whose name and version you have not approved never loads. Loadout shows it as held and offers Audit with Claude: Claude reads the mod's code and gives you a verdict with evidence. You approve it, or you leave it off. You can also choose Allow without audit, and every block is written to a log.
Loadout checks for new versions on its own: the marketplace version for a GitHub install, or the author's new commits for a copy you pinned. Audit & update has Claude review what changed before the new version installs. A new version is treated like a new mod, so a trusted plugin can't quietly turn into something else.
Tweaked a mod to fit your setup? Loadout backs up your changes and puts them back when the author's update lands. Pin turns a marketplace install into a git copy on your computer that you can edit, and Explain my changes has Claude walk you through what you changed.
Loadout reads each mod's code with claude plugin validate, not its description, and shows what it can do: +runs commands, +writes files, +network, +sends prompts, +calls the model, +adds tools, +watches tool calls, +edits the system prompt, +own panel. The risky ones are highlighted, and each card's rarity colour tells you at a glance how much power a mod has.
Every mod and plugin you have, in one panel: equip or unequip with one switch, see the version, where it came from and what it spent on the model this session, open its panel, or uninstall it. Mods that draw a band above the prompt show under Loadout's, and you can show or hide each one.
Loadout uses no tokens of its own. Audits and updates are Claude turns that start only when you press a button.
/plugin install loadout --marketplace Br4inergy/claude-loadout
Then press ⚙ Loadout in the band above the prompt, or type /loadout. It needs Claude Code 2.1.275 or later (mods with hooks modules).
~/.claude/loadout/approved.json and the mod loads.Each card shows only what the mod itself says: its name, the description from its own plugin.json and what its code can do. Loadout keeps no hand-written list of mods, so it works the same for any mod, yours or anyone else's.
Is it safe to install Claude Code plugins from GitHub or a marketplace? A plugin can run code on your machine, so it is only as safe as its code. Loadout keeps any mod you have not approved from loading and lets Claude audit the code first, so you decide with evidence instead of trusting a description.
How do I audit a Claude Code plugin before installing it? Install Loadout, then install the plugin. Loadout holds it and offers Audit with Claude, which reviews the code and gives a verdict before anything runs.
What happens when a plugin I trust gets an update? The new version is held like a new mod. Loadout tells you it is there, and Audit & update reviews the change before it installs.
Will an update erase the changes I made to a mod? No. Loadout backs up your changes and puts them back after the update.
Does Loadout cost tokens? Not by itself. Only the audits and updates you start with a button use Claude.
Does it work with plain plugins (skills, commands, MCP servers)? They show in the inventory with their details and can be turned on, off or uninstalled. The audit gate covers mods, which are plugins with code that runs inside Claude Code.
Each card's colour tells you, at a glance, how much the mod can do. It is worked out from each mod's own code, so it works the same for any mod, yours or anyone else's:
| Rarity | Colour | When |
|---|---|---|
| Poor | grey | Turned off |
| Common | white | A plain plugin (skills, commands, MCP servers): the audit gate does not cover it |
| Uncommon | green | A mod: it passed the audit gate to load |
| Rare | blue | Its code draws a panel of its own |
| Epic | purple | Its code runs commands on your machine |
| Legendary | orange | Loadout itself |
A mod that is turned off is always Poor. Otherwise the highest rarity that applies wins: a mod that runs commands and also has a panel is Epic. Epic and Legendary cards get a double border. The colours come from Claude Code's theme, so they read in both light and dark mode.
All under ~/.claude/loadout/:
| File | What it holds | |
|---|---|---|
approved.json | The mods and versions you approved: `"name": { "version", "by": "audit" \ | "manual", "approvedAt" }` |
blocked.log | Every time the gate held a mod back | |
audit-log.txt | One line per audit Claude did for you, with its verdict | |
patches/ | Backups of your own changes to pinned mods | |
pinned/ | Local clones made by Pin |
Loadout speaks English or Spanish and follows Claude Code's language setting, then your system's locale. There is nothing to configure. Works on macOS, Linux and Windows (it reads HOME, or USERPROFILE on Windows). Rarities read "Epic Mod" in English and "Mod Épico" in Spanish.
claude plugin validate <loadout folder>
claude plugin test <loadout folder>
hooks/catalogo.ts (the rows, capabilities and rarity), hooks/gate.ts (the audit gate), hooks/updates.ts (version checks), hooks/bandas.ts (bands above the prompt) and hooks/pricing.ts are pure functions, tested in tests/. hooks/register.tsx connects them to the panel, the band and the disk.
MIT. See LICENSE.
hooks/register.tsx 713 lines1/**
2 * Loadout: a safe manager for your Claude Code mods.
3 *
4 * Panel (the ⚙ Loadout button above the prompt, or /loadout): an RPG-style inventory, every mod and plugin as an item card
5 * with an Equip switch, a rarity colour (catalogo.ts, rarityOf), its version, its own description, what its code can do
6 * (commands, files, network, model...), the commit it is pinned to and what it spends on the model;
7 * audited updates, uninstall (two clicks) and "Pin" to keep your own changes.
8 * Band (above the prompt): one row with the ⚙ Loadout button, a ⚠ button when the gate holds a mod back and a ↑ button
9 * when updates wait; the bands of the other mods show under it, each one shown or hidden on its own from the panel.
10 * Audit gate: a mod whose name and version you have not approved never loads; Loadout tells you and offers to have
11 * Claude audit it. Every block is written to ~/.claude/loadout/blocked.log.
12 * Safe updates: a new version never runs until you approve it; your own changes to a pinned clone are backed up and kept.
13 *
14 * Tokens: none of its own. It reads the plugin list and what each mod spends on the model, and runs `claude plugin ...`
15 * and git when you open the panel or press a button. Audits and updates are Claude turns you start.
16 */
17import { atom, read, update } from 'claude-code'
18import type { EngineInterface, Register } from 'claude-code'
19
20import type { Approval, Blocked, Cap, ModRow, UpdateInfo } from '../types'
21import { mergeSeen, splitBands } from './bandas'
22import type { Link } from './bandas'
23import { RARITY_COLOR, builtinIds, capsOf, isSafeId, openCommandFor, pinsOf, rarityOf, shortName, sortRows, toRow } from './catalogo'
24import { isApproved, stillHeld, usesText } from './gate'
25import { pickLang, words } from './i18n'
26import type { Lang } from './i18n'
27import { costOf } from './pricing'
28import type { Usage } from './pricing'
29import { changedFiles, changedList, compareVersions, hasUpdate, listedVersion, sourceOf } from './updates'
30
31const mods = atom({ plugin: 'loadout', key: 'mods' } as const, [])
32const commands = atom({ plugin: 'loadout', key: 'commands' } as const, [])
33const confirm = atom({ plugin: 'loadout', key: 'confirm' } as const, null)
34const expanded = atom({ plugin: 'loadout', key: 'expanded' } as const, null)
35const aboutOpen = atom({ plugin: 'loadout', key: 'aboutOpen' } as const, null)
36const changesOpen = atom({ plugin: 'loadout', key: 'changesOpen' } as const, null)
37const busy = atom({ plugin: 'loadout', key: 'busy' } as const, null)
38const tab = atom({ plugin: 'loadout', key: 'tab' } as const, 'mods')
39const caps = atom({ plugin: 'loadout', key: 'caps' } as const, {})
40const paneUp = atom({ plugin: 'loadout', key: 'paneUp' } as const, false)
41const blocked = atom({ plugin: 'loadout', key: 'blocked' } as const, [])
42const bands = atom({ plugin: 'loadout', key: 'bands' } as const, [])
43const hiddenBands = atom({ plugin: 'loadout', key: 'hiddenBands' } as const, [])
44const spend = atom({ plugin: 'loadout', key: 'spend' } as const, {})
45const updates = atom({ plugin: 'loadout', key: 'updates' } as const, {})
46const checking = atom({ plugin: 'loadout', key: 'checking' } as const, false)
47
48const PANE = 'loadout'
49/** Capabilities that touch the machine or the model get the warning tint. */
50const LOUD: readonly Cap[] = ['cmd', 'write', 'net', 'system', 'gate']
51
52let lang: Lang = 'en'
53/** The mods drawing a band right now (the band's last draw); the panel says which are quiet. */
54let drawing: string[] = []
55
56const asNames = (x: unknown): string[] => (Array.isArray(x) ? x.filter((n): n is string => typeof n === 'string') : [])
57
58/** Shows or hides one mod's band, and remembers it across sessions. */
59async function toggleBand($: EngineInterface, plugin: string) {
60 const next = await update($, hiddenBands, list => (list.includes(plugin) ? list.filter(n => n !== plugin) : [...list, plugin]))
61 await $.store.set('hiddenBands', next).catch(() => undefined)
62}
63
64/** Writes down the mods seen drawing a band; from a timer, since a drawing cannot write state. */
65function noteBands($: EngineInterface, now: string[]) {
66 drawing = now
67 void $.clock.after(0, () => {
68 void (async () => {
69 const seen = await read($, bands)
70 const merged = mergeSeen(seen, now)
71 if (merged.length === seen.length) return
72 await update($, bands, () => merged)
73 await $.store.set('bandsSeen', merged).catch(() => undefined)
74 })().catch(() => undefined)
75 })
76}
77
78async function refreshCommands($: EngineInterface) {
79 const next = (await $.command.list()).map(c => ({ name: c.name, plugin: c.plugin }))
80 const prev = await read($, commands)
81 if (JSON.stringify(prev) !== JSON.stringify(next)) await update($, commands, () => next)
82}
83
84function toast($: EngineInterface, text: string) {
85 try {
86 void $.ui.toast(text)
87 } catch {
88 // a surface without toasts
89 }
90}
91
92async function home($: EngineInterface) {
93 return (await $.env.get('HOME').catch(() => null)) || (await $.env.get('USERPROFILE').catch(() => null)) || ''
94}
95
96async function claudeBin($: EngineInterface) {
97 const h = await home($)
98 return (await $.fs.exists(`${h}/.local/bin/claude`).catch(() => false)) ? `${h}/.local/bin/claude` : 'claude'
99}
100
101// ── audit gate ──────────────────────────────────────────────────────────────
102
103const approvedPath = async ($: EngineInterface) => `${await home($)}/.claude/loadout/approved.json`
104
105async function readApproved($: EngineInterface): Promise<Record<string, Approval>> {
106 try {
107 const data = JSON.parse(await $.fs.read(await approvedPath($))) as { mods?: Record<string, Approval> }
108 return data.mods ?? {}
109 } catch {
110 return {}
111 }
112}
113
114async function approve($: EngineInterface, b: Blocked, by: Approval['by']) {
115 const current = await readApproved($)
116 current[b.name] = { version: b.version, by, approvedAt: new Date().toISOString() }
117 await $.fs.write(await approvedPath($), `${JSON.stringify({ mods: current }, null, 2)}\n`)
118}
119
120/** Every block is written down in ~/.claude/loadout/blocked.log, one JSON line each: your audit trail. */
121async function logBlock($: EngineInterface, b: Blocked) {
122 try {
123 const path = `${await home($)}/.claude/loadout/blocked.log`
124 const prev = await $.fs.read(path).catch(() => '')
125 // One line per mod and version: every Claude Code process judges it again.
126 if (prev.includes(`"name":${JSON.stringify(b.name)},"version":${JSON.stringify(b.version)}`)) return
127 const line = JSON.stringify({ at: new Date().toISOString(), name: b.name, version: b.version, root: b.root, caps: b.caps })
128 await $.fs.write(path, `${prev}${line}\n`)
129 } catch {
130 // the log is best-effort
131 }
132}
133
134/** After an approval: forget what is approved now and load it. */
135async function recheck($: EngineInterface) {
136 const approved = await readApproved($)
137 const before = await read($, blocked)
138 const installed = (await read($, mods)).map(m => ({ name: shortName(m.id), version: m.version }))
139 const nowApproved = before.filter(b => isApproved(approved, b.name, b.version))
140 const still = stillHeld(before, approved, installed)
141 if (still.length < before.length) await update($, blocked, () => still)
142 // Only an approval needs the mod loaded now; a replaced version is just forgotten.
143 if (nowApproved.length > 0) {
144 toast($, words(lang).approvedReload)
145 await $.command.run({ command: 'reload-plugins' }).catch(() => undefined)
146 }
147}
148
149// ── catalog ─────────────────────────────────────────────────────────────────
150
151/** Re-reads the installed plugins, marks which are mods (ship function hooks) and scans what each mod can do. */
152async function loadMods($: EngineInterface) {
153 try {
154 const bin = await claudeBin($)
155 const run = await $.process.run([bin, 'plugin', 'list', '--json'], { timeoutMs: 30_000 })
156 const listed = JSON.parse(run.stdout) as { id: string; version?: string; enabled?: boolean; installPath?: string }[]
157 const known = await $.fs.read(`${await home($)}/.claude/plugins/known_marketplaces.json`).catch(() => '')
158 const pins = pinsOf(await $.fs.read(`${await home($)}/.claude/plugins/installed_plugins.json`).catch(() => ''))
159 const rows: ModRow[] = []
160 for (const p of listed) {
161 if (!isSafeId(p.id)) continue
162 const hooks = p.installPath ? await $.fs.read(`${p.installPath}/hooks/hooks.json`).catch(() => '') : ''
163 // The version its code says (a pinned clone moves ahead of the install record when it updates).
164 const manifest = p.installPath ? await $.fs.read(`${p.installPath}/.claude-plugin/plugin.json`).catch(() => '') : ''
165 const own = (() => {
166 try {
167 return JSON.parse(manifest) as { version?: unknown; description?: unknown }
168 } catch {
169 return {}
170 }
171 })()
172 const version = typeof own.version === 'string' && own.version ? own.version : p.version
173 const description = typeof own.description === 'string' ? own.description : undefined
174 const src = sourceOf(p.id, known)
175 rows.push({
176 ...toRow({ ...p, version, description }, typeof hooks === 'string' && hooks.includes('"modules"'), lang, pins[p.id] ?? null),
177 origin: { kind: src.kind, repo: src.kind === 'github' ? src.repo || null : null },
178 })
179 }
180 // Built-ins ship inside Claude Code and cannot be uninstalled: list only the ones turned on, read from the file on
181 // disk (the session's own copy of the settings is the one it started with).
182 const onDisk = await $.fs.read(`${await home($)}/.claude/settings.json`).catch(() => '')
183 const settings = (() => {
184 try {
185 return JSON.parse(onDisk) as { enabledPlugins?: Record<string, boolean> }
186 } catch {
187 return {}
188 }
189 })()
190 for (const id of builtinIds(settings.enabledPlugins)) {
191 if (settings.enabledPlugins?.[id] === true && !rows.some(r => r.id === id)) rows.push(toRow({ id, enabled: true }, true, lang))
192 }
193 await update($, mods, () => sortRows(rows))
194 // What each mod's code can do, read the way the engine reads it.
195 const found: Record<string, Cap[]> = {}
196 await Promise.all(
197 rows
198 .filter(r => r.isMod && r.path)
199 .map(async r => {
200 const v = await $.process.run([bin, 'plugin', 'validate', r.path ?? ''], { timeoutMs: 30_000 }).catch(() => null)
201 if (v) found[r.id] = capsOf(`${v.stdout}\n${v.stderr}`)
202 }),
203 )
204 await update($, caps, () => found)
205 } catch {
206 toast($, words(lang).failed('claude plugin list'))
207 }
208}
209
210/** Turns a plugin on or off, or uninstalls it, with the official CLI, in the user's own settings. */
211async function act($: EngineInterface, verb: 'enable' | 'disable' | 'uninstall', row: ModRow) {
212 if (!isSafeId(row.id)) return
213 const w = words(lang)
214 await update($, busy, () => row.id)
215 toast($, verb === 'uninstall' ? w.removing(row.label) : w.turning(verb === 'enable', row.label))
216 const argv = [await claudeBin($), 'plugin', verb, row.id, ...(row.builtin ? [] : ['--scope', 'user'])]
217 const run = await $.process.run(argv, { timeoutMs: 60_000 }).catch((err: unknown) => ({
218 exitCode: -1,
219 stdout: '',
220 stderr: err instanceof Error ? err.message : String(err),
221 }))
222 const ok = run.exitCode === 0
223 await update($, confirm, () => null)
224 await update($, expanded, () => null)
225 await loadMods($)
226 let applied = false
227 if (ok) applied = await $.command.run({ command: 'reload-plugins' }).then(() => true).catch(() => false)
228 await update($, busy, () => null)
229 toast($, ok ? w.done(verb, row.label, applied) : w.failed((run.stderr || run.stdout || `exit ${run.exitCode}`).trim().slice(0, 160)))
230}
231
232// ── safe updates and the patch keeper ───────────────────────────────────────
233
234const CHECK_EVERY_MS = 6 * 60 * 60 * 1000
235
236/**
237 * Looks for updates and for your own changes. A pinned clone (a folder marketplace that is a git repo) is fetched and
238 * compared with its upstream, and your changes in it are backed up as a patch; a GitHub marketplace is refreshed and
239 * its listed version compared with the installed one. Nothing is installed here: updating is an audited step.
240 */
241async function checkUpdates($: EngineInterface, quiet: boolean) {
242 const w = words(lang)
243 if (await read($, checking)) return
244 await update($, checking, () => true)
245 if (!quiet) toast($, w.checking)
246 try {
247 const h = await home($)
248 const known = await $.fs.read(`${h}/.claude/plugins/known_marketplaces.json`).catch(() => '')
249 const bin = await claudeBin($)
250 const done = new Set<string>()
251 const found: Record<string, UpdateInfo> = {}
252 for (const m of await read($, mods)) {
253 if (m.builtin || !isSafeId(m.id)) continue
254 const src = sourceOf(m.id, known)
255 const name = shortName(m.id)
256 if (src.kind === 'folder') {
257 // A branch that tracks one is compared with its upstream; a pin on a commit (detached HEAD) with origin's default.
258 const tracked = await $.process.run(['git', '-C', src.dir, 'rev-parse', '--abbrev-ref', '@{u}'], { timeoutMs: 10_000 }).catch(() => null)
259 const origin = tracked?.exitCode === 0 ? null : await $.process.run(['git', '-C', src.dir, 'rev-parse', '--verify', '--quiet', 'origin/HEAD'], { timeoutMs: 10_000 }).catch(() => null)
260 const ref = tracked?.exitCode === 0 ? '@{u}' : origin?.exitCode === 0 ? 'origin/HEAD' : null
261 let behind = 0
262 if (ref) {
263 if (!done.has(src.dir)) {
264 await $.process.run(['git', '-C', src.dir, 'fetch', '--quiet', 'origin'], { timeoutMs: 30_000 }).catch(() => null)
265 done.add(src.dir)
266 }
267 const count = await $.process.run(['git', '-C', src.dir, 'rev-list', '--count', `HEAD..${ref}`], { timeoutMs: 10_000 }).catch(() => null)
268 behind = count?.exitCode === 0 ? Number.parseInt(count.stdout.trim(), 10) || 0 : 0
269 }
270 const status = await $.process.run(['git', '-C', src.dir, 'status', '--porcelain'], { timeoutMs: 10_000 }).catch(() => null)
271 const changed = status?.exitCode === 0 ? changedFiles(status.stdout) : 0
272 let patch: string | null = null
273 if (changed > 0) {
274 const diff = await $.process.run(['git', '-C', src.dir, 'diff', 'HEAD'], { timeoutMs: 10_000 }).catch(() => null)
275 if (diff?.exitCode === 0 && diff.stdout) {
276 await $.process.run(['mkdir', '-p', `${h}/.claude/loadout/patches`], { timeoutMs: 5_000 }).catch(() => null)
277 patch = `${h}/.claude/loadout/patches/${name}.patch`
278 await $.fs.write(patch, diff.stdout).catch(() => {
279 patch = null
280 })
281 }
282 }
283 found[m.id] = { source: 'folder', behind, latest: null, changed, files: status?.exitCode === 0 ? changedList(status.stdout) : [], patch, dir: src.dir, repo: null, ref }
284 } else if (src.kind === 'github') {
285 if (!done.has(src.marketplace)) {
286 await $.process.run([bin, 'plugin', 'marketplace', 'update', src.marketplace], { timeoutMs: 60_000 }).catch(() => null)
287 done.add(src.marketplace)
288 }
289 const listed = listedVersion(await $.fs.read(`${src.dir}/.claude-plugin/marketplace.json`).catch(() => ''), name)
290 const latest = listed && m.version && compareVersions(listed, m.version) > 0 ? listed : null
291 found[m.id] = { source: 'github', behind: 0, latest, changed: 0, files: [], patch: null, dir: src.dir, repo: src.repo || null, ref: null }
292 }
293 }
294 await update($, updates, () => found)
295 await $.store.set('lastCheck', Date.now()).catch(() => undefined)
296 const n = Object.values(found).filter(hasUpdate).length
297 if (!quiet) toast($, n ? w.updatesFound(n) : w.upToDate)
298 } finally {
299 await update($, checking, () => false)
300 }
301}
302
303/** The audited update: Claude gets the exact steps for where this mod comes from. */
304async function askSafeUpdate($: EngineInterface, row: ModRow, info: UpdateInfo | undefined) {
305 const w = words(lang)
306 // Asked once: the card says Claude is on it until the next check, so it cannot be asked twice.
307 if (info) await update($, updates, all => ({ ...all, [row.id]: { ...info, requested: true } }))
308 if (info?.source === 'folder' && info.dir) {
309 await $.prompt.submit({ text: w.askSafeUpdate(row.label, row.id, w.stepsFolder(info.dir, info.behind, info.changed, info.patch, info.ref ?? 'origin/HEAD')) })
310 } else if (info?.source === 'github' && info.latest && info.dir) {
311 await $.prompt.submit({ text: w.askSafeUpdate(row.label, row.id, w.stepsGithub(row.id, info.dir, row.version, info.latest, row.path ?? '')) })
312 } else {
313 await $.prompt.submit({ text: w.askUpdate(row.label, row.id) })
314 }
315}
316
317// ── the panel ───────────────────────────────────────────────────────────────
318
319/** Re-reads the mods and, at most every 6 hours, looks for updates; then opens the panel (or brings it forward). */
320async function openPane($: EngineInterface) {
321 void loadMods($).then(async () => {
322 await recheck($)
323 const last = await $.store.get('lastCheck').catch(() => undefined)
324 if (typeof last !== 'number' || Date.now() - last > CHECK_EVERY_MS) await checkUpdates($, true)
325 })
326 // Every card opens folded: one line of description, your changes closed.
327 await update($, aboutOpen, () => null)
328 await update($, changesOpen, () => null)
329 await $.ui.open({ id: PANE, title: 'Loadout', closeOnEscape: true })
330 await update($, paneUp, () => true)
331}
332
333/** Claude Code's own record says whether the panel is up and in view, not ours: a close we did not see must not stick. */
334async function paneState($: EngineInterface) {
335 return (await $.ui.panes().catch(() => [])).find(p => p.id === PANE)
336}
337
338/** The ⚙ Loadout button: opens the panel, brings a hidden one forward, or closes the one in view. */
339async function togglePane($: EngineInterface) {
340 const pane = await paneState($)
341 if (pane?.isShown) {
342 await $.ui.close({ id: PANE }).catch(() => undefined)
343 await update($, paneUp, () => false)
344 } else await openPane($)
345}
346
347/** The ⚠ and ↑ buttons: show the panel, never close it. */
348async function showPane($: EngineInterface) {
349 const pane = await paneState($)
350 if (pane?.isShown) await update($, paneUp, () => true)
351 else await openPane($)
352}
353
354/** Adds one model call to its plugin's spend. Only other plugins' calls count: the main conversation belongs to no mod. */
355async function meter($: EngineInterface, plugin: string, model: string, result: unknown) {
356 if (!plugin || plugin === 'engine' || plugin === 'client' || plugin === 'loadout') return
357 // An op's answer comes as `{ value }`; the call's result (ModelCompleteResult) is inside.
358 const answer = ((result as { value?: unknown } | null)?.value ?? result) as { usage?: Usage } | null
359 const usage = answer?.usage
360 if (!usage) return
361 const tokens = (usage.input_tokens || 0) + (usage.output_tokens || 0) + (usage.cache_read_input_tokens || 0) + (usage.cache_creation_input_tokens || 0)
362 const usd = costOf(model, usage)
363 await update($, spend, all => {
364 const prev = all[plugin] ?? { calls: 0, tokens: 0, usd: 0 }
365 return { ...all, [plugin]: { calls: prev.calls + 1, tokens: prev.tokens + tokens, usd: prev.usd + usd } }
366 })
367}
368
369// ── hooks ───────────────────────────────────────────────────────────────────
370
371export const register: Register = on => {
372 // The audit gate: a mod you have not approved, in this exact version, never joins the session.
373 on('plugin.register', { tier: 'user' }, async ($, e, next) => {
374 if (e.name === 'loadout') return next(e)
375 const approved = await readApproved($)
376 if (isApproved(approved, e.name, e.version)) return next(e)
377 const b: Blocked = { name: e.name, version: e.version ?? '', root: e.root, caps: capsOf(usesText(e.uses)) }
378 await update($, blocked, list => [...list.filter(x => x.name !== b.name), b])
379 toast($, words(lang).blockedToast(e.name))
380 await logBlock($, b)
381 return { refuse: `Loadout: «${e.name}» v${b.version} is not audited. Approve it in Loadout.` }
382 }).catch(($, e, next) => next(e))
383
384 on('session.start', async ($, e, next) => {
385 try {
386 const settings = (await $.settings.read().catch(() => ({}))) as { language?: unknown; enabledPlugins?: Record<string, boolean> }
387 // A block is forgotten once its mod is no longer installed and on.
388 const installed = Object.entries(settings.enabledPlugins ?? {}).filter(([, v]) => v).map(([id]) => id.split('@')[0])
389 const held = await read($, blocked)
390 if (held.some(b => !installed.includes(b.name))) await update($, blocked, list => list.filter(b => installed.includes(b.name)))
391 const locale = (await $.env.get('LC_ALL').catch(() => null)) || (await $.env.get('LANG').catch(() => null)) || null
392 lang = pickLang(undefined, settings.language, locale)
393 const seen = asNames(await $.store.get('bandsSeen').catch(() => undefined))
394 const hidden = asNames(await $.store.get('hiddenBands').catch(() => undefined))
395 await update($, bands, () => seen)
396 await update($, hiddenBands, () => hidden)
397 // The old all-or-nothing switch is gone: each band now has its own.
398 await $.store.delete('hideOthers').catch(() => undefined)
399 await $.command.register({
400 name: 'loadout',
401 description: lang === 'es' ? 'Abre tu Loadout: administra tus mods y auditorías' : 'Open your Loadout: manage your mods and audits',
402 })
403 await refreshCommands($)
404 } catch {
405 // Nothing yet: the next measure looks again.
406 }
407 return next(e)
408 })
409
410 on('session.measure', async ($, e, next) => {
411 try {
412 // Other mods register their commands after Loadout starts: look again.
413 await refreshCommands($)
414 } catch {
415 // fail-open
416 }
417 return next(e)
418 })
419
420 // After a turn that worked on an audited update, look again so the card shows the result.
421 on('turn.complete', async ($, e, next) => {
422 const result = await next(e)
423 try {
424 if (Object.values(await read($, updates)).some(u => u.requested)) await checkUpdates($, true)
425 } catch {
426 // the check is best-effort
427 }
428 return result
429 })
430
431 // What each mod spends on the model: watched, never changed.
432 on('model.complete', async ($, e, next) => {
433 const result = await next(e)
434 await meter($, next.origin.plugin, e.model, result).catch(() => undefined)
435 return result
436 }).catch(($, e, next) => next(e))
437
438 on('model.fork', async ($, e, next) => {
439 const result = await next(e)
440 await meter($, next.origin.plugin, '', result).catch(() => undefined)
441 return result
442 }).catch(($, e, next) => next(e))
443
444 // However the panel closes (a button, its mark, Esc), its button stops glowing.
445 on('ui.close', async ($, e, next) => {
446 const result = await next(e)
447 if (e.id === PANE) await update($, paneUp, () => false)
448 return result
449 })
450
451 on('command.run', { command: 'loadout' }, async $ => {
452 await openPane($)
453 return {}
454 })
455
456 // The band: on top, its own row; under it, each other mod's band, unless you hid that one.
457 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
458 // Only a survey takes the band.
459 if (e.props.hasSurvey) return next(e)
460 const { Box, Button } = $.ui.resolve(e)
461 await Promise.resolve(next(e)).catch(() => null)
462 // What each mod beneath returned, its own row taken apart from the rows it nests.
463 const { bands: drawnBands, rest } = splitBands(next.trace as readonly Link[])
464 noteBands($, drawnBands.map(b => b.plugin))
465 const hidden = await read($, hiddenBands)
466 const shown = drawnBands.filter(b => !hidden.includes(b.plugin))
467 const w = words(lang)
468 const up = await read($, paneUp)
469 const held = await read($, blocked)
470 const news = Object.values(await read($, updates)).filter(u => hasUpdate(u) && !u.requested).length
471 return (
472 <Box flexDirection="column">
473 <Box key="lo-row" gap={1} justifyContent="flex-end" alignItems="center" marginBottom={1}>
474 {held.length > 0 && <Button key="lo-blocked" variant="primary" label={`⚠ ${w.blocked(held.length)}`} onPress={() => void showPane($)} />}
475 {news > 0 && <Button key="lo-updates" label={`↑ ${w.updatesFound(news)}`} onPress={() => void showPane($)} />}
476 <Button key="lo-open" hotkey="l" variant={up ? 'primary' : undefined} label={`⚙ ${w.loadout}`} onPress={() => void togglePane($)} />
477 </Box>
478 {shown.map(b => (
479 <Box key={`lo-band-${b.plugin}`} flexDirection="column">
480 {b.tree as never}
481 </Box>
482 ))}
483 {rest as never}
484 </Box>
485 )
486 })
487
488 // The panel.
489 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
490 const { Box, Button, Text } = $.ui.resolve(e)
491 const w = words(lang)
492 const list = await read($, mods)
493 const cmds = await read($, commands)
494 const pending = await read($, confirm)
495 const open = await read($, expanded)
496 const unfolded = await read($, aboutOpen)
497 const changesUnfolded = await read($, changesOpen)
498 const working = await read($, busy)
499 const which = await read($, tab)
500 const found = await read($, caps)
501 const held = await read($, blocked)
502 const seenBands = await read($, bands)
503 const hidden = await read($, hiddenBands)
504 const usage = await read($, spend)
505 const ups = await read($, updates)
506 const isChecking = await read($, checking)
507
508 const card = (m: ModRow) => {
509 const name = shortName(m.id)
510 const self = name === 'loadout'
511 const openCmd = openCommandFor(name, cmds)
512 const isBusy = working === m.id
513 const isOpen = open === m.id
514 const mine = found[m.id] ?? []
515 const sp = usage[name]
516 const up = ups[m.id]
517 const waiting = hasUpdate(up) && !up?.requested
518 const rarity = rarityOf(m, mine)
519 const tone = RARITY_COLOR[rarity]
520 const toggleBtn = self ? null : (
521 <Button key={`toggle-${m.id}`} plain onPress={() => (isBusy ? undefined : void act($, m.enabled ? 'disable' : 'enable', m))}>
522 {isBusy ? <Text dimColor>{'• • •'}</Text> : m.enabled ? <Text color="success" bold>{`◆ ${w.on}`}</Text> : <Text dimColor>{`◇ ${w.off}`}</Text>}
523 </Button>
524 )
525 return (
526 <Box
527 key={`card-${m.id}`}
528 flexDirection="column"
529 borderStyle={rarity === 'legendary' || rarity === 'epic' ? 'double' : 'round'}
530 borderColor={tone}
531 hover={{ borderColor: 'claude' }}
532 paddingX={1}
533 marginBottom={1}
534 overflow="hidden"
535 >
536 <Box justifyContent="space-between" alignItems="center" gap={1} minWidth={0}>
537 <Box gap={1} flexShrink={1} minWidth={0} overflow="hidden">
538 <Text color={tone}>{m.icon}</Text>
539 <Text bold color={tone} wrap="truncate-end">{m.label}</Text>
540 </Box>
541 {toggleBtn}
542 </Box>
543 <Text color={tone} dimColor={!m.enabled} wrap="truncate-end">
544 {w.item(w.rarity[rarity], m.isMod ? w.kindMod : w.kindPlugin)}
545 <Text dimColor>{m.builtin ? ` · ${w.builtin}` : m.version ? ` · ${w.level(m.version)}` : ''}</Text>
546 </Text>
547 {m.about !== '' &&
548 (unfolded === m.id ? (
549 <Box flexDirection="column">
550 <Text color="warning" italic dimColor wrap="wrap">{`“${m.about}”`}</Text>
551 <Button key={`about-${m.id}`} plain onPress={() => void update($, aboutOpen, x => (x === m.id ? null : m.id))}>
552 <Text dimColor>{`▴ ${w.less}`}</Text>
553 </Button>
554 </Box>
555 ) : (
556 <Button key={`about-${m.id}`} plain onPress={() => void update($, aboutOpen, x => (x === m.id ? null : m.id))}>
557 <Text color="warning" italic dimColor wrap="truncate-end">{`▸ “${m.about}”`}</Text>
558 </Button>
559 ))}
560 {mine.length > 0 && (
561 <Text wrap="wrap">
562 {mine.map((c, i) => (
563 <Text key={`cap-${m.id}-${c}`} color={LOUD.includes(c) ? 'warning' : 'success'} dimColor={!LOUD.includes(c)}>
564 {`${i ? ' ' : ''}+${w.caps[c]}`}
565 </Text>
566 ))}
567 </Text>
568 )}
569 {m.origin?.kind === 'github' && <Text dimColor wrap="wrap">{`⌂ ${w.fromStore(m.origin.repo)}`}</Text>}
570 {m.origin?.kind === 'folder' && <Text dimColor wrap="wrap">{`⌂ ${m.pin ? w.fromLocalGit(m.pin) : w.fromFolder}`}</Text>}
571 {up?.requested ? (
572 <Text color="claude">{`↻ ${w.updating}`}</Text>
573 ) : (
574 <Box flexDirection="column">
575 {up && up.behind > 0 && <Text color="claude" bold>{w.behind(up.behind)}</Text>}
576 {up?.latest && <Text color="claude" bold>{w.newVersion(up.latest)}</Text>}
577 </Box>
578 )}
579 {up && up.changed > 0 && (
580 <Button key={`changes-${m.id}`} plain onPress={() => void update($, changesOpen, x => (x === m.id ? null : m.id))}>
581 <Text dimColor wrap="wrap">{`${changesUnfolded === m.id ? '▾' : '▸'} ${w.yourChanges(up.changed)}`}</Text>
582 </Button>
583 )}
584 {up && up.changed > 0 && changesUnfolded === m.id && (
585 <Box flexDirection="column">
586 <Box flexDirection="column" paddingLeft={2}>
587 {up.files.map(f => (
588 <Text key={`file-${m.id}-${f.path}`} dimColor wrap="truncate-start">{`${w.fileStatus(f.status)} · ${f.path.split('/').pop() ?? f.path}`}</Text>
589 ))}
590 </Box>
591 {up.patch && <Text dimColor wrap="wrap">{w.patchAt(up.patch)}</Text>}
592 <Button key={`explain-${m.id}`} label={w.explainChanges} onPress={() => void $.prompt.submit({ text: w.askExplain(m.label, up.dir ?? '') })} />
593 </Box>
594 )}
595 {sp && sp.calls > 0 && (
596 <Text color={sp.usd >= 1 ? 'warning' : undefined} dimColor={sp.usd < 1} wrap="wrap">
597 {w.spent(sp.calls, sp.tokens >= 1000 ? `${Math.round(sp.tokens / 1000)}k` : String(sp.tokens), sp.usd.toFixed(2))}
598 </Text>
599 )}
600 {!self && (
601 <Box gap={1} marginTop={1} flexWrap="wrap">
602 {waiting && <Button key={`upd-now-${m.id}`} variant="primary" label={`↻ ${w.update}`} onPress={() => void askSafeUpdate($, m, up)} />}
603 {openCmd && m.enabled && mine.includes('pane') && (
604 <Button key={`open-${m.id}`} label={`${w.open} ↗`} onPress={() => void $.command.run({ command: openCmd })} />
605 )}
606 {!m.builtin && (
607 <Button key={`more-${m.id}`} label={isOpen ? `${w.less} ▴` : `${w.more} ▾`} onPress={() => void update($, expanded, x => (x === m.id ? null : m.id))} />
608 )}
609 </Box>
610 )}
611 {isOpen && (
612 <Box gap={1} marginTop={1} flexWrap="wrap">
613 {!m.builtin && !waiting && !up?.requested && <Button key={`upd-${m.id}`} label={`↻ ${w.update}`} onPress={() => void askSafeUpdate($, m, up)} />}
614 {up?.source === 'github' && up.repo && (
615 <Button key={`pin-${m.id}`} label={`⌁ ${w.pin}`} onPress={() => void $.prompt.submit({ text: w.askPin(m.label, m.id, up.repo ?? '') })} />
616 )}
617 {!m.builtin &&
618 (pending === m.id ? (
619 <Button key={`rm-${m.id}`} variant="primary" label={w.sure} onPress={() => void act($, 'uninstall', m)} />
620 ) : (
621 <Button key={`rm-${m.id}`} label={w.uninstall} dimColor onPress={() => void update($, confirm, () => m.id)} />
622 ))}
623 </Box>
624 )}
625 </Box>
626 )
627 }
628
629 const heldCard = (b: Blocked) => (
630 <Box key={`held-${b.name}`} flexDirection="column" borderStyle="round" borderColor="warning" paddingX={1} marginBottom={1}>
631 <Box gap={1}>
632 <Text color="warning">⚠</Text>
633 <Text bold>{b.name}</Text>
634 <Text dimColor>{b.version ? `v${b.version}` : ''}</Text>
635 </Box>
636 <Text color="warning" wrap="wrap">{w.pendingAudit}</Text>
637 {b.caps.length > 0 && (
638 <Text wrap="wrap">
639 {b.caps.map((c, i) => (
640 <Text key={`hcap-${b.name}-${c}`} color={LOUD.includes(c) ? 'warning' : undefined} dimColor={!LOUD.includes(c)}>
641 {`${i ? ' · ' : ''}${w.caps[c]}`}
642 </Text>
643 ))}
644 </Text>
645 )}
646 <Box gap={1} marginTop={1} flexWrap="wrap">
647 <Button key={`audit-${b.name}`} variant="primary" label={w.auditNow} onPress={() => void $.prompt.submit({ text: w.askAudit(b.name, b.version, b.root) })} />
648 <Button
649 key={`allow-${b.name}`}
650 dimColor
651 label={w.allowAnyway}
652 onPress={() => void approve($, b, 'manual').then(() => recheck($))}
653 />
654 </Box>
655 </Box>
656 )
657
658 const bandLabel = (plugin: string) => list.find(m => shortName(m.id) === plugin)?.label ?? plugin
659 const bandsBody = (
660 <Box flexDirection="column" marginTop={1}>
661 <Text bold>{w.bandsTitle}</Text>
662 {seenBands.length === 0 && <Text dimColor wrap="wrap">{w.noBands}</Text>}
663 {seenBands.map(plugin => {
664 const off = hidden.includes(plugin)
665 return (
666 <Box key={`band-row-${plugin}`} justifyContent="space-between" alignItems="center" gap={1} minWidth={0}>
667 <Box gap={1} flexShrink={1} minWidth={0} overflow="hidden">
668 <Text bold={!off} dimColor={off} wrap="truncate-end">{bandLabel(plugin)}</Text>
669 {!drawing.includes(plugin) && <Text dimColor wrap="truncate-end">{`· ${w.bandQuiet}`}</Text>}
670 </Box>
671 <Button key={`band-${plugin}`} plain onPress={() => void toggleBand($, plugin)}>
672 {off ? <Text dimColor>{`○ ${w.bandHidden}`}</Text> : <Text color="success" bold>{`● ${w.bandShown}`}</Text>}
673 </Button>
674 </Box>
675 )
676 })}
677 </Box>
678 )
679
680 const modRows = list.filter(m => m.isMod)
681 const plugins = list.filter(m => !m.isMod)
682 const tabRows = which === 'mods' ? modRows : plugins
683 const modsBody = (
684 <Box flexDirection="column">
685 {held.map(heldCard)}
686 <Box gap={1} marginBottom={1}>
687 <Button key="tab-mods" variant={which === 'mods' ? 'primary' : 'secondary'} label={`${w.mods} ${modRows.length}`} onPress={() => void update($, tab, () => 'mods')} />
688 <Button key="tab-plugins" variant={which === 'plugins' ? 'primary' : 'secondary'} label={`${w.plugins} ${plugins.length}`} onPress={() => void update($, tab, () => 'plugins')} />
689 </Box>
690 {list.length === 0 && <Text dimColor>{w.loading}</Text>}
691 {list.length > 0 && which === 'plugins' && plugins.length === 0 && <Text dimColor>{w.emptyPlugins}</Text>}
692 {tabRows.map(card)}
693 <Box gap={1} flexWrap="wrap">
694 <Button key="lo-check" label={isChecking ? w.checking : `↻ ${w.checkAll}`} onPress={() => void checkUpdates($, false)} />
695 <Button key="lo-refresh" hotkey="r" dimColor label={w.refresh} onPress={() => void loadMods($).then(() => recheck($)).then(() => checkUpdates($, true))} />
696 </Box>
697 <Text dimColor wrap="wrap">{`🛡 ${w.safeNote}`}</Text>
698 {bandsBody}
699 </Box>
700 )
701
702 return (
703 <Box flexDirection="column">
704 <Box marginBottom={1} gap={1}>
705 <Text bold color="claude">{`⚔ ${w.inventory}`}</Text>
706 <Text dimColor>{`· ${w.active(modRows.filter(m => m.enabled).length, modRows.length)}`}</Text>
707 </Box>
708 {modsBody}
709 </Box>
710 )
711 })
712}
713hooks/bandas.ts 69 lines1/**
2 * The other mods' bands, one by one. The band is one chain: each mod's hook draws its own row and, if it calls `next`,
3 * nests what the mods beneath drew. `next.trace` says what each link returned; a link's own row is its tree with the
4 * tree of the link beneath it taken out, so each mod's band can show or hide on its own.
5 */
6
7/** What Loadout reads from one `next.trace` entry. */
8export type Link = { index: number; plugin: string; returned: unknown }
9
10/** One mod's own band. */
11export type Band = { plugin: string; tree: unknown }
12
13/** Claude Code's own placeholder ({ type: 'engine' }) is never nested: the desktop would drop the whole band. */
14const isTree = (x: unknown): boolean =>
15 typeof x === 'string' ? x !== '' : !!x && typeof x === 'object' && (x as { type?: unknown }).type !== 'engine'
16
17/** `tree` with every subtree equal to `gone` (and every engine placeholder) taken out. */
18function strip(tree: unknown, gone: string | null): unknown {
19 if (!tree || typeof tree !== 'object') return tree
20 if (Array.isArray(tree)) return tree.map(t => strip(t, gone)).filter(t => t !== null)
21 if ((tree as { type?: unknown }).type === 'engine') return null
22 if (gone !== null && JSON.stringify(tree) === gone) return null
23 const el = tree as { children?: unknown }
24 if (!Array.isArray(el.children)) return tree
25 return { ...el, children: el.children.map(c => strip(c, gone)).filter(c => c !== null) }
26}
27
28/** Whether a tree still draws something once stripped (an empty Box draws nothing). */
29function draws(tree: unknown): boolean {
30 if (typeof tree === 'string') return tree.trim() !== ''
31 if (!isTree(tree)) return false
32 const el = tree as { type?: string; children?: unknown }
33 if (el.type !== 'Box') return true
34 return Array.isArray(el.children) && el.children.some(draws)
35}
36
37/**
38 * Each mod's own band, outermost first, from the links beneath Loadout. A link that only passed what was beneath it
39 * draws nothing of its own. `rest` is what the engine itself drew (no mod's), shown as it is.
40 */
41export function splitBands(trace: readonly Link[], self = 'loadout'): { bands: Band[]; rest: unknown } {
42 const links = [...trace].filter(l => l.plugin !== self).sort((a, b) => a.index - b.index)
43 const bands: Band[] = []
44 let rest: unknown = null
45 for (const [i, link] of links.entries()) {
46 if (!isTree(link.returned)) continue
47 const below = links.slice(i + 1).find(l => l.returned !== undefined)?.returned
48 const belowText = isTree(below) ? JSON.stringify(below) : null
49 if (belowText !== null && JSON.stringify(link.returned) === belowText) continue
50 const own = strip(link.returned, belowText)
51 if (!draws(own)) continue
52 if (link.plugin === 'engine') rest = own
53 else {
54 const same = bands.find(b => b.plugin === link.plugin)
55 // A mod with two hooks on the band: both rows are its own.
56 if (same) same.tree = { type: 'Box', props: { flexDirection: 'column' }, children: [same.tree, own] }
57 else bands.push({ plugin: link.plugin, tree: own })
58 }
59 }
60 return { bands, rest }
61}
62
63/** The names seen so far plus the new ones, in the order first seen. */
64export function mergeSeen(seen: readonly string[], now: readonly string[]): string[] {
65 const out = [...seen]
66 for (const n of now) if (!out.includes(n)) out.push(n)
67 return out
68}
69hooks/catalogo.ts 123 lines1/**
2 * The catalog (pure, no `$`): turns `claude plugin list --json` into panel rows, with its name,
3 * what it does (its own plugin.json description) and its glyph; reads capabilities from `claude plugin validate`.
4 */
5import type { Cap, ModRow } from '../types'
6import type { Lang } from './i18n'
7
8type Listed = { id: string; version?: string; enabled?: boolean; installPath?: string; description?: string }
9
10/** The plugin.json description without the mod's name in front ("Name: …", "Name — …"): the card shows it already. */
11export function ownWords(description: string | undefined, label: string): string {
12 const d = (description ?? '').trim()
13 const lead = new RegExp(`^${label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*[:—–-]\\s*`, 'i')
14 const rest = d.replace(lead, '').trim()
15 return rest.charAt(0).toUpperCase() + rest.slice(1)
16}
17
18/** Item rarity, Blizzard-RPG style: what a card's colour says about the mod at a glance. */
19export type Rarity = 'poor' | 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
20
21/** Theme colours (readable in light and dark): grey, white, green, blue, purple, orange. */
22export const RARITY_COLOR: Record<Rarity, string> = {
23 poor: 'inactive',
24 common: 'text',
25 uncommon: 'success',
26 rare: 'suggestion',
27 epic: 'merged',
28 legendary: 'claude',
29}
30
31/**
32 * Off → poor. Loadout → legendary. Runs commands → epic. Draws its own panel → rare.
33 * A mod (it passed the audit gate to load) → uncommon. A plain plugin, which the gate does not audit → common.
34 * Read from what any mod's code does (`claude plugin validate`), never from a list of mods we know.
35 */
36export function rarityOf(m: Pick<ModRow, 'id' | 'enabled' | 'isMod'>, caps: readonly Cap[]): Rarity {
37 if (!m.enabled) return 'poor'
38 if (shortName(m.id) === 'loadout') return 'legendary'
39 if (caps.includes('cmd')) return 'epic'
40 if (caps.includes('pane')) return 'rare'
41 return m.isMod ? 'uncommon' : 'common'
42}
43
44export const shortName = (id: string) => id.split('@')[0] ?? id
45
46export function toRow(p: Listed, isMod: boolean, lang: Lang, pin: string | null = null): ModRow {
47 const name = shortName(p.id)
48 void lang
49 return {
50 id: p.id,
51 label: name,
52 enabled: p.enabled !== false,
53 isMod,
54 builtin: p.id.endsWith('@builtin'),
55 version: p.version ?? '',
56 // What the mod says it does, in its own plugin.json: the same text everyone who installs it sees.
57 about: ownWords(p.description, name),
58 icon: name === 'loadout' ? '⚙' : isMod ? '◇' : '▪',
59 path: p.installPath ?? null,
60 pin,
61 }
62}
63
64/** The command that opens a plugin's panel: the one named like the plugin, else its only command, else none. */
65export function openCommandFor(plugin: string, commands: readonly { name: string; plugin?: string }[]): string | null {
66 const own = commands.filter(c => c.plugin === plugin)
67 if (own.some(c => c.name === plugin)) return plugin
68 return own.length === 1 ? (own[0]?.name ?? null) : null
69}
70
71/** Ids of the plugins Claude Code ships built in (they do not show in `plugin list`), from `enabledPlugins`. */
72export function builtinIds(enabledPlugins: Record<string, unknown> | undefined): string[] {
73 return Object.keys(enabledPlugins ?? {})
74 .filter(id => id.endsWith('@builtin'))
75 .sort()
76}
77
78/** Active first, then off; Loadout itself last among the mods. */
79export function sortRows(rows: ModRow[]): ModRow[] {
80 const rank = (r: ModRow) => (shortName(r.id) === 'loadout' ? 9 : r.isMod ? (r.enabled ? 0 : 1) : r.enabled ? 2 : 3)
81 return [...rows].sort((a, b) => rank(a) - rank(b) || a.label.localeCompare(b.label))
82}
83
84/** Only safe ids reach `claude plugin …` (no spaces or metacharacters). */
85export const isSafeId = (id: string) => /^[\w.-]+@[\w.-]+$/.test(id)
86
87/** Pinned commit per plugin id, from `~/.claude/plugins/installed_plugins.json`. */
88export function pinsOf(installedJson: string): Record<string, string> {
89 try {
90 const data = JSON.parse(installedJson) as { plugins?: Record<string, { gitCommitSha?: string }[]> }
91 const out: Record<string, string> = {}
92 for (const [id, entries] of Object.entries(data.plugins ?? {})) {
93 const sha = entries?.[0]?.gitCommitSha
94 if (sha) out[id] = sha.slice(0, 7)
95 }
96 return out
97 } catch {
98 return {}
99 }
100}
101
102/** What a mod's code can do, read from the `hooks:` and `calls:` lines `claude plugin validate` prints. */
103export function capsOf(validateOutput: string): Cap[] {
104 const line = (label: string) =>
105 validateOutput
106 .split('\n')
107 .filter(l => l.includes(` ${label}: `))
108 .join(' ')
109 const calls = line('calls')
110 const hooks = line('hooks')
111 const caps: Cap[] = []
112 if (/\$\.process\./.test(calls)) caps.push('cmd')
113 if (/\$\.fs\.write|\$\.fs\.remove|\$\.fs\.mkdir/.test(calls)) caps.push('write')
114 if (/\$\.http\./.test(calls)) caps.push('net')
115 if (/\$\.prompt\.submit/.test(calls)) caps.push('prompt')
116 if (/\$\.model\./.test(calls)) caps.push('model')
117 if (/\$\.tool\.register/.test(calls)) caps.push('tools')
118 if (/(^|[\s,])tool\.call/.test(hooks)) caps.push('gate')
119 if (/prompt\.compose/.test(hooks)) caps.push('system')
120 if (/ui\.render\{component=Pane/.test(hooks)) caps.push('pane')
121 return caps
122}
123hooks/gate.ts 25 lines1/**
2 * The audit gate's pure helpers (no `$`): what a mod's scan says it can do, and whether its name and version are approved.
3 */
4
5/** Capability lines in the shape `claude plugin validate` prints, from a `plugin.register` scan. */
6export const usesText = (uses: { events: readonly string[]; calls: readonly string[] }) =>
7 ` ❯ x hooks: ${uses.events.join(', ')}\n ❯ x calls: ${uses.calls.join(', ')}`
8
9/** Whether a mod's name and version are approved. */
10export const isApproved = (approved: Record<string, { version: string }>, name: string, version: string | undefined) =>
11 approved[name]?.version === (version ?? '')
12
13/**
14 * The held versions that still wait for your audit: one leaves once you approve it, or once another version of that
15 * mod is installed in its place (a version nobody runs any more needs no audit).
16 */
17export function stillHeld<B extends { name: string; version: string }>(
18 held: readonly B[],
19 approved: Record<string, { version: string }>,
20 installed: readonly { name: string; version: string }[],
21): B[] {
22 const replaced = (b: B) => installed.some(m => m.name === b.name && m.version !== '' && m.version !== b.version)
23 return held.filter(b => !isApproved(approved, b.name, b.version) && !replaced(b))
24}
25hooks/i18n.ts 238 lines1/**
2 * Loadout's words in English and Spanish, picked by Claude Code's `language` setting, then the locale (nothing to configure).
3 */
4export type Lang = 'en' | 'es'
5
6const EN = {
7 loadout: 'Loadout',
8 mods: 'Mods',
9 plugins: 'Plugins',
10 on: 'Equipped',
11 off: 'Equip',
12 open: 'Open',
13 more: 'More',
14 less: 'Less',
15 update: 'Audit & update',
16 uninstall: 'Uninstall',
17 sure: 'Sure? Uninstall',
18 refresh: 'Refresh',
19 checkAll: 'Check updates',
20 builtin: 'built-in',
21 loading: 'Loading your loadout…',
22 emptyPlugins: 'No classic plugins.',
23 active: (on: number, total: number) => `${on} of ${total} active`,
24 inventory: 'Inventory',
25 rarity: { poor: 'Poor', common: 'Common', uncommon: 'Uncommon', rare: 'Rare', epic: 'Epic', legendary: 'Legendary' },
26 level: (v: string) => `v${v}`,
27 kindMod: 'Mod',
28 kindPlugin: 'Plugin',
29 item: (rarity: string, kind: string) => `${rarity} ${kind}`,
30 turning: (on: boolean, name: string) => `${on ? 'Turning on' : 'Turning off'} ${name}…`,
31 removing: (name: string) => `Uninstalling ${name}…`,
32 done: (verb: 'enable' | 'disable' | 'uninstall', name: string, applied: boolean) =>
33 `${name} ${verb === 'enable' ? 'is on' : verb === 'disable' ? 'is off' : 'uninstalled'}${applied ? '' : ' · applies in your next session'}`,
34 failed: (why: string) => `Couldn't do it: ${why}`,
35 caps: {
36 cmd: 'runs commands',
37 write: 'writes files',
38 net: 'network',
39 prompt: 'sends prompts',
40 model: 'calls the model',
41 tools: 'adds tools',
42 gate: 'watches tool calls',
43 system: 'edits the system prompt',
44 pane: 'own panel',
45 },
46 spent: (calls: number, tokens: string, usd: string) => `This session: ${calls} model ${calls === 1 ? 'call' : 'calls'} · ${tokens} tokens · ≈$${usd}`,
47 bandsTitle: 'Bands above the prompt',
48 bandShown: 'shown',
49 bandHidden: 'hidden',
50 noBands: 'No other mod has drawn a band yet.',
51 bandQuiet: 'nothing to show right now',
52 // Safe updates and the patch keeper
53 behind: (n: number) => `↑ ${n} new ${n === 1 ? 'change' : 'changes'} from its author`,
54 newVersion: (v: string) => `↑ v${v} available`,
55 yourChanges: (n: number) => `✎ Your changes: ${n} ${n === 1 ? 'file' : 'files'} · kept when it updates`,
56 fromStore: (repo: string | null) => `Installed from a marketplace${repo ? ` (${repo})` : ''}: updates when you approve them`,
57 fromLocalGit: (sha: string) => `Installed from a git copy on this computer (commit ${sha}): you can edit it; the author's updates come only when you approve them`,
58 fromFolder: 'Installed from a folder on this computer: your edits apply when you reload',
59 fileStatus: (code: string) => ({ M: 'modified', A: 'added', D: 'deleted', '??': 'new', R: 'renamed' })[code === '??' ? '??' : code.charAt(0)] ?? code,
60 patchAt: (p: string) => `Backup: ${p}`,
61 explainChanges: 'Explain my changes',
62 askExplain: (label: string, dir: string) =>
63 `Loadout: explain in a few lines, for a non-programmer, what my own uncommitted changes to the mod «${label}» do. Run \`git -C ${dir} status\` and \`git -C ${dir} diff HEAD\` and read them; do not change anything.`,
64 checking: 'Checking for updates…',
65 upToDate: 'Everything is up to date',
66 updatesFound: (n: number) => (n === 1 ? '1 update available' : `${n} updates available`),
67 safeNote: 'New versions never run until you approve them.',
68 updating: 'Claude is updating it…',
69 pin: 'Pin to keep my changes',
70 askSafeUpdate: (label: string, id: string, steps: string) =>
71 `Loadout: update the mod «${label}» (${id}) safely. Follow these steps in order and STOP at step 2 if it is not safe.\n${steps}`,
72 stepsFolder: (dir: string, behind: number, changed: number, patch: string | null, ref: string) =>
73 [
74 `1. Its code is a pinned git clone at ${dir}, ${behind} commits behind its author. Show me what changed: \`git -C ${dir} log --oneline HEAD..${ref}\` and the diff.`,
75 '2. Audit the new code like a security review: network calls, commands, writes outside its folder, reads of secrets or env vars, rewrites of prompts or tool calls, obfuscated code; run `claude plugin validate` on it. Give me a verdict with the evidence.',
76 changed > 0
77 ? `3. If it is safe, keep my own changes (${changed} files, backed up at ${patch ?? 'the patch folder'}): \`git -C ${dir} stash\`, ${ref === '@{u}' ? `\`git -C ${dir} merge --ff-only @{u}\`` : `\`git -C ${dir} checkout --detach ${ref}\``}, \`git -C ${dir} stash pop\`. Resolve any conflict keeping both my change's intent and the author's new behavior, and tell me how.`
78 : `3. If it is safe: ${ref === '@{u}' ? `\`git -C ${dir} merge --ff-only @{u}\`` : `\`git -C ${dir} checkout --detach ${ref}\``}.`,
79 '4. Run `claude plugin validate` and `claude plugin test` (when it has tests) on the plugin folder until they pass.',
80 '5. Approve the new version in ~/.claude/loadout/approved.json: `"<plugin name>": { "version": "<its plugin.json version>", "by": "audit", "approvedAt": "<ISO date>" }`, keeping the other entries.',
81 '6. Tell me in a few lines what changed, your verdict, and to press Reload in Loadout.',
82 ].join('\n'),
83 stepsGithub: (id: string, dir: string, installed: string, latest: string, installPath: string) =>
84 [
85 `1. It is installed at v${installed} from a GitHub marketplace; the marketplace (refreshed at ${dir}) lists v${latest}. Show me what changed between the installed copy at ${installPath} and the new source.`,
86 '2. Audit the new code like a security review: network calls, commands, writes outside its folder, reads of secrets or env vars, rewrites of prompts or tool calls, obfuscated code; run `claude plugin validate` on it. Give me a verdict with the evidence.',
87 `3. If it is safe: \`claude plugin update ${id} --scope user\`.`,
88 '4. Run `claude plugin validate` on the updated copy.',
89 `5. Approve v${latest} in ~/.claude/loadout/approved.json: \`"<plugin name>": { "version": "${latest}", "by": "audit", "approvedAt": "<ISO date>" }\`, keeping the other entries.`,
90 '6. Tell me in a few lines what changed, your verdict, and to press Reload in Loadout.',
91 ].join('\n'),
92 askPin: (label: string, id: string, repo: string) =>
93 `Loadout: pin the mod «${label}» (${id}) so I can change it and still update it safely. 1. Clone https://github.com/${repo} ` +
94 'into ~/.claude/loadout/pinned/<repo name> and check out the commit installed now (gitCommitSha in ~/.claude/plugins/installed_plugins.json). ' +
95 '2. `claude plugin marketplace add` that folder. 3. Uninstall the current copy and install the same plugin from the new marketplace with `--scope user`. ' +
96 '4. Run `claude plugin validate` on it. 5. Tell me to press Reload in Loadout. Its approval in ~/.claude/loadout/approved.json stays as is.',
97 // Gate
98 blocked: (n: number) => (n === 1 ? '1 mod blocked' : `${n} mods blocked`),
99 blockedToast: (name: string) => `Loadout blocked «${name}»: not audited yet. Open Loadout to audit it.`,
100 pendingAudit: 'Waiting for audit: its code has not run',
101 auditNow: 'Audit with Claude',
102 allowAnyway: 'Allow without audit',
103 approvedReload: 'Approved · reloading',
104 askAudit: (name: string, version: string, root: string) =>
105 `Loadout blocked the mod «${name}» v${version} (${root}) because it has not been audited. Audit it now: read every file of its hooks ` +
106 'module, run `claude plugin validate` on it, and check for network calls, commands, writes outside its folder, reads of secrets or ' +
107 'env vars, rewrites of prompts or tool calls, and obfuscated code. Give me a clear verdict with the evidence. Only if it is safe, ' +
108 `add it to ~/.claude/loadout/approved.json as "${name}": { "version": "${version}", "by": "audit", "approvedAt": <ISO date> } ` +
109 '(keep the other entries), log it in ~/.claude/loadout/audit-log.txt and tell me to press Reload in Loadout.',
110 askUpdate: (label: string, id: string) =>
111 `Loadout: check whether the mod «${label}» (${id}) has a newer version in its marketplace or repository. If it does, ` +
112 'audit it like a security review (claude plugin validate plus a manual review of network calls, commands, writes, secrets, ' +
113 'rewrites and obfuscated code) and only if it passes update it and approve the new version in ~/.claude/loadout/approved.json. ' +
114 'If there is none, say so in one line.',
115}
116
117const ES: typeof EN = {
118 loadout: 'Loadout',
119 mods: 'Mods',
120 plugins: 'Plugins',
121 on: 'Equipado',
122 off: 'Equipar',
123 open: 'Abrir',
124 more: 'Más',
125 less: 'Menos',
126 update: 'Auditar y actualizar',
127 uninstall: 'Desinstalar',
128 sure: '¿Seguro? Desinstalar',
129 refresh: 'Recargar',
130 checkAll: 'Buscar actualizaciones',
131 builtin: 'integrado',
132 loading: 'Cargando tu loadout…',
133 emptyPlugins: 'Sin plugins clásicos.',
134 active: (on, total) => `${on} de ${total} activos`,
135 inventory: 'Inventario',
136 rarity: { poor: 'Pobre', common: 'Común', uncommon: 'Poco común', rare: 'Raro', epic: 'Épico', legendary: 'Legendario' },
137 level: v => `v${v}`,
138 kindMod: 'Mod',
139 kindPlugin: 'Plugin',
140 item: (rarity, kind) => `${kind} ${rarity}`,
141 turning: (on, name) => `${on ? 'Prendiendo' : 'Apagando'} ${name}…`,
142 removing: name => `Desinstalando ${name}…`,
143 done: (verb, name, applied) =>
144 `${name} ${verb === 'enable' ? 'prendido' : verb === 'disable' ? 'apagado' : 'desinstalado'}${applied ? '' : ' · aplica en tu siguiente sesión'}`,
145 failed: why => `No se pudo: ${why}`,
146 caps: {
147 cmd: 'corre comandos',
148 write: 'escribe archivos',
149 net: 'red',
150 prompt: 'envía prompts',
151 model: 'usa el modelo',
152 tools: 'agrega herramientas',
153 gate: 'vigila herramientas',
154 system: 'edita el system prompt',
155 pane: 'panel propio',
156 },
157 spent: (calls, tokens, usd) => `Esta sesión: ${calls} ${calls === 1 ? 'consulta' : 'consultas'} al modelo · ${tokens} tokens · ≈$${usd}`,
158 bandsTitle: 'Bandas sobre el prompt',
159 bandShown: 'visible',
160 bandHidden: 'oculta',
161 noBands: 'Ningún otro mod ha dibujado una banda todavía.',
162 bandQuiet: 'sin nada que mostrar ahora',
163 behind: n => `↑ ${n} ${n === 1 ? 'cambio nuevo' : 'cambios nuevos'} de su autor`,
164 newVersion: v => `↑ v${v} disponible`,
165 yourChanges: n => `✎ Tus cambios: ${n} ${n === 1 ? 'archivo' : 'archivos'} · se conservan al actualizar`,
166 fromStore: repo => `Instalado desde una tienda${repo ? ` (${repo})` : ''}: se actualiza cuando lo apruebas`,
167 fromLocalGit: sha => `Instalado desde una copia git en este equipo (commit ${sha}): puedes editarlo; las novedades del autor llegan solo cuando las apruebas`,
168 fromFolder: 'Instalado desde una carpeta de este equipo: tus cambios aplican al recargar',
169 fileStatus: code => ({ M: 'modificado', A: 'agregado', D: 'borrado', '??': 'nuevo', R: 'renombrado' })[code === '??' ? '??' : code.charAt(0)] ?? code,
170 patchAt: p => `Respaldo: ${p}`,
171 explainChanges: 'Explicar mis cambios',
172 askExplain: (label, dir) =>
173 `Loadout: explícame en pocas líneas, para alguien que no programa, qué hacen mis cambios sin commitear al mod «${label}». Corre \`git -C ${dir} status\` y \`git -C ${dir} diff HEAD\` y léelos; no cambies nada.`,
174 checking: 'Buscando actualizaciones…',
175 upToDate: 'Todo está al día',
176 updatesFound: n => (n === 1 ? '1 actualización disponible' : `${n} actualizaciones disponibles`),
177 safeNote: 'Las versiones nuevas no corren hasta que las apruebes.',
178 updating: 'Actualizándose con Claude…',
179 pin: 'Fijar para conservar mis cambios',
180 askSafeUpdate: (label, id, steps) =>
181 `Loadout: actualiza el mod «${label}» (${id}) de forma segura. Sigue estos pasos en orden y DETENTE en el paso 2 si no es seguro.\n${steps}`,
182 stepsFolder: (dir, behind, changed, patch, ref) =>
183 [
184 `1. Su código es una copia git fijada en ${dir}, ${behind} commits detrás de su autor. Muéstrame qué cambió: \`git -C ${dir} log --oneline HEAD..${ref}\` y el diff.`,
185 '2. Audita el código nuevo como una revisión de seguridad: llamadas de red, comandos, escrituras fuera de su carpeta, lectura de secretos o variables de entorno, reescritura de prompts o herramientas, código ofuscado; corre `claude plugin validate`. Dame un veredicto con la evidencia.',
186 changed > 0
187 ? `3. Si es seguro, conserva mis cambios (${changed} archivos, respaldados en ${patch ?? 'la carpeta de parches'}): \`git -C ${dir} stash\`, ${ref === '@{u}' ? `\`git -C ${dir} merge --ff-only @{u}\`` : `\`git -C ${dir} checkout --detach ${ref}\``}, \`git -C ${dir} stash pop\`. Resuelve cualquier conflicto conservando la intención de mi cambio y lo nuevo del autor, y dime cómo.`
188 : `3. Si es seguro: ${ref === '@{u}' ? `\`git -C ${dir} merge --ff-only @{u}\`` : `\`git -C ${dir} checkout --detach ${ref}\``}.`,
189 '4. Corre `claude plugin validate` y `claude plugin test` (si tiene pruebas) sobre la carpeta del plugin hasta que pasen.',
190 '5. Aprueba la versión nueva en ~/.claude/loadout/approved.json: `"<nombre del plugin>": { "version": "<versión de su plugin.json>", "by": "audit", "approvedAt": "<fecha ISO>" }`, sin borrar las demás entradas.',
191 '6. Dime en pocas líneas qué cambió, tu veredicto, y que presione Recargar en Loadout.',
192 ].join('\n'),
193 stepsGithub: (id, dir, installed, latest, installPath) =>
194 [
195 `1. Está instalado en v${installed} desde un marketplace de GitHub; el marketplace (actualizado en ${dir}) ofrece v${latest}. Muéstrame qué cambió entre la copia instalada en ${installPath} y la nueva.`,
196 '2. Audita el código nuevo como una revisión de seguridad: llamadas de red, comandos, escrituras fuera de su carpeta, lectura de secretos o variables de entorno, reescritura de prompts o herramientas, código ofuscado; corre `claude plugin validate`. Dame un veredicto con la evidencia.',
197 `3. Si es seguro: \`claude plugin update ${id} --scope user\`.`,
198 '4. Corre `claude plugin validate` sobre la copia actualizada.',
199 `5. Aprueba v${latest} en ~/.claude/loadout/approved.json: \`"<nombre del plugin>": { "version": "${latest}", "by": "audit", "approvedAt": "<fecha ISO>" }\`, sin borrar las demás entradas.`,
200 '6. Dime en pocas líneas qué cambió, tu veredicto, y que presione Recargar en Loadout.',
201 ].join('\n'),
202 askPin: (label, id, repo) =>
203 `Loadout: fija el mod «${label}» (${id}) para poder modificarlo y seguir actualizándolo seguro. 1. Clona https://github.com/${repo} ` +
204 'en ~/.claude/loadout/pinned/<nombre del repo> y cambia al commit instalado hoy (gitCommitSha en ~/.claude/plugins/installed_plugins.json). ' +
205 '2. `claude plugin marketplace add` esa carpeta. 3. Desinstala la copia actual e instala el mismo plugin desde el marketplace nuevo con `--scope user`. ' +
206 '4. Corre `claude plugin validate`. 5. Dime que presione Recargar en Loadout. Su aprobación en ~/.claude/loadout/approved.json se queda igual.',
207 blocked: n => (n === 1 ? '1 mod bloqueado' : `${n} mods bloqueados`),
208 blockedToast: name => `Loadout bloqueó «${name}»: aún no está auditado. Abre Loadout para auditarlo.`,
209 pendingAudit: 'Esperando auditoría: su código no ha corrido',
210 auditNow: 'Auditar con Claude',
211 allowAnyway: 'Permitir sin auditar',
212 approvedReload: 'Aprobado · recargando',
213 askAudit: (name, version, root) =>
214 `Loadout bloqueó el mod «${name}» v${version} (${root}) porque no está auditado. Audítalo ahora: lee todos los archivos de su ` +
215 'módulo de hooks, corre `claude plugin validate` y revisa llamadas de red, comandos, escrituras fuera de su carpeta, lectura de ' +
216 'secretos o variables de entorno, reescritura de prompts o de herramientas, y código ofuscado. Dame un veredicto claro con la ' +
217 `evidencia. Solo si es seguro, agrégalo a ~/.claude/loadout/approved.json como "${name}": { "version": "${version}", "by": "audit", ` +
218 '"approvedAt": <fecha ISO> } (sin borrar las demás entradas), anótalo en ~/.claude/loadout/audit-log.txt y dime que presione ' +
219 'Recargar en Loadout.',
220 askUpdate: (label, id) =>
221 `Loadout: revisa si el mod «${label}» (${id}) tiene una versión nueva en su marketplace o repositorio. Si la hay, ` +
222 'audítala como una revisión de seguridad (claude plugin validate + revisión manual de llamadas de red, comandos, escrituras, ' +
223 'secretos, reescrituras y código ofuscado) y solo si pasa actualízala y apruébala en ~/.claude/loadout/approved.json. ' +
224 'Si no hay versión nueva, dímelo en una línea.',
225}
226
227export type Words = typeof EN
228
229export const words = (lang: Lang): Words => (lang === 'es' ? ES : EN)
230
231/** Claude Code's `language` setting, then LANG/LC_ALL; English otherwise. An explicit 'en' or 'es' first wins (tests). */
232export function pickLang(option: unknown, settingsLanguage: unknown, locale: string | null): Lang {
233 if (option === 'en' || option === 'es') return option
234 const s = typeof settingsLanguage === 'string' ? settingsLanguage.toLowerCase() : ''
235 if (s) return s.startsWith('es') || s.startsWith('span') || s.startsWith('espa') ? 'es' : 'en'
236 return (locale ?? '').toLowerCase().startsWith('es') ? 'es' : 'en'
237}
238hooks/pricing.ts 37 lines1/**
2 * Model pricing (pure, no `$`): the estimate behind each mod's "spent" line. The engine reports tokens, not money.
3 *
4 * Pricing table adapted from savvy-progress (https://github.com/johnnyvizz), MIT License, (c) 2026 johnnyvizz.
5 */
6
7/** USD per million tokens: input, output, cache read, cache write (5-minute TTL). */
8const PRICES: [RegExp, [number, number, number, number]][] = [
9 [/fable|mythos/, [10, 50, 0.25, 12.5]],
10 [/opus-5-5/, [4, 20, 0.2, 5]],
11 [/opus/, [5, 25, 0.5, 6.25]],
12 [/sonnet/, [2, 10, 0.2, 2.5]],
13 [/haiku/, [1, 5, 0.1, 1.25]],
14]
15
16export type Usage = {
17 input_tokens: number
18 output_tokens: number
19 cache_read_input_tokens: number
20 cache_creation_input_tokens: number
21}
22
23const priceOf = (model: string): [number, number, number, number] =>
24 PRICES.find(([re]) => re.test(model.toLowerCase()))?.[1] ?? [4, 20, 0.2, 5]
25
26/** Estimated USD of one model call. */
27export const costOf = (model: string, u: Usage): number => {
28 const [i, o, r, w] = priceOf(model)
29 return (
30 ((u.input_tokens || 0) * i +
31 (u.output_tokens || 0) * o +
32 (u.cache_read_input_tokens || 0) * r +
33 (u.cache_creation_input_tokens || 0) * w) /
34 1e6
35 )
36}
37hooks/updates.ts 70 lines1/**
2 * Safe updates and the patch keeper (pure, no `$`): where each mod comes from, whether its author published something
3 * newer, and whether you changed it yourself. register.tsx runs the git and claude commands; this reads their output.
4 */
5import type { UpdateInfo } from '../types'
6
7/** Where a plugin's code comes from, read from ~/.claude/plugins/known_marketplaces.json. */
8export type Source =
9 | { kind: 'folder'; marketplace: string; dir: string }
10 | { kind: 'github'; marketplace: string; dir: string; repo: string }
11 | { kind: 'other'; marketplace: string }
12
13export const marketplaceOf = (id: string) => id.split('@')[1] ?? ''
14
15export function sourceOf(id: string, knownJson: string): Source {
16 const marketplace = marketplaceOf(id)
17 try {
18 const known = JSON.parse(knownJson) as Record<string, { source?: { source?: string; repo?: string; path?: string }; installLocation?: string }>
19 const m = known[marketplace]
20 const dir = m?.installLocation ?? m?.source?.path ?? ''
21 if (m?.source?.source === 'directory' && dir) return { kind: 'folder', marketplace, dir }
22 if (m?.source?.source === 'github' && dir) return { kind: 'github', marketplace, dir, repo: m.source.repo ?? '' }
23 } catch {
24 // unreadable registry: nothing known
25 }
26 return { kind: 'other', marketplace }
27}
28
29/** The version a marketplace lists for a plugin, from its marketplace.json. */
30export function listedVersion(marketplaceJson: string, name: string): string | null {
31 try {
32 const data = JSON.parse(marketplaceJson) as { plugins?: { name?: string; version?: string }[] }
33 return data.plugins?.find(p => p.name === name)?.version ?? null
34 } catch {
35 return null
36 }
37}
38
39/** -1, 0 or 1, comparing dotted versions numerically (1.10.0 > 1.9.2); a missing part counts as 0. */
40export function compareVersions(a: string, b: string): number {
41 const pa = a.replace(/^v/, '').split(/[.+-]/).map(n => Number.parseInt(n, 10) || 0)
42 const pb = b.replace(/^v/, '').split(/[.+-]/).map(n => Number.parseInt(n, 10) || 0)
43 for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
44 const d = (pa[i] ?? 0) - (pb[i] ?? 0)
45 if (d !== 0) return d > 0 ? 1 : -1
46 }
47 return 0
48}
49
50/** The files `git status --porcelain` lists: a status code and a path (the new one for a rename). */
51export function changedList(porcelain: string): { status: string; path: string }[] {
52 return porcelain
53 .split('\n')
54 .filter(l => l.trim() !== '')
55 .map(l => {
56 const rest = l.slice(3)
57 const arrow = rest.lastIndexOf(' -> ')
58 return { status: l.slice(0, 2).trim(), path: arrow >= 0 ? rest.slice(arrow + 4) : rest }
59 })
60}
61
62/** Files `git status --porcelain` lists as changed. */
63export const changedFiles = (porcelain: string) => porcelain.split('\n').filter(l => l.trim() !== '').length
64
65/** Whether there is anything to tell: an update, or your own changes. */
66export const isNews = (u: UpdateInfo | undefined) => !!u && (u.behind > 0 || u.latest !== null || u.changed > 0)
67
68/** Whether an update is waiting. */
69export const hasUpdate = (u: UpdateInfo | undefined) => !!u && (u.behind > 0 || u.latest !== null)
70types/index.d.ts 96 lines1// Loadout's state contract.
2
3/** What a mod's code can do, read from `claude plugin validate`. */
4export type Cap = 'cmd' | 'write' | 'net' | 'prompt' | 'model' | 'tools' | 'gate' | 'system' | 'pane'
5
6/** One installed mod or plugin as the panel shows it. */
7export type ModRow = {
8 id: string
9 /** Where it was installed from: a marketplace on GitHub, a folder (a git copy or not) on this computer, or other. */
10 origin?: { kind: 'github' | 'folder' | 'other'; repo: string | null }
11 label: string
12 enabled: boolean
13 /** true when it ships function hooks (a mod with UI), false for a classic plugin. */
14 isMod: boolean
15 builtin: boolean
16 version: string
17 /** What it does, one line. */
18 about: string
19 /** Glyph drawn beside its name. */
20 icon: string
21 /** Where its code lives (for the capability scan). */
22 path: string | null
23 /** Pinned commit, short. */
24 pin: string | null
25}
26
27/** What a mod spent on the model in this session (its own `$.model` calls). */
28export type Spend = { calls: number; tokens: number; usd: number }
29
30/** What Loadout knows about one mod's updates and your own changes to it. */
31export type UpdateInfo = {
32 source: 'folder' | 'github' | 'other'
33 /** Commits the author pushed past the pinned one (a folder that is a git clone). */
34 behind: number
35 /** A newer version in the marketplace (a GitHub marketplace). */
36 latest: string | null
37 /** Files you changed in a pinned clone: kept and re-applied on update. */
38 changed: number
39 /** Your changed files in a pinned clone, from git status. */
40 files: { status: string; path: string }[]
41 /** Where your changes are backed up as a patch, when there are any. */
42 patch: string | null
43 /** The pinned clone's folder, or the refreshed marketplace's. */
44 dir: string | null
45 /** The author's ref a pinned clone is compared with: its upstream branch, or origin/HEAD for a detached pin. */
46 ref: string | null
47 /** An audited update was asked of Claude and has not been checked again yet. */
48 requested?: boolean
49 /** owner/name of its GitHub repository, when it comes from one. */
50 repo: string | null
51}
52
53/** A mod the audit gate kept from loading. */
54export type Blocked = { name: string; version: string; root: string; caps: Cap[] }
55
56/** One approved mod in ~/.claude/loadout/approved.json. */
57export type Approval = { version: string; by: 'audit' | 'manual'; approvedAt: string }
58
59declare module 'claude-code' {
60 interface PluginState {
61 loadout: {
62 mods: ModRow[]
63 /** Commands in this session and the plugin that added each. */
64 commands: { name: string; plugin?: string }[]
65 /** Id of the mod waiting for a second click to uninstall. */
66 confirm: string | null
67 /** Id of the mod whose card shows its extra actions. */
68 expanded: string | null
69 /** The card whose long description is unfolded. */
70 aboutOpen: string | null
71 /** The card whose list of your changes is unfolded. */
72 changesOpen: string | null
73 /** Id of the mod with an action running. */
74 busy: string | null
75 /** Which list the panel shows. */
76 tab: 'mods' | 'plugins'
77 /** Capabilities per mod id. */
78 caps: Record<string, Cap[]>
79 /** Whether the Loadout panel is open (lights its button). */
80 paneUp: boolean
81 /** Mods the audit gate kept from loading. */
82 blocked: Blocked[]
83 /** The mods seen drawing a band of their own, in the order first seen. */
84 bands: string[]
85 /** The mods whose band you hid. */
86 hiddenBands: string[]
87 /** Model spend per plugin name, this session. */
88 spend: Record<string, Spend>
89 /** Updates and your own changes, per plugin id. */
90 updates: Record<string, UpdateInfo>
91 /** Whether an update check is running. */
92 checking: boolean
93 }
94 }
95}
96