From 4603a29b8aed9b48ae69027c0960724374d904b3 Mon Sep 17 00:00:00 2001 From: tmm <6759464+tmm@users.noreply.github.com> Date: Fri, 18 Sep 2026 18:05:32 +0000 Subject: [PATCH] feat(cli): expose Tapimo API commands Co-authored-by: Derek Cofausper <256792747+decofe@users.noreply.github.com> --- .changelog/README.md | 2 +- .changelog/tempo-api.md | 5 + .github/workflows/build.yml | 10 +- README.md | 31 ++++- package.json | 2 + scripts/package.ts | 12 +- src/api-cli.ts | 4 + src/api.ts | 62 ++++++++++ src/routes-cli.ts | 4 + test/api.test.ts | 232 ++++++++++++++++++++++++++++++++++++ 10 files changed, 356 insertions(+), 8 deletions(-) create mode 100644 .changelog/tempo-api.md create mode 100644 src/api-cli.ts create mode 100644 src/api.ts create mode 100644 src/routes-cli.ts create mode 100644 test/api.test.ts diff --git a/.changelog/README.md b/.changelog/README.md index 10823d2..1f17042 100644 --- a/.changelog/README.md +++ b/.changelog/README.md @@ -16,7 +16,7 @@ Description of the change. Supported bump levels are `patch`, `minor`, `major`, and `none`. -The release is versioned as one package, `wallet-cli`, and publishes both `tempo-wallet` and `tempo-request` from the same tag. Changelog entries may also use `tempo-wallet` or `tempo-request` in frontmatter when a change is scoped to one binary; any non-`none` bump still advances the shared version. +The release is versioned as one package, `wallet-cli`, and publishes `tempo-wallet`, `tempo-request`, `tempo-api`, and `tempo-routes` from the same tag. Changelog entries may also use `tempo-wallet` or `tempo-request` in frontmatter when a change is scoped to one binary; any non-`none` bump still advances the shared version. ## Releasing diff --git a/.changelog/tempo-api.md b/.changelog/tempo-api.md new file mode 100644 index 0000000..31e6641 --- /dev/null +++ b/.changelog/tempo-api.md @@ -0,0 +1,5 @@ +--- +wallet-cli: minor +--- + +Expose Tapimo's hosted API as `tempo api v1 …` and its routing endpoints as `tempo routes …`, with OpenAPI-generated help, schemas, and MCP tools. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 81d9971..441c412 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,6 +6,8 @@ on: - "v*" - "tempo-wallet@*" - "tempo-request@*" + - "tempo-api@*" + - "tempo-routes@*" workflow_dispatch: inputs: release_tag: @@ -43,14 +45,14 @@ jobs: if [[ "$TAG" == v* ]]; then VERSION="${TAG#v}" - PACKAGES='["tempo-wallet","tempo-request"]' + PACKAGES='["tempo-wallet","tempo-request","tempo-api","tempo-routes"]' RELEASE_TAG="$TAG" elif [[ "$TAG" == *"@"* ]]; then PACKAGE="${TAG%%@*}" VERSION="${TAG#*@}" RELEASE_TAG="$TAG" - if [[ "$PACKAGE" == "tempo-wallet" || "$PACKAGE" == "tempo-request" ]]; then + if [[ "$PACKAGE" == "tempo-wallet" || "$PACKAGE" == "tempo-request" || "$PACKAGE" == "tempo-api" || "$PACKAGE" == "tempo-routes" ]]; then PACKAGES="[\"${PACKAGE}\"]" else echo "Unsupported package in tag: ${PACKAGE}" >&2 @@ -58,7 +60,7 @@ jobs: fi elif [[ "$TAG" == refs/heads/* || "$TAG" == "$GITHUB_REF" ]]; then VERSION="0.0.0-dev.${GITHUB_RUN_NUMBER}" - PACKAGES='["tempo-wallet","tempo-request"]' + PACKAGES='["tempo-wallet","tempo-request","tempo-api","tempo-routes"]' RELEASE_TAG="$TAG" else echo "Unsupported tag format: ${TAG}" >&2 @@ -281,6 +283,8 @@ jobs: declare -A PKG_DESC=( [tempo-wallet]="Manage your Tempo Wallet" [tempo-request]="Make an HTTP request" + [tempo-api]="Explore the Tempo API" + [tempo-routes]="Access the Tempo asset routing API" ) SIGN_ARGS=( diff --git a/README.md b/README.md index e4df21b..57bb863 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,35 @@ tempo wallet sessions close https://service.mpp.tempo.xyz ## Commands +### Tempo API + +`tempo api` exposes the hosted Tempo API using the same OpenAPI-generated command +tree as Tapimo. `tempo routes` is a shortcut to the `/v1/routes` endpoints. +The launcher installs the corresponding `tempo-api` and `tempo-routes` extensions +on first use, once their release is published. + +```sh +tempo api --help +tempo api v1 --help +tempo routes --help +tempo routes chains --format json +tempo routes quotes --help +tempo api v1 tokens --help +``` + +Set `TEMPO_API_KEY` for authenticated API calls. API keys are separate from wallet +login; generated command help also lists authentication options supported by the +API schema. `TEMPO_API_URL` overrides the default `https://api.tempo.xyz` host. +Help and command schemas require access to that host's `/openapi.json`. + +For agents, use `--schema --format json` on a command to inspect its inputs, or +run `tempo api --mcp` / `tempo routes --mcp` to serve the generated tools over +stdio. Commands call the API directly; they do not automatically pay MPP +challenges. API errors, including authentication and payment requirements, +return a nonzero exit status. + +### Wallet + `tempo wallet` includes: - `login`, `logout`, `refresh`, `whoami`, `keys` @@ -177,7 +206,7 @@ pnpm package ## Release Artifacts -The release workflow builds standalone Linux and macOS binaries for both `tempo-wallet` and `tempo-request`. Each binary is published with a checksum, SBOM, Sigstore bundle, and GitHub attestations. +The release workflow builds standalone Linux and macOS binaries for `tempo-wallet`, `tempo-request`, `tempo-api`, and `tempo-routes`. Each binary is published with a checksum, SBOM, Sigstore bundle, and GitHub attestations. ## Security diff --git a/package.json b/package.json index 2be9d2c..2b6a885 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,9 @@ "url": "git+https://github.com/tempoxyz/wallet-cli.git" }, "bin": { + "tempo-api": "./dist/api-cli.js", "tempo-request": "./dist/request-cli.js", + "tempo-routes": "./dist/routes-cli.js", "tempo-wallet": "./dist/cli.js" }, "type": "module", diff --git a/scripts/package.ts b/scripts/package.ts index 423376a..c133259 100644 --- a/scripts/package.ts +++ b/scripts/package.ts @@ -7,7 +7,13 @@ type PackageJson = { version?: string; }; -type CliPackageName = "tempo-wallet" | "tempo-request"; +const entrypoints = { + "tempo-api": "src/api-cli.ts", + "tempo-request": "src/request-cli.ts", + "tempo-routes": "src/routes-cli.ts", + "tempo-wallet": "src/cli.ts", +}; +type CliPackageName = keyof typeof entrypoints; const args = new Set(process.argv.slice(2)); const root = resolve(import.meta.dirname, ".."); @@ -22,7 +28,7 @@ const output = resolve( root, process.env.TEMPO_WALLET_PACKAGE_OUTPUT ?? join(outDir, `${packageName}-${suffix}`), ); -const entrypoint = packageName === "tempo-request" ? "src/request-cli.ts" : "src/cli.ts"; +const entrypoint = entrypoints[packageName]; mkdirSync(outDir, { recursive: true }); @@ -66,7 +72,7 @@ function run(command: string, commandArgs: readonly string[]) { function resolvePackageName(): CliPackageName { const value = process.env.TEMPO_WALLET_PACKAGE_NAME ?? process.env.PACKAGE ?? "tempo-wallet"; - if (value === "tempo-wallet" || value === "tempo-request") return value; + if (Object.hasOwn(entrypoints, value)) return value as CliPackageName; throw new Error(`unsupported TypeScript CLI package: ${value}`); } diff --git a/src/api-cli.ts b/src/api-cli.ts new file mode 100644 index 0000000..79f8cf4 --- /dev/null +++ b/src/api-cli.ts @@ -0,0 +1,4 @@ +#!/usr/bin/env node +import { serveApi } from "./api.js"; + +void serveApi(); diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..815d5e1 --- /dev/null +++ b/src/api.ts @@ -0,0 +1,62 @@ +import { Cli, Fetch, Openapi } from "incur"; + +import { version } from "./shared/constants.js"; + +type ApiOptions = { + apiKey?: string | undefined; + url?: string | undefined; + routes?: boolean | undefined; +}; + +/** Creates commands from the same hosted OpenAPI schema used by Tapimo. */ +export async function createApiCli(options: ApiOptions = {}) { + const url = new URL(options.url ?? process.env.TEMPO_API_URL ?? "https://api.tempo.xyz"); + const apiKey = options.apiKey ?? process.env.TEMPO_API_KEY; + const spec = await Openapi.resolve(new URL("openapi.json", `${url.href.replace(/\/$/, "")}/`)); + const prefix = options.routes ? "/v1/routes" : ""; + + // Strip the routing namespace only for discovery; the request source restores it on the wire. + const openapi = options.routes + ? { + ...spec, + paths: Object.fromEntries( + Object.entries(spec.paths ?? {}) + .filter(([path]) => path.startsWith(`${prefix}/`)) + .map(([path, value]) => [path.slice(prefix.length), value]), + ), + } + : spec; + url.pathname = `${url.pathname.replace(/\/$/, "")}${prefix}`; + + return Cli.create(options.routes ? "tempo routes" : "tempo api", { + version, + description: options.routes ? "Tempo asset routing API" : "Tempo API commands", + fetch: Fetch.fromRequest(url, { + headers: apiKey ? { "tempo-api-key": apiKey } : {}, + redirect: "error", + }), + openapi, + openapiConfig: { mode: "namespace" }, + mcp: { + instructions: + "Commands call the hosted Tempo API. Use TEMPO_API_KEY or the generated authentication options for authenticated endpoints. List endpoints paginate with cursor/nextCursor.", + tools: { exclude: ["admin_*", "gecko_*", "v1_auth_*"] }, + }, + }); +} + +/** Runs an API extension, keeping version checks independent of API availability. */ +export async function serveApi(routes = false) { + try { + const argv = process.argv.slice(2); + if (argv.length === 1 && (argv[0] === "--version" || argv[0] === "-v")) { + process.stdout.write(`${version}\n`); + return; + } + const cli = await createApiCli({ routes }); + await cli.serve(argv); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} diff --git a/src/routes-cli.ts b/src/routes-cli.ts new file mode 100644 index 0000000..e41e1ca --- /dev/null +++ b/src/routes-cli.ts @@ -0,0 +1,4 @@ +#!/usr/bin/env node +import { serveApi } from "./api.js"; + +void serveApi(true); diff --git a/test/api.test.ts b/test/api.test.ts new file mode 100644 index 0000000..e56f835 --- /dev/null +++ b/test/api.test.ts @@ -0,0 +1,232 @@ +import { execFile } from "node:child_process"; +import { createServer } from "node:http"; +import { promisify } from "node:util"; + +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +import { createApiCli } from "../src/api.js"; + +const execFileAsync = promisify(execFile); +const requests: { path: string; method: string; key?: string; body: string }[] = []; +let url: string; +let status = 200; + +const spec = { + openapi: "3.1.0", + info: { title: "Tempo API fixture", version: "1" }, + components: { + securitySchemes: { + apiKey: { type: "apiKey", in: "header", name: "tempo-api-key" }, + }, + schemas: { + Deposit: { + type: "object", + required: ["chainId"], + properties: { chainId: { type: "integer" } }, + }, + }, + }, + paths: { + "/v1/routes/chains": { + get: { + summary: "Get chains", + parameters: [{ name: "limit", in: "query", schema: { type: "integer" } }], + responses: { "200": { description: "Chains" } }, + }, + }, + "/v1/routes/deposit-addresses": { + post: { + summary: "Create deposit address", + security: [{ apiKey: [] }], + requestBody: { + required: true, + content: { + "application/json": { schema: { $ref: "#/components/schemas/Deposit" } }, + }, + }, + responses: { "200": { description: "Deposit address" } }, + }, + }, + "/v1/routes/transfers/{id}": { + get: { + summary: "Get transfer", + parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], + responses: { "200": { description: "Transfer" } }, + }, + }, + "/v1/tokens": { + get: { summary: "List tokens", responses: { "200": { description: "Tokens" } } }, + }, + }, +}; + +const server = createServer(async (request, response) => { + response.setHeader("content-type", "application/json"); + if (request.url === "/openapi.json" || request.url === "/custom/openapi.json") { + response.end(JSON.stringify(spec)); + return; + } + let body = ""; + for await (const chunk of request) body += chunk; + const key = request.headers["tempo-api-key"]; + requests.push({ + path: request.url ?? "", + method: request.method ?? "", + ...(typeof key === "string" ? { key } : {}), + body, + }); + response.statusCode = status; + response.end( + JSON.stringify( + status === 200 + ? { data: ["fixture"], nextCursor: null } + : { message: "API rejected request" }, + ), + ); +}); + +beforeAll(async () => { + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing test address"); + url = `http://127.0.0.1:${address.port}`; +}); + +afterAll(async () => { + server.closeAllConnections(); + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); +}); + +beforeEach(() => { + requests.length = 0; + status = 200; +}); + +describe("Tempo API extensions", () => { + it("discovers routes without the v1 prefix or unrelated endpoint groups", async () => { + const { stdout } = await cli("routes", ["--help"]); + expect(stdout).toContain("tempo routes"); + expect(stdout).toContain("chains"); + expect(stdout).toContain("deposit-addresses"); + expect(stdout).not.toMatch(/^\s+tokens\s/m); + expect(requests).toEqual([]); + }); + + it("keeps the full Tapimo API tree in the API extension", async () => { + const { stdout } = await cli("api", ["v1", "--help"]); + expect(stdout).toContain("tokens"); + expect(stdout).toContain("routes"); + await cli("api", ["v1", "tokens", "--format", "json"]); + expect(requests[0]?.path).toBe("/v1/tokens"); + }); + + it("restores the routing path and forwards typed query parameters", async () => { + const { stdout } = await cli("routes", ["chains", "--limit", "2", "--format", "json"]); + expect(JSON.parse(stdout)).toEqual({ data: ["fixture"], nextCursor: null }); + expect(requests).toEqual([{ path: "/v1/routes/chains?limit=2", method: "GET", body: "" }]); + }); + + it("preserves custom API base paths", async () => { + await cli("routes", ["chains"], { TEMPO_API_URL: `${url}/custom/` }); + expect(requests[0]?.path).toBe("/custom/v1/routes/chains"); + }); + + it("forwards path arguments", async () => { + await cli("routes", ["transfers", "id", "test-transfer"]); + expect(requests[0]?.path).toBe("/v1/routes/transfers/test-transfer"); + }); + + it("resolves body references and sends API credentials with POST requests", async () => { + await cli("routes", ["deposit-addresses", "--chainId", "4217"], { + TEMPO_API_KEY: "fixture-key", + }); + expect(requests).toEqual([ + { + path: "/v1/routes/deposit-addresses", + method: "POST", + key: "fixture-key", + body: JSON.stringify({ chainId: 4217 }), + }, + ]); + }); + + it("lets explicit credentials override the default API key", async () => { + await cli( + "routes", + ["deposit-addresses", "--chainId", "4217", "--tempo-api-key", "explicit-key"], + { TEMPO_API_KEY: "default-key" }, + ); + expect(requests[0]?.key).toBe("explicit-key"); + }); + + it("exposes schemas without executing an endpoint", async () => { + const { stdout } = await cli("routes", ["deposit-addresses", "--schema", "--format", "json"]); + expect(JSON.parse(stdout).options.properties.chainId.type).toBe("integer"); + expect(requests).toEqual([]); + }); + + it("discovers and calls routing tools over MCP", async () => { + const api = await createApiCli({ url, routes: true, apiKey: "" }); + async function rpc(method: string, params: Record = {}) { + const response = await api.fetch( + new Request("http://localhost/mcp", { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), + }), + ); + return response.json(); + } + const tools = await rpc("tools/list"); + expect(tools.result.tools.map((tool: { name: string }) => tool.name)).toContain("search_tools"); + const search = await rpc("tools/call", { + name: "search_tools", + arguments: { query: "chains" }, + }); + expect( + JSON.parse(search.result.content[0].text).tools.map((tool: { name: string }) => tool.name), + ).toContain("chains"); + expect(requests).toEqual([]); + const result = await rpc("tools/call", { + name: "call_read_tool", + arguments: { name: "chains", arguments: { limit: 3 } }, + }); + expect(result.error).toBeUndefined(); + expect(result.result.isError).not.toBe(true); + expect(requests[0]?.path).toBe("/v1/routes/chains?limit=3"); + }); + + it.each([401, 402, 403, 500])("returns nonzero on HTTP %s without retrying", async (code) => { + status = code; + await expect(cli("routes", ["chains", "--format", "json"])).rejects.toMatchObject({ code: 1 }); + expect(requests).toHaveLength(1); + }); + + it.each(["api", "routes"] as const)( + "reports the %s version without fetching the schema", + async (extension) => { + const { stdout } = await cli(extension, ["--version"], { + TEMPO_API_URL: "http://127.0.0.1:1", + }); + expect(stdout.trim()).toMatch(/^\d+\.\d+\.\d+/); + }, + ); + + it("reports schema failures with a nonzero exit status", async () => { + await expect( + cli("routes", ["--help"], { TEMPO_API_URL: "http://127.0.0.1:1" }), + ).rejects.toMatchObject({ code: 1 }); + }); +}); + +function cli(extension: "api" | "routes", args: string[], env: Record = {}) { + return execFileAsync(process.execPath, ["--import", "tsx", `src/${extension}-cli.ts`, ...args], { + cwd: new URL("..", import.meta.url), + env: { ...process.env, TEMPO_API_KEY: "", TEMPO_API_URL: url, NO_COLOR: "1", ...env }, + }); +}