mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-26 10:34:59 +08:00
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:
@@ -707,7 +707,21 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
|||||||
reasoning_effort,
|
reasoning_effort,
|
||||||
allow_broad_cwd,
|
allow_broad_cwd,
|
||||||
),
|
),
|
||||||
_other => Ok(CliAction::Prompt {
|
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(" "),
|
prompt: rest.join(" "),
|
||||||
model,
|
model,
|
||||||
output_format,
|
output_format,
|
||||||
@@ -717,7 +731,8 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
|||||||
base_commit,
|
base_commit,
|
||||||
reasoning_effort: reasoning_effort.clone(),
|
reasoning_effort: reasoning_effort.clone(),
|
||||||
allow_broad_cwd,
|
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()
|
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> {
|
fn ranked_suggestions<'a>(input: &str, candidates: &'a [&'a str]) -> Vec<&'a str> {
|
||||||
let normalized_input = input.trim_start_matches('/').to_ascii_lowercase();
|
let normalized_input = input.trim_start_matches('/').to_ascii_lowercase();
|
||||||
let mut ranked = candidates
|
let mut ranked = candidates
|
||||||
@@ -9901,6 +9975,109 @@ mod tests {
|
|||||||
assert!(report.contains("Use /help"));
|
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]
|
#[test]
|
||||||
fn formats_namespaced_omc_slash_command_with_contract_guidance() {
|
fn formats_namespaced_omc_slash_command_with_contract_guidance() {
|
||||||
let report = format_unknown_slash_command_message("oh-my-claudecode:hud");
|
let report = format_unknown_slash_command_message("oh-my-claudecode:hud");
|
||||||
|
|||||||
Reference in New Issue
Block a user