Skip to main content
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

1
Create the root CLI
2
Start with a router CLI:
3
import { Cli, z } from 'incur'

const cli = Cli.create('my-cli', {
  description: 'My CLI',
})
4
Create a separate group CLI
5
Create another Cli instance for the group:
6
const pr = Cli.create('pr', {
  description: 'Pull request commands',
})
7
Add commands to the group
8
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 }
  },
})
9
Mount the group
10
Mount the group on the root CLI:
11
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.

Build docs developers (and LLMs) love