diff --git a/.github/workflows/typescript-sdk-cli-smoke.yml b/.github/workflows/typescript-sdk-cli-smoke.yml new file mode 100644 index 0000000..4948d98 --- /dev/null +++ b/.github/workflows/typescript-sdk-cli-smoke.yml @@ -0,0 +1,40 @@ +name: TypeScript SDK CLI Smoke + +on: + push: + paths: + - "typescript-sdk/**" + - ".github/workflows/typescript-sdk-cli-smoke.yml" + pull_request: + paths: + - "typescript-sdk/**" + - ".github/workflows/typescript-sdk-cli-smoke.yml" + +jobs: + cli-smoke: + runs-on: ubuntu-latest + defaults: + run: + working-directory: typescript-sdk + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: typescript-sdk/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Typecheck + run: npm run typecheck + + - name: Build + run: npm run build + + - name: CLI smoke tests + run: npm run test:cli-smoke diff --git a/QUICKSTART.md b/QUICKSTART.md index c188018..f441843 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -1,195 +1,195 @@ -# CommandLayer SDK Quickstart - -Install. Call a verb. Get a signed receipt. - -You can integrate CommandLayer in under 2 minutes. - ---- - -## What is CommandLayer? - -CommandLayer is the **semantic verb layer** for autonomous agents. - -It provides: - -- Standardized verbs (`summarize`, `analyze`, `classify`, etc.) -- Strict JSON request & receipt schemas -- Cryptographically signed receipts (Ed25519 + SHA-256) -- x402-compatible execution envelopes -- ERC-8004–aligned agent discovery - -CommandLayer turns agent actions into **verifiable infrastructure**. - ---- - -# 1️⃣ Install - -## TypeScript / JavaScript - -```bash -npm install @commandlayer/sdk -``` - -## Python - -```bash -pip install commandlayer -``` - ---- - -# 2️⃣ Make Your First Call - -## TypeScript - -```ts -import { createClient } from "@commandlayer/sdk"; - -const client = createClient({ - actor: "my-app" -}); - -const receipt = await client.summarize({ - content: "CommandLayer makes agent actions structured and verifiable.", - style: "bullet_points" -}); - -console.log(receipt.result.summary); -``` - ---- - -## Python - -```python -from commandlayer import create_client - -client = create_client(actor="my-app") - -receipt = client.summarize( - content="CommandLayer makes agent actions structured and verifiable.", - style="bullet_points" -) - -print(receipt.result["summary"]) -``` - ---- - -## CLI - -```bash -commandlayer summarize \ - --content "CommandLayer makes agent actions structured and verifiable." \ - --style bullet_points -``` - ---- - -# 3️⃣ What You Get Back - -Every call returns a **signed receipt**, not just raw output. - -```ts -receipt.status // "success" -receipt.metadata.receipt_id // Deterministic receipt hash -receipt.trace.duration_ms // Execution latency - -receipt.result // Structured verb output - -receipt.metadata.proof.hash_sha256 -receipt.metadata.proof.signature_b64 -receipt.metadata.proof.signer_id -receipt.metadata.proof.alg // "ed25519-sha256" -``` - -Receipts are: - -- Canonicalized -- Hashed (SHA-256) -- Signed (Ed25519) -- Verifiable independently - -By default, the SDK verifies receipts automatically. - ---- - -# 4️⃣ Available Verbs - -The Commons SDK includes 10 verbs: - -- `summarize` -- `analyze` -- `classify` -- `clean` -- `convert` -- `describe` -- `explain` -- `format` -- `parse` -- `fetch` - -All verbs return structured, signed receipts. - ---- - -# 5️⃣ Configuration - -```ts -const client = createClient({ - actor: "my-production-app", - runtime: "https://runtime.commandlayer.org", // default - verifyReceipts: true // default -}); -``` - -### Options - -- `actor` — Identifier for your application or tenant -- `runtime` — Custom runtime base URL -- `verifyReceipts` — Enable/disable signature verification - ---- - -# 6️⃣ Production Notes - -- Always set a meaningful `actor` -- Keep `verifyReceipts` enabled in production -- Store `receipt_id` for audit trails -- Treat receipts as durable evidence, not logs - ---- - -# 7️⃣ Verify a Receipt (Optional) - -```ts -import { verifyReceipt } from "@commandlayer/sdk"; - -const ok = await verifyReceipt(receipt, { - ens: true, - rpcUrl: "https://mainnet.infura.io/v3/..." -}); - -console.log("Verified:", ok); -``` - -You can verify: - -- With a provided public key (offline) -- By resolving signer pubkey from ENS -- Or disable verification entirely - ---- - -# Next Steps - -📖 Real-world usage → `EXAMPLES.md` -🚀 Deployment & publishing → `DEPLOYMENT_GUIDE.md` -🔍 SDK architecture → `DEVELOPER_EXPERIENCE.md` -🌐 Full docs → https://commandlayer.org/docs.html - ---- - -CommandLayer turns agent execution into verifiable infrastructure. - -You're ready to build. +# CommandLayer SDK Quickstart + +Install. Call a verb. Get a signed receipt. + +You can integrate CommandLayer in under 2 minutes. + +--- + +## What is CommandLayer? + +CommandLayer is the **semantic verb layer** for autonomous agents. + +It provides: + +- Standardized verbs (`summarize`, `analyze`, `classify`, etc.) +- Strict JSON request & receipt schemas +- Cryptographically signed receipts (Ed25519 + SHA-256) +- x402-compatible execution envelopes +- ERC-8004–aligned agent discovery + +CommandLayer turns agent actions into **verifiable infrastructure**. + +--- + +# 1️⃣ Install + +## TypeScript / JavaScript + +```bash +npm install @commandlayer/sdk +``` + +## Python + +```bash +pip install commandlayer +``` + +--- + +# 2️⃣ Make Your First Call + +## TypeScript + +```ts +import { createClient } from "@commandlayer/sdk"; + +const client = createClient({ + actor: "my-app" +}); + +const receipt = await client.summarize({ + content: "CommandLayer makes agent actions structured and verifiable.", + style: "bullet_points" +}); + +console.log(receipt.result.summary); +``` + +--- + +## Python + +```python +from commandlayer import create_client + +client = create_client(actor="my-app") + +receipt = client.summarize( + content="CommandLayer makes agent actions structured and verifiable.", + style="bullet_points" +) + +print(receipt["result"]["summary"]) +``` + +--- + +## CLI + +```bash +commandlayer summarize \ + --content "CommandLayer makes agent actions structured and verifiable." \ + --style bullet_points +``` + +--- + +# 3️⃣ What You Get Back + +Every call returns a **signed receipt**, not just raw output. + +```ts +receipt.status // "success" +receipt.metadata.receipt_id // Deterministic receipt hash +receipt.trace.duration_ms // Execution latency + +receipt.result // Structured verb output + +receipt.metadata.proof.hash_sha256 +receipt.metadata.proof.signature_b64 +receipt.metadata.proof.signer_id +receipt.metadata.proof.alg // "ed25519-sha256" +``` + +Receipts are: + +- Canonicalized +- Hashed (SHA-256) +- Signed (Ed25519) +- Verifiable independently + +By default, the SDK verifies receipts automatically. + +--- + +# 4️⃣ Available Verbs + +The Commons SDK includes 10 verbs: + +- `summarize` +- `analyze` +- `classify` +- `clean` +- `convert` +- `describe` +- `explain` +- `format` +- `parse` +- `fetch` + +All verbs return structured, signed receipts. + +--- + +# 5️⃣ Configuration + +```ts +const client = createClient({ + actor: "my-production-app", + runtime: "https://runtime.commandlayer.org", // default + verifyReceipts: true // default +}); +``` + +### Options + +- `actor` — Identifier for your application or tenant +- `runtime` — Custom runtime base URL +- `verifyReceipts` — Enable/disable signature verification + +--- + +# 6️⃣ Production Notes + +- Always set a meaningful `actor` +- Keep `verifyReceipts` enabled in production +- Store `receipt_id` for audit trails +- Treat receipts as durable evidence, not logs + +--- + +# 7️⃣ Verify a Receipt (Optional) + +```ts +import { verifyReceipt } from "@commandlayer/sdk"; + +const ok = await verifyReceipt(receipt, { + ens: true, + rpcUrl: "https://mainnet.infura.io/v3/..." +}); + +console.log("Verified:", ok); +``` + +You can verify: + +- With a provided public key (offline) +- By resolving signer pubkey from ENS +- Or disable verification entirely + +--- + +# Next Steps + +📖 Real-world usage → `EXAMPLES.md` +🚀 Deployment & publishing → `DEPLOYMENT_GUIDE.md` +🔍 SDK architecture → `DEVELOPER_EXPERIENCE.md` +🌐 Full docs → https://commandlayer.org/docs.html + +--- + +CommandLayer turns agent execution into verifiable infrastructure. + +You're ready to build. diff --git a/python-sdk/README.md b/python-sdk/README.md index becc253..43c5463 100644 --- a/python-sdk/README.md +++ b/python-sdk/README.md @@ -1,336 +1,118 @@ -# CommandLayer Python SDK - -Semantic verbs. Typed schemas. Signed receipts. - -This package provides the official Python SDK for **CommandLayer Commons v1.0.0**. - -Install → call a verb → receive a signed receipt → verify it. - ---- - -## What is CommandLayer? - -CommandLayer is the **semantic verb layer** for autonomous agents. - -The SDK provides: - -- Standardized Commons verbs (`summarize`, `analyze`, `fetch`, etc.) -- Strict JSON Schemas (requests + receipts) -- Cryptographically signed receipts (Ed25519 + SHA-256) -- Deterministic canonicalization (`cl-stable-json-v1`) -- Verification helpers (offline PEM or ENS-based) -- CLI-style patterns for reproducible testing - ---- - -## Installation - -```bash -pip install commandlayer -``` - -Python 3.10+ recommended. - ---- - -## Quickstart -``` -from commandlayer import create_client - -client = create_client( - actor="my-app", - runtime="https://runtime.commandlayer.org", # default - verify_receipts=True, # default (recommended) -) - -receipt = client.summarize( - content="CommandLayer turns agent actions into verifiable receipts.", - style="bullet_points", -) - -print(receipt["status"]) -print(receipt["result"]["summary"]) -print(receipt["metadata"]["receipt_id"]) -``` ---- - -## Runtime configuration - -Default runtime: - - - `https://runtime.commandlayer.org- - -Override if needed: -``` -from commandlayer import create_client - -client = create_client( - actor="my-app", - runtime="https://your-runtime.example", - verify_receipts=True, - timeout_ms=30_000, -) -``` ---- - -Keep `verify_receipts=True` in production. - ---- - -## Receipt structure - -Every call returns a signed receipt: -``` -{ - "status": "success", - "x402": { - "verb": "summarize", - "version": "1.0.0", - "entry": "x402://summarizeagent.eth/summarize/v1.0.0" - }, - "trace": { - "trace_id": "trace_ab12cd34", - "duration_ms": 118 - }, - "result": { - "summary": "..." - }, - "metadata": { - "receipt_id": "8f0a...", - "proof": { - "alg": "ed25519-sha256", - "canonical": "cl-stable-json-v1", - "signer_id": "runtime.commandlayer.eth", - "hash_sha256": "...", - "signature_b64": "..." - } - } -} -``` -Receipt guarantees: - -- Stable canonical hashing over the unsigned receipt -- SHA-256 digest of canonical JSON -- Ed25519 signature over the hash -- Deterministic verification across runtimes - - --- - -## Verifying receipts - -**Option A — Offline verification (explicit public key PEM)** - -Fastest method. No RPC required. -``` -from commandlayer import verify_receipt - -PUBLIC_KEY_PEM = """-----BEGIN PUBLIC KEY----- -MCowBQYDK2VwAyEA7Vkkmt6R02Iltp/+i3D5mraZyvLjfuTSVB33KwfzQC8= ------END PUBLIC KEY-----""" - -out = verify_receipt(receipt, public_key_pem=PUBLIC_KEY_PEM) - -print(out["ok"]) -print(out["checks"]) # schema_valid (optional), hash_matches, signature_valid -``` - -**Option B — ENS-based verification** - -Resolves the public key from ENS TXT records. - -Required ENS records: - -- `cl.receipt.pubkey_pem` -- `cl.receipt.signer_id` -- `cl.receipt.alg` - -``` -import os -from commandlayer import verify_receipt - -out = verify_receipt( - receipt, - ens_name="runtime.commandlayer.eth", - rpc_url=os.environ["ETH_RPC_URL"], - ens_txt_key="cl.receipt.pubkey_pem", # default -) - -print(out["ok"]) -print(out["values"]["pubkey_source"]) # "ens" when resolved -``` - -ENS affects verification correctness — not package build/publish. - ---- - -## Commons verbs - -All verbs return signed receipts. - -**summarize** -``` -receipt = client.summarize( - content="Long text...", - style="bullet_points", # optional - format="text", # optional - max_tokens=1000, # optional -) -``` - -**analyze** -``` -receipt = client.analyze( - content="Data...", - dimensions=["sentiment", "tone"], # optional (runtime-dependent) - max_tokens=1000, -) -``` - -**classify** -``` -receipt = client.classify( - content="Support message...", - categories=["support", "billing"], # optional (runtime-dependent) - max_tokens=1000, -) -``` - -**clean** -``` -receipt = client.clean( - content=" a \r\n\r\n b ", - operations=["trim", "normalize_newlines", "remove_empty_lines"], # optional - max_tokens=1000, -) -``` -**convert** -``` -receipt = client.convert( - content='{"a":1,"b":2}', - from_format="json", - to_format="csv", - max_tokens=1000, -) -``` -**describe** -``` -receipt = client.describe( - subject="CommandLayer receipt", - context="A receipt returned from the runtime...", - detail_level="medium", # brief|medium|detailed (runtime-dependent) - audience="general", - max_tokens=1000, -) -``` -**explain** -``` -receipt = client.explain( - subject="x402 receipt verification", - context="Explain what schema + hash + signature verification proves.", - style="step-by-step", - detail_level="medium", - audience="general", - max_tokens=1000, -) -``` -**format** - -```receipt = client.format( - content="a: 1\nb: 2", - target_style="table", # runtime-dependent - max_tokens=1000, -) -``` -**parse** -``` -receipt = client.parse( - content='{"a":1}', - content_type="json", # json|yaml|text - mode="strict", # best_effort|strict - target_schema=None, # optional - max_tokens=1000, -) -``` -**fetch** -``` -receipt = client.fetch( - source="https://example.com", - mode="text", # text|html|json (runtime-dependent) - query=None, - include_metadata=False, - max_tokens=1000, -) -``` - -## Local development - -Typical workflow: - -``` -cd python-sdk -python -m venv .venv - -# Windows -.venv\Scripts\activate -# macOS/Linux -source .venv/bin/activate - -pip install -U pip -pip install -e . -python -c "from commandlayer import create_client; print(create_client)" -``` - -Build a release: -``` -pip install -U build twine -python -m build -``` - -Publish (optional): -``` -twine upload dist/* -``` ---- - -## Versioning + release discipline - -Use SemVer: - -- Patch: bug fixes (no API break) -- Minor: new verbs/options (backward compatible) -- Major: breaking changes - -Release flow: - -- Update `CHANGELOG.md` -- Bump version -- Build -- Smoke test a live call against runtime.commandlayer.org -- Publish - ---- - -## Definition of Done - -You’re “deployed” when: - -- `pip install commandlayer` succeeds -- A verb call returns a valid receipt JSON -- Verification passes (offline or ENS-based) -- CI reproduces install + minimal smoke test - -## License - -MIT - -CommandLayer turns agent actions into verifiable infrastructure. - -Ship APIs that can prove what they did. - - - - - - - - +# CommandLayer Python SDK + +Semantic verbs. Signed receipts. Deterministic verification. + +Official Python SDK for **CommandLayer Commons v1.0.0**. + +## Installation + +```bash +pip install commandlayer +``` + +Python 3.10+ is supported. + +--- + +## Quickstart + +```python +from commandlayer import create_client + +client = create_client( + actor="my-app", + runtime="https://runtime.commandlayer.org", # optional +) + +receipt = client.summarize( + content="CommandLayer turns agent actions into verifiable receipts.", + style="bullet_points", +) + +print(receipt["status"]) +print(receipt["metadata"]["receipt_id"]) +``` + +> `verify_receipts` is **off by default** (matching TypeScript SDK behavior). + +--- + +## Client Configuration + +```python +client = create_client( + runtime="https://runtime.commandlayer.org", + actor="my-app", + timeout_ms=30_000, + verify_receipts=True, + verify={ + "public_key": "ed25519:7Vkkmt6R02Iltp/+i3D5mraZyvLjfuTSVB33KwfzQC8=", + # or ENS: + # "ens": {"name": "runtime.commandlayer.eth", "rpcUrl": "https://..."} + }, +) +``` + +### Verification options +- `verify["public_key"]` (alias: `publicKey`): explicit Ed25519 pubkey (`ed25519:`, ``, `0x`, ``) +- `verify["ens"]`: `{ "name": str, "rpcUrl"|"rpc_url": str, "pubkeyTextKey"|"pubkey_text_key"?: str }` + +--- + +## Supported Verbs + +All verbs return a signed receipt. + +```python +client.summarize(content="...", style="bullet_points") +client.analyze(content="...", goal="extract key risks") +client.classify(content="...", max_labels=5) +client.clean(content="...", operations=["trim", "normalize_newlines"]) +client.convert(content='{"a":1}', from_format="json", to_format="csv") +client.describe(subject="x402 receipt", detail="medium") +client.explain(subject="receipt verification", style="step-by-step") +client.format(content="a: 1\nb: 2", to="table") +client.parse(content='{"a":1}', content_type="json", mode="strict") +client.fetch(source="https://example.com", include_metadata=True) +``` + +--- + +## Receipt Verification API + +```python +from commandlayer import verify_receipt + +result = verify_receipt( + receipt, + public_key="ed25519:7Vkkmt6R02Iltp/+i3D5mraZyvLjfuTSVB33KwfzQC8=", +) + +print(result["ok"]) +print(result["checks"]) +``` + +ENS-based verification: + +```python +result = verify_receipt( + receipt, + ens={ + "name": "runtime.commandlayer.eth", + "rpcUrl": "https://mainnet.infura.io/v3/YOUR_KEY", + "pubkeyTextKey": "cl.pubkey", + }, +) +``` + +--- + +## Development + +```bash +cd python-sdk +python -m venv .venv +source .venv/bin/activate +pip install -e '.[dev]' +pytest +``` diff --git a/python-sdk/commandlayer/__init__.py b/python-sdk/commandlayer/__init__.py index d43b5af..1295daa 100644 --- a/python-sdk/commandlayer/__init__.py +++ b/python-sdk/commandlayer/__init__.py @@ -1,21 +1,17 @@ -# python-sdk/src/commandlayer/__init__.py - -""" -CommandLayer Python SDK. - -Semantic verbs. Typed schemas. Signed receipts. -""" - -from .client import CommandLayerClient, create_client -from .errors import CommandLayerError -from .types import Receipt, VerifyResult - -__all__ = [ - "CommandLayerClient", - "create_client", - "CommandLayerError", - "Receipt", - "VerifyResult", -] - -__version__ = "1.0.0" +"""CommandLayer Python SDK.""" + +from .client import CommandLayerClient, create_client +from .errors import CommandLayerError +from .types import Receipt, VerifyResult +from .verify import verify_receipt + +__all__ = [ + "CommandLayerClient", + "create_client", + "CommandLayerError", + "Receipt", + "VerifyResult", + "verify_receipt", +] + +__version__ = "1.0.0" diff --git a/python-sdk/commandlayer/client.py b/python-sdk/commandlayer/client.py new file mode 100644 index 0000000..5f4bfa0 --- /dev/null +++ b/python-sdk/commandlayer/client.py @@ -0,0 +1,248 @@ +from __future__ import annotations + +from typing import Any + +import httpx + +from .errors import CommandLayerError +from .types import Receipt +from .verify import verify_receipt + + +VERBS = { + "summarize", + "analyze", + "classify", + "clean", + "convert", + "describe", + "explain", + "format", + "parse", + "fetch", +} + + +def _normalize_base(url: str) -> str: + return str(url or "").rstrip("/") + + +class CommandLayerClient: + def __init__( + self, + runtime: str = "https://runtime.commandlayer.org", + actor: str = "sdk-user", + timeout_ms: int = 30_000, + verify_receipts: bool = False, + verify: dict[str, Any] | None = None, + ): + self.runtime = _normalize_base(runtime) + self.actor = actor + self.timeout_ms = timeout_ms + self.verify_receipts = verify_receipts is True + self.verify_defaults = verify or {} + self._http = httpx.Client(timeout=self.timeout_ms / 1000) + + def _ensure_verify_config_if_enabled(self) -> None: + if not self.verify_receipts: + return + + public_key = self.verify_defaults.get("public_key") or self.verify_defaults.get("publicKey") + has_explicit = bool(str(public_key or "").strip()) + ens = self.verify_defaults.get("ens") or {} + has_ens = bool(ens.get("name") and (ens.get("rpcUrl") or ens.get("rpc_url"))) + + if not has_explicit and not has_ens: + raise CommandLayerError( + "verify_receipts is enabled but no verification key config provided. " + "Set verify.public_key (or verify.publicKey) or verify.ens {name, rpcUrl}.", + 400, + ) + + def summarize(self, *, content: str, style: str | None = None, format: str | None = None, max_tokens: int = 1000) -> Receipt: + return self.call( + "summarize", + { + "input": {"content": content, "summary_style": style, "format_hint": format}, + "limits": {"max_output_tokens": max_tokens}, + }, + ) + + def analyze(self, *, content: str, goal: str | None = None, hints: list[str] | None = None, max_tokens: int = 1000) -> Receipt: + body: dict[str, Any] = {"input": content, "limits": {"max_output_tokens": max_tokens}} + if goal: + body["goal"] = goal + if hints: + body["hints"] = hints + return self.call("analyze", body) + + def classify(self, *, content: str, max_labels: int = 5, max_tokens: int = 1000) -> Receipt: + return self.call( + "classify", + { + "actor": self.actor, + "input": {"content": content}, + "limits": {"max_labels": max_labels, "max_output_tokens": max_tokens}, + }, + ) + + def clean(self, *, content: str, operations: list[str] | None = None, max_tokens: int = 1000) -> Receipt: + return self.call( + "clean", + { + "input": { + "content": content, + "operations": operations or ["normalize_newlines", "collapse_whitespace", "trim"], + }, + "limits": {"max_output_tokens": max_tokens}, + }, + ) + + def convert(self, *, content: str, from_format: str, to_format: str, max_tokens: int = 1000) -> Receipt: + return self.call( + "convert", + { + "input": {"content": content, "source_format": from_format, "target_format": to_format}, + "limits": {"max_output_tokens": max_tokens}, + }, + ) + + def describe( + self, + *, + subject: str, + audience: str = "general", + detail: str = "medium", + max_tokens: int = 1000, + ) -> Receipt: + subject = (subject or "")[:140] + return self.call( + "describe", + { + "input": {"subject": subject, "audience": audience, "detail_level": detail}, + "limits": {"max_output_tokens": max_tokens}, + }, + ) + + def explain( + self, + *, + subject: str, + audience: str = "general", + style: str = "step-by-step", + detail: str = "medium", + max_tokens: int = 1000, + ) -> Receipt: + subject = (subject or "")[:140] + return self.call( + "explain", + { + "input": { + "subject": subject, + "audience": audience, + "style": style, + "detail_level": detail, + }, + "limits": {"max_output_tokens": max_tokens}, + }, + ) + + def format(self, *, content: str, to: str, max_tokens: int = 1000) -> Receipt: + return self.call( + "format", + {"input": {"content": content, "target_style": to}, "limits": {"max_output_tokens": max_tokens}}, + ) + + def parse( + self, + *, + content: str, + content_type: str = "text", + mode: str = "best_effort", + target_schema: str | None = None, + max_tokens: int = 1000, + ) -> Receipt: + input_obj: dict[str, Any] = { + "content": content, + "content_type": content_type, + "mode": mode, + } + if target_schema: + input_obj["target_schema"] = target_schema + + return self.call("parse", {"input": input_obj, "limits": {"max_output_tokens": max_tokens}}) + + def fetch( + self, + *, + source: str, + query: str | None = None, + include_metadata: bool | None = None, + max_tokens: int = 1000, + ) -> Receipt: + input_obj: dict[str, Any] = {"source": source} + if query is not None: + input_obj["query"] = query + if include_metadata is not None: + input_obj["include_metadata"] = include_metadata + + return self.call("fetch", {"input": input_obj, "limits": {"max_output_tokens": max_tokens}}) + + def call(self, verb: str, body: dict[str, Any]) -> Receipt: + if verb not in VERBS: + raise CommandLayerError(f"Unsupported verb: {verb}", 400) + + self._ensure_verify_config_if_enabled() + url = f"{self.runtime}/{verb}/v1.0.0" + + payload = { + "x402": { + "verb": verb, + "version": "1.0.0", + "entry": f"x402://{verb}agent.eth/{verb}/v1.0.0", + }, + "actor": body.get("actor", self.actor), + **body, + } + + try: + resp = self._http.post( + url, + headers={"Content-Type": "application/json", "User-Agent": "commandlayer-py/1.0.0"}, + json=payload, + ) + except httpx.TimeoutException as err: + raise CommandLayerError("Request timed out", 408) from err + except Exception as err: + raise CommandLayerError(str(err)) from err + + try: + data = resp.json() + except Exception: + data = {} + + if not resp.is_success: + message = ( + data.get("message") if isinstance(data, dict) else None + ) or ( + (data.get("error") or {}).get("message") if isinstance(data, dict) and isinstance(data.get("error"), dict) else None + ) or f"HTTP {resp.status_code}" + raise CommandLayerError(message, resp.status_code, data) + + if self.verify_receipts: + result = verify_receipt( + data, + public_key=self.verify_defaults.get("public_key") or self.verify_defaults.get("publicKey"), + ens=self.verify_defaults.get("ens"), + ) + if not result["ok"]: + raise CommandLayerError("Receipt verification failed", 422, result) + + return data + + def close(self): + self._http.close() + + +def create_client(**kwargs) -> CommandLayerClient: + return CommandLayerClient(**kwargs) diff --git a/python-sdk/commandlayer/errors.py b/python-sdk/commandlayer/errors.py new file mode 100644 index 0000000..830bf22 --- /dev/null +++ b/python-sdk/commandlayer/errors.py @@ -0,0 +1,5 @@ +class CommandLayerError(Exception): + def __init__(self, message: str, status_code: int | None = None, details=None): + super().__init__(message) + self.status_code = status_code + self.details = details diff --git a/python-sdk/commandlayer/types.py b/python-sdk/commandlayer/types.py new file mode 100644 index 0000000..38675df --- /dev/null +++ b/python-sdk/commandlayer/types.py @@ -0,0 +1,37 @@ +from typing import Any, Literal, TypedDict + + +class VerifyChecks(TypedDict, total=False): + hash_matches: bool + signature_valid: bool + receipt_id_matches: bool + alg_matches: bool + canonical_matches: bool + + +class VerifyValues(TypedDict, total=False): + verb: str | None + signer_id: str | None + alg: str | None + canonical: str | None + claimed_hash: str | None + recomputed_hash: str | None + receipt_id: str | None + pubkey_source: Literal["explicit", "ens"] | None + ens_txt_key: str | None + + +class VerifyErrors(TypedDict, total=False): + signature_error: str | None + ens_error: str | None + verify_error: str | None + + +class VerifyResult(TypedDict): + ok: bool + checks: VerifyChecks + values: VerifyValues + errors: VerifyErrors + + +Receipt = dict[str, Any] diff --git a/python-sdk/commandlayer/verify.py b/python-sdk/commandlayer/verify.py new file mode 100644 index 0000000..d9ec9ce --- /dev/null +++ b/python-sdk/commandlayer/verify.py @@ -0,0 +1,242 @@ +import base64 +import copy +import hashlib +import json +import re +from typing import Any + +from nacl.signing import VerifyKey +from nacl.exceptions import BadSignatureError +from web3 import Web3 + +from .types import Receipt, VerifyResult + + +def canonicalize_stable_json_v1(value: Any) -> str: + def encode(v: Any) -> str: + if v is None: + return "null" + if isinstance(v, bool): + return "true" if v else "false" + if isinstance(v, str): + return json.dumps(v, ensure_ascii=False) + if isinstance(v, (int, float)): + if isinstance(v, float): + if v != v or v in (float("inf"), float("-inf")): + raise ValueError("canonicalize: non-finite number not allowed") + if v == 0.0 and str(v).startswith("-"): + return "0" + return format(v, "g") if isinstance(v, float) else str(v) + if isinstance(v, list): + return "[" + ",".join(encode(x) for x in v) + "]" + if isinstance(v, dict): + out = [] + for k in sorted(v.keys()): + val = v[k] + out.append(f"{json.dumps(str(k), ensure_ascii=False)}:{encode(val)}") + return "{" + ",".join(out) + "}" + raise ValueError(f"canonicalize: unsupported type {type(v).__name__}") + + return encode(value) + + +def sha256_hex_utf8(text: str) -> str: + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +def parse_ed25519_pubkey(text: str) -> bytes: + s = str(text).strip() + match = re.match(r"^ed25519\s*[:=]\s*(.+)$", s, re.IGNORECASE) + candidate = (match.group(1) if match else s).strip() + + if re.match(r"^(0x)?[0-9a-fA-F]{64}$", candidate): + h = candidate[2:] if candidate.startswith("0x") else candidate + return bytes.fromhex(h) + + decoded = base64.b64decode(candidate, validate=True) + if len(decoded) != 32: + raise ValueError("invalid base64 ed25519 pubkey length (need 32 bytes)") + return decoded + + +def verify_ed25519_signature_over_utf8_hash_string(hash_hex: str, signature_b64: str, pubkey32: bytes) -> bool: + if len(pubkey32) != 32: + raise ValueError("ed25519: pubkey must be 32 bytes") + sig = base64.b64decode(signature_b64) + if len(sig) != 64: + raise ValueError("ed25519: signature must be 64 bytes") + + vk = VerifyKey(pubkey32) + try: + vk.verify(hash_hex.encode("utf-8"), sig) + return True + except BadSignatureError: + return False + + +def resolve_ens_ed25519_pubkey(name: str, rpc_url: str, pubkey_text_key: str = "cl.pubkey") -> dict[str, Any]: + if not rpc_url: + return {"pubkey": None, "source": None, "error": "rpcUrl is required for ENS verification", "txt_key": pubkey_text_key} + try: + w3 = Web3(Web3.HTTPProvider(rpc_url)) + if not w3.is_connected(): + return {"pubkey": None, "source": None, "error": "Unable to connect to RPC", "txt_key": pubkey_text_key} + + try: + txt = w3.ens.get_text(name, pubkey_text_key) # type: ignore[attr-defined] + except Exception as err: + return {"pubkey": None, "source": None, "error": f"ENS TXT lookup failed: {err}", "txt_key": pubkey_text_key} + + if not txt: + return {"pubkey": None, "source": None, "error": f"ENS TXT {pubkey_text_key} missing", "txt_key": pubkey_text_key} + + pubkey = parse_ed25519_pubkey(str(txt).strip()) + return {"pubkey": pubkey, "source": "ens", "txt_key": pubkey_text_key, "txt_value": txt} + except Exception as err: + return {"pubkey": None, "source": None, "error": str(err), "txt_key": pubkey_text_key} + + +def to_unsigned_receipt(receipt: Receipt) -> Receipt: + if not isinstance(receipt, dict): + raise ValueError("receipt must be an object") + + r = copy.deepcopy(receipt) + + metadata = r.get("metadata") + if isinstance(metadata, dict): + metadata.pop("receipt_id", None) + + proof = metadata.get("proof") + if isinstance(proof, dict): + unsigned_proof = {} + for key in ("alg", "canonical", "signer_id"): + if isinstance(proof.get(key), str): + unsigned_proof[key] = proof[key] + metadata["proof"] = unsigned_proof + + r.pop("receipt_id", None) + return r + + +def recompute_receipt_hash_sha256(receipt: Receipt) -> dict[str, str]: + unsigned = to_unsigned_receipt(receipt) + canonical = canonicalize_stable_json_v1(unsigned) + hash_sha256 = sha256_hex_utf8(canonical) + return {"canonical": canonical, "hash_sha256": hash_sha256} + + +def verify_receipt(receipt: Receipt, public_key: str | None = None, ens: dict[str, Any] | None = None) -> VerifyResult: + try: + proof = ((receipt.get("metadata") or {}).get("proof") or {}) if isinstance(receipt, dict) else {} + + claimed_hash = proof.get("hash_sha256") if isinstance(proof.get("hash_sha256"), str) else None + signature_b64 = proof.get("signature_b64") if isinstance(proof.get("signature_b64"), str) else None + alg = proof.get("alg") if isinstance(proof.get("alg"), str) else None + canonical = proof.get("canonical") if isinstance(proof.get("canonical"), str) else None + signer_id = proof.get("signer_id") if isinstance(proof.get("signer_id"), str) else None + + alg_matches = alg == "ed25519-sha256" + canonical_matches = canonical == "cl-stable-json-v1" + + recomputed_hash = recompute_receipt_hash_sha256(receipt)["hash_sha256"] + hash_matches = bool(claimed_hash and claimed_hash == recomputed_hash) + + metadata = receipt.get("metadata") if isinstance(receipt.get("metadata"), dict) else {} + receipt_id = metadata.get("receipt_id") or receipt.get("receipt_id") + receipt_id = receipt_id if isinstance(receipt_id, str) else None + receipt_id_matches = bool(claimed_hash and receipt_id == claimed_hash) + + pubkey = None + pubkey_source = None + ens_error = None + ens_txt_key = None + + if public_key: + pubkey = parse_ed25519_pubkey(public_key) + pubkey_source = "explicit" + elif ens: + res = resolve_ens_ed25519_pubkey( + name=ens["name"], + rpc_url=ens.get("rpcUrl") or ens.get("rpc_url"), + pubkey_text_key=ens.get("pubkeyTextKey") or ens.get("pubkey_text_key") or "cl.pubkey", + ) + ens_txt_key = res.get("txt_key") + if not res.get("pubkey"): + ens_error = res.get("error") or "ENS pubkey not found" + else: + pubkey = res["pubkey"] + pubkey_source = "ens" + + signature_valid = False + signature_error = None + + if not alg_matches: + signature_error = f'proof.alg must be "ed25519-sha256" (got {alg})' + elif not canonical_matches: + signature_error = f'proof.canonical must be "cl-stable-json-v1" (got {canonical})' + elif not claimed_hash or not signature_b64: + signature_error = "missing proof.hash_sha256 or proof.signature_b64" + elif not pubkey: + signature_error = ens_error or "no public key available (provide public_key/publicKey or ens)" + else: + try: + signature_valid = verify_ed25519_signature_over_utf8_hash_string(claimed_hash, signature_b64, pubkey) + except Exception as err: + signature_valid = False + signature_error = str(err) + + ok = alg_matches and canonical_matches and hash_matches and receipt_id_matches and signature_valid + + return { + "ok": ok, + "checks": { + "hash_matches": hash_matches, + "signature_valid": signature_valid, + "receipt_id_matches": receipt_id_matches, + "alg_matches": alg_matches, + "canonical_matches": canonical_matches, + }, + "values": { + "verb": (((receipt.get("x402") or {}).get("verb")) if isinstance(receipt, dict) else None), + "signer_id": signer_id, + "alg": alg, + "canonical": canonical, + "claimed_hash": claimed_hash, + "recomputed_hash": recomputed_hash, + "receipt_id": receipt_id, + "pubkey_source": pubkey_source, + "ens_txt_key": ens_txt_key, + }, + "errors": { + "signature_error": signature_error, + "ens_error": ens_error, + "verify_error": None, + }, + } + except Exception as err: + return { + "ok": False, + "checks": { + "hash_matches": False, + "signature_valid": False, + "receipt_id_matches": False, + "alg_matches": False, + "canonical_matches": False, + }, + "values": { + "verb": ((receipt.get("x402") or {}).get("verb")) if isinstance(receipt, dict) else None, + "signer_id": ((((receipt.get("metadata") or {}).get("proof") or {}).get("signer_id")) if isinstance(receipt, dict) else None), + "alg": ((((receipt.get("metadata") or {}).get("proof") or {}).get("alg")) if isinstance(receipt, dict) else None), + "canonical": ((((receipt.get("metadata") or {}).get("proof") or {}).get("canonical")) if isinstance(receipt, dict) else None), + "claimed_hash": ((((receipt.get("metadata") or {}).get("proof") or {}).get("hash_sha256")) if isinstance(receipt, dict) else None), + "recomputed_hash": None, + "receipt_id": (((receipt.get("metadata") or {}).get("receipt_id")) if isinstance(receipt, dict) else None), + "pubkey_source": None, + "ens_txt_key": None, + }, + "errors": { + "signature_error": None, + "ens_error": None, + "verify_error": str(err), + }, + } diff --git a/python-sdk/pyproject.toml b/python-sdk/pyproject.toml index 30eb7f7..1feb0f8 100644 --- a/python-sdk/pyproject.toml +++ b/python-sdk/pyproject.toml @@ -1,73 +1,65 @@ -[build-system] -requires = ["setuptools>=69", "wheel"] -build-backend = "setuptools.build_meta" - -[project] -name = "commandlayer" -version = "1.0.0" -description = "CommandLayer Python SDK — semantic verbs, typed schemas, signed receipts." -readme = "README.md" -requires-python = ">=3.10" -license = { text = "MIT" } -authors = [{ name = "CommandLayer", email = "security@commandlayer.org" }] -keywords = ["commandlayer", "agents", "receipts", "x402", "ens", "schema"] -classifiers = [ - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Topic :: Software Development :: Libraries", - "Topic :: Security :: Cryptography", -] - -dependencies = [ - "httpx>=0.27.0", - "pydantic>=2.6.0", - "jsonschema>=4.21.0", - "pynacl>=1.5.0", - "eth-abi>=5.0.0", - "eth-utils>=4.0.0", - "web3>=6.20.0", -] - -[project.optional-dependencies] -dev = [ - "pytest>=8.0.0", - "pytest-asyncio>=0.23.0", - "ruff>=0.4.0", - "mypy>=1.8.0", - "build>=1.1.0", - "twine>=5.0.0", -] - -[project.urls] -Homepage = "https://commandlayer.org" -Documentation = "https://commandlayer.org/docs.html" -Repository = "https://github.com/commandlayer" -Issues = "https://github.com/commandlayer/issues" - -[tool.setuptools] -package-dir = { "" = "src" } - -[tool.setuptools.packages.find] -where = ["src"] -include = ["commandlayer*"] - -[tool.pytest.ini_options] -testpaths = ["tests"] -addopts = "-q" - -[tool.ruff] -line-length = 100 -target-version = "py310" - -[tool.mypy] -python_version = "3.10" -warn_return_any = true -warn_unused_configs = true -disallow_untyped_defs = false -ignore_missing_imports = true +[build-system] +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "commandlayer" +version = "1.0.0" +description = "CommandLayer Python SDK — semantic verbs, signed receipts, and verification helpers." +readme = "README.md" +requires-python = ">=3.10" +license = { text = "MIT" } +authors = [{ name = "CommandLayer", email = "security@commandlayer.org" }] +keywords = ["commandlayer", "agents", "receipts", "x402", "ens", "sdk"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Libraries", + "Topic :: Security :: Cryptography", +] + +dependencies = [ + "httpx>=0.27.0", + "pynacl>=1.5.0", + "web3>=6.20.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "ruff>=0.4.0", + "mypy>=1.8.0", + "build>=1.1.0", + "twine>=5.0.0", +] + +[project.urls] +Homepage = "https://commandlayer.org" +Documentation = "https://commandlayer.org/docs.html" +Repository = "https://github.com/commandlayer" +Issues = "https://github.com/commandlayer/issues" + +[tool.setuptools.packages.find] +where = ["."] +include = ["commandlayer*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q" + +[tool.ruff] +line-length = 100 +target-version = "py310" + +[tool.mypy] +python_version = "3.10" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = false +ignore_missing_imports = true diff --git a/python-sdk/tests/test_client.py b/python-sdk/tests/test_client.py new file mode 100644 index 0000000..2c844e6 --- /dev/null +++ b/python-sdk/tests/test_client.py @@ -0,0 +1,24 @@ +import pytest + +from commandlayer.client import CommandLayerClient +from commandlayer.errors import CommandLayerError + + +def test_call_rejects_unsupported_verb(): + client = CommandLayerClient() + with pytest.raises(CommandLayerError): + client.call("unknown", {}) + + +def test_verify_config_required_when_enabled(): + client = CommandLayerClient(verify_receipts=True) + with pytest.raises(CommandLayerError): + client._ensure_verify_config_if_enabled() + + +def test_verify_config_accepts_camelcase_aliases(): + client = CommandLayerClient( + verify_receipts=True, + verify={"publicKey": "ed25519:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="}, + ) + client._ensure_verify_config_if_enabled() diff --git a/python-sdk/tests/test_verify.py b/python-sdk/tests/test_verify.py new file mode 100644 index 0000000..151b507 --- /dev/null +++ b/python-sdk/tests/test_verify.py @@ -0,0 +1,45 @@ +import base64 + +from nacl.signing import SigningKey + +from commandlayer.verify import parse_ed25519_pubkey, recompute_receipt_hash_sha256, verify_receipt + + +def test_verify_receipt_happy_path_explicit_public_key(): + receipt = { + "status": "success", + "x402": { + "verb": "summarize", + "version": "1.0.0", + "entry": "x402://summarizeagent.eth/summarize/v1.0.0", + }, + "metadata": { + "proof": { + "alg": "ed25519-sha256", + "canonical": "cl-stable-json-v1", + "signer_id": "runtime.commandlayer.eth", + } + }, + } + + hash_out = recompute_receipt_hash_sha256(receipt) + sk = SigningKey.generate() + signature = sk.sign(hash_out["hash_sha256"].encode("utf-8")).signature + + receipt["metadata"]["proof"]["hash_sha256"] = hash_out["hash_sha256"] + receipt["metadata"]["proof"]["signature_b64"] = base64.b64encode(signature).decode("utf-8") + receipt["metadata"]["receipt_id"] = hash_out["hash_sha256"] + + pubkey_b64 = base64.b64encode(bytes(sk.verify_key)).decode("utf-8") + + out = verify_receipt(receipt, public_key=f"ed25519:{pubkey_b64}") + assert out["ok"] is True + assert out["checks"]["signature_valid"] is True + assert out["checks"]["hash_matches"] is True + + +def test_parse_ed25519_pubkey_rejects_invalid_base64(): + import pytest + + with pytest.raises(Exception): + parse_ed25519_pubkey("not_base64!!!") diff --git a/typescript-sdk/README.md b/typescript-sdk/README.md index 0a36e2a..403cd7d 100644 --- a/typescript-sdk/README.md +++ b/typescript-sdk/README.md @@ -271,6 +271,7 @@ Typical flow: cd typescript-sdk npm install npm run build +npm run test:cli-smoke node dist/cli.cjs summarize --content "test" --style bullet_points --json ``` --- diff --git a/typescript-sdk/package.json b/typescript-sdk/package.json index 9115b82..8e581c5 100644 --- a/typescript-sdk/package.json +++ b/typescript-sdk/package.json @@ -17,13 +17,18 @@ "bin": { "commandlayer": "./dist/cli.cjs" }, - "files": ["dist"], - "engines": { "node": ">=22" }, + "files": [ + "dist" + ], + "engines": { + "node": ">=22" + }, "scripts": { "build": "tsup", "dev": "tsup --watch", "typecheck": "tsc -p tsconfig.json --noEmit", - "prepack": "npm run build" + "prepack": "npm run build", + "test:cli-smoke": "node scripts/cli-smoke.mjs" }, "dependencies": { "commander": "^12.1.0", diff --git a/typescript-sdk/scripts/cli-smoke.mjs b/typescript-sdk/scripts/cli-smoke.mjs new file mode 100644 index 0000000..a6c0469 --- /dev/null +++ b/typescript-sdk/scripts/cli-smoke.mjs @@ -0,0 +1,48 @@ +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const sdkDir = path.resolve(__dirname, ".."); +const cliPath = path.join(sdkDir, "dist", "cli.cjs"); + +function runCase(name, args, expected) { + const result = spawnSync("node", [cliPath, ...args], { + cwd: sdkDir, + encoding: "utf8" + }); + + const output = `${result.stdout || ""}\n${result.stderr || ""}`; + + if (result.status !== expected.exitCode) { + throw new Error( + `${name}: expected exit code ${expected.exitCode}, got ${result.status}.\nOutput:\n${output}` + ); + } + + for (const snippet of expected.includes) { + if (!output.includes(snippet)) { + throw new Error(`${name}: missing expected output snippet: "${snippet}"\nOutput:\n${output}`); + } + } + + console.log(`PASS: ${name}`); +} + +runCase("help output", ["--help"], { + exitCode: 0, + includes: ["Usage: commandlayer", "CommandLayer TypeScript SDK CLI"] +}); + +runCase("argument validation", ["summarize"], { + exitCode: 1, + includes: ["required option '--content ' not specified"] +}); + +runCase("bad JSON path", ["call", "--verb", "summarize", "--body", "{not-json}"], { + exitCode: 1, + includes: ["commandlayer:", "Expected property name or '}' in JSON"] +}); + +console.log("CLI smoke tests passed."); diff --git a/typescript-sdk/tsup.config.ts b/typescript-sdk/tsup.config.ts index ddbbfc6..5bfbdb8 100644 --- a/typescript-sdk/tsup.config.ts +++ b/typescript-sdk/tsup.config.ts @@ -9,7 +9,10 @@ export default defineConfig([ clean: true, target: "es2022", platform: "node", - outDir: "dist" + outDir: "dist", + outExtension({ format }) { + return { js: format === "esm" ? ".mjs" : ".cjs" }; + } }, { entry: ["src/cli.ts"],