From df2514d4346145b6370e41403b0e8d2ec2936303 Mon Sep 17 00:00:00 2001 From: Fabiano Cruz Date: Fri, 11 Sep 2026 00:02:33 -0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(cli):=20comandos=20por=20grupo=20de=20?= =?UTF-8?q?recurso=20e=20as=2015=20meta-tools,=20derivados=20da=20superf?= =?UTF-8?q?=C3=ADcie=20publicada=20(core#125)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A CLI ganhou seis grupos de recurso (consumers, boletos, sellers, mcp-servers, wallets, triggers) cobrindo 61 operações, e um comando único sobre as 15 meta-tools publicadas. Nenhum comando é escrito à mão: os subcomandos saem de API_OPERATIONS (a tabela que o @codespar/sdk gera do documento OpenAPI servido) e as meta-tools saem de SHARED_META_TOOL_DEFINITIONS em @codespar/types. O despacho passa por cs.api — a CLI não acrescenta HTTP próprio. Um portão de cobertura lê a superfície publicada e exige que cada família de rotas tenha comando ou uma exceção com razão escrita e data; a lista de exceções é catraca (só desce) e tem controle positivo e negativo. Um segundo portão fixa os dez caminhos REST que os comandos antigos ainda montam à mão, nenhum deles declarado no documento servido. Co-Authored-By: Claude Opus 5 (1M context) --- packages/cli/CHANGELOG.md | 35 ++ packages/cli/README.md | 40 ++ packages/cli/package.json | 2 +- .../cli/src/__tests__/meta-tool-args.test.ts | 121 ++++ .../src/__tests__/resource-commands.test.ts | 242 ++++++++ .../src/__tests__/surface-coverage.test.ts | 321 ++++++++++ packages/cli/src/commands/meta-input.ts | 14 + packages/cli/src/commands/meta-tool.ts | 213 +++++++ packages/cli/src/commands/resource.ts | 122 ++++ packages/cli/src/index.ts | 123 ++++ packages/cli/src/output.ts | 90 +++ packages/cli/src/surface.ts | 573 ++++++++++++++++++ 12 files changed, 1895 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/__tests__/meta-tool-args.test.ts create mode 100644 packages/cli/src/__tests__/resource-commands.test.ts create mode 100644 packages/cli/src/__tests__/surface-coverage.test.ts create mode 100644 packages/cli/src/commands/meta-tool.ts create mode 100644 packages/cli/src/commands/resource.ts create mode 100644 packages/cli/src/surface.ts diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 7f11ddd..b587d16 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,40 @@ # @codespar/cli — changelog +## 0.7.0 — 2026-09-11 + +Resource groups and the 15 meta-tools, derived from the published +surface instead of written one by one. See +[codespar/codespar-core#125](https://github.com/codespar/codespar-core/issues/125). + +### Added + +- Six resource command groups — `consumers`, `boletos`, `sellers`, + `mcp-servers`, `wallets`, `triggers` — covering 61 operations. Each + subcommand is one row of `API_OPERATIONS`, the table `@codespar/sdk` + generates from the served OpenAPI document: the path parameters are the + positionals, `-q/--query key=value` is repeatable, and `-i/--input` is + accepted only where the operation declares a body. Dispatch goes + through `cs.api`, so the CLI adds no HTTP of its own. +- `codespar tool ` — invoke any of the 15 meta-tools published in + `@codespar/types`. `--action` is checked against the tool's published + vocabulary, `--arg key=value` is typed by the published schema, and a + missing required property fails before anything is sent. +- `codespar pay` and `codespar kyc` — shorthands for `tool codespar_pay` + and `tool codespar_kyc`. +- `codespar tools meta [name]` — the published definitions: actions, + required input, closed vocabularies, full input schema. +- A coverage gate (`src/__tests__/surface-coverage.test.ts`): every + resource family of the served document needs a command or an exception + with a reason and a date, and the exception list is a ratchet that only + goes down. A second ratchet pins the ten REST paths the older commands + still build by hand, none of which the served document declares. + +### Changed + +- Errors from the generated REST client (`CodesparApiError`, + `TimeoutError`) print the API's message and body and exit 1, instead of + falling through to the internal-error stack trace. + ## 0.6.1 — 2026-09-09 Dependency range only: `@codespar/sdk` `^0.12.0` (the generated REST diff --git a/packages/cli/README.md b/packages/cli/README.md index e23cf0d..f5e0a90 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -41,6 +41,19 @@ codespar spend --mandate --amount 1 --agent buyer \ # ramp trade at the real rate). Omit --execute to just plan it. codespar transfer shopper --from BRL --to USDC --amount 15000 +# Every published meta-tool is invocable by name; the actions come from the +# published definition, so an unknown one is refused before anything is sent +codespar tools meta +codespar tool codespar_wallet --action balance --arg consumer_id=con_0000 +codespar pay --action status --arg reference=pay_0000 + +# Resource groups are derived from the served OpenAPI document: each +# subcommand is one operation, its path parameters are the positionals +codespar sellers status slr_0000 +codespar wallets list --query status=active +codespar triggers create --input '{"url":"https://example.test/hook","events":["payment.settled"]}' +codespar boletos list con_0000 + # Manage sessions and logs codespar sessions list codespar logs tail --server stripe @@ -60,6 +73,15 @@ codespar init my-agent | `servers show ` | Show a server's details and tools | | `tools list` | List tools (filter by `--server`) | | `tools show ` | Show a tool's full input/output schema | +| `tools meta [name]` | The 15 published meta-tool definitions — actions, required input, vocabularies | +| `tool ` | Invoke any published meta-tool: `--action`, `--arg key=value`, `--input` | +| `pay` / `kyc` | Shorthand for `tool codespar_pay` / `tool codespar_kyc` | +| `consumers ` | Consumers: profile, Pix keys, Pix lookups, receipts, contact verification | +| `boletos ` | DDA: subscribe a document, list the boletos it receives | +| `sellers ` | Sellers: onboarding status, custody, pending settlement, ledger | +| `mcp-servers ` | Tenant MCP servers: register, validate, patch a tool, sweep platform fees | +| `wallets ` | Wallets: balances, ledger, funding sources, execute, transfer, custody | +| `triggers ` | Triggers (webhooks): endpoints, deliveries, DLQ, secret rotation, redelivery | | `execute ` | Run a single tool call in a throwaway session | | `discover ` | Search the catalog for tools matching a use case | | `mandate create` | Create a consumer mandate — the agent's allowance. `--slot CURRENCY:METHOD:CAP:PER_TX` (repeatable, e.g. `BRL:pix:50000:1500`) for a unified multi-currency wallet; per-currency caps, no FX | @@ -91,6 +113,24 @@ codespar init my-agent | `--base-url ` | Point at a custom API (staging, self-hosted) | | `--project ` | Scope requests to a project (multi-project orgs) | +Resource-group subcommands also take `-q, --query key=value` (repeatable), +`--timeout `, and — when the operation declares a request body — +`-i, --input ''` or `-f, --input-file `. + +## Where the commands come from + +The resource groups and the meta-tool commands are not written one by one. +`codespar sellers`, `consumers`, `boletos`, `mcp-servers`, `wallets` and +`triggers` are derived from `API_OPERATIONS` — the operation table +`@codespar/sdk` generates from the served OpenAPI document — so a +subcommand is one operation, and the request behind it is the one the +document declares. `codespar tool ` reads the 15 shared meta-tool +definitions from `@codespar/types`: the names, the actions and the +required input are the published ones, checked before anything is sent. + +A resource family with no command needs a written exception with a date +(`src/surface.ts`), and the coverage test refuses to let that list grow. + ## Configuration Resolution order (first match wins): diff --git a/packages/cli/package.json b/packages/cli/package.json index b25ee70..75c5df4 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@codespar/cli", - "version": "0.6.1", + "version": "0.7.0", "description": "CodeSpar CLI. The agentic OS for money movement in Latin America, from your terminal: authenticate, browse servers, mint mandates, execute tools, manage sessions, stream logs.", "type": "module", "bin": { diff --git a/packages/cli/src/__tests__/meta-tool-args.test.ts b/packages/cli/src/__tests__/meta-tool-args.test.ts new file mode 100644 index 0000000..046d382 --- /dev/null +++ b/packages/cli/src/__tests__/meta-tool-args.test.ts @@ -0,0 +1,121 @@ +/** + * The `codespar tool ` runner validates against the published + * definition and nothing else: the property names, the required subset + * and the closed vocabularies come from `@codespar/types`. These tests + * read the same definitions the runner reads, so a vocabulary that + * changes there changes the expectation here too — no rail, action or + * property name is retyped in this file. + */ + +import { describe, expect, it } from "vitest"; +import { SHARED_META_TOOL_DEFINITIONS } from "@codespar/sdk"; +import { buildArgs, coerceArg, requireDefinition } from "../commands/meta-tool.js"; +import { metaToolActions, metaToolNames } from "../surface.js"; + +const pay = requireDefinition("codespar_pay"); +const kyc = requireDefinition("codespar_kyc"); +const wallet = requireDefinition("codespar_wallet"); + +describe("requireDefinition", () => { + it("resolves every published name and refuses anything else", () => { + for (const name of Object.keys(SHARED_META_TOOL_DEFINITIONS)) { + expect(requireDefinition(name).name).toBe(name); + } + expect(() => requireDefinition("codespar_made_up")).toThrow(/Unknown meta-tool/); + }); + + it("lists the published tools when the name is wrong", () => { + try { + requireDefinition("nope"); + throw new Error("should have thrown"); + } catch (err) { + for (const name of metaToolNames()) { + expect((err as Error).message).toContain(name); + } + } + }); +}); + +describe("--action", () => { + it("accepts every action the definition publishes", () => { + for (const action of metaToolActions("codespar_wallet")) { + expect(buildArgs(wallet, undefined, [], action).action).toBe(action); + } + }); + + it("refuses an action outside the published vocabulary, naming it", () => { + expect(() => buildArgs(pay, undefined, [], "refund")).toThrow( + new RegExp(metaToolActions("codespar_pay").join(" \\| ")), + ); + }); + + it("refuses --action for a tool that publishes no action property", () => { + // codespar_kyc discriminates on check_type; the message must say so + // instead of silently sending an `action` the router ignores. + expect(metaToolActions("codespar_kyc")).toEqual([]); + expect(() => buildArgs(kyc, undefined, [], "status")).toThrow(/publishes no "action"/); + }); +}); + +describe("--arg", () => { + it("refuses a property the definition does not publish", () => { + expect(() => coerceArg(wallet, "not_a_property", "x")).toThrow(/has no property/); + }); + + it("types a value by the published schema", () => { + const amount = wallet.input_schema.properties.amount; + expect(amount?.type).toBe("number"); + expect(coerceArg(wallet, "amount", "1500")).toBe(1500); + expect(() => coerceArg(wallet, "amount", "lots")).toThrow(/expects a number/); + }); + + it("takes JSON for an object-typed property", () => { + const buyer = kyc.input_schema.properties.buyer; + expect(buyer?.type).toBe("object"); + expect(coerceArg(kyc, "buyer", '{"name":"Fulano"}')).toEqual({ name: "Fulano" }); + expect(() => coerceArg(kyc, "buyer", "Fulano")).toThrow(/needs valid JSON/); + }); + + it("refuses a value outside a property's published vocabulary", () => { + const check = kyc.contract.enums?.check_type ?? []; + expect(check.length).toBeGreaterThan(0); + expect(coerceArg(kyc, "check_type", check[0]!)).toBe(check[0]); + expect(() => coerceArg(kyc, "check_type", "vibes")).toThrow(/published vocabulary/); + }); + + it("rejects a pair with no equals sign", () => { + expect(() => buildArgs(wallet, undefined, ["justakey"], undefined)).toThrow(/key=value/); + }); +}); + +describe("required input", () => { + it("refuses to send when a required property is missing, and says how to pass it", () => { + expect(() => buildArgs(pay, undefined, [], undefined)).toThrow(/Nothing was sent/); + expect(() => buildArgs(pay, undefined, [], undefined)).toThrow( + new RegExp(`--action <${metaToolActions("codespar_pay").join("\\|")}>`), + ); + }); + + it("accepts --input as the base and lets --arg and --action override it", () => { + const args = buildArgs( + wallet, + { action: "statement", consumer_id: "con_0000" }, + ["consumer_id=con_1111"], + "balance", + ); + expect(args).toEqual({ action: "balance", consumer_id: "con_1111" }); + }); + + it("checks every tool's required set against an empty input", () => { + for (const name of metaToolNames()) { + const definition = requireDefinition(name); + if (definition.contract.required.length === 0) { + expect(buildArgs(definition, undefined, [], undefined)).toEqual({}); + continue; + } + expect(() => buildArgs(definition, undefined, [], undefined)).toThrow( + new RegExp(`requires ${definition.contract.required.join(", ")}`), + ); + } + }); +}); diff --git a/packages/cli/src/__tests__/resource-commands.test.ts b/packages/cli/src/__tests__/resource-commands.test.ts new file mode 100644 index 0000000..6a2ff6a --- /dev/null +++ b/packages/cli/src/__tests__/resource-commands.test.ts @@ -0,0 +1,242 @@ +/** + * The derived resource commands: their names, and the request each one + * actually makes. + * + * The names are a pure function of the operation table, so they are + * pinned here. A spec refresh that renames an existing command shows up + * as a diff in this list — which is the point: `codespar sellers status` + * is in someone's script, and it must not change silently. + */ + +import { describe, expect, it, vi, afterEach } from "vitest"; +import { CodesparApiError } from "@codespar/sdk"; +import { derivedSurface, deriveGroup } from "../surface.js"; +import { bindPathParams, parseQuery, runResourceCommand } from "../commands/resource.js"; + +const AUTH = { apiKey: "csk_test_notreal", baseUrl: "https://api.test.dev" }; + +function commandNamed(group: string, name: string) { + const derived = derivedSurface().find((g) => g.spec.name === group); + const command = derived?.commands.find((c) => c.name === name); + if (!command) throw new Error(`no derived command ${group} ${name}`); + return command; +} + +function mockFetch(status: number, body: unknown) { + const calls: Array<{ url: string; init: RequestInit }> = []; + vi.spyOn(globalThis, "fetch").mockImplementation((input: unknown, init: unknown) => { + calls.push({ url: String(input), init: init as RequestInit }); + return Promise.resolve( + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }), + ); + }); + return calls; +} + +afterEach(() => vi.restoreAllMocks()); + +const PINNED_COMMANDS = [ + "consumers list → GET /v1/consumers", + "consumers create → POST /v1/consumers", + "consumers get → GET /v1/consumers/{id}", + "consumers update → PATCH /v1/consumers/{id}", + "consumers list-pix-keys → GET /v1/consumers/{consumerId}/pix-keys", + "consumers create-pix-keys → POST /v1/consumers/{consumerId}/pix-keys", + "consumers delete-pix-keys → DELETE /v1/consumers/{consumerId}/pix-keys/{key}", + "consumers pix-charges → GET /v1/consumers/{consumerId}/pix/charges/{reference}", + "consumers pix-receivements → GET /v1/consumers/{consumerId}/pix/receivements/{endToEndId}", + "consumers pix-devolutions → GET /v1/consumers/{consumerId}/pix/devolutions/{devolutionId}", + "consumers fund → GET /v1/consumers/{consumerId}/fund/{txId}", + "consumers wallet → GET /v1/consumers/{id}/wallet", + "consumers list-mandates-card → GET /v1/consumers/mandates/{id}/card", + "consumers delete-mandates-card → DELETE /v1/consumers/mandates/{id}/card", + "consumers list-receipts → GET /v1/consumers/{consumerId}/receipts", + "consumers get-receipts → GET /v1/consumers/receipts/{id}", + "consumers contact-verifications → POST /v1/consumers/{consumerId}/contact-verifications", + "consumers contact-verifications-verify → POST /v1/consumers/{consumerId}/contact-verifications/{id}/verify", + "boletos get-subscriptions → GET /v1/consumers/{consumerId}/dda/subscriptions/{document}", + "boletos delete-subscriptions → DELETE /v1/consumers/{consumerId}/dda/subscriptions/{document}", + "boletos list → GET /v1/consumers/{consumerId}/dda/boletos", + "boletos create-subscriptions → POST /v1/consumers/{consumerId}/dda/subscriptions", + "sellers create → POST /v1/sellers", + "sellers get → GET /v1/sellers/{sellerId}", + "sellers status → GET /v1/sellers/{sellerId}/status", + "sellers pending-settlement → GET /v1/sellers/{sellerId}/pending-settlement", + "sellers custody → GET /v1/sellers/{sellerId}/custody", + "sellers ledger → GET /v1/sellers/{sellerId}/ledger", + "mcp-servers validate → POST /v1/mcp-servers/validate", + "mcp-servers list → GET /v1/mcp-servers", + "mcp-servers create → POST /v1/mcp-servers", + "mcp-servers get → GET /v1/mcp-servers/{id}", + "mcp-servers delete → DELETE /v1/mcp-servers/{id}", + "mcp-servers update → PATCH /v1/mcp-servers/{id}", + "mcp-servers tools → PATCH /v1/mcp-servers/{id}/tools/{tool}", + "mcp-servers platform-fees-sweep → POST /v1/mcp-servers/platform-fees/sweep", + "wallets list → GET /v1/wallets", + "wallets create → POST /v1/wallets", + "wallets get → GET /v1/wallets/{id}", + "wallets list-ledger → GET /v1/wallets/{id}/ledger", + "wallets create-ledger → POST /v1/wallets/{id}/ledger", + "wallets list-funding-sources → GET /v1/wallets/{id}/funding-sources", + "wallets create-funding-sources → POST /v1/wallets/{id}/funding-sources", + "wallets delete-funding-sources → DELETE /v1/wallets/{id}/funding-sources/{connection_id}/{currency}", + "wallets execute → POST /v1/wallets/{id}/execute", + "wallets list-recon-anomalies → GET /v1/wallets/{id}/recon-anomalies", + "wallets create-recon-anomalies → POST /v1/wallets/{id}/recon-anomalies/{aid}", + "wallets receive → GET /v1/wallets/{id}/receive", + "wallets custody → GET /v1/wallets/{id}/custody", + "wallets transfer → POST /v1/wallets/{id}/transfer", + "wallets statement-import → POST /v1/wallets/{id}/statement-import", + "triggers list → GET /v1/triggers", + "triggers create → POST /v1/triggers", + "triggers get → GET /v1/triggers/{id}", + "triggers delete → DELETE /v1/triggers/{id}", + "triggers rotate-secret → POST /v1/triggers/{id}/rotate-secret", + "triggers list-deliveries → GET /v1/triggers/{id}/deliveries", + "triggers get-deliveries → GET /v1/triggers/{id}/deliveries/{delivery_id}", + "triggers dlq → GET /v1/triggers/{id}/dlq", + "triggers retry-pending → POST /v1/triggers/retry-pending", + "triggers deliveries-redeliver → POST /v1/triggers/deliveries/{delivery_id}/redeliver", +]; + +describe("derived resource commands", () => { + it("are exactly these, with exactly these requests behind them", () => { + const actual = derivedSurface().flatMap(({ spec, commands }) => + commands.map((c) => `${spec.name} ${c.name} \u2192 ${c.method.toUpperCase()} ${c.path}`), + ); + expect(actual).toEqual(PINNED_COMMANDS); + }); + + it("names a subcommand after the verb alone when it has no literal suffix", () => { + expect(commandNamed("wallets", "list").path).toBe("/v1/wallets"); + expect(commandNamed("wallets", "get").params).toEqual(["id"]); + }); + + it("disambiguates a shared suffix with the verb, and leaves a unique one bare", () => { + expect(commandNamed("triggers", "list-deliveries").path).toBe("/v1/triggers/{id}/deliveries"); + expect(commandNamed("triggers", "get-deliveries").path).toBe( + "/v1/triggers/{id}/deliveries/{delivery_id}", + ); + expect(commandNamed("triggers", "dlq").method).toBe("get"); + }); + + it("keeps the parameters a nested group's prefix passes over", () => { + // `boletos` hangs off /v1/consumers/{consumerId}/dda: dropping + // consumerId would leave a command that cannot name a consumer. + expect(commandNamed("boletos", "list").params).toEqual(["consumerId"]); + expect(commandNamed("boletos", "get-subscriptions").params).toEqual([ + "consumerId", + "document", + ]); + }); + + it("derives nothing for a prefix the document does not declare", () => { + const empty = deriveGroup( + { name: "ghost", prefix: "/v1/ghost", description: "not served" }, + undefined, + [{ name: "ghost", prefix: "/v1/ghost", description: "not served" }], + ); + expect(empty.commands).toEqual([]); + }); +}); + +describe("argument binding", () => { + it("maps positionals onto path parameters in path order", () => { + expect( + bindPathParams({ path: "/v1/a/{x}/b/{y}", params: ["x", "y"] }, ["one", "two"]), + ).toEqual({ x: "one", y: "two" }); + }); + + it("refuses the wrong number of positionals, and an empty one", () => { + expect(() => bindPathParams({ path: "/v1/a/{x}", params: ["x"] }, [])).toThrow( + /takes 1 argument/, + ); + expect(() => bindPathParams({ path: "/v1/a/{x}", params: ["x"] }, [""])).toThrow( + /must not be empty/, + ); + }); + + it("parses --query pairs and collects repeats", () => { + expect(parseQuery(["status=active", "tag=a", "tag=b"])).toEqual({ + status: "active", + tag: ["a", "b"], + }); + expect(() => parseQuery(["novalue"])).toThrow(/key=value/); + }); +}); + +describe("dispatch", () => { + it("sends the operation's method, expanded path, query and auth", async () => { + const calls = mockFetch(200, { data: [{ id: "slr_0000", status: "approved" }] }); + await runResourceCommand(commandNamed("sellers", "status"), { + ...AUTH, + args: ["slr_0000"], + query: ["expand=ledger"], + json: true, + }); + expect(calls).toHaveLength(1); + expect(calls[0]!.url).toBe("https://api.test.dev/v1/sellers/slr_0000/status?expand=ledger"); + expect(calls[0]!.init.method).toBe("GET"); + expect((calls[0]!.init.headers as Record).Authorization).toBe( + "Bearer csk_test_notreal", + ); + }); + + it("sends --input as the request body when the operation declares one", async () => { + const calls = mockFetch(201, { id: "trg_0000" }); + await runResourceCommand(commandNamed("triggers", "create"), { + ...AUTH, + args: [], + query: [], + input: '{"url":"https://example.test/hook","events":["payment.settled"]}', + json: true, + }); + expect(calls[0]!.init.method).toBe("POST"); + expect(JSON.parse(String(calls[0]!.init.body))).toEqual({ + url: "https://example.test/hook", + events: ["payment.settled"], + }); + }); + + it("refuses a body on an operation that declares none, before any request", async () => { + const calls = mockFetch(200, {}); + await expect( + runResourceCommand(commandNamed("sellers", "status"), { + ...AUTH, + args: ["slr_0000"], + query: [], + input: "{}", + }), + ).rejects.toThrow(/declares no request body/); + expect(calls).toEqual([]); + }); + + it("scopes to the project when one is configured", async () => { + const calls = mockFetch(200, {}); + await runResourceCommand(commandNamed("wallets", "list"), { + ...AUTH, + project: "prj_abcdefghij123456", + args: [], + query: [], + json: true, + }); + expect((calls[0]!.init.headers as Record)["x-codespar-project"]).toBe( + "prj_abcdefghij123456", + ); + }); + + it("surfaces the API's own error instead of inventing a result", async () => { + mockFetch(404, { error: { code: "not_found", message: "wallet wal_0000 not found" } }); + await expect( + runResourceCommand(commandNamed("wallets", "get"), { + ...AUTH, + args: ["wal_0000"], + query: [], + json: true, + }), + ).rejects.toBeInstanceOf(CodesparApiError); + }); +}); diff --git a/packages/cli/src/__tests__/surface-coverage.test.ts b/packages/cli/src/__tests__/surface-coverage.test.ts new file mode 100644 index 0000000..ec36b76 --- /dev/null +++ b/packages/cli/src/__tests__/surface-coverage.test.ts @@ -0,0 +1,321 @@ +/** + * The coverage gate for the CLI's command surface. + * + * It compares two published lists against what this CLI actually + * registers: + * + * API_OPERATIONS (@codespar/sdk) → resource groups + * SHARED_META_TOOL_DEFINITIONS (@codespar/types) → the 15 meta-tools + * + * A resource group must have a derived command group, or an entry in + * SURFACE_EXCEPTIONS carrying a reason and a date. The exception list is + * a ratchet pinned by EXCEPTION_PIN: it may shrink, never grow. + * + * The checker (`auditSurface`) is a pure function of the surface passed + * to it, so this file also runs it on synthetic surfaces — one covered, + * one not — and a checker that cannot go red fails its own controls. + */ + +import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + EXCEPTION_PIN, + OPERATIONS, + PUBLISHED_GROUPS, + SURFACE_EXCEPTIONS, + auditSurface, + census, + censusGroup, + claimingGroup, + derivedSurface, + metaToolNames, +} from "../surface.js"; +import { requireDefinition } from "../commands/meta-tool.js"; + +const SRC = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +/** Census groups a derived command group covers. */ +function coveredGroups(): string[] { + const covered = new Set(); + for (const { spec, commands } of derivedSurface()) { + expect(commands.length, `${spec.name} derived no command`).toBeGreaterThan(0); + for (const command of commands) covered.add(censusGroup(command.path)); + } + return [...covered]; +} + +function liveAudit() { + return auditSurface({ + censusGroups: [...census().keys()], + coveredGroups: coveredGroups(), + exceptions: SURFACE_EXCEPTIONS, + publishedMetaTools: metaToolNames(), + invocableMetaTools: metaToolNames().filter((name) => { + try { + return requireDefinition(name).name === name; + } catch { + return false; + } + }), + }); +} + +describe("CLI surface coverage", () => { + it("gives every resource group of the served document a command or a written exception", () => { + const violations = liveAudit(); + expect( + violations.map((v) => `${v.kind}: ${v.detail}`), + [ + "A resource group of the served OpenAPI document has no CLI and no exception.", + "", + "DO NOT close this by adding an entry to SURFACE_EXCEPTIONS — that is the lazy", + "fix and it makes the CLI narrower while the number stays green. Publishing the", + "group is ONE ROW in PUBLISHED_GROUPS (name + path prefix + description); the", + "subcommands are then derived from the operation table with no further code.", + "", + "Signature of the real cause: the group appeared because packages/core refreshed", + "openapi-snapshot.json and the API grew a route family. That is a group to", + "publish, not to except.", + "", + "An exception is only for a family with no terminal use (a browser redirect, a", + "machine-to-machine handshake, a probe). It needs a reason that says which, and", + "the date it was written. Evidence accepted for a NEW exception: the route list", + "of the family, and one sentence on who calls it instead of a person.", + ].join("\n"), + ).toEqual([]); + }); + + it("holds the exception ratchet at its pin", () => { + const count = Object.keys(SURFACE_EXCEPTIONS).length; + expect( + count, + [ + `SURFACE_EXCEPTIONS has ${count} entries and EXCEPTION_PIN says ${EXCEPTION_PIN}.`, + "", + `If ${count} > ${EXCEPTION_PIN}: an exception was ADDED. Don't raise the pin. Publish`, + "the group instead (one row in PUBLISHED_GROUPS). Raising the pin needs the", + "founder's sign-off in the PR body, naming the family and why it has no terminal use.", + "", + `If ${count} < ${EXCEPTION_PIN}: an exception was RETIRED, which is the point of the`, + `ratchet. Lower EXCEPTION_PIN to ${count} in the same commit.`, + ].join("\n"), + ).toBe(EXCEPTION_PIN); + }); + + it("publishes every meta-tool the definitions publish, and no invented one", () => { + const published = metaToolNames(); + expect(published).toHaveLength(15); + for (const name of published) expect(requireDefinition(name).name).toBe(name); + // The CLI must not know a name the definitions do not publish: an + // invented tool would look invocable in --help and fail at the wire. + expect(() => requireDefinition("codespar_not_published")).toThrow(/Unknown meta-tool/); + }); + + it("still finds no /v1/admin/* operation in the served document", () => { + // The API matrix v2.1.1 names an admin/account family. It has no + // served route, so there is no exception for it and no command. When + // this goes red the routes shipped: publish the group. + expect(OPERATIONS.filter((op) => op.path.startsWith("/v1/admin"))).toEqual([]); + }); + + it("claims every operation of a published group exactly once", () => { + const claimed = new Map(); + for (const { commands } of derivedSurface()) { + for (const command of commands) { + const key = `${command.method} ${command.path}`; + claimed.set(key, (claimed.get(key) ?? 0) + 1); + } + } + const expected = OPERATIONS.filter((op) => claimingGroup(op.path) !== undefined); + expect(claimed.size).toBe(expected.length); + expect([...claimed.values()].filter((n) => n !== 1)).toEqual([]); + }); +}); + +describe("auditSurface controls", () => { + const exception = { reason: "x".repeat(30), since: "2026-09-10" }; + const base = { + censusGroups: ["alpha", "beta"], + coveredGroups: ["alpha"], + exceptions: { beta: exception }, + publishedMetaTools: ["codespar_alpha"], + invocableMetaTools: ["codespar_alpha"], + }; + + it("positive control: a covered group plus a written exception is clean", () => { + expect(auditSurface(base)).toEqual([]); + }); + + it("negative control: an uncovered group with no exception is reported", () => { + const violations = auditSurface({ ...base, exceptions: {} }); + expect(violations).toHaveLength(1); + expect(violations[0]).toMatchObject({ kind: "uncovered-group", subject: "beta" }); + }); + + it("negative control: an exception with no usable reason is reported", () => { + const violations = auditSurface({ + ...base, + exceptions: { beta: { reason: "later", since: "2026-09-10" } }, + }); + expect(violations.map((v) => v.kind)).toEqual(["empty-reason"]); + }); + + it("negative control: an exception with no ISO date is reported", () => { + const violations = auditSurface({ + ...base, + exceptions: { beta: { reason: "x".repeat(30), since: "someday" } }, + }); + expect(violations.map((v) => v.kind)).toEqual(["bad-date"]); + }); + + it("negative control: an exception for a group that is covered, or gone, is reported", () => { + expect( + auditSurface({ ...base, exceptions: { ...base.exceptions, alpha: exception } }).map( + (v) => v.subject, + ), + ).toEqual(["alpha"]); + expect( + auditSurface({ ...base, exceptions: { ...base.exceptions, gamma: exception } }).map( + (v) => v.kind, + ), + ).toEqual(["stale-exception"]); + }); + + it("negative control: a published meta-tool the CLI cannot invoke is reported", () => { + const violations = auditSurface({ ...base, invocableMetaTools: [] }); + expect(violations.map((v) => v.kind)).toEqual(["meta-tool-missing"]); + }); +}); + +/* ── Hand-written path ratchet ───────────────────────────────────── */ + +/** + * Paths the CLI builds by hand instead of dispatching through the + * generated operation table. Each of these is a route the served OpenAPI + * document does not declare, so nothing checks it: a rename on the + * backend reaches the user as a 404 at runtime. + * + * This list is a ratchet too. It exists to stop the number growing while + * the drift is worked off route by route; it is NOT a place to register + * a new hand-written call. + */ +const OFF_SPEC_PATHS = [ + "/v1/consents/init", + "/v1/consents/{}/submit", + "/v1/consumers/mandates/{}/spend", + "/v1/consumers/{}/wallet/transfer", + "/v1/logs/stream", + "/v1/servers/{}", + "/v1/sessions/{}/close", + "/v1/sessions/{}/logs", + "/v1/tools", + "/v1/tools/{}", +]; + +function sourceFiles(dir: string): string[] { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) return entry.name === "__tests__" ? [] : sourceFiles(full); + return entry.name.endsWith(".ts") ? [full] : []; + }); +} + +/** + * Comments out. A path named in prose — a JSDoc line explaining which + * route a command hits, or an exception's reason — is documentation, not + * traffic, and counting it would make the scanner report a request that + * does not exist. Block comments go first, then line comments, and a + * `//` preceded by `:` is left alone so a `https://` inside a string + * does not swallow the rest of the line. + */ +export function stripComments(source: string): string { + return source + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/(^|[^:"'`\\])\/\/.*$/gm, "$1"); +} + +/** Every `/v1/...` literal in the CLI's own source, with `${...}` → `{}`. */ +function handWrittenPaths(): string[] { + const found = new Set(); + for (const file of sourceFiles(SRC)) { + const source = stripComments(fs.readFileSync(file, "utf8")); + for (const match of source.matchAll(/["'`](\/v1\/[^"'`\s]*)["'`]/g)) { + const raw = match[1]!; + const normalised = raw.replace(/\$\{[^}]*\}/g, "{}").replace(/\{[^}]*\}/g, "{}"); + // A path prefix declared in PUBLISHED_GROUPS is a routing rule, not + // a request: the requests under it dispatch through the generated + // table, so the prefix is not hand-written traffic. + if (normalised.includes("$")) continue; + if (PUBLISHED_GROUPS.some((g) => g.prefix === normalised)) continue; + found.add(normalised); + } + } + return [...found].sort(); +} + +describe("hand-written REST paths", () => { + it("are exactly the ones already known to be off the served document", () => { + const declared = new Set( + OPERATIONS.map((op) => op.path.replace(/\{[^}]*\}/g, "{}")), + ); + const offSpec = handWrittenPaths().filter((p) => !declared.has(p)); + expect( + offSpec, + [ + "A path written by hand in the CLI is not an operation of the served OpenAPI", + "document, and it is not one of the ten already known.", + "", + "DO NOT close this by appending the path to OFF_SPEC_PATHS. That list is a debt", + "register with a downward ratchet, not an allowlist. Dispatch through the", + "generated table instead: add the group to PUBLISHED_GROUPS, or call", + "`cs.api.request(method, path, ...)` with a path the document declares.", + "", + "Signature of the real cause: someone needed a route the SDK snapshot does not", + "carry. Either the route is undocumented on the backend (fix the backend's", + "OpenAPI, then `npm run spec:refresh` in packages/core), or the path is simply", + "wrong and would 404 in production the first time it ran.", + "", + "Evidence accepted for retiring an entry: the operation appears in", + "API_OPERATIONS and the command dispatches through it.", + ].join("\n"), + ).toEqual(OFF_SPEC_PATHS); + }); + + it("control: the scanner reads code and ignores prose", () => { + const source = [ + '// a comment naming "/v1/ghost/line" must not count', + "/* a block comment naming `/v1/ghost/block` must not count */", + 'const real = await client.get("/v1/real/path");', + 'const url = "https://api.codespar.dev/v1/absolute";', + ].join("\n"); + const stripped = stripComments(source); + expect(stripped).not.toContain("/v1/ghost/line"); + expect(stripped).not.toContain("/v1/ghost/block"); + expect(stripped).toContain("/v1/real/path"); + expect(stripped).toContain("https://api.codespar.dev/v1/absolute"); + }); + + it("positive control: the scanner does see the spec-declared paths the CLI calls", () => { + // If the scanner silently found nothing, the ratchet above would pass + // for the wrong reason. It must also see paths that ARE declared. + const declared = new Set( + OPERATIONS.map((op) => op.path.replace(/\{[^}]*\}/g, "{}")), + ); + const onSpec = handWrittenPaths().filter((p) => declared.has(p)); + expect(onSpec).toContain("/v1/whoami"); + expect(onSpec.length).toBeGreaterThan(3); + }); +}); + +describe("published groups", () => { + it("names each group once and derives at least one command for it", () => { + const names = PUBLISHED_GROUPS.map((g) => g.name); + expect(new Set(names).size).toBe(names.length); + for (const { spec, commands } of derivedSurface()) { + expect(commands.length, `${spec.name} derived no command`).toBeGreaterThan(0); + expect(new Set(commands.map((c) => c.name)).size).toBe(commands.length); + } + }); +}); diff --git a/packages/cli/src/commands/meta-input.ts b/packages/cli/src/commands/meta-input.ts index ee5ab86..8bda856 100644 --- a/packages/cli/src/commands/meta-input.ts +++ b/packages/cli/src/commands/meta-input.ts @@ -35,3 +35,17 @@ export async function resolveMetaInput( throw new CliError(`${source} is not valid JSON: ${(err as Error).message}`); } } + +/** + * Same two flags, but the body is optional: a GET has nothing to send and + * a POST may take an empty body. Returns `undefined` when neither flag is + * given, and throws the same way as `resolveMetaInput` when one is given + * and does not parse. + */ +export async function resolveOptionalInput( + opts: { input?: string; inputFile?: string }, + what: string, +): Promise | undefined> { + if (opts.input === undefined && opts.inputFile === undefined) return undefined; + return resolveMetaInput(opts, what, "{}"); +} diff --git a/packages/cli/src/commands/meta-tool.ts b/packages/cli/src/commands/meta-tool.ts new file mode 100644 index 0000000..a8ee42b --- /dev/null +++ b/packages/cli/src/commands/meta-tool.ts @@ -0,0 +1,213 @@ +import { CodeSpar } from "@codespar/sdk"; +import type { SharedMetaToolDefinition } from "@codespar/sdk"; +import { CliError } from "../config.js"; +import { c, info, json, kv, renderResult, table } from "../output.js"; +import { resolveOptionalInput } from "./meta-input.js"; +import { metaToolActions, metaToolDefinition, metaToolNames, META_TOOLS } from "../surface.js"; + +export interface MetaToolCommandOptions { + apiKey: string; + baseUrl: string; + project?: string; + user?: string; + action?: string; + arg: string[]; + input?: string; + inputFile?: string; + json?: boolean; +} + +/** + * Resolve a tool name against the published definitions. The list in the + * error is the published list — the CLI knows of no other tool, and a + * name it cannot find is a name `@codespar/types` does not publish. + */ +export function requireDefinition(name: string): SharedMetaToolDefinition { + const definition = metaToolDefinition(name); + if (definition) return definition; + throw new CliError( + `Unknown meta-tool "${name}". Published tools:\n ${metaToolNames().join("\n ")}`, + ); +} + +/** + * Coerce a `--arg key=value` pair against the property's published type. + * An `object` or `array` property takes JSON; everything else takes the + * literal, so a Pix key that looks like a number stays a string when the + * contract says string. + */ +export function coerceArg( + definition: SharedMetaToolDefinition, + key: string, + raw: string, +): unknown { + const property = definition.input_schema.properties[key]; + if (!property) { + throw new CliError( + `${definition.name} has no property "${key}". Published properties:\n ${definition.contract.properties.join("\n ")}`, + ); + } + let value: unknown = raw; + if (property.type === "number" || property.type === "integer") { + const n = Number(raw); + if (!Number.isFinite(n)) throw new CliError(`${key} expects a number, got "${raw}".`); + value = n; + } else if (property.type === "boolean") { + if (raw !== "true" && raw !== "false") { + throw new CliError(`${key} expects true or false, got "${raw}".`); + } + value = raw === "true"; + } else if (property.type === "object" || property.type === "array") { + try { + value = JSON.parse(raw) as unknown; + } catch (err) { + throw new CliError( + `${key} is a ${property.type}; --arg needs valid JSON for it (or use --input): ${(err as Error).message}`, + ); + } + } + if (property.enum && typeof value === "string" && !property.enum.includes(value)) { + throw new CliError( + `${key}="${value}" is outside the published vocabulary: ${property.enum.join(" | ")}`, + ); + } + return value; +} + +/** + * Build the tool arguments from `--input`, `--arg` and `--action`, then + * check them against the published contract before anything is sent. The + * checks are the contract's own: the property names it publishes, the + * subset it marks required, and the closed vocabularies it declares. No + * vocabulary is written here — a rail or an action added to the published + * definition is accepted by this CLI without a code change. + */ +export function buildArgs( + definition: SharedMetaToolDefinition, + base: Record | undefined, + argPairs: readonly string[], + action: string | undefined, +): Record { + const args: Record = { ...(base ?? {}) }; + + for (const pair of argPairs) { + const eq = pair.indexOf("="); + if (eq <= 0) throw new CliError(`--arg expects key=value, got "${pair}".`); + const key = pair.slice(0, eq); + args[key] = coerceArg(definition, key, pair.slice(eq + 1)); + } + + if (action !== undefined) { + const actions = metaToolActions(definition.name); + if (actions.length === 0) { + const discriminator = definition.contract.required[0]; + throw new CliError( + `${definition.name} publishes no "action" property, so --action means nothing to it.` + + (discriminator + ? ` Its required input is: ${definition.contract.required.join(", ")} — pass it with --arg ${discriminator}= or --input.` + : ""), + ); + } + if (!actions.includes(action)) { + throw new CliError( + `${definition.name} --action "${action}" is outside the published vocabulary: ${actions.join(" | ")}`, + ); + } + args.action = action; + } + + const missing = definition.contract.required.filter( + (name) => args[name] === undefined || args[name] === "", + ); + if (missing.length > 0) { + const how = missing.map((m) => { + const vocabulary = definition.contract.enums?.[m]; + if (m === "action" && vocabulary) return `--action <${vocabulary.join("|")}>`; + if (vocabulary) return `--arg ${m}=<${vocabulary.join("|")}>`; + return `--arg ${m}=`; + }); + throw new CliError( + `${definition.name} requires ${missing.join(", ")}. Pass ${how.join(" ")} or --input ''. Nothing was sent.`, + ); + } + + return args; +} + +/** + * Invoke one of the published meta-tools through a throwaway session, the + * same wire `session.execute(name, args)` uses. The router picks the rail, + * so there is no `--server`. + */ +export async function metaToolCommand( + name: string, + opts: MetaToolCommandOptions, +): Promise { + const definition = requireDefinition(name); + const base = await resolveOptionalInput(opts, definition.name); + const args = buildArgs(definition, base, opts.arg, opts.action); + + const cs = new CodeSpar({ + apiKey: opts.apiKey, + baseUrl: opts.baseUrl, + projectId: opts.project, + }); + const session = await cs.create(opts.user ?? "cli-user", { servers: [] }); + + try { + const result = await session.execute(definition.name, args); + if (!result.success) { + throw new CliError(`${definition.name} failed: ${result.error ?? "unknown error"}`); + } + if (opts.json) { + json(result.data ?? null); + return; + } + info(`${definition.name}${args.action ? ` action=${String(args.action)}` : ""}`); + renderResult(result.data); + } finally { + await session.close(); + } +} + +/** `codespar tools meta` — the published definitions, as published. */ +export function listMetaToolsCommand(opts: { json?: boolean }): void { + if (opts.json) { + json(META_TOOLS); + return; + } + table( + ["TOOL", "ACTIONS", "REQUIRED"], + metaToolNames().map((name) => { + const definition = META_TOOLS[name]!; + const actions = metaToolActions(name); + return [ + name, + actions.length > 0 ? actions.join(" | ") : "-", + definition.contract.required.join(", ") || "-", + ]; + }), + ); +} + +/** `codespar tools meta ` — one definition, schema and vocabularies. */ +export function showMetaToolCommand(name: string, opts: { json?: boolean }): void { + const definition = requireDefinition(name); + if (opts.json) { + json(definition); + return; + } + kv([ + ["Name", definition.name], + ["Required", definition.contract.required.join(", ") || "-"], + ["Properties", definition.contract.properties.join(", ")], + ]); + process.stdout.write(`\n${definition.description}\n`); + const enums = definition.contract.enums ?? {}; + if (Object.keys(enums).length > 0) { + process.stdout.write(`\n${c.bold("Vocabularies")}\n`); + kv(Object.entries(enums).map(([key, values]) => [key, values.join(" | ")])); + } + process.stdout.write("\nInput schema:\n"); + process.stdout.write(JSON.stringify(definition.input_schema, null, 2) + "\n"); +} diff --git a/packages/cli/src/commands/resource.ts b/packages/cli/src/commands/resource.ts new file mode 100644 index 0000000..22ea89a --- /dev/null +++ b/packages/cli/src/commands/resource.ts @@ -0,0 +1,122 @@ +import { CodeSpar } from "@codespar/sdk"; +import { CliError } from "../config.js"; +import { info, json, renderResult } from "../output.js"; +import { resolveOptionalInput } from "./meta-input.js"; +import type { DerivedCommand } from "../surface.js"; + +export interface ResourceCommandOptions { + apiKey: string; + baseUrl: string; + project?: string; + /** Positional path parameters, in the order the path declares them. */ + args: string[]; + query: string[]; + input?: string; + inputFile?: string; + json?: boolean; + timeout?: string; +} + +/** + * The client's `api` is typed by correlating a literal path with a literal + * method; a CLI dispatches a pair that is data at runtime, which that + * correlation cannot express. One cast, at this boundary, to a signature + * that says what the runtime accepts. Everything the cast hides — that the + * pair is a real operation, that a path parameter is present — the + * generated table and `expandPath` check inside the client, and an unknown + * pair throws there rather than reaching the network. + */ +interface UntypedApi { + request( + method: string, + path: string, + options?: { + path?: Record; + query?: Record; + body?: unknown; + timeout?: number; + }, + ): Promise; +} + +/** `--query k=v` (repeatable) → `{ k: v }`, repeats collect into an array. */ +export function parseQuery(pairs: readonly string[]): Record { + const out: Record = {}; + for (const pair of pairs) { + const eq = pair.indexOf("="); + if (eq <= 0) { + throw new CliError(`--query expects key=value, got "${pair}".`); + } + const key = pair.slice(0, eq); + const value = pair.slice(eq + 1); + const existing = out[key]; + if (existing === undefined) out[key] = value; + else if (Array.isArray(existing)) existing.push(value); + else out[key] = [existing, value]; + } + return out; +} + +/** Positional arguments → the path parameter object the client expands. */ +export function bindPathParams( + command: Pick, + args: readonly string[], +): Record { + if (args.length !== command.params.length) { + throw new CliError( + `${command.path} takes ${command.params.length} argument(s) (${command.params.join(", ")}), got ${args.length}.`, + ); + } + const out: Record = {}; + command.params.forEach((name, i) => { + const value = args[i] ?? ""; + if (value === "") throw new CliError(`Path parameter <${name}> must not be empty.`); + out[name] = value; + }); + return out; +} + +/** + * Run one derived resource command: bind the positionals to the path + * parameters, pass `--query` through, pass `--input` as the request body + * when the operation declares one, and print whatever came back. No + * response is synthesised: what prints is the API's own payload, and a + * non-2xx status surfaces as the API's error. + */ +export async function runResourceCommand( + command: DerivedCommand, + opts: ResourceCommandOptions, +): Promise { + const body = await resolveOptionalInput(opts, `${command.method.toUpperCase()} ${command.path}`); + if (body !== undefined && !command.acceptsBody) { + throw new CliError( + `${command.method.toUpperCase()} ${command.path} declares no request body; drop --input / --input-file.`, + ); + } + + const timeout = opts.timeout === undefined ? undefined : Number(opts.timeout); + if (timeout !== undefined && (!Number.isFinite(timeout) || timeout <= 0)) { + throw new CliError(`--timeout expects a positive number of milliseconds, got "${opts.timeout}".`); + } + + const cs = new CodeSpar({ + apiKey: opts.apiKey, + baseUrl: opts.baseUrl, + projectId: opts.project, + ...(timeout !== undefined ? { timeout } : {}), + }); + + const api = cs.api as unknown as UntypedApi; + const result = await api.request(command.method, command.path, { + path: bindPathParams(command, opts.args), + query: parseQuery(opts.query), + ...(body !== undefined ? { body } : {}), + }); + + if (opts.json) { + json(result ?? null); + return; + } + info(`${command.method.toUpperCase()} ${command.path}`); + renderResult(result); +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 54720b6..b306e6b 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node import { Command } from "commander"; +import { CodesparApiError, TimeoutError } from "@codespar/sdk"; import { ApiClient } from "./api.js"; import { CliError, loadConfig, requireApiKey } from "./config.js"; import { loginCommand, whoamiCommand } from "./commands/login.js"; @@ -31,6 +32,13 @@ import { verificationStatusCommand } from "./commands/verification-status.js"; import { wizardCommand } from "./commands/wizard.js"; import { ledgerCommand } from "./commands/ledger.js"; import { issueCommand } from "./commands/issue.js"; +import { + listMetaToolsCommand, + metaToolCommand, + showMetaToolCommand, +} from "./commands/meta-tool.js"; +import { runResourceCommand } from "./commands/resource.js"; +import { derivedSurface, metaToolNames } from "./surface.js"; import { c } from "./output.js"; import { printBanner } from "./banner.js"; import { VERSION } from "./version.js"; @@ -144,6 +152,107 @@ tools await showToolCommand(client, name, { json: rootJsonFlag() }); }); +tools + .command("meta [name]") + .description( + `Show the ${metaToolNames().length} published meta-tool definitions (name, actions, required input)`, + ) + .action((name?: string) => { + if (name) showMetaToolCommand(name, { json: rootJsonFlag() }); + else listMetaToolsCommand({ json: rootJsonFlag() }); + }); + +// ============ meta-tools ============ +// One command over every published definition. The names, the actions and +// the required input come from `@codespar/types` via the SDK re-export, so +// a tool added there is invocable here with no code change. +program + .command("tool ") + .description( + `Invoke a published meta-tool by name (${metaToolNames().length} of them — see \`codespar tools meta\`)`, + ) + .option("--action ", "Action to run, validated against the tool's published vocabulary") + .option("--arg ", "Single input property (repeatable); typed by the published schema", collect, []) + .option("-i, --input ", "Full input as a JSON string") + .option("-f, --input-file ", "Full input from a JSON file") + .option("-u, --user ", "User id for the session (default: cli-user)") + .action( + async ( + name: string, + opts: { action?: string; arg: string[]; input?: string; inputFile?: string; user?: string }, + ) => { + const auth = await resolveAuth(); + await metaToolCommand(name, { ...opts, ...auth, json: rootJsonFlag() }); + }, + ); + +// `pay` and `kyc` are the two money methods core#125 names by hand; both +// are the same runner over the same published definition, given a shorter +// spelling because they are the ones people reach for. +for (const [alias, tool] of [ + ["pay", "codespar_pay"], + ["kyc", "codespar_kyc"], +] as const) { + program + .command(alias) + .description(`Invoke ${tool} (alias of \`codespar tool ${tool}\`)`) + .option("--action ", "Action to run, validated against the tool's published vocabulary") + .option("--arg ", "Single input property (repeatable); typed by the published schema", collect, []) + .option("-i, --input ", "Full input as a JSON string") + .option("-f, --input-file ", "Full input from a JSON file") + .option("-u, --user ", "User id for the session (default: cli-user)") + .action( + async (opts: { + action?: string; + arg: string[]; + input?: string; + inputFile?: string; + user?: string; + }) => { + const auth = await resolveAuth(); + await metaToolCommand(tool, { ...opts, ...auth, json: rootJsonFlag() }); + }, + ); +} + +// ============ resource groups (derived from the served OpenAPI document) ============ +// No per-route code: each subcommand below is one row of the SDK's +// generated operation table, with its path parameters as positionals. +for (const { spec, commands } of derivedSurface()) { + const group = program.command(spec.name).description(spec.description); + for (const command of commands) { + const sub = group + .command(command.name) + .description(`${command.method.toUpperCase()} ${command.path}`) + .option("-q, --query ", "Query parameter (repeatable)", collect, []) + .option("--timeout ", "Per-request timeout in milliseconds"); + for (const param of command.params) sub.argument(`<${param}>`); + if (command.acceptsBody) { + sub + .option("-i, --input ", "Request body as a JSON string") + .option("-f, --input-file ", "Request body from a JSON file"); + } + sub.action(async (...actionArgs: unknown[]) => { + const opts = actionArgs[command.params.length] as { + query: string[]; + input?: string; + inputFile?: string; + timeout?: string; + }; + const auth = await resolveAuth(); + await runResourceCommand(command, { + ...auth, + args: actionArgs.slice(0, command.params.length).map(String), + query: opts.query, + input: opts.input, + inputFile: opts.inputFile, + timeout: opts.timeout, + json: rootJsonFlag(), + }); + }); + } +} + // ============ execute ============ program .command("execute ") @@ -555,6 +664,20 @@ async function main() { process.stderr.write(`${c.red("✗")} ${err.message}\n`); process.exit(1); } + // The generated REST client answers with its own error types. Print the + // API's message and body verbatim — an exit code plus a stack trace + // would hide the one thing the caller needs, which is what the API said. + if (err instanceof CodesparApiError) { + process.stderr.write(`${c.red("✗")} ${err.message}\n`); + if (err.body !== undefined) { + process.stderr.write(JSON.stringify(err.body, null, 2) + "\n"); + } + process.exit(1); + } + if (err instanceof TimeoutError) { + process.stderr.write(`${c.red("✗")} ${err.message}\n`); + process.exit(1); + } // Unexpected error — show stack so we can debug. process.stderr.write(`${c.red("✗ internal error:")}\n`); process.stderr.write(String((err as Error).stack ?? err) + "\n"); diff --git a/packages/cli/src/output.ts b/packages/cli/src/output.ts index ebbf41a..4198760 100644 --- a/packages/cli/src/output.ts +++ b/packages/cli/src/output.ts @@ -61,3 +61,93 @@ export function success(msg: string): void { export function warn(msg: string): void { process.stderr.write(`${c.yellow("⚠")} ${msg}\n`); } + +/** Scalars a generic table can print in a cell. */ +function isScalar(v: unknown): boolean { + return v === null || ["string", "number", "boolean"].includes(typeof v); +} + +function cell(v: unknown): string { + if (v === null || v === undefined) return "-"; + const s = typeof v === "string" ? v : JSON.stringify(v); + return s.length <= 40 ? s : s.slice(0, 39) + "…"; +} + +/** The array inside a single-collection envelope (`{ data: [...] }`), if any. */ +function collectionOf(value: Record): [string, unknown[]] | undefined { + const arrays = Object.entries(value).filter(([, v]) => Array.isArray(v)); + if (arrays.length !== 1) return undefined; + const [key, list] = arrays[0] as [string, unknown[]]; + return [key, list]; +} + +const MAX_COLUMNS = 6; + +/** + * Print an API payload without knowing its shape. A list — bare or inside + * a single-array envelope — becomes a table over the scalar fields its + * items share; anything else prints as JSON. Deliberately dumb: a + * generic renderer that guesses at semantics would show a number under + * the wrong heading, and the payload is the product here. + */ +export function renderResult(value: unknown): void { + if (value === undefined || value === null) { + process.stderr.write(c.dim("(no content)\n")); + return; + } + if (typeof value === "string") { + process.stdout.write(value.endsWith("\n") ? value : value + "\n"); + return; + } + if (Array.isArray(value)) { + renderList(value); + return; + } + if (typeof value === "object") { + const record = value as Record; + const collection = collectionOf(record); + if (collection && collection[1].length > 0) { + const [key, list] = collection; + const rest = Object.fromEntries(Object.entries(record).filter(([k]) => k !== key)); + renderList(list); + if (Object.keys(rest).length > 0) { + process.stdout.write("\n"); + json(rest); + } + return; + } + } + json(value); +} + +function renderList(list: readonly unknown[]): void { + if (list.length === 0) { + process.stderr.write(c.dim("(no results)\n")); + return; + } + if (!list.every((item) => item !== null && typeof item === "object" && !Array.isArray(item))) { + json(list); + return; + } + const rows = list as ReadonlyArray>; + const columns: string[] = []; + for (const row of rows) { + for (const [k, v] of Object.entries(row)) { + if (isScalar(v) && !columns.includes(k)) columns.push(k); + } + } + if (columns.length === 0) { + json(list); + return; + } + const shown = columns.slice(0, MAX_COLUMNS); + table( + shown, + rows.map((row) => shown.map((k) => cell(row[k]))), + ); + if (columns.length > shown.length) { + process.stderr.write( + c.dim(`(${columns.length - shown.length} more field(s) per row — use --json)\n`), + ); + } +} diff --git a/packages/cli/src/surface.ts b/packages/cli/src/surface.ts new file mode 100644 index 0000000..e5bd70b --- /dev/null +++ b/packages/cli/src/surface.ts @@ -0,0 +1,573 @@ +/** + * The CLI's command surface, derived from the published API surface. + * + * Two sources, both published, neither retyped here: + * + * `API_OPERATIONS` (@codespar/sdk) — every REST operation of + * the served OpenAPI document + * `SHARED_META_TOOL_DEFINITIONS` (@codespar/types, + * re-exported by @codespar/sdk) — the 15 agent-facing meta-tools + * + * A resource command group is a path prefix plus a name; the subcommands + * under it are computed from the operation rows that live under that + * prefix. There is no per-route code: adding a group is one row in + * `PUBLISHED_GROUPS`, and a route added to the served document appears as + * a subcommand the next time the SDK snapshot is refreshed. + * + * `auditSurface()` is the checker behind the coverage gate + * (`__tests__/surface-coverage.test.ts`): it takes the surface as an + * argument instead of reading the module's own constants, so the gate can + * run it against synthetic inputs and prove it fails when it should. + */ + +import { API_OPERATIONS, SHARED_META_TOOL_DEFINITIONS } from "@codespar/sdk"; +import type { SharedMetaToolDefinition } from "@codespar/sdk"; + +export type HttpMethod = "get" | "post" | "put" | "patch" | "delete"; + +export interface OperationRow { + method: HttpMethod; + path: string; + /** Request body content type, or null when the operation takes no body. */ + body: string | null; +} + +/** Every operation of the served document, as plain rows. */ +export const OPERATIONS: readonly OperationRow[] = API_OPERATIONS.map((row) => ({ + method: row.method as HttpMethod, + path: row.path as string, + body: row.body as string | null, +})); + +/* ── Census ───────────────────────────────────────────────────────── */ + +/** + * The resource group an operation belongs to, for census purposes: the + * first path segment after the version prefix (`/v1/wallets/{id}/ledger` + * → `wallets`), or `(non-v1) ` for the handful of unversioned + * routes. Coarse on purpose — the census asks "does this family of + * routes have any CLI at all", not "is every route wired". + */ +export function censusGroup(path: string): string { + const segments = path.split("/").filter(Boolean); + if (segments[0] !== "v1") return `(non-v1) ${segments[0] ?? ""}`; + return segments[1] ?? ""; +} + +/** Every census group of the served document, with its operations. */ +export function census( + operations: readonly OperationRow[] = OPERATIONS, +): Map { + const groups = new Map(); + for (const op of operations) { + const key = censusGroup(op.path); + const bucket = groups.get(key); + if (bucket) bucket.push(op); + else groups.set(key, [op]); + } + return groups; +} + +/* ── Published resource groups ────────────────────────────────────── */ + +export interface GroupSpec { + /** Command name: `codespar `. */ + name: string; + /** Path prefix that claims an operation. Params are written `{}`. */ + prefix: string; + /** One-line description for `codespar --help`. */ + description: string; +} + +/** + * The resource groups this CLI publishes. Each row is a prefix; an + * operation belongs to the LONGEST prefix that matches it, so + * `/v1/consumers/{}/dda/...` lands in `boletos` and every other + * `/v1/consumers/...` route lands in `consumers`. + * + * Onda 4 of the API matrix (v2.1.1) names admin, sellers, mcp-servers, + * consumers and boletos; core#125 adds the money methods (wallets, + * triggers, and the meta-tools below). `admin` is absent on purpose: the + * served document declares no `/v1/admin/*` operation, so there is + * nothing to derive a command from (see SURFACE_EXCEPTIONS). + */ +export const PUBLISHED_GROUPS: readonly GroupSpec[] = [ + { + name: "consumers", + prefix: "/v1/consumers", + description: "Consumers: profile, Pix keys, Pix lookups, receipts, contact verification", + }, + { + name: "boletos", + prefix: "/v1/consumers/{}/dda", + description: "DDA boletos: subscribe a document, list the boletos it receives", + }, + { + name: "sellers", + prefix: "/v1/sellers", + description: "Sellers: onboarding status, custody, pending settlement, ledger", + }, + { + name: "mcp-servers", + prefix: "/v1/mcp-servers", + description: "Tenant MCP servers: register, validate, list, patch a tool, sweep platform fees", + }, + { + name: "wallets", + prefix: "/v1/wallets", + description: "Wallets: balances, ledger, funding sources, execute, transfer, custody", + }, + { + name: "triggers", + prefix: "/v1/triggers", + description: "Triggers (webhooks): endpoints, deliveries, DLQ, secret rotation, redelivery", + }, +]; + +/* ── Derivation ───────────────────────────────────────────────────── */ + +export interface DerivedCommand { + /** Subcommand name: `codespar `. */ + name: string; + method: HttpMethod; + /** Path template, `{param}` unexpanded. */ + path: string; + /** Path parameter names, in path order — the command's positional arguments. */ + params: string[]; + /** True when the operation declares a request body. */ + acceptsBody: boolean; +} + +export interface DerivedGroup { + spec: GroupSpec; + commands: DerivedCommand[]; +} + +/** `{anything}` → `{}`, so a prefix matches whatever the params are called. */ +function normalise(path: string): string { + return path.replace(/\{[^}]*\}/g, "{}"); +} + +function isParam(segment: string): boolean { + return segment.startsWith("{") && segment.endsWith("}"); +} + +/** Segment-wise prefix test: `/v1/consumers/{}/dda` matches `/v1/consumers/{cid}/dda/boletos`. */ +function underPrefix(path: string, prefix: string): boolean { + const p = normalise(path).split("/"); + const q = prefix.split("/"); + if (p.length < q.length) return false; + return q.every((segment, i) => segment === p[i]); +} + +/** The group spec that claims an operation: the longest matching prefix. */ +export function claimingGroup( + path: string, + groups: readonly GroupSpec[] = PUBLISHED_GROUPS, +): GroupSpec | undefined { + let best: GroupSpec | undefined; + for (const spec of groups) { + if (!underPrefix(path, spec.prefix)) continue; + if (!best || spec.prefix.length > best.prefix.length) best = spec; + } + return best; +} + +const COLLECTION_VERB: Record = { + get: "list", + post: "create", + put: "set", + patch: "update", + delete: "delete", +}; + +/** + * Derive the subcommands of one group. + * + * The name of a subcommand is a pure function of the operation set, not + * of the order rows appear in: the literal segments below the group's + * prefix form a suffix, and the suffix is used bare when it identifies + * exactly one operation in the group, or prefixed with the method's verb + * when two or more operations share it (`triggers list-deliveries` vs + * `triggers get-deliveries`). A route with no literal suffix is named + * after its verb alone (`list`, `get`, `create`, `update`, `delete`), and + * a suffix that merely repeats the group name collapses to the verb too + * (`GET /v1/consumers/{}/dda/boletos` is `boletos list`, not + * `boletos boletos`). + * + * The names are pinned in `__tests__/resource-commands.test.ts`, so a + * spec change that renames an existing command is a red test, not a + * silent break in someone's script. + */ +export function deriveGroup( + spec: GroupSpec, + operations: readonly OperationRow[] = OPERATIONS, + groups: readonly GroupSpec[] = PUBLISHED_GROUPS, +): DerivedGroup { + const rows = operations.filter((op) => claimingGroup(op.path, groups)?.name === spec.name); + const depth = spec.prefix.split("/").length; + + const parsed = rows.map((op) => { + const all = op.path.split("/").filter(Boolean); + const segments = op.path.split("/").slice(depth); + const literals = segments.filter((s) => !isParam(s)); + // Positionals come from the WHOLE path, not just the part below the + // prefix: `boletos` hangs off `/v1/consumers/{consumerId}/dda`, and + // dropping the parameter the prefix passes over would build a command + // that cannot address a consumer at all. + const params = all.filter(isParam).map((s) => s.slice(1, -1)); + const endsWithParam = segments.length > 0 && isParam(segments[segments.length - 1]!); + const suffix = literals.join("-"); + const verb = op.method === "get" ? (endsWithParam ? "get" : "list") : COLLECTION_VERB[op.method]; + return { op, suffix, verb, params }; + }); + + const suffixCount = new Map(); + for (const p of parsed) suffixCount.set(p.suffix, (suffixCount.get(p.suffix) ?? 0) + 1); + + const commands = parsed.map(({ op, suffix, verb, params }) => { + const bare = suffix === "" || suffix === spec.name; + const name = bare ? verb : suffixCount.get(suffix) === 1 ? suffix : `${verb}-${suffix}`; + return { + name, + method: op.method, + path: op.path, + params, + acceptsBody: op.body !== null, + }; + }); + + return { spec, commands }; +} + +/** Every published group, derived. */ +export function derivedSurface( + groups: readonly GroupSpec[] = PUBLISHED_GROUPS, + operations: readonly OperationRow[] = OPERATIONS, +): DerivedGroup[] { + return groups.map((spec) => deriveGroup(spec, operations, groups)); +} + +/* ── Meta-tools ───────────────────────────────────────────────────── */ + +/** + * The 15 agent-facing meta-tools, read from the published definitions. + * Never a list written here: `@codespar/types` publishes the names, the + * input schemas and the closed vocabularies (ent#933), and the CLI shows + * exactly those. + */ +export const META_TOOLS: Readonly> = + SHARED_META_TOOL_DEFINITIONS; + +export function metaToolNames(): string[] { + return Object.keys(META_TOOLS); +} + +export function metaToolDefinition(name: string): SharedMetaToolDefinition | undefined { + return Object.prototype.hasOwnProperty.call(META_TOOLS, name) ? META_TOOLS[name] : undefined; +} + +/** + * The closed vocabulary of a meta-tool's `action` property, or an empty + * array when the tool has no `action` (codespar_kyc discriminates on + * `check_type`, codespar_discover on `use_case`). + */ +export function metaToolActions(name: string): readonly string[] { + return metaToolDefinition(name)?.contract.enums?.action ?? []; +} + +/* ── Coverage gate ────────────────────────────────────────────────── */ + +export interface SurfaceException { + /** Why this group has no derived command group. Must say something. */ + reason: string; + /** ISO date the exception was written, so an old one is visible as old. */ + since: string; +} + +/** + * Census groups with no derived command group, each with a reason and a + * date. This list is a ratchet: it may shrink, never grow. Adding an + * entry to get the gate green is the wrong move — the machinery makes a + * group one row in `PUBLISHED_GROUPS`. + * + * Only census groups belong here. The admin/account family the API + * matrix names has no entry because it has no served route to except: + * `surface-coverage.test.ts` asserts the document still declares no + * `/v1/admin/*` operation, and goes red the day it does. + */ +export const SURFACE_EXCEPTIONS: Readonly> = { + "(non-v1) .well-known": { + reason: + "OAuth protected-resource and authorization-server discovery documents. Read by MCP clients during handshake, never by a person at a terminal.", + since: "2026-09-10", + }, + "(non-v1) oauth": { + reason: + "OAuth register/authorize/token. A browser redirect flow; `codespar login` and `codespar connect start` are the terminal-side entrances.", + since: "2026-09-10", + }, + "(non-v1) openapi.json": { + reason: + "The served spec document itself. `npm run spec:refresh` in packages/core is the maintained way to pull it, and it regenerates the client at the same time.", + since: "2026-09-10", + }, + "openapi.json": { + reason: + "Versioned alias of the served spec document. Same reason as the unversioned one: spec:refresh, not a CLI command.", + since: "2026-09-10", + }, + servers: { + reason: "Covered by the pre-existing `codespar servers list|show` commands (hand-written paths).", + since: "2026-09-10", + }, + sessions: { + reason: + "Covered by the pre-existing `codespar sessions list|show|close` and `codespar execute` commands (hand-written paths).", + since: "2026-09-10", + }, + connections: { + reason: "Covered by the pre-existing `codespar connect list|start|revoke` commands.", + since: "2026-09-10", + }, + connect: { + reason: "POST /v1/connect/start is what `codespar connect start` calls.", + since: "2026-09-10", + }, + whoami: { + reason: "Covered by the pre-existing `codespar whoami` command.", + since: "2026-09-10", + }, + "tool-calls": { + reason: + "Covered by the pre-existing `codespar payment-status` and `codespar verification-status` commands, including their SSE streams.", + since: "2026-09-10", + }, + "meta-tools": { + reason: "POST /v1/meta-tools/discover is what `codespar discover` calls.", + since: "2026-09-10", + }, + "webhook-endpoints": { + reason: + "The same ten operations as `triggers`, under the older path family. The CLI publishes the canonical `triggers` name only; wiring both would double the surface for one backend.", + since: "2026-09-10", + }, + mandates: { + reason: + "Org-scoped mandate lifecycle (pause/resume/revoke). `codespar mandate create|verify` covers issuance and offline verification; the lifecycle verbs are wave-5 work in the matrix.", + since: "2026-09-10", + }, + orgs: { + reason: "Org administration (agents, keys, audit config, approvals, data-subject anonymisation). Not in onda 4 of the matrix.", + since: "2026-09-10", + }, + organizations: { + reason: "Single org read. Not in onda 4 of the matrix.", + since: "2026-09-10", + }, + projects: { + reason: "Project CRUD and settings history. Dashboard surface; not in onda 4 of the matrix.", + since: "2026-09-10", + }, + policies: { + reason: "Policy CRUD and reorder. Not in onda 4 of the matrix.", + since: "2026-09-10", + }, + "policy-evaluations": { + reason: "Policy evaluation log. Not in onda 4 of the matrix.", + since: "2026-09-10", + }, + evaluations: { + reason: "Evaluation log alias. Not in onda 4 of the matrix.", + since: "2026-09-10", + }, + "audit-events": { + reason: "Audit event stream, incidents and config. Not in onda 4 of the matrix.", + since: "2026-09-10", + }, + audit: { + reason: "Audit event alias. Not in onda 4 of the matrix.", + since: "2026-09-10", + }, + approvals: { + reason: "Approval reads used by the caller-side poll in approval-status.ts, not by an operator at a terminal.", + since: "2026-09-10", + }, + "commerce-memory": { + reason: "Counterparties, interactions, preferences, negotiations and insights. Not in onda 4 of the matrix.", + since: "2026-09-10", + }, + counterparties: { + reason: "Counterparty reads outside commerce-memory. Not in onda 4 of the matrix.", + since: "2026-09-10", + }, + "payment-links": { + reason: "Payment link CRUD. Dashboard surface; not in onda 4 of the matrix.", + since: "2026-09-10", + }, + paywalls: { + reason: "Paywall reads and stats. Dashboard surface; not in onda 4 of the matrix.", + since: "2026-09-10", + }, + providers: { + reason: "Provider catalog and auth schemas. `codespar servers` and `codespar wizard` are the terminal entrances.", + since: "2026-09-10", + }, + agents: { + reason: "Agent registration and key rotation. Security-sensitive; wants its own design pass, not a derived command.", + since: "2026-09-10", + }, + ofb: { + reason: "Open Finance Brasil consent lifecycle. Browser redirect flow; not in onda 4 of the matrix.", + since: "2026-09-10", + }, + "bank-consents": { + reason: "Single bank-consent read, alias of the ofb family. Not in onda 4 of the matrix.", + since: "2026-09-10", + }, + "consent-records": { + reason: "Single consent-record read. Not in onda 4 of the matrix.", + since: "2026-09-10", + }, + "account-applications": { + reason: "Single account-application read. Belongs with the admin/account family that has no served routes yet.", + since: "2026-09-10", + }, + kyc: { + reason: "GET /v1/kyc/onboard/{proposalId}/status. Reachable as `codespar tool codespar_kyc --action status`.", + since: "2026-09-10", + }, + cards: { + reason: "Single card read. Reachable as `codespar tool codespar_issue --action card-get`.", + since: "2026-09-10", + }, + issuer: { + reason: "Issuer-side card read, alias of the cards family. Same meta-tool covers it.", + since: "2026-09-10", + }, + "funding-sources": { + reason: "Single funding-source read. The wallet-scoped ones are wired under `codespar wallets`.", + since: "2026-09-10", + }, + facilitator: { + reason: "x402 facilitator executions. Machine-to-machine surface driven by the x402 rail, not by an operator.", + since: "2026-09-10", + }, + cart: { + reason: "Mercado Livre / iFood cart connect starts. Browser redirect flow; `codespar connect start` is the terminal entrance.", + since: "2026-09-10", + }, + fees: { + reason: "Fee schedule reads. Pricing surface; not in onda 4 of the matrix.", + since: "2026-09-10", + }, + events: { + reason: "Event replay. Operational recovery tool; wants an explicit confirmation design, not a derived command.", + since: "2026-09-10", + }, + generate: { + reason: "Server-generation helper used by the dashboard scaffolder. `codespar init` is the terminal scaffolder.", + since: "2026-09-10", + }, + discovery: { + reason: "Discovery manifest, read by agent clients during handshake.", + since: "2026-09-10", + }, + health: { + reason: "Liveness probe. `curl` is the right tool and needs no API key.", + since: "2026-09-10", + }, +}; + +/** How many exceptions the gate expects. Lower it when one goes away. */ +export const EXCEPTION_PIN = 43; + +export type ViolationKind = + | "uncovered-group" + | "empty-reason" + | "bad-date" + | "stale-exception" + | "meta-tool-missing"; + +export interface Violation { + kind: ViolationKind; + subject: string; + detail: string; +} + +export interface SurfaceAudit { + /** Census group names present in the surface under audit. */ + censusGroups: readonly string[]; + /** Census group names that a derived command group covers. */ + coveredGroups: readonly string[]; + exceptions: Readonly>; + /** Meta-tool names the surface publishes. */ + publishedMetaTools: readonly string[]; + /** Meta-tool names the CLI can invoke. */ + invocableMetaTools: readonly string[]; +} + +const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; +const MIN_REASON = 20; + +/** + * The check itself, as a pure function of the surface handed to it. The + * gate runs it twice on synthetic inputs — once on a surface that is + * covered, once on a surface that is not — so a checker that cannot fail + * is caught by its own controls. + */ +export function auditSurface(input: SurfaceAudit): Violation[] { + const violations: Violation[] = []; + const covered = new Set(input.coveredGroups); + const census = new Set(input.censusGroups); + + for (const group of input.censusGroups) { + if (covered.has(group)) continue; + const exception = input.exceptions[group]; + if (!exception) { + violations.push({ + kind: "uncovered-group", + subject: group, + detail: `resource group "${group}" has no command and no written exception`, + }); + continue; + } + if (!exception.reason || exception.reason.trim().length < MIN_REASON) { + violations.push({ + kind: "empty-reason", + subject: group, + detail: `exception for "${group}" has no usable reason (needs at least ${MIN_REASON} characters saying why)`, + }); + } + if (!ISO_DATE.test(exception.since ?? "")) { + violations.push({ + kind: "bad-date", + subject: group, + detail: `exception for "${group}" has no ISO date (YYYY-MM-DD) saying when it was written`, + }); + } + } + + for (const group of Object.keys(input.exceptions)) { + if (census.has(group) && !covered.has(group)) continue; + violations.push({ + kind: "stale-exception", + subject: group, + detail: covered.has(group) + ? `"${group}" has a command AND an exception — delete the exception` + : `"${group}" is not a resource group of the served document — delete the exception`, + }); + } + + const invocable = new Set(input.invocableMetaTools); + for (const name of input.publishedMetaTools) { + if (invocable.has(name)) continue; + violations.push({ + kind: "meta-tool-missing", + subject: name, + detail: `meta-tool "${name}" is published but the CLI cannot invoke it`, + }); + } + + return violations; +} From 15b64a1e79ee55964b000a0cf132e41270c9ea81 Mon Sep 17 00:00:00 2001 From: Fabiano Cruz Date: Fri, 11 Sep 2026 11:58:10 -0300 Subject: [PATCH 2/2] =?UTF-8?q?fix(cli):=20publica=20os=20dois=20grupos=20?= =?UTF-8?q?que=20o=20snapshot=20de=20221=20opera=C3=A7=C3=B5es=20trouxe,?= =?UTF-8?q?=20e=20baixa=20a=20catraca=20de=20rotas=20=C3=A0=20m=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O merge do main trouxe o snapshot atualizado pelo core#143 (213 → 221 operações). Três portões da lane ficaram vermelhos, e os três estavam certos: o que mudou foi a superfície servida, não o checador. 1. `consents` e `consumer-payments` apareceram como grupos de recurso sem comando e sem exceção escrita. Viram duas linhas em `PUBLISHED_GROUPS`, que é o que o próprio texto de falha manda fazer. Não viraram exceção: a exceção é para família sem uso terminal (redirect de browser, aperto de mão máquina-a-máquina, sonda), e estas duas terminam numa pessoa — `POST /v1/consents` cunha o token cuja URL o consumidor abre, e `POST /v1/consumer-payments/execute` executa o pagamento com a cadeia de auditoria. `execute-stream` é SSE, e a descrição diz isso. 2. `/v1/consents/init` e `/v1/consumers/mandates/{}/spend` saem de `OFF_SPEC_PATHS`. Não é supressão: as duas passaram a ser declaradas pelo documento servido, então a tabela gerada agora as confere e a dívida encolheu de dez para oito. A catraca desceu, que é a única direção em que ela anda sem argumento. 3. O pino de comandos derivados vai de 61 para 66. As cinco linhas novas são exatamente as cinco que os itens 1 e 2 explicam (uma de `mandates-spend`, duas de `consents`, duas de `consumer-payments`); nada saiu e nada mudou de ordem. O pino foi REGERADO da árvore e o diff conferido linha a linha antes de entrar, não editado à mão. 86 testes verdes, build de 18 pacotes verde, e `codespar consents --help` lista os dois subcomandos derivados sem código por rota. --- .../cli/src/__tests__/resource-commands.test.ts | 5 +++++ .../cli/src/__tests__/surface-coverage.test.ts | 7 +++++-- packages/cli/src/surface.ts | 15 +++++++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/__tests__/resource-commands.test.ts b/packages/cli/src/__tests__/resource-commands.test.ts index 6a2ff6a..03b4d4b 100644 --- a/packages/cli/src/__tests__/resource-commands.test.ts +++ b/packages/cli/src/__tests__/resource-commands.test.ts @@ -43,6 +43,7 @@ const PINNED_COMMANDS = [ "consumers create → POST /v1/consumers", "consumers get → GET /v1/consumers/{id}", "consumers update → PATCH /v1/consumers/{id}", + "consumers mandates-spend → POST /v1/consumers/mandates/{id}/spend", "consumers list-pix-keys → GET /v1/consumers/{consumerId}/pix-keys", "consumers create-pix-keys → POST /v1/consumers/{consumerId}/pix-keys", "consumers delete-pix-keys → DELETE /v1/consumers/{consumerId}/pix-keys/{key}", @@ -100,6 +101,10 @@ const PINNED_COMMANDS = [ "triggers dlq → GET /v1/triggers/{id}/dlq", "triggers retry-pending → POST /v1/triggers/retry-pending", "triggers deliveries-redeliver → POST /v1/triggers/deliveries/{delivery_id}/redeliver", + "consents create → POST /v1/consents", + "consents init → POST /v1/consents/init", + "consumer-payments execute → POST /v1/consumer-payments/execute", + "consumer-payments execute-stream → POST /v1/consumer-payments/execute-stream", ]; describe("derived resource commands", () => { diff --git a/packages/cli/src/__tests__/surface-coverage.test.ts b/packages/cli/src/__tests__/surface-coverage.test.ts index ec36b76..2595f36 100644 --- a/packages/cli/src/__tests__/surface-coverage.test.ts +++ b/packages/cli/src/__tests__/surface-coverage.test.ts @@ -202,9 +202,12 @@ describe("auditSurface controls", () => { * a new hand-written call. */ const OFF_SPEC_PATHS = [ - "/v1/consents/init", + // `/v1/consents/init` and `/v1/consumers/mandates/{}/spend` left this list + // when core#143 refreshed the snapshot to 221 operations: both are now + // declared by the served document, so the generated table checks them. The + // ratchet went DOWN, which is the only direction it is allowed to move + // without an argument. "/v1/consents/{}/submit", - "/v1/consumers/mandates/{}/spend", "/v1/consumers/{}/wallet/transfer", "/v1/logs/stream", "/v1/servers/{}", diff --git a/packages/cli/src/surface.ts b/packages/cli/src/surface.ts index e5bd70b..ee4f915 100644 --- a/packages/cli/src/surface.ts +++ b/packages/cli/src/surface.ts @@ -122,6 +122,21 @@ export const PUBLISHED_GROUPS: readonly GroupSpec[] = [ prefix: "/v1/triggers", description: "Triggers (webhooks): endpoints, deliveries, DLQ, secret rotation, redelivery", }, + // Both families below arrived in the served document when packages/core + // refreshed openapi-snapshot.json to 221 operations (core#143). They are + // published, not excepted: the coverage gate reserves an exception for a + // family with no terminal use, and both of these end at a person. + { + name: "consents", + prefix: "/v1/consents", + description: "Consent tokens: mint the token whose URL the consumer opens to authorise an agent", + }, + { + name: "consumer-payments", + prefix: "/v1/consumer-payments", + description: + "Consumer payments: execute a payment on a consumer's behalf, with the audit chain (execute-stream returns SSE)", + }, ]; /* ── Derivation ───────────────────────────────────────────────────── */