mirror of
https://github.com/ultraworkers/claw-code.git
synced 2026-04-24 13:08:11 +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:
parent
883cef1a26
commit
f3f6643fb9
@ -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");
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user