SLOPSHOPPER

stack-viewer

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…

newpanecommandprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · stack-viewer
│ ┃ stack ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ ◆ Session stack Claude Code 2.1.289 │ ⏺ Read(src/auth.ts) │ ┃ │ /work/app 5 active · 0 shadowed · 0 o… │ ⎿ Read 6 lines │ ┃ ╰──────────────────────────────────────────╯ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ 1: Skills 0 2: Subagents 0 3: Commands 0 ⏺ Bash(bun test) │ ┃ 4: Hooks 0 5: MCP servers 0 6: Plugins & m ⎿ 3 pass, 1 fail │ ┃ 7: Settings 0 8: Memory 0 9: Output styles │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Skills 0 active │ ┃ Enterprise › User › Project on a name clash ✻ Worked for 42s · done 4:20 PM │ ┃ skills are namespaced, both load │ ┃ › /stack │ ┃ 1 Enterprise — managed setting ⎿ stack-viewer: 5 components across 9 types · click a type, or foc │ ┃ 2 User — ~/.claude/skill │ ┃ 3 Project — .claude/skills │ ┃ 4 Plugin — <plugin>/skills │ ┃ 5 claude.ai account — enabled on clau │ ┃ 6 MCP server — skills an MCP s │ ┃ 7 Built-in — ship with Claud │ ┃ none offered to the model here · see Co │ ┃ │ ┃ [ refresh ] [ fold all ] [ close ] │ ┃ 1-9 or click a type · click a scope (▸ ▾) to │ ┃ open or f… ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · stack
╭──────────────────────────────────────────────────────────╮ │ ◆ Session stack Claude Code 2.1.289 │ │ /work/app 5 active · 0 shadowed · 0 off │ ╰──────────────────────────────────────────────────────────╯ 1: Skills 0 2: Subagents 0 3: Commands 0 4: Hooks 0 5: MCP servers 0 6: Plugins & mods 0 7: Settings 0 8: Memory 0 9: Output styles 5 Skills 0 active Enterprise › User › Project on a name clash · plugin skills are namespaced, both load 1 Enterprise — managed settings dir 2 User — ~/.claude/skills 3 Project — .claude/skills 4 Plugin — <plugin>/skills · /plugin… 5 claude.ai account — enabled on claude.ai 6 MCP server — skills an MCP server prov… 7 Built-in — ship with Claude Code none offered to the model here · see Commands › B… [ refresh ] [ fold all ] [ close ] 1-9 or click a type · click a scope (▸ ▾) to open or f…
README

stack-viewer

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.

What it shows

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.

TypeScopes, highest firstWhen names clash
✦ SkillsEnterprise, User, Project, Plugin, claude.ai account, MCP server, Built-inEnterprise › User › Project; plugin skills are namespaced (plugin:skill), so both load
◎ SubagentsEnterprise, CLI flag (--agents), Project, User, Plugin, Built-inEnterprise › CLI flag › Project › User › Plugin
❯ CommandsUser, Project, Plugin (including mod commands), MCP prompts, Built-inA skill wins over a command file with the same name
↯ HooksEnterprise, CLI flag, Local, Project, User, Plugin (hooks.json and function-hook modules)None: every source runs. allowManagedHooksOnly and disableAllHooks turn the rest off
⇄ MCP serversEnterprise, 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 & modsEnterprise, 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
⚙ SettingsEnterprise, CLI flag (--settings), Local, Project, UserA value from the highest source wins; objects and lists merge
¶ MemoryEnterprise, Project, Local, User, Auto memoryNone: every file loads
✎ Output stylesEnterprise, Project, User, Plugin, Built-inOne 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.

Where the data comes from

What is loaded comes from the engine, so it is what the session really runs:

  • skills, custom subagents, MCP tools and memory files from the /context breakdown ($.session.usage({ breakdown }))
  • slash commands from $.command.list()
  • built-in subagents from the agent listing the engine attaches to a request (prompt.attachment, agent_listing_delta)
  • each settings source as loaded from $.settings.read({ source }) (hooks, enabledPlugins, outputStyle, every key)
  • the MCP server's config scope from each MCP tool call (mcp_server.source), once the server has been called
  • the output style the engine sends, from prompt.compose

Where 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.

Controls

  • Click a type in the sidebar, or focus the pane (ctrl+x tab) and press 1–9.
  • Click a scope (the filled row, ▸ 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.

Where it draws

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.

Options

Set them in user or managed settings under pluginConfigs["stack-viewer@skills-dir"].options ("stack-viewer" when loaded with --plugin-dir):

OptionDefaultWhat it does
columns84Columns asked for the docked pane (40–140); a width you drag it to wins

Install

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.

Source 2 files
hooks/stack-viewer.tsx 497 lines
1/**
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}
497
hooks/stack.ts 947 lines
1/**
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