From d88144d4a50fcf74d7d3673b4b588388fd6bc5fa Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 2 Apr 2026 18:24:47 +0900 Subject: [PATCH] feat(commands): slash-command validation, help formatting, CLI wiring - Add centralized validate_slash_command_input for all slash commands - Rich error messages and per-command help detail - Wire validation into CLI entrypoints in main.rs - Consistent /agents and /skills usage surface - Verified: cargo test -p commands 22 passed, integration test passed, clippy clean --- rust/crates/commands/src/lib.rs | 584 ++++++++++++++++++++++- rust/crates/rusty-claude-cli/src/main.rs | 63 ++- 2 files changed, 612 insertions(+), 35 deletions(-) diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 4e7a3ea..bdd8cff 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}; @@ -220,14 +221,14 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ name: "agents", aliases: &[], summary: "List configured agents", - argument_hint: None, + argument_hint: Some("[list|help]"), resume_supported: true, }, SlashCommandSpec { name: "skills", aliases: &[], summary: "List available skills", - argument_hint: None, + argument_hint: Some("[list|help]"), resume_supported: true, }, ]; @@ -295,6 +296,27 @@ pub enum SlashCommand { Unknown(String), } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SlashCommandParseError { + message: 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 { #[must_use] pub fn parse(input: &str) -> Option { @@ -372,6 +394,333 @@ impl SlashCommand { } } +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 +730,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 +806,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 +824,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 +905,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(), ]; @@ -1253,7 +1649,7 @@ fn normalize_optional_args(args: Option<&str>) -> Option<&str> { fn render_agents_usage(unexpected: Option<&str>) -> String { let mut lines = vec![ "Agents".to_string(), - " Usage /agents".to_string(), + " Usage /agents [list|help]".to_string(), " Direct CLI claw agents".to_string(), " Sources .codex/agents, .claude/agents, $CODEX_HOME/agents".to_string(), ]; @@ -1266,7 +1662,7 @@ fn render_agents_usage(unexpected: Option<&str>) -> String { fn render_skills_usage(unexpected: Option<&str>) -> String { let mut lines = vec![ "Skills".to_string(), - " Usage /skills".to_string(), + " Usage /skills [list|help]".to_string(), " Direct CLI claw skills".to_string(), " Sources .codex/skills, .claude/skills, legacy /commands".to_string(), ]; @@ -1282,7 +1678,18 @@ pub fn handle_slash_command( session: &Session, compaction: CompactionConfig, ) -> Option { - match SlashCommand::parse(input)? { + let command = match validate_slash_command_input(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 +1742,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,6 +1813,12 @@ mod tests { .expect("write command"); } + fn parse_error_message(input: &str) -> String { + validate_slash_command_input(input) + .expect_err("slash command should be rejected") + .to_string() + } + #[allow(clippy::too_many_lines)] #[test] fn parses_supported_slash_commands() { @@ -1576,11 +1990,93 @@ mod tests { ); } + #[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")); @@ -1613,12 +2109,60 @@ mod tests { "/plugin [list|install |enable |disable |uninstall |update ]" )); assert!(help.contains("aliases: /plugins, /marketplace")); - assert!(help.contains("/agents")); - assert!(help.contains("/skills")); + assert!(help.contains("/agents [list|help]")); + assert!(help.contains("/skills [list|help]")); assert_eq!(slash_command_specs().len(), 26); 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 renders_agents_and_skills_help_with_list_and_help_usage() { + // given + let agents = render_slash_command_help_detail("agents").expect("agents help should exist"); + let skills = render_slash_command_help_detail("skills").expect("skills help should exist"); + + // when + // then + assert!(agents.contains("Usage /agents [list|help]")); + assert!(skills.contains("Usage /skills [list|help]")); + } + + #[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"]); @@ -1886,7 +2430,7 @@ mod tests { let agents_help = super::handle_agents_slash_command(Some("help"), &cwd).expect("agents help"); - assert!(agents_help.contains("Usage /agents")); + assert!(agents_help.contains("Usage /agents [list|help]")); assert!(agents_help.contains("Direct CLI claw agents")); let agents_unexpected = @@ -1895,7 +2439,7 @@ mod tests { let skills_help = super::handle_skills_slash_command(Some("--help"), &cwd).expect("skills help"); - assert!(skills_help.contains("Usage /skills")); + assert!(skills_help.contains("Usage /skills [list|help]")); assert!(skills_help.contains("legacy /commands")); let skills_unexpected = diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index cd8d960..c7e2bdd 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -22,7 +22,8 @@ use api::{ use commands::{ handle_agents_slash_command, handle_plugins_slash_command, handle_skills_slash_command, - render_slash_command_help, resume_supported_slash_commands, slash_command_specs, SlashCommand, + render_slash_command_help, resume_supported_slash_commands, slash_command_specs, + validate_slash_command_input, SlashCommand, }; use compat_harness::{extract_manifest, UpstreamPaths}; use init::initialize_repo; @@ -422,12 +423,12 @@ 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({ + match validate_slash_command_input(&raw) { + 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 +436,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()), } } @@ -896,9 +898,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 validate_slash_command_input(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 +1426,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 validate_slash_command_input(&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)?; @@ -5246,6 +5262,23 @@ mod tests { assert!(error.contains("claw --resume SESSION.jsonl /status")); } + #[test] + fn direct_slash_commands_surface_shared_validation_errors() { + let compact_error = parse_args(&["/compact".to_string(), "now".to_string()]) + .expect_err("invalid /compact shape should be rejected"); + assert!(compact_error.contains("Unexpected arguments for /compact.")); + assert!(compact_error.contains("Usage /compact")); + + let plugins_error = parse_args(&[ + "/plugins".to_string(), + "list".to_string(), + "extra".to_string(), + ]) + .expect_err("invalid /plugins list shape should be rejected"); + assert!(plugins_error.contains("Usage: /plugin list")); + assert!(plugins_error.contains("Aliases /plugins, /marketplace")); + } + #[test] fn formats_unknown_slash_command_with_suggestions() { let report = format_unknown_slash_command_message("stats");