Files
Jason Dudash 044e888715 feat(plugins): generate Cursor plugin alongside Claude and Codex
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>
2026-06-30 14:58:42 -05:00

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:]))