LogoRobo.js

Extending the CLI

Add custom commands and extend existing ones with hooks.

Robo.js features an extensible CLI system that allows plugins and projects to add new commands and extend existing ones. Plugins can contribute their own subcommands to npx robo, and you can customize built-in commands with hooks.

Adding New Commands

Create commands by placing files in src/robo/cli/commands/. These become available as subcommands of npx robo.

File Structure

tunnel.tsnpx robo tunnel
index.tsnpx robo inspect
routes.tsnpx robo inspect routes
state.tsnpx robo inspect state

The file path determines the command name:

  • tunnel.tsnpx robo tunnel
  • inspect/routes.tsnpx robo inspect routes
  • inspect/index.tsnpx robo inspect (parent command)

Basic Command

// src/robo/cli/commands/hello.ts
export const config = {
  description: 'Say hello'
}

export default () => {
  console.log('Hello from my plugin!')
}
// src/robo/cli/commands/hello.mjs
export const config = {
  description: 'Say hello'
}

export default () => {
  console.log('Hello from my plugin!')
}

Command with Options

// src/robo/cli/commands/tunnel.ts
import { createCliCommandConfig, type CliContext } from 'robo.js/cli.js'

export const config = createCliCommandConfig({
  description: 'Start a tunnel to expose your local server',
  options: [
    { alias: '-p', name: '--port', description: 'Port to tunnel', type: 'number', default: 3000 },
    { alias: '-s', name: '--subdomain', description: 'Custom subdomain', type: 'string' }
  ]
} as const)

export default (ctx: CliContext<typeof config>) => {
  const { port, subdomain } = ctx.options
  console.log(`Starting tunnel on port ${port}...`)
  if (subdomain) {
    console.log(`Using subdomain: ${subdomain}`)
  }
}
// src/robo/cli/commands/tunnel.mjs
import { createCliCommandConfig } from 'robo.js/cli.js'

export const config = createCliCommandConfig({
  description: 'Start a tunnel to expose your local server',
  options: [
    { alias: '-p', name: '--port', description: 'Port to tunnel', type: 'number', default: 3000 },
    { alias: '-s', name: '--subdomain', description: 'Custom subdomain', type: 'string' }
  ]
})

export default (ctx) => {
  const { port, subdomain } = ctx.options
  console.log(`Starting tunnel on port ${port}...`)
  if (subdomain) {
    console.log(`Using subdomain: ${subdomain}`)
  }
}

Command Context

Handlers receive a context object with:

PropertyTypeDescription
argsstring[]Positional arguments
optionsobjectParsed option values
loggerLoggerLogger instance (forked for plugins)
cwdstringCurrent working directory
argvstring[]Raw arguments after command
resultunknownCommand result (in after hooks)

Type-Safe Configuration

Use createCliCommandConfig with as const for full TypeScript inference:

// src/robo/cli/commands/serve.ts
import { createCliCommandConfig, type CliContext } from 'robo.js/cli.js'

export const config = createCliCommandConfig({
  description: 'Start development server',
  options: [
{ alias: '-p', name: '--port', description: 'Port', type: 'number', default: 3000 },
{ alias: '-h', name: '--host', description: 'Host', type: 'string' },
{ alias: '-v', name: '--verbose', description: 'Verbose', type: 'boolean', required: true }
  ]
} as const)

export default (ctx: CliContext<typeof config>) => {
  // TypeScript knows:
  ctx.options.port    // number (has default)
  ctx.options.host    // string | undefined (optional)
  ctx.options.verbose // boolean (required)
}
// src/robo/cli/commands/serve.mjs
import { createCliCommandConfig } from 'robo.js/cli.js'

export const config = createCliCommandConfig({
  description: 'Start development server',
  options: [
{ alias: '-p', name: '--port', description: 'Port', type: 'number', default: 3000 },
{ alias: '-h', name: '--host', description: 'Host', type: 'string' },
{ alias: '-v', name: '--verbose', description: 'Verbose', type: 'boolean', required: true }
  ]
})

export default (ctx) => {
  ctx.options.port    // number (has default)
  ctx.options.host    // string | undefined (optional)
  ctx.options.verbose // boolean (required)
}

Note: The as const assertion is required for TypeScript to infer literal types from your config.

Option Types

Options support three types with automatic parsing:

TypeParsed AsExample
'string'string--name foo'foo'
'boolean'boolean--verbosetrue
'number'number--port 30003000

Option Properties

