feat(plugin): automatic updates via native marketplaces and a version gate

Installs previously drifted: a standalone `npx skills add` copy never
followed the repo, and Claude Code disables auto-update for third-party
marketplaces by default. Route every client through its own marketplace so
merged changes reach users through the native update path.

- Add `.agents/plugins/marketplace.json` so Codex resolves the plugin from
  a Git marketplace it refreshes at startup.
- Add `author` to the Claude manifest so both native manifests agree.
- Add `scripts/bump-plugin-version.py` to increment the Claude and Codex
  versions together; it refuses to run when they already differ.
- Add `scripts/verify-plugin-package.py` to gate synchronized, strictly
  increasing semver against the base ref, marketplace paths that stay
  inside the repo root, and the packaged skill.
- Add `scripts/test-plugin-package.py` covering the adversarial cases:
  missing bump, mismatched manifests, malformed semver, and a marketplace
  target that does not exist.
- Add a `plugin-package` CI job running the tests, the version gate against
  the PR base sha, and `claude plugin validate . --strict`.
- Document the per-PR bump requirement, the new gates, and the per-client
  install/update paths, including the one-time migration off standalone
  copies.

Version bumps 2.3.0 -> 2.3.1 to satisfy the new gate.
This commit is contained in:
Cathryn Lavery
2026-08-12 20:34:32 -07:00
parent 840f944f08
commit b6cae1e2e6
9 changed files with 655 additions and 23 deletions
+20
View File
@@ -0,0 +1,20 @@
{
"name": "diagram-design",
"interface": {
"displayName": "Diagram Design"
},
"plugins": [
{
"name": "diagram-design",
"source": {
"source": "local",
"path": "./"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "Productivity"
}
]
}
+5 -1
View File
@@ -1,7 +1,11 @@
{
"name": "diagram-design",
"description": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, or DP security matrix diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.",
"version": "2.3.0",
"version": "2.3.1",
"author": {
"name": "Cathryn Lavery",
"url": "https://github.com/cathrynlavery"
},
"homepage": "https://github.com/cathrynlavery/diagram-design",
"repository": "https://github.com/cathrynlavery/diagram-design",
"license": "MIT",
+1 -2
View File
@@ -1,7 +1,7 @@
{
"name": "diagram-design",
"description": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, or DP security matrix diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.",
"version": "2.3.0",
"version": "2.3.1",
"author": {
"name": "Cathryn Lavery",
"url": "https://github.com/cathrynlavery"
@@ -37,7 +37,6 @@
"defaultPrompt": [
"Make an architecture diagram of my app.",
"Create a flowchart for this decision process.",
"Build a sequence diagram for this workflow.",
"Redraw this Mermaid diagram at the right size and detail level."
],
"brandColor": "#b5523a"
+37
View File
@@ -14,6 +14,43 @@ env:
PYTHONUTF8: "1"
jobs:
plugin-package:
name: Plugin Package & Version Gate
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository history
uses: actions/checkout@v4
env:
GIT_CONFIG_COUNT: "1"
GIT_CONFIG_KEY_0: "core.autocrlf"
GIT_CONFIG_VALUE_0: "false"
with:
fetch-depth: 0
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Test plugin package tooling
run: python3 scripts/test-plugin-package.py
- name: Require a synchronized plugin version bump
env:
BASE_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
run: python3 scripts/verify-plugin-package.py "$BASE_REF"
- name: Validate Claude marketplace package without warnings
run: npx --yes @anthropic-ai/claude-code@2.1.229 plugin validate . --strict
validate:
name: Lint & Verify (${{ matrix.os }}, Python ${{ matrix.python-version }})
strategy:
+18 -1
View File
@@ -27,8 +27,21 @@ See [README.md](README.md) for the full picture, including the design system and
Every validation gate below must pass before a PR is ready. They also run automatically as GitHub Actions CI (`.github/workflows/ci.yml`).
Every PR changes the distributed plugin package, including documentation- and CI-only PRs. Increment both native manifests together before opening or updating a PR:
```bash
python3 scripts/bump-plugin-version.py # patch (default)
python3 scripts/bump-plugin-version.py --minor # minor release
python3 scripts/bump-plugin-version.py --major # major release
```
The helper refuses to run if the Claude and Codex versions already differ. If another release lands on `main` first, rebase and bump again so your version remains greater than the new base.
| What it checks | Command |
|---|---|
| Plugin bump helper and adversarial package cases | `python3 scripts/test-plugin-package.py` |
| Synchronized, increasing versions; valid marketplace paths; packaged skill | `python3 scripts/verify-plugin-package.py origin/main` |
| Claude marketplace and plugin schema, with warnings treated as errors | `claude plugin validate . --strict` |
| Accessible SVG contract (unit tests for the a11y linter) | `python3 scripts/test-lint-a11y.py` |
| Semantic-pattern routing | `python3 scripts/verify-semantic-motion.py --markdown-only` |
| Animated-example structure and accessibility | `python3 scripts/verify-semantic-motion.py --example-only` |
@@ -48,7 +61,10 @@ The semantic-pattern gate also caps `skills/diagram-design/SKILL.md` at 40,000 b
Run them all at once before pushing:
```bash
python3 scripts/test-lint-a11y.py \
python3 scripts/test-plugin-package.py \
&& python3 scripts/verify-plugin-package.py origin/main \
&& claude plugin validate . --strict \
&& python3 scripts/test-lint-a11y.py \
&& python3 scripts/verify-semantic-motion.py --markdown-only \
&& python3 scripts/verify-semantic-motion.py --example-only \
&& python3 scripts/verify-motion.py --shipped \
@@ -63,6 +79,7 @@ python3 scripts/test-lint-a11y.py \
### If a gate fails
- **`verify-plugin-package.py`:** run the bump helper if the versions did not increase. If packaging validation fails, keep both marketplaces pointed at the repository root and keep the shared skill at `skills/diagram-design/SKILL.md`.
- **`lint-skin.py`:** the failure message names the file, line, and category (`color`, `font-family`, `a11y`, `external-asset`, `pure-black`, `script`). Colors must come from the palette in `skills/diagram-design/references/style-guide.md`; fonts from the allowed list; diagrams must satisfy the accessible SVG contract (see below). The linter also requires the SHA-pinned controller from `template-motion.html` verbatim and rejects remote resources, CSS `@import`, non-fragment CSS `url()`, event handlers, `srcdoc`, executable URLs, and extra scripts.
- **`verify-*.py`:** the extractor's real behavior no longer matches its fixture or the documentation, or the reference/command/prompt wiring drifted. Fix the source of truth — do not widen a test to avoid a failure.
- **Icon assets:** you changed `scripts/vendor/icons/` or `scripts/build-icons.py` and the generated files went stale. Rerun `python3 scripts/build-icons.py` and commit the regenerated files.
+33 -19
View File
@@ -89,28 +89,35 @@ All 27 visual types ship in three static variants: minimal light, minimal dark,
## Install
**Claude Code:**
```text
/plugin marketplace add cathrynlavery/diagram-design
/plugin install diagram-design@diagram-design
```
Then enable updates once: run `/plugin`, open **Marketplaces**, select **diagram-design**, and choose **Enable auto-update**. Claude Code disables auto-update by default for third-party marketplaces; after this toggle, it refreshes the marketplace and installed plugin in the background after startup. Run `/reload-plugins` when prompted, or let the next session load the update.
**Codex:**
```bash
codex plugin marketplace add cathrynlavery/diagram-design
codex plugin add diagram-design@diagram-design
```
Codex refreshes configured Git marketplaces at startup. To fetch immediately, run `codex plugin marketplace upgrade diagram-design` and start a new session.
**Claude Cowork (organization marketplace):** Organization GitHub marketplaces currently require a private or internal repository, so first mirror this public repository into one owned by your organization. In **Organization settings → Plugins**, choose **Add plugin → GitHub**, connect that mirror, and enable **Sync automatically** from the marketplace menu. Automatic sync runs when a pull request containing a plugin version bump is merged to the mirror's default branch; direct pushes do not trigger the webhook. Install Diagram Design from the resulting organization marketplace.
**Pi:**
```bash
pi install https://github.com/cathrynlavery/diagram-design
```
Run `/reload` in an open Pi session. Pi makes the skill available for matching diagram requests; use `/skill:diagram-design` to invoke it explicitly. Pi also loads the `/export-diagram` prompt template.
Run `/reload` in an open Pi session. Pi makes the skill available for matching diagram requests; use `/skill:diagram-design` to invoke it explicitly. Pi also loads the `/export-diagram` prompt template. The unpinned Git install is intentional: Pi has no automatic package refresh, so run `pi update --extensions` to pull merged updates.
**Claude Code:**
```
/plugin marketplace add cathrynlavery/diagram-design
/plugin install diagram-design@diagram-design
```
**Claude Cowork:** Customize → Directory → Plugins → **+** → paste `cathrynlavery/diagram-design` → Sync, then install from the Personal list.
**Codex:**
```
npx skills add https://github.com/cathrynlavery/diagram-design --skill diagram-design
```
> **One-time migration:** an existing standalone `npx skills add` copy will not start following the Codex marketplace automatically. Remove that standalone copy, then use the Codex marketplace commands above. Likewise, uninstall a personal Cowork copy and reinstall Diagram Design from your organization's marketplace. Future marketplace version bumps then flow through each client's native update path.
### Editable install
@@ -306,6 +313,9 @@ Progressive disclosure. `SKILL.md` routes behavior first when needed, then layou
```
diagram-design/
├── .agents/plugins/marketplace.json — Codex marketplace catalog
├── .claude-plugin/ — Claude marketplace + plugin manifest
├── .codex-plugin/ — Codex plugin manifest
├── commands/
│ ├── export-diagram.md — Claude Code export command
│ ├── import-drawio.md — Claude Code draw.io import command
@@ -356,10 +366,14 @@ diagram-design/
│ ├── example-import-mermaid.html
│ ├── example-policy-trace-animated.html
│ └── example-sequence-oauth*.html
├── scripts/fixtures/
│ ├── sample-flowchart.mmd
│ ├── sample-readme-with-mermaid.md
── sample-adversarial.mmd
├── scripts/
│ ├── bump-plugin-version.py — synchronized Claude/Codex version bump
│ ├── verify-plugin-package.py — version + marketplace package gate
── test-plugin-package.py — adversarial package-gate tests
│ └── fixtures/
│ ├── sample-flowchart.mmd
│ ├── sample-readme-with-mermaid.md
│ └── sample-adversarial.mmd
├── docs/adr/ — short records of settled design decisions
└── docs/screenshots/ — images used in this README
```
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""Increment the synchronized Claude and Codex plugin manifest versions."""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parent.parent
MANIFEST_PATHS = (
Path(".claude-plugin/plugin.json"),
Path(".codex-plugin/plugin.json"),
)
SEMVER = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$")
class PackageVersionError(ValueError):
"""Raised when the package versions cannot be bumped safely."""
def read_manifest(path: Path) -> dict[str, Any]:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise PackageVersionError(f"could not read {path}: {exc}") from exc
if not isinstance(payload, dict):
raise PackageVersionError(f"{path} must contain a JSON object")
return payload
def parse_version(value: object, label: str) -> tuple[int, int, int]:
if not isinstance(value, str) or (match := SEMVER.fullmatch(value)) is None:
raise PackageVersionError(
f"{label} version must be strict MAJOR.MINOR.PATCH semver; got {value!r}"
)
return tuple(int(part) for part in match.groups()) # type: ignore[return-value]
def bump(root: Path, part: str = "patch") -> str:
manifests = [(relative, read_manifest(root / relative)) for relative in MANIFEST_PATHS]
raw_versions = [payload.get("version") for _, payload in manifests]
if any(version != raw_versions[0] for version in raw_versions[1:]):
rendered = ", ".join(
f"{relative}={version!r}"
for (relative, _), version in zip(manifests, raw_versions, strict=True)
)
raise PackageVersionError(f"manifest versions are not synchronized: {rendered}")
major, minor, patch = parse_version(raw_versions[0], str(MANIFEST_PATHS[0]))
if part == "major":
next_version = (major + 1, 0, 0)
elif part == "minor":
next_version = (major, minor + 1, 0)
elif part == "patch":
next_version = (major, minor, patch + 1)
else:
raise PackageVersionError(f"unsupported version part: {part}")
version = ".".join(str(value) for value in next_version)
rendered_manifests: list[tuple[Path, str]] = []
for relative, payload in manifests:
payload["version"] = version
rendered_manifests.append(
(root / relative, json.dumps(payload, indent=2, ensure_ascii=False) + "\n")
)
for path, contents in rendered_manifests:
path.write_text(contents, encoding="utf-8")
return version
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Increment both plugin manifests (patch by default)."
)
group = parser.add_mutually_exclusive_group()
group.add_argument("--major", action="store_true", help="increment the major version")
group.add_argument("--minor", action="store_true", help="increment the minor version")
return parser.parse_args()
def main() -> int:
args = parse_args()
part = "major" if args.major else "minor" if args.minor else "patch"
try:
version = bump(ROOT, part)
except PackageVersionError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
print(f"Updated Claude and Codex plugin manifests to {version}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+201
View File
@@ -0,0 +1,201 @@
#!/usr/bin/env python3
"""Regression tests for plugin versioning and marketplace package verification."""
from __future__ import annotations
import importlib.util
import json
import subprocess
import sys
import tempfile
from contextlib import contextmanager
from pathlib import Path
from types import ModuleType
from typing import Iterator
ROOT = Path(__file__).resolve().parent.parent
VERIFY_SCRIPT = ROOT / "scripts/verify-plugin-package.py"
BUMP_SCRIPT = ROOT / "scripts/bump-plugin-version.py"
PLUGIN_NAME = "diagram-design"
def load_module(name: str, path: Path) -> ModuleType:
sys.dont_write_bytecode = True
spec = importlib.util.spec_from_file_location(name, path)
if spec is None or spec.loader is None:
raise AssertionError(f"could not load {path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
VERIFY = load_module("verify_plugin_package", VERIFY_SCRIPT)
BUMP = load_module("bump_plugin_version", BUMP_SCRIPT)
def write_json(path: Path, payload: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
def manifest(version: str, codex: bool = False) -> dict:
payload = {
"name": PLUGIN_NAME,
"description": "Create editorial diagrams.",
"version": version,
"author": {"name": "Cathryn Lavery"},
}
if codex:
payload["skills"] = "./skills/"
return payload
def seed_package(root: Path, version: str = "1.2.3") -> None:
write_json(root / ".claude-plugin/plugin.json", manifest(version))
write_json(root / ".codex-plugin/plugin.json", manifest(version, codex=True))
write_json(
root / ".claude-plugin/marketplace.json",
{
"name": PLUGIN_NAME,
"plugins": [{"name": PLUGIN_NAME, "source": "./"}],
},
)
write_json(
root / ".agents/plugins/marketplace.json",
{
"name": PLUGIN_NAME,
"plugins": [
{
"name": PLUGIN_NAME,
"source": {"source": "local", "path": "./"},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL",
},
"category": "Productivity",
}
],
},
)
skill = root / "skills" / PLUGIN_NAME / "SKILL.md"
skill.parent.mkdir(parents=True, exist_ok=True)
skill.write_text(f"---\nname: {PLUGIN_NAME}\n---\n", encoding="utf-8")
@contextmanager
def package_repo() -> Iterator[Path]:
with tempfile.TemporaryDirectory() as scratch:
root = Path(scratch)
seed_package(root)
subprocess.run(["git", "init", "-q"], cwd=root, check=True)
subprocess.run(["git", "config", "user.name", "Package Test"], cwd=root, check=True)
subprocess.run(
["git", "config", "user.email", "package-test@example.invalid"],
cwd=root,
check=True,
)
subprocess.run(["git", "add", "."], cwd=root, check=True)
subprocess.run(["git", "commit", "-qm", "base package"], cwd=root, check=True)
yield root
def set_versions(root: Path, claude: str, codex: str) -> None:
for relative, version in (
(Path(".claude-plugin/plugin.json"), claude),
(Path(".codex-plugin/plugin.json"), codex),
):
payload = json.loads((root / relative).read_text(encoding="utf-8"))
payload["version"] = version
write_json(root / relative, payload)
def expect_failure(label: str, errors: list[str], needle: str) -> None:
if not any(needle in error for error in errors):
raise AssertionError(f"{label}: expected {needle!r}, got {errors}")
print(f"OK: {label} rejected")
def test_verifier() -> None:
with package_repo() as root:
set_versions(root, "1.2.4", "1.2.4")
errors = VERIFY.verify_package(root, "HEAD")
if errors:
raise AssertionError(f"valid bump failed: {errors}")
print("OK: valid synchronized bump accepted")
with package_repo() as root:
expect_failure(
"missing bump",
VERIFY.verify_package(root, "HEAD"),
"must increase",
)
with package_repo() as root:
set_versions(root, "1.2.4", "1.2.5")
expect_failure(
"mismatched manifests",
VERIFY.verify_package(root, "HEAD"),
"versions must match",
)
with package_repo() as root:
set_versions(root, "1.2", "1.2")
expect_failure(
"malformed versions",
VERIFY.verify_package(root, "HEAD"),
"strict MAJOR.MINOR.PATCH",
)
with package_repo() as root:
set_versions(root, "1.2.4", "1.2.4")
marketplace_path = root / ".agents/plugins/marketplace.json"
marketplace = json.loads(marketplace_path.read_text(encoding="utf-8"))
marketplace["plugins"][0]["source"]["path"] = "./missing"
write_json(marketplace_path, marketplace)
expect_failure(
"missing marketplace target",
VERIFY.verify_package(root, "HEAD"),
"target does not exist",
)
def test_bumper() -> None:
cases = (("patch", "1.2.4"), ("minor", "1.3.0"), ("major", "2.0.0"))
for part, expected in cases:
with tempfile.TemporaryDirectory() as scratch:
root = Path(scratch)
seed_package(root)
actual = BUMP.bump(root, part)
versions = {
json.loads((root / relative).read_text(encoding="utf-8"))["version"]
for relative in BUMP.MANIFEST_PATHS
}
if actual != expected or versions != {expected}:
raise AssertionError(
f"{part} bump: expected {expected}, got {actual} and {versions}"
)
print(f"OK: {part} bump produced {expected}")
with tempfile.TemporaryDirectory() as scratch:
root = Path(scratch)
seed_package(root)
set_versions(root, "1.2.3", "1.2.4")
try:
BUMP.bump(root)
except BUMP.PackageVersionError as exc:
if "not synchronized" not in str(exc):
raise AssertionError(f"unexpected mismatch error: {exc}") from exc
else:
raise AssertionError("version bumper accepted mismatched manifests")
print("OK: version bumper rejects mismatched manifests")
def main() -> int:
test_verifier()
test_bumper()
print("All plugin package tests passed")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+242
View File
@@ -0,0 +1,242 @@
#!/usr/bin/env python3
"""Verify synchronized version bumps and native marketplace packaging."""
from __future__ import annotations
import json
import re
import subprocess
import sys
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parent.parent
PLUGIN_NAME = "diagram-design"
MANIFEST_PATHS = {
"Claude": Path(".claude-plugin/plugin.json"),
"Codex": Path(".codex-plugin/plugin.json"),
}
CLAUDE_MARKETPLACE = Path(".claude-plugin/marketplace.json")
CODEX_MARKETPLACE = Path(".agents/plugins/marketplace.json")
SEMVER = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$")
def load_json(path: Path, errors: list[str]) -> dict[str, Any] | None:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except OSError as exc:
errors.append(f"could not read {path}: {exc}")
return None
except json.JSONDecodeError as exc:
errors.append(f"{path} is not valid JSON: {exc}")
return None
if not isinstance(payload, dict):
errors.append(f"{path} must contain a JSON object")
return None
return payload
def load_base_json(root: Path, base_ref: str, relative: Path, errors: list[str]) -> dict[str, Any] | None:
result = subprocess.run(
["git", "show", f"{base_ref}:{relative.as_posix()}"],
cwd=root,
check=False,
capture_output=True,
text=True,
)
if result.returncode != 0:
detail = result.stderr.strip() or "file not found"
errors.append(f"could not read {relative} from {base_ref}: {detail}")
return None
try:
payload = json.loads(result.stdout)
except json.JSONDecodeError as exc:
errors.append(f"{relative} at {base_ref} is not valid JSON: {exc}")
return None
if not isinstance(payload, dict):
errors.append(f"{relative} at {base_ref} must contain a JSON object")
return None
return payload
def parse_semver(value: object, label: str, errors: list[str]) -> tuple[int, int, int] | None:
if not isinstance(value, str) or (match := SEMVER.fullmatch(value)) is None:
errors.append(
f"{label} version must be strict MAJOR.MINOR.PATCH semver; got {value!r}"
)
return None
return tuple(int(part) for part in match.groups()) # type: ignore[return-value]
def resolve_local_path(root: Path, raw_path: object, label: str, errors: list[str]) -> Path | None:
if not isinstance(raw_path, str) or not raw_path:
errors.append(f"{label} must be a non-empty local path string")
return None
if raw_path not in {".", "./"} and not raw_path.startswith("./"):
errors.append(f"{label} must be '.' or start with './'; got {raw_path!r}")
return None
relative = raw_path[2:] if raw_path.startswith("./") else ""
relative_path = Path(relative)
if relative_path.is_absolute() or ".." in relative_path.parts:
errors.append(f"{label} must stay inside the marketplace root; got {raw_path!r}")
return None
resolved_root = root.resolve()
resolved = (root / relative_path).resolve()
if not resolved.is_relative_to(resolved_root):
errors.append(f"{label} resolves outside the marketplace root: {raw_path!r}")
return None
if not resolved.is_dir():
errors.append(f"{label} target does not exist or is not a directory: {raw_path!r}")
return None
return resolved
def find_plugin_entry(
marketplace: dict[str, Any], label: str, errors: list[str]
) -> dict[str, Any] | None:
if marketplace.get("name") != PLUGIN_NAME:
errors.append(
f"{label} marketplace name must remain {PLUGIN_NAME!r}; "
f"got {marketplace.get('name')!r}"
)
plugins = marketplace.get("plugins")
if not isinstance(plugins, list):
errors.append(f"{label} marketplace plugins must be an array")
return None
matches = [entry for entry in plugins if isinstance(entry, dict) and entry.get("name") == PLUGIN_NAME]
if len(matches) != 1:
errors.append(
f"{label} marketplace must contain exactly one {PLUGIN_NAME!r} entry; "
f"found {len(matches)}"
)
return None
return matches[0]
def verify_versions(
root: Path,
base_ref: str,
manifests: dict[str, dict[str, Any]],
errors: list[str],
) -> None:
current_versions = {label: payload.get("version") for label, payload in manifests.items()}
version_values = list(current_versions.values())
if any(version != version_values[0] for version in version_values[1:]):
rendered = ", ".join(f"{label}={value!r}" for label, value in current_versions.items())
errors.append(f"Claude and Codex manifest versions must match: {rendered}")
for label, relative in MANIFEST_PATHS.items():
current = parse_semver(current_versions.get(label), f"current {label}", errors)
base_manifest = load_base_json(root, base_ref, relative, errors)
if base_manifest is None:
continue
base = parse_semver(base_manifest.get("version"), f"{label} at {base_ref}", errors)
if current is not None and base is not None and current <= base:
errors.append(
f"{label} manifest version must increase relative to {base_ref}: "
f"{base_manifest.get('version')} -> {current_versions.get(label)}"
)
def verify_manifest_identity(manifests: dict[str, dict[str, Any]], errors: list[str]) -> None:
for label, payload in manifests.items():
if payload.get("name") != PLUGIN_NAME:
errors.append(
f"{label} manifest name must remain {PLUGIN_NAME!r}; got {payload.get('name')!r}"
)
def verify_marketplaces(root: Path, errors: list[str]) -> None:
claude_marketplace = load_json(root / CLAUDE_MARKETPLACE, errors)
codex_marketplace = load_json(root / CODEX_MARKETPLACE, errors)
if claude_marketplace is None or codex_marketplace is None:
return
claude_entry = find_plugin_entry(claude_marketplace, "Claude", errors)
codex_entry = find_plugin_entry(codex_marketplace, "Codex", errors)
if claude_entry is None or codex_entry is None:
return
claude_root = resolve_local_path(root, claude_entry.get("source"), "Claude plugin source", errors)
codex_source = codex_entry.get("source")
if not isinstance(codex_source, dict) or codex_source.get("source") != "local":
errors.append("Codex plugin source must be an object with source='local'")
codex_root = None
else:
codex_root = resolve_local_path(root, codex_source.get("path"), "Codex plugin source.path", errors)
policy = codex_entry.get("policy")
if not isinstance(policy, dict):
errors.append("Codex marketplace entry must include a policy object")
else:
if policy.get("installation") != "AVAILABLE":
errors.append("Codex policy.installation must be 'AVAILABLE'")
if policy.get("authentication") != "ON_INSTALL":
errors.append("Codex policy.authentication must be 'ON_INSTALL'")
if not isinstance(codex_entry.get("category"), str) or not codex_entry.get("category"):
errors.append("Codex marketplace entry must include a category")
if claude_root is not None and not (claude_root / MANIFEST_PATHS["Claude"]).is_file():
errors.append("Claude marketplace target does not contain .claude-plugin/plugin.json")
if codex_root is not None and not (codex_root / MANIFEST_PATHS["Codex"]).is_file():
errors.append("Codex marketplace target does not contain .codex-plugin/plugin.json")
if claude_root is not None and codex_root is not None and claude_root != codex_root:
errors.append("Claude and Codex marketplaces must package the same plugin root")
plugin_root = codex_root or claude_root
if plugin_root is None:
return
skill = plugin_root / "skills" / PLUGIN_NAME / "SKILL.md"
if not skill.is_file():
errors.append(f"packaged skill is missing: {skill.relative_to(root)}")
def verify_codex_skill_path(root: Path, codex_manifest: dict[str, Any], errors: list[str]) -> None:
skills_root = resolve_local_path(root, codex_manifest.get("skills"), "Codex manifest skills", errors)
if skills_root is None:
return
skill = skills_root / PLUGIN_NAME / "SKILL.md"
if not skill.is_file():
errors.append(f"Codex skills path does not contain {PLUGIN_NAME}/SKILL.md")
def verify_package(root: Path, base_ref: str) -> list[str]:
errors: list[str] = []
manifests: dict[str, dict[str, Any]] = {}
for label, relative in MANIFEST_PATHS.items():
payload = load_json(root / relative, errors)
if payload is not None:
manifests[label] = payload
if len(manifests) == len(MANIFEST_PATHS):
verify_versions(root, base_ref, manifests, errors)
verify_manifest_identity(manifests, errors)
verify_codex_skill_path(root, manifests["Codex"], errors)
verify_marketplaces(root, errors)
return errors
def main() -> int:
if len(sys.argv) != 2:
print(f"Usage: {Path(sys.argv[0]).name} <base-ref>", file=sys.stderr)
return 2
base_ref = sys.argv[1]
errors = verify_package(ROOT, base_ref)
if errors:
print("FAIL plugin package")
for error in errors:
print(f" - {error}")
return 1
versions = load_json(ROOT / MANIFEST_PATHS["Claude"], [])["version"]
print(
f"OK plugin package: Claude and Codex {versions}, "
f"marketplace paths, and packaged skill"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())