Harness manager: switchable profiles, scope provenance and on/off for every harness element

A Claude Code mod that manages your harness: every skill, rule, memory, MCP server, hook, plugin, permission and setting, with one switch each and profiles to swap whole setups.

/plugin marketplace add MK27MK/loadout
/plugin install loadout@loadout
To run it from a local clone instead:
claude --plugin-dir path/to/loadout
/loadout opens the pane. Elements are grouped by kind, and each row is one harness element:
☑ ecc:plan plugin plugin ecc (user)
☐ rules/testing.md user ~/.claude/rules/ecc/testing.md
☑ hooks.Stop policy locked · managed settings
Project picks the project whose files are listed, Scope filters by scope.▸ menu applies, duplicates, renames or deletes it.+ duplicates the profile you are looking at under a new name.Refresh reads the harness again.Keys: Tab moves between controls, Enter presses, Esc returns to the prompt.
| Kind | Off means | Writes a file |
|---|---|---|
| CLAUDE.md, rules, memories | The file is left out of the context | No |
| Skills and commands | The command is hidden and refuses to run | No |
| Agents | The agent is not offered to the model | No |
| MCP servers | Every call to the server is denied | No |
| Hook events | All hooks of that event are skipped | No |
| Hooks in settings | The hook is removed from its settings.json | Yes |
| Plugins | enabledPlugins is set to false | Yes |
| Permissions | The rule is removed from its settings.json | Yes |
| Settings | The key is removed from its settings.json | Yes |
Plugin hooks can only be skipped per event, not one by one. After a plugin toggle, run /reload-plugins.
Every row shows where the element comes from:
| Scope | Source |
|---|---|
policy | Managed settings of your organization. Always locked. |
flag | Command-line flags. Always locked. |
user | Your config directory (~/.claude, or CLAUDE_CONFIG_DIR) |
project | .claude/ and .mcp.json in the project |
local | .claude/settings.local.json |
plugin | An installed plugin, with the scope it was installed in |
account | A claude.ai connector |
memory | A memory file |
mixed | A hook event with hooks from more than one scope |
A profile starts with everything on or everything off, and remembers the elements you toggled.
default: your harness as it is.vanilla: everything off, except permissions and settings. It is built in and cannot be changed, renamed or deleted. Duplicate it to build on it.To compose a setup from several harnesses, create a profile that starts with everything off and switch on the pieces you want:
/loadout new ecc-react off
| Command | Does | |
|---|---|---|
/loadout | Opens the pane | |
/loadout status | Lists what is off in the active profile | |
/loadout list | Lists the profiles | |
/loadout use <profile> | Switches profile | |
| `/loadout new <profile> [on\ | off]` | Creates a profile with everything on or everything off. Without either, copies the active one. |
/loadout rename <profile> <new name> | Renames a profile | |
/loadout delete <profile> | Deletes a profile | |
/loadout project [<folder>] | Lists the files of another project folder. Without a folder, goes back to the session's project. |
Only you can change profiles. A /loadout use that you did not type is refused.
<config dir>/loadout-backups/.claude plugin validate .
claude plugin test .
The engine does not allow $ to cross an import, so every engine call is in hooks/register.tsx. The other files in hooks/ are pure functions.
hooks/register.tsx 1035 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PromptOrigin, Register } from 'claude-code'
3
4import type { Item, Kind, Ledger, Profile, ProfileAction, View } from '../types'
5import {
6 backupPathOf,
7 ledgerAfter,
8 messageOf,
9 parseJsonObject,
10 pathsFrom,
11 rewrittenSettings,
12} from './files'
13import {
14 agentItem,
15 commandItems,
16 enabledPluginHookFiles,
17 hookEventItems,
18 instructionItem,
19 mcpItems,
20 mcpServerNames,
21 mcpServerOf,
22 pluginHookSources,
23 pluginScopes,
24 projectCandidates,
25 settingsFound,
26 sorted,
27} from './inventory'
28import type { SettingsReading } from './inventory'
29import {
30 DEFAULT_PROFILE_NAME,
31 EMPTY_LEDGER,
32 INITIAL_PROFILES,
33 PLUGIN,
34 desiredState,
35 fallbackProfile,
36 findProfile,
37 isEnabled,
38 isFixedProfile,
39 isOnIn,
40 isShownOn,
41 isValidProfileName,
42 nameProblem,
43 removeProfile,
44 renameProfile,
45 restoredProfiles,
46 upsertProfile,
47 withOverride,
48} from './model'
49import {
50 fileItems,
51 inUserScope,
52 isMarkdownFile,
53 markersOf,
54 namedFilesOf,
55 rulesFoldersOf,
56 userFoldersOf,
57 visibleFolders,
58 withProjectFiles,
59 withSessionFiles,
60} from './project'
61import { isObject, isSoundStashedItem } from './settingsEdit'
62import { drawBand, drawPane, menuKeyOf } from './view'
63
64const PANE = 'loadout'
65const PANE_ROWS = 24
66const FALLBACK_ROWS = 30
67const FALLBACK_COLUMNS = 100
68const CORE_TIER = 'core'
69const PROFILES_KEY = 'profiles'
70const ACTIVE_KEY = 'active'
71const LEDGER_KEY = 'ledger'
72const WRITABLE_SOURCES = ['user', 'project', 'local'] as const
73const LOCKED_SOURCES = [
74 { source: 'policy', origin: 'managed settings' },
75 { source: 'flag', origin: 'command-line flag' },
76] as const
77const READ_ONLY_VERBS = ['', 'list', 'status']
78const PERSON_ORIGINS: readonly string[] = ['composer', 'bridge']
79const USAGE =
80 'Usage: /loadout [list | status | use <profile> | new <profile> [on|off] | rename <profile> <new name> | delete <profile> | project [<folder>]]'
81const NO_ITEMS: Item[] = []
82const NO_PROJECTS: string[] = []
83const MAX_RULES_DEPTH = 4
84const TRAILING_SLASHES = /\/+$/
85const STARTING_PROFILES: Profile[] = [...INITIAL_PROFILES]
86const INITIAL_VIEW: View = {
87 open: [],
88 scope: 'all',
89 page: 0,
90 project: '',
91 picker: 'none',
92 notice: '',
93 edit: 'none',
94 target: '',
95 viewed: '',
96}
97const VIEW_SHAPE = 'sections-v5'
98const FIRST_NAME_QUESTION =
99 'What should loadout call the profile that carries your current setup?'
100const FIRST_NAME_OPTIONS = {
101 header: 'Profile',
102 options: [DEFAULT_PROFILE_NAME, 'personal'],
103} as const
104
105const profilesAtom = atom({ plugin: 'loadout', key: 'profiles' } as const, STARTING_PROFILES)
106const activeAtom = atom({ plugin: 'loadout', key: 'active' } as const, DEFAULT_PROFILE_NAME)
107const loadedAtom = atom({ plugin: 'loadout', key: 'isLoaded' } as const, false)
108const seenAtom = atom({ plugin: 'loadout', key: 'seen' } as const, NO_ITEMS)
109const itemsAtom = atom({ plugin: 'loadout', key: 'items' } as const, NO_ITEMS)
110const projectsAtom = atom({ plugin: 'loadout', key: 'projects' } as const, NO_PROJECTS)
111const projectItemsAtom = atom(
112 { plugin: 'loadout', key: 'projectItems' } as const,
113 NO_ITEMS,
114)
115const viewAtom = atom({ plugin: 'loadout', key: 'view' } as const, INITIAL_VIEW, {
116 shape: VIEW_SHAPE,
117})
118
119let lastMutation: Promise<unknown> = Promise.resolve()
120let loading: Promise<boolean> | undefined
121
122const isProfile = (value: unknown): value is Profile =>
123 isObject(value) &&
124 typeof value.name === 'string' &&
125 isValidProfileName(value.name) &&
126 (value.base === 'on' || value.base === 'off') &&
127 isObject(value.overrides) &&
128 Object.values(value.overrides).every(one => typeof one === 'boolean')
129
130const settingsPaths = async ($: EngineInterface) =>
131 pathsFrom(
132 await $.env.get('HOME'),
133 await $.env.get('CLAUDE_CONFIG_DIR'),
134 await $.session.root(),
135 )
136
137const readObject = async ($: EngineInterface, path: string) => {
138 if (!(await $.fs.exists(path))) return { value: {}, text: undefined }
139 const text = await $.fs.read(path)
140
141 return { value: parseJsonObject(text, path), text }
142}
143
144const readOrWarn = async ($: EngineInterface, path: string) => {
145 try {
146 return { value: (await readObject($, path)).value, warnings: [] }
147 } catch (error) {
148 return { value: {}, warnings: [messageOf(error)] }
149 }
150}
151
152const isSymbolicLink = async ($: EngineInterface, path: string) => {
153 if (!(await $.fs.exists(path))) return false
154
155 return (await $.fs.stat(path)).isLink
156}
157
158const loadLedger = async ($: EngineInterface): Promise<Ledger> => {
159 const stored = await $.store.get(LEDGER_KEY)
160 if (!isObject(stored)) return EMPTY_LEDGER
161 const stash = isObject(stored.stash) ? stored.stash : {}
162 const raised = Array.isArray(stored.raised) ? stored.raised : []
163
164 return {
165 stash: Object.fromEntries(
166 Object.entries(stash).filter(
167 (pair): pair is [string, Item] =>
168 isSoundStashedItem(pair[1]) && pair[1].id === pair[0],
169 ),
170 ),
171 raised: raised.filter((id): id is string => typeof id === 'string'),
172 }
173}
174
175const storedProfiles = async ($: EngineInterface) => {
176 const stored = await $.store.get(PROFILES_KEY)
177 const isFirstRun = stored === undefined
178 const kept = Array.isArray(stored) ? stored.filter(isProfile) : []
179
180 return {
181 isFirstRun,
182 ...restoredProfiles(isFirstRun ? undefined : kept, await $.store.get(ACTIVE_KEY)),
183 }
184}
185
186const profilesNow = async ($: EngineInterface) => {
187 const held = {
188 profiles: await read($, profilesAtom),
189 active: await read($, activeAtom),
190 }
191
192 return (await read($, loadedAtom)) ? held : storedProfiles($)
193}
194
195const profilesOf = async ($: EngineInterface) => (await profilesNow($)).profiles
196
197const activeNameOf = async ($: EngineInterface) => (await profilesNow($)).active
198
199const activeProfile = async ($: EngineInterface) => {
200 const { profiles, active } = await profilesNow($)
201
202 return findProfile(profiles, active)
203}
204
205const shownProfile = async ($: EngineInterface) => {
206 const { viewed } = await read($, viewAtom)
207
208 return (
209 (await profilesOf($)).find(profile => profile.name === viewed) ?? activeProfile($)
210 )
211}
212
213const userFolders = async ($: EngineInterface) =>
214 userFoldersOf(await $.env.get('HOME'), await $.env.get('CLAUDE_CONFIG_DIR'))
215
216const focusShown = async ($: EngineInterface) => {
217 try {
218 const { deny } = await $.ui.focus({
219 requestId: PANE,
220 key: menuKeyOf((await shownProfile($)).name),
221 })
222 if (deny !== undefined) throw new Error(deny)
223 } catch (error) {
224 $.ui.log(`loadout left the focus where it was: ${messageOf(error)}`, { to: 'debug' })
225 }
226}
227
228const setView = ($: EngineInterface, patch: Partial<View>) =>
229 update($, viewAtom, view => ({ ...view, ...patch }))
230
231const noteSeen = async ($: EngineInterface, found: readonly Item[]) => {
232 await update($, seenAtom, seen => [
233 ...seen.filter(old => !found.some(item => item.id === old.id)),
234 ...found,
235 ])
236 await update($, itemsAtom, items => {
237 const unlisted = found.filter(item => !items.some(one => one.id === item.id))
238
239 return unlisted.length === 0 ? items : sorted([...items, ...unlisted])
240 })
241}
242
243const shownItems = async ($: EngineInterface) => {
244 const items = await read($, itemsAtom)
245 const files = await read($, projectItemsAtom)
246
247 return (await read($, viewAtom)).project === ''
248 ? withSessionFiles(items, files)
249 : withProjectFiles(items, files)
250}
251
252const say = async ($: EngineInterface, notice: string) => {
253 await setView($, { notice })
254 $.ui.toast(notice)
255
256 return notice
257}
258
259const invalidateGates = ($: EngineInterface) => {
260 $.ui.invalidate('prompt.context')
261 $.ui.invalidate('tool.describe')
262 $.ui.invalidate('command.describe')
263}
264
265const forgetProfile = ($: EngineInterface, name: string, renamedTo = '') =>
266 update($, viewAtom, view => ({
267 ...view,
268 ...(view.target === name ? { edit: 'none' as const, target: '' } : {}),
269 ...(view.viewed === name ? { viewed: renamedTo } : {}),
270 }))
271
272const saveProfiles = async ($: EngineInterface, profiles: Profile[]) => {
273 await update($, profilesAtom, () => profiles)
274 await $.store.set(PROFILES_KEY, profiles)
275}
276
277const readSettings = async ($: EngineInterface): Promise<SettingsReading[]> => {
278 const paths = await settingsPaths($)
279 const writable = WRITABLE_SOURCES.map(async (source): Promise<SettingsReading> => {
280 const path = paths[source]
281 try {
282 return { source, origin: path, value: (await readObject($, path)).value }
283 } catch (error) {
284 return { warning: messageOf(error) }
285 }
286 })
287 const locked = LOCKED_SOURCES.map(async ({ source, origin }): Promise<SettingsReading> => {
288 try {
289 const text = JSON.stringify(await $.settings.read({ source }))
290
291 return { source, origin, value: parseJsonObject(text, origin) }
292 } catch (error) {
293 return { warning: `${origin} could not be read: ${messageOf(error)}` }
294 }
295 })
296
297 return Promise.all([...writable, ...locked])
298}
299
300const refresh = async ($: EngineInterface) => {
301 const paths = await settingsPaths($)
302 const readings = await readSettings($)
303 const isPolicyKnown = readings.some(
304 reading => !('warning' in reading) && reading.source === 'policy',
305 )
306 const settings = settingsFound(readings, await loadLedger($))
307 const scopes = pluginScopes(settings.items)
308 const commands = await $.command.list()
309 const checked = await Promise.all(
310 projectCandidates(commands, paths).map(async path => ({
311 path,
312 isThere: await $.fs.exists(path),
313 })),
314 )
315 const mcpFile = await readOrWarn($, `${paths.root}/.mcp.json`)
316 const registry = await readOrWarn($, `${paths.configDir}/plugins/installed_plugins.json`)
317 const hookFiles = await Promise.all(
318 enabledPluginHookFiles(registry.value, scopes).map(async ({ key, path }) => {
319 const file = await readOrWarn($, path)
320
321 return {
322 sources: pluginHookSources(key, file.value, scopes),
323 warnings: file.warnings,
324 }
325 }),
326 )
327 const items = sorted([
328 ...settings.items,
329 ...commandItems(
330 commands,
331 paths,
332 scopes,
333 checked.filter(one => one.isThere).map(one => one.path),
334 ),
335 ...mcpItems(await $.tool.list(), mcpServerNames(mcpFile.value), paths),
336 ...hookEventItems(
337 settings.items,
338 hookFiles.flatMap(file => file.sources),
339 isPolicyKnown,
340 ),
341 ...(await read($, seenAtom)),
342 ])
343 const warnings = [
344 ...settings.warnings,
345 ...mcpFile.warnings,
346 ...registry.warnings,
347 ...hookFiles.flatMap(file => file.warnings),
348 ]
349 await update($, itemsAtom, () => items)
350 if (warnings.length > 0) await setView($, { notice: warnings.join(' | ') })
351
352 return items
353}
354
355const existingOf = async <Found,>(
356 $: EngineInterface,
357 candidates: readonly Found[],
358 pathsOf: (candidate: Found) => readonly string[],
359) => {
360 const checked = await Promise.all(
361 candidates.map(async candidate => ({
362 candidate,
363 found: await Promise.all(pathsOf(candidate).map(path => $.fs.exists(path))),
364 })),
365 )
366
367 return checked.filter(one => one.found.includes(true)).map(one => one.candidate)
368}
369
370const markdownUnder = async (
371 $: EngineInterface,
372 folder: string,
373 depth = MAX_RULES_DEPTH,
374): Promise<string[]> => {
375 if (depth === 0 || !(await $.fs.exists(folder))) return []
376 const found = await Promise.all(
377 (await $.fs.list(folder)).map(async entry => {
378 const path = `${folder}/${entry.name}`
379 if (entry.kind === 'dir') return markdownUnder($, path, depth - 1)
380
381 return isMarkdownFile(entry) ? [path] : []
382 }),
383 )
384
385 return found.flat()
386}
387
388const loadProjectFiles = async ($: EngineInterface, project: string) => {
389 const { configDir, root } = await settingsPaths($)
390 const folder = project === '' ? root : project
391 const files = await existingOf($, namedFilesOf(configDir, folder), file => [file.path])
392 const rules = await Promise.all(
393 rulesFoldersOf(folder).map(one => markdownUnder($, one)),
394 )
395 const items = fileItems(files, rules.flat(), await userFolders($))
396
397 return update($, projectItemsAtom, () => items)
398}
399
400const refreshProjects = async ($: EngineInterface) => {
401 try {
402 const root = await $.session.root()
403 const projects = await existingOf($, visibleFolders(await $.fs.list(root), root), markersOf)
404 await update($, projectsAtom, () => projects)
405 await loadProjectFiles($, (await read($, viewAtom)).project)
406 } catch (error) {
407 await setView($, { notice: `Projects could not be listed: ${messageOf(error)}` })
408 }
409}
410
411const showProject = async ($: EngineInterface, typed: string) => {
412 const root = await $.session.root()
413 const named = typed.replace(TRAILING_SLASHES, '')
414 const path = named === '' || named.startsWith('/') ? named : `${root}/${named}`
415 const project = path === root ? '' : path
416 const isFolder =
417 project === '' ||
418 ((await $.fs.exists(project)) && (await $.fs.stat(project)).kind === 'dir')
419 if (!isFolder) return say($, `No folder is at ${project}.`)
420 await loadProjectFiles($, project)
421 await setView($, { project, page: 0, picker: 'none' })
422
423 return say($, `Showing the project ${project === '' ? root : project}.`)
424}
425
426const load = async ($: EngineInterface) => {
427 const { isFirstRun, profiles, active } = await storedProfiles($)
428 await update($, profilesAtom, () => profiles)
429 await update($, activeAtom, () => active)
430 if (isFirstRun) await $.store.set(PROFILES_KEY, profiles)
431 $.ui.status(undefined)
432
433 return isFirstRun
434}
435
436const loadAll = async ($: EngineInterface) => {
437 const isFirstRun = await load($)
438 await refresh($)
439 await update($, loadedAtom, () => true)
440
441 return isFirstRun
442}
443
444const loadOnce = ($: EngineInterface) => {
445 loading ??= loadAll($).finally(() => {
446 loading = undefined
447 })
448
449 return loading
450}
451
452const ensureLoaded = async ($: EngineInterface) => {
453 if (!(await read($, loadedAtom))) await loadOnce($)
454}
455
456const inTurn = <Result,>(
457 $: EngineInterface,
458 work: () => Promise<Result>,
459): Promise<Result> => {
460 const loadedWork = async () => {
461 await ensureLoaded($)
462
463 return work()
464 }
465 const run = lastMutation.then(loadedWork, loadedWork)
466 lastMutation = run.catch(() => undefined)
467
468 return run
469}
470
471const setPersisted = async ($: EngineInterface, item: Item, isOn: boolean) => {
472 if (item.entry === undefined) return
473 const paths = await settingsPaths($)
474 const path = paths[item.entry.source]
475 if (path !== item.origin)
476 throw new Error(`${item.name} belongs to ${item.origin}, not to this project`)
477 if (item.entry.source !== 'user' && (await isSymbolicLink($, path)))
478 throw new Error(`${path} is a symbolic link; loadout does not write through it`)
479 const { value, text } = await readObject($, path)
480 const ledger = await loadLedger($)
481 const rewritten = rewrittenSettings(
482 value,
483 isOn ? (ledger.stash[item.id] ?? item) : item,
484 isOn,
485 )
486 const backup = backupPathOf(paths.configDir, path)
487 if (text !== undefined && !(await $.fs.exists(backup))) await $.fs.write(backup, text)
488 if (!isOn) await $.store.set(LEDGER_KEY, ledgerAfter(ledger, item, false))
489 if (text !== undefined || isOn) await $.fs.write(path, rewritten)
490 if (isOn) await $.store.set(LEDGER_KEY, ledgerAfter(ledger, item, true))
491}
492
493const toggle = async ($: EngineInterface, id: string) => {
494 const item = (await shownItems($)).find(one => one.id === id)
495 if (item === undefined) {
496 await refresh($)
497
498 return say($, 'That element is gone; the list was refreshed.')
499 }
500 if (item.isLocked)
501 return say($, `${item.name} is locked by the ${item.scope} scope and stays on.`)
502 const profile = await shownProfile($)
503 if (isFixedProfile(profile.name))
504 return say($, `"${profile.name}" is built in and cannot be changed; duplicate it first.`)
505 const isApplied = profile.name === (await activeProfile($)).name
506 const isOn = !isOnIn(profile, item, await loadLedger($), isApplied)
507 try {
508 if (isApplied) await setPersisted($, item, isOn)
509 } catch (error) {
510 await refresh($)
511
512 return say($, `Could not change ${item.name}: ${messageOf(error)}`)
513 }
514 await saveProfiles(
515 $,
516 upsertProfile(await profilesOf($), withOverride(profile, id, isOn)),
517 )
518 await refresh($)
519 invalidateGates($)
520 const hint = isApplied && item.kind === 'plugin' ? ' Run /reload-plugins to apply.' : ''
521
522 return say(
523 $,
524 `${item.name} is ${isOn ? 'on' : 'off'} in profile "${profile.name}".${hint}`,
525 )
526}
527
528const reconcile = async ($: EngineInterface, profile: Profile) => {
529 const ledger = await loadLedger($)
530 const pending = (await refresh($)).filter(
531 item =>
532 item.entry !== undefined && desiredState(profile, item, ledger) !== item.isOn,
533 )
534 const outcome = await pending.reduce(
535 async (before, item) => {
536 const done = await before
537 try {
538 await setPersisted($, item, !item.isOn)
539
540 return { ...done, changed: done.changed + 1 }
541 } catch (error) {
542 return { ...done, errors: [...done.errors, `${item.name}: ${messageOf(error)}`] }
543 }
544 },
545 Promise.resolve({ changed: 0, errors: [] as string[] }),
546 )
547 await refresh($)
548
549 return outcome
550}
551
552const activate = async ($: EngineInterface, profile: Profile) => {
553 const outcome = await reconcile($, profile)
554 await update($, activeAtom, () => profile.name)
555 await $.store.set(ACTIVE_KEY, profile.name)
556 await setView($, { viewed: profile.name })
557 invalidateGates($)
558
559 return outcome
560}
561
562const switchProfile = async ($: EngineInterface, name: string) => {
563 const profile = (await profilesOf($)).find(one => one.name === name)
564 if (profile === undefined) return say($, `No profile is named "${name}".`)
565 const { changed, errors } = await activate($, profile)
566 const failures = errors.length === 0 ? '' : ` Failed: ${errors.join('; ')}.`
567 const written =
568 changed === 0
569 ? ''
570 : ` ${changed} settings entries rewritten; run /reload-plugins if plugins changed.`
571
572 return say($, `Profile "${name}" is active.${written}${failures}`)
573}
574
575const addProfile = async ($: EngineInterface, created: Profile) => {
576 const profiles = await profilesOf($)
577 const problem = nameProblem(profiles, created.name)
578 if (problem !== undefined) return say($, problem)
579 await saveProfiles($, [...profiles, created])
580
581 return switchProfile($, created.name)
582}
583
584const duplicateProfile = async ($: EngineInterface, from: string, name: string) => {
585 const source = (await profilesOf($)).find(profile => profile.name === from)
586 if (source === undefined) return say($, `No profile is named "${from}".`)
587
588 return addProfile($, { ...source, name })
589}
590
591const createProfile = async (
592 $: EngineInterface,
593 name: string,
594 base?: Profile['base'],
595) =>
596 base === undefined
597 ? duplicateProfile($, (await activeProfile($)).name, name)
598 : addProfile($, { name, base, overrides: {} })
599
600const renameTo = async ($: EngineInterface, from: string, to: string) => {
601 if (isFixedProfile(from))
602 return say($, `"${from}" is built in and cannot be renamed.`)
603 const profiles = await profilesOf($)
604 if (!profiles.some(profile => profile.name === from))
605 return say($, `No profile is named "${from}".`)
606 const problem = nameProblem(profiles, to)
607 if (problem !== undefined) return say($, problem)
608 await saveProfiles($, renameProfile(profiles, from, to))
609 await forgetProfile($, from, to)
610 if ((await activeNameOf($)) === from) {
611 await update($, activeAtom, () => to)
612 await $.store.set(ACTIVE_KEY, to)
613 }
614
615 return say($, `Profile "${from}" is now "${to}".`)
616}
617
618const submitName = async (
619 $: EngineInterface,
620 typed: string,
621 work: (name: string) => Promise<string>,
622) => {
623 const name = typed.trim()
624 const problem = nameProblem(await profilesOf($), name)
625 if (problem !== undefined) return say($, problem)
626 await setView($, { edit: 'none' })
627 const said = await work(name)
628 await focusShown($)
629
630 return said
631}
632
633const deleteProfile = async ($: EngineInterface, name: string) => {
634 if (isFixedProfile(name))
635 return say($, `"${name}" is built in and cannot be deleted.`)
636 const profiles = await profilesOf($)
637 if (!profiles.some(profile => profile.name === name))
638 return say($, `No profile is named "${name}".`)
639 const kept = removeProfile(profiles, name)
640 if ((await activeNameOf($)) === name) {
641 const { errors } = await activate($, fallbackProfile(kept))
642 if (errors.length > 0)
643 return say($, `Profile "${name}" was kept; leaving it failed: ${errors.join('; ')}.`)
644 }
645 await saveProfiles($, kept)
646 await forgetProfile($, name)
647
648 return say($, `Profile "${name}" deleted.`)
649}
650
651const nameFirstProfile = async ($: EngineInterface) => {
652 const name = (await $.ui.ask(FIRST_NAME_QUESTION, FIRST_NAME_OPTIONS)).trim()
653 if (name === DEFAULT_PROFILE_NAME) return
654 await inTurn($, () => renameTo($, DEFAULT_PROFILE_NAME, name))
655}
656
657const runProfileAction = async (
658 $: EngineInterface,
659 name: string,
660 action: ProfileAction,
661) => {
662 if (action === 'duplicate' || action === 'rename') {
663 await setView($, { edit: action === 'duplicate' ? 'new' : 'rename', target: name })
664
665 return name
666 }
667 if (action === 'apply') await setView($, { edit: 'none' })
668 const said = await (action === 'apply' ? switchProfile($, name) : deleteProfile($, name))
669 await focusShown($)
670
671 return said
672}
673
674const isOff = async ($: EngineInterface, id: string, kind: Kind) => {
675 const known = (await read($, itemsAtom)).find(item => item.id === id)
676 const item: Item = known ?? {
677 id,
678 kind,
679 name: id,
680 scope: 'user',
681 origin: '',
682 isLocked: false,
683 isOn: true,
684 }
685
686 return !isEnabled(await activeProfile($), item)
687}
688
689const isKnownOff = async ($: EngineInterface, id: string, kind: Kind) => {
690 const profile = await activeProfile($)
691 const isKnown = (await read($, itemsAtom)).some(item => item.id === id)
692
693 return (isKnown || profile.overrides[id] === false) && isOff($, id, kind)
694}
695
696const isHookEventMuted = async ($: EngineInterface, event: string) => {
697 await ensureLoaded($)
698 const item = (await read($, itemsAtom)).find(one => one.id === `hook-event:${event}`)
699
700 return item !== undefined && !isEnabled(await activeProfile($), item)
701}
702
703const offNote = async ($: EngineInterface, what: string) =>
704 `${what} is turned off in the active loadout profile "${(await activeProfile($)).name}". Re-enable it with /loadout.`
705
706const openPane = async ($: EngineInterface) => {
707 await refresh($)
708 await refreshProjects($)
709 await $.ui.open({ id: PANE, title: 'Loadout', focus: true, rows: PANE_ROWS })
710
711 return 'Loadout pane opened.'
712}
713
714const listProfiles = async ($: EngineInterface) => {
715 const active = await activeProfile($)
716
717 return (await profilesOf($))
718 .map(
719 profile =>
720 `${profile.name === active.name ? '*' : ' '} ${profile.name} (${Object.keys(profile.overrides).length} overrides)`,
721 )
722 .join('\n')
723}
724
725const status = async ($: EngineInterface) => {
726 const profile = await activeProfile($)
727 const items = await refresh($)
728 const off = items.filter(item => !isShownOn(profile, item))
729
730 return [
731 `Profile "${profile.name}": ${off.length} of ${items.length} harness elements off.`,
732 ...off.map(item => ` off ${item.kind} ${item.name} [${item.scope}]`),
733 ].join('\n')
734}
735
736const runCommand = async ($: EngineInterface, args: string, origin: PromptOrigin) => {
737 const [verb = '', name = '', option = ''] = args.trim().split(/\s+/)
738 if (!READ_ONLY_VERBS.includes(verb) && !PERSON_ORIGINS.includes(origin.kind))
739 return 'Only the person at the prompt can change loadout profiles.'
740 await ensureLoaded($)
741 if (verb === '') return openPane($)
742 if (verb === 'list') return listProfiles($)
743 if (verb === 'status') return status($)
744 if (verb === 'project') return showProject($, name)
745 if (verb === 'use' && name !== '') return inTurn($, () => switchProfile($, name))
746 if (verb === 'delete' && name !== '') return inTurn($, () => deleteProfile($, name))
747 if (verb === 'rename' && name !== '' && option !== '')
748 return inTurn($, () => renameTo($, name, option))
749 if (verb === 'new' && name !== '')
750 return inTurn($, () =>
751 createProfile($, name, option === 'on' || option === 'off' ? option : undefined),
752 )
753
754 return USAGE
755}
756
757export const register: Register = on => {
758 on('session.start', async ($, e, next) => {
759 await $.command.register({
760 name: 'loadout',
761 description: 'Manage harness profiles, scopes and on/off for every harness element',
762 argumentHint:
763 '[list | status | use <profile> | new <profile> | rename <profile> <new name> | delete <profile> | project [<folder>]]',
764 })
765 try {
766 const isFirstRun = await loadOnce($)
767 if (isFirstRun)
768 void nameFirstProfile($).catch(error =>
769 $.ui.log(`loadout kept the name "default": ${messageOf(error)}`, {
770 to: 'debug',
771 }),
772 )
773 } catch (error) {
774 $.ui.log(`loadout could not read the harness: ${messageOf(error)}`)
775 }
776
777 return next(e)
778 })
779
780 on('command.run', { command: 'loadout' }, async ($, e) => {
781 try {
782 return { text: await runCommand($, e.args, e.origin) }
783 } catch (error) {
784 return { text: `loadout failed: ${messageOf(error)}` }
785 }
786 })
787
788 on('prompt.submit', async ($, e, next) => {
789 try {
790 await ensureLoaded($)
791 await refresh($)
792 } catch (error) {
793 $.ui.log(`loadout could not refresh the harness: ${messageOf(error)}`)
794 }
795
796 return next(e)
797 }).catch(($, e, next) => next(e))
798
799 on('prompt.context', async ($, e, next) => {
800 const answered = await next(e)
801 const files = answered.instructionFiles ?? e.instructionFiles
802 if (files === undefined) return answered
803 await ensureLoaded($).catch(error =>
804 $.ui.log(`loadout could not read the harness: ${messageOf(error)}`),
805 )
806 const profile = await activeProfile($)
807 const folders = await userFolders($)
808 const items = files.map(file => instructionItem(inUserScope(file, folders)))
809 await noteSeen($, items)
810 const kept = files.filter((file, index) => {
811 const item = items[index]
812
813 return item === undefined || isEnabled(profile, item)
814 })
815
816 return kept.length === files.length
817 ? answered
818 : { ...answered, instructionFiles: kept }
819 })
820
821 on('agent.offer', async ($, e, next) => {
822 if (e.provider.tier === CORE_TIER || e.provider.plugin === PLUGIN) return next(e)
823 const item = agentItem(e.agent, e.source, e.provider.plugin)
824 await noteSeen($, [item])
825
826 return isEnabled(await activeProfile($), item) ? next(e) : { isOffered: false }
827 }).catch(($, e, next) => next(e))
828
829 on('command.describe', async ($, e, next) => {
830 const described = await next(e)
831 const isManaged = e.provider.tier !== CORE_TIER && e.provider.plugin !== PLUGIN
832 if (!isManaged || !(await isOff($, `skill:${e.command}`, 'skill'))) return described
833
834 return {
835 ...described,
836 description: `[off in loadout] ${described.description}`,
837 isHidden: true,
838 }
839 })
840
841 on('command.run', async ($, e, next) =>
842 (await isKnownOff($, `skill:${e.command}`, 'skill'))
843 ? { text: await offNote($, `/${e.command}`) }
844 : next(e),
845 ).catch(($, e, next) => next(e))
846
847 on('skill.prompt', async ($, e, next) =>
848 (await isKnownOff($, `skill:${e.skill}`, 'skill'))
849 ? {
850 text: `${await offNote($, `The skill "${e.skill}"`)} Do not carry it out; tell the user it is disabled.`,
851 }
852 : next(e),
853 ).catch(($, e, next) => next(e))
854
855 on('tool.describe', async ($, e, next) => {
856 const described = await next(e)
857 const server = mcpServerOf(e.tool)
858 if (server === undefined) return described
859 if (!(await isOff($, `mcp:${server}`, 'mcp'))) return described
860
861 return {
862 ...described,
863 description: `[Disabled by the user's loadout profile: do not call.] ${described.description}`,
864 }
865 })
866
867 on('tool.call', async ($, e, next) => {
868 const server = mcpServerOf(String(e.tool))
869 if (server === undefined) return next(e)
870
871 return (await isOff($, `mcp:${server}`, 'mcp'))
872 ? { deny: await offNote($, `The MCP server "${server}"`) }
873 : next(e)
874 }).catch(($, e, next) => next(e))
875
876 on('classic.ConfigChange', async ($, e, next) =>
877 (await isHookEventMuted($, 'ConfigChange')) ? {} : next(e),
878 ).catch(($, e, next) => next(e))
879 on('classic.CwdChanged', async ($, e, next) =>
880 (await isHookEventMuted($, 'CwdChanged')) ? {} : next(e),
881 ).catch(($, e, next) => next(e))
882 on('classic.DirectoryAdded', async ($, e, next) =>
883 (await isHookEventMuted($, 'DirectoryAdded')) ? {} : next(e),
884 ).catch(($, e, next) => next(e))
885 on('classic.Elicitation', async ($, e, next) =>
886 (await isHookEventMuted($, 'Elicitation')) ? {} : next(e),
887 ).catch(($, e, next) => next(e))
888 on('classic.ElicitationResult', async ($, e, next) =>
889 (await isHookEventMuted($, 'ElicitationResult')) ? {} : next(e),
890 ).catch(($, e, next) => next(e))
891 on('classic.FileChanged', async ($, e, next) =>
892 (await isHookEventMuted($, 'FileChanged')) ? {} : next(e),
893 ).catch(($, e, next) => next(e))
894 on('classic.InstructionsLoaded', async ($, e, next) =>
895 (await isHookEventMuted($, 'InstructionsLoaded')) ? {} : next(e),
896 ).catch(($, e, next) => next(e))
897 on('classic.MessageDisplay', async ($, e, next) =>
898 (await isHookEventMuted($, 'MessageDisplay')) ? {} : next(e),
899 ).catch(($, e, next) => next(e))
900 on('classic.Notification', async ($, e, next) =>
901 (await isHookEventMuted($, 'Notification')) ? {} : next(e),
902 ).catch(($, e, next) => next(e))
903 on('classic.PermissionDenied', async ($, e, next) =>
904 (await isHookEventMuted($, 'PermissionDenied')) ? {} : next(e),
905 ).catch(($, e, next) => next(e))
906 on('classic.PermissionRequest', async ($, e, next) =>
907 (await isHookEventMuted($, 'PermissionRequest')) ? {} : next(e),
908 ).catch(($, e, next) => next(e))
909 on('classic.PostCompact', async ($, e, next) =>
910 (await isHookEventMuted($, 'PostCompact')) ? {} : next(e),
911 ).catch(($, e, next) => next(e))
912 on('classic.PostModelSwitch', async ($, e, next) =>
913 (await isHookEventMuted($, 'PostModelSwitch')) ? {} : next(e),
914 ).catch(($, e, next) => next(e))
915 on('classic.PostToolBatch', async ($, e, next) =>
916 (await isHookEventMuted($, 'PostToolBatch')) ? {} : next(e),
917 ).catch(($, e, next) => next(e))
918 on('classic.PostToolUse', async ($, e, next) =>
919 (await isHookEventMuted($, 'PostToolUse')) ? {} : next(e),
920 ).catch(($, e, next) => next(e))
921 on('classic.PostToolUseFailure', async ($, e, next) =>
922 (await isHookEventMuted($, 'PostToolUseFailure')) ? {} : next(e),
923 ).catch(($, e, next) => next(e))
924 on('classic.PreCompact', async ($, e, next) =>
925 (await isHookEventMuted($, 'PreCompact')) ? {} : next(e),
926 ).catch(($, e, next) => next(e))
927 on('classic.PreModelSwitch', async ($, e, next) =>
928 (await isHookEventMuted($, 'PreModelSwitch')) ? {} : next(e),
929 ).catch(($, e, next) => next(e))
930 on('classic.PreToolUse', async ($, e, next) =>
931 (await isHookEventMuted($, 'PreToolUse')) ? {} : next(e),
932 ).catch(($, e, next) => next(e))
933 on('classic.SessionEnd', async ($, e, next) =>
934 (await isHookEventMuted($, 'SessionEnd')) ? {} : next(e),
935 ).catch(($, e, next) => next(e))
936 on('classic.SessionStart', async ($, e, next) =>
937 (await isHookEventMuted($, 'SessionStart')) ? {} : next(e),
938 ).catch(($, e, next) => next(e))
939 on('classic.Setup', async ($, e, next) =>
940 (await isHookEventMuted($, 'Setup')) ? {} : next(e),
941 ).catch(($, e, next) => next(e))
942 on('classic.Stop', async ($, e, next) =>
943 (await isHookEventMuted($, 'Stop')) ? {} : next(e),
944 ).catch(($, e, next) => next(e))
945 on('classic.StopFailure', async ($, e, next) =>
946 (await isHookEventMuted($, 'StopFailure')) ? {} : next(e),
947 ).catch(($, e, next) => next(e))
948 on('classic.SubagentStart', async ($, e, next) =>
949 (await isHookEventMuted($, 'SubagentStart')) ? {} : next(e),
950 ).catch(($, e, next) => next(e))
951 on('classic.SubagentStop', async ($, e, next) =>
952 (await isHookEventMuted($, 'SubagentStop')) ? {} : next(e),
953 ).catch(($, e, next) => next(e))
954 on('classic.TaskCompleted', async ($, e, next) =>
955 (await isHookEventMuted($, 'TaskCompleted')) ? {} : next(e),
956 ).catch(($, e, next) => next(e))
957 on('classic.TaskCreated', async ($, e, next) =>
958 (await isHookEventMuted($, 'TaskCreated')) ? {} : next(e),
959 ).catch(($, e, next) => next(e))
960 on('classic.TeammateIdle', async ($, e, next) =>
961 (await isHookEventMuted($, 'TeammateIdle')) ? {} : next(e),
962 ).catch(($, e, next) => next(e))
963 on('classic.UserPromptExpansion', async ($, e, next) =>
964 (await isHookEventMuted($, 'UserPromptExpansion')) ? {} : next(e),
965 ).catch(($, e, next) => next(e))
966 on('classic.UserPromptSubmit', async ($, e, next) =>
967 (await isHookEventMuted($, 'UserPromptSubmit')) ? {} : next(e),
968 ).catch(($, e, next) => next(e))
969 on('classic.WorktreeCreate', async ($, e, next) =>
970 (await isHookEventMuted($, 'WorktreeCreate')) ? {} : next(e),
971 ).catch(($, e, next) => next(e))
972 on('classic.WorktreeRemove', async ($, e, next) =>
973 (await isHookEventMuted($, 'WorktreeRemove')) ? {} : next(e),
974 ).catch(($, e, next) => next(e))
975
976 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
977 if (e.props.hasSurvey) return next(e)
978 if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
979
980 return drawBand($.ui.resolve(e), (await activeProfile($)).name, () =>
981 void openPane($).catch(error =>
982 $.ui.toast(`loadout could not open its pane: ${messageOf(error)}`),
983 ),
984 )
985 })
986
987 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
988 if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
989 const report = (error: unknown) => void say($, `loadout failed: ${messageOf(error)}`)
990
991 const view = await read($, viewAtom)
992 const applied = (await activeProfile($)).name
993 const profile = await shownProfile($)
994 const isApplied = profile.name === applied
995 const ledger = isApplied ? EMPTY_LEDGER : await loadLedger($)
996
997 return drawPane(
998 $.ui.resolve(e),
999 {
1000 items: await shownItems($),
1001 root: await $.session.root(),
1002 projects: await read($, projectsAtom),
1003 profiles: await profilesOf($),
1004 profile,
1005 applied,
1006 isOn: item => isOnIn(profile, item, ledger, isApplied),
1007 view,
1008 columns: e.props.bodyColumns ?? FALLBACK_COLUMNS,
1009 rows: e.props.scroll?.bodyRows ?? FALLBACK_ROWS,
1010 isDocked: e.props.placement === 'dock',
1011 },
1012 {
1013 onToggle: id => void inTurn($, () => toggle($, id)).catch(report),
1014 onAction: (name, action) =>
1015 void inTurn($, () => runProfileAction($, name, action)).catch(report),
1016 onCreate: typed =>
1017 void inTurn($, () =>
1018 submitName($, typed, name => duplicateProfile($, view.target, name)),
1019 ).catch(report),
1020 onRename: typed =>
1021 void inTurn($, () =>
1022 submitName($, typed, name => renameTo($, view.target, name)),
1023 ).catch(report),
1024 onRefresh: () =>
1025 void refresh($)
1026 .then(() => refreshProjects($))
1027 .then(() => setView($, { notice: 'Refreshed.' }))
1028 .catch(report),
1029 onProject: path => void showProject($, path).catch(report),
1030 onView: patch => void setView($, patch).catch(report),
1031 },
1032 )
1033 })
1034}
1035hooks/files.ts 77 lines1import type { Item, Ledger, WritableSource } from '../types'
2import { applyEntry, isObject } from './settingsEdit'
3import type { SettingsObject } from './settingsEdit'
4
5export type SettingsPaths = Record<WritableSource, string> & {
6 configDir: string
7 root: string
8}
9
10const BACKUP_FOLDER = 'loadout-backups'
11const JSON_INDENT = 2
12const UNSAFE_FILE_CHARACTERS = /[^A-Za-z0-9]+/g
13
14export const isAbsolute = (path: string | undefined): path is string =>
15 path !== undefined && path.startsWith('/')
16
17export const pathsFrom = (
18 home: string | undefined,
19 configDirOverride: string | undefined,
20 root: string,
21): SettingsPaths => {
22 if (!isAbsolute(root)) throw new Error('the session has no absolute project root')
23 if (!isAbsolute(configDirOverride) && !isAbsolute(home))
24 throw new Error('neither CLAUDE_CONFIG_DIR nor HOME is an absolute path')
25 const configDir = isAbsolute(configDirOverride) ? configDirOverride : `${home}/.claude`
26
27 return {
28 configDir,
29 root,
30 user: `${configDir}/settings.json`,
31 project: `${root}/.claude/settings.json`,
32 local: `${root}/.claude/settings.local.json`,
33 }
34}
35
36export const backupPathOf = (configDir: string, path: string) =>
37 `${configDir}/${BACKUP_FOLDER}/${path.replace(UNSAFE_FILE_CHARACTERS, '_')}.json`
38
39export const parseJsonObject = (text: string, path: string): SettingsObject => {
40 try {
41 const parsed: unknown = JSON.parse(text)
42 if (isObject(parsed)) return parsed
43 } catch {
44 throw new Error(`${path} is not valid JSON`)
45 }
46 throw new Error(`${path} does not hold a JSON object`)
47}
48
49export const messageOf = (error: unknown) =>
50 error instanceof Error ? error.message : String(error)
51
52export const rewrittenSettings = (
53 settings: SettingsObject,
54 item: Item,
55 isOn: boolean,
56) => {
57 if (item.entry === undefined || item.isLocked)
58 throw new Error(`${item.name} is not a writable settings entry`)
59
60 return `${JSON.stringify(applyEntry(settings, item.entry, isOn), null, JSON_INDENT)}\n`
61}
62
63const withoutId = <Value>(record: Readonly<Record<string, Value>>, id: string) =>
64 Object.fromEntries(Object.entries(record).filter(([key]) => key !== id))
65
66export const ledgerAfter = (ledger: Ledger, item: Item, isOn: boolean): Ledger => {
67 const isRaised = ledger.raised.includes(item.id)
68 const isStashed = ledger.stash[item.id] !== undefined
69 if (!isOn && isRaised)
70 return { ...ledger, raised: ledger.raised.filter(id => id !== item.id) }
71 if (!isOn)
72 return { ...ledger, stash: { ...ledger.stash, [item.id]: { ...item, isOn: false } } }
73 if (isStashed) return { ...ledger, stash: withoutId(ledger.stash, item.id) }
74
75 return isRaised ? ledger : { ...ledger, raised: [...ledger.raised, item.id] }
76}
77hooks/inventory.ts 299 lines1import type { CommandInfo, InstructionFile, ToolInfo } from 'claude-code'
2
3import type { Item, Ledger, Scope } from '../types'
4import type { SettingsPaths } from './files'
5import { KINDS, PLUGIN } from './model'
6import { entriesOf, hookEventOf, isObject } from './settingsEdit'
7import type { SettingsObject, SettingsSourceName } from './settingsEdit'
8
9type Found = { items: Item[]; warnings: string[] }
10export type SettingsReading =
11 | { source: SettingsSourceName; origin: string; value: SettingsObject }
12 | { warning: string }
13export type HookSource = { event: string; origin: string; scope: Scope }
14export type PluginScopes = Readonly<Record<string, string[]>>
15
16const PATH_TAIL_SEGMENTS = 2
17const AGENT_SCOPES = ['policy', 'flag', 'local', 'project', 'user'] as const
18const INSTRUCTION_SCOPES: Readonly<Record<InstructionFile['kind'], Scope>> = {
19 managed: 'policy',
20 user: 'user',
21 project: 'project',
22 local: 'local',
23 memory: 'memory',
24}
25
26const runtimeItem = (
27 item: Pick<Item, 'id' | 'kind' | 'name' | 'scope' | 'origin'>,
28 isLocked = false,
29): Item => ({ ...item, isLocked, isOn: true })
30
31const MCP_TOOL = /^mcp__(.+?)__/
32
33export const mcpServerOf = (tool: string) => MCP_TOOL.exec(tool)?.[1]
34
35export const instructionItem = (file: InstructionFile): Item => {
36 const kind =
37 file.kind === 'memory'
38 ? 'memory'
39 : file.path.includes('/rules/')
40 ? 'rule'
41 : 'instruction'
42 const imported = file.parent === undefined ? '' : ` (@import from ${file.parent})`
43
44 return runtimeItem(
45 {
46 id: `${kind}:${file.path}`,
47 kind,
48 name: file.path.split('/').slice(-PATH_TAIL_SEGMENTS).join('/'),
49 scope: INSTRUCTION_SCOPES[file.kind],
50 origin: `${file.path}${imported}`,
51 },
52 file.kind === 'managed',
53 )
54}
55
56export const agentItem = (agent: string, source: string, plugin: string): Item => {
57 const scope =
58 AGENT_SCOPES.find(one => source.toLowerCase().includes(one)) ?? 'plugin'
59
60 return runtimeItem(
61 {
62 id: `agent:${agent}`,
63 kind: 'agent',
64 name: agent,
65 scope,
66 origin: scope === 'plugin' ? `plugin ${plugin}` : source,
67 },
68 scope === 'policy',
69 )
70}
71
72const isOwnPlugin = (item: Item) =>
73 item.kind === 'plugin' && item.name.split('@')[0] === PLUGIN
74
75const lockedCopy = ({ entry, ...item }: Item): Item => ({ ...item, isLocked: true })
76
77export const settingsFound = (
78 readings: readonly SettingsReading[],
79 ledger: Ledger,
80): Found => {
81 const live = readings
82 .flatMap(reading =>
83 'warning' in reading
84 ? []
85 : entriesOf(reading.value, reading.source, reading.origin),
86 )
87 .map(item => (isOwnPlugin(item) ? lockedCopy(item) : item))
88 const liveIds = new Set(live.map(item => item.id))
89 const readOrigins = readings.flatMap(reading =>
90 'warning' in reading ? [] : [reading.origin],
91 )
92 const stashed = Object.values(ledger.stash)
93 .filter(item => !liveIds.has(item.id) && readOrigins.includes(item.origin))
94 .map(item => ({ ...item, isOn: false }))
95
96 return {
97 items: [...live, ...stashed],
98 warnings: readings.flatMap(reading =>
99 'warning' in reading ? [reading.warning] : [],
100 ),
101 }
102}
103
104export const pluginScopes = (settings: readonly Item[]): PluginScopes =>
105 settings
106 .filter(item => item.kind === 'plugin')
107 .reduce<Record<string, string[]>>((scopes, item) => {
108 const name = item.name.split('@')[0] ?? item.name
109
110 return { ...scopes, [name]: [...(scopes[name] ?? []), item.scope] }
111 }, {})
112
113const UNSAFE_NAME = /[\\/]|\.\./
114
115const isManagedCommand = (command: CommandInfo) =>
116 command.source !== 'builtin' && command.plugin !== PLUGIN
117
118const projectPathsOf = (name: string, paths: SettingsPaths) => [
119 `${paths.root}/.claude/skills/${name}`,
120 `${paths.root}/.claude/commands/${name}.md`,
121]
122
123export const projectCandidates = (
124 commands: readonly CommandInfo[],
125 paths: SettingsPaths,
126) =>
127 commands
128 .filter(
129 command =>
130 isManagedCommand(command) &&
131 command.source === 'user' &&
132 !UNSAFE_NAME.test(command.name),
133 )
134 .flatMap(command => projectPathsOf(command.name, paths))
135
136const commandPlace = (
137 command: CommandInfo,
138 paths: SettingsPaths,
139 scopes: PluginScopes,
140 existing: readonly string[],
141): Pick<Item, 'scope' | 'origin'> => {
142 if (command.source === 'mcp') return { scope: 'user', origin: 'MCP server prompt' }
143 if (command.source === 'plugin') {
144 const plugin = command.plugin ?? 'unknown plugin'
145 const installed = (scopes[plugin] ?? []).join(', ')
146
147 return {
148 scope: 'plugin',
149 origin: `plugin ${plugin}${installed === '' ? '' : ` (${installed})`}`,
150 }
151 }
152 const inProject = projectPathsOf(command.name, paths).find(path =>
153 existing.includes(path),
154 )
155
156 return inProject === undefined
157 ? { scope: 'user', origin: `${paths.configDir}/skills or commands` }
158 : { scope: 'project', origin: inProject }
159}
160
161export const commandItems = (
162 commands: readonly CommandInfo[],
163 paths: SettingsPaths,
164 scopes: PluginScopes,
165 existing: readonly string[],
166): Item[] =>
167 commands.filter(isManagedCommand).map(command =>
168 runtimeItem({
169 id: `skill:${command.name}`,
170 kind: 'skill',
171 name: command.name,
172 ...commandPlace(command, paths, scopes, existing),
173 }),
174 )
175
176const mcpPlace = (
177 server: string,
178 projectServers: readonly string[],
179 paths: SettingsPaths,
180): Pick<Item, 'scope' | 'origin'> => {
181 if (projectServers.includes(server))
182 return { scope: 'project', origin: `${paths.root}/.mcp.json` }
183 if (server.startsWith('claude_ai_'))
184 return { scope: 'account', origin: 'claude.ai connector' }
185
186 return server.startsWith('plugin_')
187 ? { scope: 'plugin', origin: `plugin ${server.split('_')[1] ?? ''}` }
188 : { scope: 'user', origin: `${paths.configDir}/.claude.json` }
189}
190
191export const mcpItems = (
192 tools: readonly ToolInfo[],
193 projectServers: readonly string[],
194 paths: SettingsPaths,
195): Item[] => {
196 const counts = tools
197 .filter(tool => tool.mcp)
198 .reduce<Record<string, number>>((byServer, tool) => {
199 const server = mcpServerOf(tool.name)
200
201 return server === undefined
202 ? byServer
203 : { ...byServer, [server]: (byServer[server] ?? 0) + 1 }
204 }, {})
205
206 return Object.entries(counts).map(([server, count]) =>
207 runtimeItem({
208 id: `mcp:${server}`,
209 kind: 'mcp',
210 name: `${server} (${count} tools)`,
211 ...mcpPlace(server, projectServers, paths),
212 }),
213 )
214}
215
216export const mcpServerNames = (mcpFile: SettingsObject) =>
217 Object.keys(isObject(mcpFile.mcpServers) ? mcpFile.mcpServers : {})
218
219export const enabledPluginHookFiles = (
220 registry: SettingsObject,
221 scopes: PluginScopes,
222) =>
223 Object.entries(isObject(registry.plugins) ? registry.plugins : {}).flatMap(
224 ([key, installs]) => {
225 const first = Array.isArray(installs) ? installs[0] : undefined
226 const installPath = isObject(first) ? first.installPath : undefined
227 const name = key.split('@')[0] ?? key
228
229 return typeof installPath === 'string' && scopes[name] !== undefined
230 ? [{ key, path: `${installPath}/hooks/hooks.json` }]
231 : []
232 },
233 )
234
235export const pluginHookSources = (
236 key: string,
237 hooksFile: SettingsObject,
238 scopes: PluginScopes,
239): HookSource[] => {
240 const isForcedByPolicy = (scopes[key.split('@')[0] ?? key] ?? []).includes('policy')
241
242 return Object.entries(isObject(hooksFile.hooks) ? hooksFile.hooks : {}).flatMap(
243 ([event, list]) =>
244 (Array.isArray(list) ? list : []).map(() => ({
245 event,
246 origin: key,
247 scope: isForcedByPolicy ? ('policy' as const) : ('plugin' as const),
248 })),
249 )
250}
251
252const countLabel = (origins: readonly string[]) =>
253 Object.entries(
254 origins.reduce<Record<string, number>>(
255 (counts, origin) => ({ ...counts, [origin]: (counts[origin] ?? 0) + 1 }),
256 {},
257 ),
258 )
259 .map(([origin, count]) => `${origin} ×${count}`)
260 .join(', ')
261
262export const hookEventItems = (
263 settings: readonly Item[],
264 fromPlugins: readonly HookSource[],
265 isPolicyKnown = true,
266): Item[] => {
267 const fromSettings = settings.flatMap(item => {
268 const event = hookEventOf(item)
269
270 return event === undefined || !item.isOn
271 ? []
272 : [{ event, origin: `${item.scope} settings`, scope: item.scope }]
273 })
274 const sources = [...fromSettings, ...fromPlugins]
275
276 return [...new Set(sources.map(source => source.event))].map(event => {
277 const mine = sources.filter(source => source.event === event)
278 const scopes = [...new Set(mine.map(source => source.scope))]
279
280 return runtimeItem(
281 {
282 id: `hook-event:${event}`,
283 kind: 'hook-event',
284 name: `${event} (${mine.length} hooks, muted together)`,
285 scope: scopes.length === 1 && scopes[0] !== undefined ? scopes[0] : 'mixed',
286 origin: countLabel(mine.map(source => source.origin)),
287 },
288 !isPolicyKnown || scopes.includes('policy'),
289 )
290 })
291}
292
293export const sorted = (items: readonly Item[]): Item[] =>
294 [...items].sort(
295 (left, right) =>
296 KINDS.indexOf(left.kind) - KINDS.indexOf(right.kind) ||
297 left.name.localeCompare(right.name),
298 )
299hooks/model.ts 160 lines1import type { Item, Kind, Ledger, Profile, Scope } from '../types'
2
3export const PLUGIN = 'loadout'
4export const DEFAULT_PROFILE_NAME = 'default'
5export const VANILLA_PROFILE_NAME = 'vanilla'
6export const DEFAULT_PROFILE: Profile = {
7 name: DEFAULT_PROFILE_NAME,
8 base: 'on',
9 overrides: {},
10}
11export const VANILLA_PROFILE: Profile = {
12 name: VANILLA_PROFILE_NAME,
13 base: 'off',
14 overrides: {},
15}
16export const INITIAL_PROFILES: readonly Profile[] = [DEFAULT_PROFILE, VANILLA_PROFILE]
17
18const LEGACY_VANILLA_PROFILE_NAME = 'pristine'
19
20const PROFILE_NAME = /^[A-Za-z0-9_-]{1,40}$/
21const KINDS_KEPT_BY_BASE: readonly Kind[] = ['permission', 'setting']
22
23export const KIND_LABELS: Readonly<Record<Kind, string>> = {
24 instruction: 'CLAUDE.md',
25 rule: 'Rules',
26 memory: 'Memories',
27 skill: 'Skills & commands',
28 agent: 'Agents',
29 mcp: 'MCP servers',
30 'hook-event': 'Hook events',
31 hook: 'Hooks',
32 plugin: 'Plugins',
33 permission: 'Permissions',
34 setting: 'Settings',
35}
36
37export const KINDS = Object.keys(KIND_LABELS) as Kind[]
38
39export const SCOPES: readonly Scope[] = [
40 'policy',
41 'flag',
42 'local',
43 'project',
44 'user',
45 'plugin',
46 'account',
47 'memory',
48 'mixed',
49]
50
51export const isValidProfileName = (name: string) => PROFILE_NAME.test(name)
52
53export const nameProblem = (profiles: readonly Profile[], name: string) => {
54 if (name === '') return 'A profile name is required.'
55 if (!isValidProfileName(name))
56 return 'A profile name is 1-40 letters, digits, "-" or "_".'
57 if (profiles.some(profile => profile.name === name))
58 return `Profile "${name}" already exists.`
59
60 return undefined
61}
62
63export const isFixedProfile = (name: string) => name === VANILLA_PROFILE_NAME
64
65export const fallbackProfile = (profiles: readonly Profile[]) =>
66 profiles.find(profile => !isFixedProfile(profile.name)) ?? VANILLA_PROFILE
67
68export const findProfile = (profiles: readonly Profile[], name: string) =>
69 profiles.find(profile => profile.name === name) ?? fallbackProfile(profiles)
70
71export const isEnabled = (profile: Profile, item: Item) => {
72 if (item.isLocked) return true
73 const override = profile.overrides[item.id]
74 if (override !== undefined) return override
75 if (KINDS_KEPT_BY_BASE.includes(item.kind)) return true
76
77 return profile.base === 'on'
78}
79
80export const EMPTY_LEDGER: Ledger = { stash: {}, raised: [] }
81
82export const desiredState = (profile: Profile, item: Item, ledger: Ledger) => {
83 if (item.entry === undefined) return isEnabled(profile, item)
84 if (item.isLocked) return item.isOn
85 const override = profile.overrides[item.id]
86 if (override !== undefined) return override
87 const wasOnBeforeLoadout =
88 !ledger.raised.includes(item.id) &&
89 (item.isOn || ledger.stash[item.id] !== undefined)
90 if (KINDS_KEPT_BY_BASE.includes(item.kind)) return wasOnBeforeLoadout
91
92 return profile.base === 'on' && wasOnBeforeLoadout
93}
94
95export const isShownOn = (profile: Profile, item: Item) =>
96 item.entry === undefined ? isEnabled(profile, item) : item.isOn
97
98export const isOnIn = (
99 profile: Profile,
100 item: Item,
101 ledger: Ledger,
102 isApplied: boolean,
103) => (isApplied ? isShownOn(profile, item) : desiredState(profile, item, ledger))
104
105export const withOverride = (
106 profile: Profile,
107 id: string,
108 isOn: boolean,
109): Profile => ({
110 ...profile,
111 overrides: { ...profile.overrides, [id]: isOn },
112})
113
114export const upsertProfile = (
115 profiles: readonly Profile[],
116 profile: Profile,
117): Profile[] =>
118 profiles.some(one => one.name === profile.name)
119 ? profiles.map(one => (one.name === profile.name ? profile : one))
120 : [...profiles, profile]
121
122export const removeProfile = (
123 profiles: readonly Profile[],
124 name: string,
125): Profile[] => profiles.filter(profile => profile.name !== name)
126
127export const renameProfile = (
128 profiles: readonly Profile[],
129 from: string,
130 to: string,
131): Profile[] =>
132 profiles.map(profile => (profile.name === from ? { ...profile, name: to } : profile))
133
134const currentNameOf = (name: string) =>
135 name === LEGACY_VANILLA_PROFILE_NAME ? VANILLA_PROFILE_NAME : name
136
137export const withoutLegacyNames = (profiles: readonly Profile[]): Profile[] =>
138 profiles.some(profile => profile.name === VANILLA_PROFILE_NAME)
139 ? [...profiles]
140 : renameProfile(profiles, LEGACY_VANILLA_PROFILE_NAME, VANILLA_PROFILE_NAME)
141
142export const withVanilla = (profiles: readonly Profile[]): Profile[] =>
143 profiles.some(profile => isFixedProfile(profile.name))
144 ? profiles.map(profile => (isFixedProfile(profile.name) ? VANILLA_PROFILE : profile))
145 : [...profiles, VANILLA_PROFILE]
146
147export const restoredProfiles = (
148 stored: readonly Profile[] | undefined,
149 wanted: unknown,
150) => {
151 const profiles = withVanilla(withoutLegacyNames(stored ?? INITIAL_PROFILES))
152 const isNamed = (name: unknown) => profiles.some(profile => profile.name === name)
153 const carried = isNamed(wanted) ? String(wanted) : currentNameOf(String(wanted))
154
155 return {
156 profiles,
157 active: isNamed(carried) ? carried : fallbackProfile(profiles).name,
158 }
159}
160hooks/project.ts 105 lines1import type { FsEntry, InstructionFile } from 'claude-code'
2
3import type { Item, Scope } from '../types'
4import { isAbsolute } from './files'
5import { instructionItem, sorted } from './inventory'
6
7export type FoundFile = Pick<InstructionFile, 'path' | 'kind'>
8
9const INSTRUCTION_FILES: readonly { name: string; kind: InstructionFile['kind'] }[] = [
10 { name: 'CLAUDE.md', kind: 'project' },
11 { name: '.claude/CLAUDE.md', kind: 'project' },
12 { name: 'CLAUDE.local.md', kind: 'local' },
13]
14const HOME_CONFIG_FOLDER = '.claude'
15const TRAILING_SLASHES = /\/+$/
16const RULES_FOLDER = '.claude/rules'
17const MEMORY_INDEX = 'MEMORY.md'
18const MARKDOWN_FILE = /\.md$/
19const PROJECT_MARKERS = ['CLAUDE.md', '.claude']
20const HIDDEN_FOLDER = /^\./
21const FOLDER_NAME_SEPARATORS = /[^A-Za-z0-9]/g
22const SCOPES_OF_ONE_PROJECT: readonly Scope[] = ['project', 'local', 'memory']
23const SESSION_MARK = ' · session'
24
25export const ancestorsOf = (root: string): string[] =>
26 root
27 .split('/')
28 .filter(part => part !== '')
29 .map((_, index, parts) => `/${parts.slice(0, index + 1).join('/')}`)
30
31export const memoryFolderOf = (configDir: string, root: string) =>
32 `${configDir}/projects/${root.replace(FOLDER_NAME_SEPARATORS, '-')}/memory`
33
34export const projectLabel = (path: string, root: string) => {
35 if (path === root) return `${path.split('/').at(-1) ?? path}${SESSION_MARK}`
36
37 return path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path
38}
39
40export const withSessionFiles = (
41 items: readonly Item[],
42 files: readonly Item[],
43): Item[] =>
44 sorted([...items, ...files.filter(file => !items.some(item => item.id === file.id))])
45
46export const withProjectFiles = (
47 items: readonly Item[],
48 files: readonly Item[],
49): Item[] =>
50 withSessionFiles(
51 items.filter(item => !SCOPES_OF_ONE_PROJECT.includes(item.scope)),
52 files,
53 )
54
55export const namedFilesOf = (configDir: string, root: string): FoundFile[] => [
56 ...ancestorsOf(root).flatMap(folder =>
57 INSTRUCTION_FILES.map(({ name, kind }) => ({ path: `${folder}/${name}`, kind })),
58 ),
59 { path: `${memoryFolderOf(configDir, root)}/${MEMORY_INDEX}`, kind: 'memory' },
60]
61
62export const rulesFoldersOf = (root: string) =>
63 ancestorsOf(root).map(folder => `${folder}/${RULES_FOLDER}`)
64
65export const isMarkdownFile = (entry: FsEntry) =>
66 entry.kind === 'file' && MARKDOWN_FILE.test(entry.name)
67
68export const userFoldersOf = (
69 home: string | undefined,
70 configDir: string | undefined,
71): string[] => [
72 ...new Set(
73 [configDir, home === undefined ? undefined : `${home}/${HOME_CONFIG_FOLDER}`]
74 .filter(isAbsolute)
75 .map(folder => folder.replace(TRAILING_SLASHES, '')),
76 ),
77]
78
79export const inUserScope = <File extends FoundFile>(
80 file: File,
81 userFolders: readonly string[],
82): File =>
83 file.kind === 'project' &&
84 userFolders.some(folder => file.path.startsWith(`${folder}/`))
85 ? { ...file, kind: 'user' }
86 : file
87
88export const fileItems = (
89 files: readonly FoundFile[],
90 rules: readonly string[],
91 userFolders: readonly string[],
92) =>
93 [...files, ...rules.map((path): FoundFile => ({ path, kind: 'project' }))].map(file =>
94 instructionItem({ ...inUserScope(file, userFolders), content: '' }),
95 )
96
97export const visibleFolders = (entries: readonly FsEntry[], root: string) =>
98 entries
99 .filter(entry => entry.kind === 'dir' && !HIDDEN_FOLDER.test(entry.name))
100 .map(entry => `${root}/${entry.name}`)
101 .sort((left, right) => left.localeCompare(right))
102
103export const markersOf = (folder: string) =>
104 PROJECT_MARKERS.map(marker => `${folder}/${marker}`)
105hooks/settingsEdit.ts 243 lines1import type {
2 Item,
3 Json,
4 Scope,
5 SettingsEntry,
6 WritableSource,
7} from '../types'
8
9export type SettingsObject = { [key: string]: Json }
10export type SettingsSourceName = WritableSource | 'policy' | 'flag'
11
12const PERMISSION_LISTS = ['allow', 'deny', 'ask'] as const
13const SPECIAL_KEYS = ['enabledPlugins', 'permissions', 'hooks']
14const NAME_LIMIT = 70
15const WRITABLE: readonly string[] = ['user', 'project', 'local']
16
17export const isObject = (value: unknown): value is SettingsObject =>
18 typeof value === 'object' && value !== null && !Array.isArray(value)
19
20const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/g
21
22const clip = (raw: string) => {
23 const text = raw.replace(CONTROL_CHARACTERS, ' ')
24
25 return text.length > NAME_LIMIT ? `${text.slice(0, NAME_LIMIT - 1)}…` : text
26}
27
28const describeValue = (key: string, value: Json) => {
29 if (typeof value === 'boolean' || typeof value === 'number' || value === null)
30 return `${key} = ${String(value)}`
31 if (typeof value === 'string') return `${key} (text)`
32 if (Array.isArray(value)) return `${key} (list of ${value.length})`
33
34 return `${key} {${Object.keys(value).join(', ')}}`
35}
36
37const isSame = (left: Json, right: Json) =>
38 JSON.stringify(left) === JSON.stringify(right)
39
40const getAt = (root: Json, path: readonly string[]): Json | undefined =>
41 path.reduce<Json | undefined>(
42 (node, key) => (isObject(node) ? node[key] : undefined),
43 root,
44 )
45
46const setAt = (
47 root: SettingsObject,
48 path: readonly string[],
49 value: Json | undefined,
50): SettingsObject => {
51 const [head, ...rest] = path
52 if (head === undefined) return root
53 if (rest.length > 0) {
54 const child = root[head]
55
56 return { ...root, [head]: setAt(isObject(child) ? child : {}, rest, value) }
57 }
58 if (value !== undefined) return { ...root, [head]: value }
59 const { [head]: removed, ...kept } = root
60
61 return kept
62}
63
64export const applyEntry = (
65 settings: SettingsObject,
66 entry: SettingsEntry,
67 isOn: boolean,
68): SettingsObject => {
69 if (entry.mode === 'flag')
70 return setAt(
71 settings,
72 entry.path,
73 isOn && entry.value !== false ? entry.value : isOn,
74 )
75 if (entry.mode === 'key')
76 return setAt(settings, entry.path, isOn ? entry.value : undefined)
77 const current = getAt(settings, entry.path)
78 const list = Array.isArray(current) ? current : []
79 const others = list.filter(one => !isSame(one, entry.value))
80
81 return setAt(settings, entry.path, isOn ? [...others, entry.value] : others)
82}
83
84const hookName = (event: string, hook: Json) => {
85 if (!isObject(hook)) return `${event}: ${clip(JSON.stringify(hook))}`
86 const matcher = typeof hook.matcher === 'string' ? hook.matcher : '*'
87 const first = Array.isArray(hook.hooks) ? hook.hooks[0] : undefined
88 const command =
89 isObject(first) && typeof first.command === 'string'
90 ? first.command
91 : JSON.stringify(first ?? null)
92
93 return clip(`${event} [${matcher}] ${command}`)
94}
95
96type Draft = Pick<Item, 'kind' | 'name' | 'isOn' | 'event'> & {
97 mode: SettingsEntry['mode']
98 path: string[]
99 value: Json
100}
101
102const pluginDrafts = (settings: SettingsObject): Draft[] =>
103 Object.entries(isObject(settings.enabledPlugins) ? settings.enabledPlugins : {})
104 .map(([name, value]) => ({
105 kind: 'plugin' as const,
106 name,
107 isOn: value !== false,
108 mode: 'flag' as const,
109 path: ['enabledPlugins', name],
110 value,
111 }))
112
113const permissionDrafts = (settings: SettingsObject): Draft[] => {
114 const permissions = isObject(settings.permissions) ? settings.permissions : {}
115 const rules = PERMISSION_LISTS.flatMap(list => {
116 const values = permissions[list]
117
118 return (Array.isArray(values) ? values : []).map(rule => ({
119 kind: 'permission' as const,
120 name: clip(`${list}: ${typeof rule === 'string' ? rule : JSON.stringify(rule)}`),
121 isOn: true,
122 mode: 'element' as const,
123 path: ['permissions', list],
124 value: rule,
125 }))
126 })
127 const others = Object.entries(permissions)
128 .filter(([key]) => !PERMISSION_LISTS.some(list => list === key))
129 .map(([key, value]) => ({
130 kind: 'permission' as const,
131 name: clip(`${key} = ${JSON.stringify(value)}`),
132 isOn: true,
133 mode: 'key' as const,
134 path: ['permissions', key],
135 value,
136 }))
137
138 return [...rules, ...others]
139}
140
141const hookDrafts = (settings: SettingsObject): Draft[] =>
142 Object.entries(isObject(settings.hooks) ? settings.hooks : {}).flatMap(
143 ([event, hooks]) =>
144 (Array.isArray(hooks) ? hooks : []).map(hook => ({
145 kind: 'hook' as const,
146 event,
147 name: hookName(event, hook),
148 isOn: true,
149 mode: 'element' as const,
150 path: ['hooks', event],
151 value: hook,
152 })),
153 )
154
155const settingDrafts = (settings: SettingsObject): Draft[] =>
156 Object.entries(settings)
157 .filter(([key]) => !SPECIAL_KEYS.includes(key))
158 .map(([key, value]) => ({
159 kind: 'setting' as const,
160 name: clip(describeValue(key, value)),
161 isOn: true,
162 mode: 'key' as const,
163 path: [key],
164 value,
165 }))
166
167export const entryIdOf = (
168 kind: string,
169 origin: string,
170 entry: Pick<SettingsEntry, 'mode' | 'path' | 'value'>,
171) =>
172 [
173 kind,
174 origin,
175 entry.path.join('.'),
176 entry.mode === 'element' ? JSON.stringify(entry.value) : '',
177 ].join(':')
178
179const uniqueById = (items: readonly Item[]) =>
180 items.filter((item, index) => items.findIndex(one => one.id === item.id) === index)
181
182export const entriesOf = (
183 settings: SettingsObject,
184 source: SettingsSourceName,
185 origin: string,
186): Item[] =>
187 uniqueById(
188 [
189 ...pluginDrafts(settings),
190 ...permissionDrafts(settings),
191 ...hookDrafts(settings),
192 ...settingDrafts(settings),
193 ].map(({ mode, path, value, ...shown }) => {
194 const base = {
195 ...shown,
196 id: entryIdOf(shown.kind, origin, { mode, path, value }),
197 scope: source as Scope,
198 origin,
199 }
200
201 return WRITABLE.includes(source)
202 ? {
203 ...base,
204 isLocked: false,
205 entry: { mode, source: source as WritableSource, path, value },
206 }
207 : { ...base, isLocked: true }
208 }),
209 )
210
211const ROOT_KEY_OF_KIND: Readonly<Record<string, string>> = {
212 plugin: 'enabledPlugins',
213 permission: 'permissions',
214 hook: 'hooks',
215}
216const MODES: readonly string[] = ['flag', 'key', 'element']
217
218export const isSoundStashedItem = (value: unknown): value is Item => {
219 if (!isObject(value) || !isObject(value.entry)) return false
220 const { id, kind, name, origin, entry } = value
221 const { mode, source, path } = entry
222 if (typeof id !== 'string' || typeof kind !== 'string') return false
223 if (typeof name !== 'string' || typeof origin !== 'string') return false
224 if (typeof mode !== 'string' || !MODES.includes(mode)) return false
225 if (typeof source !== 'string' || !WRITABLE.includes(source)) return false
226 if (!Array.isArray(path) || path.length === 0) return false
227 if (!path.every((key): key is string => typeof key === 'string')) return false
228 if (entry.value === undefined) return false
229 const rootKey = ROOT_KEY_OF_KIND[kind]
230 const isRootRight =
231 kind === 'setting'
232 ? !SPECIAL_KEYS.includes(path[0] ?? '')
233 : rootKey !== undefined && path[0] === rootKey
234
235 return (
236 isRootRight &&
237 id === entryIdOf(kind, origin, { mode: mode as SettingsEntry['mode'], path, value: entry.value })
238 )
239}
240
241export const hookEventOf = (item: Item) =>
242 item.kind === 'hook' ? item.event : undefined
243hooks/view.tsx 411 lines1import type { Elements } from 'claude-code'
2
3import type { Item, Kind, Picker, Profile, ProfileAction, Scope, View } from '../types'
4import { KINDS, KIND_LABELS, SCOPES, isFixedProfile } from './model'
5import { projectLabel } from './project'
6
7export type PaneElements = Pick<
8 Elements['terminal'],
9 'Box' | 'Text' | 'Button' | 'Input' | 'Link'
10>
11
12export type PaneActions = {
13 onToggle: (id: string) => void
14 onAction: (name: string, action: ProfileAction) => void
15 onCreate: (name: string) => void
16 onRename: (name: string) => void
17 onRefresh: () => void
18 onProject: (path: string) => void
19 onView: (patch: Partial<View>) => void
20}
21
22export type PaneModel = {
23 items: readonly Item[]
24 root: string
25 projects: readonly string[]
26 profiles: readonly Profile[]
27 profile: Profile
28 applied: string
29 isOn: (item: Item) => boolean
30 view: View
31 columns: number
32 rows: number
33 isDocked: boolean
34}
35
36type SectionLine = { kind: Kind; isOpen: boolean; count: number; offCount: number }
37type ItemLine = { item: Item; isInSection: boolean }
38type Line = SectionLine | ItemLine
39type PickerOption = { value: string; label: string }
40type OpenPicker = Exclude<Picker, 'none'>
41
42const CHROME_ROWS = 15
43const FRAME_COLUMNS = 4
44const FRAME = { borderStyle: 'round', borderDimColor: true, paddingX: 1 } as const
45const MIN_LIST_ROWS = 3
46const INDENT_COLUMNS = 2
47const CHECKBOX_COLUMNS = 1
48const SCOPE_COLUMNS = 9
49const NAME_SHARE = 0.5
50const ALL = 'all'
51const CHECKED = '☑'
52const UNCHECKED = '☐'
53const OPEN_MARK = '▾'
54const CLOSED_MARK = '▸'
55const KINDS_WITHOUT_SECTION: readonly Kind[] = ['instruction']
56const PROFILE_KEY_PREFIX = 'profile:'
57const MENU_KEY_PREFIX = 'menu:'
58const ACTION_KEY_PREFIX = 'action:'
59const PICKER_KEY_PREFIX = 'picker:'
60const PICK_KEY_PREFIX = 'pick:'
61const PICKER_LABELS: Readonly<Record<OpenPicker, string>> = {
62 project: 'Project',
63 scope: 'Scope',
64}
65const APPLIED_COLOR = 'suggestion'
66const MENU_BORDER_ROWS = 2
67const ORIGIN_NOTE = / (\(@import from .*\)|or commands)$/
68const FILE_SCHEME = 'file://'
69const PROFILE_ACTIONS: readonly ProfileAction[] = ['apply', 'duplicate', 'rename', 'delete']
70const FIXED_PROFILE_ACTIONS: readonly ProfileAction[] = ['apply', 'duplicate']
71const NAME_FIELDS = {
72 new: { key: 'new-profile-name', verb: 'Duplicate', submitLabel: 'create' },
73 rename: { key: 'rename-profile-name', verb: 'Rename', submitLabel: 'rename' },
74} as const
75
76const clipEnd = (text: string, width: number) =>
77 text.length > width ? `${text.slice(0, Math.max(1, width - 1))}…` : text
78
79const clipStart = (text: string, width: number) =>
80 text.length > width ? `…${text.slice(text.length - Math.max(1, width - 1))}` : text
81
82const actionsOf = (name: string) =>
83 isFixedProfile(name) ? FIXED_PROFILE_ACTIONS : PROFILE_ACTIONS
84
85const pageSize = (rows: number, menuRows = 0) =>
86 Math.max(MIN_LIST_ROWS, rows - CHROME_ROWS - menuRows)
87
88const withToggledSection = (open: readonly Kind[], kind: Kind): Kind[] =>
89 open.includes(kind) ? open.filter(one => one !== kind) : [...open, kind]
90
91const linesOf = (
92 items: readonly Item[],
93 isOn: (item: Item) => boolean,
94 view: View,
95): Line[] => {
96 const visible = items.filter(item => view.scope === ALL || item.scope === view.scope)
97
98 return KINDS.flatMap((kind): Line[] => {
99 const ofKind = visible.filter(item => item.kind === kind)
100 if (ofKind.length === 0) return []
101 if (KINDS_WITHOUT_SECTION.includes(kind))
102 return ofKind.map(item => ({ item, isInSection: false }))
103 const isOpen = view.open.includes(kind)
104
105 return [
106 {
107 kind,
108 isOpen,
109 count: ofKind.length,
110 offCount: ofKind.filter(item => !isOn(item)).length,
111 },
112 ...(isOpen ? ofKind.map(item => ({ item, isInSection: true })) : []),
113 ]
114 })
115}
116
117export const menuKeyOf = (name: string) => `${MENU_KEY_PREFIX}${name}`
118
119const fileLinkOf = (origin: string) =>
120 origin.startsWith('/')
121 ? `${FILE_SCHEME}${origin.replace(ORIGIN_NOTE, '').split('/').map(encodeURIComponent).join('/')}`
122 : undefined
123
124export const drawBand = (
125 { Box, Button }: Pick<PaneElements, 'Box' | 'Button'>,
126 profileName: string,
127 onOpen: () => void,
128) => (
129 <Box>
130 <Button
131 key="open-pane"
132 plain
133 dimColor
134 label={`↗ loadout · ${profileName}`}
135 onPress={onOpen}
136 />
137 </Box>
138)
139
140export const drawPane = (
141 { Box, Text, Button, Input, Link }: PaneElements,
142 {
143 items,
144 root,
145 projects,
146 profiles,
147 profile,
148 applied,
149 isOn,
150 view,
151 columns,
152 rows,
153 isDocked,
154 }: PaneModel,
155 actions: PaneActions,
156) => {
157 const isMenuOpen = view.edit === 'menu' && view.target === profile.name
158 const menuActions = isMenuOpen ? actionsOf(profile.name) : []
159 const lines = linesOf(items, isOn, view)
160 const room = Math.max(20, columns - FRAME_COLUMNS - INDENT_COLUMNS - CHECKBOX_COLUMNS - SCOPE_COLUMNS - 2)
161 const nameWidth = Math.floor(room * NAME_SHARE)
162 const isRenaming =
163 view.edit === 'rename' &&
164 !isFixedProfile(view.target) &&
165 profiles.some(one => one.name === view.target)
166 const scopeOptions: PickerOption[] = [
167 { value: ALL, label: 'All' },
168 ...SCOPES.filter(scope => items.some(item => item.scope === scope)).map(scope => ({
169 value: scope,
170 label: scope,
171 })),
172 ]
173 const shownProject = view.project === '' ? root : view.project
174 const projectOptions: PickerOption[] = [
175 ...new Set([root, ...projects, shownProject]),
176 ].map(path => ({ value: path, label: projectLabel(path, root) }))
177 const pickers = {
178 project: {
179 options: projectOptions,
180 picked: shownProject,
181 onPick: actions.onProject,
182 },
183 scope: {
184 options: scopeOptions,
185 picked: view.scope,
186 onPick: (value: string) =>
187 actions.onView({ scope: value as Scope | 'all', page: 0, picker: 'none' }),
188 },
189 }
190 const openRows = [
191 ...(isMenuOpen ? [menuActions.length] : []),
192 ...(view.picker === 'none' ? [] : [pickers[view.picker].options.length]),
193 ].reduce((total, count) => total + count + MENU_BORDER_ROWS, 0)
194 const size = pageSize(rows, openRows)
195 const pages = Math.max(1, Math.ceil(lines.length / size))
196 const page = Math.min(view.page, pages - 1)
197 const drawNameField = (edit: keyof typeof NAME_FIELDS, onSubmit: (name: string) => void) => (
198 <Box flexDirection="row" gap={1}>
199 <Input
200 key={NAME_FIELDS[edit].key}
201 label={`${NAME_FIELDS[edit].verb} "${view.target}"`}
202 placeholder="name"
203 {...(edit === 'rename' ? { value: view.target } : {})}
204 submitLabel={NAME_FIELDS[edit].submitLabel}
205 autoFocus
206 onSubmit={onSubmit}
207 />
208 <Button
209 key="cancel-edit"
210 plain
211 dimColor
212 label="Cancel"
213 onPress={() => actions.onView({ edit: 'none' })}
214 />
215 </Box>
216 )
217
218 const drawPicker = (picker: OpenPicker) => {
219 const { options, picked, onPick } = pickers[picker]
220 const isOpen = view.picker === picker
221 const pickedLabel = options.find(option => option.value === picked)?.label ?? picked
222
223 return (
224 <Box flexDirection="column">
225 <Box flexDirection="row" gap={1}>
226 <Text dimColor>{PICKER_LABELS[picker]}</Text>
227 <Button
228 key={`${PICKER_KEY_PREFIX}${picker}`}
229 plain
230 label={`${pickedLabel} ${isOpen ? OPEN_MARK : CLOSED_MARK}`}
231 onPress={() => actions.onView({ picker: isOpen ? 'none' : picker })}
232 />
233 </Box>
234 {isOpen && (
235 <Box key="picker-menu" flexDirection="column" {...FRAME}>
236 {options.map(option => (
237 <Button
238 key={`${PICK_KEY_PREFIX}${picker}:${option.value}`}
239 plain
240 dimColor={option.value !== picked}
241 label={option.label}
242 onPress={() => onPick(option.value)}
243 />
244 ))}
245 </Box>
246 )}
247 </Box>
248 )
249 }
250
251 const drawSection = (line: SectionLine) => (
252 <Box flexDirection="row" gap={1}>
253 <Button
254 key={`section:${line.kind}`}
255 plain
256 label={`${line.isOpen ? OPEN_MARK : CLOSED_MARK} ${KIND_LABELS[line.kind]}`}
257 onPress={() =>
258 actions.onView({ open: withToggledSection(view.open, line.kind), page })
259 }
260 />
261 <Text dimColor>{line.count}</Text>
262 {line.offCount > 0 && <Text color="warning">{line.offCount} off</Text>}
263 </Box>
264 )
265
266 const drawItem = ({ item, isInSection }: ItemLine) => {
267 const isItemOn = isOn(item)
268 const origin = clipStart(
269 item.isLocked ? `locked · ${item.origin}` : item.origin,
270 room - nameWidth,
271 )
272 const fileLink = fileLinkOf(item.origin)
273
274 return (
275 <Box flexDirection="row" gap={1} paddingLeft={isInSection ? INDENT_COLUMNS : 0}>
276 {item.isLocked ? (
277 <Text dimColor>{CHECKED}</Text>
278 ) : (
279 <Button
280 key={`toggle:${item.id}`}
281 plain
282 label={isItemOn ? CHECKED : UNCHECKED}
283 onPress={() => actions.onToggle(item.id)}
284 />
285 )}
286 <Text dimColor={!isItemOn}>{clipEnd(item.name, nameWidth).padEnd(nameWidth)}</Text>
287 <Text color="suggestion" dimColor={!isItemOn}>
288 {item.scope.padEnd(SCOPE_COLUMNS - 2)}
289 </Text>
290 <Text dimColor>
291 {fileLink === undefined ? origin : <Link href={fileLink} label={origin} />}
292 </Text>
293 </Box>
294 )
295 }
296
297 const drawProfile = (one: Profile) => {
298 const isApplied = one.name === applied
299 const isShown = one.name === profile.name
300
301 return (
302 <Box flexDirection="column">
303 <Box flexDirection="row" gap={1}>
304 <Box flexDirection="row">
305 {isApplied && <Text color={APPLIED_COLOR}>[</Text>}
306 {isApplied && isShown ? (
307 <Text color={APPLIED_COLOR}>{one.name}</Text>
308 ) : (
309 <Button
310 key={`${PROFILE_KEY_PREFIX}${one.name}`}
311 plain
312 dimColor={!isShown}
313 label={one.name}
314 onPress={() =>
315 actions.onView({
316 viewed: one.name,
317 ...(view.edit === 'menu' ? { edit: 'none' as const } : {}),
318 })
319 }
320 />
321 )}
322 {isApplied && <Text color={APPLIED_COLOR}>]</Text>}
323 </Box>
324 {isShown && (
325 <Button
326 key={menuKeyOf(one.name)}
327 plain
328 label={isMenuOpen ? OPEN_MARK : CLOSED_MARK}
329 onPress={() =>
330 actions.onView(isMenuOpen ? { edit: 'none' } : { edit: 'menu', target: one.name })
331 }
332 />
333 )}
334 </Box>
335 {isShown && isMenuOpen && (
336 <Box key="profile-menu" flexDirection="column" {...FRAME}>
337 {menuActions.map(action => (
338 <Button
339 key={`${ACTION_KEY_PREFIX}${action}`}
340 plain
341 label={action}
342 onPress={() => actions.onAction(one.name, action)}
343 />
344 ))}
345 </Box>
346 )}
347 </Box>
348 )
349 }
350
351 return (
352 <Box flexDirection="column" {...(isDocked ? { minHeight: rows } : {})}>
353 <Box flexDirection="row" justifyContent="space-between">
354 <Box flexDirection="row" gap={1}>
355 <Text bold color="claude">
356 Loadout
357 </Text>
358 <Text dimColor>{profile.name}</Text>
359 </Box>
360 <Box key="refresh-frame" {...FRAME}>
361 <Button key="refresh" plain dimColor label="Refresh" onPress={actions.onRefresh} />
362 </Box>
363 </Box>
364 <Box flexDirection="row" gap={2} marginBottom={1}>
365 {drawPicker('project')}
366 {drawPicker('scope')}
367 </Box>
368 <Box flexDirection="column" {...FRAME}>
369 {lines.length === 0 && <Text dimColor>Nothing here in this scope.</Text>}
370 {lines
371 .slice(page * size, (page + 1) * size)
372 .map(line => ('kind' in line ? drawSection(line) : drawItem(line)))}
373 </Box>
374 {pages > 1 && (
375 <Box flexDirection="row" gap={1} justifyContent="center">
376 <Button
377 key="prev"
378 plain
379 label="Prev"
380 hotkey="p"
381 onPress={() => actions.onView({ page: Math.max(0, page - 1) })}
382 />
383 <Text dimColor>
384 page {page + 1}/{pages}
385 </Text>
386 <Button
387 key="next"
388 plain
389 label="Next"
390 hotkey="n"
391 onPress={() => actions.onView({ page: Math.min(pages - 1, page + 1) })}
392 />
393 </Box>
394 )}
395 <Box flexGrow={1} />
396 {view.notice !== '' && <Text dimColor>{clipEnd(view.notice, columns * 2)}</Text>}
397 {view.edit === 'new' && drawNameField('new', actions.onCreate)}
398 {isRenaming && drawNameField('rename', actions.onRename)}
399 <Box key="profile-tabs" flexDirection="row" gap={2} flexWrap="wrap" {...FRAME}>
400 {profiles.map(drawProfile)}
401 <Button
402 key="new-profile"
403 plain
404 label="+"
405 onPress={() => actions.onView({ edit: 'new', target: profile.name })}
406 />
407 </Box>
408 </Box>
409 )
410}
411types/index.d.ts 99 lines1export type Kind =
2 | 'instruction'
3 | 'rule'
4 | 'memory'
5 | 'skill'
6 | 'agent'
7 | 'mcp'
8 | 'hook-event'
9 | 'hook'
10 | 'plugin'
11 | 'permission'
12 | 'setting'
13
14export type Scope =
15 | 'policy'
16 | 'user'
17 | 'project'
18 | 'local'
19 | 'flag'
20 | 'plugin'
21 | 'account'
22 | 'memory'
23 | 'mixed'
24
25export type WritableSource = 'user' | 'project' | 'local'
26
27export type Json =
28 | string
29 | number
30 | boolean
31 | null
32 | Json[]
33 | { [key: string]: Json }
34
35export type SettingsEntry = {
36 mode: 'flag' | 'key' | 'element'
37 source: WritableSource
38 path: string[]
39 value: Json
40}
41
42export type Item = {
43 id: string
44 kind: Kind
45 name: string
46 scope: Scope
47 origin: string
48 isLocked: boolean
49 isOn: boolean
50 entry?: SettingsEntry
51 event?: string
52}
53
54export type Profile = {
55 name: string
56 base: 'on' | 'off'
57 overrides: Record<string, boolean>
58}
59
60export type ProfileEdit = 'none' | 'menu' | 'new' | 'rename'
61
62export type Picker = 'none' | 'project' | 'scope'
63
64export type ProfileAction = 'apply' | 'duplicate' | 'rename' | 'delete'
65
66export type View = {
67 open: Kind[]
68 scope: Scope | 'all'
69 page: number
70 project: string
71 picker: Picker
72 notice: string
73 edit: ProfileEdit
74 target: string
75 viewed: string
76}
77
78export type Stash = Record<string, Item>
79
80export type Ledger = {
81 stash: Stash
82 raised: string[]
83}
84
85declare module 'claude-code' {
86 interface PluginState {
87 loadout: {
88 profiles: Profile[]
89 active: string
90 isLoaded: boolean
91 seen: Item[]
92 items: Item[]
93 projects: string[]
94 projectItems: Item[]
95 view: Shaped<View>
96 }
97 }
98}
99