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.
99 lines
3.4 KiB
Python
Executable File
99 lines
3.4 KiB
Python
Executable File
#!/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())
|