Parse raw user messages into structured SlashCommandParseResult discriminated unions with action and argument fields.
Command Parsing
Extracting structured actions from raw user slash-command input
When a user types /model set gpt-4 or a plugin defines /tts, the command parsing layer is the first code that touches the raw text. It validates the slash prefix, extracts the action keyword and trailing arguments, and returns a strongly-typed discriminated union that downstream dispatchers can pattern-match without ever touching raw strings again.
📚 What is this & why does it matter?
Command parsing is the gateway between free-form user text and the type-safe command dispatch pipeline. Every slash command — whether built-in or provided by a plugin — passes through this layer before any business logic runs. Understanding this module is essential for debugging "command not found" errors, building new slash commands, or tracing why a particular input was routed to the agent instead of a command handler.
💡 Real-world analogy
Think of a restaurant maître d'. A guest walks in and says "table for two, near the window." The maître d' parses this into a structured request — party size: 2, preference: window — and routes it to the seating system. If the guest mumbles something unintelligible, the maître d' returns "invalid request" without bothering the kitchen. The command parser is that maître d' for every slash command.
Contents
Overview
Raw message strings from any channel surface (Telegram, Discord, WebChat) plus the configured slash prefix (e.g. /).
Does the message start with the slash prefix? Is it empty after the prefix? Can we extract a valid action keyword?
A SlashCommandParseResult — one of "no-match", "empty", "invalid", or "parsed" with action and args fields. No side effects.
flowchart TD
RAW["Raw user message"]
PREFIX{"Starts with slash prefix?"}
EMPTY{"Rest after prefix empty?"}
REGEX{"Regex extracts action + args?"}
NOMATCH["SlashCommandParseResult: no-match"]
EMPTYRES["SlashCommandParseResult: empty"]
INVALID["SlashCommandParseResult: invalid"]
PARSED["SlashCommandParseResult: parsed"]
PLUGIN{"matchPluginCommand()"}
BUILTIN{"Built-in command lookup"}
AGENT["Fall through to agent"]
RAW --> PREFIX
PREFIX -->|No| NOMATCH
PREFIX -->|Yes| EMPTY
EMPTY -->|Yes| EMPTYRES
EMPTY -->|No| REGEX
REGEX -->|No match| INVALID
REGEX -->|Match| PARSED
PARSED --> PLUGIN
PLUGIN -->|Found| BUILTIN
PLUGIN -->|Not found| AGENT
The diagram shows the complete parse flow. Raw text enters at the top, and the parser applies three sequential checks: prefix match, empty detection, and regex extraction. Only messages that survive all three checks reach the "parsed" state with structured action and args fields. From there, the plugin command matcher and built-in command lookup determine final routing.
Core Types
The parsing layer uses two key discriminated union types. SlashCommandParseResult represents the raw parse outcome, while ParsedSlashCommand wraps it with a boolean ok/error envelope for callers that prefer a simpler interface.
// src/auto-reply/reply/commands-slash-parse.ts — lines 1-9
export type SlashCommandParseResult =
| { kind: "no-match" }
| { kind: "empty" }
| { kind: "invalid" }
| { kind: "parsed"; action: string; args: string };
export type ParsedSlashCommand =
| { ok: true; action: string; args: string }
| { ok: false; message: string };
| Variant | When returned | Downstream behavior |
|---|---|---|
no-match | Message does not start with the configured slash prefix | Message passes through to the agent as regular text input |
empty | Message is exactly the slash prefix with nothing after it | Caller may use a default action (e.g. "show") or display help |
invalid | Text after the prefix cannot be split into an action keyword | Error message is returned to the user; no command is dispatched |
parsed | A valid action keyword and optional arguments were extracted | Action is looked up in the plugin registry and built-in command table |
Decision Points
| Decision | What controls it | Why it matters |
|---|---|---|
| Slash prefix detection | Case-insensitive startsWith check against the configured slash string | Prevents false positives from messages that happen to contain / mid-sentence |
| Empty-command handling | Whether rest.trim() is empty after stripping the prefix | Some commands use a default action (e.g. /model → /model show); empty parse allows this |
| Regex extraction | Pattern /^(\S+)(?:\s+([\s\S]+))?$/ on the remaining text | Cleanly separates the first word (action) from everything else (args) |
| Plugin acceptsArgs guard | command.acceptsArgs boolean on the registered plugin command | If a command does not accept args but the user provided them, the match fails and falls through to the agent |
| Case normalization | action.toLowerCase() applied after extraction | Ensures /Model and /model resolve to the same handler |
How It Works
Command parsing operates in two stages: low-level extraction in commands-slash-parse.ts and plugin matching in plugins/commands.ts. The two stages are intentionally decoupled so that the parser has zero knowledge of registered commands.
Phase 1 — Slash Prefix Extraction
The parseSlashCommandActionArgs function is a pure function with no side effects. It receives the raw message and the slash prefix string, then applies a three-step pipeline: prefix check, emptiness check, and regex extraction.
// src/auto-reply/reply/commands-slash-parse.ts — lines 11-28
export function parseSlashCommandActionArgs(
raw: string,
slash: string,
): SlashCommandParseResult {
const trimmed = raw.trim();
const slashLower = slash.toLowerCase();
if (!trimmed.toLowerCase().startsWith(slashLower)) {
return { kind: "no-match" };
}
const rest = trimmed.slice(slash.length).trim();
if (!rest) {
return { kind: "empty" };
}
const match = rest.match(/^(\S+)(?:\s+([\s\S]+))?$/);
if (!match) {
return { kind: "invalid" };
}
const action = match[1]?.toLowerCase() ?? "";
const args = (match[2] ?? "").trim();
return { kind: "parsed", action, args };
}
Phase 2 — Convenience Wrapper
The parseSlashCommandOrNull function wraps the raw parser with a boolean ok/error envelope and a configurable default action for the empty case. Callers like the auto-reply router use this to avoid switch-casing over all four parse variants.
// src/auto-reply/reply/commands-slash-parse.ts — lines 30-46
export function parseSlashCommandOrNull(
raw: string,
slash: string,
opts: { invalidMessage: string; defaultAction?: string },
): ParsedSlashCommand | null {
const parsed = parseSlashCommandActionArgs(raw, slash);
if (parsed.kind === "no-match") {
return null; // Not a command — pass through to agent
}
if (parsed.kind === "invalid") {
return { ok: false, message: opts.invalidMessage };
}
if (parsed.kind === "empty") {
return { ok: true, action: opts.defaultAction ?? "show", args: "" };
}
return { ok: true, action: parsed.action, args: parsed.args };
}
Phase 3 — Plugin Command Matching
After extraction, the parsed command body is passed to matchPluginCommand which looks up the action in the global plugin command registry. It checks both the primary key and all native name aliases, and enforces the acceptsArgs guard.
sequenceDiagram
participant User
participant AutoReply as auto-reply router
participant Parser as commands-slash-parse.ts
participant PluginMatcher as plugins/commands.ts
participant Registry as pluginCommands Map
participant Handler as Command Handler
User->>AutoReply: "/model set gpt-4"
AutoReply->>Parser: parseSlashCommandOrNull(raw, "/")
Parser-->>AutoReply: { ok: true, action: "model", args: "set gpt-4" }
AutoReply->>PluginMatcher: matchPluginCommand("/model set gpt-4")
PluginMatcher->>Registry: pluginCommands.get("/model")
Registry-->>PluginMatcher: null (built-in, not plugin)
PluginMatcher-->>AutoReply: null
AutoReply->>Handler: dispatch to built-in "model" handler
Handler-->>User: "Default model: gpt-4"
// src/plugins/commands.ts — lines 56-86
export function matchPluginCommand(
commandBody: string,
): { command: RegisteredPluginCommand; args?: string } | null {
const trimmed = commandBody.trim();
if (!trimmed.startsWith("/")) {
return null;
}
const spaceIndex = trimmed.indexOf(" ");
const commandName = spaceIndex === -1
? trimmed
: trimmed.slice(0, spaceIndex);
const args = spaceIndex === -1
? undefined
: trimmed.slice(spaceIndex + 1).trim();
const key = commandName.toLowerCase();
const command =
pluginCommands.get(key) ??
Array.from(pluginCommands.values()).find((candidate) =>
listPluginInvocationNames(candidate).includes(key),
);
if (!command) {
return null;
}
// Guard: if command doesn't accept args, don't match
if (args && !command.acceptsArgs) {
return null;
}
return { command, args: args || undefined };
}
Edge Cases
| Scenario | Input | Result | Rationale |
|---|---|---|---|
| Prefix only | / | { kind: "empty" } | Default action is applied by the convenience wrapper |
| Case mismatch | /MODEL set gpt-4 | { kind: "parsed", action: "model", args: "set gpt-4" } | Both prefix and action are lowercased for matching |
| Leading whitespace | /model set gpt-4 | { kind: "parsed", action: "model", args: "set gpt-4" } | Input is trimmed before prefix check |
| Mid-message slash | Please run /model | { kind: "no-match" } | Prefix must be at the start of the trimmed message |
| Plugin no-args guard | /tts hello world (tts.acceptsArgs = false) | null from matchPluginCommand | Falls through to agent, preventing unintended command activation |
| Multi-line args | /send line1\nline2 | { kind: "parsed", action: "send", args: "line1\nline2" } | Regex uses [\s\S]+ to capture newlines in args |
Configuration
| Setting | Location | Default | Description |
|---|---|---|---|
| Slash prefix | Passed as slash parameter | "/" | The string that identifies a message as a slash command |
| Default action | opts.defaultAction in parseSlashCommandOrNull | "show" | Action used when the user types only the slash prefix |
| Invalid message | opts.invalidMessage in parseSlashCommandOrNull | Caller-provided | Error text returned to the user on invalid parse |
Related Topics
Where parsed commands are looked up and dispatched to their registered handlers.
How native names and provider-specific aliases are resolved during matching.
Authorization checks that run after parsing but before command execution.
Source Files
Key files involved in command parsing
src/
├── auto-reply/
│ └── reply/
│ └── commands-slash-parse.ts ← Core parse functions & types
├── plugins/
│ ├── commands.ts ← matchPluginCommand() plugin lookup
│ ├── command-registration.ts ← validateCommandName(), registration
│ └── command-registry-state.ts ← Global pluginCommands Map state
└── cli/
└── program/
└── command-registry.ts ← CLI command registration (built-ins)