feat: #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>
This commit is contained in:
YeonGyu-Kim 2026-04-21 15:37:58 +09:00
parent 883cef1a26
commit f3f6643fb9

View File

@ -707,17 +707,32 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
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 <text>`.",
);
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<Vec<String>> {
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::<Vec<_>>();
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::<Vec<_>>();
(!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");