Files
huskies/server/src/chat/commands/mod.rs
T

651 lines
24 KiB
Rust
Raw Normal View History

//! Bot-level command registry shared by all chat transports.
//!
//! Commands registered here are handled directly by the bot without invoking
//! the LLM. The registry is the single source of truth — the `help` command
//! iterates it automatically so new commands appear in the help output as soon
//! as they are added.
mod ambient;
mod assign;
mod backlog;
2026-04-29 13:38:34 +00:00
mod cleanup_worktrees;
mod convert;
mod cost;
mod coverage;
mod depends;
mod diff;
mod freeze;
mod git;
mod help;
pub(crate) mod loc;
mod logs;
mod move_story;
mod new_project;
mod overview;
mod run_tests;
mod setup;
mod show;
mod status;
mod timer;
mod triage;
pub(crate) mod unblock;
mod unreleased;
use crate::chat::util::strip_bot_mention;
use crate::services::Services;
use std::path::Path;
/// A bot-level command that is handled without LLM invocation.
pub struct BotCommand {
/// The command keyword (e.g., `"help"`). Always lowercase.
pub name: &'static str,
/// Short description shown in help output.
pub description: &'static str,
/// Handler that produces the response text (Markdown), or `None` to fall
/// through to the LLM (e.g. when a command requires direct addressing but
/// the message arrived via ambient mode).
pub handler: fn(&CommandContext) -> Option<String>,
}
/// Dispatch parameters passed to `try_handle_command`.
///
/// Groups the [`Services`] bundle with per-message dispatch context.
/// Construct one per incoming message and pass it alongside the raw message
/// body.
///
/// All identifiers are platform-agnostic strings so this struct works with
/// any [`ChatTransport`](crate::chat::ChatTransport) implementation.
pub struct CommandDispatch<'a> {
/// Shared services bundle (project root, agent pool, ambient rooms, …).
pub services: &'a Services,
/// Effective project root — usually `services.project_root`, but the Matrix
/// transport overrides this in gateway mode to point at the active project.
pub project_root: &'a Path,
/// Bot user ID for mention-stripping — transport-specific (e.g. Matrix's
/// `OwnedUserId` string differs from `services.bot_user_id`).
pub bot_user_id: &'a str,
/// The room this message came from (needed by ambient).
pub room_id: &'a str,
}
/// Context passed to individual command handlers.
///
/// Holds a reference to the shared [`Services`] bundle so that handlers access
/// project-wide state via `ctx.services.*`. The effective project root may
/// differ from `services.project_root` in gateway mode — use
/// [`effective_root()`](Self::effective_root) to get the correct path.
pub struct CommandContext<'a> {
/// Shared services bundle.
pub services: &'a Services,
/// Any text after the command keyword, trimmed.
pub args: &'a str,
/// The room this message came from (needed by ambient).
pub room_id: &'a str,
/// Effective project root for this dispatch. Equals `services.project_root`
/// in standalone mode; in gateway mode the Matrix transport sets this to
/// the active-project subdirectory.
project_root: &'a Path,
}
impl<'a> CommandContext<'a> {
/// Returns the effective project root for this command invocation.
///
/// In standalone mode this equals `services.project_root`. In gateway mode
/// (Matrix transport) it resolves to the active project subdirectory.
pub fn effective_root(&self) -> &Path {
self.project_root
}
/// Test-only constructor that allows submodule tests to build a
/// `CommandContext` despite the private `project_root` field.
#[cfg(test)]
pub fn new_test(
services: &'a Services,
args: &'a str,
room_id: &'a str,
project_root: &'a Path,
) -> Self {
Self {
services,
args,
room_id,
project_root,
}
}
}
/// Returns the full list of registered bot commands.
///
/// Add new commands here — they will automatically appear in `help` output.
pub fn commands() -> &'static [BotCommand] {
&[
BotCommand {
name: "assign",
description: "Pre-assign a model to a story: `assign <number> <model>` (e.g. `assign 42 opus`)",
handler: assign::handle_assign,
},
BotCommand {
name: "backlog",
description: "Show all items in the backlog with dependency satisfaction status",
handler: backlog::handle_backlog,
},
BotCommand {
name: "help",
description: "Show this list of available commands",
handler: help::handle_help,
},
BotCommand {
name: "status",
description: "Show pipeline status and agent availability; or `status <number>` for pipeline info (stage, ACs, git diff, recent commits)",
handler: status::handle_status,
},
BotCommand {
name: "logs",
description: "Show last agent log lines for a story: `logs <number>`",
handler: logs::handle_logs,
},
BotCommand {
name: "ambient",
description: "Toggle ambient mode for this room: `ambient on` or `ambient off`",
handler: ambient::handle_ambient,
},
BotCommand {
name: "depends",
description: "Set story dependencies: `depends <number> [dep1 dep2 ...]` (no deps = clear)",
handler: depends::handle_depends,
},
BotCommand {
name: "git",
description: "Show git status: branch, uncommitted changes, and ahead/behind remote",
handler: git::handle_git,
},
BotCommand {
name: "htop",
description: "Show live system and agent process dashboard (`htop`, `htop 10m`, `htop stop`)",
handler: handle_htop_fallback,
},
BotCommand {
name: "cost",
description: "Show token spend: 24h total, top stories, breakdown by agent type, and all-time total",
handler: cost::handle_cost,
},
BotCommand {
name: "coverage",
description: "Show test coverage: cached baseline by default, or `coverage run` to rerun the full suite",
handler: coverage::handle_coverage,
},
BotCommand {
name: "run_tests",
description: "Run the project's test suite (`script/test`) and show pass/fail with output",
handler: run_tests::handle_test,
},
BotCommand {
name: "loc",
description: "Show top source files by line count: `loc` (top 10), `loc <N>`, or `loc <filepath>` for a specific file",
handler: loc::handle_loc,
},
BotCommand {
name: "move",
description: "Move a work item to a pipeline stage: `move <number> <stage>` (stages: backlog, current, qa, merge, done)",
handler: move_story::handle_move,
},
BotCommand {
name: "show",
description: "Display the full text of a work item: `show <number>`",
handler: show::handle_show,
},
BotCommand {
name: "diff",
description: "Show git diff from main branch to story worktree HEAD: `diff <number>`",
handler: diff::handle_diff,
},
BotCommand {
name: "overview",
description: "Show implementation summary for a merged story: `overview <number>`",
handler: overview::handle_overview,
},
BotCommand {
name: "start",
description: "Start a coder on a story: `start <number>` or `start <number> opus`",
handler: handle_start_fallback,
},
BotCommand {
name: "delete",
description: "Remove a work item from the pipeline: `delete <number>`",
handler: handle_delete_fallback,
},
BotCommand {
name: "rmtree",
description: "Delete the worktree for a story without removing it from the pipeline: `rmtree <number>`",
handler: handle_rmtree_fallback,
},
BotCommand {
name: "reset",
description: "Clear the current Claude Code session and start fresh",
handler: handle_reset_fallback,
},
BotCommand {
name: "rebuild",
description: "Rebuild the server binary and restart",
handler: handle_rebuild_fallback,
},
BotCommand {
name: "timer",
description: "Schedule a deferred agent start: `timer <story_id> <HH:MM>`, `timer list`, `timer cancel <story_id>`",
handler: timer::handle_timer,
},
BotCommand {
name: "convert",
description: "Convert a work item's type: `convert <number> <type>` (types: story, bug, spike, refactor, epic)",
handler: convert::handle_convert,
},
BotCommand {
name: "unblock",
description: "Reset a blocked story: `unblock <number>` (clears blocked flag and resets retry count)",
handler: unblock::handle_unblock,
},
BotCommand {
name: "freeze",
description: "Freeze a story at its current stage: `freeze <number>` (suppresses pipeline advancement and auto-assign)",
handler: freeze::handle_freeze,
},
BotCommand {
name: "unfreeze",
description: "Unfreeze a story: `unfreeze <number>` (resumes normal pipeline behaviour)",
handler: freeze::handle_unfreeze,
},
BotCommand {
name: "unreleased",
description: "Show stories merged to master since the last release tag",
handler: unreleased::handle_unreleased,
},
BotCommand {
name: "setup",
description: "Show setup wizard progress; or `setup generate` / `setup confirm` / `setup skip` / `setup retry` to drive the wizard from chat",
handler: setup::handle_setup,
},
2026-04-29 13:38:34 +00:00
BotCommand {
name: "cleanup_worktrees",
description: "List orphaned worktrees (dry run), or `cleanup_worktrees --confirm` to remove them",
handler: handle_cleanup_worktrees_fallback,
},
BotCommand {
name: "health",
description: "Show subsystem health: gateway, sled, matrix-sync, creds, and build-hash",
handler: handle_health_fallback,
},
BotCommand {
name: "new",
description: "Bootstrap a new project container (gateway only): `new project <name>`",
handler: new_project::handle_new_project_fallback,
},
BotCommand {
name: "project-rebuild",
description: "Rebuild a project's Docker image and swap the container (gateway only): `project-rebuild <name> [--timeout <secs>] [--force]`",
handler: handle_project_rebuild_fallback,
},
]
}
/// Like [`try_handle_command`] but returns `(plain_body, html_body)`.
///
/// The plain body is unchanged Markdown text suitable for the Matrix `body`
/// field (non-HTML clients). The HTML body is suitable for `formatted_body`.
///
/// The pipeline-status command (no args) injects Matrix `<font data-mx-color>`
/// tags on the traffic-light dots. All other commands produce HTML by running
/// the plain body through pulldown-cmark.
pub fn try_handle_command_with_html(
dispatch: &CommandDispatch<'_>,
message: &str,
) -> Option<(String, String)> {
let command_text =
strip_bot_mention(message, &dispatch.services.bot_name, dispatch.bot_user_id);
let trimmed = command_text.trim();
if !trimmed.is_empty() {
let (cmd_name, args) = match trimmed.split_once(char::is_whitespace) {
Some((c, a)) => (c, a.trim()),
None => (trimmed, ""),
};
// Status command: emoji indicators render natively in all clients.
if cmd_name.eq_ignore_ascii_case("status") && args.is_empty() {
let body =
status::build_pipeline_status(dispatch.project_root, &dispatch.services.agents);
let html = plain_to_html(&body);
return Some((body, html));
}
}
// Generic path: plain text body → Markdown-to-HTML.
let body = try_handle_command(dispatch, message)?;
let html = plain_to_html(&body);
Some((body, html))
}
/// Convert a Markdown string to HTML using the same options as the Matrix
/// transport's `markdown_to_html` helper.
fn plain_to_html(markdown: &str) -> String {
use pulldown_cmark::{Options, Parser, html};
let normalized = crate::chat::util::normalize_line_breaks(markdown);
let options = Options::ENABLE_TABLES
| Options::ENABLE_FOOTNOTES
| Options::ENABLE_STRIKETHROUGH
| Options::ENABLE_TASKLISTS;
let parser = Parser::new_ext(&normalized, options);
let mut out = String::new();
html::push_html(&mut out, parser);
out
}
/// Try to match a user message against a registered bot command.
///
/// The message is expected to be the raw body text (e.g., `"@timmy help"`).
/// The bot mention prefix is stripped before matching.
///
/// Returns `Some(response)` if a command matched and was handled, `None`
/// otherwise (the caller should fall through to the LLM).
pub fn try_handle_command(dispatch: &CommandDispatch<'_>, message: &str) -> Option<String> {
let command_text =
strip_bot_mention(message, &dispatch.services.bot_name, dispatch.bot_user_id);
let trimmed = command_text.trim();
if trimmed.is_empty() {
return None;
}
let (cmd_name, args) = match trimmed.split_once(char::is_whitespace) {
Some((c, a)) => (c, a.trim()),
None => (trimmed, ""),
};
let cmd_lower = cmd_name.to_ascii_lowercase();
let ctx = CommandContext {
services: dispatch.services,
args,
room_id: dispatch.room_id,
project_root: dispatch.project_root,
};
commands()
.iter()
.find(|c| c.name == cmd_lower)
.and_then(|c| (c.handler)(&ctx))
}
/// Fallback handler for the `htop` command when it is not intercepted by the
/// async handler in `on_room_message`. In practice this is never called —
/// htop is detected and handled before `try_handle_command` is invoked.
/// The entry exists in the registry only so `help` lists it.
///
/// Returns `None` to prevent the LLM from receiving "htop" as a prompt.
fn handle_htop_fallback(_ctx: &CommandContext) -> Option<String> {
None
}
/// Fallback handler for the `start` command when it is not intercepted by
/// the async handler in `on_room_message`. In practice this is never called —
/// start is detected and handled before `try_handle_command` is invoked.
/// The entry exists in the registry only so `help` lists it.
///
/// Returns `None` to prevent the LLM from receiving "start" as a prompt.
fn handle_start_fallback(_ctx: &CommandContext) -> Option<String> {
None
}
/// Fallback handler for the `rmtree` command when it is not intercepted by
/// the async handler in `on_room_message`. In practice this is never called —
/// rmtree is detected and handled before `try_handle_command` is invoked.
/// The entry exists in the registry only so `help` lists it.
///
/// Returns `None` to prevent the LLM from receiving "rmtree" as a prompt.
fn handle_rmtree_fallback(_ctx: &CommandContext) -> Option<String> {
None
}
/// Fallback handler for the `delete` command when it is not intercepted by
/// the async handler in `on_room_message`. In practice this is never called —
/// delete is detected and handled before `try_handle_command` is invoked.
/// The entry exists in the registry only so `help` lists it.
///
/// Returns `None` to prevent the LLM from receiving "delete" as a prompt.
fn handle_delete_fallback(_ctx: &CommandContext) -> Option<String> {
None
}
/// Fallback handler for the `reset` command when it is not intercepted by
/// the async handler in `on_room_message`. In practice this is never called —
/// reset is detected and handled before `try_handle_command` is invoked.
/// The entry exists in the registry only so `help` lists it.
///
/// Returns `None` to prevent the LLM from receiving "reset" as a prompt.
fn handle_reset_fallback(_ctx: &CommandContext) -> Option<String> {
None
}
/// Fallback handler for the `rebuild` command when it is not intercepted by
/// the async handler in `on_room_message`. In practice this is never called —
/// rebuild is detected and handled before `try_handle_command` is invoked.
/// The entry exists in the registry only so `help` lists it.
///
/// Returns `None` to prevent the LLM from receiving "rebuild" as a prompt.
fn handle_rebuild_fallback(_ctx: &CommandContext) -> Option<String> {
None
}
2026-04-29 13:38:34 +00:00
/// Fallback handler for the `cleanup_worktrees` command when it is not
/// intercepted by the async handler in `on_room_message`. In practice this is
/// never called — cleanup_worktrees is detected and handled before
/// `try_handle_command` is invoked. The entry exists in the registry only so
/// `help` lists it.
///
/// Returns `None` to prevent the LLM from receiving "cleanup_worktrees" as a prompt.
fn handle_cleanup_worktrees_fallback(_ctx: &CommandContext) -> Option<String> {
None
}
/// Fallback handler for the `project-rebuild` command when it is not intercepted
/// by the async gateway handler in `on_room_message`. In practice this is never
/// called — `project-rebuild` is detected and handled before `try_handle_command`
/// runs in gateway mode. The entry exists in the registry so `help` lists it.
///
/// Returns `None` to prevent the LLM from receiving the raw command text.
fn handle_project_rebuild_fallback(_ctx: &CommandContext) -> Option<String> {
None
}
/// Fallback handler for the `health` command when it is not intercepted by the
/// async handler in `on_room_message`. In practice this is never called — health
/// is detected and handled before `try_handle_command` is invoked. The entry
/// exists in the registry only so `help` lists it.
///
/// Returns `None` to prevent the LLM from receiving "health" as a prompt.
fn handle_health_fallback(_ctx: &CommandContext) -> Option<String> {
None
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use crate::services::Services;
use std::sync::Arc;
// -- test helpers (shared with submodule tests) -------------------------
/// Build a [`Services`] bundle for tests with the given bot name and a `/tmp`
/// project root.
pub fn test_services_named(bot_name: &str) -> Arc<Services> {
Services::new_test(std::path::PathBuf::from("/tmp"), bot_name.to_string())
}
/// Dispatch a message through the command registry using the given Services.
pub fn try_cmd_with_services(
bot_user_id: &str,
message: &str,
services: &Services,
) -> Option<String> {
let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch {
services,
project_root: &services.project_root,
bot_user_id,
room_id: &room_id,
};
try_handle_command(&dispatch, message)
}
/// Convenience helper: create a temporary [`Services`] with the given bot
/// name and dispatch `message`.
pub fn try_cmd_addressed(bot_name: &str, bot_user_id: &str, message: &str) -> Option<String> {
let services = test_services_named(bot_name);
try_cmd_with_services(bot_user_id, message, &services)
}
// Re-export commands() for submodule tests
pub use super::commands;
// -- try_handle_command -------------------------------------------------
#[test]
fn unknown_command_returns_none() {
let result = try_cmd_addressed(
"Timmy",
"@timmy:homeserver.local",
"@timmy what is the weather?",
);
assert!(result.is_none(), "non-command should return None");
}
#[test]
fn empty_message_after_mention_returns_none() {
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy");
assert!(
result.is_none(),
"bare mention with no command should fall through to LLM"
);
}
#[test]
fn htop_command_falls_through_to_none() {
// The htop handler returns None so the message is handled asynchronously
// in on_room_message, not here. try_handle_command must return None.
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy htop");
assert!(
result.is_none(),
"htop should not produce a sync response (handled async): {result:?}"
);
}
2026-05-14 13:57:27 +00:00
// -- malformed-args routing (story 1034) -----------------------------------
#[test]
fn malformed_unblock_args_route_to_timmy() {
// "unblock to fix the blocking issue" — verb recognised, args look like
// natural language rather than a numeric story ID. Must return None so
// the message is forwarded to the LLM instead of showing a usage error.
let result = try_cmd_addressed(
"Timmy",
"@timmy:homeserver.local",
"@timmy unblock to fix the blocking issue",
);
assert!(
result.is_none(),
"unblock with natural-language args must route to LLM (None): {result:?}"
);
}
#[test]
fn malformed_start_args_route_to_timmy_via_registry() {
// "start to get the cheese grater working" — the registry entry for
// start always returns None (handled async), so even bad args produce None.
let result = try_cmd_addressed(
"Timmy",
"@timmy:homeserver.local",
"@timmy start to get the cheese grater working",
);
assert!(
result.is_none(),
"start with natural-language args must route to LLM (None): {result:?}"
);
}
#[test]
fn malformed_show_args_route_to_timmy() {
let result = try_cmd_addressed(
"Timmy",
"@timmy:homeserver.local",
"@timmy show me the login bug",
);
assert!(
result.is_none(),
"show with natural-language args must route to LLM (None): {result:?}"
);
}
#[test]
fn malformed_freeze_args_route_to_timmy() {
let result = try_cmd_addressed(
"Timmy",
"@timmy:homeserver.local",
"@timmy freeze the pipeline until Friday",
);
assert!(
result.is_none(),
"freeze with natural-language args must route to LLM (None): {result:?}"
);
}
#[test]
fn well_formed_unblock_runs_handler() {
// Numeric story ID → command handler runs (story not found, but Some is returned).
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy unblock 1010");
assert!(
result.is_some(),
"well-formed unblock command should run the handler: {result:?}"
);
}
#[test]
fn well_formed_show_runs_handler() {
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy show 984");
assert!(
result.is_some(),
"well-formed show command should run the handler: {result:?}"
);
}
// -- commands registry --------------------------------------------------
#[test]
fn commands_registry_is_not_empty() {
assert!(
!commands().is_empty(),
"command registry must contain at least one command"
);
}
#[test]
fn all_command_names_are_lowercase() {
for cmd in commands() {
assert_eq!(
cmd.name,
cmd.name.to_ascii_lowercase(),
"command name '{}' must be lowercase",
cmd.name
);
}
}
#[test]
fn all_commands_have_descriptions() {
for cmd in commands() {
assert!(
!cmd.description.is_empty(),
"command '{}' must have a description",
cmd.name
);
}
}
}