SLOPSHOPPER

loadouts

Requip for Claude Code: named loadouts of skills, MCP servers and instructions, one equipped per project from the /loadout picker or by Claude itself, the rest…

newpaneguardcommandtoaststatus
v0.1.0MITupdated 2026-10-25JesseVent/claude-mods/plugins/loadouts
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · loadouts
│ ┃ Loadouts ✕ › fix the failing auth test and add an audit log call │ ┃ No loadouts in /plugins/loadouts/loadouts │ ┃ [ Save as loadout ] ⏺ Read(src/auth.ts) │ ┃ Enter equips · Esc closes ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /loadout │ ⎿ loadouts: No loadouts in /plugins/loadouts/loadouts. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Loadouts
No loadouts in /plugins/loadouts/loadouts [ Save as loadout ] Enter equips · Esc closes
README

loadouts

Requip for Claude Code, after Erza Scarlet's magic in Fairy Tail: named loadouts of skills, MCP servers and instructions, one equipped per project. A supabase loadout for the backend repo, a nextjs loadout for the frontend: equip one and its skills, servers and instructions are in effect, and the previously equipped loadout's go out. You equip from the /loadout picker, and Claude can requip itself with its equip tool when the work changes. Switching never needs a restart.

/loadout                 → opens the picker:
                             ● nextjs
                               skills: nextjs-best-practices, vercel-react-best-practices · servers: next-devtools
                               supabase
                               skills: supabase:supabase, supabase:supabase-postgres-best-practices · servers: supabase
                             Enter equips · Esc closes
Claude calls supabase    → refused: The supabase server is in the supabase loadout and nextjs is equipped …
/loadout supabase        → Unequipped nextjs; equipped supabase in ~/code/app.

Built and tested on Claude Code 2.1.310.

Install

/plugin install loadouts --marketplace JesseVent/claude-mods

Loadouts

One JSON file per loadout in the mod's loadouts/ folder. Add a file or edit one, then run /loadout <name>: the gates read the folder on every call, but Claude's instructions note, its skill listing and the server list are refreshed only when you equip (see Limits).

{
  "name": "supabase",
  "skills": ["supabase:supabase", "supabase:supabase-postgres-best-practices"],
  "mcpServers": {
    "supabase": { "type": "http", "url": "https://mcp.supabase.com/mcp" }
  },
  "instructions": "This project's backend is Supabase. Use the supabase MCP server's tools for the database…"
}
FieldWhat it holds
nameLowercase letters, digits and dashes, up to 30 characters
skillsSkill names as /skills lists them: supabase:supabase for a plugin's skill, nextjs-best-practices for one of your own
mcpServersServers in the same shape as a .mcp.json's mcpServers. Their names follow the loadout name's rules, because they become tool names: mcp__plugin_loadouts_<server>__<tool>
instructionsText added to Claude's system prompt while the loadout is equipped

The mod ships two examples, supabase and nextjs (the next-devtools-mcp server, run with bunx). A file that doesn't parse, or breaks a rule above, is skipped, and the start and /loadout say which and why. Two loadouts can share a server only if they define it the same way.

Commands

CommandWhat it does
/loadoutOpens the picker: every loadout, ● on the one equipped in this project, its skills and servers beneath. Enter (or a click) equips one and closes the picker; Tab or the arrow keys move between rows. Save as loadout, at the bottom, writes what's equipped here (its skills, servers and instructions, or nothing if no loadout is equipped) to a new file, loadouts/loadout-<date>-<time>.json. A toast confirms it, and the list shows it at once. To rename it, change its name in the file
/loadout <name>Equips that loadout in this project (the project root), remembered across sessions

Claude has one tool, mcp__loadouts__equip, that does the same as /loadout <name>. The status line shows loadout: <name>.

