commandlayer 1.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,43 @@
1
+ """CommandLayer Python SDK."""
2
+
3
+ from .client import CommandLayerClient, create_client, normalize_command_response
4
+ from .errors import CommandLayerError
5
+ from .types import (
6
+ CanonicalReceipt,
7
+ CommandResponse,
8
+ EnsVerifyOptions,
9
+ RuntimeMetadata,
10
+ SignerKeyResolution,
11
+ VerifyOptions,
12
+ VerifyResult,
13
+ )
14
+ from .verify import (
15
+ canonicalize_stable_json_v1,
16
+ parse_ed25519_pubkey,
17
+ recompute_receipt_hash_sha256,
18
+ resolve_signer_key,
19
+ sha256_hex_utf8,
20
+ verify_receipt,
21
+ )
22
+
23
+ __all__ = [
24
+ "CanonicalReceipt",
25
+ "CommandLayerClient",
26
+ "CommandLayerError",
27
+ "CommandResponse",
28
+ "EnsVerifyOptions",
29
+ "RuntimeMetadata",
30
+ "VerifyOptions",
31
+ "SignerKeyResolution",
32
+ "VerifyResult",
33
+ "canonicalize_stable_json_v1",
34
+ "create_client",
35
+ "normalize_command_response",
36
+ "sha256_hex_utf8",
37
+ "parse_ed25519_pubkey",
38
+ "recompute_receipt_hash_sha256",
39
+ "resolve_signer_key",
40
+ "verify_receipt",
41
+ ]
42
+
43
+ __version__ = "1.1.0"
commandlayer/client.py ADDED
@@ -0,0 +1,349 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import time
5
+ from collections.abc import Mapping
6
+ from typing import Any, cast
7
+
8
+ import httpx
9
+
10
+ from .errors import CommandLayerError
11
+ from .types import CommandResponse, RuntimeMetadata, VerifyOptions
12
+ from .verify import verify_receipt
13
+
14
+ COMMONS_VERSION = "1.1.0"
15
+ PACKAGE_VERSION = "1.1.0"
16
+ DEFAULT_RUNTIME = "https://runtime.commandlayer.org"
17
+ VERBS = {
18
+ "summarize",
19
+ "analyze",
20
+ "classify",
21
+ "clean",
22
+ "convert",
23
+ "describe",
24
+ "explain",
25
+ "format",
26
+ "parse",
27
+ "fetch",
28
+ }
29
+
30
+
31
+ def _normalize_base(url: str) -> str:
32
+ return str(url or "").rstrip("/")
33
+
34
+
35
+ def normalize_command_response(payload: Any) -> CommandResponse:
36
+ if not isinstance(payload, dict):
37
+ raise CommandLayerError("Runtime response must be a JSON object", 502, payload)
38
+
39
+ if isinstance(payload.get("receipt"), dict):
40
+ response: CommandResponse = {"receipt": payload["receipt"]}
41
+ if isinstance(payload.get("runtime_metadata"), dict):
42
+ response["runtime_metadata"] = cast(RuntimeMetadata, dict(payload["runtime_metadata"]))
43
+ return response
44
+
45
+ receipt = dict(payload)
46
+ runtime_metadata = receipt.pop("trace", None)
47
+ response = {"receipt": receipt}
48
+ if isinstance(runtime_metadata, dict):
49
+ response["runtime_metadata"] = cast(RuntimeMetadata, runtime_metadata)
50
+ return response
51
+
52
+
53
+ class CommandLayerClient:
54
+ """Synchronous CommandLayer client for Protocol-Commons v1.1.0 verbs."""
55
+
56
+ def __init__(
57
+ self,
58
+ runtime: str = DEFAULT_RUNTIME,
59
+ actor: str = "sdk-user",
60
+ timeout_ms: int = 30_000,
61
+ headers: Mapping[str, str] | None = None,
62
+ retries: int = 0,
63
+ verify_receipts: bool = False,
64
+ verify: VerifyOptions | None = None,
65
+ http_client: httpx.Client | None = None,
66
+ ):
67
+ self.runtime = _normalize_base(runtime)
68
+ self.actor = actor
69
+ self.timeout_ms = timeout_ms
70
+ self.retries = max(0, retries)
71
+ self.verify_receipts = verify_receipts is True
72
+ self.verify_defaults: VerifyOptions = verify or {}
73
+ self.default_headers = {
74
+ "Content-Type": "application/json",
75
+ "User-Agent": f"commandlayer-py/{PACKAGE_VERSION}",
76
+ }
77
+ if headers:
78
+ self.default_headers.update(dict(headers))
79
+ self._http = http_client or httpx.Client(timeout=self.timeout_ms / 1000)
80
+
81
+ def _ensure_verify_config_if_enabled(self) -> None:
82
+ if not self.verify_receipts:
83
+ return
84
+ explicit_public_key = self.verify_defaults.get("public_key") or self.verify_defaults.get(
85
+ "publicKey"
86
+ )
87
+ has_explicit = bool(str(explicit_public_key or "").strip())
88
+ ens = self.verify_defaults.get("ens") or {}
89
+ has_ens = bool(ens.get("name") and (ens.get("rpcUrl") or ens.get("rpc_url")))
90
+ if not has_explicit and not has_ens:
91
+ raise CommandLayerError(
92
+ "verify_receipts is enabled but no verification key config provided. "
93
+ "Set verify.public_key (or verify.publicKey) or verify.ens {name, rpcUrl}.",
94
+ 400,
95
+ )
96
+
97
+ def summarize(
98
+ self,
99
+ *,
100
+ content: str,
101
+ style: str | None = None,
102
+ format: str | None = None,
103
+ max_tokens: int = 1000,
104
+ ) -> CommandResponse:
105
+ return self.call(
106
+ "summarize",
107
+ {
108
+ "input": {
109
+ "content": content,
110
+ "summary_style": style,
111
+ "format_hint": format,
112
+ },
113
+ "limits": {"max_output_tokens": max_tokens},
114
+ },
115
+ )
116
+
117
+ def analyze(
118
+ self,
119
+ *,
120
+ content: str,
121
+ goal: str | None = None,
122
+ hints: list[str] | None = None,
123
+ max_tokens: int = 1000,
124
+ ) -> CommandResponse:
125
+ payload: dict[str, Any] = {
126
+ "input": content,
127
+ "limits": {"max_output_tokens": max_tokens},
128
+ }
129
+ if goal:
130
+ payload["goal"] = goal
131
+ if hints:
132
+ payload["hints"] = hints
133
+ return self.call("analyze", payload)
134
+
135
+ def classify(
136
+ self, *, content: str, max_labels: int = 5, max_tokens: int = 1000
137
+ ) -> CommandResponse:
138
+ return self.call(
139
+ "classify",
140
+ {
141
+ "actor": self.actor,
142
+ "input": {"content": content},
143
+ "limits": {"max_labels": max_labels, "max_output_tokens": max_tokens},
144
+ },
145
+ )
146
+
147
+ def clean(
148
+ self,
149
+ *,
150
+ content: str,
151
+ operations: list[str] | None = None,
152
+ max_tokens: int = 1000,
153
+ ) -> CommandResponse:
154
+ return self.call(
155
+ "clean",
156
+ {
157
+ "input": {
158
+ "content": content,
159
+ "operations": operations
160
+ or ["normalize_newlines", "collapse_whitespace", "trim"],
161
+ },
162
+ "limits": {"max_output_tokens": max_tokens},
163
+ },
164
+ )
165
+
166
+ def convert(
167
+ self,
168
+ *,
169
+ content: str,
170
+ from_format: str,
171
+ to_format: str,
172
+ max_tokens: int = 1000,
173
+ ) -> CommandResponse:
174
+ return self.call(
175
+ "convert",
176
+ {
177
+ "input": {
178
+ "content": content,
179
+ "source_format": from_format,
180
+ "target_format": to_format,
181
+ },
182
+ "limits": {"max_output_tokens": max_tokens},
183
+ },
184
+ )
185
+
186
+ def describe(
187
+ self,
188
+ *,
189
+ subject: str,
190
+ audience: str = "general",
191
+ detail: str = "medium",
192
+ max_tokens: int = 1000,
193
+ ) -> CommandResponse:
194
+ return self.call(
195
+ "describe",
196
+ {
197
+ "input": {
198
+ "subject": (subject or "")[:140],
199
+ "audience": audience,
200
+ "detail_level": detail,
201
+ },
202
+ "limits": {"max_output_tokens": max_tokens},
203
+ },
204
+ )
205
+
206
+ def explain(
207
+ self,
208
+ *,
209
+ subject: str,
210
+ audience: str = "general",
211
+ style: str = "step-by-step",
212
+ detail: str = "medium",
213
+ max_tokens: int = 1000,
214
+ ) -> CommandResponse:
215
+ return self.call(
216
+ "explain",
217
+ {
218
+ "input": {
219
+ "subject": (subject or "")[:140],
220
+ "audience": audience,
221
+ "style": style,
222
+ "detail_level": detail,
223
+ },
224
+ "limits": {"max_output_tokens": max_tokens},
225
+ },
226
+ )
227
+
228
+ def format(self, *, content: str, to: str, max_tokens: int = 1000) -> CommandResponse:
229
+ return self.call(
230
+ "format",
231
+ {
232
+ "input": {"content": content, "target_style": to},
233
+ "limits": {"max_output_tokens": max_tokens},
234
+ },
235
+ )
236
+
237
+ def parse(
238
+ self,
239
+ *,
240
+ content: str,
241
+ content_type: str = "text",
242
+ mode: str = "best_effort",
243
+ target_schema: str | None = None,
244
+ max_tokens: int = 1000,
245
+ ) -> CommandResponse:
246
+ payload: dict[str, Any] = {
247
+ "input": {
248
+ "content": content,
249
+ "content_type": content_type,
250
+ "mode": mode,
251
+ },
252
+ "limits": {"max_output_tokens": max_tokens},
253
+ }
254
+ if target_schema:
255
+ payload["input"]["target_schema"] = target_schema
256
+ return self.call("parse", payload)
257
+
258
+ def fetch(
259
+ self,
260
+ *,
261
+ source: str,
262
+ query: str | None = None,
263
+ include_metadata: bool | None = None,
264
+ max_tokens: int = 1000,
265
+ ) -> CommandResponse:
266
+ input_obj: dict[str, Any] = {"source": source}
267
+ if query is not None:
268
+ input_obj["query"] = query
269
+ if include_metadata is not None:
270
+ input_obj["include_metadata"] = include_metadata
271
+ return self.call(
272
+ "fetch",
273
+ {"input": input_obj, "limits": {"max_output_tokens": max_tokens}},
274
+ )
275
+
276
+ def _build_payload(self, verb: str, body: dict[str, Any]) -> dict[str, Any]:
277
+ return {
278
+ "x402": {
279
+ "verb": verb,
280
+ "version": COMMONS_VERSION,
281
+ "entry": f"x402://{verb}agent.eth/{verb}/v{COMMONS_VERSION}",
282
+ },
283
+ "actor": body.get("actor", self.actor),
284
+ **body,
285
+ }
286
+
287
+ def _request(self, verb: str, payload: dict[str, Any]) -> httpx.Response:
288
+ url = f"{self.runtime}/{verb}/v{COMMONS_VERSION}"
289
+ attempt = 0
290
+ while True:
291
+ try:
292
+ return self._http.post(url, headers=self.default_headers, json=payload)
293
+ except httpx.TimeoutException as err:
294
+ if attempt >= self.retries:
295
+ raise CommandLayerError("Request timed out", 408) from err
296
+ except httpx.HTTPError as err:
297
+ if attempt >= self.retries:
298
+ raise CommandLayerError(f"HTTP transport error: {err}") from err
299
+ attempt += 1
300
+ time.sleep(min(0.2 * attempt, 1.0))
301
+
302
+ def call(self, verb: str, body: dict[str, Any]) -> CommandResponse:
303
+ if verb not in VERBS:
304
+ raise CommandLayerError(f"Unsupported verb: {verb}", 400)
305
+ self._ensure_verify_config_if_enabled()
306
+ payload = self._build_payload(verb, body)
307
+ response = self._request(verb, payload)
308
+
309
+ try:
310
+ data: Any = response.json()
311
+ except json.JSONDecodeError:
312
+ data = {}
313
+
314
+ if not response.is_success:
315
+ message = (
316
+ (data.get("message") if isinstance(data, dict) else None)
317
+ or (
318
+ (data.get("error") or {}).get("message")
319
+ if isinstance(data, dict) and isinstance(data.get("error"), dict)
320
+ else None
321
+ )
322
+ or f"HTTP {response.status_code}"
323
+ )
324
+ raise CommandLayerError(str(message), response.status_code, data)
325
+
326
+ normalized = normalize_command_response(data)
327
+ if self.verify_receipts:
328
+ verify_result = verify_receipt(
329
+ normalized["receipt"],
330
+ public_key=self.verify_defaults.get("public_key")
331
+ or self.verify_defaults.get("publicKey"),
332
+ ens=self.verify_defaults.get("ens"),
333
+ )
334
+ if not verify_result["ok"]:
335
+ raise CommandLayerError("Receipt verification failed", 422, verify_result)
336
+ return normalized
337
+
338
+ def close(self) -> None:
339
+ self._http.close()
340
+
341
+ def __enter__(self) -> CommandLayerClient:
342
+ return self
343
+
344
+ def __exit__(self, *args: object) -> None:
345
+ self.close()
346
+
347
+
348
+ def create_client(**kwargs: Any) -> CommandLayerClient:
349
+ return CommandLayerClient(**kwargs)
commandlayer/errors.py ADDED
@@ -0,0 +1,12 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ class CommandLayerError(Exception):
7
+ """Top-level SDK error with optional HTTP metadata."""
8
+
9
+ def __init__(self, message: str, status_code: int | None = None, details: Any = None):
10
+ super().__init__(message)
11
+ self.status_code = status_code
12
+ self.details = details
commandlayer/types.py ADDED
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Literal, TypedDict
5
+
6
+ CanonicalReceipt = dict[str, Any]
7
+
8
+
9
+ class RuntimeMetadata(TypedDict, total=False):
10
+ trace_id: str
11
+ parent_trace_id: str | None
12
+ started_at: str
13
+ completed_at: str
14
+ duration_ms: int
15
+ provider: str
16
+ runtime: str
17
+ request_id: str
18
+
19
+
20
+ class CommandResponse(TypedDict, total=False):
21
+ receipt: CanonicalReceipt
22
+ runtime_metadata: RuntimeMetadata
23
+
24
+
25
+ class EnsVerifyOptions(TypedDict, total=False):
26
+ name: str
27
+ rpc_url: str
28
+ rpcUrl: str
29
+
30
+
31
+ class VerifyOptions(TypedDict, total=False):
32
+ public_key: str
33
+ publicKey: str
34
+ ens: EnsVerifyOptions
35
+
36
+
37
+ class VerifyChecks(TypedDict):
38
+ hash_matches: bool
39
+ signature_valid: bool
40
+ receipt_id_matches: bool
41
+ alg_matches: bool
42
+ canonical_matches: bool
43
+
44
+
45
+ class VerifyValues(TypedDict):
46
+ verb: str | None
47
+ signer_id: str | None
48
+ alg: str | None
49
+ canonical: str | None
50
+ claimed_hash: str | None
51
+ recomputed_hash: str | None
52
+ receipt_id: str | None
53
+ pubkey_source: Literal["explicit", "ens"] | None
54
+ ens_txt_key: str | None
55
+
56
+
57
+ class VerifyErrors(TypedDict):
58
+ signature_error: str | None
59
+ ens_error: str | None
60
+ verify_error: str | None
61
+
62
+
63
+ class VerifyResult(TypedDict):
64
+ ok: bool
65
+ checks: VerifyChecks
66
+ values: VerifyValues
67
+ errors: VerifyErrors
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class SignerKeyResolution:
72
+ algorithm: Literal["ed25519"]
73
+ kid: str
74
+ raw_public_key_bytes: bytes
commandlayer/verify.py ADDED
@@ -0,0 +1,335 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import copy
5
+ import hashlib
6
+ import json
7
+ import re
8
+ from typing import Any, Protocol
9
+
10
+ from nacl.exceptions import BadSignatureError
11
+ from nacl.signing import VerifyKey
12
+ from web3 import Web3
13
+
14
+ from .types import (
15
+ CanonicalReceipt,
16
+ CommandResponse,
17
+ EnsVerifyOptions,
18
+ SignerKeyResolution,
19
+ VerifyResult,
20
+ )
21
+
22
+ _ED25519_PREFIX_RE = re.compile(r"^ed25519\s*[:=]\s*(.+)$", re.IGNORECASE)
23
+ _ED25519_HEX_RE = re.compile(r"^(0x)?[0-9a-fA-F]{64}$")
24
+
25
+
26
+ class EnsTextResolver(Protocol):
27
+ def get_text(self, name: str, key: str) -> str | None: ...
28
+
29
+
30
+ class Web3EnsTextResolver:
31
+ def __init__(self, rpc_url: str):
32
+ self._w3 = Web3(Web3.HTTPProvider(rpc_url))
33
+
34
+ def get_text(self, name: str, key: str) -> str | None:
35
+ if not self._w3.is_connected():
36
+ raise ValueError(f"Unable to connect to RPC: {self._w3.provider}")
37
+ ens_module = self._w3.ens # type: ignore[attr-defined]
38
+ if ens_module is None:
39
+ raise ValueError("ENS module is unavailable on this web3 instance")
40
+ value = ens_module.get_text(name, key) # type: ignore[union-attr]
41
+ if value is None:
42
+ return None
43
+ text = str(value).strip()
44
+ return text or None
45
+
46
+
47
+ def canonicalize_stable_json_v1(value: Any) -> str:
48
+ def encode(v: Any) -> str:
49
+ if v is None:
50
+ return "null"
51
+ value_type = type(v)
52
+ if value_type is str:
53
+ return json.dumps(v, ensure_ascii=False)
54
+ if value_type is bool:
55
+ return "true" if v else "false"
56
+ if value_type in (int, float):
57
+ if isinstance(v, float):
58
+ if v != v or v in (float("inf"), float("-inf")):
59
+ raise ValueError("canonicalize: non-finite number not allowed")
60
+ if v == 0.0 and str(v).startswith("-"):
61
+ return "0"
62
+ return str(v)
63
+ if value_type in (complex, bytes, bytearray):
64
+ raise ValueError(f"canonicalize: unsupported type {value_type.__name__}")
65
+ if isinstance(v, list):
66
+ return "[" + ",".join(encode(item) for item in v) + "]"
67
+ if isinstance(v, dict):
68
+ out: list[str] = []
69
+ for key in sorted(v.keys()):
70
+ val = v[key]
71
+ if val is ...:
72
+ raise ValueError(f'canonicalize: unsupported value for key "{key}"')
73
+ out.append(f"{json.dumps(str(key), ensure_ascii=False)}:{encode(val)}")
74
+ return "{" + ",".join(out) + "}"
75
+ raise ValueError(f"canonicalize: unsupported type {value_type.__name__}")
76
+
77
+ return encode(value)
78
+
79
+
80
+ def sha256_hex_utf8(text: str) -> str:
81
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
82
+
83
+
84
+ def parse_ed25519_pubkey(text: str) -> bytes:
85
+ candidate = str(text).strip()
86
+ match = _ED25519_PREFIX_RE.match(candidate)
87
+ if match:
88
+ candidate = match.group(1).strip()
89
+ if _ED25519_HEX_RE.match(candidate):
90
+ hex_part = candidate[2:] if candidate.startswith("0x") else candidate
91
+ decoded = bytes.fromhex(hex_part)
92
+ if len(decoded) != 32:
93
+ raise ValueError("invalid ed25519 pubkey length")
94
+ return decoded
95
+ try:
96
+ decoded = base64.b64decode(candidate, validate=True)
97
+ except Exception as err: # noqa: BLE001
98
+ raise ValueError("invalid base64 in ed25519 pubkey") from err
99
+ if len(decoded) != 32:
100
+ raise ValueError("invalid base64 ed25519 pubkey length (need 32 bytes)")
101
+ return decoded
102
+
103
+
104
+ def verify_ed25519_signature_over_utf8_hash_string(
105
+ hash_hex: str,
106
+ signature_b64: str,
107
+ pubkey32: bytes,
108
+ ) -> bool:
109
+ if len(pubkey32) != 32:
110
+ raise ValueError("ed25519: pubkey must be 32 bytes")
111
+ try:
112
+ signature = base64.b64decode(signature_b64, validate=True)
113
+ except Exception as err: # noqa: BLE001
114
+ raise ValueError("ed25519: signature must be valid base64") from err
115
+ if len(signature) != 64:
116
+ raise ValueError("ed25519: signature must be 64 bytes")
117
+ verify_key = VerifyKey(pubkey32)
118
+ try:
119
+ verify_key.verify(hash_hex.encode("utf-8"), signature)
120
+ return True
121
+ except BadSignatureError:
122
+ return False
123
+
124
+
125
+ def resolve_signer_key(
126
+ name: str,
127
+ rpc_url: str,
128
+ *,
129
+ resolver: EnsTextResolver | None = None,
130
+ ) -> SignerKeyResolution:
131
+ if not rpc_url:
132
+ raise ValueError("rpcUrl is required for ENS verification")
133
+ txt_resolver = resolver or Web3EnsTextResolver(rpc_url)
134
+ signer_name = txt_resolver.get_text(name, "cl.receipt.signer")
135
+ if not signer_name:
136
+ raise ValueError(f"ENS TXT cl.receipt.signer missing for agent ENS name: {name}")
137
+ pub_key_text = txt_resolver.get_text(signer_name, "cl.sig.pub")
138
+ if not pub_key_text:
139
+ raise ValueError(f"ENS TXT cl.sig.pub missing for signer ENS name: {signer_name}")
140
+ kid = txt_resolver.get_text(signer_name, "cl.sig.kid")
141
+ if not kid:
142
+ raise ValueError(f"ENS TXT cl.sig.kid missing for signer ENS name: {signer_name}")
143
+ try:
144
+ raw_public_key_bytes = parse_ed25519_pubkey(pub_key_text)
145
+ except ValueError as err:
146
+ raise ValueError(
147
+ f"ENS TXT cl.sig.pub malformed for signer ENS name: {signer_name}. {err}"
148
+ ) from err
149
+ return SignerKeyResolution(
150
+ algorithm="ed25519",
151
+ kid=kid,
152
+ raw_public_key_bytes=raw_public_key_bytes,
153
+ )
154
+
155
+
156
+ def _extract_receipt(receipt: CanonicalReceipt | CommandResponse) -> CanonicalReceipt:
157
+ if isinstance(receipt, dict) and isinstance(receipt.get("receipt"), dict):
158
+ return dict(receipt["receipt"])
159
+ return dict(receipt)
160
+
161
+
162
+ def to_unsigned_receipt(receipt: CanonicalReceipt | CommandResponse) -> CanonicalReceipt:
163
+ target = _extract_receipt(receipt)
164
+ if not isinstance(target, dict):
165
+ raise ValueError("receipt must be an object")
166
+ unsigned = copy.deepcopy(target)
167
+ metadata = unsigned.get("metadata")
168
+ if isinstance(metadata, dict):
169
+ metadata.pop("receipt_id", None)
170
+ proof = metadata.get("proof")
171
+ if isinstance(proof, dict):
172
+ unsigned_proof: dict[str, str] = {}
173
+ for key in ("alg", "canonical", "signer_id"):
174
+ value = proof.get(key)
175
+ if isinstance(value, str):
176
+ unsigned_proof[key] = value
177
+ metadata["proof"] = unsigned_proof
178
+ unsigned.pop("receipt_id", None)
179
+ return unsigned
180
+
181
+
182
+ def recompute_receipt_hash_sha256(receipt: CanonicalReceipt | CommandResponse) -> dict[str, str]:
183
+ unsigned = to_unsigned_receipt(receipt)
184
+ canonical = canonicalize_stable_json_v1(unsigned)
185
+ return {"canonical": canonical, "hash_sha256": sha256_hex_utf8(canonical)}
186
+
187
+
188
+ def _extract_rpc_url(ens: EnsVerifyOptions) -> str:
189
+ return str(ens.get("rpcUrl") or ens.get("rpc_url") or "")
190
+
191
+
192
+ def verify_receipt(
193
+ receipt: CanonicalReceipt | CommandResponse,
194
+ public_key: str | None = None,
195
+ ens: EnsVerifyOptions | None = None,
196
+ ) -> VerifyResult:
197
+ target = _extract_receipt(receipt)
198
+ try:
199
+ proof = (
200
+ ((target.get("metadata") or {}).get("proof") or {}) if isinstance(target, dict) else {}
201
+ )
202
+ claimed_hash = (
203
+ proof.get("hash_sha256") if isinstance(proof.get("hash_sha256"), str) else None
204
+ )
205
+ signature_b64 = (
206
+ proof.get("signature_b64") if isinstance(proof.get("signature_b64"), str) else None
207
+ )
208
+ alg = proof.get("alg") if isinstance(proof.get("alg"), str) else None
209
+ canonical = proof.get("canonical") if isinstance(proof.get("canonical"), str) else None
210
+ signer_id = proof.get("signer_id") if isinstance(proof.get("signer_id"), str) else None
211
+ alg_matches = alg == "ed25519-sha256"
212
+ canonical_matches = canonical == "cl-stable-json-v1"
213
+ recomputed_hash = recompute_receipt_hash_sha256(target)["hash_sha256"]
214
+ hash_matches = bool(claimed_hash and claimed_hash == recomputed_hash)
215
+ metadata = target.get("metadata") if isinstance(target, dict) else None
216
+ receipt_id_value = metadata.get("receipt_id") if isinstance(metadata, dict) else None
217
+ receipt_id = receipt_id_value if isinstance(receipt_id_value, str) else None
218
+ receipt_id_matches = bool(claimed_hash and receipt_id == claimed_hash)
219
+
220
+ pubkey: bytes | None = None
221
+ pubkey_source: str | None = None
222
+ ens_error: str | None = None
223
+ ens_txt_key: str | None = None
224
+
225
+ if public_key:
226
+ pubkey = parse_ed25519_pubkey(public_key)
227
+ pubkey_source = "explicit"
228
+ elif ens:
229
+ ens_txt_key = "cl.receipt.signer -> cl.sig.pub, cl.sig.kid"
230
+ ens_name = ens.get("name")
231
+ if not ens_name:
232
+ ens_error = "ens.name is required"
233
+ else:
234
+ try:
235
+ signer_key = resolve_signer_key(ens_name, _extract_rpc_url(ens))
236
+ pubkey = signer_key.raw_public_key_bytes
237
+ pubkey_source = "ens"
238
+ except Exception as err: # noqa: BLE001
239
+ ens_error = str(err)
240
+
241
+ signature_valid = False
242
+ signature_error: str | None = None
243
+ if not alg_matches:
244
+ signature_error = f'proof.alg must be "ed25519-sha256" (got {alg})'
245
+ elif not canonical_matches:
246
+ signature_error = f'proof.canonical must be "cl-stable-json-v1" (got {canonical})'
247
+ elif not claimed_hash or not signature_b64:
248
+ signature_error = "missing proof.hash_sha256 or proof.signature_b64"
249
+ elif not pubkey:
250
+ signature_error = (
251
+ ens_error or "no public key available (provide public_key/publicKey or ens)"
252
+ )
253
+ else:
254
+ try:
255
+ signature_valid = verify_ed25519_signature_over_utf8_hash_string(
256
+ claimed_hash,
257
+ signature_b64,
258
+ pubkey,
259
+ )
260
+ except Exception as err: # noqa: BLE001
261
+ signature_error = str(err)
262
+
263
+ return {
264
+ "ok": alg_matches
265
+ and canonical_matches
266
+ and hash_matches
267
+ and receipt_id_matches
268
+ and signature_valid,
269
+ "checks": {
270
+ "hash_matches": hash_matches,
271
+ "signature_valid": signature_valid,
272
+ "receipt_id_matches": receipt_id_matches,
273
+ "alg_matches": alg_matches,
274
+ "canonical_matches": canonical_matches,
275
+ },
276
+ "values": {
277
+ "verb": ((target.get("x402") or {}).get("verb"))
278
+ if isinstance(target, dict)
279
+ else None,
280
+ "signer_id": signer_id,
281
+ "alg": alg,
282
+ "canonical": canonical,
283
+ "claimed_hash": claimed_hash,
284
+ "recomputed_hash": recomputed_hash,
285
+ "receipt_id": receipt_id,
286
+ "pubkey_source": pubkey_source, # type: ignore[typeddict-item]
287
+ "ens_txt_key": ens_txt_key,
288
+ },
289
+ "errors": {
290
+ "signature_error": signature_error,
291
+ "ens_error": ens_error,
292
+ "verify_error": None,
293
+ },
294
+ }
295
+ except Exception as err: # noqa: BLE001
296
+ proof = (
297
+ ((target.get("metadata") or {}).get("proof") or {}) if isinstance(target, dict) else {}
298
+ )
299
+ metadata = target.get("metadata") if isinstance(target, dict) else None
300
+ return {
301
+ "ok": False,
302
+ "checks": {
303
+ "hash_matches": False,
304
+ "signature_valid": False,
305
+ "receipt_id_matches": False,
306
+ "alg_matches": False,
307
+ "canonical_matches": False,
308
+ },
309
+ "values": {
310
+ "verb": ((target.get("x402") or {}).get("verb"))
311
+ if isinstance(target, dict)
312
+ else None,
313
+ "signer_id": proof.get("signer_id")
314
+ if isinstance(proof.get("signer_id"), str)
315
+ else None,
316
+ "alg": proof.get("alg") if isinstance(proof.get("alg"), str) else None,
317
+ "canonical": proof.get("canonical")
318
+ if isinstance(proof.get("canonical"), str)
319
+ else None,
320
+ "claimed_hash": proof.get("hash_sha256")
321
+ if isinstance(proof.get("hash_sha256"), str)
322
+ else None,
323
+ "recomputed_hash": None,
324
+ "receipt_id": metadata.get("receipt_id")
325
+ if isinstance(metadata, dict) and isinstance(metadata.get("receipt_id"), str)
326
+ else None,
327
+ "pubkey_source": None,
328
+ "ens_txt_key": None,
329
+ },
330
+ "errors": {
331
+ "signature_error": None,
332
+ "ens_error": None,
333
+ "verify_error": str(err),
334
+ },
335
+ }
@@ -0,0 +1,97 @@
1
+ Metadata-Version: 2.4
2
+ Name: commandlayer
3
+ Version: 1.1.0
4
+ Summary: CommandLayer Python SDK — semantic verbs, signed receipts, and verification helpers.
5
+ Author-email: CommandLayer <security@commandlayer.org>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/commandlayer/sdk
8
+ Project-URL: Repository, https://github.com/commandlayer/sdk
9
+ Project-URL: Documentation, https://github.com/commandlayer/sdk#readme
10
+ Project-URL: Changelog, https://github.com/commandlayer/sdk/blob/main/CHANGELOG.md
11
+ Project-URL: Issues, https://github.com/commandlayer/sdk/issues
12
+ Keywords: commandlayer,agents,receipts,protocol-commons,ens,sdk
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Topic :: Software Development :: Libraries
22
+ Classifier: Topic :: Security :: Cryptography
23
+ Requires-Python: >=3.10
24
+ Description-Content-Type: text/markdown
25
+ Requires-Dist: httpx>=0.27.0
26
+ Requires-Dist: pynacl>=1.5.0
27
+ Requires-Dist: web3>=6.20.0
28
+ Provides-Extra: dev
29
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
30
+ Requires-Dist: ruff>=0.6.0; extra == "dev"
31
+ Requires-Dist: mypy>=1.10.0; extra == "dev"
32
+ Requires-Dist: build>=1.1.0; extra == "dev"
33
+ Requires-Dist: twine>=5.0.0; extra == "dev"
34
+
35
+ # CommandLayer Python SDK
36
+
37
+ Official Python SDK for CommandLayer Commons v1.1.0.
38
+
39
+ The Python package mirrors the TypeScript SDK's protocol model:
40
+ - client methods return `{ "receipt": ..., "runtime_metadata": ... }`,
41
+ - the signed `receipt` is the canonical verification payload,
42
+ - `runtime_metadata` is optional execution context, and
43
+ - verification can use an explicit Ed25519 key or ENS discovery.
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ pip install commandlayer
49
+ ```
50
+
51
+ Supported Python versions: 3.10+.
52
+
53
+ ## Quick start
54
+
55
+ ```python
56
+ from commandlayer import create_client, verify_receipt
57
+
58
+ client = create_client(actor="docs-example")
59
+ response = client.summarize(
60
+ content="CommandLayer makes agent execution verifiable.",
61
+ style="bullet_points",
62
+ )
63
+
64
+ print(response["receipt"]["result"]["summary"])
65
+ print(response["receipt"]["metadata"]["receipt_id"])
66
+ print(response.get("runtime_metadata", {}).get("duration_ms"))
67
+
68
+ verification = verify_receipt(
69
+ response["receipt"],
70
+ public_key="ed25519:BASE64_PUBLIC_KEY",
71
+ )
72
+ print(verification["ok"])
73
+ ```
74
+
75
+ ## Verification
76
+
77
+ ```python
78
+ result = verify_receipt(
79
+ response["receipt"],
80
+ ens={
81
+ "name": "summarizeagent.eth",
82
+ "rpcUrl": "https://mainnet.infura.io/v3/YOUR_KEY",
83
+ },
84
+ )
85
+ ```
86
+
87
+ ## Development
88
+
89
+ ```bash
90
+ cd python-sdk
91
+ python -m venv .venv
92
+ source .venv/bin/activate
93
+ pip install -e '.[dev]'
94
+ ruff check .
95
+ mypy commandlayer
96
+ pytest
97
+ ```
@@ -0,0 +1,9 @@
1
+ commandlayer/__init__.py,sha256=Sxv6j4VISGe0F9ZC9ajszd4cK4WR9K-RVQZEbTSdyAE,983
2
+ commandlayer/client.py,sha256=27RJAbur969S12oeuJIAPlGfAed4vAcVAgUD3SdTAt4,10987
3
+ commandlayer/errors.py,sha256=v4e6QrsrtVkTkxk77NVQgoSvOVDoJoRihdyzsmgBglQ,352
4
+ commandlayer/types.py,sha256=8TzSj3bPpFNzI3nZ69QH--Xx7ApxJI2AAnRwxVfPeUM,1520
5
+ commandlayer/verify.py,sha256=dM34jtudtv_gzCc4GRH68P6R11BJUqIrL9FYewCW0Es,12921
6
+ commandlayer-1.1.0.dist-info/METADATA,sha256=b7VBudBYOHSjZp3yHHaa4Dw5uPQh01Kx0wDWqAPQwwI,2986
7
+ commandlayer-1.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
8
+ commandlayer-1.1.0.dist-info/top_level.txt,sha256=yX7_Pcnri1VbvOWfoUEqwuz3v_tXIn4n0Lj2BI7vEi4,13
9
+ commandlayer-1.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ commandlayer