{
  alias: '-p',           // Short flag
  name: '--port',        // Long flag (used as key in ctx.options)
  description: 'Port',   // Help text
  type: 'number',        // Value type
  default: 3000,         // Default value (makes option non-undefined)
  required: true         // Error if not provided
}
{
  alias: '-p',           // Short flag
  name: '--port',        // Long flag (used in ctx.options)
  description: 'Port',   // Help text
  type: 'number',        // Value type
  default: 3000,         // Default value (makes option non-undefined)
  required: true         // Error if not provided
}

Extending Existing Commands

Extensions let you add options and hooks to existing commands. Place them in src/robo/cli/extend/.

Extension File Naming

The filename (minus extension) determines which command is extended:

FileExtends
extend/dev.tsrobo dev
extend/build.tsrobo build
extend/cloud/status.tsrobo cloud status
dev.tsExtends robo dev
build.tsExtends robo build
status.tsExtends robo cloud status

Use nested folders to extend subcommands.

Adding Options

// src/robo/cli/extend/dev.ts
// Adds --tunnel option to `robo dev`

export const config = {
  description: 'Tunnel extension for dev command',
  options: [
{ alias: '-t', name: '--tunnel', description: 'Enable tunneling', type: 'boolean' }
  ]
}
// src/robo/cli/extend/dev.mjs
// Adds --tunnel option to `robo dev`

export const config = {
  description: 'Tunnel extension for dev command',
  options: [
{ alias: '-t', name: '--tunnel', description: 'Enable tunneling', type: 'boolean' }
  ]
}

Before Hooks

Before hooks run before the command handler. Return false to abort execution:

// src/robo/cli/extend/build.ts
import type { CliContext } from 'robo.js/cli.js'

export const config = {
  description: 'Pre-build validation'
}

export async function before(ctx: CliContext) {
  console.log('Running pre-build checks...')

  const isValid = await validateProject()
  if (!isValid) {
    console.error('Build blocked: validation failed')
    return false  // Aborts the command
  }

  return true  // Continue with command
}
// src/robo/cli/extend/build.mjs

export const config = {
  description: 'Pre-build validation'
}

export async function before(ctx) {
  console.log('Running pre-build checks...')

  const isValid = await validateProject()
  if (!isValid) {
    console.error('Build blocked: validation failed')
    return false  // Aborts the command
  }

  return true  // Continue with command
}

After Hooks

After hooks run after the command completes. Access the result via ctx.result:

// src/robo/cli/extend/build.ts
import type { CliContext } from 'robo.js/cli.js'

export const config = {
  description: 'Post-build notifications'
}

export async function after(ctx: CliContext) {
  console.log('Build completed:', ctx.result)
  await sendNotification('Build finished!')
}
// src/robo/cli/extend/build.mjs

export const config = {
  description: 'Post-build notifications'
}

export async function after(ctx) {
  console.log('Build completed:', ctx.result)
  await sendNotification('Build finished!')
}

Combined Example

// src/robo/cli/extend/dev.ts
import type { CliContext } from 'robo.js/cli.js'

export const config = {
  description: 'Enhanced dev mode',
  priority: 10,
  options: [
{ alias: '-t', name: '--tunnel', description: 'Enable tunnel', type: 'boolean' }
  ]
}

export async function before(ctx: CliContext) {
  if (ctx.options.tunnel) {
console.log('Tunnel mode enabled')
  }
  return true
}

export async function after(ctx: CliContext) {
  if (ctx.options.tunnel) {
// Start tunnel after dev server is ready
await startTunnel()
  }
}
// src/robo/cli/extend/dev.mjs

export const config = {
  description: 'Enhanced dev mode',
  priority: 10,
  options: [
{ alias: '-t', name: '--tunnel', description: 'Enable tunnel', type: 'boolean' }
  ]
}

export async function before(ctx) {
  if (ctx.options.tunnel) {
console.log('Tunnel mode enabled')
  }
  return true
}

export async function after(ctx) {
  if (ctx.options.tunnel) {
// Start tunnel after dev server is ready
await startTunnel()
  }
}

Priority Resolution

When multiple sources define the same command or option, priority determines which wins:

export const config = {
  description: 'My command',
  priority: 10  // Higher priority wins (default: 0)
}
export const config = {
  description: 'My command',
  priority: 10  // Higher priority wins (default: 0)
}

Priority applies to:

  • Command definitions (higher priority command takes precedence)
  • Option conflicts (higher priority option value used)
  • Hook execution order (higher priority hooks run first)

