Build a complete CLI from scratch with step-by-step guidance
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.
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 bunimport { 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 buildbun run dev build --env production --outdir build# With watch modebun run dev build -w# Short flagsbun run dev build -e staging -o out -w
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 bunimport { 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 forcebun run dev clean --force# Verbose modebun run dev clean -f -v
handler: async ({ flags }) => { const apiKey = process.env.API_KEY if (!apiKey) { throw new Error('API_KEY environment variable is required') } // Use the API key}