Skip to content

Writing your own console commands

Sooner or later every bot grows past the built-in make:* and sync commands and starts wanting its own project-specific tooling — a seed command that fills the database with fixtures, a rebuild-cache command you run on deploy, a cleanup-old-warnings command you kick off during maintenance windows. You could reach for npm scripts and some hastily written tsx entrypoints, but Warden gives you a much nicer option: write your own console commands using the exact same primitives the framework itself uses for make:command, warden sync, and create-@warden.

Drop a decorated class in src/console/ and the warden binary picks it up, parses arguments, wires up dependency injection, and dispatches into your handle() method with the same styled output, prompts, spinners, and error handling the framework itself uses. No subprocess boundary, no second entrypoint, no runtime surprises.

At its simplest, a console command is a class that extends ConsoleCommand, carries a @cli decorator, and implements handle():

src/console/HelloCommand.ts
import {cli, ConsoleCommand} from '@warden/console';
@cli({signature: 'hello {name}', description: 'Greet someone by name'})
export default class HelloCommand extends ConsoleCommand {
async handle(): Promise<void> {
const name = this.argument<string>('name');
this.info(`Hello, <highlight>${name}</highlight>!`);
}
}

Save the file, then:

Terminal window
pnpm warden hello Rick
# [info] Hello, Rick!

That’s the whole loop. The binary scans src/console/**/*.js at startup (once your TypeScript is compiled), registers every @cli-decorated class with Commander, and routes the parsed argv into your command. You can have as many of these as you like, and they live anywhere under src/console/.

The signature field in @cli is a single string that declares the command name, positional arguments, and options. The idea is that you should be able to look at any console command and instantly know the full shape of its inputs without scrolling past method bodies:

@cli({
signature: 'report {name} {date?} {--format=json} {--force|f}',
description: '...',
})

The supported tokens:

TokenMeaning
{name}Required positional argument
{name?}Optional positional
{name=default}Optional with default value
{name*}Variadic (consumes the rest of argv)
{--flag}Boolean flag (default false)
{--opt=}Option that requires a value
{--opt=default}Option with a default value
{--opt=*}Repeatable option (array)
{--f|flag}Short alias (either side can be long)

Inside handle() you read argument and option values via typed accessors on this:

const name = this.argument<string>('name');
const date = this.argument<string | undefined>('date');
const format = this.option<string>('format');
const force = this.option<boolean>('force');

Every method you’re likely to reach for lives on this — no separate injection or import boilerplate between you and the output you want to produce. Let’s walk through the surface.

The four leveled-output methods you’ll use daily:

this.info('Starting import...');
this.warn('No matching records found');
this.error('Failed to connect');
this.success('Imported 412 records');
this.debug('Verbose trace line'); // only shown with --verbose
this.line('Plain text, no prefix');
this.newline(2); // two blank lines

info, warn, success, and debug write to stdout; error writes to stderr. Each one carries a styled [info], [warn], [error], or prefix so your output is scannable even with a lot of it.

Every output method parses a small inline-tag language so you don’t have to thread colour helpers through template literals. Write what you want to mean, not what colour you want it to be:

this.info(`Wrote <path>dist/build.log</path>`);
this.warn(`Removing <em>stale</em> command: <cmd>/old-warn</cmd>`);
this.success(`Synced to guild <value>${guildId}</value>`);

The tag vocabulary is intentionally small:

TagUse for
<path>File or directory paths
<cmd>Command names (/warn, warden sync)
<value>Config values, IDs, env vars
<highlight>Counts or values the eye should catch
<em>Inline emphasis
<muted>Secondary / parenthetical info

Tags are stripped automatically when stdout isn’t a TTY — in CI logs or piped output, the same code produces clean plain text, no escape-code noise.

If you ever need a colour the semantic tag set doesn’t cover, the raw chalk instance is also exported as color:

import {color} from '@warden/console';
this.line(color.bgRed.white.bold(' DANGER '));

