98 lines
3.9 KiB
Python
98 lines
3.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Build the self-contained Claude skills without runtime data or secrets.
|
|
|
|
The skills live unpacked in packages/claude/dist/<engine>/ (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/<engine>/
|
|
python tools/claude/build_claude_packages.py --zip /tmp/x # also /tmp/x/zpwiki-<engine>.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/<engine>/ (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/<engine>/; with archive, also archive/zpwiki-<engine>.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-<engine>.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()
|