diff --git a/.github/workflows/catalog-drift.yml b/.github/workflows/catalog-drift.yml index 22fabb8..6479bc1 100644 --- a/.github/workflows/catalog-drift.yml +++ b/.github/workflows/catalog-drift.yml @@ -49,6 +49,7 @@ jobs: npm run sync:settings npm run sync:env-vars npm run sync:hooks + npm run sync:sub-agents # `fetchedAt` is rewritten on every sync run — strip it before # comparing so we don't open noise PRs that only bump a timestamp. @@ -62,7 +63,7 @@ jobs: set -euo pipefail real_drift=false drifted_files=() - for f in settings env-vars hooks; do + for f in settings env-vars hooks sub-agents; do head_norm=$(git show HEAD:catalog/$f.json | jq 'del(.fetchedAt)') new_norm=$(jq 'del(.fetchedAt)' catalog/$f.json) if [ "$head_norm" != "$new_norm" ]; then @@ -107,6 +108,7 @@ jobs: - `npm run sync:settings` - `npm run sync:env-vars` - `npm run sync:hooks` + - `npm run sync:sub-agents` Drifted catalogs: ``` diff --git a/catalog/sub-agents.json b/catalog/sub-agents.json new file mode 100644 index 0000000..8ed1644 --- /dev/null +++ b/catalog/sub-agents.json @@ -0,0 +1,87 @@ +{ + "source": "https://code.claude.com/docs/en/sub-agents.md", + "fetchedAt": "2026-05-07T03:21:49.842Z", + "count": 16, + "fields": [ + { + "name": "background", + "required": false, + "description": "Set to `true` to always run this subagent as a [background task](#run-subagents-in-foreground-or-background). Default: `false`" + }, + { + "name": "color", + "required": false, + "description": "Display color for the subagent in the task list and transcript. Accepts `red`, `blue`, `green`, `yellow`, `purple`, `orange`, `pink`, or `cyan`" + }, + { + "name": "description", + "required": true, + "description": "When Claude should delegate to this subagent" + }, + { + "name": "disallowedTools", + "required": false, + "description": "Tools to deny, removed from inherited or specified list" + }, + { + "name": "effort", + "required": false, + "description": "Effort level when this subagent is active. Overrides the session effort level. Default: inherits from session. Options: `low`, `medium`, `high`, `xhigh`, `max`; available levels depend on the model" + }, + { + "name": "hooks", + "required": false, + "description": "[Lifecycle hooks](#define-hooks-for-subagents) scoped to this subagent. Ignored for [plugin subagents](#choose-the-subagent-scope)" + }, + { + "name": "initialPrompt", + "required": false, + "description": "Auto-submitted as the first user turn when this agent runs as the main session agent (via `--agent` or the `agent` setting). [Commands](/en/commands) and [skills](/en/skills) are processed. Prepended to any user-provided prompt" + }, + { + "name": "isolation", + "required": false, + "description": "Set to `worktree` to run the subagent in a temporary [git worktree](/en/worktrees), giving it an isolated copy of the repository. The worktree is automatically cleaned up if the subagent makes no changes" + }, + { + "name": "maxTurns", + "required": false, + "description": "Maximum number of agentic turns before the subagent stops" + }, + { + "name": "mcpServers", + "required": false, + "description": "[MCP servers](/en/mcp) available to this subagent. Each entry is either a server name referencing an already-configured server (e.g., `\"slack\"`) or an inline definition with the server name as key and a full [MCP server config](/en/mcp#installing-mcp-servers) as value. Ignored for [plugin subagents](#choose-the-subagent-scope)" + }, + { + "name": "memory", + "required": false, + "description": "[Persistent memory scope](#enable-persistent-memory): `user`, `project`, or `local`. Enables cross-session learning" + }, + { + "name": "model", + "required": false, + "description": "[Model](#choose-a-model) to use: `sonnet`, `opus`, `haiku`, a full model ID (for example, `claude-opus-4-7`), or `inherit`. Defaults to `inherit`" + }, + { + "name": "name", + "required": true, + "description": "Unique identifier using lowercase letters and hyphens" + }, + { + "name": "permissionMode", + "required": false, + "description": "[Permission mode](#permission-modes): `default`, `acceptEdits`, `auto`, `dontAsk`, `bypassPermissions`, or `plan`. Ignored for [plugin subagents](#choose-the-subagent-scope)" + }, + { + "name": "skills", + "required": false, + "description": "[Skills](/en/skills) to load into the subagent's context at startup. The full skill content is injected, not just made available for invocation. Subagents don't inherit skills from the parent conversation" + }, + { + "name": "tools", + "required": false, + "description": "[Tools](#available-tools) the subagent can use. Inherits all tools if omitted" + } + ] +} diff --git a/package.json b/package.json index de56f19..b4d20a2 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "sync:settings": "node scripts/sync-settings.js", "sync:env-vars": "node scripts/sync-env-vars.js", "sync:hooks": "node scripts/sync-hooks.js", + "sync:sub-agents": "node scripts/sync-sub-agents.js", "test": "node --test scripts/*.test.js", "test:coverage": "node --experimental-test-coverage --test-coverage-exclude='scripts/*.test.js' --test scripts/*.test.js", "test:unit": "vitest run", diff --git a/scripts/sync-sub-agents.js b/scripts/sync-sub-agents.js new file mode 100644 index 0000000..51cae56 --- /dev/null +++ b/scripts/sync-sub-agents.js @@ -0,0 +1,127 @@ +#!/usr/bin/env node +// Fetches the Claude Code sub-agents docs page (markdown) and writes a +// reshaped catalog of supported frontmatter fields to +// catalog/sub-agents.json. Companion to sync-settings.js, sync-env-vars.js, +// and sync-hooks.js. +// +// What we parse: the `#### Supported frontmatter fields` table near the +// middle of the page, `| Field | Required | Description |`. That table +// enumerates the keys a `~/.claude/agents/.md` (or project-scoped) +// definition file's YAML frontmatter accepts — `name`, `description`, +// `tools`, `disallowedTools`, `model`, `permissionMode`, `maxTurns`, +// `skills`, `mcpServers`, `hooks`, `memory`, `background`, `effort`, +// `isolation`, `color`, `initialPrompt`. The page also documents +// built-in subagents, model-resolution rules, hook semantics, and so on, +// but those live under prose-heavy sections; the frontmatter table is +// the smallest useful artifact for the catalog and parallels the shape +// of catalog/env-vars.json and catalog/hooks.json. +// +// Idempotence: re-running on unchanged upstream produces a one-line diff +// (fetchedAt only). Records are sorted by name. + +import { writeFile, mkdir } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const SOURCE = 'https://code.claude.com/docs/en/sub-agents.md'; + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); +const OUT = resolve(SCRIPT_DIR, '..', 'catalog', 'sub-agents.json'); + +// Strip a single layer of wrapping backticks (`NAME` -> NAME). Leaves +// strings without wrapping backticks untouched. +function stripBackticks(s) { + const m = /^`(.+)`$/.exec(s); + return m ? m[1] : s; +} + +// Parse a single GFM table row into {name, required, description}. +// Returns null for lines that don't look like a 3+ cell row. +// +// `required` is normalized to a boolean: "Yes" -> true, anything else +// (typically "No") -> false. Upstream uses exactly those two values. +export function parseRow(line) { + const parts = line.split('|'); + // "| name | required | desc |" splits into + // ["", " name ", " required ", " desc ", ""]. + if (parts.length < 5) return null; + const name = stripBackticks(parts[1].trim()); + const requiredCell = parts[2].trim(); + // Defensive join in case a stray pipe appears in the description. + const description = parts.slice(3, parts.length - 1).join('|').trim(); + if (!name || !description) return null; + return { + name, + required: /^yes$/i.test(requiredCell), + description, + }; +} + +// Find the supported-frontmatter-fields table (header +// `| Field | Required | Description |`) and return its data rows. +// Returns [] if no such table exists in `markdown`. The page has other +// tables (e.g. the "Other" built-in subagents tab uses +// `| Agent | Model | When Claude uses it |`) — the header signature +// disambiguates. +export function parseTable(markdown) { + const lines = markdown.split('\n'); + const rows = []; + let inTable = false; + for (const line of lines) { + if (!inTable) { + if (/^\|\s*Field\s*\|\s*Required\s*\|\s*Description\s*\|/i.test(line)) { + inTable = true; + } + continue; + } + // Skip the alignment row (`| :--- | :--- | :--- |`) right after the header. + if (/^\|\s*:?-+:?\s*\|/.test(line)) continue; + // First non-pipe line ends the table. + if (!line.startsWith('|')) break; + const row = parseRow(line); + if (row) rows.push(row); + } + return rows; +} + +// Compose the full record list from raw markdown. Pure function: easy +// to drive from tests with a small fixture string. +export function buildRecords(markdown) { + const rows = parseTable(markdown); + rows.sort((a, b) => a.name.localeCompare(b.name)); + return rows; +} + +async function main() { + const res = await fetch(SOURCE); + if (!res.ok) { + throw new Error(`Fetch failed: ${SOURCE} → HTTP ${res.status} ${res.statusText}`); + } + const markdown = await res.text(); + + const fields = buildRecords(markdown); + if (fields.length === 0) { + throw new Error('Unexpected page shape: no `Field | Required | Description` table found'); + } + + const envelope = { + source: SOURCE, + fetchedAt: new Date().toISOString(), + count: fields.length, + fields, + }; + + await mkdir(dirname(OUT), { recursive: true }); + await writeFile(OUT, JSON.stringify(envelope, null, 2) + '\n', 'utf8'); + console.log(`Wrote ${fields.length} subagent frontmatter fields → ${OUT}`); +} + +const isMainModule = + process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; + +if (isMainModule) { + main().catch((err) => { + console.error(err.message); + process.exit(1); + }); +} diff --git a/scripts/sync-sub-agents.test.js b/scripts/sync-sub-agents.test.js new file mode 100644 index 0000000..b88d3ab --- /dev/null +++ b/scripts/sync-sub-agents.test.js @@ -0,0 +1,151 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseRow, parseTable, buildRecords } from './sync-sub-agents.js'; + +test('parseRow extracts name, required, description from a typical row', () => { + const row = parseRow('| `name` | Yes | Unique identifier |'); + assert.deepEqual(row, { + name: 'name', + required: true, + description: 'Unique identifier', + }); +}); + +test('parseRow strips wrapping backticks from the name', () => { + const row = parseRow('| `permissionMode` | No | Permission mode |'); + assert.equal(row.name, 'permissionMode'); +}); + +test('parseRow leaves a name without backticks alone', () => { + const row = parseRow('| bare | No | desc |'); + assert.equal(row.name, 'bare'); +}); + +test('parseRow normalizes "Yes" (any case) to required=true', () => { + assert.equal(parseRow('| `name` | Yes | d |').required, true); + assert.equal(parseRow('| `name` | YES | d |').required, true); + assert.equal(parseRow('| `name` | yes | d |').required, true); +}); + +test('parseRow normalizes anything-but-Yes to required=false', () => { + assert.equal(parseRow('| `tools` | No | d |').required, false); + assert.equal(parseRow('| `tools` | | d |').required, false); + assert.equal(parseRow('| `tools` | Optional | d |').required, false); +}); + +test('parseRow returns null for a line without enough cells', () => { + assert.equal(parseRow('not a row'), null); + assert.equal(parseRow('| only-one |'), null); + assert.equal(parseRow('| a | b |'), null); +}); + +test('parseRow returns null when name or description is empty', () => { + assert.equal(parseRow('| | Yes | d |'), null); + assert.equal(parseRow('| `name` | Yes | |'), null); +}); + +test('parseRow preserves backticks and links inside the description cell', () => { + const row = parseRow('| `model` | No | [Model](#choose-a-model) — `sonnet`, `opus`, … |'); + assert.equal(row.description, '[Model](#choose-a-model) — `sonnet`, `opus`, …'); +}); + +test('parseRow handles a stray pipe inside the description by joining defensively', () => { + // A description containing a pipe gets re-joined; the cell should + // round-trip through parse with the pipe intact. + const row = parseRow('| `name` | No | left | right |'); + assert.equal(row.description, 'left | right'); +}); + +test('parseTable returns rows from a minimal table', () => { + const md = [ + '| Field | Required | Description |', + '| :--- | :--- | :--- |', + '| `name` | Yes | first |', + '| `tools` | No | second |', + ].join('\n'); + const rows = parseTable(md); + assert.deepEqual( + rows.map((r) => r.name), + ['name', 'tools'], + ); +}); + +test('parseTable skips the alignment row right after the header', () => { + const md = [ + '| Field | Required | Description |', + '| :------- | :------ | :------ |', + '| `name` | Yes | x |', + ].join('\n'); + const rows = parseTable(md); + assert.equal(rows.length, 1); + assert.equal(rows[0].name, 'name'); +}); + +test('parseTable stops at the first line that is not a table row', () => { + const md = [ + '| Field | Required | Description |', + '| :--- | :--- | :--- |', + '| `name` | Yes | x |', + '', + 'Some prose after the table.', + '| `ignored` | No | not part of the table |', + ].join('\n'); + const rows = parseTable(md); + assert.deepEqual( + rows.map((r) => r.name), + ['name'], + ); +}); + +test('parseTable returns [] when the frontmatter header is absent', () => { + // The page's "Other" built-in subagents tab uses `| Agent | Model | When ... |`, + // which must not be confused with the frontmatter-fields table. + const md = [ + '| Agent | Model | When Claude uses it |', + '| :--- | :--- | :--- |', + '| statusline-setup | Sonnet | x |', + ].join('\n'); + assert.deepEqual(parseTable(md), []); +}); + +test('parseTable ignores prose before the table', () => { + const md = [ + '# Subagents', + '', + 'Some intro text with a `code` span.', + '', + '| Field | Required | Description |', + '| :--- | :--- | :--- |', + '| `name` | Yes | x |', + ].join('\n'); + const rows = parseTable(md); + assert.deepEqual(rows.map((r) => r.name), ['name']); +}); + +test('buildRecords sorts by name', () => { + const md = [ + '| Field | Required | Description |', + '| :--- | :--- | :--- |', + '| `tools` | No | last alphabetically |', + '| `description` | Yes | when to delegate |', + '| `name` | Yes | identifier |', + ].join('\n'); + const records = buildRecords(md); + assert.deepEqual( + records.map((r) => r.name), + ['description', 'name', 'tools'], + ); +}); + +test('buildRecords preserves description verbatim for downstream re-parsing', () => { + const md = [ + '| Field | Required | Description |', + '| :--- | :--- | :--- |', + '| `model` | No | [Model](#choose-a-model) to use: `sonnet`, `opus`, `haiku` |', + ].join('\n'); + const [r] = buildRecords(md); + assert.equal( + r.description, + '[Model](#choose-a-model) to use: `sonnet`, `opus`, `haiku`', + ); +}); diff --git a/spec/catalog-sync.md b/spec/catalog-sync.md index 4dda443..1cf2204 100644 --- a/spec/catalog-sync.md +++ b/spec/catalog-sync.md @@ -1,6 +1,6 @@ # Catalog sync — spec -Status: **partial implementation.** `sync-settings.js` shipped 2026-04-28; `sync-env-vars.js` shipped 2026-04-29; `sync-hooks.js` shipped 2026-04-29 (lifecycle table only — handler types and per-event input/output schemas are not yet captured). +Status: **partial implementation.** `sync-settings.js` shipped 2026-04-28; `sync-env-vars.js` shipped 2026-04-29; `sync-hooks.js` shipped 2026-04-29 (lifecycle table only — handler types and per-event input/output schemas are not yet captured); `sync-sub-agents.js` shipped 2026-05-07 (supported-frontmatter-fields table only). > Open work — new scripts, the hooks pass #2, the `read_catalog` wire-up, and the open questions at the bottom of this doc — is tracked in [`roadmap.md`](./roadmap.md). @@ -29,12 +29,15 @@ scripts/ ├── sync-env-vars.js # implemented ├── sync-env-vars.test.js ├── sync-hooks.js # implemented -└── sync-hooks.test.js +├── sync-hooks.test.js +├── sync-sub-agents.js # implemented +└── sync-sub-agents.test.js catalog/ ├── settings.json # written by sync-settings.js ├── env-vars.json # written by sync-env-vars.js -└── hooks.json # written by sync-hooks.js +├── hooks.json # written by sync-hooks.js +└── sub-agents.json # written by sync-sub-agents.js ``` ## Sources @@ -126,13 +129,45 @@ Pragmatic acceptance criteria: every row in the upstream lifecycle table appears **Test plan:** mirror `sync-env-vars.test.js`. Pure functions (`parseRow`, `parseTable`, `buildRecords`) get unit tests with small fixture strings; `main()` stays uncovered. +### Sub-agents — implemented (frontmatter fields only) + +| | | +| --- | --- | +| Source | `https://code.claude.com/docs/en/sub-agents.md` | +| Output | `catalog/sub-agents.json` (~16 entries) | +| Script | `scripts/sync-sub-agents.js` | +| Run | `npm run sync:sub-agents` | + +The page documents three things: built-in subagents (Explore / Plan / general-purpose / etc.), the YAML frontmatter that defines a custom subagent, and the operational rules around tool restrictions, hooks, and model selection. Of those, only the frontmatter table (`#### Supported frontmatter fields` — `| Field | Required | Description |`) is a single canonical artifact; the built-in agents are in tabbed prose and the operational rules are scattered across `###` sections. The first cut captures the frontmatter table — every key a `~/.claude/agents/.md` file's YAML can carry, with whether it's required and the prose description. + +**Approach:** + +1. `fetch()` the `.md` URL. +2. Locate the table by header signature `| Field | Required | Description |` (case-insensitive). The page has another 3-column table on the "Other" built-in subagents tab (`| Agent | Model | When Claude uses it |`); the header signature disambiguates. +3. For each row, extract `name` (backtick-stripped), `required` (boolean — "Yes" → `true`, anything else → `false`; upstream uses exactly those two values today), and `description` (prose, preserved verbatim including markdown links and inline code). +4. Sort by `name`, wrap with the standard envelope, write to `catalog/sub-agents.json`. + +**Output shape per record:** + +```json +{ + "name": "permissionMode", + "required": false, + "description": "[Permission mode](#permission-modes): `default`, `acceptEdits`, `auto`, `dontAsk`, `bypassPermissions`, or `plan`. Ignored for [plugin subagents](#choose-the-subagent-scope)" +} +``` + +Pragmatic acceptance criteria: every row in the upstream frontmatter table appears in the output; `name` and `description` (the only two fields upstream marks required) are flagged `required: true`. Built-in subagent identities, model-resolution order, and per-event hook semantics are out of scope for this cut and remain candidates for follow-up work. + +**Test plan:** mirror `sync-env-vars.test.js` and `sync-hooks.test.js`. Pure functions (`parseRow`, `parseTable`, `buildRecords`) get unit tests with small fixture strings; `main()` stays uncovered. + ## Future sources (not committed) -Each gets the same recipe: one script, one catalog file, one test file. Likely candidates in rough priority order: `mcp.md`, `sub-agents.md`, `permissions` doc, `keybindings.md`, `cli-reference.md`. A second `hooks.md` pass to capture handler types and per-event input/output schemas also belongs on this list. None are committed scope today. +Each gets the same recipe: one script, one catalog file, one test file. Remaining candidates in rough priority order: `mcp.md`, `permissions` doc, `keybindings.md`, `cli-reference.md`. A second `hooks.md` pass to capture handler types and per-event input/output schemas also belongs on this list, as does a second `sub-agents.md` pass to capture built-in subagent identities. None are committed scope today. ## Automation -- **CI on cron — shipped.** [`.github/workflows/catalog-drift.yml`](../.github/workflows/catalog-drift.yml) runs `npm run sync:settings`, `npm run sync:env-vars`, and `npm run sync:hooks` every Monday at 09:00 UTC and on `workflow_dispatch`. The detect step normalises out the always-changing `fetchedAt` field before deciding whether content drifted; if only the timestamp moved, the working tree is restored to HEAD and no PR is opened. Real drift opens (or updates) a single `chore/catalog-drift` PR via `peter-evans/create-pull-request@v8` (paired with `actions/checkout@v5` and `actions/setup-node@v5` for the 2026-06-02 Node 24 cutover). Required permissions: `contents: write` + `pull-requests: write`. +- **CI on cron — shipped.** [`.github/workflows/catalog-drift.yml`](../.github/workflows/catalog-drift.yml) runs `npm run sync:settings`, `npm run sync:env-vars`, `npm run sync:hooks`, and `npm run sync:sub-agents` every Monday at 09:00 UTC and on `workflow_dispatch`. The detect step normalises out the always-changing `fetchedAt` field before deciding whether content drifted; if only the timestamp moved, the working tree is restored to HEAD and no PR is opened. Real drift opens (or updates) a single `chore/catalog-drift` PR via `peter-evans/create-pull-request@v8` (paired with `actions/checkout@v5` and `actions/setup-node@v5` for the 2026-06-02 Node 24 cutover). Required permissions: `contents: write` + `pull-requests: write`. ## Future automation (not committed) diff --git a/spec/roadmap.md b/spec/roadmap.md index a389a8b..020face 100644 --- a/spec/roadmap.md +++ b/spec/roadmap.md @@ -8,9 +8,9 @@ If you ship something, mark it ✅ here and (where relevant) update the corresponding spec section. If you discover new work, add it here, not inline in another spec. -Last reviewed: 2026-05-06 (Phase 2 + read_catalog + Phase 5 + Phase 7 + +Last reviewed: 2026-05-07 (Phase 2 + read_catalog + Phase 5 + Phase 7 + path-notes click-through + Phase 6 fully shipped + three-OS CI + -managed-mcp.json topbar pill + catalog-drift cron). +managed-mcp.json topbar pill + catalog-drift cron + sync-sub-agents). ## Next-up candidates @@ -26,9 +26,10 @@ here, then jump to the relevant section for shape and rationale. convention `CLAUDE.md` describes is unused (zero tags in the inventory) — decide whether to re-tag unverified rows or retire the convention. -- **New sync scripts** (catalog sync) — `mcp.md`, `sub-agents.md`, - permissions, `keybindings.md`, `cli-reference.md`. Each gets one - script + one catalog file + one test. +- **New sync scripts** (catalog sync) — `mcp.md`, permissions, + `keybindings.md`, `cli-reference.md`. Each gets one script + one + catalog file + one test. (`sub-agents.md` shipped 2026-05-07; see + catalog-sync section below.) - **CI / drift hardening** (catalog sync) — `$ref` resolution policy; staleness signal. (Cron-driven sync with PR-on-diff shipped 2026-05-06.) @@ -140,8 +141,21 @@ Phase numbering matches the spec. `prompt`, `agent`) and per-event input/output schemas. The current `sync-hooks.js` only captures the lifecycle table. - **New sync scripts.** Each gets one script + one catalog file + one - test, per the recipe. Likely candidates: `mcp.md`, `sub-agents.md`, - permissions doc, `keybindings.md`, `cli-reference.md`. None committed. + test, per the recipe. Remaining candidates: `mcp.md`, permissions + doc, `keybindings.md`, `cli-reference.md`. +- **Sub-agents catalog (frontmatter pass).** ✅ shipped 2026-05-07. + `scripts/sync-sub-agents.js` reads + `https://code.claude.com/docs/en/sub-agents.md`'s + `#### Supported frontmatter fields` table and writes + `catalog/sub-agents.json` (16 fields — `name`, `description`, + `tools`, `disallowedTools`, `model`, `permissionMode`, `maxTurns`, + `skills`, `mcpServers`, `hooks`, `memory`, `background`, `effort`, + `isolation`, `color`, `initialPrompt`). The catalog is wired through + `read_catalog` as `sub_agents` (snake-case on the wire to match + `env_vars`); no UI consumer yet, parallel to the env-vars/hooks + catalogs. Cron sync covers the new script. Future pass: built-in + subagent identities (Explore / Plan / general-purpose / etc.) and + the operational rules around tool restrictions and hooks. - **`read_catalog` Tauri command.** ✅ shipped 2026-05-05. Rust now owns `catalog/{settings,env-vars,hooks}.json` via `include_str!` and serves them through `read_catalog`. The frontend's `src/lib/catalog.ts` diff --git a/src-tauri/src/catalog.rs b/src-tauri/src/catalog.rs index 8de14e1..f39dfd1 100644 --- a/src-tauri/src/catalog.rs +++ b/src-tauri/src/catalog.rs @@ -15,6 +15,7 @@ use serde_json::Value; const SETTINGS_JSON: &str = include_str!("../../catalog/settings.json"); const ENV_VARS_JSON: &str = include_str!("../../catalog/env-vars.json"); const HOOKS_JSON: &str = include_str!("../../catalog/hooks.json"); +const SUB_AGENTS_JSON: &str = include_str!("../../catalog/sub-agents.json"); #[derive(Debug, Serialize)] pub struct Catalogs { @@ -25,6 +26,9 @@ pub struct Catalogs { pub env_vars: Value, /// Parsed `catalog/hooks.json` — `{source, fetchedAt, count, events: [...]}`. pub hooks: Value, + /// Parsed `catalog/sub-agents.json` — `{source, fetchedAt, count, fields: [...]}`. + #[serde(rename = "sub_agents")] + pub sub_agents: Value, } fn read_catalog_inner() -> Result { @@ -35,6 +39,8 @@ fn read_catalog_inner() -> Result { .map_err(|e| format!("catalog/env-vars.json parse: {e}"))?, hooks: serde_json::from_str(HOOKS_JSON) .map_err(|e| format!("catalog/hooks.json parse: {e}"))?, + sub_agents: serde_json::from_str(SUB_AGENTS_JSON) + .map_err(|e| format!("catalog/sub-agents.json parse: {e}"))?, }) } @@ -71,6 +77,34 @@ mod tests { assert!(!arr.unwrap().is_empty(), "events array should be non-empty"); } + #[test] + fn sub_agents_catalog_parses_and_has_fields_array() { + let c = read_catalog_inner().expect("catalogs parse"); + let arr = c.sub_agents.get("fields").and_then(Value::as_array); + assert!(arr.is_some(), "sub_agents.fields should be an array"); + assert!(!arr.unwrap().is_empty(), "fields array should be non-empty"); + } + + #[test] + fn sub_agents_catalog_contains_required_anchor_fields() { + // Guard against a sync regression that drops or renames the two + // required frontmatter fields. Upstream specifies only `name` and + // `description` as required; both must be present and marked so. + let c = read_catalog_inner().unwrap(); + let arr = c.sub_agents.get("fields").unwrap().as_array().unwrap(); + for expected in ["name", "description"] { + let entry = arr + .iter() + .find(|e| e.get("name").and_then(Value::as_str) == Some(expected)) + .unwrap_or_else(|| panic!("sub-agents catalog missing field: {expected}")); + assert_eq!( + entry.get("required").and_then(Value::as_bool), + Some(true), + "{expected} should be required=true", + ); + } + } + #[test] fn settings_entries_have_a_key_field() { // Spot-check that the catalog entry shape we rely on is intact — @@ -104,6 +138,7 @@ mod tests { let c = read_catalog_inner().unwrap(); let json = serde_json::to_value(&c).unwrap(); assert!(json.get("env_vars").is_some(), "expected snake_case env_vars on the wire"); + assert!(json.get("sub_agents").is_some(), "expected snake_case sub_agents on the wire"); assert!(json.get("settings").is_some()); assert!(json.get("hooks").is_some()); } diff --git a/src/lib/catalog.test.ts b/src/lib/catalog.test.ts index f80f865..bde4e12 100644 --- a/src/lib/catalog.test.ts +++ b/src/lib/catalog.test.ts @@ -47,9 +47,11 @@ describe("findCatalogEntry", () => { }); it("walks up to the closest known parent on miss", () => { - // `env` is a catalog entry; `env.ANTHROPIC_MODEL` is not (env is a - // user-keyed map). The walk-up should land on `env`. - const e = findCatalogEntry("env.ANTHROPIC_MODEL"); + // `env` is a catalog entry; user-defined keys like `env.MY_CUSTOM_DEBUG_VAR` + // are not — upstream documents many specific `env.*` leaves but `env` + // remains a user-keyed map for everything else. The walk-up should + // land on `env`. + const e = findCatalogEntry("env.MY_CUSTOM_DEBUG_VAR"); expect(e?.key).toBe("env"); }); diff --git a/src/lib/catalog.ts b/src/lib/catalog.ts index 789b26e..ce25b18 100644 --- a/src/lib/catalog.ts +++ b/src/lib/catalog.ts @@ -35,6 +35,7 @@ export interface CatalogsWire { // aren't modeled yet because nothing in the UI reads them. env_vars: unknown; hooks: unknown; + sub_agents: unknown; } export interface CatalogMeta { diff --git a/src/test-setup.ts b/src/test-setup.ts index e3b1374..bd58299 100644 --- a/src/test-setup.ts +++ b/src/test-setup.ts @@ -6,12 +6,14 @@ import settings from "../catalog/settings.json"; import envVars from "../catalog/env-vars.json"; import hooks from "../catalog/hooks.json"; +import subAgents from "../catalog/sub-agents.json"; import { hydrateCatalogForTesting, type CatalogsWire } from "@/lib/catalog"; const catalogs: CatalogsWire = { settings: settings as CatalogsWire["settings"], env_vars: envVars, hooks, + sub_agents: subAgents, }; hydrateCatalogForTesting(catalogs);