Procházet zdrojové kódy

feat(commands): add slash command argument validation with clear error messages

- Add SlashCommandParseError type for structured parse failures
- Validate arguments for all arg-taking commands (permissions, config, session, plugin, agents, skills, teleport, resume)
- No-arg commands now reject unexpected arguments
- Error messages include help text with usage/summary/category
- 21 commands tests pass, clippy clean
YeonGyu-Kim před 2 měsíci
rodič
revize
731ba9843b
2 změnil soubory, kde provedl 664 přidání a 167 odebrání
  1. 620 141
      rust/crates/commands/src/lib.rs
  2. 44 26
      rust/crates/rusty-claude-cli/src/main.rs

+ 620 - 141
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),
 }
 
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct SlashCommandParseError {
+    message: String,
+}
+
+impl SlashCommandParseError {
+    fn new(message: impl Into<String>) -> 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<Self> {
-        let trimmed = input.trim();
-        if !trimmed.starts_with('/') {
-            return None;
+    pub fn parse(input: &str) -> Result<Option<Self>, SlashCommandParseError> {
+        validate_slash_command_input(input)
+    }
+}
+
+pub fn validate_slash_command_input(
+    input: &str,
+) -> Result<Option<SlashCommand>, 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::<Vec<_>>();
+    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, "<symbol-or-path>")?),
+        },
+        "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, "<session-path>")?),
+        },
+        "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(());
+    }
 
-        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::<Vec<_>>().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()),
-        })
+    Err(command_error(
+        &format!("Unexpected arguments for /{command}."),
+        command,
+        &format!("/{command}"),
+    ))
+}
+
+fn optional_single_arg(
+    command: &str,
+    args: &[&str],
+    argument_hint: &str,
+) -> Result<Option<String>, SlashCommandParseError> {
+    match args {
+        [] => Ok(None),
+        [value] => Ok(Some((*value).to_string())),
+        _ => Err(usage_error(command, argument_hint)),
     }
 }
 
+fn require_remainder(
+    command: &str,
+    remainder: Option<String>,
+    argument_hint: &str,
+) -> Result<String, SlashCommandParseError> {
+    remainder.ok_or_else(|| usage_error(command, argument_hint))
+}
+
+fn parse_permissions_mode(args: &[&str]) -> Result<Option<String>, 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<bool, SlashCommandParseError> {
+    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<Option<String>, 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<SlashCommand, SlashCommandParseError> {
+    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 <session-id>|fork [branch-name]]")),
+        ["switch"] => Err(usage_error("session switch", "<session-id>")),
+        ["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 <session-id>",
+        )),
+        ["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 <session-id>, or fork [branch-name]."
+            ),
+            "session",
+            "/session [list|switch <session-id>|fork [branch-name]]",
+        )),
+    }
+}
+
+fn parse_plugin_command(args: &[&str]) -> Result<SlashCommand, SlashCommandParseError> {
+    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", "<path>")),
+        ["install", target @ ..] => Ok(SlashCommand::Plugins {
+            action: Some("install".to_string()),
+            target: Some(target.join(" ")),
+        }),
+        ["enable"] => Err(usage_error("plugin enable", "<name>")),
+        ["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 <name>",
+        )),
+        ["disable"] => Err(usage_error("plugin disable", "<name>")),
+        ["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 <name>",
+        )),
+        ["uninstall"] => Err(usage_error("plugin uninstall", "<id>")),
+        ["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 <id>",
+        )),
+        ["update"] => Err(usage_error("plugin update", "<id>")),
+        ["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 <id>",
+        )),
+        [action, ..] => Err(command_error(
+            &format!(
+                "Unknown /plugin action '{action}'. Use list, install <path>, enable <name>, disable <name>, uninstall <id>, or update <id>."
+            ),
+            "plugin",
+            "/plugin [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]",
+        )),
+    }
+}
+
+fn parse_list_or_help_args(
+    command: &str,
+    args: Option<String>,
+) -> Result<Option<String>, 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<String> {
     input
         .trim()
@@ -381,6 +659,56 @@ fn remainder_after_command(input: &str, command: &str) -> Option<String> {
         .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<String> {
+    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::<Vec<_>>()
+                .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<String> {
+    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<String> {
 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<SlashCommandResult> {
-    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(" /status "),
+            Ok(Some(SlashCommand::Status))
+        );
+        assert_eq!(
+            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"),
-            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("/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 <symbol-or-path>"));
+        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 <session-id>"));
+        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 <session-id>"));
+        assert!(plugin_error.contains("Unexpected arguments for /plugin enable."));
+        assert!(plugin_error.contains("  Usage            /plugin enable <name>"));
+    }
+
     #[test]
     fn suggests_closest_slash_commands_for_typos_and_aliases() {
         assert_eq!(suggest_slash_commands("stats", 3), vec!["/status"]);

+ 44 - 26
rust/crates/rusty-claude-cli/src/main.rs

@@ -423,11 +423,11 @@ fn join_optional_args(args: &[String]) -> Option<String> {
 fn parse_direct_slash_cli_action(rest: &[String]) -> Result<CliAction, String> {
     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<CliAction, String> {
                 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<CliAction, String> {
 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)?;
@@ -5887,11 +5902,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 }))
         );
     }
 
@@ -5899,32 +5914,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())
-            })
+            }))
         );
     }