#!/usr/bin/env python3 """Bump version across all CLI manifests under a plugin directory. Iterates /.{claude,codex,opencode}-plugin/plugin.json and writes the new version. Designed for `make bump-version PLUGIN=` — see ../Makefile. Usage: bump_plugin_version.py --plugin [--level patch|minor|major] bump_plugin_version.py --plugin --version """ from __future__ import annotations import argparse import json import sys from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent MANIFEST_RELS = ( ".claude-plugin/plugin.json", ".codex-plugin/plugin.json", ".opencode/plugin.json", ) def parse_version(v: str) -> tuple[int, int, int]: parts = v.split(".") if len(parts) != 3 or not all(p.isdigit() for p in parts): raise ValueError(f"Invalid semver (need x.y.z all-digits): {v}") return tuple(int(p) for p in parts) # type: ignore[return-value] def bump(v: tuple[int, int, int], level: str) -> tuple[int, int, int]: major, minor, patch = v if level == "major": return (major + 1, 0, 0) if level == "minor": return (major, minor + 1, 0) if level == "patch": return (major, minor, patch + 1) raise ValueError(f"Unknown level: {level}") def fmt(v: tuple[int, int, int]) -> str: return ".".join(str(x) for x in v) def main(argv: list[str]) -> int: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("--plugin", required=True, help="plugin directory name (e.g. devloop)") parser.add_argument( "--level", default="patch", choices=["patch", "minor", "major"], help="bump level when --version not given (default: patch)", ) parser.add_argument("--version", default=None, help="explicit semver, overrides --level") args = parser.parse_args(argv) plugin_dir = REPO_ROOT / args.plugin if not plugin_dir.is_dir(): print(f"ERROR: plugin directory not found: {plugin_dir}", file=sys.stderr) return 1 manifests = [plugin_dir / rel for rel in MANIFEST_RELS if (plugin_dir / rel).exists()] if not manifests: print(f"ERROR: no plugin.json found under {plugin_dir}", file=sys.stderr) return 1 current_versions = [] for p in manifests: v = json.loads(p.read_text(encoding="utf-8")).get("version") or "0.0.0" current_versions.append(parse_version(v)) if args.version: parse_version(args.version) # validate new_version = args.version else: if len(set(current_versions)) > 1: divergent = [fmt(v) for v in current_versions] print( f"WARNING: divergent versions across manifests {divergent}; " f"using max as bump basis", file=sys.stderr, ) basis = max(current_versions) new_version = fmt(bump(basis, args.level)) print(f"Bumping plugin '{args.plugin}' → {new_version}") for p, old in zip(manifests, current_versions): data = json.loads(p.read_text(encoding="utf-8")) data["version"] = new_version p.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") print(f" {p.relative_to(REPO_ROOT)}: {fmt(old)} → {new_version}") return 0 if __name__ == "__main__": sys.exit(main(sys.argv[1:]))