SLOPSHOPPER

loadout

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…

newpanebandcommandtoastprocess
★ 1v0.7.2MITupdated 2026-10-14Br4inergy/claude-loadout
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · loadout
│ ┃ Loadout ✕ › fix the failing auth test an╭────────────────────────────────────╮ │ ┃ ⚔ Inventory · 0 of 0 active │ loadout │ │ ┃ ⏺ Read(src/auth.ts) │ Couldn't do it: claude plugin list │ │ ┃ [ Mods 0 ] [ Plugins 0 ] ⎿ Read 6 lines ╰────────────────────────────────────╯ │ ┃ ⏺ Update(src/auth.ts) │ ┃ Loading your loadout… ⎿ Added 2 lines, removed 1 line │ ┃ [ ↻ Check updates ] [ Refresh ] ⏺ Bash(bun test) │ ┃ 🛡 New versions never run until you approve ⎿ 3 pass, 1 fail │ ┃ them. │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Bands above the prompt │ ┃ No other mod has drawn a band yet. ✻ Worked for 42s · done 4:20 PM │ │ › /loadout │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Loadout
⚔ Inventory · 0 of 0 active [ Mods 0 ] [ Plugins 0 ] Loading your loadout… [ ↻ Check updates ] [ Refresh ] 🛡 New versions never run until you approve them. Bands above the prompt No other mod has drawn a band yet.
README

⚔ Loadout — Mod Manager for Claude Code

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.

Claude Code plugin License: MIT macOS · Linux · Windows Uses no tokens of its own

<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

Why 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.

1. 🛡 Every mod is audited before it can run

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.

2. 🔄 Updates are audited too, not just installs

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.

3. ✍️ Your own changes survive updates

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.

4. 🔍 See what each mod's code can really do

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.

5. 🎒 One inventory for everything

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.

Install

/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).

How it works

  1. Install a mod the way you always do. Loadout sees it and holds it before its code loads.
  2. Press Audit with Claude. Claude reviews the code and gives you a verdict with evidence.
  3. Approve it. Loadout records the name and version in ~/.claude/loadout/approved.json and the mod loads.
  4. When an update is out, Loadout tells you. Audit & update reviews the change, installs it and keeps your own edits.

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.

FAQ

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.

Rarity

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:

RarityColourWhen
PoorgreyTurned off
CommonwhiteA plain plugin (skills, commands, MCP servers): the audit gate does not cover it
UncommongreenA mod: it passed the audit gate to load
RareblueIts code draws a panel of its own
EpicpurpleIts code runs commands on your machine
LegendaryorangeLoadout 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.

Files

All under ~/.claude/loadout/:

FileWhat it holds
approved.jsonThe mods and versions you approved: `"name": { "version", "by": "audit" \"manual", "approvedAt" }`
blocked.logEvery time the gate held a mod back
audit-log.txtOne 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

Language

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.

Development

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.

License

MIT. See LICENSE.

Source 8 files
hooks/register.tsx 713 lines
1/**
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}
713
hooks/bandas.ts 69 lines
1/**
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}
69
hooks/catalogo.ts 123 lines
1/**
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}
123
hooks/gate.ts 25 lines
1/**
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}
25
hooks/i18n.ts 238 lines
1/**
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}
238
hooks/pricing.ts 37 lines
1/**
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}
37
hooks/updates.ts 70 lines
1/**
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)
70
types/index.d.ts 96 lines
1// 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