SLOPSHOPPER

agent-artifacts

See every skill, agent, command, hook, MCP server, plugin and setting active in a session (/artifacts), and switch named profiles of them per repo…

newpaneguardcommandpromptagents
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-artifacts
│ ┃ Agent profiles ✕ › fix the failing auth test and add an audit log call │ ┃ Profiles /plugins/agent-artifacts/profiles │ ┃ active here none · everywhere none ⏺ Read(src/auth.ts) │ ┃ No profiles yet: add one as ⎿ Read 6 lines │ ┃ profiles/<name>.json. ⏺ Update(src/auth.ts) │ ┃ write to ◉ local (.claude/settings.local.jso ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /artifacts │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agent profiles
Profiles /plugins/agent-artifacts/profiles active here none · everywhere none No profiles yet: add one as profiles/<name>.json. write to ◉ local (.claude/settings.local.json) ○ project (co
Pane · artifacts
Gathering…
README

dotfiles

Declarative macOS setup: nix-darwin + home-manager + Homebrew + Mackup.

New Machine Restore

Before starting: run mackup backup on the old machine to push latest settings to iCloud.

1. Prerequisites

# Xcode CLI tools (required by Homebrew)
xcode-select --install

# Install Determinate Nix (restart terminal after)
curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install

# Install Homebrew
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

2. Clone & build

Must clone to ~/.dotfiles — the flake and rebuild helper hardcode this path.

# Use HTTPS if SSH key not set up yet
git clone https://github.com/popemkt/mac-backup.git ~/.dotfiles
cd ~/.dotfiles
git config core.hooksPath .githooks

# Match hostname to flake config
sudo scutil --set HostName popemkt-personal
sudo scutil --set ComputerName popemkt-personal

# First time only — darwin-rebuild is not in PATH yet
sudo nix run nix-darwin -- switch --flake ~/.dotfiles#popemkt-personal

# Subsequent rebuilds, after opening a new terminal
rebuild

# Skip only the post-switch audit and advisory checks
rebuild --no-checks

rebuild delegates to scripts/rebuild, so later workflow changes take effect from the checkout without another Home Manager activation.

The rebuild prints a read-only external readiness report. Complete credentials, device enrollment, and control-plane approvals in dependency order:

system-setup status
system-setup next
system-setup enroll <integration-id>
system-setup verify

See docs/system-setup.md for command behavior, recovery policy, and how new requirements are declared. Enrollment is intentionally explicit; rebuilds never start OAuth or open third-party administration pages.

3. Restore GUI app settings (Mackup)

Sign into iCloud first and wait for Mackup folder to sync, then:

mackup restore

Restores every app in the allowlist, applications_to_sync in modules/darwin/home-manager/mackup.nix. That includes kb media (~/.dotfiles/.kb/assets, via the kb app), which is backed up rather than committed; see docs/backup-strategy.md.

4. External enrollment and remaining manual steps

system-setup is the authoritative checklist for declared external services. The table below also records standalone application sign-ins that do not yet have an operational readiness check.

ItemAction
SSH keysCopy from old machine or generate new — no keys tracked in repo
Git credentialsgh auth login
Azureaz login
GCPgcloud auth login
TailscaleFollow system-setup next; enroll policy GitOps once per tailnet as described in docs/tailscale.md
Raycastopen ~/.dotfiles/configs/raycast.rayconfig → click Import
VorssaintImport ~/.dotfiles/configs/vorssaint-settings.plist in Vorssaint (Export → overwrite that file later)
Editable/local uv toolsInstall from their owning repos if needed; repo-tracked uv tools are installed during rebuild
Archon CLIManaged by Homebrew; verify with archon workflow list
Entire CLIManaged by Homebrew; opt in per repo with entire enable --agent codex (consider --skip-push-sessions for public repos)
Cursor CLIManaged by Nix; run agent login, then use agent (or cursor-agent)
Oh My PiManaged as a Bun global; its command is omp (open a new shell after the first rebuild)
CLIProxyAPI OAuthFollow system-setup next; credentials are intentionally not tracked
CogneeFollow system-setup next for service approval and agent enrollment; see docs/cognee.md
ClaudexAfter Codex OAuth, run claudex for Claude Code backed by GPT-5.6 Sol; normal claude remains unchanged
App sign-insClaude, Discord, Warp, Lens — manual
CuaDriver (TryCUA)Install: /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/trycua/cua/main/libs/cua-driver/scripts/install.sh)&quot; → cua-driver permissions grant
TinycastPreferences and MCP server bindings (cua-driver mcp --experimental-pip) synced via Mackup
/stuff workspaceAttach /Volumes/Data external drive, or update modules/darwin/system/external-workspace.nix and modules/stacks/ai-agents/hermes.nix
Hermes agent (optional)

Not managed by nix. After cloning the hermes repo:

cp ~/.hermes/hermes-agent/ai.hermes.gateway-popemkt.plist ~/Library/LaunchAgents/
launchctl load ~/Library/LaunchAgents/ai.hermes.gateway-popemkt.plist

Plist hardcodes HERMES_HOME=/Volumes/Data/... — update if drive name differs.

CLIProxyAPI provider login

The loopback service is declarative, but OAuth credentials are mutable state. Authenticate only the providers you use:

cli-proxy-api -config ~/.config/cli-proxy-api/config.yaml -codex-login
cli-proxy-api -config ~/.config/cli-proxy-api/config.yaml -claude-login
cli-proxy-api -config ~/.config/cli-proxy-api/config.yaml -antigravity-login
cli-proxy-api -config ~/.config/cli-proxy-api/config.yaml -kimi-login
cli-proxy-api -config ~/.config/cli-proxy-api/config.yaml -xai-login

The Antigravity CLI (agy) and CLIProxyAPI keep separate OAuth state. Signing in to agy does not populate the proxy automatically. Run the -antigravity-login command above to expose Antigravity-backed models through http://127.0.0.1:8317; this registers Antigravity as a proxy upstream and does not make agy itself consume CLIProxyAPI.

The API listens on http://127.0.0.1:8317. Its generated configuration intentionally has no API key, so every local process that can reach the loopback port is trusted to use the OAuth-backed providers. OAuth state is kept in a mode-0700 directory. launchd starts the service at login and retries unsuccessful exits at most once every 30 seconds.

The claudex shell function scopes the article's Sol settings to one Claude Code process and routes it through this existing proxy. After Codex OAuth, open a new shell and run claudex; pass normal Claude Code arguments as needed.

Cursor CLI is pinned by Nix, and its automatic/self-update path is disabled. Run nix run .#github-sources -- update cursor-cli, review the generated version and hash, then rebuild and verify with agent --version.

SSH: switch from HTTPS to SSH after key setup
cd ~/.dotfiles
git remote set-url origin git@github.com:popemkt/mac-backup.git

Daily Usage

rebuild                                         # apply declared config; do not discover upgrades
system-setup status                             # verify external auth and service readiness
system-setup next                               # show the next required enrollment action
update-system                                   # prepare and validate reviewable repo pin updates
apply-system-update                             # apply/upgrade, then commit and push prepared pin files
upgrade-out-of-band                             # Determinate Nix upgrade + list macOS Software Update
nix run .#github-sources -- check               # report direct-release pins with newer upstreams
nix run .#github-sources -- verify              # verify generated sources match config at pinned versions
nix run .#github-sources -- update              # update their versions + hashes
./scripts/uv-sources check                      # report uv tool pins with newer PyPI releases
./scripts/uv-sources update                     # bump track="latest" uv pins (update-system runs this)
mackup backup --force                            # sync GUI app settings + kb media (.kb/assets) to iCloud (--force skips replace prompts)

Re-export Raycast config periodically: Raycast → Export and overwrite configs/raycast.rayconfig. Re-export Vorssaint the same way: overwrite configs/vorssaint-settings.plist.


Where to Edit

The table of where each kind of change goes lives in AGENTS.md → Where To Edit, and the rules behind it in Module Boundaries.

What's Managed Where

LayerManagesSource of truth
nix-darwinmacOS system settingsmodules/darwin/system/default.nix
Homebrew moduletaps, brews, casks, MAS appsmodules/darwin/system/homebrew.nix
home-managerCLI tools, shell, git, neovim, starshipmodules/common/home-manager/ + modules/darwin/home-manager/
Mackup → iCloudGUI app configs (Karabiner, Zed, VS Code, Warp…)~/Library/Mobile Documents/com~apple~CloudDocs/Mackup/
npm-global.nixnpm global CLIsmodules/common/home-manager/npm-global.nix
bun-global.nixBun global CLIs, including Oh My Pimodules/darwin/home-manager/bun-global.nix
agent-plugins.nixtracked Claude Code and Codex plugins and their marketplacesmodules/darwin/home-manager/agent-plugins.nix
nvfetcher + pkgs/pinned direct release packagesnvfetcher.toml + _sources/
Tailscaleapp, MagicDNS domain, private services, and access policymodules/stacks/vpn/, host declarations, and configs/tailscale/policy.hujson
Cogneepinned service version, launchd jobs, routing, and non-secret configurationmodules/stacks/ai-agents/cognee/server.nix
system-setupdeclared external requirements, dependency ordering, enrollment guidance, readiness checksmodules/darwin/system/system-setup/default.nix
configs/Raycast / Vorssaint exportsmanual import on new machine
ManualSSH keys, credentials, Hermes plist, editable uv tools—

Adding Another Machine

  1. Add hosts/<hostname>/default.nix. Import the shared Darwin system module and declare only host-specific differences there.
  2. Add <hostname> = mkDarwin "<hostname>"; under darwinConfigurations in flake.nix.
  3. Set the macOS hostname to the same value and bootstrap with sudo nix run nix-darwin -- switch --flake ~/.dotfiles#<hostname>.

Current Darwin hosts are popemkt-personal and popemkt-work. Shared behavior lives in modules; host files select roles and host-only behavior.

Lint & Format

nixfmt **/*.nix
statix check .
scripts/deadnix-repo
nix flake check --no-build

Pre-commit hook at .githooks/pre-commit runs all four on staged .nix files. Activated via git config core.hooksPath .githooks (already set on this clone).

Known Gaps

  • npm globals not yet in npm-global.nix: @tobilu/qmd, ccmanager, kanban, sudocode, yarn
  • editable/local uv tools not tracked: browser-harness
  • Hermes launchd plist — manual deploy
  • ~/.local/bin scripts (hermes, iii, plannotator) — depend on /stuff workspace
  • ~/.gitconfig has extra entries (nbstripout, agor safe.directory) not in git.nix
  • Git nbstripout filter hardcodes /Volumes/Data/... — update after restore if needed
  • TODO: trial 9Router as an isolated, opt-in experiment rather than replacing CLIProxyAPI. Evaluate Cursor model discovery and request fidelity, local-only binding, credential/account risk, mutable state and backup needs, and whether fallback routing justifies the additional service and dependency surface.
Source 4 files
hooks/register.tsx 769 lines
1// agent-artifacts: one place to see what customizes this session apart from the model (/artifacts), and to
2// switch named profiles of it per repo (/agent-profile). Reads prefer what the session loaded, through `$`;
3// a kind the API does not expose is read from disk and labelled so. No network, no processes, no model calls.
4import { atom, read, update } from 'claude-code'
5import type { ElementTable, EngineInterface, Hook, Register, RenderInput } from 'claude-code'
6
7import type { Captured, Change, Inventory, Item, KindId, Plan, Profile, ProfileView, Scope, Selection, Status, View } from '../types'
8import {
9  type McpConfig,
10  type PluginDisk,
11  SETTINGS_SOURCES,
12  SOURCE_CLASSES,
13  type Shown,
14  type Snapshot,
15  buildInventory,
16  inventoryText,
17  mask,
18  shownGroups,
19  tilde,
20} from './inventory'
21import {
22  PROFILE_NAME,
23  RUNTIME_KEYS,
24  SCOPES,
25  changeText,
26  compileRule,
27  filterDeferredTools,
28  filterSkillListing,
29  parseProfile,
30  planSettings,
31  runtimeChanges,
32  settingsPath,
33} from './profile'
34
35const INVENTORY_PANE = 'artifacts'
36const PROFILE_PANE = 'agent-profile'
37
38const DEFAULT_VIEW: View = { expanded: [], query: '', source: 'all', reveal: false }
39const DEFAULT_PROFILES: ProfileView = {
40  names: [],
41  broken: [],
42  selected: null,
43  scope: 'local',
44  plan: null,
45  message: null,
46  active: { repo: null, global: null },
47}
48const EMPTY_CAPTURED: Captured = { parents: {}, mcpProviders: {}, mods: [] }
49
50const inventory = atom({ plugin: 'agent-artifacts', key: 'inventory' } as const, null)
51const view = atom({ plugin: 'agent-artifacts', key: 'view' } as const, DEFAULT_VIEW)
52const profiles = atom({ plugin: 'agent-artifacts', key: 'profiles' } as const, DEFAULT_PROFILES)
53const captured = atom({ plugin: 'agent-artifacts', key: 'captured' } as const, EMPTY_CAPTURED)
54
55type Json = Record<string, unknown>
56const isRecord = (v: unknown): v is Json => typeof v === 'object' && v !== null && !Array.isArray(v)
57
58// ------------------------------------------------------------------ reading
59
60type Paths = { root: string; home: string; configDir: string; userJson: string }
61
62async function paths($: EngineInterface): Promise<Paths> {
63  const home = (await $.env.get('HOME')) ?? ''
64  const custom = await $.env.get('CLAUDE_CONFIG_DIR')
65  const configDir = custom ?? `${home}/.claude`
66  return { root: await $.session.root(), home, configDir, userJson: custom === undefined ? `${home}/.claude.json` : `${custom}/.claude.json` }
67}
68
69async function readText($: EngineInterface, path: string): Promise<string | null> {
70  if (!(await $.fs.exists(path))) return null
71  return $.fs.read(path)
72}
73
74async function readJson($: EngineInterface, path: string, problems: string[]): Promise<Json | null> {
75  try {
76    const text = await readText($, path)
77    if (text === null) return null
78    const parsed: unknown = JSON.parse(text)
79    if (isRecord(parsed)) return parsed
80    problems.push(`${path}: not a JSON object`)
81  } catch (err) {
82    problems.push(`${path}: ${String(err)}`)
83  }
84  return null
85}
86
87async function listNames($: EngineInterface, dir: string, suffix: string): Promise<string[]> {
88  if (!(await $.fs.exists(dir))) return []
89  const entries = await $.fs.list(dir)
90  return entries
91    .filter(e => (e.kind === 'file' || e.isLink) && e.name.endsWith(suffix))
92    .map(e => e.name)
93    .sort()
94}
95
96function mcpRecord(v: unknown): Record<string, McpConfig> {
97  if (!isRecord(v)) return {}
98  const servers = isRecord(v.mcpServers) ? v.mcpServers : v
99  const out: Record<string, McpConfig> = {}
100  for (const [name, cfg] of Object.entries(servers)) {
101    if (!isRecord(cfg)) continue
102    out[name] = {
103      type: typeof cfg.type === 'string' ? cfg.type : undefined,
104      command: typeof cfg.command === 'string' ? cfg.command : undefined,
105      url: typeof cfg.url === 'string' ? cfg.url : undefined,
106    }
107  }
108  return out
109}
110
111/** A gathering in progress: the paths it reads under, and what failed so far. */
112type Reading = { p: Paths; problems: string[] }
113
114/** A plugin's hooks.json: named by its manifest's `hooks` (inline or a path), else the default place. */
115async function pluginHooks($: EngineInterface, r: Reading, root: string, manifest: Json): Promise<Json | null> {
116  if (isRecord(manifest.hooks)) return manifest.hooks
117  const file = typeof manifest.hooks === 'string' ? `${root}/${manifest.hooks}` : `${root}/hooks/hooks.json`
118  return readJson($, file, r.problems)
119}
120
121/** A plugin's MCP servers: inline in its manifest, else its .mcp.json. */
122async function pluginMcp($: EngineInterface, r: Reading, root: string, manifest: Json): Promise<Record<string, McpConfig> | undefined> {
123  const json = isRecord(manifest.mcpServers) ? { mcpServers: manifest.mcpServers } : await readJson($, `${root}/.mcp.json`, r.problems)
124  return json === null ? undefined : mcpRecord(json)
125}
126
127/** One plugin folder as read from disk: its manifest, its hooks.json and its MCP servers. */
128async function pluginFolder($: EngineInterface, r: Reading, id: string, root: string): Promise<PluginDisk> {
129  const manifest = (await readJson($, `${root}/.claude-plugin/plugin.json`, r.problems)) ?? {}
130  const hooksJson = await pluginHooks($, r, root, manifest)
131  const str = (v: unknown) => (typeof v === 'string' ? v : undefined)
132  return {
133    name: str(manifest.name) ?? id.slice(0, id.lastIndexOf('@')),
134    root,
135    version: str(manifest.version),
136    hooks: isRecord(hooksJson?.hooks) ? hooksJson.hooks : undefined,
137    modules: Array.isArray(hooksJson?.modules) ? strings(hooksJson.modules) : undefined,
138    mcp: await pluginMcp($, r, root, manifest),
139  }
140}
141
142/** Where an installed plugin lives for this repo: a project install here, else the user install. */
143function installPath(installed: Json, id: string, root: string): string | null {
144  const installs = Array.isArray(installed[id]) ? (installed[id] as Json[]) : []
145  const install = installs.find(i => i.projectPath === root) ?? installs.find(i => i.scope === 'user') ?? installs[0]
146  return typeof install?.installPath === 'string' ? install.installPath : null
147}
148
149/** The folder of each enabled plugin, read from disk. */
150async function pluginFolders($: EngineInterface, r: Reading, installed: Json, enabled: Json): Promise<Record<string, PluginDisk>> {
151  const out: Record<string, PluginDisk> = {}
152  for (const [id, on] of Object.entries(enabled)) {
153    const root = on === true ? installPath(installed, id, r.p.root) : null
154    if (root !== null) out[id] = await pluginFolder($, r, id, root)
155  }
156  return out
157}
158
159async function readSettings($: EngineInterface, problems: string[]): Promise<Snapshot['settings']> {
160  const settings: Snapshot['settings'] = {}
161  for (const source of SETTINGS_SOURCES) {
162    try {
163      settings[source] = (await $.settings.read({ source })) as Json
164    } catch (err) {
165      problems.push(`settings (${source}): ${String(err)}`)
166    }
167  }
168  return settings
169}
170
171async function readBreakdown($: EngineInterface, problems: string[]): Promise<Snapshot['breakdown']> {
172  try {
173    const b = (await $.session.usage({ breakdown: 'summary' })).context.breakdown
174    if (b === undefined) return null
175    const skills = b.skills?.skillFrontmatter ?? []
176    return { skills, totalSkills: b.skills?.totalSkills ?? 0, agents: b.agents, memoryFiles: b.memoryFiles, mcpTools: b.mcpTools }
177  } catch (err) {
178    problems.push(`context breakdown: ${String(err)}`)
179    return null
180  }
181}
182
183/** The auto-memory folder: where the session's MEMORY.md was loaded from, else where the engine keeps it. */
184function memoryDir(p: Paths, breakdown: Snapshot['breakdown']): string {
185  const auto = breakdown?.memoryFiles.find(f => f.type === 'AutoMem')
186  if (auto !== undefined) return auto.path.slice(0, auto.path.lastIndexOf('/'))
187  return `${p.configDir}/projects/${p.root.replace(/[^A-Za-z0-9]/g, '-')}/memory`
188}
189
190async function outputStyles($: EngineInterface, p: Paths): Promise<Snapshot['disk']['outputStyles']> {
191  const of = async (dir: string, source: string) => (await listNames($, dir, '.md')).map(f => ({ name: f.slice(0, -3), source }))
192  return [...(await of(`${p.configDir}/output-styles`, 'user')), ...(await of(`${p.root}/.claude/output-styles`, 'project'))]
193}
194
195const strings = (v: unknown) => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [])
196
197/** The MCP servers ~/.claude.json configures for every repo and for this one, and this repo's .mcp.json approvals. */
198async function userMcp($: EngineInterface, { p, problems }: Reading): Promise<Pick<Snapshot['disk'], 'userMcp' | 'localMcp' | 'approvals'>> {
199  const userJson = (await readJson($, p.userJson, problems)) ?? {}
200  const projects = isRecord(userJson.projects) ? userJson.projects : {}
201  const project = isRecord(projects[p.root]) ? (projects[p.root] as Json) : {}
202  return {
203    userMcp: mcpRecord({ mcpServers: userJson.mcpServers ?? {} }),
204    localMcp: mcpRecord({ mcpServers: project.mcpServers ?? {} }),
205    approvals: {
206      enabled: strings(project.enabledMcpjsonServers),
207      disabled: strings(project.disabledMcpjsonServers),
208      enableAll: project.enableAllProjectMcpServers === true ? true : undefined,
209    },
210  }
211}
212
213async function readDisk($: EngineInterface, r: Reading, live: Pick<Snapshot, 'merged' | 'breakdown'>): Promise<Snapshot['disk']> {
214  const { p, problems } = r
215  const installedFile = await readJson($, `${p.configDir}/plugins/installed_plugins.json`, problems)
216  const installed = isRecord(installedFile?.plugins) ? installedFile.plugins : {}
217  const dir = memoryDir(p, live.breakdown)
218  const memoryFiles = await listNames($, dir, '.md')
219  return {
220    ...(await userMcp($, r)),
221    installed: installed as Snapshot['disk']['installed'],
222    marketplaces: ((await readJson($, `${p.configDir}/plugins/known_marketplaces.json`, problems)) ?? {}) as Snapshot['disk']['marketplaces'],
223    projectMcp: mcpRecord((await readJson($, `${p.root}/.mcp.json`, problems)) ?? {}),
224    plugins: await pluginFolders($, r, installed, isRecord(live.merged.enabledPlugins) ? live.merged.enabledPlugins : {}),
225    outputStyles: await outputStyles($, p),
226    memory: memoryFiles.length === 0 ? null : { dir, files: memoryFiles },
227  }
228}
229
230async function snapshot($: EngineInterface): Promise<Snapshot> {
231  const problems: string[] = []
232  const p = await paths($)
233  const settings = await readSettings($, problems)
234  const merged = ((await $.settings.read().catch(() => ({}))) ?? {}) as Json
235  const breakdown = await readBreakdown($, problems)
236  const commands = await $.command.list().catch(err => {
237    problems.push(`commands: ${String(err)}`)
238    return []
239  })
240  const act = await active($)
241  return {
242    root: p.root,
243    home: p.home,
244    self: { name: $.plugin.name, root: $.plugin.root },
245    settings,
246    merged,
247    breakdown,
248    commands,
249    captured: await flushCaptured($),
250    disk: await readDisk($, { p, problems }, { merged, breakdown }),
251    keep: act === null ? null : { skill: act.keepSkill, agent: act.keepAgent, tool: act.keepTool },
252    problems,
253  }
254}
255
256// tool.describe fires for every tool at once, so its facts gather here and reach $.state in one write.
257const seenProviders = new Map<string, string>()
258
259async function flushCaptured($: EngineInterface): Promise<Captured> {
260  const fresh = Object.fromEntries(seenProviders)
261  seenProviders.clear()
262  return update($, captured, c => ({ ...c, mcpProviders: { ...c.mcpProviders, ...fresh } }))
263}
264
265async function refresh($: EngineInterface): Promise<Inventory> {
266  const inv = buildInventory(await snapshot($), await $.clock.now())
267  await update($, inventory, () => inv)
268  return inv
269}
270
271// ------------------------------------------------------------------ profiles
272
273type Active = {
274  name: string
275  profile: Profile
276  keepSkill: (n: string) => boolean
277  keepAgent: (n: string) => boolean
278  keepTool: (n: string) => boolean
279}
280
281// The active profile for this session's repo, read once and again after every apply.
282let activeLoad: Promise<Active | null> | undefined
283
284const profileDir = ($: EngineInterface) => `${$.plugin.root}/profiles`
285
286async function selections($: EngineInterface): Promise<Record<string, Selection>> {
287  const v = await $.store.get('selections')
288  return isRecord(v) ? (v as Record<string, Selection>) : {}
289}
290
291async function readProfile($: EngineInterface, name: string): Promise<{ ok: true; profile: Profile } | { ok: false; reason: string }> {
292  if (!PROFILE_NAME.test(name)) return { ok: false, reason: `"${name}" is not a profile name` }
293  const text = await readText($, `${profileDir($)}/${name}.json`)
294  if (text === null) return { ok: false, reason: `no profile "${name}" in ${profileDir($)}` }
295  return parseProfile(text)
296}
297
298async function loadActive($: EngineInterface): Promise<Active | null> {
299  const root = await $.session.root()
300  const all = await selections($)
301  const selection = all[root] ?? all['*']
302  if (selection === undefined) return null
303  const read = await readProfile($, selection.profile)
304  if (!read.ok) {
305    $.ui.log(`agent-artifacts: the selected profile is not applied at run time: ${read.reason}`)
306    return null
307  }
308  return {
309    name: selection.profile,
310    profile: read.profile,
311    keepSkill: compileRule(read.profile.skills),
312    keepAgent: compileRule(read.profile.agents),
313    keepTool: compileRule(read.profile.tools),
314  }
315}
316
317async function active($: EngineInterface): Promise<Active | null> {
318  activeLoad ??= loadActive($).catch(() => null)
319  return activeLoad
320}
321
322async function loadProfiles($: EngineInterface): Promise<void> {
323  const files = await listNames($, profileDir($), '.json')
324  const names: string[] = []
325  const broken: ProfileView['broken'] = []
326  for (const file of files) {
327    const name = file.slice(0, -'.json'.length)
328    const r = await readProfile($, name)
329    if (r.ok) names.push(name)
330    else broken.push({ name, reason: r.reason })
331  }
332  const root = await $.session.root()
333  const all = await selections($)
334  await update($, profiles, v => ({ ...v, names, broken, active: { repo: all[root] ?? null, global: all['*'] ?? null } }))
335}
336
337const namesOf = (inv: Inventory, kind: KindId): string[] => inv.groups.find(g => g.kind === kind)?.items.map(i => i.name) ?? []
338
339async function makePlan($: EngineInterface, name: string, scope: Scope): Promise<{ plan: Plan } | { reason: string }> {
340  const r = await readProfile($, name)
341  if (!r.ok) return { reason: r.reason }
342  const p = await paths($)
343  const file = settingsPath(scope, p.root, p.configDir)
344  const before = await readText($, file)
345  const planned = planSettings(r.profile, before)
346  if (!planned.ok) return { reason: `${tilde(file, p.home)}: ${planned.reason}` }
347  const act = await active($)
348  const inv = (await read($, inventory)) ?? (await refresh($))
349  const tools = (await $.tool.list()).map(t => t.name)
350  const runtime = runtimeChanges(r.profile, act?.profile ?? null, { skills: namesOf(inv, 'skills'), agents: namesOf(inv, 'agents'), tools })
351  return { plan: { profile: name, scope, file, before, after: planned.after, changes: [...planned.changes, ...runtime] } }
352}
353
354async function preview($: EngineInterface, name: string, scope: Scope): Promise<string | null> {
355  const made = await makePlan($, name, scope)
356  if ('reason' in made) {
357    await update($, profiles, v => ({ ...v, selected: name, scope, plan: null, message: made.reason }))
358    return made.reason
359  }
360  await update($, profiles, v => ({ ...v, selected: name, scope, plan: made.plan, message: null }))
361  return null
362}
363
364function effectsLine(changes: readonly Change[]): string {
365  const by = new Map<string, Set<string>>()
366  for (const c of changes) by.set(c.effect, (by.get(c.effect) ?? new Set()).add(c.key))
367  if (by.size === 0) return 'Nothing changes.'
368  return `Takes effect: ${[...by].map(([effect, keys]) => `${effect} (${[...keys].join(', ')})`).join('; ')}.`
369}
370
371/** Records the profile as selected for this repo (or every repo, for the user scope) and drops cached answers. */
372async function select($: EngineInterface, plan: Plan, root: string): Promise<void> {
373  const all = await selections($)
374  const selection: Selection = { profile: plan.profile, scope: plan.scope, appliedAt: await $.clock.now() }
375  await $.store.set('selections', { ...all, [plan.scope === 'user' ? '*' : root]: selection })
376  activeLoad = undefined
377  $.ui.invalidate('tool.describe')
378  $.ui.invalidate('prompt.attachment')
379}
380
381const isSettingsChange = (c: Change) => !(RUNTIME_KEYS as readonly string[]).includes(c.key)
382
383/** Writes the plan the person saw, if the file still is what it was when they saw it. */
384async function apply($: EngineInterface): Promise<string> {
385  const { plan } = await read($, profiles)
386  if (plan === null) return 'Nothing to apply: preview a profile first (/agent-profile <name>).'
387  if ((await readText($, plan.file)) !== plan.before) {
388    await preview($, plan.profile, plan.scope)
389    return `${plan.file} changed since the preview. Review the new preview, then apply again.`
390  }
391  const p = await paths($)
392  if (plan.changes.some(isSettingsChange)) await $.fs.write(plan.file, plan.after)
393  await select($, plan, p.root)
394  const message = `Applied "${plan.profile}" to ${tilde(plan.file, p.home)}. ${effectsLine(plan.changes)}`
395  await update($, profiles, v => ({ ...v, plan: null, message }))
396  await loadProfiles($)
397  await refresh($)
398  return message
399}
400
401function planText(plan: Plan, home: string): string {
402  const lines = [`Profile "${plan.profile}" → ${tilde(plan.file, home)} (${plan.scope})`]
403  if (plan.changes.length === 0) lines.push('  no changes')
404  for (const c of plan.changes) lines.push(`  ${changeText(c)}   [${c.effect}]`)
405  lines.push(effectsLine(plan.changes))
406  return lines.join('\n')
407}
408
409type ProfileArgs = { name?: string; scope: Scope; isApply: boolean }
410
411function parseProfileArgs(args: string): ProfileArgs {
412  const words = args.trim().split(/\s+/).filter(w => w !== '')
413  const isApply = words[0] === 'apply'
414  const rest = isApply ? words.slice(1) : words
415  const scope = (rest.find(w => (SCOPES as readonly string[]).includes(w)) as Scope | undefined) ?? 'local'
416  const name = rest.find(w => !(SCOPES as readonly string[]).includes(w))
417  return { name, scope, isApply }
418}
419
420/** `/agent-profile apply [name scope]`: applies the previewed plan; a named one must be the plan previewed. */
421async function applyCommand($: EngineInterface, args: ProfileArgs): Promise<string> {
422  if (args.name === undefined) return apply($)
423  const { plan } = await read($, profiles)
424  const isPreviewed = plan !== null && plan.profile === args.name && plan.scope === args.scope
425  return isPreviewed ? apply($) : `Preview it first: /agent-profile ${args.name} ${args.scope}`
426}
427
428async function previewText($: EngineInterface): Promise<string> {
429  const { plan } = await read($, profiles)
430  if (plan === null) return ''
431  return `${planText(plan, (await paths($)).home)}\nRun /agent-profile apply to write it.`
432}
433
434function profileListText(v: ProfileView): string {
435  const act = v.active.repo ?? v.active.global
436  return [
437    `Profiles: ${v.names.join(', ') || '(none)'}`,
438    `Active here: ${act === null ? 'none' : `${act.profile} (${act.scope})`}`,
439    ...v.broken.map(b => `broken: ${b.name}: ${b.reason}`),
440  ].join('\n')
441}
442
443// ------------------------------------------------------------------ drawing
444
445const STATUS_MARK: Record<Status, { glyph: string; color: string }> = {
446  on: { glyph: '●', color: 'success' },
447  connected: { glyph: '●', color: 'success' },
448  off: { glyph: '○', color: 'inactive' },
449  'no-tools': { glyph: '◌', color: 'warning' },
450  pending: { glyph: '◌', color: 'warning' },
451  'scoped-off': { glyph: '⊘', color: 'warning' },
452}
453
454const ORIGIN_LABEL = { live: 'live', mixed: 'live + disk' } as const
455
456function timeOf(ms: number): string {
457  const d = new Date(ms)
458  const pad = (n: number) => String(n).padStart(2, '0')
459  return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
460}
461
462const toggle = (list: readonly KindId[], kind: KindId): KindId[] => (list.includes(kind) ? list.filter(k => k !== kind) : [...list, kind])
463
464function filterControls($: EngineInterface, e: RenderInput, v: View) {
465  if (e.surface === 'mobile') return null
466  const { Box, Input, Select } = $.ui.resolve(e)
467  return (
468    <Box flexDirection="row" gap={2} flexWrap="wrap">
469      <Input key="query" label="filter " placeholder="name, source or detail" value={v.query} submitLabel="filter" onInput={q => void update($, view, x => ({ ...x, query: q }))} onSubmit={q => void update($, view, x => ({ ...x, query: q }))} />
470      <Select key="source" label="source " value={v.source} options={SOURCE_CLASSES.map(s => ({ value: s, label: s }))} onSelect={s => void update($, view, x => ({ ...x, source: s }))} />
471    </Box>
472  )
473}
474
475type Els = Pick<ElementTable, 'Box' | 'Text' | 'Button'>
476
477function inventoryToolbar($: EngineInterface, { Box, Button }: Els, inv: Inventory, v: View) {
478  const openProfiles = () => void $.ui.open({ id: PROFILE_PANE, title: 'Agent profiles', focus: true, closeOnEscape: true })
479  return (
480    <Box flexDirection="row" gap={1} flexWrap="wrap">
481      <Button key="refresh" label="refresh" hotkey="r" onPress={() => void refresh($)} />
482      <Button key="expand" label="expand all" hotkey="e" onPress={() => update($, view, x => ({ ...x, expanded: inv.groups.map(g => g.kind) }))} />
483      <Button key="collapse" label="collapse" hotkey="c" onPress={() => update($, view, x => ({ ...x, expanded: [] }))} />
484      <Button key="reveal" label={v.reveal ? 'mask env' : 'reveal env'} hotkey="v" onPress={() => update($, view, x => ({ ...x, reveal: !x.reveal }))} />
485      <Button key="profiles" label="profiles" hotkey="p" onPress={openProfiles} />
486    </Box>
487  )
488}
489
490function itemRow({ Text }: Els, item: Item, reveal: boolean, isMixed: boolean) {
491  const mark = item.status === undefined ? null : STATUS_MARK[item.status]
492  const detail = item.secret !== undefined ? `= ${reveal ? item.secret : mask(item.secret)}` : (item.detail ?? '')
493  return (
494    <Text wrap="truncate-end">
495      {'     '}
496      {mark === null ? '  ' : <Text color={mark.color}>{mark.glyph} </Text>}
497      {item.name}
498      <Text dimColor>
499        {detail === '' ? '' : `  ${detail}`}
500        {item.fromDisk === true && isMixed ? '  (disk)' : ''}
501      </Text>
502    </Text>
503  )
504}
505
506function groupBlock($: EngineInterface, els: Els, s: Shown, reveal: boolean) {
507  const { Box, Text, Button } = els
508  const count = s.matched === s.total ? `${s.total}` : `${s.matched}/${s.total}`
509  return (
510    <Box key={`g-${s.group.kind}`} flexDirection="column">
511      <Button
512        key={`t-${s.group.kind}`}
513        plain
514        label={`${s.isOpen ? '▾' : '▸'} ${s.group.title}  ${count}  · ${ORIGIN_LABEL[s.group.origin]}`}
515        dimColor={s.matched === 0}
516        onPress={() => update($, view, x => ({ ...x, expanded: toggle(x.expanded, s.group.kind) }))}
517      />
518      {s.isOpen && s.group.note !== undefined && (
519        <Text dimColor italic wrap="wrap">
520          {'   '}
521          {s.group.note}
522        </Text>
523      )}
524      {s.isOpen &&
525        s.sections.map(section => (
526          <Box flexDirection="column">
527            <Text color="suggestion" wrap="truncate-end">
528              {'   '}
529              {section.source} · {section.items.length}
530            </Text>
531            {section.items.map(item => itemRow(els, item, reveal, s.group.origin === 'mixed'))}
532          </Box>
533        ))}
534    </Box>
535  )
536}
537
538const SCOPE_LABEL: Record<Scope, string> = {
539  local: 'local (.claude/settings.local.json)',
540  project: 'project (committed)',
541  user: 'user (every repo)',
542}
543
544function scopePicker($: EngineInterface, { Box, Text, Button }: Els, v: ProfileView) {
545  const pick = (scope: Scope) => (v.selected === null ? update($, profiles, x => ({ ...x, scope })) : preview($, v.selected, scope))
546  return (
547    <Box flexDirection="row" gap={1}>
548      <Text>write to</Text>
549      {SCOPES.map(scope => (
550        <Button key={`s-${scope}`} plain label={`${v.scope === scope ? '◉' : '○'} ${SCOPE_LABEL[scope]}`} onPress={() => void pick(scope)} />
551      ))}
552    </Box>
553  )
554}
555
556function changeColor(c: Change): string {
557  if (c.to === undefined) return 'error'
558  return c.from === undefined ? 'success' : 'warning'
559}
560
561function planBlock($: EngineInterface, { Box, Text, Button }: Els, plan: Plan, home: string) {
562  return (
563    <Box flexDirection="column" marginTop={1}>
564      <Text bold wrap="truncate-end">
565        {plan.profile} → {tilde(plan.file, home)}
566      </Text>
567      {plan.changes.length === 0 && <Text dimColor>No changes.</Text>}
568      {plan.changes.map(c => (
569        <Text wrap="truncate-end">
570          <Text color={changeColor(c)}>
571            {c.to === undefined ? '- ' : c.from === undefined ? '+ ' : '~ '}
572            {changeText(c)}
573          </Text>
574          <Text dimColor>  [{c.effect}]</Text>
575        </Text>
576      ))}
577      <Text dimColor wrap="wrap">
578        {effectsLine(plan.changes)}
579      </Text>
580      <Box flexDirection="row" gap={1}>
581        <Button key="apply" label="apply" hotkey="a" variant="primary" onPress={() => void apply($)} />
582        <Button key="cancel" label="cancel" onPress={() => update($, profiles, x => ({ ...x, plan: null, selected: null }))} />
583      </Box>
584    </Box>
585  )
586}
587
588// ------------------------------------------------------------------ hooks
589
590/** Declares the two slash commands for this session. */
591const registerCommands: Hook<'session.start'> = async ($, e, next) => {
592  await $.command.register({
593    name: 'artifacts',
594    description: 'Show every skill, agent, command, hook, MCP server, plugin and setting active here',
595    argumentHint: '[filter]',
596  })
597  await $.command.register({
598    name: 'agent-profile',
599    description: 'List, preview and apply agent-artifact profiles for this repo',
600    argumentHint: '[name] [local|project|user] | apply',
601  })
602  return next(e)
603}
604
605/** `/artifacts [filter]`: gathers the inventory and opens its pane; answers in text with no surface. */
606const runArtifacts: Hook<'command.run'> = async ($, e) => {
607  const inv = await refresh($)
608  await update($, view, v => ({ ...v, query: e.args.trim() }))
609  if ((await $.session.surfaces()).length === 0) return { text: inventoryText(inv, (await paths($)).home) }
610  const opened = await $.ui.open({ id: INVENTORY_PANE, title: 'Agent artifacts', focus: true })
611  return opened.isPlaced ? {} : { text: inventoryText(inv, (await paths($)).home) }
612}
613
614/** `/agent-profile [name] [scope] | apply`: lists, previews or applies a profile. */
615const runAgentProfile: Hook<'command.run'> = async ($, e) => {
616  const args = parseProfileArgs(e.args)
617  await loadProfiles($)
618  if (args.isApply) return { text: await applyCommand($, args) }
619  const hasSurface = (await $.session.surfaces()).length > 0
620  if (args.name !== undefined) {
621    const reason = await preview($, args.name, args.scope)
622    if (!hasSurface) return { text: reason ?? (await previewText($)) }
623  }
624  if (!hasSurface) return { text: profileListText(await read($, profiles)) }
625  await $.ui.open({ id: PROFILE_PANE, title: 'Agent profiles', focus: true, closeOnEscape: true })
626  return {}
627}
628
629/** Notes each function-hook mod admitted after this one, which no other call lists. */
630const capturePlugin: Hook<'plugin.register'> = async ($, e, next) => {
631  const mod = { name: e.name, provenance: e.provenance, events: [...e.uses.events] }
632  await update($, captured, c => (c.mods.some(m => m.name === mod.name) ? c : { ...c, mods: [...c.mods, mod] }))
633  return next(e)
634}
635
636/** Notes which file `@`-imported each instruction file, which only this event carries. */
637const captureInstructionParents: Hook<'prompt.context'> = async ($, e, next) => {
638  const r = await next(e)
639  const parents: Record<string, string> = {}
640  for (const f of r.instructionFiles ?? []) if (f.parent !== undefined) parents[f.path] = f.parent
641  if (Object.keys(parents).length > 0) await update($, captured, c => ({ ...c, parents: { ...c.parents, ...parents } }))
642  return r
643}
644
645/** Notes who provides each MCP tool, and defers the tools the active profile turns off. */
646const describeTool: Hook<'tool.describe'> = async ($, e, next) => {
647  const r = await next(e)
648  const server = /^mcp__(.+?)__/.exec(e.tool)?.[1]
649  if (server !== undefined) seenProviders.set(server, e.provider.plugin)
650  const act = await active($)
651  return act === null || act.keepTool(e.tool) ? r : { ...r, isDeferred: true }
652}
653
654/** Removes the skills and deferred tools the active profile turns off from their listings. */
655const filterAttachment: Hook<'prompt.attachment'> = async ($, e, next) => {
656  const r = await next(e)
657  if (e.origin.kind !== 'engine' || r.text === null) return r
658  const act = await active($)
659  if (act === null) return r
660  if (e.type === 'skill_listing' && act.profile.skills !== undefined) {
661    return { ...r, text: filterSkillListing(r.text, act.keepSkill)?.text ?? r.text }
662  }
663  if (e.type === 'deferred_tools_delta' && act.profile.tools !== undefined) {
664    return { ...r, text: filterDeferredTools(r.text, act.keepTool).text }
665  }
666  return r
667}
668
669/** Keeps the agent types the active profile turns off from the model. */
670const offerAgent: Hook<'agent.offer'> = async ($, e, next) => {
671  const act = await active($)
672  return act === null || act.keepAgent(e.agent) ? next(e) : { isOffered: false }
673}
674
675/** Refuses a tool, or a Skill call, the active profile turns off (the listing may still show it until /clear). */
676const callTool: Hook<'tool.call'> = async ($, e, next) => {
677  const act = await active($)
678  if (act === null) return next(e)
679  if (!act.keepTool(e.tool)) return { deny: `The tool ${e.tool} is turned off here by the agent profile "${act.name}".` }
680  if (e.tool === 'Skill') {
681    const name = String(e.skill ?? '').replace(/^\//, '')
682    if (!act.keepSkill(name)) {
683      return { deny: `The skill "${name}" is turned off here by the agent profile "${act.name}". If it is needed, ask the user to run /${name} themselves.` }
684    }
685  }
686  return next(e)
687}
688
689/** The inventory pane: a header, the toolbar, the filter, then one collapsible group per kind. */
690const drawInventory = async ($: EngineInterface, e: RenderInput) => {
691  const els = $.ui.resolve(e)
692  const { Box, Text } = els
693  const inv = await read($, inventory)
694  if (inv === null) return <Text dimColor>Gathering…</Text>
695  const v = await read($, view)
696  const prof = await read($, profiles)
697  const act = prof.active.repo ?? prof.active.global
698  return (
699    <Box flexDirection="column">
700      <Text wrap="truncate-end">
701        <Text bold>{tilde(inv.root, (await paths($)).home)}</Text>
702        <Text dimColor>
703          {'  '}profile {act === null ? 'none' : `${act.profile} (${act.scope})`} · read {timeOf(inv.builtAt)}
704        </Text>
705      </Text>
706      {inventoryToolbar($, els, inv, v)}
707      {filterControls($, e, v)}
708      {shownGroups(inv, v).map(shown => groupBlock($, els, shown, v.reveal))}
709      {inv.problems.map(problem => (
710        <Text color="warning" wrap="truncate-end">
711          ! {problem}
712        </Text>
713      ))}
714    </Box>
715  )
716}
717
718/** The profile pane: the profiles, where to write, and the preview of the selected one with Apply. */
719const drawProfiles = async ($: EngineInterface, e: RenderInput) => {
720  const els = $.ui.resolve(e)
721  const { Box, Text, Button } = els
722  const v = await read($, profiles)
723  const home = (await paths($)).home
724  const sel = (x: Selection | null) => (x === null ? 'none' : `${x.profile} (${x.scope}, ${timeOf(x.appliedAt)})`)
725  return (
726    <Box flexDirection="column">
727      <Text wrap="truncate-end">
728        <Text bold>Profiles</Text>
729        <Text dimColor>  {tilde(profileDir($), home)}</Text>
730      </Text>
731      <Text dimColor wrap="truncate-end">
732        active here {sel(v.active.repo)} · everywhere {sel(v.active.global)}
733      </Text>
734      <Box flexDirection="row" gap={1} flexWrap="wrap">
735        {v.names.length === 0 && <Text dimColor>No profiles yet: add one as profiles/&lt;name&gt;.json.</Text>}
736        {v.names.map(name => (
737          <Button key={`p-${name}`} label={name} variant={v.selected === name ? 'primary' : undefined} onPress={() => void preview($, name, v.scope)} />
738        ))}
739      </Box>
740      {v.broken.map(b => (
741        <Text color="error" wrap="truncate-end">
742          {b.name}: {b.reason}
743        </Text>
744      ))}
745      {scopePicker($, els, v)}
746      {v.plan !== null && planBlock($, els, v.plan, home)}
747      {v.message !== null && (
748        <Text color="suggestion" wrap="wrap">
749          {v.message}
750        </Text>
751      )}
752    </Box>
753  )
754}
755
756export const register: Register = on => {
757  on('session.start', registerCommands)
758  on('command.run', { command: 'artifacts' }, runArtifacts)
759  on('command.run', { command: 'agent-profile' }, runAgentProfile)
760  on('plugin.register', capturePlugin)
761  on('prompt.context', captureInstructionParents)
762  on('tool.describe', describeTool)
763  on('prompt.attachment', filterAttachment)
764  on('agent.offer', offerAgent)
765  on('tool.call', callTool)
766  on('ui.render', { component: 'Pane', requestId: INVENTORY_PANE }, drawInventory)
767  on('ui.render', { component: 'Pane', requestId: PROFILE_PANE }, drawProfiles)
768}
769
hooks/inventory.ts 527 lines
1// The inventory, built from a snapshot of what the session loaded (read through `$` in register.tsx) and the
2// files it was loaded from. Pure: no `$`, so every rule here is tested with plain data.
3import type { Captured, Group, Inventory, Item, KindId, Origin, Status, View } from '../types'
4
5type Json = Record<string, unknown>
6
7/** The settings sources, lowest precedence first, as `$.settings.read({ source })` names them. */
8export const SETTINGS_SOURCES = ['user', 'project', 'local', 'flag', 'policy'] as const
9export type SettingsSourceName = (typeof SETTINGS_SOURCES)[number]
10
11export type McpConfig = { type?: string; command?: string; url?: string }
12
13/** What one enabled plugin's folder declares, read from disk. */
14export type PluginDisk = {
15  /** The plugin's own name, from its plugin.json (it can differ in case from the id's). */
16  name: string
17  root: string
18  version?: string
19  /** hooks/hooks.json `hooks`: settings-shaped command hooks. */
20  hooks?: Json
21  /** hooks/hooks.json `modules`: function-hook modules. */
22  modules?: string[]
23  mcp?: Record<string, McpConfig>
24}
25
26export type Snapshot = {
27  root: string
28  home: string
29  self: { name: string; root: string }
30  settings: Partial<Record<SettingsSourceName, Json>>
31  merged: Json
32  breakdown: {
33    skills: { name: string; source: string; pluginName?: string; tokens: number }[]
34    totalSkills: number
35    agents: { agentType: string; source: string; tokens: number }[]
36    memoryFiles: { path: string; type: string; tokens: number }[]
37    mcpTools: { name: string; serverName: string }[]
38  } | null
39  commands: { name: string; description: string; source: string; plugin?: string }[]
40  captured: Captured
41  disk: {
42    installed: Record<string, { scope?: string; version?: string; projectPath?: string | null }[]>
43    marketplaces: Record<string, Json>
44    userMcp: Record<string, McpConfig>
45    localMcp: Record<string, McpConfig>
46    projectMcp: Record<string, McpConfig>
47    approvals: { enabled: string[]; disabled: string[]; enableAll?: boolean }
48    plugins: Record<string, PluginDisk>
49    outputStyles: { name: string; source: string }[]
50    memory: { dir: string; files: string[] } | null
51  }
52  /** The active profile's run-time rules; absent, nothing is scoped off. */
53  keep: { skill: (n: string) => boolean; agent: (n: string) => boolean; tool: (n: string) => boolean } | null
54  problems: string[]
55}
56
57export const KINDS: { kind: KindId; title: string }[] = [
58  { kind: 'skills', title: 'Skills' },
59  { kind: 'agents', title: 'Subagents' },
60  { kind: 'commands', title: 'Slash commands' },
61  { kind: 'hooks', title: 'Hooks (command)' },
62  { kind: 'mods', title: 'Mods (function hooks)' },
63  { kind: 'mcp', title: 'MCP servers' },
64  { kind: 'plugins', title: 'Plugins' },
65  { kind: 'marketplaces', title: 'Marketplaces' },
66  { kind: 'instructions', title: 'CLAUDE.md and rules' },
67  { kind: 'memory', title: 'Auto memory' },
68  { kind: 'outputStyle', title: 'Output style' },
69  { kind: 'statusLine', title: 'Status line' },
70  { kind: 'permissions', title: 'Permissions' },
71  { kind: 'env', title: 'Env vars' },
72]
73
74// ------------------------------------------------------------------ sources
75
76/** The engine's words for where a thing was defined, as the pane labels them. */
77const ENGINE_SOURCES: Record<string, string> = {
78  userSettings: 'user',
79  projectSettings: 'project',
80  localSettings: 'local',
81  policySettings: 'managed',
82  flagSettings: 'flag',
83  'built-in': 'built-in',
84  builtin: 'built-in',
85  bundled: 'built-in',
86  syncedSkills: 'claude.ai sync',
87  mcp: 'mcp',
88  user: 'user',
89  policy: 'managed',
90}
91
92/** The source classes the pane's source filter offers, in order. */
93export const SOURCE_CLASSES = ['all', 'managed', 'user', 'project', 'local', 'plugin', 'built-in', 'other'] as const
94
95export function sourceClass(source: string): string {
96  if (source === 'managed' || source === 'user' || source === 'project' || source === 'local') return source
97  if (source.startsWith('plugin')) return 'plugin'
98  if (source === 'built-in') return 'built-in'
99  return 'other'
100}
101
102const RANK = ['this mod', 'managed', 'user', 'project', 'local', 'flag', 'plugin', 'claude.ai', 'built-in']
103function rank(source: string): number {
104  const at = RANK.findIndex(r => source === r || source.startsWith(`${r} `))
105  return at === -1 ? RANK.length : at
106}
107
108/** `name` of a plugin, as its skills and agents report it, to the `name@marketplace` id settings key it by. */
109export function pluginId(name: string, ids: readonly string[]): string {
110  const lower = name.toLowerCase()
111  return ids.find(id => id.slice(0, id.lastIndexOf('@')).toLowerCase() === lower) ?? name
112}
113
114function sourceOf(word: string, plugin: string | undefined, ids: readonly string[]): string {
115  if (word === 'plugin') return plugin === undefined ? 'plugin' : `plugin ${pluginId(plugin, ids)}`
116  return ENGINE_SOURCES[word] ?? word
117}
118
119/** A settings source as the pane labels it: the managed tier is `policy` to the API. */
120const labelOf = (source: SettingsSourceName): string => (source === 'policy' ? 'managed' : source)
121
122/** The settings source a key's merged value comes from: the last one that sets it. */
123export function decidingSource(settings: Snapshot['settings'], has: (s: Json) => boolean): string | undefined {
124  let found: string | undefined
125  for (const source of SETTINGS_SOURCES) {
126    const s = settings[source]
127    if (s !== undefined && has(s)) found = labelOf(source)
128  }
129  return found
130}
131
132// ------------------------------------------------------------------ helpers
133
134const isRecord = (v: unknown): v is Json => typeof v === 'object' && v !== null && !Array.isArray(v)
135const strings = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [])
136const firstLine = (s: string, max = 80): string => {
137  const line = s.trim().split('\n', 1)[0] ?? ''
138  return line.length > max ? `${line.slice(0, max - 1)}…` : line
139}
140
141export function tilde(path: string, home: string): string {
142  return home !== '' && (path === home || path.startsWith(`${home}/`)) ? `~${path.slice(home.length)}` : path
143}
144
145export function normalizeServer(name: string): string {
146  return name.replace(/[^A-Za-z0-9_-]/g, '_')
147}
148
149/** Where a server runs, without what can carry credentials: a URL's origin, a command's program name. */
150function endpoint(cfg: McpConfig): string {
151  if (cfg.url === undefined) return (cfg.command ?? '').split('/').pop() ?? ''
152  try {
153    return new URL(cfg.url).origin
154  } catch {
155    return ''
156  }
157}
158
159function describeMcp(cfg: McpConfig): string {
160  const type = cfg.type ?? (cfg.url === undefined ? 'stdio' : 'http')
161  const where = endpoint(cfg)
162  return where === '' ? type : `${type} ${where}`
163}
164
165const byRankThenName = (a: Item, b: Item) => rank(a.source) - rank(b.source) || a.source.localeCompare(b.source) || a.name.localeCompare(b.name)
166
167type GroupText = { note?: string; isOrdered?: boolean }
168
169/** A kind's group; its items sorted by source then name, unless their own order says more (load order). */
170function group(kind: KindId, origin: Origin, items: Item[], { note, isOrdered = false }: GroupText = {}): Group {
171  const title = KINDS.find(k => k.kind === kind)?.title ?? kind
172  return { kind, title, origin, note, items: isOrdered ? items : [...items].sort(byRankThenName) }
173}
174
175// ------------------------------------------------------------------ kinds
176
177function enabledIds(s: Snapshot): string[] {
178  const ids = new Set<string>(Object.keys(isRecord(s.merged.enabledPlugins) ? s.merged.enabledPlugins : {}))
179  for (const id of Object.keys(s.disk.plugins)) ids.add(id)
180  return [...ids]
181}
182
183function skills(s: Snapshot, ids: string[]): Group {
184  if (s.breakdown === null) return group('skills', 'live', [], { note: 'The session reported no context breakdown.' })
185  const items = s.breakdown.skills.map<Item>(k => ({
186    name: k.name,
187    source: sourceOf(k.source, k.pluginName, ids),
188    detail: `${k.tokens} tok`,
189    status: s.keep !== null && !s.keep.skill(k.name) ? 'scoped-off' : undefined,
190  }))
191  const unlisted = s.breakdown.totalSkills - items.length
192  return group('skills', 'live', items, { note: unlisted > 0 ? `${unlisted} more skills fell outside the listing's token budget.` : undefined })
193}
194
195function agents(s: Snapshot, ids: string[]): Group {
196  if (s.breakdown === null) return group('agents', 'live', [], { note: 'The session reported no context breakdown.' })
197  const items = s.breakdown.agents.map<Item>(a => {
198    const plugin = a.source === 'plugin' ? a.agentType.split(':')[0] : undefined
199    return {
200      name: a.agentType,
201      source: sourceOf(a.source, plugin, ids),
202      detail: `${a.tokens} tok`,
203      status: s.keep !== null && !s.keep.agent(a.agentType) ? 'scoped-off' : undefined,
204    }
205  })
206  return group('agents', 'live', items, { note: 'Custom agents only; the built-in types (general-purpose, Explore, Plan) are not listed.' })
207}
208
209function commands(s: Snapshot, ids: string[]): Group {
210  const skillNames = new Set((s.breakdown?.skills ?? []).map(k => k.name))
211  const items = s.commands
212    .filter(c => !skillNames.has(c.name))
213    .map<Item>(c => ({
214      name: `/${c.name}`,
215      source: c.source === 'plugin' ? sourceOf('plugin', c.plugin, ids) : (ENGINE_SOURCES[c.source] ?? c.source),
216      detail: firstLine(c.description, 70),
217    }))
218  return group('commands', 'live', items, { note: 'Skills that also run as /commands are listed under Skills.' })
219}
220
221type HookEntry = { matcher?: unknown; hooks?: unknown }
222
223/** One line per hook of a settings-shaped entry: `[matcher] type: first line of its command`. */
224function entryDetails(entry: HookEntry): string[] {
225  const matcher = typeof entry.matcher === 'string' && entry.matcher !== '' ? `[${entry.matcher}] ` : ''
226  const hooks = Array.isArray(entry.hooks) ? (entry.hooks as Json[]) : []
227  return hooks.map(h => {
228    const type = typeof h.type === 'string' ? h.type : 'hook'
229    const body = [h.command, h.url, h.prompt].find((v): v is string => typeof v === 'string') ?? ''
230    return `${matcher}${type}: ${firstLine(body, 70)}`
231  })
232}
233
234function hookItems(hooks: unknown, source: string, fromDisk: boolean): Item[] {
235  if (!isRecord(hooks)) return []
236  return Object.entries(hooks).flatMap(([event, entries]) =>
237    (Array.isArray(entries) ? (entries as HookEntry[]) : [])
238      .flatMap(entryDetails)
239      .map(detail => ({ name: event, source, detail, fromDisk: fromDisk || undefined })),
240  )
241}
242
243function hooks(s: Snapshot): Group {
244  const items: Item[] = []
245  for (const source of SETTINGS_SOURCES) {
246    items.push(...hookItems(s.settings[source]?.hooks, labelOf(source), false))
247  }
248  for (const [id, p] of Object.entries(s.disk.plugins)) items.push(...hookItems(p.hooks, `plugin ${id}`, true))
249  const off = s.merged.disableAllHooks === true ? 'disableAllHooks is set: none of these run. ' : ''
250  return group('hooks', 'mixed', items, { note: `${off}Settings hooks are live; a plugin's hooks.json is read from disk.` })
251}
252
253function provenanceSource(provenance: string): string {
254  if (provenance.endsWith('@inline')) return '--plugin-dir'
255  if (provenance.endsWith('@builtin')) return 'built-in'
256  return `plugin ${provenance}`
257}
258
259function mods(s: Snapshot): Group {
260  const items: Item[] = [{ name: s.self.name, source: 'this mod', detail: tilde(s.self.root, s.home), status: 'on' }]
261  const seen = new Set([s.self.name])
262  for (const m of s.captured.mods) {
263    if (seen.has(m.name)) continue
264    seen.add(m.name)
265    items.push({ name: m.name, source: provenanceSource(m.provenance), detail: `${m.events.length} events: ${m.events.join(', ')}`, status: 'on' })
266  }
267  for (const [id, p] of Object.entries(s.disk.plugins)) {
268    if (p.modules === undefined || seen.has(p.name)) continue
269    seen.add(p.name)
270    items.push({ name: p.name, source: `plugin ${id}`, detail: `modules: ${p.modules.join(', ')}`, fromDisk: true })
271  }
272  return group('mods', 'mixed', items, { note: 'Mods loaded after this one are seen live; the rest are found in enabled plugins on disk.' })
273}
274
275/** A server's tool count, and how many of them the active profile hides. */
276function toolsDetail(tools: readonly string[], keep: Snapshot['keep']): string {
277  const hidden = keep === null ? 0 : tools.filter(t => !keep.tool(t)).length
278  return `${tools.length} tools${hidden > 0 ? `, ${hidden} scoped off` : ''}`
279}
280
281type Configured = { name: string; source: string; cfg: McpConfig; key: string; gate?: Status }
282
283/** Whether a project .mcp.json server may start: disabled, approved, or awaiting approval. */
284function projectGate(s: Snapshot): (name: string) => Status | undefined {
285  const disabled = new Set([...strings(s.merged.disabledMcpjsonServers), ...s.disk.approvals.disabled])
286  const enabled = new Set([...strings(s.merged.enabledMcpjsonServers), ...s.disk.approvals.enabled])
287  const enableAll = s.merged.enableAllProjectMcpServers === true || s.disk.approvals.enableAll === true
288  return name => (disabled.has(name) ? 'off' : enableAll || enabled.has(name) ? undefined : 'pending')
289}
290
291/** Every server a file configures, keyed as the engine names its tools (`plugin:<plugin>:<server>` for a plugin's). */
292function configuredServers(s: Snapshot): Configured[] {
293  const of = (servers: Record<string, McpConfig>, source: string) =>
294    Object.entries(servers).map(([name, cfg]) => ({ name, source, cfg, key: normalizeServer(name) }))
295  const gate = projectGate(s)
296  return [
297    ...of(s.disk.userMcp, 'user'),
298    ...of(s.disk.localMcp, 'local'),
299    ...of(s.disk.projectMcp, 'project .mcp.json').map(c => ({ ...c, gate: gate(c.name) })),
300    ...Object.entries(s.disk.plugins).flatMap(([id, p]) =>
301      Object.entries(p.mcp ?? {}).map(([name, cfg]) => ({ name, source: `plugin ${id}`, cfg, key: normalizeServer(`plugin:${p.name}:${name}`) })),
302    ),
303  ]
304}
305
306const GATE_DETAIL: Partial<Record<Status, string>> = { off: 'disabled for this project', pending: 'awaiting approval' }
307
308function configuredItem(c: Configured, tools: readonly string[] | undefined, keep: Snapshot['keep']): Item {
309  const status: Status = c.gate ?? (tools === undefined ? 'no-tools' : 'connected')
310  const detail = (c.gate === undefined ? undefined : GATE_DETAIL[c.gate]) ?? (tools === undefined ? 'no tools loaded' : toolsDetail(tools, keep))
311  return { name: c.name, source: c.source, status, detail: `${describeMcp(c.cfg)} · ${detail}`, fromDisk: true }
312}
313
314/** A server with tools that no file configures: a claude.ai connector, or anything else the engine added. */
315function unconfiguredItem(key: string, tools: readonly string[], s: Snapshot): Item {
316  const isClaudeAi = key.startsWith('claude_ai_')
317  const derived = isClaudeAi ? `claude.ai ${key.slice('claude_ai_'.length)}` : key
318  const name = s.captured.mcpProviders[key]?.replace(/^mcp:/, '') ?? derived
319  return { name, source: isClaudeAi ? 'claude.ai' : 'other', status: 'connected', detail: toolsDetail(tools, s.keep) }
320}
321
322function mcp(s: Snapshot): Group {
323  const live = new Map<string, string[]>()
324  for (const t of s.breakdown?.mcpTools ?? []) live.set(t.serverName, [...(live.get(t.serverName) ?? []), t.name])
325  const configured = configuredServers(s)
326  const items = configured.map(c => configuredItem(c, live.get(c.key), s.keep))
327  const claimed = new Set(configured.map(c => c.key))
328  for (const [key, tools] of live) if (!claimed.has(key)) items.push(unconfiguredItem(key, tools, s))
329  const note = 'Configs are read from disk; tools are what the session loaded. "no tools loaded" can mean failed, needs auth, still connecting or disabled: the hooks API does not say which.'
330  return group('mcp', 'mixed', items, { note })
331}
332
333function enabledPluginItem(s: Snapshot, id: string, on: unknown): Item {
334  const source = decidingSource(s.settings, x => isRecord(x.enabledPlugins) && id in x.enabledPlugins) ?? 'user'
335  const installs = s.disk.installed[id] ?? []
336  const version = s.disk.plugins[id]?.version ?? installs[0]?.version
337  const detail = installs.length === 0 ? 'not installed' : version === undefined ? 'installed' : `v${version}`
338  return { name: id, source, status: on === true ? 'on' : 'off', detail }
339}
340
341/** Plugins installed for every repo or for this one that no settings source names. */
342function unnamedInstalls(s: Snapshot, named: Json): Item[] {
343  return Object.entries(s.disk.installed)
344    .filter(([id]) => !(id in named))
345    .map(([id, installs]) => ({ id, here: installs.find(i => i.scope === 'user' || i.projectPath === s.root) }))
346    .filter(x => x.here !== undefined)
347    .map(x => ({ name: x.id, source: x.here?.scope ?? 'user', status: 'off', detail: 'installed, not in enabledPlugins', fromDisk: true }))
348}
349
350function plugins(s: Snapshot): Group {
351  const named = isRecord(s.merged.enabledPlugins) ? s.merged.enabledPlugins : {}
352  const items = [...Object.entries(named).map(([id, on]) => enabledPluginItem(s, id, on)), ...unnamedInstalls(s, named)]
353  return group('plugins', 'mixed', items, { note: 'Enabled state is the merged settings the session runs under; versions are read from disk.' })
354}
355
356function describeSource(source: unknown): string {
357  if (!isRecord(source)) return ''
358  for (const key of ['repo', 'path', 'url', 'package']) if (typeof source[key] === 'string') return `${source.source ?? ''} ${source[key]}`.trim()
359  return typeof source.source === 'string' ? source.source : ''
360}
361
362function marketplaces(s: Snapshot): Group {
363  const items: Item[] = []
364  const declared = new Set<string>()
365  for (const source of SETTINGS_SOURCES) {
366    const extra = s.settings[source]?.extraKnownMarketplaces
367    if (!isRecord(extra)) continue
368    for (const [name, v] of Object.entries(extra)) {
369      declared.add(name)
370      items.push({ name, source: labelOf(source), detail: describeSource(isRecord(v) ? v.source : undefined) })
371    }
372  }
373  for (const [name, v] of Object.entries(s.disk.marketplaces)) {
374    if (declared.has(name)) continue
375    items.push({ name, source: 'installed', detail: describeSource(v.source), fromDisk: true })
376  }
377  return group('marketplaces', 'mixed', items, { note: 'Settings-declared marketplaces are live; the rest come from known_marketplaces.json on disk.' })
378}
379
380function instructions(s: Snapshot): Group {
381  const files = (s.breakdown?.memoryFiles ?? []).filter(f => f.type !== 'AutoMem')
382  const items = files.map<Item>(f => {
383    const parent = s.captured.parents[f.path]
384    return {
385      name: tilde(f.path, s.home),
386      source: ENGINE_SOURCES[f.type] ?? f.type.toLowerCase(),
387      detail: `${f.tokens} tok${parent === undefined ? '' : ` · @-imported by ${tilde(parent, s.home)}`}`,
388    }
389  })
390  // Load order, not names: an import reads right after the file importing it.
391  return group('instructions', 'live', items, { note: 'Files loaded so far; nested CLAUDE.md and path-scoped rules join as files are read. @-imports are marked once a prompt has been composed.', isOrdered: true })
392}
393
394function memory(s: Snapshot): Group {
395  const auto = (s.breakdown?.memoryFiles ?? []).filter(f => f.type === 'AutoMem')
396  const items: Item[] = auto.map(f => ({ name: tilde(f.path, s.home), source: 'auto memory', detail: `${f.tokens} tok, in context`, status: 'on' }))
397  const loaded = new Set(auto.map(f => f.path))
398  if (s.disk.memory !== null) {
399    for (const file of s.disk.memory.files) {
400      const path = `${s.disk.memory.dir}/${file}`
401      if (!loaded.has(path)) items.push({ name: tilde(path, s.home), source: 'auto memory', detail: 'read on demand', fromDisk: true })
402    }
403  }
404  return group('memory', 'mixed', items, { note: auto.length === 0 ? 'No MEMORY.md is in context for this session.' : undefined, isOrdered: true })
405}
406
407function outputStyle(s: Snapshot): Group {
408  const active = typeof s.merged.outputStyle === 'string' ? s.merged.outputStyle : 'default'
409  const source = decidingSource(s.settings, x => typeof x.outputStyle === 'string') ?? 'built-in'
410  const items: Item[] = [{ name: active, source, status: 'on', detail: 'active' }]
411  for (const o of s.disk.outputStyles) if (o.name !== active) items.push({ name: o.name, source: o.source, status: 'off', fromDisk: true })
412  return group('outputStyle', 'mixed', items, { note: 'The active style is the merged settings; the other styles are files on disk.' })
413}
414
415function statusLine(s: Snapshot): Group {
416  const line = s.merged.statusLine
417  if (!isRecord(line)) return group('statusLine', 'live', [], { note: 'None configured.' })
418  const source = decidingSource(s.settings, x => isRecord(x.statusLine)) ?? 'user'
419  const body = typeof line.command === 'string' ? firstLine(line.command, 70) : ''
420  return group('statusLine', 'live', [{ name: typeof line.type === 'string' ? line.type : 'statusLine', source, detail: body, status: 'on' }])
421}
422
423/** One settings source's permission rules, deny before ask before allow, as the engine weighs them. */
424function permissionItems(settings: Json, source: string): Item[] {
425  const p = isRecord(settings.permissions) ? settings.permissions : {}
426  const mode = typeof p.defaultMode === 'string' ? [{ name: `defaultMode ${p.defaultMode}`, source, detail: 'mode' }] : []
427  return [
428    ...(['deny', 'ask', 'allow'] as const).flatMap(verdict => strings(p[verdict]).map(rule => ({ name: rule, source, detail: verdict }))),
429    ...strings(settings.allowedTools).map(rule => ({ name: rule, source, detail: 'allow (legacy allowedTools)' })),
430    ...mode,
431    ...strings(p.additionalDirectories).map(dir => ({ name: dir, source, detail: 'additional directory' })),
432  ]
433}
434
435function permissions(s: Snapshot): Group {
436  const items = SETTINGS_SOURCES.flatMap(from => {
437    const settings = s.settings[from]
438    return settings === undefined ? [] : permissionItems(settings, labelOf(from))
439  })
440  return group('permissions', 'live', items, { isOrdered: true })
441}
442
443function env(s: Snapshot): Group {
444  const items: Item[] = []
445  for (const from of SETTINGS_SOURCES) {
446    const vars = s.settings[from]?.env
447    if (!isRecord(vars)) continue
448    for (const [name, value] of Object.entries(vars)) {
449      items.push({ name, source: labelOf(from), secret: String(value) })
450    }
451  }
452  return group('env', 'live', items, { note: 'From settings files only, not the shell. Values are masked until revealed.' })
453}
454
455export function buildInventory(s: Snapshot, builtAt: number): Inventory {
456  const ids = enabledIds(s)
457  return {
458    root: s.root,
459    builtAt,
460    problems: s.problems,
461    groups: [
462      skills(s, ids),
463      agents(s, ids),
464      commands(s, ids),
465      hooks(s),
466      mods(s),
467      mcp(s),
468      plugins(s),
469      marketplaces(s),
470      instructions(s),
471      memory(s),
472      outputStyle(s),
473      statusLine(s),
474      permissions(s),
475      env(s),
476    ],
477  }
478}
479
480// ------------------------------------------------------------------ the pane's view of it
481
482export type Section = { source: string; items: Item[] }
483export type Shown = { group: Group; total: number; matched: number; isOpen: boolean; sections: Section[] }
484
485export function matches(item: Item, query: string, source: string): boolean {
486  if (source !== 'all' && sourceClass(item.source) !== source) return false
487  if (query === '') return true
488  const q = query.toLowerCase()
489  return [item.name, item.source, item.detail ?? '', item.status ?? ''].some(t => t.toLowerCase().includes(q))
490}
491
492/**
493 * What the pane draws for each group under the view's filter: a group is open when the person opened it, or
494 * when a query is typed and it has matches; its items are cut into sections by source.
495 */
496export function shownGroups(inv: Inventory, view: View): Shown[] {
497  const isFiltering = view.query !== '' || view.source !== 'all'
498  return inv.groups.map(group => {
499    const kept = group.items.filter(i => matches(i, view.query, view.source))
500    const isOpen = view.expanded.includes(group.kind) || (view.query !== '' && kept.length > 0)
501    const sections: Section[] = []
502    for (const item of kept) {
503      const last = sections[sections.length - 1]
504      if (last !== undefined && last.source === item.source) last.items.push(item)
505      else sections.push({ source: item.source, items: [item] })
506    }
507    return { group, total: group.items.length, matched: isFiltering ? kept.length : group.items.length, isOpen, sections }
508  })
509}
510
511/** A plain-text rendering, for a session with no surface to draw a pane on (`claude -p`). */
512export function inventoryText(inv: Inventory, home: string): string {
513  const lines = [`Agent artifacts in ${tilde(inv.root, home)}`]
514  for (const g of inv.groups) {
515    const bySource = new Map<string, number>()
516    for (const i of g.items) bySource.set(i.source, (bySource.get(i.source) ?? 0) + 1)
517    const parts = [...bySource].map(([s, n]) => `${s} ${n}`).join(', ')
518    lines.push(`${g.title} (${g.items.length}, ${g.origin})${parts === '' ? '' : `: ${parts}`}`)
519  }
520  for (const p of inv.problems) lines.push(`problem: ${p}`)
521  return lines.join('\n')
522}
523
524export function mask(value: string): string {
525  return value === '' ? '(empty)' : `${'•'.repeat(Math.min(8, value.length))} (${value.length} chars)`
526}
527
hooks/profile.ts 274 lines
1// Profiles: parsing, the settings a profile owns, the preview of applying one, and the run-time filters.
2// Pure: no `$`. The glob rules and the two listing filters are adapted from harness-scope
3// (github.com/shimo4228/harness-scope, MIT), which filters the same listings on this engine.
4import type { Change, Effect, Profile, Rule, Scope } from '../types'
5
6type Json = Record<string, unknown>
7export type Parsed<T> = ({ ok: true } & T) | { ok: false; reason: string }
8
9export const PROFILE_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/
10export const SCOPES: readonly Scope[] = ['local', 'project', 'user']
11/** The profile keys this mod applies at run time through its hooks; every other key is a settings key. */
12export const RUNTIME_KEYS = ['skills', 'agents', 'tools'] as const
13
14const isRecord = (v: unknown): v is Json => typeof v === 'object' && v !== null && !Array.isArray(v)
15const isStrings = (v: unknown): v is string[] => Array.isArray(v) && v.every(x => typeof x === 'string')
16
17function parseJson(text: string): unknown {
18  try {
19    return JSON.parse(text)
20  } catch {
21    return undefined
22  }
23}
24
25// ------------------------------------------------------------------ parsing
26
27function parseRule(key: string, v: unknown): Parsed<{ rule: Rule }> {
28  if (!isRecord(v)) return { ok: false, reason: `"${key}" must be an object` }
29  const keys = Object.keys(v)
30  const mode = keys[0]
31  if (keys.length !== 1 || (mode !== 'allow' && mode !== 'deny')) return { ok: false, reason: `"${key}" takes exactly one of "allow" or "deny"` }
32  const patterns = v[mode]
33  if (!isStrings(patterns)) return { ok: false, reason: `"${key}.${mode}" must be a list of strings` }
34  return { ok: true, rule: mode === 'allow' ? { allow: patterns } : { deny: patterns } }
35}
36
37function parsePlugins(v: unknown): Parsed<{ plugins: Record<string, boolean> }> {
38  if (!isRecord(v) || !Object.values(v).every(b => typeof b === 'boolean')) {
39    return { ok: false, reason: '"plugins" maps "name@marketplace" to true or false' }
40  }
41  return { ok: true, plugins: v as Record<string, boolean> }
42}
43
44function parseMcpjson(v: unknown): Parsed<{ mcpjson: NonNullable<Profile['mcpjson']> }> {
45  if (!isRecord(v)) return { ok: false, reason: '"mcpjson" must be an object' }
46  const mcpjson: NonNullable<Profile['mcpjson']> = {}
47  for (const [sub, value] of Object.entries(v)) {
48    if (sub === 'enable' || sub === 'disable') {
49      if (!isStrings(value)) return { ok: false, reason: `"mcpjson.${sub}" must be a list of server names` }
50      mcpjson[sub] = value
51    } else if (sub === 'enableAll' && typeof value === 'boolean') {
52      mcpjson.enableAll = value
53    } else {
54      return { ok: false, reason: `"mcpjson.${sub}": use enable and disable (lists) or enableAll (true or false)` }
55    }
56  }
57  return { ok: true, mcpjson }
58}
59
60/** One reader per profile key: it sets the key on the profile, or says what is wrong with the value. */
61const KEY_PARSERS: Record<string, (profile: Profile, value: unknown) => string | null> = {
62  $schema: () => null,
63  description: (profile, value) => {
64    if (typeof value !== 'string') return '"description" must be a string'
65    profile.description = value
66    return null
67  },
68  plugins: (profile, value) => {
69    const r = parsePlugins(value)
70    if (r.ok) profile.plugins = r.plugins
71    return r.ok ? null : r.reason
72  },
73  mcpjson: (profile, value) => {
74    const r = parseMcpjson(value)
75    if (r.ok) profile.mcpjson = r.mcpjson
76    return r.ok ? null : r.reason
77  },
78  ...Object.fromEntries(
79    RUNTIME_KEYS.map(key => [
80      key,
81      (profile: Profile, value: unknown) => {
82        const r = parseRule(key, value)
83        if (r.ok) profile[key] = r.rule
84        return r.ok ? null : r.reason
85      },
86    ]),
87  ),
88}
89
90export function parseProfile(text: string): Parsed<{ profile: Profile }> {
91  const v = parseJson(text)
92  if (!isRecord(v)) return { ok: false, reason: 'not a JSON object' }
93  const profile: Profile = {}
94  for (const [key, value] of Object.entries(v)) {
95    const parse = Object.hasOwn(KEY_PARSERS, key) ? KEY_PARSERS[key] : undefined
96    const reason = parse === undefined ? `unknown key "${key}" (use description, plugins, mcpjson, skills, agents, tools)` : parse(profile, value)
97    if (reason !== null) return { ok: false, reason }
98  }
99  return { ok: true, profile }
100}
101
102// ------------------------------------------------------------------ rules
103
104function globToRegExp(glob: string): RegExp {
105  const body = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.')
106  return new RegExp(`^${body}$`)
107}
108
109/** Whether a name is kept under the rule; no rule keeps everything. */
110export function compileRule(rule: Rule | undefined): (name: string) => boolean {
111  if (rule === undefined) return () => true
112  const isAllow = 'allow' in rule
113  const regs = (isAllow ? rule.allow : rule.deny).map(globToRegExp)
114  const hit = (name: string) => regs.some(r => r.test(name))
115  return isAllow ? hit : name => !hit(name)
116}
117
118function ruleText(rule: Rule): string {
119  return 'allow' in rule ? `allow ${rule.allow.join(', ') || '(none)'}` : `deny ${rule.deny.join(', ')}`
120}
121
122export type Filtered = { text: string; removed: string[] }
123
124const SKILL_HEADER = 'The following skills are available for use with the Skill tool:'
125
126/** An item runs from a line starting "- " to the next; its name ends at the first ": ". */
127function itemName(item: string): string {
128  const first = item.slice(2).split('\n', 1)[0] ?? ''
129  const cut = first.indexOf(': ')
130  return cut === -1 ? first : first.slice(0, cut)
131}
132
133/**
134 * The skill listing with the skills `keep` rejects removed, kept items byte for byte; null when the text is
135 * not the listing format this build writes, so the caller passes it through rather than guess.
136 */
137export function filterSkillListing(text: string, keep: (name: string) => boolean): Filtered | null {
138  if (!text.startsWith(SKILL_HEADER)) return null
139  const parts = text.split(/\n(?=- )/)
140  const items: string[] = []
141  for (const part of parts.slice(1)) {
142    // Skill names hold no whitespace: a "- " line whose name would is a bullet inside the previous description.
143    const last = items.length - 1
144    if (/\s/.test(itemName(part)) && last >= 0) items[last] = `${items[last]}\n${part}`
145    else items.push(part)
146  }
147  if (items.length === 0) return null
148  const removed: string[] = []
149  const kept = items.filter(item => {
150    const name = itemName(item)
151    if (keep(name)) return true
152    removed.push(name)
153    return false
154  })
155  return { text: [parts[0] ?? '', ...kept].join('\n'), removed }
156}
157
158/** The deferred-tools listing (one name per line) with the tools `keep` rejects removed. */
159export function filterDeferredTools(text: string, keep: (name: string) => boolean): Filtered {
160  const removed: string[] = []
161  const lines = text.split('\n').filter(line => {
162    const isName = line.length > 0 && !/\s/.test(line)
163    if (!isName || keep(line)) return true
164    removed.push(line)
165    return false
166  })
167  return { text: lines.join('\n'), removed }
168}
169
170// ------------------------------------------------------------------ the preview
171
172const EFFECT = {
173  enabledPlugins: '/reload-plugins',
174  enabledMcpjsonServers: 'restart',
175  disabledMcpjsonServers: 'restart',
176  enableAllProjectMcpServers: 'restart',
177  skills: 'after /clear',
178  agents: 'next turn',
179  tools: 'next turn',
180} as const satisfies Record<string, Effect>
181
182const show = (v: unknown): string | undefined => (v === undefined ? undefined : JSON.stringify(v))
183
184function listChanges(key: 'enabledMcpjsonServers' | 'disabledMcpjsonServers', before: unknown, after: string[]): Change[] {
185  const was = new Set(isStrings(before) ? before : [])
186  const now = new Set(after)
187  const changes: Change[] = []
188  for (const name of now) if (!was.has(name)) changes.push({ key, entry: name, to: 'listed', effect: EFFECT[key] })
189  for (const name of was) if (!now.has(name)) changes.push({ key, entry: name, from: 'listed', effect: EFFECT[key] })
190  return changes
191}
192
193/** Makes `enabledPlugins` exactly the profile's map (none: no key) and says which entries change. */
194function setPlugins(next: Json, current: unknown, plugins: Record<string, boolean>): Change[] {
195  const before = isRecord(current) ? current : {}
196  const changes: Change[] = []
197  for (const id of new Set([...Object.keys(before), ...Object.keys(plugins)])) {
198    const from = before[id]
199    const to = plugins[id]
200    if (from !== to) changes.push({ key: 'enabledPlugins', entry: id, from: show(from), to: show(to), effect: EFFECT.enabledPlugins })
201  }
202  if (Object.keys(plugins).length === 0) delete next.enabledPlugins
203  else next.enabledPlugins = { ...plugins }
204  return changes
205}
206
207/** Makes the `.mcp.json` approval keys the profile names exactly what it says, and says what changes. */
208function setMcpjson(next: Json, current: Json, m: NonNullable<Profile['mcpjson']>): Change[] {
209  const changes: Change[] = []
210  const lists = [
211    ['enable', 'enabledMcpjsonServers'],
212    ['disable', 'disabledMcpjsonServers'],
213  ] as const
214  for (const [sub, key] of lists) {
215    const list = m[sub]
216    if (list === undefined) continue
217    changes.push(...listChanges(key, current[key], list))
218    if (list.length === 0) delete next[key]
219    else next[key] = [...list]
220  }
221  if (m.enableAll !== undefined && current.enableAllProjectMcpServers !== m.enableAll) {
222    changes.push({ key: 'enableAllProjectMcpServers', from: show(current.enableAllProjectMcpServers), to: show(m.enableAll), effect: EFFECT.enableAllProjectMcpServers })
223    next.enableAllProjectMcpServers = m.enableAll
224  }
225  return changes
226}
227
228function parseSettings(text: string | null): Json | null {
229  if (text === null || text.trim() === '') return {}
230  const parsed = parseJson(text)
231  return isRecord(parsed) ? parsed : null
232}
233
234/**
235 * The target settings file after the profile is applied: every key the profile names is set to exactly what
236 * it says (an empty map or list removes the key), every other key is kept as it was.
237 */
238export function planSettings(profile: Profile, currentText: string | null): Parsed<{ after: string; changes: Change[] }> {
239  const current = parseSettings(currentText)
240  if (current === null) return { ok: false, reason: 'the target settings file is not a JSON object; fix it by hand first' }
241  const next: Json = { ...current }
242  const changes = [
243    ...(profile.plugins === undefined ? [] : setPlugins(next, current.enabledPlugins, profile.plugins)),
244    ...(profile.mcpjson === undefined ? [] : setMcpjson(next, current, profile.mcpjson)),
245  ]
246  return { ok: true, after: `${JSON.stringify(next, null, 2)}\n`, changes }
247}
248
249export type Names = { skills: string[]; agents: string[]; tools: string[] }
250
251/** One run-time rule's line of the preview; none when neither profile has a rule for the key. */
252function ruleChange(key: (typeof RUNTIME_KEYS)[number], rule: Rule | undefined, old: Rule | undefined, names: string[]): Change | null {
253  const from = old === undefined ? undefined : ruleText(old)
254  if (rule === undefined) return from === undefined ? null : { key, from, to: 'no rule: all shown', effect: EFFECT[key] }
255  const keep = compileRule(rule)
256  const hidden = names.filter(n => !keep(n)).length
257  return { key, from, to: `${ruleText(rule)} (hides ${hidden} of ${names.length})`, effect: EFFECT[key] }
258}
259
260/** The run-time rules' effect on what the session lists now, and the rules the previous profile drops. */
261export function runtimeChanges(profile: Profile, previous: Profile | null, names: Names): Change[] {
262  return RUNTIME_KEYS.map(key => ruleChange(key, profile[key], previous?.[key], names[key])).filter((c): c is Change => c !== null)
263}
264
265export function changeText(c: Change): string {
266  const what = c.entry === undefined ? c.key : `${c.key} ${c.entry}`
267  return `${what}: ${c.from ?? '(unset)'} → ${c.to ?? '(unset)'}`
268}
269
270export function settingsPath(scope: Scope, root: string, configDir: string): string {
271  if (scope === 'user') return `${configDir}/settings.json`
272  return `${root}/.claude/${scope === 'local' ? 'settings.local.json' : 'settings.json'}`
273}
274
types/index.d.ts 147 lines
1// agent-artifacts: the mod's type contract. Every value it keeps in $.state is declared here, and the
2// domain types the hooks module and its pure helpers share are exported from here, so they are written once.
3
4/** One kind of artifact: a group of the inventory pane, in the order the pane draws them. */
5export type KindId =
6  | 'skills'
7  | 'agents'
8  | 'commands'
9  | 'hooks'
10  | 'mods'
11  | 'mcp'
12  | 'plugins'
13  | 'marketplaces'
14  | 'instructions'
15  | 'memory'
16  | 'outputStyle'
17  | 'statusLine'
18  | 'permissions'
19  | 'env'
20
21/**
22 * Where a kind's facts came from. `live`: what this session loaded, read through the hooks API.
23 * `mixed`: partly files re-read now, which may differ from what the session loaded; each such item
24 * carries `fromDisk`. No kind is disk-only: each has a live part.
25 */
26export type Origin = 'live' | 'mixed'
27
28/** A state an item is in, when it has one. `scoped-off`: hidden from the model by the active profile. */
29export type Status = 'on' | 'off' | 'connected' | 'no-tools' | 'pending' | 'scoped-off'
30
31export type Item = {
32  name: string
33  /** Where it is defined: `user`, `project`, `local`, `managed`, `flag`, `plugin <id>`, `built-in`, ... */
34  source: string
35  detail?: string
36  status?: Status
37  /** A value drawn masked until the person reveals values (env vars). */
38  secret?: string
39  /** Set on an item of a `mixed` kind that was read from disk. */
40  fromDisk?: boolean
41}
42
43export type Group = {
44  kind: KindId
45  title: string
46  origin: Origin
47  /** One line on what the kind's facts can and cannot say. */
48  note?: string
49  items: Item[]
50}
51
52export type Inventory = {
53  root: string
54  builtAt: number
55  groups: Group[]
56  /** Files or calls that failed while gathering, so a missing kind is explained rather than silent. */
57  problems: string[]
58}
59
60/** A glob rule over names: keep only the matches (`allow`) or drop them (`deny`). */
61export type Rule = { allow: string[] } | { deny: string[] }
62
63/**
64 * A named profile. Each key it names it owns; a key it leaves out it leaves alone.
65 * `plugins` becomes the target settings file's `enabledPlugins` exactly; `mcpjson` its
66 * `enabledMcpjsonServers`, `disabledMcpjsonServers` and `enableAllProjectMcpServers` (each sub-key
67 * owned only when named). `skills`, `agents` and `tools` are filtered by this mod's hooks at run time.
68 */
69export type Profile = {
70  description?: string
71  plugins?: Record<string, boolean>
72  mcpjson?: { enable?: string[]; disable?: string[]; enableAll?: boolean }
73  skills?: Rule
74  agents?: Rule
75  tools?: Rule
76}
77
78/** Which settings file a profile is applied to. `local` is the default and is never committed. */
79export type Scope = 'local' | 'project' | 'user'
80
81/** When a change takes effect, as the preview labels it. */
82export type Effect = 'next turn' | 'after /clear' | '/reload-plugins' | 'restart'
83
84/** One line of a profile's preview: a settings key's entry changing, or a run-time rule changing. */
85export type Change = {
86  /** `enabledPlugins`, `disabledMcpjsonServers`, ... or `skills` / `agents` / `tools` for run-time rules. */
87  key: string
88  entry?: string
89  from?: string
90  to?: string
91  effect: Effect
92}
93
94/** The profile selected for a repo (or for every repo, `*`), kept in $.store across sessions. */
95export type Selection = { profile: string; scope: Scope; appliedAt: number }
96
97/** A computed, not yet written, application of a profile: what the preview shows and Apply writes. */
98export type Plan = {
99  profile: string
100  scope: Scope
101  file: string
102  /** The target file's text when the plan was made; Apply refuses if the file changed since. */
103  before: string | null
104  after: string
105  /** The settings entries that change, then the run-time rules' effect on what this session lists now. */
106  changes: Change[]
107}
108
109export type ProfileView = {
110  names: string[]
111  /** Profiles that did not parse, with the reason. */
112  broken: { name: string; reason: string }[]
113  selected: string | null
114  scope: Scope
115  plan: Plan | null
116  message: string | null
117  active: { repo: Selection | null; global: Selection | null }
118}
119
120export type View = {
121  expanded: KindId[]
122  query: string
123  source: string
124  reveal: boolean
125}
126
127/** Facts only events carry, captured as they fire so the inventory can use them later. */
128export type Captured = {
129  /** Instruction file path -> the file whose `@` import brought it in (prompt.context). */
130  parents: Record<string, string>
131  /** Normalized MCP server name -> how the engine names its provider (`claude.ai Notion`, `sigrid@...`). */
132  mcpProviders: Record<string, string>
133  /** Function-hook mods admitted after this one (plugin.register). */
134  mods: { name: string; provenance: string; events: string[] }[]
135}
136
137declare module 'claude-code' {
138  interface PluginState {
139    'agent-artifacts': {
140      inventory: Inventory | null
141      view: View
142      profiles: ProfileView
143      captured: Captured
144    }
145  }
146}
147