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")