diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 0000000..7d96b9e --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -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" + } + ] +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index e58963c..da44975 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -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", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index a55a1e5..9c76265 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -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" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 03ff9e5..3a2e044 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ad9f0f7..9fde82b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. diff --git a/README.md b/README.md index 0bd4ab3..f16da9d 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/scripts/bump-plugin-version.py b/scripts/bump-plugin-version.py new file mode 100755 index 0000000..99932e8 --- /dev/null +++ b/scripts/bump-plugin-version.py @@ -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()) diff --git a/scripts/test-plugin-package.py b/scripts/test-plugin-package.py new file mode 100755 index 0000000..db47e06 --- /dev/null +++ b/scripts/test-plugin-package.py @@ -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()) diff --git a/scripts/verify-plugin-package.py b/scripts/verify-plugin-package.py new file mode 100755 index 0000000..a2c1969 --- /dev/null +++ b/scripts/verify-plugin-package.py @@ -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} ", 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())