Skip to main content
Multi-command CLIs act as routers, directing execution to different handlers based on the subcommand. This is the pattern used by tools like git, docker, and npm.

When to Use Multi-Command Pattern

Use this pattern when your CLI:
  • Provides multiple related operations
  • Needs a clean namespace for different actions
  • Benefits from command-specific help
Examples: git commit, docker build, npm install

Creating a Router CLI

1
Create CLI without run
2
Omit the run property to create a router:
3
import { Cli, z } from 'incur'

const cli = Cli.create('my-cli', {
  description: 'My CLI',
})
4
Chain .command() calls
5
Register subcommands using .command(). Each command is independent:
6
cli
  .command('status', {
    description: 'Show repo status',
    run() {
      return { clean: true }
    },
  })
  .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'),
    }),
    alias: { saveDev: 'D' },
    run(c) {
      return { added: 1, packages: 451 }
    },
  })
  .serve()
7
Run subcommands
8
my-cli status
# → clean: true

my-cli install express -D
# → added: 1
# → packages: 451

Command Organization

Each command is self-contained with its own args, options, and handler:
Cli.create('repo', {
  description: 'Repository management',
})
  .command('clone', {
    description: 'Clone a repository',
    args: z.object({
      url: z.string().describe('Repository URL'),
    }),
    options: z.object({
      depth: z.coerce.number().optional().describe('Clone depth'),
      branch: z.string().optional().describe('Branch to clone'),
    }),
    run(c) {
      return {
        cloned: c.args.url,
        branch: c.options.branch ?? 'main',
      }
    },
  })
  .command('list', {
    description: 'List repositories',
    options: z.object({
      limit: z.coerce.number().default(10).describe('Number of repos to show'),
      private: z.boolean().optional().describe('Include private repos'),
    }),
    run(c) {
      return {
        repos: ['repo1', 'repo2'],
        count: 2,
        limit: c.options.limit,
      }
    },
  })
  .command('delete', {
    description: 'Delete a repository',
    args: z.object({
      name: z.string().describe('Repository name'),
    }),
    options: z.object({
      force: z.boolean().optional().describe('Skip confirmation'),
    }),
    run(c) {
      if (!c.options.force) {
        return c.error({
          code: 'CONFIRMATION_REQUIRED',
          message: 'Use --force to confirm deletion',
        })
      }
      return { deleted: c.args.name }
    },
  })
  .serve()

Help Text Generation

Help is automatically generated for the router and each command.

Router Help

my-cli --help
# my-cli – My CLI
#
# Usage: my-cli <command>
#
# Commands:
#   install  Install a package
#   status   Show repo status
#
# 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

Command-Specific Help

my-cli install --help
# my-cli install – Install a package
#
# Usage: my-cli install [package] [options]
#
# Arguments:
#   package  Package name (optional)
#
# Options:
#   -D, --saveDev  Save as dev dependency
#
# Global Options:
#   --format <toon|json|yaml|md|jsonl>  Output format
#   --help                              Show help
#   --json                              Shorthand for --format json
#   --verbose                           Show full output envelope

Multiple Arguments Per Command

Each command can have its own argument schema:
Cli.create('file', {
  description: 'File operations',
})
  .command('copy', {
    description: 'Copy a file',
    args: z.object({
      source: z.string().describe('Source file'),
      destination: z.string().describe('Destination file'),
    }),
    run(c) {
      return { copied: true, from: c.args.source, to: c.args.destination }
    },
  })
  .command('read', {
    description: 'Read a file',
    args: z.object({
      path: z.string().describe('File path'),
    }),
    options: z.object({
      lines: z.coerce.number().optional().describe('Number of lines to read'),
    }),
    run(c) {
      return { path: c.args.path, content: '...' }
    },
  })
  .serve()
file copy input.txt output.txt
# → copied: true
# → from: input.txt
# → to: output.txt

file read data.txt --lines 10
# → path: data.txt
# → content: ...

Complete Multi-Command Example

Here’s a complete package manager CLI:
import { Cli, z } from 'incur'

Cli.create('pkg', {
  description: 'Package manager',
  version: '1.0.0',
})
  .command('install', {
    description: 'Install packages',
    args: z.object({
      packages: z.array(z.string()).optional().describe('Packages to install'),
    }),
    options: z.object({
      dev: z.boolean().optional().describe('Install as dev dependencies'),
      global: z.boolean().optional().describe('Install globally'),
    }),
    alias: { dev: 'D', global: 'g' },
    run(c) {
      const packages = c.args.packages ?? []
      const count = packages.length || 1
      return {
        installed: count,
        dev: c.options.dev ?? false,
        global: c.options.global ?? false,
      }
    },
  })
  .command('uninstall', {
    description: 'Uninstall packages',
    args: z.object({
      package: z.string().describe('Package to uninstall'),
    }),
    run(c) {
      return { removed: c.args.package }
    },
  })
  .command('list', {
    description: 'List installed packages',
    options: z.object({
      depth: z.coerce.number().default(0).describe('Dependency depth'),
      json: z.boolean().optional().describe('Output as JSON'),
    }),
    run(c) {
      return {
        packages: [
          { name: 'express', version: '4.18.0' },
          { name: 'zod', version: '3.22.0' },
        ],
        depth: c.options.depth,
      }
    },
  })
  .command('update', {
    description: 'Update packages',
    args: z.object({
      package: z.string().optional().describe('Specific package to update'),
    }),
    options: z.object({
      latest: z.boolean().optional().describe('Update to latest version'),
    }),
    run(c) {
      return {
        updated: c.args.package ?? 'all',
        latest: c.options.latest ?? false,
      }
    },
  })
  .serve()

Usage Examples

# Install all dependencies
pkg install
# → installed: 1
# → dev: false
# → global: false

# Install specific packages as dev dependencies
pkg install typescript vitest -D
# → installed: 2
# → dev: true
# → global: false

# List installed packages
pkg list --depth 2
# → packages[2]{name,version}:
# →   express,4.18.0
# →   zod,3.22.0
# → depth: 2

# Update a specific package
pkg update express --latest
# → updated: express
# → latest: true

# Get help for a command
pkg install --help

Type Safety

All arguments and options are fully typed:
Cli.create('calc', {})
  .command('add', {
    args: z.object({
      a: z.coerce.number(),
      b: z.coerce.number(),
    }),
    run(c) {
      // c.args.a and c.args.b are typed as `number`
      return { result: c.args.a + c.args.b }
    },
  })
  .serve()
The .command() method returns the same CLI instance, so you can chain as many commands as you need. The type system tracks all registered commands for features like CTAs.

Router vs Single-Command

FeatureRouter (no run)Single-Command (with run)
Subcommands✓ Yes✗ No
Root handler✗ No✓ Yes
Use caseMulti-purpose toolSingle-purpose utility
Help formatLists commandsShows usage
Examplegit, dockergrep, curl
Don’t mix patterns! A router CLI (without run) is meant to route to subcommands. If you need both a root handler and subcommands, use sub-commands instead.

Build docs developers (and LLMs) love