diff --git a/ROADMAP.md b/ROADMAP.md index 205b9cfd..9f66fdfc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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). **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] diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index a3518264..531b0b0a 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -271,7 +271,9 @@ Run `claw --help` for usage." /// matching against the error messages produced throughout the CLI surface. fn classify_error_kind(message: &str) -> &'static str { // 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" } else if message.contains("Manifest source files are missing") { "missing_manifests" @@ -359,8 +361,9 @@ fn classify_error_kind(message: &str) -> &'static str { // #765: removed subcommands (login, logout) — hint contains migration guidance "removed_subcommand" } else if message.starts_with("unknown subcommand:") { - // #785: typo/unknown top-level subcommand (e.g. `claw dump` → did you mean dump-manifests?) - "unknown_subcommand" + // #785/#825: typo/unknown top-level subcommand (e.g. `claw dump` → did you mean dump-manifests?) + // Unified under command_not_found in #825. + "command_not_found" } else if message.starts_with("unexpected extra arguments") || message.starts_with("unexpected_extra_args:") { @@ -1375,17 +1378,23 @@ fn parse_args(args: &[String]) -> Result { ), 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) { - 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 `.", - ); - return Err(message); } + message.push_str( + "\nRun `claw --help` for the full list. If you meant to send a prompt literally, use `claw prompt `.", + ); + return Err(message); } // #147: guard empty/whitespace-only prompts at the fallthrough // path the same way `"prompt"` arm above does. Without this, @@ -12585,7 +12594,7 @@ mod tests { let typo_err = parse_args(&["sttaus".to_string()]) .expect_err("typo'd subcommand should be caught by #108 guard"); assert!( - typo_err.starts_with("unknown subcommand:"), + typo_err.contains("unknown subcommand:"), "typo guard should fire for 'sttaus', got: {typo_err}" ); // #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`"), "cli_parse" ); - // #785: unknown top-level subcommand (typo or unrecognised command) + // #785/#825: unknown top-level subcommand (typo or unrecognised command) assert_eq!( classify_error_kind("unknown subcommand: dump.\nDid you mean dump-manifests"), - "unknown_subcommand" + "command_not_found" // #825: unified from unknown_subcommand ); assert_eq!( classify_error_kind("unsupported ACP invocation. Use `claw acp`."), diff --git a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs index 6f1f3419..5ca1db35 100644 --- a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs +++ b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs @@ -2838,9 +2838,10 @@ fn unknown_subcommand_returns_typed_kind_785() { .find(|l| l.trim_start().starts_with('{')) .and_then(|l| serde_json::from_str(l).ok()) .expect("unknown subcommand should emit JSON error"); + // #825: unified under command_not_found (previously unknown_subcommand) assert_eq!( - j["error_kind"], "unknown_subcommand", - "unknown subcommand should return unknown_subcommand kind, got {:?}", + j["error_kind"], "command_not_found", + "unknown subcommand should return command_not_found kind (#825), got {:?}", j["error_kind"] ); // 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)" ); } + +// #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)"); +}