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:
Unique identifier for the command. Used for routing and execution.
Human-readable description shown in help output.
Command-specific options/flags. See Options for details.
Async function that executes the command logic. See Handlers for details.
render
RenderFunction<TFlags, TStore>
Optional TUI render function for interactive terminal interfaces.
Alternative names for the command. Useful for shortcuts.alias: 'pull' // Single alias
alias: ['s', 'st'] // Multiple aliases
Nested subcommands. Cannot be used with handler or render.
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:
Single Alias
Multiple Aliases
const syncCommand = defineCommand({
name: 'sync',
alias: 'pull', // mycli pull works the same as mycli sync
description: 'Sync with upstream',
handler: async () => {
// ...
}
})
const statusCommand = defineCommand({
name: 'status',
alias: ['s', 'st'], // mycli s, mycli st, or mycli status
description: 'Show git status',
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
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
Provide helpful descriptions
Descriptions appear in help output and should be concise but informative.description: 'Sync with upstream repository'
Use const assertions for type safety
Add as const to command names for better type inference: Group related commands
Use command groups to organize complex CLIs:defineGroup({
name: 'database',
commands: [migrateCommand, seedCommand, resetCommand]
})
See Also