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…

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.
/plugin install loadouts --marketplace JesseVent/claude-mods
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…"
}
| Field | What it holds |
|---|---|
name | Lowercase letters, digits and dashes, up to 30 characters |
skills | Skill names as /skills lists them: supabase:supabase for a plugin's skill, nextjs-best-practices for one of your own |
mcpServers | Servers 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> |
instructions | Text 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.
| Command | What it does |
|---|---|
/loadout | Opens 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>.
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)./name.❯ ./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.
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.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.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.claude plugin marketplace add <folder>), and edits take effect with no reinstall./cd moved it to). A session started in a subfolder of a repo is its own project./plugin uninstall loadouts@claude-modshooks/register.tsx 286 lines1import 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