diff --git a/ROADMAP.md b/ROADMAP.md index abf09408..9689735a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -7715,3 +7715,5 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed) 773. **Config deprecation warnings only emitted as unstructured stderr text in `--output-format json` mode** — dogfooded 2026-05-27 on `212f0b2a`. `emit_config_warning_once()` always wrote to stderr regardless of output format, causing JSON-mode callers to receive an unexpected `warning: ...` text line on stderr before the JSON object. Callers had to implement ad-hoc stripping. Fix: added `ConfigLoader::load_collecting_warnings()` method that returns `(RuntimeConfig, Vec)` so callers can surface warnings structurally; `render_config_json()` now uses this and includes a `warnings: []` array in the config JSON envelope. Existing `load()` path unchanged (still emits to stderr for text-mode callers). 36 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori startup-friction probe on `212f0b2a`, 2026-05-27. 774. **`claw agents bogus`, `claw plugins bogus`, `claw mcp bogus` returned `hint: null`** — dogfooded 2026-05-27 on `727a1ea4`. Three "unknown subcommand" envelopes had `error_kind` correctly set but `hint: null`: (1) `unknown_agents_subcommand` — both text and JSON handler emitted single-line error with inline remediation after `.`, no `\n`; (2) `unknown_plugins_action` — same, period-delimited remediation; (3) `unknown_mcp_action` — `render_mcp_usage_json` never included a `hint` field at all. Fixes: (1)+(2) added `\n` before remediation suffix in `commands/src/lib.rs`; (3) added `hint` field to `render_mcp_usage_json` pointing at supported actions. All three now return non-null `hint`. 36 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori envelope-consistency probe on `727a1ea4`, 2026-05-27. + +775. **Missing integration tests for #769-#771 interactive-only guards and #774 hint fields** — dogfooded 2026-05-27 on `c760a49c`. Fixes #769-#771 (session/cost/clear/memory/ultraplan/model/usage/stats/fork interactive-only guards) and #774 (agents/plugins/mcp unknown-subcommand hints) had no integration tests — a regression in any of those 10+ match arms would go undetected. Also: classify_error_kind unit test for `unknown_agents_subcommand` used the old single-line format string, not the `\n`-delimited format emitted after #774. Fixed: (1) updated unit test string to match new `\n`-delimited emission; (2) added `agents_plugins_mcp_unknown_subcommand_have_hint_774` asserting `error_kind` + non-null `hint` for all three; (3) added `interactive_only_guard_batch_769_to_771` asserting `interactive_only` + non-null `hint` for 10 cases. 38 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori test-coverage sweep on `c760a49c`, 2026-05-27. diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index a5e64aaa..bffbf190 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -12965,8 +12965,9 @@ mod tests { classify_error_kind("slash command /compact is interactive-only"), "interactive_only" ); + // #774: agents now uses \n-delimited format — update test string to match real emission assert_eq!( - classify_error_kind("unknown agents subcommand: bogus. Supported: list, show, help"), + classify_error_kind("unknown agents subcommand: bogus.\nSupported: list, show, help"), "unknown_agents_subcommand" ); assert_eq!( 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 4e79699f..dbf39c3b 100644 --- a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs +++ b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs @@ -2076,3 +2076,142 @@ fn slash_only_verbs_with_args_return_interactive_only_not_credentials_770() { ); } } + +#[test] +fn agents_plugins_mcp_unknown_subcommand_have_hint_774() { + // #774: `claw agents bogus`, `claw plugins bogus`, `claw mcp bogus` returned + // hint:null despite having correct error_kind. Fixed by adding \n delimiter + // to error strings in commands/src/lib.rs and explicit hint in mcp JSON envelope. + let root = unique_temp_dir("unknown-subcommands-774"); + fs::create_dir_all(&root).expect("temp dir should exist"); + + // agents bogus + { + let output = run_claw(&root, &["--output-format", "json", "agents", "bogus"], &[]); + assert!(!output.status.success(), "agents bogus should fail"); + let stderr = String::from_utf8_lossy(&output.stderr); + let json_line = stderr + .lines() + .find(|l| l.trim_start().starts_with('{')) + .expect("agents bogus should emit JSON error"); + let parsed: serde_json::Value = serde_json::from_str(json_line).unwrap(); + assert_eq!(parsed["error_kind"], "unknown_agents_subcommand"); + let hint = parsed["hint"].as_str().unwrap_or(""); + assert!( + !hint.is_empty(), + "agents bogus hint must be non-null (#774)" + ); + assert!( + hint.contains("list") || hint.contains("show") || hint.contains("help"), + "agents bogus hint must mention supported actions, got: {hint:?}" + ); + } + + // plugins bogus + { + let output = run_claw(&root, &["--output-format", "json", "plugins", "bogus"], &[]); + assert!(!output.status.success(), "plugins bogus should fail"); + let stderr = String::from_utf8_lossy(&output.stderr); + let json_line = stderr + .lines() + .find(|l| l.trim_start().starts_with('{')) + .expect("plugins bogus should emit JSON error"); + let parsed: serde_json::Value = serde_json::from_str(json_line).unwrap(); + assert_eq!(parsed["error_kind"], "unknown_plugins_action"); + let hint = parsed["hint"].as_str().unwrap_or(""); + assert!( + !hint.is_empty(), + "plugins bogus hint must be non-null (#774)" + ); + } + + // mcp bogus + { + let output = run_claw(&root, &["--output-format", "json", "mcp", "bogus"], &[]); + assert!(!output.status.success(), "mcp bogus should fail"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let json_str = if stdout.trim().starts_with('{') { + stdout.to_string() + } else { + stderr + .lines() + .find(|l| l.trim_start().starts_with('{')) + .unwrap_or("") + .to_string() + }; + let parsed: serde_json::Value = + serde_json::from_str(json_str.trim()).expect("mcp bogus should emit JSON"); + assert_eq!(parsed["error_kind"], "unknown_mcp_action"); + let hint = parsed["hint"].as_str().unwrap_or(""); + assert!(!hint.is_empty(), "mcp bogus hint must be non-null (#774)"); + } +} + +#[test] +fn interactive_only_guard_batch_769_to_771() { + // #769-#771: a sweep of slash-only verbs with args that previously fell to + // CliAction::Prompt hitting the credential gate. All must return + // error_kind:interactive_only (not missing_credentials) with non-null hint. + let root = unique_temp_dir("interactive-only-batch-769-771"); + fs::create_dir_all(&root).expect("temp dir should exist"); + // Need a git repo for some subcommands + std::process::Command::new("git") + .args(["init", "-q"]) + .current_dir(&root) + .output() + .ok(); + + let cases: &[&[&str]] = &[ + // #769: session with unknown subcommand + &["session", "bogus"], + &["session", "nuke"], + // #770: slash-only verbs with trailing args + &["cost", "breakdown"], + &["clear", "--force"], + &["memory", "reset"], + &["ultraplan", "bogus"], + &["model", "opus", "extra"], + // #771: usage/stats/fork + &["usage", "extra"], + &["stats", "extra"], + &["fork", "newbranch"], + ]; + + for args in cases { + let full_args: Vec<&str> = std::iter::once("--output-format") + .chain(std::iter::once("json")) + .chain(args.iter().copied()) + .collect(); + let output = run_claw(&root, &full_args, &[]); + assert!( + !output.status.success(), + "claw {} should exit non-zero", + args.join(" ") + ); + let stderr = String::from_utf8_lossy(&output.stderr); + let json_line = stderr + .lines() + .find(|l| l.trim_start().starts_with('{')) + .unwrap_or_else(|| { + panic!( + "claw {} should emit JSON, got stderr: {stderr}", + args.join(" ") + ) + }); + let parsed: serde_json::Value = serde_json::from_str(json_line).unwrap(); + assert_eq!( + parsed["error_kind"], + "interactive_only", + "claw {} must return interactive_only, got {:?}", + args.join(" "), + parsed["error_kind"] + ); + let hint = parsed["hint"].as_str().unwrap_or(""); + assert!( + !hint.is_empty(), + "claw {} must have non-null hint", + args.join(" ") + ); + } +}