From 41678eb097b924473b7430780705d4855529c6f9 Mon Sep 17 00:00:00 2001 From: bellman Date: Thu, 4 Jun 2026 12:47:24 +0900 Subject: [PATCH] fix: type output format selection --- ROADMAP.md | 2 +- USAGE.md | 5 + rust/README.md | 3 +- rust/crates/rusty-claude-cli/src/main.rs | 223 ++++++++++++++++-- .../tests/output_format_contract.rs | 139 +++++++++++ 5 files changed, 353 insertions(+), 19 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c21a25a1..96589fe7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6374,7 +6374,7 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed) 432. **DONE — `--allowedTools` uses a canonical snake_case registry with typed diagnostics and documented aliases** — fixed 2026-06-04 in `fix: type allowed tools validation`. `GlobalToolRegistry::normalize_allowed_tools` now normalizes built-in, plugin, runtime, and MCP wrapper tool names to canonical snake_case allow-list entries while still accepting documented aliases such as `read`, `Read`, and legacy provider-facing names like `WebFetch`/`MCPTool`. Provider tool definitions and CLI/subagent executors compare against canonical names, so aliases do not break internal dispatch. `claw --allowedTools status --output-format json` now refuses to consume `status` as a value and emits typed `missing_argument` JSON with `argument:"--allowedTools"`; unsupported names emit typed `invalid_tool_name` JSON with `tool_name`, `available`, and `tool_aliases`. `status --output-format json` exposes `allowed_tools.available` and `allowed_tools.aliases`, and help/usage docs describe canonical names plus aliases. Regression coverage: `parses_allowed_tools_flags_with_aliases_and_lists`, `rejects_allowed_tools_followed_by_subcommand_or_flag_432`, `rejects_unknown_allowed_tools`, `allowed_tools_errors_have_typed_json_and_alias_map_432`, `allowed_tools_normalize_to_canonical_snake_case_and_aliases_432`, status JSON alias assertions, MCP wrapper normalization coverage, and classifier coverage for `invalid_tool_name`. -433. **Repeated `--output-format` flag silently takes the last value without warning — `claw --output-format json --output-format text status` produces text output, no signal that the prior `json` was overridden; sibling: `--output-format` value is case-sensitive (`JSON` rejected as `kind:"unknown"`); sibling: no `CLAW_OUTPUT_FORMAT` env var for default format override** — dogfooded 2026-05-11 by Jobdori on `ce39d5c5` in response to Clawhip pinpoint nudge at `1503290592556220488`. Reproduction: `claw --output-format json --output-format text status` returns the text-format `Status\n Model claude-opus-4-6...` table — the first `--output-format json` was silently overridden. No warning, no `format_overridden:true` field, no stderr message. Scripts that compose flag arrays from multiple sources (`flags=("${BASE_FLAGS[@]}" --output-format json)` while `BASE_FLAGS` already contains `--output-format text`) silently get the wrong format. **Three sibling findings in same probe:** (a) **case-sensitivity drift**: `claw --output-format JSON status` returns `{"error":"unsupported value for --output-format: JSON (expected text or json)","kind":"unknown"}` — error message tells user to use lowercase `json` but doesn't accept the uppercase form that users often type from muscle memory. Most CLI flag-value validators (cargo, kubectl, gh) are case-insensitive for enum values or accept both forms with normalization. (b) **`kind:"unknown"` for invalid format value**: same catch-all bucket bug as #422/#423/#424/#428/#430/#431/#432 — should be `kind:"invalid_output_format"` with `value:` and `expected:["text","json"]` fields. (c) **no env-var default for output format**: `CLAW_OUTPUT_FORMAT=json claw status` silently ignored — no env override for the global default, forcing scripts to repeat `--output-format json` on every invocation. Other major CLIs honor `KUBECTL_OUTPUT=`, `AWS_DEFAULT_OUTPUT=`, `GH_NO_PROMPT=` etc. (d) **silently-ignored env vars `CLAW_LOG`/`RUST_LOG`**: no env-based log level control surfaced in `claw doctor` — debug logging requires undocumented `RUST_LOG=` (Rust convention) but `claw --help` doesn't mention either. **Required fix shape:** (a) repeated `--output-format` (or any flag that takes a value, not a count flag) emits a warning to stderr (`warning: --output-format specified multiple times; using last value 'text'`) and adds a `format_source:"flag", format_overridden:[]` field to the JSON envelope; (b) accept case-insensitive enum values for `--output-format` (`JSON`, `Json`, `json` all work), document the canonical lowercase form in `--help`; (c) emit `kind:"invalid_output_format"` (not `kind:"unknown"`) when value is invalid; (d) accept `CLAW_OUTPUT_FORMAT` env var as the default for `--output-format`, with flag-overrides-env precedence documented; (e) document `RUST_LOG` / `CLAW_LOG` in `--help` or doctor output as the log-level env vars; (f) regression test: repeated flag emits stderr warning + JSON metadata field; case-insensitive enum accepts all three casings; env-var default is honored when flag is absent. **Why this matters:** scripts that compose flag arrays from multiple sources (CI envs + per-invocation flags) silently get the wrong output format. Case-sensitive enum values trip up users typing from muscle memory. Missing env-var defaults force per-invocation flag repetition. Cross-references #422/#423/#424/#428/#430/#431/#432 (`kind:"unknown"` catch-all cluster). Source: Jobdori live dogfood, `ce39d5c5`, 2026-05-11. +433. **DONE — `--output-format` selection is typed, case-insensitive, env-configurable, and auditable** — fixed 2026-06-04 in `fix: type output format selection`. `CliOutputFormat::parse` now accepts `text`/`json` in any casing, `CLAW_OUTPUT_FORMAT` seeds the default output format when no CLI output-format flag is present, explicit flags override the env default, and repeated flags emit `warning: --output-format specified multiple times; using last value '...'`. `status --output-format json` exposes `format_source`, `format_raw`, and `format_overridden`; invalid values return typed `invalid_output_format` JSON with `value`, `expected:["text","json"]`, and a recovery hint instead of `kind:"unknown"`. Top-level help documents `CLAW_OUTPUT_FORMAT`, `CLAW_LOG`, and `RUST_LOG`, and doctor system JSON surfaces those env values. Regression coverage: `output_format_flags_and_env_have_typed_contract_433` and `classify_error_kind_returns_correct_discriminants`. Verification: `cargo fmt --manifest-path rust/Cargo.toml --all -- --check`, focused output-format and classifier tests, `scripts/roadmap-check-ids.sh`, `git diff --check`, `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli --no-run`, and `cargo build --manifest-path rust/Cargo.toml --workspace --locked`. 434. **POSIX `--` end-of-flags separator is not recognized — `claw -- "-prompt-with-dash"` returns `{"error":"unknown option: --","hint":"Did you mean -V?","kind":"cli_parse"}` instead of treating subsequent args as positional; shorthand prompt mode cannot accept dash-prefixed prompts at all** — dogfooded 2026-05-11 by Jobdori on `0e5f6958` in response to Clawhip pinpoint nudge at `1503298142286905484`. Reproduction: `claw -- "-prompt-with-dash" --output-format json` returns `{"error":"unknown option: --","hint":"Did you mean -V?\nRun \`claw --help\` for usage.","kind":"cli_parse"}`. The POSIX/GNU CLI convention — universally honored by cargo, git, npm, gh, kubectl, grep, ls, find, etc. — is that `--` terminates flag parsing and treats everything after it as positional arguments. claw rejects `--` itself as an unknown flag. **Sibling misleading-suggestion bug (recurring from #429):** the `cli_parse` hint suggests `Did you mean -V?` for `--`. `-V` is the version flag; `--` is the end-of-flags separator. They have no semantic relationship; the auto-complete is matching on prefix-character similarity only. **Sibling shorthand-prompt limitation:** `claw "-just a prompt" --output-format json` returns `{"error":"unknown option: -just a prompt","kind":"cli_parse"}` and `claw "--bogus-flag-like" --output-format json` returns the same. The shorthand non-interactive prompt mode (documented as `claw [--model MODEL] [--output-format text|json] TEXT`) cannot accept any TEXT that starts with `-` or `--`, even when the entire string is shell-quoted as a single token. Users must use the explicit `prompt` verb (`claw prompt "-prompt-with-dash"` works) to escape this, but the explicit verb is documented as alternative not required. **Required fix shape:** (a) accept POSIX `--` as the end-of-flags marker globally — every arg after `--` is positional; (b) shorthand prompt mode must distinguish "this looks like a flag" from "this is a quoted positional that happens to start with `-`" by looking at whether the token matches any registered flag name (`-h`, `-V`, `--help`, `--version`, etc.) — strings that don't match any flag should be treated as prompt text; (c) fix the "Did you mean" hint algorithm to filter by semantic category (don't suggest `-V` for `--`, suggest "use \`--\` to terminate flag parsing" if the user types just `--`); (d) regression test: `claw -- "-foo"` reaches the runtime with prompt=`-foo`; `claw "-not-a-flag"` is treated as shorthand prompt when no registered flag matches; canonical `--` is recognized. **Why this matters:** POSIX `--` is the universal mechanism for passing arbitrary text (filenames starting with `-`, prompts containing flag-like syntax, log lines, etc.) to a CLI. Failing on `--` makes claw fundamentally unergonomic in shell pipelines (`echo "-q for quiet" | xargs claw` fails). The shorthand-prompt limitation forces users to remember the `prompt` verb specifically when their prompt happens to start with `-`. Cross-references #422 (unknown subcommand fallthrough), #423 (stdin not consumed by prompt), #429 ("Did you mean --acp" misleading suggestion). Source: Jobdori live dogfood, `0e5f6958`, 2026-05-11. diff --git a/USAGE.md b/USAGE.md index 3385f394..ef0e8f14 100644 --- a/USAGE.md +++ b/USAGE.md @@ -200,6 +200,8 @@ Global workspace override flags: `--cwd PATH`, `-C PATH`, and `--directory PATH` `--allowedTools` accepts canonical snake_case tool names (for example `read_file`, `glob_search`, `web_fetch`) plus documented aliases such as `read`, `glob`, `Read`, and `WebFetch`. `claw status --output-format json` exposes `allowed_tools.available` and `allowed_tools.aliases`, and invalid values return typed `invalid_tool_name` JSON with `tool_name`, `available`, and `tool_aliases`. A missing value before a subcommand or another flag returns `missing_argument` with `argument:"--allowedTools"`. +`--output-format` accepts `text` or `json` case-insensitively and normalizes to the canonical lowercase modes. `CLAW_OUTPUT_FORMAT=json` sets the default output format for scripts, while an explicit `--output-format` flag takes precedence. Repeating the flag emits a stderr warning and JSON status envelopes expose `format_source`, `format_raw`, and `format_overridden` so composed flag arrays are auditable; invalid values return typed `invalid_output_format` JSON with `value` and `expected:["text","json"]`. + Supported permission modes (default: `workspace-write`): - `read-only` allows inspection-only local tools such as file reads, glob/grep searches, local skills, and status-style reporting. It does not allow workspace mutation, network-fetch/search tools, or arbitrary command execution. @@ -444,6 +446,9 @@ The name "codex" appears in the Claw Code ecosystem but it does **not** refer to export HTTPS_PROXY="http://proxy.corp.example:3128" export HTTP_PROXY="http://proxy.corp.example:3128" export NO_PROXY="localhost,127.0.0.1,.corp.example" +export CLAW_OUTPUT_FORMAT="json" # default non-interactive output format; flags override it +export CLAW_LOG="debug" # claw-specific log level selector surfaced by help/doctor +export RUST_LOG="claw=debug" # Rust logging convention surfaced by help/doctor cd rust ./target/debug/claw prompt "hello via the corporate proxy" diff --git a/rust/README.md b/rust/README.md index 58c76105..bae62e05 100644 --- a/rust/README.md +++ b/rust/README.md @@ -122,7 +122,7 @@ claw [OPTIONS] [COMMAND] Flags: --model MODEL - --output-format text|json + --output-format text|json (case-insensitive; CLAW_OUTPUT_FORMAT supplies the default, flags override env) --permission-mode MODE --cwd PATH, -C PATH, --directory PATH --dangerously-skip-permissions, --skip-permissions @@ -147,6 +147,7 @@ Top-level commands: ``` `claw acp` is a local discoverability surface for editor-first users: it reports the current ACP/Zed status without starting the runtime. As of April 16, 2026, claw-code does **not** ship an ACP/Zed daemon or JSON-RPC entrypoint yet, and `claw acp serve` is only a status alias until the real protocol surface lands. Status queries exit 0 and expose the same machine-readable contract via `--output-format json`; malformed ACP invocations exit 1 with `kind: unsupported_acp_invocation`. +`--output-format` accepts `text` or `json` in any casing. `CLAW_OUTPUT_FORMAT=json` selects JSON as the default for non-interactive commands, explicit flags override it, repeated flags warn on stderr, and status JSON exposes `format_source`, `format_raw`, and `format_overridden`. Help and doctor output also surface `CLAW_LOG` / `RUST_LOG` as the logging environment knobs. `claw dump-manifests` is self-contained: it emits the Rust resolver inventory for the selected workspace (commands, tools, agents, skills, and bootstrap phases) without requiring an upstream Claude Code TypeScript checkout. Use `--manifests-dir PATH` only to scope resolver discovery to another directory. The command surface is moving quickly. For the canonical live help text, run: diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 9bb8e2a6..676fd8ea 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -26,7 +26,7 @@ use std::ops::{Deref, DerefMut}; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, OnceLock}; use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant, UNIX_EPOCH}; @@ -313,10 +313,7 @@ fn main() { // When --output-format json is active, emit errors as JSON so downstream // tools can parse failures the same way they parse successes (ROADMAP #42). let argv: Vec = std::env::args().collect(); - let json_output = argv - .windows(2) - .any(|w| w[0] == "--output-format" && w[1] == "json") - || argv.iter().any(|a| a == "--output-format=json"); + let json_output = raw_args_request_json_output(&argv[1..]); if json_output { // #77/#696: classify error by prefix so downstream claws can route // without regex-scraping prose. Keep the legacy `type`/`kind` @@ -357,6 +354,14 @@ fn main() { ); } } + } else if kind == "invalid_output_format" { + if let Some(object) = error_json.as_object_mut() { + object.insert( + "value".to_string(), + serde_json::json!(invalid_output_format_value(&message)), + ); + object.insert("expected".to_string(), serde_json::json!(["text", "json"])); + } } else if kind == "invalid_tool_name" { let (tool_name, available, aliases) = invalid_tool_name_details(&message); if let Some(object) = error_json.as_object_mut() { @@ -442,6 +447,8 @@ fn classify_error_kind(message: &str) -> &'static str { "invalid_cwd" } else if message.starts_with("invalid_output_path:") { "invalid_output_path" + } else if message.starts_with("invalid_output_format:") { + "invalid_output_format" } else if message.starts_with("invalid_tool_name:") { "invalid_tool_name" } else if message.contains("unrecognized argument") || message.contains("unknown option") { @@ -586,6 +593,15 @@ fn invalid_tool_name_details(message: &str) -> (Option, Vec, Val (tool_name, available, Value::Object(aliases)) } +fn invalid_output_format_value(message: &str) -> Option { + message + .strip_prefix("invalid_output_format: unsupported value for --output-format:") + .and_then(|rest| rest.lines().next()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + /// #781: derive a stable fallback hint from a classified error kind when the error /// message itself has no `\n`-delimited hint. Returns `None` for kinds where the /// message is self-explanatory or no canonical remediation exists. @@ -631,6 +647,7 @@ fn fallback_hint_for_error_kind(kind: &str) -> Option<&'static str> { "invalid_tool_name" => Some( "Use canonical snake_case tool names from `available` or documented aliases from `tool_aliases`.", ), + "invalid_output_format" => Some("Use --output-format text or --output-format json."), _ => None, } } @@ -953,10 +970,7 @@ fn run() -> Result<(), Box> { // #824: suppress config deprecation prose warnings to stderr when JSON // output mode is active. Scan the raw argv before parse_args so the // suppression is in place before any settings file is loaded. - let json_mode = args - .windows(2) - .any(|w| w[0] == "--output-format" && w[1] == "json") - || args.iter().any(|a| a == "--output-format=json"); + let json_mode = raw_args_request_json_output(&args); if json_mode { runtime::suppress_config_warnings_for_json_mode(); } @@ -1257,16 +1271,141 @@ enum CliOutputFormat { Json, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OutputFormatSource { + Default, + Env, + Flag, +} + +impl OutputFormatSource { + fn as_str(self) -> &'static str { + match self { + Self::Default => "default", + Self::Env => "env", + Self::Flag => "flag", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct OutputFormatSelection { + format: CliOutputFormat, + source: OutputFormatSource, + raw: Option, + overridden: Vec, +} + +impl Default for OutputFormatSelection { + fn default() -> Self { + Self { + format: CliOutputFormat::Text, + source: OutputFormatSource::Default, + raw: None, + overridden: Vec::new(), + } + } +} + +static OUTPUT_FORMAT_SELECTION: OnceLock> = OnceLock::new(); + +fn output_format_selection_cell() -> &'static Mutex { + OUTPUT_FORMAT_SELECTION.get_or_init(|| Mutex::new(OutputFormatSelection::default())) +} + +fn set_current_output_format_selection(selection: &OutputFormatSelection) { + *output_format_selection_cell() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = selection.clone(); +} + +fn current_output_format_selection() -> OutputFormatSelection { + output_format_selection_cell() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() +} + +fn cli_has_output_format_flag(args: &[String]) -> bool { + args.iter() + .any(|arg| arg == "--output-format" || arg.starts_with("--output-format=")) +} + +fn raw_args_request_json_output(args: &[String]) -> bool { + let mut values = Vec::new(); + let mut index = 0; + while index < args.len() { + let arg = &args[index]; + if arg == "--output-format" { + if let Some(value) = args.get(index + 1) { + values.push(value.as_str()); + } + index += 2; + continue; + } + if let Some(value) = arg.strip_prefix("--output-format=") { + values.push(value); + } + index += 1; + } + if let Some(value) = values.last() { + let value = value.trim(); + return !value.eq_ignore_ascii_case("text"); + } + env::var("CLAW_OUTPUT_FORMAT").ok().is_some_and(|value| { + let value = value.trim(); + !value.is_empty() && !value.eq_ignore_ascii_case("text") + }) +} + +fn output_format_selection_from_env() -> Result { + match env::var("CLAW_OUTPUT_FORMAT") { + Ok(raw) if !raw.trim().is_empty() => Ok(OutputFormatSelection { + format: CliOutputFormat::parse(&raw)?, + source: OutputFormatSource::Env, + raw: Some(raw), + overridden: Vec::new(), + }), + _ => Ok(OutputFormatSelection::default()), + } +} + +fn apply_output_format_flag( + selection: &mut OutputFormatSelection, + value: &str, +) -> Result { + let parsed = CliOutputFormat::parse(value)?; + if selection.source == OutputFormatSource::Flag { + let previous = selection + .raw + .clone() + .unwrap_or_else(|| selection.format.as_str().to_string()); + eprintln!("warning: --output-format specified multiple times; using last value '{value}'"); + selection.overridden.push(previous); + } + selection.format = parsed; + selection.source = OutputFormatSource::Flag; + selection.raw = Some(value.to_string()); + set_current_output_format_selection(selection); + Ok(parsed) +} impl CliOutputFormat { fn parse(value: &str) -> Result { - match value { - "text" => Ok(Self::Text), - "json" => Ok(Self::Json), + match value.trim() { + value if value.eq_ignore_ascii_case("text") => Ok(Self::Text), + value if value.eq_ignore_ascii_case("json") => Ok(Self::Json), other => Err(format!( - "unsupported value for --output-format: {other} (expected text or json)" + "invalid_output_format: unsupported value for --output-format: {other}\nExpected: text, json\nHint: Use --output-format text or --output-format json." )), } } + + fn as_str(self) -> &'static str { + match self { + Self::Text => "text", + Self::Json => "json", + } + } } #[allow(clippy::too_many_lines)] @@ -1275,7 +1414,13 @@ fn parse_args(args: &[String]) -> Result { // #148: when user passes --model/--model=, capture the raw input so we // can attribute source: "flag" later. None means no flag was supplied. let mut model_flag_raw: Option = None; - let mut output_format = CliOutputFormat::Text; + let mut output_format_selection = if cli_has_output_format_flag(args) { + OutputFormatSelection::default() + } else { + output_format_selection_from_env()? + }; + set_current_output_format_selection(&output_format_selection); + let mut output_format = output_format_selection.format; let mut permission_mode_override = None; let mut wants_help = false; let mut wants_version = false; @@ -1339,7 +1484,7 @@ fn parse_args(args: &[String]) -> Result { let value = args .get(index + 1) .ok_or_else(|| "missing_flag_value: missing value for --output-format.\nUsage: --output-format text or --output-format json".to_string())?; - output_format = CliOutputFormat::parse(value)?; + output_format = apply_output_format_flag(&mut output_format_selection, value)?; index += 2; } "--permission-mode" => { @@ -1350,7 +1495,8 @@ fn parse_args(args: &[String]) -> Result { index += 2; } flag if flag.starts_with("--output-format=") => { - output_format = CliOutputFormat::parse(&flag[16..])?; + output_format = + apply_output_format_flag(&mut output_format_selection, &flag[16..])?; index += 1; } flag if flag.starts_with("--permission-mode=") => { @@ -2767,6 +2913,7 @@ fn print_model_validation_warning_status( let kind = classify_error_kind(error); let (short_reason, inline_hint) = split_error_hint(error); let hint = inline_hint.or_else(|| fallback_hint_for_error_kind(kind).map(String::from)); + let format_selection = current_output_format_selection(); let mut value = status_json_value( None, usage, @@ -2775,6 +2922,7 @@ fn print_model_validation_warning_status( None, None, allowed_tools, + Some(&format_selection), ); let object = value .as_object_mut() @@ -3968,6 +4116,15 @@ fn check_system_health(cwd: &Path, config: Option<&runtime::RuntimeConfig>) -> D format!("Version {}", VERSION), format!("Build target {}", BUILD_TARGET.unwrap_or("")), format!("Git SHA {}", GIT_SHA.unwrap_or("")), + format!( + "Output format env CLAW_OUTPUT_FORMAT={}", + env::var("CLAW_OUTPUT_FORMAT").unwrap_or_else(|_| "".to_string()) + ), + format!( + "Logging env CLAW_LOG={} RUST_LOG={}", + env::var("CLAW_LOG").unwrap_or_else(|_| "".to_string()), + env::var("RUST_LOG").unwrap_or_else(|_| "".to_string()) + ), ]; if let Some(model) = default_model { details.push(format!("Default model {model}")); @@ -3998,6 +4155,12 @@ fn check_system_health(cwd: &Path, config: Option<&runtime::RuntimeConfig>) -> D binary_provenance.json_value(), ), ("default_model".to_string(), json!(default_model)), + ( + "claw_output_format".to_string(), + json!(env::var("CLAW_OUTPUT_FORMAT").ok()), + ), + ("claw_log".to_string(), json!(env::var("CLAW_LOG").ok())), + ("rust_log".to_string(), json!(env::var("RUST_LOG").ok())), ])) } @@ -5445,6 +5608,7 @@ fn run_resume_command( None, // #148: resumed sessions don't have flag provenance None, None, + None, )), }) } @@ -8258,6 +8422,7 @@ fn print_status_snapshot( CliOutputFormat::Text => return Err(error.into()), }, }; + let format_selection = current_output_format_selection(); match output_format { CliOutputFormat::Text => println!( "{}", @@ -8280,6 +8445,7 @@ fn print_status_snapshot( Some(&provenance), Some(&permission_mode), allowed_tools, + Some(&format_selection), ))? ), } @@ -8299,6 +8465,7 @@ fn status_json_value( provenance: Option<&ModelProvenance>, permission_provenance: Option<&PermissionModeProvenance>, allowed_tools: Option<&AllowedToolSet>, + format_selection: Option<&OutputFormatSelection>, ) -> serde_json::Value { // #143: top-level `status` marker so claws can distinguish // a clean run from a degraded run (config parse failed but other fields @@ -8317,6 +8484,7 @@ fn status_json_value( let tool_registry = GlobalToolRegistry::builtin(); let available_tool_names = tool_registry.canonical_allowed_tool_names(); let tool_aliases = allowed_tool_aliases_json(&tool_registry); + let output_format_selection = format_selection.cloned().unwrap_or_default(); // #732: always emit an array (empty when unrestricted) so callers can do // `.allowed_tools.entries | length > 0` without a null-check first. let allowed_tool_entries = allowed_tools @@ -8343,6 +8511,9 @@ fn status_json_value( "available": available_tool_names, "aliases": tool_aliases, }, + "format_source": output_format_selection.source.as_str(), + "format_raw": output_format_selection.raw, + "format_overridden": output_format_selection.overridden, "binary_provenance": context.binary_provenance.json_value(), "usage": { "messages": usage.message_count, @@ -12529,7 +12700,15 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> { )?; writeln!( out, - " --output-format FORMAT Non-interactive output format: text or json" + " --output-format FORMAT Non-interactive output format: text or json (case-insensitive)" + )?; + writeln!( + out, + " CLAW_OUTPUT_FORMAT sets the default; flags override env" + )?; + writeln!( + out, + " Log env vars: CLAW_LOG or RUST_LOG" )?; writeln!( out, @@ -14205,6 +14384,7 @@ mod tests { None, None, None, + None, ); assert_eq!( json.get("status").and_then(|v| v.as_str()), @@ -14279,6 +14459,7 @@ mod tests { None, None, Some(&allowed), + None, ); assert_eq!( restricted_json @@ -14311,6 +14492,7 @@ mod tests { None, None, None, + None, ); assert_eq!( clean_json.get("status").and_then(|v| v.as_str()), @@ -14590,6 +14772,12 @@ mod tests { classify_error_kind("invalid_tool_name: unsupported tool in --allowedTools: teleport"), "invalid_tool_name" ); + assert_eq!( + classify_error_kind( + "invalid_output_format: unsupported value for --output-format: YAML" + ), + "invalid_output_format" + ); assert_eq!( classify_error_kind( "missing_flag_value: missing value for --model.\nUsage: --model " @@ -16119,6 +16307,7 @@ mod tests { None, None, None, + None, ); 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 647534cc..e352bb0f 100644 --- a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs +++ b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs @@ -2345,6 +2345,11 @@ fn assert_non_empty_action(parsed: &Value, args: &[&str]) { fn run_claw(current_dir: &Path, args: &[&str], envs: &[(&str, &str)]) -> Output { let mut command = Command::new(env!("CARGO_BIN_EXE_claw")); command.current_dir(current_dir).args(args); + for key in ["CLAW_OUTPUT_FORMAT", "CLAW_LOG", "RUST_LOG"] { + if !envs.iter().any(|(env_key, _)| *env_key == key) { + command.env_remove(key); + } + } for (key, value) in envs { command.env(key, value); } @@ -2722,6 +2727,140 @@ fn flag_value_errors_have_error_kind_and_hint_756() { "missing --model hint must be non-empty (#756): {parsed2}" ); } +#[test] +fn output_format_flags_and_env_have_typed_contract_433() { + let root = unique_temp_dir("output-format-433"); + fs::create_dir_all(&root).expect("temp dir"); + + let repeated = run_claw( + &root, + &[ + "--output-format", + "text", + "--output-format", + "JSON", + "status", + ], + &[], + ); + assert!(repeated.status.success()); + let repeated_stderr = String::from_utf8_lossy(&repeated.stderr); + assert!( + repeated_stderr.contains("warning: --output-format specified multiple times"), + "repeated output-format should warn on stderr: {repeated_stderr}" + ); + let repeated_json = parse_json_stdout(&repeated, "repeated output-format status"); + assert_eq!(repeated_json["kind"], "status"); + assert_eq!(repeated_json["format_source"], "flag"); + assert_eq!(repeated_json["format_raw"], "JSON"); + assert_eq!(repeated_json["format_overridden"][0], "text"); + + let repeated_text = run_claw( + &root, + &[ + "--output-format", + "json", + "--output-format", + "text", + "status", + ], + &[], + ); + assert!(repeated_text.status.success()); + let repeated_text_stderr = String::from_utf8_lossy(&repeated_text.stderr); + assert!( + repeated_text_stderr.contains("using last value 'text'"), + "json-to-text repeated output-format should warn: {repeated_text_stderr}" + ); + let repeated_text_stdout = String::from_utf8_lossy(&repeated_text.stdout); + assert!( + repeated_text_stdout.contains("Status"), + "last text output-format should produce text status: {repeated_text_stdout}" + ); + + for value in ["json", "JSON", "Json"] { + let parsed = assert_json_command(&root, &["--output-format", value, "status"]); + assert_eq!( + parsed["kind"], "status", + "case {value} should parse as JSON" + ); + assert_eq!(parsed["format_source"], "flag"); + assert_eq!(parsed["format_raw"], value); + } + + let from_env = + assert_json_command_with_env(&root, &["status"], &[("CLAW_OUTPUT_FORMAT", "json")]); + assert_eq!(from_env["kind"], "status"); + assert_eq!(from_env["format_source"], "env"); + assert_eq!(from_env["format_raw"], "json"); + + let flag_overrides_env = run_claw( + &root, + &["--output-format", "json", "status"], + &[("CLAW_OUTPUT_FORMAT", "text")], + ); + assert!(flag_overrides_env.status.success()); + let override_json = parse_json_stdout(&flag_overrides_env, "flag overrides env output-format"); + assert_eq!(override_json["kind"], "status"); + assert_eq!(override_json["format_source"], "flag"); + assert_eq!(override_json["format_raw"], "json"); + assert_eq!( + override_json["format_overridden"].as_array().map(Vec::len), + Some(0) + ); + + let invalid = run_claw(&root, &["--output-format", "YAML", "status"], &[]); + assert_eq!(invalid.status.code(), Some(1)); + assert!( + invalid.stderr.is_empty(), + "invalid output-format in JSON mode must keep stderr empty: {}", + String::from_utf8_lossy(&invalid.stderr) + ); + let invalid_json = parse_json_stdout(&invalid, "invalid output-format JSON error"); + assert_eq!(invalid_json["error_kind"], "invalid_output_format"); + assert_eq!(invalid_json["value"], "YAML"); + assert_eq!( + invalid_json["expected"], + serde_json::json!(["text", "json"]) + ); + assert!(invalid_json["hint"] + .as_str() + .is_some_and(|hint| hint.contains("--output-format json"))); + + let help = assert_json_command(&root, &["--output-format", "json", "help"]); + let help_text = help["message"].as_str().expect("help message"); + assert!( + help_text.contains("CLAW_OUTPUT_FORMAT"), + "help should document CLAW_OUTPUT_FORMAT: {help_text}" + ); + assert!( + help_text.contains("CLAW_LOG"), + "help should document CLAW_LOG: {help_text}" + ); + assert!( + help_text.contains("RUST_LOG"), + "help should document RUST_LOG: {help_text}" + ); + + let doctor = assert_json_command_with_env( + &root, + &["doctor"], + &[ + ("CLAW_OUTPUT_FORMAT", "json"), + ("CLAW_LOG", "debug"), + ("RUST_LOG", "claw=debug"), + ], + ); + let system_check = doctor["checks"] + .as_array() + .expect("doctor checks") + .iter() + .find(|check| check["name"] == "system") + .expect("system check"); + assert_eq!(system_check["claw_output_format"], "json"); + assert_eq!(system_check["claw_log"], "debug"); + assert_eq!(system_check["rust_log"], "claw=debug"); +} #[test] fn allowed_tools_errors_have_typed_json_and_alias_map_432() {