diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index acf5d28..5efdd32 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -707,17 +707,32 @@ fn parse_args(args: &[String]) -> Result { reasoning_effort, allow_broad_cwd, ), - _other => Ok(CliAction::Prompt { - prompt: rest.join(" "), - model, - output_format, - allowed_tools, - permission_mode, - compact, - base_commit, - reasoning_effort: reasoning_effort.clone(), - allow_broad_cwd, - }), + other => { + if rest.len() == 1 && looks_like_subcommand_typo(other) { + if let Some(suggestions) = suggest_similar_subcommand(other) { + let mut message = format!("unknown subcommand: {other}."); + if let Some(line) = render_suggestion_line("Did you mean", &suggestions) { + message.push('\n'); + message.push_str(&line); + } + message.push_str( + "\nRun `claw --help` for the full list. If you meant to send a prompt literally, use `claw prompt `.", + ); + return Err(message); + } + } + Ok(CliAction::Prompt { + prompt: rest.join(" "), + model, + output_format, + allowed_tools, + permission_mode, + compact, + base_commit, + reasoning_effort: reasoning_effort.clone(), + allow_broad_cwd, + }) + } } } @@ -994,6 +1009,65 @@ fn suggest_closest_term<'a>(input: &str, candidates: &'a [&'a str]) -> Option<&' ranked_suggestions(input, candidates).into_iter().next() } + +fn suggest_similar_subcommand(input: &str) -> Option> { + const KNOWN_SUBCOMMANDS: &[&str] = &[ + "help", + "version", + "status", + "sandbox", + "doctor", + "state", + "dump-manifests", + "bootstrap-plan", + "agents", + "mcp", + "skills", + "system-prompt", + "acp", + "init", + "export", + "prompt", + ]; + + let normalized_input = input.to_ascii_lowercase(); + let mut ranked = KNOWN_SUBCOMMANDS + .iter() + .filter_map(|candidate| { + let normalized_candidate = candidate.to_ascii_lowercase(); + let distance = levenshtein_distance(&normalized_input, &normalized_candidate); + let prefix_match = common_prefix_len(&normalized_input, &normalized_candidate) >= 4; + let substring_match = normalized_candidate.contains(&normalized_input) + || normalized_input.contains(&normalized_candidate); + ((distance <= 2) || prefix_match || substring_match) + .then_some((distance, *candidate)) + }) + .collect::>(); + ranked.sort_by(|left, right| left.cmp(right).then_with(|| left.1.cmp(right.1))); + ranked.dedup_by(|left, right| left.1 == right.1); + let suggestions = ranked + .into_iter() + .map(|(_, candidate)| candidate.to_string()) + .take(3) + .collect::>(); + (!suggestions.is_empty()).then_some(suggestions) +} + +fn common_prefix_len(left: &str, right: &str) -> usize { + left.chars() + .zip(right.chars()) + .take_while(|(l, r)| l == r) + .count() +} + + +fn looks_like_subcommand_typo(input: &str) -> bool { + !input.is_empty() + && input + .chars() + .all(|ch| ch.is_ascii_alphabetic() || ch == '-') +} + fn ranked_suggestions<'a>(input: &str, candidates: &'a [&'a str]) -> Vec<&'a str> { let normalized_input = input.trim_start_matches('/').to_ascii_lowercase(); let mut ranked = candidates @@ -9901,6 +9975,109 @@ mod tests { assert!(report.contains("Use /help")); } + + #[test] + fn typoed_doctor_subcommand_returns_did_you_mean_error() { + let error = parse_args(&["doctorr".to_string()]).expect_err("doctorr should error"); + assert!(error.contains("unknown subcommand: doctorr.")); + assert!(error.contains("Did you mean")); + assert!(error.contains("doctor")); + } + + #[test] + fn typoed_skills_subcommand_returns_did_you_mean_error() { + let error = parse_args(&["skilsl".to_string()]).expect_err("skilsl should error"); + assert!(error.contains("unknown subcommand: skilsl.")); + assert!(error.contains("skills")); + } + + #[test] + fn typoed_status_subcommand_returns_did_you_mean_error() { + let error = parse_args(&["statuss".to_string()]).expect_err("statuss should error"); + assert!(error.contains("unknown subcommand: statuss.")); + assert!(error.contains("status")); + } + + #[test] + fn typoed_export_subcommand_returns_did_you_mean_error() { + let error = parse_args(&["exporrt".to_string()]).expect_err("exporrt should error"); + assert!(error.contains("unknown subcommand: exporrt.")); + assert!(error.contains("Did you mean")); + assert!(error.contains("export")); + } + + #[test] + fn typoed_mcp_subcommand_returns_did_you_mean_error() { + let error = parse_args(&["mcpp".to_string()]).expect_err("mcpp should error"); + assert!(error.contains("unknown subcommand: mcpp.")); + assert!(error.contains("mcp")); + } + + #[test] + fn multi_word_prompt_still_bypasses_subcommand_typo_guard() { + assert_eq!( + parse_args(&[ + "hello".to_string(), + "world".to_string(), + "this".to_string(), + "is".to_string(), + "a".to_string(), + "prompt".to_string(), + ]) + .expect("multi-word prompt should still parse"), + CliAction::Prompt { + prompt: "hello world this is a prompt".to_string(), + model: DEFAULT_MODEL.to_string(), + output_format: CliOutputFormat::Text, + allowed_tools: None, + permission_mode: crate::default_permission_mode(), + compact: false, + base_commit: None, + reasoning_effort: None, + allow_broad_cwd: false, + } + ); + } + + #[test] + fn prompt_subcommand_allows_literal_typo_word() { + assert_eq!( + parse_args(&["prompt".to_string(), "doctorr".to_string()]) + .expect("explicit prompt subcommand should allow literal typo word"), + CliAction::Prompt { + prompt: "doctorr".to_string(), + model: DEFAULT_MODEL.to_string(), + output_format: CliOutputFormat::Text, + allowed_tools: None, + permission_mode: PermissionMode::DangerFullAccess, + compact: false, + base_commit: None, + reasoning_effort: None, + allow_broad_cwd: false, + } + ); + } + + + #[test] + fn punctuation_bearing_single_token_still_dispatches_to_prompt() { + assert_eq!( + parse_args(&["PARITY_SCENARIO:bash_permission_prompt_approved".to_string()]) + .expect("scenario token should still dispatch to prompt"), + CliAction::Prompt { + prompt: "PARITY_SCENARIO:bash_permission_prompt_approved".to_string(), + model: DEFAULT_MODEL.to_string(), + output_format: CliOutputFormat::Text, + allowed_tools: None, + permission_mode: PermissionMode::DangerFullAccess, + compact: false, + base_commit: None, + reasoning_effort: None, + allow_broad_cwd: false, + } + ); + } + #[test] fn formats_namespaced_omc_slash_command_with_contract_guidance() { let report = format_unknown_slash_command_message("oh-my-claudecode:hud");