diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index de9af6c3..d431ad9f 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -1180,6 +1180,9 @@ pub enum SlashCommand { count: Option, }, Unknown(String), + Team { + action: Option, + }, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -1277,6 +1280,7 @@ impl SlashCommand { Self::Tag { .. } => "/tag", Self::OutputStyle { .. } => "/output-style", Self::AddDir { .. } => "/add-dir", + Self::Team { .. } => "/team", Self::Sandbox => "/sandbox", Self::Mcp { .. } => "/mcp", Self::Export { .. } => "/export", @@ -4312,6 +4316,7 @@ pub fn handle_slash_command( | SlashCommand::OutputStyle { .. } | SlashCommand::AddDir { .. } | SlashCommand::History { .. } + | SlashCommand::Team { .. } | SlashCommand::Unknown(_) => None, } } diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index a3e04434..fc9c071a 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -274,6 +274,8 @@ fn classify_error_kind(message: &str) -> &'static str { "no_managed_sessions" } else if message.contains("unsupported ACP invocation") { "unsupported_acp_invocation" + } else if message.contains("unsupported skills action") { + "unsupported_skills_action" } else if message.contains("unrecognized argument") || message.contains("unknown option") { "cli_parse" } else if message.contains("invalid model syntax") { @@ -877,6 +879,35 @@ fn parse_args(args: &[String]) -> Result { } if wants_help { + // #684: --help before subcommand should still route to subcommand-specific + // help when the subcommand is one of the local-help-topic commands. + if let Some(action) = parse_local_help_action(&rest, output_format) { + return action; + } + // When --help was consumed before the subcommand, rest has no help flag. + // If rest is a simple local-help subcommand with no extra args, route there. + if !rest.is_empty() && rest[1..].iter().all(|a| is_help_flag(a)) { + let topic = match rest[0].as_str() { + "status" => Some(LocalHelpTopic::Status), + "sandbox" => Some(LocalHelpTopic::Sandbox), + "doctor" => Some(LocalHelpTopic::Doctor), + "acp" => Some(LocalHelpTopic::Acp), + "init" => Some(LocalHelpTopic::Init), + "state" => Some(LocalHelpTopic::State), + "export" => Some(LocalHelpTopic::Export), + "version" => Some(LocalHelpTopic::Version), + "system-prompt" => Some(LocalHelpTopic::SystemPrompt), + "dump-manifests" => Some(LocalHelpTopic::DumpManifests), + "bootstrap-plan" => Some(LocalHelpTopic::BootstrapPlan), + _ => None, + }; + if let Some(topic) = topic { + return Ok(CliAction::HelpTopic { + topic, + output_format, + }); + } + } return Ok(CliAction::Help { output_format }); } @@ -1020,6 +1051,14 @@ fn parse_args(args: &[String]) -> Result { ), "skills" => { let args = join_optional_args(&rest[1..]); + if let Some(action) = args.as_deref() { + let first_word = action.split_whitespace().next().unwrap_or(action); + if matches!(first_word, "remove" | "add" | "uninstall" | "delete") { + return Err(format!( + "unsupported skills action: {first_word}. Supported actions: list, install , help, or [args]" + )); + } + } match classify_skills_slash_command(args.as_deref()) { SkillSlashDispatch::Invoke(prompt) => Ok(CliAction::Prompt { prompt, @@ -1117,7 +1156,10 @@ fn parse_local_help_action( rest: &[String], output_format: CliOutputFormat, ) -> Option> { - if rest.len() != 2 || !is_help_flag(&rest[1]) { + if rest.is_empty() { + return None; + } + if !rest.iter().any(|a| is_help_flag(a)) { return None; } @@ -1126,10 +1168,6 @@ fn parse_local_help_action( "sandbox" => LocalHelpTopic::Sandbox, "doctor" => LocalHelpTopic::Doctor, "acp" => LocalHelpTopic::Acp, - // #141: add the subcommands that were previously falling back - // to global help (init/state/export/version) or erroring out - // (system-prompt/dump-manifests) or printing their primary - // output instead of help text (bootstrap-plan). "init" => LocalHelpTopic::Init, "state" => LocalHelpTopic::State, "export" => LocalHelpTopic::Export, @@ -1139,6 +1177,10 @@ fn parse_local_help_action( "bootstrap-plan" => LocalHelpTopic::BootstrapPlan, _ => return None, }; + let has_non_help = rest[1..].iter().any(|a| !is_help_flag(a)); + if has_non_help { + return None; + } Some(Ok(CliAction::HelpTopic { topic, output_format, @@ -1172,8 +1214,9 @@ fn parse_single_word_command_alias( if is_diagnostic && rest.len() > 1 { // Diagnostic verb with trailing args: reject unrecognized suffix - if is_help_flag(&rest[1]) && rest.len() == 2 { - // "doctor --help" is valid, routed to parse_local_help_action() instead + let all_extra_are_help = rest[1..].iter().all(|a| is_help_flag(a)); + if all_extra_are_help { + // "doctor --help -h" is valid, routed to parse_local_help_action() instead return None; } // Unrecognized suffix like "--json" @@ -4209,7 +4252,8 @@ fn run_resume_command( | SlashCommand::Ide { .. } | SlashCommand::Tag { .. } | SlashCommand::OutputStyle { .. } - | SlashCommand::AddDir { .. } => Err("unsupported resumed slash command".into()), + | SlashCommand::AddDir { .. } + | SlashCommand::Team { .. } => Err("unsupported resumed slash command".into()), } } @@ -5456,7 +5500,8 @@ impl LiveCli { | SlashCommand::Ide { .. } | SlashCommand::Tag { .. } | SlashCommand::OutputStyle { .. } - | SlashCommand::AddDir { .. } => { + | SlashCommand::AddDir { .. } + | SlashCommand::Team { .. } => { let cmd_name = command.slash_name(); eprintln!("{cmd_name} is not yet implemented in this build."); false @@ -12711,6 +12756,23 @@ mod tests { assert!(error.contains("skills")); } + #[test] + fn unsupported_skills_actions_return_typed_error_683() { + for action in ["remove", "add", "uninstall", "delete"] { + let error = parse_args(&["skills".to_string(), action.to_string()]) + .expect_err(&format!("skills {action} should error")); + assert!( + error.contains("unsupported skills action"), + "skills {action} should contain 'unsupported skills action', got: {error}" + ); + assert_eq!( + classify_error_kind(&error), + "unsupported_skills_action", + "skills {action} should classify as unsupported_skills_action, got: {error}" + ); + } + } + #[test] fn typoed_status_subcommand_returns_did_you_mean_error() { let error = parse_args(&["statuss".to_string()]).expect_err("statuss should error");