Sub-commands let you group related commands under a namespace. This creates nested hierarchies like my-cli pr list or docker container ls.
When to Use Sub-Commands
Use sub-commands when you need:
- Logical grouping - Group related operations (e.g., all PR commands under
pr)
- Namespace separation - Keep command names short without conflicts
- Modular organization - Split large CLIs into separate modules
- Deep hierarchies - Create multi-level command trees
Examples: git remote add, docker container stop, gh pr create
Creating Command Groups
import { Cli, z } from 'incur'
const cli = Cli.create('my-cli', {
description: 'My CLI',
})
Create a separate group CLI
Create another Cli instance for the group:
const pr = Cli.create('pr', {
description: 'Pull request commands',
})
Add commands to the group
pr.command('list', {
description: 'List pull requests',
options: z.object({
state: z.enum(['open', 'closed', 'all']).default('open'),
}),
run(c) {
return { prs: [], state: c.options.state }
},
})
Mount the group on the root CLI:
cli
.command(pr) // Mount the entire pr group
.serve()
Complete Example from README
Here’s the complete PR group example from the incur README:
import { Cli, z } from 'incur'
const cli = Cli.create('my-cli', { description: 'My CLI' })
// Create a `pr` group.
const pr = Cli.create('pr', { description: 'Pull request commands' })
.command('list', {
description: 'List pull requests',
options: z.object({
state: z.enum(['open', 'closed', 'all']).default('open'),
}),
run(c) {
return { prs: [], state: c.options.state }
},
})
cli
.command(pr) // Link the `pr` group.
.serve()
Usage
my-cli pr list --state closed
# → prs: (empty)
# → state: closed
Help Text
my-cli --help
# my-cli – My CLI
#
# Usage: my-cli <command>
#
# Commands:
# pr Pull request commands
#
# 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
my-cli pr --help
# my-cli pr – Pull request commands
#
# Usage: my-cli pr <command>
#
# Commands:
# list List pull requests
#
# Global Options:
# --format <toon|json|yaml|md|jsonl> Output format
# --help Show help
# --json Shorthand for --format json
# --verbose Show full output envelope
Multiple Commands in a Group
Add multiple commands to create a rich command group:
const pr = Cli.create('pr', {
description: 'Pull request commands',
})
.command('list', {
description: 'List pull requests',
options: z.object({
state: z.enum(['open', 'closed', 'all']).default('open'),
limit: z.coerce.number().default(10).describe('Number of PRs to show'),
}),
run(c) {
return {
prs: [
{ id: 1, title: 'Fix bug', state: 'open' },
{ id: 2, title: 'Add feature', state: 'open' },
],
state: c.options.state,
limit: c.options.limit,
}
},
})
.command('create', {
description: 'Create a pull request',
args: z.object({
title: z.string().describe('PR title'),
}),
options: z.object({
draft: z.boolean().optional().describe('Create as draft'),
base: z.string().default('main').describe('Base branch'),
}),
run(c) {
return {
id: 123,
title: c.args.title,
draft: c.options.draft ?? false,
base: c.options.base,
}
},
})
.command('merge', {
description: 'Merge a pull request',
args: z.object({
number: z.coerce.number().describe('PR number'),
}),
options: z.object({
squash: z.boolean().optional().describe('Squash commits'),
delete: z.boolean().optional().describe('Delete branch after merge'),
}),
run(c) {
return {
merged: c.args.number,
squash: c.options.squash ?? false,
deleted: c.options.delete ?? false,
}
},
})
my-cli pr list --state all --limit 5
# → prs[2]{id,title,state}:
# → 1,Fix bug,open
# → 2,Add feature,open
# → state: all
# → limit: 5
my-cli pr create "Fix parsing bug" --draft --base develop
# → id: 123
# → title: Fix parsing bug
# → draft: true
# → base: develop
my-cli pr merge 42 --squash --delete
# → merged: 42
# → squash: true
# → deleted: true
Multiple Groups
Mount multiple command groups on the root CLI:
const cli = Cli.create('gh', { description: 'GitHub CLI' })
// PR commands
const pr = Cli.create('pr', { description: 'Pull request commands' })
.command('list', { /* ... */ })
.command('create', { /* ... */ })
// Issue commands
const issue = Cli.create('issue', { description: 'Issue commands' })
.command('list', { /* ... */ })
.command('create', { /* ... */ })
.command('close', { /* ... */ })
// Repository commands
const repo = Cli.create('repo', { description: 'Repository commands' })
.command('clone', { /* ... */ })
.command('fork', { /* ... */ })
.command('view', { /* ... */ })
cli
.command(pr)
.command(issue)
.command(repo)
.serve()
gh pr list
gh issue create "Bug report"
gh repo clone owner/repo
Nested Command Hierarchies
You can nest groups multiple levels deep:
const cli = Cli.create('docker', { description: 'Docker CLI' })
// Container commands
const container = Cli.create('container', { description: 'Container operations' })
.command('ls', {
description: 'List containers',
run: () => ({ containers: [] }),
})
.command('stop', {
description: 'Stop a container',
args: z.object({ id: z.string() }),
run: (c) => ({ stopped: c.args.id }),
})
// Image commands
const image = Cli.create('image', { description: 'Image operations' })
.command('ls', {
description: 'List images',
run: () => ({ images: [] }),
})
.command('build', {
description: 'Build an image',
args: z.object({ path: z.string().default('.') }),
run: (c) => ({ built: c.args.path }),
})
cli
.command(container)
.command(image)
.serve()
docker container ls
docker container stop abc123
docker image build .
Output Policy Inheritance
Groups can set output policies that inherit to all child commands:
const internal = Cli.create('internal', {
description: 'Internal commands',
outputPolicy: 'agent-only', // Suppress output in human/TTY mode
})
.command('sync', {
run: () => ({ synced: true }),
})
.command('cache-clear', {
run: () => ({ cleared: true }),
})
cli.command(internal).serve()
When users run my-cli internal sync in a terminal, they won’t see the output data (but agents will receive it via --json or --format).
Override per Command
const internal = Cli.create('internal', {
description: 'Internal commands',
outputPolicy: 'agent-only',
})
.command('sync', {
run: () => ({ synced: true }),
// Inherits agent-only
})
.command('status', {
outputPolicy: 'all', // Override to show output
run: () => ({ ok: true }),
})
Middleware Inheritance
Groups can register middleware that runs for all child commands:
import { Cli, middleware, z } from 'incur'
const cli = Cli.create('my-cli', {
description: 'My CLI',
vars: z.object({ user: z.custom<User>() }),
})
// Auth middleware
const requireAuth = middleware((c, next) => {
if (!c.var.user) {
return c.error({ code: 'AUTH', message: 'must be logged in' })
}
return next()
})
// Protected commands
const admin = Cli.create('admin', { description: 'Admin commands' })
.use(requireAuth) // Applies to all admin commands
.command('deploy', {
run: () => ({ deployed: true }),
})
.command('backup', {
run: () => ({ backed_up: true }),
})
cli.command(admin).serve()
Middleware runs in order: root CLI → groups (in traversal order) → per-command. Each level’s middleware wraps the next.
Best Practices
Group by Domain
Organize groups around logical domains:
// Good: domain-based groups
const db = Cli.create('db', { description: 'Database commands' })
const cache = Cli.create('cache', { description: 'Cache commands' })
const api = Cli.create('api', { description: 'API commands' })
Keep Groups Focused
Each group should have a clear purpose:
// Good: focused group
const pr = Cli.create('pr', { description: 'Pull request commands' })
.command('list', { /* ... */ })
.command('create', { /* ... */ })
.command('merge', { /* ... */ })
// Avoid: mixing unrelated commands
const misc = Cli.create('misc', { description: 'Miscellaneous' })
.command('pr-list', { /* ... */ })
.command('cache-clear', { /* ... */ })
.command('user-info', { /* ... */ })
Use Consistent Naming
Use consistent verb patterns within groups:
// Good: consistent verbs
const repo = Cli.create('repo', {})
.command('create', { /* ... */ })
.command('delete', { /* ... */ })
.command('list', { /* ... */ })
// Avoid: inconsistent verbs
const repo = Cli.create('repo', {})
.command('create', { /* ... */ })
.command('remove', { /* ... */ }) // Use 'delete' instead
.command('show-all', { /* ... */ }) // Use 'list' instead
Sub-command names must be unique within their parent. Don’t create two commands with the same name in the same group.