Skip to content

Commit d1c078a

Browse files
committed
Add certificate receipt support
1 parent b369165 commit d1c078a

5 files changed

Lines changed: 422 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_services_orchestrator_skill
3+
CT_CERT_SESSION_ID=ses_YYYYMMDD_agentic_services_orchestrator_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-services-orchestrator-skill
7+
CT_CERT_SKILL_VERSION=1.0.0
8+
9+
# Optional profile variables for --registry-profile agentic_services_orchestrator_skill
10+
CT_CERT_AGENTIC_SERVICES_ORCHESTRATOR_SKILL_CLASS_ID=cls_agentic_services_orchestrator_skill
11+
CT_CERT_AGENTIC_SERVICES_ORCHESTRATOR_SKILL_SESSION_ID=ses_YYYYMMDD_agentic_services_orchestrator_skill
12+
CT_CERT_AGENTIC_SERVICES_ORCHESTRATOR_SKILL_COMPLETION_KEY=replace-with-shared-class-key

README.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,3 +111,50 @@ Use a direct, practical, low-hype tone. The orchestrator coordinates the lifecyc
111111
## License
112112

113113
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`.
114+
115+
## Certificate Receipts
116+
117+
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`:
118+
119+
```bash
120+
python scripts/request_receipt.py \
121+
--class-id "cls_agentic_services_orchestrator_skill" \
122+
--session-id "ses_YYYYMMDD_agentic_services_orchestrator_skill" \
123+
--completion-key "$CT_CERT_COMPLETION_KEY"
124+
```
125+
126+
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.
127+
128+
If the skill produced a file, include it so the receipt records an artifact hash:
129+
130+
```bash
131+
python scripts/request_receipt.py --artifact output/example.pdf
132+
```
133+
134+
### Orchestration Receipt Prerequisites
135+
136+
The orchestrator certificate requires at least three prerequisite skill receipts. Pass them with repeated flags or a JSON file:
137+
138+
```bash
139+
python scripts/request_receipt.py \
140+
--registry-profile agentic_services_orchestrator_skill \
141+
--prerequisite-receipt "CTREC-..." \
142+
--prerequisite-receipt "CTREC-..." \
143+
--prerequisite-receipt "CTREC-..."
144+
```
145+
146+
```json
147+
{
148+
"prerequisite_receipts": ["CTREC-...", "CTREC-...", "CTREC-..."]
149+
}
150+
```
151+
152+
Then pass `--prerequisite-receipts-file receipts.json`. The server enforces the three-distinct-skills rule before it returns the orchestration receipt.
153+
154+
### Receipt Tests
155+
156+
```bash
157+
python tests/test_receipt_cli.py
158+
```
159+
160+
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: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,3 +280,13 @@ python3 scripts/render_pdf.py \
280280
```
281281

282282
`scripts/render_pdf.py` applies the shared CompleteTech branding (logo, cover page, letterhead band, watermark, footer) and supports a Markdown subset: `#`/`##`/`###` headings, paragraphs, `-` bullet lists, tables, `>` callouts, `**bold**`, and `[PAGE_BREAK]`. It requires `reportlab`; the optional `--png` preview montage requires `pypdfium2` and `pillow`. See `assets/examples/` for a rendered example.
283+
284+
## Certificate Receipt Guidance
285+
286+
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.
287+
288+
Receipt requests include this skill ID: `agentic-services-orchestrator-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.
289+
290+
For `agentic-services-orchestrator-skill`, collect at least three prerequisite receipt codes and pass `--prerequisite-receipt` once per code, or use `--prerequisite-receipts-file`. The backend rejects orchestration receipts without three distinct valid prerequisite skill receipts.
291+
292+
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)