dp-zp-agent/tools/claude/build_claude_packages.py
2026-11-15 03:36:33 +01:00

88 lines
3.1 KiB
Python

#!/usr/bin/env python3
"""Build reproducible, self-contained Claude skill ZIPs without runtime data or secrets."""
from __future__ import annotations
import argparse
import hashlib
import json
from pathlib import Path
import zipfile
ROOT = Path(__file__).resolve().parents[2]
BASE = ROOT / "packages/claude"
# Skill sources live unpacked next to their archives: dist/<engine>/ holds
# exactly the files of dist/zpwiki-<engine>.zip (SKILL.md, scripts, manifest).
SOURCES = BASE / "dist"
ENGINES = ("rag", "graphrag", "crzp")
PACKAGE_VERSION = "1.13.0"
def json_bytes(value):
return (json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode()
def build_package(engine: str, output: Path) -> dict:
if engine not in ENGINES:
raise ValueError("Unknown engine")
name = engine
source = SOURCES / name
files = {
"SKILL.md": (source / "SKILL.md").read_bytes(),
"scripts/query.py": (source / "scripts/query.py").read_bytes(),
"scripts/client.py": (BASE / "client.py").read_bytes(),
}
if not files["SKILL.md"].startswith(("---\nname: " + name + "\n").encode()):
raise ValueError("Skill metadata does not match its package")
hashes = {path: hashlib.sha256(data).hexdigest() for path, data in sorted(files.items())}
files["manifest.json"] = json_bytes(
{
"schema_version": 1,
"name": name,
"version": PACKAGE_VERSION,
"engine": engine,
"entrypoint": "SKILL.md",
"minimum_python": "3.10",
"backend_required": True,
"default_mode": "verified_answer",
"files": hashes,
}
)
output.mkdir(parents=True, exist_ok=True)
# The unpacked folder is kept identical to the archive (shared client.py
# and the manifest are refreshed from their single source on every build).
folder = output / name
for path, data in files.items():
target = folder / path
target.parent.mkdir(parents=True, exist_ok=True)
if not target.exists() or target.read_bytes() != data:
target.write_bytes(data)
destination = output / ("zpwiki-" + name + ".zip")
with zipfile.ZipFile(destination, "w", compression=zipfile.ZIP_DEFLATED) as archive:
for path, data in sorted(files.items()):
info = zipfile.ZipInfo(name + "/" + path, date_time=(1980, 1, 1, 0, 0, 0))
info.compress_type = zipfile.ZIP_DEFLATED
info.create_system = 3
info.external_attr = 0o100644 << 16
archive.writestr(info, data)
return {
"name": name,
"archive": destination.name,
"sha256": hashlib.sha256(destination.read_bytes()).hexdigest(),
}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, default=BASE / "dist")
args = parser.parse_args()
packages = [build_package(engine, args.output) for engine in ENGINES]
(args.output / "packages.json").write_bytes(
json_bytes({"schema_version": 1, "packages": packages})
)
print(json.dumps(packages, ensure_ascii=False))
if __name__ == "__main__":
main()