Skip to main content
Handlers are async functions that execute your command logic. They receive a rich context object with parsed options, terminal utilities, and plugin context.

Handler Function

Define a handler in your command:
import { defineCommand, option } from '@bunli/core'
import { z } from 'zod'

const syncCommand = defineCommand({
  name: 'sync',
  description: 'Sync with remote repository',
  options: {
    remote: option(z.string().default('origin')),
    force: option(z.coerce.boolean().default(false))
  },
  handler: async ({ flags, shell, spinner, colors }) => {
    const spin = spinner('Syncing with remote...')
    
    try {
      await shell`git fetch ${flags.remote}`
      spin.succeed('Sync completed')
    } catch (error) {
      spin.fail('Sync failed')
      throw error
    }
  }
})

Handler Signature

Handlers receive a single argument with the following type:
type Handler<TFlags, TStore> = (
  args: HandlerArgs<TFlags, TStore>
) => void | Promise<void>

Handler Context

The HandlerArgs object provides everything needed to implement your command:

Parsed Options

flags
TFlags
Parsed and validated command options with full type inference.
handler: async ({ flags }) => {
  // flags.port is number (validated)
  // flags.host is string (validated)
  console.log(`Server: ${flags.host}:${flags.port}`)
}
positional
string[]
Positional arguments passed after options.
// mycli deploy app1 app2 --env production
handler: async ({ positional, flags }) => {
  // positional = ['app1', 'app2']
  // flags.env = 'production'
}

Shell Execution

shell
typeof Bun.$
Bun Shell for executing commands. Provides template literal syntax with automatic escaping.
handler: async ({ shell }) => {
  // Execute commands safely
  const result = await shell`git status`
  console.log(result.stdout.toString())
  
  // Variables are automatically escaped
  const branch = 'main'
  await shell`git checkout ${branch}`
  
  // Pipe commands
  await shell`cat file.txt | grep "pattern" | wc -l`
}

Environment

env
typeof process.env
Process environment variables.
handler: async ({ env }) => {
  const apiKey = env.API_KEY
  const nodeEnv = env.NODE_ENV || 'development'
}
cwd
string
Current working directory.
handler: async ({ cwd }) => {
  console.log(`Running in: ${cwd}`)
}

Terminal Utilities

prompt
PromptApi
Interactive prompts for user input.
handler: async ({ prompt }) => {
  // Text input
  const name = await prompt.text('What is your name?')
  
  // Confirmation
  const confirmed = await prompt.confirm('Continue?', { default: true })
  
  // Selection
  const choice = await prompt.select('Choose option:', {
    choices: ['Option A', 'Option B', 'Option C']
  })
  
  // Password
  const password = await prompt.password('Enter password:')
}
spinner
PromptSpinnerFactory
Create animated spinners for long-running operations.
handler: async ({ spinner }) => {
  const spin = spinner('Loading...')
  
  // Update message
  spin.update('Processing...')
  
  // Complete successfully
  spin.succeed('Done!')
  
  // Or show failure
  spin.fail('Failed!')
}
colors
typeof colors
Terminal color utilities from @bunli/utils.
handler: async ({ colors }) => {
  console.log(colors.red('Error message'))
  console.log(colors.green('Success message'))
  console.log(colors.blue('Info message'))
  console.log(colors.yellow('Warning message'))
  console.log(colors.cyan('Highlighted text'))
  console.log(colors.dim('Muted text'))
  console.log(colors.bold('Bold text'))
}

Terminal Information

terminal
TerminalInfo
Information about the terminal environment.
interface TerminalInfo {
  width: number          // Terminal width in columns
  height: number         // Terminal height in rows
  isInteractive: boolean // TTY available
  isCI: boolean          // Running in CI environment
  supportsColor: boolean // Color support
  supportsMouse: boolean // Mouse input support
}

handler: async ({ terminal }) => {
  if (terminal.isInteractive) {
    // Show interactive UI
  } else {
    // Use simple output for CI/pipes
  }
}

Runtime Information

runtime
RuntimeInfo
Runtime execution context.
interface RuntimeInfo {
  startTime: number    // Timestamp when command started
  args: string[]       // Raw command-line arguments
  command: string      // Command name being executed
}

handler: async ({ runtime }) => {
  const duration = Date.now() - runtime.startTime
  console.log(`Completed in ${duration}ms`)
}

Interrupt Handling

signal
AbortSignal
Signal for cooperative cancellation (Ctrl+C handling).
handler: async ({ signal }) => {
  // Check if interrupted
  if (signal.aborted) {
    console.log('Operation cancelled')
    return
  }
  
  // Listen for interrupts
  signal.addEventListener('abort', () => {
    console.log('Cleaning up...')
  })
  
  // Pass to async operations
  await fetch('https://api.example.com', { signal })
}

Plugin Context

