feat: split MCP servers into their own plugins, add per-target restrictions #3
@@ -22,6 +22,14 @@
|
|||||||
"name": "git-publish-workflow",
|
"name": "git-publish-workflow",
|
||||||
"source": "./dist/claude-code/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",
|
"name": "readme-sync-audit",
|
||||||
"source": "./dist/claude-code/readme-sync-audit"
|
"source": "./dist/claude-code/readme-sync-audit"
|
||||||
|
|||||||
@@ -19,11 +19,14 @@ from one source tree and published as a native marketplace for both tools.
|
|||||||
- [technical-devlog-scribe](#technical-devlog-scribe)
|
- [technical-devlog-scribe](#technical-devlog-scribe)
|
||||||
- [ship-it](#ship-it)
|
- [ship-it](#ship-it)
|
||||||
- [core-essentials](#core-essentials)
|
- [core-essentials](#core-essentials)
|
||||||
|
- [github-mcp](#github-mcp)
|
||||||
|
- [gitea-mcp](#gitea-mcp)
|
||||||
- [Installation](#installation)
|
- [Installation](#installation)
|
||||||
- [Claude Code](#claude-code)
|
- [Claude Code](#claude-code)
|
||||||
- [Antigravity CLI (agy)](#antigravity-cli-agy)
|
- [Antigravity CLI (agy)](#antigravity-cli-agy)
|
||||||
- [Repository Structure](#repository-structure)
|
- [Repository Structure](#repository-structure)
|
||||||
- [Anatomy of a Plugin](#anatomy-of-a-plugin)
|
- [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)
|
- [How Generation Works](#how-generation-works)
|
||||||
- [Private Overlay Builds](#private-overlay-builds)
|
- [Private Overlay Builds](#private-overlay-builds)
|
||||||
- [License](#license)
|
- [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
|
Currently bundles `delegate-agy`, which hands a subtask off to the
|
||||||
Antigravity CLI (`agy`) in headless mode — useful for a second opinion,
|
Antigravity CLI (`agy`) in headless mode — useful for a second opinion,
|
||||||
external grounded research, or a large multi-file audit (>500 lines) that
|
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
|
target; `commands/`/`agents/` have no agy equivalent and are skipped for
|
||||||
that target.
|
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
|
### How Generation Works
|
||||||
|
|
||||||
`scripts/generate_plugins.py` reads `manifest.yaml` (marketplace metadata,
|
`scripts/generate_plugins.py` reads `manifest.yaml` (marketplace metadata,
|
||||||
|
|||||||
Vendored
-3
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "core-essentials"
|
|
||||||
}
|
|
||||||
@@ -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>"
|
|
||||||
```
|
|
||||||
-10
@@ -12,16 +12,6 @@
|
|||||||
"GITEA_HOST": "${GITEA_HOST}",
|
"GITEA_HOST": "${GITEA_HOST}",
|
||||||
"GITEA_ACCESS_TOKEN": "${GITEA_ACCESS_TOKEN}"
|
"GITEA_ACCESS_TOKEN": "${GITEA_ACCESS_TOKEN}"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"github": {
|
|
||||||
"command": "npx",
|
|
||||||
"args": [
|
|
||||||
"-y",
|
|
||||||
"@modelcontextprotocol/server-github"
|
|
||||||
],
|
|
||||||
"env": {
|
|
||||||
"GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"name": "gitea-mcp"
|
||||||
|
}
|
||||||
Vendored
+14
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"github": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@modelcontextprotocol/server-github"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"name": "github-mcp"
|
||||||
|
}
|
||||||
@@ -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
|
version: 1.0.0
|
||||||
user-invocable: true
|
user-invocable: true
|
||||||
author: Rootiest
|
author: Rootiest
|
||||||
|
targets: [claude-code]
|
||||||
---
|
---
|
||||||
|
|
||||||
# Antigravity Subagent Delegation (`delegate-agy`)
|
# Antigravity Subagent Delegation (`delegate-agy`)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
-11
@@ -13,17 +13,6 @@
|
|||||||
"GITEA_HOST": "${GITEA_HOST}",
|
"GITEA_HOST": "${GITEA_HOST}",
|
||||||
"GITEA_ACCESS_TOKEN": "${GITEA_ACCESS_TOKEN}"
|
"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
@@ -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
|
version: 1.0.0
|
||||||
user-invocable: true
|
user-invocable: true
|
||||||
author: Rootiest
|
author: Rootiest
|
||||||
|
targets: [claude-code]
|
||||||
---
|
---
|
||||||
|
|
||||||
# Antigravity Subagent Delegation (`delegate-agy`)
|
# 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
|
version: 1.0.0
|
||||||
user-invocable: true
|
user-invocable: true
|
||||||
author: Rootiest
|
author: Rootiest
|
||||||
|
targets: [claude-code]
|
||||||
---
|
---
|
||||||
|
|
||||||
# Antigravity Subagent Delegation (`delegate-agy`)
|
# Antigravity Subagent Delegation (`delegate-agy`)
|
||||||
|
|||||||
@@ -8,14 +8,6 @@
|
|||||||
"GITEA_HOST": "${GITEA_HOST}",
|
"GITEA_HOST": "${GITEA_HOST}",
|
||||||
"GITEA_ACCESS_TOKEN": "${GITEA_ACCESS_TOKEN}"
|
"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": "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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"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"
|
||||||
|
}
|
||||||
+82
-20
@@ -3,14 +3,25 @@
|
|||||||
|
|
||||||
A "plugin" is a directory under `plugins/<name>/` that may bundle any mix of:
|
A "plugin" is a directory under `plugins/<name>/` that may bundle any mix of:
|
||||||
|
|
||||||
- plugin.json (required marker + metadata: name, description, version, author)
|
- plugin.json (required marker + metadata: name, description, version, author,
|
||||||
- skills/<name>/SKILL.md (0+ skills)
|
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>, ...]})
|
- hooks.json (canonical, Claude-shaped: {"<EventName>": [<matcher-group>, ...]})
|
||||||
- mcp.json ({"mcpServers": {...}}, shared shape across targets)
|
- mcp.json ({"mcpServers": {...}}, shared shape across targets)
|
||||||
- rules/AGENTS.md (agy-only; ignored by the Claude Code target)
|
- rules/AGENTS.md (agy-only; ignored by the Claude Code target)
|
||||||
- commands/*.md (Claude Code-only slash commands)
|
- commands/*.md (Claude Code-only slash commands)
|
||||||
- agents/*.md (Claude Code-only subagents)
|
- 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
|
One or more source roots (each containing its own `plugins/` directory) are
|
||||||
layered together — a later source overlays/overrides an earlier one on a
|
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
|
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
|
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 = []
|
plugins = []
|
||||||
for plugin_dir in sorted(merged_root.iterdir()):
|
for plugin_dir in sorted(merged_root.iterdir()):
|
||||||
if not plugin_dir.is_dir():
|
if not plugin_dir.is_dir():
|
||||||
@@ -136,6 +159,7 @@ def discover_plugins(merged_root: Path) -> list[dict]:
|
|||||||
for field in REQUIRED_PLUGIN_FIELDS:
|
for field in REQUIRED_PLUGIN_FIELDS:
|
||||||
if not meta.get(field):
|
if not meta.get(field):
|
||||||
raise ValidationError(f"{manifest_path}: missing required field '{field}'")
|
raise ValidationError(f"{manifest_path}: missing required field '{field}'")
|
||||||
|
plugin_targets = validate_targets(meta.get("targets"), valid_targets, str(manifest_path))
|
||||||
|
|
||||||
skills = []
|
skills = []
|
||||||
skills_dir = plugin_dir / "skills"
|
skills_dir = plugin_dir / "skills"
|
||||||
@@ -151,7 +175,8 @@ def discover_plugins(merged_root: Path) -> list[dict]:
|
|||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
f"{skill_md}: frontmatter name '{fm['name']}' does not match directory name '{skill_dir.name}'"
|
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_path = plugin_dir / "hooks.json"
|
||||||
hooks = load_json(hooks_path) if hooks_path.exists() else None
|
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,
|
"dir": plugin_dir,
|
||||||
"meta": meta,
|
"meta": meta,
|
||||||
|
"targets": plugin_targets,
|
||||||
"skills": skills,
|
"skills": skills,
|
||||||
"hooks": hooks,
|
"hooks": hooks,
|
||||||
"mcp": mcp,
|
"mcp": mcp,
|
||||||
@@ -181,6 +207,22 @@ def discover_plugins(merged_root: Path) -> list[dict]:
|
|||||||
return plugins
|
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 ─────────────────────────────────────────────────────
|
# ── Private Repo Cloning ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
_ASKPASS_SCRIPT = """#!/bin/sh
|
_ASKPASS_SCRIPT = """#!/bin/sh
|
||||||
@@ -256,7 +298,15 @@ def translate_mcp_for_agy(mcp: dict) -> dict:
|
|||||||
# ── Claude Code Target ───────────────────────────────────────────────────────
|
# ── 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"]
|
meta = plugin["meta"]
|
||||||
dest.mkdir(parents=True, exist_ok=True)
|
dest.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
@@ -268,9 +318,9 @@ def write_claude_plugin(plugin: dict, dest: Path) -> None:
|
|||||||
manifest[field] = meta[field]
|
manifest[field] = meta[field]
|
||||||
(claude_plugin_dir / "plugin.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
(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"
|
skills_out = dest / "skills"
|
||||||
for s in plugin["skills"]:
|
for s in skills:
|
||||||
shutil.copytree(s["dir"], skills_out / s["frontmatter"]["name"])
|
shutil.copytree(s["dir"], skills_out / s["frontmatter"]["name"])
|
||||||
|
|
||||||
for optional_dir in ("commands_dir", "agents_dir"):
|
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:
|
if plugin["mcp"] is not None:
|
||||||
(dest / ".mcp.json").write_text(json.dumps(plugin["mcp"], indent=2) + "\n", encoding="utf-8")
|
(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:
|
def gen_claude_code(manifest: dict, plugins: list[dict], out_dir: Path) -> None:
|
||||||
# Generated output lives under dist/claude-code/ — never inside plugins/,
|
# Generated output lives under dist/claude-code/ — never inside plugins/,
|
||||||
@@ -300,10 +352,12 @@ def gen_claude_code(manifest: dict, plugins: list[dict], out_dir: Path) -> None:
|
|||||||
marketplace_entries = []
|
marketplace_entries = []
|
||||||
for p in plugins:
|
for p in plugins:
|
||||||
name = p["meta"]["name"]
|
name = p["meta"]["name"]
|
||||||
write_claude_plugin(p, plugins_out / name)
|
if write_claude_plugin(p, plugins_out / name):
|
||||||
marketplace_entries.append({"name": name, "source": f"./dist/claude-code/{name}"})
|
marketplace_entries.append({"name": name, "source": f"./dist/claude-code/{name}"})
|
||||||
|
|
||||||
bundle_id = manifest["bundle"]["id"]
|
bundle_id = manifest["bundle"]["id"]
|
||||||
|
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 = plugins_out / bundle_id
|
||||||
(bundle_dest / ".claude-plugin").mkdir(parents=True)
|
(bundle_dest / ".claude-plugin").mkdir(parents=True)
|
||||||
(bundle_dest / ".claude-plugin" / "plugin.json").write_text(
|
(bundle_dest / ".claude-plugin" / "plugin.json").write_text(
|
||||||
@@ -314,10 +368,9 @@ def gen_claude_code(manifest: dict, plugins: list[dict], out_dir: Path) -> None:
|
|||||||
+ "\n",
|
+ "\n",
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
bundle_skills = bundle_dest / "skills"
|
bundle_skills_dir = bundle_dest / "skills"
|
||||||
for p in plugins:
|
for _, s in bundle_skills:
|
||||||
for s in p["skills"]:
|
shutil.copytree(s["dir"], bundle_skills_dir / s["frontmatter"]["name"])
|
||||||
shutil.copytree(s["dir"], bundle_skills / s["frontmatter"]["name"])
|
|
||||||
marketplace_entries.append({"name": bundle_id, "source": f"./dist/claude-code/{bundle_id}"})
|
marketplace_entries.append({"name": bundle_id, "source": f"./dist/claude-code/{bundle_id}"})
|
||||||
|
|
||||||
marketplace = {
|
marketplace = {
|
||||||
@@ -334,15 +387,21 @@ def gen_claude_code(manifest: dict, plugins: list[dict], out_dir: Path) -> None:
|
|||||||
# ── Antigravity CLI (agy) Target ─────────────────────────────────────────────
|
# ── 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"]
|
meta = plugin["meta"]
|
||||||
name = meta["name"]
|
name = meta["name"]
|
||||||
dest.mkdir(parents=True, exist_ok=True)
|
dest.mkdir(parents=True, exist_ok=True)
|
||||||
(dest / "plugin.json").write_text(json.dumps({"name": name}, indent=2) + "\n", encoding="utf-8")
|
(dest / "plugin.json").write_text(json.dumps({"name": name}, indent=2) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
if plugin["skills"]:
|
if skills:
|
||||||
skills_out = dest / "skills"
|
skills_out = dest / "skills"
|
||||||
for s in plugin["skills"]:
|
for s in skills:
|
||||||
shutil.copytree(s["dir"], skills_out / s["frontmatter"]["name"])
|
shutil.copytree(s["dir"], skills_out / s["frontmatter"]["name"])
|
||||||
|
|
||||||
if plugin["rules_dir"] is not None:
|
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"
|
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:
|
def gen_agy(manifest: dict, plugins: list[dict], out_dir: Path) -> None:
|
||||||
agy_dir = out_dir / "dist" / "agy"
|
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"])
|
write_agy_plugin(p, agy_dir / p["meta"]["name"])
|
||||||
|
|
||||||
bundle_id = manifest["bundle"]["id"]
|
bundle_id = manifest["bundle"]["id"]
|
||||||
|
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 = agy_dir / bundle_id
|
||||||
bundle_dest.mkdir(parents=True)
|
bundle_dest.mkdir(parents=True)
|
||||||
(bundle_dest / "plugin.json").write_text(json.dumps({"name": bundle_id}, indent=2) + "\n", encoding="utf-8")
|
(bundle_dest / "plugin.json").write_text(json.dumps({"name": bundle_id}, indent=2) + "\n", encoding="utf-8")
|
||||||
bundle_skills = bundle_dest / "skills"
|
bundle_skills_dir = bundle_dest / "skills"
|
||||||
for p in plugins:
|
for _, s in bundle_skills:
|
||||||
for s in p["skills"]:
|
shutil.copytree(s["dir"], bundle_skills_dir / s["frontmatter"]["name"])
|
||||||
shutil.copytree(s["dir"], bundle_skills / s["frontmatter"]["name"])
|
|
||||||
|
|
||||||
dist_readme = out_dir / "dist" / "README.md"
|
dist_readme = out_dir / "dist" / "README.md"
|
||||||
dist_readme.write_text(
|
dist_readme.write_text(
|
||||||
@@ -474,7 +536,7 @@ def main() -> int:
|
|||||||
|
|
||||||
layers = collect_layers(source_roots)
|
layers = collect_layers(source_roots)
|
||||||
merged_root = merge_layers(layers, workdir)
|
merged_root = merge_layers(layers, workdir)
|
||||||
plugins = discover_plugins(merged_root)
|
plugins = discover_plugins(merged_root, manifest["targets"])
|
||||||
|
|
||||||
if args.check:
|
if args.check:
|
||||||
print(f"OK: {len(plugins)} plugin(s), {len(manifest['targets'])} target(s) validated")
|
print(f"OK: {len(plugins)} plugin(s), {len(manifest['targets'])} target(s) validated")
|
||||||
|
|||||||
Reference in New Issue
Block a user