Skip to main content
Commands are the building blocks of your CLI application. Bunli provides a type-safe API for defining commands with automatic type inference, nested subcommands, and rich metadata.

Defining Commands

Use the defineCommand function to create a command with full type safety:
import { defineCommand, option } from '@bunli/core'
import { z } from 'zod'

const greetCommand = defineCommand({
  name: 'greet',
  description: 'A minimal greeting CLI',
  options: {
    name: option(
      z.string().default('world'),
      { short: 'n', description: 'Who to greet' }
    ),
    loud: option(
      z.coerce.boolean().default(false),
      { short: 'l', description: 'Shout the greeting' }
    ),
    times: option(
      z.coerce.number().int().positive().default(1),
      { short: 't', description: 'Number of times to greet' }
    )
  },
  handler: async ({ flags, colors }) => {
    const greeting = `Hello, ${flags.name}!`
    const message = flags.loud ? greeting.toUpperCase() : greeting
    
    for (let i = 0; i < flags.times; i++) {
      console.log(colors.cyan(message))
    }
  }
})
The defineCommand function is a type-safe helper that preserves option types and enables autocompletion throughout your command definition.

Command Structure

A command definition includes the following properties:
name
string
required
Unique identifier for the command. Used for routing and execution.
description
string
required
Human-readable description shown in help output.
options
Options
Command-specific options/flags. See Options for details.
handler
Handler<TFlags, TStore>
Async function that executes the command logic. See Handlers for details.
render
RenderFunction<TFlags, TStore>
Optional TUI render function for interactive terminal interfaces.
alias
string | string[]
Alternative names for the command. Useful for shortcuts.
alias: 'pull'  // Single alias
alias: ['s', 'st']  // Multiple aliases
commands
Command[]
Nested subcommands. Cannot be used with handler or render.
tui
CommandTuiOptions
TUI-specific configuration for terminal rendering.
tui: {
  renderer: {
    bufferMode: 'alternate'  // or 'standard'
  }
}

Command Types

Bunli has two types of commands:

Runnable Commands

Commands that can be executed directly. Must have at least a handler or render function:
const syncCommand = defineCommand({
  name: 'sync',
  description: 'Sync with upstream repository',
  alias: 'pull',
  options: {
    remote: option(
      z.string().default('origin'),
      { short: 'r', description: 'Remote name to sync with' }
    ),
    branch: option(
      z.string().optional(),
      { short: 'b', description: 'Branch to sync (defaults to current branch)' }
    ),
    force: option(
      z.coerce.boolean().default(false),
      { short: 'f', description: 'Force sync even if there are conflicts' }
    )
  },
  handler: async ({ flags, spinner, shell }) => {
    const spin = spinner('Syncing with remote...')
    
    // Get current branch if not specified
    const currentBranch = flags.branch || 
      (await shell`git branch --show-current`).stdout.toString().trim()
    
    // Fetch latest changes
    spin.update('Fetching latest changes...')
    await shell`git fetch ${flags.remote}`
    
    spin.succeed('Sync completed successfully')
  }
})

Command Groups

Commands that contain subcommands but cannot be executed directly:
import { defineGroup } from '@bunli/core'

const gitGroup = defineGroup({
  name: 'git',
  description: 'Git utilities',
  commands: [
    syncCommand,
    branchCommand,
    statusCommand,
    prCommand
  ]
})
A command group cannot have handler, render, or options. It exists only to organize subcommands.

Nested Subcommands

Create hierarchical command structures by nesting commands:
import { defineCommand, defineGroup } from '@bunli/core'

const listBranchesCommand = defineCommand({
  name: 'list',
  description: 'List all branches',
  handler: async ({ shell, colors }) => {
    const { stdout } = await shell`git branch -a`
    console.log(colors.cyan(stdout.toString()))
  }
})

const createBranchCommand = defineCommand({
  name: 'create',
  description: 'Create a new branch',
  options: {
    name: option(z.string(), { description: 'Branch name' })
  },
  handler: async ({ flags, shell }) => {
    await shell`git branch ${flags.name}`
  }
})

const branchGroup = defineGroup({
  name: 'branch',
  description: 'Manage branches',
  commands: [
    listBranchesCommand,
    createBranchCommand
  ]
})

// Usage:
// mycli branch list
// mycli branch create --name feature/new-feature

Command Registration

Register commands with your CLI instance:
import { createCLI } from '@bunli/core'

const cli = await createCLI()

// Register individual commands
cli.command(greetCommand)
cli.command(syncCommand)

// Register command groups (includes all subcommands)
cli.command(gitGroup)

// Run the CLI
await cli.run()

Command Aliases

Provide shortcuts for frequently used commands:
const syncCommand = defineCommand({
  name: 'sync',
  alias: 'pull',  // mycli pull works the same as mycli sync
  description: 'Sync with upstream',
  handler: async () => {
    // ...
  }
})

TUI Rendering

Commands can provide both a CLI handler and a TUI renderer:
import { defineCommand, option } from '@bunli/core'
import { ProgressBar } from '@bunli/tui'
import { z } from 'zod'

const greetCommand = defineCommand({
  name: 'greet',
  description: 'Interactive greeting',
  options: {
    name: option(z.string().default('world'), {
      description: 'Who to greet'
    })
  },
  // Used in interactive terminals
  render: ({ flags }) => (
    <ProgressBar
      value={progress}
      label={`Hello, ${flags.name}!`}
      color="#22c55e"
    />
  ),
  // Fallback for non-interactive environments (CI, pipes, etc.)
  handler: async ({ flags, colors }) => {
    console.log(colors.cyan(`Hello, ${flags.name}!`))
  }
})
Bunli automatically chooses render for interactive terminals and handler for non-interactive environments (CI, pipes). You can provide both for the best experience.

Type Safety

Bunli provides full type inference for command options:
const startCommand = defineCommand({
  name: 'start',
  options: {
    port: option(z.coerce.number().min(1000).max(65535).default(3000)),
    host: option(z.string().default('localhost'))
  },
  handler: async ({ flags }) => {
    // TypeScript knows:
    // flags.port is number
    // flags.host is string
    const url = `http://${flags.host}:${flags.port}`
  }
})

Best Practices

1

Use descriptive names

Command names should be clear and follow your CLI’s naming conventions.
name: 'sync'        // Good: clear action
name: 's'           // Avoid: use as alias instead
2

Provide helpful descriptions

Descriptions appear in help output and should be concise but informative.
description: 'Sync with upstream repository'
3

Use const assertions for type safety

Add as const to command names for better type inference:
name: 'sync' as const
4

Group related commands

Use command groups to organize complex CLIs:
defineGroup({
  name: 'database',
  commands: [migrateCommand, seedCommand, resetCommand]
})

See Also

Build docs developers (and LLMs) love