From ff8763df45f265fa1a3c9ce6c01740d7dd282902 Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Wed, 27 May 2026 22:07:11 -0400 Subject: [PATCH 01/20] docs(plan): add agent plugin system design Approved design for a Markdown + Node tooling layer that lets users author, validate, install, and dependency-resolve custom Claude Code agents. Covers manifest schema, registry, scaffolding CLI, and a static-assertion testing framework. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-27-agent-plugin-system-design.md | 184 ++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 docs/plans/2026-05-27-agent-plugin-system-design.md diff --git a/docs/plans/2026-05-27-agent-plugin-system-design.md b/docs/plans/2026-05-27-agent-plugin-system-design.md new file mode 100644 index 0000000..995bed4 --- /dev/null +++ b/docs/plans/2026-05-27-agent-plugin-system-design.md @@ -0,0 +1,184 @@ +# Agent Plugin System — Design + +**Date:** 2026-05-27 +**Status:** Approved (brainstorming complete) +**Branch:** `9-agent-plugin-system-for-custom-agent-creation` + +## Goal + +A plugin architecture for authoring, packaging, validating, and installing custom +Claude Code agents in a standardized, dependency-aware way. Covers four +deliverables: + +1. An agent plugin API specification (manifest + agent format). +2. A scaffolding CLI for creating new plugins. +3. A registry with dependency resolution and install/uninstall flow. +4. A validation and testing framework. + +## Key decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Plugin model | Markdown + Node tooling layer | Agents are prompt files (`.md`) consumed by Claude Code; tooling is Node/bash scripts. A plugin packages an agent plus metadata. Fits the existing repo pattern. | +| Lifecycle hooks | Plugin **management** lifecycle | Claude Code has no per-agent runtime hooks. Hooks fire during tooling operations (install/uninstall), mirroring the existing `.claude/hooks/` script convention. | +| Dependencies | Agents (semver) + skills + tools | Agent deps are versioned and resolved; skills and tools/scripts are existence-checked. | +| Testing | Static + structural assertions | Prompts can't run deterministically without invoking Claude. Tests assert verifiable facts over files. No Claude invocation; CI-friendly. | + +### Non-goals + +- No runtime agent loader (agents are prompts Claude reads directly). +- No invocation-time hooks (`onBeforeInvoke`, etc.) — Claude Code can't fire them. +- No network/marketplace fetch. All operations are local-file only. +- No side-by-side versions — one plugin resolves to exactly one installed version. + +## Architecture & layout + +``` +.claude/agent-plugins// + plugin.json # manifest: name, version, deps, hooks, metadata + agent.md # YAML frontmatter + prompt (the deliverable) + hooks/ # optional management-lifecycle scripts (.sh) + tests/ + plugin.test.json # static/structural assertions + assets/ # optional (templates, reference files) + +.claude/agent-plugins/installed.json # registry state +.claude/agent-plugin.schema.json # JSON Schema (ajv, 2020 dialect) + +scripts/ + agent-plugin-lib.js # shared: manifest/frontmatter parse, semver, catalog, topo-sort + create-agent-plugin.js (+ .sh) # scaffolding CLI + agent-registry.js (+ .sh) # resolve deps, install order, install/uninstall + validate-agent-plugin.js (+ .sh) # schema + structural validation + test-agent-plugin.js (+ .sh) # assertion runner +``` + +**Source vs. runtime split:** authoring lives in `.claude/agent-plugins//`. +Install validates → runs deps → copies `agent.md` to `.claude/agents/.md` +(where Claude Code reads it) → runs hooks → records state in `installed.json`. + +All scripts follow existing conventions: ESM Node + `.sh` wrappers, ajv schema +validation, `--json` flags, exit codes `0` (ok) / `1` (failures) / `2` (IO/usage), +matching `validate-pipeline-config.js`. + +## Manifest schema (`plugin.json`) + +```jsonc +{ + "name": "react-perf-expert", // kebab-case ^[a-z][a-z0-9-]*$; matches dir + agent.md frontmatter + "version": "1.2.0", // semver + "description": "...", // required + "author": "...", // optional + "license": "MIT", // optional + "agent": "agent.md", // path to prompt file (default "agent.md") + "dependencies": { + "agents": { "asset-cataloger": "^1.0.0" }, // name -> semver range, resolved + "skills": ["react-testing-workflows"], // existence-checked under .claude/skills/ + "tools": ["scripts/visual-diff.js"] // existence-checked, repo-relative + }, + "hooks": { // all optional; repo-relative to plugin dir + "preInstall": "hooks/check-deps.sh", + "postInstall": "hooks/register.sh", + "preUninstall": "hooks/cleanup.sh", + "postUninstall":"hooks/unregister.sh" + }, + "tests": "tests/plugin.test.json" +} +``` + +**Schema enforcement (ajv, JSON Schema 2020):** +- `name`, `version`, `description` required. `name` pattern `^[a-z][a-z0-9-]*$`; `version` semver pattern. +- `additionalProperties: false` at root and in `hooks` (catches typo'd keys). +- `dependencies.agents` values are semver ranges; `skills`/`tools` are string arrays. +- `hooks.*` constrained to the four known keys. + +**Structural checks (in `validate-agent-plugin.js`, beyond schema):** +1. `name` matches directory name **and** `name:` in `agent.md` frontmatter (three-way). +2. Every declared `hooks.*` path exists in the plugin dir. +3. The `agent` file exists. +4. `tools` deps resolve to real repo-relative files; `skills` exist under `.claude/skills/`. +5. `agent.md` frontmatter sanity: `name`, `description`, `tools` present; `model`/`permissionMode` valid enum if set. + +Errors are collected and reported together (not fail-fast), each naming the offending path. + +## Registry & dependency resolution (`agent-registry.js`) + +Builds an in-memory catalog `{ name -> { version, deps, dir } }` from all +`plugin.json` files, plus `installed.json` for current state. + +**Resolution for `install `:** +1. Build graph — walk `dependencies.agents` transitively. +2. Missing check — every agent dep exists in the catalog, else error with requiring chain. +3. Version check — resolved `version` satisfies declaring semver range; conflicting ranges for one dep across the graph → error. +4. Cycle detection — Kahn topological sort (reusing the approach in `validate-pipeline-config.js`); cycle lists involved plugins. +5. Order — topological order = install sequence (deps before dependents). +6. Non-agent deps — `skills` checked under `.claude/skills/`, `tools` as repo-relative files. Missing → error (existence-only; not installed). + +**Install execution** (per plugin, in order, idempotent): +`preInstall` hook → copy `agent.md` → `.claude/agents/.md` → `postInstall` +hook → record `{name, version, sourceHash, installedAt}` in `installed.json`. +Already-installed-and-satisfied plugins are skipped. + +**Uninstall:** refuse if another installed plugin depends on it (lists dependents) +unless `--force`; else `preUninstall` → remove `.claude/agents/.md` → +`postUninstall` → drop from `installed.json`. + +**Hook runner contract:** hooks are executable `.sh` scripts invoked with env +`PLUGIN_NAME`, `PLUGIN_DIR`, `PLUGIN_VERSION`. A failing `pre*` hook aborts the +operation; a failing `post*` hook warns only (the action already happened). A +declared-but-missing hook is a validation failure. + +**Commands:** `list`, `resolve ` (dry-run plan), `install `, +`uninstall ` — all with `--json`. + +## Scaffolding CLI (`create-agent-plugin.js`) + +```bash +node scripts/create-agent-plugin.js [--description "..."] \ + [--model opus|sonnet|haiku] [--tools "Read,Write,Bash"] \ + [--with-hooks] [--force] [--json] +``` + +Validates `name` → creates `.claude/agent-plugins//` → writes `plugin.json` +(version `0.1.0`), `agent.md` (frontmatter from flags + skeleton sections in the +`agent-expert` house style: core expertise, *When to Use*, examples), +`tests/plugin.test.json` (default assertions), and `hooks/` stubs only with +`--with-hooks`. Interactive prompts fill un-passed flags; `--json` + flags is +non-interactive for CI. Refuses to overwrite without `--force`. + +## Testing framework (`test-agent-plugin.js`) + +Reads `tests/plugin.test.json`: + +```jsonc +{ "assert": [ + "manifest.valid", // delegates to validate-agent-plugin.js + "frontmatter.has(name,description,tools)", + "frontmatter.model in (opus,sonnet,haiku)", + "deps.resolve", // delegates to registry resolver + "hooks.executable", + "prompt.section('When to Use This Agent')", + "description.examples >= 2" // counts blocks +]} +``` + +Each assertion is a named, pure predicate over the plugin's files — deterministic, +no Claude invocation. Runner accepts `` or `--all`, plus `--json`. Unknown +assertion strings are a hard error (typos can't silently pass). Exit `0`/`1`/`2`. + +The assertion catalog is fixed and documented; extended by adding predicates to the runner. + +## Testing strategy (for the tooling itself) + +Vitest (already in the repo): +- `agent-plugin-lib.js` unit tests — semver satisfaction, frontmatter parse, catalog build, topo-sort, cycle detection. +- Fixture plugins under `tests/fixtures/agent-plugins/`: valid, missing-dep, cyclic, version-mismatch, bad-frontmatter. +- Integration tests invoke each script against fixtures, asserting exit codes + `--json` payloads. +- Install/uninstall tests run against a temp dir — never mutate the real repo. + +## Rollout & docs + +- New `docs/guides/agent-plugins.md` — authoring guide, manifest reference, lifecycle, commands. +- Update `CLAUDE.md` Development Scripts section + prose script count, and `scripts/README.md`. +- These are tooling scripts (not agents/skills), so `check-doc-counts.sh` agent/skill counts are unaffected; only the prose script count needs updating. +- Optional CI wiring: `validate-agent-plugin.js --all` + `test-agent-plugin.js --all` in `verify-all.sh`. From c2546f4f53054ce0076de5889b4430ff822ce6fd Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Thu, 28 May 2026 08:04:28 -0400 Subject: [PATCH 02/20] docs(plan): add agent plugin system implementation plan Bite-sized, TDD-ordered plan for the Markdown + Node tooling layer: shared lib, manifest schema, validator, registry with dependency resolution and lifecycle hooks, scaffolding CLI, and static-assertion test runner. Each task has complete code, exact paths, and expected commands. References superpowers:executing-plans to run. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/plans/2026-05-27-agent-plugin-system.md | 1770 ++++++++++++++++++ 1 file changed, 1770 insertions(+) create mode 100644 docs/plans/2026-05-27-agent-plugin-system.md diff --git a/docs/plans/2026-05-27-agent-plugin-system.md b/docs/plans/2026-05-27-agent-plugin-system.md new file mode 100644 index 0000000..5408f21 --- /dev/null +++ b/docs/plans/2026-05-27-agent-plugin-system.md @@ -0,0 +1,1770 @@ +# Agent Plugin System Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Build a Markdown + Node tooling layer that lets users author, validate, dependency-resolve, install, and test custom Claude Code agents as versioned "plugins." + +**Architecture:** A plugin is a directory under `.claude/agent-plugins//` containing `plugin.json` (manifest), `agent.md` (the prompt), optional `hooks/`, and `tests/`. Four Node CLIs (validate, registry, create, test) share one library module. Install copies `agent.md` into `.claude/agents/.md` (where Claude Code reads it) and records state in `installed.json`. All scripts follow repo conventions: ESM, `.sh` wrappers, `--json`, exit `0/1/2`. + +**Tech Stack:** Node ESM, `ajv` (JSON Schema 2020, already a devDep), `semver` (to add), Vitest (`scripts/__tests__/`). Bash wrapper scripts. + +**Design reference:** `docs/plans/2026-05-27-agent-plugin-system-design.md` + +**Conventions to match (read these first):** +- `scripts/validate-pipeline-config.js` — arg parsing, ajv usage, error formatting, exit codes, Kahn cycle detection. +- `scripts/__tests__/check-doc-counts.test.js` — integration test style (`execFileSync`, recompute expectations from disk). +- `scripts/__tests__/vitest.config.js` — `fileParallelism: false`, 30s timeout. + +**Run a single test file:** `pnpm vitest run scripts/__tests__/` + +--- + +### Task 0: Add `semver` dependency + +**Files:** +- Modify: `package.json` (devDependencies) + +**Step 1: Add the dependency** + +Run: `pnpm add -D semver` +Expected: `semver` appears in `package.json` devDependencies; `pnpm-lock.yaml` updated. + +**Step 2: Verify it imports** + +Run: `node -e "import('semver').then(m=>console.log(typeof m.default.satisfies))"` +Expected: prints `function` + +**Step 3: Commit** + +```bash +git add package.json pnpm-lock.yaml +git commit -m "build: add semver for agent plugin dependency resolution" +``` + +--- + +### Task 1: Shared library — frontmatter & manifest parsing + +**Files:** +- Create: `scripts/agent-plugin-lib.js` +- Test: `scripts/__tests__/agent-plugin-lib.test.js` + +**Step 1: Write the failing test** + +```js +import { describe, it, expect } from "vitest"; +import { parseFrontmatter, countExamples } from "../agent-plugin-lib.js"; + +describe("parseFrontmatter", () => { + it("splits frontmatter from body", () => { + const md = "---\nname: foo\ndescription: A test agent\ntools: Read, Write\n---\nBody here"; + const { frontmatter, body, hasFrontmatter } = parseFrontmatter(md); + expect(hasFrontmatter).toBe(true); + expect(frontmatter.name).toBe("foo"); + expect(frontmatter.description).toBe("A test agent"); + expect(frontmatter.tools).toBe("Read, Write"); + expect(body.trim()).toBe("Body here"); + }); + + it("returns hasFrontmatter false when absent", () => { + const { frontmatter, hasFrontmatter } = parseFrontmatter("no frontmatter"); + expect(hasFrontmatter).toBe(false); + expect(frontmatter).toEqual({}); + }); +}); + +describe("countExamples", () => { + it("counts blocks", () => { + expect(countExamples("a x b y")).toBe(2); + expect(countExamples("none")).toBe(0); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `pnpm vitest run scripts/__tests__/agent-plugin-lib.test.js` +Expected: FAIL — cannot resolve `../agent-plugin-lib.js`. + +**Step 3: Write minimal implementation** + +```js +#!/usr/bin/env node +/** + * agent-plugin-lib.js — shared helpers for the agent-plugin tooling + * (validate / registry / create / test). Pure, side-effect-free functions + * over plugin files so each CLI stays thin and testable. + */ +import { readFileSync, existsSync, readdirSync, statSync } from "fs"; +import { join } from "path"; +import semver from "semver"; + +/** Parse simple single-line `key: value` YAML frontmatter from a Markdown string. */ +export function parseFrontmatter(content) { + const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); + if (!m) return { frontmatter: {}, body: content, hasFrontmatter: false }; + const frontmatter = {}; + for (const line of m[1].split(/\r?\n/)) { + const km = line.match(/^([A-Za-z][\w-]*):\s*(.*)$/); + if (km) frontmatter[km[1]] = km[2].trim(); + } + return { frontmatter, body: m[2], hasFrontmatter: true }; +} + +/** Count `` blocks in an agent description. */ +export function countExamples(description = "") { + return (description.match(//g) || []).length; +} +``` + +**Step 4: Run test to verify it passes** + +Run: `pnpm vitest run scripts/__tests__/agent-plugin-lib.test.js` +Expected: PASS (5 assertions). + +**Step 5: Commit** + +```bash +git add scripts/agent-plugin-lib.js scripts/__tests__/agent-plugin-lib.test.js +git commit -m "feat(plugins): add frontmatter + example parsing helpers" +``` + +--- + +### Task 2: Shared library — catalog & semver + +**Files:** +- Modify: `scripts/agent-plugin-lib.js` +- Test: `scripts/__tests__/agent-plugin-lib.test.js` +- Test fixtures: create under a temp dir inside the test (use `mkdtempSync`). + +**Step 1: Add failing tests** + +Append to the test file: + +```js +import { buildCatalog, loadManifest, satisfiesRange } from "../agent-plugin-lib.js"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +function makePlugin(root, name, version, deps = {}) { + const dir = join(root, name); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "plugin.json"), JSON.stringify({ + name, version, description: `${name} agent`, + dependencies: { agents: deps }, + })); + return dir; +} + +describe("buildCatalog + satisfiesRange", () => { + it("indexes plugins by name with version and deps", () => { + const root = mkdtempSync(join(tmpdir(), "plg-")); + try { + makePlugin(root, "alpha", "1.0.0"); + makePlugin(root, "beta", "2.1.0", { alpha: "^1.0.0" }); + const catalog = buildCatalog(root); + expect(Object.keys(catalog).sort()).toEqual(["alpha", "beta"]); + expect(catalog.beta.version).toBe("2.1.0"); + expect(catalog.beta.deps).toEqual({ alpha: "^1.0.0" }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("returns empty catalog for a missing root", () => { + expect(buildCatalog(join(tmpdir(), "does-not-exist-xyz"))).toEqual({}); + }); + + it("satisfiesRange wraps semver", () => { + expect(satisfiesRange("1.2.0", "^1.0.0")).toBe(true); + expect(satisfiesRange("2.0.0", "^1.0.0")).toBe(false); + }); +}); +``` + +**Step 2: Run to verify failure** + +Run: `pnpm vitest run scripts/__tests__/agent-plugin-lib.test.js` +Expected: FAIL — `buildCatalog`/`loadManifest`/`satisfiesRange` not exported. + +**Step 3: Implement** + +Append to `scripts/agent-plugin-lib.js`: + +```js +/** Read and parse a plugin's plugin.json. Throws if absent. */ +export function loadManifest(pluginDir) { + const p = join(pluginDir, "plugin.json"); + if (!existsSync(p)) throw new Error(`No plugin.json in ${pluginDir}`); + return JSON.parse(readFileSync(p, "utf-8")); +} + +/** Build a catalog { name -> { version, dir, manifest, deps } } from a plugins root. */ +export function buildCatalog(pluginsRoot) { + const catalog = {}; + if (!existsSync(pluginsRoot)) return catalog; + for (const entry of readdirSync(pluginsRoot)) { + const dir = join(pluginsRoot, entry); + if (!statSync(dir).isDirectory()) continue; + if (!existsSync(join(dir, "plugin.json"))) continue; + const manifest = loadManifest(dir); + catalog[manifest.name] = { + version: manifest.version, + dir, + manifest, + deps: manifest.dependencies?.agents ?? {}, + }; + } + return catalog; +} + +/** True if `version` satisfies the semver `range`. */ +export function satisfiesRange(version, range) { + return semver.satisfies(version, range, { includePrerelease: true }); +} +``` + +**Step 4: Run to verify pass** + +Run: `pnpm vitest run scripts/__tests__/agent-plugin-lib.test.js` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add scripts/agent-plugin-lib.js scripts/__tests__/agent-plugin-lib.test.js +git commit -m "feat(plugins): add catalog builder and semver range check" +``` + +--- + +### Task 3: Shared library — dependency resolution + +**Files:** +- Modify: `scripts/agent-plugin-lib.js` +- Test: `scripts/__tests__/agent-plugin-lib.test.js` + +**Step 1: Add failing tests** + +```js +import { resolveDependencies } from "../agent-plugin-lib.js"; + +describe("resolveDependencies", () => { + const catalog = { + a: { version: "1.0.0", deps: {} }, + b: { version: "1.0.0", deps: { a: "^1.0.0" } }, + c: { version: "1.0.0", deps: { b: "^1.0.0", a: "^1.0.0" } }, + }; + + it("orders dependencies before dependents", () => { + const { order, errors } = resolveDependencies(catalog, "c"); + expect(errors).toEqual([]); + expect(order.indexOf("a")).toBeLessThan(order.indexOf("b")); + expect(order.indexOf("b")).toBeLessThan(order.indexOf("c")); + expect(order[order.length - 1]).toBe("c"); + }); + + it("flags a missing dependency", () => { + const { errors } = resolveDependencies({ x: { version: "1.0.0", deps: { y: "^1.0.0" } } }, "x"); + expect(errors.some((e) => e.code === "missing")).toBe(true); + }); + + it("flags a version mismatch", () => { + const c = { p: { version: "1.0.0", deps: { q: "^2.0.0" } }, q: { version: "1.0.0", deps: {} } }; + const { errors } = resolveDependencies(c, "p"); + expect(errors.some((e) => e.code === "version")).toBe(true); + }); + + it("detects a cycle", () => { + const c = { m: { version: "1.0.0", deps: { n: "^1.0.0" } }, n: { version: "1.0.0", deps: { m: "^1.0.0" } } }; + const { errors } = resolveDependencies(c, "m"); + expect(errors.some((e) => e.code === "cycle")).toBe(true); + }); +}); +``` + +**Step 2: Run to verify failure** + +Run: `pnpm vitest run scripts/__tests__/agent-plugin-lib.test.js` +Expected: FAIL — `resolveDependencies` not exported. + +**Step 3: Implement** + +Append to `scripts/agent-plugin-lib.js`: + +```js +/** + * Resolve the transitive agent-dependency graph rooted at `rootName`. + * Returns { order, errors, involved }. `order` lists deps before dependents + * (topological). `errors` collects every problem: missing deps, semver + * mismatches, and cycles. Errors do not short-circuit — all are reported. + */ +export function resolveDependencies(catalog, rootName) { + const errors = []; + const involved = new Set(); + const edges = {}; // name -> [dependency names] + + function visit(name, chain) { + if (!catalog[name]) { + const by = chain.length ? ` (required by ${chain.join(" -> ")})` : ""; + errors.push({ code: "missing", message: `"${name}" not found in catalog${by}` }); + return; + } + if (involved.has(name)) return; + involved.add(name); + edges[name] = []; + for (const [dep, range] of Object.entries(catalog[name].deps || {})) { + edges[name].push(dep); + if (!catalog[dep]) { + errors.push({ code: "missing", message: `"${dep}" not found (required by ${[...chain, name].join(" -> ")})` }); + continue; + } + if (!satisfiesRange(catalog[dep].version, range)) { + errors.push({ code: "version", message: `"${dep}@${catalog[dep].version}" does not satisfy "${range}" (required by ${name})` }); + } + visit(dep, [...chain, name]); + } + } + visit(rootName, []); + + // Kahn topological sort over involved nodes (edge n -> d means n depends on d). + const indeg = {}; + for (const n of involved) indeg[n] = (edges[n] || []).filter((d) => involved.has(d)).length; + const queue = [...involved].filter((n) => indeg[n] === 0); + const order = []; + while (queue.length) { + const n = queue.shift(); + order.push(n); + for (const m of involved) { + if ((edges[m] || []).includes(n)) { + indeg[m]--; + if (indeg[m] === 0) queue.push(m); + } + } + } + if (order.length !== involved.size) { + const cyclic = [...involved].filter((n) => !order.includes(n)); + errors.push({ code: "cycle", message: `dependency cycle involving: ${cyclic.join(", ")}` }); + } + return { order, errors, involved: [...involved] }; +} +``` + +**Step 4: Run to verify pass** + +Run: `pnpm vitest run scripts/__tests__/agent-plugin-lib.test.js` +Expected: PASS (all describe blocks). + +**Step 5: Commit** + +```bash +git add scripts/agent-plugin-lib.js scripts/__tests__/agent-plugin-lib.test.js +git commit -m "feat(plugins): add transitive dependency resolver with cycle detection" +``` + +--- + +### Task 4: Manifest JSON Schema + +**Files:** +- Create: `.claude/agent-plugin.schema.json` +- Test: `scripts/__tests__/agent-plugin-schema.test.js` + +**Step 1: Write the failing test** + +```js +import { describe, it, expect, beforeAll } from "vitest"; +import { readFileSync } from "fs"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; + +const root = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const schemaPath = join(root, ".claude", "agent-plugin.schema.json"); + +let validate; +beforeAll(async () => { + const { default: Ajv2020 } = await import("ajv/dist/2020.js"); + const schema = JSON.parse(readFileSync(schemaPath, "utf-8")); + validate = new Ajv2020({ allErrors: true, strict: false }).compile(schema); +}); + +const base = { name: "demo-agent", version: "1.0.0", description: "A demo agent" }; + +describe("agent-plugin.schema.json", () => { + it("accepts a minimal valid manifest", () => { + expect(validate(base)).toBe(true); + }); + it("rejects a missing name", () => { + const { name, ...noName } = base; + expect(validate(noName)).toBe(false); + }); + it("rejects a non-kebab name", () => { + expect(validate({ ...base, name: "Demo_Agent" })).toBe(false); + }); + it("rejects an unknown hook key", () => { + expect(validate({ ...base, hooks: { onClick: "x.sh" } })).toBe(false); + }); + it("rejects unknown top-level keys", () => { + expect(validate({ ...base, bogus: true })).toBe(false); + }); +}); +``` + +**Step 2: Run to verify failure** + +Run: `pnpm vitest run scripts/__tests__/agent-plugin-schema.test.js` +Expected: FAIL — schema file not found. + +**Step 3: Create the schema** + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://aurelius.dev/agent-plugin.schema.json", + "title": "Agent Plugin Manifest", + "type": "object", + "additionalProperties": false, + "required": ["name", "version", "description"], + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$", + "description": "kebab-case; must match directory and agent.md frontmatter name" + }, + "version": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?$" + }, + "description": { "type": "string", "minLength": 1 }, + "author": { "type": "string" }, + "license": { "type": "string" }, + "agent": { "type": "string", "default": "agent.md" }, + "dependencies": { + "type": "object", + "additionalProperties": false, + "properties": { + "agents": { + "type": "object", + "additionalProperties": { "type": "string", "minLength": 1 } + }, + "skills": { "type": "array", "items": { "type": "string" } }, + "tools": { "type": "array", "items": { "type": "string" } } + } + }, + "hooks": { + "type": "object", + "additionalProperties": false, + "properties": { + "preInstall": { "type": "string" }, + "postInstall": { "type": "string" }, + "preUninstall": { "type": "string" }, + "postUninstall": { "type": "string" } + } + }, + "tests": { "type": "string" } + } +} +``` + +**Step 4: Run to verify pass** + +Run: `pnpm vitest run scripts/__tests__/agent-plugin-schema.test.js` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add .claude/agent-plugin.schema.json scripts/__tests__/agent-plugin-schema.test.js +git commit -m "feat(plugins): add agent plugin manifest JSON schema" +``` + +--- + +### Task 5: Test fixtures + +Create on-disk fixture plugins used by the integration tests in Tasks 6–9. + +**Files:** +- Create: `scripts/__tests__/fixtures/agent-plugins/valid-base/plugin.json` +- Create: `scripts/__tests__/fixtures/agent-plugins/valid-base/agent.md` +- Create: `scripts/__tests__/fixtures/agent-plugins/valid-base/tests/plugin.test.json` +- Create: `scripts/__tests__/fixtures/agent-plugins/depends-on-base/plugin.json` +- Create: `scripts/__tests__/fixtures/agent-plugins/depends-on-base/agent.md` +- Create: `scripts/__tests__/fixtures/agent-plugins/missing-dep/plugin.json` +- Create: `scripts/__tests__/fixtures/agent-plugins/missing-dep/agent.md` +- Create: `scripts/__tests__/fixtures/agent-plugins/name-mismatch/plugin.json` +- Create: `scripts/__tests__/fixtures/agent-plugins/name-mismatch/agent.md` + +**Step 1: valid-base/plugin.json** + +```json +{ + "name": "valid-base", + "version": "1.0.0", + "description": "A valid base agent plugin for tests", + "agent": "agent.md", + "tests": "tests/plugin.test.json" +} +``` + +**Step 2: valid-base/agent.md** + +```markdown +--- +name: valid-base +description: Use this agent to test the plugin system. Context: a test user: 'do x' assistant: 'doing x' Context: another user: 'do y' assistant: 'doing y' +tools: Read, Write +model: sonnet +--- + +You are a test agent. + +## When to Use This Agent + +Use this agent only in tests. +``` + +**Step 3: valid-base/tests/plugin.test.json** + +```json +{ + "assert": [ + "manifest.valid", + "frontmatter.has(name,description,tools)", + "frontmatter.model in (opus,sonnet,haiku)", + "deps.resolve", + "hooks.executable", + "prompt.section('When to Use This Agent')", + "description.examples >= 2" + ] +} +``` + +**Step 4: depends-on-base** — `plugin.json` (depends on valid-base) and a minimal `agent.md`: + +```json +{ + "name": "depends-on-base", + "version": "1.0.0", + "description": "Depends on valid-base", + "dependencies": { "agents": { "valid-base": "^1.0.0" } } +} +``` + +```markdown +--- +name: depends-on-base +description: Dependent agent. Context: t user: 'a' assistant: 'b' Context: u user: 'c' assistant: 'd' +tools: Read +model: sonnet +--- + +## When to Use This Agent + +Use after valid-base. +``` + +**Step 5: missing-dep** — depends on a non-existent plugin: + +```json +{ + "name": "missing-dep", + "version": "1.0.0", + "description": "Depends on a plugin that does not exist", + "dependencies": { "agents": { "ghost": "^1.0.0" } } +} +``` + +```markdown +--- +name: missing-dep +description: Bad deps. Context: t user: 'a' assistant: 'b' Context: u user: 'c' assistant: 'd' +tools: Read +--- + +## When to Use This Agent + +Never — it has a missing dependency. +``` + +**Step 6: name-mismatch** — `plugin.json` name differs from `agent.md` frontmatter name: + +```json +{ + "name": "name-mismatch", + "version": "1.0.0", + "description": "Frontmatter name will not match" +} +``` + +```markdown +--- +name: totally-different +description: Mismatch. Context: t user: 'a' assistant: 'b' Context: u user: 'c' assistant: 'd' +tools: Read +--- + +## When to Use This Agent + +Used to test the three-way name consistency check. +``` + +**Step 7: Commit** + +```bash +git add scripts/__tests__/fixtures/agent-plugins +git commit -m "test(plugins): add agent plugin fixtures" +``` + +--- + +### Task 6: `validate-agent-plugin.js` + +Schema validation + structural checks for a single plugin (or `--all`). + +**Files:** +- Create: `scripts/validate-agent-plugin.js` +- Create: `scripts/validate-agent-plugin.sh` +- Test: `scripts/__tests__/validate-agent-plugin.test.js` + +**Step 1: Write the failing test** + +```js +import { describe, it, expect } from "vitest"; +import { execFileSync } from "child_process"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SCRIPT = join(__dirname, "..", "validate-agent-plugin.js"); +const FIX = join(__dirname, "fixtures", "agent-plugins"); + +function run(args) { + try { + const stdout = execFileSync("node", [SCRIPT, ...args], { encoding: "utf-8", timeout: 30000 }); + return { stdout, exitCode: 0 }; + } catch (e) { + return { stdout: e.stdout || "", stderr: e.stderr || "", exitCode: e.status }; + } +} + +describe("validate-agent-plugin.js", () => { + it("passes a valid plugin (exit 0)", () => { + const r = run(["--dir", join(FIX, "valid-base"), "--json"]); + expect(r.exitCode).toBe(0); + expect(JSON.parse(r.stdout).ok).toBe(true); + }); + + it("fails a name mismatch (exit 1) with a clear message", () => { + const r = run(["--dir", join(FIX, "name-mismatch"), "--json"]); + expect(r.exitCode).toBe(1); + const out = JSON.parse(r.stdout); + expect(out.ok).toBe(false); + expect(JSON.stringify(out.issues)).toMatch(/name/i); + }); + + it("exits 2 on a missing directory", () => { + const r = run(["--dir", join(FIX, "nope"), "--json"]); + expect(r.exitCode).toBe(2); + }); + + it("shows usage on --help (exit 0)", () => { + const r = run(["--help"]); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain("Usage:"); + }); +}); +``` + +**Step 2: Run to verify failure** + +Run: `pnpm vitest run scripts/__tests__/validate-agent-plugin.test.js` +Expected: FAIL — script not found. + +**Step 3: Implement `scripts/validate-agent-plugin.js`** + +```js +#!/usr/bin/env node +/** + * validate-agent-plugin.js — Validate an agent plugin's manifest (against the + * JSON Schema) plus structural checks the schema cannot express: + * - manifest.name matches directory name AND agent.md frontmatter name + * - declared hook scripts exist + * - the agent file exists + * - skill deps exist under .claude/skills/, tool deps exist as repo files + * - agent.md frontmatter has name/description/tools; model/permissionMode valid + * + * Usage: + * node scripts/validate-agent-plugin.js --dir [--json] + * node scripts/validate-agent-plugin.js --all [--json] + * + * Exit codes: 0 valid · 1 invalid · 2 usage/IO error + */ +import { readFileSync, existsSync, readdirSync, statSync } from "fs"; +import { join, dirname, resolve, basename } from "path"; +import { fileURLToPath } from "url"; +import { parseFrontmatter, loadManifest, buildCatalog, resolveDependencies, countExamples } from "./agent-plugin-lib.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dirname, ".."); +const PLUGINS_ROOT = join(repoRoot, ".claude", "agent-plugins"); +const SCHEMA = join(repoRoot, ".claude", "agent-plugin.schema.json"); +const SKILLS_ROOT = join(repoRoot, ".claude", "skills"); +const VALID_MODELS = ["opus", "sonnet", "haiku"]; +const VALID_PERM = ["default", "acceptEdits", "bypassPermissions", "plan"]; + +function parseArgs(argv) { + const out = { dir: null, all: false, json: false }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--dir") out.dir = resolve(argv[++i]); + else if (a === "--all") out.all = true; + else if (a === "--json") out.json = true; + else if (a === "-h" || a === "--help") { printHelp(); process.exit(0); } + else { console.error(`Unknown argument: ${a}`); printHelp(); process.exit(2); } + } + return out; +} + +function printHelp() { + console.log(`Usage: node scripts/validate-agent-plugin.js (--dir | --all) [--json] + +Options: + --dir Validate a single plugin directory + --all Validate every plugin under .claude/agent-plugins/ + --json Machine-readable output + -h, --help Show this message`); +} + +async function compileSchema() { + const { default: Ajv2020 } = await import("ajv/dist/2020.js"); + const schema = JSON.parse(readFileSync(SCHEMA, "utf-8")); + return new Ajv2020({ allErrors: true, strict: false }).compile(schema); +} + +function validatePlugin(dir, validate, catalog) { + const issues = []; + const manifest = loadManifest(dir); // may throw -> caller treats as IO error + + if (!validate(manifest)) { + for (const e of validate.errors ?? []) { + issues.push({ path: e.instancePath || "(root)", message: e.message }); + } + } + + // name: dir vs manifest vs frontmatter + const dirName = basename(dir); + if (manifest.name && manifest.name !== dirName) { + issues.push({ path: "name", message: `manifest name "${manifest.name}" != directory "${dirName}"` }); + } + + const agentFile = join(dir, manifest.agent || "agent.md"); + if (!existsSync(agentFile)) { + issues.push({ path: "agent", message: `agent file not found: ${manifest.agent || "agent.md"}` }); + } else { + const { frontmatter, hasFrontmatter } = parseFrontmatter(readFileSync(agentFile, "utf-8")); + if (!hasFrontmatter) { + issues.push({ path: "agent", message: "agent file has no frontmatter" }); + } else { + if (manifest.name && frontmatter.name && frontmatter.name !== manifest.name) { + issues.push({ path: "agent.frontmatter.name", message: `frontmatter name "${frontmatter.name}" != manifest name "${manifest.name}"` }); + } + for (const f of ["name", "description", "tools"]) { + if (!frontmatter[f]) issues.push({ path: `agent.frontmatter.${f}`, message: `missing "${f}"` }); + } + if (frontmatter.model && !VALID_MODELS.includes(frontmatter.model)) { + issues.push({ path: "agent.frontmatter.model", message: `invalid model "${frontmatter.model}"` }); + } + if (frontmatter.permissionMode && !VALID_PERM.includes(frontmatter.permissionMode)) { + issues.push({ path: "agent.frontmatter.permissionMode", message: `invalid permissionMode "${frontmatter.permissionMode}"` }); + } + } + } + + // hooks exist + for (const [hook, rel] of Object.entries(manifest.hooks ?? {})) { + if (!existsSync(join(dir, rel))) issues.push({ path: `hooks.${hook}`, message: `hook script not found: ${rel}` }); + } + + // skills exist + for (const skill of manifest.dependencies?.skills ?? []) { + if (!existsSync(join(SKILLS_ROOT, skill))) issues.push({ path: "dependencies.skills", message: `skill not found: ${skill}` }); + } + // tools exist (repo-relative) + for (const tool of manifest.dependencies?.tools ?? []) { + if (!existsSync(join(repoRoot, tool))) issues.push({ path: "dependencies.tools", message: `tool not found: ${tool}` }); + } + + // agent deps resolve (against the catalog) + if (catalog[manifest.name]) { + const { errors } = resolveDependencies(catalog, manifest.name); + for (const e of errors) issues.push({ path: "dependencies.agents", message: e.message }); + } + + return { name: manifest.name ?? dirName, dir, ok: issues.length === 0, issues }; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + if (!args.dir && !args.all) { printHelp(); process.exit(2); } + + let validate, catalog; + try { + validate = await compileSchema(); + catalog = buildCatalog(PLUGINS_ROOT); + } catch (e) { + if (args.json) console.log(JSON.stringify({ ok: false, error: e.message })); + else console.error(`✗ ${e.message}`); + process.exit(2); + } + + let dirs; + if (args.all) { + dirs = existsSync(PLUGINS_ROOT) + ? readdirSync(PLUGINS_ROOT).map((d) => join(PLUGINS_ROOT, d)).filter((d) => statSync(d).isDirectory() && existsSync(join(d, "plugin.json"))) + : []; + } else { + if (!existsSync(join(args.dir, "plugin.json"))) { + const msg = `No plugin.json found in ${args.dir}`; + if (args.json) console.log(JSON.stringify({ ok: false, error: msg })); + else console.error(`✗ ${msg}`); + process.exit(2); + } + dirs = [args.dir]; + // Include the standalone dir in the catalog so its deps resolve. + const m = loadManifest(args.dir); + catalog[m.name] = { version: m.version, dir: args.dir, manifest: m, deps: m.dependencies?.agents ?? {} }; + } + + const results = []; + for (const d of dirs) { + try { + results.push(validatePlugin(d, validate, catalog)); + } catch (e) { + results.push({ name: basename(d), dir: d, ok: false, issues: [{ path: "(io)", message: e.message }] }); + } + } + + const ok = results.every((r) => r.ok); + if (args.json) { + console.log(JSON.stringify({ ok, results, issues: results.flatMap((r) => r.issues) }, null, 2)); + } else { + for (const r of results) { + if (r.ok) console.log(`✓ ${r.name}`); + else { + console.log(`✗ ${r.name} (${r.issues.length} issue(s)):`); + for (const i of r.issues) console.log(` ${i.path}: ${i.message}`); + } + } + } + process.exit(ok ? 0 : 1); +} + +main().catch((e) => { console.error(`✗ Unhandled error: ${e.stack ?? e.message}`); process.exit(2); }); +``` + +**Step 4: Create `scripts/validate-agent-plugin.sh`** + +```bash +#!/usr/bin/env bash +set -euo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec node "$DIR/validate-agent-plugin.js" "$@" +``` + +**Step 5: Run to verify pass** + +Run: `pnpm vitest run scripts/__tests__/validate-agent-plugin.test.js` +Expected: PASS. Also spot-check: `node scripts/validate-agent-plugin.js --dir scripts/__tests__/fixtures/agent-plugins/missing-dep` should print a `dependencies.agents` missing issue and exit 1. + +**Step 6: Commit** + +```bash +git add scripts/validate-agent-plugin.js scripts/validate-agent-plugin.sh scripts/__tests__/validate-agent-plugin.test.js +git commit -m "feat(plugins): add manifest + structural validator" +``` + +--- + +### Task 7: `agent-registry.js` + +List, resolve (dry-run), install, uninstall — with the management-lifecycle hook runner. + +**Files:** +- Create: `scripts/agent-registry.js` +- Create: `scripts/agent-registry.sh` +- Test: `scripts/__tests__/agent-registry.test.js` + +**Step 1: Write the failing test** + +The test copies fixtures into a temp repo layout (`.claude/agent-plugins/` + `.claude/agents/`) and drives install/uninstall via `--root` so the real repo is never touched. + +```js +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { execFileSync } from "child_process"; +import { mkdtempSync, mkdirSync, cpSync, rmSync, existsSync } from "fs"; +import { tmpdir } from "os"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SCRIPT = join(__dirname, "..", "agent-registry.js"); +const FIX = join(__dirname, "fixtures", "agent-plugins"); + +let root, pluginsRoot; +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "registry-")); + pluginsRoot = join(root, ".claude", "agent-plugins"); + mkdirSync(join(root, ".claude", "agents"), { recursive: true }); + mkdirSync(pluginsRoot, { recursive: true }); + cpSync(join(FIX, "valid-base"), join(pluginsRoot, "valid-base"), { recursive: true }); + cpSync(join(FIX, "depends-on-base"), join(pluginsRoot, "depends-on-base"), { recursive: true }); +}); +afterEach(() => rmSync(root, { recursive: true, force: true })); + +function run(args) { + try { + const stdout = execFileSync("node", [SCRIPT, ...args, "--root", root], { encoding: "utf-8", timeout: 30000 }); + return { stdout, exitCode: 0 }; + } catch (e) { + return { stdout: e.stdout || "", stderr: e.stderr || "", exitCode: e.status }; + } +} + +describe("agent-registry.js", () => { + it("lists available plugins", () => { + const r = run(["list", "--json"]); + expect(r.exitCode).toBe(0); + expect(JSON.parse(r.stdout).plugins.map((p) => p.name).sort()).toEqual(["depends-on-base", "valid-base"]); + }); + + it("resolves install order with deps first", () => { + const r = run(["resolve", "depends-on-base", "--json"]); + expect(r.exitCode).toBe(0); + expect(JSON.parse(r.stdout).order).toEqual(["valid-base", "depends-on-base"]); + }); + + it("installs a plugin and its deps into .claude/agents", () => { + const r = run(["install", "depends-on-base", "--json"]); + expect(r.exitCode).toBe(0); + expect(existsSync(join(root, ".claude", "agents", "valid-base.md"))).toBe(true); + expect(existsSync(join(root, ".claude", "agents", "depends-on-base.md"))).toBe(true); + expect(existsSync(join(pluginsRoot, "installed.json"))).toBe(true); + }); + + it("refuses to uninstall a depended-on plugin without --force", () => { + run(["install", "depends-on-base"]); + const r = run(["uninstall", "valid-base", "--json"]); + expect(r.exitCode).toBe(1); + expect(r.stdout).toMatch(/depend/i); + }); + + it("uninstalls cleanly when no dependents", () => { + run(["install", "depends-on-base"]); + const r = run(["uninstall", "depends-on-base", "--json"]); + expect(r.exitCode).toBe(0); + expect(existsSync(join(root, ".claude", "agents", "depends-on-base.md"))).toBe(false); + }); +}); +``` + +**Step 2: Run to verify failure** + +Run: `pnpm vitest run scripts/__tests__/agent-registry.test.js` +Expected: FAIL — script not found. + +**Step 3: Implement `scripts/agent-registry.js`** + +```js +#!/usr/bin/env node +/** + * agent-registry.js — Resolve, install, and uninstall agent plugins. + * + * Install copies a plugin's agent.md into .claude/agents/.md and records + * state in .claude/agent-plugins/installed.json. Management-lifecycle hooks + * (pre/postInstall, pre/postUninstall) run at the matching points; a failing + * pre* hook aborts, a failing post* hook only warns. + * + * Usage: + * node scripts/agent-registry.js list [--json] + * node scripts/agent-registry.js resolve [--json] + * node scripts/agent-registry.js install [--json] + * node scripts/agent-registry.js uninstall [--force] [--json] + * (--root overrides repo root; used by tests) + * + * Exit codes: 0 ok · 1 resolution/operation failure · 2 usage/IO error + */ +import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync, rmSync, createHash } from "fs"; +import { join, dirname, resolve, basename } from "path"; +import { fileURLToPath } from "url"; +import { execFileSync } from "child_process"; +import { createHash as hash } from "crypto"; +import { buildCatalog, resolveDependencies, loadManifest } from "./agent-plugin-lib.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +function parseArgs(argv) { + const out = { cmd: null, name: null, json: false, force: false, root: resolve(__dirname, "..") }; + const positional = []; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--json") out.json = true; + else if (a === "--force") out.force = true; + else if (a === "--root") out.root = resolve(argv[++i]); + else if (a === "-h" || a === "--help") { printHelp(); process.exit(0); } + else if (a.startsWith("--")) { console.error(`Unknown argument: ${a}`); printHelp(); process.exit(2); } + else positional.push(a); + } + out.cmd = positional[0] ?? null; + out.name = positional[1] ?? null; + return out; +} + +function printHelp() { + console.log(`Usage: node scripts/agent-registry.js [name] [options] + +Commands: + list List available plugins + resolve Print install order (dry run) + install Install a plugin and its dependencies + uninstall Remove an installed plugin + +Options: + --force Allow uninstall of a depended-on plugin + --json Machine-readable output + --root Override repo root (default: repo containing this script) + -h, --help Show this message`); +} + +const paths = (root) => ({ + pluginsRoot: join(root, ".claude", "agent-plugins"), + agentsDir: join(root, ".claude", "agents"), + installedFile: join(root, ".claude", "agent-plugins", "installed.json"), +}); + +function loadInstalled(p) { + return existsSync(p.installedFile) ? JSON.parse(readFileSync(p.installedFile, "utf-8")) : {}; +} +function saveInstalled(p, state) { + mkdirSync(dirname(p.installedFile), { recursive: true }); + writeFileSync(p.installedFile, JSON.stringify(state, null, 2) + "\n"); +} +function sourceHash(file) { + return hash("sha256").update(readFileSync(file)).digest("hex").slice(0, 16); +} + +function runHook(catalog, name, hook, fail) { + const entry = catalog[name]; + const rel = entry?.manifest?.hooks?.[hook]; + if (!rel) return { ran: false }; + const script = join(entry.dir, rel); + try { + execFileSync("bash", [script], { + stdio: "inherit", + env: { ...process.env, PLUGIN_NAME: name, PLUGIN_DIR: entry.dir, PLUGIN_VERSION: entry.version }, + }); + return { ran: true, ok: true }; + } catch (e) { + if (fail) throw new Error(`${hook} hook failed for "${name}": ${e.message}`); + console.warn(`⚠ ${hook} hook for "${name}" failed (continuing): ${e.message}`); + return { ran: true, ok: false }; + } +} + +function emit(json, payload, human) { + if (json) console.log(JSON.stringify(payload, null, 2)); + else human(); +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + if (!args.cmd) { printHelp(); process.exit(2); } + const p = paths(args.root); + const catalog = buildCatalog(p.pluginsRoot); + + if (args.cmd === "list") { + const installed = loadInstalled(p); + const plugins = Object.values(catalog).map((c) => ({ + name: c.manifest.name, version: c.version, installed: Boolean(installed[c.manifest.name]), + })); + emit(args.json, { plugins }, () => { + if (!plugins.length) console.log("No plugins found."); + for (const pl of plugins) console.log(`${pl.installed ? "●" : "○"} ${pl.name}@${pl.version}`); + }); + process.exit(0); + } + + if (!args.name) { console.error("This command requires a plugin name."); process.exit(2); } + if (!catalog[args.name]) { + emit(args.json, { ok: false, error: `Unknown plugin "${args.name}"` }, () => console.error(`✗ Unknown plugin "${args.name}"`)); + process.exit(2); + } + + if (args.cmd === "resolve" || args.cmd === "install") { + const { order, errors } = resolveDependencies(catalog, args.name); + if (errors.length) { + emit(args.json, { ok: false, order, errors }, () => { + console.error(`✗ Cannot resolve "${args.name}":`); + for (const e of errors) console.error(` [${e.code}] ${e.message}`); + }); + process.exit(1); + } + if (args.cmd === "resolve") { + emit(args.json, { ok: true, order }, () => console.log(`Install order: ${order.join(" -> ")}`)); + process.exit(0); + } + + // install + const installed = loadInstalled(p); + mkdirSync(p.agentsDir, { recursive: true }); + const actions = []; + for (const name of order) { + const entry = catalog[name]; + const agentSrc = join(entry.dir, entry.manifest.agent || "agent.md"); + const dest = join(p.agentsDir, `${name}.md`); + if (installed[name]?.version === entry.version && existsSync(dest)) { actions.push({ name, skipped: true }); continue; } + runHook(catalog, name, "preInstall", true); + copyFileSync(agentSrc, dest); + runHook(catalog, name, "postInstall", false); + installed[name] = { version: entry.version, sourceHash: sourceHash(agentSrc), installedAt: new Date().toISOString() }; + actions.push({ name, installed: true }); + } + saveInstalled(p, installed); + emit(args.json, { ok: true, order, actions }, () => { + for (const a of actions) console.log(a.skipped ? `= ${a.name} (already installed)` : `+ ${a.name}`); + }); + process.exit(0); + } + + if (args.cmd === "uninstall") { + const installed = loadInstalled(p); + if (!installed[args.name]) { + emit(args.json, { ok: false, error: `"${args.name}" is not installed` }, () => console.error(`✗ "${args.name}" is not installed`)); + process.exit(1); + } + // Block if an installed plugin depends on this one. + const dependents = Object.keys(installed).filter((n) => n !== args.name && catalog[n] && Object.keys(catalog[n].deps || {}).includes(args.name)); + if (dependents.length && !args.force) { + emit(args.json, { ok: false, error: "has dependents", dependents }, () => { + console.error(`✗ "${args.name}" is required by: ${dependents.join(", ")}. Use --force to override.`); + }); + process.exit(1); + } + runHook(catalog, args.name, "preUninstall", true); + const dest = join(p.agentsDir, `${args.name}.md`); + if (existsSync(dest)) rmSync(dest); + runHook(catalog, args.name, "postUninstall", false); + delete installed[args.name]; + saveInstalled(p, installed); + emit(args.json, { ok: true, removed: args.name }, () => console.log(`- ${args.name}`)); + process.exit(0); + } + + console.error(`Unknown command: ${args.cmd}`); + printHelp(); + process.exit(2); +} + +main(); +``` + +> **Note for implementer:** remove the unused `createHash` import from `fs` (it lives in `crypto`). The `import { createHash as hash } from "crypto"` line is the one to keep. Drop `createHash` from the `fs` import list. + +**Step 4: Create `scripts/agent-registry.sh`** + +```bash +#!/usr/bin/env bash +set -euo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec node "$DIR/agent-registry.js" "$@" +``` + +**Step 5: Run to verify pass** + +Run: `pnpm vitest run scripts/__tests__/agent-registry.test.js` +Expected: PASS (all 5 cases). + +**Step 6: Commit** + +```bash +git add scripts/agent-registry.js scripts/agent-registry.sh scripts/__tests__/agent-registry.test.js +git commit -m "feat(plugins): add registry with dependency resolution, install/uninstall, lifecycle hooks" +``` + +--- + +### Task 8: `create-agent-plugin.js` (scaffolding CLI) + +**Files:** +- Create: `scripts/create-agent-plugin.js` +- Create: `scripts/create-agent-plugin.sh` +- Test: `scripts/__tests__/create-agent-plugin.test.js` + +**Step 1: Write the failing test** (non-interactive mode via flags + `--root` temp dir) + +```js +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { execFileSync } from "child_process"; +import { mkdtempSync, rmSync, existsSync, readFileSync } from "fs"; +import { tmpdir } from "os"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SCRIPT = join(__dirname, "..", "create-agent-plugin.js"); + +let root; +beforeEach(() => { root = mkdtempSync(join(tmpdir(), "create-")); }); +afterEach(() => rmSync(root, { recursive: true, force: true })); + +function run(args) { + try { + const stdout = execFileSync("node", [SCRIPT, ...args, "--root", root], { encoding: "utf-8", timeout: 30000 }); + return { stdout, exitCode: 0 }; + } catch (e) { + return { stdout: e.stdout || "", stderr: e.stderr || "", exitCode: e.status }; + } +} + +describe("create-agent-plugin.js", () => { + it("scaffolds a plugin from flags", () => { + const r = run(["my-agent", "--description", "Does things", "--model", "opus", "--tools", "Read,Write", "--json"]); + expect(r.exitCode).toBe(0); + const dir = join(root, ".claude", "agent-plugins", "my-agent"); + expect(existsSync(join(dir, "plugin.json"))).toBe(true); + expect(existsSync(join(dir, "agent.md"))).toBe(true); + expect(existsSync(join(dir, "tests", "plugin.test.json"))).toBe(true); + const fm = readFileSync(join(dir, "agent.md"), "utf-8"); + expect(fm).toContain("name: my-agent"); + expect(fm).toContain("model: opus"); + }); + + it("rejects a non-kebab name (exit 2)", () => { + expect(run(["Bad_Name", "--description", "x"]).exitCode).toBe(2); + }); + + it("refuses to overwrite without --force (exit 1)", () => { + run(["dup", "--description", "x"]); + expect(run(["dup", "--description", "x"]).exitCode).toBe(1); + }); + + it("scaffolds the new plugin so it passes validation", () => { + run(["clean-agent", "--description", "A clean agent for the test"]); + const validator = join(__dirname, "..", "validate-agent-plugin.js"); + const dir = join(root, ".claude", "agent-plugins", "clean-agent"); + const out = execFileSync("node", [validator, "--dir", dir, "--json"], { encoding: "utf-8" }); + expect(JSON.parse(out).ok).toBe(true); + }); +}); +``` + +**Step 2: Run to verify failure** + +Run: `pnpm vitest run scripts/__tests__/create-agent-plugin.test.js` +Expected: FAIL — script not found. + +**Step 3: Implement `scripts/create-agent-plugin.js`** + +```js +#!/usr/bin/env node +/** + * create-agent-plugin.js — Scaffold a new agent plugin under + * .claude/agent-plugins// with a manifest, agent.md skeleton (in the + * house style), and default test assertions. Non-interactive when a name and + * --description are supplied; otherwise prompts for missing fields. + * + * Usage: + * node scripts/create-agent-plugin.js [--description "..."] \ + * [--model opus|sonnet|haiku] [--tools "Read,Write"] [--with-hooks] [--force] [--json] + * (--root overrides repo root; used by tests) + * + * Exit codes: 0 created · 1 exists (no --force) · 2 usage/IO error + */ +import { existsSync, mkdirSync, writeFileSync } from "fs"; +import { join, dirname, resolve } from "path"; +import { fileURLToPath } from "url"; +import { createInterface } from "readline"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const NAME_RE = /^[a-z][a-z0-9-]*$/; +const VALID_MODELS = ["opus", "sonnet", "haiku"]; + +function parseArgs(argv) { + const out = { name: null, description: null, model: "sonnet", tools: "Read, Write", withHooks: false, force: false, json: false, root: resolve(__dirname, "..") }; + const positional = []; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--description") out.description = argv[++i]; + else if (a === "--model") out.model = argv[++i]; + else if (a === "--tools") out.tools = argv[++i]; + else if (a === "--with-hooks") out.withHooks = true; + else if (a === "--force") out.force = true; + else if (a === "--json") out.json = true; + else if (a === "--root") out.root = resolve(argv[++i]); + else if (a === "-h" || a === "--help") { printHelp(); process.exit(0); } + else if (a.startsWith("--")) { console.error(`Unknown argument: ${a}`); printHelp(); process.exit(2); } + else positional.push(a); + } + out.name = positional[0] ?? null; + return out; +} + +function printHelp() { + console.log(`Usage: node scripts/create-agent-plugin.js [options] + +Options: + --description "..." Agent description + --model opus | sonnet | haiku (default: sonnet) + --tools "A,B" Comma-separated tool list (default: "Read, Write") + --with-hooks Scaffold hooks/ stubs + --force Overwrite an existing plugin + --json Machine-readable output + -h, --help Show this message`); +} + +function ask(question) { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((res) => rl.question(question, (a) => { rl.close(); res(a.trim()); })); +} + +function manifest(name, description, withHooks) { + const m = { name, version: "0.1.0", description, agent: "agent.md", tests: "tests/plugin.test.json" }; + if (withHooks) m.hooks = { preInstall: "hooks/pre-install.sh", postInstall: "hooks/post-install.sh" }; + return JSON.stringify(m, null, 2) + "\n"; +} + +function agentMd(name, description, model, tools) { + return `--- +name: ${name} +description: ${description} Context: a relevant situation user: 'a representative request' assistant: 'how this agent responds' Context: a second situation user: 'another request' assistant: 'the response' +tools: ${tools} +model: ${model} +--- + +You are a ${name} specialist. Describe the agent's core expertise here. + +Your core expertise areas: +- **Area 1**: specific capabilities +- **Area 2**: specific capabilities + +## When to Use This Agent + +Use this agent for: +- Use case 1 +- Use case 2 +`; +} + +const TESTS = JSON.stringify({ + assert: [ + "manifest.valid", + "frontmatter.has(name,description,tools)", + "frontmatter.model in (opus,sonnet,haiku)", + "deps.resolve", + "hooks.executable", + "prompt.section('When to Use This Agent')", + "description.examples >= 2", + ], +}, null, 2) + "\n"; + +const HOOK_STUB = `#!/usr/bin/env bash +set -u +trap 'exit 0' ERR +# PLUGIN_NAME, PLUGIN_DIR, PLUGIN_VERSION are available in the environment. +echo "hook for \${PLUGIN_NAME}" +exit 0 +`; + +async function main() { + const args = parseArgs(process.argv.slice(2)); + + let name = args.name; + if (!name && !args.json) name = await ask("Plugin name (kebab-case): "); + if (!name) { console.error("A plugin name is required."); process.exit(2); } + if (!NAME_RE.test(name)) { console.error(`✗ Invalid name "${name}". Use kebab-case: ^[a-z][a-z0-9-]*$`); process.exit(2); } + + let description = args.description; + if (!description && !args.json) description = await ask("Description: "); + if (!description) description = `The ${name} agent`; + + if (!VALID_MODELS.includes(args.model)) { console.error(`✗ Invalid model "${args.model}"`); process.exit(2); } + + const dir = join(args.root, ".claude", "agent-plugins", name); + if (existsSync(dir) && !args.force) { + if (args.json) console.log(JSON.stringify({ ok: false, error: "exists" })); + else console.error(`✗ Plugin "${name}" already exists. Use --force to overwrite.`); + process.exit(1); + } + + mkdirSync(join(dir, "tests"), { recursive: true }); + writeFileSync(join(dir, "plugin.json"), manifest(name, description, args.withHooks)); + writeFileSync(join(dir, "agent.md"), agentMd(name, description, args.model, args.tools)); + writeFileSync(join(dir, "tests", "plugin.test.json"), TESTS); + if (args.withHooks) { + mkdirSync(join(dir, "hooks"), { recursive: true }); + writeFileSync(join(dir, "hooks", "pre-install.sh"), HOOK_STUB); + writeFileSync(join(dir, "hooks", "post-install.sh"), HOOK_STUB); + } + + if (args.json) console.log(JSON.stringify({ ok: true, name, dir }, null, 2)); + else console.log(`✓ Created plugin "${name}" at ${dir}`); + process.exit(0); +} + +main().catch((e) => { console.error(`✗ ${e.stack ?? e.message}`); process.exit(2); }); +``` + +**Step 4: Create `scripts/create-agent-plugin.sh`** (same wrapper pattern as Task 6 Step 4, pointing at `create-agent-plugin.js`). + +**Step 5: Run to verify pass** + +Run: `pnpm vitest run scripts/__tests__/create-agent-plugin.test.js` +Expected: PASS. + +**Step 6: Commit** + +```bash +git add scripts/create-agent-plugin.js scripts/create-agent-plugin.sh scripts/__tests__/create-agent-plugin.test.js +git commit -m "feat(plugins): add scaffolding CLI for new agent plugins" +``` + +--- + +### Task 9: `test-agent-plugin.js` (assertion runner) + +**Files:** +- Create: `scripts/test-agent-plugin.js` +- Create: `scripts/test-agent-plugin.sh` +- Test: `scripts/__tests__/test-agent-plugin.test.js` + +**Step 1: Write the failing test** + +```js +import { describe, it, expect } from "vitest"; +import { execFileSync } from "child_process"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SCRIPT = join(__dirname, "..", "test-agent-plugin.js"); +const FIX = join(__dirname, "fixtures", "agent-plugins"); + +function run(args) { + try { + const stdout = execFileSync("node", [SCRIPT, ...args], { encoding: "utf-8", timeout: 30000 }); + return { stdout, exitCode: 0 }; + } catch (e) { + return { stdout: e.stdout || "", stderr: e.stderr || "", exitCode: e.status }; + } +} + +describe("test-agent-plugin.js", () => { + it("passes all assertions for a valid plugin", () => { + const r = run(["--dir", join(FIX, "valid-base"), "--json"]); + expect(r.exitCode).toBe(0); + const out = JSON.parse(r.stdout); + expect(out.ok).toBe(true); + expect(out.results.every((a) => a.pass)).toBe(true); + }); + + it("fails when an assertion is not met (name-mismatch)", () => { + // name-mismatch has no tests/plugin.test.json; pass an inline default set instead. + const r = run(["--dir", join(FIX, "name-mismatch"), "--json"]); + expect([1, 2]).toContain(r.exitCode); + }); + + it("errors on an unknown assertion string (exit 2)", () => { + const r = run(["--dir", join(FIX, "valid-base"), "--assert", "bogus.thing", "--json"]); + expect(r.exitCode).toBe(2); + }); +}); +``` + +**Step 2: Run to verify failure** + +Run: `pnpm vitest run scripts/__tests__/test-agent-plugin.test.js` +Expected: FAIL — script not found. + +**Step 3: Implement `scripts/test-agent-plugin.js`** + +```js +#!/usr/bin/env node +/** + * test-agent-plugin.js — Run a plugin's static/structural assertions from + * tests/plugin.test.json (or an inline --assert). Each assertion is a pure, + * deterministic predicate over the plugin's files — no Claude invocation. + * + * Usage: + * node scripts/test-agent-plugin.js --dir [--json] + * node scripts/test-agent-plugin.js --all [--json] + * node scripts/test-agent-plugin.js --dir --assert "manifest.valid" [--assert ...] + * + * Exit codes: 0 all pass · 1 a failure · 2 usage/IO/unknown-assertion error + */ +import { readFileSync, existsSync, readdirSync, statSync, accessSync, constants } from "fs"; +import { join, dirname, resolve, basename } from "path"; +import { fileURLToPath } from "url"; +import { execFileSync } from "child_process"; +import { parseFrontmatter, loadManifest, buildCatalog, resolveDependencies, countExamples } from "./agent-plugin-lib.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dirname, ".."); +const PLUGINS_ROOT = join(repoRoot, ".claude", "agent-plugins"); +const VALIDATOR = join(__dirname, "validate-agent-plugin.js"); + +function parseArgs(argv) { + const out = { dir: null, all: false, json: false, asserts: [] }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--dir") out.dir = resolve(argv[++i]); + else if (a === "--all") out.all = true; + else if (a === "--json") out.json = true; + else if (a === "--assert") out.asserts.push(argv[++i]); + else if (a === "-h" || a === "--help") { printHelp(); process.exit(0); } + else { console.error(`Unknown argument: ${a}`); printHelp(); process.exit(2); } + } + return out; +} + +function printHelp() { + console.log(`Usage: node scripts/test-agent-plugin.js (--dir | --all) [--assert "..."] [--json]`); +} + +// --- Assertion predicates. Each returns { pass, detail }. --- +function loadAgent(dir, manifest) { + const file = join(dir, manifest.agent || "agent.md"); + if (!existsSync(file)) return { frontmatter: {}, body: "" }; + return parseFrontmatter(readFileSync(file, "utf-8")); +} + +const PREDICATES = { + "manifest.valid": (ctx) => { + try { + execFileSync("node", [VALIDATOR, "--dir", ctx.dir, "--json"], { encoding: "utf-8" }); + return { pass: true }; + } catch (e) { + return { pass: false, detail: "validate-agent-plugin reported issues" }; + } + }, + "deps.resolve": (ctx) => { + const { errors } = resolveDependencies(ctx.catalog, ctx.manifest.name); + return { pass: errors.length === 0, detail: errors.map((e) => e.message).join("; ") }; + }, + "hooks.executable": (ctx) => { + for (const rel of Object.values(ctx.manifest.hooks ?? {})) { + const p = join(ctx.dir, rel); + if (!existsSync(p)) return { pass: false, detail: `missing hook ${rel}` }; + try { accessSync(p, constants.R_OK); } catch { return { pass: false, detail: `unreadable hook ${rel}` }; } + } + return { pass: true }; + }, + "description.examples >= 2": (ctx) => { + const n = countExamples(ctx.agent.frontmatter.description || ""); + return { pass: n >= 2, detail: `found ${n} example(s)` }; + }, +}; + +// Parametrised assertion families. +function evalAssertion(str, ctx) { + if (PREDICATES[str]) return PREDICATES[str](ctx); + + let m = str.match(/^frontmatter\.has\(([^)]*)\)$/); + if (m) { + const fields = m[1].split(",").map((s) => s.trim()).filter(Boolean); + const missing = fields.filter((f) => !ctx.agent.frontmatter[f]); + return { pass: missing.length === 0, detail: missing.length ? `missing ${missing.join(", ")}` : "" }; + } + + m = str.match(/^frontmatter\.(\w+) in \(([^)]*)\)$/); + if (m) { + const field = m[1]; + const allowed = m[2].split(",").map((s) => s.trim()); + const val = ctx.agent.frontmatter[field]; + if (val === undefined) return { pass: true, detail: `${field} not set (optional)` }; + return { pass: allowed.includes(val), detail: `${field}="${val}"` }; + } + + m = str.match(/^prompt\.section\('(.+)'\)$/); + if (m) { + const heading = m[1]; + const re = new RegExp(`^#{1,6}\\s+${heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`, "m"); + return { pass: re.test(ctx.agent.body), detail: re.test(ctx.agent.body) ? "" : `no section "${heading}"` }; + } + + return { pass: false, unknown: true, detail: `unknown assertion "${str}"` }; +} + +function runPlugin(dir, catalog, overrideAsserts) { + const manifest = loadManifest(dir); + const agent = loadAgent(dir, manifest); + const ctx = { dir, manifest, agent, catalog, repoRoot }; + + let asserts = overrideAsserts; + if (!asserts || asserts.length === 0) { + const testsRel = manifest.tests || "tests/plugin.test.json"; + const testsFile = join(dir, testsRel); + if (!existsSync(testsFile)) { + return { name: manifest.name, ok: false, error: `no tests file (${testsRel})`, results: [] }; + } + asserts = JSON.parse(readFileSync(testsFile, "utf-8")).assert ?? []; + } + + const results = []; + let unknown = false; + for (const a of asserts) { + const r = evalAssertion(a, ctx); + if (r.unknown) unknown = true; + results.push({ assert: a, pass: r.pass, detail: r.detail || "" }); + } + return { name: manifest.name, ok: !unknown && results.every((r) => r.pass), unknown, results }; +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + if (!args.dir && !args.all) { printHelp(); process.exit(2); } + const catalog = buildCatalog(PLUGINS_ROOT); + + let dirs; + if (args.all) { + dirs = existsSync(PLUGINS_ROOT) + ? readdirSync(PLUGINS_ROOT).map((d) => join(PLUGINS_ROOT, d)).filter((d) => statSync(d).isDirectory() && existsSync(join(d, "plugin.json"))) + : []; + } else { + if (!existsSync(join(args.dir, "plugin.json"))) { console.error(`✗ No plugin.json in ${args.dir}`); process.exit(2); } + dirs = [args.dir]; + const m = loadManifest(args.dir); + if (!catalog[m.name]) catalog[m.name] = { version: m.version, dir: args.dir, manifest: m, deps: m.dependencies?.agents ?? {} }; + } + + const reports = []; + for (const d of dirs) { + try { reports.push(runPlugin(d, catalog, args.asserts)); } + catch (e) { reports.push({ name: basename(d), ok: false, error: e.message, results: [] }); } + } + + const anyUnknown = reports.some((r) => r.unknown); + const ok = reports.every((r) => r.ok); + + if (args.json) { + console.log(JSON.stringify({ ok, results: reports.flatMap((r) => r.results), reports }, null, 2)); + } else { + for (const r of reports) { + if (r.error) { console.log(`✗ ${r.name}: ${r.error}`); continue; } + console.log(`${r.ok ? "✓" : "✗"} ${r.name}`); + for (const a of r.results) console.log(` ${a.pass ? "✓" : "✗"} ${a.assert}${a.detail ? ` — ${a.detail}` : ""}`); + } + } + if (anyUnknown) process.exit(2); + process.exit(ok ? 0 : 1); +} + +main(); +``` + +**Step 4: Create `scripts/test-agent-plugin.sh`** (wrapper pattern, pointing at `test-agent-plugin.js`). + +**Step 5: Run to verify pass** + +Run: `pnpm vitest run scripts/__tests__/test-agent-plugin.test.js` +Expected: PASS. + +**Step 6: Commit** + +```bash +git add scripts/test-agent-plugin.js scripts/test-agent-plugin.sh scripts/__tests__/test-agent-plugin.test.js +git commit -m "feat(plugins): add static assertion test runner" +``` + +--- + +### Task 10: Full suite + end-to-end smoke + +**Step 1: Run the whole new test set** + +Run: `pnpm vitest run scripts/__tests__/agent-plugin-lib.test.js scripts/__tests__/agent-plugin-schema.test.js scripts/__tests__/validate-agent-plugin.test.js scripts/__tests__/agent-registry.test.js scripts/__tests__/create-agent-plugin.test.js scripts/__tests__/test-agent-plugin.test.js` +Expected: all PASS. + +**Step 2: Manual end-to-end against a temp root** + +```bash +TMP=$(mktemp -d) +mkdir -p "$TMP/.claude/agents" +node scripts/create-agent-plugin.js demo-agent --description "A demo agent" --root "$TMP" +node scripts/validate-agent-plugin.js --dir "$TMP/.claude/agent-plugins/demo-agent" +node scripts/test-agent-plugin.js --dir "$TMP/.claude/agent-plugins/demo-agent" +node scripts/agent-registry.js install demo-agent --root "$TMP" +test -f "$TMP/.claude/agents/demo-agent.md" && echo "INSTALL OK" +node scripts/agent-registry.js uninstall demo-agent --root "$TMP" +rm -rf "$TMP" +``` +Expected: create/validate/test/install/uninstall all succeed; "INSTALL OK" prints. + +No commit (verification only). + +--- + +### Task 11: Documentation + +**Files:** +- Create: `docs/guides/agent-plugins.md` +- Modify: `scripts/README.md` +- Modify: `CLAUDE.md` (Development Scripts section + the script count in the footer line) + +**Step 1: Write `docs/guides/agent-plugins.md`** — cover: what a plugin is, directory layout, `plugin.json` field reference (link the schema), the four CLIs with examples, the management-lifecycle hooks and their env vars, the assertion catalog, and the dependency model (agents versioned; skills/tools existence-checked). + +**Step 2: Update `scripts/README.md`** — add entries for `create-agent-plugin`, `agent-registry`, `validate-agent-plugin`, `test-agent-plugin`, `agent-plugin-lib`. + +**Step 3: Update `CLAUDE.md`** — add a "Quick Command Reference" block: + +```bash +node scripts/create-agent-plugin.js [--description ...] [--model ...] [--tools ...] [--with-hooks] +node scripts/validate-agent-plugin.js --dir | --all +node scripts/agent-registry.js list | resolve | install | uninstall +node scripts/test-agent-plugin.js --dir | --all +``` + +Update the footer line's script count (recount `scripts/*.{js,sh}` after this work). Note: agent/skill counts are unchanged — these are tooling scripts, so `check-doc-counts.sh` is unaffected. + +**Step 4: Verify doc counts still pass** + +Run: `bash scripts/check-doc-counts.sh` +Expected: exit 0 (agent/skill counts unaffected). + +**Step 5: Commit** + +```bash +git add docs/guides/agent-plugins.md scripts/README.md CLAUDE.md +git commit -m "docs(plugins): document the agent plugin system" +``` + +--- + +### Task 12: Wire into `verify-all.sh` (optional but recommended) + +**Files:** +- Modify: `scripts/verify-all.sh` +- Test: `scripts/__tests__/verify-all.test.js` (extend if it enumerates checks) + +**Step 1:** Add a check that runs `node scripts/validate-agent-plugin.js --all` and `node scripts/test-agent-plugin.js --all`, but **only if** `.claude/agent-plugins/` exists and is non-empty (so the check is a no-op/pass on repos with no plugins). + +**Step 2:** Run: `bash scripts/verify-all.sh` — expected: passes, new check reported (or skipped when no plugins). + +**Step 3: Commit** + +```bash +git add scripts/verify-all.sh scripts/__tests__/verify-all.test.js +git commit -m "ci(plugins): include plugin validation + tests in verify-all" +``` + +--- + +## Final verification checklist + +- [ ] `pnpm vitest run scripts/__tests__/agent-plugin-lib.test.js scripts/__tests__/agent-plugin-schema.test.js scripts/__tests__/validate-agent-plugin.test.js scripts/__tests__/agent-registry.test.js scripts/__tests__/create-agent-plugin.test.js scripts/__tests__/test-agent-plugin.test.js` — all pass +- [ ] Manual end-to-end (Task 10 Step 2) succeeds +- [ ] `bash scripts/check-doc-counts.sh` — exit 0 +- [ ] `bash scripts/verify-all.sh` — exit 0 +- [ ] No real `.claude/agents/` files were modified by tests (all used temp roots) +- [ ] `git status` clean except intended files From ef8dd59c255cfd9316bcc575f105c15c42378273 Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Thu, 28 May 2026 08:09:24 -0400 Subject: [PATCH 03/20] build: add semver for agent plugin dependency resolution --- package.json | 1 + pnpm-lock.yaml | 36 ++++++++++++++++++------------------ 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/package.json b/package.json index 352170e..f23d4f0 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "pixelmatch": "^7.1.0", "pngjs": "^7.0.0", "prettier": "^3.8.1", + "semver": "^7.8.1", "ts-morph": "^28.0.0", "vite": "^8.0.5", "vitest": "^4.1.2" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 03e68aa..8c0ef2d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: prettier: specifier: ^3.8.1 version: 3.8.1 + semver: + specifier: ^7.8.1 + version: 7.8.1 ts-morph: specifier: ^28.0.0 version: 28.0.0 @@ -564,42 +567,36 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.2': resolution: {integrity: sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.2': resolution: {integrity: sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.2': resolution: {integrity: sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.2': resolution: {integrity: sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.2': resolution: {integrity: sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.2': resolution: {integrity: sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==} @@ -1531,28 +1528,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -1950,6 +1943,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.1: + resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==} + engines: {node: '>=10'} + hasBin: true + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -2577,7 +2575,7 @@ snapshots: '@commitlint/is-ignored@20.5.0': dependencies: '@commitlint/types': 20.5.0 - semver: 7.7.4 + semver: 7.8.1 '@commitlint/lint@20.5.0': dependencies: @@ -2651,7 +2649,7 @@ snapshots: dependencies: '@simple-libs/child-process-utils': 1.0.2 '@simple-libs/stream-utils': 1.2.0 - semver: 7.7.4 + semver: 7.8.1 optionalDependencies: conventional-commits-parser: 6.4.0 @@ -2959,7 +2957,7 @@ snapshots: npm-run-path: 6.0.0 progress: 2.0.3 rxjs: 7.8.2 - semver: 7.7.4 + semver: 7.8.1 source-map: 0.7.6 tree-kill: 1.2.2 tslib: 2.8.1 @@ -3001,7 +2999,7 @@ snapshots: '@stryker-mutator/api': 9.6.1 '@stryker-mutator/core': 9.6.1(@types/node@25.5.0) '@stryker-mutator/util': 9.6.1 - semver: 7.7.4 + semver: 7.8.1 tslib: 2.8.1 vitest: 4.1.2(@types/node@25.5.0)(vite@8.0.14(@types/node@25.5.0)(jiti@2.6.1)(yaml@2.8.3)) @@ -3235,7 +3233,7 @@ snapshots: figures: 3.2.0 find-up: 5.0.0 git-semver-tags: 5.0.1 - semver: 7.7.4 + semver: 7.8.1 yaml: 2.8.3 yargs: 17.7.2 @@ -3310,7 +3308,7 @@ snapshots: handlebars: 4.7.9 json-stringify-safe: 5.0.1 meow: 8.1.2 - semver: 7.7.4 + semver: 7.8.1 split: 1.0.1 conventional-changelog@4.0.0: @@ -3677,7 +3675,7 @@ snapshots: git-semver-tags@5.0.1: dependencies: meow: 8.1.2 - semver: 7.7.4 + semver: 7.8.1 gitconfiglocal@1.0.0: dependencies: @@ -4005,7 +4003,7 @@ snapshots: dependencies: hosted-git-info: 4.1.0 is-core-module: 2.16.1 - semver: 7.7.4 + semver: 7.8.1 validate-npm-package-license: 3.0.4 npm-run-path@6.0.0: @@ -4232,6 +4230,8 @@ snapshots: semver@7.7.4: {} + semver@7.8.1: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 From 717b054ceefb9b0b23e6d5b379725a25a5756fb7 Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Thu, 28 May 2026 08:10:02 -0400 Subject: [PATCH 04/20] feat(plugins): add frontmatter + example parsing helpers --- scripts/__tests__/agent-plugin-lib.test.js | 27 ++++++++++++++++++++++ scripts/agent-plugin-lib.js | 26 +++++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 scripts/__tests__/agent-plugin-lib.test.js create mode 100644 scripts/agent-plugin-lib.js diff --git a/scripts/__tests__/agent-plugin-lib.test.js b/scripts/__tests__/agent-plugin-lib.test.js new file mode 100644 index 0000000..25142b9 --- /dev/null +++ b/scripts/__tests__/agent-plugin-lib.test.js @@ -0,0 +1,27 @@ +import { describe, it, expect } from "vitest"; +import { parseFrontmatter, countExamples } from "../agent-plugin-lib.js"; + +describe("parseFrontmatter", () => { + it("splits frontmatter from body", () => { + const md = "---\nname: foo\ndescription: A test agent\ntools: Read, Write\n---\nBody here"; + const { frontmatter, body, hasFrontmatter } = parseFrontmatter(md); + expect(hasFrontmatter).toBe(true); + expect(frontmatter.name).toBe("foo"); + expect(frontmatter.description).toBe("A test agent"); + expect(frontmatter.tools).toBe("Read, Write"); + expect(body.trim()).toBe("Body here"); + }); + + it("returns hasFrontmatter false when absent", () => { + const { frontmatter, hasFrontmatter } = parseFrontmatter("no frontmatter"); + expect(hasFrontmatter).toBe(false); + expect(frontmatter).toEqual({}); + }); +}); + +describe("countExamples", () => { + it("counts blocks", () => { + expect(countExamples("a x b y")).toBe(2); + expect(countExamples("none")).toBe(0); + }); +}); diff --git a/scripts/agent-plugin-lib.js b/scripts/agent-plugin-lib.js new file mode 100644 index 0000000..02c991c --- /dev/null +++ b/scripts/agent-plugin-lib.js @@ -0,0 +1,26 @@ +#!/usr/bin/env node +/** + * agent-plugin-lib.js — shared helpers for the agent-plugin tooling + * (validate / registry / create / test). Pure, side-effect-free functions + * over plugin files so each CLI stays thin and testable. + */ +import { readFileSync, existsSync, readdirSync, statSync } from "fs"; +import { join } from "path"; +import semver from "semver"; + +/** Parse simple single-line `key: value` YAML frontmatter from a Markdown string. */ +export function parseFrontmatter(content) { + const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); + if (!m) return { frontmatter: {}, body: content, hasFrontmatter: false }; + const frontmatter = {}; + for (const line of m[1].split(/\r?\n/)) { + const km = line.match(/^([A-Za-z][\w-]*):\s*(.*)$/); + if (km) frontmatter[km[1]] = km[2].trim(); + } + return { frontmatter, body: m[2], hasFrontmatter: true }; +} + +/** Count `` blocks in an agent description. */ +export function countExamples(description = "") { + return (description.match(//g) || []).length; +} From f2caeac040f7b1b7a16891108deca46b62e87dc7 Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Thu, 28 May 2026 08:10:53 -0400 Subject: [PATCH 05/20] feat(plugins): add catalog builder and semver range check --- scripts/__tests__/agent-plugin-lib.test.js | 50 +++++++++++++++++++++- scripts/agent-plugin-lib.js | 31 ++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/scripts/__tests__/agent-plugin-lib.test.js b/scripts/__tests__/agent-plugin-lib.test.js index 25142b9..f0bc04e 100644 --- a/scripts/__tests__/agent-plugin-lib.test.js +++ b/scripts/__tests__/agent-plugin-lib.test.js @@ -1,5 +1,28 @@ import { describe, it, expect } from "vitest"; -import { parseFrontmatter, countExamples } from "../agent-plugin-lib.js"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { + parseFrontmatter, + countExamples, + buildCatalog, + satisfiesRange, +} from "../agent-plugin-lib.js"; + +function makePlugin(root, name, version, deps = {}) { + const dir = join(root, name); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "plugin.json"), + JSON.stringify({ + name, + version, + description: `${name} agent`, + dependencies: { agents: deps }, + }), + ); + return dir; +} describe("parseFrontmatter", () => { it("splits frontmatter from body", () => { @@ -25,3 +48,28 @@ describe("countExamples", () => { expect(countExamples("none")).toBe(0); }); }); + +describe("buildCatalog + satisfiesRange", () => { + it("indexes plugins by name with version and deps", () => { + const root = mkdtempSync(join(tmpdir(), "plg-")); + try { + makePlugin(root, "alpha", "1.0.0"); + makePlugin(root, "beta", "2.1.0", { alpha: "^1.0.0" }); + const catalog = buildCatalog(root); + expect(Object.keys(catalog).sort()).toEqual(["alpha", "beta"]); + expect(catalog.beta.version).toBe("2.1.0"); + expect(catalog.beta.deps).toEqual({ alpha: "^1.0.0" }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("returns empty catalog for a missing root", () => { + expect(buildCatalog(join(tmpdir(), "does-not-exist-xyz"))).toEqual({}); + }); + + it("satisfiesRange wraps semver", () => { + expect(satisfiesRange("1.2.0", "^1.0.0")).toBe(true); + expect(satisfiesRange("2.0.0", "^1.0.0")).toBe(false); + }); +}); diff --git a/scripts/agent-plugin-lib.js b/scripts/agent-plugin-lib.js index 02c991c..10d7d6a 100644 --- a/scripts/agent-plugin-lib.js +++ b/scripts/agent-plugin-lib.js @@ -24,3 +24,34 @@ export function parseFrontmatter(content) { export function countExamples(description = "") { return (description.match(//g) || []).length; } + +/** Read and parse a plugin's plugin.json. Throws if absent. */ +export function loadManifest(pluginDir) { + const p = join(pluginDir, "plugin.json"); + if (!existsSync(p)) throw new Error(`No plugin.json in ${pluginDir}`); + return JSON.parse(readFileSync(p, "utf-8")); +} + +/** Build a catalog { name -> { version, dir, manifest, deps } } from a plugins root. */ +export function buildCatalog(pluginsRoot) { + const catalog = {}; + if (!existsSync(pluginsRoot)) return catalog; + for (const entry of readdirSync(pluginsRoot)) { + const dir = join(pluginsRoot, entry); + if (!statSync(dir).isDirectory()) continue; + if (!existsSync(join(dir, "plugin.json"))) continue; + const manifest = loadManifest(dir); + catalog[manifest.name] = { + version: manifest.version, + dir, + manifest, + deps: manifest.dependencies?.agents ?? {}, + }; + } + return catalog; +} + +/** True if `version` satisfies the semver `range`. */ +export function satisfiesRange(version, range) { + return semver.satisfies(version, range, { includePrerelease: true }); +} From ef963413d6f315ca3258629e693fc33dda2799d7 Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Thu, 28 May 2026 08:12:42 -0400 Subject: [PATCH 06/20] feat(plugins): add transitive dependency resolver with cycle detection --- scripts/__tests__/agent-plugin-lib.test.js | 37 +++++++++++++ scripts/agent-plugin-lib.js | 62 ++++++++++++++++++++++ 2 files changed, 99 insertions(+) diff --git a/scripts/__tests__/agent-plugin-lib.test.js b/scripts/__tests__/agent-plugin-lib.test.js index f0bc04e..5d4fe6c 100644 --- a/scripts/__tests__/agent-plugin-lib.test.js +++ b/scripts/__tests__/agent-plugin-lib.test.js @@ -7,6 +7,7 @@ import { countExamples, buildCatalog, satisfiesRange, + resolveDependencies, } from "../agent-plugin-lib.js"; function makePlugin(root, name, version, deps = {}) { @@ -73,3 +74,39 @@ describe("buildCatalog + satisfiesRange", () => { expect(satisfiesRange("2.0.0", "^1.0.0")).toBe(false); }); }); + +describe("resolveDependencies", () => { + const catalog = { + a: { version: "1.0.0", deps: {} }, + b: { version: "1.0.0", deps: { a: "^1.0.0" } }, + c: { version: "1.0.0", deps: { b: "^1.0.0", a: "^1.0.0" } }, + }; + + it("orders dependencies before dependents", () => { + const { order, errors } = resolveDependencies(catalog, "c"); + expect(errors).toEqual([]); + expect(order.indexOf("a")).toBeLessThan(order.indexOf("b")); + expect(order.indexOf("b")).toBeLessThan(order.indexOf("c")); + expect(order[order.length - 1]).toBe("c"); + }); + + it("flags a missing dependency", () => { + const { errors } = resolveDependencies({ x: { version: "1.0.0", deps: { y: "^1.0.0" } } }, "x"); + expect(errors.some((e) => e.code === "missing")).toBe(true); + }); + + it("flags a version mismatch", () => { + const c = { p: { version: "1.0.0", deps: { q: "^2.0.0" } }, q: { version: "1.0.0", deps: {} } }; + const { errors } = resolveDependencies(c, "p"); + expect(errors.some((e) => e.code === "version")).toBe(true); + }); + + it("detects a cycle", () => { + const c = { + m: { version: "1.0.0", deps: { n: "^1.0.0" } }, + n: { version: "1.0.0", deps: { m: "^1.0.0" } }, + }; + const { errors } = resolveDependencies(c, "m"); + expect(errors.some((e) => e.code === "cycle")).toBe(true); + }); +}); diff --git a/scripts/agent-plugin-lib.js b/scripts/agent-plugin-lib.js index 10d7d6a..fbc98a9 100644 --- a/scripts/agent-plugin-lib.js +++ b/scripts/agent-plugin-lib.js @@ -55,3 +55,65 @@ export function buildCatalog(pluginsRoot) { export function satisfiesRange(version, range) { return semver.satisfies(version, range, { includePrerelease: true }); } + +/** + * Resolve the transitive agent-dependency graph rooted at `rootName`. + * Returns { order, errors, involved }. `order` lists deps before dependents + * (topological). `errors` collects every problem: missing deps, semver + * mismatches, and cycles. Errors do not short-circuit — all are reported. + */ +export function resolveDependencies(catalog, rootName) { + const errors = []; + const involved = new Set(); + const edges = {}; // name -> [dependency names] + + function visit(name, chain) { + if (!catalog[name]) { + const by = chain.length ? ` (required by ${chain.join(" -> ")})` : ""; + errors.push({ code: "missing", message: `"${name}" not found in catalog${by}` }); + return; + } + if (involved.has(name)) return; + involved.add(name); + edges[name] = []; + for (const [dep, range] of Object.entries(catalog[name].deps || {})) { + edges[name].push(dep); + if (!catalog[dep]) { + errors.push({ + code: "missing", + message: `"${dep}" not found (required by ${[...chain, name].join(" -> ")})`, + }); + continue; + } + if (!satisfiesRange(catalog[dep].version, range)) { + errors.push({ + code: "version", + message: `"${dep}@${catalog[dep].version}" does not satisfy "${range}" (required by ${name})`, + }); + } + visit(dep, [...chain, name]); + } + } + visit(rootName, []); + + // Kahn topological sort over involved nodes (edge n -> d means n depends on d). + const indeg = {}; + for (const n of involved) indeg[n] = (edges[n] || []).filter((d) => involved.has(d)).length; + const queue = [...involved].filter((n) => indeg[n] === 0); + const order = []; + while (queue.length) { + const n = queue.shift(); + order.push(n); + for (const m of involved) { + if ((edges[m] || []).includes(n)) { + indeg[m]--; + if (indeg[m] === 0) queue.push(m); + } + } + } + if (order.length !== involved.size) { + const cyclic = [...involved].filter((n) => !order.includes(n)); + errors.push({ code: "cycle", message: `dependency cycle involving: ${cyclic.join(", ")}` }); + } + return { order, errors, involved: [...involved] }; +} From 3c794cabfd41e826514630c2b5391bc46cc5e839 Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Thu, 28 May 2026 08:18:47 -0400 Subject: [PATCH 07/20] fix(plugins): harden frontmatter list parsing and null-safe example count --- scripts/__tests__/agent-plugin-lib.test.js | 12 +++++++++++ scripts/agent-plugin-lib.js | 25 ++++++++++++++++++---- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/scripts/__tests__/agent-plugin-lib.test.js b/scripts/__tests__/agent-plugin-lib.test.js index 5d4fe6c..c2870a7 100644 --- a/scripts/__tests__/agent-plugin-lib.test.js +++ b/scripts/__tests__/agent-plugin-lib.test.js @@ -41,6 +41,13 @@ describe("parseFrontmatter", () => { expect(hasFrontmatter).toBe(false); expect(frontmatter).toEqual({}); }); + + it("collects YAML block-list values into a comma-joined string", () => { + const md = "---\nname: foo\ntools:\n - Read\n - Write\n---\nbody"; + const { frontmatter } = parseFrontmatter(md); + expect(frontmatter.name).toBe("foo"); + expect(frontmatter.tools).toBe("Read, Write"); + }); }); describe("countExamples", () => { @@ -48,6 +55,11 @@ describe("countExamples", () => { expect(countExamples("a x b y")).toBe(2); expect(countExamples("none")).toBe(0); }); + + it("treats null/undefined as zero", () => { + expect(countExamples(null)).toBe(0); + expect(countExamples(undefined)).toBe(0); + }); }); describe("buildCatalog + satisfiesRange", () => { diff --git a/scripts/agent-plugin-lib.js b/scripts/agent-plugin-lib.js index fbc98a9..fec4e96 100644 --- a/scripts/agent-plugin-lib.js +++ b/scripts/agent-plugin-lib.js @@ -8,21 +8,38 @@ import { readFileSync, existsSync, readdirSync, statSync } from "fs"; import { join } from "path"; import semver from "semver"; -/** Parse simple single-line `key: value` YAML frontmatter from a Markdown string. */ +/** + * Parse single-line `key: value` YAML frontmatter from a Markdown string. + * Also supports YAML block-list values (a key with an empty inline value + * followed by indented `- item` lines); list items are joined with ", " to + * match the inline `tools: A, B` convention used across this repo's agents. + * Multi-line scalar/nested-map values beyond this are not supported. + */ export function parseFrontmatter(content) { const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); if (!m) return { frontmatter: {}, body: content, hasFrontmatter: false }; const frontmatter = {}; + const listAccum = {}; + let currentKey = null; for (const line of m[1].split(/\r?\n/)) { const km = line.match(/^([A-Za-z][\w-]*):\s*(.*)$/); - if (km) frontmatter[km[1]] = km[2].trim(); + if (km) { + currentKey = km[1]; + frontmatter[currentKey] = km[2].trim(); + continue; + } + const item = line.match(/^\s*-\s+(.*)$/); + if (item && currentKey && (frontmatter[currentKey] === "" || currentKey in listAccum)) { + (listAccum[currentKey] ??= []).push(item[1].trim()); + frontmatter[currentKey] = listAccum[currentKey].join(", "); + } } return { frontmatter, body: m[2], hasFrontmatter: true }; } -/** Count `` blocks in an agent description. */ +/** Count `` blocks in an agent description. Null/undefined → 0. */ export function countExamples(description = "") { - return (description.match(//g) || []).length; + return (String(description ?? "").match(//g) || []).length; } /** Read and parse a plugin's plugin.json. Throws if absent. */ From f97f9d6f873f211a920dcf602e10c3b1867d7fa9 Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Thu, 28 May 2026 08:21:43 -0400 Subject: [PATCH 08/20] feat(plugins): add agent plugin manifest JSON schema --- .claude/agent-plugin.schema.json | 46 +++++++++++++++++++ .gitignore | 1 + scripts/__tests__/agent-plugin-schema.test.js | 35 ++++++++++++++ 3 files changed, 82 insertions(+) create mode 100644 .claude/agent-plugin.schema.json create mode 100644 scripts/__tests__/agent-plugin-schema.test.js diff --git a/.claude/agent-plugin.schema.json b/.claude/agent-plugin.schema.json new file mode 100644 index 0000000..f18f0c6 --- /dev/null +++ b/.claude/agent-plugin.schema.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://aurelius.dev/agent-plugin.schema.json", + "title": "Agent Plugin Manifest", + "type": "object", + "additionalProperties": false, + "required": ["name", "version", "description"], + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$", + "description": "kebab-case; must match directory and agent.md frontmatter name" + }, + "version": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?$" + }, + "description": { "type": "string", "minLength": 1 }, + "author": { "type": "string" }, + "license": { "type": "string" }, + "agent": { "type": "string", "default": "agent.md" }, + "dependencies": { + "type": "object", + "additionalProperties": false, + "properties": { + "agents": { + "type": "object", + "additionalProperties": { "type": "string", "minLength": 1 } + }, + "skills": { "type": "array", "items": { "type": "string" } }, + "tools": { "type": "array", "items": { "type": "string" } } + } + }, + "hooks": { + "type": "object", + "additionalProperties": false, + "properties": { + "preInstall": { "type": "string" }, + "postInstall": { "type": "string" }, + "preUninstall": { "type": "string" }, + "postUninstall": { "type": "string" } + } + }, + "tests": { "type": "string" } + } +} diff --git a/.gitignore b/.gitignore index 16db006..0880470 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ !.claude/settings.json !.claude/pipeline.config.json !.claude/pipeline.config.schema.json +!.claude/agent-plugin.schema.json !.claude/test-fixtures/ # Claude Code temporary/user-specific files diff --git a/scripts/__tests__/agent-plugin-schema.test.js b/scripts/__tests__/agent-plugin-schema.test.js new file mode 100644 index 0000000..28bfa22 --- /dev/null +++ b/scripts/__tests__/agent-plugin-schema.test.js @@ -0,0 +1,35 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { readFileSync } from "fs"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; + +const root = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const schemaPath = join(root, ".claude", "agent-plugin.schema.json"); + +let validate; +beforeAll(async () => { + const { default: Ajv2020 } = await import("ajv/dist/2020.js"); + const schema = JSON.parse(readFileSync(schemaPath, "utf-8")); + validate = new Ajv2020({ allErrors: true, strict: false }).compile(schema); +}); + +const base = { name: "demo-agent", version: "1.0.0", description: "A demo agent" }; + +describe("agent-plugin.schema.json", () => { + it("accepts a minimal valid manifest", () => { + expect(validate(base)).toBe(true); + }); + it("rejects a missing name", () => { + const { name, ...noName } = base; + expect(validate(noName)).toBe(false); + }); + it("rejects a non-kebab name", () => { + expect(validate({ ...base, name: "Demo_Agent" })).toBe(false); + }); + it("rejects an unknown hook key", () => { + expect(validate({ ...base, hooks: { onClick: "x.sh" } })).toBe(false); + }); + it("rejects unknown top-level keys", () => { + expect(validate({ ...base, bogus: true })).toBe(false); + }); +}); From 16b5570cec5cf671134f8d067134b5a6ca4688a4 Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Thu, 28 May 2026 08:23:52 -0400 Subject: [PATCH 09/20] test(plugins): add agent plugin fixtures --- .gitignore | 3 ++- .../fixtures/agent-plugins/depends-on-base/agent.md | 10 ++++++++++ .../agent-plugins/depends-on-base/plugin.json | 6 ++++++ .../fixtures/agent-plugins/missing-dep/agent.md | 9 +++++++++ .../fixtures/agent-plugins/missing-dep/plugin.json | 6 ++++++ .../fixtures/agent-plugins/name-mismatch/agent.md | 9 +++++++++ .../fixtures/agent-plugins/name-mismatch/plugin.json | 5 +++++ .../fixtures/agent-plugins/valid-base/agent.md | 12 ++++++++++++ .../fixtures/agent-plugins/valid-base/plugin.json | 7 +++++++ .../agent-plugins/valid-base/tests/plugin.test.json | 11 +++++++++++ 10 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 scripts/__tests__/fixtures/agent-plugins/depends-on-base/agent.md create mode 100644 scripts/__tests__/fixtures/agent-plugins/depends-on-base/plugin.json create mode 100644 scripts/__tests__/fixtures/agent-plugins/missing-dep/agent.md create mode 100644 scripts/__tests__/fixtures/agent-plugins/missing-dep/plugin.json create mode 100644 scripts/__tests__/fixtures/agent-plugins/name-mismatch/agent.md create mode 100644 scripts/__tests__/fixtures/agent-plugins/name-mismatch/plugin.json create mode 100644 scripts/__tests__/fixtures/agent-plugins/valid-base/agent.md create mode 100644 scripts/__tests__/fixtures/agent-plugins/valid-base/plugin.json create mode 100644 scripts/__tests__/fixtures/agent-plugins/valid-base/tests/plugin.test.json diff --git a/.gitignore b/.gitignore index 0880470..ba15c6f 100644 --- a/.gitignore +++ b/.gitignore @@ -38,7 +38,8 @@ htmlcov/ playwright-report/ .playwright/ test-results/ -scripts/__tests__/fixtures/ +scripts/__tests__/fixtures/* +!scripts/__tests__/fixtures/agent-plugins/ # Environment .env diff --git a/scripts/__tests__/fixtures/agent-plugins/depends-on-base/agent.md b/scripts/__tests__/fixtures/agent-plugins/depends-on-base/agent.md new file mode 100644 index 0000000..0be63d6 --- /dev/null +++ b/scripts/__tests__/fixtures/agent-plugins/depends-on-base/agent.md @@ -0,0 +1,10 @@ +--- +name: depends-on-base +description: Dependent agent. Context: t user: 'a' assistant: 'b' Context: u user: 'c' assistant: 'd' +tools: Read +model: sonnet +--- + +## When to Use This Agent + +Use after valid-base. diff --git a/scripts/__tests__/fixtures/agent-plugins/depends-on-base/plugin.json b/scripts/__tests__/fixtures/agent-plugins/depends-on-base/plugin.json new file mode 100644 index 0000000..5671b8f --- /dev/null +++ b/scripts/__tests__/fixtures/agent-plugins/depends-on-base/plugin.json @@ -0,0 +1,6 @@ +{ + "name": "depends-on-base", + "version": "1.0.0", + "description": "Depends on valid-base", + "dependencies": { "agents": { "valid-base": "^1.0.0" } } +} diff --git a/scripts/__tests__/fixtures/agent-plugins/missing-dep/agent.md b/scripts/__tests__/fixtures/agent-plugins/missing-dep/agent.md new file mode 100644 index 0000000..38d1a3e --- /dev/null +++ b/scripts/__tests__/fixtures/agent-plugins/missing-dep/agent.md @@ -0,0 +1,9 @@ +--- +name: missing-dep +description: Bad deps. Context: t user: 'a' assistant: 'b' Context: u user: 'c' assistant: 'd' +tools: Read +--- + +## When to Use This Agent + +Never — it has a missing dependency. diff --git a/scripts/__tests__/fixtures/agent-plugins/missing-dep/plugin.json b/scripts/__tests__/fixtures/agent-plugins/missing-dep/plugin.json new file mode 100644 index 0000000..1b59599 --- /dev/null +++ b/scripts/__tests__/fixtures/agent-plugins/missing-dep/plugin.json @@ -0,0 +1,6 @@ +{ + "name": "missing-dep", + "version": "1.0.0", + "description": "Depends on a plugin that does not exist", + "dependencies": { "agents": { "ghost": "^1.0.0" } } +} diff --git a/scripts/__tests__/fixtures/agent-plugins/name-mismatch/agent.md b/scripts/__tests__/fixtures/agent-plugins/name-mismatch/agent.md new file mode 100644 index 0000000..8f07c31 --- /dev/null +++ b/scripts/__tests__/fixtures/agent-plugins/name-mismatch/agent.md @@ -0,0 +1,9 @@ +--- +name: totally-different +description: Mismatch. Context: t user: 'a' assistant: 'b' Context: u user: 'c' assistant: 'd' +tools: Read +--- + +## When to Use This Agent + +Used to test the three-way name consistency check. diff --git a/scripts/__tests__/fixtures/agent-plugins/name-mismatch/plugin.json b/scripts/__tests__/fixtures/agent-plugins/name-mismatch/plugin.json new file mode 100644 index 0000000..4a17a9f --- /dev/null +++ b/scripts/__tests__/fixtures/agent-plugins/name-mismatch/plugin.json @@ -0,0 +1,5 @@ +{ + "name": "name-mismatch", + "version": "1.0.0", + "description": "Frontmatter name will not match" +} diff --git a/scripts/__tests__/fixtures/agent-plugins/valid-base/agent.md b/scripts/__tests__/fixtures/agent-plugins/valid-base/agent.md new file mode 100644 index 0000000..48c4531 --- /dev/null +++ b/scripts/__tests__/fixtures/agent-plugins/valid-base/agent.md @@ -0,0 +1,12 @@ +--- +name: valid-base +description: Use this agent to test the plugin system. Context: a test user: 'do x' assistant: 'doing x' Context: another user: 'do y' assistant: 'doing y' +tools: Read, Write +model: sonnet +--- + +You are a test agent. + +## When to Use This Agent + +Use this agent only in tests. diff --git a/scripts/__tests__/fixtures/agent-plugins/valid-base/plugin.json b/scripts/__tests__/fixtures/agent-plugins/valid-base/plugin.json new file mode 100644 index 0000000..1e16302 --- /dev/null +++ b/scripts/__tests__/fixtures/agent-plugins/valid-base/plugin.json @@ -0,0 +1,7 @@ +{ + "name": "valid-base", + "version": "1.0.0", + "description": "A valid base agent plugin for tests", + "agent": "agent.md", + "tests": "tests/plugin.test.json" +} diff --git a/scripts/__tests__/fixtures/agent-plugins/valid-base/tests/plugin.test.json b/scripts/__tests__/fixtures/agent-plugins/valid-base/tests/plugin.test.json new file mode 100644 index 0000000..3bec3c3 --- /dev/null +++ b/scripts/__tests__/fixtures/agent-plugins/valid-base/tests/plugin.test.json @@ -0,0 +1,11 @@ +{ + "assert": [ + "manifest.valid", + "frontmatter.has(name,description,tools)", + "frontmatter.model in (opus,sonnet,haiku)", + "deps.resolve", + "hooks.executable", + "prompt.section('When to Use This Agent')", + "description.examples >= 2" + ] +} From b9cfdeda2058577259a3d73c0c2ed2781a6c4b0f Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Thu, 28 May 2026 08:29:22 -0400 Subject: [PATCH 10/20] feat(plugins): add manifest + structural validator --- .../__tests__/validate-agent-plugin.test.js | 44 ++++ scripts/validate-agent-plugin.js | 225 ++++++++++++++++++ scripts/validate-agent-plugin.sh | 4 + 3 files changed, 273 insertions(+) create mode 100644 scripts/__tests__/validate-agent-plugin.test.js create mode 100644 scripts/validate-agent-plugin.js create mode 100644 scripts/validate-agent-plugin.sh diff --git a/scripts/__tests__/validate-agent-plugin.test.js b/scripts/__tests__/validate-agent-plugin.test.js new file mode 100644 index 0000000..3a02e16 --- /dev/null +++ b/scripts/__tests__/validate-agent-plugin.test.js @@ -0,0 +1,44 @@ +import { describe, it, expect } from "vitest"; +import { execFileSync } from "child_process"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SCRIPT = join(__dirname, "..", "validate-agent-plugin.js"); +const FIX = join(__dirname, "fixtures", "agent-plugins"); + +function run(args) { + try { + const stdout = execFileSync("node", [SCRIPT, ...args], { encoding: "utf-8", timeout: 30000 }); + return { stdout, exitCode: 0 }; + } catch (e) { + return { stdout: e.stdout || "", stderr: e.stderr || "", exitCode: e.status }; + } +} + +describe("validate-agent-plugin.js", () => { + it("passes a valid plugin (exit 0)", () => { + const r = run(["--dir", join(FIX, "valid-base"), "--json"]); + expect(r.exitCode).toBe(0); + expect(JSON.parse(r.stdout).ok).toBe(true); + }); + + it("fails a name mismatch (exit 1) with a clear message", () => { + const r = run(["--dir", join(FIX, "name-mismatch"), "--json"]); + expect(r.exitCode).toBe(1); + const out = JSON.parse(r.stdout); + expect(out.ok).toBe(false); + expect(JSON.stringify(out.issues)).toMatch(/name/i); + }); + + it("exits 2 on a missing directory", () => { + const r = run(["--dir", join(FIX, "nope"), "--json"]); + expect(r.exitCode).toBe(2); + }); + + it("shows usage on --help (exit 0)", () => { + const r = run(["--help"]); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain("Usage:"); + }); +}); diff --git a/scripts/validate-agent-plugin.js b/scripts/validate-agent-plugin.js new file mode 100644 index 0000000..d480b1e --- /dev/null +++ b/scripts/validate-agent-plugin.js @@ -0,0 +1,225 @@ +#!/usr/bin/env node +/** + * validate-agent-plugin.js — Validate an agent plugin's manifest (against the + * JSON Schema) plus structural checks the schema cannot express: + * - manifest.name matches directory name AND agent.md frontmatter name + * - declared hook scripts exist + * - the agent file exists + * - skill deps exist under .claude/skills/, tool deps exist as repo files + * - agent.md frontmatter has name/description/tools; model/permissionMode valid + * + * Usage: + * node scripts/validate-agent-plugin.js --dir [--json] + * node scripts/validate-agent-plugin.js --all [--json] + * + * Exit codes: 0 valid · 1 invalid · 2 usage/IO error + */ +import { readFileSync, existsSync, readdirSync, statSync } from "fs"; +import { dirname, resolve, basename, join } from "path"; +import { fileURLToPath } from "url"; +import { + parseFrontmatter, + loadManifest, + buildCatalog, + resolveDependencies, +} from "./agent-plugin-lib.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dirname, ".."); +const PLUGINS_ROOT = join(repoRoot, ".claude", "agent-plugins"); +const SCHEMA = join(repoRoot, ".claude", "agent-plugin.schema.json"); +const SKILLS_ROOT = join(repoRoot, ".claude", "skills"); +const VALID_MODELS = ["opus", "sonnet", "haiku"]; +const VALID_PERM = ["default", "acceptEdits", "bypassPermissions", "plan"]; + +function parseArgs(argv) { + const out = { dir: null, all: false, json: false }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--dir") out.dir = resolve(argv[++i]); + else if (a === "--all") out.all = true; + else if (a === "--json") out.json = true; + else if (a === "-h" || a === "--help") { + printHelp(); + process.exit(0); + } else { + console.error(`Unknown argument: ${a}`); + printHelp(); + process.exit(2); + } + } + return out; +} + +function printHelp() { + console.log(`Usage: node scripts/validate-agent-plugin.js (--dir | --all) [--json] + +Options: + --dir Validate a single plugin directory + --all Validate every plugin under .claude/agent-plugins/ + --json Machine-readable output + -h, --help Show this message`); +} + +async function compileSchema() { + const { default: Ajv2020 } = await import("ajv/dist/2020.js"); + const schema = JSON.parse(readFileSync(SCHEMA, "utf-8")); + return new Ajv2020({ allErrors: true, strict: false }).compile(schema); +} + +function validatePlugin(dir, validate, catalog) { + const issues = []; + const manifest = loadManifest(dir); // may throw -> caller treats as IO error + + if (!validate(manifest)) { + for (const e of validate.errors ?? []) { + issues.push({ path: e.instancePath || "(root)", message: e.message }); + } + } + + // name: dir vs manifest vs frontmatter + const dirName = basename(dir); + if (manifest.name && manifest.name !== dirName) { + issues.push({ + path: "name", + message: `manifest name "${manifest.name}" != directory "${dirName}"`, + }); + } + + const agentFile = join(dir, manifest.agent || "agent.md"); + if (!existsSync(agentFile)) { + issues.push({ + path: "agent", + message: `agent file not found: ${manifest.agent || "agent.md"}`, + }); + } else { + const { frontmatter, hasFrontmatter } = parseFrontmatter(readFileSync(agentFile, "utf-8")); + if (!hasFrontmatter) { + issues.push({ path: "agent", message: "agent file has no frontmatter" }); + } else { + if (manifest.name && frontmatter.name && frontmatter.name !== manifest.name) { + issues.push({ + path: "agent.frontmatter.name", + message: `frontmatter name "${frontmatter.name}" != manifest name "${manifest.name}"`, + }); + } + for (const f of ["name", "description", "tools"]) { + if (!frontmatter[f]) + issues.push({ path: `agent.frontmatter.${f}`, message: `missing "${f}"` }); + } + if (frontmatter.model && !VALID_MODELS.includes(frontmatter.model)) { + issues.push({ + path: "agent.frontmatter.model", + message: `invalid model "${frontmatter.model}"`, + }); + } + if (frontmatter.permissionMode && !VALID_PERM.includes(frontmatter.permissionMode)) { + issues.push({ + path: "agent.frontmatter.permissionMode", + message: `invalid permissionMode "${frontmatter.permissionMode}"`, + }); + } + } + } + + // hooks exist + for (const [hook, rel] of Object.entries(manifest.hooks ?? {})) { + if (!existsSync(join(dir, rel))) + issues.push({ path: `hooks.${hook}`, message: `hook script not found: ${rel}` }); + } + + // skills exist + for (const skill of manifest.dependencies?.skills ?? []) { + if (!existsSync(join(SKILLS_ROOT, skill))) + issues.push({ path: "dependencies.skills", message: `skill not found: ${skill}` }); + } + // tools exist (repo-relative) + for (const tool of manifest.dependencies?.tools ?? []) { + if (!existsSync(join(repoRoot, tool))) + issues.push({ path: "dependencies.tools", message: `tool not found: ${tool}` }); + } + + // agent deps resolve (against the catalog) + if (catalog[manifest.name]) { + const { errors } = resolveDependencies(catalog, manifest.name); + for (const e of errors) issues.push({ path: "dependencies.agents", message: e.message }); + } + + return { name: manifest.name ?? dirName, dir, ok: issues.length === 0, issues }; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + if (!args.dir && !args.all) { + printHelp(); + process.exit(2); + } + + let validate, catalog; + try { + validate = await compileSchema(); + catalog = buildCatalog(PLUGINS_ROOT); + } catch (e) { + if (args.json) console.log(JSON.stringify({ ok: false, error: e.message })); + else console.error(`✗ ${e.message}`); + process.exit(2); + } + + let dirs; + if (args.all) { + dirs = existsSync(PLUGINS_ROOT) + ? readdirSync(PLUGINS_ROOT) + .map((d) => join(PLUGINS_ROOT, d)) + .filter((d) => statSync(d).isDirectory() && existsSync(join(d, "plugin.json"))) + : []; + } else { + if (!existsSync(join(args.dir, "plugin.json"))) { + const msg = `No plugin.json found in ${args.dir}`; + if (args.json) console.log(JSON.stringify({ ok: false, error: msg })); + else console.error(`✗ ${msg}`); + process.exit(2); + } + dirs = [args.dir]; + // Include the standalone dir in the catalog so its deps resolve. + const m = loadManifest(args.dir); + catalog[m.name] = { + version: m.version, + dir: args.dir, + manifest: m, + deps: m.dependencies?.agents ?? {}, + }; + } + + const results = []; + for (const d of dirs) { + try { + results.push(validatePlugin(d, validate, catalog)); + } catch (e) { + results.push({ + name: basename(d), + dir: d, + ok: false, + issues: [{ path: "(io)", message: e.message }], + }); + } + } + + const ok = results.every((r) => r.ok); + if (args.json) { + console.log(JSON.stringify({ ok, results, issues: results.flatMap((r) => r.issues) }, null, 2)); + } else { + for (const r of results) { + if (r.ok) console.log(`✓ ${r.name}`); + else { + console.log(`✗ ${r.name} (${r.issues.length} issue(s)):`); + for (const i of r.issues) console.log(` ${i.path}: ${i.message}`); + } + } + } + process.exit(ok ? 0 : 1); +} + +main().catch((e) => { + console.error(`✗ Unhandled error: ${e.stack ?? e.message}`); + process.exit(2); +}); diff --git a/scripts/validate-agent-plugin.sh b/scripts/validate-agent-plugin.sh new file mode 100644 index 0000000..79c914f --- /dev/null +++ b/scripts/validate-agent-plugin.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec node "$DIR/validate-agent-plugin.js" "$@" From 4352a72d24c4f6760f093747d7062218df49475c Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Thu, 28 May 2026 09:36:56 -0400 Subject: [PATCH 11/20] fix(plugins): harden validator IO handling, sibling dep resolution, and schema error detail buildCatalog now skips unreadable/malformed plugin.json entries so one bad plugin can't abort an --all scan. The validator seeds its catalog from sibling plugins in --dir mode (no false-positive missing-dep errors), adds a --plugins-root override, rejects --dir+--all, and surfaces the offending property in schema errors via formatSchemaError. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/__tests__/agent-plugin-lib.test.js | 14 +++ .../__tests__/validate-agent-plugin.test.js | 116 +++++++++++++++++- scripts/agent-plugin-lib.js | 20 ++- scripts/validate-agent-plugin.js | 99 +++++++++------ 4 files changed, 206 insertions(+), 43 deletions(-) diff --git a/scripts/__tests__/agent-plugin-lib.test.js b/scripts/__tests__/agent-plugin-lib.test.js index c2870a7..4db9c6d 100644 --- a/scripts/__tests__/agent-plugin-lib.test.js +++ b/scripts/__tests__/agent-plugin-lib.test.js @@ -81,6 +81,20 @@ describe("buildCatalog + satisfiesRange", () => { expect(buildCatalog(join(tmpdir(), "does-not-exist-xyz"))).toEqual({}); }); + it("skips a plugin with malformed plugin.json without throwing", () => { + const root = mkdtempSync(join(tmpdir(), "plg-bad-")); + try { + makePlugin(root, "good", "1.0.0"); + const badDir = join(root, "bad"); + mkdirSync(badDir, { recursive: true }); + writeFileSync(join(badDir, "plugin.json"), "{ not valid json"); + const catalog = buildCatalog(root); + expect(Object.keys(catalog)).toEqual(["good"]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + it("satisfiesRange wraps semver", () => { expect(satisfiesRange("1.2.0", "^1.0.0")).toBe(true); expect(satisfiesRange("2.0.0", "^1.0.0")).toBe(false); diff --git a/scripts/__tests__/validate-agent-plugin.test.js b/scripts/__tests__/validate-agent-plugin.test.js index 3a02e16..d479651 100644 --- a/scripts/__tests__/validate-agent-plugin.test.js +++ b/scripts/__tests__/validate-agent-plugin.test.js @@ -1,5 +1,7 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { execFileSync } from "child_process"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "fs"; +import { tmpdir } from "os"; import { join, dirname } from "path"; import { fileURLToPath } from "url"; @@ -16,19 +18,62 @@ function run(args) { } } +const AGENT_MD = `--- +name: NAME +description: A test agent. Context: a user: 'x' assistant: 'y' Context: b user: 'p' assistant: 'q' +tools: Read, Write +model: sonnet +--- + +You are a test agent. + +## When to Use This Agent + +Only in tests. +`; + +function writePlugin(root, name, manifest, { agentName = name } = {}) { + const dir = join(root, name); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "plugin.json"), + typeof manifest === "string" ? manifest : JSON.stringify(manifest), + ); + writeFileSync(join(dir, "agent.md"), AGENT_MD.replace("NAME", agentName)); + return dir; +} + describe("validate-agent-plugin.js", () => { + let tmp; + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "vap-")); + }); + afterEach(() => rmSync(tmp, { recursive: true, force: true })); + it("passes a valid plugin (exit 0)", () => { const r = run(["--dir", join(FIX, "valid-base"), "--json"]); expect(r.exitCode).toBe(0); expect(JSON.parse(r.stdout).ok).toBe(true); }); - it("fails a name mismatch (exit 1) with a clear message", () => { + it("fails a name mismatch (exit 1) and points at the frontmatter name", () => { const r = run(["--dir", join(FIX, "name-mismatch"), "--json"]); expect(r.exitCode).toBe(1); const out = JSON.parse(r.stdout); expect(out.ok).toBe(false); - expect(JSON.stringify(out.issues)).toMatch(/name/i); + expect(out.issues.some((i) => i.path === "agent.frontmatter.name")).toBe(true); + }); + + it("resolves sibling agent deps in --dir mode (depends-on-base passes)", () => { + const r = run(["--dir", join(FIX, "depends-on-base"), "--json"]); + expect(r.exitCode).toBe(0); + expect(JSON.parse(r.stdout).ok).toBe(true); + }); + + it("reports a missing agent dependency (exit 1)", () => { + const r = run(["--dir", join(FIX, "missing-dep"), "--json"]); + expect(r.exitCode).toBe(1); + expect(JSON.stringify(JSON.parse(r.stdout).issues)).toMatch(/ghost/); }); it("exits 2 on a missing directory", () => { @@ -41,4 +86,69 @@ describe("validate-agent-plugin.js", () => { expect(r.exitCode).toBe(0); expect(r.stdout).toContain("Usage:"); }); + + it("rejects --dir and --all together (exit 2)", () => { + const r = run(["--dir", join(FIX, "valid-base"), "--all"]); + expect(r.exitCode).toBe(2); + }); + + it("reports malformed plugin.json as an issue, not a crash", () => { + writePlugin(tmp, "broken", "{ not valid json"); + const r = run(["--dir", join(tmp, "broken"), "--json"]); + expect(r.exitCode).toBe(1); + const out = JSON.parse(r.stdout); // must still be valid JSON + expect(out.ok).toBe(false); + }); + + it("surfaces the offending property on a schema violation", () => { + writePlugin(tmp, "extra-key", { + name: "extra-key", + version: "1.0.0", + description: "x", + bogusKey: true, + }); + const r = run(["--dir", join(tmp, "extra-key"), "--json"]); + expect(r.exitCode).toBe(1); + expect(JSON.stringify(JSON.parse(r.stdout).issues)).toMatch(/bogusKey/); + }); + + it("flags a missing declared hook", () => { + writePlugin(tmp, "bad-hook", { + name: "bad-hook", + version: "1.0.0", + description: "x", + hooks: { preInstall: "hooks/nope.sh" }, + }); + const r = run(["--dir", join(tmp, "bad-hook"), "--json"]); + expect(r.exitCode).toBe(1); + expect(JSON.parse(r.stdout).issues.some((i) => i.path === "hooks.preInstall")).toBe(true); + }); + + it("flags a missing skill dependency", () => { + writePlugin(tmp, "bad-skill", { + name: "bad-skill", + version: "1.0.0", + description: "x", + dependencies: { skills: ["definitely-not-a-real-skill"] }, + }); + const r = run(["--dir", join(tmp, "bad-skill"), "--json"]); + expect(r.exitCode).toBe(1); + expect(JSON.stringify(JSON.parse(r.stdout).issues)).toMatch(/skill not found/); + }); + + it("validates every plugin under --plugins-root and fails if any is invalid", () => { + writePlugin(tmp, "good-one", { name: "good-one", version: "1.0.0", description: "ok" }); + writePlugin(tmp, "bad-one", { + name: "bad-one", + version: "1.0.0", + description: "x", + bogusKey: 1, + }); + const r = run(["--all", "--plugins-root", tmp, "--json"]); + expect(r.exitCode).toBe(1); + const out = JSON.parse(r.stdout); + expect(out.count).toBe(2); + expect(out.results.find((x) => x.name === "good-one").ok).toBe(true); + expect(out.results.find((x) => x.name === "bad-one").ok).toBe(false); + }); }); diff --git a/scripts/agent-plugin-lib.js b/scripts/agent-plugin-lib.js index fec4e96..320f694 100644 --- a/scripts/agent-plugin-lib.js +++ b/scripts/agent-plugin-lib.js @@ -49,15 +49,29 @@ export function loadManifest(pluginDir) { return JSON.parse(readFileSync(p, "utf-8")); } -/** Build a catalog { name -> { version, dir, manifest, deps } } from a plugins root. */ +/** Build a catalog { name -> { version, dir, manifest, deps } } from a plugins root. + * Entries that are unreadable or have a malformed/nameless plugin.json are skipped + * (the validator surfaces those separately), so one bad plugin can't abort the scan. */ export function buildCatalog(pluginsRoot) { const catalog = {}; if (!existsSync(pluginsRoot)) return catalog; for (const entry of readdirSync(pluginsRoot)) { const dir = join(pluginsRoot, entry); - if (!statSync(dir).isDirectory()) continue; + let isDir; + try { + isDir = statSync(dir).isDirectory(); + } catch { + continue; + } + if (!isDir) continue; if (!existsSync(join(dir, "plugin.json"))) continue; - const manifest = loadManifest(dir); + let manifest; + try { + manifest = loadManifest(dir); + } catch { + continue; + } + if (!manifest || typeof manifest.name !== "string") continue; catalog[manifest.name] = { version: manifest.version, dir, diff --git a/scripts/validate-agent-plugin.js b/scripts/validate-agent-plugin.js index d480b1e..29c894e 100644 --- a/scripts/validate-agent-plugin.js +++ b/scripts/validate-agent-plugin.js @@ -4,18 +4,19 @@ * JSON Schema) plus structural checks the schema cannot express: * - manifest.name matches directory name AND agent.md frontmatter name * - declared hook scripts exist - * - the agent file exists + * - the agent file exists and is a file * - skill deps exist under .claude/skills/, tool deps exist as repo files * - agent.md frontmatter has name/description/tools; model/permissionMode valid + * - agent dependencies resolve against the catalog (siblings, in --dir mode) * * Usage: * node scripts/validate-agent-plugin.js --dir [--json] - * node scripts/validate-agent-plugin.js --all [--json] + * node scripts/validate-agent-plugin.js --all [--plugins-root ] [--json] * * Exit codes: 0 valid · 1 invalid · 2 usage/IO error */ import { readFileSync, existsSync, readdirSync, statSync } from "fs"; -import { dirname, resolve, basename, join } from "path"; +import { join, dirname, resolve, basename } from "path"; import { fileURLToPath } from "url"; import { parseFrontmatter, @@ -26,18 +27,19 @@ import { const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(__dirname, ".."); -const PLUGINS_ROOT = join(repoRoot, ".claude", "agent-plugins"); +const DEFAULT_PLUGINS_ROOT = join(repoRoot, ".claude", "agent-plugins"); const SCHEMA = join(repoRoot, ".claude", "agent-plugin.schema.json"); const SKILLS_ROOT = join(repoRoot, ".claude", "skills"); const VALID_MODELS = ["opus", "sonnet", "haiku"]; const VALID_PERM = ["default", "acceptEdits", "bypassPermissions", "plan"]; function parseArgs(argv) { - const out = { dir: null, all: false, json: false }; + const out = { dir: null, all: false, json: false, pluginsRoot: DEFAULT_PLUGINS_ROOT }; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a === "--dir") out.dir = resolve(argv[++i]); else if (a === "--all") out.all = true; + else if (a === "--plugins-root") out.pluginsRoot = resolve(argv[++i]); else if (a === "--json") out.json = true; else if (a === "-h" || a === "--help") { printHelp(); @@ -48,17 +50,22 @@ function parseArgs(argv) { process.exit(2); } } + if (out.dir && out.all) { + console.error("Use either --dir or --all, not both."); + process.exit(2); + } return out; } function printHelp() { - console.log(`Usage: node scripts/validate-agent-plugin.js (--dir | --all) [--json] + console.log(`Usage: node scripts/validate-agent-plugin.js (--dir | --all) [options] Options: - --dir Validate a single plugin directory - --all Validate every plugin under .claude/agent-plugins/ - --json Machine-readable output - -h, --help Show this message`); + --dir Validate a single plugin directory + --all Validate every plugin under the plugins root + --plugins-root Plugins root for --all (default: .claude/agent-plugins) + --json Machine-readable output + -h, --help Show this message`); } async function compileSchema() { @@ -67,17 +74,24 @@ async function compileSchema() { return new Ajv2020({ allErrors: true, strict: false }).compile(schema); } +/** Flatten an ajv error to { path, message } with the offending key surfaced. */ +function formatSchemaError(err) { + const path = err.instancePath || "(root)"; + let message = err.message ?? "validation failed"; + if (err.params?.additionalProperty) message += `: "${err.params.additionalProperty}"`; + if (err.params?.missingProperty) message += `: "${err.params.missingProperty}"`; + if (err.params?.allowedValues) message += ` (allowed: ${err.params.allowedValues.join(", ")})`; + return { path, message }; +} + function validatePlugin(dir, validate, catalog) { const issues = []; - const manifest = loadManifest(dir); // may throw -> caller treats as IO error + const manifest = loadManifest(dir); // may throw -> caller reports it as an (io) issue if (!validate(manifest)) { - for (const e of validate.errors ?? []) { - issues.push({ path: e.instancePath || "(root)", message: e.message }); - } + for (const e of validate.errors ?? []) issues.push(formatSchemaError(e)); } - // name: dir vs manifest vs frontmatter const dirName = basename(dir); if (manifest.name && manifest.name !== dirName) { issues.push({ @@ -87,10 +101,16 @@ function validatePlugin(dir, validate, catalog) { } const agentFile = join(dir, manifest.agent || "agent.md"); - if (!existsSync(agentFile)) { + let agentStat = null; + try { + agentStat = statSync(agentFile); + } catch { + /* missing */ + } + if (!agentStat || !agentStat.isFile()) { issues.push({ path: "agent", - message: `agent file not found: ${manifest.agent || "agent.md"}`, + message: `agent file not found or not a file: ${manifest.agent || "agent.md"}`, }); } else { const { frontmatter, hasFrontmatter } = parseFrontmatter(readFileSync(agentFile, "utf-8")); @@ -122,24 +142,20 @@ function validatePlugin(dir, validate, catalog) { } } - // hooks exist for (const [hook, rel] of Object.entries(manifest.hooks ?? {})) { if (!existsSync(join(dir, rel))) issues.push({ path: `hooks.${hook}`, message: `hook script not found: ${rel}` }); } - // skills exist for (const skill of manifest.dependencies?.skills ?? []) { if (!existsSync(join(SKILLS_ROOT, skill))) issues.push({ path: "dependencies.skills", message: `skill not found: ${skill}` }); } - // tools exist (repo-relative) for (const tool of manifest.dependencies?.tools ?? []) { if (!existsSync(join(repoRoot, tool))) issues.push({ path: "dependencies.tools", message: `tool not found: ${tool}` }); } - // agent deps resolve (against the catalog) if (catalog[manifest.name]) { const { errors } = resolveDependencies(catalog, manifest.name); for (const e of errors) issues.push({ path: "dependencies.agents", message: e.message }); @@ -155,10 +171,9 @@ async function main() { process.exit(2); } - let validate, catalog; + let validate; try { validate = await compileSchema(); - catalog = buildCatalog(PLUGINS_ROOT); } catch (e) { if (args.json) console.log(JSON.stringify({ ok: false, error: e.message })); else console.error(`✗ ${e.message}`); @@ -166,12 +181,21 @@ async function main() { } let dirs; + let catalog; if (args.all) { - dirs = existsSync(PLUGINS_ROOT) - ? readdirSync(PLUGINS_ROOT) - .map((d) => join(PLUGINS_ROOT, d)) - .filter((d) => statSync(d).isDirectory() && existsSync(join(d, "plugin.json"))) - : []; + catalog = buildCatalog(args.pluginsRoot); + dirs = []; + if (existsSync(args.pluginsRoot)) { + for (const d of readdirSync(args.pluginsRoot)) { + const full = join(args.pluginsRoot, d); + try { + if (statSync(full).isDirectory() && existsSync(join(full, "plugin.json"))) + dirs.push(full); + } catch { + /* skip unreadable entry */ + } + } + } } else { if (!existsSync(join(args.dir, "plugin.json"))) { const msg = `No plugin.json found in ${args.dir}`; @@ -180,14 +204,8 @@ async function main() { process.exit(2); } dirs = [args.dir]; - // Include the standalone dir in the catalog so its deps resolve. - const m = loadManifest(args.dir); - catalog[m.name] = { - version: m.version, - dir: args.dir, - manifest: m, - deps: m.dependencies?.agents ?? {}, - }; + // Seed the catalog from sibling plugins so the target's agent deps resolve. + catalog = buildCatalog(dirname(args.dir)); } const results = []; @@ -206,8 +224,15 @@ async function main() { const ok = results.every((r) => r.ok); if (args.json) { - console.log(JSON.stringify({ ok, results, issues: results.flatMap((r) => r.issues) }, null, 2)); + console.log( + JSON.stringify( + { ok, count: results.length, results, issues: results.flatMap((r) => r.issues) }, + null, + 2, + ), + ); } else { + if (args.all && results.length === 0) console.log(`No plugins found under ${args.pluginsRoot}`); for (const r of results) { if (r.ok) console.log(`✓ ${r.name}`); else { From 6be51d39689ae9c44fbd2f2dc9eaaf5cff9e14aa Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Thu, 28 May 2026 09:40:38 -0400 Subject: [PATCH 12/20] feat(plugins): add registry with dependency resolution, install/uninstall, lifecycle hooks --- scripts/__tests__/agent-registry.test.js | 73 ++++++++ scripts/agent-registry.js | 227 +++++++++++++++++++++++ scripts/agent-registry.sh | 4 + 3 files changed, 304 insertions(+) create mode 100644 scripts/__tests__/agent-registry.test.js create mode 100644 scripts/agent-registry.js create mode 100644 scripts/agent-registry.sh diff --git a/scripts/__tests__/agent-registry.test.js b/scripts/__tests__/agent-registry.test.js new file mode 100644 index 0000000..c8ef856 --- /dev/null +++ b/scripts/__tests__/agent-registry.test.js @@ -0,0 +1,73 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { execFileSync } from "child_process"; +import { mkdtempSync, mkdirSync, cpSync, rmSync, existsSync } from "fs"; +import { tmpdir } from "os"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SCRIPT = join(__dirname, "..", "agent-registry.js"); +const FIX = join(__dirname, "fixtures", "agent-plugins"); + +let root, pluginsRoot; +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "registry-")); + pluginsRoot = join(root, ".claude", "agent-plugins"); + mkdirSync(join(root, ".claude", "agents"), { recursive: true }); + mkdirSync(pluginsRoot, { recursive: true }); + cpSync(join(FIX, "valid-base"), join(pluginsRoot, "valid-base"), { recursive: true }); + cpSync(join(FIX, "depends-on-base"), join(pluginsRoot, "depends-on-base"), { recursive: true }); +}); +afterEach(() => rmSync(root, { recursive: true, force: true })); + +function run(args) { + try { + const stdout = execFileSync("node", [SCRIPT, ...args, "--root", root], { + encoding: "utf-8", + timeout: 30000, + }); + return { stdout, exitCode: 0 }; + } catch (e) { + return { stdout: e.stdout || "", stderr: e.stderr || "", exitCode: e.status }; + } +} + +describe("agent-registry.js", () => { + it("lists available plugins", () => { + const r = run(["list", "--json"]); + expect(r.exitCode).toBe(0); + expect( + JSON.parse(r.stdout) + .plugins.map((p) => p.name) + .sort(), + ).toEqual(["depends-on-base", "valid-base"]); + }); + + it("resolves install order with deps first", () => { + const r = run(["resolve", "depends-on-base", "--json"]); + expect(r.exitCode).toBe(0); + expect(JSON.parse(r.stdout).order).toEqual(["valid-base", "depends-on-base"]); + }); + + it("installs a plugin and its deps into .claude/agents", () => { + const r = run(["install", "depends-on-base", "--json"]); + expect(r.exitCode).toBe(0); + expect(existsSync(join(root, ".claude", "agents", "valid-base.md"))).toBe(true); + expect(existsSync(join(root, ".claude", "agents", "depends-on-base.md"))).toBe(true); + expect(existsSync(join(pluginsRoot, "installed.json"))).toBe(true); + }); + + it("refuses to uninstall a depended-on plugin without --force", () => { + run(["install", "depends-on-base"]); + const r = run(["uninstall", "valid-base", "--json"]); + expect(r.exitCode).toBe(1); + expect(r.stdout).toMatch(/depend/i); + }); + + it("uninstalls cleanly when no dependents", () => { + run(["install", "depends-on-base"]); + const r = run(["uninstall", "depends-on-base", "--json"]); + expect(r.exitCode).toBe(0); + expect(existsSync(join(root, ".claude", "agents", "depends-on-base.md"))).toBe(false); + }); +}); diff --git a/scripts/agent-registry.js b/scripts/agent-registry.js new file mode 100644 index 0000000..82663ff --- /dev/null +++ b/scripts/agent-registry.js @@ -0,0 +1,227 @@ +#!/usr/bin/env node +/** + * agent-registry.js — Resolve, install, and uninstall agent plugins. + * + * Install copies a plugin's agent.md into /.claude/agents/.md and + * records state in /.claude/agent-plugins/installed.json. Management- + * lifecycle hooks (pre/postInstall, pre/postUninstall) run at the matching + * points; a failing pre* hook aborts, a failing post* hook only warns. + * + * Usage: + * node scripts/agent-registry.js list [--json] + * node scripts/agent-registry.js resolve [--json] + * node scripts/agent-registry.js install [--json] + * node scripts/agent-registry.js uninstall [--force] [--json] + * (--root overrides repo root; used by tests) + * + * Exit codes: 0 ok · 1 resolution/operation failure · 2 usage/IO error + */ +import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync, rmSync } from "fs"; +import { join, dirname, resolve } from "path"; +import { fileURLToPath } from "url"; +import { execFileSync } from "child_process"; +import { createHash } from "crypto"; +import { buildCatalog, resolveDependencies } from "./agent-plugin-lib.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +function parseArgs(argv) { + const out = { cmd: null, name: null, json: false, force: false, root: resolve(__dirname, "..") }; + const positional = []; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--json") out.json = true; + else if (a === "--force") out.force = true; + else if (a === "--root") out.root = resolve(argv[++i]); + else if (a === "-h" || a === "--help") { + printHelp(); + process.exit(0); + } else if (a.startsWith("--")) { + console.error(`Unknown argument: ${a}`); + printHelp(); + process.exit(2); + } else positional.push(a); + } + out.cmd = positional[0] ?? null; + out.name = positional[1] ?? null; + return out; +} + +function printHelp() { + console.log(`Usage: node scripts/agent-registry.js [name] [options] + +Commands: + list List available plugins + resolve Print install order (dry run) + install Install a plugin and its dependencies + uninstall Remove an installed plugin + +Options: + --force Allow uninstall of a depended-on plugin + --json Machine-readable output + --root Override repo root (default: repo containing this script) + -h, --help Show this message`); +} + +const paths = (root) => ({ + pluginsRoot: join(root, ".claude", "agent-plugins"), + agentsDir: join(root, ".claude", "agents"), + installedFile: join(root, ".claude", "agent-plugins", "installed.json"), +}); + +function loadInstalled(p) { + return existsSync(p.installedFile) ? JSON.parse(readFileSync(p.installedFile, "utf-8")) : {}; +} +function saveInstalled(p, state) { + mkdirSync(dirname(p.installedFile), { recursive: true }); + writeFileSync(p.installedFile, JSON.stringify(state, null, 2) + "\n"); +} +function sourceHash(file) { + return createHash("sha256").update(readFileSync(file)).digest("hex").slice(0, 16); +} + +function runHook(catalog, name, hook, fail) { + const entry = catalog[name]; + const rel = entry?.manifest?.hooks?.[hook]; + if (!rel) return { ran: false }; + const script = join(entry.dir, rel); + try { + execFileSync("bash", [script], { + stdio: "inherit", + env: { + ...process.env, + PLUGIN_NAME: name, + PLUGIN_DIR: entry.dir, + PLUGIN_VERSION: entry.version, + }, + }); + return { ran: true, ok: true }; + } catch (e) { + if (fail) throw new Error(`${hook} hook failed for "${name}": ${e.message}`, { cause: e }); + console.warn(`⚠ ${hook} hook for "${name}" failed (continuing): ${e.message}`); + return { ran: true, ok: false }; + } +} + +function emit(json, payload, human) { + if (json) console.log(JSON.stringify(payload, null, 2)); + else human(); +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + if (!args.cmd) { + printHelp(); + process.exit(2); + } + const p = paths(args.root); + const catalog = buildCatalog(p.pluginsRoot); + + if (args.cmd === "list") { + const installed = loadInstalled(p); + const plugins = Object.values(catalog).map((c) => ({ + name: c.manifest.name, + version: c.version, + installed: Boolean(installed[c.manifest.name]), + })); + emit(args.json, { plugins }, () => { + if (!plugins.length) console.log("No plugins found."); + for (const pl of plugins) console.log(`${pl.installed ? "●" : "○"} ${pl.name}@${pl.version}`); + }); + process.exit(0); + } + + if (!args.name) { + console.error("This command requires a plugin name."); + process.exit(2); + } + if (!catalog[args.name]) { + emit(args.json, { ok: false, error: `Unknown plugin "${args.name}"` }, () => + console.error(`✗ Unknown plugin "${args.name}"`), + ); + process.exit(2); + } + + if (args.cmd === "resolve" || args.cmd === "install") { + const { order, errors } = resolveDependencies(catalog, args.name); + if (errors.length) { + emit(args.json, { ok: false, order, errors }, () => { + console.error(`✗ Cannot resolve "${args.name}":`); + for (const e of errors) console.error(` [${e.code}] ${e.message}`); + }); + process.exit(1); + } + if (args.cmd === "resolve") { + emit(args.json, { ok: true, order }, () => + console.log(`Install order: ${order.join(" -> ")}`), + ); + process.exit(0); + } + + // install + const installed = loadInstalled(p); + mkdirSync(p.agentsDir, { recursive: true }); + const actions = []; + for (const name of order) { + const entry = catalog[name]; + const agentSrc = join(entry.dir, entry.manifest.agent || "agent.md"); + const dest = join(p.agentsDir, `${name}.md`); + if (installed[name]?.version === entry.version && existsSync(dest)) { + actions.push({ name, skipped: true }); + continue; + } + runHook(catalog, name, "preInstall", true); + copyFileSync(agentSrc, dest); + runHook(catalog, name, "postInstall", false); + installed[name] = { + version: entry.version, + sourceHash: sourceHash(agentSrc), + installedAt: new Date().toISOString(), + }; + actions.push({ name, installed: true }); + } + saveInstalled(p, installed); + emit(args.json, { ok: true, order, actions }, () => { + for (const a of actions) + console.log(a.skipped ? `= ${a.name} (already installed)` : `+ ${a.name}`); + }); + process.exit(0); + } + + if (args.cmd === "uninstall") { + const installed = loadInstalled(p); + if (!installed[args.name]) { + emit(args.json, { ok: false, error: `"${args.name}" is not installed` }, () => + console.error(`✗ "${args.name}" is not installed`), + ); + process.exit(1); + } + // Block if an installed plugin depends on this one. + const dependents = Object.keys(installed).filter( + (n) => + n !== args.name && catalog[n] && Object.keys(catalog[n].deps || {}).includes(args.name), + ); + if (dependents.length && !args.force) { + emit(args.json, { ok: false, error: "has dependents", dependents }, () => { + console.error( + `✗ "${args.name}" is required by: ${dependents.join(", ")}. Use --force to override.`, + ); + }); + process.exit(1); + } + runHook(catalog, args.name, "preUninstall", true); + const dest = join(p.agentsDir, `${args.name}.md`); + if (existsSync(dest)) rmSync(dest); + runHook(catalog, args.name, "postUninstall", false); + delete installed[args.name]; + saveInstalled(p, installed); + emit(args.json, { ok: true, removed: args.name }, () => console.log(`- ${args.name}`)); + process.exit(0); + } + + console.error(`Unknown command: ${args.cmd}`); + printHelp(); + process.exit(2); +} + +main(); diff --git a/scripts/agent-registry.sh b/scripts/agent-registry.sh new file mode 100644 index 0000000..9d6fc32 --- /dev/null +++ b/scripts/agent-registry.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec node "$DIR/agent-registry.js" "$@" From 83b50de922bbf1f57c789f7df245c4b131102edd Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Thu, 28 May 2026 09:49:31 -0400 Subject: [PATCH 13/20] fix(plugins): persist install state incrementally, guard installed.json, cover hook paths --- scripts/__tests__/agent-registry.test.js | 81 ++++++++- scripts/agent-registry.js | 217 +++++++++++++---------- 2 files changed, 198 insertions(+), 100 deletions(-) diff --git a/scripts/__tests__/agent-registry.test.js b/scripts/__tests__/agent-registry.test.js index c8ef856..2306cb2 100644 --- a/scripts/__tests__/agent-registry.test.js +++ b/scripts/__tests__/agent-registry.test.js @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { execFileSync } from "child_process"; -import { mkdtempSync, mkdirSync, cpSync, rmSync, existsSync } from "fs"; +import { mkdtempSync, mkdirSync, cpSync, rmSync, existsSync, writeFileSync } from "fs"; import { tmpdir } from "os"; import { join, dirname } from "path"; import { fileURLToPath } from "url"; @@ -20,11 +20,12 @@ beforeEach(() => { }); afterEach(() => rmSync(root, { recursive: true, force: true })); -function run(args) { +function run(args, env = {}) { try { const stdout = execFileSync("node", [SCRIPT, ...args, "--root", root], { encoding: "utf-8", timeout: 30000, + env: { ...process.env, ...env }, }); return { stdout, exitCode: 0 }; } catch (e) { @@ -32,6 +33,41 @@ function run(args) { } } +const HOOKED_AGENT_MD = `--- +name: hooked +description: A hooked agent. Context: a user: 'x' assistant: 'y' Context: b user: 'p' assistant: 'q' +tools: Read +model: sonnet +--- + +## When to Use This Agent + +In hook tests. +`; + +function writeHookedPlugin() { + const dir = join(pluginsRoot, "hooked"); + mkdirSync(join(dir, "hooks"), { recursive: true }); + writeFileSync( + join(dir, "plugin.json"), + JSON.stringify({ + name: "hooked", + version: "1.0.0", + description: "hooked agent", + hooks: { preInstall: "hooks/pre-install.sh", postInstall: "hooks/post-install.sh" }, + }), + ); + writeFileSync(join(dir, "agent.md"), HOOKED_AGENT_MD); + writeFileSync( + join(dir, "hooks", "pre-install.sh"), + '#!/usr/bin/env bash\nif [ "${FAIL_PRE:-0}" = "1" ]; then echo pre-fail; exit 1; fi\nexit 0\n', + ); + writeFileSync( + join(dir, "hooks", "post-install.sh"), + '#!/usr/bin/env bash\nif [ "${FAIL_POST:-0}" = "1" ]; then echo post-fail; exit 1; fi\nexit 0\n', + ); +} + describe("agent-registry.js", () => { it("lists available plugins", () => { const r = run(["list", "--json"]); @@ -70,4 +106,45 @@ describe("agent-registry.js", () => { expect(r.exitCode).toBe(0); expect(existsSync(join(root, ".claude", "agents", "depends-on-base.md"))).toBe(false); }); + + it("allows uninstall of a depended-on plugin with --force", () => { + run(["install", "depends-on-base"]); + const r = run(["uninstall", "valid-base", "--force", "--json"]); + expect(r.exitCode).toBe(0); + expect(existsSync(join(root, ".claude", "agents", "valid-base.md"))).toBe(false); + }); + + it("exits 1 when uninstalling a plugin that is not installed", () => { + const r = run(["uninstall", "valid-base", "--json"]); + expect(r.exitCode).toBe(1); + expect(r.stdout).toMatch(/not installed/i); + }); + + it("aborts install when the preInstall hook fails (exit 1, no agent copied)", () => { + writeHookedPlugin(); + const r = run(["install", "hooked", "--json"], { FAIL_PRE: "1" }); + expect(r.exitCode).toBe(1); + expect(existsSync(join(root, ".claude", "agents", "hooked.md"))).toBe(false); + }); + + it("install succeeds when the postInstall hook fails (warn-only, exit 0)", () => { + writeHookedPlugin(); + const r = run(["install", "hooked", "--json"], { FAIL_POST: "1" }); + expect(r.exitCode).toBe(0); + expect(existsSync(join(root, ".claude", "agents", "hooked.md"))).toBe(true); + }); + + it("skips an already-installed plugin without re-running its preInstall hook", () => { + writeHookedPlugin(); + expect(run(["install", "hooked"]).exitCode).toBe(0); + const r = run(["install", "hooked", "--json"], { FAIL_PRE: "1" }); + expect(r.exitCode).toBe(0); + expect(JSON.parse(r.stdout).actions.every((a) => a.skipped)).toBe(true); + }); + + it("reports malformed installed.json as an IO error (exit 2)", () => { + writeFileSync(join(pluginsRoot, "installed.json"), "{ broken json"); + const r = run(["install", "valid-base", "--json"]); + expect(r.exitCode).toBe(2); + }); }); diff --git a/scripts/agent-registry.js b/scripts/agent-registry.js index 82663ff..c32a812 100644 --- a/scripts/agent-registry.js +++ b/scripts/agent-registry.js @@ -14,6 +14,8 @@ * node scripts/agent-registry.js uninstall [--force] [--json] * (--root overrides repo root; used by tests) * + * Hooks run via `bash`; ensure bash is on PATH (Git Bash/WSL on Windows). + * * Exit codes: 0 ok · 1 resolution/operation failure · 2 usage/IO error */ import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync, rmSync } from "fs"; @@ -70,7 +72,16 @@ const paths = (root) => ({ }); function loadInstalled(p) { - return existsSync(p.installedFile) ? JSON.parse(readFileSync(p.installedFile, "utf-8")) : {}; + if (!existsSync(p.installedFile)) return {}; + try { + return JSON.parse(readFileSync(p.installedFile, "utf-8")); + } catch (e) { + const err = new Error(`Failed to parse installed.json (${p.installedFile}): ${e.message}`, { + cause: e, + }); + err.code = "IO"; + throw err; + } } function saveInstalled(p, state) { mkdirSync(dirname(p.installedFile), { recursive: true }); @@ -97,8 +108,10 @@ function runHook(catalog, name, hook, fail) { }); return { ran: true, ok: true }; } catch (e) { - if (fail) throw new Error(`${hook} hook failed for "${name}": ${e.message}`, { cause: e }); - console.warn(`⚠ ${hook} hook for "${name}" failed (continuing): ${e.message}`); + const detail = + e.code === "ENOENT" ? "bash not found on PATH (required to run hooks)" : e.message; + if (fail) throw new Error(`${hook} hook failed for "${name}": ${detail}`, { cause: e }); + console.warn(`⚠ ${hook} hook for "${name}" failed (continuing): ${detail}`); return { ran: true, ok: false }; } } @@ -115,113 +128,121 @@ function main() { process.exit(2); } const p = paths(args.root); - const catalog = buildCatalog(p.pluginsRoot); - - if (args.cmd === "list") { - const installed = loadInstalled(p); - const plugins = Object.values(catalog).map((c) => ({ - name: c.manifest.name, - version: c.version, - installed: Boolean(installed[c.manifest.name]), - })); - emit(args.json, { plugins }, () => { - if (!plugins.length) console.log("No plugins found."); - for (const pl of plugins) console.log(`${pl.installed ? "●" : "○"} ${pl.name}@${pl.version}`); - }); - process.exit(0); - } - - if (!args.name) { - console.error("This command requires a plugin name."); - process.exit(2); - } - if (!catalog[args.name]) { - emit(args.json, { ok: false, error: `Unknown plugin "${args.name}"` }, () => - console.error(`✗ Unknown plugin "${args.name}"`), - ); - process.exit(2); - } - if (args.cmd === "resolve" || args.cmd === "install") { - const { order, errors } = resolveDependencies(catalog, args.name); - if (errors.length) { - emit(args.json, { ok: false, order, errors }, () => { - console.error(`✗ Cannot resolve "${args.name}":`); - for (const e of errors) console.error(` [${e.code}] ${e.message}`); + try { + const catalog = buildCatalog(p.pluginsRoot); + + if (args.cmd === "list") { + const installed = loadInstalled(p); + const plugins = Object.values(catalog).map((c) => ({ + name: c.manifest.name, + version: c.version, + installed: Boolean(installed[c.manifest.name]), + })); + emit(args.json, { plugins }, () => { + if (!plugins.length) console.log("No plugins found."); + for (const pl of plugins) + console.log(`${pl.installed ? "●" : "○"} ${pl.name}@${pl.version}`); }); - process.exit(1); - } - if (args.cmd === "resolve") { - emit(args.json, { ok: true, order }, () => - console.log(`Install order: ${order.join(" -> ")}`), - ); process.exit(0); } - // install - const installed = loadInstalled(p); - mkdirSync(p.agentsDir, { recursive: true }); - const actions = []; - for (const name of order) { - const entry = catalog[name]; - const agentSrc = join(entry.dir, entry.manifest.agent || "agent.md"); - const dest = join(p.agentsDir, `${name}.md`); - if (installed[name]?.version === entry.version && existsSync(dest)) { - actions.push({ name, skipped: true }); - continue; - } - runHook(catalog, name, "preInstall", true); - copyFileSync(agentSrc, dest); - runHook(catalog, name, "postInstall", false); - installed[name] = { - version: entry.version, - sourceHash: sourceHash(agentSrc), - installedAt: new Date().toISOString(), - }; - actions.push({ name, installed: true }); + if (!args.name) { + console.error("This command requires a plugin name."); + process.exit(2); } - saveInstalled(p, installed); - emit(args.json, { ok: true, order, actions }, () => { - for (const a of actions) - console.log(a.skipped ? `= ${a.name} (already installed)` : `+ ${a.name}`); - }); - process.exit(0); - } - - if (args.cmd === "uninstall") { - const installed = loadInstalled(p); - if (!installed[args.name]) { - emit(args.json, { ok: false, error: `"${args.name}" is not installed` }, () => - console.error(`✗ "${args.name}" is not installed`), + if (!catalog[args.name]) { + emit(args.json, { ok: false, error: `Unknown plugin "${args.name}"` }, () => + console.error(`✗ Unknown plugin "${args.name}"`), ); - process.exit(1); + process.exit(2); } - // Block if an installed plugin depends on this one. - const dependents = Object.keys(installed).filter( - (n) => - n !== args.name && catalog[n] && Object.keys(catalog[n].deps || {}).includes(args.name), - ); - if (dependents.length && !args.force) { - emit(args.json, { ok: false, error: "has dependents", dependents }, () => { - console.error( - `✗ "${args.name}" is required by: ${dependents.join(", ")}. Use --force to override.`, + + if (args.cmd === "resolve" || args.cmd === "install") { + const { order, errors } = resolveDependencies(catalog, args.name); + if (errors.length) { + emit(args.json, { ok: false, order, errors }, () => { + console.error(`✗ Cannot resolve "${args.name}":`); + for (const e of errors) console.error(` [${e.code}] ${e.message}`); + }); + process.exit(1); + } + if (args.cmd === "resolve") { + emit(args.json, { ok: true, order }, () => + console.log(`Install order: ${order.join(" -> ")}`), ); + process.exit(0); + } + + // install + const installed = loadInstalled(p); + mkdirSync(p.agentsDir, { recursive: true }); + const actions = []; + for (const name of order) { + const entry = catalog[name]; + const agentSrc = join(entry.dir, entry.manifest.agent || "agent.md"); + const dest = join(p.agentsDir, `${name}.md`); + if (installed[name]?.version === entry.version && existsSync(dest)) { + actions.push({ name, skipped: true }); + continue; + } + runHook(catalog, name, "preInstall", true); // may throw; prior plugins are already persisted + copyFileSync(agentSrc, dest); + runHook(catalog, name, "postInstall", false); + installed[name] = { + version: entry.version, + sourceHash: sourceHash(agentSrc), + installedAt: new Date().toISOString(), + }; + saveInstalled(p, installed); // persist incrementally so disk and record can't desync on abort + actions.push({ name, installed: true }); + } + emit(args.json, { ok: true, order, actions }, () => { + for (const a of actions) + console.log(a.skipped ? `= ${a.name} (already installed)` : `+ ${a.name}`); }); - process.exit(1); + process.exit(0); } - runHook(catalog, args.name, "preUninstall", true); - const dest = join(p.agentsDir, `${args.name}.md`); - if (existsSync(dest)) rmSync(dest); - runHook(catalog, args.name, "postUninstall", false); - delete installed[args.name]; - saveInstalled(p, installed); - emit(args.json, { ok: true, removed: args.name }, () => console.log(`- ${args.name}`)); - process.exit(0); - } - console.error(`Unknown command: ${args.cmd}`); - printHelp(); - process.exit(2); + if (args.cmd === "uninstall") { + const installed = loadInstalled(p); + if (!installed[args.name]) { + emit(args.json, { ok: false, error: `"${args.name}" is not installed` }, () => + console.error(`✗ "${args.name}" is not installed`), + ); + process.exit(1); + } + const dependents = Object.keys(installed).filter( + (n) => + n !== args.name && catalog[n] && Object.keys(catalog[n].deps || {}).includes(args.name), + ); + if (dependents.length && !args.force) { + emit(args.json, { ok: false, error: "has dependents", dependents }, () => { + console.error( + `✗ "${args.name}" is required by: ${dependents.join(", ")}. Use --force to override.`, + ); + }); + process.exit(1); + } + runHook(catalog, args.name, "preUninstall", true); + const dest = join(p.agentsDir, `${args.name}.md`); + if (existsSync(dest)) rmSync(dest); + runHook(catalog, args.name, "postUninstall", false); + delete installed[args.name]; + saveInstalled(p, installed); + emit(args.json, { ok: true, removed: args.name }, () => console.log(`- ${args.name}`)); + process.exit(0); + } + + console.error(`Unknown command: ${args.cmd}`); + printHelp(); + process.exit(2); + } catch (e) { + const code = e.code === "IO" ? 2 : 1; + if (args.json) console.log(JSON.stringify({ ok: false, error: e.message })); + else console.error(`✗ ${e.message}`); + process.exit(code); + } } main(); From ea49fda04cbdb9b71018345735e8364d4d55a68f Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Thu, 28 May 2026 09:52:43 -0400 Subject: [PATCH 14/20] feat(plugins): add scaffolding CLI for new agent plugins --- scripts/__tests__/create-agent-plugin.test.js | 67 +++++++ scripts/create-agent-plugin.js | 189 ++++++++++++++++++ scripts/create-agent-plugin.sh | 4 + 3 files changed, 260 insertions(+) create mode 100644 scripts/__tests__/create-agent-plugin.test.js create mode 100644 scripts/create-agent-plugin.js create mode 100644 scripts/create-agent-plugin.sh diff --git a/scripts/__tests__/create-agent-plugin.test.js b/scripts/__tests__/create-agent-plugin.test.js new file mode 100644 index 0000000..91772b4 --- /dev/null +++ b/scripts/__tests__/create-agent-plugin.test.js @@ -0,0 +1,67 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { execFileSync } from "child_process"; +import { mkdtempSync, rmSync, existsSync, readFileSync } from "fs"; +import { tmpdir } from "os"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SCRIPT = join(__dirname, "..", "create-agent-plugin.js"); + +let root; +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "create-")); +}); +afterEach(() => rmSync(root, { recursive: true, force: true })); + +function run(args) { + try { + const stdout = execFileSync("node", [SCRIPT, ...args, "--root", root], { + encoding: "utf-8", + timeout: 30000, + }); + return { stdout, exitCode: 0 }; + } catch (e) { + return { stdout: e.stdout || "", stderr: e.stderr || "", exitCode: e.status }; + } +} + +describe("create-agent-plugin.js", () => { + it("scaffolds a plugin from flags", () => { + const r = run([ + "my-agent", + "--description", + "Does things", + "--model", + "opus", + "--tools", + "Read,Write", + "--json", + ]); + expect(r.exitCode).toBe(0); + const dir = join(root, ".claude", "agent-plugins", "my-agent"); + expect(existsSync(join(dir, "plugin.json"))).toBe(true); + expect(existsSync(join(dir, "agent.md"))).toBe(true); + expect(existsSync(join(dir, "tests", "plugin.test.json"))).toBe(true); + const fm = readFileSync(join(dir, "agent.md"), "utf-8"); + expect(fm).toContain("name: my-agent"); + expect(fm).toContain("model: opus"); + }); + + it("rejects a non-kebab name (exit 2)", () => { + expect(run(["Bad_Name", "--description", "x"]).exitCode).toBe(2); + }); + + it("refuses to overwrite without --force (exit 1)", () => { + run(["dup", "--description", "x"]); + expect(run(["dup", "--description", "x"]).exitCode).toBe(1); + }); + + it("scaffolds the new plugin so it passes validation", () => { + run(["clean-agent", "--description", "A clean agent for the test"]); + const validator = join(__dirname, "..", "validate-agent-plugin.js"); + const dir = join(root, ".claude", "agent-plugins", "clean-agent"); + const out = execFileSync("node", [validator, "--dir", dir, "--json"], { encoding: "utf-8" }); + expect(JSON.parse(out).ok).toBe(true); + }); +}); diff --git a/scripts/create-agent-plugin.js b/scripts/create-agent-plugin.js new file mode 100644 index 0000000..ebcccbc --- /dev/null +++ b/scripts/create-agent-plugin.js @@ -0,0 +1,189 @@ +#!/usr/bin/env node +/** + * create-agent-plugin.js — Scaffold a new agent plugin under + * .claude/agent-plugins// with a manifest, agent.md skeleton (in the + * house style), and default test assertions. Non-interactive when a name and + * --description are supplied; otherwise prompts for missing fields. + * + * Usage: + * node scripts/create-agent-plugin.js [--description "..."] \ + * [--model opus|sonnet|haiku] [--tools "Read,Write"] [--with-hooks] [--force] [--json] + * (--root overrides repo root; used by tests) + * + * Exit codes: 0 created · 1 exists (no --force) · 2 usage/IO error + */ +import { existsSync, mkdirSync, writeFileSync } from "fs"; +import { join, dirname, resolve } from "path"; +import { fileURLToPath } from "url"; +import { createInterface } from "readline"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const NAME_RE = /^[a-z][a-z0-9-]*$/; +const VALID_MODELS = ["opus", "sonnet", "haiku"]; + +function parseArgs(argv) { + const out = { + name: null, + description: null, + model: "sonnet", + tools: "Read, Write", + withHooks: false, + force: false, + json: false, + root: resolve(__dirname, ".."), + }; + const positional = []; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--description") out.description = argv[++i]; + else if (a === "--model") out.model = argv[++i]; + else if (a === "--tools") out.tools = argv[++i]; + else if (a === "--with-hooks") out.withHooks = true; + else if (a === "--force") out.force = true; + else if (a === "--json") out.json = true; + else if (a === "--root") out.root = resolve(argv[++i]); + else if (a === "-h" || a === "--help") { + printHelp(); + process.exit(0); + } else if (a.startsWith("--")) { + console.error(`Unknown argument: ${a}`); + printHelp(); + process.exit(2); + } else positional.push(a); + } + out.name = positional[0] ?? null; + return out; +} + +function printHelp() { + console.log(`Usage: node scripts/create-agent-plugin.js [options] + +Options: + --description "..." Agent description + --model opus | sonnet | haiku (default: sonnet) + --tools "A,B" Comma-separated tool list (default: "Read, Write") + --with-hooks Scaffold hooks/ stubs + --force Overwrite an existing plugin + --json Machine-readable output + -h, --help Show this message`); +} + +function ask(question) { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((res) => + rl.question(question, (a) => { + rl.close(); + res(a.trim()); + }), + ); +} + +function manifest(name, description, withHooks) { + const m = { + name, + version: "0.1.0", + description, + agent: "agent.md", + tests: "tests/plugin.test.json", + }; + if (withHooks) + m.hooks = { preInstall: "hooks/pre-install.sh", postInstall: "hooks/post-install.sh" }; + return JSON.stringify(m, null, 2) + "\n"; +} + +function agentMd(name, description, model, tools) { + return `--- +name: ${name} +description: ${description} Context: a relevant situation user: 'a representative request' assistant: 'how this agent responds' Context: a second situation user: 'another request' assistant: 'the response' +tools: ${tools} +model: ${model} +--- + +You are a ${name} specialist. Describe the agent's core expertise here. + +Your core expertise areas: +- **Area 1**: specific capabilities +- **Area 2**: specific capabilities + +## When to Use This Agent + +Use this agent for: +- Use case 1 +- Use case 2 +`; +} + +const TESTS = + JSON.stringify( + { + assert: [ + "manifest.valid", + "frontmatter.has(name,description,tools)", + "frontmatter.model in (opus,sonnet,haiku)", + "deps.resolve", + "hooks.executable", + "prompt.section('When to Use This Agent')", + "description.examples >= 2", + ], + }, + null, + 2, + ) + "\n"; + +const HOOK_STUB = `#!/usr/bin/env bash +set -u +trap 'exit 0' ERR +# PLUGIN_NAME, PLUGIN_DIR, PLUGIN_VERSION are available in the environment. +echo "hook for \${PLUGIN_NAME}" +exit 0 +`; + +async function main() { + const args = parseArgs(process.argv.slice(2)); + + let name = args.name; + if (!name && !args.json) name = await ask("Plugin name (kebab-case): "); + if (!name) { + console.error("A plugin name is required."); + process.exit(2); + } + if (!NAME_RE.test(name)) { + console.error(`✗ Invalid name "${name}". Use kebab-case: ^[a-z][a-z0-9-]*$`); + process.exit(2); + } + + let description = args.description; + if (!description && !args.json) description = await ask("Description: "); + if (!description) description = `The ${name} agent`; + + if (!VALID_MODELS.includes(args.model)) { + console.error(`✗ Invalid model "${args.model}"`); + process.exit(2); + } + + const dir = join(args.root, ".claude", "agent-plugins", name); + if (existsSync(dir) && !args.force) { + if (args.json) console.log(JSON.stringify({ ok: false, error: "exists" })); + else console.error(`✗ Plugin "${name}" already exists. Use --force to overwrite.`); + process.exit(1); + } + + mkdirSync(join(dir, "tests"), { recursive: true }); + writeFileSync(join(dir, "plugin.json"), manifest(name, description, args.withHooks)); + writeFileSync(join(dir, "agent.md"), agentMd(name, description, args.model, args.tools)); + writeFileSync(join(dir, "tests", "plugin.test.json"), TESTS); + if (args.withHooks) { + mkdirSync(join(dir, "hooks"), { recursive: true }); + writeFileSync(join(dir, "hooks", "pre-install.sh"), HOOK_STUB); + writeFileSync(join(dir, "hooks", "post-install.sh"), HOOK_STUB); + } + + if (args.json) console.log(JSON.stringify({ ok: true, name, dir }, null, 2)); + else console.log(`✓ Created plugin "${name}" at ${dir}`); + process.exit(0); +} + +main().catch((e) => { + console.error(`✗ ${e.stack ?? e.message}`); + process.exit(2); +}); diff --git a/scripts/create-agent-plugin.sh b/scripts/create-agent-plugin.sh new file mode 100644 index 0000000..f2fdeda --- /dev/null +++ b/scripts/create-agent-plugin.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec node "$DIR/create-agent-plugin.js" "$@" From 739b5411c039e5c044cb97ae002a4dc14f94158e Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Thu, 28 May 2026 10:00:37 -0400 Subject: [PATCH 15/20] fix(plugins): guard scaffolder against non-interactive hang, normalize tools, roll back writes --- scripts/__tests__/create-agent-plugin.test.js | 48 ++++++++++++++++--- scripts/create-agent-plugin.js | 46 +++++++++++------- 2 files changed, 72 insertions(+), 22 deletions(-) diff --git a/scripts/__tests__/create-agent-plugin.test.js b/scripts/__tests__/create-agent-plugin.test.js index 91772b4..2a13432 100644 --- a/scripts/__tests__/create-agent-plugin.test.js +++ b/scripts/__tests__/create-agent-plugin.test.js @@ -7,6 +7,7 @@ import { fileURLToPath } from "url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const SCRIPT = join(__dirname, "..", "create-agent-plugin.js"); +const VALIDATOR = join(__dirname, "..", "validate-agent-plugin.js"); let root; beforeEach(() => { @@ -26,8 +27,14 @@ function run(args) { } } +function validate(name) { + const dir = join(root, ".claude", "agent-plugins", name); + const out = execFileSync("node", [VALIDATOR, "--dir", dir, "--json"], { encoding: "utf-8" }); + return JSON.parse(out); +} + describe("create-agent-plugin.js", () => { - it("scaffolds a plugin from flags", () => { + it("scaffolds a plugin from flags (tools normalized to comma-space)", () => { const r = run([ "my-agent", "--description", @@ -46,22 +53,51 @@ describe("create-agent-plugin.js", () => { const fm = readFileSync(join(dir, "agent.md"), "utf-8"); expect(fm).toContain("name: my-agent"); expect(fm).toContain("model: opus"); + expect(fm).toContain("tools: Read, Write"); }); it("rejects a non-kebab name (exit 2)", () => { expect(run(["Bad_Name", "--description", "x"]).exitCode).toBe(2); }); + it("rejects an invalid model (exit 2)", () => { + expect(run(["ok-name", "--description", "x", "--model", "gpt"]).exitCode).toBe(2); + }); + + it("exits 2 (does not hang) when no name is given in non-interactive mode", () => { + expect(run(["--description", "x"]).exitCode).toBe(2); + }); + it("refuses to overwrite without --force (exit 1)", () => { run(["dup", "--description", "x"]); expect(run(["dup", "--description", "x"]).exitCode).toBe(1); }); - it("scaffolds the new plugin so it passes validation", () => { + it("overwrites an existing plugin with --force (exit 0)", () => { + run(["dup", "--description", "first"]); + const r = run(["dup", "--description", "second", "--force", "--json"]); + expect(r.exitCode).toBe(0); + const fm = readFileSync(join(root, ".claude", "agent-plugins", "dup", "agent.md"), "utf-8"); + expect(fm).toContain("second"); + }); + + it("scaffolds hook stubs and a hooks manifest block with --with-hooks", () => { + const r = run(["hooked-agent", "--description", "Has hooks", "--with-hooks", "--json"]); + expect(r.exitCode).toBe(0); + const dir = join(root, ".claude", "agent-plugins", "hooked-agent"); + expect(existsSync(join(dir, "hooks", "pre-install.sh"))).toBe(true); + expect(existsSync(join(dir, "hooks", "post-install.sh"))).toBe(true); + const manifest = JSON.parse(readFileSync(join(dir, "plugin.json"), "utf-8")); + expect(manifest.hooks.preInstall).toBe("hooks/pre-install.sh"); + }); + + it("scaffolds a plugin that passes validation", () => { run(["clean-agent", "--description", "A clean agent for the test"]); - const validator = join(__dirname, "..", "validate-agent-plugin.js"); - const dir = join(root, ".claude", "agent-plugins", "clean-agent"); - const out = execFileSync("node", [validator, "--dir", dir, "--json"], { encoding: "utf-8" }); - expect(JSON.parse(out).ok).toBe(true); + expect(validate("clean-agent").ok).toBe(true); + }); + + it("keeps a --with-hooks plugin valid", () => { + run(["hooked-valid", "--description", "Hooked and valid", "--with-hooks"]); + expect(validate("hooked-valid").ok).toBe(true); }); }); diff --git a/scripts/create-agent-plugin.js b/scripts/create-agent-plugin.js index ebcccbc..275db46 100644 --- a/scripts/create-agent-plugin.js +++ b/scripts/create-agent-plugin.js @@ -12,7 +12,7 @@ * * Exit codes: 0 created · 1 exists (no --force) · 2 usage/IO error */ -import { existsSync, mkdirSync, writeFileSync } from "fs"; +import { existsSync, mkdirSync, writeFileSync, rmSync } from "fs"; import { join, dirname, resolve } from "path"; import { fileURLToPath } from "url"; import { createInterface } from "readline"; @@ -69,13 +69,14 @@ Options: } function ask(question) { - const rl = createInterface({ input: process.stdin, output: process.stdout }); - return new Promise((res) => + return new Promise((res) => { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + rl.on("close", () => res("")); rl.question(question, (a) => { rl.close(); res(a.trim()); - }), - ); + }); + }); } function manifest(name, description, withHooks) { @@ -140,9 +141,10 @@ exit 0 async function main() { const args = parseArgs(process.argv.slice(2)); + const interactive = Boolean(process.stdin.isTTY); let name = args.name; - if (!name && !args.json) name = await ask("Plugin name (kebab-case): "); + if (!name && !args.json && interactive) name = await ask("Plugin name (kebab-case): "); if (!name) { console.error("A plugin name is required."); process.exit(2); @@ -153,7 +155,7 @@ async function main() { } let description = args.description; - if (!description && !args.json) description = await ask("Description: "); + if (!description && !args.json && interactive) description = await ask("Description: "); if (!description) description = `The ${name} agent`; if (!VALID_MODELS.includes(args.model)) { @@ -161,21 +163,33 @@ async function main() { process.exit(2); } + const tools = args.tools + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + .join(", "); + const dir = join(args.root, ".claude", "agent-plugins", name); - if (existsSync(dir) && !args.force) { + const preExisting = existsSync(dir); + if (preExisting && !args.force) { if (args.json) console.log(JSON.stringify({ ok: false, error: "exists" })); else console.error(`✗ Plugin "${name}" already exists. Use --force to overwrite.`); process.exit(1); } - mkdirSync(join(dir, "tests"), { recursive: true }); - writeFileSync(join(dir, "plugin.json"), manifest(name, description, args.withHooks)); - writeFileSync(join(dir, "agent.md"), agentMd(name, description, args.model, args.tools)); - writeFileSync(join(dir, "tests", "plugin.test.json"), TESTS); - if (args.withHooks) { - mkdirSync(join(dir, "hooks"), { recursive: true }); - writeFileSync(join(dir, "hooks", "pre-install.sh"), HOOK_STUB); - writeFileSync(join(dir, "hooks", "post-install.sh"), HOOK_STUB); + try { + mkdirSync(join(dir, "tests"), { recursive: true }); + writeFileSync(join(dir, "plugin.json"), manifest(name, description, args.withHooks)); + writeFileSync(join(dir, "agent.md"), agentMd(name, description, args.model, tools)); + writeFileSync(join(dir, "tests", "plugin.test.json"), TESTS); + if (args.withHooks) { + mkdirSync(join(dir, "hooks"), { recursive: true }); + writeFileSync(join(dir, "hooks", "pre-install.sh"), HOOK_STUB); + writeFileSync(join(dir, "hooks", "post-install.sh"), HOOK_STUB); + } + } catch (e) { + if (!preExisting) rmSync(dir, { recursive: true, force: true }); + throw e; } if (args.json) console.log(JSON.stringify({ ok: true, name, dir }, null, 2)); From 1aa904db1e5ff7e6911464cc283d726e916c3416 Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Thu, 28 May 2026 10:06:03 -0400 Subject: [PATCH 16/20] feat(plugins): add static assertion test runner --- scripts/__tests__/test-agent-plugin.test.js | 127 +++++++++++ scripts/test-agent-plugin.js | 235 ++++++++++++++++++++ scripts/test-agent-plugin.sh | 4 + 3 files changed, 366 insertions(+) create mode 100644 scripts/__tests__/test-agent-plugin.test.js create mode 100644 scripts/test-agent-plugin.js create mode 100644 scripts/test-agent-plugin.sh diff --git a/scripts/__tests__/test-agent-plugin.test.js b/scripts/__tests__/test-agent-plugin.test.js new file mode 100644 index 0000000..d2c72a6 --- /dev/null +++ b/scripts/__tests__/test-agent-plugin.test.js @@ -0,0 +1,127 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { execFileSync } from "child_process"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, cpSync } from "fs"; +import { tmpdir } from "os"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SCRIPT = join(__dirname, "..", "test-agent-plugin.js"); +const FIX = join(__dirname, "fixtures", "agent-plugins"); + +function run(args) { + try { + const stdout = execFileSync("node", [SCRIPT, ...args], { encoding: "utf-8", timeout: 30000 }); + return { stdout, exitCode: 0 }; + } catch (e) { + return { stdout: e.stdout || "", stderr: e.stderr || "", exitCode: e.status }; + } +} + +function writePlugin( + root, + name, + { examples = 2, section = true, tools = "Read, Write", model = "sonnet" } = {}, +) { + const dir = join(root, name); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "plugin.json"), + JSON.stringify({ name, version: "1.0.0", description: `${name} agent` }), + ); + const exBlocks = Array.from( + { length: examples }, + (_, i) => `Context: c${i} user: 'a' assistant: 'b'`, + ).join(" "); + const body = section ? "\n## When to Use This Agent\n\nHere.\n" : "\nNo section here.\n"; + writeFileSync( + join(dir, "agent.md"), + `---\nname: ${name}\ndescription: Desc. ${exBlocks}\ntools: ${tools}\nmodel: ${model}\n---\n${body}`, + ); + return dir; +} + +describe("test-agent-plugin.js", () => { + let tmp; + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "tap-")); + }); + afterEach(() => rmSync(tmp, { recursive: true, force: true })); + + it("passes all assertions for a valid plugin", () => { + const r = run(["--dir", join(FIX, "valid-base"), "--json"]); + expect(r.exitCode).toBe(0); + const out = JSON.parse(r.stdout); + expect(out.ok).toBe(true); + expect(out.results.every((a) => a.pass)).toBe(true); + }); + + it("fails (exit 1) when the tests file is missing", () => { + const r = run(["--dir", join(FIX, "name-mismatch"), "--json"]); + expect(r.exitCode).toBe(1); + }); + + it("errors (exit 2) on an unknown assertion", () => { + const r = run(["--dir", join(FIX, "valid-base"), "--assert", "bogus.thing", "--json"]); + expect(r.exitCode).toBe(2); + }); + + it("passes an inline --assert that holds", () => { + const r = run(["--dir", join(FIX, "valid-base"), "--assert", "manifest.valid", "--json"]); + expect(r.exitCode).toBe(0); + expect(JSON.parse(r.stdout).ok).toBe(true); + }); + + it("fails prompt.section when the heading is absent", () => { + writePlugin(tmp, "no-section", { section: false }); + const r = run([ + "--dir", + join(tmp, "no-section"), + "--assert", + "prompt.section('When to Use This Agent')", + "--json", + ]); + expect(r.exitCode).toBe(1); + expect(JSON.parse(r.stdout).results[0].pass).toBe(false); + }); + + it("fails description.examples >= 2 with only one example", () => { + writePlugin(tmp, "one-example", { examples: 1 }); + const r = run([ + "--dir", + join(tmp, "one-example"), + "--assert", + "description.examples >= 2", + "--json", + ]); + expect(r.exitCode).toBe(1); + }); + + it("fails frontmatter.has when a field is missing", () => { + const dir = join(tmp, "no-tools"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "plugin.json"), + JSON.stringify({ name: "no-tools", version: "1.0.0", description: "x" }), + ); + writeFileSync( + join(dir, "agent.md"), + "---\nname: no-tools\ndescription: D. a b\nmodel: sonnet\n---\n\n## When to Use This Agent\n\nx\n", + ); + const r = run(["--dir", dir, "--assert", "frontmatter.has(name,description,tools)", "--json"]); + expect(r.exitCode).toBe(1); + expect(JSON.stringify(JSON.parse(r.stdout).results)).toMatch(/tools/); + }); + + it("rejects --dir and --all together (exit 2)", () => { + const r = run(["--dir", join(FIX, "valid-base"), "--all"]); + expect(r.exitCode).toBe(2); + }); + + it("tests every plugin under --plugins-root", () => { + cpSync(join(FIX, "valid-base"), join(tmp, "valid-base"), { recursive: true }); + const r = run(["--all", "--plugins-root", tmp, "--json"]); + expect(r.exitCode).toBe(0); + expect(JSON.parse(r.stdout).ok).toBe(true); + }); +}); diff --git a/scripts/test-agent-plugin.js b/scripts/test-agent-plugin.js new file mode 100644 index 0000000..4c7c835 --- /dev/null +++ b/scripts/test-agent-plugin.js @@ -0,0 +1,235 @@ +#!/usr/bin/env node +/** + * test-agent-plugin.js — Run a plugin's static/structural assertions from + * tests/plugin.test.json (or inline --assert). Each assertion is a pure, + * deterministic predicate over the plugin's files — no Claude invocation. + * + * Usage: + * node scripts/test-agent-plugin.js --dir [--json] + * node scripts/test-agent-plugin.js --all [--plugins-root ] [--json] + * node scripts/test-agent-plugin.js --dir --assert "manifest.valid" [--assert ...] + * + * Exit codes: 0 all pass · 1 a failure · 2 usage/IO/unknown-assertion error + */ +import { readFileSync, existsSync, readdirSync, statSync, accessSync, constants } from "fs"; +import { join, dirname, resolve, basename } from "path"; +import { fileURLToPath } from "url"; +import { execFileSync } from "child_process"; +import { + parseFrontmatter, + loadManifest, + buildCatalog, + resolveDependencies, + countExamples, +} from "./agent-plugin-lib.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dirname, ".."); +const DEFAULT_PLUGINS_ROOT = join(repoRoot, ".claude", "agent-plugins"); +const VALIDATOR = join(__dirname, "validate-agent-plugin.js"); + +function parseArgs(argv) { + const out = { + dir: null, + all: false, + json: false, + asserts: [], + pluginsRoot: DEFAULT_PLUGINS_ROOT, + }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--dir") out.dir = resolve(argv[++i]); + else if (a === "--all") out.all = true; + else if (a === "--plugins-root") out.pluginsRoot = resolve(argv[++i]); + else if (a === "--json") out.json = true; + else if (a === "--assert") out.asserts.push(argv[++i]); + else if (a === "-h" || a === "--help") { + printHelp(); + process.exit(0); + } else { + console.error(`Unknown argument: ${a}`); + printHelp(); + process.exit(2); + } + } + if (out.dir && out.all) { + console.error("Use either --dir or --all, not both."); + process.exit(2); + } + return out; +} + +function printHelp() { + console.log(`Usage: node scripts/test-agent-plugin.js (--dir | --all) [options] + +Options: + --dir Test a single plugin directory + --all Test every plugin under the plugins root + --plugins-root Plugins root for --all (default: .claude/agent-plugins) + --assert "" Override assertions (repeatable) instead of tests/plugin.test.json + --json Machine-readable output + -h, --help Show this message`); +} + +function loadAgent(dir, manifest) { + const file = join(dir, manifest.agent || "agent.md"); + if (!existsSync(file)) return { frontmatter: {}, body: "" }; + return parseFrontmatter(readFileSync(file, "utf-8")); +} + +const PREDICATES = { + "manifest.valid": (ctx) => { + try { + execFileSync("node", [VALIDATOR, "--dir", ctx.dir, "--json"], { encoding: "utf-8" }); + return { pass: true }; + } catch { + return { pass: false, detail: "validate-agent-plugin reported issues" }; + } + }, + "deps.resolve": (ctx) => { + const { errors } = resolveDependencies(ctx.catalog, ctx.manifest.name); + return { pass: errors.length === 0, detail: errors.map((e) => e.message).join("; ") }; + }, + "hooks.executable": (ctx) => { + for (const rel of Object.values(ctx.manifest.hooks ?? {})) { + const p = join(ctx.dir, rel); + if (!existsSync(p)) return { pass: false, detail: `missing hook ${rel}` }; + try { + accessSync(p, constants.R_OK); + } catch { + return { pass: false, detail: `unreadable hook ${rel}` }; + } + } + return { pass: true }; + }, + "description.examples >= 2": (ctx) => { + const n = countExamples(ctx.agent.frontmatter.description || ""); + return { pass: n >= 2, detail: `found ${n} example(s)` }; + }, +}; + +function evalAssertion(str, ctx) { + if (PREDICATES[str]) return PREDICATES[str](ctx); + + let m = str.match(/^frontmatter\.has\(([^)]*)\)$/); + if (m) { + const fields = m[1] + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + const missing = fields.filter((f) => !ctx.agent.frontmatter[f]); + return { + pass: missing.length === 0, + detail: missing.length ? `missing ${missing.join(", ")}` : "", + }; + } + + m = str.match(/^frontmatter\.(\w+) in \(([^)]*)\)$/); + if (m) { + const field = m[1]; + const allowed = m[2].split(",").map((s) => s.trim()); + const val = ctx.agent.frontmatter[field]; + if (val === undefined) return { pass: true, detail: `${field} not set (optional)` }; + return { pass: allowed.includes(val), detail: `${field}="${val}"` }; + } + + m = str.match(/^prompt\.section\('(.+)'\)$/); + if (m) { + const heading = m[1]; + const re = new RegExp(`^#{1,6}\\s+${heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`, "m"); + const pass = re.test(ctx.agent.body); + return { pass, detail: pass ? "" : `no section "${heading}"` }; + } + + return { pass: false, unknown: true, detail: `unknown assertion "${str}"` }; +} + +function runPlugin(dir, catalog, overrideAsserts) { + const manifest = loadManifest(dir); + const agent = loadAgent(dir, manifest); + const ctx = { dir, manifest, agent, catalog, repoRoot }; + + let asserts = overrideAsserts; + if (!asserts || asserts.length === 0) { + const testsRel = manifest.tests || "tests/plugin.test.json"; + const testsFile = join(dir, testsRel); + if (!existsSync(testsFile)) { + return { name: manifest.name, ok: false, error: `no tests file (${testsRel})`, results: [] }; + } + asserts = JSON.parse(readFileSync(testsFile, "utf-8")).assert ?? []; + } + + const results = []; + let unknown = false; + for (const a of asserts) { + const r = evalAssertion(a, ctx); + if (r.unknown) unknown = true; + results.push({ assert: a, pass: r.pass, detail: r.detail || "" }); + } + return { name: manifest.name, ok: !unknown && results.every((r) => r.pass), unknown, results }; +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + if (!args.dir && !args.all) { + printHelp(); + process.exit(2); + } + + let dirs; + let catalog; + if (args.all) { + catalog = buildCatalog(args.pluginsRoot); + dirs = []; + if (existsSync(args.pluginsRoot)) { + for (const d of readdirSync(args.pluginsRoot)) { + const full = join(args.pluginsRoot, d); + try { + if (statSync(full).isDirectory() && existsSync(join(full, "plugin.json"))) + dirs.push(full); + } catch { + /* skip unreadable entry */ + } + } + } + } else { + if (!existsSync(join(args.dir, "plugin.json"))) { + console.error(`✗ No plugin.json in ${args.dir}`); + process.exit(2); + } + dirs = [args.dir]; + catalog = buildCatalog(dirname(args.dir)); + } + + const reports = []; + for (const d of dirs) { + try { + reports.push(runPlugin(d, catalog, args.asserts)); + } catch (e) { + reports.push({ name: basename(d), ok: false, error: e.message, results: [] }); + } + } + + const anyUnknown = reports.some((r) => r.unknown); + const ok = reports.every((r) => r.ok); + + if (args.json) { + console.log( + JSON.stringify({ ok, results: reports.flatMap((r) => r.results), reports }, null, 2), + ); + } else { + for (const r of reports) { + if (r.error) { + console.log(`✗ ${r.name}: ${r.error}`); + continue; + } + console.log(`${r.ok ? "✓" : "✗"} ${r.name}`); + for (const a of r.results) + console.log(` ${a.pass ? "✓" : "✗"} ${a.assert}${a.detail ? ` — ${a.detail}` : ""}`); + } + } + if (anyUnknown) process.exit(2); + process.exit(ok ? 0 : 1); +} + +main(); diff --git a/scripts/test-agent-plugin.sh b/scripts/test-agent-plugin.sh new file mode 100644 index 0000000..456ca61 --- /dev/null +++ b/scripts/test-agent-plugin.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec node "$DIR/test-agent-plugin.js" "$@" From b75f3a80e1e616ad67a5aa5f14a67b44be420795 Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Thu, 28 May 2026 10:15:53 -0400 Subject: [PATCH 17/20] refactor(plugins): share plugin-dir scan, surface validator detail in runner --- scripts/__tests__/agent-plugin-lib.test.js | 16 +++++ scripts/__tests__/test-agent-plugin.test.js | 78 +++++++++++++++++++++ scripts/agent-plugin-lib.js | 16 +++++ scripts/test-agent-plugin.js | 29 ++++---- scripts/validate-agent-plugin.js | 16 +---- 5 files changed, 127 insertions(+), 28 deletions(-) diff --git a/scripts/__tests__/agent-plugin-lib.test.js b/scripts/__tests__/agent-plugin-lib.test.js index 4db9c6d..fc93a61 100644 --- a/scripts/__tests__/agent-plugin-lib.test.js +++ b/scripts/__tests__/agent-plugin-lib.test.js @@ -8,6 +8,7 @@ import { buildCatalog, satisfiesRange, resolveDependencies, + listPluginDirs, } from "../agent-plugin-lib.js"; function makePlugin(root, name, version, deps = {}) { @@ -99,6 +100,21 @@ describe("buildCatalog + satisfiesRange", () => { expect(satisfiesRange("1.2.0", "^1.0.0")).toBe(true); expect(satisfiesRange("2.0.0", "^1.0.0")).toBe(false); }); + + it("listPluginDirs returns only directories containing plugin.json", () => { + const root = mkdtempSync(join(tmpdir(), "lpd-")); + try { + makePlugin(root, "p1", "1.0.0"); + mkdirSync(join(root, "not-a-plugin"), { recursive: true }); + writeFileSync(join(root, "loose.txt"), "x"); + const names = listPluginDirs(root) + .map((d) => d.split(/[\\/]/).pop()) + .sort(); + expect(names).toEqual(["p1"]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); }); describe("resolveDependencies", () => { diff --git a/scripts/__tests__/test-agent-plugin.test.js b/scripts/__tests__/test-agent-plugin.test.js index d2c72a6..96841ad 100644 --- a/scripts/__tests__/test-agent-plugin.test.js +++ b/scripts/__tests__/test-agent-plugin.test.js @@ -124,4 +124,82 @@ describe("test-agent-plugin.js", () => { expect(r.exitCode).toBe(0); expect(JSON.parse(r.stdout).ok).toBe(true); }); + + it("passes frontmatter. in (...) when the value is allowed", () => { + const r = run([ + "--dir", + join(FIX, "valid-base"), + "--assert", + "frontmatter.model in (opus,sonnet,haiku)", + "--json", + ]); + expect(r.exitCode).toBe(0); + }); + + it("fails frontmatter. in (...) when the value is not allowed", () => { + const r = run([ + "--dir", + join(FIX, "valid-base"), + "--assert", + "frontmatter.model in (opus,haiku)", + "--json", + ]); + expect(r.exitCode).toBe(1); + }); + + it("passes frontmatter. in (...) when the field is unset", () => { + const r = run([ + "--dir", + join(FIX, "valid-base"), + "--assert", + "frontmatter.permissionMode in (default,plan)", + "--json", + ]); + expect(r.exitCode).toBe(0); + }); + + it("fails deps.resolve for a missing dependency", () => { + const r = run(["--dir", join(FIX, "missing-dep"), "--assert", "deps.resolve", "--json"]); + expect(r.exitCode).toBe(1); + }); + + it("fails hooks.executable when a declared hook is absent", () => { + const dir = join(tmp, "bad-hook"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "plugin.json"), + JSON.stringify({ + name: "bad-hook", + version: "1.0.0", + description: "x", + hooks: { preInstall: "hooks/nope.sh" }, + }), + ); + writeFileSync( + join(dir, "agent.md"), + "---\nname: bad-hook\ndescription: D. a b\ntools: Read\nmodel: sonnet\n---\n\n## When to Use This Agent\n\nx\n", + ); + const r = run(["--dir", dir, "--assert", "hooks.executable", "--json"]); + expect(r.exitCode).toBe(1); + }); + + it("forces exit 2 under --all when one plugin has an unknown assertion", () => { + cpSync(join(FIX, "valid-base"), join(tmp, "valid-base"), { recursive: true }); + const weird = join(tmp, "weird"); + mkdirSync(join(weird, "tests"), { recursive: true }); + writeFileSync( + join(weird, "plugin.json"), + JSON.stringify({ name: "weird", version: "1.0.0", description: "x" }), + ); + writeFileSync( + join(weird, "agent.md"), + "---\nname: weird\ndescription: D. a b\ntools: Read\nmodel: sonnet\n---\n\n## When to Use This Agent\n\nx\n", + ); + writeFileSync( + join(weird, "tests", "plugin.test.json"), + JSON.stringify({ assert: ["bogus.assertion"] }), + ); + const r = run(["--all", "--plugins-root", tmp, "--json"]); + expect(r.exitCode).toBe(2); + }); }); diff --git a/scripts/agent-plugin-lib.js b/scripts/agent-plugin-lib.js index 320f694..f03c247 100644 --- a/scripts/agent-plugin-lib.js +++ b/scripts/agent-plugin-lib.js @@ -82,6 +82,22 @@ export function buildCatalog(pluginsRoot) { return catalog; } +/** List absolute paths of plugin directories (those containing plugin.json) under a root. + * Skips unreadable entries; returns [] if the root is missing. */ +export function listPluginDirs(root) { + const dirs = []; + if (!existsSync(root)) return dirs; + for (const entry of readdirSync(root)) { + const full = join(root, entry); + try { + if (statSync(full).isDirectory() && existsSync(join(full, "plugin.json"))) dirs.push(full); + } catch { + /* skip unreadable entry */ + } + } + return dirs; +} + /** True if `version` satisfies the semver `range`. */ export function satisfiesRange(version, range) { return semver.satisfies(version, range, { includePrerelease: true }); diff --git a/scripts/test-agent-plugin.js b/scripts/test-agent-plugin.js index 4c7c835..e96c0c1 100644 --- a/scripts/test-agent-plugin.js +++ b/scripts/test-agent-plugin.js @@ -11,7 +11,7 @@ * * Exit codes: 0 all pass · 1 a failure · 2 usage/IO/unknown-assertion error */ -import { readFileSync, existsSync, readdirSync, statSync, accessSync, constants } from "fs"; +import { readFileSync, existsSync, accessSync, constants } from "fs"; import { join, dirname, resolve, basename } from "path"; import { fileURLToPath } from "url"; import { execFileSync } from "child_process"; @@ -21,6 +21,7 @@ import { buildCatalog, resolveDependencies, countExamples, + listPluginDirs, } from "./agent-plugin-lib.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -82,8 +83,16 @@ const PREDICATES = { try { execFileSync("node", [VALIDATOR, "--dir", ctx.dir, "--json"], { encoding: "utf-8" }); return { pass: true }; - } catch { - return { pass: false, detail: "validate-agent-plugin reported issues" }; + } catch (e) { + let detail = "validate-agent-plugin reported issues"; + try { + const out = JSON.parse(e.stdout || "{}"); + const first = out.issues?.[0]; + if (first) detail = `${out.issues.length} issue(s): ${first.path}: ${first.message}`; + } catch { + /* keep the generic detail */ + } + return { pass: false, detail }; } }, "deps.resolve": (ctx) => { @@ -180,18 +189,7 @@ function main() { let catalog; if (args.all) { catalog = buildCatalog(args.pluginsRoot); - dirs = []; - if (existsSync(args.pluginsRoot)) { - for (const d of readdirSync(args.pluginsRoot)) { - const full = join(args.pluginsRoot, d); - try { - if (statSync(full).isDirectory() && existsSync(join(full, "plugin.json"))) - dirs.push(full); - } catch { - /* skip unreadable entry */ - } - } - } + dirs = listPluginDirs(args.pluginsRoot); } else { if (!existsSync(join(args.dir, "plugin.json"))) { console.error(`✗ No plugin.json in ${args.dir}`); @@ -218,6 +216,7 @@ function main() { JSON.stringify({ ok, results: reports.flatMap((r) => r.results), reports }, null, 2), ); } else { + if (args.all && reports.length === 0) console.log(`No plugins found under ${args.pluginsRoot}`); for (const r of reports) { if (r.error) { console.log(`✗ ${r.name}: ${r.error}`); diff --git a/scripts/validate-agent-plugin.js b/scripts/validate-agent-plugin.js index 29c894e..8366d33 100644 --- a/scripts/validate-agent-plugin.js +++ b/scripts/validate-agent-plugin.js @@ -15,7 +15,7 @@ * * Exit codes: 0 valid · 1 invalid · 2 usage/IO error */ -import { readFileSync, existsSync, readdirSync, statSync } from "fs"; +import { readFileSync, existsSync, statSync } from "fs"; import { join, dirname, resolve, basename } from "path"; import { fileURLToPath } from "url"; import { @@ -23,6 +23,7 @@ import { loadManifest, buildCatalog, resolveDependencies, + listPluginDirs, } from "./agent-plugin-lib.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -184,18 +185,7 @@ async function main() { let catalog; if (args.all) { catalog = buildCatalog(args.pluginsRoot); - dirs = []; - if (existsSync(args.pluginsRoot)) { - for (const d of readdirSync(args.pluginsRoot)) { - const full = join(args.pluginsRoot, d); - try { - if (statSync(full).isDirectory() && existsSync(join(full, "plugin.json"))) - dirs.push(full); - } catch { - /* skip unreadable entry */ - } - } - } + dirs = listPluginDirs(args.pluginsRoot); } else { if (!existsSync(join(args.dir, "plugin.json"))) { const msg = `No plugin.json found in ${args.dir}`; From 073de7f0f1c1ef46e3c63d06c2c26467b06a735d Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Thu, 28 May 2026 10:20:19 -0400 Subject: [PATCH 18/20] docs(plugins): document the agent plugin system Add docs/guides/agent-plugins.md (authoring guide: layout, manifest reference, the four CLIs, dependency model, lifecycle hooks, assertion catalog), and wire the commands into scripts/README.md and CLAUDE.md. Bump the documented script count to reflect the new tooling. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 18 +++- docs/guides/agent-plugins.md | 184 +++++++++++++++++++++++++++++++++++ scripts/README.md | 22 +++++ 3 files changed, 222 insertions(+), 2 deletions(-) create mode 100644 docs/guides/agent-plugins.md diff --git a/CLAUDE.md b/CLAUDE.md index 7050a29..cc48782 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,6 +122,12 @@ node scripts/validate-pipeline-config.js --config --schema # Export generated components as a publishable design-system workspace ./scripts/export-design-system.sh [--scope @org] [--output dir] [--framework ] [--dry-run] [--json] +# Author / validate / install / test custom agents as versioned plugins +node scripts/create-agent-plugin.js [--description ...] [--model ...] [--tools ...] [--with-hooks] +node scripts/validate-agent-plugin.js --dir # or --all [--plugins-root ] +node scripts/agent-registry.js (list | resolve | install | uninstall ) +node scripts/test-agent-plugin.js --dir # or --all [--plugins-root ] + # Incremental build with caching and profiling ./scripts/incremental-build.sh [phase|all] [--force] [--parallel] @@ -551,6 +557,14 @@ gh issue create # Create issue ./scripts/verify-all.sh --ci # CI mode: JSON output, exit 1 on any failure ``` +**Agent Plugins** (custom agents as versioned plugins — see `docs/guides/agent-plugins.md`): +```bash +node scripts/create-agent-plugin.js [--description ...] [--model ...] [--tools ...] [--with-hooks] +node scripts/validate-agent-plugin.js --dir # or --all +node scripts/agent-registry.js (list | resolve | install | uninstall ) +node scripts/test-agent-plugin.js --dir # or --all +``` + **Build Performance & Caching:** ```bash ./scripts/incremental-build.sh # Incremental build with caching @@ -565,7 +579,7 @@ node scripts/metrics-dashboard.js summary # Quick metrics summary --- -**Last Updated:** 2026-05-23 -**Architecture:** 53 agents, 20 skills, 4 plugins + gh CLI, Figma + Canva + Playwright MCP, 33 scripts, 8 hooks +**Last Updated:** 2026-05-28 +**Architecture:** 53 agents, 20 skills, 4 plugins + gh CLI, Figma + Canva + Playwright MCP, 38 scripts, 8 hooks > **Keeping counts in sync:** When adding or removing agents, skills, scripts, or hooks, update all count references across the project. Search for the old count number in `*.md` files to find all references: `CLAUDE.md`, `README.md`, `CONTRIBUTING.md`, `docs/onboarding/`, `docs/react-development/`, and `.claude/AGENT-NAMING-GUIDE.md`. The agent and skill counts are enforced automatically by `scripts/check-doc-counts.sh` (run in CI and on pre-commit), which recounts `.claude/agents/` and `.claude/skills/` and fails on any documented count that disagrees. diff --git a/docs/guides/agent-plugins.md b/docs/guides/agent-plugins.md new file mode 100644 index 0000000..11f8c13 --- /dev/null +++ b/docs/guides/agent-plugins.md @@ -0,0 +1,184 @@ +# Agent Plugin System + +A plugin packages a **custom Claude Code agent** plus the metadata needed to +validate, install, and dependency-resolve it. This lets you author agents in a +standardized, versioned way and install them (with their dependencies) into +`.claude/agents/` where Claude Code reads them. + +> Design and rationale: `docs/plans/2026-05-27-agent-plugin-system-design.md`. + +## What a plugin is + +A plugin is a directory under `.claude/agent-plugins//`: + +``` +.claude/agent-plugins// + plugin.json # manifest (validated against a JSON Schema) + agent.md # the agent: YAML frontmatter + prompt (the deliverable) + hooks/ # optional management-lifecycle scripts (.sh) + tests/ + plugin.test.json # static assertions run by the test runner +``` + +Authoring lives here. **Installing** copies `agent.md` to +`.claude/agents/.md` (where Claude Code reads it) and records state in +`.claude/agent-plugins/installed.json`. + +## The four CLIs + +Each is an ESM Node script with a `.sh` wrapper, a `--json` mode, and exit codes +`0` (ok) / `1` (failure) / `2` (usage/IO). They share `scripts/agent-plugin-lib.js`. + +| Script | Purpose | +|--------|---------| +| `create-agent-plugin.js` | Scaffold a new plugin directory | +| `validate-agent-plugin.js` | Validate a manifest (schema + structural checks) | +| `agent-registry.js` | List / resolve / install / uninstall plugins | +| `test-agent-plugin.js` | Run a plugin's static assertions | + +### Create + +```bash +node scripts/create-agent-plugin.js [--description "..."] \ + [--model opus|sonnet|haiku] [--tools "Read,Write"] [--with-hooks] [--force] [--json] +``` + +Scaffolds `plugin.json` (version `0.1.0`), an `agent.md` skeleton in the house +style (core expertise, *When to Use*, two `` blocks), and default test +assertions. With `--with-hooks`, also writes `hooks/pre-install.sh` and +`hooks/post-install.sh` stubs. Refuses to overwrite without `--force`. Prompts +for missing fields only on an interactive TTY (non-interactive runs need the +flags or `--json`). + +### Validate + +```bash +node scripts/validate-agent-plugin.js --dir [--json] +node scripts/validate-agent-plugin.js --all [--plugins-root ] [--json] +``` + +Validates `plugin.json` against `.claude/agent-plugin.schema.json` (collecting +all errors), then runs structural checks the schema cannot express: + +- `name` matches the directory name **and** the `agent.md` frontmatter `name`. +- The agent file exists; its frontmatter has `name`/`description`/`tools`, and + `model`/`permissionMode` are valid if present. +- Declared hook scripts exist. +- `skills` deps exist under `.claude/skills/`; `tools` deps exist as repo files. +- Agent dependencies resolve (in `--dir` mode the catalog is seeded from sibling + plugins, so siblings resolve without being installed). + +### Registry (install / uninstall) + +```bash +node scripts/agent-registry.js list [--json] +node scripts/agent-registry.js resolve [--json] # dry-run install order +node scripts/agent-registry.js install [--json] +node scripts/agent-registry.js uninstall [--force] [--json] +``` + +`install` resolves the dependency graph, installs in topological order (deps +first), runs lifecycle hooks, copies each `agent.md` into `.claude/agents/`, and +records `{version, sourceHash, installedAt}` in `installed.json`. Already-installed +plugins at the same version are skipped (idempotent). `uninstall` refuses to +remove a plugin another installed plugin depends on unless `--force`. + +### Test + +```bash +node scripts/test-agent-plugin.js --dir [--json] +node scripts/test-agent-plugin.js --all [--plugins-root ] [--json] +node scripts/test-agent-plugin.js --dir --assert "manifest.valid" [--assert ...] +``` + +Runs the assertions in the plugin's `tests/plugin.test.json` (or inline +`--assert` overrides). Assertions are **pure, deterministic predicates over the +plugin's files** — no Claude invocation. An unknown assertion string is a hard +error (exit 2) so typos can't silently pass. + +## Manifest reference (`plugin.json`) + +```jsonc +{ + "name": "react-perf-expert", // kebab-case ^[a-z][a-z0-9-]*$; matches dir + agent.md name + "version": "1.2.0", // semver + "description": "...", // required + "author": "...", // optional + "license": "MIT", // optional + "agent": "agent.md", // path to the prompt file (default "agent.md") + "dependencies": { + "agents": { "asset-cataloger": "^1.0.0" }, // name -> semver range; resolved + version-checked + "skills": ["react-testing-workflows"], // existence-checked under .claude/skills/ + "tools": ["scripts/visual-diff.js"] // existence-checked, repo-relative + }, + "hooks": { // all optional; paths relative to the plugin dir + "preInstall": "hooks/check-deps.sh", + "postInstall": "hooks/register.sh", + "preUninstall": "hooks/cleanup.sh", + "postUninstall":"hooks/unregister.sh" + }, + "tests": "tests/plugin.test.json" +} +``` + +The schema is strict (`additionalProperties: false` at the root and in `hooks`), +so typo'd keys are rejected. + +## Dependencies + +- **Agent deps** are versioned (`name → semver range`). The registry resolves + them transitively, topologically sorts the install order (deps before + dependents), and reports **missing** deps, **version** mismatches, and + **cycles** — all in one pass. +- **Skill and tool deps** are existence-checked only (they are not installed): + skills must exist under `.claude/skills/`, tools as repo-relative file paths. + +## Lifecycle hooks + +Hooks attach to the **plugin-management lifecycle** (not agent invocation — +Claude Code has no per-agent runtime hooks). The registry runs them via `bash` +with the environment variables `PLUGIN_NAME`, `PLUGIN_DIR`, and `PLUGIN_VERSION`: + +| Hook | When | On failure | +|------|------|------------| +| `preInstall` | before copying the agent | **aborts** the install | +| `postInstall` | after the agent is recorded | warns only | +| `preUninstall` | before removing the agent | **aborts** the uninstall | +| `postUninstall` | after the agent is removed | warns only | + +Hooks require `bash` on PATH (Git Bash/WSL on Windows). Follow the defensive +skeleton: `set -u`, `trap 'exit 0' ERR`, explicit `exit`. + +## Assertion catalog (`tests/plugin.test.json`) + +```jsonc +{ "assert": [ + "manifest.valid", // delegates to validate-agent-plugin.js + "frontmatter.has(name,description,tools)", + "frontmatter.model in (opus,sonnet,haiku)", // passes if the field is unset + "deps.resolve", // dependencies resolve with no errors + "hooks.executable", // declared hook files exist and are readable + "prompt.section('When to Use This Agent')", // a matching markdown heading exists + "description.examples >= 2" // counts blocks in the description +]} +``` + +The catalog is fixed; extend it by adding a predicate to `test-agent-plugin.js`. + +## Typical workflow + +```bash +# 1. Scaffold +node scripts/create-agent-plugin.js my-expert --description "Does the thing" --with-hooks + +# 2. Edit .claude/agent-plugins/my-expert/agent.md, then validate + test +node scripts/validate-agent-plugin.js --dir .claude/agent-plugins/my-expert +node scripts/test-agent-plugin.js --dir .claude/agent-plugins/my-expert + +# 3. Install it (and any dependencies) into .claude/agents/ +node scripts/agent-registry.js install my-expert +``` + +`validate-agent-plugin.js --all` and `test-agent-plugin.js --all` validate/test +every plugin under `.claude/agent-plugins/` and are wired into `verify-all.sh` +(they no-op when no plugins exist). diff --git a/scripts/README.md b/scripts/README.md index b118bb9..04a3d10 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -144,6 +144,28 @@ All scripts live in `scripts/` and are designed to run from the project root. - **Usage**: `./scripts/setup-project.sh my-app --next` or `--vite` - **What it does**: Copies template configs, installs dependencies, sets up testing +## Agent Plugin System + +Tooling to author, validate, install, and test custom Claude Code agents as +versioned plugins. Full guide: [`docs/guides/agent-plugins.md`](../docs/guides/agent-plugins.md). +All four CLIs share `agent-plugin-lib.js` and use `--json` + exit codes `0/1/2`. + +### Create Agent Plugin (`create-agent-plugin.js`) +- **Purpose**: Scaffold a new plugin (manifest, agent.md skeleton, default tests, optional hook stubs) +- **Usage**: `node scripts/create-agent-plugin.js [--description "..."] [--model opus|sonnet|haiku] [--tools "Read,Write"] [--with-hooks] [--force] [--json]` + +### Validate Agent Plugin (`validate-agent-plugin.js`) +- **Purpose**: Validate a manifest against the JSON Schema + structural checks (name consistency, agent file, hooks/skills/tools existence, dependency resolution) +- **Usage**: `node scripts/validate-agent-plugin.js --dir [--json]` or `--all [--plugins-root ]` + +### Agent Registry (`agent-registry.js`) +- **Purpose**: List / resolve / install / uninstall plugins with transitive dependency resolution and management-lifecycle hooks +- **Usage**: `node scripts/agent-registry.js (list | resolve | install | uninstall [--force]) [--json]` + +### Test Agent Plugin (`test-agent-plugin.js`) +- **Purpose**: Run a plugin's static, deterministic assertions (no Claude invocation); unknown assertions fail loudly +- **Usage**: `node scripts/test-agent-plugin.js --dir [--json]` or `--all [--plugins-root ]` + ## Agent-Specific Scripts Each agent has supporting scripts in `scripts//`: From 4e1182be1658f1365632e3aa013e8e62b493bbb3 Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Thu, 28 May 2026 10:24:00 -0400 Subject: [PATCH 19/20] ci(plugins): wire agent-plugin validate + test into verify-all Add an `agent-plugins` check that runs validate-agent-plugin.js --all and test-agent-plugin.js --all via verify-agent-plugins.sh. It skips (like bundle-size) when no plugins exist under .claude/agent-plugins/, so it is a no-op on repos without plugins. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/__tests__/verify-all.test.js | 31 ++++++++++++++++++++++++---- scripts/lib/common.sh | 10 +++++++++ scripts/verify-agent-plugins.sh | 11 ++++++++++ scripts/verify-all.sh | 14 ++++++++++++- 4 files changed, 61 insertions(+), 5 deletions(-) create mode 100644 scripts/verify-agent-plugins.sh diff --git a/scripts/__tests__/verify-all.test.js b/scripts/__tests__/verify-all.test.js index dd1f042..8991b39 100644 --- a/scripts/__tests__/verify-all.test.js +++ b/scripts/__tests__/verify-all.test.js @@ -108,8 +108,8 @@ describe("all checks pass", () => { const json = JSON.parse(r.stdout); expect(json.ok).toBe(true); expect(json.summary.fail).toBe(0); - expect(json.summary.total).toBe(8); - expect(json.checks).toHaveLength(8); + expect(json.summary.total).toBe(9); + expect(json.checks).toHaveLength(9); for (const check of json.checks) { expect(["pass", "skip"]).toContain(check.status); } @@ -153,9 +153,9 @@ describe("--skip and --include", () => { const json = JSON.parse(r.stdout); const passed = json.checks.filter((c) => c.status === "pass").map((c) => c.name); expect(passed.sort()).toEqual(["tokens", "types"]); - // The other six should all be skipped. + // The other seven should all be skipped. expect(json.summary.pass).toBe(2); - expect(json.summary.skip).toBe(6); + expect(json.summary.skip).toBe(7); }); it("failure inside --include still triggers exit 1", () => { @@ -187,6 +187,29 @@ describe("bundle-size conditional", () => { }); }); +describe("agent-plugins conditional", () => { + it("skips agent-plugins when no plugins exist", () => { + const r = run(tmpProject({ hasBuild: true }), ["--ci"]); + const json = JSON.parse(r.stdout); + const ap = json.checks.find((c) => c.name === "agent-plugins"); + expect(ap.status).toBe("skip"); + expect(ap.reason).toContain("no plugins"); + }); + + it("runs agent-plugins when a plugin exists", () => { + const dir = tmpProject({ hasBuild: true }); + mkdirSync(join(dir, ".claude", "agent-plugins", "p"), { recursive: true }); + writeFileSync(join(dir, ".claude", "agent-plugins", "p", "plugin.json"), "{}"); + const wrapper = join(dir, "scripts", "verify-agent-plugins.sh"); + writeFileSync(wrapper, "#!/usr/bin/env bash\necho ran\nexit 0\n"); + chmodSync(wrapper, 0o755); + const r = run(dir, ["--ci"]); + const json = JSON.parse(r.stdout); + const ap = json.checks.find((c) => c.name === "agent-plugins"); + expect(ap.status).toBe("pass"); + }); +}); + describe("missing check script", () => { it("marks the check as skip with a 'script not found' reason", () => { const dir = tmpProject({ hasBuild: true }); diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh index 941382a..fccc269 100644 --- a/scripts/lib/common.sh +++ b/scripts/lib/common.sh @@ -148,6 +148,16 @@ common_build_artifact_exists() { return 1 } +# Returns 0 if at least one agent plugin (a dir with plugin.json) exists under +# .claude/agent-plugins/ in the cwd. +common_agent_plugins_exist() { + local d + for d in .claude/agent-plugins/*/plugin.json; do + [[ -f "$d" ]] && return 0 + done + return 1 +} + # --- pipeline.config.json access ----------------------------------------- # # Thin wrapper around scripts/lib/pipeline-config.js so shell scripts no longer diff --git a/scripts/verify-agent-plugins.sh b/scripts/verify-agent-plugins.sh new file mode 100644 index 0000000..2dc8ba8 --- /dev/null +++ b/scripts/verify-agent-plugins.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# verify-agent-plugins.sh — Validate and test every agent plugin under +# .claude/agent-plugins/. Used as the `agent-plugins` check in verify-all.sh. +# Exits non-zero if validation or any plugin's assertions fail. +set -uo pipefail + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +node "$DIR/validate-agent-plugin.js" --all || exit 1 +node "$DIR/test-agent-plugin.js" --all || exit 1 +exit 0 diff --git a/scripts/verify-all.sh b/scripts/verify-all.sh index c0ec402..9635d9e 100644 --- a/scripts/verify-all.sh +++ b/scripts/verify-all.sh @@ -15,7 +15,7 @@ # # Check names: # lint-and-format, types, tests, accessibility, tokens, dead-code, security, -# bundle-size +# bundle-size, agent-plugins # # Exit codes: # 0 — every check passed (or was skipped intentionally) @@ -58,6 +58,7 @@ done # Each check: name | script path | extra args (space-separated) # `bundle-size` is conditional — it runs only when a build artifact exists. +# `agent-plugins` is conditional — it runs only when .claude/agent-plugins/ has plugins. ALL_CHECKS=( "lint-and-format|./scripts/lint-and-format.sh|--check" "types|./scripts/check-types.sh|" @@ -67,6 +68,7 @@ ALL_CHECKS=( "dead-code|./scripts/check-dead-code.sh|" "security|./scripts/check-security.sh|" "bundle-size|./scripts/check-bundle-size.sh|" + "agent-plugins|./scripts/verify-agent-plugins.sh|" ) if [[ "$LIST_ONLY" == "true" ]]; then @@ -125,6 +127,16 @@ run_check() { return 0 fi + if [[ "$name" == "agent-plugins" ]] && ! common_agent_plugins_exist; then + RESULTS_NAME+=("$name") + RESULTS_STATUS+=("skip") + RESULTS_EXIT+=("0") + RESULTS_MS+=("0") + RESULTS_REASON+=("no plugins under .claude/agent-plugins") + emit_progress "▸ $name … skipped (no plugins)" + return 0 + fi + if [[ ! -x "$script" ]] && [[ ! -f "$script" ]]; then RESULTS_NAME+=("$name") RESULTS_STATUS+=("skip") From a30f43f4ebeb544cacacf4f3aa2735bb0d951488 Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Thu, 28 May 2026 10:32:39 -0400 Subject: [PATCH 20/20] fix(plugins): track plugin sources in git and validate before install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un-ignore .claude/agent-plugins/ so authored plugins (and installed.json) are version-controlled — the "versioned plugins" premise was broken because .claude/* ignored the whole tree. Also validate each plugin before copying its agent.md into .claude/agents/, so a structurally invalid agent can no longer install with exit 0. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 1 + scripts/__tests__/agent-registry.test.js | 13 ++++++++++ scripts/agent-registry.js | 33 ++++++++++++++++++++++++ 3 files changed, 47 insertions(+) diff --git a/.gitignore b/.gitignore index ba15c6f..8de24ef 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ !.claude/pipeline.config.json !.claude/pipeline.config.schema.json !.claude/agent-plugin.schema.json +!.claude/agent-plugins/ !.claude/test-fixtures/ # Claude Code temporary/user-specific files diff --git a/scripts/__tests__/agent-registry.test.js b/scripts/__tests__/agent-registry.test.js index 2306cb2..ccf2c70 100644 --- a/scripts/__tests__/agent-registry.test.js +++ b/scripts/__tests__/agent-registry.test.js @@ -120,6 +120,19 @@ describe("agent-registry.js", () => { expect(r.stdout).toMatch(/not installed/i); }); + it("refuses to install a plugin that fails validation (exit 1, not copied)", () => { + const dir = join(pluginsRoot, "broken-agent"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "plugin.json"), + JSON.stringify({ name: "broken-agent", version: "1.0.0", description: "x" }), + ); + writeFileSync(join(dir, "agent.md"), "no frontmatter here"); + const r = run(["install", "broken-agent", "--json"]); + expect(r.exitCode).toBe(1); + expect(existsSync(join(root, ".claude", "agents", "broken-agent.md"))).toBe(false); + }); + it("aborts install when the preInstall hook fails (exit 1, no agent copied)", () => { writeHookedPlugin(); const r = run(["install", "hooked", "--json"], { FAIL_PRE: "1" }); diff --git a/scripts/agent-registry.js b/scripts/agent-registry.js index c32a812..016b809 100644 --- a/scripts/agent-registry.js +++ b/scripts/agent-registry.js @@ -26,6 +26,7 @@ import { createHash } from "crypto"; import { buildCatalog, resolveDependencies } from "./agent-plugin-lib.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); +const VALIDATOR = join(__dirname, "validate-agent-plugin.js"); function parseArgs(argv) { const out = { cmd: null, name: null, json: false, force: false, root: resolve(__dirname, "..") }; @@ -91,6 +92,24 @@ function sourceHash(file) { return createHash("sha256").update(readFileSync(file)).digest("hex").slice(0, 16); } +// Validate a plugin dir via the validator CLI. Returns { ok, detail }. +function validatePluginDir(dir) { + try { + execFileSync("node", [VALIDATOR, "--dir", dir, "--json"], { encoding: "utf-8" }); + return { ok: true }; + } catch (e) { + let detail = "validation failed"; + try { + const out = JSON.parse(e.stdout || "{}"); + const first = out.issues?.[0]; + if (first) detail = `${out.issues.length} issue(s): ${first.path}: ${first.message}`; + } catch { + /* keep the generic detail */ + } + return { ok: false, detail }; + } +} + function runHook(catalog, name, hook, fail) { const entry = catalog[name]; const rel = entry?.manifest?.hooks?.[hook]; @@ -174,6 +193,20 @@ function main() { process.exit(0); } + // Validate every plugin before copying anything, so a broken agent + // never lands in .claude/agents/ and the install stays all-or-nothing. + for (const name of order) { + const v = validatePluginDir(catalog[name].dir); + if (!v.ok) { + emit( + args.json, + { ok: false, error: `"${name}" failed validation`, detail: v.detail }, + () => console.error(`✗ "${name}" failed validation: ${v.detail}`), + ); + process.exit(1); + } + } + // install const installed = loadInstalled(p); mkdirSync(p.agentsDir, { recursive: true });