How it works

  • A loadout only manages what it names. A skill or server some loadout names is on only while that loadout is equipped, and with no loadout equipped all of them are off. Skills, servers and tools that no loadout names are left alone.
  • Every loadout's servers are loaded; the gate decides. The mod writes all of them to loadouts.mcp.json, which its manifest names, so the servers join at the start. A call to a server of a loadout that isn't equipped is refused before it runs. So is a read of its MCP resources (ReadMcpResourceTool and the other resource tools, matched by the server they name). Equipping changes the gate, not the server list, so it needs no reload unless a loadout's mcpServers changed since the list was last written (the first bullet under Limits).
  • A resource listing that names no server is always refused, since it would reach every server; Claude names one and retries.
  • If the check itself fails, a call to a loadout server is refused too.
  • Skills. The skills of loadouts that aren't equipped are left out of the skill listing Claude reads, the listing already in the conversation included. A call to one through the Skill tool is refused, and so is a typed /name.
  • Claude knows. The system prompt's environment section names the equipped loadout with its instructions, skills and server tool prefixes, plus the other loadouts and what's in them. Claude re-reads it from the next request after each equip.

What it can reach

❯ ./register.tsx hooks: session.start, tool.check, tool.call{tool=mcp__loadouts__equip}, skill.prompt, prompt.attachment{type=skill_listing}, prompt.section{name=env_info_simple}, ui.render{component=Pane, requestId=loadouts}, command.run{command=loadout} ❯ ./register.tsx calls: $.clock.after (via reload), $.command.register, $.command.run (via reload), $.fs.exists (via capture), $.fs.list (via load), $.fs.read (via load, sync), $.fs.write (via capture, sync), $.session.root (via equip, equipped), $.store.get (via get), $.store.set (via equip), $.tool.register, $.ui.close, $.ui.invalidate, $.ui.log, $.ui.open (via picker), $.ui.resolve, $.ui.status, $.ui.toast

It reads its own loadouts/ folder and writes loadouts.mcp.json in its own folder, plus a new file in loadouts/ each time you press Save as loadout. Its store holds each project root's equipped loadout.

Limits

  • Adding or changing a loadout's servers takes a reload, which the mod runs. Claude Code reads a plugin's server list only when it loads the plugin, and saving a loadout file reloads nothing by itself. The mod rewrites the list at each start, and again when you equip after editing a loadout's mcpServers, then queues /reload-plugins. After /loadout <name> that reload runs as soon as the command ends, and the new server connects: checked live with a server added mid-session. If Claude Code refuses the reload, the mod logs a line asking you to run /reload-plugins.
  • Other edits reach Claude at the next equip. An edited skills list or instructions, or a new loadout file, reaches the Skill-tool and /name gates at once. Claude's instructions note and its skill listing change only at the next equip or the next session, so run /loadout <name> after editing.
  • An unequipped server stays connected. Its tools are still listed for Claude, behind tool search; every call to them is refused, and the system prompt says they're off.
  • A loadout server takes over another server with the same URL. Claude Code runs one server per URL. With the supabase example, equipped or not, the official Supabase plugin's plugin:supabase:supabase server doesn't load, and /mcp shows the claude.ai Supabase connector as hidden: same URL as plugin:loadouts:supabase. That keeps Supabase behind the loadout's gate. Keep the Supabase plugin enabled, since the example's skills are its own, and sign the loadout's server in once: /mcp → plugin:loadouts:supabase → Authenticate.
  • The loadout files live in the mod's folder. Installed from GitHub, that folder is a copy, and an update replaces it: keep your loadouts in a clone of the repository, installed as a folder marketplace (claude plugin marketplace add <folder>), and edits take effect with no reinstall.
  • Projects are keyed by the session's root, the folder the session started in (or /cd moved it to). A session started in a subfolder of a repo is its own project.

Uninstall

