Skip to main content
In this tutorial, you’ll build a real CLI tool from the ground up. By the end, you’ll have a working task runner with validation, spinners, and colored output.
This guide assumes you’ve completed the Installation steps. You should have Bun and Bunli installed.

What You’ll Build

A task CLI with a build command that:
  • Accepts environment options (dev, staging, production)
  • Validates output directory paths
  • Parses JSON configuration
  • Shows progress with spinners
  • Provides colored terminal output

Project Setup

1

Create project directory

Create a new directory for your CLI:
mkdir task-cli
cd task-cli
2

Initialize package.json

Create a package.json file:
bun init -y
Then edit it to add the necessary fields:
package.json
{
  "name": "task-cli",
  "version": "0.1.0",
  "type": "module",
  "description": "A task runner CLI built with Bunli",
  "bin": {
    "task": "./dist/index.js"
  },
  "scripts": {
    "dev": "bun run src/index.ts",
    "build": "bunli build",
    "typecheck": "tsc --noEmit"
  }
}
The "type": "module" field is required to use ES modules.
3

Install dependencies

Install Bunli core framework and Zod for validation:
bun add @bunli/core zod
bun add -d bunli typescript @types/bun
4

Configure TypeScript

Create a tsconfig.json file:
tsconfig.json
{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "lib": ["ESNext"],
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "types": ["bun-types"]
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}
5

Create source directory

Create the directory structure:
mkdir -p src/commands

Creating Your First Command

Now let’s build the build command with real-world features.
1

Define the build command

Create src/commands/build.ts:
src/commands/build.ts
import { defineCommand, option } from '@bunli/core'
import { z } from 'zod'

export default defineCommand({
  name: 'build',
  description: 'Build project with validation and transformation',
  options: {
    // Environment with enum validation
    env: option(
      z.enum(['development', 'staging', 'production'])
        .default('development'),
      { 
        short: 'e', 
        description: 'Build environment' 
      }
    ),
    
    // Output directory with validation
    outdir: option(
      z.string()
        .min(1, 'Output directory cannot be empty')
        .default('dist'),
      { 
        short: 'o', 
        description: 'Output directory' 
      }
    ),
    
    // Watch mode boolean
    watch: option(
      z.coerce.boolean().default(false),
      { 
        short: 'w', 
        description: 'Watch for changes' 
      }
    )
  },
  
  handler: async ({ flags, colors, spinner }) => {
    const spin = spinner('Building project...')
    
    try {
      // Simulate build process
      await new Promise(resolve => setTimeout(resolve, 1000))
      
      spin.update('Validating configuration...')
      await new Promise(resolve => setTimeout(resolve, 500))
      
      spin.update('Compiling assets...')
      await new Promise(resolve => setTimeout(resolve, 800))
      
      spin.succeed('Build completed successfully!')
      
      console.log(colors.bold('\nBuild Summary:'))
      console.log(`  Environment: ${colors.cyan(flags.env)}`)
      console.log(`  Output: ${colors.cyan(flags.outdir)}`)
      
      if (flags.watch) {
        console.log(colors.yellow('\nWatching for changes...'))
      }
      
    } catch (error) {
      spin.fail('Build failed')
      console.error(colors.red(`Error: ${error instanceof Error ? error.message : String(error)}`))
      throw error
    }
  }
})
The handler context provides utilities like spinner for progress indicators and colors for terminal styling.
2

Create CLI entry point

Create src/index.ts to wire up your command:
src/index.ts
#!/usr/bin/env bun
import { createCLI } from '@bunli/core'
import buildCommand from './commands/build.js'

const cli = await createCLI({
  name: 'task',
  version: '0.1.0',
  description: 'A task runner CLI built with Bunli'
})

cli.command(buildCommand)

await cli.run()
Notice the .js extension in the import, even though we’re writing TypeScript. This is required for ES modules in Bunli.
3

Run your CLI

Test your CLI in development mode:
bun run dev build
Expected output:
⠋ Building project...
⠙ Validating configuration...
⠹ Compiling assets...
✓ Build completed successfully!

Build Summary:
  Environment: development
  Output: dist
Try different options:
# Production build
bun run dev build --env production --outdir build

# With watch mode
bun run dev build -w

# Short flags
bun run dev build -e staging -o out -w

Understanding the Code

Command Options

Each option uses Zod for schema validation and type inference:
env: option(
  z.enum(['development', 'staging', 'production']).default('development'),
  { short: 'e', description: 'Build environment' }
)
  • Schema: z.enum() restricts values to specific strings
  • Default: .default() provides a fallback value
  • Metadata: short creates a -e alias, description shows in help

Type Coercion

Booleans from CLI args are strings by default. Use z.coerce.boolean() to convert:
watch: option(
  z.coerce.boolean().default(false),
  { short: 'w', description: 'Watch for changes' }
)
Now both --watch and --watch=true work correctly.

Handler Context

The handler receives a context object with utilities:
handler: async ({ flags, colors, spinner }) => {
  // flags: Your validated, type-safe options
  // colors: Terminal color utilities
  // spinner: Progress indicator utilities
}

Adding More Commands

Let’s add a clean command to demonstrate multi-command CLIs.
1

Create the clean command

Create src/commands/clean.ts:
src/commands/clean.ts
import { defineCommand, option } from '@bunli/core'
import { z } from 'zod'