context
CommandContext<TStore>
Plugin context with shared store (available when plugins are loaded).
handler: async ({ context }) => {
  if (context) {
    // Access plugin store
    const value = context.store.somePluginData
    
    // Update store
    context.setStoreValue('key', newValue)
    
    // Check if value exists
    if (context.hasStoreValue('key')) {
      const val = context.getStoreValue('key')
    }
  }
}

Async Handlers

All handlers are async and can use await:
const deployCommand = defineCommand({
  name: 'deploy',
  options: {
    env: option(z.enum(['dev', 'staging', 'prod']))
  },
  handler: async ({ flags, shell, spinner, colors }) => {
    const spin = spinner('Deploying...')
    
    try {
      // Build
      spin.update('Building application...')
      await shell`bun run build`
      
      // Test
      spin.update('Running tests...')
      await shell`bun test`
      
      // Deploy
      spin.update(`Deploying to ${flags.env}...`)
      await shell`./deploy.sh ${flags.env}`
      
      spin.succeed(colors.green('Deployment successful!'))
    } catch (error) {
      spin.fail(colors.red('Deployment failed'))
      throw error
    }
  }
})

Error Handling in Handlers

Handlers can throw errors which are caught by Bunli:
handler: async ({ flags }) => {
  if (!flags.required) {
    throw new Error('Missing required configuration')
  }
  // Continue execution
}

Working with Shell Commands

The shell utility provides safe command execution:
handler: async ({ shell, colors }) => {
  // Simple command
  await shell`echo "Hello, World!"`
  
  // Capture output
  const result = await shell`git branch --show-current`
  const branch = result.stdout.toString().trim()
  console.log(colors.cyan(`Current branch: ${branch}`))
  
  // Handle errors
  try {
    await shell`git push origin main`
  } catch (error) {
    console.error(colors.red('Push failed'))
    throw error
  }
  
  // Conditional execution
  const hasChanges = await shell`git status --porcelain`
  if (hasChanges.stdout.toString().trim()) {
    console.log('Uncommitted changes detected')
  }
  
  // Pipe commands
  const count = await shell`git log --oneline | wc -l`
  console.log(`Total commits: ${count.stdout.toString().trim()}`)
}

Interactive Prompts

Create interactive command experiences:
const initCommand = defineCommand({
  name: 'init',
  description: 'Initialize a new project',
  handler: async ({ prompt, shell, spinner, colors }) => {
    // Gather information
    const name = await prompt.text('Project name:', {
      default: 'my-app'
    })
    
    const type = await prompt.select('Project type:', {
      choices: [
        { label: 'Web App', value: 'web' },
        { label: 'CLI Tool', value: 'cli' },
        { label: 'Library', value: 'lib' }
      ]
    })
    
    const useTs = await prompt.confirm('Use TypeScript?', {
      default: true
    })
    
    // Create project
    const spin = spinner('Creating project...')
    
    await shell`mkdir ${name}`
    await shell`cd ${name} && npm init -y`
    
    if (useTs) {
      await shell`cd ${name} && npm install -D typescript`
    }
    
    spin.succeed(colors.green(`Project ${name} created!`))
  }
})

Progress Indication

Provide feedback for long-running operations:
handler: async ({ spinner, colors }) => {
  const tasks = [
    'Installing dependencies',
    'Building application',
    'Running tests',
    'Creating bundle'
  ]
  
  for (const task of tasks) {
    const spin = spinner(task)
    
    try {
      // Simulate work
      await performTask(task)
      spin.succeed(colors.green(`✓ ${task}`))
    } catch (error) {
      spin.fail(colors.red(`✗ ${task}`))
      throw error
    }
  }
}

Type-Safe Flags

Bunli automatically infers flag types from options:
const buildCommand = defineCommand({
  name: 'build',
  options: {
    minify: option(z.coerce.boolean().default(false)),
    sourcemap: option(z.coerce.boolean().default(true)),
    outdir: option(z.string().default('dist')),
    target: option(z.enum(['node', 'browser'])),
    workers: option(z.coerce.number().optional())
  },
  handler: async ({ flags }) => {
    // TypeScript knows the exact types:
    // flags.minify: boolean
    // flags.sourcemap: boolean
    // flags.outdir: string
    // flags.target: 'node' | 'browser'
    // flags.workers: number | undefined
    
    if (flags.minify) {
      // Minify code
    }
    
    if (flags.workers) {
      // Use specific number of workers
    }
  }
})

Best Practices

1

Always use async handlers

Even if your handler is synchronous, use async for consistency.
handler: async ({ colors }) => {
  console.log(colors.green('Done'))
}
2

Provide progress feedback

Use spinners and messages for operations that take time.
const spin = spinner('Processing...')
await longOperation()
spin.succeed('Complete!')
3

Handle interrupts gracefully

Check the abort signal and clean up resources.
signal.addEventListener('abort', async () => {
  await cleanup()
})
4

Use structured error handling

Throw meaningful errors with context.
throw new DeploymentError({
  message: 'Deploy failed',
  environment: flags.env,
  cause: originalError
})

See Also

Build docs developers (and LLMs) love