A bar above the prompt with your pinned commands and a prompt-cache countdown, a /mod-manager pane to turn mods on and off, run them and save presets of your…

<img src=".claude-plugin/icon.png" width="72" align="right" alt="">
The mod manager. You get three things:
/mod-manager pane/hub, a live web control room on 127.0.0.1:8796
/mod-manager lets you:It lists itself first, marked.
mod_presets tool, but only you can apply one./hub opens the web app. It has four views:Ctrl K opens a command palette.
mod-setup skill teaches an agent to install, check and add mods in this marketplace.| Setting | Default | |
|---|---|---|
port | 8796 | The local port the web app listens on. |
showBar | true | Off hides the bar; /mod-manager and /hub still work. |
cacheTtl | auto | 5m or 1h fixes the countdown's lifetime instead of learning it. |
The page never runs anything itself. It queues one of a fixed set of actions, and the mod checks each one against what this session has installed. The server listens on 127.0.0.1 only and needs a token only the served page knows. It refuses foreign Host and Origin headers. See SECURITY.md.
The server writes ~/.claude/mod-hub/hub.log, so a hub that stopped says why. A hub that disappears is started again, at most once a minute.
node --test server/hub.test.mjs
cd web && npm ci && npm run build # writes ../app, which is committed
npm run build:demo # the NON-LIVE DATA Pages demo, into web/dist-demohooks/register.tsx 1258 lines1// Mod Hub: every installed mod in one place, without typing commands in chat.
2// - A bar above the prompt: ⚙ Mods opens the manager pane, ◈ Hub the web app, then a
3// button for each pinned command, and ↻ Reload once a mod was turned on or off.
4// - /mod-manager: a pane listing each mod with On/Off, a button per command, and pins.
5// - /hub: starts the local web app (server/hub.mjs) and opens it in the browser.
6// - Presets: named setups (mods on or off, pins, commands to run), saved and applied from
7// the pane or the hub. Claude can make them through the mod_presets tool; only the person applies.
8// - The bar shows how long the prompt cache stays warm after the last request.
9// The page only queues actions; this module checks each one against what is
10// installed and runs it: a command through $.command.run, On/Off through the
11// running Claude Code's `plugin enable|disable`, a reload through /reload-plugins.
12import { atom, read, update } from 'claude-code'
13import type { CommandInfo, EngineInterface, Register, Timer } from 'claude-code'
14
15import type { HubState, ModRow } from '../types'
16import { bandsFile, parseOrder, stackBands } from './bands'
17import { cleanView, counts, DEFAULT_VIEW, FILTERS, isDefault, isNew, SORTS, sources, viewMods, type View } from '../shared/view'
18import { cleanPreset, countdown, isToggle, plan, snapshot, summary, upsert, type Preset, type RecentCommand } from '../shared/presets'
19
20const NAME = 'mod-hub'
21const PANE = 'mod-hub'
22const PER_PAGE = { cards: 6, compact: 12 }
23const VIEW_KEY = 'view'
24const TICK_MS = 1_500 // how often the hub is asked for actions while it runs
25const REFRESH_MS = 15_000 // how often installed plugins are read again
26const PINS_KEY = 'pins'
27const DEFAULT_PINS = ['mod-logic', 'mission']
28const SERVER_VERSION = 3 // server/hub.mjs SERVER_VERSION: a running hub below this is replaced
29const PRESETS_KEY = 'presets'
30const APPLY_KEY = 'pendingApply' // a preset whose commands wait for the reload its mods asked for
31const TTL_KEY = 'cacheTtl'
32const MIN5 = 5 * 60_000
33const HOUR = 60 * 60_000
34const MAX_RECENT = 12
35
36const state = atom({ plugin: 'mod-hub', key: 'state' } as const, null as HubState | null)
37const hubUrl = atom({ plugin: 'mod-hub', key: 'hubUrl' } as const, '')
38const note = atom({ plugin: 'mod-hub', key: 'note' } as const, '')
39const view = atom({ plugin: 'mod-hub', key: 'view' } as const, DEFAULT_VIEW as View)
40const compact = atom({ plugin: 'mod-hub', key: 'compact' } as const, false)
41const clickMode = atom({ plugin: 'mod-hub', key: 'clickMode' } as const, 'run' as 'run' | 'copy')
42const page = atom({ plugin: 'mod-hub', key: 'page' } as const, 0)
43const busy = atom({ plugin: 'mod-hub', key: 'busy' } as const, '')
44const tab = atom({ plugin: 'mod-hub', key: 'tab' } as const, 'mods' as 'mods' | 'presets')
45const editing = atom({ plugin: 'mod-hub', key: 'editing' } as const, '')
46const confirmDelete = atom({ plugin: 'mod-hub', key: 'confirmDelete' } as const, '')
47const draftName = atom({ plugin: 'mod-hub', key: 'draftName' } as const, '')
48const recentAtom = atom({ plugin: 'mod-hub', key: 'recent' } as const, [] as RecentCommand[])
49const cacheAt = atom({ plugin: 'mod-hub', key: 'cacheAt' } as const, 0)
50const beat = atom({ plugin: 'mod-hub', key: 'beat' } as const, 0)
51
52type Server = { port: number; host: string; token: string; pid: number }
53type Action =
54 | { seq: number; type: 'run'; command: string; args?: string }
55 | { seq: number; type: 'toggle'; id: string; on: boolean }
56 | { seq: number; type: 'reload' }
57 | { seq: number; type: 'pins'; pins: string[] }
58 | { seq: number; type: 'preset-save'; preset: unknown }
59 | { seq: number; type: 'preset-delete'; id: string }
60 | { seq: number; type: 'preset-apply'; id: string }
61 | { seq: number; type: 'preset-snapshot'; name: string }
62
63let port = 8796
64let showBar = true
65let timer: Timer | undefined
66let ticks = 0
67let lastSeq = 0
68let lastPushed = ''
69let isTurnRunning = false // a command run now waits for the turn to end; the person is told so
70let pending: string[] = [] // module-level: a reload runs register again and empties it, which is right
71let bandOrder: Promise<string[]> | undefined
72let ttlMs = MIN5 // the cache lifetime: 5 min until a request shows it lasts an hour
73let isTtlLearned = false
74let ttlOverride: number | null = null
75let beatTimer: Timer | undefined
76let wasHubUp = false // only a hub that was up and went away is restarted on its own
77let lastRestartAt = 0
78
79export const register: Register = (on, options) => {
80 port = typeof options.port === 'number' && options.port >= 1024 ? options.port : 8796
81 showBar = options.showBar !== false
82 ttlOverride = options.cacheTtl === '5m' ? MIN5 : options.cacheTtl === '1h' ? HOUR : null
83 pending = []
84 lastPushed = ''
85 bandOrder = undefined
86
87 on('session.start', async ($, e, next) => {
88 const r = await next(e)
89 await $.command.register({ name: 'mod-manager', description: 'Turn mods on and off, run their commands, and pin them to the bar' }).catch(() => {})
90 await $.command.register({ name: 'hub', description: 'Open the Mod Hub web app: every mod, live, with a command palette' }).catch(() => {})
91 timer?.cancel()
92 timer = $.clock.every(TICK_MS, () => {
93 void tick($).catch(() => {})
94 })
95 await refresh($).catch(() => {})
96 await restoreView($).catch(() => {})
97 await restoreCache($).catch(() => {})
98 await $.tool.register({
99 name: 'mod_presets',
100 description:
101 "Make, change, list or delete the person's mod presets (mod-hub). A preset is a named setup: mods to turn on or off " +
102 "(only the mods it names change), the bar's pinned commands, and slash commands to run when applied. Use action " +
103 "'list' first: it shows the installed mods, their commands (toggles marked), the commands the person ran lately and " +
104 "the presets so far. 'save' creates one (no id) or updates one (its id). You only save presets: the person applies " +
105 'them from the Mod Manager or the hub. Leave commands marked as toggles off (enabled: false) unless asked.',
106 inputSchema: {
107 type: 'object',
108 properties: {
109 action: { type: 'string', enum: ['list', 'save', 'delete'] },
110 id: { type: 'string', description: 'For delete: the preset id' },
111 preset: {
112 type: 'object',
113 description: 'For save. Give id to update an existing preset.',
114 properties: {
115 id: { type: 'string' },
116 name: { type: 'string' },
117 note: { type: 'string', description: 'One line on what it is for' },
118 mods: { type: 'object', description: 'plugin id (name@marketplace) to "on" or "off"', additionalProperties: { type: 'string', enum: ['on', 'off'] } },
119 steps: { type: 'array', items: { type: 'object', properties: { command: { type: 'string' }, args: { type: 'string' }, enabled: { type: 'boolean' } }, required: ['command'] } },
120 pins: { type: 'array', items: { type: 'string' }, description: 'Command names for the bar; leave out to keep the pins as they are' },
121 },
122 required: ['name'],
123 },
124 },
125 required: ['action'],
126 },
127 }).catch(() => {})
128 $.clock.after(1_500, () => void resumeApply($).catch(() => {})) // after the other mods have registered their commands
129 void connect($).catch(() => {}) // a hub left running from an earlier session picks up again
130 return r
131 })
132
133 on('command.run', { command: 'mod-manager' }, async $ => {
134 await refresh($).catch(() => {})
135 await $.ui.open({ id: PANE, title: 'Mod Manager', focus: true })
136 return { text: 'Mod Manager opened.' }
137 })
138
139 on('turn.start', async ($, e, next) => {
140 isTurnRunning = true
141 return next(e)
142 })
143 on('turn.complete', async ($, e, next) => {
144 if (!e.agentId) isTurnRunning = false // a subagent's turn ending leaves the main one running
145 return next(e)
146 })
147
148 on('command.run', { command: 'hub' }, async $ => {
149 const url = await openHub($)
150 return { text: url ? `Mod Hub: ${url}` : `Mod Hub did not start: ${await read($, note)}` }
151 })
152
153 // Every command the person runs, remembered for a preset's "save current setup".
154 on('command.run', async ($, e, next) => {
155 const r = await next(e)
156 if (e.origin.kind !== 'plugin') await remember($, e.command, e.args ?? '').catch(() => {})
157 return r
158 })
159
160 // Each main-thread model request restarts the prompt cache's clock.
161 on('turn.step', async function* ($, e, next) {
162 const result = yield* next(e)
163 if (!e.agentId && result.usage) await sawRequest($, result.usage.cache_read_input_tokens).catch(() => {})
164 return result
165 })
166
167 on('tool.call', { tool: 'mcp__mod-hub__mod_presets' }, async ($, e) => presetTool($, e as unknown as Record<string, unknown>))
168
169 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => drawPane($, e))
170
171 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
172 const rest = await next(e)
173 if (!showBar || e.props.hasSurvey) return rest
174 const s = await read($, state)
175 const { Box, Button, Text } = $.ui.resolve(e)
176 const runnable = new Set(allCommands(s).map(c => c.name))
177 const pins = (s?.pins ?? []).filter(p => runnable.has(p))
178 const cache = await cacheLine($)
179 // Its own panel, bordered like context-bar's, so the buttons read as one control and not as loose text.
180 const mine = (
181 <Box flexDirection="column" borderStyle="round" borderColor="inactive" paddingX={1}>
182 <Box flexDirection="row" flexWrap="wrap" columnGap={1} alignItems="center">
183 <Text color="#e3a857" bold>
184 {'▣ mods'}
185 </Text>
186 <Text dimColor>│</Text>
187 <Button key="mods" label="⚙ Manage" onPress={() => openPane($)} />
188 <Button key="hub" label="◈ Hub" onPress={() => void openHub($)} />
189 {pending.length > 0 && (
190 <Button key="reload" label={`↻ Reload · ${pending.length} changed`} variant="primary" onPress={() => reload($)} />
191 )}
192 <Text dimColor>│</Text>
193 <Text color={cache.color} dimColor={cache.isDim}>
194 {cache.text}
195 </Text>
196 <Text dimColor>│</Text>
197 {pins.length > 0 ? <Text dimColor>pinned</Text> : <Text dimColor>☆ pin commands in Manage to keep them here</Text>}
198 {pins.map(p => (
199 <Button key={`pin-${p}`} label={`/${p}`} plain onPress={() => pressCommand($, p)} />
200 ))}
201 </Box>
202 </Box>
203 )
204 return stackBands(rest, NAME, mine, 'above', await (bandOrder ??= readBandOrder($).catch(() => [])))
205 })
206}
207
208// ── Reading what is installed ─────────────────────────────────────────────
209
210async function refresh($: EngineInterface) {
211 const [commands, installed, enabled, version, stored] = await Promise.all([
212 $.command.list().catch((): CommandInfo[] => []),
213 readInstalled($),
214 $.settings.read({ key: 'enabledPlugins' }).then(v => (v && typeof v === 'object' ? (v as Record<string, unknown>) : {}), () => ({}) as Record<string, unknown>),
215 $.session.version().then(v => v.version, () => undefined),
216 $.store.get(PINS_KEY).catch(() => undefined),
217 ])
218 const pins = Array.isArray(stored) ? stored.filter((p): p is string => typeof p === 'string') : DEFAULT_PINS
219 const presets = await loadPresets($)
220 const recent = await read($, recentAtom)
221 const at = await read($, cacheAt)
222 await update($, state, () => ({ ...build(commands, installed, enabled, pins, pending, version), presets, recent, cache: at ? { at, ttlMs, isLearned: isTtlLearned } : null }))
223}
224
225type Installed = { id: string; name: string; marketplace: string; version: string; installedAt: string; description: string }
226
227// Every plugin in a commands list and a settings snapshot, as rows the bar, pane and hub draw.
228export function build(
229 commands: CommandInfo[],
230 installed: Installed[],
231 enabled: Record<string, unknown>,
232 pins: string[],
233 pendingIds: string[],
234 claudeVersion?: string,
235): HubState {
236 const byPlugin = new Map<string, { name: string; description: string }[]>()
237 const otherCommands: HubState['otherCommands'] = []
238 for (const c of commands) {
239 if (c.source === 'plugin') {
240 const key = (c.plugin ?? '').replace(/@.*$/, '')
241 byPlugin.set(key, [...(byPlugin.get(key) ?? []), { name: c.name, description: c.description }])
242 } else {
243 otherCommands.push({ name: c.name, description: c.description, source: c.source })
244 }
245 }
246 const mods: ModRow[] = installed
247 .map(p => ({
248 ...p,
249 enabled: enabled[p.id] !== false, // installed and not switched off
250 commands: (byPlugin.get(p.name) ?? []).sort((a, b) => a.name.localeCompare(b.name)),
251 isSelf: p.name === NAME,
252 }))
253 .sort((a, b) => Number(b.enabled) - Number(a.enabled) || a.name.localeCompare(b.name))
254 return { generatedAt: Date.now(), claudeVersion, mods, otherCommands, pins, pending: [...pendingIds], presets: [], recent: [], cache: null }
255}
256
257function allCommands(s: HubState | null) {
258 return [...(s?.mods.flatMap(m => m.commands) ?? []), ...(s?.otherCommands ?? [])]
259}
260
261async function configDir($: EngineInterface): Promise<string> {
262 const config = await $.env.get('CLAUDE_CONFIG_DIR').catch(() => undefined)
263 if (config) return config.replace(/\\/g, '/').replace(/\/+$/, '')
264 const home = (await $.env.get('HOME').catch(() => undefined)) ?? (await $.env.get('USERPROFILE').catch(() => undefined)) ?? ''
265 return `${home.replace(/\\/g, '/').replace(/\/+$/, '')}/.claude`
266}
267
268async function readInstalled($: EngineInterface): Promise<Installed[]> {
269 const text = await $.fs.read(`${await configDir($)}/plugins/installed_plugins.json`).catch(() => '')
270 let plugins: Record<string, unknown> = {}
271 try {
272 plugins = (JSON.parse(typeof text === 'string' ? text : '') as { plugins?: Record<string, unknown> }).plugins ?? {}
273 } catch {
274 return []
275 }
276 const out: Installed[] = []
277 for (const [id, rows] of Object.entries(plugins)) {
278 const row = (Array.isArray(rows) ? rows[0] : undefined) as Record<string, unknown> | undefined
279 if (!row) continue
280 const at = id.lastIndexOf('@')
281 const path = typeof row.installPath === 'string' ? row.installPath.replace(/\\/g, '/') : ''
282 const manifest = path ? await $.fs.read(`${path}/.claude-plugin/plugin.json`).catch(() => '') : ''
283 let description = ''
284 try {
285 const d = (JSON.parse(typeof manifest === 'string' ? manifest : '') as { description?: unknown }).description
286 description = typeof d === 'string' ? d : ''
287 } catch {}
288 out.push({
289 id,
290 name: at > 0 ? id.slice(0, at) : id,
291 marketplace: at > 0 ? id.slice(at + 1) : '',
292 version: String(row.version ?? ''),
293 installedAt: String(row.installedAt ?? ''),
294 description,
295 })
296 }
297 return out
298}
299
300async function readBandOrder($: EngineInterface): Promise<string[]> {
301 const home = (await $.env.get('HOME').catch(() => undefined)) ?? (await $.env.get('USERPROFILE').catch(() => undefined))?.replace(/\\/g, '/')
302 const file = bandsFile(await $.env.get('CLAUDE_CONFIG_DIR'), home)
303 return file && (await $.fs.exists(file)) ? parseOrder(await $.fs.read(file)) : []
304}
305
306// ── Doing things ──────────────────────────────────────────────────────────
307
308async function openPane($: EngineInterface) {
309 await refresh($).catch(() => {})
310 await $.ui.open({ id: PANE, title: 'Mod Manager', focus: true })
311}
312
313// Runs a slash command the session lists, as if the person typed it.
314async function runCommand($: EngineInterface, command: string, args = '') {
315 const known = (await $.command.list().catch((): CommandInfo[] => [])).some(c => c.name === command)
316 if (!known) {
317 await update($, note, () => `/${command} is not a command this session has`)
318 return
319 }
320 // The engine runs a command started mid-turn once the turn ends: say so, so the press is not a mystery.
321 if (isTurnRunning) $.ui.toast(`/${command} is queued: it runs as soon as Claude finishes this turn`)
322 if (command !== 'reload-plugins') await remember($, command, args).catch(() => {})
323 await $.command.run({ command, args }).catch(async (err: unknown) => {
324 await update($, note, () => `/${command} did not run: ${err instanceof Error ? err.message : String(err)}`)
325 })
326}
327
328// Puts `/command` on the clipboard: the surface's own first, else the system's (clip, pbcopy, xclip).
329async function copyCommand($: EngineInterface, command: string) {
330 const text = `/${command}`
331 const viaSurface = await $.ui.copy({ text }).catch(() => ({ isCopied: false }))
332 let isCopied = viaSurface.isCopied
333 if (!isCopied) {
334 const isWindows = /^[A-Za-z]:\//.test($.plugin.root.replace(/\\/g, '/'))
335 const tools = isWindows ? [['clip']] : [['pbcopy'], ['wl-copy'], ['xclip', '-selection', 'clipboard']]
336 for (const argv of tools) {
337 const r = await $.process.run(argv, { stdin: text, timeoutMs: 5_000 }).catch(() => null)
338 if (r?.exitCode === 0) {
339 isCopied = true
340 break
341 }
342 }
343 }
344 $.ui.toast(isCopied ? `Copied ${text}: paste it into the prompt` : `Could not reach a clipboard. The command is ${text}`)
345}
346
347// What a press on a command does: run it, or copy it, as the Click switch says.
348async function pressCommand($: EngineInterface, command: string) {
349 if ((await read($, clickMode)) === 'copy') await copyCommand($, command)
350 else await runCommand($, command)
351}
352
353// Turns an installed plugin on or off through the running Claude Code; takes effect on reload.
354async function toggle($: EngineInterface, id: string, isOn: boolean) {
355 const s = await read($, state)
356 const mod = s?.mods.find(m => m.id === id)
357 if (!mod || mod.isSelf) return
358 await update($, busy, () => id)
359 const exe = (await $.env.get('CLAUDE_CODE_EXECPATH').catch(() => undefined)) || 'claude'
360 const r = await $.process.run([exe, 'plugin', isOn ? 'enable' : 'disable', id], { timeoutMs: 60_000 }).catch(() => null)
361 await update($, busy, () => '')
362 if (!r || r.exitCode !== 0) {
363 await update($, note, () => `Could not turn ${mod.name} ${isOn ? 'on' : 'off'}: ${(r?.stderr || r?.stdout || 'claude did not run').trim().split('\n').slice(-1)[0]}`)
364 return
365 }
366 pending = pending.includes(id) ? pending.filter(p => p !== id) : [...pending, id] // a second flip undoes the first
367 await update($, note, () => '')
368 await update($, state, prev => (prev ? { ...prev, pending: [...pending], mods: prev.mods.map(m => (m.id === id ? { ...m, enabled: isOn } : m)) } : prev))
369 $.ui.toast(`${mod.name} turned ${isOn ? 'on' : 'off'}. Reload to apply`)
370}
371
372async function reload($: EngineInterface) {
373 await $.command.run({ command: 'reload-plugins' }).catch(() => $.ui.toast('Run /reload-plugins to apply the changes'))
374}
375
376async function setPins($: EngineInterface, pins: string[]) {
377 const clean = [...new Set(pins.filter(p => typeof p === 'string' && /^[\w:.-]{1,64}$/.test(p)))].slice(0, 12)
378 await $.store.set(PINS_KEY, clean).catch(() => {})
379 await update($, state, prev => (prev ? { ...prev, pins: clean } : prev))
380}
381
382async function togglePin($: EngineInterface, command: string) {
383 const pins = (await read($, state))?.pins ?? []
384 await setPins($, pins.includes(command) ? pins.filter(p => p !== command) : [...pins, command])
385}
386
387// ── The hub server ────────────────────────────────────────────────────────
388
389async function serverInfo($: EngineInterface): Promise<Server | null> {
390 const text = await $.fs.read(`${await configDir($)}/mod-hub/server.json`).catch(() => '')
391 try {
392 const s = JSON.parse(typeof text === 'string' ? text : '') as Server
393 return typeof s.port === 'number' && typeof s.token === 'string' ? s : null
394 } catch {
395 return null
396 }
397}
398
399// What a running hub says about itself; null when nothing answers as mod-hub.
400async function ping($: EngineInterface, s: Server): Promise<{ pid: number; version: number } | null> {
401 const r = await $.http.fetch(`http://127.0.0.1:${s.port}/api/ping`, { headers: { 'x-mod-hub-token': s.token } }).catch(() => null)
402 if (r?.status !== 200) return null
403 try {
404 const p = JSON.parse(r.text) as { ok?: boolean; app?: string; pid?: number; version?: number }
405 return p.ok && p.app === 'mod-hub' ? { pid: Number(p.pid ?? 0), version: Number(p.version ?? 1) } : null
406 } catch {
407 return null
408 }
409}
410
411// Finds a running hub and remembers its address; '' when none answers. A hub older than this
412// mod's server (left running across an update) is replaced, or it would keep serving the old API.
413async function connect($: EngineInterface): Promise<string> {
414 const s = await serverInfo($)
415 let alive = s ? await ping($, s) : null
416 if (s && alive && alive.version < SERVER_VERSION) {
417 await replaceHub($, s, alive.pid)
418 alive = null
419 for (let i = 0; i < 20 && !alive; i++) {
420 await $.clock.sleep(250)
421 const fresh = await serverInfo($)
422 alive = fresh ? await ping($, fresh) : null
423 }
424 }
425 const info = alive ? await serverInfo($) : null
426 const url = info && alive ? `http://127.0.0.1:${info.port}/` : ''
427 await update($, hubUrl, () => url)
428 return url
429}
430
431// Stops an old hub (by /api/shutdown, or for one too old to have it, by its pid: only the pid that
432// answered as mod-hub and that server.json names) and starts this version's.
433async function replaceHub($: EngineInterface, s: Server, pid: number) {
434 const r = await $.http.fetch(`http://127.0.0.1:${s.port}/api/shutdown`, { method: 'POST', headers: { 'x-mod-hub-token': s.token } }).catch(() => null)
435 if (r?.status !== 200 && pid > 0 && pid === s.pid) {
436 const isWindows = /^[A-Za-z]:\//.test($.plugin.root.replace(/\\/g, '/'))
437 await $.process.run(isWindows ? ['taskkill', '/PID', String(pid), '/F'] : ['kill', String(pid)], { timeoutMs: 10_000 }).catch(() => null)
438 }
439 await $.clock.sleep(400) // the port frees once the old process is gone
440 await startServer($)
441}
442
443// Starts the hub when it is not running, waits for it to answer, and opens it in the browser.
444async function openHub($: EngineInterface, hash = ''): Promise<string> {
445 let url = await connect($)
446 if (!url) {
447 const started = await startServer($)
448 if (!started) return ''
449 for (let i = 0; i < 20 && !url; i++) {
450 await $.clock.sleep(250)
451 url = await connect($)
452 }
453 if (!url) {
454 await update($, note, () => `the hub did not answer on port ${port}; is something else using it?`)
455 return ''
456 }
457 }
458 lastPushed = '' // a fresh page gets the whole state at once
459 await push($).catch(() => {})
460 await openUrl($, `${url}${hash}`)
461 return url
462}
463
464async function startServer($: EngineInterface): Promise<boolean> {
465 const root = $.plugin.root.replace(/\\/g, '/')
466 const args = [`${root}/server/hub.mjs`, '--dir', `${await configDir($)}/mod-hub`, '--app', `${root}/app`, '--port', String(port)]
467 const isWindows = /^[A-Za-z]:\//.test(root)
468 // Detached, so the hub outlives this call and the session: on Windows WMI starts it as a child of
469 // the WMI service, outside the app's process tree, so closing a window cannot take it down. nohup elsewhere.
470 const line = ['node', ...args].map(a => `"${a}"`).join(' ').replace(/'/g, "''")
471 const argv = isWindows
472 ? ['powershell', '-NoProfile', '-NonInteractive', '-Command', `$si = New-CimInstance -ClassName Win32_ProcessStartup -ClientOnly -Property @{ShowWindow=[uint16]0}; $r = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{CommandLine='${line}'; ProcessStartupInformation=$si}; if ($r.ReturnValue -ne 0) { [Console]::Error.WriteLine("WMI could not start node: $($r.ReturnValue)"); exit 1 }`]
473 : ['sh', '-c', 'nohup node "$@" >/dev/null 2>&1 &', 'mod-hub', ...args]
474 const r = await $.process.run(argv, { timeoutMs: 15_000 }).catch((err: unknown) => ({ exitCode: -1, stdout: '', stderr: String(err) }))
475 if (r.exitCode !== 0) {
476 await update($, note, () => `could not start the hub (is Node.js installed?): ${r.stderr.trim().split('\n').slice(-1)[0]}`)
477 return false
478 }
479 return true
480}
481
482async function openUrl($: EngineInterface, url: string) {
483 const isWindows = /^[A-Za-z]:\//.test($.plugin.root.replace(/\\/g, '/'))
484 const uname = isWindows ? null : await $.process.run(['uname', '-s'], { timeoutMs: 3_000 }).catch(() => null)
485 const argv = isWindows ? ['explorer.exe', url] : [uname?.stdout.trim() === 'Darwin' ? 'open' : 'xdg-open', url]
486 await $.process.run(argv, { timeoutMs: 10_000 }).catch(() => null)
487}
488
489// Sends the state when it changed (and every ~10 s, so the page knows the session is alive).
490async function push($: EngineInterface) {
491 const s = await read($, state)
492 const server = await serverInfo($)
493 if (!s || !server) return
494 const body = JSON.stringify(s)
495 const sameAsLast = body.replace(/"generatedAt":\d+/, '') === lastPushed
496 if (sameAsLast && ticks % 7 !== 0) return
497 const r = await $.http.fetch(`http://127.0.0.1:${server.port}/api/state`, {
498 method: 'POST',
499 headers: { 'content-type': 'application/json', 'x-mod-hub-token': server.token },
500 body,
501 }).catch(() => null)
502 if (r?.ok) lastPushed = body.replace(/"generatedAt":\d+/, '')
503 else await update($, hubUrl, () => '')
504}
505
506// A hub that was up and went away is started again, at most once a minute; why it stopped is in hub.log.
507async function restartLostHub($: EngineInterface) {
508 if (!wasHubUp) return
509 const now = await $.clock.now()
510 if (now - lastRestartAt < 60_000) return
511 lastRestartAt = now
512 if (await startServer($)) $.ui.toast(`The Mod Hub stopped, so it was started again. Why it stopped: ${await configDir($)}/mod-hub/hub.log`)
513}
514
515// One beat: read plugins now and then; while the hub runs, send the state and run what it queued.
516async function tick($: EngineInterface) {
517 ticks++
518 if (ticks % Math.round(REFRESH_MS / TICK_MS) === 0) await refresh($).catch(() => {})
519 if (!(await read($, hubUrl))) {
520 if (ticks % 10 === 0 && !(await connect($))) await restartLostHub($) // a hub started elsewhere joins within 15 s
521 return
522 }
523 wasHubUp = true
524 await push($)
525 const server = await serverInfo($)
526 if (!server) return
527 const r = await $.http.fetch(`http://127.0.0.1:${server.port}/api/actions?after=${lastSeq}`, { headers: { 'x-mod-hub-token': server.token } }).catch(() => null)
528 if (!r?.ok) return
529 let actions: Action[] = []
530 try {
531 actions = (JSON.parse(r.text) as { actions?: Action[] }).actions ?? []
532 } catch {
533 return
534 }
535 for (const a of actions) {
536 if (typeof a.seq !== 'number' || a.seq <= lastSeq) continue
537 lastSeq = a.seq
538 await perform($, a)
539 }
540}
541
542// What the page asked for, checked against this session before it runs.
543async function perform($: EngineInterface, a: Action) {
544 if (a.type === 'run' && typeof a.command === 'string') await runCommand($, a.command, typeof a.args === 'string' ? a.args.slice(0, 500) : '')
545 else if (a.type === 'toggle' && typeof a.id === 'string' && typeof a.on === 'boolean') await toggle($, a.id, a.on)
546 else if (a.type === 'reload') await reload($)
547 else if (a.type === 'pins' && Array.isArray(a.pins)) await setPins($, a.pins)
548 else if (a.type === 'preset-save') await savePreset($, a.preset, 'you')
549 else if (a.type === 'preset-delete' && typeof a.id === 'string') await deletePreset($, a.id)
550 else if (a.type === 'preset-apply' && typeof a.id === 'string') await applyPreset($, a.id)
551 else if (a.type === 'preset-snapshot' && typeof a.name === 'string') await snapshotPreset($, a.name)
552}
553
554// ── The pane ──────────────────────────────────────────────────────────────
555
556async function restoreView($: EngineInterface) {
557 const saved = (await $.store.get(VIEW_KEY).catch(() => undefined)) as { view?: unknown; compact?: unknown } | undefined
558 await update($, view, () => ({ ...cleanView(saved?.view), query: '' })) // the search starts empty each session
559 await update($, compact, () => saved?.compact === true)
560 await update($, clickMode, () => ((saved as { clickMode?: unknown } | undefined)?.clickMode === 'copy' ? 'copy' : 'run'))
561}
562
563async function setClickMode($: EngineInterface, mode: 'run' | 'copy') {
564 await update($, clickMode, () => mode)
565 await $.store.set(VIEW_KEY, { view: { ...(await read($, view)), query: '' }, compact: await read($, compact), clickMode: mode }).catch(() => {})
566}
567
568// Changes the view, starts it at its first page, and keeps it for next session.
569async function setView($: EngineInterface, change: Partial<View>) {
570 const next = await update($, view, v => ({ ...v, ...change }))
571 await update($, page, () => 0)
572 await $.store.set(VIEW_KEY, { view: { ...next, query: '' }, compact: await read($, compact), clickMode: await read($, clickMode) }).catch(() => {})
573}
574
575async function setCompact($: EngineInterface, isCompact: boolean) {
576 await update($, compact, () => isCompact)
577 await update($, page, () => 0)
578 await $.store.set(VIEW_KEY, { view: { ...(await read($, view)), query: '' }, compact: isCompact, clickMode: await read($, clickMode) }).catch(() => {})
579}
580
581// "3 h ago", "2 days ago": how long since a mod was installed.
582export function since(iso: string, now: number): string {
583 const at = Date.parse(iso)
584 if (!Number.isFinite(at)) return ''
585 const m = Math.max(0, Math.round((now - at) / 60_000))
586 if (m < 60) return `${m} min ago`
587 const h = Math.round(m / 60)
588 if (h < 48) return `${h} h ago`
589 return `${Math.round(h / 24)} days ago`
590}
591
592// The strip under the pane's title: one rounded bar split into on, waiting on a reload, and off.
593export function statusStrip(on: number, changed: number, off: number): string {
594 const W = 860
595 const H = 54
596 const total = Math.max(1, on + changed + off)
597 const parts = [
598 { n: on, color: '#e3a857', label: 'on' },
599 { n: changed, color: '#e8d27a', label: 'waiting on a reload' },
600 { n: off, color: '#6b645a', label: 'off' },
601 ].filter(p => p.n > 0)
602 const o = [`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" font-family="Segoe UI, system-ui, sans-serif">`]
603 o.push(`<rect x="0" y="6" width="${W}" height="12" rx="6" fill="#2b2620"/>`)
604 let x = 0
605 parts.forEach((p, i) => {
606 const w = (p.n / total) * W
607 const isFirst = i === 0
608 const isLast = i === parts.length - 1
609 o.push(`<rect x="${x.toFixed(1)}" y="6" width="${Math.max(2, w - (isLast ? 0 : 3)).toFixed(1)}" height="12" rx="${isFirst || isLast ? 6 : 2}" fill="${p.color}"/>`)
610 x += w
611 })
612 let lx = 0
613 for (const p of parts) {
614 o.push(`<circle cx="${lx + 5}" cy="40" r="4.5" fill="${p.color}"/>`)
615 o.push(`<text x="${lx + 15}" y="44.5" font-size="13" fill="#b9b1a2"><tspan font-weight="700" fill="#ece6da">${p.n}</tspan> ${p.label}</text>`)
616 lx += 36 + String(p.n).length * 7.8 + p.label.length * 6.3 // 13 px Segoe: digits bold, words regular
617 }
618 o.push('</svg>')
619 return o.join('')
620}
621
622async function drawPane($: EngineInterface, e: Parameters<EngineInterface['ui']['resolve']>[0]) {
623 const elements = $.ui.resolve(e)
624 const { Box, Text, Button } = elements
625 const Input = 'Input' in elements ? elements.Input : undefined
626 const Select = 'Select' in elements ? elements.Select : undefined
627 const Svg = 'Svg' in elements ? elements.Svg : undefined
628 const s = await read($, state)
629 if (!s) return <Text dimColor>Reading your mods…</Text>
630 if ((await read($, tab)) === 'presets') return drawPresets($, e, s)
631 const v = await read($, view)
632 const isCompact = await read($, compact)
633 const at = await read($, page)
634 const problem = await read($, note)
635 const working = await read($, busy)
636 const url = await read($, hubUrl)
637 const now = await $.clock.now()
638 const mode = await read($, clickMode)
639
640 const ctx = { pins: s.pins, pending: s.pending, now }
641 // This mod always leads the list, wherever the sort would put it.
642 const shown = viewMods(s.mods, v, ctx).sort((a, b) => Number(b.isSelf) - Number(a.isSelf))
643 const tally = counts(s.mods, v, ctx)
644 const per = isCompact ? PER_PAGE.compact : PER_PAGE.cards
645 const pages = Math.max(1, Math.ceil(shown.length / per))
646 const p = Math.min(at, pages - 1)
647 const on = s.mods.filter(m => m.enabled).length
648 const markets = sources(s.mods)
649 const changedCount = s.pending.length
650
651 const pill = (key: string, text: string, bg: string) => (
652 <Text key={key} backgroundColor={bg} color="inverseText" weight="semibold" size="caption">
653 {` ${text} `}
654 </Text>
655 )
656 const flip = (m: ModRow) =>
657 m.isSelf ? (
658 pill('self-on', '◈ ALWAYS ON', 'claude')
659 ) : working === m.id ? (
660 <Text color="suggestion">switching…</Text>
661 ) : (
662 <Button key={`flip-${m.id}`} label={m.enabled ? '● On' : '○ Off'} variant={m.enabled ? 'primary' : undefined} onPress={() => toggle($, m.id, !m.enabled)} />
663 )
664 const commandButtons = (m: ModRow) =>
665 m.commands.map(c => (
666 <Box key={`cmd-${m.id}-${c.name}`} flexDirection="row">
667 <Button key={`run-${c.name}`} label={`${mode === 'copy' ? '⧉' : '▶'} /${c.name}`} onPress={() => pressCommand($, c.name)} />
668 {mode === 'copy' ? (
669 <Button key={`alt-${c.name}`} label="▶" plain dimColor onPress={() => runCommand($, c.name)} />
670 ) : (
671 <Button key={`alt-${c.name}`} label="⧉" plain dimColor onPress={() => copyCommand($, c.name)} />
672 )}
673 <Button key={`pin-${c.name}`} label={s.pins.includes(c.name) ? '★' : '☆'} plain dimColor={!s.pins.includes(c.name)} onPress={() => togglePin($, c.name)} />
674 </Box>
675 ))
676 const badges = (m: ModRow) => [
677 m.isSelf ? pill(`b-self-${m.id}`, 'THIS MOD', 'claude') : null,
678 isNew(m, now) ? pill(`b-new-${m.id}`, 'NEW', 'success') : null,
679 s.pending.includes(m.id) ? pill(`b-chg-${m.id}`, 'RELOAD TO APPLY', 'warning') : null,
680 ]
681
682 return (
683 <Box flexDirection="column" gap={1}>
684 {/* Header: the name, one line of status, and the pane's actions. */}
685 <Box flexDirection="row" justifyContent="space-between" alignItems="center" flexWrap="wrap" columnGap={2}>
686 <Box flexDirection="column">
687 <Text size="title" weight="bold">
688 <Text color="claude">{'▣ '}</Text>
689 {'Mod Manager'}
690 </Text>
691 <Text size="footnote" dimColor>
692 {`${s.mods.length} installed · ${on} on${changedCount ? ` · ${changedCount} waiting on a reload` : ''}${url ? ' · hub running' : ''}`}
693 </Text>
694 </Box>
695 <Box flexDirection="row" columnGap={1} alignItems="center">
696 <Button key="tab-mods" label="▣ Mods" variant="primary" onPress={() => update($, tab, () => 'mods')} />
697 <Button key="tab-presets" label={`★ Presets${s.presets.length ? ` · ${s.presets.length}` : ''}`} plain onPress={() => update($, tab, () => 'presets')} />
698 {changedCount > 0 && <Button key="reload" label={`↻ Reload · ${changedCount}`} variant="primary" onPress={() => reload($)} />}
699 <Button key="hub" label={url ? '◈ Open Hub' : '◈ Start Hub'} onPress={() => void openHub($)} />
700 <Button key="close" label="✕" plain dimColor role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
701 </Box>
702 </Box>
703
704 {Svg && (
705 <Svg
706 key="strip"
707 source={statusStrip(
708 s.mods.filter(m => m.enabled && !s.pending.includes(m.id)).length,
709 changedCount,
710 s.mods.filter(m => !m.enabled && !s.pending.includes(m.id)).length,
711 )}
712 alt={`${on} of ${s.mods.length} mods on`}
713 />
714 )}
715
716 {/* Toolbar: search, filters and view settings in one card. */}
717 <Box flexDirection="column" borderStyle="round" borderColor="inactive" paddingX={1} gap={1}>
718 {Input && (
719 <Input key="search" placeholder="Search mods and commands…" value={v.query} submitLabel="search" onInput={q => setView($, { query: q })} onSubmit={q => setView($, { query: q })} />
720 )}
721 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
722 {FILTERS.filter(f => f.value === 'all' || tally[f.value] > 0 || v.filter === f.value).map(f => (
723 <Button
724 key={`filter-${f.value}`}
725 label={`${f.label} ${tally[f.value]}`}
726 variant={v.filter === f.value ? 'primary' : undefined}
727 plain={v.filter === f.value ? undefined : true}
728 dimColor={v.filter !== f.value}
729 onPress={() => setView($, { filter: f.value })}
730 />
731 ))}
732 </Box>
733 <Box flexDirection="row" flexWrap="wrap" columnGap={2} alignItems="center">
734 {Select && <Select key="sort" label="Sort" options={SORTS} value={v.sort} onSelect={value => setView($, { sort: value as View['sort'] })} />}
735 {Select && markets.length > 1 && (
736 <Select
737 key="source"
738 label="From"
739 options={[{ value: '', label: 'Every marketplace' }, ...markets.map(m => ({ value: m, label: `@${m}` }))]}
740 value={v.source}
741 onSelect={value => setView($, { source: value })}
742 />
743 )}
744 <Button key="layout" label={isCompact ? '▦ Cards' : '▤ Compact'} plain onPress={() => setCompact($, !isCompact)} />
745 <Button key="click-mode" label={mode === 'copy' ? 'Click: ⧉ Copy' : 'Click: ▶ Run'} variant={mode === 'copy' ? 'primary' : undefined} onPress={() => setClickMode($, mode === 'copy' ? 'run' : 'copy')} />
746 {!isDefault(v) && <Button key="reset" label="✕ Reset" plain dimColor onPress={() => setView($, { ...DEFAULT_VIEW })} />}
747 </Box>
748 </Box>
749
750 {problem !== '' && (
751 <Box flexDirection="row" columnGap={1} borderStyle="round" borderColor="error" paddingX={1}>
752 <Text color="error" wrap="wrap">
753 {problem}
754 </Text>
755 <Button key="dismiss-note" label="Dismiss" plain dimColor onPress={() => update($, note, () => '')} />
756 </Box>
757 )}
758
759 <Text size="caption" weight="semibold" dimColor>
760 {shown.length === 0 ? 'NOTHING SHOWN' : `SHOWING ${p * per + 1}–${Math.min(shown.length, (p + 1) * per)} OF ${shown.length}${shown.length < s.mods.length ? ` · ${s.mods.length} INSTALLED` : ''}`}
761 </Text>
762
763 {shown.length === 0 && (
764 <Box flexDirection="column" alignItems="center" borderStyle="round" borderColor="inactive" paddingY={1}>
765 <Text dimColor>{`No mod is ${FILTERS.find(f => f.value === v.filter)?.hint ?? 'here'}${v.query ? ` and matches "${v.query}"` : ''}.`}</Text>
766 <Button key="reset-empty" label="Show all mods" plain onPress={() => setView($, { ...DEFAULT_VIEW })} />
767 </Box>
768 )}
769
770 {isCompact ? (
771 <Box flexDirection="column" borderStyle="round" borderColor="inactive" paddingX={1}>
772 {shown.slice(p * per, (p + 1) * per).map(m => (
773 <Box key={`row-${m.id}`} flexDirection="row" columnGap={1} alignItems="center" flexWrap="wrap">
774 {flip(m)}
775 <Text weight="semibold" dimColor={!m.enabled} color={m.isSelf ? 'claude' : undefined}>
776 {m.isSelf ? `◈ ${m.name}` : m.name}
777 </Text>
778 {badges(m)}
779 {commandButtons(m)}
780 </Box>
781 ))}
782 </Box>
783 ) : (
784 shown.slice(p * per, (p + 1) * per).map(m => (
785 <Box
786 key={`mod-${m.id}`}
787 flexDirection="column"
788 borderStyle={m.isSelf ? 'double' : 'round'}
789 borderColor={m.isSelf ? 'claude' : m.enabled ? 'success' : 'inactive'}
790 borderDimColor={!m.enabled && !m.isSelf}
791 hover={{ borderColor: 'claude', borderDimColor: false }}
792 paddingX={1}
793 >
794 <Box flexDirection="row" justifyContent="space-between" alignItems="center" columnGap={1}>
795 <Text wrap="truncate-end">
796 <Text size="heading" weight="semibold" dimColor={!m.enabled} color={m.isSelf ? 'claude' : undefined}>
797 {m.isSelf ? `◈ ${m.name}` : m.name}
798 </Text>
799 <Text size="footnote" mono dimColor>{` v${m.version}`}</Text>
800 </Text>
801 {flip(m)}
802 </Box>
803 {badges(m).some(Boolean) && (
804 <Box flexDirection="row" columnGap={1}>
805 {badges(m)}
806 </Box>
807 )}
808 {m.isSelf && (
809 <Text size="footnote" color="claude">
810 The Mod Manager you are using. It stays on so you can always get back here.
811 </Text>
812 )}
813 {m.description !== '' && (
814 <Text size="footnote" dimColor wrap="wrap">
815 {m.description.length > 170 ? `${m.description.slice(0, 169)}…` : m.description}
816 </Text>
817 )}
818 <Box flexDirection="row" flexWrap="wrap" columnGap={1} alignItems="center">
819 {m.commands.length === 0 && <Text size="footnote" dimColor italic>{m.enabled ? 'No commands: it works by itself, above the prompt or in hooks.' : 'Off: its commands come back once it is on and reloaded.'}</Text>}
820 {commandButtons(m)}
821 </Box>
822 <Box flexDirection="row" justifyContent="space-between" alignItems="center">
823 <Text size="caption" dimColor>
824 {`@${m.marketplace}${m.installedAt ? ` · installed ${since(m.installedAt, now)}` : ''}`}
825 </Text>
826 <Button key={`open-${m.id}`} label="Open in Hub ↗" plain dimColor onPress={() => void openHub($, `#/mod/${encodeURIComponent(m.id)}`)} />
827 </Box>
828 </Box>
829 ))
830 )}
831
832 {pages > 1 && (
833 <Box flexDirection="row" justifyContent="space-between" alignItems="center">
834 <Button key="prev" label="‹ Prev" plain dimColor={p === 0} onPress={() => update($, page, n => Math.max(0, n - 1))} />
835 <Text size="footnote" dimColor tabular>{`page ${p + 1} of ${pages}`}</Text>
836 <Button key="next" label="Next ›" plain dimColor={p >= pages - 1} onPress={() => update($, page, n => Math.min(pages - 1, n + 1))} />
837 </Box>
838 )}
839 </Box>
840 )
841}
842
843// ── Recent commands ───────────────────────────────────────────────────────
844
845async function remember($: EngineInterface, command: string, args: string) {
846 const at = await $.clock.now()
847 const list = await update($, recentAtom, prev => [{ command, args: args.trim(), at }, ...prev.filter(r => !(r.command === command && r.args === args.trim()))].slice(0, MAX_RECENT))
848 await update($, state, prev => (prev ? { ...prev, recent: list } : prev))
849}
850
851// ── The prompt cache ──────────────────────────────────────────────────────
852
853async function restoreCache($: EngineInterface) {
854 if (ttlOverride !== null) {
855 ttlMs = ttlOverride
856 isTtlLearned = true
857 return
858 }
859 const known = await $.store.get(TTL_KEY).catch(() => undefined)
860 if (known === '1h') {
861 ttlMs = HOUR
862 isTtlLearned = true
863 }
864 if ((await read($, cacheAt)) > 0) startBeat($)
865}
866
867// A request finished. One that still read from the cache more than five minutes after the
868// last shows the cache lasts an hour here; that is remembered for later sessions.
869async function sawRequest($: EngineInterface, cacheRead: number) {
870 const now = await $.clock.now()
871 const prev = await read($, cacheAt)
872 if (ttlOverride === null && ttlMs < HOUR && prev > 0 && now - prev > MIN5 + 10_000 && now - prev < HOUR && cacheRead > 0) {
873 ttlMs = HOUR
874 isTtlLearned = true
875 await $.store.set(TTL_KEY, '1h').catch(() => {})
876 }
877 await update($, cacheAt, () => now)
878 await update($, state, prev2 => (prev2 ? { ...prev2, cache: { at: now, ttlMs, isLearned: isTtlLearned } } : prev2))
879 startBeat($)
880}
881
882// Ticks once a second while the cache is warm, so the bar's countdown moves; stops once it is cold.
883function startBeat($: EngineInterface) {
884 beatTimer?.cancel()
885 beatTimer = $.clock.every(1_000, () => {
886 void tickBeat($).catch(() => {})
887 })
888}
889
890async function tickBeat($: EngineInterface) {
891 const at = await read($, cacheAt)
892 if (!at || (await $.clock.now()) - at > ttlMs) {
893 beatTimer?.cancel()
894 beatTimer = undefined
895 }
896 await update($, beat, n => n + 1)
897}
898
899// The bar's cache part: how long it stays warm, or that it went cold.
900async function cacheLine($: EngineInterface): Promise<{ text: string; color?: string; isDim: boolean }> {
901 await read($, beat) // redraws each second while warm
902 const at = await read($, cacheAt)
903 if (!at) return { text: '◷ cache · no request yet', isDim: true }
904 const left = at + ttlMs - (await $.clock.now())
905 const life = ttlMs >= HOUR ? '1 h' : '5 min'
906 if (left <= 0) return { text: `◷ cache cold · next message re-reads it (${life} cache)`, isDim: true }
907 return { text: `◷ cache warm · ${countdown(left)} left`, color: left <= 60_000 ? 'warning' : 'success', isDim: false }
908}
909
910// ── Presets: storing ──────────────────────────────────────────────────────
911
912async function loadPresets($: EngineInterface): Promise<Preset[]> {
913 const raw = await $.store.get(PRESETS_KEY).catch(() => undefined)
914 const now = await $.clock.now()
915 return (Array.isArray(raw) ? raw : []).map(p => cleanPreset(p, now)).filter((p): p is Preset => p !== null && p.id !== '')
916}
917
918async function storePresets($: EngineInterface, list: Preset[]) {
919 await $.store.set(PRESETS_KEY, list).catch(() => {})
920 await update($, state, prev => (prev ? { ...prev, presets: list } : prev))
921}
922
923// Saves one (new, or by its id); returns it as stored, or null when it was not a preset.
924async function savePreset($: EngineInterface, raw: unknown, by: 'you' | 'claude'): Promise<Preset | null> {
925 const now = await $.clock.now()
926 const clean = cleanPreset(raw, now)
927 if (!clean) return null
928 const list = upsert(await loadPresets($), { ...clean, by: by === 'claude' ? 'claude' : clean.by }, now)
929 await storePresets($, list)
930 return list.find(p => p.name === clean.name && (clean.id ? p.id === clean.id : true)) ?? list.at(-1) ?? null
931}
932
933async function deletePreset($: EngineInterface, id: string) {
934 await storePresets($, (await loadPresets($)).filter(p => p.id !== id))
935 if ((await read($, editing)) === id) await update($, editing, () => '')
936}
937
938// Changes one preset in place.
939async function editPreset($: EngineInterface, id: string, change: (p: Preset) => Preset) {
940 const list = await loadPresets($)
941 const now = await $.clock.now()
942 await storePresets($, list.map(p => (p.id === id ? { ...change(p), id, updatedAt: now } : p)))
943}
944
945// The current setup, saved under a name.
946async function snapshotPreset($: EngineInterface, name: string) {
947 const s = await read($, state)
948 const clean = name.trim().slice(0, 60)
949 if (!s || !clean) {
950 $.ui.toast('Give the preset a name first')
951 return
952 }
953 const saved = await savePreset($, snapshot(clean, s.mods, s.pins, await read($, recentAtom), await $.clock.now()), 'you')
954 await update($, draftName, () => '')
955 if (saved) {
956 await update($, editing, () => saved.id)
957 $.ui.toast(`Saved preset "${saved.name}": ${summary(saved)}`)
958 }
959}
960
961// ── Presets: applying ─────────────────────────────────────────────────────
962
963// Turns its mods on or off, sets its pins, and runs its commands. A mod change needs a reload
964// first, so the commands are kept for after it and run once the mods are back.
965async function applyPreset($: EngineInterface, id: string) {
966 const s = await read($, state)
967 const preset = (await loadPresets($)).find(p => p.id === id)
968 if (!s || !preset) return
969 const { flips, steps, missing } = plan(preset, s.mods.filter(m => !m.isSelf))
970 for (const f of flips) await toggle($, f.id, f.on)
971 if (preset.pins) await setPins($, preset.pins)
972 if (missing.length) $.ui.toast(`${preset.name}: ${missing.length} of its mods are not installed here, so they were skipped`)
973 if (flips.length > 0) {
974 await $.store.set(APPLY_KEY, { id, at: await $.clock.now() }).catch(() => {})
975 $.ui.toast(`Applying ${preset.name}: reloading for ${flips.length} mod${flips.length === 1 ? '' : 's'}${steps.length ? `, then running ${steps.length} command${steps.length === 1 ? '' : 's'}` : ''}`)
976 await reload($)
977 return
978 }
979 await runSteps($, preset)
980}
981
982async function runSteps($: EngineInterface, preset: Preset) {
983 const steps = preset.steps.filter(x => x.enabled)
984 for (const step of steps) await runCommand($, step.command, step.args)
985 $.ui.toast(steps.length ? `${preset.name} applied: ran ${steps.map(x => `/${x.command}`).join(', ')}` : `${preset.name} applied`)
986}
987
988// After the reload a preset asked for: run the commands it still owes, once, if it was recent.
989async function resumeApply($: EngineInterface) {
990 const owed = (await $.store.get(APPLY_KEY).catch(() => undefined)) as { id?: string; at?: number } | undefined
991 if (!owed?.id) return
992 await $.store.delete(APPLY_KEY).catch(() => {})
993 if ((await $.clock.now()) - (owed.at ?? 0) > 120_000) return
994 const preset = (await loadPresets($)).find(p => p.id === owed.id)
995 if (preset) await runSteps($, preset)
996}
997
998// ── Presets: Claude's tool ────────────────────────────────────────────────
999
1000async function presetTool($: EngineInterface, input: Record<string, unknown>) {
1001 const s = await read($, state)
1002 if (!s) return { deny: 'mod-hub is still reading the installed mods; try again in a moment.' }
1003 const action = input.action
1004 if (action === 'list') {
1005 const mods = s.mods
1006 .filter(m => !m.isSelf)
1007 .map(m => `- ${m.id} (${m.enabled ? 'on' : 'off'})${m.commands.length ? `: ${m.commands.map(c => `/${c.name}${isToggle(c.description) ? ' [toggle]' : ''}`).join(', ')}` : ''}`)
1008 const presets = s.presets.map(p => `- ${p.id} "${p.name}" by ${p.by}: ${summary(p)}`)
1009 const recent = s.recent.map(r => `/${r.command}${r.args ? ` ${r.args}` : ''}`)
1010 return {
1011 result: [
1012 `Installed mods (${s.mods.length - 1}):`,
1013 ...mods,
1014 '',
1015 `Recently run: ${recent.length ? recent.join(', ') : 'nothing yet'}`,
1016 `Pins on the bar: ${s.pins.map(x => `/${x}`).join(', ') || 'none'}`,
1017 '',
1018 presets.length ? `Presets (${presets.length}):` : 'No presets yet.',
1019 ...presets,
1020 ].join('\n'),
1021 }
1022 }
1023 if (action === 'delete') {
1024 const id = typeof input.id === 'string' ? input.id : ''
1025 if (!s.presets.some(p => p.id === id)) return { deny: `No preset with id ${id}. Use action list to see them.` }
1026 await deletePreset($, id)
1027 return { result: `Deleted preset ${id}.` }
1028 }
1029 if (action === 'save') {
1030 const clean = cleanPreset(input.preset, await $.clock.now())
1031 if (!clean) return { deny: 'A preset needs at least a name. Mods map plugin ids to "on" or "off"; steps are { command, args, enabled }.' }
1032 const ids = new Set(s.mods.map(m => m.id))
1033 const unknownMods = Object.keys(clean.mods).filter(id => !ids.has(id))
1034 const known = new Set([...s.mods.flatMap(m => m.commands.map(c => c.name)), ...s.otherCommands.map(c => c.name)])
1035 const unknownSteps = clean.steps.filter(x => !known.has(x.command)).map(x => `/${x.command}`)
1036 if (unknownMods.length) return { deny: `Not installed here: ${unknownMods.join(', ')}. Use action list for the plugin ids.` }
1037 const saved = await savePreset($, clean, 'claude')
1038 if (!saved) return { deny: 'That preset could not be saved.' }
1039 return {
1040 result:
1041 `Saved preset "${saved.name}" (id ${saved.id}): ${summary(saved)}.` +
1042 (unknownSteps.length ? ` Note: ${unknownSteps.join(', ')} ${unknownSteps.length === 1 ? 'is' : 'are'} not a command right now; it runs only if its mod is on when the preset is applied.` : '') +
1043 ' The person applies it from ★ Presets in the Mod Manager, or the hub.',
1044 }
1045 }
1046 return { deny: 'action must be list, save or delete' }
1047}
1048
1049// ── Presets: the pane ─────────────────────────────────────────────────────
1050
1051async function drawPresets($: EngineInterface, e: Parameters<EngineInterface['ui']['resolve']>[0], s: HubState) {
1052 const elements = $.ui.resolve(e)
1053 const { Box, Text, Button } = elements
1054 const Input = 'Input' in elements ? elements.Input : undefined
1055 const open = await read($, editing)
1056 const doomed = await read($, confirmDelete)
1057 const name = await read($, draftName)
1058 const recent = await read($, recentAtom)
1059 const describe = new Map(s.mods.flatMap(m => m.commands.map(c => [c.name, c.description] as const)))
1060 const known = new Set([...describe.keys(), ...s.otherCommands.map(c => c.name)])
1061 const others = s.mods.filter(m => !m.isSelf)
1062
1063 const pill = (key: string, text: string, bg: string) => (
1064 <Text key={key} backgroundColor={bg} color="inverseText" weight="semibold" size="caption">
1065 {` ${text} `}
1066 </Text>
1067 )
1068 const caption = (key: string, text: string) => (
1069 <Text key={key} size="caption" weight="semibold" dimColor>
1070 {text}
1071 </Text>
1072 )
1073
1074 return (
1075 <Box flexDirection="column" gap={1}>
1076 <Box flexDirection="row" justifyContent="space-between" alignItems="center" flexWrap="wrap" columnGap={2}>
1077 <Box flexDirection="column">
1078 <Text size="title" weight="bold">
1079 <Text color="claude">{'★ '}</Text>
1080 {'Presets'}
1081 </Text>
1082 <Text size="footnote" dimColor>
1083 {s.presets.length ? `${s.presets.length} saved · apply one to switch your whole setup` : 'Save a setup once, switch back to it in one press'}
1084 </Text>
1085 </Box>
1086 <Box flexDirection="row" columnGap={1} alignItems="center">
1087 <Button key="tab-mods" label="▣ Mods" plain onPress={() => update($, tab, () => 'mods')} />
1088 <Button key="tab-presets" label={`★ Presets${s.presets.length ? ` · ${s.presets.length}` : ''}`} variant="primary" onPress={() => update($, tab, () => 'presets')} />
1089 <Button key="close" label="✕" plain dimColor role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
1090 </Box>
1091 </Box>
1092
1093 {/* New preset: the current setup under a name. */}
1094 <Box flexDirection="column" borderStyle="round" borderColor="claude" paddingX={1} gap={1}>
1095 <Text size="heading" weight="semibold">
1096 New preset from your current setup
1097 </Text>
1098 <Text size="footnote" dimColor wrap="wrap">
1099 {`Keeps every mod's on or off, your ${s.pins.length} pin${s.pins.length === 1 ? '' : 's'}, and the commands you ran lately${recent.length ? `: ${recent.slice(0, 6).map(r => `/${r.command}${r.args ? ` ${r.args}` : ''}`).join(', ')}` : ' (none yet: run some, or add them after)'}. Toggles go in unticked.`}
1100 </Text>
1101 <Box flexDirection="row" columnGap={1} alignItems="center" flexWrap="wrap">
1102 {Input && <Input key="new-name" placeholder="Name it: Focus, Streaming, Everything on…" value={name} submitLabel="save" onInput={v => update($, draftName, () => v)} onSubmit={v => snapshotPreset($, v)} />}
1103 <Button key="snapshot" label="★ Save current setup" variant="primary" onPress={async () => snapshotPreset($, await read($, draftName))} />
1104 </Box>
1105 <Text size="caption" dimColor>
1106 Or ask Claude: "make me a preset for streaming with snake off". It can save presets for you; only you apply them.
1107 </Text>
1108 </Box>
1109
1110 {s.presets.length === 0 && (
1111 <Box flexDirection="column" alignItems="center" borderStyle="round" borderColor="inactive" paddingY={1}>
1112 <Text dimColor>No presets yet.</Text>
1113 </Box>
1114 )}
1115
1116 {s.presets.map(p => {
1117 const isOpen = open === p.id
1118 const { flips, steps, missing } = plan(p, others)
1119 const willDo = [
1120 flips.length ? `turn ${flips.filter(f => f.on).length ? `${flips.filter(f => f.on).length} on` : ''}${flips.filter(f => f.on).length && flips.filter(f => !f.on).length ? ', ' : ''}${flips.filter(f => !f.on).length ? `${flips.filter(f => !f.on).length} off` : ''} (then reload)` : '',
1121 steps.length ? `run ${steps.length} command${steps.length === 1 ? '' : 's'}` : '',
1122 p.pins ? 'set the pins' : '',
1123 ].filter(Boolean)
1124 return (
1125 <Box key={`preset-${p.id}`} flexDirection="column" borderStyle="round" borderColor={isOpen ? 'claude' : 'inactive'} hover={{ borderColor: 'claude' }} paddingX={1}>
1126 <Box flexDirection="row" justifyContent="space-between" alignItems="center" columnGap={1} flexWrap="wrap">
1127 <Box flexDirection="row" columnGap={1} alignItems="center">
1128 <Text size="heading" weight="semibold">
1129 {p.name}
1130 </Text>
1131 {p.by === 'claude' && pill(`by-${p.id}`, 'BY CLAUDE', 'suggestion')}
1132 </Box>
1133 <Box flexDirection="row" columnGap={1} alignItems="center">
1134 <Button key={`apply-${p.id}`} label="▶ Apply" variant="primary" onPress={() => applyPreset($, p.id)} />
1135 <Button key={`edit-${p.id}`} label={isOpen ? '✓ Done' : '✎ Edit'} plain onPress={() => update($, editing, cur => (cur === p.id ? '' : p.id))} />
1136 <Button key={`copy-${p.id}`} label="⧉" plain dimColor onPress={() => savePreset($, { ...p, id: '', name: `${p.name} copy`, by: 'you' }, 'you')} />
1137 <Button
1138 key={`delete-${p.id}`}
1139 label={doomed === p.id ? 'Press again to delete' : '✕'}
1140 plain={doomed === p.id ? undefined : true}
1141 variant={doomed === p.id ? 'primary' : undefined}
1142 dimColor={doomed !== p.id}
1143 onPress={async () => {
1144 if ((await read($, confirmDelete)) === p.id) {
1145 await deletePreset($, p.id)
1146 await update($, confirmDelete, () => '')
1147 } else await update($, confirmDelete, () => p.id)
1148 }}
1149 />
1150 </Box>
1151 </Box>
1152 {p.note !== '' && (
1153 <Text size="footnote" dimColor wrap="wrap">
1154 {p.note}
1155 </Text>
1156 )}
1157 <Text size="footnote" wrap="wrap">
1158 {summary(p)}
1159 </Text>
1160 <Text size="caption" dimColor wrap="wrap">
1161 {willDo.length ? `Applying now would ${willDo.join(', ')}.` : 'Applying now changes nothing: everything already matches.'}
1162 {missing.length ? ` ${missing.length} of its mods are not installed here.` : ''}
1163 </Text>
1164
1165 {isOpen && (
1166 <Box flexDirection="column" gap={1} paddingTop={1}>
1167 {Input && (
1168 <Input key={`rename-${p.id}`} label="Name" placeholder="Preset name" value={p.name} submitLabel="rename" onSubmit={v => editPreset($, p.id, x => ({ ...x, name: v.trim() || x.name }))} />
1169 )}
1170 {Input && <Input key={`note-${p.id}`} label="Note" placeholder="What it is for (optional)" value={p.note} submitLabel="save" onSubmit={v => editPreset($, p.id, x => ({ ...x, note: v.slice(0, 300) }))} />}
1171
1172 {caption(`cap-mods-${p.id}`, 'MODS · leave as is, or turn on or off when applied')}
1173 {others.map(m => {
1174 const want = p.mods[m.id]
1175 const set = (v: 'on' | 'off' | undefined) =>
1176 editPreset($, p.id, x => {
1177 const mods = { ...x.mods }
1178 if (v) mods[m.id] = v
1179 else delete mods[m.id]
1180 return { ...x, mods }
1181 })
1182 return (
1183 <Box key={`pm-${p.id}-${m.id}`} flexDirection="row" columnGap={1} alignItems="center">
1184 <Button key={`pm-leave-${m.id}`} label="·" variant={!want ? 'primary' : undefined} plain={want ? true : undefined} dimColor={!!want} onPress={() => set(undefined)} />
1185 <Button key={`pm-on-${m.id}`} label="On" variant={want === 'on' ? 'primary' : undefined} plain={want === 'on' ? undefined : true} dimColor={want !== 'on'} onPress={() => set('on')} />
1186 <Button key={`pm-off-${m.id}`} label="Off" variant={want === 'off' ? 'primary' : undefined} plain={want === 'off' ? undefined : true} dimColor={want !== 'off'} onPress={() => set('off')} />
1187 <Text weight={want ? 'semibold' : undefined} dimColor={!want}>
1188 {m.name}
1189 </Text>
1190 <Text size="caption" dimColor>{m.enabled ? 'now on' : 'now off'}</Text>
1191 </Box>
1192 )
1193 })}
1194
1195 {caption(`cap-steps-${p.id}`, 'COMMANDS · run in order when applied')}
1196 {p.steps.length === 0 && <Text size="footnote" dimColor>None yet. Add one below.</Text>}
1197 {p.steps.map((step, i) => {
1198 const toggleLike = isToggle(describe.get(step.command) ?? '')
1199 const move = (to: number) =>
1200 editPreset($, p.id, x => {hooks/bands.ts 73 lines1// Band order: every band mod ships this same file (canonical copy: shared/bands.ts in the repo).
2// The order file, <configDir>/claude-code-mods/bands.json, lists mod names top to bottom
3// (the last sits right above the prompt). Each band mod wraps its band in a Box keyed
4// `band:<name>` and returns one column keyed `bands`; the next band mod unpacks that
5// column, adds its own band and sorts again, so the last one to draw sorts them all.
6// Edit shared/bands.ts, then copy it over every mod's hooks/bands.ts (a repo test checks).
7// Reading the file needs $, which Claude Code follows only within one file, so each mod
8// reads it in its own register file with bandsFile and parseOrder, once per session.
9import type { RenderElement, RenderNode } from 'claude-code'
10
11const COLUMN = 'bands'
12const PREFIX = 'band:'
13
14// Where the checklist saves the order: CLAUDE_CONFIG_DIR, or ~/.claude. Undefined with neither.
15export function bandsFile(configDir: string | undefined, home: string | undefined): string | undefined {
16 const dir = configDir ? configDir.replace(/\/+$/, '') : home ? `${home}/.claude` : ''
17 return dir ? `${dir}/claude-code-mods/bands.json` : undefined
18}
19
20// The names in an order file; [] when it is no JSON or not { order: string[] }.
21export function parseOrder(text: string): string[] {
22 try {
23 const order = (JSON.parse(text) as { order?: unknown } | null)?.order
24 return Array.isArray(order) && order.every(n => typeof n === 'string') ? order : []
25 } catch {
26 return []
27 }
28}
29
30/**
31 * One column of bands: `mine` wrapped in a Box keyed `band:<name>`, placed
32 * `at` (above or below) what `next(e)` returned, then sorted by `order`.
33 * With an order, items that are no band come first, then bands not in the
34 * order (in their current order), then the listed ones in file order. With
35 * no order nothing moves, so the screen looks as it did before.
36 */
37export function stackBands(rest: RenderElement | null | undefined, name: string, mine: RenderNode, at: 'above' | 'below', order: string[]): RenderElement {
38 const own: RenderElement = { type: 'Box', props: { key: PREFIX + name, flexDirection: 'column' }, children: [mine] }
39 const others = unpack(rest)
40 const items = at === 'above' ? [own, ...others] : [...others, own]
41 return { type: 'Box', props: { key: COLUMN, flexDirection: 'column' }, children: order.length ? sorted(items, order) : items }
42}
43
44// Another band mod's column gives up its items; anything else is one item.
45function unpack(rest: RenderElement | null | undefined): RenderNode[] {
46 if (!rest) return []
47 if (!isColumn(rest)) return [rest]
48 const kids = rest.children ?? []
49 const owner = rest.part?.plugin
50 // The engine stamped the column with its owner: that stamp belongs on the owner's own band.
51 return kids.map(k => (owner && typeof k !== 'string' && bandName(k) === owner && !('part' in k && k.part) ? { ...k, part: rest.part } : k))
52}
53
54function isColumn(el: RenderElement): el is RenderElement & { type: 'Box' } {
55 return el.type === 'Box' && el.props?.key === COLUMN
56}
57
58function bandName(node: RenderNode): string | undefined {
59 if (typeof node === 'string' || node.type !== 'Box') return undefined
60 const key = node.props?.key
61 return typeof key === 'string' && key.startsWith(PREFIX) ? key.slice(PREFIX.length) : undefined
62}
63
64function sorted(items: RenderNode[], order: string[]): RenderNode[] {
65 const rank = (n: RenderNode) => {
66 const name = bandName(n)
67 if (name === undefined) return -2 // not a band: first
68 const i = order.indexOf(name)
69 return i < 0 ? -1 : i // a band not in the order: on top of the listed ones
70 }
71 return items.map((n, i) => ({ n, i, r: rank(n) })).sort((a, b) => a.r - b.r || a.i - b.i).map(x => x.n)
72}
73shared/view.ts 109 lines1// Which mods a view shows, and in what order. Pure, with no `claude-code` import, so the
2// /mod-manager pane and the web hub (web/src imports this file; it sits outside hooks/ so Vite finds shared/tsconfig.json) filter exactly alike.
3
4export type Filter = 'all' | 'on' | 'off' | 'pinned' | 'commands' | 'changed' | 'new'
5export type Sort = 'status' | 'name' | 'newest' | 'commands'
6
7export type ViewMod = {
8 id: string
9 name: string
10 marketplace: string
11 description: string
12 enabled: boolean
13 installedAt: string
14 commands: { name: string; description: string }[]
15}
16
17export type View = { query: string; filter: Filter; sort: Sort; source: string } // source '' is every marketplace
18export type Context = { pins: readonly string[]; pending: readonly string[]; now: number }
19
20export const DEFAULT_VIEW: View = { query: '', filter: 'all', sort: 'status', source: '' }
21export const NEW_MS = 24 * 60 * 60 * 1000
22
23export const FILTERS: { value: Filter; label: string; hint: string }[] = [
24 { value: 'all', label: 'All', hint: 'every installed mod' },
25 { value: 'on', label: 'On', hint: 'turned on' },
26 { value: 'off', label: 'Off', hint: 'turned off' },
27 { value: 'pinned', label: 'Pinned', hint: 'with a command on the bar' },
28 { value: 'commands', label: 'Commands', hint: 'with slash commands' },
29 { value: 'changed', label: 'Changed', hint: 'turned on or off, waiting on a reload' },
30 { value: 'new', label: 'New', hint: 'installed in the last day' },
31]
32
33export const SORTS: { value: Sort; label: string }[] = [
34 { value: 'status', label: 'On first' },
35 { value: 'name', label: 'A to Z' },
36 { value: 'newest', label: 'Newest' },
37 { value: 'commands', label: 'Most commands' },
38]
39
40export function isNew(m: ViewMod, now: number): boolean {
41 const at = Date.parse(m.installedAt)
42 return Number.isFinite(at) && now - at < NEW_MS
43}
44
45export function matches(m: ViewMod, filter: Filter, ctx: Context): boolean {
46 switch (filter) {
47 case 'on':
48 return m.enabled
49 case 'off':
50 return !m.enabled
51 case 'pinned':
52 return m.commands.some(c => ctx.pins.includes(c.name))
53 case 'commands':
54 return m.commands.length > 0
55 case 'changed':
56 return ctx.pending.includes(m.id)
57 case 'new':
58 return isNew(m, ctx.now)
59 default:
60 return true
61 }
62}
63
64// Every word of the query must appear in the mod's name, description, marketplace or a command.
65export function matchesQuery(m: ViewMod, query: string): boolean {
66 const words = query.toLowerCase().replace(/\//g, ' ').split(/\s+/).filter(Boolean)
67 if (words.length === 0) return true
68 const hay = `${m.name} ${m.description} ${m.marketplace} ${m.commands.map(c => `${c.name} ${c.description}`).join(' ')}`.toLowerCase()
69 return words.every(w => hay.includes(w))
70}
71
72const ORDER: Record<Sort, (a: ViewMod, b: ViewMod) => number> = {
73 status: (a, b) => Number(b.enabled) - Number(a.enabled) || a.name.localeCompare(b.name),
74 name: (a, b) => a.name.localeCompare(b.name),
75 newest: (a, b) => (Date.parse(b.installedAt) || 0) - (Date.parse(a.installedAt) || 0) || a.name.localeCompare(b.name),
76 commands: (a, b) => b.commands.length - a.commands.length || a.name.localeCompare(b.name),
77}
78
79export function viewMods<M extends ViewMod>(mods: readonly M[], view: View, ctx: Context): M[] {
80 return mods
81 .filter(m => (!view.source || m.marketplace === view.source) && matches(m, view.filter, ctx) && matchesQuery(m, view.query))
82 .sort(ORDER[view.sort] ?? ORDER.status)
83}
84
85// How many mods each filter would show, within the chosen marketplace and search, for the chips.
86export function counts(mods: readonly ViewMod[], view: View, ctx: Context): Record<Filter, number> {
87 const pool = mods.filter(m => (!view.source || m.marketplace === view.source) && matchesQuery(m, view.query))
88 return Object.fromEntries(FILTERS.map(f => [f.value, pool.filter(m => matches(m, f.value, ctx)).length])) as Record<Filter, number>
89}
90
91export function sources(mods: readonly ViewMod[]): string[] {
92 return [...new Set(mods.map(m => m.marketplace).filter(Boolean))].sort()
93}
94
95// A stored or posted view, with anything unknown put back to its default.
96export function cleanView(v: unknown): View {
97 const o = (v && typeof v === 'object' ? v : {}) as Partial<Record<keyof View, unknown>>
98 return {
99 query: typeof o.query === 'string' ? o.query.slice(0, 200) : '',
100 filter: FILTERS.some(f => f.value === o.filter) ? (o.filter as Filter) : 'all',
101 sort: SORTS.some(s => s.value === o.sort) ? (o.sort as Sort) : 'status',
102 source: typeof o.source === 'string' ? o.source.slice(0, 200) : '',
103 }
104}
105
106export function isDefault(v: View): boolean {
107 return v.query === '' && v.filter === 'all' && v.sort === 'status' && v.source === ''
108}
109shared/presets.ts 137 lines1// Presets: a named setup to come back to. A preset can turn mods on or off (only the mods it
2// names), set the bar's pins, and run a list of commands. Pure, with no `claude-code` import:
3// the mod applies presets, and the web hub (web/src imports this file) edits them the same way.
4
5export type ModState = 'on' | 'off'
6export type PresetStep = { command: string; args: string; enabled: boolean }
7export type Preset = {
8 id: string
9 name: string
10 note: string
11 mods: Record<string, ModState> // plugin id → wanted state; a mod not listed is left as it is
12 steps: PresetStep[]
13 pins: string[] | null // null leaves the bar's pins alone
14 by: 'you' | 'claude'
15 updatedAt: number
16}
17export type RecentCommand = { command: string; args: string; at: number }
18
19export const MAX_PRESETS = 30
20export const MAX_STEPS = 20
21const NAME = /^[\w:.-]{1,64}$/
22
23// A command that flips something each time it runs: applying a preset twice would undo it.
24export function isToggle(description: string): boolean {
25 return /\b(show or hide|shows or hides|toggle|turns? it (?:on|off)|on or off|again turns|hide it|hides it)\b/i.test(description)
26}
27
28export function newId(name: string, taken: readonly string[]): string {
29 const base = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'preset'
30 let id = base
31 for (let i = 2; taken.includes(id); i++) id = `${base}-${i}`
32 return id
33}
34
35// A preset from anywhere (the store, the web page, Claude's tool), kept to what is valid:
36// known shapes, bounded sizes, command names that could be commands.
37export function cleanPreset(raw: unknown, now: number): Preset | null {
38 if (!raw || typeof raw !== 'object') return null
39 const p = raw as Record<string, unknown>
40 const name = typeof p.name === 'string' ? p.name.trim().slice(0, 60) : ''
41 if (!name) return null
42 const mods: Record<string, ModState> = {}
43 if (p.mods && typeof p.mods === 'object') {
44 for (const [id, state] of Object.entries(p.mods as Record<string, unknown>).slice(0, 100)) {
45 if (/^[\w.:@-]{1,200}$/.test(id) && (state === 'on' || state === 'off')) mods[id] = state
46 }
47 }
48 const steps: PresetStep[] = []
49 for (const s of Array.isArray(p.steps) ? p.steps.slice(0, MAX_STEPS) : []) {
50 if (!s || typeof s !== 'object') continue
51 const step = s as Record<string, unknown>
52 const command = typeof step.command === 'string' ? step.command.trim().replace(/^\//, '') : ''
53 if (!NAME.test(command)) continue
54 steps.push({ command, args: typeof step.args === 'string' ? step.args.slice(0, 300) : '', enabled: step.enabled !== false })
55 }
56 const pins = Array.isArray(p.pins) ? [...new Set(p.pins.filter((x): x is string => typeof x === 'string' && NAME.test(x)))].slice(0, 12) : null
57 return {
58 id: typeof p.id === 'string' && /^[\w-]{1,60}$/.test(p.id) ? p.id : '',
59 name,
60 note: typeof p.note === 'string' ? p.note.slice(0, 300) : '',
61 mods,
62 steps,
63 pins,
64 by: p.by === 'claude' ? 'claude' : 'you',
65 updatedAt: typeof p.updatedAt === 'number' ? p.updatedAt : now,
66 }
67}
68
69// Saves a preset into the list: a new id for a new one, an update in place for a known id.
70export function upsert(list: readonly Preset[], preset: Preset, now: number): Preset[] {
71 const known = preset.id && list.some(p => p.id === preset.id)
72 const id = known ? preset.id : newId(preset.name, list.map(p => p.id))
73 const saved = { ...preset, id, updatedAt: now }
74 return known ? list.map(p => (p.id === id ? saved : p)) : [...list, saved].slice(-MAX_PRESETS)
75}
76
77// The current setup as a preset: every mod as it is now, the bar's pins, and the commands the
78// person ran recently, in order, once each. Toggles go in unticked, so applying it changes nothing.
79export function snapshot(
80 name: string,
81 mods: readonly { id: string; enabled: boolean; isSelf: boolean; commands: { name: string; description: string }[] }[],
82 pins: readonly string[],
83 recent: readonly RecentCommand[],
84 now: number,
85): Preset {
86 const describe = new Map(mods.flatMap(m => m.commands.map(c => [c.name, c.description] as const)))
87 const seen = new Set<string>()
88 const steps: PresetStep[] = []
89 for (const r of recent) {
90 const key = `${r.command} ${r.args}`.trim()
91 if (seen.has(key) || !describe.has(r.command)) continue
92 seen.add(key)
93 steps.push({ command: r.command, args: r.args, enabled: !isToggle(describe.get(r.command) ?? '') })
94 }
95 return {
96 id: '',
97 name,
98 note: '',
99 mods: Object.fromEntries(mods.filter(m => !m.isSelf).map(m => [m.id, m.enabled ? 'on' : 'off'])),
100 steps: steps.slice(0, MAX_STEPS),
101 pins: [...pins],
102 by: 'you',
103 updatedAt: now,
104 }
105}
106
107// What applying it would change now: the mods that flip, and the commands that run.
108export function plan(preset: Preset, mods: readonly { id: string; enabled: boolean }[]) {
109 const current = new Map(mods.map(m => [m.id, m.enabled]))
110 const flips = Object.entries(preset.mods)
111 .filter(([id, want]) => current.has(id) && current.get(id) !== (want === 'on'))
112 .map(([id, want]) => ({ id, on: want === 'on' }))
113 const missing = Object.keys(preset.mods).filter(id => !current.has(id))
114 return { flips, steps: preset.steps.filter(s => s.enabled), missing }
115}
116
117// One line on what a preset does, for a card.
118export function summary(preset: Preset): string {
119 const on = Object.values(preset.mods).filter(s => s === 'on').length
120 const off = Object.values(preset.mods).filter(s => s === 'off').length
121 const steps = preset.steps.filter(s => s.enabled)
122 return [
123 on || off ? `${on} on${off ? `, ${off} off` : ''}` : '',
124 steps.length ? `runs ${steps.map(s => `/${s.command}${s.args ? ` ${s.args}` : ''}`).join(', ')}` : '',
125 preset.pins ? `${preset.pins.length} pinned` : '',
126 ]
127 .filter(Boolean)
128 .join(' · ') || 'empty: add mods or commands'
129}
130
131// "0:42", "4:05", "58 min" for the cache countdown.
132export function countdown(ms: number): string {
133 const s = Math.max(0, Math.ceil(ms / 1000))
134 if (s >= 600) return `${Math.round(s / 60)} min`
135 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
136}
137types/index.d.ts 69 lines1// A view of the mod list; shared/view.ts holds the same shape with its logic.
2export type View = { query: string; filter: 'all' | 'on' | 'off' | 'pinned' | 'commands' | 'changed' | 'new'; sort: 'status' | 'name' | 'newest' | 'commands'; source: string }
3
4// A preset and what goes in one; shared/presets.ts holds the same shapes with their logic.
5export type PresetStep = { command: string; args: string; enabled: boolean }
6export type Preset = {
7 id: string
8 name: string
9 note: string
10 mods: Record<string, 'on' | 'off'>
11 steps: PresetStep[]
12 pins: string[] | null
13 by: 'you' | 'claude'
14 updatedAt: number
15}
16export type RecentCommand = { command: string; args: string; at: number }
17
18// The prompt cache, as the last main-thread request left it.
19export type CacheState = { at: number; ttlMs: number; isLearned: boolean } | null
20
21// One installed plugin (a mod or a classic plugin) as the hub and the pane show it.
22export type ModRow = {
23 id: string // name@marketplace
24 name: string
25 marketplace: string
26 version: string
27 description: string
28 enabled: boolean
29 installedAt: string
30 commands: { name: string; description: string }[]
31 isSelf: boolean // mod-hub itself: never turned off from here
32}
33
34// What the hub web app is told, and what the band and the pane draw from.
35export type HubState = {
36 generatedAt: number
37 claudeVersion?: string
38 mods: ModRow[]
39 // Commands not from a plugin: Claude Code's own, the person's, MCP prompts. For the palette.
40 otherCommands: { name: string; description: string; source: string }[]
41 pins: string[] // command names shown as buttons in the bar
42 pending: string[] // plugin ids turned on or off since the last reload
43 presets: Preset[]
44 recent: RecentCommand[] // commands the person ran lately, newest first
45 cache: CacheState
46}
47
48declare module 'claude-code' {
49 interface PluginState {
50 'mod-hub': {
51 state: HubState | null
52 hubUrl: string // '' until the hub server answers
53 note: string // the last thing that went wrong, shown in the pane
54 view: View // search, filter, sort and marketplace; kept across sessions
55 compact: boolean // one line per mod instead of a card
56 clickMode: 'run' | 'copy' // what pressing a command does, in the pane and the bar
57 page: number
58 busy: string // the plugin id being turned on or off, '' when none
59 tab: 'mods' | 'presets' // the pane's two views
60 editing: string // the preset id open in the editor, '' when none
61 confirmDelete: string // the preset id whose Delete was pressed once
62 draftName: string // the name typed for a new preset
63 recent: RecentCommand[] // kept across a reload, which empties the module's own variables
64 cacheAt: number // when the last main-thread request finished; 0 before any
65 beat: number // moves once a second while the cache is warm, so the countdown redraws
66 }
67 }
68}
69