Commands
Build custom CLI commands with typed arguments, flags, interactive prompts, and rich terminal UI.
Introduction
@tekir/commands provides everything you need to build CLI tools: a BaseCommand class with lifecycle hooks, typed arguments and flags, interactive prompts, tables, task runners, and colors. Commands placed in the commands/ directory are auto-discovered at boot.
Installation
bun add @tekir/commandsCreating Commands
Generate a command with make:command:
tekir make:command SendEmailOr create one manually. Extend BaseCommand, set commandName, and implement run(). Define arguments and flags as static properties:
import { BaseCommand } from '@tekir/commands'
export default class SendEmailCommand extends BaseCommand {
static commandName = 'send:email'
static description = 'Send an email to a user'
static args = {
email: { type: 'string', description: 'Recipient address' }
}
static flags = {
subject: { type: 'string', default: 'Hello' },
dry: { type: 'boolean', alias: 'd', description: 'Dry run' }
}
async run() {
if (this.flags.dry) {
this.logger.info(`Would send "${this.flags.subject}" to ${this.args.email}`)
} else {
this.logger.success(`Sent to ${this.args.email}`)
}
}
}Run it:
tekir send:email [email protected] --subject "Welcome" -dEvery command gets --help automatically:
$ tekir send:email --help
send:email
Send an email to a user
Arguments:
email Recipient address
Flags:
--subject [default: Hello]
--dry, -d Dry runCommand options control boot behavior:
export default class QueueWorkerCommand extends BaseCommand {
static commandName = 'queue:worker'
static description = 'Process queued jobs'
static aliases = ['worker', 'qw']
static help = [
'Starts a long-running worker process.',
'Use --concurrency to control parallelism.'
]
static options = {
startApp: true, // boot the app before running
staysAlive: true, // don't exit after run() completes
allowUnknownFlags: false
}
static flags = {
concurrency: { type: 'number', default: 1, alias: 'c' }
}
async run() {
this.logger.info(`Processing jobs with concurrency ${this.flags.concurrency}`)
// long-running work...
}
}- startApp: boot the app before running (access models, services)
- staysAlive: don't exit after
run()(for workers, watchers) - allowUnknownFlags: don't error on unrecognized flags
Arguments
Arguments are positional values after the command name. Define them in static args: the order of keys determines the order of arguments. All args are required by default.
static args = {
name: { type: 'string', description: 'User name' },
role: { type: 'string', description: 'User role' }
}
// tekir greet Alice admin
// this.args.name → 'Alice'
// this.args.role → 'admin'Spread Arguments
A spread argument captures all remaining positional values into an array. It must be the last argument.
static args = {
packages: { type: 'spread', description: 'Packages to install' }
}
// tekir install lodash zod drizzle
// this.args.packages → ['lodash', 'zod', 'drizzle']Optional Arguments
Set required: false and optionally provide a default:
static args = {
env: { type: 'string', required: false, default: 'production' }
}
// tekir deploy
// this.args.env → 'production'
// tekir deploy staging
// this.args.env → 'staging'Use parse to transform or validate argument values:
static args = {
email: {
type: 'string',
parse: (value) => value.toLowerCase()
}
}
static flags = {
port: {
type: 'number',
parse: (value) => {
if (value < 1 || value > 65535) throw new Error('Port must be between 1 and 65535')
return value
}
}
}Flags
Flags are named options prefixed with --. They can appear in any order and support four types:
Boolean Flags
Present = true, absent = undefined. Supports negation with --no- prefix.
static flags = {
verbose: { type: 'boolean', description: 'Enable verbose output' },
minify: { type: 'boolean', default: true }
}
// --verbose → this.flags.verbose = true
// --no-minify → this.flags.minify = false
// (omitted) → this.flags.minify = true (default)String Flags
Accept text values with --flag value or --flag=value.
static flags = {
driver: { type: 'string', default: 'sqlite', description: 'Database driver' }
}
// --driver mysql → this.flags.driver = 'mysql'
// --driver=postgres → this.flags.driver = 'postgres'
// (omitted) → this.flags.driver = 'sqlite' (default)Number Flags
Validated as numbers automatically. Invalid input throws an error.
static flags = {
port: { type: 'number', default: 3000 }
}
// --port 8080 → this.flags.port = 8080
// --port abc → Error: Flag --port must be a valid numberArray Flags
Specify the same flag multiple times to collect values into an array.
static flags = {
groups: { type: 'array', description: 'User groups' }
}
// --groups admin --groups mod
// this.flags.groups → ['admin', 'mod']Flag Aliases
Single-character aliases use a single -. Multiple boolean aliases can be combined: -rs.
static flags = {
resource: { type: 'boolean', alias: 'r' },
singular: { type: 'boolean', alias: 's' },
name: { type: 'string', alias: 'n' }
}
// -rs → resource = true, singular = true
// -n MyController → name = 'MyController'Lifecycle
Commands execute four lifecycle methods in order: prepare → interact → run → completed. Only run() is required.
import { BaseCommand } from '@tekir/commands'
export default class SetupCommand extends BaseCommand {
static commandName = 'setup'
static description = 'Set up the project'
async prepare() {
// Runs first: initialize state
this.logger.info('Preparing...')
}
async interact() {
// Runs second: collect user input
const name = await this.prompt.ask('Project name', { default: 'my-app' })
const db = await this.prompt.choice('Database', ['sqlite', 'postgres', 'mysql'])
// store for run()
this.parsed.flags.projectName = name
this.parsed.flags.database = db
}
async run() {
// Runs third: main logic
const tasks = this.ui.tasks()
await tasks
.add('Create project', async (task) => {
task.update('Scaffolding files...')
return 'Done'
})
.add('Install dependencies', async () => {
return 'Installed'
})
.run()
}
async completed() {
// Runs last: cleanup or error handling
if (this.error) {
this.logger.error(this.error)
return true // tell the framework we handled it
}
this.ui.sticker()
.add('Project created successfully!')
.add('')
.add(`cd ${this.flags.projectName}`)
.add('tekir serve')
.render()
}
}- prepare: initialize state, register cleanup hooks
- interact: display prompts, collect user input
- run: main command logic
- completed: cleanup, error handling. Return
trueto suppress default error output
Prompts
Every command has a this.prompt object for interactive terminal input:
// Text input
const name = await this.prompt.ask('What is your name?')
// With validation
const email = await this.prompt.ask('Email', {
validate: (v) => v.includes('@') ? true : 'Enter a valid email'
})
// With default value
const port = await this.prompt.ask('Port', { default: '3000' })
// Password (masked input)
const password = await this.prompt.secure('Database password')
// Yes/No confirmation
const confirmed = await this.prompt.confirm('Delete all data?')
// Toggle with custom labels
const proceed = await this.prompt.toggle('Continue?', ['Yup', 'Nope'])
// Single choice
const db = await this.prompt.choice('Select database', [
{ name: 'sqlite', message: 'SQLite' },
{ name: 'pg', message: 'PostgreSQL' },
{ name: 'mysql', message: 'MySQL' }
])
// Multiple choice
const features = await this.prompt.multiple('Select features', [
'auth',
'cache',
'queue',
'mail'
])Terminal UI
Logger
this.logger provides severity-leveled output with icons and colors. Actions track the status of operations.
// Log levels
this.logger.debug('Loading config')
this.logger.info('Server starting')
this.logger.success('Deployment complete')
this.logger.warning('SSL cert expires soon')
this.logger.error('Connection failed')
this.logger.error(new Error('Timeout'))
this.logger.fatal(new Error('Process crashed'))
// Action indicators
const action = this.logger.action('creating config/auth.ts')
action.displayDuration().succeeded()
// or
action.skipped('File already exists')
// or
action.failed('Permission denied')Tables
Render data in aligned columns with optional right-alignment:
const table = this.ui.table()
table.head(['Migration', 'Duration', { content: 'Status', hAlign: 'right' }])
table.row(['20240101_users.sql', '2ms', { content: this.colors.green('DONE'), hAlign: 'right' }])
table.row(['20240102_posts.sql', '5ms', { content: this.colors.red('FAILED'), hAlign: 'right' }])
table.render()Task Runner
Execute multiple operations with progress indicators. Supports verbose mode for debugging.
const tasks = this.ui.tasks()
await tasks
.add('Clone repository', async (task) => {
task.update('Downloading...')
return 'Cloned'
})
.add('Install dependencies', async (task) => {
task.update('Running bun install...')
return 'Installed 42 packages'
})
.add('Run migrations', async (task) => {
try {
await runMigrations()
return 'Applied 3 migrations'
} catch (e) {
return task.error('Migration failed')
}
})
.run()
// Verbose mode: logs every task.update() call
const tasks = this.ui.tasks({ verbose: this.flags.verbose })Stickers & Instructions
Stickers draw boxed content for important information. Instructions render arrow-prefixed steps.
// Boxed content (draws attention)
this.ui.sticker()
.add('Server started')
.add('')
.add(`Local: ${this.colors.cyan('http://localhost:3000')}`)
.add(`Network: ${this.colors.cyan('http://192.168.1.2:3000')}`)
.render()
// Step-by-step instructions (arrow prefixed)
this.ui.instructions()
.add('Run bun install')
.add('Copy .env.example to .env')
.add('Run tekir migrate')
.render()Colors are available on this.colors:
// Foreground colors
this.colors.red('error')
this.colors.green('success')
this.colors.yellow('warning')
this.colors.blue('info')
this.colors.cyan('link')
this.colors.magenta('special')
this.colors.gray('muted')
// Styles
this.colors.bold('important')
this.colors.dim('subtle')
this.colors.underline('link')
// Background colors
this.colors.bgRed(' ERROR ')
this.colors.bgGreen(' OK ')Auto-discovery
Commands in the commands/ directory are automatically discovered and registered at boot. Each file should export a default BaseCommand subclass. Command names with colons (e.g. send:email) are grouped in help output.
// commands/ directory is auto-discovered at boot.
// Any file that exports a BaseCommand subclass is registered.
// commands/
// send_email.ts → send:email
// queue_worker.ts → queue:worker
// db_reset.ts → db:reset
// You can also register manually in start/commands.ts:
import { SendEmailCommand } from '../commands/send_email'
export default [SendEmailCommand]