diff --git a/README.md b/README.md index c9a6356..758c69b 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ [![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178c6.svg)](https://www.typescriptlang.org/) [![Base · x402](https://img.shields.io/badge/Base-x402-fc6f6f.svg)](https://x402.org) -Open-source toolkit for **h402 — the x402 router for agent capabilities**. One endpoint per task: providers compete behind each capability, h402 auto-pins the best one per call, and settles in Base USDC over x402. +Open-source toolkit for **h402 — the x402 capability store for agents**. Discover a task, inspect its enabled providers and provider-native contracts, then execute one concrete provider path with Base USDC settlement when required. -> x402 is the rail. h402 is the router. +> x402 is the payment rail. h402 is the capability store and execution surface. - **Browse capabilities** → https://h402.hunt.town/catalog - **Docs & agent quickstart** → https://h402.hunt.town/docs @@ -26,15 +26,17 @@ Open-source toolkit for **h402 — the x402 router for agent capabilities**. One ```bash npm install -g @h402/cli -h402 search "web search" -h402 quote web/search --json '{"query":"agent payments"}' -h402 call ai/news # free; no wallet required +h402 search "web search" # compact route/provider summaries +h402 show web/search # full route + all provider contracts +h402 show web/search --provider stableenrich-exa # one full provider-native contract +h402 quote web/search --provider stableenrich-exa --json '{"query":"agent payments"}' +h402 call ai/news # free; omitted provider resolves defaultProvider # Set up a signer only when you want to call a route that returns a payable 402: h402 wallet list # read-only native-binding preflight; [] is OK h402 wallet create --name agent h402 wallet fund --name agent -h402 call web/search --name agent --json '{"query":"agent payments"}' +h402 call web/search --provider stableenrich-exa --name agent --json '{"query":"agent payments"}' ``` Browsing, quoting, and free-route calls do not require a local wallet. Wallet creation creates a local signing wallet only; `h402 auth` creates the optional bonus-credit session. A funded local wallet is required only if the first response is a payable `402`. @@ -47,11 +49,13 @@ OWS wallet creation and signing use native bindings available only on macOS and ## How it works -You call a task (`category/action`) before the CLI resolves a wallet. An initial 2xx is returned directly — `h402.paidBy` says whether it was `free` (no charge) or covered by bonus `credit` from an authenticated session. Only when the first response is an x402 `402 PAYMENT-REQUIRED` does the CLI resolve a funded local wallet, sign a Base USDC EIP-3009 authorization locally, and retry. Pass `--max-usd ` (or store a string `maxUsd`, such as `"0.05"`, in `~/.h402/config.json`) to refuse signing a challenge above that USDC cap. Keys never leave your machine. +Each call uses one concrete provider. Without `--provider`, the CLI resolves the route's current `defaultProvider` from full catalog detail before sending the call. Successes include `h402.cliProviderSelection`, and post-resolution failures include the same metadata at `error.detail.h402.cliProviderSelection`. Its `pinnedCommand` is a shell-escaped fresh-call recipe that preserves non-secret request, backend, wallet, and payment-safety flags and omits passphrases and the previous idempotency key. Passing `--provider` skips default resolution and calls that pinned path directly. A `410` response is never retried automatically: read `error.detail.error.candidates`, inspect the replacement with `h402 show`, then start a new explicit call. An unknown route preserves `error.detail.error.recovery.command`, which points back to `h402 search`. -A successful `call` prints `{ "data": , "meta"?: , "h402": }`: the upstream provider's JSON is under `data`; route-level normalized metadata may appear under `meta`; and `h402` always carries `routeId`, `provider`, `selectedCandidateId`, `routing`, and `paidBy`. `ledgerEntryId` is present for credit or x402-paid calls; `paymentTransaction` and CLI-added `signedAmount` are x402-payment-only fields; free calls omit all three. Optional `followUp` instructions describe async work. Do not discard `meta` — it is part of the route contract when present. On failure the CLI exits non-zero and writes `{ "error": { "message", "detail"? } }` to stderr — `message` is a human-readable diagnostic, and `detail` carries the backend's JSON error when the request reached the backend. +After provider resolution, the CLI sends the request before resolving a wallet. An initial 2xx is returned directly — `h402.paidBy` says whether it was `free` (no charge) or covered by bonus `credit` from an authenticated session. Only when the first response is an x402 `402 PAYMENT-REQUIRED` does the CLI resolve a funded local wallet, sign a Base USDC EIP-3009 authorization locally, and retry the same pinned provider path. Pass `--max-usd ` (or store a string `maxUsd`, such as `"0.05"`, in `~/.h402/config.json`) to refuse signing a challenge above that USDC cap. Keys never leave your machine. -Async routes may return a job receipt instead of the final result. Async parent route IDs end in `-async`; a single-parent follow-up is `-status`, while shared multi-parent follow-ups may use a shared `*-status` name. When `h402.followUp` is present, follow its `method`, `path`, `params.jobId`, `docsUrl`, and `instruction` (or the route's `*-status` capability) until the job completes. The follow-up path is provider-bound, so preserve the provider segment from that path when translating the instruction to CLI form. Match `followUp.method` — GET params go via `--query`, POST bodies via `--json`; the CLI rejects `--query` on a POST (`` means its JSON-encoded object): +A successful `call` prints `{ "data": , "meta"?: , "h402": }`: `data` stays provider-native, optional `meta` remains reserved envelope metadata rather than normalized provider output, and `h402` carries the provider-pinned execution receipt plus CLI-added `cliProviderSelection`. `ledgerEntryId` is present for credit or x402-paid calls; `paymentTransaction` and CLI-added `signedAmount` are x402-payment-only fields; free calls omit all three. Optional `h402.followUp` instructions describe async work. On failure the CLI exits non-zero and writes `{ "error": { "message", "detail"? } }` to stderr — `message` is human-readable and `detail` preserves the backend recovery body unchanged. + +Async routes may return a job receipt instead of the final result. Async parent route IDs end in `-async`; a single-parent follow-up is `-status`, while shared multi-parent follow-ups may use a shared `*-status` name. When `h402.followUp` is present, follow its provider-native `params` object together with `method`, `path`, `docsUrl`, and `instruction` until the provider reports completion. The follow-up path is provider-bound, so preserve its provider segment. Match `followUp.method` — GET params go via `--query`, POST bodies via `--json`; the CLI rejects `--query` on a POST (`` means its JSON-encoded object): ```bash # followUp.method GET (most status polls): @@ -65,7 +69,7 @@ h402 call \ --json '' ``` -Auto routing capability-routes provider-native input to an enabled candidate whose strict schema accepts it. `web/search` accepts common fields such as `query` and `limit` on the default `auto` route, and capable candidates can also accept native fields such as `freshness` without a pin. Use `--provider` only for determinism, deliberate provider selection, or provider-bound follow-ups. +`h402 search` returns compact route/provider summaries. Use `h402 show ` for full provider-native schemas and samples, then pin a provider for reproducibility. ## Development diff --git a/SKILL.md b/SKILL.md index b5e72c3..83d7bee 100644 --- a/SKILL.md +++ b/SKILL.md @@ -2,20 +2,19 @@ name: h402 description: >- Call any agent capability — web search, crypto & market data, maps, social, - finance, security checks, OCR, weather, and more — through one endpoint and - pay per call in Base USDC over x402, using the open-source h402 CLI. Use when + finance, security checks, OCR, weather, and more — through h402's capability + store and concrete provider paths, paying per call in Base USDC over x402 with + the open-source h402 CLI. Use when an agent needs live external data or a paid API without managing per-provider API keys or subscriptions. --- # h402 — pay-per-call capabilities for AI agents -h402 is the **x402 router for agent capabilities**: one canonical endpoint per task. -You call the *task* (e.g. `web/search`), and h402 routes the call to the best provider -and returns a free result or settles a payable challenge in Base USDC. Behind each -capability, providers compete on price and live quality; h402 auto-pins the best one -to your call. No per-vendor API keys or subscriptions; a funded wallet is needed only -for a payable challenge. +h402 is the **x402 capability store for agents**. Discover a task, inspect its enabled +providers and provider-native contracts, then call one concrete provider path. h402 +returns a free result or settles a payable challenge in Base USDC without per-vendor +API keys or subscriptions. A funded wallet is needed only for a payable challenge. ## When to use this @@ -37,9 +36,11 @@ npm install -g @h402/cli # install the CLI Calls go to the production backend (`https://h402.hunt.town`) by default; set `H402_API_URL` or `--api-url` to point at another backend. ```bash -h402 search "AI news" -h402 quote web/search --json '{"query":"agent APIs"}' -h402 call ai/news # direct 2xx; no wallet or payment +h402 search "web search" # compact summaries +h402 show web/search # full route + all provider contracts +h402 show web/search --provider stableenrich-exa # one full provider-native contract +h402 quote web/search --provider stableenrich-exa --json '{"query":"agent APIs"}' +h402 call ai/news # direct 2xx; omitted provider resolves defaultProvider ``` Wallet creation creates a local signing wallet only; `h402 auth` creates the optional bonus-credit session. A funded local wallet is required only if the first response is a payable `402`. @@ -63,26 +64,31 @@ h402 wallet balance --name agent A few dollars of USDC covers hundreds of calls — most routes cost **$0.001–$0.05** each. -## The loop: find → (quote) → call +## The loop: find → inspect → (quote) → call ```bash -# 1. Find a route (returns JSON catalog matches) +# 1. Find a route (compact JSON summaries) h402 search "token holders" -# 2. (optional) Preview the price before paying -h402 quote crypto/token-holders --json '{"tokenAddress":"0x37f0c2915CeCC7e977183B8543Fc0864d03E064C","chain":"base"}' +# 2. Inspect full provider-native contracts and samples +h402 show crypto/token-holders +h402 show crypto/token-holders --provider nansen -# 3. Call it — pays automatically on the 402 challenge, returns the JSON result -h402 call crypto/token-holders --name agent \ - --json '{"tokenAddress":"0x37f0c2915CeCC7e977183B8543Fc0864d03E064C","chain":"base"}' +# 3. (optional) Preview the price for that concrete provider +h402 quote crypto/token-holders --provider nansen \ + --json '{"chain":"base","token_address":"0x833589fCD6eDb6E08f4C7C32D4f71b54bdA02913"}' + +# 4. Call it — pays on a 402 challenge and keeps the provider pinned +h402 call crypto/token-holders --provider nansen --name agent \ + --json '{"chain":"base","token_address":"0x833589fCD6eDb6E08f4C7C32D4f71b54bdA02913"}' ``` - A route id is `category/action` (e.g. `web/search`, `maps/place-details`, `finance/stock-quote`). - `--json '{...}'` is the request body; use `--query '{...}'` for GET query params instead. -- Auto routing capability-routes provider-native input to an enabled candidate whose strict schema accepts it. `web/search` accepts common fields such as `query` and `limit` on the default `auto` route, and capable candidates can also accept native fields such as `freshness` without a pin. Use `--provider` only for determinism, deliberate provider selection, or provider-bound follow-ups. +- Each call uses one concrete provider. Without `--provider`, the CLI resolves the route's current `defaultProvider` from full catalog detail before execution. Successes record the choice in `h402.cliProviderSelection`; post-resolution failures record it at `error.detail.h402.cliProviderSelection`. The shell-escaped `pinnedCommand` is a fresh-call recipe that keeps non-secret request, backend, wallet, and payment-safety flags but omits passphrases and the previous idempotency key. Prefer explicit `--provider` after inspection for reproducibility. A `410` response is never retried automatically; inspect `error.detail.error.candidates` and start a new explicit call only after choosing one. For an unknown route, follow the preserved `error.detail.error.recovery.command` search guidance. - Every command prints **JSON to stdout** (including `wallet fund` and `wallet balance`); failures print to stderr and exit non-zero. -- A successful `call` returns `{ "data": , "meta"?: , "h402": }` — read the provider output from `data`, preserve `meta` when present, and inspect `h402` for `routeId`, `provider`, `selectedCandidateId`, `routing`, and `paidBy`. `ledgerEntryId` is present for credit or x402-paid calls; `paymentTransaction` and CLI-added `signedAmount` are x402-payment-only fields; free calls omit all three. Optional `followUp` describes async work. A failure exits non-zero and writes `{ "error": { "message", "detail"? } }` to stderr — read `error.message` for the reason, `error.detail` for the backend's JSON error when present. -- Async parent route IDs end in `-async`; a single-parent follow-up is `-status`, while shared multi-parent follow-ups may use a shared `*-status` name. If `h402.followUp` is present, the response is a job receipt, not the final result. Follow `h402.followUp.method`, `path`, `params.jobId`, `docsUrl`, and `instruction` (or the route's `*-status` capability) until the async job completes. The follow-up path is provider-bound, so preserve its provider segment in the CLI call. Match `followUp.method` — GET params go via `--query`, POST bodies via `--json`; the CLI rejects `--query` on a POST (`` means its JSON-encoded object): +- A successful `call` returns `{ "data": , "meta"?: , "h402": }` — `data` remains provider-native, optional `meta` is reserved envelope metadata rather than normalized provider output, and `h402` carries the execution receipt plus `cliProviderSelection`. `ledgerEntryId` is present for credit or x402-paid calls; `paymentTransaction` and CLI-added `signedAmount` are x402-payment-only fields; free calls omit all three. Optional `h402.followUp` describes async work. A failure exits non-zero and writes `{ "error": { "message", "detail"? } }` to stderr; `detail` preserves the backend recovery body unchanged. +- Async parent route IDs end in `-async`; a single-parent follow-up is `-status`, while shared multi-parent follow-ups may use a shared `*-status` name. If `h402.followUp` is present, the response is a job receipt, not the final result. Pass its provider-native `params` object according to `method` and preserve the provider from `path`. Match `followUp.method` — GET params go via `--query`, POST bodies via `--json`; the CLI rejects `--query` on a POST (`` means its JSON-encoded object): ```bash # followUp.method GET (most status polls): diff --git a/packages/cli/README.md b/packages/cli/README.md index 8209ee8..362f188 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -3,7 +3,7 @@ [![npm](https://img.shields.io/npm/v/%40h402%2Fcli?label=%40h402%2Fcli)](https://www.npmjs.com/package/@h402/cli) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](../../LICENSE) -Local, non-custodial CLI for [h402](../../README.md) — the x402 router for agent capabilities. Browse and quote without a wallet, call free routes directly, and pay from a local wallet only when challenged over x402. **Private keys never leave your machine.** +Local, non-custodial CLI for [h402](../../README.md) — the x402 capability store for agents. Search compact summaries, inspect provider-native contracts, execute one concrete provider path, and pay from a local wallet only when challenged over x402. **Private keys never leave your machine.** Building an AI agent? See [`SKILL.md`](../../SKILL.md) for an agent-ready walkthrough. @@ -20,15 +20,17 @@ npm install -g @h402/cli ## Quickstart ```bash -h402 search "AI news" # wallet-free catalog browsing -h402 quote web/search --json '{"query":"agent APIs"}' # wallet-free quote -h402 call ai/news # free route; no wallet required +h402 search "web search" # compact wallet-free summaries +h402 show web/search # full route + provider contracts +h402 show web/search --provider stableenrich-exa # one full native contract +h402 quote web/search --provider stableenrich-exa --json '{"query":"agent APIs"}' +h402 call ai/news # free; omitted provider resolves defaultProvider # Only for routes that answer with a payable 402: h402 wallet list # read-only native-binding preflight; [] is OK h402 wallet create --name agent # local signing wallet h402 wallet fund --name agent # Base USDC address + instructions -h402 call web/search --name agent --json '{"query":"agent APIs"}' +h402 call web/search --provider stableenrich-exa --name agent --json '{"query":"agent APIs"}' ``` Browsing, quoting, and free-route calls do not require a local wallet. Wallet creation creates a local signing wallet only; `h402 auth` creates the optional bonus-credit session. A funded local wallet is required only if the first response is a payable `402`. @@ -47,7 +49,8 @@ Calls hit the production backend (`https://h402.hunt.town`) by default — overr | `h402 wallet fund --name ` | Print the Base USDC deposit address and funding instructions | | `h402 auth --name ` | Create an optional backend bonus-credit session with a wallet signature | | `h402 credits` | Show the bonus-credit balance for the signed-in session | -| `h402 search ` | Search the catalog (JSON results) | +| `h402 search ` | Search compact route/provider summaries | +| `h402 show [--provider ]` | Fetch full route or one provider-native contract | | `h402 quote ` | Preview the x402 `PAYMENT-REQUIRED` envelope without paying | | `h402 call ` | Execute a route and pay if challenged; free routes need no wallet | @@ -59,10 +62,10 @@ Run `h402 --help`, `h402 --help`, or `h402 wallet --help` | --- | --- | --- | | `--name ` | wallet create/address/balance/fund; auth; call | Wallet to use (default `h402`) | | `--wallet 0x...` | wallet address/balance/fund; auth; call | Sign with the local wallet that owns this address (must exist locally; must agree with `--name` if both are passed) | -| `--api-url ` | auth, credits, search, quote, call | Backend base URL override (or `H402_API_URL`; default `https://h402.hunt.town`) | +| `--api-url ` | auth, credits, search, show, quote, call | Backend base URL override (or `H402_API_URL`; default `https://h402.hunt.town`) | | `--json '{...}'` | quote, call | Request body (sets method to POST) | | `--query '{...}'` | quote, call | URL query params (GET); values must be strings/numbers/booleans | -| `--provider ` | quote, call | Pin a provider; default is `auto` (h402 picks the best) | +| `--provider ` | show, quote, call | Select a concrete provider; quote/call omission resolves the catalog default, while show omission lists all enabled providers | | `--method GET\|POST` | quote, call | Override the method (inferred from `--json` otherwise) | | `--passphrase []` | wallet create, auth, call | Passphrase for a passphrase-protected wallet; omit the value to be prompted (or `H402_WALLET_PASSPHRASE`) | | `--no-passphrase` | wallet create, auth, call | Force passphrase-less signing even if `H402_WALLET_PASSPHRASE` is set (the default needs no flag) | @@ -75,12 +78,15 @@ Route ids are `category/action`, e.g. `web/search`, `maps/place-details`, `finan ## How a call works +Each call uses one concrete provider. Without `--provider`, the CLI resolves the route's current `defaultProvider` from `/api/catalog/routes/` before any execution request; explicit `--provider` goes straight to that pinned path. Successes include `h402.cliProviderSelection`, and post-resolution failures include the same metadata at `error.detail.h402.cliProviderSelection`. Its `pinnedCommand` is a shell-escaped fresh-call recipe that preserves non-secret request, backend, wallet, and payment-safety flags and omits passphrases and the previous idempotency key. A `410` response is never retried automatically — inspect `error.detail.error.candidates` with `h402 show`, then start a new explicit call. Unknown routes preserve the server's `error.detail.error.recovery.command` search guidance. + ``` h402 call web/search --json '{"query":"..."}' │ - ├─ initial request (before wallet resolution) + ├─ resolve defaultProvider from full route detail + ├─ request /routes//web/search (before wallet resolution) ├─ 2xx → returned directly; h402.paidBy says free or credit - └─ payable 402 → resolve wallet, sign Base USDC locally, then retry the same request + └─ payable 402 → resolve wallet, sign Base USDC locally, then retry that same pinned request ``` If a route returns a payable 402, you're charged the exact per-call price (most paid @@ -106,11 +112,11 @@ confirms that the original authorization was not paid. ## Agents & automation -Every command prints JSON to stdout — `search`, `quote`, `call`, `auth`, `credits`, and `wallet create`/`list`/`restore`/`address`/`balance`/`fund`. +Every command prints JSON to stdout — `search`, `show`, `quote`, `call`, `auth`, `credits`, and `wallet create`/`list`/`restore`/`address`/`balance`/`fund`. -A successful `call` is wrapped as `{ "data": , "meta"?: , "h402": }` — read the upstream provider payload from `data`, preserve `meta` when present, and inspect `h402` for `routeId`, `provider`, `selectedCandidateId`, `routing` (`auto`/`manual`), and `paidBy` (`x402-exact`/`credit`/`free`). `ledgerEntryId` is present for credit or x402-paid calls; `paymentTransaction` and CLI-added `signedAmount` are x402-payment-only fields; free calls omit all three. Optional `followUp` describes async work. A failed call exits non-zero and writes `{ "error": { "message", "detail"? } }` to stderr — `message` is always a readable diagnostic; `detail` holds the backend's JSON error when one was returned. +A successful `call` is wrapped as `{ "data": , "meta"?: , "h402": }` — `data` remains provider-native, optional `meta` remains reserved envelope metadata rather than normalized provider output, and `h402` includes the provider-pinned execution receipt plus CLI-added `cliProviderSelection`. `ledgerEntryId` is present for credit or x402-paid calls; `paymentTransaction` and CLI-added `signedAmount` are x402-payment-only fields; free calls omit all three. Optional `h402.followUp` describes async work. A failed call exits non-zero and writes `{ "error": { "message", "detail"? } }` to stderr; `detail` preserves the backend recovery body unchanged. -Async routes may return a job receipt instead of the final result. Async parent route IDs end in `-async`; a single-parent follow-up is `-status`, while shared multi-parent follow-ups may use a shared `*-status` name. When `h402.followUp` is present, follow its `method`, `path`, `params.jobId`, `docsUrl`, and `instruction` (or the route's `*-status` capability) until the job completes. The follow-up path is provider-bound, so preserve the provider segment from that path when translating the instruction to CLI form. Match `followUp.method` — GET params go via `--query`, POST bodies via `--json`; the CLI rejects `--query` on a POST (`` means its JSON-encoded object): +Async routes may return a job receipt instead of the final result. Async parent route IDs end in `-async`; a single-parent follow-up is `-status`, while shared multi-parent follow-ups may use a shared `*-status` name. When `h402.followUp` is present, pass its provider-native `params` object according to `method` and preserve the provider segment from `path`. Match `followUp.method` — GET params go via `--query`, POST bodies via `--json`; the CLI rejects `--query` on a POST (`` means its JSON-encoded object): ```bash # followUp.method GET (most status polls): @@ -124,12 +130,13 @@ h402 call \ --json '' ``` -Auto routing capability-routes provider-native input to an enabled candidate whose strict schema accepts it. `web/search` accepts common fields such as `query` and `limit` on the default `auto` route, and capable candidates can also accept native fields such as `freshness` without a pin. Use `--provider` only for determinism, deliberate provider selection, or provider-bound follow-ups. +`h402 search` intentionally returns compact summaries. Fetch full schemas, request examples, and provider-native samples with `h402 show ` before pinning. ```bash -h402 search "token holders" # JSON to stdout -h402 call crypto/token-holders --name agent \ - --json '{"tokenAddress":"0x37f0c2915CeCC7e977183B8543Fc0864d03E064C","chain":"base"}' # JSON result, non-zero exit on failure +h402 search "token holders" # compact JSON to stdout +h402 show crypto/token-holders --provider nansen # full native schema/sample +h402 call crypto/token-holders --provider nansen --name agent \ + --json '{"chain":"base","token_address":"0x833589fCD6eDb6E08f4C7C32D4f71b54bdA02913"}' # JSON result, non-zero exit on failure ``` Signing needs no flags for the default passphrase-less wallets. Only when a wallet was created with an opt-in passphrase, `export H402_WALLET_PASSPHRASE=...` (or pass `--passphrase `) — the CLI tells you exactly this when it hits such a wallet non-interactively. diff --git a/packages/cli/src/catalog.ts b/packages/cli/src/catalog.ts new file mode 100644 index 0000000..10a0fbf --- /dev/null +++ b/packages/cli/src/catalog.ts @@ -0,0 +1,200 @@ +import { assertOk, requestJson } from "./api.js"; +import { CliError } from "./errors.js"; +import { assertConcreteProvider, encodeRouteId, type ParsedArgs } from "./utils.js"; + +export type ProviderSelection = { + source: "explicit" | "catalog-default"; + provider: string; + pinnedCommand: string; +}; + +export type CatalogCandidate = { + provider: string; + [key: string]: unknown; +}; + +export type CatalogRoute = { + id: string; + routeKey: string; + defaultProvider: string; + defaultCandidateKey?: string; + candidates: CatalogCandidate[]; + [key: string]: unknown; +}; + +type CatalogRouteEnvelope = { route: CatalogRoute }; +type SelectionCommand = "call" | "quote" | "show"; + +const PINNED_COMMAND_FLAGS: Record = { + call: ["api-url", "json", "query", "method", "name", "wallet", "no-passphrase", "no-credit", "max-usd"], + quote: ["api-url", "json", "query", "method"], + show: ["api-url"] +}; + +const BOOLEAN_PINNED_COMMAND_FLAGS = new Set(["no-passphrase", "no-credit"]); + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function shellArg(value: string) { + return /^[A-Za-z0-9_./:@+-]+$/.test(value) ? value : `'${value.replaceAll("'", `'\\''`)}'`; +} + +function pinnedCommand(command: SelectionCommand, routeId: string, provider: string, flags: ParsedArgs["flags"]) { + const parts = ["h402", command, shellArg(routeId), "--provider", shellArg(provider)]; + for (const name of PINNED_COMMAND_FLAGS[command]) { + const value = flags[name]; + if (value === undefined) continue; + if (BOOLEAN_PINNED_COMMAND_FLAGS.has(name)) { + if (value === true || value === "true") parts.push(`--${name}`); + continue; + } + if (typeof value === "string") parts.push(`--${name}`, shellArg(value)); + } + return parts.join(" "); +} + +function selection( + command: SelectionCommand, + routeId: string, + provider: string, + source: ProviderSelection["source"], + flags: ParsedArgs["flags"] +): ProviderSelection { + return { + source, + provider, + pinnedCommand: pinnedCommand(command, routeId, provider, flags) + }; +} + +function recoveryCandidates(route: CatalogRoute) { + const encodedRoute = encodeRouteId(route.id); + return route.candidates.map((candidate) => ({ + provider: candidate.provider, + ...(typeof candidate.candidateKey === "string" ? { candidateKey: candidate.candidateKey } : {}), + ...(typeof candidate.status === "string" ? { status: candidate.status } : {}), + path: `/routes/${encodeURIComponent(candidate.provider)}/${encodedRoute}` + })); +} + +function invalidCatalogResponse(routeId: string, message: string, detail?: unknown): never { + throw new CliError(`Catalog detail for ${routeId} is invalid: ${message}`, { + error: { code: "invalid_catalog_response", message }, + routeId, + ...(detail === undefined ? {} : { detail }) + }); +} + +function parseCatalogRoute(routeId: string, body: unknown): CatalogRoute { + if (!isRecord(body) || !isRecord(body.route)) { + return invalidCatalogResponse(routeId, "expected a route object"); + } + const route = body.route; + if (typeof route.id !== "string" || route.id !== routeId) { + return invalidCatalogResponse(routeId, "id does not match the requested route", { id: route.id }); + } + if (typeof route.defaultProvider !== "string" || !route.defaultProvider) { + return invalidCatalogResponse(routeId, "defaultProvider is missing"); + } + try { + assertConcreteProvider(route.defaultProvider); + } catch (error) { + return invalidCatalogResponse(routeId, "defaultProvider is not a concrete provider slug", { + defaultProvider: route.defaultProvider, + reason: error instanceof Error ? error.message : String(error) + }); + } + if (!Array.isArray(route.candidates) || route.candidates.length === 0) { + return invalidCatalogResponse(routeId, "enabled candidates are missing"); + } + const candidates: CatalogCandidate[] = route.candidates.map((candidate, index) => { + if (!isRecord(candidate) || typeof candidate.provider !== "string" || !candidate.provider) { + return invalidCatalogResponse(routeId, `candidate ${index} lacks provider`); + } + try { + assertConcreteProvider(candidate.provider); + } catch (error) { + return invalidCatalogResponse(routeId, `candidate ${index} has an invalid provider slug`, { + provider: candidate.provider, + reason: error instanceof Error ? error.message : String(error) + }); + } + return candidate as CatalogCandidate; + }); + if (!candidates.some((candidate) => candidate.provider === route.defaultProvider && candidate.status === "enabled")) { + return invalidCatalogResponse(routeId, "defaultProvider is not an enabled candidate", { + defaultProvider: route.defaultProvider, + candidates: recoveryCandidates({ ...(route as CatalogRoute), candidates }) + }); + } + return { ...(route as CatalogRoute), candidates }; +} + +export async function fetchCatalogRoute(apiUrl: string, routeId: string): Promise { + const body = assertOk(await requestJson(apiUrl, `/api/catalog/routes/${encodeRouteId(routeId)}`)); + return { route: parseCatalogRoute(routeId, body) }; +} + +export async function resolveProvider( + apiUrl: string, + routeId: string, + explicitProvider: string | undefined, + command: "call" | "quote", + flags: ParsedArgs["flags"], + effectiveMaxUsd?: string +): Promise<{ selection: ProviderSelection; route?: CatalogRoute }> { + const effectiveFlags = { + ...flags, + "api-url": apiUrl, + ...(effectiveMaxUsd === undefined ? {} : { "max-usd": effectiveMaxUsd }) + }; + if (explicitProvider !== undefined) { + return { selection: selection(command, routeId, assertConcreteProvider(explicitProvider), "explicit", effectiveFlags) }; + } + const { route } = await fetchCatalogRoute(apiUrl, routeId); + return { + route, + selection: selection(command, routeId, route.defaultProvider, "catalog-default", effectiveFlags) + }; +} + +export function selectCatalogCandidate(route: CatalogRoute, provider: string) { + const candidate = route.candidates.find((item) => item.provider === provider); + if (candidate) { + return candidate; + } + const message = `Provider "${provider}" is not enabled for ${route.id}.`; + throw new CliError(message, { + error: { + code: "provider_unavailable", + message, + routeId: route.id, + requestedProvider: provider, + defaultProvider: route.defaultProvider, + candidates: recoveryCandidates(route) + } + }); +} + +export function explicitShowSelection(apiUrl: string, routeId: string, provider: string, flags: ParsedArgs["flags"]) { + return selection("show", routeId, provider, "explicit", { ...flags, "api-url": apiUrl }); +} + +export function withProviderSelection(body: unknown, providerSelection: ProviderSelection) { + if (isRecord(body)) { + const h402 = isRecord(body.h402) ? body.h402 : {}; + return { ...body, h402: { ...h402, cliProviderSelection: providerSelection } }; + } + return { data: body, h402: { cliProviderSelection: providerSelection } }; +} + +export function withProviderSelectionError(error: unknown, providerSelection: ProviderSelection) { + const existingDetail = error instanceof CliError && isRecord(error.detail) ? error.detail : {}; + const h402 = isRecord(existingDetail.h402) ? existingDetail.h402 : {}; + return new CliError(error instanceof Error ? error.message : String(error), { + ...existingDetail, + h402: { ...h402, cliProviderSelection: providerSelection } + }); +} diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index fb62632..a53513e 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -1,11 +1,31 @@ import { randomUUID } from "node:crypto"; import { assertOk, backendErrorCode, IDEMPOTENCY_MONEY_GUIDANCE, requestJson, type ApiResponse } from "./api.js"; import { BASE_USDC_BALANCE_ASSET, BASE_USDC_BALANCE_NETWORK, getBaseUsdcBalance } from "./base-usdc-balance.js"; +import { + explicitShowSelection, + fetchCatalogRoute, + resolveProvider, + selectCatalogCandidate, + withProviderSelection, + withProviderSelectionError +} from "./catalog.js"; import { backendUrl, loadConfig, updateConfig, type CliConfig } from "./config.js"; import { CliError } from "./errors.js"; import { createOwsWallet, getOwsWallet, listOwsWallets, signOwsMessage } from "./ows.js"; import { promptPassphrase } from "./prompt.js"; -import { buildProxyPath, flagBoolean, flagString, parseJsonFlag, parseQueryFlag, printJson, requireValue, resolveMethod, type ParsedArgs } from "./utils.js"; +import { + assertConcreteProvider, + buildProxyPath, + encodeRouteId, + flagBoolean, + flagString, + parseJsonFlag, + parseQueryFlag, + printJson, + requireValue, + resolveMethod, + type ParsedArgs +} from "./utils.js"; import { createPaymentSignatureHeader, paymentRequiredFromResponse, selectBaseUsdcRequirement, X402_HEADERS } from "./x402.js"; const DEFAULT_WALLET_NAME = "h402"; @@ -309,6 +329,17 @@ function rejectQueryOnPost(method: "GET" | "POST", query: Record, idempotency export async function callCommand(args: ParsedArgs) { rejectExtraPositionals(args, 2, "call", "Did you forget --json for a request body or --query for URL parameters? Run: h402 call --help"); - const config = await loadConfig(); - const apiUrl = backendUrl(config, flagString(args.flags, "api-url")); const routeId = requireValue(args.positional[1], "route id is required"); + encodeRouteId(routeId); const body = parseJsonFlag(args.flags); const query = parseQueryFlag(args.flags); - const provider = flagString(args.flags, "provider"); + const explicitProvider = explicitProviderFlag(args.flags); const method = resolveMethod(args.flags, body !== undefined); rejectQueryOnPost(method, query); const idempotencyKey = flagString(args.flags, "idempotency-key", randomUUID()) as string; + const config = await loadConfig(); + const apiUrl = backendUrl(config, flagString(args.flags, "api-url")); const paymentCap = maxUsd(args, config); const token = config.sessions[apiUrl]; - const path = buildProxyPath(routeId, query, provider); - const requestBody = body === undefined ? undefined : JSON.stringify(body); - const headers: Record = { - "idempotency-key": idempotencyKey - }; - - if (token && !flagBoolean(args.flags, "no-credit")) { - headers.authorization = `Bearer ${token}`; - } + const { selection: providerSelection } = await resolveProvider(apiUrl, routeId, explicitProvider, "call", args.flags, paymentCap?.raw); let signedRequestSent = false; try { + const path = buildProxyPath(routeId, providerSelection.provider, query); + const requestBody = body === undefined ? undefined : JSON.stringify(body); + const headers: Record = { + "idempotency-key": idempotencyKey + }; + + if (token && !flagBoolean(args.flags, "no-credit")) { + headers.authorization = `Bearer ${token}`; + } + const first = await requestJson(apiUrl, path, { method, headers, @@ -515,7 +590,7 @@ export async function callCommand(args: ParsedArgs) { // A 2xx means the route answered without payment (free, or covered by credit). // A non-2xx first response (incl. an unparseable 402) is a real error: assertOk // exits non-zero instead of printing the error body as a successful result. - await printJson(assertOk(first)); + await printJson(withProviderSelection(assertOk(first), providerSelection)); return; } @@ -551,11 +626,11 @@ export async function callCommand(args: ParsedArgs) { } } - await printJson(withSignedAmount(assertOk(paid), accepted)); + await printJson(withProviderSelection(withSignedAmount(assertOk(paid), accepted), providerSelection)); } catch (error) { const settlementRiskIsUnresolved = (signedRequestSent || isPaymentSettlementFailure(error)) && !isConclusiveSettlementFailure(error, idempotencyKey); const guardedError = settlementRiskIsUnresolved ? withSettlementRiskGuidance(error) : error; - throw withIdempotencyKey(guardedError, idempotencyKey); + throw withProviderSelectionError(withIdempotencyKey(guardedError, idempotencyKey), providerSelection); } } diff --git a/packages/cli/src/help.ts b/packages/cli/src/help.ts index 2d7f23d..ad33a47 100644 --- a/packages/cli/src/help.ts +++ b/packages/cli/src/help.ts @@ -22,7 +22,8 @@ const FLAGS = { apiUrl: { name: "api-url", value: "", desc: "Backend base URL (or H402_API_URL; default https://h402.hunt.town)" }, json: { name: "json", value: "'{...}'", desc: "Request body (sets method to POST)" }, query: { name: "query", value: "'{...}'", desc: "URL query params; values must be string/number/boolean" }, - provider: { name: "provider", value: "", desc: "Pin a provider (default auto)" }, + provider: { name: "provider", value: "", desc: "Select a concrete provider (omitted: resolve the catalog default)" }, + showProvider: { name: "provider", value: "", desc: "Select one concrete provider (omitted: list all enabled providers)" }, method: { name: "method", value: "GET|POST", desc: "Override the HTTP method" }, passphrase: { name: "passphrase", @@ -69,15 +70,21 @@ export const COMMANDS: Record = { credits: { usage: "h402 credits [flags]", summary: "Show the bonus-credit balance for the signed-in session", flags: [FLAGS.apiUrl] }, search: { usage: "h402 search [flags]", - summary: "Search the catalog without a wallet (JSON results)", + summary: "Search the catalog without a wallet (compact JSON results)", flags: [FLAGS.apiUrl, FLAGS.limit], examples: ['h402 search "web search"'] }, + show: { + usage: "h402 show [flags]", + summary: "Inspect a route and its full provider-native contracts", + flags: [FLAGS.apiUrl, FLAGS.showProvider], + examples: ["h402 show web/search", "h402 show web/search --provider stableenrich-exa"] + }, quote: { usage: "h402 quote [flags]", summary: "Preview the x402 PAYMENT-REQUIRED envelope without paying or a wallet", flags: [FLAGS.apiUrl, FLAGS.json, FLAGS.query, FLAGS.provider, FLAGS.method], - examples: ["h402 quote web/search --json '{\"query\":\"agent APIs\"}'"] + examples: ["h402 quote web/search --provider stableenrich-exa --json '{\"query\":\"agent APIs\"}'"] }, call: { usage: "h402 call [flags]", @@ -96,7 +103,7 @@ export const COMMANDS: Record = { FLAGS.maxUsd, FLAGS.idempotencyKey ], - examples: ["h402 call ai/news", "h402 call web/search --name agent --json '{\"query\":\"agent APIs\"}'"] + examples: ["h402 call ai/news", "h402 call web/search --provider stableenrich-exa --name agent --json '{\"query\":\"agent APIs\"}'"] } }; @@ -147,7 +154,7 @@ function renderFlag(flag: Flag): string { } export function topLevelHelp(): string { - const lines = ["h402 — the x402 router for agent capabilities", "", "Usage: h402 [flags]", "", "Commands:"]; + const lines = ["h402 — the x402 capability store", "", "Usage: h402 [flags]", "", "Commands:"]; for (const [name, spec] of Object.entries(COMMANDS)) { lines.push(` ${name.padEnd(10)} ${spec.summary}`); } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 6bbd7a6..ac8fa96 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -5,6 +5,7 @@ import { creditsCommand, quoteCommand, searchCommand, + showCommand, walletCommand } from "./commands.js"; import { errorEnvelope } from "./errors.js"; @@ -45,6 +46,7 @@ async function main() { if (command === "auth") return authCommand(args); if (command === "credits") return creditsCommand(args); if (command === "search") return searchCommand(args); + if (command === "show") return showCommand(args); if (command === "quote") return quoteCommand(args); if (command === "call") return callCommand(args); diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils.ts index 70e5589..c0c590f 100644 --- a/packages/cli/src/utils.ts +++ b/packages/cli/src/utils.ts @@ -109,7 +109,7 @@ export function parseQueryFlag(flags: Record) { if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { throw new Error(`Flag --query must be a JSON object, e.g. --query '{"q":"Seoul"}' (got ${JSON.stringify(value)}).`); } - return parsed as Record; + return validateQueryParams(parsed as Record); } function writeStream(stream: NodeJS.WritableStream, text: string): Promise { @@ -152,26 +152,54 @@ export function requireValue(value: T | undefined | null, message: string): T return value; } -// The provider rides the path: /routes/{auto|provider}/{category}/{action}. -export function buildProxyPath(routeId: string, query?: Record, provider?: string) { +const PINNED_PATH_SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +export function assertConcreteProvider(provider: string) { + if (typeof provider !== "string" || !provider) { + throw new Error("Provider is required for a pinned route path"); + } + if (provider.toLowerCase() === "auto") { + throw new Error('Provider "auto" is reserved for the retired routing endpoint; select a concrete provider.'); + } + if (!PINNED_PATH_SLUG.test(provider)) { + throw new Error(`Provider must be a lowercase slug using letters, numbers, and single hyphens (got ${JSON.stringify(provider)}).`); + } + return provider; +} + +export function encodeRouteId(routeId: string) { const parts = routeId.split("/"); if (parts.length !== 2 || parts.some((part) => !part)) { throw new Error("Route id must look like category/action"); } - const path = `/routes/${encodeURIComponent(provider ?? "auto")}/${parts.map(encodeURIComponent).join("/")}`; - if (!query) { - return path; + for (const part of parts) { + if (!PINNED_PATH_SLUG.test(part)) { + throw new Error(`Route id segment must be a lowercase slug using letters, numbers, and single hyphens (got ${JSON.stringify(part)}).`); + } } - const searchParams = new URLSearchParams(); + return parts.map(encodeURIComponent).join("/"); +} + +export function validateQueryParams(query: Record) { for (const [key, value] of Object.entries(query)) { - // Reject anything we can't faithfully serialize as a single URL query value. - // Silently dropping arrays/objects/null would send the request with missing - // filters and return unintended results. if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") { throw new Error( `--query value for "${key}" must be a string, number, or boolean; arrays, objects, and null are not supported. Use --json for structured request bodies.` ); } + } + return query as Record; +} + +// Every execution path is provider-pinned. Callers must resolve an explicit +// provider or the catalog's current display default before building this path. +export function buildProxyPath(routeId: string, provider: string, query?: Record) { + const path = `/routes/${encodeURIComponent(assertConcreteProvider(provider))}/${encodeRouteId(routeId)}`; + if (!query) { + return path; + } + const searchParams = new URLSearchParams(); + for (const [key, value] of Object.entries(validateQueryParams(query))) { searchParams.set(key, String(value)); } const queryString = searchParams.toString(); diff --git a/packages/cli/tests/api.test.ts b/packages/cli/tests/api.test.ts index db174e6..66c36db 100644 --- a/packages/cli/tests/api.test.ts +++ b/packages/cli/tests/api.test.ts @@ -23,7 +23,7 @@ describe("requestJson", () => { }); vi.stubGlobal("fetch", fetch); - await expect(requestJson("https://api.example", "/routes/auto/web/search")).resolves.toMatchObject({ status: 200, body: { ok: true } }); + await expect(requestJson("https://api.example", "/routes/demo/web/search")).resolves.toMatchObject({ status: 200, body: { ok: true } }); expect(H402_HTTP_TIMEOUT_MS).toBeGreaterThanOrEqual(450_000); const init = fetch.mock.calls[0]?.[1] as { dispatcher?: unknown; headers?: HeadersInit } | undefined; @@ -71,7 +71,7 @@ describe("requestJson", () => { vi.fn(async () => response(500, { error: { message: "boom" } })) ); - const result = await requestJson("https://staging.example", "/routes/auto/web/search"); + const result = await requestJson("https://staging.example", "/routes/demo/web/search"); const error = (() => { try { assertOk(result); @@ -83,7 +83,7 @@ describe("requestJson", () => { expect(error).toBeInstanceOf(CliError); expect(error).toMatchObject({ message: "Request failed: 500 Error: boom", - detail: { backendUrl: "https://staging.example", url: "https://staging.example/routes/auto/web/search", error: { message: "boom" } } + detail: { backendUrl: "https://staging.example", url: "https://staging.example/routes/demo/web/search", error: { message: "boom" } } }); }); @@ -97,7 +97,7 @@ describe("requestJson", () => { it(`adds no-double-charge guidance for ${code}`, () => { const result = { backendUrl: "https://staging.example", - url: "https://staging.example/routes/auto/web/search", + url: "https://staging.example/routes/demo/web/search", status: 409, statusText: "Conflict", headers: new Headers(), @@ -117,7 +117,7 @@ describe("requestJson", () => { message: expect.stringMatching(/do NOT sign or pay with a new idempotency key/i), detail: { backendUrl: "https://staging.example", - url: "https://staging.example/routes/auto/web/search", + url: "https://staging.example/routes/demo/web/search", error: { code, message: "idempotency key conflict" } } }); @@ -127,7 +127,7 @@ describe("requestJson", () => { it("leaves payment settlement proof validation to the call command", () => { const result = { backendUrl: "https://staging.example", - url: "https://staging.example/routes/auto/web/search", + url: "https://staging.example/routes/demo/web/search", status: 409, statusText: "Conflict", headers: new Headers(), diff --git a/packages/cli/tests/call-free-route.test.ts b/packages/cli/tests/call-free-route.test.ts index a153873..d2d7c26 100644 --- a/packages/cli/tests/call-free-route.test.ts +++ b/packages/cli/tests/call-free-route.test.ts @@ -28,7 +28,7 @@ vi.mock("../src/ows.js", () => ({ const { callCommand } = await import("../src/commands"); function args(flags: ParsedArgs["flags"] = {}): ParsedArgs { - return { positional: ["call", "ai/image-generate-async-status"], flags }; + return { positional: ["call", "ai/image-generate-async-status"], flags: { provider: "stablestudio-image", ...flags } }; } function res(status: number, body: unknown, headers: Record = {}) { @@ -61,7 +61,7 @@ describe("callCommand free routes", () => { await callCommand(args({ query: '{"jobId":"job_123"}' })); expect(fetch).toHaveBeenCalledWith( - "https://test.example/routes/auto/ai/image-generate-async-status?jobId=job_123", + "https://test.example/routes/stablestudio-image/ai/image-generate-async-status?jobId=job_123", expect.objectContaining({ method: "GET" }) ); expect(stdout).toHaveBeenCalledWith(expect.stringContaining("complete")); @@ -72,7 +72,21 @@ describe("callCommand free routes", () => { const fetch = vi.fn(async () => res(402, challenge)); vi.stubGlobal("fetch", fetch); - await expect(callCommand(args())).rejects.toThrow(/No address known for wallet "h402"/); + const error = await callCommand(args()).catch((thrown: unknown) => thrown); + + expect(error).toMatchObject({ + message: expect.stringMatching(/No address known for wallet "h402"/), + detail: { + h402: { + cliProviderSelection: { + source: "explicit", + provider: "stablestudio-image", + pinnedCommand: + "h402 call ai/image-generate-async-status --provider stablestudio-image --api-url https://test.example" + } + } + } + }); expect(fetch).toHaveBeenCalled(); }); }); diff --git a/packages/cli/tests/call-max-usd.test.ts b/packages/cli/tests/call-max-usd.test.ts index ee3817a..90931a9 100644 --- a/packages/cli/tests/call-max-usd.test.ts +++ b/packages/cli/tests/call-max-usd.test.ts @@ -38,7 +38,7 @@ function config(overrides: Partial = {}): CliConfig { } function args(flags: ParsedArgs["flags"] = {}): ParsedArgs { - return { positional: ["call", "web/search"], flags }; + return { positional: ["call", "web/search"], flags: { provider: "demo", ...flags } }; } function challenge(amount: unknown) { @@ -134,7 +134,20 @@ describe("callCommand --max-usd", () => { const fetch = vi.fn().mockResolvedValueOnce(res(402, challenge("50000"))); vi.stubGlobal("fetch", fetch); - await expect(callCommand(args({ "max-usd": "0.049999" }))).rejects.toThrow(/exceeds --max-usd 0.049999/); + const error = await callCommand(args({ "max-usd": "0.049999" })).catch((thrown: unknown) => thrown); + + expect(error).toMatchObject({ + message: expect.stringMatching(/exceeds --max-usd 0.049999/), + detail: { + h402: { + cliProviderSelection: { + source: "explicit", + provider: "demo", + pinnedCommand: "h402 call web/search --provider demo --api-url https://test.example --max-usd 0.049999" + } + } + } + }); expect(signOwsTypedData).not.toHaveBeenCalled(); expect(fetch).toHaveBeenCalledTimes(1); }); @@ -143,7 +156,18 @@ describe("callCommand --max-usd", () => { loadConfig.mockResolvedValue(config({ maxUsd: "0.01" })); vi.stubGlobal("fetch", vi.fn().mockResolvedValueOnce(res(402, challenge("50000")))); - await expect(callCommand(args())).rejects.toThrow(/exceeds --max-usd 0.01/); + const error = await callCommand(args()).catch((thrown: unknown) => thrown); + + expect(error).toMatchObject({ + message: expect.stringMatching(/exceeds --max-usd 0.01/), + detail: { + h402: { + cliProviderSelection: { + pinnedCommand: "h402 call web/search --provider demo --api-url https://test.example --max-usd 0.01" + } + } + } + }); }); it("lets --max-usd override config.maxUsd", async () => { diff --git a/packages/cli/tests/call-settlement-retry.test.ts b/packages/cli/tests/call-settlement-retry.test.ts index b17be8d..c2be8a9 100644 --- a/packages/cli/tests/call-settlement-retry.test.ts +++ b/packages/cli/tests/call-settlement-retry.test.ts @@ -77,6 +77,21 @@ function requestHeaders(fetch: ReturnType, index: number) { return new Headers(init?.headers); } +const catalogRoute = { + id: "web/search", + routeKey: "search", + category: "web", + action: "search", + defaultProvider: "demo", + candidates: [{ provider: "demo", status: "enabled", inputSchema: { type: "object" }, inputExample: { query: "h402" } }] +}; + +function omittedProviderArgs() { + const parsed = args(); + delete parsed.flags.provider; + return parsed; +} + describe("callCommand pending settlement reconciliation", () => { let stdout: ReturnType; @@ -127,6 +142,61 @@ describe("callCommand pending settlement reconciliation", () => { expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"ok": true')); }); + it("keeps a catalog-selected provider path through the payable retry", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(res(200, { route: catalogRoute })) + .mockResolvedValueOnce(res(402, challenge())) + .mockResolvedValueOnce(res(200, { data: { ok: true }, h402: { provider: "demo" } })); + vi.stubGlobal("fetch", fetch); + + await callCommand(omittedProviderArgs()); + + expect(fetch).toHaveBeenCalledTimes(3); + expect(String(fetch.mock.calls[0][0])).toBe("https://test.example/api/catalog/routes/web/search"); + expect(String(fetch.mock.calls[1][0])).toBe("https://test.example/routes/demo/web/search"); + expect(String(fetch.mock.calls[2][0])).toBe(String(fetch.mock.calls[1][0])); + expect(requestHeaders(fetch, 1).get("idempotency-key")).toBe(IDEMPOTENCY_KEY); + expect(requestHeaders(fetch, 2).get("idempotency-key")).toBe(IDEMPOTENCY_KEY); + expect(requestHeaders(fetch, 2).get("PAYMENT-SIGNATURE")).toBeTruthy(); + expect(signOwsTypedData).toHaveBeenCalledTimes(1); + }); + + it("does not retry or switch providers after a signed 410", async () => { + const recovery = { + error: { + code: "provider_unavailable", + message: "Provider changed", + routeId: "web/search", + requestedProvider: "demo", + defaultProvider: "other", + candidates: [{ provider: "other", candidateKey: "search:other", status: "enabled", path: "/routes/other/web/search" }] + } + }; + const fetch = vi.fn().mockResolvedValueOnce(res(402, challenge())).mockResolvedValueOnce(res(410, recovery)); + vi.stubGlobal("fetch", fetch); + + const error = await callCommand(args()).catch((thrown: unknown) => thrown); + + expect(fetch).toHaveBeenCalledTimes(2); + expect(String(fetch.mock.calls[0][0])).toBe("https://test.example/routes/demo/web/search"); + expect(String(fetch.mock.calls[1][0])).toBe(String(fetch.mock.calls[0][0])); + expect(signOwsTypedData).toHaveBeenCalledTimes(1); + expect(error).toMatchObject({ + detail: expect.objectContaining({ + error: recovery.error, + h402: { + cliProviderSelection: { + source: "explicit", + provider: "demo", + pinnedCommand: "h402 call web/search --provider demo --api-url https://test.example --json '{\"query\":\"h402\"}'" + } + } + }) + }); + expect((error as Error).message).toMatch(/do NOT sign or pay with a new idempotency key/i); + }); + it("stops after bounded retries without creating another authorization", async () => { const fetch = vi .fn() diff --git a/packages/cli/tests/cli-help.test.ts b/packages/cli/tests/cli-help.test.ts index aaea77a..513efe2 100644 --- a/packages/cli/tests/cli-help.test.ts +++ b/packages/cli/tests/cli-help.test.ts @@ -25,7 +25,7 @@ describe("version + command discovery", () => { describe("help rendering", () => { it("top-level help lists every command", () => { const help = topLevelHelp(); - for (const command of ["wallet", "auth", "credits", "search", "quote", "call"]) { + for (const command of ["wallet", "auth", "credits", "search", "show", "quote", "call"]) { expect(help).toContain(command); } }); @@ -37,11 +37,26 @@ describe("help rendering", () => { expect(help).toContain("h402 call web/search"); }); - it("the call help includes an auto-routed example", () => { + it("the call help includes a provider-pinned paid example", () => { const exampleLines = commandHelp(["call"]) .split("\n") .filter((line) => line.includes("h402 call web/search")); - expect(exampleLines.length).toBeGreaterThan(0); + expect(exampleLines).toHaveLength(1); + expect(exampleLines[0]).toContain("--provider stableenrich-exa"); + expect(exampleLines[0]).not.toContain(" auto"); + }); + + it("describes omitted-provider behavior per command", () => { + expect(commandHelp(["show"])).toMatch(/omitted: list all enabled providers/i); + expect(commandHelp(["quote"])).toMatch(/omitted: resolve the catalog default/i); + expect(commandHelp(["call"])).toMatch(/omitted: resolve the catalog default/i); + }); + + it("documents full provider detail through show", () => { + const help = commandHelp(["show"]); + expect(help).toContain("h402 show web/search"); + expect(help).toContain("--provider"); + expect(help).toContain("full provider-native contracts"); }); it("describes wallet-free discovery, quoting, and conditional call payment", () => { @@ -53,6 +68,7 @@ describe("help rendering", () => { expect(call).toContain("Execute a route and pay if challenged"); expect(call).toContain("h402 call ai/news"); expect(call).not.toContain("Execute a paid proxy call"); + expect(top).toContain("x402 capability store"); }); it("distinguishes a signing wallet from an optional bonus-credit session", () => { diff --git a/packages/cli/tests/docs.test.ts b/packages/cli/tests/docs.test.ts index 13bc9bd..44304a9 100644 --- a/packages/cli/tests/docs.test.ts +++ b/packages/cli/tests/docs.test.ts @@ -11,32 +11,42 @@ const DOC_FILES: Record = { "SKILL.md": path.join(here, "..", "..", "..", "SKILL.md") }; -describe("doc examples stay runnable against the catalog contract", () => { +describe("doc examples stay runnable against the provider-pinned catalog contract", () => { for (const [label, file] of Object.entries(DOC_FILES)) { - it(`${label}: does not describe web/search limit as provider-specific`, () => { + it(`${label}: teaches compact search followed by full route/provider inspection`, () => { const text = readFileSync(file, "utf8"); - expect(text).toMatch(/`web\/search` (accepts common fields such as `query` and `limit`|fields such as `query` and `limit` are common fields)/); - expect(text).not.toContain('Provider-specific fields (e.g. `limit` on `web/search`)'); - expect(text).not.toMatch(/limit[^\n]+web\/search[^\n]+provider-specific/i); - expect(text).not.toMatch(/provider-specific[^\n]+limit[^\n]+web\/search/i); + expect(text).toContain('h402 search "web search"'); + expect(text).toContain("h402 show web/search"); + expect(text).toContain("h402 show web/search --provider stableenrich-exa"); + expect(text).toContain("defaultProvider"); }); - it(`${label}: documents the current call envelope and async follow-up contract`, () => { + it(`${label}: documents provider-native output and explicit CLI selection metadata`, () => { const text = readFileSync(file, "utf8"); - expect(text).toContain('"meta"?: '); + expect(text).toContain( + '{ "data": , "meta"?: , "h402": }' + ); + expect(text).toContain("reserved envelope metadata rather than normalized provider output"); + expect(text).toContain("h402.cliProviderSelection"); expect(text).toContain("paymentTransaction"); expect(text).toContain("h402.followUp"); - expect(text).toContain("params.jobId"); - expect(text).not.toContain('Provider-specific fields (e.g. `limit` on `web/search`)'); - expect(text).not.toContain('{ "data": , "h402": }'); + expect(text).not.toMatch(/params\.jobId/); }); } + it("positions h402 as a capability store, not a runtime router", () => { + for (const file of Object.values(DOC_FILES)) { + const text = readFileSync(file, "utf8"); + expect(text).toMatch(/capabilit(?:y|ies) store/i); + expect(text).not.toMatch(/x402 router|canonical endpoint|providers compete behind|auto-pins/i); + } + }); + it("package README flag table matches command-specific strict flag handling", () => { const text = readFileSync(DOC_FILES["package README.md"], "utf8"); expect(text).toContain("| `--name ` | wallet create/address/balance/fund; auth; call |"); expect(text).toContain("| `--wallet 0x...` | wallet address/balance/fund; auth; call |"); - expect(text).toContain("| `--api-url ` | auth, credits, search, quote, call |"); + expect(text).toContain("| `--api-url ` | auth, credits, search, show, quote, call |"); expect(text).toContain("| `--passphrase []` | wallet create, auth, call |"); expect(text).toContain("| `--no-passphrase` | wallet create, auth, call |"); expect(text).not.toContain("| `--name ` | all |"); @@ -45,7 +55,7 @@ describe("doc examples stay runnable against the catalog contract", () => { }); it("payable token-holder examples use one valid catalog address instead of an EVM placeholder", () => { - const validInput = '{"tokenAddress":"0x37f0c2915CeCC7e977183B8543Fc0864d03E064C","chain":"base"}'; + const validInput = '{"chain":"base","token_address":"0x833589fCD6eDb6E08f4C7C32D4f71b54bdA02913"}'; for (const label of ["package README.md", "SKILL.md"]) { expect(readFileSync(DOC_FILES[label], "utf8")).toContain(validInput); } @@ -96,17 +106,24 @@ describe("doc examples stay runnable against the catalog contract", () => { } }); - it("documents capability-aware auto routing and provider-bound async follow-ups", () => { + it("documents provider-first resolution, no auto path, and provider-bound async follow-ups", () => { const asyncRouteConvention = "Async parent route IDs end in `-async`; a single-parent follow-up is `-status`, while shared multi-parent follow-ups may use a shared `*-status` name."; for (const file of Object.values(DOC_FILES)) { const text = readFileSync(file, "utf8"); - expect(text).toContain("Auto routing capability-routes provider-native input to an enabled candidate whose strict schema accepts it."); - expect(text).toContain("Use `--provider` only for determinism, deliberate provider selection, or provider-bound follow-ups."); + expect(text).toContain("Each call uses one concrete provider."); + expect(text).toContain("Without `--provider`, the CLI resolves the route's current `defaultProvider`"); + expect(text).toContain("A `410` response is never retried automatically"); + expect(text).toContain("error.detail.error.candidates"); + expect(text).toContain("error.detail.error.recovery.command"); + expect(text).toContain("post-resolution failures"); + expect(text).toContain("fresh-call recipe"); + expect(text).toContain("omits passphrases and the previous idempotency key"); expect(text).toContain(asyncRouteConvention); expect(text).toContain("h402 call "); expect(text).toContain("--provider "); - expect(text).not.toMatch(/provider-specific fields[^\n]+require pinning/i); + expect(text).not.toMatch(/auto[- ]rout/i); + expect(text).not.toContain("/routes/auto/"); // The template must stay method-aware: GET polls use --query, POST polls // use --json — the CLI rejects --query combined with POST. diff --git a/packages/cli/tests/provider-selection.test.ts b/packages/cli/tests/provider-selection.test.ts new file mode 100644 index 0000000..9e3854d --- /dev/null +++ b/packages/cli/tests/provider-selection.test.ts @@ -0,0 +1,431 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CliError, errorEnvelope } from "../src/errors"; +import type { ParsedArgs } from "../src/utils"; + +const { loadConfig } = vi.hoisted(() => ({ loadConfig: vi.fn() })); +vi.mock("../src/config.js", async (importOriginal) => ({ + ...(await importOriginal()), + loadConfig, + updateConfig: vi.fn() +})); +vi.mock("../src/ows.js", () => ({ + createOwsWallet: vi.fn(), + getOwsWallet: vi.fn(), + listOwsWallets: vi.fn(), + signOwsMessage: vi.fn(), + signOwsTypedData: vi.fn() +})); + +const { callCommand, quoteCommand, searchCommand, showCommand } = await import("../src/commands"); + +const route = { + id: "web/search", + routeKey: "search", + category: "web", + action: "search", + title: "Web search", + summary: "Search the web", + method: "POST", + provider: "stableenrich-exa", + inputSchema: { type: "object", properties: { query: { type: "string" } } }, + inputExample: { query: "default" }, + price: { mode: "fixed", amountUsd: 0.01 }, + defaultProvider: "stableenrich-exa", + defaultCandidateKey: "web/search:stableenrich-exa", + flow: { parent: "web/research-task" }, + flowFollowUps: ["web/search-status"], + candidates: [ + { + candidateKey: "search:stableenrich-exa", + provider: "stableenrich-exa", + method: "POST", + status: "enabled", + price: { mode: "fixed", usd: 0.01 }, + inputSchema: { type: "object", required: ["query"] }, + inputExample: { query: "h402" }, + sampleOutput: { results: [{ title: "h402" }] } + }, + { + candidateKey: "search:blockrun-grok", + provider: "blockrun-grok", + method: "POST", + status: "enabled", + price: { mode: "dynamic", minUsd: 0.001, maxUsd: 1.25 }, + inputSchema: { type: "object", required: ["query"] }, + inputExample: { query: "provider native" }, + sampleOutput: { output: "native" } + } + ] +}; + +function res(status: number, body: unknown, headers: Record = {}) { + return { status, statusText: status === 200 ? "OK" : "Gone", text: async () => JSON.stringify(body), headers: new Headers(headers) }; +} +function args(command: string, flags: ParsedArgs["flags"] = {}): ParsedArgs { + return { positional: [command, "web/search"], flags }; +} +function printed(spy: ReturnType) { + return JSON.parse(spy.mock.calls.map((call) => String(call[0])).join("")); +} + +const challenge = { + x402Version: 2, + accepts: [{ scheme: "exact", network: "eip155:8453", asset: "0x", amount: "1", payTo: "0x", maxTimeoutSeconds: 60 }] +}; + +describe("provider-first catalog commands", () => { + let stdout: ReturnType; + beforeEach(() => { + stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + loadConfig.mockResolvedValue({ backendUrl: "https://test.example", sessions: {}, wallets: {} }); + }); + afterEach(() => { + stdout.mockRestore(); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + loadConfig.mockReset(); + }); + + it("resolves an omitted call provider from detail and calls only the pinned path", async () => { + const fetch = vi.fn().mockResolvedValueOnce(res(200, { route })).mockResolvedValueOnce(res(200, { data: { ok: true }, h402: { provider: "stableenrich-exa" } })); + vi.stubGlobal("fetch", fetch); + + await callCommand( + args("call", { + "api-url": "https://custom.example/v1", + json: `{"query":"O'Reilly AI"}`, + method: "POST", + name: "agent wallet", + wallet: "0x1111111111111111111111111111111111111111", + passphrase: "never-print-this", + "no-passphrase": true, + "no-credit": true, + "max-usd": "0.05", + "idempotency-key": "idem-1" + }) + ); + + expect(String(fetch.mock.calls[0][0])).toBe("https://custom.example/v1/api/catalog/routes/web/search"); + expect(String(fetch.mock.calls[1][0])).toBe("https://custom.example/v1/routes/stableenrich-exa/web/search"); + expect(fetch).toHaveBeenCalledTimes(2); + expect(printed(stdout).h402.cliProviderSelection).toEqual({ + source: "catalog-default", + provider: "stableenrich-exa", + pinnedCommand: + "h402 call web/search --provider stableenrich-exa --api-url https://custom.example/v1 --json '{\"query\":\"O'\\''Reilly AI\"}' --method POST --name 'agent wallet' --wallet 0x1111111111111111111111111111111111111111 --no-passphrase --no-credit --max-usd 0.05" + }); + expect(printed(stdout).h402.cliProviderSelection.pinnedCommand).not.toContain("never-print-this"); + expect(printed(stdout).h402.cliProviderSelection.pinnedCommand).not.toContain("idem-1"); + }); + + it("validates the payment cap before resolving an omitted provider", async () => { + const fetch = vi.fn(); + vi.stubGlobal("fetch", fetch); + + await expect(callCommand(args("call", { "max-usd": "0.0000001" }))).rejects.toThrow(/at most 6 decimal places/); + + expect(fetch).not.toHaveBeenCalled(); + }); + + it("uses an explicit provider without a catalog lookup or auto path", async () => { + const fetch = vi.fn().mockResolvedValue(res(200, { data: { ok: true }, h402: { provider: "blockrun-grok" } })); + vi.stubGlobal("fetch", fetch); + + await callCommand(args("call", { provider: "blockrun-grok", json: '{"query":"h402"}' })); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(String(fetch.mock.calls[0][0])).toBe("https://test.example/routes/blockrun-grok/web/search"); + expect(String(fetch.mock.calls[0][0])).not.toContain("/routes/auto/"); + expect(printed(stdout).h402.cliProviderSelection.pinnedCommand).toBe( + "h402 call web/search --provider blockrun-grok --api-url https://test.example --json '{\"query\":\"h402\"}'" + ); + }); + + it("pins an environment-derived backend in the reproducible command", async () => { + vi.stubEnv("H402_API_URL", "https://env.example/"); + const fetch = vi.fn().mockResolvedValue(res(200, { data: { ok: true }, h402: { provider: "blockrun-grok" } })); + vi.stubGlobal("fetch", fetch); + + await callCommand(args("call", { provider: "blockrun-grok" })); + + expect(String(fetch.mock.calls[0][0])).toBe("https://env.example/routes/blockrun-grok/web/search"); + expect(printed(stdout).h402.cliProviderSelection.pinnedCommand).toBe( + "h402 call web/search --provider blockrun-grok --api-url https://env.example" + ); + }); + + it("rejects the auto sentinel locally for call, quote, and show", async () => { + const fetch = vi.fn(); + vi.stubGlobal("fetch", fetch); + + await expect(callCommand(args("call", { provider: "auto" }))).rejects.toThrow(/auto.*reserved/i); + await expect(quoteCommand(args("quote", { provider: "auto" }))).rejects.toThrow(/auto.*reserved/i); + await expect(showCommand(args("show", { provider: "auto" }))).rejects.toThrow(/auto.*reserved/i); + + expect(fetch).not.toHaveBeenCalled(); + }); + + it("rejects an auto catalog default before an execution request", async () => { + const autoRoute = { + ...route, + defaultProvider: "auto", + defaultCandidateKey: "search:auto", + candidates: [{ ...route.candidates[0], provider: "auto" }] + }; + const fetch = vi.fn().mockResolvedValue(res(200, { route: autoRoute })); + vi.stubGlobal("fetch", fetch); + + const error = await callCommand(args("call")).catch((thrown: unknown) => thrown); + + expect(error).toBeInstanceOf(CliError); + expect(errorEnvelope(error)).toMatchObject({ error: { detail: { error: { code: "invalid_catalog_response" } } } }); + expect(fetch).toHaveBeenCalledTimes(1); + expect(String(fetch.mock.calls[0][0])).toContain("/api/catalog/routes/web/search"); + }); + + it("rejects a disabled catalog default before an execution request", async () => { + const disabledRoute = { + ...route, + candidates: route.candidates.map((candidate) => + candidate.provider === route.defaultProvider ? { ...candidate, status: "disabled" } : candidate + ) + }; + const fetch = vi.fn().mockResolvedValue(res(200, { route: disabledRoute })); + vi.stubGlobal("fetch", fetch); + + const error = await callCommand(args("call")).catch((thrown: unknown) => thrown); + + expect(error).toBeInstanceOf(CliError); + expect(errorEnvelope(error)).toMatchObject({ error: { detail: { error: { code: "invalid_catalog_response" } } } }); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it("validates show provider and structured query values before network work", async () => { + const fetch = vi.fn(); + vi.stubGlobal("fetch", fetch); + + await expect(showCommand(args("show", { provider: "" }))).rejects.toThrow(/provider requires a non-empty/i); + await expect(callCommand(args("call", { query: '{"filter":{"recent":true}}' }))).rejects.toThrow(/"filter" must be a string, number, or boolean/); + await expect(quoteCommand(args("quote", { query: '{"ids":[1,2]}' }))).rejects.toThrow(/"ids" must be a string, number, or boolean/); + + expect(fetch).not.toHaveBeenCalled(); + }); + + it("prints a reproducible GET query and the default provider with a quote challenge", async () => { + const fetch = vi.fn().mockResolvedValueOnce(res(200, { route })).mockResolvedValueOnce(res(402, challenge)); + vi.stubGlobal("fetch", fetch); + + await quoteCommand( + args("quote", { + "api-url": "https://custom.example/v1", + query: '{"q":"agent APIs","limit":3}', + method: "GET" + }) + ); + + expect(printed(stdout)).toMatchObject({ + h402: { + cliProviderSelection: { + source: "catalog-default", + provider: "stableenrich-exa", + pinnedCommand: + "h402 quote web/search --provider stableenrich-exa --api-url https://custom.example/v1 --query '{\"q\":\"agent APIs\",\"limit\":3}' --method GET" + } + }, + paymentRequired: challenge + }); + expect(String(fetch.mock.calls[1][0])).toContain("/routes/stableenrich-exa/web/search?q=agent+APIs&limit=3"); + }); + + it("stops on 410 with machine-readable candidates and never retries", async () => { + const recovery = { + error: { + code: "provider_unavailable", + message: "Provider changed", + routeId: "web/search", + requestedProvider: "stableenrich-exa", + defaultProvider: "blockrun-grok", + candidates: [ + { + provider: "blockrun-grok", + candidateKey: "search:blockrun-grok", + status: "enabled", + path: "/routes/blockrun-grok/web/search" + } + ] + } + }; + const fetch = vi.fn().mockResolvedValueOnce(res(200, { route })).mockResolvedValueOnce(res(410, recovery)); + vi.stubGlobal("fetch", fetch); + + const error = await callCommand(args("call", { json: '{"query":"h402"}', "idempotency-key": "idem-410" })).catch((thrown: unknown) => thrown); + + expect(error).toBeInstanceOf(CliError); + expect(errorEnvelope(error)).toMatchObject({ + error: { + detail: { + error: recovery.error, + h402: { + cliProviderSelection: { + source: "catalog-default", + provider: "stableenrich-exa", + pinnedCommand: + "h402 call web/search --provider stableenrich-exa --api-url https://test.example --json '{\"query\":\"h402\"}'" + } + } + } + } + }); + expect(fetch).toHaveBeenCalledTimes(2); + expect(fetch.mock.calls.some((call) => String(call[0]).includes("/routes/auto/"))).toBe(false); + }); + + it("preserves the exact unknown-route search recovery when default resolution fails", async () => { + const missing = { + error: { + code: "route_not_found", + message: "Route not found", + routeId: "web/search", + recovery: { command: 'h402 search "web/search"' } + } + }; + const fetch = vi.fn().mockResolvedValue(res(404, missing)); + vi.stubGlobal("fetch", fetch); + + const error = await callCommand(args("call", { json: '{"query":"h402"}' })).catch((thrown: unknown) => thrown); + + expect(error).toBeInstanceOf(CliError); + expect(errorEnvelope(error)).toMatchObject({ error: { detail: { error: missing.error } } }); + expect(JSON.stringify(errorEnvelope(error))).not.toContain("suggestions"); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it("shows every full candidate when no provider is selected", async () => { + const fetch = vi.fn().mockResolvedValue(res(200, { route })); + vi.stubGlobal("fetch", fetch); + + await showCommand(args("show")); + + expect(printed(stdout)).toEqual({ route }); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it("shows full route detail and one selected candidate", async () => { + const fetch = vi.fn().mockResolvedValue(res(200, { route })); + vi.stubGlobal("fetch", fetch); + + await showCommand(args("show", { provider: "blockrun-grok" })); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(printed(stdout)).toMatchObject({ + route: { + id: "web/search", + routeKey: "search", + defaultProvider: "stableenrich-exa", + flow: { parent: "web/research-task" }, + flowFollowUps: ["web/search-status"] + }, + candidate: { provider: "blockrun-grok", sampleOutput: { output: "native" } }, + providerSelection: { + source: "explicit", + provider: "blockrun-grok", + pinnedCommand: "h402 show web/search --provider blockrun-grok --api-url https://test.example" + } + }); + expect(printed(stdout).route).not.toHaveProperty("provider"); + expect(printed(stdout).route).not.toHaveProperty("inputSchema"); + expect(printed(stdout).route).not.toHaveProperty("inputExample"); + expect(printed(stdout).route).not.toHaveProperty("price"); + expect(printed(stdout).route).not.toHaveProperty("candidates"); + }); + + it("returns machine-readable candidates for an unknown show provider", async () => { + const fetch = vi.fn().mockResolvedValue(res(200, { route })); + vi.stubGlobal("fetch", fetch); + + const error = await showCommand(args("show", { provider: "missing" })).catch((thrown: unknown) => thrown); + + expect(error).toBeInstanceOf(CliError); + expect(errorEnvelope(error)).toMatchObject({ + error: { + detail: { + error: { + code: "provider_unavailable", + routeId: "web/search", + requestedProvider: "missing", + defaultProvider: "stableenrich-exa" + } + } + } + }); + expect((error as CliError).detail).toMatchObject({ + error: { + candidates: [ + { + provider: "stableenrich-exa", + candidateKey: "search:stableenrich-exa", + status: "enabled", + path: "/routes/stableenrich-exa/web/search" + }, + { + provider: "blockrun-grok", + candidateKey: "search:blockrun-grok", + status: "enabled", + path: "/routes/blockrun-grok/web/search" + } + ] + } + }); + }); + + it("attaches provider selection to quote errors after resolution", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(res(200, { route })) + .mockResolvedValueOnce(res(500, { error: { code: "upstream_failed", message: "Provider failed" } })); + vi.stubGlobal("fetch", fetch); + + const error = await quoteCommand(args("quote", { json: '{"query":"h402"}' })).catch((thrown: unknown) => thrown); + + expect(errorEnvelope(error)).toMatchObject({ + error: { + detail: { + error: { code: "upstream_failed" }, + h402: { + cliProviderSelection: { + source: "catalog-default", + provider: "stableenrich-exa", + pinnedCommand: + "h402 quote web/search --provider stableenrich-exa --api-url https://test.example --json '{\"query\":\"h402\"}'" + } + } + } + } + }); + }); + + it("prints compact search results without fetching detail", async () => { + const compact = { + query: "web", + results: [ + { + id: "web/search", + title: "Web search", + summary: "Search the web", + method: "POST", + providers: ["stableenrich-exa", "blockrun-grok"], + defaultProvider: "stableenrich-exa", + priceRangeMicroUsd: { min: "1000", max: "1250000" }, + health: { status: "healthy" } + } + ] + }; + const fetch = vi.fn().mockResolvedValue(res(200, compact)); + vi.stubGlobal("fetch", fetch); + + await searchCommand({ positional: ["search", "web"], flags: {} }); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(printed(stdout)).toEqual(compact); + }); +}); diff --git a/packages/cli/tests/quote-call.test.ts b/packages/cli/tests/quote-call.test.ts index 4603a82..2ccb1a1 100644 --- a/packages/cli/tests/quote-call.test.ts +++ b/packages/cli/tests/quote-call.test.ts @@ -21,7 +21,7 @@ function stubFetch(status: number, body: unknown, headers: Record { detail: { idempotencyKey: "idem-123", backendUrl: "https://test.example", - url: "https://test.example/routes/auto/web/search", + url: "https://test.example/routes/demo/web/search", ...backend } } diff --git a/packages/cli/tests/utils.test.ts b/packages/cli/tests/utils.test.ts index 02d0d76..a0fc37e 100644 --- a/packages/cli/tests/utils.test.ts +++ b/packages/cli/tests/utils.test.ts @@ -40,39 +40,47 @@ describe("parseArgs", () => { }); it("parses --flag=value syntax", () => { - expect(parseArgs(["quote", "weather/current", "--query={\"q\":\"Seoul\"}", "--provider=auto"])).toEqual({ + expect(parseArgs(["quote", "weather/current", "--query={\"q\":\"Seoul\"}", "--provider=weatherkit"])).toEqual({ positional: ["quote", "weather/current"], - flags: { query: '{"q":"Seoul"}', provider: "auto" } + flags: { query: '{"q":"Seoul"}', provider: "weatherkit" } }); }); }); describe("buildProxyPath", () => { - it("maps route ids to auto-routed backend paths", () => { - expect(buildProxyPath("web/search")).toBe("/routes/auto/web/search"); + it("requires a concrete provider", () => { + expect(() => buildProxyPath("web/search", undefined as unknown as string)).toThrow("Provider is required"); + }); + + it("rejects the tombstoned auto sentinel and non-slug provider segments", () => { + expect(() => buildProxyPath("web/search", "auto")).toThrow(/reserved/i); + expect(() => buildProxyPath("web/search", ".")).toThrow(/provider.*slug/i); + expect(() => buildProxyPath("web/search", "..")).toThrow(/provider.*slug/i); }); it("appends primitive query parameters", () => { - expect(buildProxyPath("maps/place-details", { placeId: "ChIJ123", includePhotos: false, maxResults: 3 })).toBe( - "/routes/auto/maps/place-details?placeId=ChIJ123&includePhotos=false&maxResults=3" + expect(buildProxyPath("maps/place-details", "google-maps", { placeId: "ChIJ123", includePhotos: false, maxResults: 3 })).toBe( + "/routes/google-maps/maps/place-details?placeId=ChIJ123&includePhotos=false&maxResults=3" ); }); it("pins providers through the path segment", () => { - expect(buildProxyPath("web/search", undefined, "stableenrich-exa")).toBe("/routes/stableenrich-exa/web/search"); - expect(buildProxyPath("web/search", { query: "best AI tools" }, "stableenrich-firecrawl")).toBe( + expect(buildProxyPath("web/search", "stableenrich-exa")).toBe("/routes/stableenrich-exa/web/search"); + expect(buildProxyPath("web/search", "stableenrich-firecrawl", { query: "best AI tools" })).toBe( "/routes/stableenrich-firecrawl/web/search?query=best+AI+tools" ); }); it("rejects malformed route ids", () => { - expect(() => buildProxyPath("web/search/exa")).toThrow("Route id must look like"); + expect(() => buildProxyPath("web/search/exa", "stableenrich-exa")).toThrow("Route id must look like"); + expect(() => buildProxyPath("./search", "stableenrich-exa")).toThrow(/route id segment.*slug/i); + expect(() => buildProxyPath("web/..", "stableenrich-exa")).toThrow(/route id segment.*slug/i); }); it("rejects array, object, and null query values instead of silently dropping them", () => { - expect(() => buildProxyPath("crypto/holders", { ids: [1, 2, 3] })).toThrow(/"ids" must be a string, number, or boolean/); - expect(() => buildProxyPath("crypto/holders", { filter: { chain: "base" } })).toThrow(/"filter"/); - expect(() => buildProxyPath("crypto/holders", { cursor: null })).toThrow(/"cursor"/); + expect(() => buildProxyPath("crypto/holders", "demo", { ids: [1, 2, 3] })).toThrow(/"ids" must be a string, number, or boolean/); + expect(() => buildProxyPath("crypto/holders", "demo", { filter: { chain: "base" } })).toThrow(/"filter"/); + expect(() => buildProxyPath("crypto/holders", "demo", { cursor: null })).toThrow(/"cursor"/); }); }); @@ -98,6 +106,11 @@ describe("parseQueryFlag", () => { it("suggests JSON object syntax for key=value query input", () => { expect(() => parseQueryFlag({ query: "q=Seoul" })).toThrow(/--query must be a JSON object.*key=value syntax is not supported/); }); + + it("rejects structured query values during parsing", () => { + expect(() => parseQueryFlag({ query: '{"filters":["recent"]}' })).toThrow(/"filters" must be a string, number, or boolean/); + expect(() => parseQueryFlag({ query: '{"cursor":null}' })).toThrow(/"cursor"/); + }); }); describe("resolveMethod", () => {