Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
2 changes: 1 addition & 1 deletion .github/plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
20 changes: 13 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,10 @@ the **filesystem**: every plugin must have a matching `plugins/<name>/plugin.jso
`name`/`description`/`version` and `source` `./plugins/<name>`), and no `plugins/<name>/` 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, any MCP server keys in an optional `plugins/<name>/.mcp.json`, and any
custom-agent entries in an optional `plugins/<name>/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
Expand Down Expand Up @@ -99,9 +101,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/<plugin>/skills/<skill>/` 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/<plugin>/skills/<skill>/` 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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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 /
Expand All @@ -118,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

Expand Down
39 changes: 35 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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).

Expand Down
8 changes: 8 additions & 0 deletions plugins/gitops-kubernetes/.mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"mcpServers": {
"flux-operator-mcp": {
"command": "flux-operator-mcp",
"args": ["serve"]
}
}
}
2 changes: 1 addition & 1 deletion plugins/gitops-kubernetes/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
119 changes: 90 additions & 29 deletions scripts/validate-manifests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@
# 3. Every plugins/<name>/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
Expand Down Expand Up @@ -42,13 +43,37 @@ validate_marketplace_parity() {
echo "✓ Marketplace manifests are in sync"
}

# 3. Every plugins/<name>/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/<name>/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)"
Expand All @@ -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>/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>/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
# 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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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>/SKILL.md"
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
Expand Down Expand Up @@ -126,35 +172,50 @@ validate_marketplace_plugins_parity() {
return "$failed"
}

# Skill directory names (sorted, space-separated) bundled under plugins/<name>/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, across ALL three
# resource kinds validate_plugin_json accepts (ADR 0001 §D3): every skill directory under
# plugins/<name>/skills/, every MCP server key in an optional plugins/<name>/.mcp.json, AND
# every custom-agent entry under an optional plugins/<name>/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"
{
for d in "plugins/$name/skills"/*/; do
[ -d "$d" ] || continue
basename "$d"
done
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' ' '
}

# 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:
# | [`<name>`](plugins/<name>/) | `skill-a`, `skill-b` | <editorial description> |
# The Description column stays free prose (plugin.json↔manifest already guards it).
# | [`<name>`](plugins/<name>/) | `skill-a`, `mcp-server-b` | <editorial description> |
# 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/<name>/ without a
# plugin.json would otherwise pass here yet stay invisible to the orphan scan below
Expand All @@ -164,12 +225,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/<name>/ on disk appears as a README row (no plugin missing from the table).
Expand Down
Loading