Bunli
API Reference

defineCommand

Define type-safe commands with automatic inference

defineCommand

Defines a command with full type inference for options and handler arguments.

Syntax

function defineCommand<TOptions extends Options>(
  command: CommandDefinition<TOptions>
): Command<TOptions>

Parameters

command

The command definition object.

interface CommandDefinition<TOptions> {
  name: string
  description: string
  alias?: string | string[]
  options?: TOptions
  handler?: Handler<InferOptions<TOptions>>
  commands?: Command[]
}

Type Inference

defineCommand automatically infers types from your Zod schemas:

import { defineCommand, option } from '@bunli/core'
import { z } from 'zod'

const command = defineCommand({
  name: 'serve',
  description: 'Start server',
  options: {
    port: option(z.coerce.number().default(3000)),
    host: option(z.string().default('localhost'))
  },
  handler: async ({ flags }) => {
    // TypeScript knows:
    // flags.port is number
    // flags.host is string
  }
})

Examples

Basic Command

export default defineCommand({
  name: 'hello',
  description: 'Say hello',
  handler: async ({ colors }) => {
    console.log(colors.green('Hello, World!'))
  }
})

Command with Options

export default defineCommand({
  name: 'deploy',
  description: 'Deploy application',
  options: {
    env: option(
      z.enum(['dev', 'staging', 'prod']),
      { description: 'Target environment', short: 'e' }
    ),
    force: option(
      z.coerce.boolean().default(false),
      { description: 'Force deployment', short: 'f' }
    ),
    tag: option(
      z.string().optional(),
      { description: 'Version tag' }
    )
  },
  handler: async ({ flags, shell, spinner }) => {
    const spin = spinner(`Deploying to ${flags.env}...`)
    spin.start()
    
    if (flags.tag) {
      await shell`git tag ${flags.tag}`
    }
    
    await shell`deploy --env ${flags.env} ${flags.force ? '--force' : ''}`
    spin.succeed('Deployed successfully!')
  }
})

Command with Aliases

export default defineCommand({
  name: 'development',
  alias: ['dev', 'd'],
  description: 'Start development server',
  handler: async ({ shell }) => {
    await shell`bun run dev`
  }
})

Nested Commands

export default defineCommand({
  name: 'db',
  description: 'Database operations',
  commands: [
    defineCommand({
      name: 'migrate',
      alias: 'm',
      description: 'Run migrations',
      options: {
        direction: option(
          z.enum(['up', 'down']).default('up'),
          { short: 'd', description: 'Migration direction' }
        )
      },
      handler: async ({ flags, shell }) => {
        await shell`bun run db:migrate ${flags.direction}`
      }
    }),
    defineCommand({
      name: 'seed',
      description: 'Seed database',
      handler: async ({ shell }) => {
        await shell`bun run db:seed`
      }
    })
  ]
})

Handler Context

The handler receives a context object with these properties:

interface HandlerArgs<TFlags> {
  // Parsed and validated option values
  flags: TFlags
  
  // Non-flag arguments
  positional: string[]
  
  // Bun Shell ($)
  shell: typeof Bun.$
  
  // Environment variables
  env: typeof process.env
  
  // Current working directory
  cwd: string
  
  // Interactive prompts (from @bunli/utils)
  prompt: {
    (message: string, options?: PromptOptions): Promise<string>
    confirm(message: string, options?: ConfirmOptions): Promise<boolean>
    select<T>(message: string, options: SelectOptions<T>): Promise<T>
    password(message: string, options?: PromptOptions): Promise<string>
  }
  
  // Progress spinner (from @bunli/utils)
  spinner: (text?: string) => Spinner
  
  // Terminal colors (from @bunli/utils)
  colors: Colors
  
  // Plugin context (when using plugins)
  context?: CommandContext<TStore>
}

Using Handler Context

handler: async ({ flags, positional, shell, env, cwd, prompt, spinner, colors, context }) => {
  // Access parsed flags
  console.log(`Port: ${flags.port}`)
  
  // Use positional arguments
  const [file] = positional
  
  // Run shell commands
  const result = await shell`ls -la ${file}`.text()
  
  // Check environment
  if (env.NODE_ENV === 'production') {
    const confirm = await prompt.confirm('Deploy to production?')
    if (!confirm) return
  }
  
  // Show progress
  const spin = spinner('Building...')
  spin.start()
  await shell`bun run build`
  spin.succeed('Build complete!')
  
  // Colored output
  console.log(colors.green('✓ Success'))
}

Validation

Options are validated automatically before the handler runs:

// This command requires a valid port number
export default defineCommand({
  name: 'serve',
  options: {
    port: option(
      z.coerce.number()
        .int()
        .min(1)
        .max(65535),
      { description: 'Port number' }
    )
  },
  handler: async ({ flags }) => {
    // flags.port is guaranteed to be 1-65535
  }
})

// Invalid input shows error:
// $ my-cli serve --port 70000
// Validation errors:
//   --port:
//     • Number must be less than or equal to 65535

Command Without Handler

Commands can be defined without handlers when they only contain subcommands:

export default defineCommand({
  name: 'tools',
  description: 'Development tools',
  commands: [
    // Subcommands here
  ]
  // No handler - shows help when called directly
})

Using Plugin Context

When using plugins, commands can access the type-safe plugin store:

// With plugins configured in createCLI
const cli = await createCLI({
  plugins: [
    aiAgentPlugin(),
    timingPlugin()
  ] as const
})

// In your command:
export default defineCommand({
  name: 'build',
  description: 'Build the project',
  handler: async ({ flags, context, colors }) => {
    // Access plugin store with full type safety
    if (context?.env.isAIAgent) {
      // Provide structured output for AI
      console.log(JSON.stringify({
        status: 'building',
        agents: context.store.aiAgents
      }))
    } else {
      // Human-friendly output
      console.log(colors.blue('Building project...'))
    }
    
    // Access timing data from plugin
    if (context?.store.startTime) {
      console.log(`Started at: ${new Date(context.store.startTime)}`)
    }
  }
})

Use defineCommand for the best TypeScript experience. It provides complete type inference from your option schemas to your handler implementation.

Best Practices

  1. Always add descriptions - Help users understand what commands do
  2. Use semantic names - Command names should be verbs (build, deploy, test)
  3. Add short flags - For frequently used options
  4. Group related commands - Use nested commands for organization
  5. Validate early - Use Zod schemas to validate input before processing

See Also