Reach for color sparingly — every time you do, you’re introducing a one-off style that won’t stay consistent with the rest of Warden’s output if the framework’s palette ever changes. The semantic tags are the expected path.

When your console command needs input — a project name, a confirmation, a pick from a list — Warden gives you a family of prompt helpers straight on this:

const name = await this.ask('Project name?');
const overwrite = await this.confirm('Overwrite existing files?', false);
const env = await this.choice('Which environment?', [
{name: 'Staging', value: 'staging'},
{name: 'Production', value: 'production'},
]);
const features = await this.multichoice('Enable features:', [
{name: 'Cache', value: 'cache'},
{name: 'i18n', value: 'i18n'},
]);
const secret = await this.secret('API key?');

Each of these does what its name suggests — confirmation, single choice, multi-select, free text, hidden secret. Under the hood they’re backed by @inquirer/prompts, but the adapter is swappable, which is what makes them scriptable in tests.

If stdin isn’t a TTY (CI environments, piped input), any prompt throws a clear error — unless --no-interaction was passed, in which case missing required arguments fail fast without ever attempting to prompt.

Spinners, progress bars, and structured output

Section titled “Spinners, progress bars, and structured output”

Long operations should feel alive, and the occasional table or diff makes dense output scannable. The helpers for all of this live on this:

// Spinner around an async operation — auto-cleans up on error
await this.withSpinner('Fetching records...', async () => {
return await api.fetchAll();
});
// Progress bar over a list of items
await this.withProgressBar(files, async (file, bar) => {
await process(file);
bar.advance();
});
// Pretty table
this.table([
{name: 'alice', score: 98},
{name: 'bob', score: 76},
]);
// Boxed callout
this.box('Notice', 'Run warden sync to deploy commands.');
// Unified diff of two strings
this.diff(oldConfig, newConfig);

The with* helpers are worth calling out specifically: they wrap an async operation and guarantee cleanup on both success and failure. If fn() throws, the spinner gets a red ✖ instead of a green ✓, the progress bar clears itself, and the error bubbles up naturally for the error-handling pipeline to catch. You don’t have to write the try/finally dance yourself.

To signal completion, failure, or invoke another console command:

this.fail('Config file missing'); // prints styled error, exits 1
this.fail('Quota exceeded', 3); // custom exit code
this.exit(0); // exit without logging
const code = await this.call('sync', { // invoke another @cli command in-process
options: {guild: '123'},
});

this.call() is the one worth lingering on. It invokes another console command by name in-process — no subprocess, no re-parsing argv. The invoked command shares the same DI container with the caller and streams its output into the same console, so nested output appears inline. If your deploy console command runs sync and then cache:warm and then logs “Deployed ✓”, that’s a clean three-step sequence with one set of spinners and one set of error handlers — not three subprocess invocations stitched together.

Every ConsoleCommand gets --force, --dry-run, --verbose, and --no-interaction registered automatically — you don’t declare them in your signature. Read them via:

if (this.isForce()) { /* skip confirmation prompts */ }
if (this.isDryRun()) { /* print plan, don't mutate */ }
if (this.isVerbose()) { /* extra detail */ }
if (this.isInteractive()) { /* safe to prompt */ }

Respecting these flags is how your console command feels like a native part of the framework. --force always means “skip the confirmation”, --dry-run always means “don’t actually do it”, and so on — whichever console command the user ran.

@cli registers your class as @injectable() automatically, so constructor injection works exactly like it does for slash commands and every other handler:

import {cli, ConsoleCommand} from '@warden/console';
import {Config, Logger} from '@warden/core';
@cli({signature: 'ping-api', description: 'Ping our external API'})
export default class PingApiCommand extends ConsoleCommand {
constructor(private config: Config, private logger: Logger) {
super();
}
async handle(): Promise<void> {
const url = this.config.getOrFail<string>('api.url');
this.logger.debug(`Pinging ${url}`);
// ...
}
}