/plugin uninstall loadouts@claude-mods
Source 1 files
hooks/register.tsx 286 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3type Engine = EngineInterface
4/** One file in loadouts/: what equipping it puts in effect. */
5export type Loadout = { name: string; skills: string[]; mcpServers: Record<string, unknown>; instructions: string }
6/** Per project root, the loadout equipped there. */
7type Equipped = Record<string, string>
8
9const DIR = 'loadouts'
10const FILE = 'loadouts.mcp.json'
11/** How the engine names this mod's servers' tools: `mcp__plugin_loadouts_<server>__<tool>`. */
12const PREFIX = 'mcp__plugin_loadouts_'
13const EQUIP = 'mcp__loadouts__equip'
14const PANE = 'loadouts'
15/** Loadout and server names: a server's becomes part of its tools' names, which keep only these. */
16const NAME = /^[a-z0-9][a-z0-9-]{0,29}$/
17
18const get = async <T,>($: Engine, key: string, empty: T) => ((await $.store.get(key)) ?? empty) as T
19const list = (xs: readonly string[]) => xs.join(', ') || 'none'
20
21/** A loadout file read, or why it isn't one. */
22export function parse(file: string, text: string): Loadout | string {
23  let raw: unknown
24  try {
25    raw = JSON.parse(text)
26  } catch {
27    return `${file} isn't JSON`
28  }
29  const l = (raw ?? {}) as Partial<Record<keyof Loadout, unknown>>
30  if (typeof l.name !== 'string' || !NAME.test(l.name)) return `${file}: "name" is up to 30 lowercase letters, digits and dashes`
31  if (l.skills !== undefined && !(Array.isArray(l.skills) && l.skills.every(s => typeof s === 'string'))) return `${file}: "skills" is a list of skill names`
32  const servers = l.mcpServers ?? {}
33  if (typeof servers !== 'object' || servers === null || Array.isArray(servers)) return `${file}: "mcpServers" is an object, as in .mcp.json`
34  const bad = Object.entries(servers).find(([k, v]) => !NAME.test(k) || typeof v !== 'object' || v === null)
35  if (bad) return `${file}: server "${bad[0]}" needs a name of lowercase letters, digits and dashes, and an object as in .mcp.json`
36  if (l.instructions !== undefined && typeof l.instructions !== 'string') return `${file}: "instructions" is a string`
37  return { name: l.name, skills: (l.skills as string[] | undefined) ?? [], mcpServers: servers as Record<string, unknown>, instructions: l.instructions ?? '' }
38}
39
40/** Every loadout in the mod's loadouts/ folder, by file name; a file that isn't one is left out and said why. */
41async function load($: Engine) {
42  const dir = `${$.plugin.root}/${DIR}`
43  const loadouts: Loadout[] = []
44  const problems: string[] = []
45  const defs = new Map<string, string>()
46  const files = (await $.fs.list(dir).catch(() => [])).filter(f => f.kind === 'file' && f.name.endsWith('.json')).map(f => f.name).sort()
47  for (const file of files) {
48    const l = parse(file, await $.fs.read(`${dir}/${file}`).catch(() => ''))
49    if (typeof l === 'string') {
50      problems.push(l)
51      continue
52    }
53    if (loadouts.some(o => o.name === l.name)) {
54      problems.push(`${file}: the name ${l.name} is taken by another file`)
55      continue
56    }
57    // Two loadouts may share a server only as one server: the same definition.
58    const clash = Object.keys(l.mcpServers).find(k => defs.has(k) && defs.get(k) !== JSON.stringify(l.mcpServers[k]))
59    if (clash) {
60      problems.push(`${file}: server "${clash}" is defined differently by another loadout`)
61      continue
62    }
63    for (const [k, v] of Object.entries(l.mcpServers)) defs.set(k, JSON.stringify(v))
64    loadouts.push(l)
65  }
66  return { loadouts, problems }
67}
68
69async function equipped($: Engine, loadouts: readonly Loadout[]) {
70  const name = (await get<Equipped>($, 'equipped', {}))[await $.session.root()]
71  return loadouts.find(l => l.name === name)
72}
73
74/** The manifest's server list: every loadout's servers, so equipping changes the gate and never the servers loaded. */
75export function servers(loadouts: readonly Loadout[]) {
76  return { mcpServers: Object.assign({}, ...loadouts.map(l => l.mcpServers)) as Record<string, unknown> }
77}
78
79/** Writes the manifest's server list: true when it changed, and the engine has to reload it. */
80async function sync($: Engine, loadouts: readonly Loadout[]) {
81  const text = `${JSON.stringify(servers(loadouts), null, 2)}\n`
82  const path = `${$.plugin.root}/${FILE}`
83  if ((await $.fs.read(path).catch(() => '')) === text) return false
84  await $.fs.write(path, text)
85  return true
86}
87
88/** From a timer, since the host refuses it from a hook the turn waits on; when it is refused anyway, the person runs it. */
89function reload($: Engine) {
90  $.clock.after(0, () => void $.command.run({ command: 'reload-plugins' }).catch(err => $.ui.log(`could not reload the loadout servers (${String(err)}): run /reload-plugins`)))
91}
92
93const contents = (l: Loadout) => `skills: ${list(l.skills)} · servers: ${list(Object.keys(l.mcpServers))}`
94const tools = (l: Loadout) => Object.keys(l.mcpServers).map(s => `${PREFIX}${s}__*`)
95
96/** Skills some loadout names and the equipped one doesn't: off. A skill no loadout names is left alone. */
97async function offSkills($: Engine) {
98  const { loadouts } = await load($)
99  const on = new Set((await equipped($, loadouts))?.skills)
100  return new Set(loadouts.flatMap(l => l.skills).filter(s => !on.has(s)))
101}
102
103async function note($: Engine) {
104  const { loadouts } = await load($)
105  if (!loadouts.length) return ''
106  const on = await equipped($, loadouts)
107  const others = loadouts.filter(l => l !== on).map(l => `${l.name} (${contents(l)})`)
108  const head = on
109    ? [
110        `The ${on.name} loadout is equipped in this project.`,
111        on.instructions && `Its instructions: ${on.instructions}`,
112        `Its skills: ${list(on.skills)}. Its MCP servers: ${list(Object.keys(on.mcpServers))}${tools(on).length ? ` (the ${tools(on).join(' and ')} tools)` : ''}.`,
113      ]
114    : ['No loadout is equipped in this project.']
115  return [
116    `Loadouts (loadouts mod): ${head[0]}`,
117    ...head.slice(1).filter(Boolean),
118    others.length ? `Not equipped: ${others.join('; ')}. Their skills and MCP servers are off: calls to them are refused, and the skill listing leaves their skills out, so earlier turns may name skills that are off now.` : '',
119    `When the work calls for another loadout, equip it with the ${EQUIP} tool; the one equipped now is unequipped.`,
120  ]
121    .filter(Boolean)
122    .join('\n')
123}
124
125/** Why a call reaching this mod's server `server` is refused, or undefined when its loadout is equipped. */
126async function refusal($: Engine, server: string) {
127  const { loadouts } = await load($)
128  const on = await equipped($, loadouts)
129  if (on && Object.hasOwn(on.mcpServers, server)) return undefined
130  const owners = loadouts.filter(l => Object.hasOwn(l.mcpServers, server)).map(l => l.name)
131  const now = on ? `${on.name} is equipped` : 'no loadout is equipped'
132  return owners.length
133    ? `The ${server} server is in the ${owners.join(' and ')} loadout and ${now}: if the task needs it, equip ${owners[0]} with the ${EQUIP} tool.`
134    : `No loadout has a ${server} server any more, and ${now}.`
135}
136
137const skillOff = (name: string) => `The ${name} skill is in a loadout that isn't equipped in this project, so it is off: if the task needs it, equip its loadout with the ${EQUIP} tool.`
138
139/** The mod's server a call reaches: by its own tool name, or by the `server` the MCP resource tools take (`plugin:loadouts:supabase`). */
140function target(tool: string, input: unknown) {
141  if (tool.startsWith(PREFIX)) return tool.slice(PREFIX.length).split('__')[0]
142  const named = (input as { server?: unknown } | null)?.server
143  const m = typeof named === 'string' ? /^plugin[:_]loadouts[:_](.+)$/.exec(named) : null
144  return m?.[1]
145}
146
147/** A resource listing that names no server reaches every server, unequipped loadouts' included, so it is refused. */
148const unnamed = (tool: string, input: unknown) => tool === 'ListMcpResourcesTool' && !(input as { server?: unknown } | null)?.server
149
150/** Equips `name` in this project, unequipping the one before. */
151async function equip($: Engine, name: string) {
152  const { loadouts } = await load($)
153  if (!loadouts.some(l => l.name === name)) return `There's no loadout named ${name}: ${list(loadouts.map(l => l.name))}.`
154  const root = await $.session.root()
155  const all = await get<Equipped>($, 'equipped', {})
156  const was = all[root]
157  await $.store.set('equipped', { ...all, [root]: name })
158  // A loadout file gained or changed a server since the start: that one needs the engine to reload the manifest.
159  if (await sync($, loadouts)) reload($)
160  $.ui.status(`loadout: ${name}`)
161  // Claude's note and the skill listing it already holds change from the next request on.
162  $.ui.invalidate('prompt.section')
163  $.ui.invalidate('prompt.attachment')
164  $.ui.invalidate('ui.render')
165  return was && was !== name ? `Unequipped ${was}; equipped ${name} in ${root}.` : `Equipped ${name} in ${root}.`
166}
167
168const pad = (n: number) => String(n).padStart(2, '0')
169/** `loadout-20261024-151830`: the capture's moment in local time, within the name rules. */
170const stamp = (d: Date) => `loadout-${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`
171
172/** Saves what is in effect here, the equipped loadout's skills, servers and instructions, as a new loadout file. */
173async function capture($: Engine) {
174  const { loadouts } = await load($)
175  const now = await equipped($, loadouts)
176  const name = stamp(new Date())
177  const path = `${$.plugin.root}/${DIR}/${name}.json`
178  if (loadouts.some(l => l.name === name) || (await $.fs.exists(path))) throw new Error(`${name} already exists`)
179  const loadout: Loadout = { name, skills: now?.skills ?? [], mcpServers: now?.mcpServers ?? {}, instructions: now?.instructions ?? '' }
180  await $.fs.write(path, `${JSON.stringify(loadout, null, 2)}\n`)
181  return name
182}
183
184/** Opens the picker, or resizes the open one, to fit `count` loadouts: two rows each, the button and the hint. */
185const picker = ($: Engine, count: number) => $.ui.open({ id: PANE, title: 'Loadouts', focus: true, closeOnEscape: true, rows: count * 2 + 2 })
186
187/** What /loadout records when it opens the picker: no ●, since a pick in the pane would leave it stale in the transcript. */
188async function summary($: Engine) {
189  const { loadouts, problems } = await load($)
190  const rows = loadouts.map(l => `${l.name}   ${contents(l)}`)
191  return [rows.length ? rows.join('\n') : `No loadouts in ${$.plugin.root}/${DIR}.`, ...problems.map(p => `Skipped ${p}.`)].join('\n')
192}
193
194export const register: Register = on => {
195  on('session.start', async ($, e, next) => {
196    await $.command.register({ name: 'loadout', description: 'Equip a loadout of skills, MCP servers and instructions in this project', argumentHint: '[<loadout>]' })
197    await $.tool.register({
198      name: 'equip',
199      description: "Equips a loadout in this project: its skills, MCP servers and instructions go into effect and the previously equipped loadout's go out. The system prompt's Loadouts note lists the loadouts.",
200      inputSchema: { type: 'object', properties: { loadout: { type: 'string', description: "The loadout's name" } }, required: ['loadout'] },
201      isDeferred: false,
202    })
203    const { loadouts, problems } = await load($)
204    for (const p of problems) $.ui.log(`skipped ${p}`)
205    if (await sync($, loadouts)) reload($)
206    const now = await equipped($, loadouts)
207    $.ui.status(now && `loadout: ${now.name}`)
208    return next(e)
209  })
210
211  // Refused, never waved through, when the check itself fails: the call could reach an unequipped loadout's server.
212  on('tool.check', async ($, e, next) => {
213    if (unnamed(e.tool, e.input)) {
214      return { decision: 'deny', reason: "Name a server: loadouts refuses a listing of every server's resources, which could include a loadout that isn't equipped." }
215    }
216    const named = e.tool === 'Skill' ? (e.input as { skill?: unknown } | null)?.skill : undefined
217    if (typeof named === 'string' && (await offSkills($)).has(named)) return { decision: 'deny', reason: skillOff(named) }
218    const server = target(e.tool, e.input)
219    const reason = server && (await refusal($, server))
220    return reason ? { decision: 'deny', reason } : next(e)
221  }).catch(($, e, next) => (target(e.tool, e.input) ? { decision: 'deny', reason: 'loadouts could not tell which loadout is equipped.' } : next(e)))
222
223  on('tool.call', { tool: EQUIP }, async ($, e) => {
224    const name = (e as { loadout?: unknown }).loadout
225    return { result: typeof name === 'string' ? await equip($, name) : 'Name the loadout to equip.' }
226  })
227
228  // A skill typed as /name or preloaded; the Skill tool is refused before this, at tool.check.
229  on('skill.prompt', async ($, e, next) => ((await offSkills($)).has(e.skill) ? { text: skillOff(e.skill) } : next(e)))
230
231  on('prompt.attachment', { type: 'skill_listing' }, async ($, e, next) => {
232    const r = await next(e)
233    const off = await offSkills($)
234    if (r.text === null || !off.size) return r
235    const isOff = (line: string) => [...off].some(s => line === `- ${s}` || line.startsWith(`- ${s}:`) || line.startsWith(`- ${s} `))
236    return { text: r.text.split('\n').filter(line => !isOff(line)).join('\n') }
237  }).catch(($, e, next) => next(e))
238
239  // A section, not prompt.compose: the engine keeps the system prompt it composed, and asks a section again on invalidate.
240  on('prompt.section', { name: 'env_info_simple' }, async ($, e, next) => {
241    const r = await next(e)
242    const n = await note($)
243    return n ? { text: [r.text, n].filter(Boolean).join('\n\n') } : r
244  }).catch(($, e, next) => next(e))
245
246  // The picker: a row per loadout, ● on the one equipped here, its skills and servers beneath. Enter equips and closes;
247  // Save as loadout copies what's equipped into a new file, which the list then shows, and the pane stays open.
248  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
249    const { Box, Text, Button } = $.ui.resolve(e)
250    const { loadouts } = await load($)
251    const now = await equipped($, loadouts)
252    const pick = async (name: string) => {
253      await equip($, name)
254      await $.ui.close({ id: PANE })
255    }
256    const save = async () => {
257      try {
258        $.ui.toast(`Saved ${await capture($)}`)
259        await picker($, loadouts.length + 1)
260      } catch (err) {
261        $.ui.log(`could not save the loadout (${String(err)})`)
262      }
263      $.ui.invalidate('ui.render')
264    }
265    const rows = []
266    for (const l of loadouts) {
267      rows.push(<Button key={l.name} plain label={`${l === now ? '●' : ' '} ${l.name}`} {...(l === now ? { autoFocus: true as const } : {})} onPress={() => pick(l.name)} />)
268      rows.push(<Text dimColor>{`  ${contents(l)}`}</Text>)
269    }
270    return (
271      <Box flexDirection="column">
272        {rows.length ? rows : <Text dimColor>No loadouts in {`${$.plugin.root}/${DIR}`}</Text>}
273        <Button key="save" label="Save as loadout" onPress={save} />
274        <Text dimColor>Enter equips · Esc closes</Text>
275      </Box>
276    )
277  })
278
279  on('command.run', { command: 'loadout' }, async ($, e) => {
280    const name = e.args.trim()
281    if (name) return { text: await equip($, name) }
282    await picker($, (await load($)).loadouts.length)
283    return { text: await summary($) }
284  })
285}
286