feat(marketplace): implement plugin marketplace
Implements a new plugin marketplace structure and expands repository support beyond basic skills to include plugins, MCPs, hooks, etc.
This commit is contained in:
Executable
+190
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate per-agent plugin manifests from the skills/ SSoT.
|
||||
|
||||
Reads manifest.yaml (marketplace/org metadata + target list) and every
|
||||
skills/<name>/SKILL.md (per-skill frontmatter), then regenerates:
|
||||
|
||||
- descriptions.json (from SKILL.md frontmatter)
|
||||
- .claude-plugin/marketplace.json (Claude Code target)
|
||||
- dist/agy/** (Antigravity CLI target)
|
||||
|
||||
Run with --check to only validate the SSoT (frontmatter, manifest.yaml) and
|
||||
skip writing any output — used as the pull-request gate.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SKILLS_DIR = ROOT / "skills"
|
||||
MANIFEST_PATH = ROOT / "manifest.yaml"
|
||||
|
||||
REQUIRED_FRONTMATTER_FIELDS = ("name", "description", "version", "author")
|
||||
|
||||
|
||||
class ValidationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def load_manifest() -> dict:
|
||||
with MANIFEST_PATH.open("r", encoding="utf-8") as f:
|
||||
manifest = yaml.safe_load(f)
|
||||
for key in ("marketplace", "bundle", "targets"):
|
||||
if key not in manifest:
|
||||
raise ValidationError(f"manifest.yaml is missing required key '{key}'")
|
||||
return manifest
|
||||
|
||||
|
||||
def parse_frontmatter(skill_md: Path) -> dict:
|
||||
text = skill_md.read_text(encoding="utf-8")
|
||||
if not text.startswith("---\n"):
|
||||
raise ValidationError(f"{skill_md}: missing YAML frontmatter delimiter")
|
||||
end = text.find("\n---", 4)
|
||||
if end == -1:
|
||||
raise ValidationError(f"{skill_md}: unterminated YAML frontmatter")
|
||||
raw = text[4:end]
|
||||
data = yaml.safe_load(raw) or {}
|
||||
for field in REQUIRED_FRONTMATTER_FIELDS:
|
||||
if not data.get(field):
|
||||
raise ValidationError(f"{skill_md}: frontmatter missing required field '{field}'")
|
||||
return data
|
||||
|
||||
|
||||
def discover_skills() -> list[dict]:
|
||||
skills = []
|
||||
seen_names = set()
|
||||
for skill_dir in sorted(SKILLS_DIR.iterdir()):
|
||||
if not skill_dir.is_dir():
|
||||
continue
|
||||
skill_md = skill_dir / "SKILL.md"
|
||||
if not skill_md.exists():
|
||||
raise ValidationError(f"{skill_dir}: missing SKILL.md")
|
||||
frontmatter = parse_frontmatter(skill_md)
|
||||
name = frontmatter["name"]
|
||||
if name != skill_dir.name:
|
||||
raise ValidationError(
|
||||
f"{skill_md}: frontmatter name '{name}' does not match directory name '{skill_dir.name}'"
|
||||
)
|
||||
if name in seen_names:
|
||||
raise ValidationError(f"duplicate skill name '{name}'")
|
||||
seen_names.add(name)
|
||||
skills.append({"dir": skill_dir, "frontmatter": frontmatter})
|
||||
if not skills:
|
||||
raise ValidationError("no skills found under skills/")
|
||||
return skills
|
||||
|
||||
|
||||
def gen_descriptions_json(skills: list[dict]) -> None:
|
||||
descriptions = {s["frontmatter"]["name"]: s["frontmatter"]["description"] for s in skills}
|
||||
out = ROOT / "descriptions.json"
|
||||
out.write_text(json.dumps(descriptions, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def gen_claude_code(manifest: dict, skills: list[dict]) -> None:
|
||||
plugin_dir = ROOT / ".claude-plugin"
|
||||
plugin_dir.mkdir(exist_ok=True)
|
||||
|
||||
plugins = []
|
||||
for s in skills:
|
||||
fm = s["frontmatter"]
|
||||
plugins.append(
|
||||
{
|
||||
"name": fm["name"],
|
||||
"description": fm["description"],
|
||||
"source": "./",
|
||||
"skills": [f"./skills/{fm['name']}"],
|
||||
}
|
||||
)
|
||||
|
||||
plugins.append(
|
||||
{
|
||||
"name": manifest["bundle"]["id"],
|
||||
"description": f"{manifest['marketplace']['description']} (all skills)",
|
||||
"source": "./",
|
||||
"skills": ["./skills/"],
|
||||
}
|
||||
)
|
||||
|
||||
marketplace = {
|
||||
"name": manifest["marketplace"]["name"],
|
||||
"owner": manifest["marketplace"]["owner"],
|
||||
"description": manifest["marketplace"]["description"],
|
||||
"plugins": plugins,
|
||||
}
|
||||
|
||||
out = plugin_dir / "marketplace.json"
|
||||
out.write_text(json.dumps(marketplace, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def gen_agy(manifest: dict, skills: list[dict]) -> None:
|
||||
agy_dir = ROOT / "dist" / "agy"
|
||||
if agy_dir.exists():
|
||||
shutil.rmtree(agy_dir)
|
||||
agy_dir.mkdir(parents=True)
|
||||
|
||||
def write_plugin(plugin_name: str, description: str, members: list[dict]) -> None:
|
||||
plugin_root = agy_dir / plugin_name
|
||||
skills_out = plugin_root / "skills"
|
||||
skills_out.mkdir(parents=True)
|
||||
for s in members:
|
||||
dest = skills_out / s["frontmatter"]["name"]
|
||||
shutil.copytree(s["dir"], dest)
|
||||
plugin_json = {"name": plugin_name, "description": description}
|
||||
(plugin_root / "plugin.json").write_text(
|
||||
json.dumps(plugin_json, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
for s in skills:
|
||||
fm = s["frontmatter"]
|
||||
write_plugin(fm["name"], fm["description"], [s])
|
||||
|
||||
write_plugin(
|
||||
manifest["bundle"]["id"],
|
||||
f"{manifest['marketplace']['description']} (all skills)",
|
||||
skills,
|
||||
)
|
||||
|
||||
dist_readme = ROOT / "dist" / "README.md"
|
||||
dist_readme.write_text(
|
||||
"# Generated\n\n"
|
||||
"This directory is generated by `scripts/generate_plugins.py` from "
|
||||
"`manifest.yaml` and `skills/`. Do not edit files here directly — "
|
||||
"edit the source skill instead and regenerate.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
GENERATORS = {
|
||||
"claude-code": gen_claude_code,
|
||||
"agy": gen_agy,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
check_only = "--check" in sys.argv[1:]
|
||||
try:
|
||||
manifest = load_manifest()
|
||||
skills = discover_skills()
|
||||
for target in manifest["targets"]:
|
||||
if target not in GENERATORS:
|
||||
raise ValidationError(f"manifest.yaml: unknown target '{target}'")
|
||||
if check_only:
|
||||
print(f"OK: {len(skills)} skill(s), {len(manifest['targets'])} target(s) validated")
|
||||
return 0
|
||||
gen_descriptions_json(skills)
|
||||
for target in manifest["targets"]:
|
||||
GENERATORS[target](manifest, skills)
|
||||
print(f"Generated plugins for {len(skills)} skill(s), targets: {', '.join(manifest['targets'])}")
|
||||
return 0
|
||||
except ValidationError as e:
|
||||
print(f"error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1 @@
|
||||
pyyaml
|
||||
Reference in New Issue
Block a user