export default defineCommand({
  name: 'clean',
  description: 'Remove build artifacts',
  options: {
    force: option(
      z.coerce.boolean().default(false),
      {
        short: 'f',
        description: 'Force delete without confirmation'
      }
    ),
    verbose: option(
      z.coerce.boolean().default(false),
      {
        short: 'v',
        description: 'Show detailed output'
      }
    )
  },
  handler: async ({ flags, colors, spinner }) => {
    if (!flags.force) {
      console.log(colors.yellow('⚠️  This will delete all build artifacts'))
      console.log('Run with --force to proceed')
      return
    }

    const spin = spinner('Cleaning build artifacts...')
    
    // Simulate cleanup
    await new Promise(resolve => setTimeout(resolve, 1000))
    
    if (flags.verbose) {
      spin.info('Removing dist/ directory')
      spin.info('Removing node_modules/.cache')
      spin.info('Removing .bunli/ generated files')
    }
    
    spin.succeed('Clean completed!')
  }
})
2

Register the command

Update src/index.ts to include the new command:
src/index.ts
#!/usr/bin/env bun
import { createCLI } from '@bunli/core'
import buildCommand from './commands/build.js'
import cleanCommand from './commands/clean.js'

const cli = await createCLI({
  name: 'task',
  version: '0.1.0',
  description: 'A task runner CLI built with Bunli'
})

cli.command(buildCommand)
cli.command(cleanCommand)

await cli.run()
3

Test the new command

Try the clean command:
# Without force (shows warning)
bun run dev clean

# With force
bun run dev clean --force

# Verbose mode
bun run dev clean -f -v

Advanced Features

Complex Validation and Transformation

Zod schemas can include transformations:
memory: option(
  z.string()
    .regex(/^\d+[kmg]?$/i, 'Memory must be a number with optional unit (k, m, g)')
    .optional()
    .transform((val) => {
      const raw = val ?? '512m'
      const num = parseInt(raw)
      const unit = raw.slice(-1).toLowerCase()
      const multipliers = { k: 1024, m: 1024 * 1024, g: 1024 * 1024 * 1024 }
      return num * (multipliers[unit as keyof typeof multipliers] || 1)
    }),
  { short: 'm', description: 'Memory limit (e.g., 512m, 2g)' }
)
This accepts inputs like 512m or 2g and converts them to bytes.

Parsing Complex Inputs

Handle key-value pairs:
variables: option(
  z.string()
    .transform((val) => {
      const vars: Record<string, string> = {}
      val.split(',').forEach(pair => {
        const [key, value] = pair.split('=')
        if (key && value) {
          vars[key.trim()] = value.trim()
        }
      })
      return vars
    })
    .optional(),
  { 
    short: 'v', 
    description: 'Environment variables (key1=value1,key2=value2)' 
  }
)
Usage:
bun run dev build --variables "NODE_ENV=production,API_URL=https://api.example.com"

Building for Production

1

Configure Bunli

Create a bunli.config.ts file:
bunli.config.ts
import { defineConfig } from 'bunli'

export default defineConfig({
  entry: './src/index.ts',
  outDir: './dist'
})
2

Build the CLI

Compile your CLI to a standalone binary:
bun run build
Expected output:
Building CLI...
✓ Compiled successfully
✓ Generated: ./dist/index.js
3

Test the built CLI

Run your production build:
./dist/index.js build --env production
4

Make it globally available

Install your CLI globally for local testing:
bun link
Now you can use it anywhere:
task build --env staging
task clean --force

Testing Your CLI

Add tests using Bun’s built-in test runner:
src/commands/build.test.ts
import { describe, expect, test } from 'bun:test'
import buildCommand from './build.js'

describe('build command', () => {
  test('has correct name and description', () => {
    expect(buildCommand.name).toBe('build')
    expect(buildCommand.description).toBe('Build project with validation and transformation')
  })

  test('defines required options', () => {
    expect(buildCommand.options).toHaveProperty('env')
    expect(buildCommand.options).toHaveProperty('outdir')
    expect(buildCommand.options).toHaveProperty('watch')
  })
})
Run tests:
bun test
Check out the @bunli/test package for advanced CLI testing utilities.

Project Structure Summary

Your complete project structure:
task-cli/
├── src/
│   ├── index.ts              # CLI entry point
│   └── commands/
│       ├── build.ts          # Build command
│       ├── clean.ts          # Clean command
│       └── build.test.ts     # Tests
├── dist/                     # Built output (generated)
├── package.json              # Project metadata
├── tsconfig.json             # TypeScript config
├── bunli.config.ts           # Bunli configuration
└── README.md                 # Documentation

Next Steps

You’ve built a working CLI from scratch! Here’s what to explore next:

Core Concepts

Dive deeper into commands, options, and handlers

Plugins

Extend your CLI with plugins for config, completions, and more

Type Generation

Auto-generate TypeScript types for your commands

TUI Components

Build interactive terminal interfaces with React

Common Patterns

Help Command

Bunli automatically generates help output:
task --help
task build --help

Version Flag

Version is automatically handled:
task --version

Exit Codes

Handle errors with proper exit codes:
handler: async ({ flags }) => {
  try {
    // Your logic here
  } catch (error) {
    console.error('Failed:', error)
    process.exit(1)
  }
}

Environment Variables

Access environment variables in handlers:
handler: async ({ flags }) => {
  const apiKey = process.env.API_KEY
  if (!apiKey) {
    throw new Error('API_KEY environment variable is required')
  }
  // Use the API key
}

Tips and Best Practices

Always use .js extensions in imports, even in TypeScript files. This is required for ES modules:
import command from './commands/build.js' // Correct
import command from './commands/build'    // Wrong
Use short flags for frequently used options. They make your CLI more user-friendly:
option(schema, { short: 'e', description: '...' })
Provide sensible defaults to reduce required configuration:
z.string().default('dist')
Validate early using Zod schemas. This catches errors before your handler runs and provides clear error messages to users.
You now have a solid foundation for building powerful CLIs with Bunli. Happy coding!

Build docs developers (and LLMs) love