SLOPSHOPPER

loadout

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

newpanebandguardcommandtoast
★ 11v0.3.0MITupdated 2026-10-08MK27MK/loadout
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · loadout
│ ┃ Loadout ✕ › fix the failing auth test and add an audit log call │ ┃ Loadout default╭──────────────────────────── │ ┃ │ Refresh ⏺ Read(src/auth.ts) │ ┃ ╰──────────────────────────── ⎿ Read 6 lines │ ┃ Project app · session ▸ Scope All ▸ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ╭─────────────────────────────────────────── ⏺ Bash(bun test) │ ┃ │ ☑ app/CLAUDE.md project /work/app/CL ⎿ 3 pass, 1 fail │ ┃ │ file:///work │ ┃ │ ▸ Skills & commands 1 ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ╰─────────────────────────────────────────── │ ┃ ╭──────────────────────────────────────────╮ ✻ Worked for 42s · done 4:20 PM │ ┃ │ [default] ▸ vanilla + │ │ ┃ ╰──────────────────────────────────────────╯ › /loadout │ ┃ ⎿ loadout: Loadout pane opened. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ↗ loadout · default ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
↗ loadout · default
Pane · Loadout
Loadout default╭──────────────────────────────────────────── │ Refresh ╰──────────────────────────────────────────── Project app · session ▸ Scope All ▸ ╭─────────────────────────────────────────────────────────── │ ☑ app/CLAUDE.md project /work/app/CLAUDE.md file:/// │ ▸ Skills & commands 1 ╰─────────────────────────────────────────────────────────── ╭──────────────────────────────────────────────────────────╮ │ [default] ▸ vanilla + │ ╰──────────────────────────────────────────────────────────╯
README

loadout

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.

The loadout pane next to a Claude Code session

Install

/plugin marketplace add MK27MK/loadout
/plugin install loadout@loadout

To run it from a local clone instead:

claude --plugin-dir path/to/loadout

The pane

/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
  • Press the checkbox to turn an element on or off.
  • Project picks the project whose files are listed, Scope filters by scope.
  • The tabs at the bottom are the profiles, with the applied one in brackets. Press a tab to look at a profile. Its ▸ 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.

What it manages

KindOff meansWrites a file
CLAUDE.md, rules, memoriesThe file is left out of the contextNo
Skills and commandsThe command is hidden and refuses to runNo
AgentsThe agent is not offered to the modelNo
MCP serversEvery call to the server is deniedNo
Hook eventsAll hooks of that event are skippedNo
Hooks in settingsThe hook is removed from its settings.jsonYes
PluginsenabledPlugins is set to falseYes
PermissionsThe rule is removed from its settings.jsonYes
SettingsThe key is removed from its settings.jsonYes

Plugin hooks can only be skipped per event, not one by one. After a plugin toggle, run /reload-plugins.

Scopes

Every row shows where the element comes from:

ScopeSource
policyManaged settings of your organization. Always locked.
flagCommand-line flags. Always locked.
userYour config directory (~/.claude, or CLAUDE_CONFIG_DIR)
project.claude/ and .mcp.json in the project
local.claude/settings.local.json
pluginAn installed plugin, with the scope it was installed in
accountA claude.ai connector
memoryA memory file
mixedA hook event with hooks from more than one scope

Profiles

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

Commands

CommandDoes
/loadoutOpens the pane
/loadout statusLists what is off in the active profile
/loadout listLists 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.

Safety

  • Before the first change to a settings file, loadout copies it to <config dir>/loadout-backups/.
  • A removed entry is stored and restored only into the file it came from.
  • Setting values are never shown, only their keys.
  • Project settings behind a symbolic link are not written.

Development

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.

License

MIT

Source 8 files
hooks/register.tsx 1035 lines
1import { 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}
1035
hooks/files.ts 77 lines
1import 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}
77
hooks/inventory.ts 299 lines
1import 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  )
299
hooks/model.ts 160 lines
1import 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}
160
hooks/project.ts 105 lines
1import 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}`)
105
hooks/settingsEdit.ts 243 lines
1import 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
243
hooks/view.tsx 411 lines
1import 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}
411
types/index.d.ts 99 lines
1export 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