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/commands

Creating Commands

Generate a command with make:command:

tekir make:command SendEmail

Or create one manually. Extend BaseCommand, set commandName, and implement run(). Define arguments and flags as static properties:

commands/send_email.ts
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" -d

Every 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 run

Command 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 number

Array 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 true to 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]