diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 657f3f62..cfa2f56f 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -6433,6 +6433,26 @@ impl LiveCli { if action.as_deref() == Some("list") { if let Some(filter) = target.as_deref() { if filter.starts_with('-') { + if matches!(output_format, CliOutputFormat::Json) { + // ROADMAP #817: this is a handled local inventory parse error. + // Keep it on stdout in JSON mode so `plugins list --` matches the + // sibling JSON inventory/local surfaces instead of falling through + // to the top-level stderr error path. + let obj = json!({ + "type": "error", + "kind": "plugin", + "action": "list", + "status": "error", + "error_kind": "cli_parse", + "error": format!("unknown option for `claw plugins list`: {filter}"), + "message": format!("unknown option for `claw plugins list`: {filter}"), + "unexpected": filter, + "hint": "Usage: claw plugins list []\nFilters are id substrings, not flags.", + "exit_code": 1, + }); + println!("{}", serde_json::to_string_pretty(&obj)?); + std::process::exit(1); + } return Err(format!( "unknown option for `claw plugins list`: {filter}\nUsage: claw plugins list []\nFilters are id substrings, not flags." ).into()); @@ -6513,20 +6533,6 @@ impl LiveCli { } } else if is_list_action { if let Some(filter) = target { - // #793: flag-shaped tokens silently became substring filters on - // plugins list, returning empty success instead of an error. - if filter.starts_with('-') { - let obj = json!({ - "kind": "plugin", - "action": "list", - "status": "error", - "error_kind": "unknown_option", - "unexpected": filter, - "hint": "Usage: claw plugins list []\nFilters are id substrings, not flags.", - }); - println!("{}", serde_json::to_string_pretty(&obj)?); - std::process::exit(1); - } let needle = filter.to_lowercase(); payload .plugins 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 fd185158..69efe561 100644 --- a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs +++ b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs @@ -3338,11 +3338,12 @@ fn skills_list_flag_shaped_filter_returns_unknown_option_792() { } #[test] -fn plugins_list_flag_shaped_filter_returns_unknown_option_793() { +fn plugins_list_flag_shaped_filter_returns_cli_parse_on_stdout_793_817() { // #793: `claw plugins list --bogus-flag` silently returned status:"ok" with empty // plugins list instead of an error. The list filter branch in print_plugins treated // "--bogus-flag" as an id substring filter and found no matches, producing a false-positive. - // Fix: added flag-prefix guard; filter tokens starting with "-" now return unknown_option. + // #817: in JSON mode, handled local parse errors now return error_kind:"cli_parse" + // on stdout with stderr empty. let root = unique_temp_dir("plugins-list-flag-793"); fs::create_dir_all(&root).expect("temp dir"); std::process::Command::new("git") @@ -3366,19 +3367,16 @@ fn plugins_list_flag_shaped_filter_returns_unknown_option_793() { !output.status.success(), "plugins list --unknown-flag must exit non-zero (#793)" ); - // #803: the early flag guard now returns Err before the JSON branch, - // so the error envelope goes to stderr via the main error handler. - let stderr = String::from_utf8_lossy(&output.stderr); - let j: serde_json::Value = stderr - .lines() - .find(|l| l.trim_start().starts_with('{')) - .and_then(|l| serde_json::from_str(l).ok()) - .expect("plugins list flag-filter should emit valid JSON on stderr"); + assert_eq!(output.status.code(), Some(1), "exit code must be 1 (#817)"); + // #817: handled JSON local parse errors stay on stdout, with stderr empty. assert!( - j["error_kind"] == "unknown_option" || j["error_kind"] == "cli_parse", - "plugins list flag-shaped filter must return typed error, got {:?}", - j["error_kind"] + output.stderr.is_empty(), + "plugins list flag-filter JSON error must keep stderr empty (#817), got: {}", + String::from_utf8_lossy(&output.stderr) ); + let j: serde_json::Value = serde_json::from_slice(&output.stdout) + .expect("plugins list flag-filter should emit valid JSON on stdout"); + assert_eq!(j["error_kind"], "cli_parse"); assert_eq!(j["status"], "error"); let h = j["hint"] .as_str() @@ -3697,6 +3695,62 @@ fn plugins_extra_args_have_non_null_hint_797() { ); } +#[test] +fn plugins_list_trailing_dash_json_error_uses_stdout_817() { + // ROADMAP #817: JSON inventory/local parse errors are machine-readable on + // stdout. `plugins list --` used to route through the top-level error path, + // leaving stdout empty and writing the JSON envelope to stderr. + let root = unique_temp_dir("plugins-list-dash-817"); + fs::create_dir_all(&root).expect("temp dir"); + + let output = run_claw( + &root, + &["--output-format", "json", "plugins", "list", "--"], + &[], + ); + assert!( + !output.status.success(), + "plugins list -- must exit non-zero (#817)" + ); + assert_eq!(output.status.code(), Some(1), "exit code must be 1 (#817)"); + assert!( + output.stderr.is_empty(), + "JSON parse error must keep stderr empty (#817), got: {}", + String::from_utf8_lossy(&output.stderr) + ); + let j: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("stdout should be JSON error (#817)"); + assert_eq!(j["kind"], "plugin"); + assert_eq!(j["action"], "list"); + assert_eq!(j["status"], "error"); + assert_eq!(j["error_kind"], "cli_parse"); + assert_eq!(j["unexpected"], "--"); +} + +#[test] +fn plugins_list_trailing_dash_text_error_stays_on_stderr_817() { + let root = unique_temp_dir("plugins-list-dash-text-817"); + fs::create_dir_all(&root).expect("temp dir"); + + let output = run_claw(&root, &["plugins", "list", "--"], &[]); + assert!( + !output.status.success(), + "plugins list -- text mode must exit non-zero (#817)" + ); + assert_eq!(output.status.code(), Some(1), "exit code must be 1 (#817)"); + assert!( + output.stdout.is_empty(), + "text parse error should not emit stdout (#817), got: {}", + String::from_utf8_lossy(&output.stdout) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("[error-kind: cli_parse]"), "{stderr}"); + assert!( + stderr.contains("unknown option for `claw plugins list`: --"), + "{stderr}" + ); +} + #[test] fn empty_prompt_has_non_null_hint_798() { // #798: `claw --output-format json ""` returned empty_prompt + hint:null.