diff --git a/.github/workflows/code-quality.yaml b/.github/workflows/code-quality.yaml index 8f6bafee..6158aad3 100644 --- a/.github/workflows/code-quality.yaml +++ b/.github/workflows/code-quality.yaml @@ -30,5 +30,11 @@ jobs: - name: Check TypeScript Types run: pnpm dlx turbo check-types + - name: Run CommandKit Command Handler Tests + run: pnpm test:commandkit + + - name: Check Generated API Docs + run: pnpm docgen:check + - name: Check Prettier Formatting run: pnpm prettier:check diff --git a/AGENTS.md b/AGENTS.md index eb40842d..6286b509 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,14 @@ preserve convention-based discovery: - Config file at root: `commandkit.config.ts` (or `.js`) - App entrypoint: `src/app.ts` (exports discord.js client) - Commands: `src/app/commands/**` +- Hierarchical Discovery Tokens: + - `[command]` - top-level command directory + - `{group}` - subcommand group directory + - `(category)` - organizational category directory + - `command.ts` / `group.ts` - definition files + - `.subcommand.ts` - subcommand shorthand - Events: `src/app/events/**` + - Optional feature paths: - i18n: `src/app/locales/**` - tasks: `src/app/tasks/**` diff --git a/apps/test-bot/src/app/commands/(general)/(animal)/cat.ts b/apps/test-bot/src/app/commands/(general)/(animal)/cat.ts index fab7f0f9..3053b417 100644 --- a/apps/test-bot/src/app/commands/(general)/(animal)/cat.ts +++ b/apps/test-bot/src/app/commands/(general)/(animal)/cat.ts @@ -28,6 +28,7 @@ export const command: CommandData = { export const metadata: CommandMetadata = { nameAliases: { message: 'Cat Message', + user: 'Cat User', }, }; diff --git a/apps/test-bot/src/app/commands/(general)/help.ts b/apps/test-bot/src/app/commands/(general)/help.ts index 54e0e531..3f12233f 100644 --- a/apps/test-bot/src/app/commands/(general)/help.ts +++ b/apps/test-bot/src/app/commands/(general)/help.ts @@ -1,5 +1,6 @@ import { CommandData, ChatInputCommand } from 'commandkit'; import { redEmbedColor } from '@/feature-flags/red-embed-color'; +import { toMessageRoute, toSlashRoute } from '@/utils/hierarchical-demo'; import { Colors } from 'discord.js'; export const command: CommandData = { @@ -34,11 +35,37 @@ export const chatInput: ChatInputCommand = async (ctx) => { }) .join('\n'); + const hierarchicalRoutes = ctx.commandkit.commandHandler + .getRuntimeCommandsArray() + .map((command) => { + return ( + (command.data.command as Record).__routeKey ?? + command.data.command.name + ); + }) + .filter((route) => route.includes('.')) + .sort() + .map((route) => `${toSlashRoute(route)} | ${toMessageRoute(route)}`) + .join('\n'); + return interaction.editReply({ embeds: [ { title: 'Help', description: commands, + fields: hierarchicalRoutes + ? [ + { + name: 'Hierarchical Routes', + value: hierarchicalRoutes, + }, + { + name: 'Hierarchical Middleware', + value: + 'Global middleware always runs first. Hierarchical leaves then use only the current directory `+middleware` and any same-directory `+.middleware`.', + }, + ] + : undefined, footer: { text: `Bot Version: ${botVersion} | Shard ID ${interaction.guild?.shardId ?? 'N/A'}`, }, diff --git a/apps/test-bot/src/app/commands/(hierarchical)/+middleware.ts b/apps/test-bot/src/app/commands/(hierarchical)/+middleware.ts new file mode 100644 index 00000000..e2344bbf --- /dev/null +++ b/apps/test-bot/src/app/commands/(hierarchical)/+middleware.ts @@ -0,0 +1,6 @@ +import { MiddlewareContext } from 'commandkit'; +import { recordHierarchyStage } from '@/utils/hierarchical-demo'; + +export function beforeExecute(ctx: MiddlewareContext) { + recordHierarchyStage(ctx, 'category:(hierarchical)'); +} diff --git a/apps/test-bot/src/app/commands/(hierarchical)/[ops]/+middleware.ts b/apps/test-bot/src/app/commands/(hierarchical)/[ops]/+middleware.ts new file mode 100644 index 00000000..4c18f2f0 --- /dev/null +++ b/apps/test-bot/src/app/commands/(hierarchical)/[ops]/+middleware.ts @@ -0,0 +1,6 @@ +import { MiddlewareContext } from 'commandkit'; +import { recordHierarchyStage } from '@/utils/hierarchical-demo'; + +export function beforeExecute(ctx: MiddlewareContext) { + recordHierarchyStage(ctx, 'root:ops'); +} diff --git a/apps/test-bot/src/app/commands/(hierarchical)/[ops]/+status.middleware.ts b/apps/test-bot/src/app/commands/(hierarchical)/[ops]/+status.middleware.ts new file mode 100644 index 00000000..6dc7db0b --- /dev/null +++ b/apps/test-bot/src/app/commands/(hierarchical)/[ops]/+status.middleware.ts @@ -0,0 +1,6 @@ +import { MiddlewareContext } from 'commandkit'; +import { recordHierarchyStage } from '@/utils/hierarchical-demo'; + +export function beforeExecute(ctx: MiddlewareContext) { + recordHierarchyStage(ctx, 'command:status'); +} diff --git a/apps/test-bot/src/app/commands/(hierarchical)/[ops]/command.ts b/apps/test-bot/src/app/commands/(hierarchical)/[ops]/command.ts new file mode 100644 index 00000000..b95ea4b8 --- /dev/null +++ b/apps/test-bot/src/app/commands/(hierarchical)/[ops]/command.ts @@ -0,0 +1,7 @@ +import { CommandData } from 'commandkit'; + +export const command: CommandData = { + name: 'ops', + description: + 'Direct-subcommand hierarchical root. Try /ops status or /ops deploy.', +}; diff --git a/apps/test-bot/src/app/commands/(hierarchical)/[ops]/deploy/+middleware.ts b/apps/test-bot/src/app/commands/(hierarchical)/[ops]/deploy/+middleware.ts new file mode 100644 index 00000000..b462f909 --- /dev/null +++ b/apps/test-bot/src/app/commands/(hierarchical)/[ops]/deploy/+middleware.ts @@ -0,0 +1,6 @@ +import { MiddlewareContext } from 'commandkit'; +import { recordHierarchyStage } from '@/utils/hierarchical-demo'; + +export function beforeExecute(ctx: MiddlewareContext) { + recordHierarchyStage(ctx, 'leaf-dir:deploy'); +} diff --git a/apps/test-bot/src/app/commands/(hierarchical)/[ops]/deploy/deploy.subcommand.ts b/apps/test-bot/src/app/commands/(hierarchical)/[ops]/deploy/deploy.subcommand.ts new file mode 100644 index 00000000..9c364317 --- /dev/null +++ b/apps/test-bot/src/app/commands/(hierarchical)/[ops]/deploy/deploy.subcommand.ts @@ -0,0 +1,47 @@ +import { ApplicationCommandOptionType } from 'discord.js'; +import { + ChatInputCommandContext, + CommandData, + MessageCommandContext, +} from 'commandkit'; +import { replyWithHierarchyDemo } from '@/utils/hierarchical-demo'; + +export const command: CommandData = { + name: 'deploy', + description: 'Run a folder-based direct subcommand under the root.', + options: [ + { + name: 'environment', + description: 'Where the deployment should go', + type: ApplicationCommandOptionType.String, + required: false, + choices: [ + { name: 'staging', value: 'staging' }, + { name: 'production', value: 'production' }, + ], + }, + { + name: 'dry_run', + description: 'Whether to simulate the deployment', + type: ApplicationCommandOptionType.Boolean, + required: false, + }, + ], +}; + +async function execute(ctx: ChatInputCommandContext | MessageCommandContext) { + const environment = ctx.options.getString('environment') ?? 'staging'; + const dryRun = ctx.options.getBoolean('dry_run') ?? true; + + return replyWithHierarchyDemo(ctx, { + title: 'Ops Deploy', + shape: 'root command -> direct subcommand', + leafStyle: 'folder leaf ([deploy]/command.ts)', + summary: + 'Shows a direct folder-based subcommand with middleware scoped only to the leaf directory, plus the same prefix route syntax.', + details: [`environment: ${environment}`, `dry_run: ${dryRun}`], + }); +} + +export const chatInput = execute; +export const message = execute; diff --git a/apps/test-bot/src/app/commands/(hierarchical)/[ops]/status.subcommand.ts b/apps/test-bot/src/app/commands/(hierarchical)/[ops]/status.subcommand.ts new file mode 100644 index 00000000..1d841738 --- /dev/null +++ b/apps/test-bot/src/app/commands/(hierarchical)/[ops]/status.subcommand.ts @@ -0,0 +1,41 @@ +import { ApplicationCommandOptionType } from 'discord.js'; +import { + ChatInputCommandContext, + CommandData, + MessageCommandContext, +} from 'commandkit'; +import { replyWithHierarchyDemo } from '@/utils/hierarchical-demo'; + +export const command: CommandData = { + name: 'status', + description: 'Inspect a direct shorthand subcommand under the root.', + options: [ + { + name: 'scope', + description: 'Which subsystem to inspect', + type: ApplicationCommandOptionType.String, + required: false, + choices: [ + { name: 'bot', value: 'bot' }, + { name: 'database', value: 'database' }, + { name: 'workers', value: 'workers' }, + ], + }, + ], +}; + +async function execute(ctx: ChatInputCommandContext | MessageCommandContext) { + const scope = ctx.options.getString('scope') ?? 'bot'; + + return replyWithHierarchyDemo(ctx, { + title: 'Ops Status', + shape: 'root command -> direct subcommand', + leafStyle: 'shorthand file (status.subcommand.ts)', + summary: + 'Shows a direct subcommand branch without groups, where the leaf shares the root command directory and therefore uses that directory middleware plus same-directory command middleware.', + details: [`scope: ${scope}`], + }); +} + +export const chatInput = execute; +export const message = execute; diff --git a/apps/test-bot/src/app/commands/(hierarchical)/[workspace]/+middleware.ts b/apps/test-bot/src/app/commands/(hierarchical)/[workspace]/+middleware.ts new file mode 100644 index 00000000..61bfbc08 --- /dev/null +++ b/apps/test-bot/src/app/commands/(hierarchical)/[workspace]/+middleware.ts @@ -0,0 +1,6 @@ +import { MiddlewareContext } from 'commandkit'; +import { recordHierarchyStage } from '@/utils/hierarchical-demo'; + +export function beforeExecute(ctx: MiddlewareContext) { + recordHierarchyStage(ctx, 'root:workspace'); +} diff --git a/apps/test-bot/src/app/commands/(hierarchical)/[workspace]/command.ts b/apps/test-bot/src/app/commands/(hierarchical)/[workspace]/command.ts new file mode 100644 index 00000000..649aa2e4 --- /dev/null +++ b/apps/test-bot/src/app/commands/(hierarchical)/[workspace]/command.ts @@ -0,0 +1,7 @@ +import { CommandData } from 'commandkit'; + +export const command: CommandData = { + name: 'workspace', + description: + 'Grouped hierarchical root. Try /workspace notes add or /workspace team handoff.', +}; diff --git a/apps/test-bot/src/app/commands/(hierarchical)/[workspace]/{notes}/+add.middleware.ts b/apps/test-bot/src/app/commands/(hierarchical)/[workspace]/{notes}/+add.middleware.ts new file mode 100644 index 00000000..86f94469 --- /dev/null +++ b/apps/test-bot/src/app/commands/(hierarchical)/[workspace]/{notes}/+add.middleware.ts @@ -0,0 +1,6 @@ +import { MiddlewareContext } from 'commandkit'; +import { recordHierarchyStage } from '@/utils/hierarchical-demo'; + +export function beforeExecute(ctx: MiddlewareContext) { + recordHierarchyStage(ctx, 'command:add'); +} diff --git a/apps/test-bot/src/app/commands/(hierarchical)/[workspace]/{notes}/+middleware.ts b/apps/test-bot/src/app/commands/(hierarchical)/[workspace]/{notes}/+middleware.ts new file mode 100644 index 00000000..25a89fb7 --- /dev/null +++ b/apps/test-bot/src/app/commands/(hierarchical)/[workspace]/{notes}/+middleware.ts @@ -0,0 +1,6 @@ +import { MiddlewareContext } from 'commandkit'; +import { recordHierarchyStage } from '@/utils/hierarchical-demo'; + +export function beforeExecute(ctx: MiddlewareContext) { + recordHierarchyStage(ctx, 'group:notes'); +} diff --git a/apps/test-bot/src/app/commands/(hierarchical)/[workspace]/{notes}/add.subcommand.ts b/apps/test-bot/src/app/commands/(hierarchical)/[workspace]/{notes}/add.subcommand.ts new file mode 100644 index 00000000..de0e9aad --- /dev/null +++ b/apps/test-bot/src/app/commands/(hierarchical)/[workspace]/{notes}/add.subcommand.ts @@ -0,0 +1,43 @@ +import { ApplicationCommandOptionType } from 'discord.js'; +import { + ChatInputCommandContext, + CommandData, + MessageCommandContext, +} from 'commandkit'; +import { replyWithHierarchyDemo } from '@/utils/hierarchical-demo'; + +export const command: CommandData = { + name: 'add', + description: 'Create a note using a grouped shorthand subcommand.', + options: [ + { + name: 'title', + description: 'Title for the note', + type: ApplicationCommandOptionType.String, + required: false, + }, + { + name: 'topic', + description: 'Topic bucket for the note', + type: ApplicationCommandOptionType.String, + required: false, + }, + ], +}; + +async function execute(ctx: ChatInputCommandContext | MessageCommandContext) { + const title = ctx.options.getString('title') ?? 'untitled'; + const topic = ctx.options.getString('topic') ?? 'general'; + + return replyWithHierarchyDemo(ctx, { + title: 'Workspace Notes Add', + shape: 'root command -> group -> subcommand', + leafStyle: 'shorthand file (add.subcommand.ts)', + summary: + 'Shows a grouped shorthand leaf that uses only middleware from the current group directory, including same-directory command middleware.', + details: [`title: ${title}`, `topic: ${topic}`], + }); +} + +export const chatInput = execute; +export const message = execute; diff --git a/apps/test-bot/src/app/commands/(hierarchical)/[workspace]/{notes}/archive/+middleware.ts b/apps/test-bot/src/app/commands/(hierarchical)/[workspace]/{notes}/archive/+middleware.ts new file mode 100644 index 00000000..e83fda53 --- /dev/null +++ b/apps/test-bot/src/app/commands/(hierarchical)/[workspace]/{notes}/archive/+middleware.ts @@ -0,0 +1,6 @@ +import { MiddlewareContext } from 'commandkit'; +import { recordHierarchyStage } from '@/utils/hierarchical-demo'; + +export function beforeExecute(ctx: MiddlewareContext) { + recordHierarchyStage(ctx, 'leaf-dir:archive'); +} diff --git a/apps/test-bot/src/app/commands/(hierarchical)/[workspace]/{notes}/archive/archive.subcommand.ts b/apps/test-bot/src/app/commands/(hierarchical)/[workspace]/{notes}/archive/archive.subcommand.ts new file mode 100644 index 00000000..fc703e7f --- /dev/null +++ b/apps/test-bot/src/app/commands/(hierarchical)/[workspace]/{notes}/archive/archive.subcommand.ts @@ -0,0 +1,43 @@ +import { ApplicationCommandOptionType } from 'discord.js'; +import { + ChatInputCommandContext, + CommandData, + MessageCommandContext, +} from 'commandkit'; +import { replyWithHierarchyDemo } from '@/utils/hierarchical-demo'; + +export const command: CommandData = { + name: 'archive', + description: 'Archive a note using a folder-based grouped subcommand.', + options: [ + { + name: 'note', + description: 'The note name to archive', + type: ApplicationCommandOptionType.String, + required: false, + }, + { + name: 'reason', + description: 'Why the note is being archived', + type: ApplicationCommandOptionType.String, + required: false, + }, + ], +}; + +async function execute(ctx: ChatInputCommandContext | MessageCommandContext) { + const note = ctx.options.getString('note') ?? 'sprint-plan'; + const reason = ctx.options.getString('reason') ?? 'cleanup'; + + return replyWithHierarchyDemo(ctx, { + title: 'Workspace Notes Archive', + shape: 'root command -> group -> subcommand', + leafStyle: 'folder leaf ([archive]/command.ts)', + summary: + 'Shows a grouped leaf discovered from a nested command directory with middleware scoped only to that leaf directory.', + details: [`note: ${note}`, `reason: ${reason}`], + }); +} + +export const chatInput = execute; +export const message = execute; diff --git a/apps/test-bot/src/app/commands/(hierarchical)/[workspace]/{notes}/group.ts b/apps/test-bot/src/app/commands/(hierarchical)/[workspace]/{notes}/group.ts new file mode 100644 index 00000000..92c50be1 --- /dev/null +++ b/apps/test-bot/src/app/commands/(hierarchical)/[workspace]/{notes}/group.ts @@ -0,0 +1,6 @@ +import { CommandData } from 'commandkit'; + +export const command: CommandData = { + name: 'notes', + description: 'Workspace note operations grouped under a root command.', +}; diff --git a/apps/test-bot/src/app/commands/(hierarchical)/[workspace]/{team}/+middleware.ts b/apps/test-bot/src/app/commands/(hierarchical)/[workspace]/{team}/+middleware.ts new file mode 100644 index 00000000..cfbbcfb8 --- /dev/null +++ b/apps/test-bot/src/app/commands/(hierarchical)/[workspace]/{team}/+middleware.ts @@ -0,0 +1,6 @@ +import { MiddlewareContext } from 'commandkit'; +import { recordHierarchyStage } from '@/utils/hierarchical-demo'; + +export function beforeExecute(ctx: MiddlewareContext) { + recordHierarchyStage(ctx, 'group:team'); +} diff --git a/apps/test-bot/src/app/commands/(hierarchical)/[workspace]/{team}/group.ts b/apps/test-bot/src/app/commands/(hierarchical)/[workspace]/{team}/group.ts new file mode 100644 index 00000000..9fab85ad --- /dev/null +++ b/apps/test-bot/src/app/commands/(hierarchical)/[workspace]/{team}/group.ts @@ -0,0 +1,6 @@ +import { CommandData } from 'commandkit'; + +export const command: CommandData = { + name: 'team', + description: 'Team-oriented subcommands under the workspace root.', +}; diff --git a/apps/test-bot/src/app/commands/(hierarchical)/[workspace]/{team}/handoff.subcommand.ts b/apps/test-bot/src/app/commands/(hierarchical)/[workspace]/{team}/handoff.subcommand.ts new file mode 100644 index 00000000..c64423db --- /dev/null +++ b/apps/test-bot/src/app/commands/(hierarchical)/[workspace]/{team}/handoff.subcommand.ts @@ -0,0 +1,43 @@ +import { ApplicationCommandOptionType } from 'discord.js'; +import { + ChatInputCommandContext, + CommandData, + MessageCommandContext, +} from 'commandkit'; +import { replyWithHierarchyDemo } from '@/utils/hierarchical-demo'; + +export const command: CommandData = { + name: 'handoff', + description: 'Hand work over to a teammate from a second group branch.', + options: [ + { + name: 'owner', + description: 'Who is taking over the work', + type: ApplicationCommandOptionType.String, + required: false, + }, + { + name: 'project', + description: 'Which project is being handed off', + type: ApplicationCommandOptionType.String, + required: false, + }, + ], +}; + +async function execute(ctx: ChatInputCommandContext | MessageCommandContext) { + const owner = ctx.options.getString('owner') ?? 'alex'; + const project = ctx.options.getString('project') ?? 'migration'; + + return replyWithHierarchyDemo(ctx, { + title: 'Workspace Team Handoff', + shape: 'root command -> sibling group -> subcommand', + leafStyle: 'shorthand file (handoff.subcommand.ts)', + summary: + 'Shows that one root can host multiple groups while each leaf still resolves through the same route index and only uses middleware from its own group directory.', + details: [`owner: ${owner}`, `project: ${project}`], + }); +} + +export const chatInput = execute; +export const message = execute; diff --git a/apps/test-bot/src/utils/hierarchical-demo.ts b/apps/test-bot/src/utils/hierarchical-demo.ts new file mode 100644 index 00000000..e4a511a4 --- /dev/null +++ b/apps/test-bot/src/utils/hierarchical-demo.ts @@ -0,0 +1,85 @@ +import { + ChatInputCommandContext, + Logger, + MessageCommandContext, +} from 'commandkit'; + +const TRACE_KEY = 'hierarchical-demo.trace'; + +type StoreShape = { + get(key: string): unknown; + set(key: string, value: unknown): unknown; +}; + +type TraceContext = { + commandName: string; + store: StoreShape; +}; + +export interface HierarchyReplyOptions { + title: string; + shape: string; + leafStyle: string; + summary: string; + details?: string[]; +} + +export function recordHierarchyStage(ctx: TraceContext, stage: string) { + const trace = getHierarchyTrace(ctx); + trace.push(stage); + ctx.store.set(TRACE_KEY, trace); + Logger.info(`[hierarchy demo] ${stage} -> ${ctx.commandName}`); +} + +export function getHierarchyTrace(ctx: { store: StoreShape }) { + const value = ctx.store.get(TRACE_KEY); + + return Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === 'string') + : []; +} + +export function toSlashRoute(route: string) { + return `/${route.replace(/\./g, ' ')}`; +} + +export function toMessageRoute(route: string) { + return `!${route.replace(/\./g, ':')}`; +} + +type HierarchyContext = ChatInputCommandContext | MessageCommandContext; + +export async function replyWithHierarchyDemo( + ctx: HierarchyContext, + options: HierarchyReplyOptions, +) { + const lines = [ + `**${options.title}**`, + `Route: ${ctx.commandName}`, + `Slash: ${toSlashRoute(ctx.commandName)}`, + `Prefix: ${toMessageRoute(ctx.commandName)}`, + `Invoked Root: ${ctx.invokedCommandName}`, + `Execution: ${ctx.isMessage() ? 'message command' : 'chat input command'}`, + `Middleware Scope: global -> current directory -> command-specific`, + `Middleware Trace: ${getHierarchyTrace(ctx).join(' -> ') || '(none)'}`, + `Shape: ${options.shape}`, + `Leaf Style: ${options.leafStyle}`, + `Summary: ${options.summary}`, + ]; + + if (ctx.isMessage()) { + lines.push(`Raw Args: ${ctx.args().join(' ') || '(none)'}`); + } + + for (const detail of options.details ?? []) { + lines.push(`- ${detail}`); + } + + const content = lines.join('\n'); + + if (ctx.isMessage()) { + return ctx.message.reply(content); + } + + return ctx.interaction.reply({ content }); +} diff --git a/apps/website/docs/api-reference/cache/functions/cache.mdx b/apps/website/docs/api-reference/cache/functions/cache.mdx index fb38ab1f..53dbfc0c 100644 --- a/apps/website/docs/api-reference/cache/functions/cache.mdx +++ b/apps/website/docs/api-reference/cache/functions/cache.mdx @@ -18,14 +18,14 @@ import MemberDescription from '@site/src/components/MemberDescription'; The cache plugin for CommandKit. ```ts title="Signature" -function cache(options?: Partial<{ - compiler: CommonDirectiveTransformerOptions; - runtime: Record; +function cache(options?: Partial<{ + compiler: CommonDirectiveTransformerOptions; + runtime: Record; }>): CommandKitPlugin[] ``` Parameters ### options -CommonDirectiveTransformerOptions; runtime: Record<never, never>; }>`} /> +CommonDirectiveTransformerOptions; runtime: Record<never, never>; }>`} /> diff --git a/apps/website/docs/api-reference/cache/interfaces/cache-context.mdx b/apps/website/docs/api-reference/cache/interfaces/cache-context.mdx index 96a5492f..0139d405 100644 --- a/apps/website/docs/api-reference/cache/interfaces/cache-context.mdx +++ b/apps/website/docs/api-reference/cache/interfaces/cache-context.mdx @@ -19,12 +19,12 @@ Context for managing cache operations within an async scope ```ts title="Signature" interface CacheContext { - params: { - /** Custom name for the cache entry */ - name?: string; - /** Time-to-live in milliseconds */ - ttl?: number | null; - tags: Set; + params: { + /** Custom name for the cache entry */ + name?: string; + /** Time-to-live in milliseconds */ + ttl?: number | null; + tags: Set; }; } ``` @@ -33,7 +33,7 @@ interface CacheContext { ### params -cache entry */ name?: string; /** Time-to-live in milliseconds */ ttl?: number | null; tags: Set<string>; }`} /> +cache entry */ name?: string; /** Time-to-live in milliseconds */ ttl?: number | null; tags: Set<string>; }`} /> diff --git a/apps/website/docs/api-reference/commandkit/classes/app-command-handler.mdx b/apps/website/docs/api-reference/commandkit/classes/app-command-handler.mdx index eb676424..63cb2ca9 100644 --- a/apps/website/docs/api-reference/commandkit/classes/app-command-handler.mdx +++ b/apps/website/docs/api-reference/commandkit/classes/app-command-handler.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## AppCommandHandler - + Handles application commands for CommandKit, including loading, registration, and execution. Manages both slash commands and message commands with middleware support. @@ -21,21 +21,25 @@ Manages both slash commands and message commands with middleware support. ```ts title="Signature" class AppCommandHandler { public readonly registrar: CommandRegistrar; - public readonly commandRunner = new AppCommandRunner(this); - public readonly externalCommandData = new Collection(); - public readonly externalMiddlewareData = new Collection(); + public readonly commandRunner: AppCommandRunner = new AppCommandRunner(this); + public readonly externalCommandData: Collection = + new Collection(); + public readonly externalMiddlewareData: Collection = + new Collection(); constructor(commandkit: CommandKit) - printBanner() => ; - getCommandsArray() => ; - registerCommandHandler() => ; + printBanner() => void; + getCommandsArray() => LoadedCommand[]; + getRuntimeCommandsArray() => LoadedCommand[]; + getHierarchicalNodesArray() => LoadedCommand[]; + registerCommandHandler() => void; prepareCommandRun(source: Interaction | Message, cmdName?: string) => Promise; resolveMessageCommandName(name: string) => string; - reloadCommands() => ; - addExternalMiddleware(data: Middleware[]) => ; - addExternalCommands(data: Command[]) => ; - registerExternalLoadedMiddleware(data: LoadedMiddleware[]) => ; - registerExternalLoadedCommands(data: LoadedCommand[]) => ; - loadCommands() => ; + reloadCommands() => Promise; + addExternalMiddleware(data: Middleware[]) => Promise; + addExternalCommands(data: Command[]) => Promise; + registerExternalLoadedMiddleware(data: LoadedMiddleware[]) => Promise; + registerExternalLoadedCommands(data: LoadedCommand[]) => Promise; + loadCommands() => Promise; getMetadataFor(command: string, hint?: 'user' | 'message') => CommandMetadata | null; } ``` @@ -49,17 +53,17 @@ class AppCommandHandler { Command registrar for handling Discord API registration. ### commandRunner - +AppCommandRunner`} /> Command runner instance for executing commands. ### externalCommandData - +Command>`} /> External command data storage. ### externalMiddlewareData - +Middleware>`} /> External middleware data storage. ### constructor @@ -69,17 +73,27 @@ External middleware data storage. Creates a new AppCommandHandler instance. ### printBanner - `} /> + void`} /> Prints a formatted banner showing all loaded commands organized by category. ### getCommandsArray - `} /> + LoadedCommand[]`} /> Gets an array of all loaded commands, including pre-generated context menu entries. +### getRuntimeCommandsArray + + LoadedCommand[]`} /> + +Gets all executable runtime routes, including hierarchical leaves. +### getHierarchicalNodesArray + + LoadedCommand[]`} /> + +Gets loaded hierarchical command nodes, including non-executable containers. ### registerCommandHandler - `} /> + void`} /> Registers event handlers for Discord interactions and messages. ### prepareCommandRun @@ -94,32 +108,32 @@ Prepares a command for execution by resolving the command and its middleware. ### reloadCommands - `} /> + Promise<void>`} /> Reloads all commands and middleware from scratch. ### addExternalMiddleware -Middleware[]) => `} /> +Middleware[]) => Promise<void>`} /> Adds external middleware data to be loaded. ### addExternalCommands -Command[]) => `} /> +Command[]) => Promise<void>`} /> Adds external command data to be loaded. ### registerExternalLoadedMiddleware - `} /> + Promise<void>`} /> Registers externally loaded middleware. ### registerExternalLoadedCommands -LoadedCommand[]) => `} /> +LoadedCommand[]) => Promise<void>`} /> Registers externally loaded commands. ### loadCommands - `} /> + Promise<void>`} /> Loads all commands and middleware from the router. ### getMetadataFor diff --git a/apps/website/docs/api-reference/commandkit/classes/command-registrar.mdx b/apps/website/docs/api-reference/commandkit/classes/command-registrar.mdx index 27c129c6..f6f7831f 100644 --- a/apps/website/docs/api-reference/commandkit/classes/command-registrar.mdx +++ b/apps/website/docs/api-reference/commandkit/classes/command-registrar.mdx @@ -13,26 +13,17 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## CommandRegistrar - + Handles registration of Discord application commands (slash commands, context menus). ```ts title="Signature" class CommandRegistrar { constructor(commandkit: CommandKit) - getCommandsData() => (CommandData & { - __metadata?: CommandMetadata; - __applyId(id: string): void; - })[]; + getCommandsData() => RegistrationCommandData[]; register() => ; - updateGlobalCommands(commands: (CommandData & { - __metadata?: CommandMetadata; - __applyId(id: string): void; - })[]) => ; - updateGuildCommands(commands: (CommandData & { - __metadata?: CommandMetadata; - __applyId(id: string): void; - })[]) => ; + updateGlobalCommands(commands: RegistrationCommandData[]) => ; + updateGuildCommands(commands: RegistrationCommandData[]) => ; } ``` @@ -45,7 +36,7 @@ class CommandRegistrar { Creates an instance of CommandRegistrar. ### getCommandsData - (CommandData & { __metadata?: CommandMetadata; __applyId(id: string): void; })[]`} /> + RegistrationCommandData[]`} /> Gets the commands data, consuming pre-generated context menu entries when available. ### register @@ -55,12 +46,12 @@ Gets the commands data, consuming pre-generated context menu entries when availa Registers loaded commands. ### updateGlobalCommands -CommandData & { __metadata?: CommandMetadata; __applyId(id: string): void; })[]) => `} /> + `} /> Updates the global commands. ### updateGuildCommands -CommandData & { __metadata?: CommandMetadata; __applyId(id: string): void; })[]) => `} /> + `} /> Updates the guild commands. diff --git a/apps/website/docs/api-reference/commandkit/classes/commands-router.mdx b/apps/website/docs/api-reference/commandkit/classes/commands-router.mdx index 895c9bec..6303314b 100644 --- a/apps/website/docs/api-reference/commandkit/classes/commands-router.mdx +++ b/apps/website/docs/api-reference/commandkit/classes/commands-router.mdx @@ -13,19 +13,30 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## CommandsRouter - + Handles discovery and parsing of command and middleware files in the filesystem. ```ts title="Signature" class CommandsRouter { constructor(options: CommandsRouterOptions) - populate(data: ParsedCommandData) => ; + populate(data: ParsedCommandData) => void; isValidPath() => boolean; - clear() => ; - scan() => ; - getData() => ; - toJSON() => ; + clear() => void; + scan() => Promise; + getData() => { + commands: Collection; + middlewares: Collection; + treeNodes: Collection; + compiledRoutes: Collection; + diagnostics: CommandRouteDiagnostic[]; + }; + getTreeData() => { + treeNodes: Collection; + compiledRoutes: Collection; + diagnostics: CommandRouteDiagnostic[]; + }; + toJSON() => ParsedCommandData; } ``` @@ -38,9 +49,9 @@ class CommandsRouter { Creates a new CommandsRouter instance. ### populate -ParsedCommandData) => `} /> +ParsedCommandData) => void`} /> -Populates the router with existing command and middleware data. +Populates the router with existing command, middleware, and tree data. ### isValidPath boolean`} /> @@ -48,22 +59,27 @@ Populates the router with existing command and middleware data. Checks if the configured entrypoint path exists. ### clear - `} /> + void`} /> -Clears all loaded commands and middleware. +Clears all loaded commands, middleware, and compiled tree data. ### scan - `} /> + Promise<ParsedCommandData>`} /> Scans the filesystem for commands and middleware files. ### getData - `} /> + { commands: Collection<string, Command>; middlewares: Collection<string, Middleware>; treeNodes: Collection<string, CommandTreeNode>; compiledRoutes: Collection<string, CompiledCommandRoute>; diagnostics: CommandRouteDiagnostic[]; }`} /> -Gets the raw command and middleware collections. +Gets the raw command, middleware, and compiled tree collections. +### getTreeData + + { treeNodes: Collection<string, CommandTreeNode>; compiledRoutes: Collection<string, CompiledCommandRoute>; diagnostics: CommandRouteDiagnostic[]; }`} /> + +Gets only the internal command tree and compiled route data. ### toJSON - `} /> + ParsedCommandData`} /> Converts the loaded data to a JSON-serializable format. diff --git a/apps/website/docs/api-reference/commandkit/classes/message-command-options.mdx b/apps/website/docs/api-reference/commandkit/classes/message-command-options.mdx index 9b166702..830b2228 100644 --- a/apps/website/docs/api-reference/commandkit/classes/message-command-options.mdx +++ b/apps/website/docs/api-reference/commandkit/classes/message-command-options.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## MessageCommandOptions - + Provides typed access to message command options with methods similar to Discord.js interaction options. diff --git a/apps/website/docs/api-reference/commandkit/classes/message-command-parser.mdx b/apps/website/docs/api-reference/commandkit/classes/message-command-parser.mdx index b27c5227..aae12481 100644 --- a/apps/website/docs/api-reference/commandkit/classes/message-command-parser.mdx +++ b/apps/website/docs/api-reference/commandkit/classes/message-command-parser.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## MessageCommandParser - + Parses message content into structured command data with options and subcommands. @@ -24,6 +24,7 @@ class MessageCommandParser { options: void getOption(name: string) => T | undefined; getCommand() => string; + getFullRoute() => string[]; getSubcommand() => string | undefined; getSubcommandGroup() => string | undefined; getPrefix() => ; @@ -59,6 +60,11 @@ Gets a specific option value by name. string`} /> Gets the main command name. +### getFullRoute + + string[]`} /> + +Gets the full command route as an array of segments. ### getSubcommand string | undefined`} /> diff --git a/apps/website/docs/api-reference/commandkit/classes/middleware-context.mdx b/apps/website/docs/api-reference/commandkit/classes/middleware-context.mdx index 397124db..675e26c0 100644 --- a/apps/website/docs/api-reference/commandkit/classes/middleware-context.mdx +++ b/apps/website/docs/api-reference/commandkit/classes/middleware-context.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## MiddlewareContext - + Extended context class for middleware execution with additional control methods. diff --git a/apps/website/docs/api-reference/commandkit/functions/build-only.mdx b/apps/website/docs/api-reference/commandkit/functions/build-only.mdx index 300448f7..40b1c259 100644 --- a/apps/website/docs/api-reference/commandkit/functions/build-only.mdx +++ b/apps/website/docs/api-reference/commandkit/functions/build-only.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## buildOnly - + Creates a function from the given function that runs only in build mode. diff --git a/apps/website/docs/api-reference/commandkit/functions/create-proxy.mdx b/apps/website/docs/api-reference/commandkit/functions/create-proxy.mdx index 08206009..42a3776d 100644 --- a/apps/website/docs/api-reference/commandkit/functions/create-proxy.mdx +++ b/apps/website/docs/api-reference/commandkit/functions/create-proxy.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## createProxy - + Creates a simple proxy object that mirrors the target object. diff --git a/apps/website/docs/api-reference/commandkit/functions/defer.mdx b/apps/website/docs/api-reference/commandkit/functions/defer.mdx index 0b8d627d..8594b6dc 100644 --- a/apps/website/docs/api-reference/commandkit/functions/defer.mdx +++ b/apps/website/docs/api-reference/commandkit/functions/defer.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## defer - + Defers the execution of a function. diff --git a/apps/website/docs/api-reference/commandkit/functions/dev-only.mdx b/apps/website/docs/api-reference/commandkit/functions/dev-only.mdx index c3c9bf65..6e9fae52 100644 --- a/apps/website/docs/api-reference/commandkit/functions/dev-only.mdx +++ b/apps/website/docs/api-reference/commandkit/functions/dev-only.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## devOnly - + Creates a function from the given function that runs only in development mode. diff --git a/apps/website/docs/api-reference/commandkit/functions/json-serialize.mdx b/apps/website/docs/api-reference/commandkit/functions/json-serialize.mdx index 69780f82..a3c27eea 100644 --- a/apps/website/docs/api-reference/commandkit/functions/json-serialize.mdx +++ b/apps/website/docs/api-reference/commandkit/functions/json-serialize.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## JsonSerialize - + Serializes a value to JSON. diff --git a/apps/website/docs/api-reference/commandkit/functions/no-build-only.mdx b/apps/website/docs/api-reference/commandkit/functions/no-build-only.mdx index 2c9af655..b40ce2b2 100644 --- a/apps/website/docs/api-reference/commandkit/functions/no-build-only.mdx +++ b/apps/website/docs/api-reference/commandkit/functions/no-build-only.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## noBuildOnly - + Creates a function from the given function that runs only outside of build mode. diff --git a/apps/website/docs/api-reference/commandkit/interfaces/app-command-native.mdx b/apps/website/docs/api-reference/commandkit/interfaces/app-command-native.mdx index c8eae3ff..75a310a0 100644 --- a/apps/website/docs/api-reference/commandkit/interfaces/app-command-native.mdx +++ b/apps/website/docs/api-reference/commandkit/interfaces/app-command-native.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## AppCommandNative - + Represents a native command structure used in CommandKit. This structure includes the command definition and various handlers for different interaction types. diff --git a/apps/website/docs/api-reference/commandkit/interfaces/command-route-diagnostic.mdx b/apps/website/docs/api-reference/commandkit/interfaces/command-route-diagnostic.mdx new file mode 100644 index 00000000..cc62950a --- /dev/null +++ b/apps/website/docs/api-reference/commandkit/interfaces/command-route-diagnostic.mdx @@ -0,0 +1,48 @@ +--- +title: "CommandRouteDiagnostic" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## CommandRouteDiagnostic + + + +Validation or compilation diagnostic emitted while building the +command tree. + +```ts title="Signature" +interface CommandRouteDiagnostic { + code: string; + message: string; + path: string; +} +``` + +
+ +### code + + + + +### message + + + + +### path + + + + + + +
diff --git a/apps/website/docs/api-reference/commandkit/interfaces/command-tree-node.mdx b/apps/website/docs/api-reference/commandkit/interfaces/command-tree-node.mdx new file mode 100644 index 00000000..654776f9 --- /dev/null +++ b/apps/website/docs/api-reference/commandkit/interfaces/command-tree-node.mdx @@ -0,0 +1,108 @@ +--- +title: "CommandTreeNode" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## CommandTreeNode + + + +Internal tree node representing either a filesystem command or a +hierarchical container. + +```ts title="Signature" +interface CommandTreeNode { + id: string; + source: CommandTreeNodeSource; + kind: CommandTreeNodeKind; + token: string; + route: string[]; + category: string | null; + parentId: string | null; + childIds: string[]; + directoryPath: string; + definitionPath: string | null; + relativePath: string; + shorthand: boolean; + executable: boolean; +} +``` + +
+ +### id + + + + +### source + +CommandTreeNodeSource`} /> + + +### kind + +CommandTreeNodeKind`} /> + + +### token + + + + +### route + + + + +### category + + + + +### parentId + + + + +### childIds + + + + +### directoryPath + + + + +### definitionPath + + + + +### relativePath + + + + +### shorthand + + + + +### executable + + + + + + +
diff --git a/apps/website/docs/api-reference/commandkit/interfaces/command.mdx b/apps/website/docs/api-reference/commandkit/interfaces/command.mdx index e45adbaf..05edd9dc 100644 --- a/apps/website/docs/api-reference/commandkit/interfaces/command.mdx +++ b/apps/website/docs/api-reference/commandkit/interfaces/command.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## Command - + Represents a command with its metadata and middleware associations. diff --git a/apps/website/docs/api-reference/commandkit/interfaces/commands-router-options.mdx b/apps/website/docs/api-reference/commandkit/interfaces/commands-router-options.mdx index ea33e499..9230e0bb 100644 --- a/apps/website/docs/api-reference/commandkit/interfaces/commands-router-options.mdx +++ b/apps/website/docs/api-reference/commandkit/interfaces/commands-router-options.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## CommandsRouterOptions - + Configuration options for the commands router. diff --git a/apps/website/docs/api-reference/commandkit/interfaces/compiled-command-route.mdx b/apps/website/docs/api-reference/commandkit/interfaces/compiled-command-route.mdx new file mode 100644 index 00000000..b9ab4ee1 --- /dev/null +++ b/apps/website/docs/api-reference/commandkit/interfaces/compiled-command-route.mdx @@ -0,0 +1,89 @@ +--- +title: "CompiledCommandRoute" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## CompiledCommandRoute + + + +Executable command route produced from the internal tree. + +```ts title="Signature" +interface CompiledCommandRoute { + id: string; + key: string; + kind: Exclude; + token: string; + route: string[]; + category: string | null; + definitionPath: string; + relativePath: string; + nodeId: string; + middlewares: string[]; +} +``` + +
+ +### id + + + + +### key + + + + +### kind + +CommandTreeNodeKind, 'root' | 'group'>`} /> + + +### token + + + + +### route + + + + +### category + + + + +### definitionPath + + + + +### relativePath + + + + +### nodeId + + + + +### middlewares + + + + + + +
diff --git a/apps/website/docs/api-reference/commandkit/interfaces/custom-app-command-props.mdx b/apps/website/docs/api-reference/commandkit/interfaces/custom-app-command-props.mdx index 4bd185dc..7b59396d 100644 --- a/apps/website/docs/api-reference/commandkit/interfaces/custom-app-command-props.mdx +++ b/apps/website/docs/api-reference/commandkit/interfaces/custom-app-command-props.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## CustomAppCommandProps - + Custom properties that can be added to an AppCommand. This allows for additional metadata or configuration to be associated with a command. diff --git a/apps/website/docs/api-reference/commandkit/interfaces/loaded-command.mdx b/apps/website/docs/api-reference/commandkit/interfaces/loaded-command.mdx index 588d84f6..46845bff 100644 --- a/apps/website/docs/api-reference/commandkit/interfaces/loaded-command.mdx +++ b/apps/website/docs/api-reference/commandkit/interfaces/loaded-command.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## LoadedCommand - + Represents a loaded command with its metadata and configuration. diff --git a/apps/website/docs/api-reference/commandkit/interfaces/middleware.mdx b/apps/website/docs/api-reference/commandkit/interfaces/middleware.mdx index f1675ee8..ab430475 100644 --- a/apps/website/docs/api-reference/commandkit/interfaces/middleware.mdx +++ b/apps/website/docs/api-reference/commandkit/interfaces/middleware.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## Middleware - + Represents a middleware with its metadata and scope. diff --git a/apps/website/docs/api-reference/commandkit/interfaces/parsed-command-data.mdx b/apps/website/docs/api-reference/commandkit/interfaces/parsed-command-data.mdx index b03d8b26..f92c82db 100644 --- a/apps/website/docs/api-reference/commandkit/interfaces/parsed-command-data.mdx +++ b/apps/website/docs/api-reference/commandkit/interfaces/parsed-command-data.mdx @@ -13,14 +13,17 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## ParsedCommandData - + -Data structure containing parsed commands and middleware. +Data structure containing parsed commands, middleware, and tree data. ```ts title="Signature" interface ParsedCommandData { commands: Record; middlewares: Record; + treeNodes?: Record; + compiledRoutes?: Record; + diagnostics?: CommandRouteDiagnostic[]; } ``` @@ -36,6 +39,21 @@ interface ParsedCommandData { Middleware>`} /> +### treeNodes + +CommandTreeNode>`} /> + + +### compiledRoutes + +CompiledCommandRoute>`} /> + + +### diagnostics + +CommandRouteDiagnostic[]`} /> + + diff --git a/apps/website/docs/api-reference/commandkit/interfaces/parsed-message-command.mdx b/apps/website/docs/api-reference/commandkit/interfaces/parsed-message-command.mdx index 2c212958..dc67159d 100644 --- a/apps/website/docs/api-reference/commandkit/interfaces/parsed-message-command.mdx +++ b/apps/website/docs/api-reference/commandkit/interfaces/parsed-message-command.mdx @@ -23,6 +23,7 @@ interface ParsedMessageCommand { options: { name: string; value: unknown }[]; subcommand?: string; subcommandGroup?: string; + fullRoute: string[]; } ``` @@ -48,6 +49,11 @@ interface ParsedMessageCommand { +### fullRoute + + + + diff --git a/apps/website/docs/api-reference/commandkit/interfaces/pre-register-commands-event.mdx b/apps/website/docs/api-reference/commandkit/interfaces/pre-register-commands-event.mdx index a159528f..74df8a8e 100644 --- a/apps/website/docs/api-reference/commandkit/interfaces/pre-register-commands-event.mdx +++ b/apps/website/docs/api-reference/commandkit/interfaces/pre-register-commands-event.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## PreRegisterCommandsEvent - + Event object passed to plugins before command registration. diff --git a/apps/website/docs/api-reference/commandkit/interfaces/prepared-app-command-execution.mdx b/apps/website/docs/api-reference/commandkit/interfaces/prepared-app-command-execution.mdx index d431b84e..b9f85a55 100644 --- a/apps/website/docs/api-reference/commandkit/interfaces/prepared-app-command-execution.mdx +++ b/apps/website/docs/api-reference/commandkit/interfaces/prepared-app-command-execution.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## PreparedAppCommandExecution - + Represents a prepared command execution with all necessary data and middleware. diff --git a/apps/website/docs/api-reference/commandkit/interfaces/simple-proxy.mdx b/apps/website/docs/api-reference/commandkit/interfaces/simple-proxy.mdx index 979f81c5..b791c48b 100644 --- a/apps/website/docs/api-reference/commandkit/interfaces/simple-proxy.mdx +++ b/apps/website/docs/api-reference/commandkit/interfaces/simple-proxy.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## SimpleProxy - + Represents a simple proxy object that mirrors a target object. diff --git a/apps/website/docs/api-reference/commandkit/types/app-command.mdx b/apps/website/docs/api-reference/commandkit/types/app-command.mdx index dcd53aa5..03a3398c 100644 --- a/apps/website/docs/api-reference/commandkit/types/app-command.mdx +++ b/apps/website/docs/api-reference/commandkit/types/app-command.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## AppCommand - + Represents a command in the CommandKit application, including its metadata and handlers. This type extends the native command structure with additional properties. diff --git a/apps/website/docs/api-reference/commandkit/types/command-builder-like.mdx b/apps/website/docs/api-reference/commandkit/types/command-builder-like.mdx index e1d584a5..8c21234a 100644 --- a/apps/website/docs/api-reference/commandkit/types/command-builder-like.mdx +++ b/apps/website/docs/api-reference/commandkit/types/command-builder-like.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## CommandBuilderLike - + Type representing command builder objects supported by CommandKit. diff --git a/apps/website/docs/api-reference/commandkit/types/command-tree-node-kind.mdx b/apps/website/docs/api-reference/commandkit/types/command-tree-node-kind.mdx new file mode 100644 index 00000000..647f793f --- /dev/null +++ b/apps/website/docs/api-reference/commandkit/types/command-tree-node-kind.mdx @@ -0,0 +1,26 @@ +--- +title: "CommandTreeNodeKind" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## CommandTreeNodeKind + + + +Logical node kinds after tree compilation. + +```ts title="Signature" +type CommandTreeNodeKind = | 'root' + | 'flat' + | 'command' + | 'group' + | 'subcommand' +``` diff --git a/apps/website/docs/api-reference/commandkit/types/command-tree-node-source.mdx b/apps/website/docs/api-reference/commandkit/types/command-tree-node-source.mdx new file mode 100644 index 00000000..0d0eb2d5 --- /dev/null +++ b/apps/website/docs/api-reference/commandkit/types/command-tree-node-source.mdx @@ -0,0 +1,26 @@ +--- +title: "CommandTreeNodeSource" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## CommandTreeNodeSource + + + +Source types for command tree nodes discovered from the filesystem. + +```ts title="Signature" +type CommandTreeNodeSource = | 'root' + | 'flat' + | 'directory' + | 'group' + | 'shorthand' +``` diff --git a/apps/website/docs/api-reference/commandkit/types/command-type-data.mdx b/apps/website/docs/api-reference/commandkit/types/command-type-data.mdx index d82ef3cb..27b6f445 100644 --- a/apps/website/docs/api-reference/commandkit/types/command-type-data.mdx +++ b/apps/website/docs/api-reference/commandkit/types/command-type-data.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## CommandTypeData - + Type representing command data identifier. diff --git a/apps/website/docs/api-reference/commandkit/types/message-command-options-schema.mdx b/apps/website/docs/api-reference/commandkit/types/message-command-options-schema.mdx index 5b54e647..b4f0234f 100644 --- a/apps/website/docs/api-reference/commandkit/types/message-command-options-schema.mdx +++ b/apps/website/docs/api-reference/commandkit/types/message-command-options-schema.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## MessageCommandOptionsSchema - + Schema defining the types of options for a message command. diff --git a/apps/website/docs/api-reference/commandkit/types/resolvable-command.mdx b/apps/website/docs/api-reference/commandkit/types/resolvable-command.mdx index c82f05d0..72607bfc 100644 --- a/apps/website/docs/api-reference/commandkit/types/resolvable-command.mdx +++ b/apps/website/docs/api-reference/commandkit/types/resolvable-command.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## ResolvableCommand - + Type for commands that can be resolved by the handler. diff --git a/apps/website/docs/api-reference/commandkit/types/run-command.mdx b/apps/website/docs/api-reference/commandkit/types/run-command.mdx index 73142a16..baeb2b87 100644 --- a/apps/website/docs/api-reference/commandkit/types/run-command.mdx +++ b/apps/website/docs/api-reference/commandkit/types/run-command.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## RunCommand - + Function type for wrapping command execution with custom logic. diff --git a/apps/website/docs/api-reference/ratelimit/functions/build-scope-prefix.mdx b/apps/website/docs/api-reference/ratelimit/functions/build-scope-prefix.mdx index 2894ed8a..5553da6a 100644 --- a/apps/website/docs/api-reference/ratelimit/functions/build-scope-prefix.mdx +++ b/apps/website/docs/api-reference/ratelimit/functions/build-scope-prefix.mdx @@ -18,11 +18,11 @@ import MemberDescription from '@site/src/components/MemberDescription'; Build a prefix for resets by scope/identifier. ```ts title="Signature" -function buildScopePrefix(scope: RateLimitScope, keyPrefix: string | undefined, identifiers: { - userId?: string; - guildId?: string; - channelId?: string; - commandName?: string; +function buildScopePrefix(scope: RateLimitScope, keyPrefix: string | undefined, identifiers: { + userId?: string; + guildId?: string; + channelId?: string; + commandName?: string; }): string | null ``` Parameters @@ -37,5 +37,5 @@ Parameters ### identifiers - + diff --git a/apps/website/docs/api-reference/ratelimit/functions/ratelimit.mdx b/apps/website/docs/api-reference/ratelimit/functions/ratelimit.mdx index 7b518949..f6b01988 100644 --- a/apps/website/docs/api-reference/ratelimit/functions/ratelimit.mdx +++ b/apps/website/docs/api-reference/ratelimit/functions/ratelimit.mdx @@ -20,13 +20,13 @@ Create compiler + runtime plugins for rate limiting. Runtime options are provided via configureRatelimit(). ```ts title="Signature" -function ratelimit(options?: Partial<{ - compiler: import('commandkit').CommonDirectiveTransformerOptions; +function ratelimit(options?: Partial<{ + compiler: import('commandkit').CommonDirectiveTransformerOptions; }>): CommandKitPlugin[] ``` Parameters ### options -commandkit').CommonDirectiveTransformerOptions; }>`} /> +commandkit').CommonDirectiveTransformerOptions; }>`} /> diff --git a/apps/website/docs/api-reference/ratelimit/functions/resolve-scope-keys.mdx b/apps/website/docs/api-reference/ratelimit/functions/resolve-scope-keys.mdx index 496aad2b..3c964dda 100644 --- a/apps/website/docs/api-reference/ratelimit/functions/resolve-scope-keys.mdx +++ b/apps/website/docs/api-reference/ratelimit/functions/resolve-scope-keys.mdx @@ -18,13 +18,13 @@ import MemberDescription from '@site/src/components/MemberDescription'; Resolve keys for multiple scopes, dropping unresolvable ones. ```ts title="Signature" -function resolveScopeKeys(params: Omit & { - scopes: RateLimitScope[]; +function resolveScopeKeys(params: Omit & { + scopes: RateLimitScope[]; }): ResolvedScopeKey[] ``` Parameters ### params -ResolveScopeKeyParams, 'scope'> & { scopes: RateLimitScope[]; }`} /> +ResolveScopeKeyParams, 'scope'> & { scopes: RateLimitScope[]; }`} /> diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-hooks.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-hooks.mdx index 73347345..714ca542 100644 --- a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-hooks.mdx +++ b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-hooks.mdx @@ -23,9 +23,9 @@ interface RateLimitHooks { onAllowed?: (info: RateLimitHookContext) => void | Promise; onReset?: (key: string) => void | Promise; onViolation?: (key: string, count: number) => void | Promise; - onStorageError?: ( - error: unknown, - fallbackUsed: boolean, + onStorageError?: ( + error: unknown, + fallbackUsed: boolean, ) => void | Promise; } ``` @@ -54,7 +54,7 @@ interface RateLimitHooks { ### onStorageError - + diff --git a/apps/website/docs/guide/02-commands/07-category-directory.mdx b/apps/website/docs/guide/02-commands/07-category-directory.mdx index 587f84ed..e55a14fe 100644 --- a/apps/website/docs/guide/02-commands/07-category-directory.mdx +++ b/apps/website/docs/guide/02-commands/07-category-directory.mdx @@ -40,3 +40,18 @@ plugins, or even other commands through your `commandkit` instance. ├── package.json └── tsconfig.json ``` + +:::info + +You can learn more about command categories +[here](../02-commands/07-category-directory.mdx). + +::: + +> [!NOTE] Transparent Categories vs Subcommand Hierarchy: Folders +> using parenthesis `(Category)` strictly serve to group handlers +> mentally and apply transparent middlewares. They do **not** change +> the execution slash command (`/ban`). If you are designing +> comprehensive setups combining sub-commands, you should establish a +> Root Command directory using square brackets `[commandName]`. Read +> more in the [Subcommand Hierarchy Guide](./09-subcommand-hierarchy). diff --git a/apps/website/docs/guide/02-commands/08-middlewares.mdx b/apps/website/docs/guide/02-commands/08-middlewares.mdx index 3b411577..c254f179 100644 --- a/apps/website/docs/guide/02-commands/08-middlewares.mdx +++ b/apps/website/docs/guide/02-commands/08-middlewares.mdx @@ -196,8 +196,8 @@ instance of the `CommandKitErrorCodes.StopMiddlewares` error. ### Directory-scoped middleware Create a `+middleware.ts` file in any commands directory to apply -middleware to all commands within that directory and its -subdirectories. +middleware to commands whose definition files live in that same +directory. @@ -205,7 +205,7 @@ subdirectories. import type { MiddlewareContext } from 'commandkit'; export function beforeExecute(ctx: MiddlewareContext) { - // This middleware will run before any moderation command + // This middleware will run before commands defined in this directory if (!ctx.interaction.member.permissions.has('KickMembers')) { throw new Error('You need moderation permissions to use this command'); } @@ -223,7 +223,7 @@ subdirectories. * @param {MiddlewareContext} ctx - The middleware context */ export function beforeExecute(ctx) { - // This middleware will run before any moderation command + // This middleware will run before commands defined in this directory if (!ctx.interaction.member.permissions.has('KickMembers')) { throw new Error('You need moderation permissions to use this command'); } @@ -233,6 +233,16 @@ subdirectories. +For hierarchical command trees, "current directory" means the +directory that contains the leaf definition: + +- a shorthand leaf such as `add.subcommand.ts` uses middleware from the + directory that contains that file +- a folder leaf such as `[archive]/command.ts` uses middleware from the + `[archive]` directory +- parent command and group directories do not automatically contribute + `+middleware.ts` files to deeper leaves + ### Command-specific middleware For command-specific middleware, create a file named @@ -310,8 +320,10 @@ apply middleware to every command in your entire Discord bot. :::tip -Middleware execution (both before and after) follows a hierarchy: -global middlewares run first, then directory-scoped middlewares, and -finally command-scoped middlewares. +Middleware execution order is deterministic: + +- global middlewares run first +- current-directory middlewares run second +- same-directory command-scoped middlewares run last ::: diff --git a/apps/website/docs/guide/02-commands/09-subcommand-hierarchy.mdx b/apps/website/docs/guide/02-commands/09-subcommand-hierarchy.mdx new file mode 100644 index 00000000..09e9f711 --- /dev/null +++ b/apps/website/docs/guide/02-commands/09-subcommand-hierarchy.mdx @@ -0,0 +1,129 @@ +--- +title: Subcommand Hierarchy +--- + +CommandKit supports fully filesystem-based slash command trees. The +filesystem tokens are explicit: + +- `[command]` defines a root command directory +- `{group}` defines a subcommand group directory +- `command.ts` defines a directory-backed command node +- `group.ts` defines a group node +- `*.subcommand.ts` defines a shorthand subcommand leaf + +## Root Command Directories `[command]` + +Wrap a directory name in square brackets to declare a root slash +command. The root directory must contain a `command.ts` file. + +```text title="Directory Structure" +src/app/commands/ +└── (general)/ + └── [settings]/ + ├── command.ts + ├── profile.subcommand.ts + └── security.subcommand.ts +``` + +This produces: + +- `/settings profile` +- `/settings security` + +Use `command.ts` to define the root command's description and shared +options or metadata for the `/settings` root. + +## Group Directories `{group}` + +Use curly braces inside a root command directory to define a subcommand +group. Group directories must contain a `group.ts` file. + +```text title="Directory Structure" +src/app/commands/ +└── [settings]/ + ├── command.ts + └── {notifications}/ + ├── group.ts + ├── enable.subcommand.ts + └── disable.subcommand.ts +``` + +This produces: + +- `/settings notifications enable` +- `/settings notifications disable` + +## Folder Leaves And Shorthand Leaves + +You can define leaves in two ways: + +1. Shorthand files with `*.subcommand.ts` +2. Directory-backed leaves with `[leaf]/command.ts` + +Both compile to executable subcommands. + +```text title="Directory Structure" +src/app/commands/ +└── [ops]/ + ├── command.ts + ├── status.subcommand.ts + └── [deploy]/ + └── command.ts +``` + +This produces: + +- `/ops status` +- `/ops deploy` + +## Middleware Scope In Hierarchical Trees + +Hierarchical commands use the same middleware model as flat commands: + +- `+global-middleware.ts` always applies +- `+middleware.ts` applies only from the current directory +- `+.middleware.ts` applies only from that same current + directory + +That means hierarchical leaves do not inherit `+middleware.ts` from +ancestor command or group directories. + +Example: + +```text title="Directory Structure" +src/app/commands/ +└── [workspace]/ + ├── command.ts + ├── +middleware.ts + └── {notes}/ + ├── group.ts + ├── +middleware.ts + ├── +add.middleware.ts + ├── add.subcommand.ts + └── [archive]/ + ├── +middleware.ts + └── command.ts +``` + +`add.subcommand.ts` uses: + +- global middleware +- `{notes}/+middleware.ts` +- `{notes}/+add.middleware.ts` + +`[archive]/command.ts` uses: + +- global middleware +- `[archive]/+middleware.ts` + +## Categories Still Work + +Parenthesis directories such as `(general)` remain organizational only. +They do not change the slash route shape and they do not create command +or group nodes by themselves. + +:::info +Use `(category)` for organization, `[command]` for root commands, and +`{group}` for subcommand groups. That separation keeps the filesystem +shape and the generated Discord route aligned. +::: diff --git a/apps/website/docs/guide/08-advanced/02-file-naming-conventions.mdx b/apps/website/docs/guide/08-advanced/02-file-naming-conventions.mdx index f1a7d2b7..9a295574 100644 --- a/apps/website/docs/guide/08-advanced/02-file-naming-conventions.mdx +++ b/apps/website/docs/guide/08-advanced/02-file-naming-conventions.mdx @@ -4,9 +4,9 @@ title: File Naming Conventions ## app.ts -The `src/app.ts` (or `app.js`) is a special file that acts as the -entry point for your application. It is where you define and export -your `Discord.js` client instance. +The `src/app.ts` (or `app.js`) file is the entry point for your +application. It should define and export your `discord.js` client +instance. ```ts title="src/app.ts" import { Client } from 'discord.js'; @@ -15,72 +15,55 @@ const client = new Client({ /* options */ }); -// Optional: Override the default DISCORD_TOKEN environment variable client.token = 'YOUR_BOT_TOKEN'; export default client; ``` -## +middleware.ts +## Middleware Files -The `src/app/commands/+middleware.ts` (or `+middleware.js`) file is -used to define middleware functions for your application. Middleware -functions get called before and after the command execution. +The `src/app/commands` tree supports three middleware filenames: + +- `+global-middleware.ts`: runs for every command +- `+middleware.ts`: runs for leaves defined in the same directory +- `+.middleware.ts`: runs for the matching leaf token in the + same directory ```ts title="src/app/commands/+middleware.ts" import { MiddlewareContext } from 'commandkit'; export function beforeExecute(context: MiddlewareContext) { - // This function will be executed before the command is executed console.log('Before command execution'); } export function afterExecute(context: MiddlewareContext) { - // This function will be executed after the command is executed console.log('After command execution'); } ``` -There are 3 types of middlewares you can create: - -- `+middleware.ts` (or `+middleware.js`): This file is used to define - middleware functions that will be executed for all sibling commands - in the application. -- `+.middleware.ts` (or `+.middleware.js`): This - file is used to define middleware functions that will be executed - for a specific command in the application. The `` part of - the filename should match the name of the command file. This is - useful for defining middleware functions that should be applied to a - specific command. -- `+global-middleware.ts` (or `+global-middleware.js`): This file is - used to define middleware functions that will be executed for all - commands in the application, regardless of their location in the - file system. - :::info -You can learn more about middlewares +You can learn more about middleware behavior [here](../02-commands/08-middlewares.mdx). ::: -## (category) directory +## (category) Directory -In your commands directory, you can create a category using -parenthesis (e.g. `(Moderation)`). This is useful for organizing your -commands into logical groups, making it easier to manage and maintain -your code. +Use parenthesis directories such as `(Moderation)` for organization +only. Categories help structure the repo and category metadata, but they +do not change slash command routes. -``` +```text src/app/commands/ -├── (Moderation) -│ ├── ban.ts -│ ├── kick.ts -│ ├── mute.ts -│ ├── unmute.ts -│ ├── warn.ts -│ ├── warn-list.ts -│ └── warn-remove.ts +└── (Moderation)/ + ├── ban.ts + ├── kick.ts + ├── mute.ts + ├── unmute.ts + ├── warn.ts + ├── warn-list.ts + └── warn-remove.ts ``` :::info @@ -89,3 +72,47 @@ You can learn more about command categories [here](../02-commands/07-category-directory.mdx). ::: + +## Hierarchical Command Directories + +CommandKit uses distinct tokens for hierarchical command trees: + +- `[command]` for root command directories +- `{group}` for subcommand group directories +- `command.ts` for directory-backed command nodes +- `group.ts` for group definition files +- `*.subcommand.ts` for shorthand leaves +- `[leaf]/command.ts` for directory-backed leaves + +```text +src/app/commands/ +└── [settings]/ + ├── command.ts + ├── profile.subcommand.ts + └── {notifications}/ + ├── group.ts + ├── enable.subcommand.ts + └── [disable]/ + └── command.ts +``` + +This transforms into: + +- `/settings profile` +- `/settings notifications enable` +- `/settings notifications disable` + +Middleware inside these trees follows the same same-directory rule: + +- `+middleware.ts` applies only to leaves defined in that directory +- `+.middleware.ts` applies only to the matching leaf token in + that directory +- ancestor directories do not automatically contribute middleware to + deeper hierarchical leaves + +:::info + +You can learn more about configuring tree structures +[here](../02-commands/09-subcommand-hierarchy.mdx). + +::: diff --git a/package.json b/package.json index e56ba6eb..43d13ce4 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ "check-types": "turbo run --filter=\"./packages/*\" check-types", "build": "turbo run --filter=\"./packages/*\" build", "docgen": "tsx ./scripts/docs/generate-typescript-docs.ts && pnpm format", + "docgen:check": "tsx ./scripts/docs/generate-typescript-docs.ts && git diff --exit-code -- apps/website/docs/api-reference", + "test:commandkit": "pnpm --filter commandkit test -- --run spec/reload-commands.test.ts spec/context-command-identifier.test.ts spec/hierarchical-command-registration.test.ts spec/hierarchical-command-handler.test.ts spec/commands-router.test.ts spec/message-command-parser.test.ts spec/context-menu-registration.test.ts", "prettier:check": "prettier --experimental-cli --check . --ignore-path=.prettierignore", "format": "prettier --experimental-cli --write . --ignore-path=.prettierignore" }, @@ -84,7 +86,7 @@ "fast-xml-parser@>=5.0.0 <5.5.6": ">=5.5.6", "flatted@<=3.4.1": ">=3.4.2", "fast-xml-parser@>=4.0.0-beta.3 <=5.5.6": ">=5.5.7", - "path-to-regexp@<0.1.13": ">=0.1.13", + "path-to-regexp@<0.1.12": "^0.1.12", "handlebars@>=4.0.0 <=4.7.8": ">=4.7.9", "brace-expansion@>=4.0.0 <5.0.5": ">=5.0.5", "handlebars@>=4.0.0 <4.7.9": ">=4.7.9", diff --git a/packages/commandkit/spec/commands-router.test.ts b/packages/commandkit/spec/commands-router.test.ts new file mode 100644 index 00000000..5f13489f --- /dev/null +++ b/packages/commandkit/spec/commands-router.test.ts @@ -0,0 +1,179 @@ +import { afterEach, describe, expect, test } from 'vitest'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { CommandsRouter } from '../src/app/router/CommandsRouter'; + +const tmpRoots: string[] = []; +const tempBaseDir = join(__dirname, '.tmp'); + +function normalizePath(path: string) { + return path.replace(/\\/g, '/'); +} + +async function createCommandsFixture( + files: Array<[relativePath: string, contents?: string]>, +) { + await mkdir(tempBaseDir, { recursive: true }); + + const root = await mkdtemp(join(tempBaseDir, 'commands-router-')); + tmpRoots.push(root); + + for (const [relativePath, contents = 'export {};'] of files) { + const fullPath = join(root, relativePath); + await mkdir(dirname(fullPath), { recursive: true }); + await writeFile(fullPath, contents); + } + + return root; +} + +afterEach(async () => { + await Promise.all( + tmpRoots + .splice(0) + .map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +describe('CommandsRouter', () => { + test('discovers flat commands, nested categories, and current middleware ordering', async () => { + const entrypoint = await createCommandsFixture([ + ['_ignored.ts'], + ['+global-middleware.ts'], + ['ping.ts'], + ['(General)/+middleware.ts'], + ['(General)/+pong.middleware.ts'], + ['(General)/pong.ts'], + ['(General)/(Animals)/+middleware.ts'], + ['(General)/(Animals)/cat.ts'], + ]); + + const router = new CommandsRouter({ entrypoint }); + const result = await router.scan(); + const commands = Object.values(result.commands); + const middlewares = result.middlewares; + + expect(commands).toHaveLength(3); + + const byName = Object.fromEntries( + commands.map((command) => [command.name, command]), + ); + + expect(byName.ping.category).toBeNull(); + expect(byName.pong.category).toBe('General'); + expect(byName.cat.category).toBe('General:Animals'); + + const middlewarePathsFor = (commandName: 'ping' | 'pong' | 'cat') => { + return byName[commandName].middlewares.map((id) => { + return normalizePath(middlewares[id].relativePath); + }); + }; + + expect(middlewarePathsFor('ping')).toEqual(['/+global-middleware.ts']); + expect(middlewarePathsFor('pong')).toEqual([ + '/+global-middleware.ts', + '/(General)/+middleware.ts', + '/(General)/+pong.middleware.ts', + ]); + + // Current behavior is same-directory only for directory middleware. + expect(middlewarePathsFor('cat')).toEqual([ + '/+global-middleware.ts', + '/(General)/(Animals)/+middleware.ts', + ]); + }); + + test('discovers hierarchical nodes and compiles executable route middleware chains', async () => { + const entrypoint = await createCommandsFixture([ + ['+global-middleware.ts'], + ['[admin]/command.ts'], + ['[admin]/+middleware.ts'], + ['[admin]/{moderation}/group.ts'], + ['[admin]/{moderation}/+middleware.ts'], + ['[admin]/{moderation}/+ban.middleware.ts'], + ['[admin]/{moderation}/ban.subcommand.ts'], + ['[admin]/{moderation}/[kick]/command.ts'], + ['[admin]/{moderation}/[kick]/+kick.middleware.ts'], + ]); + + const router = new CommandsRouter({ entrypoint }); + const result = await router.scan(); + const treeNodes = Object.values(result.treeNodes ?? {}); + const compiledRoutes = result.compiledRoutes ?? {}; + const middlewares = result.middlewares; + + expect(Object.values(result.commands)).toHaveLength(0); + expect(Object.keys(compiledRoutes).sort()).toEqual([ + 'admin.moderation.ban', + 'admin.moderation.kick', + ]); + + const adminNode = treeNodes.find( + (node) => node.route.join('.') === 'admin', + ); + const moderationNode = treeNodes.find( + (node) => node.route.join('.') === 'admin.moderation', + ); + const banNode = treeNodes.find( + (node) => node.route.join('.') === 'admin.moderation.ban', + ); + const kickNode = treeNodes.find( + (node) => node.route.join('.') === 'admin.moderation.kick', + ); + + expect(adminNode).toMatchObject({ + kind: 'command', + executable: false, + }); + expect(moderationNode).toMatchObject({ + kind: 'group', + executable: false, + }); + expect(banNode).toMatchObject({ + kind: 'subcommand', + shorthand: true, + executable: true, + }); + expect(kickNode).toMatchObject({ + kind: 'subcommand', + shorthand: false, + executable: true, + }); + + const middlewarePathsFor = (routeKey: string) => { + return compiledRoutes[routeKey].middlewares.map((id) => { + return normalizePath(middlewares[id].relativePath); + }); + }; + + expect(middlewarePathsFor('admin.moderation.ban')).toEqual([ + '/+global-middleware.ts', + '/[admin]/{moderation}/+middleware.ts', + '/[admin]/{moderation}/+ban.middleware.ts', + ]); + expect(middlewarePathsFor('admin.moderation.kick')).toEqual([ + '/+global-middleware.ts', + '/[admin]/{moderation}/[kick]/+kick.middleware.ts', + ]); + }); + + test('emits diagnostics for invalid hierarchical structures', async () => { + const entrypoint = await createCommandsFixture([ + ['{oops}/group.ts'], + ['[admin]/command.ts'], + ['[admin]/ban.subcommand.ts'], + ['[admin]/{moderation}/group.ts'], + ['[admin]/[ban]/command.ts'], + ['[broken]/+middleware.ts'], + ]); + + const router = new CommandsRouter({ entrypoint }); + const result = await router.scan(); + const diagnosticCodes = (result.diagnostics ?? []).map((diag) => diag.code); + + expect(diagnosticCodes).toContain('ROOT_GROUP_NOT_ALLOWED'); + expect(diagnosticCodes).toContain('DUPLICATE_SIBLING_TOKEN'); + expect(diagnosticCodes).toContain('MIXED_ROOT_CHILDREN'); + expect(diagnosticCodes).toContain('MISSING_COMMAND_DEFINITION'); + }); +}); diff --git a/packages/commandkit/spec/context-command-identifier.test.ts b/packages/commandkit/spec/context-command-identifier.test.ts new file mode 100644 index 00000000..bb8f3514 --- /dev/null +++ b/packages/commandkit/spec/context-command-identifier.test.ts @@ -0,0 +1,195 @@ +import { afterEach, describe, expect, test } from 'vitest'; +import { Client, Collection, Message } from 'discord.js'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { CommandKit } from '../src/commandkit'; +import { CommandExecutionMode, Context } from '../src/app/commands/Context'; +import { AppCommandHandler } from '../src/app/handlers/AppCommandHandler'; +import { CommandsRouter } from '../src/app/router'; + +const tmpRoots: string[] = []; +const tempBaseDir = join(__dirname, '.tmp'); + +async function createCommandsFixture( + files: Array<[relativePath: string, contents?: string]>, +) { + await mkdir(tempBaseDir, { recursive: true }); + + const root = await mkdtemp(join(tempBaseDir, 'context-command-identifier-')); + tmpRoots.push(root); + + for (const [relativePath, contents = 'export {};'] of files) { + const fullPath = join(root, relativePath); + await mkdir(dirname(fullPath), { recursive: true }); + await writeFile(fullPath, contents); + } + + return root; +} + +function createMessage(content: string) { + const message = Object.create(Message.prototype) as Message & { + attachments: Collection; + author: { bot: boolean }; + channel: null; + channelId: string; + guild: null; + guildId: string; + mentions: { + channels: Collection; + roles: Collection; + users: Collection; + }; + }; + + Object.defineProperties(message, { + attachments: { + value: new Collection(), + writable: true, + }, + author: { + value: { bot: false }, + writable: true, + }, + content: { + value: content, + writable: true, + }, + channel: { + value: null, + writable: true, + }, + channelId: { + value: 'channel-1', + writable: true, + }, + guild: { + value: null, + writable: true, + }, + guildId: { + value: 'guild-1', + writable: true, + }, + mentions: { + value: { + channels: new Collection(), + roles: new Collection(), + users: new Collection(), + }, + writable: true, + }, + }); + + return message; +} + +async function createContextForMessage( + content: string, + prefixes: string[], + files: Array<[relativePath: string, contents?: string]>, + commandOverride?: string, +) { + CommandKit.instance = undefined; + + const entrypoint = await createCommandsFixture(files); + const client = new Client({ intents: [] }); + const commandkit = new CommandKit({ client }); + const handler = new AppCommandHandler(commandkit); + const router = new CommandsRouter({ entrypoint }); + const message = createMessage(content); + + commandkit.commandHandler = handler; + commandkit.commandsRouter = router; + commandkit.appConfig.getMessageCommandPrefix = () => prefixes; + + await router.scan(); + await handler.loadCommands(); + + const prepared = await handler.prepareCommandRun(message, commandOverride); + + if (!prepared) { + throw new Error(`Expected prepared command for message: ${content}`); + } + + const context = new Context(commandkit, { + command: prepared.command, + executionMode: CommandExecutionMode.Message, + interaction: null as never, + message, + messageCommandParser: prepared.messageCommandParser as never, + }); + + return { client, context }; +} + +afterEach(async () => { + CommandKit.instance = undefined; + await Promise.all( + tmpRoots + .splice(0) + .map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +describe('Context.getCommandIdentifier', () => { + test('returns the canonical identifier for message commands across supported prefixes', async () => { + const files: Array<[string, string]> = [ + [ + 'ping.mjs', + ` +export const command = { description: 'Ping' }; +export const metadata = { aliases: ['p'] }; +export async function message() {} +`, + ], + [ + '[admin]/command.mjs', + `export const command = { description: 'Admin' };`, + ], + [ + '[admin]/{moderation}/group.mjs', + `export const command = { description: 'Moderation' };`, + ], + [ + '[admin]/{moderation}/ban.subcommand.mjs', + ` +export const command = { description: 'Ban' }; +export async function message() {} +`, + ], + ]; + + const bang = await createContextForMessage('!ping', ['!'], files); + const multi = await createContextForMessage('??ping', ['??'], files); + const mention = await createContextForMessage( + '<@123>ping', + ['<@123>'], + files, + ); + const alias = await createContextForMessage('!p', ['!'], files, 'p'); + const hierarchical = await createContextForMessage( + '!admin:moderation:ban', + ['!'], + files, + ); + + try { + expect(bang.context.getCommandIdentifier()).toBe('ping'); + expect(multi.context.getCommandIdentifier()).toBe('ping'); + expect(mention.context.getCommandIdentifier()).toBe('ping'); + expect(alias.context.getCommandIdentifier()).toBe('ping'); + expect(hierarchical.context.getCommandIdentifier()).toBe( + 'admin.moderation.ban', + ); + } finally { + await Promise.all([ + bang.client.destroy(), + multi.client.destroy(), + mention.client.destroy(), + alias.client.destroy(), + hierarchical.client.destroy(), + ]); + } + }); +}); diff --git a/packages/commandkit/spec/hierarchical-command-handler.test.ts b/packages/commandkit/spec/hierarchical-command-handler.test.ts new file mode 100644 index 00000000..b5869bc6 --- /dev/null +++ b/packages/commandkit/spec/hierarchical-command-handler.test.ts @@ -0,0 +1,248 @@ +import { afterEach, describe, expect, test } from 'vitest'; +import { + Client, + Collection, + Interaction, + Message, + type ApplicationCommandOptionType, +} from 'discord.js'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { CommandKit } from '../src/commandkit'; +import { AppCommandHandler } from '../src/app/handlers/AppCommandHandler'; +import { CommandsRouter } from '../src/app/router'; + +const tmpRoots: string[] = []; +const tempBaseDir = join(__dirname, '.tmp'); + +async function createCommandsFixture( + files: Array<[relativePath: string, contents?: string]>, +) { + await mkdir(tempBaseDir, { recursive: true }); + + const root = await mkdtemp(join(tempBaseDir, 'hierarchical-handler-')); + tmpRoots.push(root); + + for (const [relativePath, contents = 'export {};'] of files) { + const fullPath = join(root, relativePath); + await mkdir(dirname(fullPath), { recursive: true }); + await writeFile(fullPath, contents); + } + + return root; +} + +function createMessage(content: string) { + const message = Object.create(Message.prototype) as Message & { + attachments: Collection; + author: { bot: boolean }; + guild: null; + guildId: string; + mentions: { + channels: Collection; + roles: Collection; + users: Collection; + }; + }; + + Object.defineProperties(message, { + attachments: { + value: new Collection(), + writable: true, + }, + author: { + value: { bot: false }, + writable: true, + }, + content: { + value: content, + writable: true, + }, + guild: { + value: null, + writable: true, + }, + guildId: { + value: 'guild-1', + writable: true, + }, + mentions: { + value: { + channels: new Collection(), + roles: new Collection(), + users: new Collection(), + }, + writable: true, + }, + }); + + return message; +} + +function createChatInputInteraction( + commandName: string, + options?: { + subcommandGroup?: string; + subcommand?: string; + }, +) { + return { + commandName, + guildId: 'guild-1', + isAutocomplete: () => false, + isChatInputCommand: () => true, + isCommand: () => true, + isContextMenuCommand: () => false, + isMessageContextMenuCommand: () => false, + isUserContextMenuCommand: () => false, + options: { + getSubcommand: (_required?: boolean) => options?.subcommand, + getSubcommandGroup: (_required?: boolean) => options?.subcommandGroup, + }, + } as unknown as Interaction; +} + +async function createHandlerWithCommands( + files: Array<[relativePath: string, contents?: string]>, +) { + CommandKit.instance = undefined; + + const entrypoint = await createCommandsFixture(files); + const client = new Client({ intents: [] }); + const commandkit = new CommandKit({ client }); + const handler = new AppCommandHandler(commandkit); + const router = new CommandsRouter({ entrypoint }); + + commandkit.commandHandler = handler; + commandkit.commandsRouter = router; + + await router.scan(); + await handler.loadCommands(); + + return { client, commandkit, handler, router }; +} + +afterEach(async () => { + CommandKit.instance = undefined; + await Promise.all( + tmpRoots + .splice(0) + .map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +describe('Hierarchical command runtime loading', () => { + test('loads hierarchical executable leaves into the runtime index while preserving flat commands', async () => { + const commandModule = ( + description: string, + options: ApplicationCommandOptionType[] = [], + ) => ` +export const command = { + description: ${JSON.stringify(description)}, + options: ${JSON.stringify( + options.map((type, index) => ({ + name: index === 0 ? 'reason' : `opt${index}`, + type, + })), + )} +}; + +export async function chatInput() {} +export async function message() {} +`; + + const { client, handler } = await createHandlerWithCommands([ + ['+global-middleware.mjs', 'export function beforeExecute() {}'], + ['ping.mjs', commandModule('Ping')], + [ + '[admin]/command.mjs', + 'export const command = { description: "Admin" };', + ], + ['[admin]/+middleware.mjs', 'export function beforeExecute() {}'], + [ + '[admin]/{moderation}/group.mjs', + 'export const command = { description: "Moderation" };', + ], + [ + '[admin]/{moderation}/+middleware.mjs', + 'export function beforeExecute() {}', + ], + [ + '[admin]/{moderation}/+ban.middleware.mjs', + 'export function beforeExecute() {}', + ], + [ + '[admin]/{moderation}/ban.subcommand.mjs', + commandModule('Ban', [3 as ApplicationCommandOptionType]), + ], + ['[admin]/{moderation}/[kick]/command.mjs', commandModule('Kick')], + ]); + + try { + // Public command list remains flat for now until registrar support lands. + expect( + handler.getCommandsArray().map((command) => command.command.name), + ).toEqual(['ping']); + + expect( + handler.getRuntimeCommandsArray().map((command) => { + return (command.data.command as Record).__routeKey; + }), + ).toEqual(['ping', 'admin.moderation.ban', 'admin.moderation.kick']); + + const preparedFlat = await handler.prepareCommandRun( + createChatInputInteraction('ping'), + ); + expect(preparedFlat?.command.data.command.name).toBe('ping'); + + const preparedHierarchical = await handler.prepareCommandRun( + createChatInputInteraction('admin', { + subcommand: 'ban', + subcommandGroup: 'moderation', + }), + ); + + expect( + (preparedHierarchical?.command.data.command as Record) + .__routeKey, + ).toBe('admin.moderation.ban'); + expect(preparedHierarchical?.command.data.message).toBeTypeOf('function'); + expect( + preparedHierarchical?.middlewares.map((middleware) => { + return middleware.middleware.relativePath.replace(/\\/g, '/'); + }), + ).toEqual([ + '/+global-middleware.mjs', + '/[admin]/{moderation}/+middleware.mjs', + '/[admin]/{moderation}/+ban.middleware.mjs', + '', + ]); + + const preparedByOverride = await handler.prepareCommandRun( + createChatInputInteraction('admin'), + 'admin:moderation:kick', + ); + expect( + (preparedByOverride?.command.data.command as Record) + .__routeKey, + ).toBe('admin.moderation.kick'); + + const preparedMessage = await handler.prepareCommandRun( + createMessage('!admin:moderation:ban reason:spam'), + ); + expect( + (preparedMessage?.command.data.command as Record) + .__routeKey, + ).toBe('admin.moderation.ban'); + expect(preparedMessage?.messageCommandParser?.getCommand()).toBe('admin'); + expect(preparedMessage?.messageCommandParser?.getSubcommandGroup()).toBe( + 'moderation', + ); + expect(preparedMessage?.messageCommandParser?.getSubcommand()).toBe( + 'ban', + ); + } finally { + await client.destroy(); + } + }); +}); diff --git a/packages/commandkit/spec/hierarchical-command-registration.test.ts b/packages/commandkit/spec/hierarchical-command-registration.test.ts new file mode 100644 index 00000000..e662218c --- /dev/null +++ b/packages/commandkit/spec/hierarchical-command-registration.test.ts @@ -0,0 +1,211 @@ +import { afterEach, describe, expect, test } from 'vitest'; +import { + ApplicationCommandOptionType, + ApplicationCommandType, + Client, +} from 'discord.js'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { CommandKit } from '../src/commandkit'; +import { AppCommandHandler } from '../src/app/handlers/AppCommandHandler'; +import { CommandsRouter } from '../src/app/router'; + +const tmpRoots: string[] = []; +const tempBaseDir = join(__dirname, '.tmp'); + +async function createCommandsFixture( + files: Array<[relativePath: string, contents?: string]>, +) { + await mkdir(tempBaseDir, { recursive: true }); + + const root = await mkdtemp(join(tempBaseDir, 'hierarchical-registration-')); + tmpRoots.push(root); + + for (const [relativePath, contents = 'export {};'] of files) { + const fullPath = join(root, relativePath); + await mkdir(dirname(fullPath), { recursive: true }); + await writeFile(fullPath, contents); + } + + return root; +} + +function createCommandModule(options: { + description: string; + optionTypes?: ApplicationCommandOptionType[]; + metadata?: Record; +}) { + return ` +export const command = { + description: ${JSON.stringify(options.description)}, + options: ${JSON.stringify( + (options.optionTypes ?? []).map((type, index) => ({ + name: index === 0 ? 'reason' : `opt${index}`, + type, + })), + )} +}; + +${options.metadata ? `export const metadata = ${JSON.stringify(options.metadata)};` : ''} + +export async function chatInput() {} +`; +} + +async function createHandlerWithCommands( + files: Array<[relativePath: string, contents?: string]>, +) { + CommandKit.instance = undefined; + + const entrypoint = await createCommandsFixture(files); + const client = new Client({ intents: [] }); + const commandkit = new CommandKit({ client }); + const handler = new AppCommandHandler(commandkit); + const router = new CommandsRouter({ entrypoint }); + + commandkit.commandHandler = handler; + commandkit.commandsRouter = router; + + await router.scan(); + await handler.loadCommands(); + + return { client, handler }; +} + +afterEach(async () => { + CommandKit.instance = undefined; + await Promise.all( + tmpRoots + .splice(0) + .map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +describe('Hierarchical command registration', () => { + test('registers a hierarchical root as a single Discord chat-input payload', async () => { + const { client, handler } = await createHandlerWithCommands([ + ['ping.mjs', createCommandModule({ description: 'Ping' })], + [ + '[admin]/command.mjs', + 'export const command = { description: "Admin" };', + ], + [ + '[admin]/{moderation}/group.mjs', + 'export const command = { description: "Moderation" };', + ], + [ + '[admin]/{moderation}/ban.subcommand.mjs', + createCommandModule({ + description: 'Ban', + optionTypes: [ApplicationCommandOptionType.String], + }), + ], + [ + '[admin]/{moderation}/[kick]/command.mjs', + createCommandModule({ description: 'Kick' }), + ], + ]); + + try { + const registrationCommands = handler.registrar.getCommandsData(); + + expect(registrationCommands).toHaveLength(2); + + const ping = registrationCommands.find((entry) => entry.name === 'ping'); + const admin = registrationCommands.find( + (entry) => entry.name === 'admin', + ); + + expect(ping?.type).toBe(ApplicationCommandType.ChatInput); + expect(admin?.type).toBe(ApplicationCommandType.ChatInput); + expect(admin?.description).toBe('Admin'); + expect(admin?.options).toEqual([ + { + description: 'Moderation', + name: 'moderation', + options: [ + { + description: 'Ban', + name: 'ban', + options: [ + { + name: 'reason', + type: ApplicationCommandOptionType.String, + }, + ], + type: ApplicationCommandOptionType.Subcommand, + }, + { + description: 'Kick', + name: 'kick', + options: [], + type: ApplicationCommandOptionType.Subcommand, + }, + ], + type: ApplicationCommandOptionType.SubcommandGroup, + }, + ]); + + expect( + registrationCommands.find((entry) => entry.name === 'ban'), + ).toBeUndefined(); + expect( + registrationCommands.find((entry) => entry.name === 'kick'), + ).toBeUndefined(); + + admin?.__applyId('admin-id'); + + const rootNode = handler + .getHierarchicalNodesArray() + .find( + (entry) => + (entry.data.command as Record).__routeKey === 'admin', + ); + + expect(rootNode?.discordId).toBe('admin-id'); + } finally { + await client.destroy(); + } + }); + + test('skips hierarchical roots when chat-input leaves use mixed guild scopes', async () => { + const { client, handler } = await createHandlerWithCommands([ + [ + '[admin]/command.mjs', + 'export const command = { description: "Admin" };', + ], + [ + '[admin]/{moderation}/group.mjs', + 'export const command = { description: "Moderation" };', + ], + [ + '[admin]/{moderation}/ban.subcommand.mjs', + createCommandModule({ + description: 'Ban', + metadata: { + guilds: ['guild-a'], + }, + }), + ], + [ + '[admin]/{moderation}/[kick]/command.mjs', + createCommandModule({ + description: 'Kick', + metadata: { + guilds: ['guild-b'], + }, + }), + ], + ]); + + try { + const registrationCommands = handler.registrar.getCommandsData(); + + expect( + registrationCommands.find((entry) => entry.name === 'admin'), + ).toBeUndefined(); + } finally { + await client.destroy(); + } + }); +}); diff --git a/packages/commandkit/spec/message-command-parser.test.ts b/packages/commandkit/spec/message-command-parser.test.ts new file mode 100644 index 00000000..10eab767 --- /dev/null +++ b/packages/commandkit/spec/message-command-parser.test.ts @@ -0,0 +1,79 @@ +import { Collection, ApplicationCommandOptionType, Message } from 'discord.js'; +import { describe, expect, test } from 'vitest'; +import { MessageCommandParser } from '../src/app/commands/MessageCommandParser'; + +function createMessage(content: string) { + return { + attachments: new Collection(), + content, + guild: null, + mentions: { + channels: new Collection(), + roles: new Collection(), + users: new Collection(), + }, + } as unknown as Message; +} + +describe('MessageCommandParser', () => { + test('parses a flat prefix command with typed options', () => { + const schemaCalls: string[] = []; + const parser = new MessageCommandParser( + createMessage('!ping enabled:true count:2 title:neo'), + ['!'], + (command) => { + schemaCalls.push(command); + + return { + count: ApplicationCommandOptionType.Integer, + enabled: ApplicationCommandOptionType.Boolean, + title: ApplicationCommandOptionType.String, + }; + }, + ); + + expect(parser.getPrefix()).toBe('!'); + expect(parser.getCommand()).toBe('ping'); + expect(parser.getSubcommand()).toBeUndefined(); + expect(parser.getSubcommandGroup()).toBeUndefined(); + expect(parser.getFullCommand()).toBe('ping'); + expect(parser.getArgs()).toEqual(['enabled:true', 'count:2', 'title:neo']); + expect(schemaCalls).toEqual(['ping']); + + expect(parser.options.getBoolean('enabled')).toBe(true); + expect(parser.options.getInteger('count')).toBe(2); + expect(parser.options.getString('title')).toBe('neo'); + }); + + test('throws when the message does not match the configured prefix', () => { + const parser = new MessageCommandParser( + createMessage('?ping'), + ['!'], + () => ({}), + ); + + expect(() => parser.parse()).toThrow(); + }); + + test('parses colon-delimited hierarchical prefix routes', () => { + const schemaCalls: string[] = []; + const parser = new MessageCommandParser( + createMessage('!admin:moderation:ban reason:spam'), + ['!'], + (command) => { + schemaCalls.push(command); + + return { + reason: ApplicationCommandOptionType.String, + }; + }, + ); + + expect(parser.getCommand()).toBe('admin'); + expect(parser.getSubcommandGroup()).toBe('moderation'); + expect(parser.getSubcommand()).toBe('ban'); + expect(parser.getFullCommand()).toBe('admin moderation ban'); + expect(schemaCalls).toEqual(['admin moderation ban']); + expect(parser.options.getString('reason')).toBe('spam'); + }); +}); diff --git a/packages/commandkit/spec/reload-commands.test.ts b/packages/commandkit/spec/reload-commands.test.ts new file mode 100644 index 00000000..2be3d1b8 --- /dev/null +++ b/packages/commandkit/spec/reload-commands.test.ts @@ -0,0 +1,302 @@ +import { afterEach, describe, expect, test } from 'vitest'; +import { Client, Collection, Interaction, Message } from 'discord.js'; +import { + mkdir, + mkdtemp, + rename, + rm, + unlink, + writeFile, +} from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { CommandKit } from '../src/commandkit'; +import { + AppCommandHandler, + PreparedAppCommandExecution, +} from '../src/app/handlers/AppCommandHandler'; +import { CommandsRouter } from '../src/app/router'; + +const tmpRoots: string[] = []; +const tempBaseDir = join(__dirname, '.tmp'); + +async function createCommandsFixture( + files: Array<[relativePath: string, contents?: string]>, +) { + await mkdir(tempBaseDir, { recursive: true }); + + const root = await mkdtemp(join(tempBaseDir, 'reload-commands-')); + tmpRoots.push(root); + + for (const [relativePath, contents = 'export {};'] of files) { + const fullPath = join(root, relativePath); + await mkdir(dirname(fullPath), { recursive: true }); + await writeFile(fullPath, contents); + } + + return root; +} + +function createMessage(content: string) { + const message = Object.create(Message.prototype) as Message & { + attachments: Collection; + author: { bot: boolean }; + guild: null; + guildId: string; + mentions: { + channels: Collection; + roles: Collection; + users: Collection; + }; + }; + + Object.defineProperties(message, { + attachments: { + value: new Collection(), + writable: true, + }, + author: { + value: { bot: false }, + writable: true, + }, + content: { + value: content, + writable: true, + }, + guild: { + value: null, + writable: true, + }, + guildId: { + value: 'guild-1', + writable: true, + }, + mentions: { + value: { + channels: new Collection(), + roles: new Collection(), + users: new Collection(), + }, + writable: true, + }, + }); + + return message; +} + +function createChatInputInteraction( + commandName: string, + options?: { + subcommandGroup?: string; + subcommand?: string; + }, +) { + return { + commandName, + guildId: 'guild-1', + isAutocomplete: () => false, + isChatInputCommand: () => true, + isCommand: () => true, + isContextMenuCommand: () => false, + isMessageContextMenuCommand: () => false, + isUserContextMenuCommand: () => false, + options: { + getSubcommand: (_required?: boolean) => options?.subcommand, + getSubcommandGroup: (_required?: boolean) => options?.subcommandGroup, + }, + } as unknown as Interaction; +} + +async function createHandlerWithCommands( + files: Array<[relativePath: string, contents?: string]>, +) { + CommandKit.instance = undefined; + + const entrypoint = await createCommandsFixture(files); + const client = new Client({ intents: [] }); + const commandkit = new CommandKit({ client }); + const handler = new AppCommandHandler(commandkit); + const router = new CommandsRouter({ entrypoint }); + + commandkit.commandHandler = handler; + commandkit.commandsRouter = router; + + await router.scan(); + await handler.loadCommands(); + + return { client, handler, entrypoint }; +} + +function getNonPermissionMiddlewarePaths( + prepared: PreparedAppCommandExecution | null, +) { + return (prepared?.middlewares ?? []) + .map((middleware) => middleware.middleware.relativePath.replace(/\\/g, '/')) + .filter(Boolean); +} + +afterEach(async () => { + CommandKit.instance = undefined; + await Promise.all( + tmpRoots + .splice(0) + .map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +describe('AppCommandHandler.reloadCommands', () => { + test('rescans flat commands and middleware before rebuilding runtime caches', async () => { + const { client, handler, entrypoint } = await createHandlerWithCommands([ + ['+global-middleware.mjs', 'export function beforeExecute() {}'], + [ + 'ping.mjs', + ` +export const command = { description: 'Ping' }; +export async function chatInput() {} +export async function message() {} +`, + ], + ]); + + try { + expect( + handler + .getRuntimeCommandsArray() + .map((command) => command.command.name), + ).toEqual(['ping']); + + await writeFile( + join(entrypoint, 'pong.mjs'), + ` +export const command = { description: 'Pong' }; +export async function chatInput() {} +export async function message() {} +`, + ); + await writeFile( + join(entrypoint, '+ping.middleware.mjs'), + 'export function beforeExecute() {}', + ); + + await handler.reloadCommands(); + + expect( + handler + .getRuntimeCommandsArray() + .map((command) => command.command.name) + .sort(), + ).toEqual(['ping', 'pong']); + + const pingAfterAdd = await handler.prepareCommandRun( + createMessage('!ping'), + ); + expect(getNonPermissionMiddlewarePaths(pingAfterAdd)).toEqual([ + '/+global-middleware.mjs', + '/+ping.middleware.mjs', + ]); + + await unlink(join(entrypoint, '+ping.middleware.mjs')); + await rename(join(entrypoint, 'ping.mjs'), join(entrypoint, 'pang.mjs')); + await writeFile( + join(entrypoint, '+pang.middleware.mjs'), + 'export function beforeExecute() {}', + ); + + await handler.reloadCommands(); + + expect( + handler + .getRuntimeCommandsArray() + .map((command) => command.command.name) + .sort(), + ).toEqual(['pang', 'pong']); + expect( + await handler.prepareCommandRun(createMessage('!ping')), + ).toBeNull(); + + const pangAfterRename = await handler.prepareCommandRun( + createMessage('!pang'), + ); + expect(getNonPermissionMiddlewarePaths(pangAfterRename)).toEqual([ + '/+global-middleware.mjs', + '/+pang.middleware.mjs', + ]); + } finally { + await client.destroy(); + } + }); + + test('rescans hierarchical leaves after additions and removals', async () => { + const { client, handler, entrypoint } = await createHandlerWithCommands([ + ['+global-middleware.mjs', 'export function beforeExecute() {}'], + [ + '[admin]/command.mjs', + `export const command = { description: 'Admin' };`, + ], + [ + '[admin]/{moderation}/group.mjs', + `export const command = { description: 'Moderation' };`, + ], + [ + '[admin]/{moderation}/ban.subcommand.mjs', + ` +export const command = { description: 'Ban' }; +export async function chatInput() {} +export async function message() {} +`, + ], + ]); + + try { + expect( + handler.getRuntimeCommandsArray().map((command) => { + return (command.data.command as Record).__routeKey; + }), + ).toEqual(['admin.moderation.ban']); + + await mkdir(join(entrypoint, '[admin]', '{moderation}', '[kick]'), { + recursive: true, + }); + await writeFile( + join(entrypoint, '[admin]', '{moderation}', '[kick]', 'command.mjs'), + ` +export const command = { description: 'Kick' }; +export async function chatInput() {} +export async function message() {} +`, + ); + + await handler.reloadCommands(); + + expect( + handler + .getRuntimeCommandsArray() + .map((command) => { + return (command.data.command as Record).__routeKey; + }) + .sort(), + ).toEqual(['admin.moderation.ban', 'admin.moderation.kick']); + + await unlink( + join(entrypoint, '[admin]', '{moderation}', 'ban.subcommand.mjs'), + ); + + await handler.reloadCommands(); + + expect( + handler.getRuntimeCommandsArray().map((command) => { + return (command.data.command as Record).__routeKey; + }), + ).toEqual(['admin.moderation.kick']); + + const removedBan = await handler.prepareCommandRun( + createChatInputInteraction('admin', { + subcommandGroup: 'moderation', + subcommand: 'ban', + }), + ); + expect(removedBan).toBeNull(); + } finally { + await client.destroy(); + } + }); +}); diff --git a/packages/commandkit/src/app/commands/AppCommandRunner.ts b/packages/commandkit/src/app/commands/AppCommandRunner.ts index 4490ae04..5e3c4569 100644 --- a/packages/commandkit/src/app/commands/AppCommandRunner.ts +++ b/packages/commandkit/src/app/commands/AppCommandRunner.ts @@ -71,7 +71,11 @@ export class AppCommandRunner { const env = new CommandKitEnvironment(commandkit); env.setType(CommandKitEnvironmentType.CommandHandler); env.variables.set('commandHandlerType', 'app'); - env.variables.set('currentCommandName', prepared.command.command.name); + env.variables.set( + 'currentCommandName', + (prepared.command.data.command as Record).__routeKey ?? + prepared.command.command.name, + ); env.variables.set('execHandlerKind', executionMode); env.variables.set('customHandler', options?.handler ?? null); @@ -161,6 +165,8 @@ export class AppCommandRunner { Logger.error`[${marker} - ${time}] Error executing command: ${error}`; const commandName = + (prepared.command?.data?.command as Record) + ?.__routeKey ?? prepared.command?.data?.command?.name ?? prepared.command.command.name; @@ -183,6 +189,8 @@ export class AppCommandRunner { ); const commandName = + (prepared.command?.data?.command as Record) + ?.__routeKey ?? prepared.command?.data?.command?.name ?? prepared.command.command.name; @@ -208,7 +216,11 @@ export class AppCommandRunner { ? (runCommand as RunCommand)(_executeCommand) : _executeCommand; - env.markStart(prepared.command.data.command.name); + env.markStart( + ((prepared.command.data.command as Record) + .__routeKey as string | undefined) ?? + prepared.command.data.command.name, + ); const res = await commandkit.plugins.execute( async (ctx, plugin) => { diff --git a/packages/commandkit/src/app/commands/Context.ts b/packages/commandkit/src/app/commands/Context.ts index 5c143664..6e076a02 100644 --- a/packages/commandkit/src/app/commands/Context.ts +++ b/packages/commandkit/src/app/commands/Context.ts @@ -300,12 +300,25 @@ export class Context< * Gets the name of the current command. */ public get commandName(): string { + const routeKey = (this.command.data.command as Record) + .__routeKey; + + if (typeof routeKey === 'string' && routeKey.length) { + return routeKey; + } + + if (this.command.data.command.name) { + return this.command.data.command.name; + } + if (this.isInteraction()) { return this.interaction.commandName; } - const maybeAlias = this.config.messageCommandParser!.getCommand(); - return this.commandkit.commandHandler.resolveMessageCommandName(maybeAlias); + const parser = this.config.messageCommandParser!; + return this.commandkit.commandHandler.resolveMessageCommandName( + parser.getFullCommand(), + ); } /** @@ -476,7 +489,7 @@ export class Context< if (this.isInteraction()) { return this.interaction.commandName; } else { - return this.message.content.split(' ')[0].slice(1); + return this.commandName; } } diff --git a/packages/commandkit/src/app/commands/MessageCommandParser.ts b/packages/commandkit/src/app/commands/MessageCommandParser.ts index 6cef5cdd..1bd3dd0d 100644 --- a/packages/commandkit/src/app/commands/MessageCommandParser.ts +++ b/packages/commandkit/src/app/commands/MessageCommandParser.ts @@ -21,6 +21,7 @@ export interface ParsedMessageCommand { options: { name: string; value: unknown }[]; subcommand?: string; subcommandGroup?: string; + fullRoute: string[]; } /** @@ -102,6 +103,14 @@ export class MessageCommandParser { return this.parse().command; } + /** + * Gets the full command route as an array of segments. + * @returns Array of command segments + */ + public getFullRoute(): string[] { + return this.parse().fullRoute; + } + /** * Gets the subcommand name if present. * @returns The subcommand name or undefined @@ -140,9 +149,7 @@ export class MessageCommandParser { * @returns The complete command string */ public getFullCommand() { - return [this.getCommand(), this.getSubcommandGroup(), this.getSubcommand()] - .filter((v) => v) - .join(' '); + return this.getFullRoute().join(' '); } /** @@ -163,27 +170,28 @@ export class MessageCommandParser { } const parts = content.slice(prefix.length).trim().split(' '); - const command = parts.shift(); + const commandToken = parts.shift(); this.#args = parts; + let command: string | undefined = ''; let subcommandGroup: string | undefined; let subcommand: string | undefined; - if (command?.includes(':')) { - const [, group, cmd] = command.split(':'); + const fullRoute = commandToken?.split(':') ?? []; + + if (fullRoute.length) { + command = fullRoute[0]; - if (!cmd && group) { - subcommand = group; - } else if (cmd && group) { - subcommandGroup = group; - subcommand = cmd; + if (fullRoute.length === 2) { + subcommand = fullRoute[1]; + } else if (fullRoute.length >= 3) { + subcommandGroup = fullRoute[1]; + subcommand = fullRoute[fullRoute.length - 1]; } } - const schema = this.schema( - [command, subcommandGroup, subcommand].filter(Boolean).join(' ').trim(), - ); + const schema = this.schema(fullRoute.join(' ').trim()); const options = parts .map((part) => { @@ -244,6 +252,7 @@ export class MessageCommandParser { options, subcommand, subcommandGroup, + fullRoute, }; return this.#parsed; diff --git a/packages/commandkit/src/app/handlers/AppCommandHandler.ts b/packages/commandkit/src/app/handlers/AppCommandHandler.ts index 4af92f15..9aaeb19c 100644 --- a/packages/commandkit/src/app/handlers/AppCommandHandler.ts +++ b/packages/commandkit/src/app/handlers/AppCommandHandler.ts @@ -10,6 +10,7 @@ import { Message, SlashCommandBuilder, } from 'discord.js'; +import { dirname } from 'node:path'; import type { CommandKit } from '../../commandkit'; import { getConfig } from '../../config/config'; import { AsyncFunction, GenericFunction } from '../../context/async-context'; @@ -33,7 +34,12 @@ import { middlewareId as permissions_middlewareId, } from '../middlewares/permissions'; import { CommandRegistrar } from '../register/CommandRegistrar'; -import { Command, Middleware } from '../router'; +import { + Command, + CommandTreeNode, + CompiledCommandRoute, + Middleware, +} from '../router'; const KNOWN_NON_HANDLER_KEYS = [ 'command', @@ -199,6 +205,22 @@ export class AppCommandHandler { */ private loadedCommands = new Collection(); + /** + * Executable runtime commands indexed by canonical route key. + * This includes flat commands and hierarchical executable leaves. + * @private + * @internal + */ + private runtimeRouteIndex = new Collection(); + + /** + * Loaded hierarchical command nodes keyed by tree node id. + * Container nodes are cached here for registration compilation. + * @private + * @internal + */ + private hierarchicalNodes = new Collection(); + /** * @private * @internal @@ -225,17 +247,19 @@ export class AppCommandHandler { /** * Command runner instance for executing commands. */ - public readonly commandRunner = new AppCommandRunner(this); + public readonly commandRunner: AppCommandRunner = new AppCommandRunner(this); /** * External command data storage. */ - public readonly externalCommandData = new Collection(); + public readonly externalCommandData: Collection = + new Collection(); /** * External middleware data storage. */ - public readonly externalMiddlewareData = new Collection(); + public readonly externalMiddlewareData: Collection = + new Collection(); /** * Creates a new AppCommandHandler instance. @@ -248,101 +272,179 @@ export class AppCommandHandler { /** * Prints a formatted banner showing all loaded commands organized by category. */ - public printBanner() { + public printBanner(): void { const uncategorized = crypto.randomUUID(); - // Group commands by category - const categorizedCommands = this.getCommandsArray().reduce( - (acc, cmd) => { - const category = cmd.command.category || uncategorized; - acc[category] = acc[category] || []; - acc[category].push(cmd); - return acc; - }, - {} as Record, + // Collect flat commands + const flatCommands = this.getCommandsArray(); + + // Collect hierarchical root nodes from treeNodes (kind === 'command') + const treeNodes = Array.from( + this.commandkit.commandsRouter?.getData().treeNodes.values() ?? [], + ); + const hierarchicalRoots = treeNodes.filter( + (n) => n.kind === 'command' && n.source !== 'root', ); + // Total = flat commands + hierarchical roots (top-level slash commands) + const totalCount = flatCommands.length + hierarchicalRoots.length; + console.log( - colors.green( - `Loaded ${colors.magenta(this.loadedCommands.size.toString())} commands:`, - ), + colors.green(`Loaded ${colors.magenta(totalCount.toString())} commands:`), ); - const categories = Object.keys(categorizedCommands).sort(); + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + const printHierarchicalNode = ( + nodeId: string, + prefix: string, + indent: string, + ) => { + const node = treeNodes.find((n) => n.id === nodeId); + if (!node || node.kind === 'root') return; + + const loadedNode = this.hierarchicalNodes.get(nodeId); + const hasMw = + loadedNode && loadedNode.command.middlewares.length > 0 + ? colors.magenta(' (λ)') + : ''; + + const kindLabel = + node.kind === 'group' + ? colors.cyan(` [group]`) + : node.kind === 'command' + ? '' + : ''; + + console.log( + `${colors.green(prefix)} ${colors.yellow(node.token)}${kindLabel}${hasMw}`, + ); - // Build category tree for all nesting depths - const categoryTree: Record = {}; + // Render children + const children = node.childIds; + children.forEach((childId, idx) => { + const isLastChild = idx === children.length - 1; + const childPrefix = indent + (isLastChild ? '└─' : '├─'); + const childIndent = indent + (isLastChild ? ' ' : '│ '); + printHierarchicalNode(childId, childPrefix, childIndent); + }); + }; - // Find the best parent for nested categories - categories.forEach((category) => { - if (category === uncategorized) return; - - if (category.includes(':')) { - const parts = category.split(':'); - let bestParent = null; - - // Try to find the deepest existing parent - for (let i = parts.length - 1; i > 0; i--) { - const potentialParent = parts.slice(0, i).join(':'); - if (categories.includes(potentialParent)) { - bestParent = potentialParent; - break; - } - } + // ------------------------------------------------------------------ + // Group flat commands by category + // ------------------------------------------------------------------ + type BannerEntry = + | { type: 'flat'; cmd: LoadedCommand } + | { type: 'hierarchical'; root: (typeof hierarchicalRoots)[number] }; + + interface CategoryBucket { + flat: LoadedCommand[]; + hierarchical: (typeof hierarchicalRoots)[number][]; + } + + const categoryBuckets: Record = {}; + const ensureBucket = (cat: string) => { + categoryBuckets[cat] ??= { flat: [], hierarchical: [] }; + }; + + for (const cmd of flatCommands) { + const cat = cmd.command.category || uncategorized; + ensureBucket(cat); + categoryBuckets[cat].flat.push(cmd); + } + for (const root of hierarchicalRoots) { + const cat = root.category || uncategorized; + ensureBucket(cat); + categoryBuckets[cat].hierarchical.push(root); + } - // If we found a parent, add this category as its child - if (bestParent) { - categoryTree[bestParent] = categoryTree[bestParent] || []; - categoryTree[bestParent].push(category); + const categories = Object.keys(categoryBuckets).sort(); + + // Build category parent tree + const categoryTree: Record = {}; + categories.forEach((category) => { + if (category === uncategorized || !category.includes(':')) return; + const parts = category.split(':'); + for (let i = parts.length - 1; i > 0; i--) { + const potentialParent = parts.slice(0, i).join(':'); + if (categories.includes(potentialParent)) { + categoryTree[potentialParent] ??= []; + categoryTree[potentialParent].push(category); + break; } } }); - // Track categories we've processed to avoid duplicates const processedCategories = new Set(); - // Recursive function to print a category and its children const printCategory = ( category: string, indent: string = '', isLast: boolean = false, parentPrefix: string = '', ) => { - // Skip if already processed if (processedCategories.has(category)) return; processedCategories.add(category); - const commands = categorizedCommands[category]; + const bucket = categoryBuckets[category]; const hasChildren = categoryTree[category] && categoryTree[category].length > 0; + const allEntries = [...bucket.flat, ...bucket.hierarchical]; const thisPrefix = isLast ? '└─' : '├─'; const childIndent = parentPrefix + (isLast ? ' ' : '│ '); - // Print category name if not uncategorized if (category !== uncategorized) { - // For nested categories, only print the last part after the colon const displayName = category.includes(':') ? category.split(':').pop() : category; - console.log( colors.cyan(`${indent}${thisPrefix} ${colors.bold(displayName!)}`), ); } - // Print commands in this category - commands.forEach((cmd, cmdIndex) => { - const cmdIsLast = cmdIndex === commands.length - 1 && !hasChildren; - const cmdPrefix = cmdIsLast ? '└─' : '├─'; - const cmdIndent = category !== uncategorized ? childIndent : indent; + const cmdIndent = category !== uncategorized ? childIndent : indent; + const totalEntries = allEntries.length; + let entryIndex = 0; + // Print flat commands + bucket.flat.forEach((cmd) => { + const isLastEntry = entryIndex === totalEntries - 1 && !hasChildren; + const cmdPrefix = isLastEntry ? '└─' : '├─'; const name = cmd.data.command.name; const hasMw = cmd.command.middlewares.length > 0; const middlewareIcon = hasMw ? colors.magenta(' (λ)') : ''; - console.log( `${colors.green(`${cmdIndent}${cmdPrefix}`)} ${colors.yellow(name)}${middlewareIcon}`, ); + entryIndex++; + }); + + // Print hierarchical roots (with their sub-trees) + bucket.hierarchical.forEach((root) => { + const isLastEntry = entryIndex === totalEntries - 1 && !hasChildren; + const rootPrefix = cmdIndent + (isLastEntry ? '└─' : '├─'); + const rootChildIndent = cmdIndent + (isLastEntry ? ' ' : '│ '); + + const loadedNode = this.hierarchicalNodes.get(root.id); + const hasMw = + loadedNode && loadedNode.command.middlewares.length > 0 + ? colors.magenta(' (λ)') + : ''; + console.log( + `${colors.green(rootPrefix)} ${colors.yellow(root.token)}${hasMw}`, + ); + + // Print children of this root + root.childIds.forEach((childId, idx) => { + const isLastChild = idx === root.childIds.length - 1; + const childPrefix = rootChildIndent + (isLastChild ? '└─' : '├─'); + const childIndentNext = + rootChildIndent + (isLastChild ? ' ' : '│ '); + printHierarchicalNode(childId, childPrefix, childIndentNext); + }); + + entryIndex++; }); // Process nested categories @@ -355,28 +457,21 @@ export class AppCommandHandler { } }; - // Find and print top-level categories const topLevelCategories = categories .filter((category) => { if (category === uncategorized) return true; - if (category.includes(':')) { const parts = category.split(':'); - // Check if any parent path exists as a category for (let i = 1; i < parts.length; i++) { const parentPath = parts.slice(0, i).join(':'); - if (categories.includes(parentPath)) { - return false; // Not top-level, it has a parent - } + if (categories.includes(parentPath)) return false; } - return true; // No parent found, so it's top-level + return true; } - - return true; // Not nested, so it's top-level + return true; }) .sort(); - // Print each top-level category topLevelCategories.forEach((category, index) => { const isLast = index === topLevelCategories.length - 1; printCategory(category, '', isLast); @@ -387,14 +482,30 @@ export class AppCommandHandler { * Gets an array of all loaded commands, including pre-generated context menu entries. * @returns Array of loaded commands */ - public getCommandsArray() { + public getCommandsArray(): LoadedCommand[] { return Array.from(this.loadedCommands.values()); } + /** + * Gets all executable runtime routes, including hierarchical leaves. + * @returns Array of route-indexed commands + */ + public getRuntimeCommandsArray(): LoadedCommand[] { + return Array.from(this.runtimeRouteIndex.values()); + } + + /** + * Gets loaded hierarchical command nodes, including non-executable containers. + * @returns Array of hierarchical node definitions + */ + public getHierarchicalNodesArray(): LoadedCommand[] { + return Array.from(this.hierarchicalNodes.values()); + } + /** * Registers event handlers for Discord interactions and messages. */ - public registerCommandHandler() { + public registerCommandHandler(): void { this.onInteraction ??= async (interaction: Interaction) => { const success = await this.commandkit.plugins.execute( async (ctx, plugin) => { @@ -442,6 +553,49 @@ export class AppCommandHandler { this.commandkit.client.on(Events.MessageCreate, this.onMessageCreate); } + /** + * @private + * @internal + */ + private normalizeRouteKey(input: string) { + return input + .trim() + .replace(/[.:]/g, ' ') + .split(/\s+/) + .filter(Boolean) + .join('.'); + } + + /** + * @private + * @internal + */ + private buildInteractionRouteKey(source: Interaction) { + if (!source.isCommand() && !source.isAutocomplete()) { + return ''; + } + + const segments = [source.commandName]; + + if (source.isChatInputCommand() || source.isAutocomplete()) { + const group = source.options.getSubcommandGroup(false); + const subcommand = source.options.getSubcommand(false); + + if (group) segments.push(group); + if (subcommand) segments.push(subcommand); + } + + return segments.filter(Boolean).join('.'); + } + + /** + * @private + * @internal + */ + private buildMessageRouteKey(parser: MessageCommandParser) { + return parser.getFullRoute().join('.'); + } + /** * Prepares a command for execution by resolving the command and its middleware. * @param source - The interaction or message that triggered the command @@ -459,9 +613,16 @@ export class AppCommandHandler { } let parser: MessageCommandParser | undefined; + let routeKey: string | undefined; + let usedCommandOverride = false; + + if (cmdName) { + routeKey = this.normalizeRouteKey(cmdName); + usedCommandOverride = true; + } // Extract command name (and possibly subcommand) from the source - if (!cmdName) { + if (!routeKey) { if (source instanceof Message) { if (source.author.bot) return null; @@ -483,8 +644,7 @@ export class AppCommandHandler { ? prefix : [prefix], (command: string) => { - // Find the command by name - const loadedCommand = this.findCommandByName(command); + const loadedCommand = this.findCommandByRoute(command); if (!loadedCommand) { if ( COMMANDKIT_IS_DEV && @@ -521,9 +681,7 @@ export class AppCommandHandler { ); try { - const fullCommand = parser.getFullCommand(); - const parts = fullCommand.split(' '); - cmdName = parts[0]; + routeKey = this.buildMessageRouteKey(parser); } catch (e) { if (isErrorType(e, CommandKitErrorCodes.InvalidCommandPrefix)) { return null; @@ -539,7 +697,7 @@ export class AppCommandHandler { if (!isAnyCommand) return null; - cmdName = source.commandName; + routeKey = this.buildInteractionRouteKey(source); } } @@ -551,7 +709,9 @@ export class AppCommandHandler { ? 'message' : undefined : undefined; - const loadedCommand = this.findCommandByName(cmdName, hint); + const loadedCommand = hint + ? this.findCommandByName(routeKey!, hint) + : this.findCommandByRoute(routeKey!, usedCommandOverride); if (!loadedCommand) return null; // If this is a guild specific command, check if we're in the right guild @@ -648,28 +808,68 @@ export class AppCommandHandler { return null; } - public resolveMessageCommandName(name: string): string { - for (const [, loadedCommand] of this.loadedCommands) { - if (loadedCommand.data.command.name === name) { - return loadedCommand.data.command.name; - } + /** + * Finds a command by its canonical route key. + * @param route - The command route or command name + * @param allowFlatAliasFallback - Whether to check flat aliases if the route key was not found + * @returns The loaded command or null if not found + */ + private findCommandByRoute( + route: string, + allowFlatAliasFallback = true, + ): LoadedCommand | null { + const normalizedRoute = this.normalizeRouteKey(route); + const directMatch = this.runtimeRouteIndex.get(normalizedRoute); + if (directMatch) return directMatch; - const aliases = loadedCommand.data.metadata?.aliases; + if (!allowFlatAliasFallback || normalizedRoute.includes('.')) { + return null; + } - if (aliases && Array.isArray(aliases) && aliases.includes(name)) { - return loadedCommand.data.command.name; + for (const loadedCommand of this.runtimeRouteIndex.values()) { + const aliases = loadedCommand.data.metadata?.aliases; + if ( + aliases && + Array.isArray(aliases) && + aliases.includes(normalizedRoute) + ) { + return loadedCommand; } } + return null; + } + + /** + * @private + * @internal + */ + private getRouteKeyFor(command: LoadedCommand) { + return ( + (command.data.command as Record).__routeKey ?? + this.normalizeRouteKey(command.data.command.name) + ); + } + + public resolveMessageCommandName(name: string): string { + const loadedCommand = this.findCommandByRoute(name); + if (loadedCommand) { + return this.getRouteKeyFor(loadedCommand); + } + return name; } /** * Reloads all commands and middleware from scratch. */ - public async reloadCommands() { + public async reloadCommands(): Promise { + await this.commandkit.commandsRouter?.scan(); + this.loadedCommands.clear(); this.loadedMiddlewares.clear(); + this.runtimeRouteIndex.clear(); + this.hierarchicalNodes.clear(); this.externalCommandData.clear(); this.externalMiddlewareData.clear(); @@ -680,7 +880,7 @@ export class AppCommandHandler { * Adds external middleware data to be loaded. * @param data - Array of middleware data to add */ - public async addExternalMiddleware(data: Middleware[]) { + public async addExternalMiddleware(data: Middleware[]): Promise { for (const middleware of data) { if (!middleware.id) continue; @@ -692,7 +892,7 @@ export class AppCommandHandler { * Adds external command data to be loaded. * @param data - Array of command data to add */ - public async addExternalCommands(data: Command[]) { + public async addExternalCommands(data: Command[]): Promise { for (const command of data) { if (!command.id) continue; @@ -704,7 +904,9 @@ export class AppCommandHandler { * Registers externally loaded middleware. * @param data - Array of loaded middleware to register */ - public async registerExternalLoadedMiddleware(data: LoadedMiddleware[]) { + public async registerExternalLoadedMiddleware( + data: LoadedMiddleware[], + ): Promise { for (const middleware of data) { this.loadedMiddlewares.set(middleware.middleware.id, middleware); } @@ -714,16 +916,19 @@ export class AppCommandHandler { * Registers externally loaded commands. * @param data - Array of loaded commands to register */ - public async registerExternalLoadedCommands(data: LoadedCommand[]) { + public async registerExternalLoadedCommands( + data: LoadedCommand[], + ): Promise { for (const command of data) { this.loadedCommands.set(command.command.id, command); + this.registerRuntimeRoute(command); } } /** * Loads all commands and middleware from the router. */ - public async loadCommands() { + public async loadCommands(): Promise { await this.commandkit.plugins.execute((ctx, plugin) => { return plugin.onBeforeCommandsLoad(ctx); }); @@ -734,7 +939,8 @@ export class AppCommandHandler { throw new Error('Commands router has not yet initialized'); } - const { commands, middlewares } = commandsRouter.getData(); + const { commands, middlewares, treeNodes, compiledRoutes } = + commandsRouter.getData(); const combinedCommands = this.externalCommandData.size ? commands.concat(this.externalCommandData) @@ -754,16 +960,26 @@ export class AppCommandHandler { await this.loadCommand(id, command); } + const hierarchicalNodes = Array.from(treeNodes.values()) + .filter((node) => node.source !== 'flat' && !!node.definitionPath) + .sort((left, right) => left.route.length - right.route.length); + + for (const node of hierarchicalNodes) { + const routeKey = node.route.join('.'); + await this.loadHierarchicalNode( + node, + compiledRoutes.get(routeKey) ?? undefined, + ); + } + // generate types if (COMMANDKIT_IS_DEV) { - const commandNames = Array.from(this.loadedCommands.values()).map( - (v) => v.data.command.name, - ); - const aliases = Array.from(this.loadedCommands.values()).flatMap( + const commandNames = Array.from(this.runtimeRouteIndex.keys()); + const aliases = Array.from(this.runtimeRouteIndex.values()).flatMap( (v) => v.metadata.aliases || [], ); - const allNames = [...commandNames, ...aliases]; + const allNames = Array.from(new Set([...commandNames, ...aliases])); await rewriteCommandDeclaration( `type CommandTypeData = ${allNames.map((name) => JSON.stringify(name)).join(' | ')}`, @@ -808,6 +1024,153 @@ export class AppCommandHandler { } } + /** + * @private + * @internal + */ + private shouldIndexAsRuntimeRoute(command: LoadedCommand) { + return !!( + command.data.chatInput || + command.data.message || + command.data.autocomplete + ); + } + + /** + * @private + * @internal + */ + private registerRuntimeRoute(command: LoadedCommand, routeKey?: string) { + if (!this.shouldIndexAsRuntimeRoute(command)) return; + + const key = this.normalizeRouteKey(routeKey ?? command.data.command.name); + if (!key) return; + + const commandData = command.data.command as Record; + commandData.__routeKey ??= key; + + this.runtimeRouteIndex.set(key, command); + } + + /** + * @private + * @internal + */ + private async processCommandFile( + fileUrl: string, + identifier: string, + fallbackName: string, + isHierarchical: boolean, + ) { + const commandFileData = (await import( + `${toFileURL(fileUrl)}?t=${Date.now()}` + )) as AppCommandNative; + + if (!commandFileData.command) { + throw new Error( + `Invalid export for ${isHierarchical ? 'hierarchical node' : 'command'} ${identifier}: no command definition found`, + ); + } + + const metadataFunc = commandFileData.generateMetadata; + const metadataObj = commandFileData.metadata; + + if (metadataFunc && metadataObj) { + throw new Error( + 'A command may only export either `generateMetadata` or `metadata`, not both', + ); + } + + const metadata = (metadataFunc ? await metadataFunc() : metadataObj) ?? { + aliases: [], + guilds: [], + userPermissions: [], + botPermissions: [], + }; + + let commandName = commandFileData.command.name; + + if (isHierarchical) { + if (typeof commandName === 'string' && commandName !== fallbackName) { + Logger.warn( + `Hierarchical node \`${identifier}\` overrides its command name with \`${commandName}\`. The filesystem token \`${fallbackName}\` will be used instead.`, + ); + } + commandName = fallbackName; + } else { + commandName = commandName || fallbackName; + } + + let commandDescription = commandFileData.command.description as + | string + | undefined; + + if (!commandDescription && commandFileData.chatInput) { + commandDescription = 'No command description set.'; + } + + const updatedCommandData = { + ...commandFileData.command, + name: commandName, + description: commandDescription, + } as CommandData; + + let handlerCount = 0; + + for (const [key, propValidator] of Object.entries(commandDataSchema) as [ + CommandDataSchemaKey, + CommandDataSchemaValue, + ][]) { + const exportedProp = commandFileData[key]; + + if (exportedProp) { + if (!(await propValidator(exportedProp))) { + throw new Error( + `Invalid export for ${isHierarchical ? 'hierarchical node' : 'command'} ${identifier}: ${key} does not match expected value`, + ); + } + + if (!KNOWN_NON_HANDLER_KEYS.includes(key)) { + handlerCount++; + } + } + } + + let lastUpdated = updatedCommandData; + + await this.commandkit.plugins.execute(async (ctx, plugin) => { + const res = await plugin.prepareCommand(ctx, lastUpdated); + + if (res) { + lastUpdated = res as CommandData; + } + }); + + const commandJson = + 'toJSON' in lastUpdated && typeof lastUpdated.toJSON === 'function' + ? lastUpdated.toJSON() + : lastUpdated; + + if ('guilds' in commandJson || 'aliases' in commandJson) { + Logger.warn( + `Command \`${identifier}\` uses deprecated metadata properties. Please update to use the new \`metadata\` object or \`generateMetadata\` function.`, + ); + } + + const resolvedMetadata = { + guilds: commandJson.guilds, + aliases: commandJson.aliases, + ...metadata, + }; + + return { + commandFileData, + handlerCount, + commandJson, + resolvedMetadata, + }; + } + /** * @private * @internal @@ -816,7 +1179,7 @@ export class AppCommandHandler { try { // Skip if path is null (directory-only command group) - external plugins if (command.path === null) { - this.loadedCommands.set(id, { + const loadedCommand: LoadedCommand = { discordId: null, command, metadata: { @@ -830,131 +1193,141 @@ export class AppCommandHandler { name: command.name, }, }, - }); + }; + + this.loadedCommands.set(id, loadedCommand); + this.registerRuntimeRoute(loadedCommand); return; } - const commandFileData = (await import( - `${toFileURL(command.path)}?t=${Date.now()}` - )) as AppCommandNative; - - if (!commandFileData.command) { - throw new Error( - `Invalid export for command ${command.name}: no command definition found`, + const { commandFileData, handlerCount, commandJson, resolvedMetadata } = + await this.processCommandFile( + command.path, + command.name, + command.name, + false, ); - } - const metadataFunc = commandFileData.generateMetadata; - const metadataObj = commandFileData.metadata; - - if (metadataFunc && metadataObj) { + if (handlerCount === 0) { throw new Error( - 'A command may only export either `generateMetadata` or `metadata`, not both', + `Invalid export for command ${command.name}: at least one handler function must be provided`, ); } - const metadata = (metadataFunc ? await metadataFunc() : metadataObj) ?? { - aliases: [], - guilds: [], - userPermissions: [], - botPermissions: [], + const loadedCommand: LoadedCommand = { + discordId: null, + command, + metadata: resolvedMetadata, + data: { + ...commandFileData, + metadata: resolvedMetadata, + command: commandJson, + }, }; - // Apply the specified logic for name and description - const commandName = commandFileData.command.name || command.name; - let commandDescription = commandFileData.command.description as - | string - | undefined; - - // since `CommandData.description` is optional, set a fallback description if none provided - if (!commandDescription && commandFileData.chatInput) { - commandDescription = 'No command description set.'; - } + this.loadedCommands.set(id, loadedCommand); + this.registerRuntimeRoute(loadedCommand); - // Update the command data with resolved name and description - const updatedCommandData = { - ...commandFileData.command, - name: commandName, - description: commandDescription, - } as CommandData; + // Pre-generate context menu commands so the handler cache + // is aware of them before CommandRegistrar runs (#558) + this.generateContextMenuCommands( + id, + command, + commandFileData, + commandJson, + resolvedMetadata, + ); + } catch (error) { + Logger.error`Failed to load command ${command.name} (${id}): ${error}`; + } + } - let handlerCount = 0; + /** + * Loads a hierarchical command node into the hierarchical cache. + * Executable leaves are also added to the runtime route index. + * @private + * @internal + */ + private async loadHierarchicalNode( + node: CommandTreeNode, + compiledRoute?: CompiledCommandRoute, + ) { + if (!node.definitionPath) return; + + const routeKey = node.route.join('.'); + const command: Command = { + id: node.id, + name: routeKey, + path: node.definitionPath, + relativePath: compiledRoute?.relativePath ?? node.relativePath, + parentPath: dirname(node.definitionPath), + middlewares: compiledRoute ? [...compiledRoute.middlewares] : [], + category: node.category, + }; - for (const [key, propValidator] of Object.entries(commandDataSchema) as [ - CommandDataSchemaKey, - CommandDataSchemaValue, - ][]) { - const exportedProp = commandFileData[key]; + try { + const { commandFileData, handlerCount, commandJson, resolvedMetadata } = + await this.processCommandFile(command.path, routeKey, node.token, true); - if (exportedProp) { - if (!(await propValidator(exportedProp))) { - throw new Error( - `Invalid export for command ${command.name}: ${key} does not match expected value`, - ); - } + const isRootHierarchyLeaf = node.kind === 'command'; + const hasContextMenuHandlers = !!( + commandFileData.userContextMenu || commandFileData.messageContextMenu + ); + const hasExecutableSlashHandlers = !!( + commandFileData.chatInput || + commandFileData.message || + commandFileData.autocomplete + ); - if (!KNOWN_NON_HANDLER_KEYS.includes(key)) { - // command file includes a handler function (chatInput, message, etc) - handlerCount++; - } - } + if (!isRootHierarchyLeaf && hasContextMenuHandlers) { + throw new Error( + `Invalid export for hierarchical node ${routeKey}: context menu handlers are only supported for top-level root commands.`, + ); } - if (handlerCount === 0) { + if (node.executable && handlerCount === 0) { throw new Error( - `Invalid export for command ${command.name}: at least one handler function must be provided`, + `Invalid export for hierarchical node ${routeKey}: executable leaves must provide at least one handler function`, ); } - let lastUpdated = updatedCommandData; - - await this.commandkit.plugins.execute(async (ctx, plugin) => { - const res = await plugin.prepareCommand(ctx, lastUpdated); - - if (res) { - lastUpdated = res as CommandData; - } - }); - - const commandJson = - 'toJSON' in lastUpdated && typeof lastUpdated.toJSON === 'function' - ? lastUpdated.toJSON() - : lastUpdated; - - if ('guilds' in commandJson || 'aliases' in commandJson) { - Logger.warn( - `Command \`${command.name}\` uses deprecated metadata properties. Please update to use the new \`metadata\` object or \`generateMetadata\` function.`, + if (!node.executable && hasExecutableSlashHandlers) { + throw new Error( + `Invalid export for hierarchical node ${routeKey}: non-leaf hierarchical nodes cannot export executable slash/prefix handlers`, ); } - const resolvedMetadata = { - guilds: commandJson.guilds, - aliases: commandJson.aliases, - ...metadata, - }; - - this.loadedCommands.set(id, { + const loadedCommand: LoadedCommand = { discordId: null, command, metadata: resolvedMetadata, data: { ...commandFileData, metadata: resolvedMetadata, - command: commandJson, + command: { + ...commandJson, + __routeKey: routeKey, + }, }, - }); + }; - // Pre-generate context menu commands so the handler cache - // is aware of them before CommandRegistrar runs (#558) - this.generateContextMenuCommands( - id, - command, - commandFileData, - commandJson, - resolvedMetadata, - ); + this.hierarchicalNodes.set(node.id, loadedCommand); + + if (node.executable) { + this.registerRuntimeRoute(loadedCommand, routeKey); + } + + if (isRootHierarchyLeaf && hasContextMenuHandlers) { + this.generateContextMenuCommands( + node.id, + command, + commandFileData, + commandJson, + resolvedMetadata, + ); + } } catch (error) { - Logger.error`Failed to load command ${command.name} (${id}): ${error}`; + Logger.error`Failed to load hierarchical node ${routeKey} (${node.id}): ${error}`; } } @@ -968,7 +1341,9 @@ export class AppCommandHandler { command: string, hint?: 'user' | 'message', ): CommandMetadata | null { - const loadedCommand = this.findCommandByName(command, hint); + const loadedCommand = hint + ? this.findCommandByName(command, hint) + : this.findCommandByRoute(command); if (!loadedCommand) return null; return (loadedCommand.metadata ??= { diff --git a/packages/commandkit/src/app/register/CommandRegistrar.ts b/packages/commandkit/src/app/register/CommandRegistrar.ts index 41b5198b..591499ac 100644 --- a/packages/commandkit/src/app/register/CommandRegistrar.ts +++ b/packages/commandkit/src/app/register/CommandRegistrar.ts @@ -1,8 +1,18 @@ -import { ApplicationCommandType, REST, Routes } from 'discord.js'; +import { + ApplicationCommandOptionType, + ApplicationCommandType, + REST, + Routes, +} from 'discord.js'; import type { CommandKit } from '../../commandkit'; import { CommandData, CommandMetadata } from '../../types'; import { Logger } from '../../logger/Logger'; +type RegistrationCommandData = CommandData & { + __metadata?: CommandMetadata; + __applyId(id: string): void; +}; + /** * Event object passed to plugins before command registration. */ @@ -37,10 +47,17 @@ export class CommandRegistrar { /** * Gets the commands data, consuming pre-generated context menu entries when available. */ - public getCommandsData(): (CommandData & { - __metadata?: CommandMetadata; - __applyId(id: string): void; - })[] { + public getCommandsData(): RegistrationCommandData[] { + return [ + ...this.getFlatCommandsData(), + ...this.getHierarchicalCommandsData(), + ]; + } + + /** + * Gets flat command data, consuming pre-generated context menu entries when available. + */ + private getFlatCommandsData(): RegistrationCommandData[] { const handler = this.commandkit.commandHandler; const commands = handler.getCommandsArray(); const commandIds = new Set(commands.map((command) => command.command.id)); @@ -50,10 +67,11 @@ export class CommandRegistrar { cmd.command.id.endsWith('::user-ctx') || cmd.command.id.endsWith('::message-ctx'); - const json: CommandData = + const json = this.sanitizeCommandData( 'toJSON' in cmd.data.command ? cmd.data.command.toJSON() - : cmd.data.command; + : cmd.data.command, + ); const __metadata = cmd.metadata ?? cmd.data.metadata; const isContextMenuType = @@ -74,10 +92,7 @@ export class CommandRegistrar { ]; } - const collections: (CommandData & { - __metadata?: CommandMetadata; - __applyId(id: string): void; - })[] = []; + const collections: RegistrationCommandData[] = []; const hasPreGeneratedUserContextMenu = commandIds.has( `${cmd.command.id}::user-ctx`, ); @@ -127,6 +142,243 @@ export class CommandRegistrar { }); } + /** + * Gets hierarchical chat-input command payloads compiled from cached tree nodes. + */ + private getHierarchicalCommandsData(): RegistrationCommandData[] { + const router = this.commandkit.commandsRouter; + if (!router) return []; + + const { treeNodes } = router.getData(); + const hierarchicalNodes = new Map( + this.commandkit.commandHandler + .getHierarchicalNodesArray() + .map((node) => [node.command.id, node] as const), + ); + + const rootNodes = Array.from(treeNodes.values()).filter((node) => { + return ( + node.source !== 'flat' && + node.kind === 'command' && + node.route.length === 1 + ); + }); + + const commands: RegistrationCommandData[] = []; + + for (const rootNode of rootNodes) { + const payload = this.buildHierarchicalRootPayload( + rootNode.id, + treeNodes, + hierarchicalNodes, + ); + + if (payload) { + commands.push(payload); + } + } + + return commands; + } + + /** + * Removes internal runtime-only fields before Discord registration data is emitted. + */ + private sanitizeCommandData(command: CommandData | Record) { + const { __routeKey, ...json } = command as Record; + return json as CommandData; + } + + /** + * Builds a top-level Discord payload for a hierarchical command root. + */ + private buildHierarchicalRootPayload( + rootNodeId: string, + treeNodes: ReturnType['treeNodes'], + hierarchicalNodes: Map< + string, + ReturnType< + CommandKit['commandHandler']['getHierarchicalNodesArray'] + >[number] + >, + ): RegistrationCommandData | null { + const rootNode = treeNodes.get(rootNodeId); + const rootLoaded = hierarchicalNodes.get(rootNodeId); + + if (!rootNode || !rootLoaded) { + return null; + } + + const rootJson = this.sanitizeCommandData( + 'toJSON' in rootLoaded.data.command + ? rootLoaded.data.command.toJSON() + : rootLoaded.data.command, + ); + + if (rootNode.executable) { + if (!rootLoaded.data.chatInput) return null; + + return { + ...rootJson, + type: ApplicationCommandType.ChatInput, + description: rootJson.description ?? 'No command description set.', + __metadata: rootLoaded.metadata ?? rootLoaded.data.metadata, + __applyId: (id: string) => { + rootLoaded.discordId = id; + }, + }; + } + + const options = rootNode.childIds + .map((childId) => + this.buildHierarchicalOption(childId, treeNodes, hierarchicalNodes), + ) + .filter(Boolean) as Record[]; + + if (!options.length) { + return null; + } + + const scopeKeys = new Set( + this.collectHierarchicalGuildScopes( + rootNode.childIds, + treeNodes, + hierarchicalNodes, + ), + ); + + if (scopeKeys.size > 1) { + Logger.error( + `Failed to register hierarchical command "${rootJson.name ?? rootNode.token}": all chat-input leaves under the same root must use the same guild scope in v1.`, + ); + return null; + } + + const scopeKey = scopeKeys.values().next().value as string | undefined; + const scopeGuilds = scopeKey ? scopeKey.split(',').filter(Boolean) : []; + const metadata = { + ...(rootLoaded.metadata ?? rootLoaded.data.metadata), + guilds: scopeGuilds.length ? scopeGuilds : undefined, + }; + + return { + ...rootJson, + type: ApplicationCommandType.ChatInput, + description: rootJson.description ?? 'No command description set.', + options: options as CommandData['options'], + __metadata: metadata, + __applyId: (id: string) => { + rootLoaded.discordId = id; + }, + }; + } + + /** + * Builds a nested subcommand or subcommand-group option from a hierarchical node. + */ + private buildHierarchicalOption( + nodeId: string, + treeNodes: ReturnType['treeNodes'], + hierarchicalNodes: Map< + string, + ReturnType< + CommandKit['commandHandler']['getHierarchicalNodesArray'] + >[number] + >, + ): Record | null { + const node = treeNodes.get(nodeId); + const loadedNode = hierarchicalNodes.get(nodeId); + + if (!node || !loadedNode) { + return null; + } + + const json = this.sanitizeCommandData( + 'toJSON' in loadedNode.data.command + ? loadedNode.data.command.toJSON() + : loadedNode.data.command, + ); + + if (node.kind === 'group') { + const options = node.childIds + .map((childId) => + this.buildHierarchicalOption(childId, treeNodes, hierarchicalNodes), + ) + .filter(Boolean) as Record[]; + + if (!options.length) { + return null; + } + + return { + ...json, + type: ApplicationCommandOptionType.SubcommandGroup, + description: json.description ?? 'No command description set.', + options: options as CommandData['options'], + }; + } + + if (!node.executable || !loadedNode.data.chatInput) { + return null; + } + + return { + ...json, + type: ApplicationCommandOptionType.Subcommand, + description: json.description ?? 'No command description set.', + }; + } + + /** + * Collects normalized guild scopes for all chat-input leaves within a hierarchical subtree. + */ + private collectHierarchicalGuildScopes( + nodeIds: string[], + treeNodes: ReturnType['treeNodes'], + hierarchicalNodes: Map< + string, + ReturnType< + CommandKit['commandHandler']['getHierarchicalNodesArray'] + >[number] + >, + ) { + const scopes: string[] = []; + + for (const nodeId of nodeIds) { + const node = treeNodes.get(nodeId); + const loadedNode = hierarchicalNodes.get(nodeId); + + if (!node || !loadedNode) { + continue; + } + + if (node.kind === 'group') { + scopes.push( + ...this.collectHierarchicalGuildScopes( + node.childIds, + treeNodes, + hierarchicalNodes, + ), + ); + continue; + } + + if (!node.executable || !loadedNode.data.chatInput) { + continue; + } + + scopes.push( + (loadedNode.metadata?.guilds ?? []) + .filter(Boolean) + .slice() + .sort() + .join(','), + ); + } + + return scopes; + } + /** * Registers loaded commands. */ @@ -173,12 +425,7 @@ export class CommandRegistrar { /** * Updates the global commands. */ - public async updateGlobalCommands( - commands: (CommandData & { - __metadata?: CommandMetadata; - __applyId(id: string): void; - })[], - ) { + public async updateGlobalCommands(commands: RegistrationCommandData[]) { if (!commands.length) return; let prevented = false; @@ -227,12 +474,7 @@ export class CommandRegistrar { /** * Updates the guild commands. */ - public async updateGuildCommands( - commands: (CommandData & { - __metadata?: CommandMetadata; - __applyId(id: string): void; - })[], - ) { + public async updateGuildCommands(commands: RegistrationCommandData[]) { if (!commands.length) return; let prevented = false; diff --git a/packages/commandkit/src/app/router/CommandTree.ts b/packages/commandkit/src/app/router/CommandTree.ts new file mode 100644 index 00000000..763680b0 --- /dev/null +++ b/packages/commandkit/src/app/router/CommandTree.ts @@ -0,0 +1,65 @@ +/** + * Source types for command tree nodes discovered from the filesystem. + */ +export type CommandTreeNodeSource = + | 'root' + | 'flat' + | 'directory' + | 'group' + | 'shorthand'; + +/** + * Logical node kinds after tree compilation. + */ +export type CommandTreeNodeKind = + | 'root' + | 'flat' + | 'command' + | 'group' + | 'subcommand'; + +/** + * Internal tree node representing either a filesystem command or a + * hierarchical container. + */ +export interface CommandTreeNode { + id: string; + source: CommandTreeNodeSource; + kind: CommandTreeNodeKind; + token: string; + route: string[]; + category: string | null; + parentId: string | null; + childIds: string[]; + directoryPath: string; + definitionPath: string | null; + relativePath: string; + shorthand: boolean; + executable: boolean; +} + +/** + * Executable command route produced from the internal tree. + */ +export interface CompiledCommandRoute { + id: string; + key: string; + kind: Exclude; + token: string; + route: string[]; + category: string | null; + definitionPath: string; + relativePath: string; + nodeId: string; + middlewares: string[]; +} + +/** + * Validation or compilation diagnostic emitted while building the + * command tree. + */ +export interface CommandRouteDiagnostic { + code: string; + message: string; + path: string; +} diff --git a/packages/commandkit/src/app/router/CommandsRouter.ts b/packages/commandkit/src/app/router/CommandsRouter.ts index 8101bf5f..5dee3604 100644 --- a/packages/commandkit/src/app/router/CommandsRouter.ts +++ b/packages/commandkit/src/app/router/CommandsRouter.ts @@ -1,7 +1,13 @@ import { Collection } from 'discord.js'; import { Dirent, existsSync } from 'node:fs'; import { readdir } from 'node:fs/promises'; -import { basename, extname, join, normalize } from 'node:path'; +import { basename, dirname, extname, join, normalize } from 'node:path'; +import { + CommandRouteDiagnostic, + CommandTreeNode, + CommandTreeNodeKind, + CompiledCommandRoute, +} from './CommandTree'; /** * Represents a command with its metadata and middleware associations. @@ -30,11 +36,14 @@ export interface Middleware { } /** - * Data structure containing parsed commands and middleware. + * Data structure containing parsed commands, middleware, and tree data. */ export interface ParsedCommandData { commands: Record; middlewares: Record; + treeNodes?: Record; + compiledRoutes?: Record; + diagnostics?: CommandRouteDiagnostic[]; } /** @@ -44,6 +53,8 @@ export interface CommandsRouterOptions { entrypoint: string; } +const ROOT_NODE_ID = '__commandkit_router_root__'; + /** * @private * @internal @@ -75,6 +86,41 @@ const COMMAND_PATTERN = /^([^+().][^().]*)\.(m|c)?(j|t)sx?$/; */ const CATEGORY_PATTERN = /^\(.+\)$/; +/** + * @private + * @internal + */ +const COMMAND_DIRECTORY_PATTERN = /^\[([^\][\\\/]+)\]$/; + +/** + * @private + * @internal + */ +const GROUP_DIRECTORY_PATTERN = /^\{([^}{\\\/]+)\}$/; + +/** + * @private + * @internal + */ +/** + * @private + * @internal + */ +const COMMAND_DEFINITION_PATTERN = /^command\.(m|c)?(j|t)sx?$/; + +/** + * @private + * @internal + */ +const GROUP_DEFINITION_PATTERN = /^group\.(m|c)?(j|t)sx?$/; + +/** + * @private + * @internal + */ +const SUBCOMMAND_FILE_PATTERN = + /^([^+().][^().]*)\.subcommand\.(m|c)?(j|t)sx?$/; + /** * Handles discovery and parsing of command and middleware files in the filesystem. */ @@ -91,6 +137,24 @@ export class CommandsRouter { */ private middlewares = new Collection(); + /** + * @private + * @internal + */ + private treeNodes = new Collection(); + + /** + * @private + * @internal + */ + private compiledRoutes = new Collection(); + + /** + * @private + * @internal + */ + private diagnostics: CommandRouteDiagnostic[] = []; + /** * Creates a new CommandsRouter instance. * @param options - Configuration options for the router @@ -98,10 +162,12 @@ export class CommandsRouter { public constructor(private readonly options: CommandsRouterOptions) {} /** - * Populates the router with existing command and middleware data. + * Populates the router with existing command, middleware, and tree data. * @param data - Parsed command data to populate with */ - public populate(data: ParsedCommandData) { + public populate(data: ParsedCommandData): void { + this.clear(); + for (const [id, command] of Object.entries(data.commands)) { this.commands.set(id, command); } @@ -109,6 +175,16 @@ export class CommandsRouter { for (const [id, middleware] of Object.entries(data.middlewares)) { this.middlewares.set(id, middleware); } + + for (const [id, node] of Object.entries(data.treeNodes ?? {})) { + this.treeNodes.set(id, node); + } + + for (const [key, route] of Object.entries(data.compiledRoutes ?? {})) { + this.compiledRoutes.set(key, route); + } + + this.diagnostics = [...(data.diagnostics ?? [])]; } /** @@ -148,63 +224,124 @@ export class CommandsRouter { } /** - * Clears all loaded commands and middleware. + * @private + * @internal */ - public clear() { - this.commands.clear(); - this.middlewares.clear(); + private isCommandDirectory(name: string): boolean { + return COMMAND_DIRECTORY_PATTERN.test(name); } /** - * Scans the filesystem for commands and middleware files. - * @returns Parsed command data + * @private + * @internal */ - public async scan() { - const entries = await readdir(this.options.entrypoint, { - withFileTypes: true, - }); + private isGroupDirectory(name: string): boolean { + return GROUP_DIRECTORY_PATTERN.test(name); + } - for (const entry of entries) { - // ignore _ prefixed files - if (entry.name.startsWith('_')) continue; + /** + * @private + * @internal + */ + private isCommandDefinition(name: string): boolean { + return COMMAND_DEFINITION_PATTERN.test(name); + } - const fullPath = join(this.options.entrypoint, entry.name); + /** + * @private + * @internal + */ + private isGroupDefinition(name: string): boolean { + return GROUP_DEFINITION_PATTERN.test(name); + } + + /** + * @private + * @internal + */ + private isSubcommandFile(name: string): boolean { + return SUBCOMMAND_FILE_PATTERN.test(name); + } - if (entry.isDirectory()) { - const category = this.isCategory(entry.name) - ? entry.name.slice(1, -1) - : null; + /** + * Clears all loaded commands, middleware, and compiled tree data. + */ + public clear(): void { + this.commands.clear(); + this.middlewares.clear(); + this.treeNodes.clear(); + this.compiledRoutes.clear(); + this.diagnostics = []; + } - await this.traverse(fullPath, category); - } else { - await this.handle(entry); - } - } + /** + * Scans the filesystem for commands and middleware files. + * @returns Parsed command data + */ + public async scan(): Promise { + this.clear(); + this.initializeRootNode(); + + await this.traverseDirectory( + this.options.entrypoint, + 'normal', + null, + ROOT_NODE_ID, + ); await this.applyMiddlewares(); + this.compileTree(); return this.toJSON(); } /** - * Gets the raw command and middleware collections. - * @returns Object containing commands and middlewares collections + * Gets the raw command, middleware, and compiled tree collections. + * @returns Object containing router collections */ - public getData() { + public getData(): { + commands: Collection; + middlewares: Collection; + treeNodes: Collection; + compiledRoutes: Collection; + diagnostics: CommandRouteDiagnostic[]; + } { return { commands: this.commands, middlewares: this.middlewares, + treeNodes: this.treeNodes, + compiledRoutes: this.compiledRoutes, + diagnostics: this.diagnostics, + }; + } + + /** + * Gets only the internal command tree and compiled route data. + * @returns Object containing tree data + */ + public getTreeData(): { + treeNodes: Collection; + compiledRoutes: Collection; + diagnostics: CommandRouteDiagnostic[]; + } { + return { + treeNodes: this.treeNodes, + compiledRoutes: this.compiledRoutes, + diagnostics: this.diagnostics, }; } /** * Converts the loaded data to a JSON-serializable format. - * @returns Plain object with commands and middlewares + * @returns Plain object with commands, middleware, and tree data */ - public toJSON() { + public toJSON(): ParsedCommandData { return { commands: Object.fromEntries(this.commands.entries()), middlewares: Object.fromEntries(this.middlewares.entries()), + treeNodes: Object.fromEntries(this.treeNodes.entries()), + compiledRoutes: Object.fromEntries(this.compiledRoutes.entries()), + diagnostics: this.diagnostics, }; } @@ -212,35 +349,453 @@ export class CommandsRouter { * @private * @internal */ - private async traverse(path: string, category: string | null) { + private initializeRootNode() { + this.treeNodes.set(ROOT_NODE_ID, { + id: ROOT_NODE_ID, + source: 'root', + kind: 'root', + token: '', + route: [], + category: null, + parentId: null, + childIds: [], + directoryPath: this.options.entrypoint, + definitionPath: null, + relativePath: '', + shorthand: false, + executable: false, + }); + } + + /** + * @private + * @internal + */ + private async traverseDirectory( + path: string, + state: 'normal' | 'command' | 'group', + category: string | null, + parentId: string, + token?: string, + ) { + let node: CommandTreeNode | null = null; + + if (state === 'command') { + node = this.createTreeNode({ + source: 'directory', + token: token!, + category, + parentId, + directoryPath: path, + definitionPath: null, + shorthand: false, + }); + if (!node) return; + } else if (state === 'group') { + node = this.createTreeNode({ + source: 'group', + token: token!, + category, + parentId, + directoryPath: path, + definitionPath: null, + shorthand: false, + }); + if (!node) return; + } + + const currentNodeId = node ? node.id : parentId; + const entries = await readdir(path, { withFileTypes: true, }); for (const entry of entries) { - // ignore _ prefixed files if (entry.name.startsWith('_')) continue; + const fullPath = join(path, entry.name); + if (entry.isFile()) { - if (this.isCommand(entry.name) || this.isMiddleware(entry.name)) { - await this.handle(entry, category); + if (state === 'command') { + if (this.isCommandDefinition(entry.name)) { + node!.definitionPath = fullPath; + node!.relativePath = this.replaceEntrypoint(fullPath); + continue; + } + if (this.isSubcommandFile(entry.name)) { + this.createTreeNode({ + source: 'shorthand', + token: entry.name.match(SUBCOMMAND_FILE_PATTERN)![1], + category, + parentId: currentNodeId, + directoryPath: path, + definitionPath: fullPath, + shorthand: true, + }); + continue; + } + if (this.isMiddleware(entry.name)) { + await this.handle(entry, category); + continue; + } + if (this.isCommand(entry.name)) { + this.addDiagnostic( + 'UNSUPPORTED_FILE_IN_COMMAND_DIRECTORY', + 'Only command.ts, middleware files, and subcommand shorthand files are supported inside a command directory.', + fullPath, + ); + } + } else if (state === 'group') { + if (this.isGroupDefinition(entry.name)) { + node!.definitionPath = fullPath; + node!.relativePath = this.replaceEntrypoint(fullPath); + continue; + } + if (this.isSubcommandFile(entry.name)) { + this.createTreeNode({ + source: 'shorthand', + token: entry.name.match(SUBCOMMAND_FILE_PATTERN)![1], + category, + parentId: currentNodeId, + directoryPath: path, + definitionPath: fullPath, + shorthand: true, + }); + continue; + } + if (this.isMiddleware(entry.name)) { + await this.handle(entry, category); + continue; + } + if (this.isCommand(entry.name)) { + this.addDiagnostic( + 'UNSUPPORTED_FILE_IN_GROUP_DIRECTORY', + 'Only group.ts, middleware files, and subcommand shorthand files are supported inside a group directory.', + fullPath, + ); + } + } else { + if (this.isSubcommandFile(entry.name)) { + if (currentNodeId === ROOT_NODE_ID) { + this.addDiagnostic( + 'ORPHAN_SUBCOMMAND_FILE', + 'Subcommand shorthand files must be nested inside a command or group directory.', + fullPath, + ); + } else { + this.createTreeNode({ + source: 'shorthand', + token: entry.name.match(SUBCOMMAND_FILE_PATTERN)![1], + category, + parentId: currentNodeId, + directoryPath: path, + definitionPath: fullPath, + shorthand: true, + }); + } + continue; + } + if (this.isCommand(entry.name) || this.isMiddleware(entry.name)) { + const result = await this.handle(entry, category); + if (result.command) { + this.createFlatCommandNode(result.command); + } + } } - } else if ( - entry.isDirectory() && - this.isCategory(entry.name) && - category - ) { - // nested category - const nestedCategory = this.isCategory(entry.name) + continue; + } + + if (!entry.isDirectory()) continue; + + if (this.isCommandDirectory(entry.name)) { + if (state === 'command' || state === 'group') { + await this.traverseDirectory( + fullPath, + 'command', + category, + currentNodeId, + entry.name.match(COMMAND_DIRECTORY_PATTERN)![1], + ); + } else if (currentNodeId !== ROOT_NODE_ID) { + this.addDiagnostic( + 'NESTED_COMMAND_NOT_ALLOWED', + 'Command directories cannot be nested under group or subcommand directories unless they are part of a hierarchical structure.', + fullPath, + ); + } else { + await this.traverseDirectory( + fullPath, + 'command', + category, + ROOT_NODE_ID, + entry.name.match(COMMAND_DIRECTORY_PATTERN)![1], + ); + } + continue; + } + + if (this.isGroupDirectory(entry.name)) { + if (currentNodeId === ROOT_NODE_ID) { + this.addDiagnostic( + 'ROOT_GROUP_NOT_ALLOWED', + 'Group directories must be nested inside a command directory.', + fullPath, + ); + } else { + await this.traverseDirectory( + fullPath, + 'group', + category, + currentNodeId, + entry.name.match(GROUP_DIRECTORY_PATTERN)![1], + ); + } + continue; + } + + if (this.isCategory(entry.name)) { + const nestedCategory = category ? `${category}:${entry.name.slice(1, -1)}` - : null; - await this.traverse(join(path, entry.name), nestedCategory); + : entry.name.slice(1, -1); + await this.traverseDirectory( + fullPath, + 'normal', + nestedCategory, + currentNodeId, + ); + continue; + } + + await this.traverseDirectory(fullPath, 'normal', category, currentNodeId); + } + + if (state === 'command' && node && !node.definitionPath) { + this.addDiagnostic( + 'MISSING_COMMAND_DEFINITION', + 'Command directories must include a command.ts file.', + path, + ); + } else if (state === 'group' && node && !node.definitionPath) { + this.addDiagnostic( + 'MISSING_GROUP_DEFINITION', + 'Group directories must include a group.ts file.', + path, + ); + } + } + + /** + * @private + * @internal + */ + private createFlatCommandNode(command: Command) { + this.createTreeNode({ + id: command.id, + source: 'flat', + token: command.name, + category: command.category, + parentId: ROOT_NODE_ID, + directoryPath: command.parentPath, + definitionPath: command.path, + shorthand: false, + }); + } + + /** + * @private + * @internal + */ + private createTreeNode(options: { + id?: string; + source: Exclude; + token: string; + category: string | null; + parentId: string; + directoryPath: string; + definitionPath: string | null; + shorthand: boolean; + }) { + const parent = this.treeNodes.get(options.parentId); + + if (!parent) { + this.addDiagnostic( + 'MISSING_PARENT_NODE', + `Unable to create command tree node "${options.token}" because its parent node was not found.`, + options.directoryPath, + ); + return null; + } + + const duplicate = parent.childIds.some((childId) => { + return this.treeNodes.get(childId)?.token === options.token; + }); + + if (duplicate) { + this.addDiagnostic( + 'DUPLICATE_SIBLING_TOKEN', + `Duplicate command token "${options.token}" found under the same parent.`, + options.definitionPath ?? options.directoryPath, + ); + return null; + } + + const route = [...parent.route, options.token]; + const node: CommandTreeNode = { + id: options.id ?? crypto.randomUUID(), + source: options.source, + kind: this.resolveNodeKind(options.source, route.length), + token: options.token, + route, + category: options.category, + parentId: options.parentId, + childIds: [], + directoryPath: options.directoryPath, + definitionPath: options.definitionPath, + relativePath: this.replaceEntrypoint( + options.definitionPath ?? options.directoryPath, + ), + shorthand: options.shorthand, + executable: false, + }; + + this.treeNodes.set(node.id, node); + parent.childIds.push(node.id); + + return node; + } + + /** + * @private + * @internal + */ + private resolveNodeKind( + source: CommandTreeNode['source'], + depth: number, + ): CommandTreeNodeKind { + switch (source) { + case 'root': + return 'root'; + case 'flat': + return 'flat'; + case 'group': + return 'group'; + case 'shorthand': + return 'subcommand'; + case 'directory': + return depth === 1 ? 'command' : 'subcommand'; + default: + return source satisfies never; + } + } + + /** + * @private + * @internal + */ + private compileTree() { + this.compiledRoutes.clear(); + + for (const node of this.treeNodes.values()) { + if (node.id === ROOT_NODE_ID) continue; + + const hasChildren = node.childIds.length > 0; + node.executable = + !!node.definitionPath && node.kind !== 'group' && !hasChildren; + + if (node.kind === 'subcommand' && hasChildren) { + this.addDiagnostic( + 'SUBCOMMAND_CANNOT_HAVE_CHILDREN', + `Subcommand "${node.route.join('.')}" cannot contain child command nodes.`, + node.definitionPath ?? node.directoryPath, + ); + } + + if (node.kind === 'command') { + const childKinds = new Set( + node.childIds + .map((childId) => this.treeNodes.get(childId)?.kind) + .filter(Boolean), + ); + + if (childKinds.has('group') && childKinds.has('subcommand')) { + this.addDiagnostic( + 'MIXED_ROOT_CHILDREN', + `Command "${node.route.join('.')}" cannot mix direct subcommands and subcommand groups.`, + node.definitionPath ?? node.directoryPath, + ); + } } - // TODO: handle subcommands + if (!node.executable || !node.definitionPath) continue; + + const key = node.route.join('.'); + const routeKind = node.kind as Exclude< + CommandTreeNodeKind, + 'root' | 'group' + >; + this.compiledRoutes.set(key, { + id: node.id, + key, + kind: routeKind, + token: node.token, + route: node.route, + category: node.category, + definitionPath: node.definitionPath, + relativePath: this.replaceEntrypoint(node.definitionPath), + nodeId: node.id, + middlewares: this.collectCompiledMiddlewares(node), + }); } } + /** + * @private + * @internal + */ + private collectCompiledMiddlewares(node: CommandTreeNode) { + const allMiddlewares = Array.from(this.middlewares.values()); + const globalMiddlewares = allMiddlewares + .filter((middleware) => middleware.global) + .map((middleware) => middleware.id); + + const directoryMiddlewares = allMiddlewares + .filter((middleware) => { + return ( + !middleware.global && + !middleware.command && + middleware.parentPath === node.directoryPath + ); + }) + .map((middleware) => middleware.id); + + const commandSpecificMiddlewares = allMiddlewares + .filter((middleware) => { + return ( + middleware.command === node.token && + middleware.parentPath === node.directoryPath + ); + }) + .map((middleware) => middleware.id); + + return [ + ...globalMiddlewares, + ...directoryMiddlewares, + ...commandSpecificMiddlewares, + ]; + } + + /** + * @private + * @internal + */ + private addDiagnostic(code: string, message: string, path: string) { + this.diagnostics.push({ + code, + message, + path: normalize(path), + }); + } + /** * @private * @internal @@ -261,7 +816,10 @@ export class CommandsRouter { }; this.commands.set(command.id, command); - } else if (this.isMiddleware(name)) { + return { command }; + } + + if (this.isMiddleware(name)) { const middleware: Middleware = { id: crypto.randomUUID(), name: basename(path, extname(path)), @@ -275,7 +833,10 @@ export class CommandsRouter { }; this.middlewares.set(middleware.id, middleware); + return { middleware }; } + + return {}; } /** @@ -292,12 +853,13 @@ export class CommandsRouter { .map((middleware) => middleware.id); const directorySpecificMiddlewares = allMiddlewares - .filter( - (middleware) => + .filter((middleware) => { + return ( !middleware.global && !middleware.command && - middleware.parentPath === commandPath, - ) + middleware.parentPath === commandPath + ); + }) .map((middleware) => middleware.id); const globalMiddlewares = allMiddlewares diff --git a/packages/commandkit/src/app/router/index.ts b/packages/commandkit/src/app/router/index.ts index 4831a413..7f6e2c6a 100644 --- a/packages/commandkit/src/app/router/index.ts +++ b/packages/commandkit/src/app/router/index.ts @@ -1,2 +1,3 @@ +export * from './CommandTree'; export * from './CommandsRouter'; export * from './EventsRouter'; diff --git a/packages/commandkit/src/utils/dev-hooks.ts b/packages/commandkit/src/utils/dev-hooks.ts index fe107136..68fd41bb 100644 --- a/packages/commandkit/src/utils/dev-hooks.ts +++ b/packages/commandkit/src/utils/dev-hooks.ts @@ -88,11 +88,11 @@ export function registerDevHooks(commandkit: CommandKit) { switch (event) { case HMREventType.ReloadCommands: - commandkit.commandHandler.reloadCommands(); + await commandkit.commandHandler.reloadCommands(); handled = true; break; case HMREventType.ReloadEvents: - commandkit.eventHandler.reloadEvents(); + await commandkit.eventHandler.reloadEvents(); handled = true; break; case HMREventType.Unknown: diff --git a/packages/commandkit/src/utils/utilities.ts b/packages/commandkit/src/utils/utilities.ts index c5a7b7bc..10b513e0 100644 --- a/packages/commandkit/src/utils/utilities.ts +++ b/packages/commandkit/src/utils/utilities.ts @@ -95,25 +95,41 @@ export function debounce R>( ms: number, ): F { let timer: NodeJS.Timeout | null = null; - let resolve: ((value: R | PromiseLike) => void) | null = null; + let sharedPromise: Promise | null = null; + let sharedResolve: ((value: R | PromiseLike) => void) | null = null; + let sharedReject: ((reason?: any) => void) | null = null; return ((...args: any[]) => { if (timer) { clearTimeout(timer); - if (resolve) { - resolve(null as unknown as R); // Resolve with null if debounced - } } - return new Promise((res) => { - resolve = res; - timer = setTimeout(() => { - const result = fn(...args); - res(result); - timer = null; - resolve = null; - }, ms); - }); + if (!sharedPromise) { + sharedPromise = new Promise((res, rej) => { + sharedResolve = res; + sharedReject = rej; + }); + } + + timer = setTimeout(async () => { + const currentResolve = sharedResolve; + const currentReject = sharedReject; + + // Clear state so next call starts a new debounce window + timer = null; + sharedPromise = null; + sharedResolve = null; + sharedReject = null; + + try { + const result = await fn(...args); + if (currentResolve) currentResolve(result); + } catch (err) { + if (currentReject) currentReject(err); + } + }, ms); + + return sharedPromise; }) as F; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e942cb14..934a331e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -78,7 +78,7 @@ overrides: fast-xml-parser@>=5.0.0 <5.5.6: '>=5.5.6' flatted@<=3.4.1: '>=3.4.2' fast-xml-parser@>=4.0.0-beta.3 <=5.5.6: '>=5.5.7' - path-to-regexp@<0.1.13: '>=0.1.13' + path-to-regexp@<0.1.12: ^0.1.12 handlebars@>=4.0.0 <=4.7.8: '>=4.7.9' brace-expansion@>=4.0.0 <5.0.5: '>=5.0.5' handlebars@>=4.0.0 <4.7.9: '>=4.7.9' @@ -8732,15 +8732,15 @@ packages: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} + path-to-regexp@0.1.13: + resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==} + path-to-regexp@1.9.0: resolution: {integrity: sha512-xIp7/apCFJuUHdDLWe8O1HIkb0kQrOMb/0u6FXQjemHn/ii5LrIzU6bdECnsiTF/GjZkMEKg1xdiZwNqDYlZ6g==} path-to-regexp@3.3.0: resolution: {integrity: sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==} - path-to-regexp@8.3.0: - resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} - path-to-regexp@8.4.2: resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} @@ -18471,7 +18471,7 @@ snapshots: methods: 1.1.2 on-finished: 2.4.1 parseurl: 1.3.3 - path-to-regexp: 8.3.0 + path-to-regexp: 0.1.13 proxy-addr: 2.0.7 qs: 6.15.0 range-parser: 1.2.1 @@ -20802,14 +20802,14 @@ snapshots: lru-cache: 11.2.2 minipass: 7.1.3 + path-to-regexp@0.1.13: {} + path-to-regexp@1.9.0: dependencies: isarray: 0.0.1 path-to-regexp@3.3.0: {} - path-to-regexp@8.3.0: {} - path-to-regexp@8.4.2: {} path-type@4.0.0: {} diff --git a/skills/commandkit/SKILL.md b/skills/commandkit/SKILL.md index 938084d6..439f1dc5 100644 --- a/skills/commandkit/SKILL.md +++ b/skills/commandkit/SKILL.md @@ -9,11 +9,12 @@ tags: - framework - commands - events + - hierarchical-commands description: > Build and maintain Discord bots with CommandKit core conventions. - Use when implementing command/event architecture, middleware chains, - JSX components, and commandkit.config setup for plugin-based - features. + Use when implementing command/event architecture (including + hierarchical subcommands), middleware chains, JSX components, and + commandkit.config setup for plugin-based features. --- # CommandKit Core diff --git a/skills/commandkit/references/00-filesystem-structure.md b/skills/commandkit/references/00-filesystem-structure.md index 41f082af..2627df7a 100644 --- a/skills/commandkit/references/00-filesystem-structure.md +++ b/skills/commandkit/references/00-filesystem-structure.md @@ -39,9 +39,27 @@ src/ utils/ ``` +## Hierarchical structure (Advanced) + +```txt +src/ + app/ + commands/ + (general)/ # Category (meta-only) + [workspace]/ # Root command + command.ts # Command logic + {notes}/ # Subcommand group + group.ts # Group logic + add.subcommand.ts # Subcommand shorthand +``` + ## Important details - Use exact API/export names shown in the example. +- Hierarchical tokens: + - `[name]`: Command directory (requires `command.ts`). + - `{name}`: Group directory (requires `group.ts`). + - `(name)`: Category directory (organizational only). - Keep filesystem placement aligned with enabled plugins and feature expectations. - Preserve deterministic behavior and explicit error handling in @@ -53,6 +71,8 @@ src/ - Keep snippets as baseline patterns and adapt them to real command names and data models. +- Hierarchical commands: Preserve the root-group-sub hierarchy using + the specialized brackets/braces/parentheses. - Validate external inputs and permission boundaries before side effects. - Keep setup deterministic so startup behavior is stable across @@ -63,4 +83,6 @@ src/ - Creating feature files in arbitrary folders not discovered by CommandKit. - Renaming key directories without matching framework conventions. +- Hierarchy bugs: Forgetting `command.ts` inside a `[command]` + directory or `group.ts` inside a `{group}` directory. - Missing root config file while expecting auto-discovery to work. diff --git a/skills/commandkit/references/02-chat-input-command.md b/skills/commandkit/references/02-chat-input-command.md index 7ac5016c..688065b7 100644 --- a/skills/commandkit/references/02-chat-input-command.md +++ b/skills/commandkit/references/02-chat-input-command.md @@ -2,11 +2,13 @@ ## Purpose -Provide a safe baseline for implementing slash commands with CommandKit conventions. +Provide a safe baseline for implementing slash commands with +CommandKit conventions. ## When to use -Use when implementing or reviewing this feature in a CommandKit-based project. +Use when implementing or reviewing this feature in a CommandKit-based +project. ## Filesystem @@ -19,7 +21,7 @@ project/ events/ ``` -## Example +## Example: Standard Command ```ts import type { ChatInputCommand, CommandData } from 'commandkit'; @@ -34,20 +36,54 @@ export const chatInput: ChatInputCommand = async (ctx) => { }; ``` +## Example: Hierarchical Command (command.ts) + +```ts +import type { ChatInputCommand, CommandData } from 'commandkit'; + +export const command: CommandData = { + name: 'workspace', + description: 'Manage workspace', +}; + +export const chatInput: ChatInputCommand = async (ctx) => { + await ctx.interaction.reply('Workspace command root'); +}; +``` + +## Example: Subcommand Shorthand (list.subcommand.ts) + +```ts +import type { ChatInputCommand } from 'commandkit'; + +export const chatInput: ChatInputCommand = async (ctx) => { + await ctx.interaction.reply('Listing tools...'); +}; +``` + ## Important details +- All handlers receive a `Context` (ctx) providing access to + `interaction`, `client`, and metadata. - Use exact API/export names shown in the example. -- Keep filesystem placement aligned with enabled plugins and feature expectations. -- Preserve deterministic behavior and explicit error handling in implementation code. +- Keep filesystem placement aligned with enabled plugins and feature + expectations. +- Preserve deterministic behavior and explicit error handling in + implementation code. ## Best practices -- Keep snippets as baseline patterns and adapt them to real command names and data models. -- Validate external inputs and permission boundaries before side effects. -- Keep setup deterministic so startup behavior is stable across environments. +- Leverage `ctx.options` for easy option parsing. +- Keep snippets as baseline patterns and adapt them to real command + names and data models. +- Validate external inputs and permission boundaries before side + effects. +- Keep setup deterministic so startup behavior is stable across + environments. ## Common mistakes - Skipping validation for user-provided inputs before side effects. - Changing structure/config without verifying companion files. -- Copying snippets without adapting identifiers and environment values. +- Copying snippets without adapting identifiers and environment + values. diff --git a/skills/commandkit/references/05-middlewares.md b/skills/commandkit/references/05-middlewares.md index 222b3db0..525535f8 100644 --- a/skills/commandkit/references/05-middlewares.md +++ b/skills/commandkit/references/05-middlewares.md @@ -2,11 +2,13 @@ ## Purpose -Describe middleware scopes, ordering, and safe cross-cutting usage patterns. +Describe middleware scopes, ordering, and safe cross-cutting usage +patterns. ## When to use -Use when you need auth/logging/validation before or after command execution. +Use when you need auth/logging/validation before or after command +execution. ## Filesystem @@ -25,7 +27,9 @@ project/ import type { MiddlewareContext } from 'commandkit'; export function beforeExecute(ctx: MiddlewareContext) { - console.log(`User ${ctx.interaction.user.id} is about to execute a command`); + console.log( + `User ${ctx.interaction.user.id} is about to execute a command`, + ); } export function afterExecute(ctx: MiddlewareContext) { @@ -37,18 +41,37 @@ export function afterExecute(ctx: MiddlewareContext) { ## Important details +- Middleware variants: + - `+global-middleware.ts`: Applies to all commands in tree. + - `+middleware.ts`: Applies to current directory commands. + - `+.middleware.ts`: Applies to specific command only. +- Hierarchical leaves use the same rule as flat commands: only the + current directory contributes `+middleware.ts`, and only the same + directory contributes `+.middleware.ts`. - Use exact API/export names shown in the example. -- Keep filesystem placement aligned with enabled plugins and feature expectations. -- Preserve deterministic behavior and explicit error handling in implementation code. +- Keep filesystem placement aligned with enabled plugins and feature + expectations. +- Preserve deterministic behavior and explicit error handling in + implementation code. ## Best practices -- Keep snippets as baseline patterns and adapt them to real command names and data models. -- Validate external inputs and permission boundaries before side effects. -- Keep setup deterministic so startup behavior is stable across environments. +- Use `+global-middleware.ts` for cross-cutting logic like audit + logging. +- Use `+middleware.ts` in the directory that should own the leaf's + shared behavior. +- Keep snippets as baseline patterns and adapt them to real command + names and data models. +- Validate external inputs and permission boundaries before side + effects. +- Keep setup deterministic so startup behavior is stable across + environments. ## Common mistakes +- Assuming hierarchical leaves inherit ancestor `+middleware.ts` + files. - Skipping validation for user-provided inputs before side effects. - Changing structure/config without verifying companion files. -- Copying snippets without adapting identifiers and environment values. +- Copying snippets without adapting identifiers and environment + values. diff --git a/skills/commandkit/references/08-file-naming-conventions.md b/skills/commandkit/references/08-file-naming-conventions.md index 4353ce8a..0440ebfe 100644 --- a/skills/commandkit/references/08-file-naming-conventions.md +++ b/skills/commandkit/references/08-file-naming-conventions.md @@ -47,14 +47,22 @@ export function beforeExecute(ctx: MiddlewareContext) { ## Important details - `src/app.ts` must export the discord.js client instance. -- Command categories use parenthesized directories (for example - `(Moderation)`) for organization. +- Directory naming: + - `(Category)`: Organizational grouping (meta-only). + - `[command]`: Canonical command directory. + - `{group}`: Canonical subcommand group directory. +- File naming: + - `command.ts`: Main logic for `[command]`. + - `group.ts`: Definition for `{group}`. + - `.subcommand.ts`: Individual subcommand logic. - Middleware filename variants define global, directory-scoped, and command-scoped behavior. ## Best practices - Use descriptive, stable command filenames and folder categories. +- Root-Group-Sub: Use the specialized brackets `[]` and braces `{}` to + define deep command routes. - Keep middleware naming exact (`+middleware`, `+global-middleware`, `+.middleware`). - Keep event handlers inside event-name folders to preserve discovery @@ -64,5 +72,7 @@ export function beforeExecute(ctx: MiddlewareContext) { - Placing handlers in custom paths not recognized by convention discovery. +- Misnaming definition files (e.g., using `index.ts` instead of + `command.ts`). - Misspelling middleware filename prefixes/signatures. - Forgetting to export default client from `src/app.ts`.