Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
31 changes: 20 additions & 11 deletions rust/crates/rusty-claude-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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:")
{
Expand Down Expand Up @@ -1375,17 +1378,23 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
),
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 <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
// path the same way `"prompt"` arm above does. Without this,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`."),
Expand Down
78 changes: 76 additions & 2 deletions rust/crates/rusty-claude-cli/tests/output_format_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)");
}
Loading