incur is a TypeScript framework for building CLIs that work for both AI agents and humans. Use when creating new CLIs.
TypeScript framework for building CLIs for agents and human consumption. Strictly typed schemas for arguments and options, structured output envelopes, auto-generated skill files, and agent discovery via Skills, MCP, and --llms.
import { Cli, z } from "incur";
const cli = Cli.create("greet", {
description: "A greeting CLI",
args: z.object({
name: z.string().describe("Name to greet"),
}),
run({ args }) {
return { message: `hello ${args.name}` };
},
});
cli.serve();
greet world
# → message: hello world
Cli.create() is the entry point. It has two modes:
Pass run to create a CLI with no subcommands:
const cli = Cli.create("tool", {
description: "Does one thing",
args: z.object({ file: z.string() }),
run({ args, options }) {
return { processed: args.file };
},
});
Omit run to create a CLI that registers subcommands via .command():
const cli = Cli.create("gh", {
version: "1.0.0",
description: "GitHub CLI",
});
cli.command("status", {
description: "Show repo status",
run() {
return { clean: true };
},
});
cli.serve();
cli.command("install", {
description: "Install a package",
args: z.object({
package: z.string().optional().describe("Package name"),
}),
options: z.object({
saveDev: z.boolean().optional().describe("Save as dev dependency"),
global: z.boolean().optional().describe("Install globally"),
}),
alias: { saveDev: "D", global: "g" },
output: z.object({
added: z.number(),
packages: z.number(),
}),
examples: [
{ args: { package: "express" }, description: "Install a package" },
{
args: { package: "vitest" },
options: { saveDev: true },
description: "Install as dev dependency",
},
],
run({ args, options }) {
return { added: 1, packages: 451 };
},
});
.command() is chainable — it returns the CLI instance:
cli
.command("ping", { run: () => ({ pong: true }) })
.command("version", { run: () => ({ version: "1.0.0" }) });
Create a sub-CLI and mount it as a command group:
const cli = Cli.create("gh", { description: "GitHub CLI" });
const pr = Cli.create("pr", { description: "Pull request commands" });
pr.command("list", {
description: "List pull requests",
options: z.object({
state: z.enum(["open", "closed", "all"]).default("open"),
}),
run({ options }) {
return { prs: [], state: options.state };
},
});
pr.command("view", {
description: "View a pull request",
args: z.object({ number: z.number() }),
run({ args }) {
return { number: args.number, title: "Fix bug" };
},
});
// Mount onto the parent CLI
cli.command(pr);
cli.serve();
gh pr list --state closed
gh pr view 42
Groups nest arbitrarily:
const cli = Cli.create("gh", { description: "GitHub CLI" });
const pr = Cli.create("pr", { description: "Pull requests" });
const review = Cli.create("review", { description: "Review commands" });
review.command("approve", { run: () => ({ approved: true }) });
pr.command(review);
cli.command(pr);
// → gh pr review approve
Mount an HTTP server as a command with .command('name', { fetch }). Argv is translated into HTTP requests using curl-style flags:
import { Cli } from "incur";
import { Hono } from "hono";
const app = new Hono();
app.get("/users", (c) => c.json({ users: [{ id: 1, name: "Alice" }] }));
app.post("/users", async (c) => c.json({ created: true, ...(await c.req.json()) }, 201));
Cli.create("my-cli", { description: "My CLI" }).command("api", { fetch: app.fetch }).serve();
my-cli api users # GET /users
my-cli api users -X POST -d '{"name":"Bob"}' # POST /users
my-cli api users --limit 5 # GET /users?limit=5
Pass an OpenAPI spec alongside fetch to generate typed subcommands with args, options, and descriptions from the spec:
Cli.create("my-cli", { description: "My CLI" })
.command("api", { fetch: app.fetch, openapi: spec })
.serve();
my-cli api listUsers --limit 5 # GET /users?limit=5
my-cli api getUser 42 # GET /users/42
my-cli api createUser --name Bob # POST /users with body
my-cli api --help # shows typed subcommands
Works with any (Request) => Response handler — Hono, Elysia, etc. Specs from @hono/zod-openapi are supported directly.
Expose your CLI as a standard Fetch API handler with cli.fetch. Works with Bun, Cloudflare Workers, Deno, Hono, and anything that accepts (req: Request) => Response.
import { Cli, z } from "incur";
const cli = Cli.create("my-cli", { version: "1.0.0" }).command("users", {
args: z.object({ id: z.coerce.number().optional() }),
options: z.object({ limit: z.coerce.number().default(10) }),
run(c) {
if (c.args.id) return { id: c.args.id, name: "Alice" };
return { users: [{ id: 1, name: "Alice" }], limit: c.options.limit };
},
});
Bun.serve(cli); // Bun
Deno.serve(cli.fetch); // Deno
export default cli; // Cloudflare Workers
app.all("*", (c) => cli.fetch(c.request)); // Elysia
app.use((c) => cli.fetch(c.req.raw)); // Hono
export const GET = cli.fetch; // Next.js
export const POST = cli.fetch;
Request mapping:
| HTTP | CLI equivalent |
|---|---|
GET /users?limit=5 | my-cli users --limit 5 |
GET /users/42 | my-cli users 42 (positional arg) |
POST /users with JSON body | my-cli users --name Bob |
GET / | root command (or 404) |
Responses are JSON envelopes: { "ok": true, "data": { ... }, "meta": { "command": "users", "duration": "3ms" } }.
Error status codes: 400 for validation errors, 404 for unknown commands, 500 for thrown errors.
Async generator commands stream as NDJSON (application/x-ndjson). Middleware runs the same as serve().
If a resolved command is a fetch gateway (.command('api', { fetch })), the request is forwarded to the nested handler.
The fetch handler exposes an MCP endpoint at /mcp. Agents can discover and call commands as MCP tools over HTTP:
POST /mcp { "jsonrpc": "2.0", "method": "initialize", ... }
POST /mcp { "jsonrpc": "2.0", "method": "tools/list", ... }
POST /mcp { "jsonrpc": "2.0", "method": "tools/call", "params": { "name": "users", ... } }
The MCP server is initialized lazily on the first /mcp request. Non-/mcp paths route to the command API as usual.
All schemas use Zod. Arguments are positional (assigned by schema key order). Options are named flags.