Plugin Commands

Plugins can contribute commands the same way. When a plugin is installed, its CLI commands become available:

// src/robo/cli/commands/my-plugin/status.ts
// Available as: npx robo my-plugin status

export const config = {
  description: 'Check plugin status'
}

export default () => {
  console.log('Plugin is running!')
}
// src/robo/cli/commands/my-plugin/status.mjs
// Available as: npx robo my-plugin status

export const config = {
  description: 'Check plugin status'
}

export default () => {
  console.log('Plugin is running!')
}

Interactive Terminal Commands

Terminal commands are a separate system from CLI commands. They provide /-prefixed commands inside the interactive terminal (available during robo dev and robo start).

Place files in src/robo/terminal/commands/ instead of src/robo/cli/commands/:

FeatureCLI CommandsTerminal Commands
Locationsrc/robo/cli/commands/src/robo/terminal/commands/
Invocationnpx robo <command>/<command> in interactive terminal
Config helpercreateCliCommandConfigcreateTerminalCommandConfig
Context typeCliContextTerminalContext
Import fromrobo.js/cli.jsrobo.js

Terminal commands receive a TerminalContext with access to runtime (state and Flashcore), config, and a write() function for output. See the Interactive Terminal page for full documentation and examples.

Priority Details

When multiple sources define the same command, priority determines which one wins. Project-level commands automatically receive a +100 priority boost over plugin commands, ensuring your local commands always take precedence.

The priority field in your config is additive — it stacks on top of any automatic boost. For example, a project command with priority: 10 has an effective priority of 110, while a plugin command with priority: 10 has an effective priority of 10.

This applies to both CLI commands (src/robo/cli/commands/) and terminal commands (src/robo/terminal/commands/).

CLI Manifest

During robo build, all CLI commands, extensions, and terminal commands are indexed into a manifest at .robo/manifest/cli/@.json. This allows the CLI to load commands efficiently at startup without scanning the filesystem.

If the manifest is missing (e.g., running without a build), the CLI falls back to runtime discovery by scanning directories directly.

Use robo cli --inspect to view the contents of this manifest — it shows all registered commands, their sources, options, and which plugin provides each one.

Inspecting CLI Commands

Use robo cli --inspect to see all registered commands and their sources:

npx robo cli --inspect

This shows:

  • All available commands
  • Which plugin provides each command
  • Registered extensions and hooks
  • Option configurations

Examples

Subcommand Hierarchy

// src/robo/cli/commands/db/index.ts
export const config = { description: 'Database commands' }
export default () => {
  console.log('Use: db migrate, db seed, or db reset')
}
// src/robo/cli/commands/db/index.mjs
export const config = { description: 'Database commands' }
export default () => {
  console.log('Use: db migrate, db seed, or db reset')
}
// src/robo/cli/commands/db/migrate.ts
export const config = { description: 'Run migrations' }
export default async () => {
  await runMigrations()
}
// src/robo/cli/commands/db/migrate.mjs
export const config = { description: 'Run migrations' }
export default async () => {
  await runMigrations()
}
// src/robo/cli/commands/db/seed.ts
export const config = { description: 'Seed database' }
export default async () => {
  await seedDatabase()
}
// src/robo/cli/commands/db/seed.mjs
export const config = { description: 'Seed database' }
export default async () => {
  await seedDatabase()
}

Extension with Abort Logic

// src/robo/cli/extend/build.ts
export const config = {
  description: 'Build safety checks'
}

export async function before(ctx) {
  const hasChanges = await checkGitStatus()
  if (hasChanges) {
    console.error('Error: Uncommitted changes detected')
    console.error('Please commit or stash changes before building')
    return false
  }

  if (!ctx.options.dev) {
    const branch = await getCurrentBranch()
    if (branch !== 'main') {
      console.warn(`Warning: Building from ${branch}, not main`)
    }
  }

  return true
}
// src/robo/cli/extend/build.mjs
export const config = {
  description: 'Build safety checks'
}

export async function before(ctx) {
  const hasChanges = await checkGitStatus()
  if (hasChanges) {
    console.error('Error: Uncommitted changes detected')
    console.error('Please commit or stash changes before building')
    return false
  }

  if (!ctx.options.dev) {
    const branch = await getCurrentBranch()
    if (branch !== 'main') {
      console.warn(`Warning: Building from ${branch}, not main`)
    }
  }

  return true
}

Next Steps

On this page