#!/usr/bin/env python3 """ Package the openproject plugin into a distributable `.plugin` file. A `.plugin` is just a zip archive of the plugin tree with `.claude-plugin/plugin.json` at the root. Timestamps are normalized and entries are sorted so builds are deterministic (identical input -> identical bytes). Usage: python3 build.py # credential-free build (no config.json) python3 build.py --bake # personal build WITH config.json (your API token!) Output: -.plugin (or --baked.plugin with --bake) """ from __future__ import annotations import argparse import json import os import zipfile ROOT = os.path.dirname(os.path.abspath(__file__)) FIXED_DATE = (2026, 1, 1, 0, 0, 0) # deterministic mtime for every entry # Directories/files never included in any build. EXCLUDE_DIRS = {".git", "__pycache__", ".claude"} EXCLUDE_FILES = {".DS_Store", ".gitignore", "build.py"} # Excluded unless --bake (carries the API token). SECRET_FILES = {"config.json"} def iter_files(bake): for dirpath, dirnames, filenames in os.walk(ROOT): dirnames[:] = sorted(d for d in dirnames if d not in EXCLUDE_DIRS) for fn in sorted(filenames): if fn in EXCLUDE_FILES: continue if fn in SECRET_FILES and not bake: continue full = os.path.join(dirpath, fn) rel = os.path.relpath(full, ROOT) if rel.endswith(".plugin"): continue # never nest a previous artifact yield full, rel def main(): ap = argparse.ArgumentParser() ap.add_argument("--bake", action="store_true", help="include config.json (your API token) in the artifact") args = ap.parse_args() with open(os.path.join(ROOT, ".claude-plugin", "plugin.json")) as fh: meta = json.load(fh) name, version = meta["name"], meta["version"] suffix = "-baked" if args.bake else "" out = os.path.join(ROOT, f"{name}-{version}{suffix}.plugin") files = sorted(iter_files(args.bake), key=lambda x: x[1]) with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as zf: for full, rel in files: with open(full, "rb") as fh: data = fh.read() info = zipfile.ZipInfo(rel.replace(os.sep, "/"), date_time=FIXED_DATE) info.compress_type = zipfile.ZIP_DEFLATED info.external_attr = 0o644 << 16 zf.writestr(info, data) size = os.path.getsize(out) print(f"Built {os.path.basename(out)} ({size:,} bytes, {len(files)} entries)") if args.bake: print(" ⚠ This artifact contains your API token — do not share it.") if __name__ == "__main__": main()