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/clipiPure 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.tsimport{z}from"zod"import{createCli,defineApi,get}from"@evolvingprograms/clipi"constgithub=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),})),},})constprogram=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:
constuser=awaitgithub.user({username: "torvalds"})// ^^^^ Promise<unknown> ← no response schema, you cast or narrowAttach 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(),}),},),}constuser=awaitgithub.user({username: "torvalds"})// ^^^^ { login: string, id: number, html_url: string }user.login// ✓user.notAField// ✗ type errorOnly 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"constlookup=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 })=>{constuser=awaitgithub.user({ username })return{login: user.login,url: user.html_url}},})constprogram=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 }=awaitlookup({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:
constgithub=defineApi({name: "github",baseUrl: "https://api.github.com",auth: "GITHUB_TOKEN",// ← that's itendpoints: { ... },})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 torvaldsenv:
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"constyahoo=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()})),}),},),},})consts=awaityahoo.summary({symbol: "AAPL",modules: ["summaryDetail","financialData"],})s.summaryDetail.marketCap// ✓s.financialData.totalCash// ✓s.earningsHistory// ✗ type error — not requestedThe 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:
classGithubErrorextendsError{overridereadonlyname="GithubError"}constlookup=defineCommand({name: "lookup",schema: z.object({username: z.string()}),handler: async({ username })=>{if(username.length<1)thrownewGithubError("empty username")returngithub.user({ username })},})constprogram=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.