A side pane that maps every component your session runs and where it is installed from: skills, subagents, commands, hooks, MCP servers, plugins and mods…

A side pane that maps every component your Claude Code session runs and where each one is installed from. The sidebar lists the component types. Pick one and the pane draws its scopes as a tree, in precedence order, with what is installed in each scope and whether it is active, shadowed by a higher scope, or off.
╭──────────────────────────────────────────────────────────────────────────────╮
│ ◆ Session stack Claude Code 2.1.306 │
│ ~/work/app 158 active · 7 shadowed · 0 off │
╰──────────────────────────────────────────────────────────────────────────────╯
COMPONENTS Skills 21 active · 1 shadowed
▌1: Skills 21 Enterprise › User › Project on a name clash · plugin
2: Subagents 4 skills are namespaced, both load
3: Commands 99
4: Hooks 7 1 Enterprise — managed settings dir
5: MCP servers 3 2 ▾ User 2 ~/.claude/skills
6: Plugins & mods 3 deploy
7: Settings 11 pdf-tools
8: Memory 2
9: Output styles 8 3 ▾ Project 2 .claude/skills
d̶e̶p̶l̶o̶y̶ shadowed by User
name active release-notes
n̶a̶m̶e̶ shadowed
name off 4 ▾ Plugin 2 <plugin>/skills · …
≈ read off files hello-mod:greet
team-tools:changelog
5 claude.ai account — enabled on claude.ai
6 MCP server — skills an MCP server…
7 ▸ Built-in 16 ship with Claude Code
batch, claude-api, debug +13 click to open
[ refresh ] [ fold all ] [ close ]
Run /stack to open it, /stack refresh (or running /stack again) to read the stack again, /stack close to close it.
Each type lists every scope it can be installed at, empty ones included, so you can see where a component could live as well as where it does. The orange number is the scope's precedence: 1 wins over 2 when two components share a name.
| Type | Scopes, highest first | When names clash |
|---|---|---|
| ✦ Skills | Enterprise, User, Project, Plugin, claude.ai account, MCP server, Built-in | Enterprise › User › Project; plugin skills are namespaced (plugin:skill), so both load |
| ◎ Subagents | Enterprise, CLI flag (--agents), Project, User, Plugin, Built-in | Enterprise › CLI flag › Project › User › Plugin |
| ❯ Commands | User, Project, Plugin (including mod commands), MCP prompts, Built-in | A skill wins over a command file with the same name |
| ↯ Hooks | Enterprise, CLI flag, Local, Project, User, Plugin (hooks.json and function-hook modules) | None: every source runs. allowManagedHooksOnly and disableAllHooks turn the rest off |
| ⇄ MCP servers | Enterprise, Local, Project, User, Plugin, claude.ai, This session (--mcp-config, SDK) | Enterprise › Local › Project › User › Plugin › claude.ai, the whole entry wins. A .mcp.json server nobody approved shows awaiting approval |
| ◈ Plugins & mods | Enterprise, CLI flag, Local, Project, User (enabledPlugins), Project skills dir, User skills dir, This session (--plugin-dir) | The highest settings source that names a plugin decides whether it is on |
| ⚙ Settings | Enterprise, CLI flag (--settings), Local, Project, User | A value from the highest source wins; objects and lists merge |
| ¶ Memory | Enterprise, Project, Local, User, Auto memory | None: every file loads |
| ✎ Output styles | Enterprise, Project, User, Plugin, Built-in | One style is selected (★), by the highest settings source with outputStyle |
It draws in Anthropic's palette only: ivory for what loads, warm grays for paths and notes, orange for the selected type, the precedence numbers and anything shadowed. A shadowed component is struck through with the scope that wins over it (shadowed by User, overridden by Local). An off component is installed but not loaded: a skill with a broken frontmatter, a hook under allowManagedHooksOnly, a plugin disabled in a higher scope, an MCP server that did not connect.
What is loaded comes from the engine, so it is what the session really runs:
/context breakdown ($.session.usage({ breakdown }))$.command.list()prompt.attachment, agent_listing_delta)$.settings.read({ source }) (hooks, enabledPlugins, outputStyle, every key)mcp_server.source), once the server has been calledprompt.composeWhere a file sits comes from reading the folders each type installs to: ~/.claude (or $CLAUDE_CONFIG_DIR), the project's .claude/, the managed settings directory, ~/.claude/plugins/installed_plugins.json, .claude.json and .mcp.json. That is how the pane finds what the engine does not list, a project skill shadowed by a user one for example. A scope known only from files, never confirmed by the engine, is marked ≈: a plugin loaded with --plugin-dir, an MCP server not in any config file, a mod in a project skills dir (it loads once the folder is trusted).
From MCP config files the mod keeps server names only, never their env, headers or arguments. Settings values are shown in one short line: env shows variable names, never values, and a settings key whose name looks sensitive (key, token, secret, auth, helper, uuid, org…) shows •••. Hook commands and URLs are shown with bearer tokens, secret-looking parameters and flags (token=, --api-key=, sig=…) and long opaque strings masked as •••. Nothing is written to disk or kept between sessions.
The built-in subagents (Explore, Plan, general-purpose, claude-code-guide…) are read off the engine's agent listing, the reminder that tells the model which agent types it can launch: every type it names that no agent file, setting or plugin defines. The engine sends it with the first message of a session, so until one is sent their scope says so; it fills in on its own when the listing arrives. Nested .claude/ folders between the working directory and the repository root are not scanned; the engine still reports what loads from them.
1–9.▸ folded, ▾ open) to open or fold it. A folded scope previews its first names and how many more it holds. Long built-in lists start folded.r or refresh reads the stack again, after you install something mid-session.In the fullscreen layout the engine docks the pane beside the transcript (turn it on with /tui fullscreen, at least 110 columns). On the classic layout it sits above the prompt. Under 72 columns the sidebar becomes a row of chips above the tree. In Claude Desktop the pane draws in the session's side panel, and every padded cell uses non-breaking spaces so the columns keep their width.
Set them in user or managed settings under pluginConfigs["stack-viewer@skills-dir"].options ("stack-viewer" when loaded with --plugin-dir):
| Option | Default | What it does |
|---|---|---|
columns | 84 | Columns asked for the docked pane (40–140); a width you drag it to wins |
npx claude-code-templates@latest --mod ui/stack-viewer
The CLI writes the plugin to .claude/skills/stack-viewer/, which Claude Code loads as stack-viewer@skills-dir once the project is trusted. To load it everywhere, copy that folder to ~/.claude/skills/.
Needs Claude Code 2.1.287 or later. It only observes: every hook passes its event on unchanged.
hooks/stack-viewer.tsx 497 lines1/**
2 * stack-viewer — Claude Mod
3 *
4 * A side pane that maps every component this session runs and where each one
5 * is installed from. A sidebar lists the component types (skills, subagents,
6 * commands, hooks, MCP servers, plugins and mods, settings, memory, output
7 * styles); pick one and the pane draws its scopes as a tree in precedence
8 * order (Enterprise, CLI flag, Local, Project, User, Plugin, Built-in…), each
9 * with what is installed there and whether it is active, shadowed by a higher
10 * scope, or off.
11 *
12 * `/stack` opens it (or reads the stack again) · `/stack close` closes it
13 *
14 * What is loaded comes from the engine (the /context breakdown, the agent
15 * listing, the command list, each settings source); where a file sits comes
16 * from reading the folders each type installs to. A scope read off files alone is marked `≈`.
17 * It only observes: every hook passes its event on unchanged.
18 *
19 * Needs Claude Code >= 2.1.287.
20 *
21 * Options (pluginConfigs["stack-viewer@skills-dir"].options):
22 * columns: number width asked for the docked pane (default 84)
23 */
24import type { Register, RenderChildren, RenderSurface } from 'claude-code'
25import { KINDS, SCOPE, buildStack, cell, counts, fit, readAgentListing, scanFiles } from './stack.ts'
26import type { Facts, Files, Io, Item, Kind, ScopeNode, Section } from './stack.ts'
27
28const PANE = 'stack-viewer'
29const COMMAND = 'stack'
30const SOURCES = ['user', 'project', 'local', 'flag', 'policy'] as const
31/** Claude Code's fixed managed-settings locations (macOS, Linux/WSL, Windows), not user paths. */
32const MANAGED_DIRS = ['/Library/Application Support/ClaudeCode', '/etc/claude-code', 'C:/Program Files/ClaudeCode']
33
34// Anthropic's palette: ivory text, warm grays, one orange accent
35const ORANGE = '#d97757'
36const IVORY = '#faf9f5'
37const GRAY = '#b0aea5'
38const DIM = '#87867f'
39const LINE = '#5e5d59'
40const ROW_BG = '#3d3d3a'
41
42let sections: Section[] = []
43let selected: Kind = 'skills'
44let isOpen = false
45let error: string | undefined
46let header = { version: '', where: '' }
47/** Scopes the person folded or unfolded, against the default (open when it has items). */
48const toggled = new Set<string>()
49/** MCP server → config scope, as the engine reported it on a call. */
50const mcpSeen: Record<string, string> = {}
51let activeStyle: string | undefined
52/** Agent type → description, from the engine's agent listing (built-ins included). */
53const agentTypes = new Map<string, string>()
54let hasAgentListing = false
55/** What the open pane was built from, to rebuild it when the agent listing arrives. */
56let lastBuild: { files: Files; facts: Facts } | undefined
57
58export const register: Register = (on, options) => {
59 const columns = paneColumns(options.columns)
60
61 on('session.start', async ($, e, next) => {
62 const r = await next(e)
63 await $.command
64 .register({
65 name: COMMAND,
66 description: 'Map of every component this session runs and the scope it is installed from (close|refresh)',
67 argumentHint: '[close|refresh]',
68 immediate: true,
69 })
70 .catch(err => $.ui.log(`stack-viewer: /${COMMAND} not registered: ${err}`))
71 return r
72 })
73
74 on('command.run', { command: COMMAND }, async ($, e) => {
75 const arg = e.args.trim().toLowerCase()
76 if (arg === 'close' || arg === 'stop') {
77 await $.ui.close({ id: PANE }).catch(() => undefined)
78 isOpen = false
79 return { text: 'stack-viewer closed' }
80 }
81 const isRefresh = arg === 'refresh'
82 try {
83 const [usage, commands, cwd, root, version, home, profile, configDir] = await Promise.all([
84 $.session.usage({ breakdown: 'summary' }).catch(() => undefined),
85 $.command.list().catch(() => []),
86 $.session.cwd(),
87 $.session.root().catch(() => ''),
88 $.session.version().catch(() => undefined),
89 $.env.get('HOME'),
90 $.env.get('USERPROFILE'),
91 $.env.get('CLAUDE_CONFIG_DIR'),
92 ])
93 const settings: Facts['settings'] = {}
94 for (const source of SOURCES) {
95 settings[source] = ((await $.settings.read({ source }).catch(() => ({}))) ?? {}) as Record<string, unknown>
96 }
97 const io: Io = {
98 read: path => $.fs.read(path).then(t => (typeof t === 'string' ? t : undefined), () => undefined),
99 list: path => $.fs.list(path).then(l => l.map(x => ({ name: x.name, kind: x.kind })), () => []),
100 }
101 const homeDir = (home ?? profile ?? '').replace(/\\/g, '/')
102 const cfg = (configDir ?? `${homeDir}/.claude`).replace(/\\/g, '/')
103 const files = await scanFiles(io, {
104 configDir: cfg,
105 claudeJson: configDir ? `${cfg}/.claude.json` : `${homeDir}/.claude.json`,
106 cwd,
107 root: root || cwd,
108 managedDirs: MANAGED_DIRS,
109 selfRoot: $.plugin.root,
110 home: homeDir,
111 })
112 const b = usage?.context.breakdown
113 const skills = b?.skills
114 const facts: Facts = {
115 skills: skills?.skillFrontmatter ?? [],
116 agents: b?.agents ?? [],
117 commands,
118 mcpTools: b?.mcpTools ?? [],
119 memory: b?.memoryFiles ?? [],
120 settings,
121 activeStyle,
122 mcpSeen,
123 skillsOverBudget: skills ? Math.max(0, skills.totalSkills - skills.includedSkills) : undefined,
124 home: homeDir,
125 configDir: configDir ? cfg : undefined,
126 cwd,
127 agentListing: listing(),
128 }
129 lastBuild = { files, facts }
130 sections = buildStack(files, facts)
131 header = {
132 version: version ? `Claude Code ${version.version}` : '',
133 where: cwd.startsWith(homeDir + '/') && homeDir ? `~/${cwd.slice(homeDir.length + 1)}` : cwd,
134 }
135 error = undefined
136 } catch (err) {
137 error = `could not read the stack: ${err}`
138 }
139 if (!isRefresh || !isOpen) {
140 try {
141 await $.ui.open({ id: PANE, title: 'stack', focus: true, columns })
142 } catch (err) {
143 isOpen = false
144 return { text: `stack-viewer: pane not opened: ${err}` }
145 }
146 isOpen = true
147 }
148 $.ui.invalidate('ui.render')
149 if (isRefresh) return {}
150 const total = sections.reduce((n, s) => n + counts(s).active, 0)
151 const hint = e.presentation.isFullscreen
152 ? 'click a type, or focus the pane (ctrl+x tab) and press 1-9'
153 : 'drawn above the prompt; /tui fullscreen docks it beside the transcript'
154 return { text: `${total} components across ${sections.length} types · ${hint} · /${COMMAND} close` }
155 })
156
157 // the output style the engine actually sends, for a style a plugin forces
158 on('prompt.compose', async ($, e, next) => {
159 if (e.outputStyle) activeStyle = e.outputStyle.name
160 return next(e)
161 })
162
163 // the agent listing is the one place the built-in agent types are named
164 on('prompt.attachment', async ($, e, next) => {
165 if (e.type.startsWith('agent_listing') && !e.agentId) {
166 readAgentListing(e.text, agentTypes)
167 hasAgentListing = true
168 if (lastBuild && isOpen) {
169 lastBuild.facts = { ...lastBuild.facts, agentListing: listing() }
170 sections = buildStack(lastBuild.files, lastBuild.facts)
171 $.ui.invalidate('ui.render')
172 }
173 }
174 return next(e)
175 })
176
177 // the engine names an MCP server's config scope on each of its tool calls
178 on('classic.PostToolUse', async ($, e, next) => {
179 const s = e.mcp_server
180 if (s && mcpSeen[s.name] !== s.source) {
181 mcpSeen[s.name] = s.source
182 }
183 return next(e)
184 })
185
186 on('ui.close', async ($, e, next) => {
187 if (e.id !== PANE) return next(e)
188 const r = await next(e)
189 isOpen = false
190 return r
191 })
192
193 on('ui.press', async ($, e, next) => {
194 if (e.plugin !== $.plugin.name || e.requestId !== PANE) return next(e)
195 const r = await next(e)
196 const key = e.element
197 if (key === 'close') {
198 await $.ui.close({ id: PANE }).catch(() => undefined)
199 return r
200 }
201 if (key === 'refresh') {
202 await $.command.run({ command: COMMAND, args: 'refresh' }).catch(() => undefined)
203 return r
204 }
205 if (key === 'fold') {
206 const section = sections.find(s => s.kind === selected)
207 for (const s of section?.scopes ?? []) {
208 const id = `${selected}:${s.id}`
209 if (isExpanded(selected, s)) toggled.has(id) ? toggled.delete(id) : toggled.add(id)
210 }
211 } else if (key.startsWith('nav:')) {
212 selected = key.slice(4) as Kind
213 } else if (key.startsWith('scope:')) {
214 const id = key.slice(6)
215 toggled.has(id) ? toggled.delete(id) : toggled.add(id)
216 }
217 $.ui.invalidate('ui.render')
218 return r
219 })
220
221 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
222 if (e.requestId !== PANE) return next(e)
223 const { Box, Text, Button } = $.ui.resolve(e)
224 const width = Math.max(36, e.props.bodyColumns - 1)
225 const sp = spacer(e.surface)
226 const noop = () => {}
227 const isWide = width >= 72
228 const navW = isWide ? 24 : width
229 const mainW = isWide ? width - navW - 2 : width
230 const section = sections.find(s => s.kind === selected) ?? sections[0]
231
232 const totals = sections.map(counts).reduce(
233 (t, c) => ({ active: t.active + c.active, shadowed: t.shadowed + c.shadowed, off: t.off + c.off }),
234 { active: 0, shadowed: 0, off: 0 },
235 )
236
237 const top = (
238 <Box key="top" flexDirection="column" borderStyle="round" borderColor={LINE} paddingX={1} marginBottom={1}>
239 <Text key="t">
240 <Text color={ORANGE} bold>{'◆ '}</Text>
241 <Text bold color={IVORY}>{'Session stack'}</Text>
242 <Text color={DIM}>{sp(fit(` ${header.version}`, Math.max(1, width - 20)))}</Text>
243 </Text>
244 <Text key="s" wrap="truncate-end">
245 <Text color={DIM}>{sp(`${tail(header.where, Math.max(8, width - 46))} `)}</Text>
246 <Text color={IVORY}>{sp(`${totals.active} active`)}</Text>
247 <Text color={totals.shadowed ? ORANGE : DIM}>{sp(` · ${totals.shadowed} shadowed`)}</Text>
248 <Text color={DIM}>{sp(` · ${totals.off} off`)}</Text>
249 </Text>
250 </Box>
251 )
252
253 if (error || !section) {
254 return (
255 <Box flexDirection="column">
256 {top}
257 <Text color={ORANGE}>{fit(error ?? 'Nothing read yet: run /stack.', width)}</Text>
258 </Box>
259 )
260 }
261
262 const navRow = (i: number) => {
263 const k = KINDS[i]!
264 const c = counts(sections.find(x => x.kind === k.kind)!)
265 const isSel = k.kind === selected
266 const labelW = navW - 8 // bar, `1: ` hotkey prefix, count
267 return (
268 <Box key={`n${k.kind}`} flexDirection="row" width={navW}>
269 <Text key="bar" color={ORANGE}>{sp(isSel ? '▌' : ' ')}</Text>
270 <Button key={`nav:${k.kind}`} plain hotkey={`${i + 1}`} dimColor={!isSel} label={sp(cell(k.title, labelW))} onPress={noop} />
271 <Text key="c" color={isSel ? IVORY : DIM}>{sp(cell(`${c.active}`, 4, 'right'))}</Text>
272 </Box>
273 )
274 }
275
276 const nav = isWide ? (
277 <Box key="nav" flexDirection="column" width={navW} flexShrink={0}>
278 <Text key="h" color={DIM}>{sp(cell(' COMPONENTS', navW))}</Text>
279 {KINDS.map((_, i) => navRow(i))}
280 <Box key="legend" flexDirection="column" marginTop={1}>
281 <Text key="l1">
282 <Text color={IVORY}>{sp(' name ')}</Text>
283 <Text color={DIM}>{'active'}</Text>
284 </Text>
285 <Text key="l2">
286 <Text>{' '}</Text>
287 <Text color={GRAY} strikethrough>{'name'}</Text>
288 <Text color={DIM}>{sp(' shadowed')}</Text>
289 </Text>
290 <Text key="l3">
291 <Text color={LINE}>{sp(' name ')}</Text>
292 <Text color={DIM}>{'off'}</Text>
293 </Text>
294 <Text key="l4" color={DIM}>{sp(' ≈ read off files')}</Text>
295 </Box>
296 </Box>
297 ) : (
298 <Box key="nav" flexDirection="column" marginBottom={1}>
299 {chipRows(width).map((row, r) => (
300 <Box key={`cr${r}`} flexDirection="row" columnGap={2}>
301 {row.map(i => {
302 const k = KINDS[i]!
303 const c = counts(sections.find(x => x.kind === k.kind)!)
304 return <Button key={`nav:${k.kind}`} plain hotkey={`${i + 1}`} dimColor={k.kind !== selected} label={sp(`${k.title} ${c.active}`)} onPress={noop} />
305 })}
306 </Box>
307 ))}
308 </Box>
309 )
310
311 const meta = KINDS.find(k => k.kind === section.kind)!
312 const c = counts(section)
313 const INDENT = 5 // rank, space, fold arrow: items sit under the scope label
314
315 const itemRow = (s: ScopeNode, item: Item, n: number, colW: number) => {
316 const rest = mainW - INDENT - 1
317 const note = item.isInferred ? `≈ ${item.note ?? 'scope read off files'}` : item.note
318 const tail = [item.detail, note].filter(Boolean).join(' ')
319 const nameLen = Array.from(item.name).length
320 const tailLen = Array.from(tail).length
321 // a long name keeps its full width and drops its detail to a line of its own
322 // a long built-in list stays one row per item, its descriptions cut
323 const isSplit = !!tail && nameLen + tailLen + 1 > rest && s.id !== 'builtin'
324 const nameW = !tail || isSplit ? rest : Math.min(Math.max(10, Math.floor(rest * 0.5)), Math.max(10, nameLen, colW))
325 const tailW = isSplit ? rest - 2 : Math.max(0, rest - nameW - 1)
326 const nameColor = item.isSelected ? ORANGE : item.status === 'active' ? IVORY : item.status === 'shadowed' ? GRAY : LINE
327 const tailColor = item.status === 'shadowed' ? ORANGE : item.status === 'off' ? LINE : DIM
328 const tailText = (
329 <Text key="d" color={tailColor} wrap="truncate-end">
330 {sp(` ${fit(tail, tailW)}`)}
331 </Text>
332 )
333 const row = (
334 <Box key={`i${n}`} flexDirection="row">
335 <Text key="r">{sp(' '.repeat(INDENT))}</Text>
336 <Text key="n" color={nameColor} strikethrough={item.status === 'shadowed'} bold={item.isSelected}>
337 {sp(fit(item.name, nameW))}
338 </Text>
339 <Text key="p">{sp(' '.repeat(Math.max(0, nameW - Math.min(nameLen, nameW))))}</Text>
340 {!isSplit && tailW > 2 && tail ? tailText : null}
341 </Box>
342 )
343 if (!isSplit) return row
344 return (
345 <Box key={`i${n}`} flexDirection="column">
346 {row}
347 <Box key="t" flexDirection="row">
348 <Text key="r">{sp(' '.repeat(INDENT + 1))}</Text>
349 {tailText}
350 </Box>
351 </Box>
352 )
353 }
354
355 const scopeBlock = (s: ScopeNode, rank: number) => {
356 const isOpenScope = isExpanded(section.kind, s)
357 const empty = s.items.length === 0
358 const marker = empty ? ' ' : isOpenScope ? '▾' : '▸'
359 // a scope with something inside is a filled bar, so it reads as a control
360 const bg = empty ? undefined : ROW_BG
361 const labelW = Math.min(19, Math.max(10, Math.floor(mainW * 0.34)))
362 const whereW = Math.max(0, mainW - INDENT - labelW - 6)
363 const body: RenderChildren[] = []
364 if (isOpenScope && !empty) {
365 // names line up in one column per scope, so the details read as a table
366 const colW = Math.max(0, ...s.items.map(it => Array.from(it.name).length))
367 s.items.forEach((it, i) => body.push(itemRow(s, it, i, colW)))
368 } else if (!empty) {
369 // a folded scope previews what it holds, so it reads as something to open
370 body.push(
371 <Text key="peek" wrap="truncate-end">
372 <Text color={DIM}>{sp(' '.repeat(INDENT) + peek(s.items.map(it => it.name), mainW - INDENT - 16))}</Text>
373 <Text color={ORANGE}>{sp(' click to open')}</Text>
374 </Text>,
375 )
376 }
377 if (s.note && (isOpenScope || empty)) {
378 body.push(
379 <Text key="note" color={DIM} italic>
380 {sp(' '.repeat(INDENT) + fit(s.note, mainW - INDENT))}
381 </Text>,
382 )
383 }
384 return (
385 <Box key={`s${s.id}`} flexDirection="column" marginBottom={empty ? 0 : 1}>
386 <Box key="h" flexDirection="row" width={mainW} backgroundColor={bg}>
387 <Text key="n" color={empty ? LINE : ORANGE} backgroundColor={bg} bold={!empty}>{sp(cell(`${rank}`, 2, 'right'))}</Text>
388 <Text key="g" backgroundColor={bg}>{' '}</Text>
389 <Button key={`scope:${section.kind}:${s.id}`} plain dimColor={empty} label={sp(cell(`${marker} ${SCOPE[s.id].label}`, labelW + 2))} onPress={noop} />
390 <Text key="c" color={empty ? LINE : IVORY} backgroundColor={bg} bold={!empty}>{sp(cell(empty ? '—' : `${s.items.length}`, 4, 'right'))}</Text>
391 <Text key="x" backgroundColor={bg}>{' '}</Text>
392 {whereW > 4 ? <Text key="w" color={empty ? LINE : DIM} backgroundColor={bg}>{sp(cell(s.where, whereW))}</Text> : null}
393 </Box>
394 {body}
395 </Box>
396 )
397 }
398
399 const summary = [`${c.active} ${section.noun}`, c.shadowed ? `${c.shadowed} shadowed` : '', c.off ? `${c.off} off` : '']
400 .filter(Boolean)
401 .join(' · ')
402
403 const main = (
404 <Box key="main" flexDirection="column" width={mainW} flexShrink={0}>
405 <Text key="title">
406 <Text color={ORANGE} bold>{sp(meta.title)}</Text>
407 <Text color={DIM}>{sp(fit(` ${summary}`, Math.max(1, mainW - meta.title.length)))}</Text>
408 </Text>
409 <Text key="rule" color={DIM} italic wrap="wrap">{sp(section.rule)}</Text>
410 {section.extra ? <Text key="extra" color={ORANGE} wrap="wrap">{sp(section.extra)}</Text> : null}
411 <Box key="gap" height={1} />
412 {section.scopes.map((s, i) => scopeBlock(s, i + 1))}
413 </Box>
414 )
415
416 return (
417 <Box flexDirection="column">
418 {top}
419 {isWide ? (
420 <Box key="cols" flexDirection="row" columnGap={2}>
421 {nav}
422 {main}
423 </Box>
424 ) : (
425 <Box key="cols" flexDirection="column">
426 {nav}
427 {main}
428 </Box>
429 )}
430 <Box key="foot" marginTop={1} flexDirection="column">
431 <Box key="toolbar" flexDirection="row" columnGap={1}>
432 <Button key="refresh" label="refresh" hotkey="r" onPress={noop} />
433 <Button key="fold" label="fold all" onPress={noop} />
434 <Button key="close" label="close" onPress={noop} />
435 </Box>
436 <Text key="hint" color={DIM}>{sp(fit('1-9 or click a type · click a scope (▸ ▾) to open or fold it · r reads again', width))}</Text>
437 </Box>
438 </Box>
439 )
440 })
441}
442
443function listing(): Facts['agentListing'] {
444 return hasAgentListing ? [...agentTypes].map(([name, description]) => ({ name, description })) : undefined
445}
446
447/** The end of a path, cut at the front: `…/scratchpad/demo`. */
448function tail(path: string, width: number): string {
449 const chars = Array.from(path)
450 return chars.length <= width ? path : '…' + chars.slice(chars.length - width + 1).join('')
451}
452
453/** The first names that fit `width`, then how many more: `deploy, pdf-tools +3`. */
454function peek(names: string[], width: number): string {
455 let out = ''
456 for (let i = 0; i < names.length; i++) {
457 const more = names.length - i - 1
458 const next = (out ? `${out}, ` : '') + names[i]
459 const tail = more ? ` +${more}` : ''
460 if (Array.from(next + tail).length > width) return out ? `${out} +${names.length - i}` : fit(names[i]!, width)
461 out = next
462 }
463 return out
464}
465
466/** A scope starts open when it has something to show, except the long built-in lists. */
467function isExpanded(kind: Kind, s: ScopeNode): boolean {
468 const byDefault = s.items.length > 0 && !(s.id === 'builtin' && s.items.length > 8)
469 return toggled.has(`${kind}:${s.id}`) ? !byDefault : byDefault
470}
471
472/** The type chips for a narrow pane, packed into rows `width` wide. */
473function chipRows(width: number): number[][] {
474 const rows: number[][] = [[]]
475 let used = 0
476 KINDS.forEach((k, i) => {
477 const w = k.title.length + 7
478 if (used && used + w > width) {
479 rows.push([])
480 used = 0
481 }
482 rows[rows.length - 1]!.push(i)
483 used += w + 2
484 })
485 return rows
486}
487
488function paneColumns(v: unknown): number {
489 const n = typeof v === 'number' && Number.isFinite(v) ? Math.round(v) : 84
490 return Math.min(140, Math.max(40, n))
491}
492
493/** Desktop and the remote surfaces collapse runs of spaces; keep them. */
494function spacer(surface: RenderSurface): (s: string) => string {
495 return surface === 'terminal' ? s => s : s => s.replace(/ /g, '\u00a0')
496}
497hooks/stack.ts 947 lines1/**
2 * stack-viewer: the model behind the pane. Pure functions, no `$`.
3 *
4 * `scanFiles` walks the folders each component type is installed from, through
5 * an `Io` the hooks module builds from `$.fs`. `buildStack` joins what was found
6 * on disk with what the engine says is loaded (the /context breakdown, the
7 * command list, the settings layers) into one Section per component type: its
8 * scopes in precedence order, each with the components installed there and
9 * whether each one is active, shadowed by a higher scope, or off.
10 */
11
12export type Kind = 'skills' | 'agents' | 'commands' | 'hooks' | 'mcp' | 'plugins' | 'settings' | 'memory' | 'styles'
13
14export type ScopeId =
15 | 'managed'
16 | 'flag'
17 | 'local'
18 | 'project'
19 | 'user'
20 | 'plugin'
21 | 'skillsdir-project'
22 | 'skillsdir-user'
23 | 'session'
24 | 'claudeai'
25 | 'synced'
26 | 'mcp'
27 | 'automem'
28 | 'builtin'
29 | 'other'
30
31export type Status = 'active' | 'shadowed' | 'off'
32
33export type Item = {
34 name: string
35 /** One short line after the name: tokens, a version, a hook's matcher. */
36 detail?: string
37 status: Status
38 /** Why: `shadowed by User`, `awaiting approval`, `merged`. */
39 note?: string
40 /** The scope that wins over this one, for a shadowed item. */
41 by?: ScopeId
42 /** The scope was read off files, not reported by the engine. */
43 isInferred?: boolean
44 /** The selected output style. */
45 isSelected?: boolean
46}
47
48export type ScopeNode = {
49 id: ScopeId
50 /** Where this scope lives on disk, or what it is. */
51 where: string
52 items: Item[]
53 /** Said once under the scope's header (no items to list, or why). */
54 note?: string
55}
56
57export type Section = {
58 kind: Kind
59 /** How the scopes combine, in one line. */
60 rule: string
61 /** What `active` means for this kind (`active`, `available`, `set`). */
62 noun: string
63 scopes: ScopeNode[]
64 /** One extra line under the rule, e.g. the selected output style. */
65 extra?: string
66}
67
68export const SCOPE: Record<ScopeId, { label: string }> = {
69 managed: { label: 'Enterprise' },
70 flag: { label: 'CLI flag' },
71 local: { label: 'Local' },
72 project: { label: 'Project' },
73 user: { label: 'User' },
74 plugin: { label: 'Plugin' },
75 'skillsdir-project': { label: 'Project skills dir' },
76 'skillsdir-user': { label: 'User skills dir' },
77 session: { label: 'This session' },
78 claudeai: { label: 'claude.ai' },
79 synced: { label: 'claude.ai account' },
80 mcp: { label: 'MCP server' },
81 automem: { label: 'Auto memory' },
82 builtin: { label: 'Built-in' },
83 other: { label: 'Other' },
84}
85
86export const KINDS: { kind: Kind; title: string }[] = [
87 { kind: 'skills', title: 'Skills' },
88 { kind: 'agents', title: 'Subagents' },
89 { kind: 'commands', title: 'Commands' },
90 { kind: 'hooks', title: 'Hooks' },
91 { kind: 'mcp', title: 'MCP servers' },
92 { kind: 'plugins', title: 'Plugins & mods' },
93 { kind: 'settings', title: 'Settings' },
94 { kind: 'memory', title: 'Memory' },
95 { kind: 'styles', title: 'Output styles' },
96]
97
98// ---------------------------------------------------------------------------
99// What is on disk
100
101/** The file system calls the scan needs; each resolves, never rejects. */
102export type Io = {
103 read: (path: string) => Promise<string | undefined>
104 list: (path: string) => Promise<{ name: string; kind: 'file' | 'dir' | 'other' }[]>
105}
106
107export type Base = {
108 /** The Claude config directory: `$CLAUDE_CONFIG_DIR`, else `~/.claude`. */
109 configDir: string
110 /** Where `.claude.json` is: beside the config dir's parent, or inside it. */
111 claudeJson: string
112 /** The working directory and the project root (often the same). */
113 cwd: string
114 root: string
115 /** The managed settings directories to try, first found wins. */
116 managedDirs: string[]
117 /** This mod's own folder, so it shows up even when nothing else names it. */
118 selfRoot?: string
119 home?: string
120}
121
122type FileScope = 'managed' | 'user' | 'project'
123
124export type Found = { scope: FileScope; name: string; path: string }
125
126export type PluginRoot = {
127 /** `name@marketplace` for an installed plugin, the manifest name otherwise. */
128 id: string
129 name: string
130 version?: string
131 root: string
132 origin: 'installed' | 'skills-dir' | 'self'
133 /** Where an installed plugin was installed, or which skills dir holds it. */
134 scope?: 'managed' | 'user' | 'project' | 'local'
135 commands: string[]
136 styles: string[]
137 hookEvents: { event: string; matcher?: string; command: string }[]
138 modules: number
139 mcpServers: string[]
140}
141
142export type McpEntry = { scope: 'managed' | 'local' | 'project' | 'user'; name: string }
143
144export type Files = {
145 managedDir?: string
146 skills: Found[]
147 commands: Found[]
148 agents: Found[]
149 styles: Found[]
150 plugins: PluginRoot[]
151 mcp: McpEntry[]
152}
153
154const join = (...parts: string[]) => parts.join('/').replace(/\/{2,}/g, '/')
155
156/** The frontmatter `name:` of a markdown file, if it has one. */
157export function frontmatterName(text: string | undefined): string | undefined {
158 if (!text) return undefined
159 const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)
160 if (!fm) return undefined
161 const m = /^name:\s*["']?(.+?)["']?\s*$/m.exec(fm[1]!)
162 return m?.[1]?.trim() || undefined
163}
164
165function parseJson(text: string | undefined): any {
166 if (!text) return undefined
167 try {
168 return JSON.parse(text)
169 } catch {
170 return undefined
171 }
172}
173
174/** Markdown files under `dir`, as `a:b` names for `a/b.md`, three levels deep. */
175async function markdownTree(io: Io, dir: string, depth = 3, prefix = ''): Promise<{ name: string; path: string }[]> {
176 const out: { name: string; path: string }[] = []
177 for (const e of await io.list(dir)) {
178 const path = join(dir, e.name)
179 if (e.kind === 'dir' && depth > 1) out.push(...(await markdownTree(io, path, depth - 1, `${prefix}${e.name}:`)))
180 else if (e.kind !== 'dir' && e.name.endsWith('.md')) out.push({ name: prefix + e.name.slice(0, -3), path })
181 }
182 return out
183}
184
185async function scanPluginRoot(
186 io: Io,
187 root: string,
188 origin: PluginRoot['origin'],
189 fallback: { id?: string; scope?: PluginRoot['scope']; version?: string },
190): Promise<PluginRoot | undefined> {
191 const manifest = parseJson(await io.read(join(root, '.claude-plugin/plugin.json')))
192 if (!manifest && origin !== 'installed') return undefined
193 const name: string = manifest?.name ?? fallback.id?.split('@')[0] ?? root.split('/').pop() ?? root
194 const commands = (await markdownTree(io, join(root, 'commands'))).map(c => c.name)
195 const styles: string[] = []
196 for (const f of await markdownTree(io, join(root, 'output-styles'), 1)) {
197 styles.push(frontmatterName(await io.read(f.path)) ?? f.name)
198 }
199 const hooksJson = parseJson(await io.read(join(root, 'hooks/hooks.json')))
200 const hookEvents: PluginRoot['hookEvents'] = []
201 for (const [event, groups] of Object.entries((hooksJson?.hooks ?? {}) as Record<string, unknown>)) {
202 for (const g of Array.isArray(groups) ? groups : []) {
203 for (const hk of Array.isArray(g?.hooks) ? g.hooks : []) {
204 hookEvents.push({ event, matcher: g?.matcher || undefined, command: hookLabel(hk) })
205 }
206 }
207 }
208 const modules = Array.isArray(hooksJson?.modules) ? hooksJson.modules.length : 0
209 const mcp = parseJson(await io.read(join(root, '.mcp.json')))
210 const mcpServers = [
211 ...Object.keys((mcp?.mcpServers ?? mcp ?? {}) as object),
212 ...Object.keys((manifest?.mcpServers ?? {}) as object),
213 ].filter(k => k !== 'mcpServers')
214 return {
215 id: fallback.id ?? name,
216 name,
217 version: manifest?.version ?? fallback.version,
218 root,
219 origin,
220 scope: fallback.scope,
221 commands,
222 styles,
223 hookEvents,
224 modules,
225 mcpServers: [...new Set(mcpServers)],
226 }
227}
228
229/** What a hook entry runs, short: its command, URL, or type. */
230export function hookLabel(hk: any): string {
231 if (typeof hk?.command === 'string') return redact(hk.command)
232 if (typeof hk?.url === 'string') return redact(hk.url)
233 if (typeof hk?.prompt === 'string') return `prompt: ${redact(hk.prompt)}`
234 return String(hk?.type ?? 'hook')
235}
236
237/** Masks what reads like a credential in a command or URL: bearer tokens, secret-looking params and flags, long opaque runs. */
238export function redact(text: string): string {
239 return text
240 .replace(/(bearer|basic)\s+[^\s"']+/gi, '$1 •••')
241 .replace(/([?&;\s-](?:[\w-]*?(?:token|key|secret|password|passwd|auth|sig|signature|credential)[\w-]*)[=:]\s*)("[^"]*"|'[^']*'|[^\s&"']+)/gi, '$1•••')
242 .replace(/\b[A-Za-z0-9+/_-]{32,}={0,2}/g, '•••')
243}
244
245/**
246 * Every place a component can be installed from, read off disk: skills,
247 * commands, agents and output styles at managed, user and project level; the
248 * installed plugins and the plugin folders in the skills dirs; MCP servers in
249 * `.claude.json`, `.mcp.json` and `managed-mcp.json`.
250 */
251export async function scanFiles(io: Io, base: Base): Promise<Files> {
252 let managedDir: string | undefined
253 for (const d of base.managedDirs) {
254 if ((await io.list(d)).length) {
255 managedDir = d
256 break
257 }
258 }
259 const projectDirs = [...new Set([base.cwd, base.root])].map(d => join(d, '.claude'))
260 const levels: { scope: FileScope; dir: string }[] = [
261 ...(managedDir ? [{ scope: 'managed' as const, dir: join(managedDir, '.claude') }] : []),
262 { scope: 'user', dir: base.configDir },
263 ...projectDirs.map(dir => ({ scope: 'project' as const, dir })),
264 ]
265
266 const files: Files = { managedDir, skills: [], commands: [], agents: [], styles: [], plugins: [], mcp: [] }
267 const seen = new Set<string>()
268 const once = (k: string) => (seen.has(k) ? false : (seen.add(k), true))
269
270 for (const { scope, dir } of levels) {
271 for (const e of await io.list(join(dir, 'skills'))) {
272 if (e.kind !== 'dir' && e.kind !== 'other') continue
273 const sub = join(dir, 'skills', e.name)
274 const skill = await io.read(join(sub, 'SKILL.md'))
275 if (skill !== undefined) {
276 const name = frontmatterName(skill) ?? e.name
277 if (once(`s:${scope}:${name}`)) files.skills.push({ scope, name, path: join(sub, 'SKILL.md') })
278 continue
279 }
280 if (scope === 'managed') continue
281 const root = await scanPluginRoot(io, sub, 'skills-dir', { scope: scope === 'user' ? 'user' : 'project' })
282 if (root && once(`p:${root.root}`)) files.plugins.push(root)
283 }
284 for (const c of await markdownTree(io, join(dir, 'commands'))) {
285 if (once(`c:${scope}:${c.name}`)) files.commands.push({ scope, name: c.name, path: c.path })
286 }
287 for (const a of await markdownTree(io, join(dir, 'agents'))) {
288 const name = frontmatterName(await io.read(a.path)) ?? a.name.split(':').pop()!
289 if (once(`a:${scope}:${name}`)) files.agents.push({ scope, name, path: a.path })
290 }
291 for (const s of await markdownTree(io, join(dir, 'output-styles'), 1)) {
292 const name = frontmatterName(await io.read(s.path)) ?? s.name
293 if (once(`o:${scope}:${name}`)) files.styles.push({ scope, name, path: s.path })
294 }
295 }
296
297 // installed plugins: one entry per install, kept when it applies here
298 const installed = parseJson(await io.read(join(base.configDir, 'plugins/installed_plugins.json')))
299 const here = new Set([base.cwd, base.root])
300 for (const [id, entries] of Object.entries((installed?.plugins ?? {}) as Record<string, any[]>)) {
301 for (const ent of Array.isArray(entries) ? entries : []) {
302 const scope = ent?.scope as PluginRoot['scope']
303 if ((scope === 'project' || scope === 'local') && !here.has(ent?.projectPath)) continue
304 if (typeof ent?.installPath !== 'string') continue
305 const root = await scanPluginRoot(io, ent.installPath, 'installed', { id, scope, version: ent?.version })
306 if (root && once(`p:${root.root}`)) files.plugins.push(root)
307 }
308 }
309 if (base.selfRoot && !files.plugins.some(p => p.root === base.selfRoot)) {
310 const self = await scanPluginRoot(io, base.selfRoot, 'self', {})
311 if (self) files.plugins.push(self)
312 }
313
314 // MCP servers by config scope; only names are kept, never their env or headers
315 const cj = parseJson(await io.read(base.claudeJson))
316 for (const name of Object.keys((cj?.mcpServers ?? {}) as object)) files.mcp.push({ scope: 'user', name })
317 for (const dir of here) {
318 for (const name of Object.keys((cj?.projects?.[dir]?.mcpServers ?? {}) as object)) {
319 if (once(`m:local:${name}`)) files.mcp.push({ scope: 'local', name })
320 }
321 const mcpJson = parseJson(await io.read(join(dir, '.mcp.json')))
322 for (const name of Object.keys((mcpJson?.mcpServers ?? {}) as object)) {
323 if (once(`m:project:${name}`)) files.mcp.push({ scope: 'project', name })
324 }
325 }
326 if (managedDir) {
327 const managed = parseJson(await io.read(join(managedDir, 'managed-mcp.json')))
328 for (const name of Object.keys((managed?.mcpServers ?? {}) as object)) files.mcp.push({ scope: 'managed', name })
329 }
330 return files
331}
332
333// ---------------------------------------------------------------------------
334// What the engine says is loaded
335
336type SettingsSourceName = 'user' | 'project' | 'local' | 'flag' | 'policy'
337
338export type Facts = {
339 /** The skill listing: skills and commands the model can call. */
340 skills: { name: string; source: string; pluginName?: string }[]
341 /** Custom agents the Agent tool offers. */
342 agents: { agentType: string; source: string }[]
343 /**
344 * Every agent type the Agent tool offers, built-ins included, as the engine's
345 * agent listing names them; undefined until a request has carried one.
346 */
347 agentListing?: { name: string; description: string }[]
348 /** Every slash command. */
349 commands: { name: string; source: string; plugin?: string; description?: string }[]
350 /** MCP tools, by server. */
351 mcpTools: { name: string; serverName: string }[]
352 /** CLAUDE.md and other memory files in the context. */
353 memory: { path: string; type: string; tokens: number }[]
354 /** Each settings source as loaded. */
355 settings: Partial<Record<SettingsSourceName, Record<string, unknown>>>
356 /** The output style the last request carried; undefined when none seen yet. */
357 activeStyle?: string | null
358 /** MCP server name → the config scope the engine reported on a call. */
359 mcpSeen: Record<string, string>
360 /** Skills over the listing's budget, not in `skills`. */
361 skillsOverBudget?: number
362 /** For shortening paths; `configDir` only when `$CLAUDE_CONFIG_DIR` moved it. */
363 home?: string
364 configDir?: string
365 cwd?: string
366}
367
368/** Settings sources, highest precedence first, with the scope each one is. */
369const SETTINGS_ORDER: { source: SettingsSourceName; scope: ScopeId; where: string }[] = [
370 { source: 'policy', scope: 'managed', where: 'managed-settings.json' },
371 { source: 'flag', scope: 'flag', where: '--settings' },
372 { source: 'local', scope: 'local', where: '.claude/settings.local.json' },
373 { source: 'project', scope: 'project', where: '.claude/settings.json' },
374 { source: 'user', scope: 'user', where: '~/.claude/settings.json' },
375]
376
377const ENGINE_SOURCE: Record<string, ScopeId> = {
378 policySettings: 'managed',
379 flagSettings: 'flag',
380 localSettings: 'local',
381 projectSettings: 'project',
382 userSettings: 'user',
383 plugin: 'plugin',
384 'built-in': 'builtin',
385 builtin: 'builtin',
386 mcp: 'mcp',
387 syncedSkills: 'synced',
388}
389const scopeOfSource = (s: string): ScopeId => ENGINE_SOURCE[s] ?? 'other'
390
391const label = (s: ScopeId) => SCOPE[s].label
392
393/** `~/…` and `./…` in place of the home and working directories. */
394export function shortPath(path: string, f: Pick<Facts, 'home' | 'cwd' | 'configDir'>): string {
395 if (f.configDir && path.startsWith(f.configDir + '/')) return '$CLAUDE_CONFIG_DIR/' + path.slice(f.configDir.length + 1)
396 if (f.cwd && path.startsWith(f.cwd + '/')) return './' + path.slice(f.cwd.length + 1)
397 if (f.home && path.startsWith(f.home + '/')) return '~/' + path.slice(f.home.length + 1)
398 return path
399}
400
401function node(id: ScopeId, where: string, items: Item[] = [], note?: string): ScopeNode {
402 return { id, where, items: items.sort(byName), note }
403}
404const byName = (a: Item, b: Item) => a.name.localeCompare(b.name)
405
406/** The first scope in `order` that holds `name`, by `has`. */
407function winner<T extends ScopeId>(order: T[], has: (s: T) => boolean): T | undefined {
408 return order.find(has)
409}
410
411// ---------------------------------------------------------------------------
412// Sections
413
414function skillsSection(files: Files, f: Facts, isCommand: (scope: FileScope, name: string) => boolean): Section {
415 const pluginCommands = new Set(files.plugins.flatMap(p => p.commands.map(c => `${p.name}:${c}`)))
416 const listed = f.skills.filter(s => {
417 const scope = scopeOfSource(s.source)
418 if (scope === 'plugin') return !pluginCommands.has(s.name)
419 return !((scope === 'user' || scope === 'project') && isCommand(scope, s.name))
420 })
421 const listedIn = (scope: ScopeId, name: string) => listed.some(s => s.name === name && scopeOfSource(s.source) === scope)
422 const menu = new Set(f.commands.filter(c => c.source === 'user').map(c => c.name))
423 const order: ScopeId[] = ['managed', 'user', 'project']
424 const fileNode = (scope: FileScope, where: string) => {
425 const items: Item[] = []
426 const names = new Set([
427 ...files.skills.filter(s => s.scope === scope).map(s => s.name),
428 ...listed.filter(s => scopeOfSource(s.source) === scope).map(s => s.name),
429 ])
430 for (const name of names) {
431 if (listedIn(scope, name)) {
432 items.push({ name, status: 'active' })
433 continue
434 }
435 const by = winner(order, s => s !== scope && listedIn(s, name))
436 if (by) items.push({ name, status: 'shadowed', by, note: `shadowed by ${label(by)}` })
437 else if (menu.has(name)) items.push({ name, status: 'active', note: 'menu only (/name)' })
438 else items.push({ name, status: 'off', note: 'not loaded' })
439 }
440 return node(scope, where, items)
441 }
442 const byScope = (scope: ScopeId) =>
443 listed
444 .filter(s => scopeOfSource(s.source) === scope)
445 .map<Item>(s => ({ name: s.name, status: 'active', detail: s.pluginName && scope !== 'plugin' ? s.pluginName : undefined }))
446 const known = new Set<ScopeId>(['managed', 'user', 'project', 'plugin', 'synced', 'mcp', 'builtin'])
447 const other = listed.filter(s => !known.has(scopeOfSource(s.source)))
448 const scopes = [
449 fileNode('managed', files.managedDir ? `${files.managedDir}/.claude/skills` : 'managed settings dir'),
450 fileNode('user', '~/.claude/skills'),
451 fileNode('project', '.claude/skills'),
452 node('plugin', '<plugin>/skills · /plugin:skill', byScope('plugin')),
453 node('synced', 'enabled on claude.ai', byScope('synced')),
454 node('mcp', 'skills an MCP server provides', byScope('mcp')),
455 node(
456 'builtin',
457 'ship with Claude Code',
458 byScope('builtin'),
459 byScope('builtin').length ? undefined : 'none offered to the model here · see Commands › Built-in',
460 ),
461 ]
462 if (other.length) scopes.push(node('other', 'reported by the engine', other.map(s => ({ name: s.name, status: 'active', detail: s.source }))))
463 return {
464 kind: 'skills',
465 noun: 'active',
466 rule: 'Enterprise › User › Project on a name clash · plugin skills are namespaced, both load',
467 extra: f.skillsOverBudget ? `${f.skillsOverBudget} more over the listing budget, not shown to the model` : undefined,
468 scopes,
469 }
470}
471
472function commandsSection(files: Files, f: Facts): Section {
473 const skillFiles = new Map<string, FileScope>()
474 for (const s of files.skills) if (!skillFiles.has(s.name)) skillFiles.set(s.name, s.scope)
475 const listedAs = (name: string) => f.skills.filter(s => s.name === name).map(s => scopeOfSource(s.source))
476 const menu = new Set(f.commands.filter(c => c.source === 'user').map(c => c.name))
477 const fileNode = (scope: FileScope, where: string) => {
478 const items: Item[] = []
479 for (const c of files.commands.filter(c => c.scope === scope)) {
480 const skillScope = skillFiles.get(c.name)
481 const listed = listedAs(c.name)
482 if (skillScope && listed.length) {
483 const by = listed.find(s => s === 'managed' || s === 'user' || s === 'project') ?? skillScope
484 items.push({ name: c.name, status: 'shadowed', by, note: `a ${label(by)} skill of the same name wins` })
485 } else if (listed.includes(scope)) items.push({ name: c.name, status: 'active' })
486 else if (listed.length) items.push({ name: c.name, status: 'shadowed', by: listed[0], note: `shadowed by ${label(listed[0]!)}` })
487 else if (menu.has(c.name)) items.push({ name: c.name, status: 'active', note: 'menu only' })
488 else items.push({ name: c.name, status: 'off', note: 'not loaded' })
489 }
490 return node(scope, where, items)
491 }
492 const pluginCommands = new Set(files.plugins.flatMap(p => p.commands.map(c => `${p.name}:${c}`)))
493 const listedPlugin = new Set(f.skills.filter(s => s.source === 'plugin').map(s => s.name))
494 const plugin = f.commands
495 .filter(c => c.source === 'plugin' && (pluginCommands.has(c.name) || !listedPlugin.has(c.name)))
496 .map<Item>(c => ({ name: c.name, status: 'active', detail: c.plugin && !c.name.startsWith(`${c.plugin}:`) ? c.plugin : undefined }))
497 const builtinSkills = new Set(f.skills.filter(s => scopeOfSource(s.source) === 'builtin').map(s => s.name))
498 const builtin = new Map<string, string | undefined>()
499 for (const c of f.commands) {
500 if (c.source === 'builtin' && !builtinSkills.has(c.name) && !c.name.startsWith('_') && !builtin.has(c.name)) builtin.set(c.name, c.description)
501 }
502 return {
503 kind: 'commands',
504 noun: 'active',
505 rule: 'Same names as skills: a skill wins over a command file · User › Project',
506 scopes: [
507 fileNode('user', '~/.claude/commands'),
508 fileNode('project', '.claude/commands'),
509 node('plugin', '<plugin>/commands · registered by mods', plugin),
510 node('mcp', 'MCP prompts', f.commands.filter(c => c.source === 'mcp').map(c => ({ name: c.name, status: 'active' }))),
511 node('builtin', 'ship with Claude Code', [...builtin].map(([name, description]) => ({ name, detail: description || undefined, status: 'active' }))),
512 ],
513 }
514}
515
516function agentsSection(files: Files, f: Facts): Section {
517 const order: ScopeId[] = ['managed', 'flag', 'project', 'user', 'plugin']
518 const listedIn = (scope: ScopeId, name: string) => f.agents.some(a => a.agentType === name && scopeOfSource(a.source) === scope)
519 const make = (scope: ScopeId, where: string) => {
520 const items: Item[] = []
521 const fsScope = scope === 'managed' || scope === 'user' || scope === 'project' ? scope : undefined
522 const names = new Set([
523 ...f.agents.filter(a => scopeOfSource(a.source) === scope).map(a => a.agentType),
524 ...(fsScope ? files.agents.filter(a => a.scope === fsScope).map(a => a.name) : []),
525 ])
526 for (const name of names) {
527 if (listedIn(scope, name)) {
528 items.push({ name, status: 'active' })
529 continue
530 }
531 const by = winner(order, s => s !== scope && listedIn(s, name))
532 items.push(by ? { name, status: 'shadowed', by, note: `shadowed by ${label(by)}` } : { name, status: 'off', note: 'not loaded' })
533 }
534 return node(scope, where, items)
535 }
536 const scopes = [
537 make('managed', 'managed settings'),
538 make('flag', '--agents JSON'),
539 make('project', '.claude/agents'),
540 make('user', '~/.claude/agents'),
541 make('plugin', '<plugin>/agents · plugin:agent'),
542 builtinAgents(files, f),
543 ]
544 const other = f.agents.filter(a => !order.includes(scopeOfSource(a.source)) && scopeOfSource(a.source) !== 'builtin')
545 if (other.length) scopes.push(node('other', 'reported by the engine', other.map(a => ({ name: a.agentType, status: 'active', detail: a.source }))))
546 return {
547 kind: 'agents',
548 noun: 'active',
549 rule: 'Enterprise › CLI flag › Project › User › Plugin on a name clash',
550 scopes,
551 }
552}
553
554/** Built-in agents: the listed types no agent file, setting or plugin defines. */
555function builtinAgents(files: Files, f: Facts): ScopeNode {
556 if (!f.agentListing) return node('builtin', 'ship with Claude Code', [], 'listed once a message is sent: send one, then refresh')
557 const custom = new Set([...f.agents.map(a => a.agentType), ...files.agents.map(a => a.name)])
558 const items: Item[] = f.agentListing
559 .filter(a => !custom.has(a.name) && !a.name.includes(':'))
560 .map(a => ({ name: a.name, detail: a.description || undefined, status: 'active' }))
561 return node('builtin', 'ship with Claude Code', items)
562}
563
564/**
565 * Folds one agent-listing attachment into `into` (type → description). The
566 * engine sends the full list first, then deltas; a section whose heading says
567 * the types are gone removes them.
568 */
569export function readAgentListing(text: string, into: Map<string, string>): void {
570 let isRemoval = false
571 for (const line of text.split('\n')) {
572 const m = /^- ([^:\s][^:]*?(?::[^:\s]+)?): (.*)$/.exec(line)
573 if (!m) {
574 if (line.trim()) isRemoval = /no longer|removed|unavailable/i.test(line)
575 continue
576 }
577 const name = m[1]!.trim()
578 if (isRemoval) into.delete(name)
579 else into.set(name, firstSentence(m[2]!.replace(/\s*\(Tools: [^)]*\)\s*$/, '')))
580 }
581}
582
583function firstSentence(text: string): string {
584 const m = /^(.+?[.!?])(\s|$)/.exec(text)
585 return (m ? m[1]! : text).trim()
586}
587
588function hooksSection(files: Files, f: Facts): Section {
589 const policy = f.settings.policy ?? {}
590 const isManagedOnly = policy.allowManagedHooksOnly === true
591 const isAllOff = policy.disableAllHooks === true
592 const othersOff =
593 !isAllOff && (['flag', 'local', 'project', 'user'] as const).find(s => f.settings[s]?.disableAllHooks !== undefined)
594 const disabledBy = othersOff && f.settings[othersOff]?.disableAllHooks === true ? othersOff : undefined
595 const scopes = SETTINGS_ORDER.map(({ source, scope, where }) => {
596 const items: Item[] = []
597 const hooks = (f.settings[source]?.hooks ?? {}) as Record<string, unknown>
598 for (const [event, groups] of Object.entries(hooks)) {
599 for (const g of Array.isArray(groups) ? groups : []) {
600 for (const hk of Array.isArray((g as any)?.hooks) ? (g as any).hooks : []) {
601 const matcher = (g as any)?.matcher
602 const off =
603 isAllOff ? 'disableAllHooks (Enterprise)'
604 : scope !== 'managed' && isManagedOnly ? 'allowManagedHooksOnly'
605 : scope !== 'managed' && disabledBy ? `disableAllHooks (${label(SETTINGS_ORDER.find(o => o.source === disabledBy)!.scope)})`
606 : undefined
607 items.push({
608 name: event,
609 detail: `${matcher ? matcher : '*'} → ${hookLabel(hk)}`,
610 status: off ? 'off' : 'active',
611 note: off,
612 })
613 }
614 }
615 }
616 return { id: scope, where, items, note: undefined } as ScopeNode
617 })
618 const pluginItems: Item[] = []
619 for (const p of files.plugins) {
620 for (const h of p.hookEvents) {
621 pluginItems.push({
622 name: h.event,
623 detail: `${p.name} · ${h.matcher ?? '*'} → ${h.command}`,
624 status: isAllOff || isManagedOnly ? 'off' : 'active',
625 note: isAllOff ? 'disableAllHooks' : isManagedOnly ? 'allowManagedHooksOnly' : undefined,
626 })
627 }
628 if (p.modules) {
629 pluginItems.push({
630 name: 'function hooks',
631 detail: `${p.name} · ${p.modules} module${p.modules > 1 ? 's' : ''}`,
632 status: 'active',
633 note: p.origin === 'self' ? 'this mod' : undefined,
634 })
635 }
636 }
637 scopes.push(node('plugin', 'hooks/hooks.json of each plugin and mod', pluginItems))
638 return {
639 kind: 'hooks',
640 noun: 'active',
641 rule: 'Every source runs: hooks add up, none replaces another',
642 scopes,
643 }
644}
645
646function mcpSection(files: Files, f: Facts): Section {
647 const tools = new Map<string, number>()
648 for (const t of f.mcpTools) tools.set(t.serverName, (tools.get(t.serverName) ?? 0) + 1)
649 const order: ScopeId[] = ['managed', 'local', 'project', 'user']
650 const configured = (scope: ScopeId, name: string) =>
651 files.mcp.some(m => m.scope === scope && m.name === name) ||
652 (scope === 'managed' && Object.prototype.hasOwnProperty.call((f.settings.policy?.managedMcpServers ?? {}) as object, name))
653 const seenScope = (name: string): ScopeId | undefined => {
654 const s = f.mcpSeen[name]
655 if (!s) return undefined
656 if (s === 'enterprise') return 'managed'
657 if (s === 'claudeai') return 'claudeai'
658 if (s === 'dynamic' || s === 'sdk' || s === 'agent') return 'session'
659 return (['managed', 'local', 'project', 'user', 'plugin'] as string[]).includes(s) ? (s as ScopeId) : 'other'
660 }
661 const merged = mergeSettings(f.settings)
662 const approved = (name: string) => {
663 const disabled = (merged.disabledMcpjsonServers as string[] | undefined) ?? []
664 if (disabled.includes(name)) return false
665 if (merged.enableAllProjectMcpServers === true) return true
666 return ((merged.enabledMcpjsonServers as string[] | undefined) ?? []).includes(name)
667 }
668 const detail = (name: string) => {
669 const n = tools.get(name)
670 return n ? `${n} tool${n > 1 ? 's' : ''}` : undefined
671 }
672 const nodes = new Map<ScopeId, Item[]>()
673 const push = (s: ScopeId, item: Item) => nodes.set(s, [...(nodes.get(s) ?? []), item])
674 const names = new Set([...files.mcp.map(m => m.name), ...Object.keys((f.settings.policy?.managedMcpServers ?? {}) as object)])
675 for (const name of names) {
676 const top = seenScope(name) ?? winner(order, s => configured(s, name))!
677 for (const s of order) {
678 if (!configured(s, name)) continue
679 if (s === top) {
680 const isUp = tools.has(name)
681 push(s, {
682 name,
683 detail: detail(name),
684 status: isUp ? 'active' : 'off',
685 note: isUp ? undefined : s === 'project' && !approved(name) ? 'awaiting approval' : 'not connected',
686 })
687 } else push(s, { name, status: 'shadowed', by: top, note: `shadowed by ${label(top)}` })
688 }
689 }
690 for (const name of tools.keys()) {
691 if (names.has(name)) continue
692 const seen = seenScope(name)
693 const scope: ScopeId =
694 seen ?? (name.startsWith('plugin:') || name.startsWith('plugin_') ? 'plugin' : /^claude[._]ai[ _]/.test(name) ? 'claudeai' : 'session')
695 push(scope, {
696 name,
697 detail: detail(name),
698 status: 'active',
699 isInferred: seen === undefined && scope === 'session',
700 note: seen === undefined && scope === 'session' ? '--mcp-config or SDK' : undefined,
701 })
702 }
703 return {
704 kind: 'mcp',
705 noun: 'connected',
706 rule: 'Enterprise › Local › Project › User › Plugin › claude.ai, the whole entry wins',
707 scopes: [
708 node('managed', 'managed-mcp.json · managedMcpServers', nodes.get('managed')),
709 node('local', '~/.claude.json, this project', nodes.get('local')),
710 node('project', '.mcp.json', nodes.get('project')),
711 node('user', '~/.claude.json', nodes.get('user')),
712 node('plugin', '<plugin>/.mcp.json', nodes.get('plugin')),
713 node('claudeai', 'claude.ai connectors', nodes.get('claudeai')),
714 node('session', '--mcp-config · SDK', nodes.get('session')),
715 ...(nodes.get('other') ? [node('other', 'reported by the engine', nodes.get('other'))] : []),
716 ],
717 }
718}
719
720function pluginsSection(files: Files, f: Facts): Section {
721 const order = SETTINGS_ORDER
722 const enabled = (src: SettingsSourceName) => (f.settings[src]?.enabledPlugins ?? {}) as Record<string, unknown>
723 const ids = new Set(order.flatMap(o => Object.keys(enabled(o.source))))
724 const roots = new Map(files.plugins.map(p => [p.id, p]))
725 const byName = new Map(files.plugins.map(p => [p.name, p]))
726 const summary = (name: string, root?: PluginRoot) => {
727 const parts: string[] = []
728 if (root?.version) parts.push(`v${root.version}`)
729 const skills = f.skills.filter(s => s.pluginName === name).length
730 const agents = f.agents.filter(a => a.agentType.startsWith(`${name}:`)).length
731 const cmds = f.commands.filter(c => c.plugin === name && !f.skills.some(s => s.name === c.name)).length
732 const mcp = new Set(f.mcpTools.filter(t => t.serverName.includes(`plugin:${name}:`) || t.serverName.startsWith(`plugin_${name}_`)).map(t => t.serverName)).size
733 if (skills) parts.push(`${skills} skill${skills > 1 ? 's' : ''}`)
734 if (agents) parts.push(`${agents} agent${agents > 1 ? 's' : ''}`)
735 if (cmds) parts.push(`${cmds} cmd${cmds > 1 ? 's' : ''}`)
736 if (mcp) parts.push(`${mcp} MCP`)
737 if (root?.hookEvents.length || root?.modules) parts.push('hooks')
738 return parts.join(' · ') || undefined
739 }
740 const nodes = new Map<ScopeId, Item[]>()
741 const push = (s: ScopeId, item: Item) => nodes.set(s, [...(nodes.get(s) ?? []), item])
742 const placed = new Set<string>()
743 for (const id of ids) {
744 const name = id.split('@')[0]!
745 placed.add(name)
746 const top = order.find(o => enabled(o.source)[id] !== undefined)!
747 for (const o of order) {
748 const v = enabled(o.source)[id]
749 if (v === undefined) continue
750 if (o === top) {
751 const isOn = v !== false
752 push(o.scope, { name: id, detail: summary(name, roots.get(id) ?? byName.get(name)), status: isOn ? 'active' : 'off', note: isOn ? undefined : 'disabled here' })
753 } else push(o.scope, { name: id, status: 'shadowed', by: top.scope, note: `overridden by ${label(top.scope)}` })
754 }
755 }
756 for (const p of files.plugins) {
757 if (p.origin !== 'skills-dir' || placed.has(p.name)) continue
758 placed.add(p.name)
759 push(p.scope === 'user' ? 'skillsdir-user' : 'skillsdir-project', {
760 name: `${p.name}@skills-dir`,
761 detail: summary(p.name, p),
762 status: 'active',
763 note: p.scope === 'project' ? 'loads once the folder is trusted' : undefined,
764 isInferred: true,
765 })
766 }
767 // plugins the engine shows components for but nothing on disk installed
768 const seen = new Set<string>([
769 ...f.skills.map(s => s.pluginName).filter((n): n is string => !!n),
770 ...f.commands.map(c => c.plugin).filter((n): n is string => !!n),
771 ...f.agents.filter(a => a.source === 'plugin' && a.agentType.includes(':')).map(a => a.agentType.split(':')[0]!),
772 ...files.plugins.filter(p => p.origin === 'self').map(p => p.name),
773 ])
774 for (const name of seen) {
775 if (placed.has(name)) continue
776 const root = byName.get(name)
777 push('session', {
778 name,
779 detail: summary(name, root),
780 status: 'active',
781 isInferred: true,
782 note: root?.origin === 'self' ? 'this mod · --plugin-dir or SDK' : '--plugin-dir or SDK',
783 })
784 }
785 return {
786 kind: 'plugins',
787 noun: 'enabled',
788 rule: 'enabledPlugins: Enterprise › CLI flag › Local › Project › User, the highest that names it decides',
789 scopes: [
790 node('managed', 'managed enabledPlugins', nodes.get('managed')),
791 node('flag', '--settings', nodes.get('flag')),
792 node('local', '.claude/settings.local.json', nodes.get('local')),
793 node('project', '.claude/settings.json', nodes.get('project')),
794 node('user', '~/.claude/settings.json', nodes.get('user')),
795 node('skillsdir-project', '.claude/skills/<mod>', nodes.get('skillsdir-project')),
796 node('skillsdir-user', '~/.claude/skills/<mod>', nodes.get('skillsdir-user')),
797 node('session', '--plugin-dir · SDK', nodes.get('session')),
798 ],
799 }
800}
801
802/** The engine's merge: a scalar from the highest source that has it, lists joined, objects merged. */
803export function mergeSettings(settings: Facts['settings']): Record<string, unknown> {
804 const out: Record<string, unknown> = {}
805 for (const { source } of [...SETTINGS_ORDER].reverse()) {
806 for (const [k, v] of Object.entries(settings[source] ?? {})) {
807 const prev = out[k]
808 if (Array.isArray(prev) && Array.isArray(v)) out[k] = [...prev, ...v]
809 else if (isObject(prev) && isObject(v)) out[k] = { ...prev, ...v }
810 else out[k] = v
811 }
812 }
813 return out
814}
815
816const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
817
818const SENSITIVE = /key|token|secret|password|passwd|auth|credential|cookie|helper|uuid|org/i
819
820/** A settings value as one short, safe line: no secrets, no env values. */
821export function showValue(key: string, v: unknown): string {
822 if (key === 'env' && isObject(v)) {
823 const keys = Object.keys(v)
824 return keys.length ? `${keys.length} var${keys.length > 1 ? 's' : ''}: ${keys.join(', ')}` : '{}'
825 }
826 if (SENSITIVE.test(key)) return '•••'
827 if (typeof v === 'boolean' || typeof v === 'number') return String(v)
828 if (typeof v === 'string') return v.length > 40 ? `"${v.slice(0, 39)}…"` : `"${v}"`
829 if (Array.isArray(v)) return `[${v.length}]`
830 if (isObject(v)) {
831 const keys = Object.keys(v)
832 return `{ ${keys.slice(0, 4).join(', ')}${keys.length > 4 ? ', …' : ''} }`
833 }
834 return String(v)
835}
836
837function settingsSection(f: Facts): Section {
838 const scopes = SETTINGS_ORDER.map(({ source, scope, where }) => {
839 const items: Item[] = []
840 for (const [k, v] of Object.entries(f.settings[source] ?? {})) {
841 const isMerged = isObject(v) || Array.isArray(v)
842 const top = isMerged ? undefined : SETTINGS_ORDER.find(o => f.settings[o.source]?.[k] !== undefined && !isObject(f.settings[o.source]?.[k]))
843 if (isMerged) items.push({ name: k, detail: showValue(k, v), status: 'active', note: 'merged' })
844 else if (top && top.source !== source) items.push({ name: k, detail: showValue(k, v), status: 'shadowed', by: top.scope, note: `overridden by ${label(top.scope)}` })
845 else items.push({ name: k, detail: showValue(k, v), status: 'active' })
846 }
847 return node(scope, where, items)
848 })
849 return {
850 kind: 'settings',
851 noun: 'in effect',
852 rule: 'Enterprise › CLI flag › Local › Project › User · a value wins, objects and lists merge',
853 scopes,
854 }
855}
856
857function memorySection(f: Facts): Section {
858 const map: Record<string, ScopeId> = { Managed: 'managed', User: 'user', Project: 'project', Local: 'local', AutoMem: 'automem' }
859 const by = new Map<ScopeId, Item[]>()
860 for (const m of f.memory) {
861 const s = map[m.type] ?? 'other'
862 by.set(s, [...(by.get(s) ?? []), { name: shortPath(m.path, f), detail: `${m.tokens} tok`, status: 'active' }])
863 }
864 const scopes = [
865 node('managed', 'managed CLAUDE.md', by.get('managed')),
866 node('project', 'CLAUDE.md · .claude/CLAUDE.md · rules', by.get('project')),
867 node('local', 'CLAUDE.local.md', by.get('local')),
868 node('user', '~/.claude/CLAUDE.md', by.get('user')),
869 node('automem', 'auto memory', by.get('automem')),
870 ]
871 if (by.get('other')) scopes.push(node('other', 'reported by the engine', by.get('other')))
872 return { kind: 'memory', noun: 'loaded', rule: 'All load together, none replaces another', scopes }
873}
874
875const BUILTIN_STYLES = ['Default', 'Proactive', 'Concise', 'Explanatory', 'Learning']
876
877function stylesSection(files: Files, f: Facts): Section {
878 const setIn = SETTINGS_ORDER.filter(o => typeof f.settings[o.source]?.outputStyle === 'string')
879 const fromSettings = setIn[0] ? String(f.settings[setIn[0].source]!.outputStyle) : undefined
880 const selected = f.activeStyle === null ? 'Default' : (f.activeStyle ?? fromSettings ?? 'Default')
881 const mark = (name: string): Item => ({
882 name,
883 status: 'active',
884 isSelected: name === selected || (name === 'Default' && selected.toLowerCase() === 'default'),
885 note: name === selected && setIn[0] ? `selected in ${label(setIn[0].scope)} settings` : undefined,
886 })
887 const fromFiles = (scope: FileScope) => files.styles.filter(s => s.scope === scope).map(s => mark(s.name))
888 const plugin = files.plugins.flatMap(p => p.styles.map(s => ({ ...mark(s), detail: p.name })))
889 const overridden = setIn.slice(1).map(o => `${label(o.scope)}: ${String(f.settings[o.source]!.outputStyle)}`)
890 return {
891 kind: 'styles',
892 noun: 'available',
893 rule: 'One style is selected; the highest settings source with outputStyle picks it',
894 extra: `selected: ${selected}${setIn[0] ? ` ← ${label(setIn[0].scope)}` : ''}${overridden.length ? ` · overrides ${overridden.join(', ')}` : ''}`,
895 scopes: [
896 node('managed', 'managed .claude/output-styles', fromFiles('managed')),
897 node('project', '.claude/output-styles', fromFiles('project')),
898 node('user', '~/.claude/output-styles', fromFiles('user')),
899 node('plugin', '<plugin>/output-styles', plugin),
900 { id: 'builtin', where: 'ship with Claude Code', items: BUILTIN_STYLES.map(mark) },
901 ],
902 }
903}
904
905/** Every component type's section, in the sidebar's order. */
906export function buildStack(files: Files, f: Facts): Section[] {
907 const commandFile = new Set(files.commands.map(c => `${c.scope}:${c.name}`))
908 const skillFile = new Set(files.skills.map(s => `${s.scope}:${s.name}`))
909 // a listed `userSettings` entry is a command when only a command file has that name there
910 const isCommand = (scope: FileScope, name: string) => commandFile.has(`${scope}:${name}`) && !skillFile.has(`${scope}:${name}`)
911 const sections: Record<Kind, Section> = {
912 skills: skillsSection(files, f, isCommand),
913 agents: agentsSection(files, f),
914 commands: commandsSection(files, f),
915 hooks: hooksSection(files, f),
916 mcp: mcpSection(files, f),
917 plugins: pluginsSection(files, f),
918 settings: settingsSection(f),
919 memory: memorySection(f),
920 styles: stylesSection(files, f),
921 }
922 return KINDS.map(k => sections[k.kind])
923}
924
925export type Counts = { active: number; shadowed: number; off: number }
926
927export function counts(section: Section): Counts {
928 const c: Counts = { active: 0, shadowed: 0, off: 0 }
929 for (const s of section.scopes) for (const i of s.items) c[i.status]++
930 return c
931}
932
933/** `text` cut to `width` cells with an ellipsis. */
934export function fit(text: string, width: number): string {
935 const w = Math.max(1, Math.floor(width))
936 const chars = Array.from(text)
937 if (chars.length <= w) return text
938 return chars.slice(0, Math.max(0, w - 1)).join('') + '…'
939}
940
941/** `text` cut or padded to exactly `width` cells. */
942export function cell(text: string, width: number, align: 'left' | 'right' = 'left'): string {
943 const t = fit(text, width)
944 const pad = ' '.repeat(Math.max(0, width - Array.from(t).length))
945 return align === 'left' ? t + pad : pad + t
946}
947