fix: type output format selection

This commit is contained in:
bellman
2026-06-04 12:47:24 +09:00
parent ecd3e4ceb9
commit 41678eb097
5 changed files with 353 additions and 19 deletions

View File

@@ -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.

View File

@@ -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"

View File

@@ -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:

View File

@@ -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<String> = 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<String>, Vec<String>, Val
(tool_name, available, Value::Object(aliases))
}
fn invalid_output_format_value(message: &str) -> Option<String> {
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<dyn std::error::Error>> {
// #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<String>,
overridden: Vec<String>,
}
impl Default for OutputFormatSelection {
fn default() -> Self {
Self {
format: CliOutputFormat::Text,
source: OutputFormatSource::Default,
raw: None,
overridden: Vec::new(),
}
}
}
static OUTPUT_FORMAT_SELECTION: OnceLock<Mutex<OutputFormatSelection>> = OnceLock::new();
fn output_format_selection_cell() -> &'static Mutex<OutputFormatSelection> {
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<OutputFormatSelection, String> {
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<CliOutputFormat, String> {
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<Self, String> {
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<CliAction, String> {
// #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<String> = 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<CliAction, String> {
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<CliAction, String> {
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("<unknown>")),
format!("Git SHA {}", GIT_SHA.unwrap_or("<unknown>")),
format!(
"Output format env CLAW_OUTPUT_FORMAT={}",
env::var("CLAW_OUTPUT_FORMAT").unwrap_or_else(|_| "<unset>".to_string())
),
format!(
"Logging env CLAW_LOG={} RUST_LOG={}",
env::var("CLAW_LOG").unwrap_or_else(|_| "<unset>".to_string()),
env::var("RUST_LOG").unwrap_or_else(|_| "<unset>".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 <provider/model>"
@@ -16119,6 +16307,7 @@ mod tests {
None,
None,
None,
None,
);
assert_eq!(

View File

@@ -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() {