← Files AMDARCHIVED FILE
.github/scripts/generate_codex_plugin.py
10.3 KB · Sep 30, 2026 · 23:13 UTC
#!/usr/bin/env -S uv run --quiet
# /// script
# requires-python = ">=3.10"
# dependencies = []
# ///
"""Generate the Codex plugin manifest and marketplace from canonical sources.
Codex packages a plugin with a manifest at `.codex-plugin/plugin.json` and
distributes it through a repo marketplace catalog at
`.agents/plugins/marketplace.json`
(https://developers.openai.com/codex/plugins/build). AMD ships the same single
curated `amd-skills` bundle it exposes to Claude and Cursor, so both Codex files
are generated (never hand-maintained) from the same sources the Cursor mirror
uses, keeping every ecosystem in lockstep.
Sources of truth:
- `plugin-metadata.json` (repo root): shared, vendor-neutral identity and
discovery metadata (name, displayName, description, version, brandColor,
author, homepage, repository, license, keywords).
- `.claude-plugin/marketplace.json`: the curated bundle entry -- its `skills`
array (which skills ship) and its human-readable catalog `description` (reused
as the Codex `longDescription`).
Outputs:
- `.codex-plugin/plugin.json`: the Codex plugin manifest (identity, the curated
`skills` list, and the `interface` install-surface metadata).
- `.agents/plugins/marketplace.json`: the Codex repo marketplace that points at
the plugin (`source.path` `./`, the repo root) with its install policy.
Usage:
uv run .github/scripts/generate_codex_plugin.py # write
uv run .github/scripts/generate_codex_plugin.py --check # validate only
`--check` fails if either 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"
CODEX_PLUGIN = ROOT / ".codex-plugin" / "plugin.json"
CODEX_MARKETPLACE = ROOT / ".agents" / "plugins" / "marketplace.json"
# Codex-specific catalog taxonomy and install policy. These describe how the
# bundle presents in Codex-facing catalogs rather than vendor-neutral identity,
# so they live here instead of plugin-metadata.json.
CATEGORY = "Developer Tools"
# The bundled skills read files/config and can write files, install packages,
# and launch local services; declare both surfaces so install prompts are
# accurate.
CAPABILITIES = ["Read", "Write"]
# `AVAILABLE` (installable, opt-in) + `ON_INSTALL` are the values Codex's own
# build docs use for a local marketplace entry. The bundle needs no auth of its
# own, but `policy.authentication` is required, so use the documented default.
POLICY = {"installation": "AVAILABLE", "authentication": "ON_INSTALL"}
# Brand image used for both the catalog logo and the composer icon. Path is
# relative to the plugin root (the repo root, since source.path is "./"), per
# the guide's path rules.
LOGO = "./assets/amd.png"
# Starter prompts Codex surfaces on the install surface, one per published
# focus area (server inference, local image gen, local-AI app integration).
DEFAULT_PROMPT = [
"Use AMD Skills to deploy this LLM for inference on my AMD Instinct GPU",
"Learn how to generate images locally and generate the image of a cat",
"Convert my cloud LLM app into an app that uses local inference",
]
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`.
Mirrors generate_cursor_marketplace.py: both generators read the same
canonical files, so both refuse to emit anything while the sources
disagree.
"""
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})."
)
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 get_bundle_entry(claude: dict) -> dict:
"""Return the single curated bundle plugin entry from the Claude manifest."""
plugins = claude.get("plugins")
if not isinstance(plugins, list) or not plugins:
raise ValueError(
".claude-plugin/marketplace.json is missing its `plugins` array."
)
entry = plugins[0]
if not isinstance(entry, dict):
raise ValueError(".claude-plugin/marketplace.json plugins[0] must be an object.")
return entry
def build_codex_plugin(metadata: dict, bundle: dict) -> dict:
"""Build the `.codex-plugin/plugin.json` manifest.
Identity and discovery fields come from plugin-metadata.json; the curated
`skills` list and the human-readable long description come from the Claude
bundle entry so Codex publishes exactly the same skills as the other
ecosystems.
"""
author = metadata.get("author") or {}
developer_name = author.get("name") if isinstance(author, dict) else None
manifest: dict = {
"name": metadata["name"],
"version": metadata["version"],
"description": metadata["description"],
"author": author,
"homepage": metadata.get("homepage"),
"repository": metadata.get("repository"),
"license": metadata.get("license"),
"keywords": metadata.get("keywords", []),
# Curate the same folders the Claude bundle publishes. Codex documents
# `skills` as a directory pointer, but an explicit list keeps the
# unpublished skills under skills/ out of the shipped bundle.
"skills": list(bundle.get("skills", [])),
"interface": {
"displayName": metadata.get("displayName", metadata["name"]),
"shortDescription": metadata["description"],
"longDescription": bundle.get("description", metadata["description"]),
"developerName": developer_name or metadata["name"],
"category": CATEGORY,
"capabilities": list(CAPABILITIES),
"websiteURL": metadata.get("homepage"),
"brandColor": metadata.get("brandColor"),
"logo": LOGO,
"composerIcon": LOGO,
"defaultPrompt": list(DEFAULT_PROMPT),
},
}
# Drop keys that resolved to None so the manifest stays clean when an
# optional source field is absent.
return _prune_none(manifest)
def build_codex_marketplace(metadata: dict) -> dict:
"""Build the `.agents/plugins/marketplace.json` repo catalog.
A single local entry points at the repo root (`./`), where the
`.codex-plugin/plugin.json` manifest lives, so `codex plugin marketplace
add amd/skills` exposes the bundle.
"""
return {
"name": metadata["name"],
"interface": {
"displayName": metadata.get("displayName", metadata["name"]),
},
"plugins": [
{
"name": metadata["name"],
"source": {"source": "local", "path": "./"},
"policy": dict(POLICY),
"category": CATEGORY,
}
],
}
def _prune_none(value):
"""Recursively drop dict keys whose value is None."""
if isinstance(value, dict):
return {k: _prune_none(v) for k, v in value.items() if v is not None}
if isinstance(value, list):
return [_prune_none(v) for v in value]
return value
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 .codex-plugin/plugin.json and "
".agents/plugins/marketplace.json from the canonical Claude marketplace "
"and plugin-metadata.json."
)
parser.add_argument(
"--check",
action="store_true",
help="Validate the generated files are 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
bundle = get_bundle_entry(claude)
targets = [
(CODEX_PLUGIN, render_json(build_codex_plugin(metadata, bundle))),
(CODEX_MARKETPLACE, render_json(build_codex_marketplace(metadata))),
]
stale = [
path for path, content in targets
if not write_or_check(path, content, check=args.check)
]
if args.check:
if stale:
for path in stale:
print(f"{path.relative_to(ROOT).as_posix()} is out of date.", file=sys.stderr)
print(
"Run: uv run .github/scripts/generate_codex_plugin.py",
file=sys.stderr,
)
return 1
print("Codex plugin and marketplace manifests are up to date.")
return 0
for path, _ in targets:
print(f"Wrote {path.relative_to(ROOT).as_posix()}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
SHA-256: 8f05ffceb2290747ee8b8290a45b8bbbf6b85c349e81b07779bc3eba6f8dee31