diff --git a/crates/forge_main/src/model.rs b/crates/forge_main/src/model.rs index db8bba41a9..6852225a34 100644 --- a/crates/forge_main/src/model.rs +++ b/crates/forge_main/src/model.rs @@ -1,5 +1,6 @@ use std::sync::{Arc, Mutex}; +use clap::error::ErrorKind; use clap::{Parser, Subcommand}; use forge_api::{AgentInfo, Model, Template}; use forge_domain::UserCommand; @@ -308,6 +309,11 @@ impl ForgeCommandManager { .strip_prefix('/') .or_else(|| first.strip_prefix(':')) .unwrap_or(first); + let command_prefix = first + .chars() + .next() + .filter(|c| *c == '/' || *c == ':') + .unwrap_or(':'); let rest: Vec<&str> = tokens.collect(); // Build argv: [bare_command, arg1, arg2, …] @@ -372,8 +378,17 @@ impl ForgeCommandManager { ))); } - // Surface a clean error from Clap (strips ANSI + binary name noise). - Err(anyhow::anyhow!("{}", clap_err.render().to_string().trim())) + // Surface user-friendly errors for unknown commands. + if clap_err.kind() == ErrorKind::InvalidSubcommand { + return Err(anyhow::anyhow!( + "Unknown command '{command_prefix}{command_name}'. Run '{command_prefix}help' to list available commands." + )); + } + + // Surface a clean error from Clap (strips ANSI + internal parser name). + let rendered = clap_err.render().to_string(); + let cleaned = rendered.replace("forge_cmd", "forge"); + Err(anyhow::anyhow!("{}", cleaned.trim())) } } } @@ -1470,6 +1485,24 @@ mod tests { ); } + #[test] + fn test_parse_invalid_command_with_colon_returns_helpful_error() { + let fixture = ForgeCommandManager::default(); + let actual = fixture.parse(":celar").unwrap_err().to_string(); + let expected = + "Unknown command ':celar'. Run ':help' to list available commands.".to_string(); + assert_eq!(actual, expected); + } + + #[test] + fn test_parse_invalid_command_with_slash_returns_helpful_error() { + let fixture = ForgeCommandManager::default(); + let actual = fixture.parse("/celar").unwrap_err().to_string(); + let expected = + "Unknown command '/celar'. Run '/help' to list available commands.".to_string(); + assert_eq!(actual, expected); + } + #[test] fn test_parse_tool_command() { // Setup diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index e4910997d1..aa68c7318e 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -1320,56 +1320,58 @@ impl A + Send + Sync> UI /// Lists all the commands async fn on_show_commands(&mut self, porcelain: bool) -> anyhow::Result<()> { - let mut info = Info::new(); + if porcelain { + // Build the full info with type/description columns for porcelain + // (used by the shell plugin for tab completion). + let mut info = Info::new(); - // Generate built-in commands directly from the SlashCommand enum so - // the list always stays in sync with what the REPL actually supports. - // Internal/meta variants (Message, Custom, Shell, AgentSwitch, Rename) - // are excluded via is_internal(). - for cmd in AppCommand::iter().filter(|c| !c.is_internal()) { - info = info - .add_title(cmd.name()) - .add_key_value("type", CommandType::Command) - .add_key_value("description", cmd.usage()); - } - - // Add agent aliases - info = info - .add_title("ask") - .add_key_value("type", CommandType::Agent) - .add_key_value( - "description", - "Research and investigation agent [alias for: sage]", - ) - .add_title("plan") - .add_key_value("type", CommandType::Agent) - .add_key_value( - "description", - "Planning and strategy agent [alias for: muse]", - ); + // Generate built-in commands directly from the SlashCommand enum so + // the list always stays in sync with what the REPL actually supports. + // Internal/meta variants (Message, Custom, Shell, AgentSwitch, Rename) + // are excluded via is_internal(). + for cmd in AppCommand::iter().filter(|c| !c.is_internal()) { + info = info + .add_title(cmd.name()) + .add_key_value("type", CommandType::Command) + .add_key_value("description", cmd.usage()); + } - // Fetch agent infos and add them to the commands list. - // Uses get_agent_infos() so no provider/model is required for listing. - let agent_infos = self.api.get_agent_infos().await?; - for agent_info in agent_infos { - let title = agent_info - .title - .map(|title| title.lines().collect::>().join(" ")); + // Add agent aliases info = info - .add_title(agent_info.id.to_string()) + .add_title("ask") .add_key_value("type", CommandType::Agent) - .add_key_value("description", title); - } + .add_key_value( + "description", + "Research and investigation agent [alias for: sage]", + ) + .add_title("plan") + .add_key_value("type", CommandType::Agent) + .add_key_value( + "description", + "Planning and strategy agent [alias for: muse]", + ); - let custom_commands = self.api.get_commands().await?; - for command in custom_commands { - info = info - .add_title(command.name.clone()) - .add_key_value("type", CommandType::Custom) - .add_key_value("description", command.description.clone()); - } + // Fetch agent infos and add them to the commands list. + // Uses get_agent_infos() so no provider/model is required for listing. + let agent_infos = self.api.get_agent_infos().await?; + for agent_info in agent_infos { + let title = agent_info + .title + .map(|title| title.lines().collect::>().join(" ")); + info = info + .add_title(agent_info.id.to_string()) + .add_key_value("type", CommandType::Agent) + .add_key_value("description", title); + } + + let custom_commands = self.api.get_commands().await?; + for command in custom_commands { + info = info + .add_title(command.name.clone()) + .add_key_value("type", CommandType::Custom) + .add_key_value("description", command.description.clone()); + } - if porcelain { // Original order from Info: [$ID, type, description] // So the original order is fine! But $ID should become COMMAND let porcelain = Porcelain::from(&info) @@ -1385,6 +1387,9 @@ impl A + Send + Sync> UI }); self.writeln(porcelain)?; } else { + // Non-porcelain: render in the same flat format as :help in the REPL. + let command_manager = ForgeCommandManager::default(); + let info = Info::from(&command_manager); self.writeln(info)?; } diff --git a/shell-plugin/lib/actions/core.zsh b/shell-plugin/lib/actions/core.zsh index e8da1dc8db..01f11f775d 100644 --- a/shell-plugin/lib/actions/core.zsh +++ b/shell-plugin/lib/actions/core.zsh @@ -61,6 +61,12 @@ function _forge_action_retry() { _forge_handle_conversation_command "retry" } +# Action handler: Show available commands (mirrors :help in the REPL) +function _forge_action_help() { + echo + $_FORGE_BIN list command +} + # Helper function to handle conversation commands that require an active conversation function _forge_handle_conversation_command() { local subcommand="$1" diff --git a/shell-plugin/lib/dispatcher.zsh b/shell-plugin/lib/dispatcher.zsh index 90828771d7..59a8c018b8 100644 --- a/shell-plugin/lib/dispatcher.zsh +++ b/shell-plugin/lib/dispatcher.zsh @@ -157,6 +157,9 @@ function forge-accept-line() { retry|r) _forge_action_retry ;; + help) + _forge_action_help + ;; agent|a) _forge_action_agent "$input_text" ;;