Skip to content
37 changes: 35 additions & 2 deletions crates/forge_main/src/model.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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, …]
Expand Down Expand Up @@ -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()))
}
}
}
Expand Down Expand Up @@ -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
Expand Down
93 changes: 49 additions & 44 deletions crates/forge_main/src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1320,56 +1320,58 @@ impl<A: API + ConsoleWriter + 'static, F: Fn(ForgeConfig) -> 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::<Vec<_>>().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::<Vec<_>>().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)
Expand All @@ -1385,6 +1387,9 @@ impl<A: API + ConsoleWriter + 'static, F: Fn(ForgeConfig) -> 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)?;
}

Expand Down
6 changes: 6 additions & 0 deletions shell-plugin/lib/actions/core.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions shell-plugin/lib/dispatcher.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,9 @@ function forge-accept-line() {
retry|r)
_forge_action_retry
;;
help)
_forge_action_help
;;
agent|a)
_forge_action_agent "$input_text"
;;
Expand Down
Loading