Make commands
Introduction
Section titled “Introduction”Spinning up a new slash command, event handler, or middleware class from scratch is the kind of thing you’d forget the exact shape of after a couple of weeks away from the code. You’d open another file, copy it, paste it, rename the class, tweak the decorator, and hope you didn’t miss an import. Warden takes that chore away from you with a family of make:* generators that hand you a working file with the correct decorator, imports, and boilerplate — one class per file, one purpose per class, exactly the way the rest of the framework is organised.
Every generator is available through the warden binary, which ships with @warden/console. If you scaffolded your project with create-@warden it’s already installed; if you set things up by hand, a quick pnpm add -D @warden/console gets you going.
pnpm warden make:command WarnThat’s all it takes to stub a brand new slash command. Your new file appears at src/commands/WarnCommand.ts, wired up and ready for you to start filling in the actual behaviour.
Available generators
Section titled “Available generators”You’ll find a generator for every handler type the framework supports:
| Console command | What it generates |
|---|---|
make:command | Slash command class |
make:event | Event handler class |
make:middleware | Middleware class |
make:button | Button interaction handler |
make:modal | Modal submission handler |
make:menu | Select menu handler |
make:context | Context menu command (user or message) |
make:error | Error handler class |
make:job | Cron job class |
make:decompose | Custom event decomposer |
We’ll walk through each in turn, but before we do there are a handful of flags every generator shares — worth knowing up front.
Universal options
Section titled “Universal options”No matter which generator you reach for, the following flags are always available:
| Flag | Effect |
|---|---|
-f, --force | Overwrite an existing file without prompting |
--dry-run | Print what would be created without touching disk |
-v, --verbose | Show the rendered file contents in dry-run output |
--no-interaction | Fail instead of prompting when required input is missing |
If you ever forget what a particular generator accepts, --help shows you the full signature:
pnpm warden make:command --helpmake:command
Section titled “make:command”Slash commands are the bread and butter of most Discord bots, so make:command is the generator you’ll reach for most often. Give it a class name and it takes care of the rest:
pnpm warden make:command WarnOpen the newly created src/commands/WarnCommand.ts and you’ll see:
import {command, reply} from '@warden/core';import type {Command, Params} from '@warden/core';import type {ChatInputCommandInteraction} from 'discord.js';
@command({name: 'warn', description: 'A command'})export default class WarnCommand implements Command { public async execute(interaction: ChatInputCommandInteraction, params: Params): Promise<void> { await reply(interaction, 'Hello from warn!'); }}There are a couple of things worth calling out. First, the class name you pass in is normalised to PascalCase and the Command suffix is appended automatically if you didn’t already include it — so warn, Warn, and WarnCommand all produce the same file. Second, the slash command’s name: in the decorator is derived by stripping that suffix and kebab-casing the rest, which gives you warn instead of warn-command. ModStatsCommand becomes mod-stats, and so on. It’s the kind of polish you wouldn’t think to add yourself but will appreciate every day.
If you’d rather set the description up front instead of editing the decorator afterwards, pass --description:
pnpm warden make:command Warn --description "Warn a user"make:event
Section titled “make:event”Most bots react to something happening in Discord — a member joining, a role being added, a message being flagged. Each of those lives in its own event handler. Running make:event gives you a class wired up to listen:
pnpm warden make:event MemberJoinLog --event guildMemberAddWhich produces src/events/MemberJoinLogEvent.ts:
import {event, Logger} from '@warden/core';import type {Event} from '@warden/core';
@event({name: 'MemberJoinLogEvent', event: 'guildMemberAdd'})export default class MemberJoinLogEvent implements Event<'guildMemberAdd'> { constructor(private logger: Logger) {}
public async execute(): Promise<void> { this.logger.info('MemberJoinLogEvent fired'); }}Notice that the Logger is already injected into the constructor — because every generated handler is @injectable, you can add new dependencies just by listing them as constructor parameters and Warden will wire everything up. No extra imports, no registration step.
The --event flag defaults to ready if you leave it off — handy when you’re stubbing something quickly and plan to change the event afterwards, or when you genuinely are listening to ready.
make:middleware
Section titled “make:middleware”Middleware is where you put the cross-cutting checks and setup that run before your slash commands: “is this user a moderator?”, “is this guild in the database?”, “has this member cooled down yet?”. Running make:middleware gives you a typed middleware class ready to slot into your command’s middleware option:
pnpm warden make:middleware IsModeratorThe generated file at src/middleware/IsModerator.ts:
import {Middleware, injectable} from '@warden/core';import type {NextFn} from '@warden/core';import type {ChatInputCommandInteraction} from 'discord.js';
@injectable()export default class IsModerator extends Middleware<ChatInputCommandInteraction> { public async handle( ctx: ChatInputCommandInteraction, next: NextFn<ChatInputCommandInteraction>, ): Promise<void> { await next(ctx); }}The generator defaults to ChatInputCommandInteraction as the type parameter since that’s what you’ll want most of the time. If you’re writing middleware for button interactions or modal submissions instead, just swap the generic and the ctx type, and you’re off.
make:button
Section titled “make:button”Every button in a Discord embed needs a handler on the backend. Warden routes them by custom ID, and make:button gives you a new handler class with a sensible custom ID already in place:
pnpm warden make:button ConfirmBanProduces src/buttons/ConfirmBanButton.ts:
import {button} from '@warden/core';import type {Button, Params} from '@warden/core';import type {ButtonInteraction} from 'discord.js';
@button({customId: 'confirm-ban'})export default class ConfirmBanButton implements Button { public async execute(interaction: ButtonInteraction, params: Params): Promise<void> { await interaction.reply({content: 'Button clicked!', ephemeral: true}); }}By default the custom ID is derived from the class name, but if you want a parameterised ID so you can carry state (like a user or case ID) through to the handler, pass --customId:
pnpm warden make:button ConfirmBan --customId "confirm-ban:{userId}"Those {userId} placeholders are captured into params at runtime — see Components for the full story.
make:modal
Section titled “make:modal”Modals collect structured input from users, and — like buttons — they route by custom ID. make:modal gives you a handler class ready to consume modal submissions:
pnpm warden make:modal WarnReasonGenerated at src/modals/WarnReasonModal.ts:
import {modal} from '@warden/core';import type {Modal, Params} from '@warden/core';import type {ModalSubmitInteraction} from 'discord.js';
@modal({customId: 'warn-reason'})export default class WarnReasonModal implements Modal { public async execute(interaction: ModalSubmitInteraction, params: Params): Promise<void> { await interaction.reply({content: 'Modal submitted!', ephemeral: true}); }}As with make:button, you can override the custom ID with --customId — useful when you want to carry context through from the button that opened the modal.
make:menu
Section titled “make:menu”Select menus — string selects, role selects, channel selects — all route through the same @menu() decorator. Running make:menu gives you a handler for whichever kind you’re building:
pnpm warden make:menu ModActionProduces src/menus/ModActionMenu.ts:
import {menu} from '@warden/core';import type {SelectMenu, Params} from '@warden/core';import type {AnySelectMenuInteraction} from 'discord.js';
@menu({customId: 'mod-action'})export default class ModActionMenu implements SelectMenu { public async execute(interaction: AnySelectMenuInteraction, params: Params): Promise<void> { await interaction.reply({content: 'Selection made!', ephemeral: true}); }}The stub types interaction as AnySelectMenuInteraction so the same class handles whichever select variant you’re building. Narrow to StringSelectMenuInteraction or RoleSelectMenuInteraction once you know which kind you’re receiving.
make:context
Section titled “make:context”Context menu commands are the ones users invoke by right-clicking a user or a message, and they come in exactly those two flavours. Because Warden needs to know which flavour you’re building, the generator takes a --type flag:
pnpm warden make:context WarnUser --type userpnpm warden make:context FlagMessage --type messageIf you leave --type off, it defaults to user. A make:context WarnUser --type user invocation produces src/context/WarnUserContext.ts:
import {context} from '@warden/core';import type {ContextMenu, Params} from '@warden/core';import type {UserContextMenuCommandInteraction} from 'discord.js';
@context({name: 'WarnUser', type: 'user'})export default class WarnUserContext implements ContextMenu { public async execute(interaction: UserContextMenuCommandInteraction, params: Params): Promise<void> { const target = interaction.targetUser; await interaction.reply(`Targeted ${target.username}`); }}Context menu entries show up in Discord’s UI with whatever you put in name, so you’ll often want something more human-readable than the class name. Pass --contextName "Warn User" to the generator to set that up front, or edit the decorator afterwards.
make:error
Section titled “make:error”When something in a handler throws, Warden routes the error through a pipeline of error handlers. make:error stubs out a new one for you:
pnpm warden make:error CommandGenerated at src/errors/CommandHandler.ts:
import {errorHandler, ErrorHandler, Logger} from '@warden/core';import type {ErrorContext} from '@warden/core';
@errorHandler()export default class CommandHandler extends ErrorHandler { constructor(private logger: Logger) { super(); }
public async handle(error: Error, context: ErrorContext): Promise<void> { this.logger.error(`Error in ${context.type} "${context.name}": ${error.message}`); }}By default the generator produces a catch-all handler — one that sees every unhandled error from every handler type. That’s usually the right starting point. If you want to scope this handler to just slash commands, or just buttons, or just jobs, add a type parameter to the decorator once the file is generated:
@errorHandler({type: 'command'})You can have as many error handlers as you like, and Warden dispatches to whichever one matches the handler type most specifically. Head over to Error handling if you want the full picture.
make:job
Section titled “make:job”Need something to run every minute, every hour, or at the stroke of midnight? That’s a job. Running make:job gives you a class scheduled on a sensible default cadence:
pnpm warden make:job CleanExpiredWarningsWhich lands at src/jobs/CleanExpiredWarningsJob.ts:
import {job, every, Logger} from '@warden/core';import type {Job} from '@warden/core';
@job({name: 'CleanExpiredWarningsJob', schedule: every.minute()})export default class CleanExpiredWarningsJob implements Job { constructor(private logger: Logger) {}
public async execute(): Promise<void> { this.logger.info('CleanExpiredWarningsJob executed'); }}The schedule defaults to every.minute() because that’s the most useful thing to have running when you’re iterating. When you’re ready to commit to the real cadence, swap in every.hour(), every.day(), or a raw cron expression like '0 0 * * *'. See Jobs for the shortlist of helpers and what they expand to.
make:decompose
Section titled “make:decompose”Discord’s gateway emits fairly coarse events — messageUpdate fires for any change at all, guildMemberUpdate for anything from a nickname change to a timeout to a role being added. Decomposers let you turn one raw Discord event into several named ones that match what you’re actually trying to handle. make:decompose stubs a new one:
pnpm warden make:decompose BannedLinkEdit --source messageUpdate --emit messageBannedLinkEditYou get src/events/decompositions/BannedLinkEdit.ts:
import {decompose} from '@warden/core';import type {GuildMember} from 'discord.js';
@decompose({source: 'messageUpdate', emit: 'messageBannedLinkEdit'})export default class BannedLinkEdit { public test(oldMember: GuildMember, newMember: GuildMember): boolean { return false; }
public extract(oldMember: GuildMember, newMember: GuildMember): [GuildMember] { return [newMember]; }}--source defaults to guildMemberUpdate because that’s the most commonly decomposed event, and --emit defaults to the kebab-cased class name. The stub types arguments as GuildMember for the same reason — if you’re decomposing a different source event, swap the types in test() and extract() to match what that event gives you. The Event decomposition guide walks through the full pattern.
Previewing with dry-run
Section titled “Previewing with dry-run”Sometimes you want to see what a generator will produce before committing it to disk — maybe to sanity-check the class name normalisation, or just to remind yourself of the stub’s shape. Every generator supports --dry-run:
pnpm warden make:command Warn --dry-run# [info] Would create src/commands/WarnCommand.tsPair it with --verbose if you’d like to see the whole rendered file, not just the path:
pnpm warden make:command Warn --dry-run --verboseNothing is written to disk either way, so --dry-run is completely safe to run repeatedly while you’re figuring out the right class name.
Overwriting existing files
Section titled “Overwriting existing files”If the target file already exists, the generator plays it safe: in an interactive terminal it asks you whether you want to overwrite, and in a non-interactive one (like CI) it refuses and exits with a non-zero code. When you know what you’re doing and want to skip the prompt — which you usually will when you’re regenerating a file you’ve been editing — pass --force:
pnpm warden make:command Warn --force