Any service your bot already depends on — a database client, a cache, an HTTP client — is available in your console commands the same way it’s available in your slash command handlers.

Alongside the required handle(), ConsoleCommand supports three optional hooks for setup, teardown, and interruption:

HookWhen it runs
before()Before handle(). Good for setup that may fail fast.
after()After handle() returns successfully. Cleanup on the happy path.
cleanup()When the process receives SIGINT / SIGTERM during handle(). Restores terminal state, then the process exits 130.

A typical shape for a command that opens a connection and wants to roll back cleanly if the user hits Ctrl+C mid-run:

export default class ImportCommand extends ConsoleCommand {
private connection?: Connection;
async before(): Promise<void> {
this.connection = await openConnection();
}
async handle(): Promise<void> {
await this.connection!.import();
}
async after(): Promise<void> {
await this.connection?.close();
}
async cleanup(): Promise<void> {
// User hit Ctrl+C mid-import
await this.connection?.rollback();
}
}

You’ll reach for before and after whenever there’s setup and teardown you want to guarantee. cleanup is a little rarer, but absolutely invaluable for any command that mutates external state — give your users a clean Ctrl+C and they’ll thank you for it.

Putting it all together, here’s a seed command that populates a database from a JSON file. It exercises just about every primitive: dry-run, confirmation, a progress bar, dependency injection, and styled output.

src/console/SeedCommand.ts
import {cli, ConsoleCommand} from '@warden/console';
import {readFileSync} from 'node:fs';
import {DrizzleService} from '@warden/drizzle';
interface SeedRow {
id: string;
name: string;
}
@cli({
signature: 'seed {file=seeds/users.json} {--table=users}',
description: 'Seed the database from a JSON file',
})
export default class SeedCommand extends ConsoleCommand {
constructor(private db: DrizzleService) {
super();
}
async handle(): Promise<void> {
const file = this.argument<string>('file');
const table = this.option<string>('table') ?? 'users';
this.info(`Seeding <cmd>${table}</cmd> from <path>${file}</path>`);
const rows = JSON.parse(readFileSync(file, 'utf-8')) as SeedRow[];
this.info(`Found <highlight>${rows.length}</highlight> rows`);
if (this.isDryRun()) {
this.table(rows.slice(0, 5));
this.info('Dry-run — no inserts will be performed');
return;
}
if (!this.isForce() && !(await this.confirm(`Insert ${rows.length} rows into ${table}?`, false))) {
return this.fail('Cancelled');
}
await this.withProgressBar(rows, async (row, bar) => {
await this.db.client.insert(table, row);
bar.advance();
});
this.success(`Seeded <highlight>${rows.length}</highlight> rows into <cmd>${table}</cmd>`);
}
}

Running it against different environments feels natural:

Terminal window
pnpm warden seed seeds/users.json --table users
pnpm warden seed --dry-run
pnpm warden seed --force # skip confirmation for CI

There’s nothing special about this class — it’s a @cli class under src/console/, nothing more. But because it participates in every Warden convention, your users get a familiar experience whether they’re running make:command, sync, or this brand new seed you just wrote.

The warden binary scans three locations by default, in order:

  1. Its own bundled console commandsmake:*, sync, sync:clean ship with @warden/console.

  2. Framework and plugin packagesnode_modules/@warden/*/dist/commands/**/*.js. Plugins can contribute console commands by dropping decorated classes in a commands/ directory in their package. @warden/drizzle, for example, could ship a make:migration.

  3. Your projectsrc/console/**/*.js. This is where your own console commands live.

Because the binary runs after your TypeScript is compiled (unless you’ve wired up a runtime TS loader like tsx), console commands under src/console/ need to be in the build output. If your tsup / tsc config emits to dist/, src/console/Foo.ts becomes dist/console/Foo.js, and you adjust the scan pattern accordingly. The CLI entry point — typically src/cli.ts — wires this up via bot.console(...):

