From c6f40bcf6b771090fff310352feebd25c1523719 Mon Sep 17 00:00:00 2001 From: Rootiest Date: Mon, 24 Aug 2026 20:28:36 -0400 Subject: [PATCH] feat: split MCP servers into their own plugins, add per-target restrictions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_014YVWWAnvR5TDQzmfY3RXG9 --- .claude-plugin/marketplace.json | 8 ++ README.md | 46 ++++++- dist/agy/core-essentials/plugin.json | 3 - .../skills/delegate-agy/SKILL.md | 18 --- .../mcp_config.json | 10 -- dist/agy/gitea-mcp/plugin.json | 3 + dist/agy/github-mcp/mcp_config.json | 14 ++ dist/agy/github-mcp/plugin.json | 3 + .../skills/delegate-agy/SKILL.md | 18 --- .../skills/delegate-agy/SKILL.md | 1 + .../gitea-mcp/.claude-plugin/plugin.json | 6 + .../{core-essentials => gitea-mcp}/.mcp.json | 11 -- .../github-mcp/.claude-plugin/plugin.json | 6 + dist/claude-code/github-mcp/.mcp.json | 15 ++ .../skills/delegate-agy/SKILL.md | 1 + .../skills/delegate-agy/SKILL.md | 1 + .../{core-essentials => gitea-mcp}/mcp.json | 8 -- plugins/gitea-mcp/plugin.json | 6 + plugins/github-mcp/mcp.json | 12 ++ plugins/github-mcp/plugin.json | 6 + scripts/generate_plugins.py | 130 +++++++++++++----- 21 files changed, 223 insertions(+), 103 deletions(-) delete mode 100644 dist/agy/core-essentials/plugin.json delete mode 100644 dist/agy/core-essentials/skills/delegate-agy/SKILL.md rename dist/agy/{core-essentials => gitea-mcp}/mcp_config.json (56%) create mode 100644 dist/agy/gitea-mcp/plugin.json create mode 100644 dist/agy/github-mcp/mcp_config.json create mode 100644 dist/agy/github-mcp/plugin.json delete mode 100644 dist/agy/rootiest-ai-all/skills/delegate-agy/SKILL.md create mode 100644 dist/claude-code/gitea-mcp/.claude-plugin/plugin.json rename dist/claude-code/{core-essentials => gitea-mcp}/.mcp.json (56%) create mode 100644 dist/claude-code/github-mcp/.claude-plugin/plugin.json create mode 100644 dist/claude-code/github-mcp/.mcp.json rename plugins/{core-essentials => gitea-mcp}/mcp.json (55%) create mode 100644 plugins/gitea-mcp/plugin.json create mode 100644 plugins/github-mcp/mcp.json create mode 100644 plugins/github-mcp/plugin.json diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 14a3fac..3f38507 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -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" diff --git a/README.md b/README.md index c9f84ac..c8ae7ad 100644 --- a/README.md +++ b/README.md @@ -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, diff --git a/dist/agy/core-essentials/plugin.json b/dist/agy/core-essentials/plugin.json deleted file mode 100644 index 0a70d7d..0000000 --- a/dist/agy/core-essentials/plugin.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "name": "core-essentials" -} diff --git a/dist/agy/core-essentials/skills/delegate-agy/SKILL.md b/dist/agy/core-essentials/skills/delegate-agy/SKILL.md deleted file mode 100644 index c2d9290..0000000 --- a/dist/agy/core-essentials/skills/delegate-agy/SKILL.md +++ /dev/null @@ -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 "" -``` diff --git a/dist/agy/core-essentials/mcp_config.json b/dist/agy/gitea-mcp/mcp_config.json similarity index 56% rename from dist/agy/core-essentials/mcp_config.json rename to dist/agy/gitea-mcp/mcp_config.json index b7398fa..b7977e9 100644 --- a/dist/agy/core-essentials/mcp_config.json +++ b/dist/agy/gitea-mcp/mcp_config.json @@ -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}" - } } } } diff --git a/dist/agy/gitea-mcp/plugin.json b/dist/agy/gitea-mcp/plugin.json new file mode 100644 index 0000000..de0c993 --- /dev/null +++ b/dist/agy/gitea-mcp/plugin.json @@ -0,0 +1,3 @@ +{ + "name": "gitea-mcp" +} diff --git a/dist/agy/github-mcp/mcp_config.json b/dist/agy/github-mcp/mcp_config.json new file mode 100644 index 0000000..cf01f26 --- /dev/null +++ b/dist/agy/github-mcp/mcp_config.json @@ -0,0 +1,14 @@ +{ + "mcpServers": { + "github": { + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-github" + ], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}" + } + } + } +} diff --git a/dist/agy/github-mcp/plugin.json b/dist/agy/github-mcp/plugin.json new file mode 100644 index 0000000..1f868cf --- /dev/null +++ b/dist/agy/github-mcp/plugin.json @@ -0,0 +1,3 @@ +{ + "name": "github-mcp" +} diff --git a/dist/agy/rootiest-ai-all/skills/delegate-agy/SKILL.md b/dist/agy/rootiest-ai-all/skills/delegate-agy/SKILL.md deleted file mode 100644 index c2d9290..0000000 --- a/dist/agy/rootiest-ai-all/skills/delegate-agy/SKILL.md +++ /dev/null @@ -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 "" -``` diff --git a/dist/claude-code/core-essentials/skills/delegate-agy/SKILL.md b/dist/claude-code/core-essentials/skills/delegate-agy/SKILL.md index c2d9290..d2ef95c 100644 --- a/dist/claude-code/core-essentials/skills/delegate-agy/SKILL.md +++ b/dist/claude-code/core-essentials/skills/delegate-agy/SKILL.md @@ -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`) diff --git a/dist/claude-code/gitea-mcp/.claude-plugin/plugin.json b/dist/claude-code/gitea-mcp/.claude-plugin/plugin.json new file mode 100644 index 0000000..c834f46 --- /dev/null +++ b/dist/claude-code/gitea-mcp/.claude-plugin/plugin.json @@ -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" +} diff --git a/dist/claude-code/core-essentials/.mcp.json b/dist/claude-code/gitea-mcp/.mcp.json similarity index 56% rename from dist/claude-code/core-essentials/.mcp.json rename to dist/claude-code/gitea-mcp/.mcp.json index bc8db1c..1b420fe 100644 --- a/dist/claude-code/core-essentials/.mcp.json +++ b/dist/claude-code/gitea-mcp/.mcp.json @@ -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}" - } } } } diff --git a/dist/claude-code/github-mcp/.claude-plugin/plugin.json b/dist/claude-code/github-mcp/.claude-plugin/plugin.json new file mode 100644 index 0000000..e8fb56d --- /dev/null +++ b/dist/claude-code/github-mcp/.claude-plugin/plugin.json @@ -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" +} diff --git a/dist/claude-code/github-mcp/.mcp.json b/dist/claude-code/github-mcp/.mcp.json new file mode 100644 index 0000000..3176e8e --- /dev/null +++ b/dist/claude-code/github-mcp/.mcp.json @@ -0,0 +1,15 @@ +{ + "mcpServers": { + "github": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-github" + ], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}" + } + } + } +} diff --git a/dist/claude-code/rootiest-ai-all/skills/delegate-agy/SKILL.md b/dist/claude-code/rootiest-ai-all/skills/delegate-agy/SKILL.md index c2d9290..d2ef95c 100644 --- a/dist/claude-code/rootiest-ai-all/skills/delegate-agy/SKILL.md +++ b/dist/claude-code/rootiest-ai-all/skills/delegate-agy/SKILL.md @@ -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`) diff --git a/plugins/core-essentials/skills/delegate-agy/SKILL.md b/plugins/core-essentials/skills/delegate-agy/SKILL.md index c2d9290..d2ef95c 100644 --- a/plugins/core-essentials/skills/delegate-agy/SKILL.md +++ b/plugins/core-essentials/skills/delegate-agy/SKILL.md @@ -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`) diff --git a/plugins/core-essentials/mcp.json b/plugins/gitea-mcp/mcp.json similarity index 55% rename from plugins/core-essentials/mcp.json rename to plugins/gitea-mcp/mcp.json index 099d04a..597e01d 100644 --- a/plugins/core-essentials/mcp.json +++ b/plugins/gitea-mcp/mcp.json @@ -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}" - } } } } diff --git a/plugins/gitea-mcp/plugin.json b/plugins/gitea-mcp/plugin.json new file mode 100644 index 0000000..c834f46 --- /dev/null +++ b/plugins/gitea-mcp/plugin.json @@ -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" +} diff --git a/plugins/github-mcp/mcp.json b/plugins/github-mcp/mcp.json new file mode 100644 index 0000000..4300ea2 --- /dev/null +++ b/plugins/github-mcp/mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "github": { + "type": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}" + } + } + } +} diff --git a/plugins/github-mcp/plugin.json b/plugins/github-mcp/plugin.json new file mode 100644 index 0000000..e8fb56d --- /dev/null +++ b/plugins/github-mcp/plugin.json @@ -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" +} diff --git a/scripts/generate_plugins.py b/scripts/generate_plugins.py index 9625d21..2cc98fa 100755 --- a/scripts/generate_plugins.py +++ b/scripts/generate_plugins.py @@ -3,14 +3,25 @@ A "plugin" is a directory under `plugins//` that may bundle any mix of: - - plugin.json (required marker + metadata: name, description, version, author) - - skills//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//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: {"": [, ...]}) - 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")