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

Declarative macOS setup: nix-darwin + home-manager + Homebrew + Mackup.
Before starting: run
mackup backupon the old machine to push latest settings to iCloud.
# 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)"
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.
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.
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.
| Item | Action |
|---|---|
| SSH keys | Copy from old machine or generate new — no keys tracked in repo |
| Git credentials | gh auth login |
| Azure | az login |
| GCP | gcloud auth login |
| Tailscale | Follow system-setup next; enroll policy GitOps once per tailnet as described in docs/tailscale.md |
| Raycast | open ~/.dotfiles/configs/raycast.rayconfig → click Import |
| Vorssaint | Import ~/.dotfiles/configs/vorssaint-settings.plist in Vorssaint (Export → overwrite that file later) |
| Editable/local uv tools | Install from their owning repos if needed; repo-tracked uv tools are installed during rebuild |
| Archon CLI | Managed by Homebrew; verify with archon workflow list |
| Entire CLI | Managed by Homebrew; opt in per repo with entire enable --agent codex (consider --skip-push-sessions for public repos) |
| Cursor CLI | Managed by Nix; run agent login, then use agent (or cursor-agent) |
| Oh My Pi | Managed as a Bun global; its command is omp (open a new shell after the first rebuild) |
| CLIProxyAPI OAuth | Follow system-setup next; credentials are intentionally not tracked |
| Cognee | Follow system-setup next for service approval and agent enrollment; see docs/cognee.md |
| Claudex | After Codex OAuth, run claudex for Claude Code backed by GPT-5.6 Sol; normal claude remains unchanged |
| App sign-ins | Claude, 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)" → cua-driver permissions grant |
| Tinycast | Preferences and MCP server bindings (cua-driver mcp --experimental-pip) synced via Mackup |
| /stuff workspace | Attach /Volumes/Data external drive, or update modules/darwin/system/external-workspace.nix and modules/stacks/ai-agents/hermes.nix |
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.
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.
cd ~/.dotfiles
git remote set-url origin git@github.com:popemkt/mac-backup.git
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: overwriteconfigs/vorssaint-settings.plist.
The table of where each kind of change goes lives in AGENTS.md → Where To Edit, and the rules behind it in Module Boundaries.
| Layer | Manages | Source of truth |
|---|---|---|
| nix-darwin | macOS system settings | modules/darwin/system/default.nix |
| Homebrew module | taps, brews, casks, MAS apps | modules/darwin/system/homebrew.nix |
| home-manager | CLI tools, shell, git, neovim, starship | modules/common/home-manager/ + modules/darwin/home-manager/ |
| Mackup → iCloud | GUI app configs (Karabiner, Zed, VS Code, Warp…) | ~/Library/Mobile Documents/com~apple~CloudDocs/Mackup/ |
| npm-global.nix | npm global CLIs | modules/common/home-manager/npm-global.nix |
| bun-global.nix | Bun global CLIs, including Oh My Pi | modules/darwin/home-manager/bun-global.nix |
| agent-plugins.nix | tracked Claude Code and Codex plugins and their marketplaces | modules/darwin/home-manager/agent-plugins.nix |
nvfetcher + pkgs/ | pinned direct release packages | nvfetcher.toml + _sources/ |
| Tailscale | app, MagicDNS domain, private services, and access policy | modules/stacks/vpn/, host declarations, and configs/tailscale/policy.hujson |
| Cognee | pinned service version, launchd jobs, routing, and non-secret configuration | modules/stacks/ai-agents/cognee/server.nix |
| system-setup | declared external requirements, dependency ordering, enrollment guidance, readiness checks | modules/darwin/system/system-setup/default.nix |
configs/ | Raycast / Vorssaint exports | manual import on new machine |
| Manual | SSH keys, credentials, Hermes plist, editable uv tools | — |
hosts/<hostname>/default.nix. Import the shared Darwin system module and declare only host-specific differences there.<hostname> = mkDarwin "<hostname>"; under darwinConfigurations in flake.nix.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.
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).
npm-global.nix: @tobilu/qmd, ccmanager, kanban, sudocode, yarnbrowser-harness~/.local/bin scripts (hermes, iii, plannotator) — depend on /stuff workspace~/.gitconfig has extra entries (nbstripout, agor safe.directory) not in git.nix/Volumes/Data/... — update after restore if neededhooks/register.tsx 769 lines1// 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/<name>.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}
769hooks/inventory.ts 527 lines1// 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}
527hooks/profile.ts 274 lines1// 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}
274types/index.d.ts 147 lines1// 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