src/cli.ts
import 'dotenv/config';
import bot from './bootstrap';
await bot.console({
scan: ['./dist/console/**/*.js'], // if you compile first
});

bot.console() is installed on the Bot class as a side effect of importing @warden/console in bootstrap.ts. Behind the scenes it kernels the bot (boots plugins without Discord), scans your specified globs alongside node_modules/@warden/*/dist/commands/**/*.js automatically, and dispatches process.argv into the matched command.

Because console commands are “just classes”, they’re eminently unit-testable. @warden/console/testing gives you two things: a FakeConsole that captures output and scripts prompt answers, and a runConsoleCommand harness that drives a class through its real lifecycle without touching stdin or stdout. The flavour of the API is intentionally similar to TestBot and FakeInteraction that you’ll find on the Discord side:

import {describe, it, expect} from 'vitest';
import {FakeConsole, runConsoleCommand} from '@warden/console/testing';
import SeedCommand from '../../src/console/SeedCommand';
describe('SeedCommand', () => {
it('refuses to seed without confirmation', async () => {
const console = new FakeConsole();
console.prompts.answer('Insert 3 rows into users?', false);
const code = await runConsoleCommand(SeedCommand, {
args: {file: 'fixtures/users.json'},
console,
});
expect(code).toBe(1);
expect(console.output).toContain('Cancelled');
});
});

FakeConsole captures everything written (ANSI stripped) into output and lines, and it scripts prompt answers by their question text. See Testing for the broader picture across the whole framework.

Uncaught errors inside handle() flow through the same pipeline the built-in commands use. A default handler formats the error message, prints the stack when --verbose is set, and exits with code 1. That covers the common case, but if you want custom handling for a specific error class, drop a @cliErrorHandler class anywhere the scanner picks it up:

src/console/ImportErrorHandler.ts
import {cliErrorHandler} from '@warden/console';
import type {ConsoleErrorHandler, CliErrorContext} from '@warden/console';
import {ImportError} from '../errors/ImportError';
@cliErrorHandler({type: ImportError})
export default class ImportErrorHandler implements ConsoleErrorHandler<ImportError> {
async handle(error: ImportError, ctx: CliErrorContext): Promise<void> {
ctx.console.error(`<em>Import failed:</em> ${error.message}`);
ctx.console.line(` Source: <path>${error.source}</path>`);
ctx.console.line(` Row: ${error.row}`);
ctx.exit(2);
}
}

Handlers are matched by instanceof, so subclasses of the declared type are caught too. A handler registered with no type is the catch-all fallback — useful when you want a single place to turn every unhandled error into a Sentry event, for example.

handle() returning normally exits 0. Throwing an Error is caught by the error pipeline and exits 1 (or whatever the matched handler returns). To exit with a specific non-zero code intentionally:

this.exit(3); // bare exit
this.fail('Quota exceeded', 3); // styled error + exit 3
throw new CliExit(3); // equivalent to this.exit(3)

Sometimes you’ve got a console command that’s technically accessible but isn’t meant for day-to-day use — an internal maintenance task, an experimental feature, a destructive operation you’d rather not advertise. Pass hidden: true to keep it off the warden --help listing while leaving it perfectly runnable:

@cli({
signature: 'internal:rebuild-index',
description: 'Internal-only: rebuild the search index',
hidden: true,
})
export default class RebuildIndex extends ConsoleCommand {
async handle(): Promise<void> { /* ... */ }
}

If you’ve got a console command with a long name that you type a lot, give it a shorter alias:

@cli({
signature: 'deploy:prod',
description: 'Deploy to production',
aliases: ['deploy', 'ship'],
})

pnpm warden deploy:prod, pnpm warden deploy, and pnpm warden ship all route to the same class. The canonical name is still what shows up in --help, but any alias works on the command line. Useful for the commands you run ten times a day.