feat: split MCP servers into their own plugins, add per-target restrictions
Generate plugin manifests / validate (pull_request) Successful in 18s
Generate plugin manifests / generate (pull_request) Skipped

Split the GitHub/Gitea MCP servers out of core-essentials into their own
github-mcp/gitea-mcp plugins, so a user can install either, both, or
neither independently instead of always getting both bundled together.

Add an optional "targets" field, honored at both levels:
  - plugin.json: restricts the whole plugin (including any hooks/mcp/
    rules/commands/agents it bundles) to specific targets.
  - a skill's own SKILL.md frontmatter: restricts just that one skill,
    independent of its plugin's sibling skills (narrowed to, never wider
    than, the plugin's own targets).

Omitting it (the default everywhere) means every target in manifest.yaml,
so this is fully backward compatible. A plugin left with no content at
all for a given target is now skipped entirely for that target rather
than emitting an empty output directory / marketplace entry.

Applies this to the actual motivating case: delegate-agy only makes sense
run from Claude Code (it shells out to `agy`), so it's now marked
targets: [claude-code] and no longer ships into dist/agy at all — it was
previously being installed into agy for no reason.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014YVWWAnvR5TDQzmfY3RXG9
This commit is contained in:
2026-08-24 20:28:36 -04:00
co-authored by Claude Sonnet 5
parent 68fbadbfec
commit c6f40bcf6b
21 changed files with 223 additions and 103 deletions
+8
View File
@@ -22,6 +22,14 @@
"name": "git-publish-workflow",
"source": "./dist/claude-code/git-publish-workflow"
},
{
"name": "gitea-mcp",
"source": "./dist/claude-code/gitea-mcp"
},
{
"name": "github-mcp",
"source": "./dist/claude-code/github-mcp"
},
{
"name": "readme-sync-audit",
"source": "./dist/claude-code/readme-sync-audit"
+45 -1
View File
@@ -19,11 +19,14 @@ from one source tree and published as a native marketplace for both tools.
- [technical-devlog-scribe](#technical-devlog-scribe)
- [ship-it](#ship-it)
- [core-essentials](#core-essentials)
- [github-mcp](#github-mcp)
- [gitea-mcp](#gitea-mcp)
- [Installation](#installation)
- [Claude Code](#claude-code)
- [Antigravity CLI (agy)](#antigravity-cli-agy)
- [Repository Structure](#repository-structure)
- [Anatomy of a Plugin](#anatomy-of-a-plugin)
- [Restricting a Plugin (or a Skill) to Specific Targets](#restricting-a-plugin-or-a-skill-to-specific-targets)
- [How Generation Works](#how-generation-works)
- [Private Overlay Builds](#private-overlay-builds)
- [License](#license)
@@ -130,7 +133,28 @@ Two sequential phases — Phase 2 is blocked until Phase 1 succeeds:
Currently bundles `delegate-agy`, which hands a subtask off to the
Antigravity CLI (`agy`) in headless mode — useful for a second opinion,
external grounded research, or a large multi-file audit (>500 lines) that
would otherwise bloat the current context.
would otherwise bloat the current context. Only makes sense run *from*
Claude Code, so its `SKILL.md` declares `targets: [claude-code]` — this
plugin has no agy output at all.
---
### `github-mcp`
**Purpose:** Connect Claude Code/agy to GitHub via the official
`@modelcontextprotocol/server-github`, authenticated with a
`GITHUB_PERSONAL_ACCESS_TOKEN` you set locally — never stored in the repo.
---
### `gitea-mcp`
**Purpose:** Connect Claude Code/agy to a Gitea instance via the official
`gitea-mcp` server, authenticated with `GITEA_HOST`/`GITEA_ACCESS_TOKEN`
you set locally.
Split from `github-mcp` into its own plugin so you can install either,
both, or neither independently.
---
@@ -218,6 +242,26 @@ verbatim where the two tools' schemas diverge:
target; `commands/`/`agents/` have no agy equivalent and are skipped for
that target.
### Restricting a Plugin (or a Skill) to Specific Targets
Some content only makes sense for one tool — `delegate-agy` (Claude Code
shelling out to `agy`) has no reason to exist *inside* agy, for instance.
Declare a `targets` list wherever it's needed:
- In `plugin.json`, `"targets": ["claude-code"]` restricts the **whole
plugin** — including any hooks/mcp/rules/commands/agents it bundles —
to just the listed targets.
- In a skill's own `SKILL.md` frontmatter, `targets: [claude-code]`
restricts **just that skill**, independent of its sibling skills in the
same plugin. A skill's targets are narrowed to, never wider than, its
plugin's own targets.
Omitting `targets` (the default everywhere) means "every target in
`manifest.yaml`" — today's behavior for every existing plugin. A plugin
left with no content at all for a given target (every skill excluded, and
nothing of its own) is skipped entirely for that target — no empty output
directory, no marketplace entry.
### How Generation Works
`scripts/generate_plugins.py` reads `manifest.yaml` (marketplace metadata,
-3
View File
@@ -1,3 +0,0 @@
{
"name": "core-essentials"
}
-18
View File
@@ -1,18 +0,0 @@
---
name: delegate-agy
description: Delegates a subtask to the Antigravity CLI (agy) when the user wants a second opinion, external grounded research, or needs a large multi-file audit (>500 lines) processed without bloating the current context.
version: 1.0.0
user-invocable: true
author: Rootiest
---
# Antigravity Subagent Delegation (`delegate-agy`)
When the user asks for a second opinion, external grounded research, or when a task requires processing large multi-file audits (>500 lines) that would bloat context, delegate the subtask to `agy`.
## Execution Syntax
Run `agy` in headless, non-interactive mode using the bash tool:
```bash
agy --dangerously-skip-permissions -p "<detailed_task_prompt>"
```
@@ -12,16 +12,6 @@
"GITEA_HOST": "${GITEA_HOST}",
"GITEA_ACCESS_TOKEN": "${GITEA_ACCESS_TOKEN}"
}
},
"github": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-github"
],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}"
}
}
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"name": "gitea-mcp"
}
+14
View File
@@ -0,0 +1,14 @@
{
"mcpServers": {
"github": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-github"
],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}"
}
}
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"name": "github-mcp"
}
-18
View File
@@ -1,18 +0,0 @@
---
name: delegate-agy
description: Delegates a subtask to the Antigravity CLI (agy) when the user wants a second opinion, external grounded research, or needs a large multi-file audit (>500 lines) processed without bloating the current context.
version: 1.0.0
user-invocable: true
author: Rootiest
---
# Antigravity Subagent Delegation (`delegate-agy`)
When the user asks for a second opinion, external grounded research, or when a task requires processing large multi-file audits (>500 lines) that would bloat context, delegate the subtask to `agy`.
## Execution Syntax
Run `agy` in headless, non-interactive mode using the bash tool:
```bash
agy --dangerously-skip-permissions -p "<detailed_task_prompt>"
```
@@ -4,6 +4,7 @@ description: Delegates a subtask to the Antigravity CLI (agy) when the user want
version: 1.0.0
user-invocable: true
author: Rootiest
targets: [claude-code]
---
# Antigravity Subagent Delegation (`delegate-agy`)
+6
View File
@@ -0,0 +1,6 @@
{
"name": "gitea-mcp",
"description": "Connects Claude Code/agy to a Gitea instance via the official gitea-mcp server, authenticated with GITEA_HOST/GITEA_ACCESS_TOKEN you set locally.",
"version": "1.0.0",
"author": "Rootiest"
}
@@ -13,17 +13,6 @@
"GITEA_HOST": "${GITEA_HOST}",
"GITEA_ACCESS_TOKEN": "${GITEA_ACCESS_TOKEN}"
}
},
"github": {
"type": "stdio",
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-github"
],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}"
}
}
}
}
@@ -0,0 +1,6 @@
{
"name": "github-mcp",
"description": "Connects Claude Code/agy to GitHub via the official MCP server, authenticated with a GITHUB_PERSONAL_ACCESS_TOKEN you set locally.",
"version": "1.0.0",
"author": "Rootiest"
}
+15
View File
@@ -0,0 +1,15 @@
{
"mcpServers": {
"github": {
"type": "stdio",
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-github"
],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}"
}
}
}
}
@@ -4,6 +4,7 @@ description: Delegates a subtask to the Antigravity CLI (agy) when the user want
version: 1.0.0
user-invocable: true
author: Rootiest
targets: [claude-code]
---
# Antigravity Subagent Delegation (`delegate-agy`)
@@ -4,6 +4,7 @@ description: Delegates a subtask to the Antigravity CLI (agy) when the user want
version: 1.0.0
user-invocable: true
author: Rootiest
targets: [claude-code]
---
# Antigravity Subagent Delegation (`delegate-agy`)
@@ -8,14 +8,6 @@
"GITEA_HOST": "${GITEA_HOST}",
"GITEA_ACCESS_TOKEN": "${GITEA_ACCESS_TOKEN}"
}
},
"github": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}"
}
}
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"name": "gitea-mcp",
"description": "Connects Claude Code/agy to a Gitea instance via the official gitea-mcp server, authenticated with GITEA_HOST/GITEA_ACCESS_TOKEN you set locally.",
"version": "1.0.0",
"author": "Rootiest"
}
+12
View File
@@ -0,0 +1,12 @@
{
"mcpServers": {
"github": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}"
}
}
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"name": "github-mcp",
"description": "Connects Claude Code/agy to GitHub via the official MCP server, authenticated with a GITHUB_PERSONAL_ACCESS_TOKEN you set locally.",
"version": "1.0.0",
"author": "Rootiest"
}
+96 -34
View File
@@ -3,14 +3,25 @@
A "plugin" is a directory under `plugins/<name>/` that may bundle any mix of:
- plugin.json (required marker + metadata: name, description, version, author)
- skills/<name>/SKILL.md (0+ skills)
- plugin.json (required marker + metadata: name, description, version, author,
optional targets: ["claude-code", "agy", ...] restricts the
WHOLE plugin, including its hooks/mcp/rules/commands/agents, to
only the listed targets; default is every target in manifest.yaml)
- skills/<name>/SKILL.md (0+ skills; a skill's own frontmatter may also declare
`targets:` to further restrict just that one skill, independent
of its sibling skills narrowed to, never wider than, the
plugin's own `targets`)
- hooks.json (canonical, Claude-shaped: {"<EventName>": [<matcher-group>, ...]})
- mcp.json ({"mcpServers": {...}}, shared shape across targets)
- rules/AGENTS.md (agy-only; ignored by the Claude Code target)
- commands/*.md (Claude Code-only slash commands)
- agents/*.md (Claude Code-only subagents)
A plugin that ends up with no content at all for a given target (every
skill excluded, and no hooks/mcp/rules/commands/agents of its own) is
skipped entirely for that target it never gets an empty output directory
or marketplace entry.
One or more source roots (each containing its own `plugins/` directory) are
layered together a later source overlays/overrides an earlier one on a
per-plugin, per-file basis. This is how a private repo (PII/tokens/local-only
@@ -120,7 +131,19 @@ def merge_layers(layers: "dict[str, list[Path]]", workdir: Path) -> Path:
return merged_root
def discover_plugins(merged_root: Path) -> list[dict]:
def validate_targets(value: "list | None", valid_targets: list[str], context: str) -> "list[str] | None":
"""Validate an optional 'targets' restriction list. None means 'all targets'."""
if value is None:
return None
if not isinstance(value, list) or not value:
raise ValidationError(f"{context}: 'targets' must be a non-empty list")
for t in value:
if t not in valid_targets:
raise ValidationError(f"{context}: unknown target '{t}' (valid: {', '.join(valid_targets)})")
return value
def discover_plugins(merged_root: Path, valid_targets: list[str]) -> list[dict]:
plugins = []
for plugin_dir in sorted(merged_root.iterdir()):
if not plugin_dir.is_dir():
@@ -136,6 +159,7 @@ def discover_plugins(merged_root: Path) -> list[dict]:
for field in REQUIRED_PLUGIN_FIELDS:
if not meta.get(field):
raise ValidationError(f"{manifest_path}: missing required field '{field}'")
plugin_targets = validate_targets(meta.get("targets"), valid_targets, str(manifest_path))
skills = []
skills_dir = plugin_dir / "skills"
@@ -151,7 +175,8 @@ def discover_plugins(merged_root: Path) -> list[dict]:
raise ValidationError(
f"{skill_md}: frontmatter name '{fm['name']}' does not match directory name '{skill_dir.name}'"
)
skills.append({"dir": skill_dir, "frontmatter": fm})
skill_targets = validate_targets(fm.get("targets"), valid_targets, str(skill_md))
skills.append({"dir": skill_dir, "frontmatter": fm, "targets": skill_targets})
hooks_path = plugin_dir / "hooks.json"
hooks = load_json(hooks_path) if hooks_path.exists() else None
@@ -163,6 +188,7 @@ def discover_plugins(merged_root: Path) -> list[dict]:
{
"dir": plugin_dir,
"meta": meta,
"targets": plugin_targets,
"skills": skills,
"hooks": hooks,
"mcp": mcp,
@@ -181,6 +207,22 @@ def discover_plugins(merged_root: Path) -> list[dict]:
return plugins
def plugin_supports(plugin: dict, target: str) -> bool:
allowed = plugin["targets"]
return allowed is None or target in allowed
def skill_supports(skill: dict, plugin: dict, target: str) -> bool:
if not plugin_supports(plugin, target):
return False
allowed = skill["targets"]
return allowed is None or target in allowed
def active_skills(plugin: dict, target: str) -> list[dict]:
return [s for s in plugin["skills"] if skill_supports(s, plugin, target)]
# ── Private Repo Cloning ─────────────────────────────────────────────────────
_ASKPASS_SCRIPT = """#!/bin/sh
@@ -256,7 +298,15 @@ def translate_mcp_for_agy(mcp: dict) -> dict:
# ── Claude Code Target ───────────────────────────────────────────────────────
def write_claude_plugin(plugin: dict, dest: Path) -> None:
def write_claude_plugin(plugin: dict, dest: Path) -> bool:
"""Returns False (writing nothing) if the plugin has no claude-code content."""
skills = active_skills(plugin, "claude-code")
has_content = bool(
skills or plugin["hooks"] or plugin["mcp"] or plugin["commands_dir"] or plugin["agents_dir"]
)
if not has_content:
return False
meta = plugin["meta"]
dest.mkdir(parents=True, exist_ok=True)
@@ -268,9 +318,9 @@ def write_claude_plugin(plugin: dict, dest: Path) -> None:
manifest[field] = meta[field]
(claude_plugin_dir / "plugin.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
if plugin["skills"]:
if skills:
skills_out = dest / "skills"
for s in plugin["skills"]:
for s in skills:
shutil.copytree(s["dir"], skills_out / s["frontmatter"]["name"])
for optional_dir in ("commands_dir", "agents_dir"):
@@ -288,6 +338,8 @@ def write_claude_plugin(plugin: dict, dest: Path) -> None:
if plugin["mcp"] is not None:
(dest / ".mcp.json").write_text(json.dumps(plugin["mcp"], indent=2) + "\n", encoding="utf-8")
return True
def gen_claude_code(manifest: dict, plugins: list[dict], out_dir: Path) -> None:
# Generated output lives under dist/claude-code/ — never inside plugins/,
@@ -300,25 +352,26 @@ def gen_claude_code(manifest: dict, plugins: list[dict], out_dir: Path) -> None:
marketplace_entries = []
for p in plugins:
name = p["meta"]["name"]
write_claude_plugin(p, plugins_out / name)
marketplace_entries.append({"name": name, "source": f"./dist/claude-code/{name}"})
if write_claude_plugin(p, plugins_out / name):
marketplace_entries.append({"name": name, "source": f"./dist/claude-code/{name}"})
bundle_id = manifest["bundle"]["id"]
bundle_dest = plugins_out / bundle_id
(bundle_dest / ".claude-plugin").mkdir(parents=True)
(bundle_dest / ".claude-plugin" / "plugin.json").write_text(
json.dumps(
{"name": bundle_id, "description": f"{manifest['marketplace']['description']} (all skills)"},
indent=2,
bundle_skills = [(p, s) for p in plugins for s in active_skills(p, "claude-code")]
if bundle_skills:
bundle_dest = plugins_out / bundle_id
(bundle_dest / ".claude-plugin").mkdir(parents=True)
(bundle_dest / ".claude-plugin" / "plugin.json").write_text(
json.dumps(
{"name": bundle_id, "description": f"{manifest['marketplace']['description']} (all skills)"},
indent=2,
)
+ "\n",
encoding="utf-8",
)
+ "\n",
encoding="utf-8",
)
bundle_skills = bundle_dest / "skills"
for p in plugins:
for s in p["skills"]:
shutil.copytree(s["dir"], bundle_skills / s["frontmatter"]["name"])
marketplace_entries.append({"name": bundle_id, "source": f"./dist/claude-code/{bundle_id}"})
bundle_skills_dir = bundle_dest / "skills"
for _, s in bundle_skills:
shutil.copytree(s["dir"], bundle_skills_dir / s["frontmatter"]["name"])
marketplace_entries.append({"name": bundle_id, "source": f"./dist/claude-code/{bundle_id}"})
marketplace = {
"name": manifest["marketplace"]["name"],
@@ -334,15 +387,21 @@ def gen_claude_code(manifest: dict, plugins: list[dict], out_dir: Path) -> None:
# ── Antigravity CLI (agy) Target ─────────────────────────────────────────────
def write_agy_plugin(plugin: dict, dest: Path) -> None:
def write_agy_plugin(plugin: dict, dest: Path) -> bool:
"""Returns False (writing nothing) if the plugin has no agy content."""
skills = active_skills(plugin, "agy")
has_content = bool(skills or plugin["rules_dir"] or plugin["hooks"] or plugin["mcp"])
if not has_content:
return False
meta = plugin["meta"]
name = meta["name"]
dest.mkdir(parents=True, exist_ok=True)
(dest / "plugin.json").write_text(json.dumps({"name": name}, indent=2) + "\n", encoding="utf-8")
if plugin["skills"]:
if skills:
skills_out = dest / "skills"
for s in plugin["skills"]:
for s in skills:
shutil.copytree(s["dir"], skills_out / s["frontmatter"]["name"])
if plugin["rules_dir"] is not None:
@@ -358,6 +417,8 @@ def write_agy_plugin(plugin: dict, dest: Path) -> None:
json.dumps(translate_mcp_for_agy(plugin["mcp"]), indent=2) + "\n", encoding="utf-8"
)
return True
def gen_agy(manifest: dict, plugins: list[dict], out_dir: Path) -> None:
agy_dir = out_dir / "dist" / "agy"
@@ -369,13 +430,14 @@ def gen_agy(manifest: dict, plugins: list[dict], out_dir: Path) -> None:
write_agy_plugin(p, agy_dir / p["meta"]["name"])
bundle_id = manifest["bundle"]["id"]
bundle_dest = agy_dir / bundle_id
bundle_dest.mkdir(parents=True)
(bundle_dest / "plugin.json").write_text(json.dumps({"name": bundle_id}, indent=2) + "\n", encoding="utf-8")
bundle_skills = bundle_dest / "skills"
for p in plugins:
for s in p["skills"]:
shutil.copytree(s["dir"], bundle_skills / s["frontmatter"]["name"])
bundle_skills = [(p, s) for p in plugins for s in active_skills(p, "agy")]
if bundle_skills:
bundle_dest = agy_dir / bundle_id
bundle_dest.mkdir(parents=True)
(bundle_dest / "plugin.json").write_text(json.dumps({"name": bundle_id}, indent=2) + "\n", encoding="utf-8")
bundle_skills_dir = bundle_dest / "skills"
for _, s in bundle_skills:
shutil.copytree(s["dir"], bundle_skills_dir / s["frontmatter"]["name"])
dist_readme = out_dir / "dist" / "README.md"
dist_readme.write_text(
@@ -474,7 +536,7 @@ def main() -> int:
layers = collect_layers(source_roots)
merged_root = merge_layers(layers, workdir)
plugins = discover_plugins(merged_root)
plugins = discover_plugins(merged_root, manifest["targets"])
if args.check:
print(f"OK: {len(plugins)} plugin(s), {len(manifest['targets'])} target(s) validated")