Skip to content

Commit 33416af

Browse files
committed
Add certificate receipt support
1 parent 21ef81b commit 33416af

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_envelope_skill
3+
CT_CERT_SESSION_ID=ses_YYYYMMDD_agentic_envelope_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-envelope-skill
7+
CT_CERT_SKILL_VERSION=1.0.0
8+
9+
# Optional profile variables for --registry-profile agentic_envelope_skill
10+
CT_CERT_AGENTIC_ENVELOPE_SKILL_CLASS_ID=cls_agentic_envelope_skill
11+
CT_CERT_AGENTIC_ENVELOPE_SKILL_SESSION_ID=ses_YYYYMMDD_agentic_envelope_skill
12+
CT_CERT_AGENTIC_ENVELOPE_SKILL_COMPLETION_KEY=replace-with-shared-class-key

README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,3 +144,30 @@ This standalone skill was extracted from the envelope generator originally bundl
144144
## License
145145

146146
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`.
147+
148+
## Certificate Receipts
149+
150+
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`:
151+
152+
```bash
153+
python scripts/request_receipt.py \
154+
--class-id "cls_agentic_envelope_skill" \
155+
--session-id "ses_YYYYMMDD_agentic_envelope_skill" \
156+
--completion-key "$CT_CERT_COMPLETION_KEY"
157+
```
158+
159+
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.
160+
161+
If the skill produced a file, include it so the receipt records an artifact hash:
162+
163+
```bash
164+
python scripts/request_receipt.py --artifact output/example.pdf
165+
```
166+
167+
### Receipt Tests
168+
169+
```bash
170+
python tests/test_receipt_cli.py
171+
```
172+
173+
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
@@ -77,3 +77,11 @@ python generate_envelope.py \
7777
4. Use `--no-return-address` only when the user asks to omit the sender address.
7878
5. If packaging multiple artifacts, return a manifest with artifact path, recipient, delivery mode, approval status, and missing facts.
7979
6. Return the generated PDF path and remind the user to print at 100% scale when physical mailing is requested.
80+
81+
## Certificate Receipt Guidance
82+
83+
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.
84+
85+
Receipt requests include this skill ID: `agentic-envelope-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.
86+
87+
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)