From e95f8dc82be9c1e288ca1fa4f370000b7c703f29 Mon Sep 17 00:00:00 2001 From: AboMeezO Date: Wed, 8 Apr 2026 04:48:22 +0300 Subject: [PATCH 01/12] feat: add hierarchical command proposal docs and initial test scaffolding for command parsing and routing --- .../hierarchical-commands-checklist.md | 266 +++++++++ docs/proposals/hierarchical-commands.md | 545 ++++++++++++++++++ .../commandkit/spec/commands-router.test.ts | 85 +++ .../spec/message-command-parser.test.ts | 57 ++ 4 files changed, 953 insertions(+) create mode 100644 docs/proposals/hierarchical-commands-checklist.md create mode 100644 docs/proposals/hierarchical-commands.md create mode 100644 packages/commandkit/spec/commands-router.test.ts create mode 100644 packages/commandkit/spec/message-command-parser.test.ts diff --git a/docs/proposals/hierarchical-commands-checklist.md b/docs/proposals/hierarchical-commands-checklist.md new file mode 100644 index 00000000..543d25e6 --- /dev/null +++ b/docs/proposals/hierarchical-commands-checklist.md @@ -0,0 +1,266 @@ +# Hierarchical Commands Implementation Checklist + +Status: Draft + +Companion to: + +- `docs/proposals/hierarchical-commands.md` + +## Purpose + +This document breaks the hierarchical commands RFC into concrete write +scopes so the work can be planned as issues or delivered in phased PRs +without starting implementation prematurely. + +This is intentionally split by subsystem: + +- router/compiler +- runtime handler +- registrar +- tests +- docs + +## Suggested PR Order + +1. characterization tests +2. router/compiler internals +3. runtime route resolution +4. registration integration +5. docs and test-bot examples + +The first PR should not change behavior. It should only lock current +behavior into tests. + +## 1. Router / Compiler + +### Primary write scope + +- `packages/commandkit/src/app/router/CommandsRouter.ts` +- `packages/commandkit/src/app/router/index.ts` + +### Likely new files + +- `packages/commandkit/src/app/router/CommandTree.ts` +- `packages/commandkit/src/app/router/CommandTreeCompiler.ts` +- `packages/commandkit/src/app/router/CommandTreeValidator.ts` + +### Checklist + +- [ ] Keep the current flat command scan path working unchanged. +- [ ] Add discovery support for `[name]` command directories. +- [ ] Add discovery support for `{name}` group directories. +- [ ] Add discovery support for `*.subcommand.ts` shorthand files. +- [ ] Preserve existing `(Category)` traversal semantics. +- [ ] Distinguish category directories from command/group directories. +- [ ] Build an internal tree representation instead of only flat + `Command` records. +- [ ] Compile the tree into explicit outputs: + - registration roots + - runtime route index + - middleware index + - validation diagnostics +- [ ] Keep flat commands compiling through the same pipeline. +- [ ] Validate duplicate sibling tokens. +- [ ] Validate that groups cannot exist at the root. +- [ ] Validate that shorthand and folder-based subcommands cannot + define the same leaf. +- [ ] Validate Discord nesting constraints on compiled payloads. +- [ ] Decide whether nested categories remain legal inside + command/group directories and enforce that consistently. +- [ ] Replace same-directory middleware lookup with ancestry-based + middleware compilation. +- [ ] Preserve current JSON/debug output semantics or update them + deliberately for the new compiled shape. + +### Done when + +- Filesystem discovery can represent both flat and hierarchical + commands. +- A compiled route like `admin.moderation.ban` can be resolved without + scanning loaded commands by name. +- Middleware chains are precomputed per executable route. + +## 2. Runtime Handler + +### Primary write scope + +- `packages/commandkit/src/app/handlers/AppCommandHandler.ts` +- `packages/commandkit/src/app/commands/Context.ts` +- `packages/commandkit/src/app/commands/MessageCommandParser.ts` + +### Checklist + +- [ ] Stop resolving chat-input commands by root name only. +- [ ] Build full interaction routes from: + - `commandName` + - `getSubcommandGroup(false)` + - `getSubcommand(false)` +- [ ] Build full prefix routes from: + - `parser.getCommand()` + - `parser.getSubcommandGroup()` + - `parser.getSubcommand()` +- [ ] Resolve the final leaf command from the compiled runtime route + index. +- [ ] Load middleware from the compiled middleware index, not by + re-deriving it at runtime. +- [ ] Keep permissions middleware behavior unchanged. +- [ ] Preserve flat command resolution behavior for current apps. +- [ ] Decide whether container `command.ts` files may execute as + prefix commands in v1 and enforce the rule. +- [ ] Review `resolveMessageCommandName()` and alias lookup so route + resolution stays unambiguous. +- [ ] Review `Context.commandName`, `Context.invokedCommandName`, and + `forwardCommand()` for route-aware behavior. +- [ ] Decide whether to add a new full-route context property such as + `commandRoute`. +- [ ] Keep `AppCommandRunner` changes minimal and limited to consuming + a resolved leaf command. + +### Done when + +- Interactions and prefix commands resolve the same leaf route given + the same logical command path. +- Flat commands still work without any route-specific branching in + user code. + +## 3. Registrar + +### Primary write scope + +- `packages/commandkit/src/app/register/CommandRegistrar.ts` + +### Checklist + +- [ ] Stop assuming one loaded command always equals one top-level + chat input payload. +- [ ] Register compiled root chat-input payloads with nested + subcommands/groups. +- [ ] Keep pre-generated context menu registration behavior unchanged. +- [ ] Preserve guild/global registration splitting based on metadata. +- [ ] Decide where metadata lives for compiled roots vs executable + leaf routes. +- [ ] Ensure Discord IDs are still applied to the correct runtime + objects. +- [ ] Confirm no duplicate payloads are emitted when flat and + hierarchical commands coexist. +- [ ] Keep plugin pre-registration hooks working with the new payload + source. + +### Done when + +- A hierarchical command tree registers as one Discord root command + payload. +- Context menus remain unaffected. + +## 4. Tests + +### Primary write scope + +- `packages/commandkit/spec/**` + +### Likely new files + +- `packages/commandkit/spec/commands-router.test.ts` +- `packages/commandkit/spec/hierarchical-commands.test.ts` +- `packages/commandkit/spec/middleware-inheritance.test.ts` +- `packages/commandkit/spec/command-registration.test.ts` + +### Checklist + +- [ ] Add characterization coverage for current flat discovery. +- [ ] Add characterization coverage for current category behavior. +- [ ] Add characterization coverage for current middleware ordering. +- [ ] Add characterization coverage for current prefix parsing. +- [ ] Add route discovery tests for: + - flat commands + - command directories + - group directories + - shorthand subcommands +- [ ] Add validation tests for: + - duplicate sibling tokens + - illegal root groups + - illegal mixed primitive options and subcommands + - shorthand/folder collisions +- [ ] Add runtime resolution tests for: + - `admin` + - `admin.ban` + - `admin.moderation.ban` +- [ ] Add prefix resolution tests for: + - `!admin:ban` + - `!admin:moderation:ban` +- [ ] Add middleware inheritance tests across ancestor directories. +- [ ] Add registration tests for nested Discord payload generation. +- [ ] Add coexistence tests proving flat and hierarchical commands can + load together. +- [ ] Add regression tests for context menu registration so the new + work does not disturb the existing flow. + +### Done when + +- The old flat model is protected by characterization tests. +- The new route model is covered end-to-end from discovery to + registration and execution preparation. + +## 5. Docs + +### Primary write scope + +- `apps/website/docs/guide/02-commands/**` +- `apps/test-bot/src/app/commands/**` + +### Checklist + +- [ ] Add a guide page for hierarchical commands and filesystem + syntax. +- [ ] Document the distinction between: + - categories + - command directories + - group directories + - subcommand shorthand +- [ ] Update middleware docs so inheritance matches actual runtime + behavior. +- [ ] Document prefix-command route syntax for hierarchical commands. +- [ ] Document any limitations in v1: + - flat-only context menus + - deferred external/plugin-injected hierarchy + - alias rules +- [ ] Add `apps/test-bot` examples for: + - a root command with grouped subcommands + - middleware inheritance across levels + - prefix usage for the same routes +- [ ] Update docs that currently imply all commands are single-file + flat files. +- [ ] Decide whether API reference updates are needed immediately or + only after public types change. + +### Done when + +- The new filesystem grammar is discoverable from the guide. +- The example app demonstrates the intended structure. + +## Cross-Cutting Decisions To Settle Before Coding + +- [ ] Finalize the filesystem grammar: + - `[name]` + - `{name}` + - `*.subcommand.ts` +- [ ] Decide whether container nodes are executable for prefix + commands. +- [ ] Decide whether nested categories are legal inside command/group + directories. +- [ ] Decide alias behavior for leaf subcommands. +- [ ] Decide whether context should expose a full route API in v1. +- [ ] Decide how plugin/external flat injection coexists with the new + compiled route model in the short term. + +## Release Gate + +The feature is ready for merge only when all of the following are +true: + +- [ ] flat commands are behaviorally unchanged +- [ ] hierarchical commands resolve by full route +- [ ] middleware inheritance is deterministic and tested +- [ ] Discord registration emits correct nested payloads +- [ ] docs and example usage are updated +- [ ] context menu behavior is unchanged diff --git a/docs/proposals/hierarchical-commands.md b/docs/proposals/hierarchical-commands.md new file mode 100644 index 00000000..0b26090d --- /dev/null +++ b/docs/proposals/hierarchical-commands.md @@ -0,0 +1,545 @@ +# Hierarchical Commands RFC + +Status: Draft + +## Summary + +This RFC proposes a filesystem-based hierarchical command system for +CommandKit that adds native support for subcommands and subcommand +groups without breaking the current flat command model. + +The current codebase already supports: + +- flat command discovery from `src/app/commands/**` +- flat command loading and registration +- message-command parsing of `command:subcommand` and + `command:group:subcommand` +- Discord-native subcommands when a developer manually builds them in + a single command file + +What is missing is framework-level support for discovering, +validating, registering, and executing hierarchical commands from the +filesystem. + +This RFC introduces an internal command tree and compiler layer +between filesystem discovery and runtime execution. Existing flat +commands continue to work unchanged. + +## Motivation + +Today, hierarchical commands are not a first-class framework feature. + +The current implementation is built around a flat `Command` record: + +- `packages/commandkit/src/app/router/CommandsRouter.ts` +- `packages/commandkit/src/app/handlers/AppCommandHandler.ts` +- `packages/commandkit/src/app/register/CommandRegistrar.ts` + +Important constraints in the current code: + +- `CommandsRouter.scan()` returns a flat command list. +- `CommandsRouter` only treats `(Category)` directories as special. +- `CommandsRouter` still has a `TODO: handle subcommands`. +- `AppCommandHandler.prepareCommandRun()` resolves commands by root + name. +- `CommandRegistrar.getCommandsData()` assumes one loaded command maps + to one top-level Discord payload, plus context-menu siblings. +- Message parsing already extracts `subcommand` and `subcommandGroup`, + but that data is not used for route resolution. + +This creates a mismatch: + +- the parser understands routes +- Discord understands routes +- the filesystem and runtime model do not + +Large bots then end up packing many related subcommands into one file, +which hurts discoverability, middleware scoping, and long-term +maintenance. + +## Goals + +- Add native filesystem discovery for hierarchical chat-input + commands. +- Keep existing flat command files working as-is. +- Keep existing category directories working as-is. +- Keep the current execution lifecycle and middleware hooks. +- Make middleware inheritance follow route ancestry. +- Support both directory-based subcommands and a shorthand file + suffix. +- Compile the tree into a registration payload set and a runtime route + index. + +## Non-goals + +- No breaking change to flat commands. +- No hierarchy for context menu commands. +- No change to the public command file contract for flat command + files. +- No first-pass support for hierarchical external/plugin-injected + commands. +- No change to `AppCommandRunner`'s execution flow beyond consuming a + resolved leaf route. + +## Current State + +### Discovery + +`CommandsRouter` currently recognizes: + +- flat command files such as `ping.ts` +- middleware files such as `+middleware.ts`, `+global-middleware.ts`, + and `+ping.middleware.ts` +- category directories such as `(Moderation)` + +It does not currently recognize command directories, subcommand +groups, or subcommand shorthand files. + +### Runtime resolution + +`AppCommandHandler.prepareCommandRun()` currently resolves only the +root command name: + +- for interactions: `source.commandName` +- for prefix commands: the first token returned by + `MessageCommandParser` + +The parsed subcommand fields are not used to select a leaf command. + +### Registration + +`CommandRegistrar.getCommandsData()` currently flattens loaded +commands into Discord registration payloads. That model works for flat +slash commands and context menus, but it is not expressive enough for +a compiled route tree. + +### Middleware + +The docs describe directory middleware as applying to subdirectories, +but the current router only applies middleware from the same +directory, plus global and command-specific middleware. This RFC +treats ancestry inheritance as part of the hierarchical command work +so the runtime matches the documented mental model. + +## Proposed Filesystem Grammar + +### Existing flat commands remain valid + +These remain unchanged: + +```txt +src/app/commands/ + ping.ts + (Moderation)/ + ban.ts +``` + +### New hierarchical command syntax + +This RFC proposes a Windows-safe syntax: + +```txt +src/app/commands/ + [admin]/ + command.ts + + {moderation}/ + group.ts + ban.subcommand.ts + + [kick]/ + command.ts +``` + +This maps to: + +- `/admin moderation ban` +- `/admin moderation kick` + +### Naming rules + +- `(Category)` keeps its current meaning and remains purely + organizational. +- `[name]` defines a command node directory. +- `{name}` defines a subcommand-group directory. +- `command.ts` defines a command node. +- `group.ts` defines a group node. +- `.subcommand.ts` defines a leaf subcommand shorthand in the + containing command or group directory. + +Examples: + +- `[admin]/command.ts` defines the root command `admin` +- `{moderation}/group.ts` defines the group `moderation` +- `ban.subcommand.ts` defines the subcommand `ban` +- `[kick]/command.ts` defines the subcommand `kick` + +### Why this syntax + +The initial proposal used angle-bracket directories for subcommands. +That is not portable because `<` and `>` are invalid in Windows +filenames. + +This RFC uses only syntax that is valid on Windows and does not +collide with the existing `(Category)` convention. + +## Semantics + +### Executable vs non-executable nodes + +- Flat command files remain executable leaves. +- A root command with children is a non-executable container for + Discord chat-input registration and route metadata. +- A group node is a non-executable container. +- A leaf subcommand is executable. + +This means a hierarchical `command.ts` may define metadata without +being directly executable when children exist. + +### File export expectations + +To minimize new API surface: + +- `command.ts` continues using the existing command-file export shape. +- `group.ts` reuses the same `command` metadata pattern, but it must + not export executable handlers. +- Leaf files must still export at least one executable handler. + +Validation rules determine whether a node is allowed to have handlers +based on its compiled role. + +### Prefix command syntax + +Message-command routing keeps the current colon-delimited syntax: + +- `!admin:ban` +- `!admin:moderation:ban` + +This avoids introducing a new ambiguous parser grammar for +space-delimited prefix commands. + +## Internal Model + +The implementation should stop treating filesystem discovery as the +final command shape. + +### Stage 1: raw tree discovery + +Introduce an internal tree model: + +```ts +type CommandTreeNodeKind = + | 'root' + | 'command' + | 'group' + | 'subcommand'; + +interface CommandTreeNode { + id: string; + kind: CommandTreeNodeKind; + token: string; + route: string[]; + fsPath: string; + definitionPath: string | null; + parentId: string | null; + childIds: string[]; + category: string | null; + inheritedDirectories: string[]; + shorthand: boolean; +} +``` + +Notes: + +- `token` is one path segment such as `admin` or `ban`. +- `route` is the full route token list. +- `definitionPath` is null for synthetic/internal nodes if needed. +- `category` preserves the existing `(Category)` behavior. + +### Stage 2: compile outputs + +Compile the tree into explicit outputs: + +- `registrationRoots` + - root chat-input command payloads with nested options +- `runtimeRouteIndex` + - `admin` + - `admin.ban` + - `admin.moderation.ban` +- `middlewareIndex` + - full ordered middleware chain per executable route +- `validationDiagnostics` + - structural and Discord-shape errors + +The key design point is this: + +`CommandsRouter` should no longer be responsible for producing the +same shape that runtime execution consumes. + +## Discovery Rules + +### Categories + +- `(Category)` directories remain supported exactly as they work + today. +- Category directories may contain flat commands, hierarchical command + directories, middleware, and nested categories. +- Categories do not become command nodes. + +### Command directories + +- `[name]/command.ts` defines a command node. +- A command directory may contain: + - subcommand groups + - subcommands + - middleware + - nested category directories +- A command directory with children is a container command. + +### Group directories + +- `{name}/group.ts` defines a subcommand group node. +- A group directory may contain: + - subcommands + - middleware + - nested categories if explicitly supported by implementation + +### Subcommand shorthand + +- `.subcommand.ts` defines a leaf subcommand in the containing + command or group directory. +- It is equivalent to `[name]/command.ts`. +- It cannot define a group. +- It cannot have child nodes. + +## Validation Rules + +Validation should happen before loading executable modules. + +### Structural rules + +- Duplicate sibling tokens are not allowed. +- A group cannot exist at the root of `src/app/commands`. +- A subcommand cannot have children. +- A shorthand subcommand cannot coexist with `[name]/command.ts` in + the same parent scope. +- A container command cannot also behave like a flat executable + command for chat-input registration. + +### Handler rules + +- Group nodes cannot export executable handlers. +- Non-leaf hierarchical command nodes cannot export `chatInput` or + `autocomplete` handlers. +- Leaf hierarchical nodes may export `chatInput`, `autocomplete`, and + `message`. +- Context-menu handlers remain flat-only in v1. + +### Discord-shape rules + +- Root commands can contain either subcommands or groups, matching + Discord constraints. +- Root commands cannot mix direct primitive options with subcommands. +- Group nodes can only contain subcommands. +- Name and description constraints must be validated on compiled + payloads, not only raw files. + +## Middleware Inheritance + +Middleware should be compiled per executable route in ancestry order. + +### Ordering + +For a route like `admin.moderation.ban`, the middleware order should +be: + +1. global middleware +2. ancestor directory middleware from outermost to innermost +3. leaf-directory middleware +4. command-specific middleware for the leaf token +5. built-in permissions middleware + +Example: + +```txt +src/app/commands/ + +global-middleware.ts + [admin]/ + +middleware.ts + {moderation}/ + +middleware.ts + +ban.middleware.ts + ban.subcommand.ts +``` + +Compiled middleware chain for `admin.moderation.ban`: + +1. `+global-middleware.ts` +2. `[admin]/+middleware.ts` +3. `{moderation}/+middleware.ts` +4. `{moderation}/+ban.middleware.ts` +5. permissions middleware + +This also fixes the current mismatch between docs and implementation +for directory ancestry. + +## Runtime Resolution + +### Interactions + +For chat-input interactions, build the route from: + +- `interaction.commandName` +- `interaction.options.getSubcommandGroup(false)` +- `interaction.options.getSubcommand(false)` + +Then resolve the compiled leaf route from `runtimeRouteIndex`. + +### Prefix commands + +For message commands, build the route from: + +- `parser.getCommand()` +- `parser.getSubcommandGroup()` +- `parser.getSubcommand()` + +Resolution should use the full route, not only the root command. + +### Execution + +`AppCommandRunner` should continue executing a prepared leaf command. +The main change is in how `prepareCommandRun()` resolves the target +and builds the middleware chain. + +## Registration Model + +Hierarchical filesystem commands should compile into top-level Discord +chat-input payloads. + +Example compiled registration output: + +```txt +[admin]/command.ts + {moderation}/group.ts + ban.subcommand.ts +``` + +produces one top-level Discord command: + +```txt +admin + moderation + ban +``` + +This is different from the current model where one loaded command +becomes one top-level slash payload. + +For that reason, hierarchical compiled commands should not be forced +into the current `LoadedCommand` shape unchanged. A new internal +compiled route type is cleaner than stretching the flat type until it +breaks. + +## Backward Compatibility + +### Supported combinations + +- flat-only apps continue to work unchanged +- hierarchical-only apps work with the new syntax +- both styles can coexist in the same project + +### Deferred compatibility + +The current external command injection APIs are flat: + +- `addExternalCommands(data: Command[])` +- `registerExternalLoadedCommands(data: LoadedCommand[])` + +This RFC defers hierarchical external/plugin-injected commands to a +follow-up design. v1 should support hierarchical discovery only for +filesystem commands. + +## Implementation Plan + +### Phase 0: characterization tests + +Add tests for current behavior before changing internals: + +- flat discovery +- category handling +- flat command resolution +- current middleware ordering +- current message parser behavior + +### Phase 1: internal tree and compiler + +- add tree discovery structures +- add compiler outputs +- keep flat commands compiling through the same pipeline +- keep current public behavior unchanged + +### Phase 2: router and validation + +- support `[name]`, `{name}`, and `.subcommand.ts` +- emit diagnostics for invalid structures +- make directory middleware ancestry-based + +### Phase 3: runtime resolution + +- resolve interaction routes by full path +- resolve prefix routes by full path +- compile middleware chains per leaf route + +### Phase 4: registrar integration + +- register compiled root chat-input payloads +- keep context menu registration flat + +### Phase 5: docs and examples + +- add a guide page for hierarchical commands +- update middleware docs to match actual inheritance +- add `apps/test-bot` examples + +## Test Plan + +Add dedicated coverage for: + +- router discovery of command directories, groups, and shorthand files +- duplicate sibling validation +- invalid mixed root options/subcommand structures +- interaction resolution for: + - `admin` + - `admin.ban` + - `admin.moderation.ban` +- prefix resolution for: + - `!admin:ban` + - `!admin:moderation:ban` +- middleware inheritance order across ancestors +- registration payload nesting +- flat and hierarchical coexistence + +## Open Questions + +1. Should a container `command.ts` be allowed to export a `message` + handler for a root-only prefix command, or should container nodes + be non-executable across all modes? +2. Should nested categories inside command/group directories be + allowed in v1, or should category traversal stop at command nodes + to keep discovery simpler? +3. Should aliases apply only to root prefix commands in v1, or can + leaf subcommands define aliases too? +4. Should runtime context expose a new full-route property such as + `ctx.commandRoute`, or should v1 keep `ctx.commandName` semantics + unchanged and defer richer route APIs? + +## Recommended Next Step + +The companion implementation checklist lives at: + +- `docs/proposals/hierarchical-commands-checklist.md` + +The main engineering decision is already clear: + +hierarchical commands should be implemented as a compiled tree model, +not as more special cases on top of the current flat records. diff --git a/packages/commandkit/spec/commands-router.test.ts b/packages/commandkit/spec/commands-router.test.ts new file mode 100644 index 00000000..2b14f49c --- /dev/null +++ b/packages/commandkit/spec/commands-router.test.ts @@ -0,0 +1,85 @@ +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', + ]); + }); +}); 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..5ce44725 --- /dev/null +++ b/packages/commandkit/spec/message-command-parser.test.ts @@ -0,0 +1,57 @@ +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(); + }); +}); From 60308bc130bae816a44451701954e31817477030 Mon Sep 17 00:00:00 2001 From: AboMeezO Date: Wed, 8 Apr 2026 05:20:10 +0300 Subject: [PATCH 02/12] feat: implement filesystem-based command router with tree-based path resolution and middleware support --- .../commandkit/spec/commands-router.test.ts | 97 +++ .../commandkit/src/app/router/CommandTree.ts | 65 ++ .../src/app/router/CommandsRouter.ts | 752 +++++++++++++++++- packages/commandkit/src/app/router/index.ts | 1 + 4 files changed, 882 insertions(+), 33 deletions(-) create mode 100644 packages/commandkit/src/app/router/CommandTree.ts diff --git a/packages/commandkit/spec/commands-router.test.ts b/packages/commandkit/spec/commands-router.test.ts index 2b14f49c..283f21a0 100644 --- a/packages/commandkit/spec/commands-router.test.ts +++ b/packages/commandkit/spec/commands-router.test.ts @@ -82,4 +82,101 @@ describe('CommandsRouter', () => { '/(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]/+middleware.ts', + '/[admin]/{moderation}/+middleware.ts', + '/[admin]/{moderation}/+ban.middleware.ts', + ]); + expect(middlewarePathsFor('admin.moderation.kick')).toEqual([ + '/+global-middleware.ts', + '/[admin]/+middleware.ts', + '/[admin]/{moderation}/+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/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..7db8523a 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,37 @@ 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 + */ +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 +133,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 +158,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) { + this.clear(); + for (const [id, command] of Object.entries(data.commands)) { this.commands.set(id, command); } @@ -109,6 +171,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,11 +220,54 @@ export class CommandsRouter { } /** - * Clears all loaded commands and middleware. + * @private + * @internal + */ + private isCommandDirectory(name: string): boolean { + return COMMAND_DIRECTORY_PATTERN.test(name); + } + + /** + * @private + * @internal + */ + private isGroupDirectory(name: string): boolean { + return GROUP_DIRECTORY_PATTERN.test(name); + } + + /** + * @private + * @internal + */ + private isCommandDefinition(name: string): boolean { + return COMMAND_DEFINITION_PATTERN.test(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); + } + + /** + * Clears all loaded commands, middleware, and compiled tree data. */ public clear() { this.commands.clear(); this.middlewares.clear(); + this.treeNodes.clear(); + this.compiledRoutes.clear(); + this.diagnostics = []; } /** @@ -160,51 +275,111 @@ export class CommandsRouter { * @returns Parsed command data */ public async scan() { + this.clear(); + this.initializeRootNode(); + const entries = await readdir(this.options.entrypoint, { withFileTypes: true, }); for (const entry of entries) { - // ignore _ prefixed files if (entry.name.startsWith('_')) continue; const fullPath = join(this.options.entrypoint, entry.name); - if (entry.isDirectory()) { - const category = this.isCategory(entry.name) - ? entry.name.slice(1, -1) - : null; + if (entry.isFile()) { + if (this.isSubcommandFile(entry.name)) { + this.addDiagnostic( + 'ROOT_SUBCOMMAND_NOT_ALLOWED', + 'Subcommand shorthand files must be nested inside a command or group directory.', + fullPath, + ); + continue; + } + + if (this.isCommand(entry.name) || this.isMiddleware(entry.name)) { + const result = await this.handle(entry); + + if (result.command) { + this.createFlatCommandNode(result.command); + } + } + + continue; + } + + if (!entry.isDirectory()) continue; + + if (this.isCategory(entry.name)) { + await this.traverseLegacyDirectory(fullPath, entry.name.slice(1, -1)); + continue; + } - await this.traverse(fullPath, category); - } else { - await this.handle(entry); + if (this.isCommandDirectory(entry.name)) { + await this.traverseCommandDirectory( + fullPath, + entry.name.match(COMMAND_DIRECTORY_PATTERN)![1], + null, + ROOT_NODE_ID, + ); + continue; } + + if (this.isGroupDirectory(entry.name)) { + this.addDiagnostic( + 'ROOT_GROUP_NOT_ALLOWED', + 'Group directories must be nested inside a command directory.', + fullPath, + ); + continue; + } + + await this.traverseLegacyDirectory(fullPath, null); } 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() { 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() { + 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() { 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 +387,539 @@ 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 traverseLegacyDirectory(path: string, category: string | null) { 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.isSubcommandFile(entry.name)) { + this.addDiagnostic( + 'ORPHAN_SUBCOMMAND_FILE', + 'Subcommand shorthand files must be nested inside a command or group directory.', + fullPath, + ); + continue; + } + if (this.isCommand(entry.name) || this.isMiddleware(entry.name)) { + const result = await this.handle(entry, category); + + if (result.command) { + this.createFlatCommandNode(result.command); + } + } + + continue; + } + + if (!entry.isDirectory()) continue; + + if (this.isCommandDirectory(entry.name)) { + await this.traverseCommandDirectory( + fullPath, + entry.name.match(COMMAND_DIRECTORY_PATTERN)![1], + category, + ROOT_NODE_ID, + ); + continue; + } + + if (this.isGroupDirectory(entry.name)) { + this.addDiagnostic( + 'ORPHAN_GROUP_DIRECTORY', + 'Group directories must be nested inside a command directory.', + fullPath, + ); + continue; + } + + if (this.isCategory(entry.name) && category) { + const nestedCategory = `${category}:${entry.name.slice(1, -1)}`; + await this.traverseLegacyDirectory(fullPath, nestedCategory); + } + } + } + + /** + * @private + * @internal + */ + private async traverseCommandDirectory( + path: string, + token: string, + category: string | null, + parentId: string, + ) { + const node = this.createTreeNode({ + source: 'directory', + token, + category, + parentId, + directoryPath: path, + definitionPath: null, + shorthand: false, + }); + + if (!node) return; + + const entries = await readdir(path, { + withFileTypes: true, + }); + + for (const entry of entries) { + if (entry.name.startsWith('_')) continue; + + const fullPath = join(path, entry.name); + + if (entry.isFile()) { + 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: node.id, + directoryPath: path, + definitionPath: fullPath, + shorthand: true, + }); + continue; + } + + if (this.isMiddleware(entry.name)) { await this.handle(entry, category); + continue; } - } else if ( - entry.isDirectory() && - this.isCategory(entry.name) && - category - ) { - // nested category - const nestedCategory = this.isCategory(entry.name) - ? `${category}:${entry.name.slice(1, -1)}` - : null; - await this.traverse(join(path, entry.name), nestedCategory); + + 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, + ); + } + + continue; + } + + if (!entry.isDirectory()) continue; + + if (this.isCommandDirectory(entry.name)) { + await this.traverseCommandDirectory( + fullPath, + entry.name.match(COMMAND_DIRECTORY_PATTERN)![1], + category, + node.id, + ); + continue; + } + + if (this.isGroupDirectory(entry.name)) { + await this.traverseGroupDirectory( + fullPath, + entry.name.match(GROUP_DIRECTORY_PATTERN)![1], + category, + node.id, + ); + continue; + } + + if (this.isCategory(entry.name)) { + this.addDiagnostic( + 'UNSUPPORTED_CATEGORY_IN_HIERARCHY', + 'Category directories inside command/group directories are not supported in this initial implementation.', + fullPath, + ); } + } - // TODO: handle subcommands + if (!node.definitionPath) { + this.addDiagnostic( + 'MISSING_COMMAND_DEFINITION', + 'Command directories must include a command.ts file.', + path, + ); } } + /** + * @private + * @internal + */ + private async traverseGroupDirectory( + path: string, + token: string, + category: string | null, + parentId: string, + ) { + const node = this.createTreeNode({ + source: 'group', + token, + category, + parentId, + directoryPath: path, + definitionPath: null, + shorthand: false, + }); + + if (!node) return; + + const entries = await readdir(path, { + withFileTypes: true, + }); + + for (const entry of entries) { + if (entry.name.startsWith('_')) continue; + + const fullPath = join(path, entry.name); + + if (entry.isFile()) { + 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: node.id, + 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, + ); + } + + continue; + } + + if (!entry.isDirectory()) continue; + + if (this.isCommandDirectory(entry.name)) { + await this.traverseCommandDirectory( + fullPath, + entry.name.match(COMMAND_DIRECTORY_PATTERN)![1], + category, + node.id, + ); + continue; + } + + if (this.isGroupDirectory(entry.name)) { + this.addDiagnostic( + 'NESTED_GROUP_NOT_ALLOWED', + 'Subcommand groups cannot contain nested group directories.', + fullPath, + ); + continue; + } + + if (this.isCategory(entry.name)) { + this.addDiagnostic( + 'UNSUPPORTED_CATEGORY_IN_HIERARCHY', + 'Category directories inside command/group directories are not supported in this initial implementation.', + fullPath, + ); + } + } + + if (!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, + ); + } + } + + 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 directoryPaths = this.getDirectoryAncestors(node.directoryPath); + const directoryMiddlewares = directoryPaths.flatMap((path) => { + return allMiddlewares + .filter((middleware) => { + return ( + !middleware.global && + !middleware.command && + middleware.parentPath === path + ); + }) + .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 getDirectoryAncestors(path: string) { + const normalizedPath = normalize(path); + const normalizedEntrypoint = normalize(this.options.entrypoint); + const ancestors: string[] = []; + + let current = normalizedPath; + + while (current.startsWith(normalizedEntrypoint)) { + ancestors.push(current); + + if (current === normalizedEntrypoint) break; + + const parent = normalize(dirname(current)); + if (parent === current) break; + current = parent; + } + + return ancestors.reverse(); + } + + /** + * @private + * @internal + */ + private addDiagnostic(code: string, message: string, path: string) { + this.diagnostics.push({ + code, + message, + path: normalize(path), + }); + } + /** * @private * @internal @@ -261,7 +940,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 +957,10 @@ export class CommandsRouter { }; this.middlewares.set(middleware.id, middleware); + return { middleware }; } + + return {}; } /** @@ -292,12 +977,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'; From 4d61d8418cb54079f049166a3cb2d9db176d6f93 Mon Sep 17 00:00:00 2001 From: AboMeezO Date: Wed, 8 Apr 2026 20:40:05 +0300 Subject: [PATCH 03/12] feat: implement hierarchical command routing and registration system with middleware support --- .../hierarchical-commands-checklist.md | 266 -------- docs/proposals/hierarchical-commands.md | 545 ---------------- .../spec/hierarchical-command-handler.test.ts | 249 ++++++++ .../hierarchical-command-registration.test.ts | 211 +++++++ .../spec/message-command-parser.test.ts | 22 + .../src/app/commands/AppCommandRunner.ts | 16 +- .../commandkit/src/app/commands/Context.ts | 17 +- .../src/app/commands/MessageCommandParser.ts | 9 +- .../src/app/handlers/AppCommandHandler.ts | 588 +++++++++++++++--- .../src/app/register/CommandRegistrar.ts | 288 ++++++++- .../src/app/router/CommandsRouter.ts | 162 +++-- packages/commandkit/src/cli/development.ts | 254 -------- packages/commandkit/src/utils/utilities.ts | 42 +- 13 files changed, 1385 insertions(+), 1284 deletions(-) delete mode 100644 docs/proposals/hierarchical-commands-checklist.md delete mode 100644 docs/proposals/hierarchical-commands.md create mode 100644 packages/commandkit/spec/hierarchical-command-handler.test.ts create mode 100644 packages/commandkit/spec/hierarchical-command-registration.test.ts delete mode 100644 packages/commandkit/src/cli/development.ts diff --git a/docs/proposals/hierarchical-commands-checklist.md b/docs/proposals/hierarchical-commands-checklist.md deleted file mode 100644 index 543d25e6..00000000 --- a/docs/proposals/hierarchical-commands-checklist.md +++ /dev/null @@ -1,266 +0,0 @@ -# Hierarchical Commands Implementation Checklist - -Status: Draft - -Companion to: - -- `docs/proposals/hierarchical-commands.md` - -## Purpose - -This document breaks the hierarchical commands RFC into concrete write -scopes so the work can be planned as issues or delivered in phased PRs -without starting implementation prematurely. - -This is intentionally split by subsystem: - -- router/compiler -- runtime handler -- registrar -- tests -- docs - -## Suggested PR Order - -1. characterization tests -2. router/compiler internals -3. runtime route resolution -4. registration integration -5. docs and test-bot examples - -The first PR should not change behavior. It should only lock current -behavior into tests. - -## 1. Router / Compiler - -### Primary write scope - -- `packages/commandkit/src/app/router/CommandsRouter.ts` -- `packages/commandkit/src/app/router/index.ts` - -### Likely new files - -- `packages/commandkit/src/app/router/CommandTree.ts` -- `packages/commandkit/src/app/router/CommandTreeCompiler.ts` -- `packages/commandkit/src/app/router/CommandTreeValidator.ts` - -### Checklist - -- [ ] Keep the current flat command scan path working unchanged. -- [ ] Add discovery support for `[name]` command directories. -- [ ] Add discovery support for `{name}` group directories. -- [ ] Add discovery support for `*.subcommand.ts` shorthand files. -- [ ] Preserve existing `(Category)` traversal semantics. -- [ ] Distinguish category directories from command/group directories. -- [ ] Build an internal tree representation instead of only flat - `Command` records. -- [ ] Compile the tree into explicit outputs: - - registration roots - - runtime route index - - middleware index - - validation diagnostics -- [ ] Keep flat commands compiling through the same pipeline. -- [ ] Validate duplicate sibling tokens. -- [ ] Validate that groups cannot exist at the root. -- [ ] Validate that shorthand and folder-based subcommands cannot - define the same leaf. -- [ ] Validate Discord nesting constraints on compiled payloads. -- [ ] Decide whether nested categories remain legal inside - command/group directories and enforce that consistently. -- [ ] Replace same-directory middleware lookup with ancestry-based - middleware compilation. -- [ ] Preserve current JSON/debug output semantics or update them - deliberately for the new compiled shape. - -### Done when - -- Filesystem discovery can represent both flat and hierarchical - commands. -- A compiled route like `admin.moderation.ban` can be resolved without - scanning loaded commands by name. -- Middleware chains are precomputed per executable route. - -## 2. Runtime Handler - -### Primary write scope - -- `packages/commandkit/src/app/handlers/AppCommandHandler.ts` -- `packages/commandkit/src/app/commands/Context.ts` -- `packages/commandkit/src/app/commands/MessageCommandParser.ts` - -### Checklist - -- [ ] Stop resolving chat-input commands by root name only. -- [ ] Build full interaction routes from: - - `commandName` - - `getSubcommandGroup(false)` - - `getSubcommand(false)` -- [ ] Build full prefix routes from: - - `parser.getCommand()` - - `parser.getSubcommandGroup()` - - `parser.getSubcommand()` -- [ ] Resolve the final leaf command from the compiled runtime route - index. -- [ ] Load middleware from the compiled middleware index, not by - re-deriving it at runtime. -- [ ] Keep permissions middleware behavior unchanged. -- [ ] Preserve flat command resolution behavior for current apps. -- [ ] Decide whether container `command.ts` files may execute as - prefix commands in v1 and enforce the rule. -- [ ] Review `resolveMessageCommandName()` and alias lookup so route - resolution stays unambiguous. -- [ ] Review `Context.commandName`, `Context.invokedCommandName`, and - `forwardCommand()` for route-aware behavior. -- [ ] Decide whether to add a new full-route context property such as - `commandRoute`. -- [ ] Keep `AppCommandRunner` changes minimal and limited to consuming - a resolved leaf command. - -### Done when - -- Interactions and prefix commands resolve the same leaf route given - the same logical command path. -- Flat commands still work without any route-specific branching in - user code. - -## 3. Registrar - -### Primary write scope - -- `packages/commandkit/src/app/register/CommandRegistrar.ts` - -### Checklist - -- [ ] Stop assuming one loaded command always equals one top-level - chat input payload. -- [ ] Register compiled root chat-input payloads with nested - subcommands/groups. -- [ ] Keep pre-generated context menu registration behavior unchanged. -- [ ] Preserve guild/global registration splitting based on metadata. -- [ ] Decide where metadata lives for compiled roots vs executable - leaf routes. -- [ ] Ensure Discord IDs are still applied to the correct runtime - objects. -- [ ] Confirm no duplicate payloads are emitted when flat and - hierarchical commands coexist. -- [ ] Keep plugin pre-registration hooks working with the new payload - source. - -### Done when - -- A hierarchical command tree registers as one Discord root command - payload. -- Context menus remain unaffected. - -## 4. Tests - -### Primary write scope - -- `packages/commandkit/spec/**` - -### Likely new files - -- `packages/commandkit/spec/commands-router.test.ts` -- `packages/commandkit/spec/hierarchical-commands.test.ts` -- `packages/commandkit/spec/middleware-inheritance.test.ts` -- `packages/commandkit/spec/command-registration.test.ts` - -### Checklist - -- [ ] Add characterization coverage for current flat discovery. -- [ ] Add characterization coverage for current category behavior. -- [ ] Add characterization coverage for current middleware ordering. -- [ ] Add characterization coverage for current prefix parsing. -- [ ] Add route discovery tests for: - - flat commands - - command directories - - group directories - - shorthand subcommands -- [ ] Add validation tests for: - - duplicate sibling tokens - - illegal root groups - - illegal mixed primitive options and subcommands - - shorthand/folder collisions -- [ ] Add runtime resolution tests for: - - `admin` - - `admin.ban` - - `admin.moderation.ban` -- [ ] Add prefix resolution tests for: - - `!admin:ban` - - `!admin:moderation:ban` -- [ ] Add middleware inheritance tests across ancestor directories. -- [ ] Add registration tests for nested Discord payload generation. -- [ ] Add coexistence tests proving flat and hierarchical commands can - load together. -- [ ] Add regression tests for context menu registration so the new - work does not disturb the existing flow. - -### Done when - -- The old flat model is protected by characterization tests. -- The new route model is covered end-to-end from discovery to - registration and execution preparation. - -## 5. Docs - -### Primary write scope - -- `apps/website/docs/guide/02-commands/**` -- `apps/test-bot/src/app/commands/**` - -### Checklist - -- [ ] Add a guide page for hierarchical commands and filesystem - syntax. -- [ ] Document the distinction between: - - categories - - command directories - - group directories - - subcommand shorthand -- [ ] Update middleware docs so inheritance matches actual runtime - behavior. -- [ ] Document prefix-command route syntax for hierarchical commands. -- [ ] Document any limitations in v1: - - flat-only context menus - - deferred external/plugin-injected hierarchy - - alias rules -- [ ] Add `apps/test-bot` examples for: - - a root command with grouped subcommands - - middleware inheritance across levels - - prefix usage for the same routes -- [ ] Update docs that currently imply all commands are single-file - flat files. -- [ ] Decide whether API reference updates are needed immediately or - only after public types change. - -### Done when - -- The new filesystem grammar is discoverable from the guide. -- The example app demonstrates the intended structure. - -## Cross-Cutting Decisions To Settle Before Coding - -- [ ] Finalize the filesystem grammar: - - `[name]` - - `{name}` - - `*.subcommand.ts` -- [ ] Decide whether container nodes are executable for prefix - commands. -- [ ] Decide whether nested categories are legal inside command/group - directories. -- [ ] Decide alias behavior for leaf subcommands. -- [ ] Decide whether context should expose a full route API in v1. -- [ ] Decide how plugin/external flat injection coexists with the new - compiled route model in the short term. - -## Release Gate - -The feature is ready for merge only when all of the following are -true: - -- [ ] flat commands are behaviorally unchanged -- [ ] hierarchical commands resolve by full route -- [ ] middleware inheritance is deterministic and tested -- [ ] Discord registration emits correct nested payloads -- [ ] docs and example usage are updated -- [ ] context menu behavior is unchanged diff --git a/docs/proposals/hierarchical-commands.md b/docs/proposals/hierarchical-commands.md deleted file mode 100644 index 0b26090d..00000000 --- a/docs/proposals/hierarchical-commands.md +++ /dev/null @@ -1,545 +0,0 @@ -# Hierarchical Commands RFC - -Status: Draft - -## Summary - -This RFC proposes a filesystem-based hierarchical command system for -CommandKit that adds native support for subcommands and subcommand -groups without breaking the current flat command model. - -The current codebase already supports: - -- flat command discovery from `src/app/commands/**` -- flat command loading and registration -- message-command parsing of `command:subcommand` and - `command:group:subcommand` -- Discord-native subcommands when a developer manually builds them in - a single command file - -What is missing is framework-level support for discovering, -validating, registering, and executing hierarchical commands from the -filesystem. - -This RFC introduces an internal command tree and compiler layer -between filesystem discovery and runtime execution. Existing flat -commands continue to work unchanged. - -## Motivation - -Today, hierarchical commands are not a first-class framework feature. - -The current implementation is built around a flat `Command` record: - -- `packages/commandkit/src/app/router/CommandsRouter.ts` -- `packages/commandkit/src/app/handlers/AppCommandHandler.ts` -- `packages/commandkit/src/app/register/CommandRegistrar.ts` - -Important constraints in the current code: - -- `CommandsRouter.scan()` returns a flat command list. -- `CommandsRouter` only treats `(Category)` directories as special. -- `CommandsRouter` still has a `TODO: handle subcommands`. -- `AppCommandHandler.prepareCommandRun()` resolves commands by root - name. -- `CommandRegistrar.getCommandsData()` assumes one loaded command maps - to one top-level Discord payload, plus context-menu siblings. -- Message parsing already extracts `subcommand` and `subcommandGroup`, - but that data is not used for route resolution. - -This creates a mismatch: - -- the parser understands routes -- Discord understands routes -- the filesystem and runtime model do not - -Large bots then end up packing many related subcommands into one file, -which hurts discoverability, middleware scoping, and long-term -maintenance. - -## Goals - -- Add native filesystem discovery for hierarchical chat-input - commands. -- Keep existing flat command files working as-is. -- Keep existing category directories working as-is. -- Keep the current execution lifecycle and middleware hooks. -- Make middleware inheritance follow route ancestry. -- Support both directory-based subcommands and a shorthand file - suffix. -- Compile the tree into a registration payload set and a runtime route - index. - -## Non-goals - -- No breaking change to flat commands. -- No hierarchy for context menu commands. -- No change to the public command file contract for flat command - files. -- No first-pass support for hierarchical external/plugin-injected - commands. -- No change to `AppCommandRunner`'s execution flow beyond consuming a - resolved leaf route. - -## Current State - -### Discovery - -`CommandsRouter` currently recognizes: - -- flat command files such as `ping.ts` -- middleware files such as `+middleware.ts`, `+global-middleware.ts`, - and `+ping.middleware.ts` -- category directories such as `(Moderation)` - -It does not currently recognize command directories, subcommand -groups, or subcommand shorthand files. - -### Runtime resolution - -`AppCommandHandler.prepareCommandRun()` currently resolves only the -root command name: - -- for interactions: `source.commandName` -- for prefix commands: the first token returned by - `MessageCommandParser` - -The parsed subcommand fields are not used to select a leaf command. - -### Registration - -`CommandRegistrar.getCommandsData()` currently flattens loaded -commands into Discord registration payloads. That model works for flat -slash commands and context menus, but it is not expressive enough for -a compiled route tree. - -### Middleware - -The docs describe directory middleware as applying to subdirectories, -but the current router only applies middleware from the same -directory, plus global and command-specific middleware. This RFC -treats ancestry inheritance as part of the hierarchical command work -so the runtime matches the documented mental model. - -## Proposed Filesystem Grammar - -### Existing flat commands remain valid - -These remain unchanged: - -```txt -src/app/commands/ - ping.ts - (Moderation)/ - ban.ts -``` - -### New hierarchical command syntax - -This RFC proposes a Windows-safe syntax: - -```txt -src/app/commands/ - [admin]/ - command.ts - - {moderation}/ - group.ts - ban.subcommand.ts - - [kick]/ - command.ts -``` - -This maps to: - -- `/admin moderation ban` -- `/admin moderation kick` - -### Naming rules - -- `(Category)` keeps its current meaning and remains purely - organizational. -- `[name]` defines a command node directory. -- `{name}` defines a subcommand-group directory. -- `command.ts` defines a command node. -- `group.ts` defines a group node. -- `.subcommand.ts` defines a leaf subcommand shorthand in the - containing command or group directory. - -Examples: - -- `[admin]/command.ts` defines the root command `admin` -- `{moderation}/group.ts` defines the group `moderation` -- `ban.subcommand.ts` defines the subcommand `ban` -- `[kick]/command.ts` defines the subcommand `kick` - -### Why this syntax - -The initial proposal used angle-bracket directories for subcommands. -That is not portable because `<` and `>` are invalid in Windows -filenames. - -This RFC uses only syntax that is valid on Windows and does not -collide with the existing `(Category)` convention. - -## Semantics - -### Executable vs non-executable nodes - -- Flat command files remain executable leaves. -- A root command with children is a non-executable container for - Discord chat-input registration and route metadata. -- A group node is a non-executable container. -- A leaf subcommand is executable. - -This means a hierarchical `command.ts` may define metadata without -being directly executable when children exist. - -### File export expectations - -To minimize new API surface: - -- `command.ts` continues using the existing command-file export shape. -- `group.ts` reuses the same `command` metadata pattern, but it must - not export executable handlers. -- Leaf files must still export at least one executable handler. - -Validation rules determine whether a node is allowed to have handlers -based on its compiled role. - -### Prefix command syntax - -Message-command routing keeps the current colon-delimited syntax: - -- `!admin:ban` -- `!admin:moderation:ban` - -This avoids introducing a new ambiguous parser grammar for -space-delimited prefix commands. - -## Internal Model - -The implementation should stop treating filesystem discovery as the -final command shape. - -### Stage 1: raw tree discovery - -Introduce an internal tree model: - -```ts -type CommandTreeNodeKind = - | 'root' - | 'command' - | 'group' - | 'subcommand'; - -interface CommandTreeNode { - id: string; - kind: CommandTreeNodeKind; - token: string; - route: string[]; - fsPath: string; - definitionPath: string | null; - parentId: string | null; - childIds: string[]; - category: string | null; - inheritedDirectories: string[]; - shorthand: boolean; -} -``` - -Notes: - -- `token` is one path segment such as `admin` or `ban`. -- `route` is the full route token list. -- `definitionPath` is null for synthetic/internal nodes if needed. -- `category` preserves the existing `(Category)` behavior. - -### Stage 2: compile outputs - -Compile the tree into explicit outputs: - -- `registrationRoots` - - root chat-input command payloads with nested options -- `runtimeRouteIndex` - - `admin` - - `admin.ban` - - `admin.moderation.ban` -- `middlewareIndex` - - full ordered middleware chain per executable route -- `validationDiagnostics` - - structural and Discord-shape errors - -The key design point is this: - -`CommandsRouter` should no longer be responsible for producing the -same shape that runtime execution consumes. - -## Discovery Rules - -### Categories - -- `(Category)` directories remain supported exactly as they work - today. -- Category directories may contain flat commands, hierarchical command - directories, middleware, and nested categories. -- Categories do not become command nodes. - -### Command directories - -- `[name]/command.ts` defines a command node. -- A command directory may contain: - - subcommand groups - - subcommands - - middleware - - nested category directories -- A command directory with children is a container command. - -### Group directories - -- `{name}/group.ts` defines a subcommand group node. -- A group directory may contain: - - subcommands - - middleware - - nested categories if explicitly supported by implementation - -### Subcommand shorthand - -- `.subcommand.ts` defines a leaf subcommand in the containing - command or group directory. -- It is equivalent to `[name]/command.ts`. -- It cannot define a group. -- It cannot have child nodes. - -## Validation Rules - -Validation should happen before loading executable modules. - -### Structural rules - -- Duplicate sibling tokens are not allowed. -- A group cannot exist at the root of `src/app/commands`. -- A subcommand cannot have children. -- A shorthand subcommand cannot coexist with `[name]/command.ts` in - the same parent scope. -- A container command cannot also behave like a flat executable - command for chat-input registration. - -### Handler rules - -- Group nodes cannot export executable handlers. -- Non-leaf hierarchical command nodes cannot export `chatInput` or - `autocomplete` handlers. -- Leaf hierarchical nodes may export `chatInput`, `autocomplete`, and - `message`. -- Context-menu handlers remain flat-only in v1. - -### Discord-shape rules - -- Root commands can contain either subcommands or groups, matching - Discord constraints. -- Root commands cannot mix direct primitive options with subcommands. -- Group nodes can only contain subcommands. -- Name and description constraints must be validated on compiled - payloads, not only raw files. - -## Middleware Inheritance - -Middleware should be compiled per executable route in ancestry order. - -### Ordering - -For a route like `admin.moderation.ban`, the middleware order should -be: - -1. global middleware -2. ancestor directory middleware from outermost to innermost -3. leaf-directory middleware -4. command-specific middleware for the leaf token -5. built-in permissions middleware - -Example: - -```txt -src/app/commands/ - +global-middleware.ts - [admin]/ - +middleware.ts - {moderation}/ - +middleware.ts - +ban.middleware.ts - ban.subcommand.ts -``` - -Compiled middleware chain for `admin.moderation.ban`: - -1. `+global-middleware.ts` -2. `[admin]/+middleware.ts` -3. `{moderation}/+middleware.ts` -4. `{moderation}/+ban.middleware.ts` -5. permissions middleware - -This also fixes the current mismatch between docs and implementation -for directory ancestry. - -## Runtime Resolution - -### Interactions - -For chat-input interactions, build the route from: - -- `interaction.commandName` -- `interaction.options.getSubcommandGroup(false)` -- `interaction.options.getSubcommand(false)` - -Then resolve the compiled leaf route from `runtimeRouteIndex`. - -### Prefix commands - -For message commands, build the route from: - -- `parser.getCommand()` -- `parser.getSubcommandGroup()` -- `parser.getSubcommand()` - -Resolution should use the full route, not only the root command. - -### Execution - -`AppCommandRunner` should continue executing a prepared leaf command. -The main change is in how `prepareCommandRun()` resolves the target -and builds the middleware chain. - -## Registration Model - -Hierarchical filesystem commands should compile into top-level Discord -chat-input payloads. - -Example compiled registration output: - -```txt -[admin]/command.ts - {moderation}/group.ts - ban.subcommand.ts -``` - -produces one top-level Discord command: - -```txt -admin - moderation - ban -``` - -This is different from the current model where one loaded command -becomes one top-level slash payload. - -For that reason, hierarchical compiled commands should not be forced -into the current `LoadedCommand` shape unchanged. A new internal -compiled route type is cleaner than stretching the flat type until it -breaks. - -## Backward Compatibility - -### Supported combinations - -- flat-only apps continue to work unchanged -- hierarchical-only apps work with the new syntax -- both styles can coexist in the same project - -### Deferred compatibility - -The current external command injection APIs are flat: - -- `addExternalCommands(data: Command[])` -- `registerExternalLoadedCommands(data: LoadedCommand[])` - -This RFC defers hierarchical external/plugin-injected commands to a -follow-up design. v1 should support hierarchical discovery only for -filesystem commands. - -## Implementation Plan - -### Phase 0: characterization tests - -Add tests for current behavior before changing internals: - -- flat discovery -- category handling -- flat command resolution -- current middleware ordering -- current message parser behavior - -### Phase 1: internal tree and compiler - -- add tree discovery structures -- add compiler outputs -- keep flat commands compiling through the same pipeline -- keep current public behavior unchanged - -### Phase 2: router and validation - -- support `[name]`, `{name}`, and `.subcommand.ts` -- emit diagnostics for invalid structures -- make directory middleware ancestry-based - -### Phase 3: runtime resolution - -- resolve interaction routes by full path -- resolve prefix routes by full path -- compile middleware chains per leaf route - -### Phase 4: registrar integration - -- register compiled root chat-input payloads -- keep context menu registration flat - -### Phase 5: docs and examples - -- add a guide page for hierarchical commands -- update middleware docs to match actual inheritance -- add `apps/test-bot` examples - -## Test Plan - -Add dedicated coverage for: - -- router discovery of command directories, groups, and shorthand files -- duplicate sibling validation -- invalid mixed root options/subcommand structures -- interaction resolution for: - - `admin` - - `admin.ban` - - `admin.moderation.ban` -- prefix resolution for: - - `!admin:ban` - - `!admin:moderation:ban` -- middleware inheritance order across ancestors -- registration payload nesting -- flat and hierarchical coexistence - -## Open Questions - -1. Should a container `command.ts` be allowed to export a `message` - handler for a root-only prefix command, or should container nodes - be non-executable across all modes? -2. Should nested categories inside command/group directories be - allowed in v1, or should category traversal stop at command nodes - to keep discovery simpler? -3. Should aliases apply only to root prefix commands in v1, or can - leaf subcommands define aliases too? -4. Should runtime context expose a new full-route property such as - `ctx.commandRoute`, or should v1 keep `ctx.commandName` semantics - unchanged and defer richer route APIs? - -## Recommended Next Step - -The companion implementation checklist lives at: - -- `docs/proposals/hierarchical-commands-checklist.md` - -The main engineering decision is already clear: - -hierarchical commands should be implemented as a compiled tree model, -not as more special cases on top of the current flat records. 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..44e3ad74 --- /dev/null +++ b/packages/commandkit/spec/hierarchical-command-handler.test.ts @@ -0,0 +1,249 @@ +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]/+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 index 5ce44725..10eab767 100644 --- a/packages/commandkit/spec/message-command-parser.test.ts +++ b/packages/commandkit/spec/message-command-parser.test.ts @@ -54,4 +54,26 @@ describe('MessageCommandParser', () => { 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/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..5c78eb13 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(), + ); } /** diff --git a/packages/commandkit/src/app/commands/MessageCommandParser.ts b/packages/commandkit/src/app/commands/MessageCommandParser.ts index 6cef5cdd..5e776b7c 100644 --- a/packages/commandkit/src/app/commands/MessageCommandParser.ts +++ b/packages/commandkit/src/app/commands/MessageCommandParser.ts @@ -163,15 +163,18 @@ 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 = commandToken; let subcommandGroup: string | undefined; let subcommand: string | undefined; - if (command?.includes(':')) { - const [, group, cmd] = command.split(':'); + if (commandToken?.includes(':')) { + const [root, group, cmd] = commandToken.split(':'); + + command = root; if (!cmd && group) { subcommand = group; diff --git a/packages/commandkit/src/app/handlers/AppCommandHandler.ts b/packages/commandkit/src/app/handlers/AppCommandHandler.ts index 4af92f15..a6ef0df0 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 @@ -251,98 +273,179 @@ export class AppCommandHandler { public printBanner() { 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:`, + `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 +458,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); @@ -391,6 +487,22 @@ export class AppCommandHandler { return Array.from(this.loadedCommands.values()); } + /** + * Gets all executable runtime routes, including hierarchical leaves. + * @returns Array of route-indexed commands + */ + public getRuntimeCommandsArray() { + return Array.from(this.runtimeRouteIndex.values()); + } + + /** + * Gets loaded hierarchical command nodes, including non-executable containers. + * @returns Array of hierarchical node definitions + */ + public getHierarchicalNodesArray() { + return Array.from(this.hierarchicalNodes.values()); + } + /** * Registers event handlers for Discord interactions and messages. */ @@ -442,6 +554,55 @@ 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.getCommand(), + parser.getSubcommandGroup(), + parser.getSubcommand(), + ] + .filter(Boolean) + .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 +620,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 +651,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 +688,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 +704,7 @@ export class AppCommandHandler { if (!isAnyCommand) return null; - cmdName = source.commandName; + routeKey = this.buildInteractionRouteKey(source); } } @@ -551,7 +716,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,19 +815,55 @@ 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; } @@ -670,6 +873,8 @@ export class AppCommandHandler { public async reloadCommands() { this.loadedCommands.clear(); this.loadedMiddlewares.clear(); + this.runtimeRouteIndex.clear(); + this.hierarchicalNodes.clear(); this.externalCommandData.clear(); this.externalMiddlewareData.clear(); @@ -717,6 +922,7 @@ export class AppCommandHandler { public async registerExternalLoadedCommands(data: LoadedCommand[]) { for (const command of data) { this.loadedCommands.set(command.command.id, command); + this.registerRuntimeRoute(command); } } @@ -734,7 +940,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 +961,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 +1025,34 @@ 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 @@ -816,7 +1061,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,7 +1075,10 @@ export class AppCommandHandler { name: command.name, }, }, - }); + }; + + this.loadedCommands.set(id, loadedCommand); + this.registerRuntimeRoute(loadedCommand); return; } @@ -933,7 +1181,7 @@ export class AppCommandHandler { ...metadata, }; - this.loadedCommands.set(id, { + const loadedCommand: LoadedCommand = { discordId: null, command, metadata: resolvedMetadata, @@ -942,7 +1190,10 @@ export class AppCommandHandler { metadata: resolvedMetadata, command: commandJson, }, - }); + }; + + this.loadedCommands.set(id, loadedCommand); + this.registerRuntimeRoute(loadedCommand); // Pre-generate context menu commands so the handler cache // is aware of them before CommandRegistrar runs (#558) @@ -958,6 +1209,173 @@ export class AppCommandHandler { } } + /** + * 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, + }; + + try { + const commandFileData = (await import( + `${toFileURL(command.path)}?t=${Date.now()}` + )) as AppCommandNative; + + if (!commandFileData.command) { + throw new Error( + `Invalid export for hierarchical node ${routeKey}: 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: [], + }; + + if ( + typeof commandFileData.command.name === 'string' && + commandFileData.command.name !== node.token + ) { + Logger.warn( + `Hierarchical node \`${routeKey}\` overrides its command name with \`${commandFileData.command.name}\`. The filesystem token \`${node.token}\` will be used instead.`, + ); + } + + const commandName = node.token; + let commandDescription = commandFileData.command.description as + | string + | undefined; + + if (!commandDescription) { + 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 hierarchical node ${routeKey}: ${key} does not match expected value`, + ); + } + + if (!KNOWN_NON_HANDLER_KEYS.includes(key)) { + handlerCount++; + } + } + } + + if ( + commandFileData.userContextMenu || + commandFileData.messageContextMenu + ) { + throw new Error( + `Invalid export for hierarchical node ${routeKey}: context menu handlers are only supported for flat commands`, + ); + } + + if (node.executable && handlerCount === 0) { + throw new Error( + `Invalid export for hierarchical node ${routeKey}: executable leaves must provide at least one handler function`, + ); + } + + if (!node.executable && handlerCount > 0) { + throw new Error( + `Invalid export for hierarchical node ${routeKey}: non-leaf hierarchical nodes cannot export executable handlers`, + ); + } + + 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 \`${routeKey}\` uses deprecated metadata properties. Please update to use the new \`metadata\` object or \`generateMetadata\` function.`, + ); + } + + const resolvedMetadata = { + guilds: commandJson.guilds, + aliases: commandJson.aliases, + ...metadata, + }; + + const loadedCommand: LoadedCommand = { + discordId: null, + command, + metadata: resolvedMetadata, + data: { + ...commandFileData, + metadata: resolvedMetadata, + command: { + ...commandJson, + __routeKey: routeKey, + }, + }, + }; + + this.hierarchicalNodes.set(node.id, loadedCommand); + + if (node.executable) { + this.registerRuntimeRoute(loadedCommand, routeKey); + } + } catch (error) { + Logger.error`Failed to load hierarchical node ${routeKey} (${node.id}): ${error}`; + } + } + /** * Gets the metadata for a command. * @param command - The command name to get metadata for @@ -968,7 +1386,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/CommandsRouter.ts b/packages/commandkit/src/app/router/CommandsRouter.ts index 7db8523a..56d9046b 100644 --- a/packages/commandkit/src/app/router/CommandsRouter.ts +++ b/packages/commandkit/src/app/router/CommandsRouter.ts @@ -98,6 +98,10 @@ const COMMAND_DIRECTORY_PATTERN = /^\[([^\][\\\/]+)\]$/; */ const GROUP_DIRECTORY_PATTERN = /^\{([^}{\\\/]+)\}$/; +/** + * @private + * @internal + */ /** * @private * @internal @@ -278,64 +282,7 @@ export class CommandsRouter { this.clear(); this.initializeRootNode(); - const entries = await readdir(this.options.entrypoint, { - withFileTypes: true, - }); - - for (const entry of entries) { - if (entry.name.startsWith('_')) continue; - - const fullPath = join(this.options.entrypoint, entry.name); - - if (entry.isFile()) { - if (this.isSubcommandFile(entry.name)) { - this.addDiagnostic( - 'ROOT_SUBCOMMAND_NOT_ALLOWED', - 'Subcommand shorthand files must be nested inside a command or group directory.', - fullPath, - ); - continue; - } - - if (this.isCommand(entry.name) || this.isMiddleware(entry.name)) { - const result = await this.handle(entry); - - if (result.command) { - this.createFlatCommandNode(result.command); - } - } - - continue; - } - - if (!entry.isDirectory()) continue; - - if (this.isCategory(entry.name)) { - await this.traverseLegacyDirectory(fullPath, entry.name.slice(1, -1)); - continue; - } - - if (this.isCommandDirectory(entry.name)) { - await this.traverseCommandDirectory( - fullPath, - entry.name.match(COMMAND_DIRECTORY_PATTERN)![1], - null, - ROOT_NODE_ID, - ); - continue; - } - - if (this.isGroupDirectory(entry.name)) { - this.addDiagnostic( - 'ROOT_GROUP_NOT_ALLOWED', - 'Group directories must be nested inside a command directory.', - fullPath, - ); - continue; - } - - await this.traverseLegacyDirectory(fullPath, null); - } + await this.traverseNormalDirectory(this.options.entrypoint, null, ROOT_NODE_ID); await this.applyMiddlewares(); this.compileTree(); @@ -409,23 +356,34 @@ export class CommandsRouter { * @private * @internal */ - private async traverseLegacyDirectory(path: string, category: string | null) { + private async traverseNormalDirectory(path: string, category: string | null, parentId: string) { const entries = await readdir(path, { withFileTypes: true, }); for (const entry of entries) { if (entry.name.startsWith('_')) continue; - const fullPath = join(path, entry.name); if (entry.isFile()) { if (this.isSubcommandFile(entry.name)) { - this.addDiagnostic( - 'ORPHAN_SUBCOMMAND_FILE', - 'Subcommand shorthand files must be nested inside a command or group directory.', - fullPath, - ); + if (parentId === 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, + directoryPath: path, + definitionPath: fullPath, + shorthand: true, + }); + } continue; } @@ -443,6 +401,14 @@ export class CommandsRouter { if (!entry.isDirectory()) continue; if (this.isCommandDirectory(entry.name)) { + if (parentId !== ROOT_NODE_ID) { + this.addDiagnostic( + 'NESTED_COMMAND_NOT_ALLOWED', + 'Command directories cannot be nested under group or subcommand directories.', + fullPath, + ); + continue; + } await this.traverseCommandDirectory( fullPath, entry.name.match(COMMAND_DIRECTORY_PATTERN)![1], @@ -453,18 +419,30 @@ export class CommandsRouter { } if (this.isGroupDirectory(entry.name)) { - this.addDiagnostic( - 'ORPHAN_GROUP_DIRECTORY', - 'Group directories must be nested inside a command directory.', + if (parentId === ROOT_NODE_ID) { + this.addDiagnostic( + 'ORPHAN_GROUP_DIRECTORY', + 'Group directories must be nested inside a command directory.', + fullPath, + ); + continue; + } + await this.traverseGroupDirectory( fullPath, + entry.name.match(GROUP_DIRECTORY_PATTERN)![1], + category, + parentId, ); continue; } - if (this.isCategory(entry.name) && category) { - const nestedCategory = `${category}:${entry.name.slice(1, -1)}`; - await this.traverseLegacyDirectory(fullPath, nestedCategory); + if (this.isCategory(entry.name)) { + const nestedCategory = category ? `${category}:${entry.name.slice(1, -1)}` : entry.name.slice(1, -1); + await this.traverseNormalDirectory(fullPath, nestedCategory, parentId); + continue; } + + await this.traverseNormalDirectory(fullPath, category, parentId); } } @@ -538,11 +516,10 @@ export class CommandsRouter { if (!entry.isDirectory()) continue; if (this.isCommandDirectory(entry.name)) { - await this.traverseCommandDirectory( + this.addDiagnostic( + 'NESTED_COMMAND_NOT_ALLOWED', + 'Command directories cannot contain nested root command directories.', fullPath, - entry.name.match(COMMAND_DIRECTORY_PATTERN)![1], - category, - node.id, ); continue; } @@ -557,13 +534,14 @@ export class CommandsRouter { continue; } + if (this.isCategory(entry.name)) { - this.addDiagnostic( - 'UNSUPPORTED_CATEGORY_IN_HIERARCHY', - 'Category directories inside command/group directories are not supported in this initial implementation.', - fullPath, - ); + const nestedCategory = category ? `${category}:${entry.name.slice(1, -1)}` : entry.name.slice(1, -1); + await this.traverseNormalDirectory(fullPath, nestedCategory, node.id); + continue; } + + await this.traverseNormalDirectory(fullPath, category, node.id); } if (!node.definitionPath) { @@ -645,31 +623,31 @@ export class CommandsRouter { if (!entry.isDirectory()) continue; if (this.isCommandDirectory(entry.name)) { - await this.traverseCommandDirectory( + this.addDiagnostic( + 'NESTED_COMMAND_NOT_ALLOWED', + 'Group directories cannot contain nested root command directories.', fullPath, - entry.name.match(COMMAND_DIRECTORY_PATTERN)![1], - category, - node.id, ); continue; } if (this.isGroupDirectory(entry.name)) { - this.addDiagnostic( - 'NESTED_GROUP_NOT_ALLOWED', - 'Subcommand groups cannot contain nested group directories.', + await this.traverseGroupDirectory( fullPath, + entry.name.match(GROUP_DIRECTORY_PATTERN)![1], + category, + node.id, ); continue; } if (this.isCategory(entry.name)) { - this.addDiagnostic( - 'UNSUPPORTED_CATEGORY_IN_HIERARCHY', - 'Category directories inside command/group directories are not supported in this initial implementation.', - fullPath, - ); + const nestedCategory = category ? `${category}:${entry.name.slice(1, -1)}` : entry.name.slice(1, -1); + await this.traverseNormalDirectory(fullPath, nestedCategory, node.id); + continue; } + + await this.traverseNormalDirectory(fullPath, category, node.id); } if (!node.definitionPath) { diff --git a/packages/commandkit/src/cli/development.ts b/packages/commandkit/src/cli/development.ts deleted file mode 100644 index 50c187fd..00000000 --- a/packages/commandkit/src/cli/development.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { join } from 'path'; -import { getPossibleConfigPaths, loadConfigFile } from '../config/loader'; -import { isCompilerPlugin } from '../plugins'; -import { createAppProcess } from './app-process'; -import { buildApplication } from './build'; -import { watch } from 'chokidar'; -import { debounce } from '../utils/utilities'; -import colors from '../utils/colors'; -import { ChildProcess } from 'node:child_process'; -import { setTimeout as sleep } from 'node:timers/promises'; -import { randomUUID } from 'node:crypto'; -import { COMMANDKIT_CWD, HMREventType } from '../utils/constants'; -import { findEntrypoint } from './common'; - -/** - * @private - * @internal - */ -async function buildAndStart(configPath: string, skipStart = false) { - const config = await loadConfigFile(configPath); - const mainFile = findEntrypoint('.commandkit'); - - await buildApplication({ - configPath, - isDev: true, - plugins: config.plugins.flat(2).filter((p) => isCompilerPlugin(p)), - rolldownPlugins: config.rolldownPlugins, - }); - - if (skipStart) return null as never; - - const ps = createAppProcess(mainFile, configPath, true); - - return ps; -} - -/** - * @private - * @internal - */ -const isCommandSource = (p: string) => - p.replaceAll('\\', '/').includes('src/app/commands'); - -/** - * @private - * @internal - */ -const isEventSource = (p: string) => - p.replaceAll('\\', '/').includes('src/app/events'); - -/** - * @private - * @internal - */ -export async function bootstrapDevelopmentServer(configPath?: string) { - process.env.COMMANDKIT_BOOTSTRAP_MODE = 'development'; - process.env.COMMANDKIT_INTERNAL_IS_CLI_PROCESS = 'true'; - const start = performance.now(); - const cwd = configPath || COMMANDKIT_CWD; - const configPaths = getPossibleConfigPaths(cwd); - - const watcher = watch([join(cwd, 'src'), ...configPaths], { - ignoreInitial: true, - }); - - let ps: ChildProcess | null = null; - - const waitForAcknowledgment = (messageId: string): Promise => { - return new Promise((resolve) => { - if (!ps) return resolve(false); - - let _handled = false; - const onMessage = (message: any) => { - _handled = true; - if (typeof message !== 'object' || message === null) return; - - const { type, id, handled } = message; - if (type === 'commandkit-hmr-ack' && id === messageId) { - ps?.off('message', onMessage); - resolve(!!handled); - } - }; - - ps.once('message', onMessage); - - if (!_handled) { - sleep(3000).then(() => { - ps?.off('message', onMessage); - resolve(false); - }); - } - }); - }; - - const sendHmrEvent = async ( - event: HMREventType, - path?: string, - ): Promise => { - if (!ps || !ps.send) return false; - - const messageId = randomUUID(); - const messagePromise = waitForAcknowledgment(messageId); - - ps.send({ event, path, id: messageId }); - - // Wait for acknowledgment or timeout after 3 seconds - try { - let triggered = false; - const res = !!(await Promise.race([ - messagePromise, - sleep(3000).then(() => { - if (!triggered) { - console.warn( - colors.yellow( - `HMR acknowledgment timed out for event ${event} on path ${path}`, - ), - ); - } - return false; - }), - ])); - - triggered = true; - - return res; - } catch (error) { - console.error( - colors.red(`Error waiting for HMR acknowledgment: ${error}`), - ); - return false; - } - }; - - const performHMR = debounce(async (path?: string): Promise => { - if (!path || !ps) return false; - - let eventType: HMREventType | null = null; - let eventDescription = ''; - - if (isCommandSource(path)) { - eventType = HMREventType.ReloadCommands; - eventDescription = 'command(s)'; - } else if (isEventSource(path)) { - eventType = HMREventType.ReloadEvents; - eventDescription = 'event(s)'; - } else { - eventType = HMREventType.Unknown; - eventDescription = 'unknown source'; - } - - if (eventType) { - console.log( - `${colors.cyanBright(`Attempting to reload ${eventDescription} at`)} ${colors.yellowBright(path)}`, - ); - - await buildAndStart(cwd, true); - const hmrHandled = await sendHmrEvent(eventType, path); - - if (hmrHandled) { - console.log( - `${colors.greenBright(`Successfully hot reloaded ${eventDescription} at`)} ${colors.yellowBright(path)}`, - ); - return true; - } - } - - return false; - }, 700); - - const isConfigUpdate = (path: string) => { - const isConfig = configPaths.some((configPath) => path === configPath); - - if (!isConfig) return false; - - console.log( - colors.yellowBright( - 'It seems like commandkit config file was updated, please restart the server manually to apply changes.', - ), - ); - - return isConfig; - }; - - const hmrHandler = async (path: string) => { - if (isConfigUpdate(path)) return; - const hmr = await performHMR(path); - if (hmr) return; - - console.log( - `${colors.yellowBright('⚡️ Performing full restart due to the changes in')} ${colors.cyanBright(path)}`, - ); - - ps?.kill(); - ps = await buildAndStart(cwd); - }; - - process.stdin.on('data', async (d) => { - const command = d.toString().trim(); - - switch (command) { - case 'r': - console.log(`Received restart command, restarting...`); - ps?.kill(); - ps = null; - ps = await buildAndStart(cwd); - break; - case 'rc': - console.log(`Received reload commands command, reloading...`); - await sendHmrEvent(HMREventType.ReloadCommands); - break; - case 're': - console.log(`Received reload events command, reloading...`); - await sendHmrEvent(HMREventType.ReloadEvents); - break; - break; - } - }); - - watcher.on('change', hmrHandler); - watcher.on('add', hmrHandler); - watcher.on('unlink', hmrHandler); - watcher.on('unlinkDir', hmrHandler); - watcher.on('error', (e) => { - console.error(e); - }); - - console.log(`${colors.greenBright('Bootstrapped CommandKit Development Environment in')} ${colors.yellowBright(`${(performance.now() - start).toFixed(2)}ms`)} -${colors.greenBright('Watching for changes in')} ${colors.yellowBright('src')} ${colors.greenBright('directory')} - -${colors.greenBright('Commands:')} -${colors.yellowBright('r')} - Restart the server -${colors.yellowBright('rc')} - Reload all commands -${colors.yellowBright('re')} - Reload all events`); - - const buildStart = performance.now(); - - ps = await buildAndStart(cwd); - - const buildEnd = performance.now(); - - console.log( - `\n${colors.greenBright('Development mode compilation took')} ${colors.yellowBright(`${(buildEnd - buildStart).toFixed(2)}ms`)}\n`, - ); - - return { - watcher, - isConfigUpdate, - performHMR, - hmrHandler, - sendHmrEvent, - getProcess: () => ps, - buildAndStart, - }; -} 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; } From 37ee84ea6ae6748feb5f7900e52fd1e10f1579e6 Mon Sep 17 00:00:00 2001 From: AboMeezO Date: Wed, 8 Apr 2026 20:40:17 +0300 Subject: [PATCH 04/12] feat: implement hierarchical command structure with middleware tracing and demo utilities --- .../app/commands/(general)/(animal)/cat.ts | 1 + .../src/app/commands/(general)/help.ts | 22 +++++ .../commands/(hierarchical)/+middleware.ts | 6 ++ .../(hierarchical)/[ops]/+middleware.ts | 6 ++ .../[ops]/+status.middleware.ts | 6 ++ .../commands/(hierarchical)/[ops]/command.ts | 7 ++ .../[ops]/deploy/+middleware.ts | 6 ++ .../[ops]/deploy/deploy.subcommand.ts | 47 +++++++++++ .../(hierarchical)/[ops]/status.subcommand.ts | 41 +++++++++ .../(hierarchical)/[workspace]/+middleware.ts | 6 ++ .../(hierarchical)/[workspace]/command.ts | 7 ++ .../[workspace]/{notes}/+add.middleware.ts | 6 ++ .../[workspace]/{notes}/+middleware.ts | 6 ++ .../[workspace]/{notes}/add.subcommand.ts | 43 ++++++++++ .../{notes}/archive/+middleware.ts | 6 ++ .../{notes}/archive/archive.subcommand.ts | 43 ++++++++++ .../[workspace]/{notes}/group.ts | 6 ++ .../[workspace]/{team}/+middleware.ts | 6 ++ .../[workspace]/{team}/group.ts | 6 ++ .../[workspace]/{team}/handoff.subcommand.ts | 43 ++++++++++ apps/test-bot/src/utils/hierarchical-demo.ts | 84 +++++++++++++++++++ 21 files changed, 404 insertions(+) create mode 100644 apps/test-bot/src/app/commands/(hierarchical)/+middleware.ts create mode 100644 apps/test-bot/src/app/commands/(hierarchical)/[ops]/+middleware.ts create mode 100644 apps/test-bot/src/app/commands/(hierarchical)/[ops]/+status.middleware.ts create mode 100644 apps/test-bot/src/app/commands/(hierarchical)/[ops]/command.ts create mode 100644 apps/test-bot/src/app/commands/(hierarchical)/[ops]/deploy/+middleware.ts create mode 100644 apps/test-bot/src/app/commands/(hierarchical)/[ops]/deploy/deploy.subcommand.ts create mode 100644 apps/test-bot/src/app/commands/(hierarchical)/[ops]/status.subcommand.ts create mode 100644 apps/test-bot/src/app/commands/(hierarchical)/[workspace]/+middleware.ts create mode 100644 apps/test-bot/src/app/commands/(hierarchical)/[workspace]/command.ts create mode 100644 apps/test-bot/src/app/commands/(hierarchical)/[workspace]/{notes}/+add.middleware.ts create mode 100644 apps/test-bot/src/app/commands/(hierarchical)/[workspace]/{notes}/+middleware.ts create mode 100644 apps/test-bot/src/app/commands/(hierarchical)/[workspace]/{notes}/add.subcommand.ts create mode 100644 apps/test-bot/src/app/commands/(hierarchical)/[workspace]/{notes}/archive/+middleware.ts create mode 100644 apps/test-bot/src/app/commands/(hierarchical)/[workspace]/{notes}/archive/archive.subcommand.ts create mode 100644 apps/test-bot/src/app/commands/(hierarchical)/[workspace]/{notes}/group.ts create mode 100644 apps/test-bot/src/app/commands/(hierarchical)/[workspace]/{team}/+middleware.ts create mode 100644 apps/test-bot/src/app/commands/(hierarchical)/[workspace]/{team}/group.ts create mode 100644 apps/test-bot/src/app/commands/(hierarchical)/[workspace]/{team}/handoff.subcommand.ts create mode 100644 apps/test-bot/src/utils/hierarchical-demo.ts 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..9b028918 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,32 @@ 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, + }, + ] + : 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..95010a91 --- /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 leaf-directory middleware and 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..9029f7ca --- /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, plus command-specific middleware at the root level.', + 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..bfa5d816 --- /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 leaf discovered from a shorthand file with command-specific middleware in the group directory.', + 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..10577d04 --- /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 leaf-directory middleware.', + 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..fe9a2d79 --- /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.', + 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..43d78eb8 --- /dev/null +++ b/apps/test-bot/src/utils/hierarchical-demo.ts @@ -0,0 +1,84 @@ +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 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 }); +} From a47ae0ea7d35bc97509051f838f68688f1fd4316 Mon Sep 17 00:00:00 2001 From: AboMeezO Date: Wed, 8 Apr 2026 20:45:30 +0300 Subject: [PATCH 05/12] fromat & docs update --- .../02-commands/07-category-directory.mdx | 15 +++++ .../02-commands/09-subcommand-hierarchy.mdx | 60 +++++++++++++++++++ .../02-file-naming-conventions.mdx | 29 +++++++++ .../src/app/handlers/AppCommandHandler.ts | 7 +-- .../src/app/router/CommandsRouter.ts | 25 ++++++-- 5 files changed, 125 insertions(+), 11 deletions(-) create mode 100644 apps/website/docs/guide/02-commands/09-subcommand-hierarchy.mdx 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/09-subcommand-hierarchy.mdx b/apps/website/docs/guide/02-commands/09-subcommand-hierarchy.mdx new file mode 100644 index 00000000..43e635cb --- /dev/null +++ b/apps/website/docs/guide/02-commands/09-subcommand-hierarchy.mdx @@ -0,0 +1,60 @@ +--- +title: Subcommand Hierarchy +--- + +CommandKit provides an elegant and fully filesystem-based way to configure slash command structures that involve subcommands and subcommand groups. You don't need to manually bundle choices; simply group your files within folders! + +## Command Directories `[command]` + +By wrapping a directory name in square brackets `[]` (e.g., `[settings]`), CommandKit registers that directory as a **Root Slash Command**. + +Inside an active command directory, any valid command file `.ts` or `.js` represents a subcommand. + +```text title="Directory Structure" +src/app/commands/ +└── (general)/ + └── [settings]/ // Root application command `/settings` + ├── profile.ts // -> /settings profile + └── security.ts // -> /settings security +``` + +The underlying code inside `profile.ts` naturally takes the properties of a standard `ChatInputCommand`, but CommandKit parses it under the parent `/settings` command automatically. + +## Subcommand Group Directories `[group]` + +You can nest another `[]` wrapped directory immediately inside your root command to define a **Subcommand Group**. + +```text title="Directory Structure" +src/app/commands/ +└── [settings]/ // Root slash command + └── [notifications]/ // Subcommand group + ├── enable.ts // -> /settings notifications enable + └── disable.ts // -> /settings notifications disable +``` + +Your folders will exactly mirror your intended Discord hierarchy: +`/root_command group subcommand`. + +## Internal Overrides & Validations + +When writing subcommands using the file-structure syntax, the inner file defines its own execution handler, but the root command directory controls the top-level command description and execution state. + +### Resolving the Root Command Data + +To customize the Root Command's description (e.g. configuring the description of `/settings`), or adding middlewares or permissions to the root itself, use an index or specific metadata file. + +By default, any `command` object export found in the command files is merged, but since multiple files construct the tree, having specific `+middleware.ts` within the `[command]` boundaries helps secure that entire branch. + +```text title="Directory Structure" +src/app/commands/ +└── [settings]/ + ├── +middleware.ts // Runs for all subcommands inside [settings] + ├── profile.ts + └── security.ts +``` + +This lets you perfectly scope execution middleware exclusively for one complex sub-command tree without spilling out to the rest of your application! + +:::info +Folders named `[command]` directly register to Discord's API as an integrated command tree. Do **NOT** confuse these with transparent category folders constructed with parenthesis `(category)`, which just group files together without altering their command slash structure. +::: 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..248ae100 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 @@ -89,3 +89,32 @@ You can learn more about command categories [here](../02-commands/07-category-directory.mdx). ::: + +## [directory] notation + +By utilizing square brackets for folder names (e.g. `[settings]`), CommandKit recognizes the folder as a **Root Command** or **Subcommand Group**, merging everything inside into a single subcommand tree natively sent to Discord. + +``` +src/app/commands/ +└── [settings] + ├── +middleware.ts + ├── [notifications] + │ ├── enable.ts + │ └── disable.ts + └── options.ts +``` + +This transforms into the slash commands: + +- `/settings notifications enable` +- `/settings notifications disable` +- `/settings options` + +Any file named correctly (i.e. does not start with `_` or `+`) inside these structures operates as a leaf subcommand. + +:::info + +You can learn more about configuring tree structures +[here](../02-commands/09-subcommand-hierarchy.mdx). + +::: diff --git a/packages/commandkit/src/app/handlers/AppCommandHandler.ts b/packages/commandkit/src/app/handlers/AppCommandHandler.ts index a6ef0df0..c9e5071c 100644 --- a/packages/commandkit/src/app/handlers/AppCommandHandler.ts +++ b/packages/commandkit/src/app/handlers/AppCommandHandler.ts @@ -288,9 +288,7 @@ export class AppCommandHandler { const totalCount = flatCommands.length + hierarchicalRoots.length; console.log( - colors.green( - `Loaded ${colors.magenta(totalCount.toString())} commands:`, - ), + colors.green(`Loaded ${colors.magenta(totalCount.toString())} commands:`), ); // ------------------------------------------------------------------ @@ -438,8 +436,7 @@ export class AppCommandHandler { // Print children of this root root.childIds.forEach((childId, idx) => { const isLastChild = idx === root.childIds.length - 1; - const childPrefix = - rootChildIndent + (isLastChild ? '└─' : '├─'); + const childPrefix = rootChildIndent + (isLastChild ? '└─' : '├─'); const childIndentNext = rootChildIndent + (isLastChild ? ' ' : '│ '); printHierarchicalNode(childId, childPrefix, childIndentNext); diff --git a/packages/commandkit/src/app/router/CommandsRouter.ts b/packages/commandkit/src/app/router/CommandsRouter.ts index 56d9046b..e26ea3a5 100644 --- a/packages/commandkit/src/app/router/CommandsRouter.ts +++ b/packages/commandkit/src/app/router/CommandsRouter.ts @@ -282,7 +282,11 @@ export class CommandsRouter { this.clear(); this.initializeRootNode(); - await this.traverseNormalDirectory(this.options.entrypoint, null, ROOT_NODE_ID); + await this.traverseNormalDirectory( + this.options.entrypoint, + null, + ROOT_NODE_ID, + ); await this.applyMiddlewares(); this.compileTree(); @@ -356,7 +360,11 @@ export class CommandsRouter { * @private * @internal */ - private async traverseNormalDirectory(path: string, category: string | null, parentId: string) { + private async traverseNormalDirectory( + path: string, + category: string | null, + parentId: string, + ) { const entries = await readdir(path, { withFileTypes: true, }); @@ -437,7 +445,9 @@ export class CommandsRouter { } if (this.isCategory(entry.name)) { - const nestedCategory = category ? `${category}:${entry.name.slice(1, -1)}` : entry.name.slice(1, -1); + const nestedCategory = category + ? `${category}:${entry.name.slice(1, -1)}` + : entry.name.slice(1, -1); await this.traverseNormalDirectory(fullPath, nestedCategory, parentId); continue; } @@ -534,9 +544,10 @@ export class CommandsRouter { continue; } - if (this.isCategory(entry.name)) { - const nestedCategory = category ? `${category}:${entry.name.slice(1, -1)}` : entry.name.slice(1, -1); + const nestedCategory = category + ? `${category}:${entry.name.slice(1, -1)}` + : entry.name.slice(1, -1); await this.traverseNormalDirectory(fullPath, nestedCategory, node.id); continue; } @@ -642,7 +653,9 @@ export class CommandsRouter { } if (this.isCategory(entry.name)) { - const nestedCategory = category ? `${category}:${entry.name.slice(1, -1)}` : entry.name.slice(1, -1); + const nestedCategory = category + ? `${category}:${entry.name.slice(1, -1)}` + : entry.name.slice(1, -1); await this.traverseNormalDirectory(fullPath, nestedCategory, node.id); continue; } From faa3e11431e382ba8877a96ecc293b41ab3188d3 Mon Sep 17 00:00:00 2001 From: AboMeezO Date: Wed, 8 Apr 2026 21:06:57 +0300 Subject: [PATCH 06/12] chore: update path-to-regexp security override in package.json and lockfile --- package.json | 2 +- pnpm-lock.yaml | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index e56ba6eb..36e8bc47 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,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/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: {} From 2a7ba242ea376e536e48145df0ec297053bd1bef Mon Sep 17 00:00:00 2001 From: AboMeezO Date: Wed, 8 Apr 2026 21:39:09 +0300 Subject: [PATCH 07/12] feat: implement CommandsRouter for filesystem-based command and middleware discovery --- packages/commandkit/src/cli/development.ts | 254 +++++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100644 packages/commandkit/src/cli/development.ts diff --git a/packages/commandkit/src/cli/development.ts b/packages/commandkit/src/cli/development.ts new file mode 100644 index 00000000..50c187fd --- /dev/null +++ b/packages/commandkit/src/cli/development.ts @@ -0,0 +1,254 @@ +import { join } from 'path'; +import { getPossibleConfigPaths, loadConfigFile } from '../config/loader'; +import { isCompilerPlugin } from '../plugins'; +import { createAppProcess } from './app-process'; +import { buildApplication } from './build'; +import { watch } from 'chokidar'; +import { debounce } from '../utils/utilities'; +import colors from '../utils/colors'; +import { ChildProcess } from 'node:child_process'; +import { setTimeout as sleep } from 'node:timers/promises'; +import { randomUUID } from 'node:crypto'; +import { COMMANDKIT_CWD, HMREventType } from '../utils/constants'; +import { findEntrypoint } from './common'; + +/** + * @private + * @internal + */ +async function buildAndStart(configPath: string, skipStart = false) { + const config = await loadConfigFile(configPath); + const mainFile = findEntrypoint('.commandkit'); + + await buildApplication({ + configPath, + isDev: true, + plugins: config.plugins.flat(2).filter((p) => isCompilerPlugin(p)), + rolldownPlugins: config.rolldownPlugins, + }); + + if (skipStart) return null as never; + + const ps = createAppProcess(mainFile, configPath, true); + + return ps; +} + +/** + * @private + * @internal + */ +const isCommandSource = (p: string) => + p.replaceAll('\\', '/').includes('src/app/commands'); + +/** + * @private + * @internal + */ +const isEventSource = (p: string) => + p.replaceAll('\\', '/').includes('src/app/events'); + +/** + * @private + * @internal + */ +export async function bootstrapDevelopmentServer(configPath?: string) { + process.env.COMMANDKIT_BOOTSTRAP_MODE = 'development'; + process.env.COMMANDKIT_INTERNAL_IS_CLI_PROCESS = 'true'; + const start = performance.now(); + const cwd = configPath || COMMANDKIT_CWD; + const configPaths = getPossibleConfigPaths(cwd); + + const watcher = watch([join(cwd, 'src'), ...configPaths], { + ignoreInitial: true, + }); + + let ps: ChildProcess | null = null; + + const waitForAcknowledgment = (messageId: string): Promise => { + return new Promise((resolve) => { + if (!ps) return resolve(false); + + let _handled = false; + const onMessage = (message: any) => { + _handled = true; + if (typeof message !== 'object' || message === null) return; + + const { type, id, handled } = message; + if (type === 'commandkit-hmr-ack' && id === messageId) { + ps?.off('message', onMessage); + resolve(!!handled); + } + }; + + ps.once('message', onMessage); + + if (!_handled) { + sleep(3000).then(() => { + ps?.off('message', onMessage); + resolve(false); + }); + } + }); + }; + + const sendHmrEvent = async ( + event: HMREventType, + path?: string, + ): Promise => { + if (!ps || !ps.send) return false; + + const messageId = randomUUID(); + const messagePromise = waitForAcknowledgment(messageId); + + ps.send({ event, path, id: messageId }); + + // Wait for acknowledgment or timeout after 3 seconds + try { + let triggered = false; + const res = !!(await Promise.race([ + messagePromise, + sleep(3000).then(() => { + if (!triggered) { + console.warn( + colors.yellow( + `HMR acknowledgment timed out for event ${event} on path ${path}`, + ), + ); + } + return false; + }), + ])); + + triggered = true; + + return res; + } catch (error) { + console.error( + colors.red(`Error waiting for HMR acknowledgment: ${error}`), + ); + return false; + } + }; + + const performHMR = debounce(async (path?: string): Promise => { + if (!path || !ps) return false; + + let eventType: HMREventType | null = null; + let eventDescription = ''; + + if (isCommandSource(path)) { + eventType = HMREventType.ReloadCommands; + eventDescription = 'command(s)'; + } else if (isEventSource(path)) { + eventType = HMREventType.ReloadEvents; + eventDescription = 'event(s)'; + } else { + eventType = HMREventType.Unknown; + eventDescription = 'unknown source'; + } + + if (eventType) { + console.log( + `${colors.cyanBright(`Attempting to reload ${eventDescription} at`)} ${colors.yellowBright(path)}`, + ); + + await buildAndStart(cwd, true); + const hmrHandled = await sendHmrEvent(eventType, path); + + if (hmrHandled) { + console.log( + `${colors.greenBright(`Successfully hot reloaded ${eventDescription} at`)} ${colors.yellowBright(path)}`, + ); + return true; + } + } + + return false; + }, 700); + + const isConfigUpdate = (path: string) => { + const isConfig = configPaths.some((configPath) => path === configPath); + + if (!isConfig) return false; + + console.log( + colors.yellowBright( + 'It seems like commandkit config file was updated, please restart the server manually to apply changes.', + ), + ); + + return isConfig; + }; + + const hmrHandler = async (path: string) => { + if (isConfigUpdate(path)) return; + const hmr = await performHMR(path); + if (hmr) return; + + console.log( + `${colors.yellowBright('⚡️ Performing full restart due to the changes in')} ${colors.cyanBright(path)}`, + ); + + ps?.kill(); + ps = await buildAndStart(cwd); + }; + + process.stdin.on('data', async (d) => { + const command = d.toString().trim(); + + switch (command) { + case 'r': + console.log(`Received restart command, restarting...`); + ps?.kill(); + ps = null; + ps = await buildAndStart(cwd); + break; + case 'rc': + console.log(`Received reload commands command, reloading...`); + await sendHmrEvent(HMREventType.ReloadCommands); + break; + case 're': + console.log(`Received reload events command, reloading...`); + await sendHmrEvent(HMREventType.ReloadEvents); + break; + break; + } + }); + + watcher.on('change', hmrHandler); + watcher.on('add', hmrHandler); + watcher.on('unlink', hmrHandler); + watcher.on('unlinkDir', hmrHandler); + watcher.on('error', (e) => { + console.error(e); + }); + + console.log(`${colors.greenBright('Bootstrapped CommandKit Development Environment in')} ${colors.yellowBright(`${(performance.now() - start).toFixed(2)}ms`)} +${colors.greenBright('Watching for changes in')} ${colors.yellowBright('src')} ${colors.greenBright('directory')} + +${colors.greenBright('Commands:')} +${colors.yellowBright('r')} - Restart the server +${colors.yellowBright('rc')} - Reload all commands +${colors.yellowBright('re')} - Reload all events`); + + const buildStart = performance.now(); + + ps = await buildAndStart(cwd); + + const buildEnd = performance.now(); + + console.log( + `\n${colors.greenBright('Development mode compilation took')} ${colors.yellowBright(`${(buildEnd - buildStart).toFixed(2)}ms`)}\n`, + ); + + return { + watcher, + isConfigUpdate, + performHMR, + hmrHandler, + sendHmrEvent, + getProcess: () => ps, + buildAndStart, + }; +} From 0ea8a9973b1d476459e2869e2449bd4c430f45e4 Mon Sep 17 00:00:00 2001 From: AboMeezO Date: Wed, 8 Apr 2026 21:39:17 +0300 Subject: [PATCH 08/12] feat: implement AppCommandHandler and CommandsRouter for enhanced command management and routing --- .../src/app/handlers/AppCommandHandler.ts | 341 +++++++--------- .../src/app/router/CommandsRouter.ts | 375 +++++++----------- 2 files changed, 283 insertions(+), 433 deletions(-) diff --git a/packages/commandkit/src/app/handlers/AppCommandHandler.ts b/packages/commandkit/src/app/handlers/AppCommandHandler.ts index c9e5071c..f468632a 100644 --- a/packages/commandkit/src/app/handlers/AppCommandHandler.ts +++ b/packages/commandkit/src/app/handlers/AppCommandHandler.ts @@ -1050,6 +1050,125 @@ export class AppCommandHandler { 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 @@ -1079,71 +1198,8 @@ export class AppCommandHandler { 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 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: [], - }; - - // 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.'; - } - - // Update the command data with resolved name and description - 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 command ${command.name}: ${key} does not match expected value`, - ); - } - - if (!KNOWN_NON_HANDLER_KEYS.includes(key)) { - // command file includes a handler function (chatInput, message, etc) - handlerCount++; - } - } - } + const { commandFileData, handlerCount, commandJson, resolvedMetadata } = + await this.processCommandFile(command.path, command.name, command.name, false); if (handlerCount === 0) { throw new Error( @@ -1151,33 +1207,6 @@ export class AppCommandHandler { ); } - 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.`, - ); - } - - const resolvedMetadata = { - guilds: commandJson.guilds, - aliases: commandJson.aliases, - ...metadata, - }; - const loadedCommand: LoadedCommand = { discordId: null, command, @@ -1230,83 +1259,20 @@ export class AppCommandHandler { }; try { - const commandFileData = (await import( - `${toFileURL(command.path)}?t=${Date.now()}` - )) as AppCommandNative; - - if (!commandFileData.command) { - throw new Error( - `Invalid export for hierarchical node ${routeKey}: 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: [], - }; - - if ( - typeof commandFileData.command.name === 'string' && - commandFileData.command.name !== node.token - ) { - Logger.warn( - `Hierarchical node \`${routeKey}\` overrides its command name with \`${commandFileData.command.name}\`. The filesystem token \`${node.token}\` will be used instead.`, - ); - } - - const commandName = node.token; - let commandDescription = commandFileData.command.description as - | string - | undefined; - - if (!commandDescription) { - 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 hierarchical node ${routeKey}: ${key} does not match expected value`, - ); - } - - if (!KNOWN_NON_HANDLER_KEYS.includes(key)) { - handlerCount++; - } - } - } + const { commandFileData, handlerCount, commandJson, resolvedMetadata } = + await this.processCommandFile(command.path, routeKey, node.token, true); + + const isRootHierarchyLeaf = node.kind === 'command'; + const hasContextMenuHandlers = !!(commandFileData.userContextMenu || commandFileData.messageContextMenu); + const hasExecutableSlashHandlers = !!( + commandFileData.chatInput || + commandFileData.message || + commandFileData.autocomplete + ); - if ( - commandFileData.userContextMenu || - commandFileData.messageContextMenu - ) { + if (!isRootHierarchyLeaf && hasContextMenuHandlers) { throw new Error( - `Invalid export for hierarchical node ${routeKey}: context menu handlers are only supported for flat commands`, + `Invalid export for hierarchical node ${routeKey}: context menu handlers are only supported for top-level root commands.`, ); } @@ -1316,39 +1282,12 @@ export class AppCommandHandler { ); } - if (!node.executable && handlerCount > 0) { + if (!node.executable && hasExecutableSlashHandlers) { throw new Error( - `Invalid export for hierarchical node ${routeKey}: non-leaf hierarchical nodes cannot export executable handlers`, + `Invalid export for hierarchical node ${routeKey}: non-leaf hierarchical nodes cannot export executable slash/prefix handlers`, ); } - 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 \`${routeKey}\` uses deprecated metadata properties. Please update to use the new \`metadata\` object or \`generateMetadata\` function.`, - ); - } - - const resolvedMetadata = { - guilds: commandJson.guilds, - aliases: commandJson.aliases, - ...metadata, - }; - const loadedCommand: LoadedCommand = { discordId: null, command, @@ -1368,6 +1307,16 @@ export class AppCommandHandler { 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 hierarchical node ${routeKey} (${node.id}): ${error}`; } diff --git a/packages/commandkit/src/app/router/CommandsRouter.ts b/packages/commandkit/src/app/router/CommandsRouter.ts index e26ea3a5..56df83d7 100644 --- a/packages/commandkit/src/app/router/CommandsRouter.ts +++ b/packages/commandkit/src/app/router/CommandsRouter.ts @@ -282,8 +282,9 @@ export class CommandsRouter { this.clear(); this.initializeRootNode(); - await this.traverseNormalDirectory( + await this.traverseDirectory( this.options.entrypoint, + 'normal', null, ROOT_NODE_ID, ); @@ -360,187 +361,189 @@ export class CommandsRouter { * @private * @internal */ - private async traverseNormalDirectory( + 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) { if (entry.name.startsWith('_')) continue; + const fullPath = join(path, entry.name); if (entry.isFile()) { - if (this.isSubcommandFile(entry.name)) { - if (parentId === ROOT_NODE_ID) { + 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( - 'ORPHAN_SUBCOMMAND_FILE', - 'Subcommand shorthand files must be nested inside a command or group directory.', + 'UNSUPPORTED_FILE_IN_COMMAND_DIRECTORY', + 'Only command.ts, middleware files, and subcommand shorthand files are supported inside a command directory.', fullPath, ); - } else { + } + } 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, + parentId: currentNodeId, directoryPath: path, definitionPath: fullPath, shorthand: true, }); + continue; } - continue; - } - - if (this.isCommand(entry.name) || this.isMiddleware(entry.name)) { - const result = await this.handle(entry, category); - - if (result.command) { - this.createFlatCommandNode(result.command); + 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); + } } } - continue; } if (!entry.isDirectory()) continue; if (this.isCommandDirectory(entry.name)) { - if (parentId !== ROOT_NODE_ID) { + if (state === 'command') { + this.addDiagnostic( + 'NESTED_COMMAND_NOT_ALLOWED', + 'Command directories cannot contain nested root command directories.', + fullPath, + ); + } else if (state === 'group') { + this.addDiagnostic( + 'NESTED_COMMAND_NOT_ALLOWED', + 'Group directories cannot contain nested root command directories.', + fullPath, + ); + } else if (currentNodeId !== ROOT_NODE_ID) { this.addDiagnostic( 'NESTED_COMMAND_NOT_ALLOWED', 'Command directories cannot be nested under group or subcommand directories.', fullPath, ); - continue; + } else { + await this.traverseDirectory( + fullPath, + 'command', + category, + ROOT_NODE_ID, + entry.name.match(COMMAND_DIRECTORY_PATTERN)![1] + ); } - await this.traverseCommandDirectory( - fullPath, - entry.name.match(COMMAND_DIRECTORY_PATTERN)![1], - category, - ROOT_NODE_ID, - ); continue; } if (this.isGroupDirectory(entry.name)) { - if (parentId === ROOT_NODE_ID) { + if (currentNodeId === ROOT_NODE_ID) { this.addDiagnostic( 'ORPHAN_GROUP_DIRECTORY', 'Group directories must be nested inside a command directory.', fullPath, ); - continue; - } - await this.traverseGroupDirectory( - fullPath, - entry.name.match(GROUP_DIRECTORY_PATTERN)![1], - category, - parentId, - ); - continue; - } - - if (this.isCategory(entry.name)) { - const nestedCategory = category - ? `${category}:${entry.name.slice(1, -1)}` - : entry.name.slice(1, -1); - await this.traverseNormalDirectory(fullPath, nestedCategory, parentId); - continue; - } - - await this.traverseNormalDirectory(fullPath, category, parentId); - } - } - - /** - * @private - * @internal - */ - private async traverseCommandDirectory( - path: string, - token: string, - category: string | null, - parentId: string, - ) { - const node = this.createTreeNode({ - source: 'directory', - token, - category, - parentId, - directoryPath: path, - definitionPath: null, - shorthand: false, - }); - - if (!node) return; - - const entries = await readdir(path, { - withFileTypes: true, - }); - - for (const entry of entries) { - if (entry.name.startsWith('_')) continue; - - const fullPath = join(path, entry.name); - - if (entry.isFile()) { - 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: node.id, - 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.', + } else { + await this.traverseDirectory( fullPath, + 'group', + category, + currentNodeId, + entry.name.match(GROUP_DIRECTORY_PATTERN)![1] ); } - - continue; - } - - if (!entry.isDirectory()) continue; - - if (this.isCommandDirectory(entry.name)) { - this.addDiagnostic( - 'NESTED_COMMAND_NOT_ALLOWED', - 'Command directories cannot contain nested root command directories.', - fullPath, - ); - continue; - } - - if (this.isGroupDirectory(entry.name)) { - await this.traverseGroupDirectory( - fullPath, - entry.name.match(GROUP_DIRECTORY_PATTERN)![1], - category, - node.id, - ); continue; } @@ -548,122 +551,20 @@ export class CommandsRouter { const nestedCategory = category ? `${category}:${entry.name.slice(1, -1)}` : entry.name.slice(1, -1); - await this.traverseNormalDirectory(fullPath, nestedCategory, node.id); + await this.traverseDirectory(fullPath, 'normal', nestedCategory, currentNodeId); continue; } - await this.traverseNormalDirectory(fullPath, category, node.id); + await this.traverseDirectory(fullPath, 'normal', category, currentNodeId); } - if (!node.definitionPath) { + if (state === 'command' && node && !node.definitionPath) { this.addDiagnostic( 'MISSING_COMMAND_DEFINITION', 'Command directories must include a command.ts file.', path, ); - } - } - - /** - * @private - * @internal - */ - private async traverseGroupDirectory( - path: string, - token: string, - category: string | null, - parentId: string, - ) { - const node = this.createTreeNode({ - source: 'group', - token, - category, - parentId, - directoryPath: path, - definitionPath: null, - shorthand: false, - }); - - if (!node) return; - - const entries = await readdir(path, { - withFileTypes: true, - }); - - for (const entry of entries) { - if (entry.name.startsWith('_')) continue; - - const fullPath = join(path, entry.name); - - if (entry.isFile()) { - 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: node.id, - 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, - ); - } - - continue; - } - - if (!entry.isDirectory()) continue; - - if (this.isCommandDirectory(entry.name)) { - this.addDiagnostic( - 'NESTED_COMMAND_NOT_ALLOWED', - 'Group directories cannot contain nested root command directories.', - fullPath, - ); - continue; - } - - if (this.isGroupDirectory(entry.name)) { - await this.traverseGroupDirectory( - fullPath, - entry.name.match(GROUP_DIRECTORY_PATTERN)![1], - category, - node.id, - ); - continue; - } - - if (this.isCategory(entry.name)) { - const nestedCategory = category - ? `${category}:${entry.name.slice(1, -1)}` - : entry.name.slice(1, -1); - await this.traverseNormalDirectory(fullPath, nestedCategory, node.id); - continue; - } - - await this.traverseNormalDirectory(fullPath, category, node.id); - } - - if (!node.definitionPath) { + } else if (state === 'group' && node && !node.definitionPath) { this.addDiagnostic( 'MISSING_GROUP_DEFINITION', 'Group directories must include a group.ts file.', From 890150cfabaefbcf137f12ad3be2e3319c971358 Mon Sep 17 00:00:00 2001 From: AboMeezO Date: Wed, 8 Apr 2026 22:14:06 +0300 Subject: [PATCH 09/12] feat: implement AppCommandHandler, CommandsRouter, and development CLI utilities for enhanced command management --- .../src/app/handlers/AppCommandHandler.ts | 11 +++++-- .../src/app/router/CommandsRouter.ts | 33 ++++++++++--------- 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/packages/commandkit/src/app/handlers/AppCommandHandler.ts b/packages/commandkit/src/app/handlers/AppCommandHandler.ts index f468632a..24e501c7 100644 --- a/packages/commandkit/src/app/handlers/AppCommandHandler.ts +++ b/packages/commandkit/src/app/handlers/AppCommandHandler.ts @@ -1199,7 +1199,12 @@ export class AppCommandHandler { } const { commandFileData, handlerCount, commandJson, resolvedMetadata } = - await this.processCommandFile(command.path, command.name, command.name, false); + await this.processCommandFile( + command.path, + command.name, + command.name, + false, + ); if (handlerCount === 0) { throw new Error( @@ -1263,7 +1268,9 @@ export class AppCommandHandler { await this.processCommandFile(command.path, routeKey, node.token, true); const isRootHierarchyLeaf = node.kind === 'command'; - const hasContextMenuHandlers = !!(commandFileData.userContextMenu || commandFileData.messageContextMenu); + const hasContextMenuHandlers = !!( + commandFileData.userContextMenu || commandFileData.messageContextMenu + ); const hasExecutableSlashHandlers = !!( commandFileData.chatInput || commandFileData.message || diff --git a/packages/commandkit/src/app/router/CommandsRouter.ts b/packages/commandkit/src/app/router/CommandsRouter.ts index 56df83d7..4914b78a 100644 --- a/packages/commandkit/src/app/router/CommandsRouter.ts +++ b/packages/commandkit/src/app/router/CommandsRouter.ts @@ -366,7 +366,7 @@ export class CommandsRouter { state: 'normal' | 'command' | 'group', category: string | null, parentId: string, - token?: string + token?: string, ) { let node: CommandTreeNode | null = null; @@ -498,22 +498,18 @@ export class CommandsRouter { if (!entry.isDirectory()) continue; if (this.isCommandDirectory(entry.name)) { - if (state === 'command') { - this.addDiagnostic( - 'NESTED_COMMAND_NOT_ALLOWED', - 'Command directories cannot contain nested root command directories.', - fullPath, - ); - } else if (state === 'group') { - this.addDiagnostic( - 'NESTED_COMMAND_NOT_ALLOWED', - 'Group directories cannot contain nested root command directories.', + 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.', + 'Command directories cannot be nested under group or subcommand directories unless they are part of a hierarchical structure.', fullPath, ); } else { @@ -522,7 +518,7 @@ export class CommandsRouter { 'command', category, ROOT_NODE_ID, - entry.name.match(COMMAND_DIRECTORY_PATTERN)![1] + entry.name.match(COMMAND_DIRECTORY_PATTERN)![1], ); } continue; @@ -531,7 +527,7 @@ export class CommandsRouter { if (this.isGroupDirectory(entry.name)) { if (currentNodeId === ROOT_NODE_ID) { this.addDiagnostic( - 'ORPHAN_GROUP_DIRECTORY', + 'ROOT_GROUP_NOT_ALLOWED', 'Group directories must be nested inside a command directory.', fullPath, ); @@ -541,7 +537,7 @@ export class CommandsRouter { 'group', category, currentNodeId, - entry.name.match(GROUP_DIRECTORY_PATTERN)![1] + entry.name.match(GROUP_DIRECTORY_PATTERN)![1], ); } continue; @@ -551,7 +547,12 @@ export class CommandsRouter { const nestedCategory = category ? `${category}:${entry.name.slice(1, -1)}` : entry.name.slice(1, -1); - await this.traverseDirectory(fullPath, 'normal', nestedCategory, currentNodeId); + await this.traverseDirectory( + fullPath, + 'normal', + nestedCategory, + currentNodeId, + ); continue; } From d89384f2a8af07c44fa85be341475fd42061841f Mon Sep 17 00:00:00 2001 From: AboMeezO Date: Wed, 8 Apr 2026 22:47:03 +0300 Subject: [PATCH 10/12] Refactor command handling system to dynamically support arbitrary subcommand nesting depth across parsers, handlers, and execution contexts. --- .../src/app/commands/MessageCommandParser.ts | 36 +++++++++++-------- .../src/app/handlers/AppCommandHandler.ts | 8 +---- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/packages/commandkit/src/app/commands/MessageCommandParser.ts b/packages/commandkit/src/app/commands/MessageCommandParser.ts index 5e776b7c..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(' '); } /** @@ -167,26 +174,24 @@ export class MessageCommandParser { this.#args = parts; - let command: string | undefined = commandToken; + let command: string | undefined = ''; let subcommandGroup: string | undefined; let subcommand: string | undefined; - if (commandToken?.includes(':')) { - const [root, group, cmd] = commandToken.split(':'); + const fullRoute = commandToken?.split(':') ?? []; - command = root; + 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) => { @@ -247,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 24e501c7..db90cca1 100644 --- a/packages/commandkit/src/app/handlers/AppCommandHandler.ts +++ b/packages/commandkit/src/app/handlers/AppCommandHandler.ts @@ -591,13 +591,7 @@ export class AppCommandHandler { * @internal */ private buildMessageRouteKey(parser: MessageCommandParser) { - return [ - parser.getCommand(), - parser.getSubcommandGroup(), - parser.getSubcommand(), - ] - .filter(Boolean) - .join('.'); + return parser.getFullRoute().join('.'); } /** From dad59a1046adb8f4aeb1f35d3f3d20e03f92cacf Mon Sep 17 00:00:00 2001 From: AboMeezO Date: Wed, 8 Apr 2026 23:15:17 +0300 Subject: [PATCH 11/12] docs: update skills and operational guides to support hierarchical command discovery and routing conventions --- AGENTS.md | 7 +++ skills/commandkit/SKILL.md | 7 +-- .../references/00-filesystem-structure.md | 22 ++++++++ .../references/02-chat-input-command.md | 54 +++++++++++++++---- .../commandkit/references/05-middlewares.md | 38 +++++++++---- .../references/08-file-naming-conventions.md | 14 ++++- 6 files changed, 119 insertions(+), 23 deletions(-) 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/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..0c0c896b 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,34 @@ 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. +- Inheritance: Subcommands inherit all global and directory-level + middlewares from their parents in the hierarchy. - 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 `{group}` folders for group-specific auth. +- 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 +- Breaking middleware inheritance by misnaming 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`. From 824e5e849d9217092b6147015eadc70458fadcf7 Mon Sep 17 00:00:00 2001 From: AboMeezO Date: Thu, 9 Apr 2026 01:14:15 +0300 Subject: [PATCH 12/12] Fix command handler reloads, middleware scope, and docs --- .github/workflows/code-quality.yaml | 6 + .../src/app/commands/(general)/help.ts | 5 + .../[ops]/deploy/deploy.subcommand.ts | 2 +- .../(hierarchical)/[ops]/status.subcommand.ts | 2 +- .../[workspace]/{notes}/add.subcommand.ts | 2 +- .../{notes}/archive/archive.subcommand.ts | 2 +- .../[workspace]/{team}/handoff.subcommand.ts | 2 +- apps/test-bot/src/utils/hierarchical-demo.ts | 1 + .../api-reference/cache/functions/cache.mdx | 8 +- .../cache/interfaces/cache-context.mdx | 14 +- .../classes/app-command-handler.mdx | 64 ++-- .../commandkit/classes/command-registrar.mdx | 23 +- .../commandkit/classes/commands-router.mdx | 44 ++- .../classes/message-command-options.mdx | 2 +- .../classes/message-command-parser.mdx | 8 +- .../commandkit/classes/middleware-context.mdx | 2 +- .../commandkit/functions/build-only.mdx | 2 +- .../commandkit/functions/create-proxy.mdx | 2 +- .../commandkit/functions/defer.mdx | 2 +- .../commandkit/functions/dev-only.mdx | 2 +- .../commandkit/functions/json-serialize.mdx | 2 +- .../commandkit/functions/no-build-only.mdx | 2 +- .../interfaces/app-command-native.mdx | 2 +- .../interfaces/command-route-diagnostic.mdx | 48 +++ .../interfaces/command-tree-node.mdx | 108 +++++++ .../commandkit/interfaces/command.mdx | 2 +- .../interfaces/commands-router-options.mdx | 2 +- .../interfaces/compiled-command-route.mdx | 89 ++++++ .../interfaces/custom-app-command-props.mdx | 2 +- .../commandkit/interfaces/loaded-command.mdx | 2 +- .../commandkit/interfaces/middleware.mdx | 2 +- .../interfaces/parsed-command-data.mdx | 22 +- .../interfaces/parsed-message-command.mdx | 6 + .../pre-register-commands-event.mdx | 2 +- .../prepared-app-command-execution.mdx | 2 +- .../commandkit/interfaces/simple-proxy.mdx | 2 +- .../commandkit/types/app-command.mdx | 2 +- .../commandkit/types/command-builder-like.mdx | 2 +- .../types/command-tree-node-kind.mdx | 26 ++ .../types/command-tree-node-source.mdx | 26 ++ .../commandkit/types/command-type-data.mdx | 2 +- .../types/message-command-options-schema.mdx | 2 +- .../commandkit/types/resolvable-command.mdx | 2 +- .../commandkit/types/run-command.mdx | 2 +- .../functions/build-scope-prefix.mdx | 12 +- .../ratelimit/functions/ratelimit.mdx | 6 +- .../functions/resolve-scope-keys.mdx | 6 +- .../ratelimit/interfaces/rate-limit-hooks.mdx | 8 +- .../docs/guide/02-commands/08-middlewares.mdx | 26 +- .../02-commands/09-subcommand-hierarchy.mdx | 123 +++++-- .../02-file-naming-conventions.mdx | 104 +++--- package.json | 2 + .../commandkit/spec/commands-router.test.ts | 3 - .../spec/context-command-identifier.test.ts | 195 +++++++++++ .../spec/hierarchical-command-handler.test.ts | 1 - .../commandkit/spec/reload-commands.test.ts | 302 ++++++++++++++++++ .../commandkit/src/app/commands/Context.ts | 2 +- .../src/app/handlers/AppCommandHandler.ts | 36 ++- .../src/app/router/CommandsRouter.ts | 67 ++-- packages/commandkit/src/utils/dev-hooks.ts | 4 +- .../commandkit/references/05-middlewares.md | 11 +- 61 files changed, 1195 insertions(+), 267 deletions(-) create mode 100644 apps/website/docs/api-reference/commandkit/interfaces/command-route-diagnostic.mdx create mode 100644 apps/website/docs/api-reference/commandkit/interfaces/command-tree-node.mdx create mode 100644 apps/website/docs/api-reference/commandkit/interfaces/compiled-command-route.mdx create mode 100644 apps/website/docs/api-reference/commandkit/types/command-tree-node-kind.mdx create mode 100644 apps/website/docs/api-reference/commandkit/types/command-tree-node-source.mdx create mode 100644 packages/commandkit/spec/context-command-identifier.test.ts create mode 100644 packages/commandkit/spec/reload-commands.test.ts 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/apps/test-bot/src/app/commands/(general)/help.ts b/apps/test-bot/src/app/commands/(general)/help.ts index 9b028918..3f12233f 100644 --- a/apps/test-bot/src/app/commands/(general)/help.ts +++ b/apps/test-bot/src/app/commands/(general)/help.ts @@ -59,6 +59,11 @@ export const chatInput: ChatInputCommand = async (ctx) => { 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: { 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 index 95010a91..9c364317 100644 --- 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 @@ -38,7 +38,7 @@ async function execute(ctx: ChatInputCommandContext | MessageCommandContext) { shape: 'root command -> direct subcommand', leafStyle: 'folder leaf ([deploy]/command.ts)', summary: - 'Shows a direct folder-based subcommand with leaf-directory middleware and the same prefix route syntax.', + '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}`], }); } 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 index 9029f7ca..1d841738 100644 --- a/apps/test-bot/src/app/commands/(hierarchical)/[ops]/status.subcommand.ts +++ b/apps/test-bot/src/app/commands/(hierarchical)/[ops]/status.subcommand.ts @@ -32,7 +32,7 @@ async function execute(ctx: ChatInputCommandContext | MessageCommandContext) { shape: 'root command -> direct subcommand', leafStyle: 'shorthand file (status.subcommand.ts)', summary: - 'Shows a direct subcommand branch without groups, plus command-specific middleware at the root level.', + '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}`], }); } 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 index bfa5d816..de0e9aad 100644 --- 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 @@ -34,7 +34,7 @@ async function execute(ctx: ChatInputCommandContext | MessageCommandContext) { shape: 'root command -> group -> subcommand', leafStyle: 'shorthand file (add.subcommand.ts)', summary: - 'Shows a grouped leaf discovered from a shorthand file with command-specific middleware in the group directory.', + 'Shows a grouped shorthand leaf that uses only middleware from the current group directory, including same-directory command middleware.', details: [`title: ${title}`, `topic: ${topic}`], }); } 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 index 10577d04..fc703e7f 100644 --- 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 @@ -34,7 +34,7 @@ async function execute(ctx: ChatInputCommandContext | MessageCommandContext) { shape: 'root command -> group -> subcommand', leafStyle: 'folder leaf ([archive]/command.ts)', summary: - 'Shows a grouped leaf discovered from a nested command directory with leaf-directory middleware.', + 'Shows a grouped leaf discovered from a nested command directory with middleware scoped only to that leaf directory.', details: [`note: ${note}`, `reason: ${reason}`], }); } 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 index fe9a2d79..c64423db 100644 --- 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 @@ -34,7 +34,7 @@ async function execute(ctx: ChatInputCommandContext | MessageCommandContext) { 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.', + '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}`], }); } diff --git a/apps/test-bot/src/utils/hierarchical-demo.ts b/apps/test-bot/src/utils/hierarchical-demo.ts index 43d78eb8..e4a511a4 100644 --- a/apps/test-bot/src/utils/hierarchical-demo.ts +++ b/apps/test-bot/src/utils/hierarchical-demo.ts @@ -60,6 +60,7 @@ export async function replyWithHierarchyDemo( `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}`, 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/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 index 43e635cb..09e9f711 100644 --- a/apps/website/docs/guide/02-commands/09-subcommand-hierarchy.mdx +++ b/apps/website/docs/guide/02-commands/09-subcommand-hierarchy.mdx @@ -2,59 +2,128 @@ title: Subcommand Hierarchy --- -CommandKit provides an elegant and fully filesystem-based way to configure slash command structures that involve subcommands and subcommand groups. You don't need to manually bundle choices; simply group your files within folders! +CommandKit supports fully filesystem-based slash command trees. The +filesystem tokens are explicit: -## Command Directories `[command]` +- `[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 -By wrapping a directory name in square brackets `[]` (e.g., `[settings]`), CommandKit registers that directory as a **Root Slash Command**. +## Root Command Directories `[command]` -Inside an active command directory, any valid command file `.ts` or `.js` represents a subcommand. +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]/ // Root application command `/settings` - ├── profile.ts // -> /settings profile - └── security.ts // -> /settings security + └── [settings]/ + ├── command.ts + ├── profile.subcommand.ts + └── security.subcommand.ts ``` -The underlying code inside `profile.ts` naturally takes the properties of a standard `ChatInputCommand`, but CommandKit parses it under the parent `/settings` command automatically. +This produces: -## Subcommand Group Directories `[group]` +- `/settings profile` +- `/settings security` -You can nest another `[]` wrapped directory immediately inside your root command to define a **Subcommand Group**. +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]/ // Root slash command - └── [notifications]/ // Subcommand group - ├── enable.ts // -> /settings notifications enable - └── disable.ts // -> /settings notifications disable +└── [settings]/ + ├── command.ts + └── {notifications}/ + ├── group.ts + ├── enable.subcommand.ts + └── disable.subcommand.ts ``` -Your folders will exactly mirror your intended Discord hierarchy: -`/root_command group subcommand`. +This produces: -## Internal Overrides & Validations +- `/settings notifications enable` +- `/settings notifications disable` -When writing subcommands using the file-structure syntax, the inner file defines its own execution handler, but the root command directory controls the top-level command description and execution state. +## Folder Leaves And Shorthand Leaves -### Resolving the Root Command Data +You can define leaves in two ways: -To customize the Root Command's description (e.g. configuring the description of `/settings`), or adding middlewares or permissions to the root itself, use an index or specific metadata file. +1. Shorthand files with `*.subcommand.ts` +2. Directory-backed leaves with `[leaf]/command.ts` -By default, any `command` object export found in the command files is merged, but since multiple files construct the tree, having specific `+middleware.ts` within the `[command]` boundaries helps secure that entire branch. +Both compile to executable subcommands. ```text title="Directory Structure" src/app/commands/ -└── [settings]/ - ├── +middleware.ts // Runs for all subcommands inside [settings] - ├── profile.ts - └── security.ts +└── [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 ``` -This lets you perfectly scope execution middleware exclusively for one complex sub-command tree without spilling out to the rest of your application! +`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 -Folders named `[command]` directly register to Discord's API as an integrated command tree. Do **NOT** confuse these with transparent category folders constructed with parenthesis `(category)`, which just group files together without altering their command slash structure. +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 248ae100..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 @@ -90,27 +73,42 @@ You can learn more about command categories ::: -## [directory] notation +## Hierarchical Command Directories -By utilizing square brackets for folder names (e.g. `[settings]`), CommandKit recognizes the folder as a **Root Command** or **Subcommand Group**, merging everything inside into a single subcommand tree natively sent to Discord. +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] - ├── +middleware.ts - ├── [notifications] - │ ├── enable.ts - │ └── disable.ts - └── options.ts +└── [settings]/ + ├── command.ts + ├── profile.subcommand.ts + └── {notifications}/ + ├── group.ts + ├── enable.subcommand.ts + └── [disable]/ + └── command.ts ``` -This transforms into the slash commands: +This transforms into: +- `/settings profile` - `/settings notifications enable` - `/settings notifications disable` -- `/settings options` -Any file named correctly (i.e. does not start with `_` or `+`) inside these structures operates as a leaf subcommand. +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 diff --git a/package.json b/package.json index 36e8bc47..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" }, diff --git a/packages/commandkit/spec/commands-router.test.ts b/packages/commandkit/spec/commands-router.test.ts index 283f21a0..5f13489f 100644 --- a/packages/commandkit/spec/commands-router.test.ts +++ b/packages/commandkit/spec/commands-router.test.ts @@ -148,14 +148,11 @@ describe('CommandsRouter', () => { expect(middlewarePathsFor('admin.moderation.ban')).toEqual([ '/+global-middleware.ts', - '/[admin]/+middleware.ts', '/[admin]/{moderation}/+middleware.ts', '/[admin]/{moderation}/+ban.middleware.ts', ]); expect(middlewarePathsFor('admin.moderation.kick')).toEqual([ '/+global-middleware.ts', - '/[admin]/+middleware.ts', - '/[admin]/{moderation}/+middleware.ts', '/[admin]/{moderation}/[kick]/+kick.middleware.ts', ]); }); 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 index 44e3ad74..b5869bc6 100644 --- a/packages/commandkit/spec/hierarchical-command-handler.test.ts +++ b/packages/commandkit/spec/hierarchical-command-handler.test.ts @@ -213,7 +213,6 @@ export async function message() {} }), ).toEqual([ '/+global-middleware.mjs', - '/[admin]/+middleware.mjs', '/[admin]/{moderation}/+middleware.mjs', '/[admin]/{moderation}/+ban.middleware.mjs', '', 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/Context.ts b/packages/commandkit/src/app/commands/Context.ts index 5c78eb13..6e076a02 100644 --- a/packages/commandkit/src/app/commands/Context.ts +++ b/packages/commandkit/src/app/commands/Context.ts @@ -489,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/handlers/AppCommandHandler.ts b/packages/commandkit/src/app/handlers/AppCommandHandler.ts index db90cca1..9aaeb19c 100644 --- a/packages/commandkit/src/app/handlers/AppCommandHandler.ts +++ b/packages/commandkit/src/app/handlers/AppCommandHandler.ts @@ -247,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. @@ -270,7 +272,7 @@ export class AppCommandHandler { /** * Prints a formatted banner showing all loaded commands organized by category. */ - public printBanner() { + public printBanner(): void { const uncategorized = crypto.randomUUID(); // Collect flat commands @@ -480,7 +482,7 @@ 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()); } @@ -488,7 +490,7 @@ export class AppCommandHandler { * Gets all executable runtime routes, including hierarchical leaves. * @returns Array of route-indexed commands */ - public getRuntimeCommandsArray() { + public getRuntimeCommandsArray(): LoadedCommand[] { return Array.from(this.runtimeRouteIndex.values()); } @@ -496,14 +498,14 @@ export class AppCommandHandler { * Gets loaded hierarchical command nodes, including non-executable containers. * @returns Array of hierarchical node definitions */ - public getHierarchicalNodesArray() { + 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) => { @@ -861,7 +863,9 @@ export class AppCommandHandler { /** * 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(); @@ -876,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; @@ -888,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; @@ -900,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); } @@ -910,7 +916,9 @@ 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); @@ -920,7 +928,7 @@ export class AppCommandHandler { /** * 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); }); diff --git a/packages/commandkit/src/app/router/CommandsRouter.ts b/packages/commandkit/src/app/router/CommandsRouter.ts index 4914b78a..5dee3604 100644 --- a/packages/commandkit/src/app/router/CommandsRouter.ts +++ b/packages/commandkit/src/app/router/CommandsRouter.ts @@ -165,7 +165,7 @@ export class CommandsRouter { * 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)) { @@ -266,7 +266,7 @@ export class CommandsRouter { /** * Clears all loaded commands, middleware, and compiled tree data. */ - public clear() { + public clear(): void { this.commands.clear(); this.middlewares.clear(); this.treeNodes.clear(); @@ -278,7 +278,7 @@ export class CommandsRouter { * Scans the filesystem for commands and middleware files. * @returns Parsed command data */ - public async scan() { + public async scan(): Promise { this.clear(); this.initializeRootNode(); @@ -299,7 +299,13 @@ export class CommandsRouter { * 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, @@ -313,7 +319,11 @@ export class CommandsRouter { * Gets only the internal command tree and compiled route data. * @returns Object containing tree data */ - public getTreeData() { + public getTreeData(): { + treeNodes: Collection; + compiledRoutes: Collection; + diagnostics: CommandRouteDiagnostic[]; + } { return { treeNodes: this.treeNodes, compiledRoutes: this.compiledRoutes, @@ -325,7 +335,7 @@ export class CommandsRouter { * Converts the loaded data to a JSON-serializable format. * @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()), @@ -748,18 +758,15 @@ export class CommandsRouter { .filter((middleware) => middleware.global) .map((middleware) => middleware.id); - const directoryPaths = this.getDirectoryAncestors(node.directoryPath); - const directoryMiddlewares = directoryPaths.flatMap((path) => { - return allMiddlewares - .filter((middleware) => { - return ( - !middleware.global && - !middleware.command && - middleware.parentPath === path - ); - }) - .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) => { @@ -777,30 +784,6 @@ export class CommandsRouter { ]; } - /** - * @private - * @internal - */ - private getDirectoryAncestors(path: string) { - const normalizedPath = normalize(path); - const normalizedEntrypoint = normalize(this.options.entrypoint); - const ancestors: string[] = []; - - let current = normalizedPath; - - while (current.startsWith(normalizedEntrypoint)) { - ancestors.push(current); - - if (current === normalizedEntrypoint) break; - - const parent = normalize(dirname(current)); - if (parent === current) break; - current = parent; - } - - return ancestors.reverse(); - } - /** * @private * @internal 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/skills/commandkit/references/05-middlewares.md b/skills/commandkit/references/05-middlewares.md index 0c0c896b..525535f8 100644 --- a/skills/commandkit/references/05-middlewares.md +++ b/skills/commandkit/references/05-middlewares.md @@ -45,8 +45,9 @@ export function afterExecute(ctx: MiddlewareContext) { - `+global-middleware.ts`: Applies to all commands in tree. - `+middleware.ts`: Applies to current directory commands. - `+.middleware.ts`: Applies to specific command only. -- Inheritance: Subcommands inherit all global and directory-level - middlewares from their parents in the hierarchy. +- 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. @@ -57,7 +58,8 @@ export function afterExecute(ctx: MiddlewareContext) { - Use `+global-middleware.ts` for cross-cutting logic like audit logging. -- Use `+middleware.ts` in `{group}` folders for group-specific auth. +- 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 @@ -67,7 +69,8 @@ export function afterExecute(ctx: MiddlewareContext) { ## Common mistakes -- Breaking middleware inheritance by misnaming files. +- 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