Encode any HTTP API as a zod schema, get a typed client + a
working CLI back. Every endpoint in your schema is reachable from
the command line as api <endpoint> --flag value — no manual
argv parsing, no flag definitions, no JSON-arg gymnastics.
bun add @evolvingprograms/clipi
Pure TypeScript, no build step. Designed for Bun.
Runs on Node ≥ 22 with --experimental-strip-types. Depends on
zod and commander (pulled in transitively).
Define a schema. That's the whole thing:
// my-cli.ts import { z } from "zod" import { createCli, defineApi, get } from "@evolvingprograms/clipi" const github = defineApi({ name: "github", baseUrl: "https://api.github.com", endpoints: { user: get("/users/{username}", z.object({ username: z.string(), })), search: get("/search/repositories", z.object({ q: z.string(), sort: z.enum(["stars", "forks", "updated"]).optional(), per_page: z.coerce.number().int().default(30), })), }, }) const program = createCli({ name: "gh", description: "Tiny GitHub CLI", api: github, }) program.run()
Run it:
$ bun my-cli.ts api user --username torvalds
{"login":"torvalds","id":1024025,...}
$ bun my-cli.ts api search --q "language:rust stars:>50000" --sort stars --per-page 5
{"total_count":...,"items":[...]}
$ bun my-cli.ts api search --help
Usage: gh api search [options]
Options:
--q <v>
--sort <stars|forks|updated>
--per-page <n>Every endpoint becomes an api <name> subcommand. Every zod
schema key becomes a --flag. --help is generated automatically
from the schema. The response comes back as JSON on stdout.
That's the minimum useful thing — schema in, CLI out.
The same schema gives you a programmatic client:
const user = await github.user({ username: "torvalds" }) // ^^^^ Promise<unknown> ← no response schema, you cast or narrow
Attach a response schema and the return type becomes the
inferred shape, with runtime validation:
endpoints: { user: get( "/users/{username}", z.object({ username: z.string() }), { response: z.object({ login: z.string(), id: z.number(), html_url: z.string(), }), }, ), } const user = await github.user({ username: "torvalds" }) // ^^^^ { login: string, id: number, html_url: string } user.login // ✓ user.notAField // ✗ type error
Only schematize the fields you actually use — extra fields in the response are ignored at runtime.
The generic api <endpoint> surface hits the endpoint exactly as
specified. Most of the time you want something more ergonomic —
defaults, positional args, post-processing, multiple calls
composed together. That's what defineCommand is for.
import { defineCommand } from "@evolvingprograms/clipi" const lookup = defineCommand({ name: "lookup", description: "Look up a user by login", schema: z.object({ username: z.string() }), positional: ["username"], // → `gh lookup torvalds` (not `--username`) handler: async ({ username }) => { const user = await github.user({ username }) return { login: user.login, url: user.html_url } }, }) const program = createCli({ name: "gh", description: "Tiny GitHub CLI", api: github, commands: [lookup], }) program.run()
$ bun my-cli.ts lookup torvalds
{"login":"torvalds","url":"https://github.com/torvalds"}The CLI now exposes both surfaces — gh lookup <username>
for the common path and gh api user --username <name> for raw
access.
Each defineCommand is also callable as a function:
import { lookup } from "./commands/lookup" const { login } = await lookup({ username: "torvalds" })
Typed args, runtime validation, return type inferred from the handler.
Most APIs use a bearer token from an env var. auth: "ENV_NAME"
is the shortcut — same effect as writing
Authorization: Bearer ${env.ENV_NAME} in a headers callback,
without any of the boilerplate:
const github = defineApi({ name: "github", baseUrl: "https://api.github.com", auth: "GITHUB_TOKEN", // ← that's it endpoints: { ... }, })
defineApi reads GITHUB_TOKEN lazily — on each request, not at
construction — so you can import your CLI module before the env
is set. Run it:
GITHUB_TOKEN=$(gh auth token) bun my-cli.ts api user --username torvaldsIn GitHub Actions, secrets.GITHUB_TOKEN is auto-provisioned —
pass it through in your workflow:
- run: bun my-cli.ts api user --username torvalds env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
If the env var isn't set when a request fires, you get a clear
error: github: required env var GITHUB_TOKEN is not set.
For non-Bearer auth or multiple env vars, use requires.env +
headers:
defineApi({ name: "myapi", baseUrl: "https://example.test", requires: { env: ["MYAPI_KEY", "MYAPI_SECRET"] }, headers: ({ env }) => ({ "X-API-Key": env.MYAPI_KEY, // typed as `string` "X-API-Secret": env.MYAPI_SECRET, // not `string | undefined` }), endpoints: { ... }, })
The headers and baseQuery callbacks both receive ctx.env
typed against your declared requires.env tuple (no as const
needed).
baseQuery is merged into the query string on every call —
useful for APIs that want their key as a query param instead of
a header:
defineApi({ name: "fred", baseUrl: "https://api.stlouisfed.org/fred", requires: { env: ["FRED_API_KEY"] }, baseQuery: ({ env }) => ({ api_key: env.FRED_API_KEY, file_type: "json", }), endpoints: { ... }, })
Some APIs let you request specific slices of a response — Yahoo
Finance's quoteSummary?modules=summaryDetail,financialData,
GraphQL-ish field selectors, etc. The response shape depends on
what you asked for.
dependent() makes that typed:
import { dependent } from "@evolvingprograms/clipi" const yahoo = defineApi({ name: "yahoo", baseUrl: "https://query1.finance.yahoo.com", endpoints: { summary: dependent( "/v10/finance/quoteSummary/{symbol}", z.object({ symbol: z.string() }), "modules", { summaryDetail: z.object({ marketCap: z.number().optional() }), financialData: z.object({ totalCash: z.number().optional() }), earningsHistory: z.object({ history: z.array(z.object({ epsActual: z.number().optional() })), }), }, ), }, }) const s = await yahoo.summary({ symbol: "AAPL", modules: ["summaryDetail", "financialData"], }) s.summaryDetail.marketCap // ✓ s.financialData.totalCash // ✓ s.earningsHistory // ✗ type error — not requested
The literal tuple at modules flows through to the return type
via a const type parameter — no as const needed at the call
site. Runtime validation only runs the schemas for modules you
asked for.
bun ... api summary --symbol AAPL --modules summaryDetail,financialData
works too — the CLI side accepts a comma-separated list.
Throw an instance of your own errorClass from a handler and it
maps to <cli-name>: <message> on stderr with exit code 1:
class GithubError extends Error { override readonly name = "GithubError" } const lookup = defineCommand({ name: "lookup", schema: z.object({ username: z.string() }), handler: async ({ username }) => { if (username.length < 1) throw new GithubError("empty username") return github.user({ username }) }, }) const program = createCli({ name: "gh", description: "...", api: github, commands: [lookup], errorClass: GithubError, }) program.run()
Other thrown errors (including ZodError from input validation
and the env-not-set error from auth/requires.env) get
pretty-printed under the same prefix.
post(path, params, opts?) is identical to get apart from the
HTTP method. Body construction from params is on the roadmap —
for now use headers + a custom serializer if you need it.
Runnable, self-contained demos. Each lives in its own folder
(index.ts + schema.ts + commands/) using flat top-level
const exports — copy and adapt.
examples/github/— the canonical real-world example. Bearer auth viaauth: "GITHUB_TOKEN", response schemas, two friendly commands (lookup,top), and anif (import.meta.main)block so you can run it as a script:GITHUB_TOKEN=$(gh auth token) bun examples/github/ top "language:typescript stars:>10000".examples/echo/— exercises every endpoint shape (static GET, path placeholders, intentional 5xx, dependent endpoint), three command shapes (positional, dependent dispatch, intentional throw), and a customerrorClass. Used as the integration-test fixture.examples/kebab/— proves the walker converts camelCase schema keys to kebab-case--flag-nameCLI options.
- A code generator. Schemas are runtime values, not files
generated from OpenAPI. If you want OpenAPI ingestion, write
a converter that emits
defineApi(...)source. - A way to skip writing the schema. clipi buys you the CLI
- typed client; you still describe each endpoint.
- A fetch wrapper. It uses a small built-in fetcher (timeout + optional retry) but offers no caching, no request/response middleware, no streaming. Keep your fetch layer simple and put cross-cutting concerns elsewhere.
src/api/—defineApi,get,post,dependent,callEndpoint,addApiCli(the auto-CLI walker).src/cli/—defineCommand,createCli, zod-schema → commander flag walker, error mapping.src/http.ts— internal polite-fetch (timeout + retry).src/types.ts— all shared types.examples/— runnable demos linked above.qa/integration.test.ts— full pipeline againstBun.serve.qa/github.live.test.ts— live GitHub API test gated onGITHUB_TOKEN/gh auth token.
v0.1 — pre-release. API may change. Use a pinned version.
MIT.