| 1 | #!/usr/bin/env python3 |
| 2 | """Packs each Claude Code plugin under plugins/ into dist/<name>-<version>.zip. |
| 3 | |
| 4 | The zip has the plugin's files at its root (.claude-plugin/plugin.json first), which is what |
| 5 | claude.ai's Customize > Plugins > Add > Upload plugin and `claude --plugin-dir <zip>` take. |
| 6 | Tests and the Codex-only parts stay out. The output is byte-for-byte reproducible: sorted |
| 7 | entries, a fixed timestamp and fixed permissions, so a rebuild of the same sources is the |
| 8 | same file (Diffinery serves a copy of these zips; see apps/diffinery/claude-plugins/README.md). |
| 9 | |
| 10 | python3 scripts/pack-plugins.py # every plugin |
| 11 | python3 scripts/pack-plugins.py diffinery-ci |
| 12 | """ |
| 13 | import json |
| 14 | import pathlib |
| 15 | import sys |
| 16 | import zipfile |
| 17 | |
| 18 | ROOT = pathlib.Path(__file__).resolve().parent.parent |
| 19 | SKIP_DIRS = {"tests", ".codex-plugin", "codex-skills", "node_modules"} |
| 20 | # Codex reads its own MCP config: Codex substitutes no ${user_config.*} values. |
| 21 | SKIP_FILES = {"codex.mcp.json"} |
| 22 | STAMP = (2026, 1, 1, 0, 0, 0) |
| 23 | |
| 24 | |
| 25 | def files(plugin: pathlib.Path): |
| 26 | out = [] |
| 27 | for p in plugin.rglob("*"): |
| 28 | rel = p.relative_to(plugin) |
| 29 | if not p.is_file() or any(part in SKIP_DIRS for part in rel.parts) or rel.as_posix() in SKIP_FILES: |
| 30 | continue |
| 31 | # The engine lays generated type files beside a mod it loads from a working folder. |
| 32 | if rel.parts[:2] == (".claude-plugin", "types"): |
| 33 | continue |
| 34 | out.append(rel) |
| 35 | manifest = pathlib.Path(".claude-plugin/plugin.json") |
| 36 | return sorted(out, key=lambda r: (r != manifest, r.as_posix())) |
| 37 | |
| 38 | |
| 39 | def pack(plugin: pathlib.Path) -> pathlib.Path: |
| 40 | meta = json.loads((plugin / ".claude-plugin/plugin.json").read_text()) |
| 41 | dest = ROOT / "dist" / f"{meta['name']}-{meta['version']}.zip" |
| 42 | dest.parent.mkdir(exist_ok=True) |
| 43 | with zipfile.ZipFile(dest, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as z: |
| 44 | for rel in files(plugin): |
| 45 | info = zipfile.ZipInfo(rel.as_posix(), STAMP) |
| 46 | info.external_attr = 0o644 << 16 |
| 47 | info.compress_type = zipfile.ZIP_DEFLATED |
| 48 | z.writestr(info, (plugin / rel).read_bytes()) |
| 49 | return dest |
| 50 | |
| 51 | |
| 52 | def main() -> None: |
| 53 | names = sys.argv[1:] or sorted(p.name for p in (ROOT / "plugins").iterdir() if (p / ".claude-plugin/plugin.json").is_file()) |
| 54 | for name in names: |
| 55 | print(pack(ROOT / "plugins" / name).relative_to(ROOT)) |
| 56 | |
| 57 | |
| 58 | if __name__ == "__main__": |
| 59 | main() |