mirror of
https://github.com/ultraworkers/claw-code.git
synced 2026-05-30 03:35:20 +08:00
fix: unknown single-word subcommand emits command_not_found instead of missing_credentials (#825)
When looks_like_subcommand_typo fires on a single word with no close fuzzy matches, the fallthrough reached CliAction::Prompt → provider startup → misleading missing_credentials error. Fix: always return Err with command_not_found: prefix from the typo guard (with or without suggestions). Added command_not_found classifier arm in classify_error_kind. Unified existing unknown_subcommand kind under command_not_found in #825. Three new regression tests in output_format_contract.rs: - unknown_subcommand_json_emits_command_not_found - unknown_subcommand_text_emits_command_not_found_on_stderr - unknown_subcommand_typo_with_suggestions_json_emits_command_not_found Updated pre-existing unit test assertion (starts_with → contains) and classifier unit test (unknown_subcommand → command_not_found). 572 tests pass, 1 pre-existing worker_boot failure unrelated.
This commit is contained in:
parent
de7edd5bb1
commit
70d64be033
@ -7870,3 +7870,9 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed)
|
|||||||
**Required fix shape.** Add a global `SUPPRESS_CONFIG_WARNINGS_STDERR: AtomicBool` flag in `config.rs`. Set it to `true` immediately when `--output-format json` is detected in `main.rs` (before any settings load). Gate `emit_config_warning_once` on that flag. Text-mode invocations continue to print to stderr; JSON-mode invocations silently suppress the prose warning (warnings remain available via structured `config` output).
|
**Required fix shape.** Add a global `SUPPRESS_CONFIG_WARNINGS_STDERR: AtomicBool` flag in `config.rs`. Set it to `true` immediately when `--output-format json` is detected in `main.rs` (before any settings load). Gate `emit_config_warning_once` on that flag. Text-mode invocations continue to print to stderr; JSON-mode invocations silently suppress the prose warning (warnings remain available via structured `config` output).
|
||||||
|
|
||||||
**Acceptance.** With deprecated `enabledPlugins` in `~/.claw/settings.json`, all JSON-mode surfaces (`status`, `sandbox`, `system-prompt`, `mcp list`, `skills list`, `agents list`, plus all `--resume /config*` forms) exit with empty stderr. Text-mode output is unchanged. [SCOPE: claw-code]
|
**Acceptance.** With deprecated `enabledPlugins` in `~/.claw/settings.json`, all JSON-mode surfaces (`status`, `sandbox`, `system-prompt`, `mcp list`, `skills list`, `agents list`, plus all `--resume /config*` forms) exit with empty stderr. Text-mode output is unchanged. [SCOPE: claw-code]
|
||||||
|
|
||||||
|
825. **Unknown single-word subcommand falls through to provider startup and surfaces `missing_credentials` instead of `command_not_found`** — dogfooded 2026-05-29 14:00 on `main` `de7edd5b`. `claw foobar` (and `claw --output-format json foobar`) hit the `looks_like_subcommand_typo` guard, which checked for close fuzzy matches but fell through silently when no suggestions matched. The fallthrough routed to `CliAction::Prompt`, triggering Anthropic provider startup and a misleading `missing_credentials` error (or burning API tokens if credentials were present). The `command_not_found` error kind existed in the registry but was never emitted by this path.
|
||||||
|
|
||||||
|
**Required fix shape.** When `looks_like_subcommand_typo` fires on a single-word positional arg with no close suggestions, emit `command_not_found:` rather than falling through. Add `command_not_found:` prefix classifier to `classify_error_kind`. Result: clean `{"error_kind":"command_not_found",...}` envelope on stdout (JSON mode), error on stderr (text mode), zero provider startup.
|
||||||
|
|
||||||
|
**Acceptance.** `claw --output-format json foobar` exits 1, stdout `error_kind:"command_not_found"`, stderr empty, no Anthropic call. Typo with suggestions (`claw statuz`) also gets `command_not_found` plus `hint` with suggestions. [SCOPE: claw-code]
|
||||||
|
|||||||
@ -271,7 +271,9 @@ Run `claw --help` for usage."
|
|||||||
/// matching against the error messages produced throughout the CLI surface.
|
/// matching against the error messages produced throughout the CLI surface.
|
||||||
fn classify_error_kind(message: &str) -> &'static str {
|
fn classify_error_kind(message: &str) -> &'static str {
|
||||||
// Check specific patterns first (more specific before generic)
|
// Check specific patterns first (more specific before generic)
|
||||||
if message.contains("missing Anthropic credentials") {
|
if message.starts_with("command_not_found:") {
|
||||||
|
"command_not_found"
|
||||||
|
} else if message.contains("missing Anthropic credentials") {
|
||||||
"missing_credentials"
|
"missing_credentials"
|
||||||
} else if message.contains("Manifest source files are missing") {
|
} else if message.contains("Manifest source files are missing") {
|
||||||
"missing_manifests"
|
"missing_manifests"
|
||||||
@ -359,8 +361,9 @@ fn classify_error_kind(message: &str) -> &'static str {
|
|||||||
// #765: removed subcommands (login, logout) — hint contains migration guidance
|
// #765: removed subcommands (login, logout) — hint contains migration guidance
|
||||||
"removed_subcommand"
|
"removed_subcommand"
|
||||||
} else if message.starts_with("unknown subcommand:") {
|
} else if message.starts_with("unknown subcommand:") {
|
||||||
// #785: typo/unknown top-level subcommand (e.g. `claw dump` → did you mean dump-manifests?)
|
// #785/#825: typo/unknown top-level subcommand (e.g. `claw dump` → did you mean dump-manifests?)
|
||||||
"unknown_subcommand"
|
// Unified under command_not_found in #825.
|
||||||
|
"command_not_found"
|
||||||
} else if message.starts_with("unexpected extra arguments")
|
} else if message.starts_with("unexpected extra arguments")
|
||||||
|| message.starts_with("unexpected_extra_args:")
|
|| message.starts_with("unexpected_extra_args:")
|
||||||
{
|
{
|
||||||
@ -1375,17 +1378,23 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
|||||||
),
|
),
|
||||||
other => {
|
other => {
|
||||||
if rest.len() == 1 && looks_like_subcommand_typo(other) {
|
if rest.len() == 1 && looks_like_subcommand_typo(other) {
|
||||||
|
// #825: always emit a command_not_found error for
|
||||||
|
// single-word all-alpha/dash tokens that don't match any
|
||||||
|
// known subcommand — with or without close suggestions.
|
||||||
|
// Previously, no-suggestion cases fell through silently to
|
||||||
|
// CliAction::Prompt and triggered a misleading
|
||||||
|
// `missing_credentials` error after provider startup.
|
||||||
|
let mut message = format!("command_not_found: unknown subcommand: {other}.");
|
||||||
if let Some(suggestions) = suggest_similar_subcommand(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) {
|
if let Some(line) = render_suggestion_line("Did you mean", &suggestions) {
|
||||||
message.push('\n');
|
message.push('\n');
|
||||||
message.push_str(&line);
|
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);
|
|
||||||
}
|
}
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
// #147: guard empty/whitespace-only prompts at the fallthrough
|
// #147: guard empty/whitespace-only prompts at the fallthrough
|
||||||
// path the same way `"prompt"` arm above does. Without this,
|
// path the same way `"prompt"` arm above does. Without this,
|
||||||
@ -12585,7 +12594,7 @@ mod tests {
|
|||||||
let typo_err = parse_args(&["sttaus".to_string()])
|
let typo_err = parse_args(&["sttaus".to_string()])
|
||||||
.expect_err("typo'd subcommand should be caught by #108 guard");
|
.expect_err("typo'd subcommand should be caught by #108 guard");
|
||||||
assert!(
|
assert!(
|
||||||
typo_err.starts_with("unknown subcommand:"),
|
typo_err.contains("unknown subcommand:"),
|
||||||
"typo guard should fire for 'sttaus', got: {typo_err}"
|
"typo guard should fire for 'sttaus', got: {typo_err}"
|
||||||
);
|
);
|
||||||
// #148: `--model` flag must be captured as model_flag_raw so status
|
// #148: `--model` flag must be captured as model_flag_raw so status
|
||||||
@ -13240,10 +13249,10 @@ mod tests {
|
|||||||
classify_error_kind("unrecognized argument `--foo` for subcommand `doctor`"),
|
classify_error_kind("unrecognized argument `--foo` for subcommand `doctor`"),
|
||||||
"cli_parse"
|
"cli_parse"
|
||||||
);
|
);
|
||||||
// #785: unknown top-level subcommand (typo or unrecognised command)
|
// #785/#825: unknown top-level subcommand (typo or unrecognised command)
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
classify_error_kind("unknown subcommand: dump.\nDid you mean dump-manifests"),
|
classify_error_kind("unknown subcommand: dump.\nDid you mean dump-manifests"),
|
||||||
"unknown_subcommand"
|
"command_not_found" // #825: unified from unknown_subcommand
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
classify_error_kind("unsupported ACP invocation. Use `claw acp`."),
|
classify_error_kind("unsupported ACP invocation. Use `claw acp`."),
|
||||||
|
|||||||
@ -2838,9 +2838,10 @@ fn unknown_subcommand_returns_typed_kind_785() {
|
|||||||
.find(|l| l.trim_start().starts_with('{'))
|
.find(|l| l.trim_start().starts_with('{'))
|
||||||
.and_then(|l| serde_json::from_str(l).ok())
|
.and_then(|l| serde_json::from_str(l).ok())
|
||||||
.expect("unknown subcommand should emit JSON error");
|
.expect("unknown subcommand should emit JSON error");
|
||||||
|
// #825: unified under command_not_found (previously unknown_subcommand)
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
j["error_kind"], "unknown_subcommand",
|
j["error_kind"], "command_not_found",
|
||||||
"unknown subcommand should return unknown_subcommand kind, got {:?}",
|
"unknown subcommand should return command_not_found kind (#825), got {:?}",
|
||||||
j["error_kind"]
|
j["error_kind"]
|
||||||
);
|
);
|
||||||
// hint should point at the suggestion and/or --help
|
// hint should point at the suggestion and/or --help
|
||||||
@ -3865,3 +3866,76 @@ fn diff_non_git_dir_has_error_kind_and_hint_801() {
|
|||||||
"diff non-git must have message field (#801)"
|
"diff non-git must have message field (#801)"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #825: unknown single-word subcommand must return command_not_found, not
|
||||||
|
// fall through to missing_credentials after provider startup.
|
||||||
|
#[test]
|
||||||
|
fn unknown_subcommand_json_emits_command_not_found() {
|
||||||
|
let root = unique_temp_dir("unknown-cmd-json-825");
|
||||||
|
std::fs::create_dir_all(&root).expect("create temp dir");
|
||||||
|
let output = run_claw(&root, &["--output-format", "json", "foobar"], &[]);
|
||||||
|
assert_eq!(
|
||||||
|
output.status.code(),
|
||||||
|
Some(1),
|
||||||
|
"unknown subcommand should exit 1"
|
||||||
|
);
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
assert!(
|
||||||
|
!stdout.trim().is_empty(),
|
||||||
|
"unknown subcommand JSON envelope must be on stdout"
|
||||||
|
);
|
||||||
|
let j: serde_json::Value =
|
||||||
|
serde_json::from_str(stdout.trim()).expect("stdout must be parseable JSON (#825)");
|
||||||
|
assert_eq!(
|
||||||
|
j["error_kind"], "command_not_found",
|
||||||
|
"unknown subcommand must emit command_not_found, not missing_credentials (#825): {j}"
|
||||||
|
);
|
||||||
|
assert_eq!(j["status"], "error");
|
||||||
|
assert!(
|
||||||
|
stderr.is_empty(),
|
||||||
|
"unknown subcommand in JSON mode must have empty stderr (#825), got: {stderr:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_subcommand_text_emits_command_not_found_on_stderr() {
|
||||||
|
let root = unique_temp_dir("unknown-cmd-text-825");
|
||||||
|
std::fs::create_dir_all(&root).expect("create temp dir");
|
||||||
|
let output = run_claw(&root, &["foobar"], &[]);
|
||||||
|
assert_eq!(
|
||||||
|
output.status.code(),
|
||||||
|
Some(1),
|
||||||
|
"unknown subcommand should exit 1"
|
||||||
|
);
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let _ = stdout;
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
assert!(
|
||||||
|
stderr.contains("command_not_found"),
|
||||||
|
"text mode unknown subcommand must mention command_not_found on stderr (#825), got: {stderr:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!stderr.contains("missing_credentials"),
|
||||||
|
"text mode unknown subcommand must not show missing_credentials (#825)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_subcommand_typo_with_suggestions_json_emits_command_not_found() {
|
||||||
|
let root = unique_temp_dir("unknown-cmd-typo-825");
|
||||||
|
std::fs::create_dir_all(&root).expect("create temp dir");
|
||||||
|
let output = run_claw(&root, &["--output-format", "json", "statuz"], &[]);
|
||||||
|
assert_eq!(output.status.code(), Some(1));
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
let j: serde_json::Value =
|
||||||
|
serde_json::from_str(stdout.trim()).expect("typo envelope must be valid JSON (#825)");
|
||||||
|
assert_eq!(j["error_kind"], "command_not_found", "#825 typo: {j}");
|
||||||
|
let hint = j["hint"].as_str().unwrap_or("");
|
||||||
|
assert!(
|
||||||
|
hint.contains("status") || hint.contains("state"),
|
||||||
|
"typo hint should suggest status/state, got: {hint:?}"
|
||||||
|
);
|
||||||
|
assert!(stderr.is_empty(), "typo JSON must have empty stderr (#825)");
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user