mirror of
https://github.com/instructkr/claw-code.git
synced 2026-06-04 13:37:09 +08:00
fix: type output format selection
This commit is contained in:
@@ -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.
|
||||
|
||||
5
USAGE.md
5
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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user