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.
- commandlayer/__init__.py +43 -0
- commandlayer/client.py +349 -0
- commandlayer/errors.py +12 -0
- commandlayer/types.py +74 -0
- commandlayer/verify.py +335 -0
- commandlayer-1.1.0.dist-info/METADATA +97 -0
- commandlayer-1.1.0.dist-info/RECORD +9 -0
- commandlayer-1.1.0.dist-info/WHEEL +5 -0
- commandlayer-1.1.0.dist-info/top_level.txt +1 -0
commandlayer/__init__.py
ADDED
|
@@ -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 @@
|
|
|
1
|
+
commandlayer
|