| name | incur |
|---|---|
| description | incur is a TypeScript framework for building CLIs that work for both AI agents and humans. Use when creating new CLIs. |
| command | incur |
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.
npm i incurpnpm i incurbun i incurimport { 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 worldCli.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 42Groups 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 approveAll schemas use Zod. Arguments are positional (assigned by schema key order). Options are named flags.
args: z.object({
repo: z.string().describe('Repository in owner/repo format'),
branch: z.string().optional().describe('Branch name'),
})tool clone owner/repo main
# ^^^^^^^^^^ ^^^^
# repo branchoptions: z.object({
state: z.enum(['open', 'closed']).default('open').describe('Filter by state'),
limit: z.number().default(30).describe('Max results'),
label: z.array(z.string()).optional().describe('Filter by labels'),
verbose: z.boolean().optional().describe('Show details'),
})Supported parsing:
--flag valueand--flag=value-f valueshort aliases (viaaliasproperty)--verboseboolean flags (true),--no-verbose(false)--label bug --label featurearray options- Automatic type coercion (string → number, string → boolean)
- Defaults from
.default(), optionality from.optional()
alias: { state: 's', limit: 'l' }tool list -s closed -l 10env: z.object({
NPM_TOKEN: z.string().optional().describe('Auth token'),
NPM_REGISTRY: z.string().default('https://registry.npmjs.org').describe('Registry URL'),
})Environment variables are parsed from process.env and validated against the Zod schema.
Define alternative usage patterns to show in --help instead of the auto-generated synopsis:
Cli.create('curl.md', {
args: z.object({ url: z.string() }),
options: z.object({ objective: z.string().optional() }),
usage: [
{ args: { url: true } },
{ args: { url: true }, options: { objective: true } },
{ prefix: 'cat file.txt |', suffix: '| head' },
],
run({ args }) {
return { content: '...' }
},
})Renders in help as:
Usage: curl.md <url>
curl.md <url> --objective <objective>
cat file.txt | curl.md | head
Each usage entry supports:
| Property | Type | Description |
|---|---|---|
args |
Partial<Record<key, true>> |
Argument keys to include as <key> placeholders |
options |
Partial<Record<key, true>> |
Option keys to include as --key <key> flags |
prefix |
string |
Text prepended before the command (e.g. piping) |
suffix |
string |
Text appended after the command |
Both args and options are strictly typed from the Zod schemas — only valid keys are allowed.
Usage patterns also work on subcommands via .command().
Every command returns data. incur wraps it in a structured envelope and serializes to the requested format.
Define output to declare the return shape:
cli.command('info', {
output: z.object({
name: z.string(),
version: z.string(),
}),
run() {
return { name: 'express', version: '4.21.2' }
},
})When output is provided, TypeScript enforces that run() returns the correct shape.
Control with --format <fmt> or --json:
| Flag | Format | Description |
|---|---|---|
| (default) | TOON | Token-efficient, ~40% fewer tokens than JSON |
--format json |
JSON | JSON.parse()-safe |
--format yaml |
YAML | Human-readable |
--format md |
Markdown | Tables for docs/issues |
With --verbose, the full envelope is emitted:
tool info express --verboseok: true
data:
name: express
version: 4.21.2
meta:
command: info
duration: 12ms
Without --verbose, only data is emitted. On errors, only the error block is emitted.
incur adapts output based on whether stdout is a TTY:
| Scenario | TTY (human) | Non-TTY (agent/pipe) |
|---|---|---|
| Command output | Formatted data only | TOON envelope |
| Errors | Human-readable message | Error envelope |
--help |
Pretty help text | Same |
--json / --format |
Overrides to structured | Same |
The run context includes agent — true when stdout is not a TTY (piped or consumed by an agent), false when running in a terminal:
cli.command('deploy', {
run(c) {
if (!c.agent) console.log('Deploying...')
return { status: 'ok' }
},
})Use the context helpers for explicit result control:
run(c) {
const item = await db.find(c.args.id)
if (!item)
return error({
code: 'NOT_FOUND',
message: `Item ${c.args.id} not found`,
retryable: false,
})
return c.ok(item)
}Suggest next commands to guide agents on success:
run(c) {
const result = { id: 42, name: c.args.name }
return c.ok(result, {
cta: {
description: 'Suggested commands:',
commands: [
{ command: 'get', args: { id: 42 }, description: 'View the item' },
'list',
],
},
})
}Or on errors, to help agents self-correct:
run(c) {
const token = process.env.GH_TOKEN
if (!token)
return c.error({
code: 'NOT_AUTHENTICATED',
message: 'GitHub token not found',
retryable: true,
cta: {
description: 'To authenticate:',
commands: [
{ command: 'auth login', description: 'Log in to GitHub' },
{ command: 'config set', options: { token: true }, description: 'Set token manually' },
],
},
})
// ...
}Every incur CLI has built-in Model Context Protocol (MCP) support — exposing commands as MCP tools that agents can call directly.
Register the CLI as an MCP server for your agents:
my-cli mcp addThis registers the CLI with your agent's MCP config. Works with Claude Code, Cursor, Amp, and others out of the box.
Options:
| Flag | Description |
|---|---|
-c, --command |
Override the command agents will run to start the server |
--agent <agent> |
Target a specific agent (e.g. claude-code, cursor) |
--no-global |
Install to project instead of globally |
Start the CLI as an MCP stdio server:
my-cli --mcpThis exposes all commands as MCP tools over stdin/stdout. Command groups are flattened with underscores (e.g. pr_list, pr_view). Arguments and options are merged into a single flat input schema.
All incur-based CLIs can auto-generate and install agent skill files with skills add:
my-cli skills addThis generates Markdown skill files from your command definitions and installs them so agents discover your CLI automatically.
It is also possible to configure skills add:
const cli = Cli.create('my-cli', {
sync: {
depth: 1,
include: ['_root'],
suggestions: ['install react as a dependency', 'check for outdated packages'],
},
})| Option | Type | Description |
|---|---|---|
depth |
number |
Grouping depth for skill files. 0 = single file, 1 = one per top-level command. Default: 1 |
include |
string[] |
Glob patterns for additional SKILL.md files to include. Use '_root' for the project-level SKILL.md |
suggestions |
string[] |
Example prompts shown after sync to help users get started |
Every incur CLI gets a built-in --llms flag that outputs a machine-readable manifest of all commands:
tool --llmsOutputs Markdown skill documentation by default.
# tool install
Install a package
## Arguments
| Name | Type | Required | Description |
| --------- | -------- | -------- | ----------------------- |
| `package` | `string` | no | Package name to install |
## Options
| Flag | Type | Default | Description |
| ----------- | --------- | ------- | ---------------------- |
| `--saveDev` | `boolean` | | Save as dev dependency |
| `--global` | `boolean` | | Install globally |Use --llms --format json for JSON schema manifest:
{
"version": "incur.v1",
"commands": [
{
"name": "install",
"description": "Install a package",
"schema": {
"args": { "type": "object", "properties": { "package": { "type": "string" } } },
"options": { "type": "object", "properties": { "saveDev": { "type": "boolean" } } },
"output": { "type": "object", "properties": { "added": { "type": "number" } } }
}
}
]
}| Flag | Description |
|---|---|
--help, -h |
Show help for the CLI or a specific command |
--version |
Print CLI version |
--llms |
Output agent-readable command manifest |
--mcp |
Start as an MCP stdio server |
--json |
Shorthand for --format json |
--format <fmt> |
Output format: toon, json, yaml, md |
--verbose |
Include full envelope (ok, data, meta) |
cli.command('deploy', {
args: z.object({ env: z.enum(['staging', 'production']) }),
options: z.object({ force: z.boolean().optional() }),
examples: [
{ args: { env: 'staging' }, description: 'Deploy to staging' },
{ args: { env: 'production' }, options: { force: true }, description: 'Force deploy to prod' },
],
run({ args }) {
return { deployed: args.env }
},
})Examples appear in --help output and generated skill files.
cli.command('publish', {
hint: 'Requires NPM_TOKEN to be set in your environment.',
// ...
})Hints are displayed after examples in help output and included in skill files.
Control whether output data is displayed to TTY consumers. 'all' (default) shows output to everyone. 'agent-only' suppresses data in human/TTY mode while still returning it via --json, --format, or --verbose.
cli.command('deploy', {
outputPolicy: 'agent-only',
run() {
return { id: 'deploy-123', url: 'https://staging.example.com' }
},
})Set on a group or root CLI to inherit across children. Children can override:
const sub = Cli.create('internal', { outputPolicy: 'agent-only' })
sub.command('sync', { run: () => ({ synced: true }) }) // inherits agent-only
sub.command('status', { outputPolicy: 'all', run: () => ({}) }) // overridesCall .serve() to parse process.argv and run:
cli.serve()For testing, pass custom argv and DI overrides:
let output = ''
await cli.serve(['install', 'express', '--json'], {
stdout(s) {
output += s
},
exit() {},
})| Option | Type | Description |
|---|---|---|
stdout |
(s: string) => void |
Override stdout writer |
exit |
(code: number) => void |
Override exit handler |
env |
Record<string, string | undefined> |
Override environment variables |
Use async *run to stream chunks incrementally. Yield objects for structured data or plain strings for text:
cli.command('logs', {
description: 'Tail logs',
async *run() {
yield 'connecting...'
yield 'streaming logs'
yield 'done'
},
})Each yielded value is written as a line in human/TOON mode. With --format jsonl, each chunk becomes {"type":"chunk","data":"..."}. You can also yield objects:
async *run() {
yield { progress: 50 }
yield { progress: 100 }
}Use ok() or error() as the return value to attach CTAs or signal failure:
async *run({ ok }) {
yield { step: 1 }
yield { step: 2 }
return ok(undefined, { cta: { commands: ['status'] } })
}Generate type definitions for your CLI's command map to get typed CTAs:
incur genThis creates a incur.generated.ts file that registers your commands on the Cli.Commands type, enabling autocomplete on CTA command names, args, and options.
import { Cli, z } from 'incur'
const cli = Cli.create('npm', {
version: '10.9.2',
description: 'The package manager for JavaScript.',
sync: {
suggestions: ['install react as a dependency', 'check for outdated packages'],
},
})
cli.command('install', {
description: 'Install a package',
args: z.object({
package: z.string().optional().describe('Package name to install'),
}),
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().describe('Number of packages added'),
packages: z.number().describe('Total packages'),
}),
examples: [
{ args: { package: 'express' }, description: 'Install a package' },
{
args: { package: 'vitest' },
options: { saveDev: true },
description: 'Install as dev dependency',
},
],
run({ args }) {
if (!args.package) return { added: 120, packages: 450 }
return { added: 1, packages: 451 }
},
})
cli.command('outdated', {
description: 'Check for outdated packages',
options: z.object({
global: z.boolean().describe('Check global packages'),
}),
alias: { global: 'g' },
output: z.object({
packages: z.array(
z.object({
name: z.string(),
current: z.string(),
wanted: z.string(),
latest: z.string(),
}),
),
}),
run() {
return {
packages: [{ name: 'express', current: '4.18.0', wanted: '4.21.2', latest: '4.21.2' }],
}
},
})
cli.serve()
export default cliAlways
export default cliso thatincur gencan import it and generate types.