#!/usr/bin/env python3 """Build the self-contained Claude skills without runtime data or secrets. The skills live unpacked in packages/claude/dist// (SKILL.md, scripts, manifest); no archive is kept in the repository. A reproducible ZIP for upload to claude.ai is made only on request: python tools/claude/build_claude_packages.py # refresh dist// python tools/claude/build_claude_packages.py --zip /tmp/x # also /tmp/x/zpwiki-.zip """ 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: dist// (SKILL.md, scripts, manifest). SOURCES = BASE / "dist" ENGINES = ("rag", "graphrag") PACKAGE_VERSION = "2.0.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, archive: Path | None = None) -> dict: """Write output//; with archive, also archive/zpwiki-.zip.""" 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) # 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) result = {"name": name, "folder": name, "manifest_sha256": hashlib.sha256(files["manifest.json"]).hexdigest()} if archive is None: return result archive.mkdir(parents=True, exist_ok=True) destination = archive / ("zpwiki-" + name + ".zip") with zipfile.ZipFile(destination, "w", compression=zipfile.ZIP_DEFLATED) as bundle: 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 bundle.writestr(info, data) return {**result, "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") parser.add_argument("--zip", type=Path, help="also write zpwiki-.zip for upload into this folder") args = parser.parse_args() packages = [build_package(engine, args.output, args.zip) for engine in ENGINES] (args.output / "packages.json").write_bytes( json_bytes({"schema_version": 2, "packages": [{k: p[k] for k in ("name", "folder", "manifest_sha256")} for p in packages]}) ) print(json.dumps(packages, ensure_ascii=False)) if __name__ == "__main__": main()