Command Parsing

Focus Deep-Dive

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

Primary role

Parse raw user messages into structured SlashCommandParseResult discriminated unions with action and argument fields.

Main inputs

Raw message strings from any channel surface (Telegram, Discord, WebChat) plus the configured slash prefix (e.g. /).

Key decisions

Does the message start with the slash prefix? Is it empty after the prefix? Can we extract a valid action keyword?

Outputs / side effects

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 };
VariantWhen returnedDownstream behavior
no-matchMessage does not start with the configured slash prefixMessage passes through to the agent as regular text input
emptyMessage is exactly the slash prefix with nothing after itCaller may use a default action (e.g. "show") or display help
invalidText after the prefix cannot be split into an action keywordError message is returned to the user; no command is dispatched
parsedA valid action keyword and optional arguments were extractedAction is looked up in the plugin registry and built-in command table

Decision Points

DecisionWhat controls itWhy it matters
Slash prefix detectionCase-insensitive startsWith check against the configured slash stringPrevents false positives from messages that happen to contain / mid-sentence
Empty-command handlingWhether rest.trim() is empty after stripping the prefixSome commands use a default action (e.g. /model → /model show); empty parse allows this
Regex extractionPattern /^(\S+)(?:\s+([\s\S]+))?$/ on the remaining textCleanly separates the first word (action) from everything else (args)
Plugin acceptsArgs guardcommand.acceptsArgs boolean on the registered plugin commandIf a command does not accept args but the user provided them, the match fails and falls through to the agent
Case normalizationaction.toLowerCase() applied after extractionEnsures /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

ScenarioInputResultRationale
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 slashPlease 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 matchPluginCommandFalls 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

SettingLocationDefaultDescription
Slash prefixPassed as slash parameter"/"The string that identifies a message as a slash command
Default actionopts.defaultAction in parseSlashCommandOrNull"show"Action used when the user types only the slash prefix
Invalid messageopts.invalidMessage in parseSlashCommandOrNullCaller-providedError text returned to the user on invalid parse
Command Registry

Where parsed commands are looked up and dispatched to their registered handlers.

Command Aliases

How native names and provider-specific aliases are resolved during matching.

Permission Gating

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)