From d782ce2138675c1e1696c3ea37e031f3d528e4f6 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Tue, 30 Jun 2026 21:29:11 +0200 Subject: [PATCH 1/2] feat(gitops-kubernetes): bundle the flux-operator-mcp server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Realize the "not skills-only" marketplace (ADR 0001 §D4): bundle the first non-skill plugin resource. The gitops-kubernetes plugin's gitops-cluster-debug skill already declares `compatibility: Requires flux-operator-mcp`, so bundling the server makes it self-sufficient on Claude Code / Copilot CLI. - plugins/gitops-kubernetes/.mcp.json: canonical mcpServers entry for flux-operator-mcp (stdio `command`), auto-loaded by Claude Code + Copilot CLI. - scripts/validate-manifests.sh: generalize the gate per ADR §D3 (none weakened): require >=1 recognized resource (skills/ | .mcp.json | agents/); validate a bundled .mcp.json (valid JSON, non-empty .mcpServers, each server has command or url); README Skills column -> Resources (skills + MCP server names). - scripts/validate-manifests.test.sh: +6 self-tests pinning the new behaviour (valid stdio/remote pass, missing-from-README drift, non-JSON, empty .mcpServers, command/url-less server). 33/33 pass. - README.md: Resources column, MCP servers section documenting the Copilot CLI + VS Code (`servers` key) config and the binary install; broadened scope prose. - plugin.json + both marketplace manifests: broadened description (in parity). - AGENTS.md: resource-model conventions refreshed. Fixes #42 Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude-plugin/marketplace.json | 2 +- .github/plugin/marketplace.json | 2 +- AGENTS.md | 14 ++-- README.md | 39 ++++++++- plugins/gitops-kubernetes/.mcp.json | 8 ++ plugins/gitops-kubernetes/plugin.json | 2 +- scripts/validate-manifests.sh | 112 +++++++++++++++++++------- scripts/validate-manifests.test.sh | 41 +++++++++- 8 files changed, 175 insertions(+), 45 deletions(-) create mode 100644 plugins/gitops-kubernetes/.mcp.json diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 3ff11ba..b506381 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -11,7 +11,7 @@ "plugins": [ { "name": "gitops-kubernetes", - "description": "Flux CD debugging, knowledge, repository auditing, and tenant onboarding skills", + "description": "Flux CD debugging, knowledge, repository auditing, and tenant onboarding — skills plus the Flux MCP server for live-cluster debugging", "version": "1.0.0", "source": "./plugins/gitops-kubernetes" }, diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index 3ff11ba..b506381 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -11,7 +11,7 @@ "plugins": [ { "name": "gitops-kubernetes", - "description": "Flux CD debugging, knowledge, repository auditing, and tenant onboarding skills", + "description": "Flux CD debugging, knowledge, repository auditing, and tenant onboarding — skills plus the Flux MCP server for live-cluster debugging", "version": "1.0.0", "source": "./plugins/gitops-kubernetes" }, diff --git a/AGENTS.md b/AGENTS.md index 84c425f..35acef0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,8 +53,9 @@ the **filesystem**: every plugin must have a matching `plugins//plugin.jso `name`/`description`/`version` and `source` `./plugins/`), and no `plugins//` may exist without a manifest entry — so the manifests can never drift from what the repo actually ships. CI also checks the human-facing **README plugin table** against the filesystem: every plugin has a table row -(and vice versa) and each row's **Skills** column matches that plugin's on-disk `skills/` directories, -so the catalogue a reader sees can never drift from what ships either. +(and vice versa) and each row's **Resources** column matches that plugin's bundled resources — its +on-disk `skills/` directories plus any MCP server keys in an optional `plugins//.mcp.json` — so +the catalogue a reader sees can never drift from what ships either. All of these checks live in one place — [`scripts/validate-manifests.sh`](scripts/validate-manifests.sh), which CI runs and you can run locally (`./scripts/validate-manifests.sh`) before pushing. Its behaviour @@ -99,9 +100,12 @@ membership) is authored here. 1. **Two manifests in parity.** Every plugin appears in **both** `marketplace.json` files with the same `name`/`description`/`version`/`source`; CI enforces the diff. Edit both together. 2. **Plugin layout.** A plugin is a directory under `plugins/` with a `plugin.json` (kebab-case `name` - matching `^[a-z0-9-]+$`, a `description`, a `version`, and `"skills": "skills/"`) plus a `skills/` - subdirectory of installed skills. Skill dirs sit at `plugins//skills//` and each holds - a conformant `SKILL.md` (CI discovers them at depth 4). + matching `^[a-z0-9-]+$`, a `description`, a `version`) that declares **at least one resource**: + a `skills/` subdirectory (`"skills": "skills/"`), a bundled `.mcp.json` (MCP servers), and/or an + `agents/` directory. Skill dirs sit at `plugins//skills//` and each holds a conformant + `SKILL.md` (CI discovers them at depth 4). A bundled `.mcp.json` is a `{ "mcpServers": { … } }` map + whose every server carries a `command` (stdio) or `url` (remote); see + [ADR 0001](docs/adr/0001-bundling-mcp-servers-and-custom-agents.md) for the cross-tool delivery model. 3. **agentskills.io spec.** Every bundled `SKILL.md` must validate against the [`agentskills.io`](https://agentskills.io) spec — CI validates each discovered skill in a matrix. 4. **Tool-neutral.** Keep names, descriptions, and README framing cross-tool (VS Code / Copilot CLI / diff --git a/README.md b/README.md index 4857080..302645b 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,9 @@ This is a **tool-neutral plugin marketplace**, not a skills-only bundler. Every ## Plugins -| Plugin | Skills | Description | -|--------|--------|-------------| -| [`gitops-kubernetes`](plugins/gitops-kubernetes/) | `gitops-cluster-debug`, `gitops-knowledge`, `gitops-repo-audit`, `gitops-tenant-onboarding` | Flux CD debugging, knowledge, repository auditing, and tenant onboarding | +| Plugin | Resources | Description | +|--------|-----------|-------------| +| [`gitops-kubernetes`](plugins/gitops-kubernetes/) | `gitops-cluster-debug`, `gitops-knowledge`, `gitops-repo-audit`, `gitops-tenant-onboarding` (skills) · `flux-operator-mcp` (MCP server) | Flux CD debugging, knowledge, repository auditing, and tenant onboarding — bundles the Flux MCP server for live-cluster debugging | | [`github`](plugins/github/) | `gh-cli`, `gh-stack`, `github-actions-docs`, `github-issues` | GitHub CLI, stacked PRs, Actions docs, and issue management | | [`agentic-engineering`](plugins/agentic-engineering/) | `agent-instructions`, `copilot-instructions-blueprint-generator`, `copilot-sdk`, `find-skills` | Agentic AI framework SDKs, AI-assistant instruction authoring, and skill discovery | | [`go`](plugins/go/) | `golang-pro` | Go best practices, concurrency, generics, interfaces, and testing | @@ -55,11 +55,42 @@ Add the marketplace, then install a plugin — run these inside Claude Code: Browse everything on offer with `/plugin` (**Discover** tab) or list it with `/plugin list`. The bundled [`.claude-plugin/marketplace.json`](.claude-plugin/marketplace.json) is also discovered automatically when this repo is added as a plugin source. +## MCP servers + +A plugin may bundle [MCP](https://modelcontextprotocol.io) servers as well as skills. The +[`gitops-kubernetes`](plugins/gitops-kubernetes/) plugin bundles the **Flux MCP server** +([`flux-operator-mcp`](https://github.com/controlplaneio-fluxcd/flux-operator/tree/main/cmd/mcp)) +so its `gitops-cluster-debug` skill — which `Requires flux-operator-mcp` — works against a live +cluster out of the box. + +The server is authored once as the plugin's [`.mcp.json`](plugins/gitops-kubernetes/.mcp.json) +(`mcpServers` map). How each tool consumes it differs (per [ADR 0001](docs/adr/0001-bundling-mcp-servers-and-custom-agents.md)): + +- **Claude Code** and **Copilot CLI** — the bundled `.mcp.json` is loaded automatically when the + plugin is installed; no extra configuration is needed. +- **VS Code** consumes MCP but does not bundle it from a plugin. Add the equivalent entry to your + workspace `.vscode/mcp.json` (note the key is `servers`, not `mcpServers`): + + ```json + { + "servers": { + "flux-operator-mcp": { "command": "flux-operator-mcp", "args": ["serve"] } + } + } + ``` + +All three paths invoke the same `flux-operator-mcp` binary, so install it first — e.g. +`brew install controlplaneio-fluxcd/tap/flux-operator-mcp` or `go install +github.com/controlplaneio-fluxcd/flux-operator/cmd/mcp@latest` (it reads your kubeconfig from +`KUBECONFIG` / `~/.kube/config`). See the +[Flux MCP docs](https://fluxcd.control-plane.io/operator/mcp/) for read-only mode and remote +transport. + ## How it works Skills are installed from their upstream repositories using [`gh skill install`](https://github.blog/changelog/2026-04-16-manage-agent-skills-with-github-cli/). A [daily update workflow](.github/workflows/update-agent-skills.yaml) runs [`gh skill update --all`](https://github.com/devantler-tech/actions/tree/main/update-agent-skills) via the [`update-agent-skills`](https://github.com/devantler-tech/reusable-workflows/blob/main/.github/workflows/update-agent-skills.yaml) reusable workflow and opens a PR when upstream content has drifted. -Each plugin directory is self-contained with a `plugin.json` manifest and a `skills/` subdirectory holding the installed `SKILL.md` files (plus any supporting assets). Each `SKILL.md` contains `metadata.github-*` frontmatter for upstream provenance — no lockfile needed. +Each plugin directory is self-contained with a `plugin.json` manifest and its bundled resources — a `skills/` subdirectory holding the installed `SKILL.md` files (plus any supporting assets), and optionally an `.mcp.json` declaring bundled MCP servers. Each `SKILL.md` contains `metadata.github-*` frontmatter for upstream provenance — no lockfile needed. Each bundled skill is pulled from its own upstream (recorded in its `SKILL.md` `metadata.github-*` frontmatter), spanning many sources — including our in-house sibling library [`devantler-tech/agent-skills`](https://github.com/devantler-tech/agent-skills). diff --git a/plugins/gitops-kubernetes/.mcp.json b/plugins/gitops-kubernetes/.mcp.json new file mode 100644 index 0000000..ffe62d2 --- /dev/null +++ b/plugins/gitops-kubernetes/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "flux-operator-mcp": { + "command": "flux-operator-mcp", + "args": ["serve"] + } + } +} diff --git a/plugins/gitops-kubernetes/plugin.json b/plugins/gitops-kubernetes/plugin.json index d52b42d..6d10143 100644 --- a/plugins/gitops-kubernetes/plugin.json +++ b/plugins/gitops-kubernetes/plugin.json @@ -1,6 +1,6 @@ { "name": "gitops-kubernetes", - "description": "Flux CD debugging, knowledge, repository auditing, and tenant onboarding skills", + "description": "Flux CD debugging, knowledge, repository auditing, and tenant onboarding — skills plus the Flux MCP server for live-cluster debugging", "version": "1.0.0", "author": { "name": "devantler-tech", diff --git a/scripts/validate-manifests.sh b/scripts/validate-manifests.sh index 15c54e2..67fc3f4 100755 --- a/scripts/validate-manifests.sh +++ b/scripts/validate-manifests.sh @@ -8,8 +8,9 @@ # 3. Every plugins//plugin.json is complete and well-shaped. # 4. Manifest entries and on-disk plugins are in lockstep (no missing/orphan plugin, # no name/description/version/source divergence). -# 5. The README plugin table and on-disk plugins/skills are in lockstep (every plugin -# has a row and vice versa; each row's Skills column matches the plugin's skills/). +# 5. The README plugin table and on-disk plugin resources are in lockstep (every plugin +# has a row and vice versa; each row's Resources column matches the plugin's bundled +# skills + MCP servers). # # Operates on the current working directory (run from the repo root, exactly as CI # does). Documented in AGENTS.md for local runs and self-tested by @@ -42,13 +43,37 @@ validate_marketplace_parity() { echo "✓ Marketplace manifests are in sync" } -# 3. Every plugins//plugin.json is complete and well-shaped. +# A bundled MCP server (ADR 0001 §D3): an .mcp.json must be valid JSON with a +# non-empty '.mcpServers' object, each server carrying a 'command' (stdio transport) +# or a 'url' (remote transport). +validate_mcp_json() { + local mcp="$1" bad + if ! jq -e . "$mcp" > /dev/null 2>&1; then + echo "::error::$mcp: not valid JSON" + return 1 + fi + if [ "$(jq -r '(.mcpServers // {}) | length' "$mcp")" -eq 0 ]; then + echo "::error::$mcp: '.mcpServers' must be a non-empty object" + return 1 + fi + bad=$(jq -r '.mcpServers | to_entries[] + | select((.value.command // "") == "" and (.value.url // "") == "") | .key' "$mcp") + if [ -n "$bad" ]; then + echo "::error::$mcp: server(s) missing a 'command' (stdio) or 'url' (remote): ${bad//$'\n'/ }" + return 1 + fi + return 0 +} + +# 3. Every plugins//plugin.json is complete and well-shaped, declaring at least +# one recognized resource (skills/, a bundled .mcp.json, or agents/) — ADR 0001 §D3. validate_plugin_json() { local failed=0 - local pj plugin_dir ok plugin_name + local pj plugin_dir ok plugin_name resource_count for pj in plugins/*/plugin.json; do plugin_dir=$(dirname "$pj") ok=1 + resource_count=0 plugin_name=$(jq -r '.name // ""' "$pj") if ! echo "$plugin_name" | grep -qE '^[a-z0-9-]+$'; then echo "::error::$pj: name '$plugin_name' must be kebab-case (a-z, 0-9, hyphens)" @@ -62,12 +87,33 @@ validate_plugin_json() { echo "::error::$pj: missing or empty 'version' field" ok=0 fi - if [ "$(jq -r '.skills // ""' "$pj")" != "skills/" ]; then - echo "::error::$pj: 'skills' must be \"skills/\"" - ok=0 + # Skills resource: when '.skills' is declared it must be "skills/" and contain at + # least one /SKILL.md. (Existing check — unchanged, only made conditional.) + if [ "$(jq -e 'has("skills")' "$pj")" = "true" ]; then + if [ "$(jq -r '.skills // ""' "$pj")" != "skills/" ]; then + echo "::error::$pj: 'skills' must be \"skills/\"" + ok=0 + elif ! find "$plugin_dir/skills" -mindepth 2 -maxdepth 2 -name SKILL.md -print -quit 2>/dev/null | grep -q .; then + echo "::error::$plugin_dir: 'skills/' must contain at least one /SKILL.md" + ok=0 + else + resource_count=$((resource_count + 1)) + fi + fi + # MCP resource: a bundled .mcp.json at the plugin root must validate. + if [ -f "$plugin_dir/.mcp.json" ]; then + if validate_mcp_json "$plugin_dir/.mcp.json"; then + resource_count=$((resource_count + 1)) + else + ok=0 + fi fi - if ! find "$plugin_dir/skills" -mindepth 2 -maxdepth 2 -name SKILL.md -print -quit 2>/dev/null | grep -q .; then - echo "::error::$plugin_dir: 'skills/' must contain at least one /SKILL.md" + # Custom-agents resource: an agents/ directory holding at least one entry. + if find "$plugin_dir/agents" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null | grep -q .; then + resource_count=$((resource_count + 1)) + fi + if [ "$resource_count" -eq 0 ]; then + echo "::error::$plugin_dir: must declare at least one resource (skills/, .mcp.json, or agents/)" ok=0 fi if [ "$ok" -eq 1 ]; then @@ -126,35 +172,43 @@ validate_marketplace_plugins_parity() { return "$failed" } -# Skill directory names (sorted, space-separated) bundled under plugins//skills/. -# Count EVERY skill directory, not only those that already carry a SKILL.md, so a -# stray/half-added skill folder (the exact drift this parity check guards against) -# is surfaced rather than silently hidden. -plugin_disk_skills() { - local name="$1" d - for d in "plugins/$name/skills"/*/; do - [ -d "$d" ] || continue - basename "$d" - done | sort | tr '\n' ' ' +# Resource token names (sorted, space-separated) a plugin bundles: every skill +# directory under plugins//skills/ PLUS every MCP server key in an optional +# plugins//.mcp.json. These are the tokens the README "Resources" column must +# list (ADR 0001 §D3). Count EVERY skill directory, not only those that already carry a +# SKILL.md, so a stray/half-added skill folder (the exact drift this parity check guards +# against) is surfaced rather than silently hidden. +plugin_disk_resources() { + local name="$1" d mcp="plugins/$1/.mcp.json" + { + for d in "plugins/$name/skills"/*/; do + [ -d "$d" ] || continue + basename "$d" + done + if [ -f "$mcp" ]; then + jq -r '.mcpServers // {} | keys[]' "$mcp" + fi + } | sort | tr '\n' ' ' } -# 5. The README plugin table and on-disk plugins/skills are in lockstep. +# 5. The README plugin table and on-disk plugin resources are in lockstep. # Table rows look like: -# | [``](plugins//) | `skill-a`, `skill-b` | | -# The Description column stays free prose (plugin.json↔manifest already guards it). +# | [``](plugins//) | `skill-a`, `mcp-server-b` | | +# The Resources column lists every bundled skill AND MCP server; the Description +# column stays free prose (plugin.json↔manifest already guards it). # The backticks below are literal table-cell markers in regex/sed patterns, not command # substitution — SC2016 (won't-expand) is a false positive here. # shellcheck disable=SC2016 validate_readme_parity() { local failed=0 - local line name readme_skills disk_skills + local line name readme_resources disk_resources local readme_names=() - # Each README plugin row: parse the plugin name (col 1) and its Skills column (col 3). + # Each README plugin row: parse the plugin name (col 1) and its Resources column (col 3). while IFS= read -r line; do name=$(printf '%s' "$line" | sed -nE 's/^\| \[`([a-z0-9-]+)`\].*/\1/p') [ -z "$name" ] && continue readme_names+=("$name") - readme_skills=$(printf '%s' "$line" | awk -F'|' '{print $3}' \ + readme_resources=$(printf '%s' "$line" | awk -F'|' '{print $3}' \ | grep -oE '`[a-z0-9-]+`' | tr -d '`' | sort | tr '\n' ' ') # Require the manifest, not just the directory: a stray plugins// without a # plugin.json would otherwise pass here yet stay invisible to the orphan scan below @@ -164,12 +218,12 @@ validate_readme_parity() { failed=1 continue fi - disk_skills=$(plugin_disk_skills "$name") - if [ "$readme_skills" != "$disk_skills" ]; then - echo "::error::$README Skills for '$name' (${readme_skills% }) differ from plugins/$name/skills/ (${disk_skills% })" + disk_resources=$(plugin_disk_resources "$name") + if [ "$readme_resources" != "$disk_resources" ]; then + echo "::error::$README Resources for '$name' (${readme_resources% }) differ from on-disk resources (${disk_resources% })" failed=1 else - echo "✓ $README ↔ plugins/$name (skills: ${disk_skills% })" + echo "✓ $README ↔ plugins/$name (resources: ${disk_resources% })" fi done < <(grep -E '^\| \[`[a-z0-9-]+`\]' "$README") # Every plugins// on disk appears as a README row (no plugin missing from the table). diff --git a/scripts/validate-manifests.test.sh b/scripts/validate-manifests.test.sh index 39695ab..aa1c2d0 100755 --- a/scripts/validate-manifests.test.sh +++ b/scripts/validate-manifests.test.sh @@ -179,17 +179,17 @@ printf 'Ghost skill.\n' > "$d/plugins/gamma/skills/example-skill/SKILL.md" printf '| [`gamma`](plugins/gamma/) | `example-skill` | Ghost plugin |\n' >> "$d/README.md" check_fail "README row for dir without plugin.json fails" "README.md lists plugin 'gamma' with no plugins/gamma/plugin.json on disk" "$d" -# A stray skill directory with no SKILL.md is still counted, so the README Skills +# A stray skill directory with no SKILL.md is still counted, so the README Resources # column drifts out of lockstep and the guard fails (it is not silently hidden). d=$(fresh) mkdir -p "$d/plugins/alpha/skills/half-added-skill" -check_fail "skill dir without SKILL.md still counted (drift caught)" "README.md Skills for 'alpha'" "$d" +check_fail "skill dir without SKILL.md still counted (drift caught)" "README.md Resources for 'alpha'" "$d" -# A skill added on disk but not reflected in the README Skills column. +# A skill added on disk but not reflected in the README Resources column. d=$(fresh) mkdir -p "$d/plugins/alpha/skills/second-skill" printf 'Second skill.\n' > "$d/plugins/alpha/skills/second-skill/SKILL.md" -check_fail "README skills drift vs disk fails" "README.md Skills for 'alpha'" "$d" +check_fail "README skills drift vs disk fails" "README.md Resources for 'alpha'" "$d" # A plugin on disk (and in the manifests) with no README table row. d=$(fresh) @@ -267,6 +267,39 @@ Body. EOF check_fail "SKILL.md with comment-only github-repo fails" "missing upstream provenance" "$d" +# --- check 7: bundled MCP servers (.mcp.json) --- +# A plugin bundling a valid .mcp.json alongside its skills passes, and the bundled MCP +# server name is required in the README Resources column (parity counts skills + servers). +d=$(fresh) +printf '%s\n' '{ "mcpServers": { "test-mcp": { "command": "test-mcp", "args": ["serve"] } } }' > "$d/plugins/alpha/.mcp.json" +# shellcheck disable=SC2016 +sed 's/`example-skill` | Alpha plugin/`example-skill`, `test-mcp` | Alpha plugin/' "$d/README.md" > "$d/tmp" && mv "$d/tmp" "$d/README.md" +check_pass "plugin bundling a valid .mcp.json passes (MCP server in README resources)" "$d" + +# A remote (url) MCP server is equally valid. +d=$(fresh) +printf '%s\n' '{ "mcpServers": { "test-mcp": { "type": "http", "url": "https://example.com/mcp" } } }' > "$d/plugins/alpha/.mcp.json" +# shellcheck disable=SC2016 +sed 's/`example-skill` | Alpha plugin/`example-skill`, `test-mcp` | Alpha plugin/' "$d/README.md" > "$d/tmp" && mv "$d/tmp" "$d/README.md" +check_pass "plugin bundling a remote (url) MCP server passes" "$d" + +# A bundled MCP server name missing from the README Resources column drifts out of lockstep. +d=$(fresh) +printf '%s\n' '{ "mcpServers": { "test-mcp": { "command": "test-mcp" } } }' > "$d/plugins/alpha/.mcp.json" +check_fail "MCP server missing from README resources fails" "README.md Resources for 'alpha'" "$d" + +# An .mcp.json that is not valid JSON is rejected. +d=$(fresh); printf '%s\n' 'not json' > "$d/plugins/alpha/.mcp.json" +check_fail "non-JSON .mcp.json fails" "not valid JSON" "$d" + +# An empty '.mcpServers' object is rejected. +d=$(fresh); printf '%s\n' '{ "mcpServers": {} }' > "$d/plugins/alpha/.mcp.json" +check_fail "empty .mcpServers fails" "'.mcpServers' must be a non-empty object" "$d" + +# A server carrying neither 'command' (stdio) nor 'url' (remote) is rejected. +d=$(fresh); printf '%s\n' '{ "mcpServers": { "bad": { "args": ["serve"] } } }' > "$d/plugins/alpha/.mcp.json" +check_fail "MCP server with no command/url fails" "missing a 'command' (stdio) or 'url' (remote)" "$d" + echo "-----------------------------------------" echo "validate-manifests.sh self-test: $pass passed, $fail failed" [ "$fail" -eq 0 ] From 0a6adec483e5b00d840e93f7c1c14a5a78d100b4 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Tue, 30 Jun 2026 22:20:33 +0200 Subject: [PATCH 2/2] fix(validate): enumerate agents/ in resource parity; align AGENTS.md resource model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit flagged an internal inconsistency: validate_plugin_json accepts a non-empty agents/ as a standalone resource (ADR 0001 §D3 three-resource model), but plugin_disk_resources (the README parity enumerator) and AGENTS.md still described only skills + MCP. Complete the generalization coherently — no check weakened, parity strengthened: - plugin_disk_resources now also enumerates agents/ entries (basename, .md stripped), kept in lockstep with validate_plugin_json's resource model so a plugin can't satisfy that check with a resource kind the enumerator ignores. - AGENTS.md README-parity rule + lockstep convention now name all three resource kinds (skills/, .mcp.json keys, agents/) and the Resources column. - +2 self-test cases pinning agents/-in-resources parity (33 -> 35 passing). Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 10 ++++++---- scripts/validate-manifests.sh | 19 +++++++++++++------ scripts/validate-manifests.test.sh | 15 +++++++++++++++ 3 files changed, 34 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 35acef0..2564bee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,8 +54,9 @@ the **filesystem**: every plugin must have a matching `plugins//plugin.jso without a manifest entry — so the manifests can never drift from what the repo actually ships. CI also checks the human-facing **README plugin table** against the filesystem: every plugin has a table row (and vice versa) and each row's **Resources** column matches that plugin's bundled resources — its -on-disk `skills/` directories plus any MCP server keys in an optional `plugins//.mcp.json` — so -the catalogue a reader sees can never drift from what ships either. +on-disk `skills/` directories, any MCP server keys in an optional `plugins//.mcp.json`, and any +custom-agent entries in an optional `plugins//agents/` — so the catalogue a reader sees can never +drift from what ships either. All of these checks live in one place — [`scripts/validate-manifests.sh`](scripts/validate-manifests.sh), which CI runs and you can run locally (`./scripts/validate-manifests.sh`) before pushing. Its behaviour @@ -122,8 +123,9 @@ membership) is authored here. intent, not a version bump. 8. **README and manifests stay in lockstep.** The README plugin table mirrors the manifests; update it in the same PR whenever the plugin set changes. CI enforces this: every plugin has a table row and - vice versa, and each row's **Skills** column matches that plugin's `skills/` directories on disk (the - **Description** column stays editorial). + vice versa, and each row's **Resources** column matches that plugin's bundled resources on disk — its + `skills/` directories, any `.mcp.json` server keys, and any `agents/` entries (the **Description** + column stays editorial). ## Validation diff --git a/scripts/validate-manifests.sh b/scripts/validate-manifests.sh index 67fc3f4..1214d9a 100755 --- a/scripts/validate-manifests.sh +++ b/scripts/validate-manifests.sh @@ -172,12 +172,15 @@ validate_marketplace_plugins_parity() { return "$failed" } -# Resource token names (sorted, space-separated) a plugin bundles: every skill -# directory under plugins//skills/ PLUS every MCP server key in an optional -# plugins//.mcp.json. These are the tokens the README "Resources" column must -# list (ADR 0001 §D3). Count EVERY skill directory, not only those that already carry a -# SKILL.md, so a stray/half-added skill folder (the exact drift this parity check guards -# against) is surfaced rather than silently hidden. +# Resource token names (sorted, space-separated) a plugin bundles, across ALL three +# resource kinds validate_plugin_json accepts (ADR 0001 §D3): every skill directory under +# plugins//skills/, every MCP server key in an optional plugins//.mcp.json, AND +# every custom-agent entry under an optional plugins//agents/ (its basename, with a +# trailing .md stripped). These are the tokens the README "Resources" column must list. +# Count EVERY skill directory / agent entry, not only those already fleshed out, so a +# stray/half-added folder (the exact drift this parity check guards against) is surfaced +# rather than silently hidden. Kept in lockstep with validate_plugin_json's resource model +# so a plugin can never satisfy that check with a resource kind this enumerator ignores. plugin_disk_resources() { local name="$1" d mcp="plugins/$1/.mcp.json" { @@ -188,6 +191,10 @@ plugin_disk_resources() { if [ -f "$mcp" ]; then jq -r '.mcpServers // {} | keys[]' "$mcp" fi + for d in "plugins/$name/agents"/*; do + [ -e "$d" ] || continue + basename "$d" .md + done } | sort | tr '\n' ' ' } diff --git a/scripts/validate-manifests.test.sh b/scripts/validate-manifests.test.sh index aa1c2d0..df44e30 100755 --- a/scripts/validate-manifests.test.sh +++ b/scripts/validate-manifests.test.sh @@ -300,6 +300,21 @@ check_fail "empty .mcpServers fails" "'.mcpServers' must be a non-empty object" d=$(fresh); printf '%s\n' '{ "mcpServers": { "bad": { "args": ["serve"] } } }' > "$d/plugins/alpha/.mcp.json" check_fail "MCP server with no command/url fails" "missing a 'command' (stdio) or 'url' (remote)" "$d" +# --- check 8: bundled custom agents (agents/) --- +# validate_plugin_json accepts a non-empty agents/ as a standalone resource, so the parity +# enumerator must list each agent entry (basename, trailing .md stripped) in the README too. +# A plugin bundling agents/.md passes when that agent name is in the README Resources. +d=$(fresh) +mkdir -p "$d/plugins/alpha/agents"; printf '%s\n' 'A custom agent.' > "$d/plugins/alpha/agents/test-agent.md" +# shellcheck disable=SC2016 +sed 's/`example-skill` | Alpha plugin/`example-skill`, `test-agent` | Alpha plugin/' "$d/README.md" > "$d/tmp" && mv "$d/tmp" "$d/README.md" +check_pass "plugin bundling a custom agent passes (agent in README resources)" "$d" + +# A bundled agent name missing from the README Resources column drifts out of lockstep. +d=$(fresh) +mkdir -p "$d/plugins/alpha/agents"; printf '%s\n' 'A custom agent.' > "$d/plugins/alpha/agents/test-agent.md" +check_fail "custom agent missing from README resources fails" "README.md Resources for 'alpha'" "$d" + echo "-----------------------------------------" echo "validate-manifests.sh self-test: $pass passed, $fail failed" [ "$fail" -eq 0 ]