From b6f8201b7152463987c5621549586eed70cba4a9 Mon Sep 17 00:00:00 2001
From: h1-hunt
Date: Wed, 22 Jul 2026 18:44:53 +0000
Subject: [PATCH 1/5] feat: pin CLI calls to catalog providers
---
README.md | 24 +-
SKILL.md | 48 +--
packages/cli/README.md | 41 +--
packages/cli/src/catalog.ts | 153 ++++++++++
packages/cli/src/commands.ts | 95 +++++-
packages/cli/src/help.ts | 17 +-
packages/cli/src/index.ts | 2 +
packages/cli/src/utils.ts | 48 ++-
packages/cli/tests/api.test.ts | 12 +-
packages/cli/tests/call-free-route.test.ts | 4 +-
packages/cli/tests/call-max-usd.test.ts | 2 +-
.../cli/tests/call-settlement-retry.test.ts | 56 ++++
packages/cli/tests/cli-help.test.ts | 22 +-
packages/cli/tests/docs.test.ts | 44 +--
packages/cli/tests/provider-selection.test.ts | 282 ++++++++++++++++++
packages/cli/tests/quote-call.test.ts | 4 +-
packages/cli/tests/utils.test.ts | 37 ++-
17 files changed, 771 insertions(+), 120 deletions(-)
create mode 100644 packages/cli/src/catalog.ts
create mode 100644 packages/cli/tests/provider-selection.test.ts
diff --git a/README.md b/README.md
index c9a6356..89d5a0b 100644
--- a/README.md
+++ b/README.md
@@ -6,9 +6,9 @@
[](https://www.typescriptlang.org/)
[](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. The result includes `h402.cliProviderSelection` with the selected provider and a reproducible pinned command; passing `--provider` skips default resolution and calls that pinned path directly. A `410` response is never retried automatically: read its machine-readable alternatives, inspect the replacement with `h402 show`, then start a new explicit call.
-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": , "h402": }`: `data` stays provider-native, while `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 machine-readable backend recovery fields such as alternatives.
+
+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..13c50a3 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 and records the choice in `h402.cliProviderSelection`. Prefer explicit `--provider` after inspection for reproducibility. A `410` response is never retried automatically; inspect its machine-readable alternatives and start a new explicit call only after choosing one.
- 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": , "h402": }` — `data` remains provider-native; inspect `h402` for the execution receipt and `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 machine-readable recovery alternatives.
+- 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..9276a0c 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -3,7 +3,7 @@
[](https://www.npmjs.com/package/@h402/cli)
[](../../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. Every success includes `h402.cliProviderSelection` with the source, provider, and reproducible pinned command. A `410` response is never retried automatically — inspect its machine-readable alternatives with `h402 show`, then start a new explicit call.
+
```
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": , "h402": }` — `data` remains provider-native, 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 machine-readable route/provider alternatives.
-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..085d33f
--- /dev/null
+++ b/packages/cli/src/catalog.ts
@@ -0,0 +1,153 @@
+import { assertOk, requestJson } from "./api.js";
+import { CliError } from "./errors.js";
+import { assertConcreteProvider, encodeRouteId } 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 };
+
+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 selection(command: "call" | "quote" | "show", routeId: string, provider: string, source: ProviderSelection["source"]): ProviderSelection {
+ return {
+ source,
+ provider,
+ pinnedCommand: `h402 ${command} ${shellArg(routeId)} --provider ${shellArg(provider)}`
+ };
+}
+
+function alternatives(route: CatalogRoute) {
+ const encodedRoute = encodeRouteId(route.id);
+ return route.candidates.map(({ provider }) => ({
+ provider,
+ pinnedPath: `/routes/${encodeURIComponent(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)) {
+ return invalidCatalogResponse(routeId, "defaultProvider is not an enabled candidate", {
+ defaultProvider: route.defaultProvider,
+ alternatives: candidates.map(({ provider }) => ({
+ provider,
+ pinnedPath: `/routes/${encodeURIComponent(provider)}/${encodeRouteId(routeId)}`
+ }))
+ });
+ }
+ 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"
+): Promise<{ selection: ProviderSelection; route?: CatalogRoute }> {
+ if (explicitProvider !== undefined) {
+ return { selection: selection(command, routeId, assertConcreteProvider(explicitProvider), "explicit") };
+ }
+ const { route } = await fetchCatalogRoute(apiUrl, routeId);
+ return {
+ route,
+ selection: selection(command, routeId, route.defaultProvider, "catalog-default")
+ };
+}
+
+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: "unknown_provider", message },
+ routeId: route.id,
+ requestedProvider: provider,
+ defaultProvider: route.defaultProvider,
+ alternatives: alternatives(route)
+ });
+}
+
+export function explicitShowSelection(routeId: string, provider: string) {
+ return selection("show", routeId, provider, "explicit");
+}
+
+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 } };
+}
diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts
index fb62632..144d0eb 100644
--- a/packages/cli/src/commands.ts
+++ b/packages/cli/src/commands.ts
@@ -1,11 +1,30 @@
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
+} 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 +328,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 { selection: providerSelection } = await resolveProvider(apiUrl, routeId, explicitProvider, "call");
+ const path = buildProxyPath(routeId, providerSelection.provider, query);
const requestBody = body === undefined ? undefined : JSON.stringify(body);
const headers: Record = {
"idempotency-key": idempotencyKey
@@ -515,7 +582,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,7 +618,7 @@ 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);
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..cf351c0 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"));
diff --git a/packages/cli/tests/call-max-usd.test.ts b/packages/cli/tests/call-max-usd.test.ts
index ee3817a..1fa2b7a 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) {
diff --git a/packages/cli/tests/call-settlement-retry.test.ts b/packages/cli/tests/call-settlement-retry.test.ts
index b17be8d..69049b9 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", 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,47 @@ 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", pinnedPath: "/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({ defaultProvider: "other", candidates: recovery.candidates }) });
+ 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..7b30a54 100644
--- a/packages/cli/tests/docs.test.ts
+++ b/packages/cli/tests/docs.test.ts
@@ -11,32 +11,40 @@ 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": , "h402": }');
+ 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.toContain('"meta"?: ');
+ 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 +53,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 +104,19 @@ 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(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..2435edd
--- /dev/null
+++ b/packages/cli/tests/provider-selection.test.ts
@@ -0,0 +1,282 @@
+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", () => ({
+ loadConfig,
+ updateConfig: vi.fn(),
+ backendUrl: () => "https://test.example"
+}));
+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",
+ candidates: [
+ {
+ 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" }] }
+ },
+ {
+ 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();
+ 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", { json: '{"query":"h402"}', "idempotency-key": "idem-1" }));
+
+ 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/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"
+ });
+ });
+
+ 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/");
+ });
+
+ 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("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 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", { json: '{"query":"h402"}' }));
+
+ expect(printed(stdout)).toMatchObject({
+ providerSelection: {
+ source: "catalog-default",
+ provider: "stableenrich-exa",
+ pinnedCommand: "h402 quote web/search --provider stableenrich-exa"
+ },
+ paymentRequired: challenge
+ });
+ });
+
+ it("stops on 410 with machine-readable alternatives 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", status: "enabled", pinnedPath: "/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: { defaultProvider: "blockrun-grok", candidates: recovery.candidates } } });
+ expect(fetch).toHaveBeenCalledTimes(2);
+ expect(fetch.mock.calls.some((call) => String(call[0]).includes("/routes/auto/"))).toBe(false);
+ });
+
+ it("preserves machine-readable route suggestions when default resolution fails", async () => {
+ const missing = {
+ error: { code: "route_not_found", message: "Unknown route" },
+ routeId: "web/search",
+ suggestions: [{ routeId: "web/answer" }]
+ };
+ 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: { suggestions: missing.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" },
+ candidate: { provider: "blockrun-grok", sampleOutput: { output: "native" } },
+ providerSelection: { source: "explicit", provider: "blockrun-grok" }
+ });
+ 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 alternatives 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: "unknown_provider" }, routeId: "web/search", defaultProvider: "stableenrich-exa" } }
+ });
+ expect((error as CliError).detail).toMatchObject({
+ alternatives: [
+ { provider: "stableenrich-exa", pinnedPath: "/routes/stableenrich-exa/web/search" },
+ { provider: "blockrun-grok", pinnedPath: "/routes/blockrun-grok/web/search" }
+ ]
+ });
+ });
+
+ 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", () => {
From 73e415a9d9bc518de34c86965fafd9be4bc3e369 Mon Sep 17 00:00:00 2001
From: sebayaki
Date: Thu, 23 Jul 2026 12:02:49 +0900
Subject: [PATCH 2/5] fix: align provider-pinned CLI contracts
---
README.md | 4 +-
SKILL.md | 4 +-
packages/cli/README.md | 4 +-
packages/cli/src/catalog.ts | 83 ++++++---
packages/cli/src/commands.ts | 60 ++++---
packages/cli/tests/call-free-route.test.ts | 15 +-
packages/cli/tests/call-max-usd.test.ts | 15 +-
.../cli/tests/call-settlement-retry.test.ts | 26 ++-
packages/cli/tests/docs.test.ts | 11 +-
packages/cli/tests/provider-selection.test.ts | 163 +++++++++++++++---
10 files changed, 295 insertions(+), 90 deletions(-)
diff --git a/README.md b/README.md
index 89d5a0b..758c69b 100644
--- a/README.md
+++ b/README.md
@@ -49,11 +49,11 @@ OWS wallet creation and signing use native bindings available only on macOS and
## How it works
-Each call uses one concrete provider. Without `--provider`, the CLI resolves the route's current `defaultProvider` from full catalog detail before sending the call. The result includes `h402.cliProviderSelection` with the selected provider and a reproducible pinned command; passing `--provider` skips default resolution and calls that pinned path directly. A `410` response is never retried automatically: read its machine-readable alternatives, inspect the replacement with `h402 show`, then start a new explicit call.
+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`.
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.
-A successful `call` prints `{ "data": , "h402": }`: `data` stays provider-native, while `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 machine-readable backend recovery fields such as alternatives.
+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):
diff --git a/SKILL.md b/SKILL.md
index 13c50a3..83d7bee 100644
--- a/SKILL.md
+++ b/SKILL.md
@@ -85,9 +85,9 @@ h402 call crypto/token-holders --provider nansen --name agent \
- 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.
-- Each call uses one concrete provider. Without `--provider`, the CLI resolves the route's current `defaultProvider` from full catalog detail before execution and records the choice in `h402.cliProviderSelection`. Prefer explicit `--provider` after inspection for reproducibility. A `410` response is never retried automatically; inspect its machine-readable alternatives and start a new explicit call only after choosing one.
+- 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": , "h402": }` — `data` remains provider-native; inspect `h402` for the execution receipt and `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 machine-readable recovery alternatives.
+- 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
diff --git a/packages/cli/README.md b/packages/cli/README.md
index 9276a0c..362f188 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -78,7 +78,7 @@ 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. Every success includes `h402.cliProviderSelection` with the source, provider, and reproducible pinned command. A `410` response is never retried automatically — inspect its machine-readable alternatives with `h402 show`, then start a new explicit call.
+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":"..."}'
@@ -114,7 +114,7 @@ confirms that the original authorization was not paid.
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": , "h402": }` — `data` remains provider-native, 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 machine-readable route/provider alternatives.
+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, 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):
diff --git a/packages/cli/src/catalog.ts b/packages/cli/src/catalog.ts
index 085d33f..e36e5f4 100644
--- a/packages/cli/src/catalog.ts
+++ b/packages/cli/src/catalog.ts
@@ -1,6 +1,6 @@
import { assertOk, requestJson } from "./api.js";
import { CliError } from "./errors.js";
-import { assertConcreteProvider, encodeRouteId } from "./utils.js";
+import { assertConcreteProvider, encodeRouteId, type ParsedArgs } from "./utils.js";
export type ProviderSelection = {
source: "explicit" | "catalog-default";
@@ -23,6 +23,15 @@ export type CatalogRoute = {
};
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);
@@ -32,19 +41,41 @@ function shellArg(value: string) {
return /^[A-Za-z0-9_./:@+-]+$/.test(value) ? value : `'${value.replaceAll("'", `'\\''`)}'`;
}
-function selection(command: "call" | "quote" | "show", routeId: string, provider: string, source: ProviderSelection["source"]): ProviderSelection {
+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: `h402 ${command} ${shellArg(routeId)} --provider ${shellArg(provider)}`
+ pinnedCommand: pinnedCommand(command, routeId, provider, flags)
};
}
-function alternatives(route: CatalogRoute) {
+function recoveryCandidates(route: CatalogRoute) {
const encodedRoute = encodeRouteId(route.id);
- return route.candidates.map(({ provider }) => ({
- provider,
- pinnedPath: `/routes/${encodeURIComponent(provider)}/${encodedRoute}`
+ 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}`
}));
}
@@ -95,10 +126,7 @@ function parseCatalogRoute(routeId: string, body: unknown): CatalogRoute {
if (!candidates.some((candidate) => candidate.provider === route.defaultProvider)) {
return invalidCatalogResponse(routeId, "defaultProvider is not an enabled candidate", {
defaultProvider: route.defaultProvider,
- alternatives: candidates.map(({ provider }) => ({
- provider,
- pinnedPath: `/routes/${encodeURIComponent(provider)}/${encodeRouteId(routeId)}`
- }))
+ candidates: recoveryCandidates({ ...(route as CatalogRoute), candidates })
});
}
return { ...(route as CatalogRoute), candidates };
@@ -113,15 +141,16 @@ export async function resolveProvider(
apiUrl: string,
routeId: string,
explicitProvider: string | undefined,
- command: "call" | "quote"
+ command: "call" | "quote",
+ flags: ParsedArgs["flags"]
): Promise<{ selection: ProviderSelection; route?: CatalogRoute }> {
if (explicitProvider !== undefined) {
- return { selection: selection(command, routeId, assertConcreteProvider(explicitProvider), "explicit") };
+ return { selection: selection(command, routeId, assertConcreteProvider(explicitProvider), "explicit", flags) };
}
const { route } = await fetchCatalogRoute(apiUrl, routeId);
return {
route,
- selection: selection(command, routeId, route.defaultProvider, "catalog-default")
+ selection: selection(command, routeId, route.defaultProvider, "catalog-default", flags)
};
}
@@ -132,16 +161,19 @@ export function selectCatalogCandidate(route: CatalogRoute, provider: string) {
}
const message = `Provider "${provider}" is not enabled for ${route.id}.`;
throw new CliError(message, {
- error: { code: "unknown_provider", message },
- routeId: route.id,
- requestedProvider: provider,
- defaultProvider: route.defaultProvider,
- alternatives: alternatives(route)
+ error: {
+ code: "provider_unavailable",
+ message,
+ routeId: route.id,
+ requestedProvider: provider,
+ defaultProvider: route.defaultProvider,
+ candidates: recoveryCandidates(route)
+ }
});
}
-export function explicitShowSelection(routeId: string, provider: string) {
- return selection("show", routeId, provider, "explicit");
+export function explicitShowSelection(routeId: string, provider: string, flags: ParsedArgs["flags"]) {
+ return selection("show", routeId, provider, "explicit", flags);
}
export function withProviderSelection(body: unknown, providerSelection: ProviderSelection) {
@@ -151,3 +183,12 @@ export function withProviderSelection(body: unknown, providerSelection: Provider
}
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 144d0eb..1053eb1 100644
--- a/packages/cli/src/commands.ts
+++ b/packages/cli/src/commands.ts
@@ -6,7 +6,8 @@ import {
fetchCatalogRoute,
resolveProvider,
selectCatalogCandidate,
- withProviderSelection
+ withProviderSelection,
+ withProviderSelectionError
} from "./catalog.js";
import { backendUrl, loadConfig, updateConfig, type CliConfig } from "./config.js";
import { CliError } from "./errors.js";
@@ -373,12 +374,14 @@ export async function showCommand(args: ParsedArgs) {
tags: route.tags,
defaultProvider: route.defaultProvider,
defaultCandidateKey: route.defaultCandidateKey,
- stats: route.stats
+ stats: route.stats,
+ flow: route.flow,
+ flowFollowUps: route.flowFollowUps
};
await printJson({
route: routeSummary,
candidate,
- providerSelection: explicitShowSelection(routeId, provider)
+ providerSelection: explicitShowSelection(routeId, provider, args.flags)
});
}
@@ -405,19 +408,23 @@ export async function quoteCommand(args: ParsedArgs) {
rejectQueryOnPost(method, query);
const config = await loadConfig();
const apiUrl = backendUrl(config, flagString(args.flags, "api-url"));
- const { selection: providerSelection } = await resolveProvider(apiUrl, routeId, explicitProvider, "quote");
- const result = await requestJson(apiUrl, buildProxyPath(routeId, providerSelection.provider, query), {
- method,
- body: body === undefined ? undefined : JSON.stringify(body)
- });
- const paymentRequired = paymentRequiredFromResponse(result.headers, result.body);
- if (paymentRequired) {
- await printJson({ providerSelection, paymentRequired });
- return;
+ const { selection: providerSelection } = await resolveProvider(apiUrl, routeId, explicitProvider, "quote", args.flags);
+ try {
+ const result = await requestJson(apiUrl, buildProxyPath(routeId, providerSelection.provider, query), {
+ method,
+ body: body === undefined ? undefined : JSON.stringify(body)
+ });
+ const paymentRequired = paymentRequiredFromResponse(result.headers, result.body);
+ if (paymentRequired) {
+ await printJson(withProviderSelection({ paymentRequired }, providerSelection));
+ return;
+ }
+ // No challenge: a free route returns its result with a 2xx. Any non-2xx
+ // (404/410/500/...) is a real error and must exit non-zero, not print as a result.
+ await printJson(withProviderSelection(assertOk(result), providerSelection));
+ } catch (error) {
+ throw withProviderSelectionError(error, providerSelection);
}
- // No challenge: a free route returns its result with a 2xx. Any non-2xx
- // (404/410/500/...) is a real error and must exit non-zero, not print as a result.
- await printJson(withProviderSelection(assertOk(result), providerSelection));
}
function parseUsdMicros(raw: string, source: string) {
@@ -554,19 +561,20 @@ export async function callCommand(args: ParsedArgs) {
const apiUrl = backendUrl(config, flagString(args.flags, "api-url"));
const paymentCap = maxUsd(args, config);
const token = config.sessions[apiUrl];
- const { selection: providerSelection } = await resolveProvider(apiUrl, routeId, explicitProvider, "call");
- 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 { selection: providerSelection } = await resolveProvider(apiUrl, routeId, explicitProvider, "call", args.flags);
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,
@@ -623,6 +631,6 @@ export async function callCommand(args: ParsedArgs) {
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/tests/call-free-route.test.ts b/packages/cli/tests/call-free-route.test.ts
index cf351c0..1335591 100644
--- a/packages/cli/tests/call-free-route.test.ts
+++ b/packages/cli/tests/call-free-route.test.ts
@@ -72,7 +72,20 @@ 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"
+ }
+ }
+ }
+ });
expect(fetch).toHaveBeenCalled();
});
});
diff --git a/packages/cli/tests/call-max-usd.test.ts b/packages/cli/tests/call-max-usd.test.ts
index 1fa2b7a..c8d4fc2 100644
--- a/packages/cli/tests/call-max-usd.test.ts
+++ b/packages/cli/tests/call-max-usd.test.ts
@@ -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 --max-usd 0.049999"
+ }
+ }
+ }
+ });
expect(signOwsTypedData).not.toHaveBeenCalled();
expect(fetch).toHaveBeenCalledTimes(1);
});
diff --git a/packages/cli/tests/call-settlement-retry.test.ts b/packages/cli/tests/call-settlement-retry.test.ts
index 69049b9..1192991 100644
--- a/packages/cli/tests/call-settlement-retry.test.ts
+++ b/packages/cli/tests/call-settlement-retry.test.ts
@@ -164,11 +164,14 @@ describe("callCommand pending settlement reconciliation", () => {
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", pinnedPath: "/routes/other/web/search" }]
+ 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);
@@ -179,7 +182,18 @@ describe("callCommand pending settlement reconciliation", () => {
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({ defaultProvider: "other", candidates: recovery.candidates }) });
+ expect(error).toMatchObject({
+ detail: expect.objectContaining({
+ error: recovery.error,
+ h402: {
+ cliProviderSelection: {
+ source: "explicit",
+ provider: "demo",
+ pinnedCommand: "h402 call web/search --provider demo --json '{\"query\":\"h402\"}'"
+ }
+ }
+ })
+ });
expect((error as Error).message).toMatch(/do NOT sign or pay with a new idempotency key/i);
});
diff --git a/packages/cli/tests/docs.test.ts b/packages/cli/tests/docs.test.ts
index 7b30a54..44304a9 100644
--- a/packages/cli/tests/docs.test.ts
+++ b/packages/cli/tests/docs.test.ts
@@ -23,11 +23,13 @@ describe("doc examples stay runnable against the provider-pinned catalog contrac
it(`${label}: documents provider-native output and explicit CLI selection metadata`, () => {
const text = readFileSync(file, "utf8");
- expect(text).toContain('{ "data": , "h402": }');
+ 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).not.toContain('"meta"?: ');
expect(text).not.toMatch(/params\.jobId/);
});
}
@@ -112,6 +114,11 @@ describe("doc examples stay runnable against the provider-pinned catalog contrac
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 ");
diff --git a/packages/cli/tests/provider-selection.test.ts b/packages/cli/tests/provider-selection.test.ts
index 2435edd..1b38b70 100644
--- a/packages/cli/tests/provider-selection.test.ts
+++ b/packages/cli/tests/provider-selection.test.ts
@@ -32,8 +32,11 @@ const route = {
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",
@@ -43,6 +46,7 @@ const route = {
sampleOutput: { results: [{ title: "h402" }] }
},
{
+ candidateKey: "search:blockrun-grok",
provider: "blockrun-grok",
method: "POST",
status: "enabled",
@@ -85,7 +89,20 @@ describe("provider-first catalog commands", () => {
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", { json: '{"query":"h402"}', "idempotency-key": "idem-1" }));
+ 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://test.example/api/catalog/routes/web/search");
expect(String(fetch.mock.calls[1][0])).toBe("https://test.example/routes/stableenrich-exa/web/search");
@@ -93,8 +110,11 @@ describe("provider-first catalog commands", () => {
expect(printed(stdout).h402.cliProviderSelection).toEqual({
source: "catalog-default",
provider: "stableenrich-exa",
- pinnedCommand: "h402 call web/search --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 () => {
@@ -157,29 +177,49 @@ describe("provider-first catalog commands", () => {
expect(fetch).not.toHaveBeenCalled();
});
- it("prints the default provider with a quote challenge", async () => {
+ 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", { json: '{"query":"h402"}' }));
+ await quoteCommand(
+ args("quote", {
+ "api-url": "https://custom.example/v1",
+ query: '{"q":"agent APIs","limit":3}',
+ method: "GET"
+ })
+ );
expect(printed(stdout)).toMatchObject({
- providerSelection: {
- source: "catalog-default",
- provider: "stableenrich-exa",
- pinnedCommand: "h402 quote web/search --provider stableenrich-exa"
+ 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 alternatives and never retries", async () => {
+ 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", status: "enabled", pinnedPath: "/routes/blockrun-grok/web/search" }]
+ 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);
@@ -187,16 +227,32 @@ describe("provider-first catalog commands", () => {
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: { defaultProvider: "blockrun-grok", candidates: recovery.candidates } } });
+ 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 --json '{\"query\":\"h402\"}'"
+ }
+ }
+ }
+ }
+ });
expect(fetch).toHaveBeenCalledTimes(2);
expect(fetch.mock.calls.some((call) => String(call[0]).includes("/routes/auto/"))).toBe(false);
});
- it("preserves machine-readable route suggestions when default resolution fails", async () => {
+ it("preserves the exact unknown-route search recovery when default resolution fails", async () => {
const missing = {
- error: { code: "route_not_found", message: "Unknown route" },
- routeId: "web/search",
- suggestions: [{ routeId: "web/answer" }]
+ 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);
@@ -204,7 +260,8 @@ describe("provider-first catalog commands", () => {
const error = await callCommand(args("call", { json: '{"query":"h402"}' })).catch((thrown: unknown) => thrown);
expect(error).toBeInstanceOf(CliError);
- expect(errorEnvelope(error)).toMatchObject({ error: { detail: { suggestions: missing.suggestions } } });
+ expect(errorEnvelope(error)).toMatchObject({ error: { detail: { error: missing.error } } });
+ expect(JSON.stringify(errorEnvelope(error))).not.toContain("suggestions");
expect(fetch).toHaveBeenCalledTimes(1);
});
@@ -226,7 +283,13 @@ describe("provider-first catalog commands", () => {
expect(fetch).toHaveBeenCalledTimes(1);
expect(printed(stdout)).toMatchObject({
- route: { id: "web/search", routeKey: "search", defaultProvider: "stableenrich-exa" },
+ 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" }
});
@@ -237,7 +300,7 @@ describe("provider-first catalog commands", () => {
expect(printed(stdout).route).not.toHaveProperty("candidates");
});
- it("returns machine-readable alternatives for an unknown show provider", async () => {
+ it("returns machine-readable candidates for an unknown show provider", async () => {
const fetch = vi.fn().mockResolvedValue(res(200, { route }));
vi.stubGlobal("fetch", fetch);
@@ -245,13 +308,59 @@ describe("provider-first catalog commands", () => {
expect(error).toBeInstanceOf(CliError);
expect(errorEnvelope(error)).toMatchObject({
- error: { detail: { error: { code: "unknown_provider" }, routeId: "web/search", defaultProvider: "stableenrich-exa" } }
+ error: {
+ detail: {
+ error: {
+ code: "provider_unavailable",
+ routeId: "web/search",
+ requestedProvider: "missing",
+ defaultProvider: "stableenrich-exa"
+ }
+ }
+ }
});
expect((error as CliError).detail).toMatchObject({
- alternatives: [
- { provider: "stableenrich-exa", pinnedPath: "/routes/stableenrich-exa/web/search" },
- { provider: "blockrun-grok", pinnedPath: "/routes/blockrun-grok/web/search" }
- ]
+ 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 --json '{\"query\":\"h402\"}'"
+ }
+ }
+ }
+ }
});
});
From 5a41f8f445429c5872d62e374d80bb10e393e314 Mon Sep 17 00:00:00 2001
From: sebayaki
Date: Thu, 23 Jul 2026 12:10:02 +0900
Subject: [PATCH 3/5] fix: reject disabled catalog defaults
---
packages/cli/src/catalog.ts | 2 +-
packages/cli/tests/provider-selection.test.ts | 17 +++++++++++++++++
2 files changed, 18 insertions(+), 1 deletion(-)
diff --git a/packages/cli/src/catalog.ts b/packages/cli/src/catalog.ts
index e36e5f4..12723ac 100644
--- a/packages/cli/src/catalog.ts
+++ b/packages/cli/src/catalog.ts
@@ -123,7 +123,7 @@ function parseCatalogRoute(routeId: string, body: unknown): CatalogRoute {
}
return candidate as CatalogCandidate;
});
- if (!candidates.some((candidate) => candidate.provider === route.defaultProvider)) {
+ 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 })
diff --git a/packages/cli/tests/provider-selection.test.ts b/packages/cli/tests/provider-selection.test.ts
index 1b38b70..9f92070 100644
--- a/packages/cli/tests/provider-selection.test.ts
+++ b/packages/cli/tests/provider-selection.test.ts
@@ -166,6 +166,23 @@ describe("provider-first catalog commands", () => {
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);
From 4a5dd4128132afc684e791965447fe17c1efa77c Mon Sep 17 00:00:00 2001
From: sebayaki
Date: Thu, 23 Jul 2026 13:07:47 +0900
Subject: [PATCH 4/5] test: align catalog retry fixture
---
packages/cli/tests/call-settlement-retry.test.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/cli/tests/call-settlement-retry.test.ts b/packages/cli/tests/call-settlement-retry.test.ts
index 1192991..8bd340b 100644
--- a/packages/cli/tests/call-settlement-retry.test.ts
+++ b/packages/cli/tests/call-settlement-retry.test.ts
@@ -83,7 +83,7 @@ const catalogRoute = {
category: "web",
action: "search",
defaultProvider: "demo",
- candidates: [{ provider: "demo", inputSchema: { type: "object" }, inputExample: { query: "h402" } }]
+ candidates: [{ provider: "demo", status: "enabled", inputSchema: { type: "object" }, inputExample: { query: "h402" } }]
};
function omittedProviderArgs() {
From b683f3782876719573e9ebbb92b89d26343932a4 Mon Sep 17 00:00:00 2001
From: sebayaki
Date: Thu, 23 Jul 2026 15:58:10 +0900
Subject: [PATCH 5/5] fix: pin effective CLI recipe settings
---
packages/cli/src/catalog.ts | 16 +++++---
packages/cli/src/commands.ts | 4 +-
packages/cli/tests/call-free-route.test.ts | 3 +-
packages/cli/tests/call-max-usd.test.ts | 15 ++++++-
.../cli/tests/call-settlement-retry.test.ts | 2 +-
packages/cli/tests/provider-selection.test.ts | 39 +++++++++++++++----
6 files changed, 60 insertions(+), 19 deletions(-)
diff --git a/packages/cli/src/catalog.ts b/packages/cli/src/catalog.ts
index 12723ac..10a0fbf 100644
--- a/packages/cli/src/catalog.ts
+++ b/packages/cli/src/catalog.ts
@@ -142,15 +142,21 @@ export async function resolveProvider(
routeId: string,
explicitProvider: string | undefined,
command: "call" | "quote",
- flags: ParsedArgs["flags"]
+ 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", flags) };
+ 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", flags)
+ selection: selection(command, routeId, route.defaultProvider, "catalog-default", effectiveFlags)
};
}
@@ -172,8 +178,8 @@ export function selectCatalogCandidate(route: CatalogRoute, provider: string) {
});
}
-export function explicitShowSelection(routeId: string, provider: string, flags: ParsedArgs["flags"]) {
- return selection("show", routeId, provider, "explicit", flags);
+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) {
diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts
index 1053eb1..a53513e 100644
--- a/packages/cli/src/commands.ts
+++ b/packages/cli/src/commands.ts
@@ -381,7 +381,7 @@ export async function showCommand(args: ParsedArgs) {
await printJson({
route: routeSummary,
candidate,
- providerSelection: explicitShowSelection(routeId, provider, args.flags)
+ providerSelection: explicitShowSelection(apiUrl, routeId, provider, args.flags)
});
}
@@ -561,7 +561,7 @@ export async function callCommand(args: ParsedArgs) {
const apiUrl = backendUrl(config, flagString(args.flags, "api-url"));
const paymentCap = maxUsd(args, config);
const token = config.sessions[apiUrl];
- const { selection: providerSelection } = await resolveProvider(apiUrl, routeId, explicitProvider, "call", args.flags);
+ const { selection: providerSelection } = await resolveProvider(apiUrl, routeId, explicitProvider, "call", args.flags, paymentCap?.raw);
let signedRequestSent = false;
try {
diff --git a/packages/cli/tests/call-free-route.test.ts b/packages/cli/tests/call-free-route.test.ts
index 1335591..d2d7c26 100644
--- a/packages/cli/tests/call-free-route.test.ts
+++ b/packages/cli/tests/call-free-route.test.ts
@@ -81,7 +81,8 @@ describe("callCommand free routes", () => {
cliProviderSelection: {
source: "explicit",
provider: "stablestudio-image",
- pinnedCommand: "h402 call ai/image-generate-async-status --provider stablestudio-image"
+ pinnedCommand:
+ "h402 call ai/image-generate-async-status --provider stablestudio-image --api-url https://test.example"
}
}
}
diff --git a/packages/cli/tests/call-max-usd.test.ts b/packages/cli/tests/call-max-usd.test.ts
index c8d4fc2..90931a9 100644
--- a/packages/cli/tests/call-max-usd.test.ts
+++ b/packages/cli/tests/call-max-usd.test.ts
@@ -143,7 +143,7 @@ describe("callCommand --max-usd", () => {
cliProviderSelection: {
source: "explicit",
provider: "demo",
- pinnedCommand: "h402 call web/search --provider demo --max-usd 0.049999"
+ pinnedCommand: "h402 call web/search --provider demo --api-url https://test.example --max-usd 0.049999"
}
}
}
@@ -156,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 8bd340b..c2be8a9 100644
--- a/packages/cli/tests/call-settlement-retry.test.ts
+++ b/packages/cli/tests/call-settlement-retry.test.ts
@@ -189,7 +189,7 @@ describe("callCommand pending settlement reconciliation", () => {
cliProviderSelection: {
source: "explicit",
provider: "demo",
- pinnedCommand: "h402 call web/search --provider demo --json '{\"query\":\"h402\"}'"
+ pinnedCommand: "h402 call web/search --provider demo --api-url https://test.example --json '{\"query\":\"h402\"}'"
}
}
})
diff --git a/packages/cli/tests/provider-selection.test.ts b/packages/cli/tests/provider-selection.test.ts
index 9f92070..9e3854d 100644
--- a/packages/cli/tests/provider-selection.test.ts
+++ b/packages/cli/tests/provider-selection.test.ts
@@ -3,10 +3,10 @@ import { CliError, errorEnvelope } from "../src/errors";
import type { ParsedArgs } from "../src/utils";
const { loadConfig } = vi.hoisted(() => ({ loadConfig: vi.fn() }));
-vi.mock("../src/config.js", () => ({
+vi.mock("../src/config.js", async (importOriginal) => ({
+ ...(await importOriginal()),
loadConfig,
- updateConfig: vi.fn(),
- backendUrl: () => "https://test.example"
+ updateConfig: vi.fn()
}));
vi.mock("../src/ows.js", () => ({
createOwsWallet: vi.fn(),
@@ -82,6 +82,7 @@ describe("provider-first catalog commands", () => {
afterEach(() => {
stdout.mockRestore();
vi.unstubAllGlobals();
+ vi.unstubAllEnvs();
loadConfig.mockReset();
});
@@ -104,8 +105,8 @@ describe("provider-first catalog commands", () => {
})
);
- 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/stableenrich-exa/web/search");
+ 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",
@@ -135,6 +136,22 @@ describe("provider-first catalog commands", () => {
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 () => {
@@ -252,7 +269,8 @@ describe("provider-first catalog commands", () => {
cliProviderSelection: {
source: "catalog-default",
provider: "stableenrich-exa",
- pinnedCommand: "h402 call web/search --provider stableenrich-exa --json '{\"query\":\"h402\"}'"
+ pinnedCommand:
+ "h402 call web/search --provider stableenrich-exa --api-url https://test.example --json '{\"query\":\"h402\"}'"
}
}
}
@@ -308,7 +326,11 @@ describe("provider-first catalog commands", () => {
flowFollowUps: ["web/search-status"]
},
candidate: { provider: "blockrun-grok", sampleOutput: { output: "native" } },
- providerSelection: { source: "explicit", provider: "blockrun-grok" }
+ 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");
@@ -373,7 +395,8 @@ describe("provider-first catalog commands", () => {
cliProviderSelection: {
source: "catalog-default",
provider: "stableenrich-exa",
- pinnedCommand: "h402 quote web/search --provider stableenrich-exa --json '{\"query\":\"h402\"}'"
+ pinnedCommand:
+ "h402 quote web/search --provider stableenrich-exa --api-url https://test.example --json '{\"query\":\"h402\"}'"
}
}
}