← Files AMDARCHIVED FILE
.github/scripts/generate_cursor_marketplace.py
7.98 KB · Sep 30, 2026 · 23:13 UTC
#!/usr/bin/env -S uv run --quiet
# /// script
# requires-python = ">=3.10"
# dependencies = []
# ///
"""Generate the Cursor marketplace manifest from the canonical sources.
Both ecosystems use the same marketplace model: a single curated plugin
(`amd-skills`) whose `source` is the repo root bundles the published skills
listed in its `skills` array (the skill folders ship in place under `skills/`,
so there is no separate plugin tree). The Cursor manifest mirrors the Claude
marketplace one-for-one; to avoid drift it is generated, not hand-maintained.
Sources of truth:
- `plugin-metadata.json` (repo root): shared identity and discovery metadata
(name, description, version, author, homepage, repository, license,
keywords). This is the vendor-neutral metadata file, reused by every
marketplace/manifest target. It is NOT a plugin manifest.
- `.claude-plugin/marketplace.json`: the per-skill plugin entries and their
human-readable descriptions (hand-maintained, since the catalog blurbs
intentionally differ from the SKILL.md routing descriptions).
Output:
- `.cursor-plugin/marketplace.json`: a mirror of the Claude marketplace so
Cursor exposes exactly the same skills as Claude.
Usage:
uv run .github/scripts/generate_cursor_marketplace.py # write
uv run .github/scripts/generate_cursor_marketplace.py --check # validate only
`--check` fails if the generated file is stale or if the Claude marketplace
top-level identity has drifted from `plugin-metadata.json`.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent.parent
PLUGIN_METADATA = ROOT / "plugin-metadata.json"
CLAUDE_MARKETPLACE = ROOT / ".claude-plugin" / "marketplace.json"
CURSOR_MARKETPLACE = ROOT / ".cursor-plugin" / "marketplace.json"
# Plugin-entry fields Cursor's marketplace parser recognizes
# (https://cursor.com/docs/reference/plugins#plugin-entry-fields). The Claude
# bundle entry may carry Claude-only keys (e.g. `strict`) that Cursor does not
# document, so entries are whitelist-filtered to these before enrichment.
CURSOR_PLUGIN_ENTRY_FIELDS = {
"name",
"source",
"description",
"version",
"author",
"homepage",
"repository",
"license",
"keywords",
"logo",
"category",
"tags",
"skills",
"rules",
"agents",
"commands",
"hooks",
"mcpServers",
}
# Cursor-facing catalog taxonomy, mirroring the Codex generator. Kept here (not
# in plugin-metadata.json) because it describes how the bundle presents in a
# catalog rather than vendor-neutral identity.
CATEGORY = "Developer Tools"
# Brand image for the marketplace listing. Cursor resolves a relative path to a
# raw.githubusercontent.com URL for the repo/commit, so point at the committed
# asset (no leading "./", per the reference's example).
LOGO = "assets/amd.png"
def load_json(path: Path) -> dict:
if not path.exists():
raise FileNotFoundError(f"Missing required file: {path}")
return json.loads(path.read_text(encoding="utf-8"))
def check_identity_consistency(metadata: dict, claude: dict) -> list[str]:
"""Return error strings if the Claude marketplace top-level identity has
drifted from the canonical `plugin-metadata.json`."""
errors: list[str] = []
name = metadata.get("name")
description = metadata.get("description")
version = metadata.get("version")
if claude.get("name") != name:
errors.append(
f".claude-plugin/marketplace.json `name` ({claude.get('name')!r}) "
f"must match plugin-metadata.json `name` ({name!r})."
)
# The marketplace schema only allows `name`, `owner`, `metadata`, and
# `plugins` at the root -- the human-readable blurb lives in
# `metadata.description`, not a top-level `description`.
claude_description = (claude.get("metadata") or {}).get("description")
if claude_description != description:
errors.append(
".claude-plugin/marketplace.json metadata.description must match "
"plugin-metadata.json `description`."
)
claude_version = (claude.get("metadata") or {}).get("version")
if claude_version != version:
errors.append(
f".claude-plugin/marketplace.json metadata.version "
f"({claude_version!r}) must match plugin-metadata.json `version` "
f"({version!r})."
)
return errors
def build_plugin_entry(metadata: dict, claude_entry: dict) -> dict:
"""Turn a Claude bundle entry into a Cursor-compliant plugin entry.
Drops Claude-only keys Cursor does not document (e.g. `strict`) and layers
the repo's identity/discovery metadata (author, repository, logo, ...) on
top so the marketplace listing carries it. Because the entry's `source` is
the repo root, Cursor never merges a separate `.cursor-plugin/plugin.json`,
so this inline metadata is the only place Cursor sees it.
"""
entry = {k: v for k, v in claude_entry.items() if k in CURSOR_PLUGIN_ENTRY_FIELDS}
author = metadata.get("author") or {}
enrichment = {
"author": author if isinstance(author, dict) and author else None,
"homepage": metadata.get("homepage"),
"repository": metadata.get("repository"),
"license": metadata.get("license"),
"keywords": metadata.get("keywords") or None,
"logo": LOGO,
"category": CATEGORY,
}
for key, value in enrichment.items():
if value is not None and key not in entry:
entry[key] = value
return entry
def build_cursor_marketplace(metadata: dict, claude: dict) -> dict:
author = metadata.get("author") or {}
owner_name = author.get("name") if isinstance(author, dict) else None
return {
"name": metadata["name"],
"owner": {"name": owner_name} if owner_name else {},
"metadata": {
"description": metadata["description"],
"version": metadata["version"],
},
"plugins": [
build_plugin_entry(metadata, entry)
for entry in claude.get("plugins", [])
],
}
def render_json(data: dict) -> str:
return json.dumps(data, indent=2, ensure_ascii=False) + "\n"
def write_or_check(path: Path, content: str, check: bool) -> bool:
"""Return True when the file is already up to date."""
current = path.read_text(encoding="utf-8") if path.exists() else None
if current == content:
return True
if check:
return False
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
return True
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Generate .cursor-plugin/marketplace.json from the "
"canonical Claude marketplace and plugin-metadata.json."
)
parser.add_argument(
"--check",
action="store_true",
help="Validate the generated manifest is up to date without writing.",
)
args = parser.parse_args(argv)
metadata = load_json(PLUGIN_METADATA)
claude = load_json(CLAUDE_MARKETPLACE)
identity_errors = check_identity_consistency(metadata, claude)
if identity_errors:
print("Marketplace identity is inconsistent:", file=sys.stderr)
for err in identity_errors:
print(f" - {err}", file=sys.stderr)
return 1
content = render_json(build_cursor_marketplace(metadata, claude))
up_to_date = write_or_check(CURSOR_MARKETPLACE, content, check=args.check)
if args.check:
if not up_to_date:
print(
f"{CURSOR_MARKETPLACE.relative_to(ROOT)} is out of date.",
file=sys.stderr,
)
print(
"Run: uv run .github/scripts/generate_cursor_marketplace.py",
file=sys.stderr,
)
return 1
print("Cursor marketplace manifest is up to date.")
return 0
print(f"Wrote {CURSOR_MARKETPLACE.relative_to(ROOT)}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
SHA-256: 84055653a96038199a854555fee34b45b13730be5f95fd8154892f8474362846