//! 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 cost; mod git; mod help; pub(crate) mod loc; mod move_story; mod overview; mod setup; mod show; mod status; mod timer; mod triage; pub(crate) mod unblock; mod unreleased; use crate::agents::AgentPool; use crate::chat::util::strip_bot_mention; use std::collections::HashSet; use std::path::Path; use std::sync::{Arc, Mutex}; /// 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, } /// Dispatch parameters passed to `try_handle_command`. /// /// Groups all the caller-supplied context needed to dispatch and execute bot /// commands. 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> { /// The bot's display name (e.g., "Timmy"). pub bot_name: &'a str, /// The bot's full user ID (e.g., `"@timmy:homeserver.local"` on Matrix). pub bot_user_id: &'a str, /// Project root directory (needed by status, ambient). pub project_root: &'a Path, /// Agent pool (needed by status). pub agents: &'a AgentPool, /// Set of room IDs with ambient mode enabled (needed by ambient). pub ambient_rooms: &'a Arc>>, /// The room this message came from (needed by ambient). pub room_id: &'a str, } /// Context passed to individual command handlers. pub struct CommandContext<'a> { /// The bot's display name (e.g., "Timmy"). pub bot_name: &'a str, /// Any text after the command keyword, trimmed. pub args: &'a str, /// Project root directory (needed by status, ambient). pub project_root: &'a Path, /// Agent pool (needed by status). pub agents: &'a AgentPool, /// Set of room IDs with ambient mode enabled (needed by ambient). pub ambient_rooms: &'a Arc>>, /// The room this message came from (needed by ambient). pub room_id: &'a str, } /// 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 ` (e.g. `assign 42 opus`)", handler: assign::handle_assign, }, 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 ` for a story triage dump", handler: status::handle_status, }, BotCommand { name: "ambient", description: "Toggle ambient mode for this room: `ambient on` or `ambient off`", handler: ambient::handle_ambient, }, 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: "loc", description: "Show top source files by line count: `loc` (top 10), `loc `, or `loc ` for a specific file", handler: loc::handle_loc, }, BotCommand { name: "move", description: "Move a work item to a pipeline stage: `move ` (stages: backlog, current, qa, merge, done)", handler: move_story::handle_move, }, BotCommand { name: "show", description: "Display the full text of a work item: `show `", handler: show::handle_show, }, BotCommand { name: "overview", description: "Show implementation summary for a merged story: `overview `", handler: overview::handle_overview, }, BotCommand { name: "start", description: "Start a coder on a story: `start ` or `start opus`", handler: handle_start_fallback, }, BotCommand { name: "delete", description: "Remove a work item from the pipeline: `delete `", handler: handle_delete_fallback, }, BotCommand { name: "rmtree", description: "Delete the worktree for a story without removing it from the pipeline: `rmtree `", 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 `, `timer list`, `timer cancel `", handler: timer::handle_timer, }, BotCommand { name: "unblock", description: "Reset a blocked story: `unblock ` (clears blocked flag and resets retry count)", handler: unblock::handle_unblock, }, 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 confirm` / `setup skip` / `setup retry` to drive the wizard from chat", handler: setup::handle_setup, }, ] } /// 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 `` /// 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.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, ""), }; // Only the no-arg status variant shows the pipeline with traffic-light // dots; `status ` is a triage dump that needs no colour tags. if cmd_name.eq_ignore_ascii_case("status") && args.is_empty() { let body = status::build_pipeline_status(dispatch.project_root, dispatch.agents); let html = status::build_pipeline_status_html(dispatch.project_root, dispatch.agents); 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 { let command_text = strip_bot_mention(message, dispatch.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 { bot_name: dispatch.bot_name, args, project_root: dispatch.project_root, agents: dispatch.agents, ambient_rooms: dispatch.ambient_rooms, room_id: dispatch.room_id, }; 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 { 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 { 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 { 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 { 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 { 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 { None } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] pub(crate) mod tests { use super::*; use crate::agents::AgentPool; // -- test helpers (shared with submodule tests) ------------------------- pub fn test_ambient_rooms() -> Arc>> { Arc::new(Mutex::new(HashSet::new())) } pub fn test_agents() -> Arc { Arc::new(AgentPool::new_test(3000)) } pub fn try_cmd( bot_name: &str, bot_user_id: &str, message: &str, ambient_rooms: &Arc>>, ) -> Option { let agents = test_agents(); let room_id = "!test:example.com".to_string(); let dispatch = CommandDispatch { bot_name, bot_user_id, project_root: std::path::Path::new("/tmp"), agents: &agents, ambient_rooms, room_id: &room_id, }; try_handle_command(&dispatch, message) } pub fn try_cmd_addressed(bot_name: &str, bot_user_id: &str, message: &str) -> Option { try_cmd(bot_name, bot_user_id, message, &test_ambient_rooms()) } // 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:?}" ); } // -- 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 ); } } }