Single-command CLIs are perfect for utilities that perform one specific task. Instead of registering subcommands, you pass the run handler directly to Cli.create().
When to Use Single-Command Pattern
Use this pattern when your CLI:
- Performs a single, focused task
- Doesn’t need subcommands or command groups
- Has a simple input/output flow
Examples: greet, convert, validate, ping
Basic Single-Command CLI
Start by importing Cli and z from incur:
import { Cli, z } from 'incur'
Pass the run handler directly to Cli.create():
Cli.create('greet', {
description: 'A greeting CLI',
args: z.object({
name: z.string().describe('Name to greet'),
}),
run(c) {
return { message: `hello ${c.args.name}` }
},
}).serve()
greet world
# → message: hello world
Adding Arguments
Define positional arguments using a Zod schema. Arguments are parsed in the order they appear in the schema:
Cli.create('add', {
description: 'Add two numbers',
args: z.object({
a: z.coerce.number().describe('First number'),
b: z.coerce.number().describe('Second number'),
}),
run(c) {
return { result: c.args.a + c.args.b }
},
}).serve()
Arguments are always required unless marked with .optional(). Use z.coerce.number() or z.coerce.boolean() to parse string input from the command line.
Adding Options
Options are named flags that users pass with --flag syntax:
Cli.create('greet', {
description: 'A greeting CLI',
args: z.object({
name: z.string().describe('Name to greet'),
}),
options: z.object({
loud: z.boolean().default(false).describe('Shout the greeting'),
prefix: z.string().optional().describe('Add a prefix'),
}),
run(c) {
let message = `hello ${c.args.name}`
if (c.options.prefix) message = `${c.options.prefix} ${message}`
if (c.options.loud) message = message.toUpperCase()
return { message }
},
}).serve()
greet alice --loud
# → message: HELLO ALICE
greet bob --prefix "Hey there:" --loud
# → message: HEY THERE: HELLO BOB
Option Aliases
Create short aliases for options:
Cli.create('greet', {
args: z.object({
name: z.string(),
}),
options: z.object({
loud: z.boolean().default(false),
}),
alias: { loud: 'L' },
run(c) {
const message = c.options.loud ? `HELLO ${c.args.name}` : `hello ${c.args.name}`
return { message }
},
}).serve()
greet alice -L
# → message: HELLO ALICE
Output Handling
The return value from run is automatically formatted and displayed to the user.
Default Output (TOON)
By default, incur uses TOON format - a compact, token-efficient format:
Cli.create('status', {
run() {
return {
clean: true,
branch: 'main',
files: ['src/index.ts', 'README.md'],
}
},
}).serve()
status
# → clean: true
# → branch: main
# → files[2]:
# → src/index.ts
# → README.md
Async Handlers
Handlers can be async:
Cli.create('fetch', {
args: z.object({
url: z.string().describe('URL to fetch'),
}),
async run(c) {
const response = await fetch(c.args.url)
const data = await response.json()
return { data }
},
}).serve()
Error Handling
Use c.error() for structured errors:
Cli.create('divide', {
args: z.object({
a: z.coerce.number(),
b: z.coerce.number(),
}),
run(c) {
if (c.args.b === 0) {
return c.error({
code: 'DIVISION_BY_ZERO',
message: 'Cannot divide by zero',
})
}
return { result: c.args.a / c.args.b }
},
}).serve()
Or throw an IncurError:
import { Cli, Errors, z } from 'incur'
Cli.create('auth', {
run() {
const token = process.env.API_TOKEN
if (!token) {
throw new Errors.IncurError({
code: 'NOT_AUTHENTICATED',
message: 'API_TOKEN environment variable not set',
retryable: false,
})
}
return { authenticated: true }
},
}).serve()
Context Properties
The run handler receives a context object with:
c.args - Parsed positional arguments
c.options - Parsed option flags
c.name - The CLI name (useful for help messages)
c.agent - true if called by an agent (stdout is not a TTY)
c.env - Parsed environment variables (if env schema is defined)
c.var - Variables set by middleware
c.ok(data, meta?) - Return success with optional CTAs
c.error(options) - Return an error
Cli.create('whoami', {
run(c) {
if (!c.agent) {
console.log('Running in terminal mode...')
}
return { name: c.name, agent: c.agent }
},
}).serve()
Built-in Help
Help is generated automatically from your schemas:
greet --help
# greet – A greeting CLI
#
# Usage: greet <name>
#
# Arguments:
# name Name to greet
#
# Built-in Commands:
# completions Generate shell completion script
# mcp add Register as an MCP server
# skills add Sync skill files to your agent
#
# Global Options:
# --format <toon|json|yaml|md|jsonl> Output format
# --help Show help
# --llms Print LLM-readable manifest
# --mcp Start as MCP stdio server
# --verbose Show full output envelope
# --version Show version
Complete Example
Here’s a complete single-command CLI with all features:
import { Cli, z } from 'incur'
Cli.create('calc', {
description: 'Simple calculator',
version: '1.0.0',
args: z.object({
operation: z.enum(['add', 'subtract', 'multiply', 'divide']).describe('Operation to perform'),
a: z.coerce.number().describe('First number'),
b: z.coerce.number().describe('Second number'),
}),
options: z.object({
precision: z.coerce.number().default(2).describe('Decimal precision'),
}),
run(c) {
let result: number
switch (c.args.operation) {
case 'add':
result = c.args.a + c.args.b
break
case 'subtract':
result = c.args.a - c.args.b
break
case 'multiply':
result = c.args.a * c.args.b
break
case 'divide':
if (c.args.b === 0) {
return c.error({
code: 'DIVISION_BY_ZERO',
message: 'Cannot divide by zero',
})
}
result = c.args.a / c.args.b
break
}
return {
operation: c.args.operation,
result: Number(result.toFixed(c.options.precision)),
}
},
}).serve()
calc multiply 5 7
# → operation: multiply
# → result: 35
calc divide 10 3 --precision 4
# → operation: divide
# → result: 3.3333
If you need subcommands, use the multi-command pattern instead. Don’t pass both run and .command() calls - they have different purposes.