diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 4e7a3ea..8c26d63 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -1,5 +1,6 @@ use std::collections::BTreeMap; use std::env; +use std::fmt; use std::fs; use std::path::{Path, PathBuf}; @@ -295,83 +296,360 @@ pub enum SlashCommand { Unknown(String), } -impl SlashCommand { - #[must_use] - pub fn parse(input: &str) -> Option { - let trimmed = input.trim(); - if !trimmed.starts_with('/') { - return None; - } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SlashCommandParseError { + message: String, +} - let mut parts = trimmed.trim_start_matches('/').split_whitespace(); - let command = parts.next().unwrap_or_default(); - Some(match command { - "help" => Self::Help, - "status" => Self::Status, - "sandbox" => Self::Sandbox, - "compact" => Self::Compact, - "bughunter" => Self::Bughunter { - scope: remainder_after_command(trimmed, command), - }, - "commit" => Self::Commit, - "pr" => Self::Pr { - context: remainder_after_command(trimmed, command), - }, - "issue" => Self::Issue { - context: remainder_after_command(trimmed, command), - }, - "ultraplan" => Self::Ultraplan { - task: remainder_after_command(trimmed, command), - }, - "teleport" => Self::Teleport { - target: remainder_after_command(trimmed, command), - }, - "debug-tool-call" => Self::DebugToolCall, - "model" => Self::Model { - model: parts.next().map(ToOwned::to_owned), - }, - "permissions" => Self::Permissions { - mode: parts.next().map(ToOwned::to_owned), - }, - "clear" => Self::Clear { - confirm: parts.next() == Some("--confirm"), - }, - "cost" => Self::Cost, - "resume" => Self::Resume { - session_path: parts.next().map(ToOwned::to_owned), - }, - "config" => Self::Config { - section: parts.next().map(ToOwned::to_owned), - }, - "memory" => Self::Memory, - "init" => Self::Init, - "diff" => Self::Diff, - "version" => Self::Version, - "export" => Self::Export { - path: parts.next().map(ToOwned::to_owned), - }, - "session" => Self::Session { - action: parts.next().map(ToOwned::to_owned), - target: parts.next().map(ToOwned::to_owned), - }, - "plugin" | "plugins" | "marketplace" => Self::Plugins { - action: parts.next().map(ToOwned::to_owned), - target: { - let remainder = parts.collect::>().join(" "); - (!remainder.is_empty()).then_some(remainder) - }, - }, - "agents" => Self::Agents { - args: remainder_after_command(trimmed, command), - }, - "skills" => Self::Skills { - args: remainder_after_command(trimmed, command), - }, - other => Self::Unknown(other.to_string()), - }) +impl SlashCommandParseError { + fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } } } +impl fmt::Display for SlashCommandParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for SlashCommandParseError {} + +impl SlashCommand { + pub fn parse(input: &str) -> Result, SlashCommandParseError> { + validate_slash_command_input(input) + } +} + +pub fn validate_slash_command_input( + input: &str, +) -> Result, SlashCommandParseError> { + let trimmed = input.trim(); + if !trimmed.starts_with('/') { + return Ok(None); + } + + let mut parts = trimmed.trim_start_matches('/').split_whitespace(); + let command = parts.next().unwrap_or_default(); + if command.is_empty() { + return Err(SlashCommandParseError::new( + "Slash command name is missing. Use /help to list available slash commands.", + )); + } + + let args = parts.collect::>(); + let remainder = remainder_after_command(trimmed, command); + + Ok(Some(match command { + "help" => { + validate_no_args(command, &args)?; + SlashCommand::Help + } + "status" => { + validate_no_args(command, &args)?; + SlashCommand::Status + } + "sandbox" => { + validate_no_args(command, &args)?; + SlashCommand::Sandbox + } + "compact" => { + validate_no_args(command, &args)?; + SlashCommand::Compact + } + "bughunter" => SlashCommand::Bughunter { scope: remainder }, + "commit" => { + validate_no_args(command, &args)?; + SlashCommand::Commit + } + "pr" => SlashCommand::Pr { context: remainder }, + "issue" => SlashCommand::Issue { context: remainder }, + "ultraplan" => SlashCommand::Ultraplan { task: remainder }, + "teleport" => SlashCommand::Teleport { + target: Some(require_remainder(command, remainder, "")?), + }, + "debug-tool-call" => { + validate_no_args(command, &args)?; + SlashCommand::DebugToolCall + } + "model" => SlashCommand::Model { + model: optional_single_arg(command, &args, "[model]")?, + }, + "permissions" => SlashCommand::Permissions { + mode: parse_permissions_mode(&args)?, + }, + "clear" => SlashCommand::Clear { + confirm: parse_clear_args(&args)?, + }, + "cost" => { + validate_no_args(command, &args)?; + SlashCommand::Cost + } + "resume" => SlashCommand::Resume { + session_path: Some(require_remainder(command, remainder, "")?), + }, + "config" => SlashCommand::Config { + section: parse_config_section(&args)?, + }, + "memory" => { + validate_no_args(command, &args)?; + SlashCommand::Memory + } + "init" => { + validate_no_args(command, &args)?; + SlashCommand::Init + } + "diff" => { + validate_no_args(command, &args)?; + SlashCommand::Diff + } + "version" => { + validate_no_args(command, &args)?; + SlashCommand::Version + } + "export" => SlashCommand::Export { path: remainder }, + "session" => parse_session_command(&args)?, + "plugin" | "plugins" | "marketplace" => parse_plugin_command(&args)?, + "agents" => SlashCommand::Agents { + args: parse_list_or_help_args(command, remainder)?, + }, + "skills" => SlashCommand::Skills { + args: parse_list_or_help_args(command, remainder)?, + }, + other => SlashCommand::Unknown(other.to_string()), + })) +} +fn validate_no_args(command: &str, args: &[&str]) -> Result<(), SlashCommandParseError> { + if args.is_empty() { + return Ok(()); + } + + Err(command_error( + &format!("Unexpected arguments for /{command}."), + command, + &format!("/{command}"), + )) +} + +fn optional_single_arg( + command: &str, + args: &[&str], + argument_hint: &str, +) -> Result, SlashCommandParseError> { + match args { + [] => Ok(None), + [value] => Ok(Some((*value).to_string())), + _ => Err(usage_error(command, argument_hint)), + } +} + +fn require_remainder( + command: &str, + remainder: Option, + argument_hint: &str, +) -> Result { + remainder.ok_or_else(|| usage_error(command, argument_hint)) +} + +fn parse_permissions_mode(args: &[&str]) -> Result, SlashCommandParseError> { + let mode = optional_single_arg( + "permissions", + args, + "[read-only|workspace-write|danger-full-access]", + )?; + if let Some(mode) = mode { + if matches!( + mode.as_str(), + "read-only" | "workspace-write" | "danger-full-access" + ) { + return Ok(Some(mode)); + } + return Err(command_error( + &format!( + "Unsupported /permissions mode '{mode}'. Use read-only, workspace-write, or danger-full-access." + ), + "permissions", + "/permissions [read-only|workspace-write|danger-full-access]", + )); + } + + Ok(None) +} + +fn parse_clear_args(args: &[&str]) -> Result { + match args { + [] => Ok(false), + ["--confirm"] => Ok(true), + [unexpected] => Err(command_error( + &format!("Unsupported /clear argument '{unexpected}'. Use /clear or /clear --confirm."), + "clear", + "/clear [--confirm]", + )), + _ => Err(usage_error("clear", "[--confirm]")), + } +} + +fn parse_config_section(args: &[&str]) -> Result, SlashCommandParseError> { + let section = optional_single_arg("config", args, "[env|hooks|model|plugins]")?; + if let Some(section) = section { + if matches!(section.as_str(), "env" | "hooks" | "model" | "plugins") { + return Ok(Some(section)); + } + return Err(command_error( + &format!("Unsupported /config section '{section}'. Use env, hooks, model, or plugins."), + "config", + "/config [env|hooks|model|plugins]", + )); + } + + Ok(None) +} + +fn parse_session_command(args: &[&str]) -> Result { + match args { + [] => Ok(SlashCommand::Session { + action: None, + target: None, + }), + ["list"] => Ok(SlashCommand::Session { + action: Some("list".to_string()), + target: None, + }), + ["list", ..] => Err(usage_error("session", "[list|switch |fork [branch-name]]")), + ["switch"] => Err(usage_error("session switch", "")), + ["switch", target] => Ok(SlashCommand::Session { + action: Some("switch".to_string()), + target: Some((*target).to_string()), + }), + ["switch", ..] => Err(command_error( + "Unexpected arguments for /session switch.", + "session", + "/session switch ", + )), + ["fork"] => Ok(SlashCommand::Session { + action: Some("fork".to_string()), + target: None, + }), + ["fork", target] => Ok(SlashCommand::Session { + action: Some("fork".to_string()), + target: Some((*target).to_string()), + }), + ["fork", ..] => Err(command_error( + "Unexpected arguments for /session fork.", + "session", + "/session fork [branch-name]", + )), + [action, ..] => Err(command_error( + &format!( + "Unknown /session action '{action}'. Use list, switch , or fork [branch-name]." + ), + "session", + "/session [list|switch |fork [branch-name]]", + )), + } +} + +fn parse_plugin_command(args: &[&str]) -> Result { + match args { + [] => Ok(SlashCommand::Plugins { + action: None, + target: None, + }), + ["list"] => Ok(SlashCommand::Plugins { + action: Some("list".to_string()), + target: None, + }), + ["list", ..] => Err(usage_error("plugin list", "")), + ["install"] => Err(usage_error("plugin install", "")), + ["install", target @ ..] => Ok(SlashCommand::Plugins { + action: Some("install".to_string()), + target: Some(target.join(" ")), + }), + ["enable"] => Err(usage_error("plugin enable", "")), + ["enable", target] => Ok(SlashCommand::Plugins { + action: Some("enable".to_string()), + target: Some((*target).to_string()), + }), + ["enable", ..] => Err(command_error( + "Unexpected arguments for /plugin enable.", + "plugin", + "/plugin enable ", + )), + ["disable"] => Err(usage_error("plugin disable", "")), + ["disable", target] => Ok(SlashCommand::Plugins { + action: Some("disable".to_string()), + target: Some((*target).to_string()), + }), + ["disable", ..] => Err(command_error( + "Unexpected arguments for /plugin disable.", + "plugin", + "/plugin disable ", + )), + ["uninstall"] => Err(usage_error("plugin uninstall", "")), + ["uninstall", target] => Ok(SlashCommand::Plugins { + action: Some("uninstall".to_string()), + target: Some((*target).to_string()), + }), + ["uninstall", ..] => Err(command_error( + "Unexpected arguments for /plugin uninstall.", + "plugin", + "/plugin uninstall ", + )), + ["update"] => Err(usage_error("plugin update", "")), + ["update", target] => Ok(SlashCommand::Plugins { + action: Some("update".to_string()), + target: Some((*target).to_string()), + }), + ["update", ..] => Err(command_error( + "Unexpected arguments for /plugin update.", + "plugin", + "/plugin update ", + )), + [action, ..] => Err(command_error( + &format!( + "Unknown /plugin action '{action}'. Use list, install , enable , disable , uninstall , or update ." + ), + "plugin", + "/plugin [list|install |enable |disable |uninstall |update ]", + )), + } +} + +fn parse_list_or_help_args( + command: &str, + args: Option, +) -> Result, SlashCommandParseError> { + match normalize_optional_args(args.as_deref()) { + None | Some("list" | "help" | "-h" | "--help") => Ok(args), + Some(unexpected) => Err(command_error( + &format!( + "Unexpected arguments for /{command}: {unexpected}. Use /{command}, /{command} list, or /{command} help." + ), + command, + &format!("/{command} [list|help]"), + )), + } +} + +fn usage_error(command: &str, argument_hint: &str) -> SlashCommandParseError { + let usage = format!("/{command} {argument_hint}"); + let usage = usage.trim_end().to_string(); + command_error( + &format!("Usage: {usage}"), + command_root_name(command), + &usage, + ) +} + +fn command_error(message: &str, command: &str, usage: &str) -> SlashCommandParseError { + let detail = render_slash_command_help_detail(command) + .map(|detail| format!("\n\n{detail}")) + .unwrap_or_default(); + SlashCommandParseError::new(format!("{message}\n Usage {usage}{detail}")) +} + fn remainder_after_command(input: &str, command: &str) -> Option { input .trim() @@ -381,6 +659,56 @@ fn remainder_after_command(input: &str, command: &str) -> Option { .map(ToOwned::to_owned) } +fn find_slash_command_spec(name: &str) -> Option<&'static SlashCommandSpec> { + slash_command_specs().iter().find(|spec| { + spec.name.eq_ignore_ascii_case(name) + || spec + .aliases + .iter() + .any(|alias| alias.eq_ignore_ascii_case(name)) + }) +} + +fn command_root_name(command: &str) -> &str { + command.split_whitespace().next().unwrap_or(command) +} + +fn slash_command_usage(spec: &SlashCommandSpec) -> String { + match spec.argument_hint { + Some(argument_hint) => format!("/{} {argument_hint}", spec.name), + None => format!("/{}", spec.name), + } +} + +fn slash_command_detail_lines(spec: &SlashCommandSpec) -> Vec { + let mut lines = vec![format!("/{}", spec.name)]; + lines.push(format!(" Summary {}", spec.summary)); + lines.push(format!(" Usage {}", slash_command_usage(spec))); + lines.push(format!( + " Category {}", + slash_command_category(spec.name) + )); + if !spec.aliases.is_empty() { + lines.push(format!( + " Aliases {}", + spec.aliases + .iter() + .map(|alias| format!("/{alias}")) + .collect::>() + .join(", ") + )); + } + if spec.resume_supported { + lines.push(" Resume Supported with --resume SESSION.jsonl".to_string()); + } + lines +} + +#[must_use] +pub fn render_slash_command_help_detail(name: &str) -> Option { + find_slash_command_spec(name).map(|spec| slash_command_detail_lines(spec).join("\n")) +} + #[must_use] pub fn slash_command_specs() -> &'static [SlashCommandSpec] { SLASH_COMMAND_SPECS @@ -407,10 +735,7 @@ fn slash_command_category(name: &str) -> &'static str { } fn format_slash_command_help_line(spec: &SlashCommandSpec) -> String { - let name = match spec.argument_hint { - Some(argument_hint) => format!("/{} {}", spec.name, argument_hint), - None => format!("/{}", spec.name), - }; + let name = slash_command_usage(spec); let alias_suffix = if spec.aliases.is_empty() { String::new() } else { @@ -428,7 +753,7 @@ fn format_slash_command_help_line(spec: &SlashCommandSpec) -> String { } else { "" }; - format!(" {name:<20} {}{alias_suffix}{resume}", spec.summary) + format!(" {name:<66} {}{alias_suffix}{resume}", spec.summary) } fn levenshtein_distance(left: &str, right: &str) -> usize { @@ -509,8 +834,8 @@ pub fn suggest_slash_commands(input: &str, limit: usize) -> Vec { pub fn render_slash_command_help() -> String { let mut lines = vec![ "Slash commands".to_string(), - " Start here /status, /diff, /agents, /skills, /commit".to_string(), - " [resume] means the command also works with --resume SESSION.jsonl".to_string(), + " Start here /status, /diff, /agents, /skills, /commit".to_string(), + " [resume] also works with --resume SESSION.jsonl".to_string(), String::new(), ]; @@ -1282,7 +1607,18 @@ pub fn handle_slash_command( session: &Session, compaction: CompactionConfig, ) -> Option { - match SlashCommand::parse(input)? { + let command = match SlashCommand::parse(input) { + Ok(Some(command)) => command, + Ok(None) => return None, + Err(error) => { + return Some(SlashCommandResult { + message: error.to_string(), + session: session.clone(), + }); + } + }; + + match command { SlashCommand::Compact => { let result = compact_session(session, compaction); let message = if result.removed_message_count == 0 { @@ -1335,8 +1671,9 @@ mod tests { use super::{ handle_plugins_slash_command, handle_slash_command, load_agents_from_roots, load_skills_from_roots, render_agents_report, render_plugins_report, render_skills_report, - render_slash_command_help, resume_supported_slash_commands, slash_command_specs, - suggest_slash_commands, DefinitionSource, SkillOrigin, SkillRoot, SlashCommand, + render_slash_command_help, render_slash_command_help_detail, + resume_supported_slash_commands, slash_command_specs, suggest_slash_commands, + validate_slash_command_input, DefinitionSource, SkillOrigin, SkillRoot, SlashCommand, }; use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginMetadata, PluginSummary}; use runtime::{CompactionConfig, ContentBlock, ConversationMessage, MessageRole, Session}; @@ -1405,182 +1742,288 @@ mod tests { .expect("write command"); } + fn parse_error_message(input: &str) -> String { + SlashCommand::parse(input) + .expect_err("slash command should be rejected") + .to_string() + } + #[allow(clippy::too_many_lines)] #[test] fn parses_supported_slash_commands() { - assert_eq!(SlashCommand::parse("/help"), Some(SlashCommand::Help)); - assert_eq!(SlashCommand::parse(" /status "), Some(SlashCommand::Status)); - assert_eq!(SlashCommand::parse("/sandbox"), Some(SlashCommand::Sandbox)); + assert_eq!(SlashCommand::parse("/help"), Ok(Some(SlashCommand::Help))); assert_eq!( - SlashCommand::parse("/bughunter runtime"), - Some(SlashCommand::Bughunter { - scope: Some("runtime".to_string()) - }) - ); - assert_eq!(SlashCommand::parse("/commit"), Some(SlashCommand::Commit)); - assert_eq!( - SlashCommand::parse("/pr ready for review"), - Some(SlashCommand::Pr { - context: Some("ready for review".to_string()) - }) + SlashCommand::parse(" /status "), + Ok(Some(SlashCommand::Status)) ); assert_eq!( - SlashCommand::parse("/issue flaky test"), - Some(SlashCommand::Issue { - context: Some("flaky test".to_string()) - }) - ); - assert_eq!( - SlashCommand::parse("/ultraplan ship both features"), - Some(SlashCommand::Ultraplan { - task: Some("ship both features".to_string()) - }) - ); - assert_eq!( - SlashCommand::parse("/teleport conversation.rs"), - Some(SlashCommand::Teleport { - target: Some("conversation.rs".to_string()) - }) - ); - assert_eq!( - SlashCommand::parse("/debug-tool-call"), - Some(SlashCommand::DebugToolCall) + SlashCommand::parse("/sandbox"), + Ok(Some(SlashCommand::Sandbox)) ); assert_eq!( SlashCommand::parse("/bughunter runtime"), - Some(SlashCommand::Bughunter { + Ok(Some(SlashCommand::Bughunter { scope: Some("runtime".to_string()) - }) + })) + ); + assert_eq!( + SlashCommand::parse("/commit"), + Ok(Some(SlashCommand::Commit)) ); - assert_eq!(SlashCommand::parse("/commit"), Some(SlashCommand::Commit)); assert_eq!( SlashCommand::parse("/pr ready for review"), - Some(SlashCommand::Pr { + Ok(Some(SlashCommand::Pr { context: Some("ready for review".to_string()) - }) + })) ); assert_eq!( SlashCommand::parse("/issue flaky test"), - Some(SlashCommand::Issue { + Ok(Some(SlashCommand::Issue { context: Some("flaky test".to_string()) - }) + })) ); assert_eq!( SlashCommand::parse("/ultraplan ship both features"), - Some(SlashCommand::Ultraplan { + Ok(Some(SlashCommand::Ultraplan { task: Some("ship both features".to_string()) - }) + })) ); assert_eq!( SlashCommand::parse("/teleport conversation.rs"), - Some(SlashCommand::Teleport { + Ok(Some(SlashCommand::Teleport { target: Some("conversation.rs".to_string()) - }) + })) ); assert_eq!( SlashCommand::parse("/debug-tool-call"), - Some(SlashCommand::DebugToolCall) + Ok(Some(SlashCommand::DebugToolCall)) + ); + assert_eq!( + SlashCommand::parse("/bughunter runtime"), + Ok(Some(SlashCommand::Bughunter { + scope: Some("runtime".to_string()) + })) + ); + assert_eq!( + SlashCommand::parse("/commit"), + Ok(Some(SlashCommand::Commit)) + ); + assert_eq!( + SlashCommand::parse("/pr ready for review"), + Ok(Some(SlashCommand::Pr { + context: Some("ready for review".to_string()) + })) + ); + assert_eq!( + SlashCommand::parse("/issue flaky test"), + Ok(Some(SlashCommand::Issue { + context: Some("flaky test".to_string()) + })) + ); + assert_eq!( + SlashCommand::parse("/ultraplan ship both features"), + Ok(Some(SlashCommand::Ultraplan { + task: Some("ship both features".to_string()) + })) + ); + assert_eq!( + SlashCommand::parse("/teleport conversation.rs"), + Ok(Some(SlashCommand::Teleport { + target: Some("conversation.rs".to_string()) + })) + ); + assert_eq!( + SlashCommand::parse("/debug-tool-call"), + Ok(Some(SlashCommand::DebugToolCall)) ); assert_eq!( SlashCommand::parse("/model claude-opus"), - Some(SlashCommand::Model { + Ok(Some(SlashCommand::Model { model: Some("claude-opus".to_string()), - }) + })) ); assert_eq!( SlashCommand::parse("/model"), - Some(SlashCommand::Model { model: None }) + Ok(Some(SlashCommand::Model { model: None })) ); assert_eq!( SlashCommand::parse("/permissions read-only"), - Some(SlashCommand::Permissions { + Ok(Some(SlashCommand::Permissions { mode: Some("read-only".to_string()), - }) + })) ); assert_eq!( SlashCommand::parse("/clear"), - Some(SlashCommand::Clear { confirm: false }) + Ok(Some(SlashCommand::Clear { confirm: false })) ); assert_eq!( SlashCommand::parse("/clear --confirm"), - Some(SlashCommand::Clear { confirm: true }) + Ok(Some(SlashCommand::Clear { confirm: true })) ); - assert_eq!(SlashCommand::parse("/cost"), Some(SlashCommand::Cost)); + assert_eq!(SlashCommand::parse("/cost"), Ok(Some(SlashCommand::Cost))); assert_eq!( SlashCommand::parse("/resume session.json"), - Some(SlashCommand::Resume { + Ok(Some(SlashCommand::Resume { session_path: Some("session.json".to_string()), - }) + })) ); assert_eq!( SlashCommand::parse("/config"), - Some(SlashCommand::Config { section: None }) + Ok(Some(SlashCommand::Config { section: None })) ); assert_eq!( SlashCommand::parse("/config env"), - Some(SlashCommand::Config { + Ok(Some(SlashCommand::Config { section: Some("env".to_string()) - }) + })) + ); + assert_eq!( + SlashCommand::parse("/memory"), + Ok(Some(SlashCommand::Memory)) + ); + assert_eq!(SlashCommand::parse("/init"), Ok(Some(SlashCommand::Init))); + assert_eq!(SlashCommand::parse("/diff"), Ok(Some(SlashCommand::Diff))); + assert_eq!( + SlashCommand::parse("/version"), + Ok(Some(SlashCommand::Version)) ); - assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory)); - assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init)); - assert_eq!(SlashCommand::parse("/diff"), Some(SlashCommand::Diff)); - assert_eq!(SlashCommand::parse("/version"), Some(SlashCommand::Version)); assert_eq!( SlashCommand::parse("/export notes.txt"), - Some(SlashCommand::Export { + Ok(Some(SlashCommand::Export { path: Some("notes.txt".to_string()) - }) + })) ); assert_eq!( SlashCommand::parse("/session switch abc123"), - Some(SlashCommand::Session { + Ok(Some(SlashCommand::Session { action: Some("switch".to_string()), target: Some("abc123".to_string()) - }) + })) ); assert_eq!( SlashCommand::parse("/plugins install demo"), - Some(SlashCommand::Plugins { + Ok(Some(SlashCommand::Plugins { action: Some("install".to_string()), target: Some("demo".to_string()) - }) + })) ); assert_eq!( SlashCommand::parse("/plugins list"), - Some(SlashCommand::Plugins { + Ok(Some(SlashCommand::Plugins { action: Some("list".to_string()), target: None - }) + })) ); assert_eq!( SlashCommand::parse("/plugins enable demo"), - Some(SlashCommand::Plugins { + Ok(Some(SlashCommand::Plugins { action: Some("enable".to_string()), target: Some("demo".to_string()) - }) + })) ); assert_eq!( SlashCommand::parse("/plugins disable demo"), - Some(SlashCommand::Plugins { + Ok(Some(SlashCommand::Plugins { action: Some("disable".to_string()), target: Some("demo".to_string()) - }) + })) ); assert_eq!( SlashCommand::parse("/session fork incident-review"), - Some(SlashCommand::Session { + Ok(Some(SlashCommand::Session { action: Some("fork".to_string()), target: Some("incident-review".to_string()) - }) + })) ); } + #[test] + fn rejects_unexpected_arguments_for_no_arg_commands() { + // given + let input = "/compact now"; + + // when + let error = parse_error_message(input); + + // then + assert!(error.contains("Unexpected arguments for /compact.")); + assert!(error.contains(" Usage /compact")); + assert!(error.contains(" Summary Compact local session history")); + } + + #[test] + fn rejects_invalid_argument_values() { + // given + let input = "/permissions admin"; + + // when + let error = parse_error_message(input); + + // then + assert!(error.contains( + "Unsupported /permissions mode 'admin'. Use read-only, workspace-write, or danger-full-access." + )); + assert!(error.contains( + " Usage /permissions [read-only|workspace-write|danger-full-access]" + )); + } + + #[test] + fn rejects_missing_required_arguments() { + // given + let input = "/teleport"; + + // when + let error = parse_error_message(input); + + // then + assert!(error.contains("Usage: /teleport ")); + assert!(error.contains(" Category Discovery & debugging")); + } + + #[test] + fn rejects_invalid_session_and_plugin_shapes() { + // given + let session_input = "/session switch"; + let plugin_input = "/plugins list extra"; + + // when + let session_error = parse_error_message(session_input); + let plugin_error = parse_error_message(plugin_input); + + // then + assert!(session_error.contains("Usage: /session switch ")); + assert!(session_error.contains("/session")); + assert!(plugin_error.contains("Usage: /plugin list")); + assert!(plugin_error.contains("Aliases /plugins, /marketplace")); + } + + #[test] + fn rejects_invalid_agents_and_skills_arguments() { + // given + let agents_input = "/agents show planner"; + let skills_input = "/skills show help"; + + // when + let agents_error = parse_error_message(agents_input); + let skills_error = parse_error_message(skills_input); + + // then + assert!(agents_error.contains( + "Unexpected arguments for /agents: show planner. Use /agents, /agents list, or /agents help." + )); + assert!(agents_error.contains(" Usage /agents [list|help]")); + assert!(skills_error.contains( + "Unexpected arguments for /skills: show help. Use /skills, /skills list, or /skills help." + )); + assert!(skills_error.contains(" Usage /skills [list|help]")); + } + #[test] fn renders_help_from_shared_specs() { let help = render_slash_command_help(); - assert!(help.contains("Start here /status, /diff, /agents, /skills, /commit")); - assert!(help.contains("works with --resume SESSION.jsonl")); + assert!(help.contains("Start here /status, /diff, /agents, /skills, /commit")); + assert!(help.contains("[resume] also works with --resume SESSION.jsonl")); assert!(help.contains("Session & visibility")); assert!(help.contains("Workspace & git")); assert!(help.contains("Discovery & debugging")); @@ -1619,6 +2062,42 @@ mod tests { assert_eq!(resume_supported_slash_commands().len(), 14); } + #[test] + fn renders_per_command_help_detail() { + // given + let command = "plugins"; + + // when + let help = render_slash_command_help_detail(command).expect("detail help should exist"); + + // then + assert!(help.contains("/plugin")); + assert!(help.contains("Summary Manage Claw Code plugins")); + assert!(help.contains("Aliases /plugins, /marketplace")); + assert!(help.contains("Category Workspace & git")); + } + + #[test] + fn validate_slash_command_input_rejects_extra_single_value_arguments() { + // given + let session_input = "/session switch current next"; + let plugin_input = "/plugin enable demo extra"; + + // when + let session_error = validate_slash_command_input(session_input) + .expect_err("session input should be rejected") + .to_string(); + let plugin_error = validate_slash_command_input(plugin_input) + .expect_err("plugin input should be rejected") + .to_string(); + + // then + assert!(session_error.contains("Unexpected arguments for /session switch.")); + assert!(session_error.contains(" Usage /session switch ")); + assert!(plugin_error.contains("Unexpected arguments for /plugin enable.")); + assert!(plugin_error.contains(" Usage /plugin enable ")); + } + #[test] fn suggests_closest_slash_commands_for_typos_and_aliases() { assert_eq!(suggest_slash_commands("stats", 3), vec!["/status"]); diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index cb25722..6762198 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -423,11 +423,11 @@ fn join_optional_args(args: &[String]) -> Option { fn parse_direct_slash_cli_action(rest: &[String]) -> Result { let raw = rest.join(" "); match SlashCommand::parse(&raw) { - Some(SlashCommand::Help) => Ok(CliAction::Help), - Some(SlashCommand::Agents { args }) => Ok(CliAction::Agents { args }), - Some(SlashCommand::Skills { args }) => Ok(CliAction::Skills { args }), - Some(SlashCommand::Unknown(name)) => Err(format_unknown_direct_slash_command(&name)), - Some(command) => Err({ + Ok(Some(SlashCommand::Help)) => Ok(CliAction::Help), + Ok(Some(SlashCommand::Agents { args })) => Ok(CliAction::Agents { args }), + Ok(Some(SlashCommand::Skills { args })) => Ok(CliAction::Skills { args }), + Ok(Some(SlashCommand::Unknown(name))) => Err(format_unknown_direct_slash_command(&name)), + Ok(Some(command)) => Err({ let _ = command; format!( "slash command {command_name} is interactive-only. Start `claw` and run it there, or use `claw --resume SESSION.jsonl {command_name}` / `claw --resume {latest} {command_name}` when the command is marked [resume] in /help.", @@ -435,7 +435,8 @@ fn parse_direct_slash_cli_action(rest: &[String]) -> Result { latest = LATEST_SESSION_REFERENCE, ) }), - None => Err(format!("unknown subcommand: {}", rest[0])), + Ok(None) => Err(format!("unknown subcommand: {}", rest[0])), + Err(error) => Err(error.to_string()), } } @@ -680,7 +681,7 @@ fn parse_resume_args(args: &[String]) -> Result { fn resume_command_can_absorb_token(current_command: &str, token: &str) -> bool { matches!( SlashCommand::parse(current_command), - Some(SlashCommand::Export { path: None }) + Ok(Some(SlashCommand::Export { path: None })) ) && !looks_like_slash_command_token(token) } @@ -896,9 +897,16 @@ fn resume_session(session_path: &Path, commands: &[String]) { let mut session = session; for raw_command in commands { - let Some(command) = SlashCommand::parse(raw_command) else { - eprintln!("unsupported resumed command: {raw_command}"); - std::process::exit(2); + let command = match SlashCommand::parse(raw_command) { + Ok(Some(command)) => command, + Ok(None) => { + eprintln!("unsupported resumed command: {raw_command}"); + std::process::exit(2); + } + Err(error) => { + eprintln!("{error}"); + std::process::exit(2); + } }; match run_resume_command(&resolved_path, &session, &command) { Ok(ResumeCommandOutcome { @@ -1417,11 +1425,18 @@ fn run_repl( cli.persist_session()?; break; } - if let Some(command) = SlashCommand::parse(&trimmed) { - if cli.handle_repl_command(command)? { - cli.persist_session()?; + match SlashCommand::parse(&trimmed) { + Ok(Some(command)) => { + if cli.handle_repl_command(command)? { + cli.persist_session()?; + } + continue; + } + Ok(None) => {} + Err(error) => { + eprintln!("{error}"); + continue; } - continue; } editor.push_history(input); cli.run_turn(&trimmed)?; @@ -5889,11 +5904,11 @@ UU conflicted.rs", fn clear_command_requires_explicit_confirmation_flag() { assert_eq!( SlashCommand::parse("/clear"), - Some(SlashCommand::Clear { confirm: false }) + Ok(Some(SlashCommand::Clear { confirm: false })) ); assert_eq!( SlashCommand::parse("/clear --confirm"), - Some(SlashCommand::Clear { confirm: true }) + Ok(Some(SlashCommand::Clear { confirm: true })) ); } @@ -5901,32 +5916,35 @@ UU conflicted.rs", fn parses_resume_and_config_slash_commands() { assert_eq!( SlashCommand::parse("/resume saved-session.jsonl"), - Some(SlashCommand::Resume { + Ok(Some(SlashCommand::Resume { session_path: Some("saved-session.jsonl".to_string()) - }) + })) ); assert_eq!( SlashCommand::parse("/clear --confirm"), - Some(SlashCommand::Clear { confirm: true }) + Ok(Some(SlashCommand::Clear { confirm: true })) ); assert_eq!( SlashCommand::parse("/config"), - Some(SlashCommand::Config { section: None }) + Ok(Some(SlashCommand::Config { section: None })) ); assert_eq!( SlashCommand::parse("/config env"), - Some(SlashCommand::Config { + Ok(Some(SlashCommand::Config { section: Some("env".to_string()) - }) + })) ); - assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory)); - assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init)); + assert_eq!( + SlashCommand::parse("/memory"), + Ok(Some(SlashCommand::Memory)) + ); + assert_eq!(SlashCommand::parse("/init"), Ok(Some(SlashCommand::Init))); assert_eq!( SlashCommand::parse("/session fork incident-review"), - Some(SlashCommand::Session { + Ok(Some(SlashCommand::Session { action: Some("fork".to_string()), target: Some("incident-review".to_string()) - }) + })) ); }