@bunli/core
The core CLI framework for Bunli
@bunli/core
The core CLI framework that powers Bunli. Provides type-safe command definitions, automatic validation, and rich handler contexts.
@bunli/core has zero dependencies and integrates with the Standard Schema v1 specification for validation, supporting Zod, Valibot, and other schema libraries.
Installation
bun add @bunli/core
npm install @bunli/core
pnpm add @bunli/core
Core APIs
createCLI
Create a new CLI instance with configuration:
import { createCLI } from '@bunli/core'
const cli = createCLI({
name: 'my-cli',
version: '1.0.0',
description: 'My awesome CLI tool'
})
// With command manifest for lazy loading
const cli = createCLI({
name: 'my-cli',
version: '1.0.0',
description: 'My awesome CLI tool',
commands: {
manifest: './commands/manifest.js'
}
})
// With plugins for extended functionality
const cli = await createCLI({
name: 'my-cli',
version: '1.0.0',
description: 'My awesome CLI tool',
plugins: [
configMergerPlugin(),
aiAgentPlugin()
]
})
// Add commands
cli.command({
name: 'hello',
description: 'Say hello',
handler: async () => {
console.log('Hello, World!')
}
})
// Initialize (loads manifest if configured)
await cli.init()
// Run the CLI
await cli.run()
defineCommand
Define commands with full type inference:
import { defineCommand, option } from '@bunli/core'
import { z } from 'zod'
export default defineCommand({
name: 'deploy',
description: 'Deploy the application',
alias: ['d', 'ship'], // Can be string or array
options: {
env: option(
z.enum(['dev', 'staging', 'prod']),
{ description: 'Target environment' }
),
dry: option(
z.coerce.boolean().default(false),
{ short: 'd', description: 'Perform a dry run' }
)
},
handler: async ({ flags, positional, shell, env, cwd, prompt, spinner, colors }) => {
const spin = spinner(`Deploying to ${flags.env}...`)
spin.start()
if (!flags.dry) {
await shell`git push ${flags.env} main`
}
spin.succeed('Deployed successfully!')
}
})
option
Create typed options with validation:
import { option } from '@bunli/core'
import { z } from 'zod'
// Options always require a schema
const portOption = option(
z.coerce.number().int().min(1).max(65535).default(3000),
{
description: 'Port number',
short: 'p'
}
)
// Use in command
defineCommand({
name: 'serve',
options: {
port: portOption,
host: option(
z.string().ip().or(z.literal('localhost')).default('localhost'),
{ description: 'Host to bind to', short: 'h' }
)
},
handler: async ({ flags }) => {
// flags.port is number between 1-65535
// flags.host is valid IP or 'localhost'
}
})
All options must have a schema - there are no raw options in Bunli. Use z.coerce
for CLI inputs to automatically convert string inputs to the correct type (boolean, number, etc.)
Handler Context
Every command handler receives a rich context object:
interface HandlerArgs<T> {
// Parsed and validated flags
flags: T
// Non-flag arguments
positional: string[]
// Bun Shell for running commands
shell: typeof Bun.$
// Environment variables
env: typeof process.env
// Current working directory
cwd: string
// Utility functions from @bunli/utils (auto-imported)
prompt: typeof import('@bunli/utils').prompt
spinner: typeof import('@bunli/utils').spinner
colors: typeof import('@bunli/utils').colors
// Plugin context (when using plugins)
context?: CommandContext<TStore>
// CLI instance
cli: CLI
}
Using the Shell
The shell is Bun's native shell with full support for piping, globbing, and more:
handler: async ({ shell, flags }) => {
// Simple commands
await shell`echo "Hello, World!"`
// With variables (automatically escaped)
const file = "my file.txt"
await shell`cat ${file}`
// Piping
const count = await shell`ls -la | wc -l`.text()
// Error handling
try {
await shell`test -f ${flags.config}`
} catch {
throw new Error('Config file not found')
}
// Get output as text
const branch = await shell`git branch --show-current`.text()
// Check if command exists
if (await shell`which docker`.quiet()) {
await shell`docker ps`
}
}
Interactive Prompts
handler: async ({ prompt }) => {
// Text input
const name = await prompt('What is your name?')
// With validation
const email = await prompt('Email:', {
validate: (value) => {
if (!value.includes('@')) {
return 'Please enter a valid email'
}
}
})
// Confirmation
if (await prompt.confirm('Continue?')) {
// User confirmed
}
// Selection
const color = await prompt.select('Choose a color:', {
options: [
{ value: 'red', label: 'Red' },
{ value: 'blue', label: 'Blue' },
{ value: 'green', label: 'Green' }
]
})
// Password (with masking)
const password = await prompt.password('Enter password:')
// With schema validation
const apiKey = await prompt('API Key:', {
schema: z.string().min(32).regex(/^[A-Za-z0-9]+$/)
})
}
Progress Indicators
handler: async ({ spinner, shell }) => {
// Basic spinner
const spin = spinner('Loading...')
spin.start()
await shell`sleep 2`
spin.stop()
// With status updates
spin.update('Downloading...')
await downloadFile()
spin.update('Installing...')
await install()
// Success/failure states
spin.succeed('Installation complete!')
// or
spin.fail('Installation failed')
// Warning state
spin.warn('Installation completed with warnings')
}
Nested Commands
Organize related commands:
cli.command({
name: 'db',
description: 'Database commands',
commands: [
defineCommand({
name: 'migrate',
description: 'Run migrations',
handler: async ({ shell }) => {
await shell`bun run db:migrate`
}
}),
defineCommand({
name: 'seed',
description: 'Seed database',
options: {
force: option(
z.coerce.boolean().default(false),
{ description: 'Force seed in production' }
)
},
handler: async ({ flags, env, shell }) => {
if (env.NODE_ENV === 'production' && !flags.force) {
throw new Error('Use --force to seed in production')
}
await shell`bun run db:seed`
}
})
]
})
Command Manifests
For larger CLIs, use command manifests for lazy loading:
// commands/manifest.ts
export default {
build: () => import('./build.js'),
deploy: () => import('./deploy.js'),
test: () => import('./test.js'),
// Nested commands
db: {
migrate: () => import('./db/migrate.js'),
seed: () => import('./db/seed.js'),
backup: () => import('./db/backup.js')
}
}
// cli.ts
import { createCLI } from '@bunli/core'
import manifest from './commands/manifest.js'
const cli = createCLI({
name: 'my-cli',
version: '1.0.0'
})
// Load commands from manifest
await cli.load(manifest)
await cli.run()
Command manifests enable lazy loading, significantly improving startup time for large CLIs. Commands are only loaded when actually invoked.
Error Handling
Bunli provides helpful error messages automatically:
// Validation errors are automatically formatted
$ my-cli deploy --env=qa
Validation errors:
--env:
• Invalid enum value. Expected 'dev' | 'staging' | 'prod', received 'qa'
// Invalid option value with details
$ my-cli serve --port abc
Validation errors:
--port:
• Expected number, received nan
// Unknown command
$ my-cli unknown
Unknown command: unknown
// Automatic help generation
$ my-cli deploy --help
Usage: my-cli deploy [options]
Deploy the application
Options:
--env Target environment
--dry, -d Perform a dry run (default: false)
Custom error handling:
handler: async ({ flags }) => {
try {
await riskyOperation()
} catch (error) {
// Re-throw with helpful context
throw new Error(`Failed to deploy to ${flags.env}: ${error.message}`)
}
}
Type Safety
Bunli provides complete type safety throughout:
const command = defineCommand({
options: {
port: option(
z.coerce.number().int().positive().default(3000),
{ description: 'Server port' }
),
host: option(
z.string().default('localhost'),
{ description: 'Server host' }
),
secure: option(
z.coerce.boolean().optional(),
{ description: 'Use HTTPS' }
)
},
handler: async ({ flags }) => {
// TypeScript infers types from Zod schemas:
// flags.port: number
// flags.host: string
// flags.secure: boolean | undefined
// This would be a type error:
// flags.port.toUpperCase()
}
})
Use defineCommand
for the best type inference experience. TypeScript will automatically infer types from your options configuration.
Advanced Features
defineConfig
Define configuration for the Bunli CLI:
// bunli.config.ts
import { defineConfig } from '@bunli/core'
export default defineConfig({
name: 'my-cli',
version: '1.0.0',
description: 'My CLI tool',
commands: {
manifest: './commands/manifest.js'
},
build: {
entry: './src/cli.ts',
outdir: './dist',
targets: ['darwin-arm64', 'linux-x64', 'windows-x64'],
compress: true
},
dev: {
watch: true,
inspect: false
}
})
Schema Transforms
Leverage schema transforms for advanced parsing:
// Parse JSON input
const configOption = option(
z.string().transform(val => JSON.parse(val)).pipe(
z.object({
name: z.string(),
port: z.number()
})
),
{ description: 'JSON configuration' }
)
// Parse key-value pairs
const envOption = option(
z.string().transform(val => {
const pairs = val.split(',')
return Object.fromEntries(
pairs.map(p => p.split('='))
)
}),
{ description: 'Environment variables (KEY=VALUE,...)' }
)
// Parse file size
const sizeOption = option(
z.string().regex(/^\d+[kmg]b?$/i).transform(val => {
const match = val.match(/^(\d+)([kmg])b?$/i)
const [, num, unit] = match!
const multipliers = { k: 1024, m: 1024**2, g: 1024**3 }
return parseInt(num) * multipliers[unit.toLowerCase()]
}),
{ description: 'Size limit (e.g., 512k, 1g)' }
)
Standard Schema Support
Bunli supports any validation library that implements Standard Schema v1:
// Using Valibot instead of Zod
import * as v from 'valibot'
import { option } from '@bunli/core'
const portOption = option(
v.pipe(
v.string(),
v.transform((val) => parseInt(val, 10)),
v.number(),
v.minValue(1),
v.maxValue(65535)
),
{ short: 'p', description: 'Port number' }
)
Plugin System
Bunli includes a powerful plugin system for extending functionality:
import { createPlugin } from '@bunli/core/plugin'
// Create a simple plugin
const myPlugin = createPlugin({
name: 'my-plugin',
store: {
requestCount: 0
},
beforeCommand({ store }) {
store.requestCount++
},
afterCommand({ store }) {
console.log(`Total requests: ${store.requestCount}`)
}
})
// Use in CLI with type-safe store
const cli = await createCLI({
name: 'my-cli',
plugins: [myPlugin]
})
// Access plugin store in commands
defineCommand({
name: 'info',
handler: async ({ context }) => {
console.log(`Requests: ${context?.store.requestCount}`)
}
})
Learn more in the Plugin Documentation.
API Reference
See the API Reference for detailed documentation of all exports.