Skip to content

Commit f3f6643

Browse files
feat: ultraworkers#108 add did-you-mean guard for subcommand typos (prevents silent LLM dispatch)
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1 parent 883cef1 commit f3f6643

1 file changed

Lines changed: 188 additions & 11 deletions

File tree

  • rust/crates/rusty-claude-cli/src

rust/crates/rusty-claude-cli/src/main.rs

Lines changed: 188 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
"\nRun `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+
9971071
fn 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

Comments
 (0)