Skip to content

Commit 6a37586

Browse files
committed
Add certificate receipt support
1 parent 4a45175 commit 6a37586

5 files changed

Lines changed: 400 additions & 0 deletions

File tree

.env.example

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
CT_CERT_RECEIPT_API_URL=https://cert.complete.tech/api/skill-runs
2+
CT_CERT_CLASS_ID=cls_agentic_invoice_skill
3+
CT_CERT_SESSION_ID=ses_YYYYMMDD_agentic_invoice_skill
4+
CT_CERT_COMPLETION_KEY=replace-with-shared-class-key
5+
CT_CERT_COMPLETION_KEY_ENV=CT_CERT_COMPLETION_KEY
6+
CT_CERT_SKILL_ID=agentic-invoice-skill
7+
CT_CERT_SKILL_VERSION=1.0.0
8+
9+
# Optional profile variables for --registry-profile agentic_invoice_skill
10+
CT_CERT_AGENTIC_INVOICE_SKILL_CLASS_ID=cls_agentic_invoice_skill
11+
CT_CERT_AGENTIC_INVOICE_SKILL_SESSION_ID=ses_YYYYMMDD_agentic_invoice_skill
12+
CT_CERT_AGENTIC_INVOICE_SKILL_COMPLETION_KEY=replace-with-shared-class-key

README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,3 +110,30 @@ Use clear, specific, auditable line items. Separate professional services, pass-
110110
## License
111111

112112
Code, templates, and documentation are licensed under the MIT License. CompleteTech LLC names, logos, seals, and brand assets are reserved and are not licensed for reuse except to identify this project. See `LICENSE` and `BRAND_ASSETS.md`.
113+
114+
## Certificate Receipts
115+
116+
This skill can run normally without a classroom key. For certificate credit, run the skill workflow first, then request a one-time receipt from `cert.complete.tech`:
117+
118+
```bash
119+
python scripts/request_receipt.py \
120+
--class-id "cls_agentic_invoice_skill" \
121+
--session-id "ses_YYYYMMDD_agentic_invoice_skill" \
122+
--completion-key "$CT_CERT_COMPLETION_KEY"
123+
```
124+
125+
The helper sends `class_id`, `session_id`, `completion_key`, `skill_id`, `skill_version`, a generated `run_id`, optional artifact hash, and metadata to `https://cert.complete.tech/api/skill-runs`. It prints the receipt code and writes a receipt JSON file. Students use the receipt code at `https://cert.complete.tech/claim`. Do not commit real completion keys.
126+
127+
If the skill produced a file, include it so the receipt records an artifact hash:
128+
129+
```bash
130+
python scripts/request_receipt.py --artifact output/example.pdf
131+
```
132+
133+
### Receipt Tests
134+
135+
```bash
136+
python tests/test_receipt_cli.py
137+
```
138+
139+
The test uses a local fake receipt API and does not require live keys or the live `cert.complete.tech` endpoint.

SKILL.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,3 +126,11 @@ python3 scripts/render_invoice.py --template milestone-invoice \
126126
- `--no-pdf` emits Markdown only (the original behavior); `--no-cover` drops the cover page.
127127
- Already drafted the Markdown yourself? Render it directly: `python3 scripts/render_pdf.py --markdown artifact.md --out artifact.pdf --logo assets/logo.png --title "..."`.
128128
- The PDF supports a Markdown subset: `#`/`##`/`###` headings, paragraphs, `-` bullets, tables, `>` callouts, `**bold**`, and `[PAGE_BREAK]`. PDF requires `reportlab`; the optional `--png` preview requires `pypdfium2` and `pillow`. See `assets/examples/` for a rendered example.
129+
130+
## Certificate Receipt Guidance
131+
132+
The skill remains usable without a classroom key. When certificate credit is needed, use `scripts/request_receipt.py` after the skill run. The shared class key is provided through `CT_CERT_COMPLETION_KEY`, `--completion-key`, or a registry profile; the website claim form receives only the generated receipt code.
133+
134+
Receipt requests include this skill ID: `agentic-invoice-skill`. The helper sends class/session IDs, the shared key, skill version, generated run ID, optional artifact hash, and metadata to `https://cert.complete.tech/api/skill-runs`. The student claims the certificate at `https://cert.complete.tech/claim` with the returned receipt.
135+
136+
Do not print, store, or commit real classroom completion keys.

scripts/request_receipt.py

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
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

Comments
 (0)