@@ -707,17 +707,32 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
707707 reasoning_effort,
708708 allow_broad_cwd,
709709 ) ,
710- _other => Ok ( CliAction :: Prompt {
711- prompt : rest. join ( " " ) ,
712- model,
713- output_format,
714- allowed_tools,
715- permission_mode,
716- compact,
717- base_commit,
718- reasoning_effort : reasoning_effort. clone ( ) ,
719- allow_broad_cwd,
720- } ) ,
710+ other => {
711+ if rest. len ( ) == 1 && looks_like_subcommand_typo ( other) {
712+ if let Some ( suggestions) = suggest_similar_subcommand ( other) {
713+ let mut message = format ! ( "unknown subcommand: {other}." ) ;
714+ if let Some ( line) = render_suggestion_line ( "Did you mean" , & suggestions) {
715+ message. push ( '\n' ) ;
716+ message. push_str ( & line) ;
717+ }
718+ message. push_str (
719+ "\n Run `claw --help` for the full list. If you meant to send a prompt literally, use `claw prompt <text>`." ,
720+ ) ;
721+ return Err ( message) ;
722+ }
723+ }
724+ Ok ( CliAction :: Prompt {
725+ prompt : rest. join ( " " ) ,
726+ model,
727+ output_format,
728+ allowed_tools,
729+ permission_mode,
730+ compact,
731+ base_commit,
732+ reasoning_effort : reasoning_effort. clone ( ) ,
733+ allow_broad_cwd,
734+ } )
735+ }
721736 }
722737}
723738
@@ -994,6 +1009,65 @@ fn suggest_closest_term<'a>(input: &str, candidates: &'a [&'a str]) -> Option<&'
9941009 ranked_suggestions ( input, candidates) . into_iter ( ) . next ( )
9951010}
9961011
1012+
1013+ fn suggest_similar_subcommand ( input : & str ) -> Option < Vec < String > > {
1014+ const KNOWN_SUBCOMMANDS : & [ & str ] = & [
1015+ "help" ,
1016+ "version" ,
1017+ "status" ,
1018+ "sandbox" ,
1019+ "doctor" ,
1020+ "state" ,
1021+ "dump-manifests" ,
1022+ "bootstrap-plan" ,
1023+ "agents" ,
1024+ "mcp" ,
1025+ "skills" ,
1026+ "system-prompt" ,
1027+ "acp" ,
1028+ "init" ,
1029+ "export" ,
1030+ "prompt" ,
1031+ ] ;
1032+
1033+ let normalized_input = input. to_ascii_lowercase ( ) ;
1034+ let mut ranked = KNOWN_SUBCOMMANDS
1035+ . iter ( )
1036+ . filter_map ( |candidate| {
1037+ let normalized_candidate = candidate. to_ascii_lowercase ( ) ;
1038+ let distance = levenshtein_distance ( & normalized_input, & normalized_candidate) ;
1039+ let prefix_match = common_prefix_len ( & normalized_input, & normalized_candidate) >= 4 ;
1040+ let substring_match = normalized_candidate. contains ( & normalized_input)
1041+ || normalized_input. contains ( & normalized_candidate) ;
1042+ ( ( distance <= 2 ) || prefix_match || substring_match)
1043+ . then_some ( ( distance, * candidate) )
1044+ } )
1045+ . collect :: < Vec < _ > > ( ) ;
1046+ ranked. sort_by ( |left, right| left. cmp ( right) . then_with ( || left. 1 . cmp ( right. 1 ) ) ) ;
1047+ ranked. dedup_by ( |left, right| left. 1 == right. 1 ) ;
1048+ let suggestions = ranked
1049+ . into_iter ( )
1050+ . map ( |( _, candidate) | candidate. to_string ( ) )
1051+ . take ( 3 )
1052+ . collect :: < Vec < _ > > ( ) ;
1053+ ( !suggestions. is_empty ( ) ) . then_some ( suggestions)
1054+ }
1055+
1056+ fn common_prefix_len ( left : & str , right : & str ) -> usize {
1057+ left. chars ( )
1058+ . zip ( right. chars ( ) )
1059+ . take_while ( |( l, r) | l == r)
1060+ . count ( )
1061+ }
1062+
1063+
1064+ fn looks_like_subcommand_typo ( input : & str ) -> bool {
1065+ !input. is_empty ( )
1066+ && input
1067+ . chars ( )
1068+ . all ( |ch| ch. is_ascii_alphabetic ( ) || ch == '-' )
1069+ }
1070+
9971071fn ranked_suggestions < ' a > ( input : & str , candidates : & ' a [ & ' a str ] ) -> Vec < & ' a str > {
9981072 let normalized_input = input. trim_start_matches ( '/' ) . to_ascii_lowercase ( ) ;
9991073 let mut ranked = candidates
@@ -9901,6 +9975,109 @@ mod tests {
99019975 assert ! ( report. contains( "Use /help" ) ) ;
99029976 }
99039977
9978+
9979+ #[ test]
9980+ fn typoed_doctor_subcommand_returns_did_you_mean_error ( ) {
9981+ let error = parse_args ( & [ "doctorr" . to_string ( ) ] ) . expect_err ( "doctorr should error" ) ;
9982+ assert ! ( error. contains( "unknown subcommand: doctorr." ) ) ;
9983+ assert ! ( error. contains( "Did you mean" ) ) ;
9984+ assert ! ( error. contains( "doctor" ) ) ;
9985+ }
9986+
9987+ #[ test]
9988+ fn typoed_skills_subcommand_returns_did_you_mean_error ( ) {
9989+ let error = parse_args ( & [ "skilsl" . to_string ( ) ] ) . expect_err ( "skilsl should error" ) ;
9990+ assert ! ( error. contains( "unknown subcommand: skilsl." ) ) ;
9991+ assert ! ( error. contains( "skills" ) ) ;
9992+ }
9993+
9994+ #[ test]
9995+ fn typoed_status_subcommand_returns_did_you_mean_error ( ) {
9996+ let error = parse_args ( & [ "statuss" . to_string ( ) ] ) . expect_err ( "statuss should error" ) ;
9997+ assert ! ( error. contains( "unknown subcommand: statuss." ) ) ;
9998+ assert ! ( error. contains( "status" ) ) ;
9999+ }
10000+
10001+ #[ test]
10002+ fn typoed_export_subcommand_returns_did_you_mean_error ( ) {
10003+ let error = parse_args ( & [ "exporrt" . to_string ( ) ] ) . expect_err ( "exporrt should error" ) ;
10004+ assert ! ( error. contains( "unknown subcommand: exporrt." ) ) ;
10005+ assert ! ( error. contains( "Did you mean" ) ) ;
10006+ assert ! ( error. contains( "export" ) ) ;
10007+ }
10008+
10009+ #[ test]
10010+ fn typoed_mcp_subcommand_returns_did_you_mean_error ( ) {
10011+ let error = parse_args ( & [ "mcpp" . to_string ( ) ] ) . expect_err ( "mcpp should error" ) ;
10012+ assert ! ( error. contains( "unknown subcommand: mcpp." ) ) ;
10013+ assert ! ( error. contains( "mcp" ) ) ;
10014+ }
10015+
10016+ #[ test]
10017+ fn multi_word_prompt_still_bypasses_subcommand_typo_guard ( ) {
10018+ assert_eq ! (
10019+ parse_args( & [
10020+ "hello" . to_string( ) ,
10021+ "world" . to_string( ) ,
10022+ "this" . to_string( ) ,
10023+ "is" . to_string( ) ,
10024+ "a" . to_string( ) ,
10025+ "prompt" . to_string( ) ,
10026+ ] )
10027+ . expect( "multi-word prompt should still parse" ) ,
10028+ CliAction :: Prompt {
10029+ prompt: "hello world this is a prompt" . to_string( ) ,
10030+ model: DEFAULT_MODEL . to_string( ) ,
10031+ output_format: CliOutputFormat :: Text ,
10032+ allowed_tools: None ,
10033+ permission_mode: crate :: default_permission_mode( ) ,
10034+ compact: false ,
10035+ base_commit: None ,
10036+ reasoning_effort: None ,
10037+ allow_broad_cwd: false ,
10038+ }
10039+ ) ;
10040+ }
10041+
10042+ #[ test]
10043+ fn prompt_subcommand_allows_literal_typo_word ( ) {
10044+ assert_eq ! (
10045+ parse_args( & [ "prompt" . to_string( ) , "doctorr" . to_string( ) ] )
10046+ . expect( "explicit prompt subcommand should allow literal typo word" ) ,
10047+ CliAction :: Prompt {
10048+ prompt: "doctorr" . to_string( ) ,
10049+ model: DEFAULT_MODEL . to_string( ) ,
10050+ output_format: CliOutputFormat :: Text ,
10051+ allowed_tools: None ,
10052+ permission_mode: PermissionMode :: DangerFullAccess ,
10053+ compact: false ,
10054+ base_commit: None ,
10055+ reasoning_effort: None ,
10056+ allow_broad_cwd: false ,
10057+ }
10058+ ) ;
10059+ }
10060+
10061+
10062+ #[ test]
10063+ fn punctuation_bearing_single_token_still_dispatches_to_prompt ( ) {
10064+ assert_eq ! (
10065+ parse_args( & [ "PARITY_SCENARIO:bash_permission_prompt_approved" . to_string( ) ] )
10066+ . expect( "scenario token should still dispatch to prompt" ) ,
10067+ CliAction :: Prompt {
10068+ prompt: "PARITY_SCENARIO:bash_permission_prompt_approved" . to_string( ) ,
10069+ model: DEFAULT_MODEL . to_string( ) ,
10070+ output_format: CliOutputFormat :: Text ,
10071+ allowed_tools: None ,
10072+ permission_mode: PermissionMode :: DangerFullAccess ,
10073+ compact: false ,
10074+ base_commit: None ,
10075+ reasoning_effort: None ,
10076+ allow_broad_cwd: false ,
10077+ }
10078+ ) ;
10079+ }
10080+
990410081 #[ test]
990510082 fn formats_namespaced_omc_slash_command_with_contract_guidance ( ) {
990610083 let report = format_unknown_slash_command_message ( "oh-my-claudecode:hud" ) ;
0 commit comments