|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Request a CompleteTech certificate receipt for a skill run. |
| 3 | +
|
| 4 | +This helper is intentionally self-contained so each skill repository can carry |
| 5 | +the same classroom receipt workflow without depending on a shared package. |
| 6 | +""" |
| 7 | + |
| 8 | +from __future__ import annotations |
| 9 | + |
| 10 | +import argparse |
| 11 | +import hashlib |
| 12 | +import json |
| 13 | +import os |
| 14 | +import re |
| 15 | +import uuid |
| 16 | +from datetime import datetime, timezone |
| 17 | +from pathlib import Path |
| 18 | +from typing import Any, Optional |
| 19 | +from urllib import error, request |
| 20 | + |
| 21 | +ROOT = Path(__file__).resolve().parents[1] |
| 22 | +DEFAULT_RECEIPT_API_URL = "https://cert.complete.tech/api/skill-runs" |
| 23 | + |
| 24 | + |
| 25 | +def load_env_file(path: Path) -> None: |
| 26 | + if not path.is_file(): |
| 27 | + return |
| 28 | + for raw_line in path.read_text(encoding="utf-8").splitlines(): |
| 29 | + line = raw_line.strip() |
| 30 | + if not line or line.startswith("#") or "=" not in line: |
| 31 | + continue |
| 32 | + key, value = line.split("=", 1) |
| 33 | + key = key.strip() |
| 34 | + value = value.strip() |
| 35 | + if not key or key in os.environ: |
| 36 | + continue |
| 37 | + if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'": |
| 38 | + value = value[1:-1] |
| 39 | + os.environ[key] = value |
| 40 | + |
| 41 | + |
| 42 | +def first_present(*values: Optional[str]) -> str: |
| 43 | + for value in values: |
| 44 | + if value is not None and value.strip(): |
| 45 | + return value.strip() |
| 46 | + return "" |
| 47 | + |
| 48 | + |
| 49 | +def env_profile_prefix(profile: str) -> str: |
| 50 | + normalized = re.sub(r"[^A-Za-z0-9]+", "_", profile).strip("_").upper() |
| 51 | + return f"CT_CERT_{normalized}" |
| 52 | + |
| 53 | + |
| 54 | +def skill_metadata() -> tuple[str, str]: |
| 55 | + skill_md = ROOT / "SKILL.md" |
| 56 | + text = skill_md.read_text(encoding="utf-8") if skill_md.exists() else "" |
| 57 | + name_match = re.search(r"^name:\s*([A-Za-z0-9._-]+)\s*$", text, re.MULTILINE) |
| 58 | + version_match = re.search(r"^version:\s*([A-Za-z0-9._-]+)\s*$", text, re.MULTILINE) |
| 59 | + fallback = ROOT.name.replace("_", "-") |
| 60 | + return ( |
| 61 | + name_match.group(1) if name_match else fallback, |
| 62 | + version_match.group(1) if version_match else "1.0.0", |
| 63 | + ) |
| 64 | + |
| 65 | + |
| 66 | +def sha256_file(path: Path) -> str: |
| 67 | + digest = hashlib.sha256() |
| 68 | + with path.open("rb") as handle: |
| 69 | + for chunk in iter(lambda: handle.read(1024 * 1024), b""): |
| 70 | + digest.update(chunk) |
| 71 | + return "sha256:" + digest.hexdigest() |
| 72 | + |
| 73 | + |
| 74 | +def load_receipts(raw_values: list[str], raw_file: Optional[str]) -> list[str]: |
| 75 | + receipts = [value.strip() for value in raw_values if value and value.strip()] |
| 76 | + if not raw_file: |
| 77 | + return receipts |
| 78 | + data = json.loads(Path(raw_file).expanduser().read_text(encoding="utf-8")) |
| 79 | + if isinstance(data, list): |
| 80 | + receipts.extend(str(value).strip() for value in data if str(value).strip()) |
| 81 | + elif isinstance(data, dict): |
| 82 | + raw_receipts = data.get("prerequisite_receipts") or data.get("receipts") or [] |
| 83 | + if not isinstance(raw_receipts, list): |
| 84 | + raise SystemExit("Prerequisite receipt JSON must contain a receipts array.") |
| 85 | + receipts.extend(str(value).strip() for value in raw_receipts if str(value).strip()) |
| 86 | + else: |
| 87 | + raise SystemExit("Prerequisite receipt JSON must be an array or object.") |
| 88 | + return receipts |
| 89 | + |
| 90 | + |
| 91 | +def post_json(api_url: str, payload: dict[str, Any]) -> dict[str, Any]: |
| 92 | + body = json.dumps(payload).encode("utf-8") |
| 93 | + req = request.Request( |
| 94 | + api_url, |
| 95 | + data=body, |
| 96 | + headers={ |
| 97 | + "Accept": "application/json", |
| 98 | + "Content-Type": "application/json", |
| 99 | + "User-Agent": f"{payload['skill_id']}/receipt-helper", |
| 100 | + }, |
| 101 | + method="POST", |
| 102 | + ) |
| 103 | + try: |
| 104 | + with request.urlopen(req, timeout=30) as response: |
| 105 | + data = response.read().decode("utf-8") |
| 106 | + except error.HTTPError as exc: |
| 107 | + details = exc.read().decode("utf-8", errors="replace") |
| 108 | + raise RuntimeError(f"Receipt API rejected the request with HTTP {exc.code}: {details}") from exc |
| 109 | + except error.URLError as exc: |
| 110 | + raise RuntimeError(f"Receipt API request failed: {exc.reason}") from exc |
| 111 | + decoded = json.loads(data) |
| 112 | + if not isinstance(decoded, dict) or decoded.get("ok") is not True: |
| 113 | + raise RuntimeError(f"Receipt API returned an unsuccessful response: {decoded!r}") |
| 114 | + return decoded |
| 115 | + |
| 116 | + |
| 117 | +def parse_args() -> argparse.Namespace: |
| 118 | + default_skill_id, default_skill_version = skill_metadata() |
| 119 | + parser = argparse.ArgumentParser(description=__doc__) |
| 120 | + parser.add_argument("--receipt-api-url", default=None) |
| 121 | + parser.add_argument("--registry-profile", default=None) |
| 122 | + parser.add_argument("--class-id", default=None) |
| 123 | + parser.add_argument("--session-id", default=None) |
| 124 | + parser.add_argument("--completion-key", default=None) |
| 125 | + parser.add_argument("--completion-key-env", default=None) |
| 126 | + parser.add_argument("--skill-id", default=None) |
| 127 | + parser.add_argument("--skill-version", default=None) |
| 128 | + parser.add_argument("--run-id", default=None) |
| 129 | + parser.add_argument("--artifact", default=None, help="Optional output artifact path to hash.") |
| 130 | + parser.add_argument("--artifact-hash", default=None) |
| 131 | + parser.add_argument("--metadata", action="append", default=[], help="Metadata as key=value.") |
| 132 | + parser.add_argument("--receipt-out", default=None, help="Receipt JSON output path.") |
| 133 | + parser.add_argument("--prerequisite-receipt", action="append", default=[]) |
| 134 | + parser.add_argument("--prerequisite-receipts-file", default=None) |
| 135 | + parser.set_defaults(default_skill_id=default_skill_id, default_skill_version=default_skill_version) |
| 136 | + return parser.parse_args() |
| 137 | + |
| 138 | + |
| 139 | +def main() -> int: |
| 140 | + args = parse_args() |
| 141 | + load_env_file(ROOT / ".env") |
| 142 | + load_env_file(ROOT / ".env.local") |
| 143 | + |
| 144 | + profile: dict[str, str] = {} |
| 145 | + if args.registry_profile: |
| 146 | + prefix = env_profile_prefix(args.registry_profile) |
| 147 | + profile = { |
| 148 | + "class_id": os.environ.get(f"{prefix}_CLASS_ID", ""), |
| 149 | + "session_id": os.environ.get(f"{prefix}_SESSION_ID", ""), |
| 150 | + "completion_key": os.environ.get(f"{prefix}_COMPLETION_KEY", ""), |
| 151 | + } |
| 152 | + |
| 153 | + key_env = first_present(args.completion_key_env, os.environ.get("CT_CERT_COMPLETION_KEY_ENV"), "CT_CERT_COMPLETION_KEY") |
| 154 | + artifact_hash = first_present(args.artifact_hash) |
| 155 | + if not artifact_hash and args.artifact: |
| 156 | + artifact_hash = sha256_file(Path(args.artifact).expanduser()) |
| 157 | + |
| 158 | + metadata: dict[str, str] = { |
| 159 | + "requested_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), |
| 160 | + "repository": ROOT.name, |
| 161 | + } |
| 162 | + if args.artifact: |
| 163 | + metadata["artifact"] = Path(args.artifact).expanduser().name |
| 164 | + for entry in args.metadata: |
| 165 | + if "=" not in entry: |
| 166 | + raise SystemExit(f"--metadata must be key=value, got: {entry}") |
| 167 | + key, value = entry.split("=", 1) |
| 168 | + metadata[key.strip()] = value.strip() |
| 169 | + |
| 170 | + payload: dict[str, Any] = { |
| 171 | + "class_id": first_present(args.class_id, profile.get("class_id"), os.environ.get("CT_CERT_CLASS_ID")), |
| 172 | + "session_id": first_present(args.session_id, profile.get("session_id"), os.environ.get("CT_CERT_SESSION_ID")), |
| 173 | + "completion_key": first_present(args.completion_key, profile.get("completion_key"), os.environ.get(key_env)), |
| 174 | + "skill_id": first_present(args.skill_id, os.environ.get("CT_CERT_SKILL_ID"), args.default_skill_id), |
| 175 | + "skill_version": first_present(args.skill_version, os.environ.get("CT_CERT_SKILL_VERSION"), args.default_skill_version), |
| 176 | + "run_id": first_present(args.run_id, str(uuid.uuid4())), |
| 177 | + "metadata": metadata, |
| 178 | + } |
| 179 | + if artifact_hash: |
| 180 | + payload["artifact_hash"] = artifact_hash |
| 181 | + prerequisite_receipts = load_receipts(args.prerequisite_receipt, args.prerequisite_receipts_file) |
| 182 | + if prerequisite_receipts: |
| 183 | + payload["prerequisite_receipts"] = prerequisite_receipts |
| 184 | + |
| 185 | + missing = [key for key in ("class_id", "session_id", "completion_key", "skill_id", "run_id") if not payload[key]] |
| 186 | + if missing: |
| 187 | + raise SystemExit(f"Missing receipt field(s): {', '.join(missing)}") |
| 188 | + |
| 189 | + api_url = first_present(args.receipt_api_url, os.environ.get("CT_CERT_RECEIPT_API_URL"), DEFAULT_RECEIPT_API_URL) |
| 190 | + receipt = post_json(api_url, payload) |
| 191 | + record = { |
| 192 | + "receipt_code": str(receipt["receipt_code"]), |
| 193 | + "claim_url": str(receipt["claim_url"]), |
| 194 | + "expires_at": str(receipt["expires_at"]), |
| 195 | + "class_id": payload["class_id"], |
| 196 | + "session_id": payload["session_id"], |
| 197 | + "skill_id": payload["skill_id"], |
| 198 | + "skill_version": payload["skill_version"], |
| 199 | + "run_id": str(receipt.get("run_id") or payload["run_id"]), |
| 200 | + "artifact_hash": artifact_hash, |
| 201 | + "prerequisite_receipts_count": len(prerequisite_receipts), |
| 202 | + } |
| 203 | + |
| 204 | + receipt_out = Path(args.receipt_out).expanduser() if args.receipt_out else ROOT / "output" / f"{record['run_id']}.receipt.json" |
| 205 | + receipt_out.parent.mkdir(parents=True, exist_ok=True) |
| 206 | + receipt_out.write_text(json.dumps(record, indent=2, sort_keys=True) + "\n", encoding="utf-8") |
| 207 | + |
| 208 | + print(f"Receipt Code: {record['receipt_code']}") |
| 209 | + print(f"Claim URL: {record['claim_url']}") |
| 210 | + print(f"Receipt JSON: {receipt_out}") |
| 211 | + return 0 |
| 212 | + |
| 213 | + |
| 214 | +if __name__ == "__main__": |
| 215 | + try: |
| 216 | + raise SystemExit(main()) |
| 217 | + except RuntimeError as exc: |
| 218 | + print(f"Error: {exc}") |
| 219 | + raise SystemExit(1) |
0 commit comments