Merge pull request 'feat: split MCP servers into their own plugins, add per-target restrictions' (#3) from feat-split-mcp-plugins-and-targets into main
Generate plugin manifests / validate (push) Skipped
Generate plugin manifests / generate (push) Successful in 16s

Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
2026-08-25 00:32:29 +00:00
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")