Custom CLI Command

Learn how to create custom CLI commands in Vercube using class-based decorators, register them in vercube.config.ts, and inject services via the DI container

Vercube CLI is built on top of citty but exposes a class-based decorator layer that lets you define commands as TypeScript classes. Custom commands are loaded at runtime via jiti - no build step required.

How Custom Commands Work

  1. Define a class extending BaseCommand and decorate it with @Command.
  2. Annotate properties with @Arg (positional) or @Flag (named option).
  3. Register the class in vercube.config.ts under cli.commands.
  4. Run vercube <name> - the CLI loads your config, resolves the class, injects dependencies, injects parsed args, and calls run().
// src/Commands/Deploy.ts
import { BaseCommand, Command, Flag } from '@vercube/cli/toolkit';

@Command({ name: 'deploy', description: 'Deploy to production' })
export class DeployCommand extends BaseCommand {
  @Flag({ name: 'env', description: 'Target environment', default: 'staging' })
  public env: string;

  public override async run(): Promise<void> {
    console.log(`Deploying to ${this.env}...`);
  }
}
// vercube.config.ts
import { defineConfig } from '@vercube/core';
import { DeployCommand } from './src/Commands/Deploy';

export default defineConfig({
  cli: {
    commands: [DeployCommand],
  },
});
vercube deploy --env production

BaseCommand

Every command must extend BaseCommand and implement run().

import { BaseCommand } from '@vercube/cli/toolkit';

export abstract class BaseCommand {
  protected container: Container; // CLI DI container - prefer @Inject instead

  public abstract run(): Promise<void>;
}

Dependency Injection

Commands are resolved through the CLI DI container, so any property decorated with @Inject is automatically wired before run() is called.

import { Inject } from '@vercube/di';
import { Logger } from '@vercube/logger';
import { BaseCommand, Command } from '@vercube/cli/toolkit';

@Command({ name: 'status', description: 'Show application status' })
export class StatusCommand extends BaseCommand {
  @Inject(Logger)
  private readonly gLogger: Logger;

  public override async run(): Promise<void> {
    this.gLogger.info('Application is running.');
  }
}

@Command

Class decorator that registers command metadata.

@Command(meta: CommandMeta)
FieldTypeRequiredDescription
namestringCommand name used on the CLI (vercube <name>)
descriptionstringShort description shown in --help
subCommands(new () => BaseCommand)[]-Child command classes (see Subcommands)

@Arg

Property decorator for positional arguments (order-sensitive, no -- prefix).

@Arg(options: ArgOptions)
FieldTypeRequiredDescription
namestringArgument name shown in usage (vercube cmd <name>)
descriptionstring-Short description shown in --help
requiredboolean-Whether the argument is required (default: false)
@Command({ name: 'greet', description: 'Print a greeting' })
export class GreetCommand extends BaseCommand {
  @Arg({ name: 'name', description: 'Name to greet', required: true })
  public name: string;

  public override async run(): Promise<void> {
    console.log(`Hello, ${this.name}!`);
  }
}

@Flag

Property decorator for named options (--flag-name value).

@Flag(options: FlagOptions)
FieldTypeRequiredDescription
namestringFlag name used on the CLI (--name)
descriptionstring-Short description shown in --help
defaultunknown-Default value - also used to infer type
requiredboolean-Whether the flag is required (default: false)
type'string' | 'boolean' | 'number'-Explicit citty value type (inferred from default when omitted)

The value type is automatically inferred from default:

@Flag({ name: 'limit', default: 10 })    // → type: 'number'
@Flag({ name: 'json',  default: false }) // → type: 'boolean'
@Flag({ name: 'env',   default: 'dev' }) // → type: 'string'

Full example with multiple flags:

@Command({ name: 'export', description: 'Export data' })
export class ExportCommand extends BaseCommand {
  @Flag({ name: 'format', description: 'Output format', default: 'json' })
  public format: string;

  @Flag({ name: 'limit', description: 'Max records', default: 100 })
  public limit: number;

  @Flag({ name: 'pretty', description: 'Pretty-print output', default: false })
  public pretty: boolean;

  public override async run(): Promise<void> {
    console.log(`Exporting ${this.limit} records as ${this.format} (pretty: ${this.pretty})`);
  }
}

Subcommands

Commands can be nested by passing subCommands to @Command. Each subcommand is an independent BaseCommand class.

// src/Commands/DbMigrate.ts
@Command({ name: 'migrate', description: 'Run pending migrations' })
export class DbMigrateCommand extends BaseCommand {
  @Flag({ name: 'dry-run', description: 'Preview without applying', default: false })
  public dryRun: boolean;

  public override async run(): Promise<void> {
    console.log(this.dryRun ? 'Dry run - no changes applied.' : 'Migrations applied.');
  }
}

// src/Commands/Db.ts
@Command({
  name: 'db',
  description: 'Database utilities',
  subCommands: [DbMigrateCommand, DbSeedCommand],
})
export class DbCommand extends BaseCommand {
  public override async run(): Promise<void> {}
}

Register only the top-level parent in cli.commands - subcommands are discovered automatically:

// vercube.config.ts
export default defineConfig({
  cli: {
    commands: [DbCommand], // DbMigrateCommand and DbSeedCommand are picked up automatically
  },
});

Usage:

vercube db --help
vercube db migrate
vercube db migrate --dry-run
vercube db seed --env test

Registration via plugins

Plugins can call ctx.register(MyCommand) from setupCLI (on a BasePlugin subclass) or from the cli hook on an object / defineVercubePlugin (syntax sugar for the same shape). The CLI uses the same loadVercubeConfig pipeline as the runtime, so commands registered from plugins appear next to those listed under cli.commands. See Plugins.

Registration

Register commands in vercube.config.ts under cli.commands, or from a plugin’s setupCLI / cli hook. The CLI loads this file using jiti at startup - TypeScript is transpiled on-the-fly, so no separate build step is needed for your command files.

// vercube.config.ts
import { defineConfig } from '@vercube/core';
import { DeployCommand } from './src/Commands/Deploy';
import { DbCommand } from './src/Commands/Db';

export default defineConfig({
  cli: {
    commands: [
      DeployCommand,
      DbCommand,      // registers DbCommand + all its subCommands
    ],
  },
});
Only register top-level commands. Subcommands declared in subCommands: [...] are discovered and registered automatically by the CLI.

Full Example

A working example is available in the repository under examples/cli-commands. It demonstrates:

  • A simple greet command with @Arg, @Flag, and @Inject
  • A db parent command with migrate and seed subcommands
  • Registering everything in vercube.config.ts
Previous

Plugins

Extend Vercube with config, runtime, CLI, and dev-only hooks - Vite-style plugins in vercube.config.ts