mirror of
https://github.com/NVIDIA/skills.git
synced 2026-09-14 15:41:50 +08:00
044e888715
Add a Cursor track to the plugin build pipeline so each catalog plugin in
plugins.d/ also emits a .cursor-plugin/plugin.json manifest and is
registered in a top-level .cursor-plugin/marketplace.json, mirroring the
existing Claude (.claude-plugin/) and Codex (.codex-plugin/) tracks.
- build-plugins.py: add render_cursor_plugin_json() and
upsert_cursor_marketplace(); wire both into build_catalog_plugin() and
main(); include the cursor marketplace in the drift check.
- Cursor manifest omits the skills field so Cursor folder discovery scans
skills/ (verified loading via cursor-agent --plugin-dir); projects the
author down to {name}; strips the leading ./ from logo for
raw.githubusercontent resolution.
- Add .cursor-plugin/marketplace.json (owner per Cursor schema); document
marketplace_enabled.cursor in _defaults.yml and nvidia-skills.yml.
- validate-plugins.yml: watch .cursor-plugin/marketplace.json and document
the Cursor validation story.
- Update plugins.d/README.md and the build-plugins.py docstring.
Signed-off-by: Jason Dudash <jdudash@nvidia.com>
698 lines
25 KiB
Python
Executable File
698 lines
25 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
# SPDX-License-Identifier: CC-BY-4.0 AND Apache-2.0
|
|
"""
|
|
Build plugin packages from plugins.d/ and per-plugin .skills-manifest.yml files.
|
|
|
|
Inputs (hand-maintained):
|
|
plugins.d/_defaults.yml
|
|
Shared default fields applied to every catalog plugin (author, license,
|
|
homepage, brand color, policy URLs, etc.). Per-plugin yaml fields
|
|
override these. Filenames starting with `_` are treated as includes and
|
|
are never built into a plugin themselves.
|
|
|
|
plugins.d/<name>.yml
|
|
Catalog plugin spec. Drives full regeneration of plugins/<name>/
|
|
(skills/ tree, .claude-plugin/plugin.json, .codex-plugin/plugin.json,
|
|
.cursor-plugin/plugin.json) and its entry in all three
|
|
marketplace.json files.
|
|
|
|
plugins/<name>/.skills-manifest.yml
|
|
Per-plugin skill manifest for hand-curated plugins. Only the skills/
|
|
tree is rebuilt; plugin.jsons, assets, README, and the marketplace
|
|
entry stay hand-edited.
|
|
|
|
plugins/<name>/assets/<file> Hand-maintained logo/image assets.
|
|
plugins/<name>/README.md Optional plugin-specific notes.
|
|
|
|
Generated outputs (committed):
|
|
plugins/<name>/skills/<skill>/ Real dir or symlink (see below)
|
|
plugins/<name>/.claude-plugin/plugin.json (catalog plugins only)
|
|
plugins/<name>/.codex-plugin/plugin.json (catalog plugins only)
|
|
plugins/<name>/.cursor-plugin/plugin.json (catalog plugins only)
|
|
.claude-plugin/marketplace.json (catalog plugin entries; others preserved)
|
|
.agents/plugins/marketplace.json (catalog plugin entries; others preserved)
|
|
.cursor-plugin/marketplace.json (catalog plugin entries; others preserved)
|
|
|
|
How the skills/ tree gets populated is selected by `skill_files:` in
|
|
plugins.d/<name>.yml (defaults to `copy` via plugins.d/_defaults.yml):
|
|
|
|
copy rsync each curated skill into plugins/<name>/skills/<skill>/.
|
|
Real files. Required for `codex plugin add` (Codex 0.132
|
|
silently drops symlinks during install).
|
|
symlink Relative symlink plugins/<name>/skills/<skill> →
|
|
../../../skills/<Product>/<skill>. No duplicated SKILL.md;
|
|
the plugin tree stays in sync with the canonical catalog
|
|
automatically. Works for `claude plugin install` and
|
|
`npx skills add`. NOT compatible with Codex local install.
|
|
|
|
Usage:
|
|
build-plugins.py Build everything.
|
|
build-plugins.py --check Build, then fail if the working tree changed
|
|
(CI drift guard).
|
|
build-plugins.py --only NAME Restrict to a single plugin name.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import yaml
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
PLUGINS_D = REPO_ROOT / "plugins.d"
|
|
PLUGINS_D_DEFAULTS = PLUGINS_D / "_defaults.yml"
|
|
PLUGINS_DIR = REPO_ROOT / "plugins"
|
|
SKILLS_DIR = REPO_ROOT / "skills"
|
|
CLAUDE_MARKETPLACE = REPO_ROOT / ".claude-plugin" / "marketplace.json"
|
|
AGENTS_MARKETPLACE = REPO_ROOT / ".agents" / "plugins" / "marketplace.json"
|
|
CURSOR_MARKETPLACE = REPO_ROOT / ".cursor-plugin" / "marketplace.json"
|
|
|
|
# Default policy block written for every plugin entry in
|
|
# .agents/plugins/marketplace.json. Mirrors the existing convention.
|
|
AGENTS_PLUGIN_POLICY = {
|
|
"installation": "AVAILABLE",
|
|
"authentication": "ON_INSTALL",
|
|
}
|
|
|
|
|
|
# ---------------------------- helpers ----------------------------------------
|
|
|
|
|
|
def log(msg: str) -> None:
|
|
print(msg, flush=True)
|
|
|
|
|
|
def die(msg: str, code: int = 1) -> None:
|
|
print(f"error: {msg}", file=sys.stderr, flush=True)
|
|
sys.exit(code)
|
|
|
|
|
|
def error(msg: str) -> None:
|
|
"""Like die() but does not exit. For recoverable per-item failures
|
|
that should be loud in logs but not block the rest of the build."""
|
|
print(f"error: {msg}", file=sys.stderr, flush=True)
|
|
|
|
|
|
def read_yaml(path: Path) -> dict[str, Any]:
|
|
"""Strict YAML read — dies on parse error or non-mapping root.
|
|
Use for files whose absence/corruption is structural (e.g.
|
|
plugins.d/_defaults.yml). For per-plugin yaml files, prefer
|
|
read_yaml_lenient so one bad file does not block the rest."""
|
|
with path.open() as f:
|
|
data = yaml.safe_load(f)
|
|
if not isinstance(data, dict):
|
|
die(f"{path}: expected a YAML mapping at top level")
|
|
return data
|
|
|
|
|
|
def read_yaml_lenient(path: Path) -> dict[str, Any] | None:
|
|
"""Soft YAML read — returns None on parse error or non-mapping root,
|
|
logs an error to stderr. Caller decides whether to skip the file."""
|
|
try:
|
|
with path.open() as f:
|
|
data = yaml.safe_load(f)
|
|
except yaml.YAMLError as e:
|
|
error(f"{path}: YAML parse error: {e}")
|
|
return None
|
|
if not isinstance(data, dict):
|
|
error(
|
|
f"{path}: expected a YAML mapping at top level, got "
|
|
f"{type(data).__name__}"
|
|
)
|
|
return None
|
|
return data
|
|
|
|
|
|
def read_json(path: Path) -> Any:
|
|
with path.open() as f:
|
|
return json.load(f)
|
|
|
|
|
|
def write_json(path: Path, data: Any) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
with path.open("w", encoding="utf-8") as f:
|
|
json.dump(data, f, indent=4, ensure_ascii=False)
|
|
f.write("\n")
|
|
|
|
|
|
def make_relative_symlink(target: Path, link: Path) -> None:
|
|
"""Create a symlink at `link` pointing at `target`, using a path relative
|
|
to `link`'s parent directory so the link survives repo relocation."""
|
|
link.parent.mkdir(parents=True, exist_ok=True)
|
|
if link.is_symlink() or link.exists():
|
|
link.unlink()
|
|
rel = os.path.relpath(target, link.parent)
|
|
os.symlink(rel, link, target_is_directory=True)
|
|
|
|
|
|
def expand_skill_paths(entries: list[str]) -> list[tuple[str, Path]]:
|
|
"""
|
|
Expand each entry into a list of (skill_basename, absolute_source_path).
|
|
|
|
- "skills/cuopt/cuopt-install/" → [("cuopt-install", REPO/skills/cuopt/cuopt-install)]
|
|
- "skills/cuopt/" → all immediate children of skills/cuopt that contain SKILL.md
|
|
|
|
Soft-fails any entry that would otherwise abort the build because of an
|
|
upstream catalog change (rename, removal, compliance-driven SKILL.md
|
|
drop, basename collision). The plugin ships with whatever curated
|
|
skills DID resolve, the warnings are surfaced in stdout, and the daily
|
|
sync PR is not blocked by curation drift in plugins.d/<name>.yml.
|
|
Hard config errors (bad YAML, invalid plugin name, etc.) still die.
|
|
"""
|
|
out: list[tuple[str, Path]] = []
|
|
for entry in entries:
|
|
rel = entry.rstrip("/")
|
|
src = (REPO_ROOT / rel).resolve()
|
|
if not src.exists():
|
|
log(
|
|
f" ! warning: include_skills entry missing on disk, skipping: {entry} "
|
|
f"(upstream rename/removal? update plugins.d/<name>.yml)"
|
|
)
|
|
continue
|
|
if not src.is_dir():
|
|
log(
|
|
f" ! warning: include_skills entry is not a directory, skipping: {entry}"
|
|
)
|
|
continue
|
|
skill_md = src / "SKILL.md"
|
|
if skill_md.is_file():
|
|
out.append((src.name, src))
|
|
else:
|
|
children = sorted(p for p in src.iterdir() if p.is_dir())
|
|
found = 0
|
|
for child in children:
|
|
if (child / "SKILL.md").is_file():
|
|
out.append((child.name, child))
|
|
found += 1
|
|
if found == 0:
|
|
log(
|
|
f" ! warning: no SKILL.md under {entry}, skipping "
|
|
f"(compliance enforcement may have dropped every skill here)"
|
|
)
|
|
seen: dict[str, Path] = {}
|
|
deduped: list[tuple[str, Path]] = []
|
|
for name, src in out:
|
|
if name in seen and seen[name] != src:
|
|
log(
|
|
f" ! warning: duplicate skill basename {name!r} across "
|
|
f"include_skills, keeping first: {seen[name]} (dropping {src})"
|
|
)
|
|
continue
|
|
seen[name] = src
|
|
deduped.append((name, src))
|
|
return deduped
|
|
|
|
|
|
VALID_MATERIALIZATIONS = ("copy", "symlink")
|
|
|
|
|
|
def rsync_dir(src: Path, dst: Path) -> None:
|
|
"""rsync -a --delete src/ dst/ ; src must exist as a directory."""
|
|
if not src.is_dir():
|
|
die(f"source is not a directory: {src}")
|
|
dst.mkdir(parents=True, exist_ok=True)
|
|
subprocess.run(
|
|
["rsync", "-a", "--delete", f"{src}/", f"{dst}/"],
|
|
check=True,
|
|
)
|
|
|
|
|
|
def materialize_skills(
|
|
plugin_name: str,
|
|
entries: list[str],
|
|
mode: str = "copy",
|
|
) -> list[str]:
|
|
"""Replace plugins/<name>/skills/ with the curated subset. Returns skill names.
|
|
|
|
`mode` selects materialization strategy:
|
|
- "copy" rsync each skill into plugins/<name>/skills/<skill>/.
|
|
Real files. Larger repo. Required for Codex local-install
|
|
(codex plugin add silently drops symlinks).
|
|
- "symlink" relative symlinks under plugins/<name>/skills/<skill> →
|
|
../../../skills/<Product>/<skill>. No duplicate SKILL.md.
|
|
Works for Claude install and `npx skills add`. Currently
|
|
NOT supported by Codex local marketplace install.
|
|
"""
|
|
if mode not in VALID_MATERIALIZATIONS:
|
|
log(
|
|
f" ! warning: plugin {plugin_name!r}: invalid skill_files={mode!r}, "
|
|
f"falling back to 'copy' (allowed: {', '.join(VALID_MATERIALIZATIONS)})"
|
|
)
|
|
mode = "copy"
|
|
plugin_dir = PLUGINS_DIR / plugin_name
|
|
target = plugin_dir / "skills"
|
|
if target.is_symlink() or target.exists():
|
|
if target.is_symlink():
|
|
target.unlink()
|
|
else:
|
|
shutil.rmtree(target)
|
|
target.mkdir(parents=True)
|
|
|
|
pairs = expand_skill_paths(entries)
|
|
names: list[str] = []
|
|
for name, src in pairs:
|
|
dst = target / name
|
|
if mode == "symlink":
|
|
make_relative_symlink(src, dst)
|
|
else: # copy
|
|
rsync_dir(src, dst)
|
|
names.append(name)
|
|
if mode == "symlink":
|
|
word = "symlink" if len(names) == 1 else "symlinks"
|
|
else:
|
|
word = "copy" if len(names) == 1 else "copies"
|
|
log(f" ✓ skills/ ({len(names)} {word})")
|
|
return names
|
|
|
|
|
|
# ---------------------------- catalog plugin ---------------------------------
|
|
|
|
|
|
def render_claude_plugin_json(spec: dict[str, Any]) -> dict[str, Any]:
|
|
out: dict[str, Any] = {
|
|
"name": spec["name"],
|
|
"version": str(spec.get("version", "1.0.0")),
|
|
"description": spec["description"],
|
|
}
|
|
if "display_name" in spec:
|
|
out["displayName"] = spec["display_name"]
|
|
if "author" in spec:
|
|
out["author"] = spec["author"]
|
|
if "homepage" in spec:
|
|
out["homepage"] = spec["homepage"]
|
|
if "repository" in spec:
|
|
out["repository"] = spec["repository"]
|
|
if "license" in spec:
|
|
out["license"] = spec["license"]
|
|
if "keywords" in spec:
|
|
out["keywords"] = list(spec["keywords"])
|
|
# Skills tree is always inside the plugin (Codex rejects ".." paths,
|
|
# Anthropic spec requires "./" prefixes). Container path; Claude scans
|
|
# immediate children for SKILL.md.
|
|
out["skills"] = ["./skills/"]
|
|
return out
|
|
|
|
|
|
def render_codex_plugin_json(spec: dict[str, Any]) -> dict[str, Any]:
|
|
out: dict[str, Any] = {
|
|
"name": spec["name"],
|
|
"version": str(spec.get("version", "1.0.0")),
|
|
"description": spec["description"],
|
|
}
|
|
if "author" in spec:
|
|
out["author"] = spec["author"]
|
|
if "homepage" in spec:
|
|
out["homepage"] = spec["homepage"]
|
|
if "repository" in spec:
|
|
out["repository"] = spec["repository"]
|
|
if "license" in spec:
|
|
out["license"] = spec["license"]
|
|
if "keywords" in spec:
|
|
out["keywords"] = list(spec["keywords"])
|
|
|
|
# Skills tree is always inside the plugin (Codex rejects ".." paths).
|
|
out["skills"] = "./skills/"
|
|
|
|
interface: dict[str, Any] = {}
|
|
if "display_name" in spec:
|
|
interface["displayName"] = spec["display_name"]
|
|
if "short_description" in spec:
|
|
interface["shortDescription"] = spec["short_description"]
|
|
if "long_description" in spec:
|
|
interface["longDescription"] = spec["long_description"]
|
|
if "author" in spec and "name" in spec["author"]:
|
|
interface["developerName"] = spec["author"]["name"]
|
|
if "category" in spec:
|
|
interface["category"] = spec["category"]
|
|
if "capabilities" in spec:
|
|
interface["capabilities"] = list(spec["capabilities"])
|
|
for src_key, dst_key in (
|
|
("website_url", "websiteURL"),
|
|
("privacy_policy_url", "privacyPolicyURL"),
|
|
("terms_of_service_url", "termsOfServiceURL"),
|
|
):
|
|
value = spec.get(src_key)
|
|
if value is None or value == "":
|
|
continue
|
|
interface[dst_key] = value
|
|
if "logo" in spec:
|
|
interface["logo"] = spec["logo"]
|
|
if "composer_icon" in spec:
|
|
interface["composerIcon"] = spec["composer_icon"]
|
|
if "screenshots" in spec:
|
|
interface["screenshots"] = list(spec["screenshots"])
|
|
if "brand_color" in spec:
|
|
interface["brandColor"] = spec["brand_color"]
|
|
if "default_prompts" in spec:
|
|
interface["defaultPrompt"] = list(spec["default_prompts"])
|
|
if interface:
|
|
out["interface"] = interface
|
|
return out
|
|
|
|
|
|
def render_cursor_plugin_json(spec: dict[str, Any]) -> dict[str, Any]:
|
|
"""Render plugins/<name>/.cursor-plugin/plugin.json.
|
|
|
|
Follows the Cursor plugin manifest schema
|
|
(https://cursor.com/docs/reference/plugins): only `name` is required;
|
|
everything else is optional. The `skills` field is intentionally
|
|
omitted so Cursor's folder-based discovery scans plugins/<name>/skills/
|
|
for each subdirectory containing a SKILL.md — the same tree the Claude
|
|
and Codex manifests point at. Cursor's author object is {name, email?},
|
|
so we project our {name, url} author down to just `name` (the url is
|
|
already carried by `homepage`/`repository`). The `logo` path drops the
|
|
leading "./" because Cursor resolves relative logo paths against
|
|
raw.githubusercontent.com.
|
|
"""
|
|
out: dict[str, Any] = {
|
|
"name": spec["name"],
|
|
"version": str(spec.get("version", "1.0.0")),
|
|
"description": spec["description"],
|
|
}
|
|
author = spec.get("author")
|
|
if isinstance(author, dict):
|
|
cursor_author: dict[str, Any] = {}
|
|
if author.get("name"):
|
|
cursor_author["name"] = author["name"]
|
|
if author.get("email"):
|
|
cursor_author["email"] = author["email"]
|
|
if cursor_author:
|
|
out["author"] = cursor_author
|
|
if "homepage" in spec:
|
|
out["homepage"] = spec["homepage"]
|
|
if "repository" in spec:
|
|
out["repository"] = spec["repository"]
|
|
if "license" in spec:
|
|
out["license"] = spec["license"]
|
|
if "keywords" in spec:
|
|
out["keywords"] = list(spec["keywords"])
|
|
logo = spec.get("logo")
|
|
if isinstance(logo, str) and logo:
|
|
out["logo"] = logo[2:] if logo.startswith("./") else logo
|
|
return out
|
|
|
|
|
|
def build_catalog_plugin(spec: dict[str, Any]) -> str:
|
|
# `name` is already validated for kebab-case + uniqueness in discover()
|
|
# before specs reach this function.
|
|
name = spec["name"]
|
|
plugin_dir = PLUGINS_DIR / name
|
|
plugin_dir.mkdir(parents=True, exist_ok=True)
|
|
log(f"── catalog plugin: {name} ──")
|
|
|
|
mode = spec.get("skill_files", "copy")
|
|
materialize_skills(name, spec.get("include_skills", []), mode=mode)
|
|
|
|
write_json(plugin_dir / ".claude-plugin" / "plugin.json", render_claude_plugin_json(spec))
|
|
log(" ✓ .claude-plugin/plugin.json")
|
|
write_json(plugin_dir / ".codex-plugin" / "plugin.json", render_codex_plugin_json(spec))
|
|
log(" ✓ .codex-plugin/plugin.json")
|
|
write_json(plugin_dir / ".cursor-plugin" / "plugin.json", render_cursor_plugin_json(spec))
|
|
log(" ✓ .cursor-plugin/plugin.json")
|
|
|
|
asset_fields = ["logo", "composer_icon"]
|
|
asset_paths: list[str] = [spec[f] for f in asset_fields if isinstance(spec.get(f), str)]
|
|
asset_paths += [s for s in (spec.get("screenshots") or []) if isinstance(s, str)]
|
|
for asset in asset_paths:
|
|
if not asset.startswith("./"):
|
|
continue
|
|
rel = asset[2:]
|
|
path = plugin_dir / rel
|
|
if not path.is_file():
|
|
log(f" ! warning: asset declared but missing on disk: {path.relative_to(REPO_ROOT)}")
|
|
|
|
return name
|
|
|
|
|
|
# ---------------------------- curated plugin ---------------------------------
|
|
|
|
|
|
def build_curated_plugin(plugin_dir: Path) -> str | None:
|
|
"""
|
|
A curated plugin is one with plugins/<name>/.skills-manifest.yml but
|
|
no plugins.d/<name>.yml. We rebuild only its skills/ tree (mode
|
|
selected by `skill_files:` in the manifest, default `copy`) and
|
|
trust everything else to be hand-maintained.
|
|
|
|
Returns the plugin name on success, or None if we skipped the build
|
|
due to a malformed manifest. The marketplace upsert preserves the
|
|
plugin's existing entry verbatim either way.
|
|
"""
|
|
manifest_path = plugin_dir / ".skills-manifest.yml"
|
|
name = plugin_dir.name
|
|
spec = read_yaml_lenient(manifest_path)
|
|
if spec is None:
|
|
error(
|
|
f" ! skipping curated plugin {name!r} — "
|
|
f"{manifest_path.relative_to(REPO_ROOT)} is unreadable; "
|
|
f"existing plugins/{name}/skills/ left untouched"
|
|
)
|
|
return None
|
|
log(f"── curated plugin: {name} ──")
|
|
skills = spec.get("skills") or []
|
|
if not skills:
|
|
log(
|
|
f" ! warning: {manifest_path.relative_to(REPO_ROOT)} has empty "
|
|
f"'skills' list; producing empty plugins/{name}/skills/"
|
|
)
|
|
mode = spec.get("skill_files", "copy")
|
|
materialize_skills(name, skills, mode=mode)
|
|
return name
|
|
|
|
|
|
# ---------------------------- marketplaces -----------------------------------
|
|
|
|
|
|
def is_marketplace_enabled(spec: dict[str, Any], marketplace_key: str) -> bool:
|
|
flags = spec.get("marketplace_enabled") or {}
|
|
return bool(flags.get(marketplace_key, True))
|
|
|
|
|
|
def upsert_claude_marketplace(
|
|
catalog_specs: dict[str, dict[str, Any]],
|
|
curated_names: set[str],
|
|
) -> None:
|
|
if not CLAUDE_MARKETPLACE.is_file():
|
|
die(f"missing {CLAUDE_MARKETPLACE.relative_to(REPO_ROOT)}")
|
|
data = read_json(CLAUDE_MARKETPLACE)
|
|
existing = data.get("plugins", [])
|
|
managed = set(catalog_specs.keys())
|
|
|
|
new_plugins: list[dict[str, Any]] = []
|
|
for entry in existing:
|
|
name = entry.get("name")
|
|
if name in managed:
|
|
continue
|
|
if name in curated_names:
|
|
new_plugins.append(entry)
|
|
for name in sorted(catalog_specs):
|
|
spec = catalog_specs[name]
|
|
if not is_marketplace_enabled(spec, "claude"):
|
|
continue
|
|
new_plugins.append({
|
|
"name": name,
|
|
"source": f"./plugins/{name}",
|
|
"description": spec["description"],
|
|
})
|
|
new_plugins.sort(key=lambda p: p.get("name", ""))
|
|
data["plugins"] = new_plugins
|
|
write_json(CLAUDE_MARKETPLACE, data)
|
|
log(f" ✓ {CLAUDE_MARKETPLACE.relative_to(REPO_ROOT)} ({len(new_plugins)} plugin(s))")
|
|
|
|
|
|
def upsert_agents_marketplace(
|
|
catalog_specs: dict[str, dict[str, Any]],
|
|
curated_names: set[str],
|
|
) -> None:
|
|
if not AGENTS_MARKETPLACE.is_file():
|
|
die(f"missing {AGENTS_MARKETPLACE.relative_to(REPO_ROOT)}")
|
|
data = read_json(AGENTS_MARKETPLACE)
|
|
existing = data.get("plugins", [])
|
|
managed = set(catalog_specs.keys())
|
|
|
|
new_plugins: list[dict[str, Any]] = []
|
|
for entry in existing:
|
|
name = entry.get("name")
|
|
if name in managed:
|
|
continue
|
|
if name in curated_names:
|
|
new_plugins.append(entry)
|
|
for name in sorted(catalog_specs):
|
|
spec = catalog_specs[name]
|
|
if not is_marketplace_enabled(spec, "codex"):
|
|
continue
|
|
new_plugins.append({
|
|
"name": name,
|
|
"source": {"source": "local", "path": f"./plugins/{name}"},
|
|
"policy": dict(AGENTS_PLUGIN_POLICY),
|
|
"category": spec.get("category", "Developer Tools"),
|
|
})
|
|
new_plugins.sort(key=lambda p: p.get("name", ""))
|
|
data["plugins"] = new_plugins
|
|
write_json(AGENTS_MARKETPLACE, data)
|
|
log(f" ✓ {AGENTS_MARKETPLACE.relative_to(REPO_ROOT)} ({len(new_plugins)} plugin(s))")
|
|
|
|
|
|
def upsert_cursor_marketplace(
|
|
catalog_specs: dict[str, dict[str, Any]],
|
|
curated_names: set[str],
|
|
) -> None:
|
|
if not CURSOR_MARKETPLACE.is_file():
|
|
die(f"missing {CURSOR_MARKETPLACE.relative_to(REPO_ROOT)}")
|
|
data = read_json(CURSOR_MARKETPLACE)
|
|
existing = data.get("plugins", [])
|
|
managed = set(catalog_specs.keys())
|
|
|
|
new_plugins: list[dict[str, Any]] = []
|
|
for entry in existing:
|
|
name = entry.get("name")
|
|
if name in managed:
|
|
continue
|
|
if name in curated_names:
|
|
new_plugins.append(entry)
|
|
for name in sorted(catalog_specs):
|
|
spec = catalog_specs[name]
|
|
if not is_marketplace_enabled(spec, "cursor"):
|
|
continue
|
|
new_plugins.append({
|
|
"name": name,
|
|
"source": f"./plugins/{name}",
|
|
"description": spec["description"],
|
|
})
|
|
new_plugins.sort(key=lambda p: p.get("name", ""))
|
|
data["plugins"] = new_plugins
|
|
write_json(CURSOR_MARKETPLACE, data)
|
|
log(f" ✓ {CURSOR_MARKETPLACE.relative_to(REPO_ROOT)} ({len(new_plugins)} plugin(s))")
|
|
|
|
|
|
# ---------------------------- main -------------------------------------------
|
|
|
|
|
|
def merge_with_defaults(defaults: dict[str, Any], plugin: dict[str, Any]) -> dict[str, Any]:
|
|
merged = dict(defaults)
|
|
merged.update(plugin)
|
|
return merged
|
|
|
|
|
|
def discover() -> tuple[dict[str, dict[str, Any]], list[Path]]:
|
|
# _defaults.yml is structural (every plugin merges from it); a parse
|
|
# failure there makes downstream merges meaningless, so keep strict.
|
|
defaults: dict[str, Any] = {}
|
|
if PLUGINS_D_DEFAULTS.is_file():
|
|
defaults = read_yaml(PLUGINS_D_DEFAULTS)
|
|
|
|
# Per-plugin yaml files are validated leniently: one bad/duplicate/
|
|
# unnamed file logs an error and gets skipped; the rest of the
|
|
# catalog still builds.
|
|
catalog: dict[str, dict[str, Any]] = {}
|
|
if PLUGINS_D.is_dir():
|
|
for ymlfile in sorted(PLUGINS_D.glob("*.yml")):
|
|
if ymlfile.name.startswith("_"):
|
|
continue
|
|
rel = ymlfile.relative_to(REPO_ROOT)
|
|
plugin_spec = read_yaml_lenient(ymlfile)
|
|
if plugin_spec is None:
|
|
error(f" ! skipping {rel} — YAML errors above")
|
|
continue
|
|
spec = merge_with_defaults(defaults, plugin_spec)
|
|
name = spec.get("name")
|
|
if not name:
|
|
error(f"{rel}: missing 'name', skipping")
|
|
continue
|
|
if not re.fullmatch(r"[a-z0-9][a-z0-9-]*", name):
|
|
error(
|
|
f"{rel}: plugin name {name!r} is not lowercase "
|
|
f"kebab-case, skipping"
|
|
)
|
|
continue
|
|
if name in catalog:
|
|
first = catalog[name].get("__source", "?")
|
|
error(
|
|
f"{rel}: duplicate plugin name {name!r} (already "
|
|
f"declared in {first}), skipping"
|
|
)
|
|
continue
|
|
spec["__source"] = str(rel)
|
|
catalog[name] = spec
|
|
|
|
curated: list[Path] = []
|
|
if PLUGINS_DIR.is_dir():
|
|
for plugin_dir in sorted(PLUGINS_DIR.iterdir()):
|
|
if not plugin_dir.is_dir():
|
|
continue
|
|
if plugin_dir.name in catalog:
|
|
continue
|
|
if (plugin_dir / ".skills-manifest.yml").is_file():
|
|
curated.append(plugin_dir)
|
|
return catalog, curated
|
|
|
|
|
|
def main(argv: list[str]) -> int:
|
|
p = argparse.ArgumentParser(description="Build NVIDIA skills plugins.")
|
|
p.add_argument("--only", help="Only build the named plugin.")
|
|
p.add_argument(
|
|
"--check",
|
|
action="store_true",
|
|
help="After building, fail if the working tree changed (CI drift guard).",
|
|
)
|
|
args = p.parse_args(argv)
|
|
|
|
catalog, curated = discover()
|
|
if args.only:
|
|
catalog = {k: v for k, v in catalog.items() if k == args.only}
|
|
curated = [d for d in curated if d.name == args.only]
|
|
if not catalog and not curated:
|
|
die(f"no plugin named '{args.only}' found in plugins.d/ or plugins/")
|
|
|
|
log(f"Found {len(catalog)} catalog plugin(s) and {len(curated)} curated plugin(s).")
|
|
|
|
for name in sorted(catalog):
|
|
build_catalog_plugin(catalog[name])
|
|
for plugin_dir in curated:
|
|
build_curated_plugin(plugin_dir)
|
|
|
|
log("── marketplaces ──")
|
|
curated_names = {d.name for d in curated}
|
|
upsert_claude_marketplace(catalog, curated_names)
|
|
upsert_agents_marketplace(catalog, curated_names)
|
|
upsert_cursor_marketplace(catalog, curated_names)
|
|
|
|
if args.check:
|
|
log("── drift check ──")
|
|
result = subprocess.run(
|
|
[
|
|
"git",
|
|
"status",
|
|
"--porcelain",
|
|
"plugins/",
|
|
".claude-plugin/marketplace.json",
|
|
".agents/plugins/marketplace.json",
|
|
".cursor-plugin/marketplace.json",
|
|
],
|
|
cwd=REPO_ROOT,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if result.stdout.strip():
|
|
print(result.stdout, file=sys.stderr)
|
|
die(
|
|
"plugin tree drifted from sources; "
|
|
"run .github/scripts/build-plugins.sh and commit the result"
|
|
)
|
|
log(" ✓ no drift")
|
|
|
|
log("done.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv[1:]))
|