From 55da1893158fe88bad3a4bde71b63456aec401e7 Mon Sep 17 00:00:00 2001 From: bellman Date: Wed, 3 Jun 2026 19:12:20 +0900 Subject: [PATCH] fix: keep JSON control surfaces local --- ROADMAP.md | 46 ++- rust/crates/rusty-claude-cli/src/main.rs | 192 +++++++++-- .../tests/output_format_contract.rs | 298 ++++++++++++++++-- 3 files changed, 472 insertions(+), 64 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index eb9fa747..9d6108f5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -7778,10 +7778,22 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed) 805. **`claw skills show ` in text mode silently returned "No skills found." instead of an error** — dogfooded 2026-05-27 on `2c3c0f60`. The text-mode show handler in `handle_skills_slash_command` returned `render_skills_report(&matched)` with an empty vec instead of checking for empty match and returning an error. JSON mode already returned `skill_not_found` since #706. Fix: added `matched.is_empty()` guard with `skill_not_found` error + `\n` hint suggesting `claw skills list`. 62 CLI contract tests pass. [SCOPE: claw-code] 806. **`claw plugins show ` in text mode returned "No plugins installed." instead of an error** — dogfooded 2026-05-27 on `ae6a207d`. The text-mode path in `print_plugins` printed `payload.message` (the full list render) without checking if the requested plugin existed. JSON mode correctly returned `plugin_not_found`. Fix: added show-action filtering + not-found guard to text-mode path; added `starts_with("plugin_not_found:")` arm to classifier for the new error prefix. 63 CLI contract tests pass. [SCOPE: claw-code] -807. **`claw models` / `claw model` with `--output-format json` hang with zero stdout instead of returning bounded model discovery/help JSON or a typed unsupported response** — dogfooded 2026-05-27 on `ae6a207` while checking docs/usage model-alias surface after PR #3162 opened. Both `cargo run -q -p rusty-claude-cli -- models --output-format json` and the actual rebuilt `./rust/target/debug/claw models --output-format json` timed out under an 8s outer timeout with stdout `0`; stderr only contained config deprecation warnings. The same silent timeout reproduced for `models help --output-format json`, `model --output-format json`, and `model help --output-format json`. **Required fix shape:** (a) make `model(s)` help/list/discovery commands return bounded stdout JSON without entering prompt/provider/auth paths; (b) if the command is unsupported, return a standard typed JSON error envelope with `error_kind`, non-null `hint`, and `message`; (c) ensure docs model-alias tables and CLI model discovery surfaces do not diverge; (d) add regression coverage for `models --output-format json`, `models help --output-format json`, `model --output-format json`, and `model help --output-format json` proving they do not hang or emit zero-byte stdout. **Why this matters:** model selection is a setup/control-plane surface. If the natural model discovery commands hang silently, claws cannot verify aliases like `qwen-max` / `qwen-plus`, distinguish unsupported command spelling from provider startup, or safely guide users during first-run model setup. Source: gaebal-gajae 13:30/14:00 dogfood probe; GitHub issue creation was blocked by API rate limit, so the finding was recorded directly in ROADMAP. -808. **Control-plane commands `claw config`, `claw settings`, `claw status`, and `claw doctor` with `--output-format json` hang with zero stdout instead of returning bounded JSON/help or a typed unsupported envelope** — dogfooded 2026-05-27 on `86f45a1` after ROADMAP #807 landed. Each of `./rust/target/debug/claw config --output-format json`, `config help --output-format json`, `settings --output-format json`, `settings help --output-format json`, `status --output-format json`, and `doctor --output-format json` timed out under an 8s outer timeout with stdout `0`; stderr only contained the local deprecated `enabledPlugins` settings warning. **Required fix shape:** keep non-interactive control-plane/info commands out of prompt/provider startup paths; return bounded JSON stdout for supported status/config/help surfaces, or a standard typed JSON error envelope with `error_kind`, non-null `hint`, and `message` for unsupported spellings; add timeout/nonzero-stdout regression coverage for the six repro commands. **Why this matters:** claws and users need first-run diagnostics/config/status surfaces that are safe to call from scripts. Silent hangs make setup triage indistinguishable from provider startup, auth, or model discovery failures. Source: gaebal-gajae 17:00 dogfood probe; rechecked 17:30 after `cargo build --manifest-path rust/Cargo.toml -p rusty-claude-cli` produced `claw --version` Git SHA `23a7de6`, and the same timeout reproduced for current HOME and a clean `HOME=/tmp/claw-clean-home-1730` (clean HOME produced rc 124, stdout 0, stderr 0 for `config`, `status`, and `doctor`). [SCOPE: claw-code] +807. **DONE — `claw models` / `claw model` with `--output-format json` hang with zero stdout instead of returning bounded model discovery/help JSON or a typed unsupported response** — dogfooded 2026-05-27 on `ae6a207` while checking docs/usage model-alias surface after PR #3162 opened. Both `cargo run -q -p rusty-claude-cli -- models --output-format json` and the actual rebuilt `./rust/target/debug/claw models --output-format json` timed out under an 8s outer timeout with stdout `0`; stderr only contained config deprecation warnings. The same silent timeout reproduced for `models help --output-format json`, `model --output-format json`, and `model help --output-format json`. **Required fix shape:** (a) make `model(s)` help/list/discovery commands return bounded stdout JSON without entering prompt/provider/auth paths; (b) if the command is unsupported, return a standard typed JSON error envelope with `error_kind`, non-null `hint`, and `message`; (c) ensure docs model-alias tables and CLI model discovery surfaces do not diverge; (d) add regression coverage for `models --output-format json`, `models help --output-format json`, `model --output-format json`, and `model help --output-format json` proving they do not hang or emit zero-byte stdout. **Why this matters:** model selection is a setup/control-plane surface. If the natural model discovery commands hang silently, claws cannot verify aliases like `qwen-max` / `qwen-plus`, distinguish unsupported command spelling from provider startup, or safely guide users during first-run model setup. Source: gaebal-gajae 13:30/14:00 dogfood probe; GitHub issue creation was blocked by API rate limit, so the finding was recorded directly in ROADMAP. + + **Fix applied.** `model` and `models` now route to a local `CliAction::Models` surface. Bare `models --output-format json` emits bounded local model metadata (default model, built-in aliases, optional configured model) without provider startup, while `model help --output-format json` routes through the structured local help envelope. + + **Verification.** Regression test `models_json_and_model_help_json_are_local_807` asserts bounded exit, parseable stdout JSON, empty stderr, no `missing_credentials`, and `requires_provider_request:false` for the models list envelope. +808. **DONE — Control-plane commands `claw config`, `claw settings`, `claw status`, and `claw doctor` with `--output-format json` hang with zero stdout instead of returning bounded JSON/help or a typed unsupported envelope** — dogfooded 2026-05-27 on `86f45a1` after ROADMAP #807 landed. Each of `./rust/target/debug/claw config --output-format json`, `config help --output-format json`, `settings --output-format json`, `settings help --output-format json`, `status --output-format json`, and `doctor --output-format json` timed out under an 8s outer timeout with stdout `0`; stderr only contained the local deprecated `enabledPlugins` settings warning. **Required fix shape:** keep non-interactive control-plane/info commands out of prompt/provider startup paths; return bounded JSON stdout for supported status/config/help surfaces, or a standard typed JSON error envelope with `error_kind`, non-null `hint`, and `message` for unsupported spellings; add timeout/nonzero-stdout regression coverage for the six repro commands. **Why this matters:** claws and users need first-run diagnostics/config/status surfaces that are safe to call from scripts. Silent hangs make setup triage indistinguishable from provider startup, auth, or model discovery failures. Source: gaebal-gajae 17:00 dogfood probe; rechecked 17:30 after `cargo build --manifest-path rust/Cargo.toml -p rusty-claude-cli` produced `claw --version` Git SHA `23a7de6`, and the same timeout reproduced for current HOME and a clean `HOME=/tmp/claw-clean-home-1730` (clean HOME produced rc 124, stdout 0, stderr 0 for `config`, `status`, and `doctor`). [SCOPE: claw-code] + + **Fix applied.** `settings` now routes locally: bare `settings --output-format json` reuses the config JSON envelope for the synthetic `settings` section, and `settings help --output-format json` returns a structured local help envelope. Existing `config`, `status`, and `doctor` JSON routes remain local. + + **Verification.** Regression test `settings_json_and_help_json_are_local_808` asserts bounded exit, parseable stdout JSON, empty stderr, no `missing_credentials`, `section:"settings"` for bare settings, and structured help for `settings help --output-format json`. 809. **Top-level help/version/MCP/plugin JSON spellings hang with zero stdout in trailing `--output-format json` form instead of returning bounded JSON/help or typed unsupported envelopes** — dogfooded 2026-05-27 on rebuilt main `db81598` (`cargo build --manifest-path rust/Cargo.toml -p rusty-claude-cli`; `claw --version` Git SHA `db81598`). `help --output-format json`, `version --output-format json`, `mcp --output-format json`, `mcp help --output-format json`, `plugins --output-format json`, and `plugins help --output-format json` each timed out under an 8s outer timeout with stdout `0`; stderr only contained the local deprecated `enabledPlugins` settings warning. Leading global-style probes (`--help --output-format json`, `--version --output-format json`) fail immediately as `[error-kind: cli_parse] unknown option`, so the hang is again in the trailing subcommand-style routing/startup path. **Required fix shape:** treat help/version/MCP/plugin discovery surfaces as bounded non-interactive control-plane commands; either return JSON help/list/version payloads or standard typed JSON unsupported envelopes with `error_kind`, non-null `hint`, and `message`; add timeout/nonzero-stdout regression coverage for the six trailing repro commands and parser-envelope coverage for leading global-style spellings. **Why this matters:** claws need safe scriptable help/version/plugin/MCP discovery before provider/session startup; silent hangs hide whether a command is unsupported, misparsed, or initializing runtime state. Source: gaebal-gajae 19:00 dogfood probe. [SCOPE: claw-code] -810. **TTY JSON success for `config`/`plugins --output-format json` contaminates stdout with deprecated-settings warnings before the JSON object** — dogfooded 2026-05-27 on rebuilt main `db81598` after #809. Under pseudo-TTY (`script -q -c "./rust/target/debug/claw config --output-format json"` and `plugins --output-format json`), the commands return rc `0` and bounded JSON, but stdout begins with `warning: /home/bellman/.claw/settings.json: field "enabledPlugins" is deprecated ...` before the JSON object (`first_json_index=121`). Parsing succeeds only after manually stripping the warning/prefix; raw stdout is not valid JSON. **Required fix shape:** in JSON mode, keep diagnostics/warnings on stderr or include structured warning fields inside the JSON envelope, but never prepend human warnings to stdout; add regression coverage that raw stdout from JSON commands parses from byte 0 under TTY and non-TTY modes. **Why this matters:** even when the TTY path avoids the hang from #807/#808/#809, claws and scripts still cannot safely `json.loads(stdout)` if configuration warnings are mixed into stdout. Source: gaebal-gajae 20:00 pseudo-TTY dogfood probe. [SCOPE: claw-code] +810. **DONE — TTY JSON success for `config`/`plugins --output-format json` contaminates stdout with deprecated-settings warnings before the JSON object** — dogfooded 2026-05-27 on rebuilt main `db81598` after #809. Under pseudo-TTY (`script -q -c "./rust/target/debug/claw config --output-format json"` and `plugins --output-format json`), the commands return rc `0` and bounded JSON, but stdout begins with `warning: /home/bellman/.claw/settings.json: field "enabledPlugins" is deprecated ...` before the JSON object (`first_json_index=121`). Parsing succeeds only after manually stripping the warning/prefix; raw stdout is not valid JSON. **Required fix shape:** in JSON mode, keep diagnostics/warnings on stderr or include structured warning fields inside the JSON envelope, but never prepend human warnings to stdout; add regression coverage that raw stdout from JSON commands parses from byte 0 under TTY and non-TTY modes. **Why this matters:** even when the TTY path avoids the hang from #807/#808/#809, claws and scripts still cannot safely `json.loads(stdout)` if configuration warnings are mixed into stdout. Source: gaebal-gajae 20:00 pseudo-TTY dogfood probe. [SCOPE: claw-code] + + **Fix applied.** Existing global JSON-mode settings warning suppression now prevents deprecated `enabledPlugins` prose from prefixing JSON stdout, and the regression matrix asserts stdout starts with `{` at byte 0 for representative local JSON surfaces under an isolated deprecated settings fixture. + + **Verification.** `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli global_json_surfaces_suppress_config_deprecation_stderr_810_821_824 -- --nocapture`. 811. **Previously typed JSON error/list surfaces hang in plain non-TTY trailing `--output-format json` form instead of emitting their JSON envelopes** — dogfooded 2026-05-27 on rebuilt main `b0e94c9` after #810. In plain non-TTY automation, `agents list --bogus --output-format json`, `skills show does-not-exist --output-format json`, `plugins show does-not-exist --output-format json`, `diff --output-format json`, `sessions show does-not-exist --output-format json`, and `resume bogus --output-format json` each timed out under an 8s outer timeout with stdout `0`; stderr only contained the local deprecated `enabledPlugins` settings warning. Several of these surfaces had prior roadmap fixes for typed JSON/text envelopes, so this is a regression-class scriptability gap: the command-specific envelope may exist, but plain non-TTY trailing JSON invocation routes into interactive startup before reaching it. **Required fix shape:** ensure trailing `--output-format json` is honored before any interactive/provider/session startup for error/list surfaces; add plain non-TTY timeout regression coverage that asserts raw stdout is a parseable typed JSON envelope for the six repro commands, including `error_kind`, non-null `hint`, and `message` where applicable. **Why this matters:** claws primarily invoke CLI checks from non-TTY automation; a fix that only works in manual/TTY mode still leaves JSON error handling unusable for agents. Source: gaebal-gajae 20:30 dogfood probe. [SCOPE: claw-code] @@ -7849,7 +7861,7 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed) **Acceptance.** All `claw --output-format json session ` invocations exit 1 with the JSON envelope on stdout and empty stderr. Text mode continues to print the error to stderr. [SCOPE: claw-code] -821. **`status`, `sandbox`, and `system-prompt` in JSON mode still emit config deprecation warning to stderr** — dogfooded 2026-05-29 10:30 on `main` `42aff269`. After #816 fixed config deprecation stderr leakage for `plugins list`, `mcp list`, `doctor`, and `config`, three JSON-mode surfaces continue to emit the `enabledPlugins is deprecated` prose warning to stderr: `claw --output-format json status` (122 bytes stderr), `claw --output-format json sandbox` (122 bytes stderr), `claw --output-format json system-prompt` (122 bytes stderr). These surfaces return well-formed JSON on stdout (rc=0) but leak the config warning to stderr, leaving machine consumers with mixed-channel output. `version`, `acp`, `agents`, `skills`, `mcp`, `plugins`, and `doctor` all have clean stderr after #816. +821. **DONE — `status`, `sandbox`, and `system-prompt` in JSON mode still emit config deprecation warning to stderr** — dogfooded 2026-05-29 10:30 on `main` `42aff269`. After #816 fixed config deprecation stderr leakage for `plugins list`, `mcp list`, `doctor`, and `config`, three JSON-mode surfaces continue to emit the `enabledPlugins is deprecated` prose warning to stderr: `claw --output-format json status` (122 bytes stderr), `claw --output-format json sandbox` (122 bytes stderr), `claw --output-format json system-prompt` (122 bytes stderr). These surfaces return well-formed JSON on stdout (rc=0) but leak the config warning to stderr, leaving machine consumers with mixed-channel output. `version`, `acp`, `agents`, `skills`, `mcp`, `plugins`, and `doctor` all have clean stderr after #816. **Required fix shape.** Extend the JSON-mode config-warning suppression applied in #816 to cover `status`, `sandbox`, and `system-prompt`. The fix should apply globally: any JSON-mode surface that completes successfully should not emit config deprecation prose to stderr. Text mode should keep the human stderr warning. @@ -7857,24 +7869,36 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed) **Follow-up (2026-05-29 12:00, `main` `3dbb35c3`).** Broader sweep confirms additional surfaces with the same 122-byte stderr leak in JSON mode: `--resume latest /config` (all subforms: bare, `env`, `hooks`, `model`, `plugins`) and `--resume latest /providers` (doctor alias). The fix must apply to all config-loading paths, not just the three originally documented surfaces. The suppression guard should fire at the settings-load level so any JSON-mode invocation benefits without per-surface patching. + **Fix applied.** The warning-suppression regression matrix now covers `status`, `sandbox`, and `system-prompt` with deprecated `enabledPlugins` settings, asserting successful JSON, stdout JSON from byte 0, and empty stderr while preserving the existing text-mode warning assertion. + + **Verification.** `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli global_json_surfaces_suppress_config_deprecation_stderr_810_821_824 -- --nocapture`; text-mode preservation remains covered by `local_text_surface_preserves_config_deprecation_stderr_816`. + 822. **Unknown top-level subcommand falls through to REPL/provider startup instead of returning a `command_not_found` error** — dogfooded 2026-05-29 11:00 on `main` `69b59079`. `claw --output-format json foobar` does not return a structured `command_not_found` error; instead it falls through to the interactive/API path and hits `missing_credentials` (rc=1, stderr: `{"error_kind":"missing_credentials",...}`). Two gaps in one: (1) the unrecognized command word is silently treated as a prompt/text argument, not flagged as unknown, so the user gets a misleading "no credentials" error instead of "command not found"; (2) the resulting error goes to stderr. This makes automation scripts that probe for command availability impossible to distinguish from auth failures. **Required fix shape.** Before falling through to the REPL/prompt path, check whether the first positional arg matches any known subcommand. If not, return a typed error: `{"error_kind":"command_not_found","message":"unknown command: foobar","hint":"Run `claw --help` for available commands.","status":"error"}` on stdout (JSON mode, rc=1) or stderr (text mode). This mirrors the behavior of `--bogus-flag` (which correctly returns `cli_parse`) but for unknown positional commands. **Acceptance.** `claw --output-format json foobar` exits 1, stdout contains JSON with `error_kind:"command_not_found"`, stderr empty. Text mode prints the error to stderr. No provider startup attempted. [SCOPE: claw-code] -823. **`claw --output-format json prompt` with missing/empty prompt text routes JSON error to stderr (stdout empty)** — dogfooded 2026-05-29 11:30 on `main` `3a76c4f4`. `claw --output-format json prompt` (no text) and `claw --output-format json prompt ""` (empty string) both exit rc=1, stdout empty, and write `{"error_kind":"missing_prompt","action":"abort",...}` to stderr. The envelope is well-formed but channel-inconsistent: JSON mode machine consumers reading stdout for command results get empty stdout and must check stderr to detect the error. This is the same class as #819 (export session-not-found) and #820 (interactive_only / session subcommands), and the same root cause: the top-level abort handler writes to stderr regardless of output-format mode. +823. **DONE — `claw --output-format json prompt` with missing/empty prompt text routes JSON errors to stdout with empty stderr** — dogfooded 2026-05-29 11:30 on `main` `3a76c4f4`. `claw --output-format json prompt` (no text) and `claw --output-format json prompt ""` (empty string) both exited rc=1, stdout empty, and wrote `{"error_kind":"missing_prompt","action":"abort",...}` to stderr. The envelope was well-formed but channel-inconsistent: JSON mode machine consumers reading stdout for command results got empty stdout and had to check stderr to detect the error. This is the same class as #819 (export session-not-found) and #820 (interactive_only / session subcommands), and the same root cause: the top-level abort handler wrote to stderr regardless of output-format mode. **Required fix shape.** In JSON mode, route `missing_prompt` abort errors to stdout (rc=1) and keep stderr empty. This is the same fix pattern as #817/#819/#820: detect JSON output mode in the abort handler and redirect the structured envelope to stdout. Add regression coverage for `claw --output-format json prompt` (no arg) and `claw --output-format json prompt ""` asserting rc=1, stdout parseable JSON with `error_kind:"missing_prompt"`, stderr empty. **Acceptance.** Both invocations exit 1 with JSON envelope on stdout and empty stderr. Text mode still prints to stderr. [SCOPE: claw-code] -824. **Global settings-load deprecation warning still leaks to stderr in JSON mode for `status`, `sandbox`, `system-prompt`, `skills`, `mcp`, `agents` surfaces** — dogfooded 2026-05-29 13:30 on `main` `b4b1ba10`. After #816 and #821 (doc), the `enabledPlugins is deprecated` config warning still reaches stderr on every JSON-mode surface that loads settings: `claw --output-format json status`, `sandbox`, `system-prompt`, `mcp list`, `skills list`, `agents list` all emit `warning: /path/.claw/settings.json: field "enabledPlugins" is deprecated (line 2)...` to stderr. Root cause: `emit_config_warning_once()` in `runtime/src/config.rs` always uses `eprintln!` with no output-format awareness. The `config` surface avoids the duplicate by collecting warnings into a structured `warnings[]` field, but all other surfaces hit the raw `eprintln!` path. + **Fix applied.** The top-level JSON abort handler now emits structured error envelopes to stdout, so both missing `prompt` text paths keep the existing `missing_prompt` classification while preserving empty stderr. Regression coverage now asserts `claw --output-format json prompt` and `claw --output-format json prompt ""` exit rc=1, parse stdout JSON with `error_kind:"missing_prompt"`, and leave stderr empty. + + **Verification.** `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli prompt_no_arg_json_error_kind_750 -- --nocapture`; `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli prompt_empty_arg_json_stdout_missing_prompt_823 -- --nocapture`. + +824. **DONE — Global settings-load deprecation warning still leaks to stderr in JSON mode for `status`, `sandbox`, `system-prompt`, `skills`, `mcp`, `agents` surfaces** — dogfooded 2026-05-29 13:30 on `main` `b4b1ba10`. After #816 and #821 (doc), the `enabledPlugins is deprecated` config warning still reaches stderr on every JSON-mode surface that loads settings: `claw --output-format json status`, `sandbox`, `system-prompt`, `mcp list`, `skills list`, `agents list` all emit `warning: /path/.claw/settings.json: field "enabledPlugins" is deprecated (line 2)...` to stderr. Root cause: `emit_config_warning_once()` in `runtime/src/config.rs` always uses `eprintln!` with no output-format awareness. The `config` surface avoids the duplicate by collecting warnings into a structured `warnings[]` field, but all other surfaces hit the raw `eprintln!` path. **Required fix shape.** Add a global `SUPPRESS_CONFIG_WARNINGS_STDERR: AtomicBool` flag in `config.rs`. Set it to `true` immediately when `--output-format json` is detected in `main.rs` (before any settings load). Gate `emit_config_warning_once` on that flag. Text-mode invocations continue to print to stderr; JSON-mode invocations silently suppress the prose warning (warnings remain available via structured `config` output). **Acceptance.** With deprecated `enabledPlugins` in `~/.claw/settings.json`, all JSON-mode surfaces (`status`, `sandbox`, `system-prompt`, `mcp list`, `skills list`, `agents list`, plus all `--resume /config*` forms) exit with empty stderr. Text-mode output is unchanged. [SCOPE: claw-code] + **Fix applied.** The focused matrix now exercises the global settings-load path for `status`, `sandbox`, `system-prompt`, `mcp list`, `skills list`, `agents list`, and a generated-session resume `/config` invocation under deprecated `enabledPlugins`, requiring empty stderr for every JSON-mode surface. + + **Verification.** `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli global_json_surfaces_suppress_config_deprecation_stderr_810_821_824 -- --nocapture`; `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli local_text_surface_preserves_config_deprecation_stderr_816 -- --nocapture`. + 825. **Unknown single-word subcommand falls through to provider startup and surfaces `missing_credentials` instead of `command_not_found`** — dogfooded 2026-05-29 14:00 on `main` `de7edd5b`. `claw foobar` (and `claw --output-format json foobar`) hit the `looks_like_subcommand_typo` guard, which checked for close fuzzy matches but fell through silently when no suggestions matched. The fallthrough routed to `CliAction::Prompt`, triggering Anthropic provider startup and a misleading `missing_credentials` error (or burning API tokens if credentials were present). The `command_not_found` error kind existed in the registry but was never emitted by this path. **Required fix shape.** When `looks_like_subcommand_typo` fires on a single-word positional arg with no close suggestions, emit `command_not_found:` rather than falling through. Add `command_not_found:` prefix classifier to `classify_error_kind`. Result: clean `{"error_kind":"command_not_found",...}` envelope on stdout (JSON mode), error on stderr (text mode), zero provider startup. @@ -7928,3 +7952,13 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed) **Fix applied.** `mcp show` without a server name now emits a typed `missing_argument` response instead of reusing `unknown_mcp_action`. The direct JSON path returns `{kind:"mcp", action:"show", status:"error", error_kind:"missing_argument"}` with a usage hint on stdout and an empty stderr stream; the slash-command parser also classifies `/mcp show` as `missing_argument` via the shared error-kind classifier. **Verification.** `cargo fmt --manifest-path rust/Cargo.toml --all -- --check`; `cargo test --manifest-path rust/Cargo.toml -p commands mcp -- --nocapture`; `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli mcp_show_missing_server_name_returns_missing_argument_830 -- --nocapture`; `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli classify_error_kind_returns_correct_discriminants -- --nocapture`; direct probe `cargo run --manifest-path rust/Cargo.toml -q -p rusty-claude-cli -- --output-format json mcp show`. + +831. **DONE — Direct resume-safe slash commands route to `interactive_only` instead of local JSON actions** — PR #3205 showed that direct slash invocations such as `claw --output-format json /status`, `/diff`, `/version`, `/doctor`, and `/sandbox` were parsed successfully as resume-safe slash commands, but the direct CLI parser still fell through to generic `interactive_only` guidance instead of dispatching to the same pure-local `CliAction` handlers as the bare subcommands. + + **Required fix shape.** In the direct slash CLI parser, map resume-safe local slash command variants to the corresponding local `CliAction` variants. Preserve non-resume-safe slash command guidance from #829. + + **Acceptance.** `claw --output-format json /version`, `/sandbox`, `/diff`, and `/status` succeed with their expected local JSON `kind` and environment-dependent local `status`, stdout JSON, and empty stderr; non-resume-safe slash commands still emit `interactive_only` without bogus local routing. [SCOPE: claw-code] + + **Fix applied.** `parse_direct_slash_cli_action` now routes `/status`, `/diff`, `/version`, `/doctor`, and `/sandbox` directly to the same local `CliAction` variants as `status`, `diff`, `version`, `doctor`, and `sandbox`. The generic `interactive_only` branch remains the fallback for valid but live-REPL-only slash commands, preserving the #829 non-resume-safe hint behavior. + + **Verification.** `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli direct_resume_safe_slash_commands_route_to_local_json_actions_831 -- --nocapture`. diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 36cd7257..9f9d019b 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -647,6 +647,10 @@ fn run() -> Result<(), Box> { ); } }, + CliAction::Models { + action, + output_format, + } => print_models(action.as_deref(), output_format)?, CliAction::Diff { output_format } => match output_format { CliOutputFormat::Text => { println!("{}", render_diff_report()?); @@ -770,6 +774,10 @@ enum CliAction { section: Option, output_format: CliOutputFormat, }, + Models { + action: Option, + output_format: CliOutputFormat, + }, Diff { output_format: CliOutputFormat, }, @@ -817,6 +825,8 @@ enum LocalHelpTopic { Plugins, Mcp, Config, + Model, + Settings, Diff, } @@ -1076,6 +1086,8 @@ fn parse_args(args: &[String]) -> Result { "plugins" | "plugin" | "marketplace" => Some(LocalHelpTopic::Plugins), "mcp" => Some(LocalHelpTopic::Mcp), "config" => Some(LocalHelpTopic::Config), + "model" | "models" => Some(LocalHelpTopic::Model), + "settings" => Some(LocalHelpTopic::Settings), "diff" => Some(LocalHelpTopic::Diff), _ => None, }; @@ -1292,10 +1304,23 @@ fn parse_args(args: &[String]) -> Result { "interactive_only: `claw ultraplan` is a slash command.\nStart `claw` and run `/ultraplan` inside the REPL." .to_string(), ), - "model" if rest.len() > 1 => Err( - "interactive_only: `claw model` is a slash command.\nStart `claw` and run `/model [model-name]` inside the REPL." - .to_string(), - ), + "model" | "models" => { + let tail = &rest[1..]; + let action = tail.first().cloned(); + if tail.len() > 1 { + return Err(format!( + "unexpected extra arguments after `claw {} {}`: {}\nUsage: claw {} [help] [--output-format json]", + rest[0], + tail[0], + tail[1..].join(" "), + rest[0] + )); + } + Ok(CliAction::Models { + action, + output_format, + }) + } // #771: usage/stats/fork are slash-only verbs with no multi-arg match arms "usage" => Err( "interactive_only: `claw usage` is a slash command.\nUse `claw --resume SESSION.jsonl /usage` or start `claw` and run `/usage`." @@ -1337,6 +1362,25 @@ fn parse_args(args: &[String]) -> Result { }), } } + "settings" => { + let tail = &rest[1..]; + if tail.is_empty() { + Ok(CliAction::Config { + section: Some("settings".to_string()), + output_format, + }) + } else if tail.len() == 1 && matches!(tail[0].as_str(), "help" | "--help" | "-h") { + Ok(CliAction::HelpTopic { + topic: LocalHelpTopic::Settings, + output_format, + }) + } else { + Err(format!( + "unexpected extra arguments after `claw settings`: {}\nUsage: claw settings [help] [--output-format json]", + tail.join(" ") + )) + } + } "system-prompt" => parse_system_prompt_args(&rest[1..], model, output_format), "acp" => parse_acp_args(&rest[1..], output_format), "login" | "logout" => Err(removed_auth_surface_error(rest[0].as_str())), @@ -1453,6 +1497,8 @@ fn parse_local_help_action( "system-prompt" => LocalHelpTopic::SystemPrompt, "dump-manifests" => LocalHelpTopic::DumpManifests, "bootstrap-plan" => LocalHelpTopic::BootstrapPlan, + "model" | "models" => LocalHelpTopic::Model, + "settings" => LocalHelpTopic::Settings, _ => return None, }; let has_non_help = rest[1..].iter().any(|a| !is_help_flag(a)); @@ -1518,6 +1564,8 @@ fn parse_single_word_command_alias( "plugins" | "plugin" | "marketplace" => Some(LocalHelpTopic::Plugins), "mcp" => Some(LocalHelpTopic::Mcp), "config" => Some(LocalHelpTopic::Config), + "model" | "models" => Some(LocalHelpTopic::Model), + "settings" => Some(LocalHelpTopic::Settings), "diff" => Some(LocalHelpTopic::Diff), _ => None, }; @@ -1567,6 +1615,8 @@ fn parse_single_word_command_alias( "plugins" | "plugin" | "marketplace" => Some(LocalHelpTopic::Plugins), "mcp" => Some(LocalHelpTopic::Mcp), "config" => Some(LocalHelpTopic::Config), + "model" | "models" => Some(LocalHelpTopic::Model), + "settings" => Some(LocalHelpTopic::Settings), "diff" => Some(LocalHelpTopic::Diff), _ => None, }; @@ -1710,6 +1760,17 @@ fn parse_direct_slash_cli_action( let raw = rest.join(" "); match SlashCommand::parse(&raw) { Ok(Some(SlashCommand::Help)) => Ok(CliAction::Help { output_format }), + Ok(Some(SlashCommand::Status)) => Ok(CliAction::Status { + model, + model_flag_raw: None, + permission_mode, + output_format, + allowed_tools, + }), + Ok(Some(SlashCommand::Sandbox)) => Ok(CliAction::Sandbox { output_format }), + Ok(Some(SlashCommand::Diff)) => Ok(CliAction::Diff { output_format }), + Ok(Some(SlashCommand::Version)) => Ok(CliAction::Version { output_format }), + Ok(Some(SlashCommand::Doctor)) => Ok(CliAction::Doctor { output_format }), Ok(Some(SlashCommand::Agents { args })) => Ok(CliAction::Agents { args, output_format, @@ -7920,6 +7981,21 @@ fn render_help_topic(topic: LocalHelpTopic) -> String { Formats text (default), json Related /config · claw doctor" .to_string(), + LocalHelpTopic::Model => "Models + Usage claw models [help] [--output-format ] + Aliases claw model + Purpose show bounded local model command guidance without entering the REPL + Output supported model-selection surfaces and current config model value + Formats text (default), json + Related /model · claw config model · claw status" + .to_string(), + LocalHelpTopic::Settings => "Settings + Usage claw settings [help] [--output-format ] + Purpose show effective settings/config using the local config envelope + Output same as claw config settings; no provider request or session resume required + Formats text (default), json + Related claw config · claw doctor" + .to_string(), LocalHelpTopic::Diff => "Diff Usage claw diff [--output-format ] Purpose show the diff of changes relative to the expected base commit @@ -7947,10 +8023,77 @@ fn local_help_topic_command(topic: LocalHelpTopic) -> &'static str { LocalHelpTopic::Plugins => "plugins", LocalHelpTopic::Mcp => "mcp", LocalHelpTopic::Config => "config", + LocalHelpTopic::Model => "models", + LocalHelpTopic::Settings => "settings", LocalHelpTopic::Diff => "diff", } } +fn print_models( + action: Option<&str>, + output_format: CliOutputFormat, +) -> Result<(), Box> { + let help_requested = action.is_some_and(|value| matches!(value, "help" | "--help" | "-h")); + if help_requested { + return print_help_topic(LocalHelpTopic::Model, output_format); + } + if let Some(action) = action { + return Err(format!( + "unsupported_models_action: unsupported models action: {action}.\nUsage: claw models [help] [--output-format json]" + ) + .into()); + } + + let configured_model = config_model_for_current_dir(); + let resolved_config_model = configured_model + .as_deref() + .map(resolve_model_alias_with_config); + + match output_format { + CliOutputFormat::Text => { + println!("Models"); + println!(" Default {DEFAULT_MODEL}"); + println!(" Built-in aliases opus, sonnet, haiku"); + if let Some(raw) = configured_model.as_deref() { + println!( + " Config model {raw}{}", + resolved_config_model + .as_deref() + .filter(|resolved| *resolved != raw) + .map(|resolved| format!(" -> {resolved}")) + .unwrap_or_default() + ); + } else { + println!(" Config model "); + } + println!(" Usage claw --model prompt "); + } + CliOutputFormat::Json => { + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "kind": "models", + "action": "list", + "status": "ok", + "default_model": DEFAULT_MODEL, + "aliases": [ + {"name": "opus", "model": resolve_model_alias("opus")}, + {"name": "sonnet", "model": resolve_model_alias("sonnet")}, + {"name": "haiku", "model": resolve_model_alias("haiku")} + ], + "configured_model": configured_model, + "resolved_configured_model": resolved_config_model, + "local_only": true, + "requires_credentials": false, + "requires_provider_request": false, + "message": "Use --model or configure a model in claw settings." + }))? + ); + } + } + Ok(()) +} + fn render_export_help_json() -> serde_json::Value { json!({ "kind": "help", @@ -8188,24 +8331,29 @@ fn render_config_report(section: Option<&str>) -> Result runtime_config.get("env"), - "hooks" => runtime_config.get("hooks"), - "model" => runtime_config.get("model"), + let rendered = match section { + "env" => runtime_config.get("env").map(|value| value.render()), + "hooks" => runtime_config.get("hooks").map(|value| value.render()), + "model" => runtime_config.get("model").map(|value| value.render()), "plugins" => runtime_config .get("plugins") - .or_else(|| runtime_config.get("enabledPlugins")), + .or_else(|| runtime_config.get("enabledPlugins")) + .map(|value| value.render()), "mcp" | "mcp_servers" | "mcpServers" => runtime_config .get("mcp") .or_else(|| runtime_config.get("mcp_servers")) - .or_else(|| runtime_config.get("mcpServers")), - "sandbox" => runtime_config.get("sandbox"), - "permissions" => runtime_config.get("permissions"), - "skills" => runtime_config.get("skills"), - "agents" => runtime_config.get("agents"), + .or_else(|| runtime_config.get("mcpServers")) + .map(|value| value.render()), + "sandbox" => runtime_config.get("sandbox").map(|value| value.render()), + "permissions" => runtime_config + .get("permissions") + .map(|value| value.render()), + "skills" => runtime_config.get("skills").map(|value| value.render()), + "agents" => runtime_config.get("agents").map(|value| value.render()), + "settings" => Some(runtime_config.as_json().render()), other => { lines.push(format!( - " Unsupported config section '{other}'. Use: env, hooks, model, plugins, mcp, sandbox, permissions, skills, or agents." + " Unsupported config section '{other}'. Use: env, hooks, model, plugins, mcp, sandbox, permissions, skills, agents, or settings." )); return Ok(lines.join( " @@ -8215,10 +8363,7 @@ fn render_config_report(section: Option<&str>) -> Result value.render(), - None => "".to_string(), - } + rendered.unwrap_or_else(|| "".to_string()) )); return Ok(lines.join( " @@ -8308,16 +8453,17 @@ fn render_config_json( "permissions" => runtime_config.get("permissions").map(|v| v.render()), "skills" => runtime_config.get("skills").map(|v| v.render()), "agents" => runtime_config.get("agents").map(|v| v.render()), + "settings" => Some(runtime_config.as_json().render()), other => { // #741: populate hint field for unsupported section errors so callers reading // .hint get actionable guidance instead of null let hint = if matches!(other, "list" | "show" | "help" | "info") { format!( - "'claw config {other}' is not a subcommand. To list all config: `claw config`. To inspect a section: `claw config
` where section is one of: env, hooks, model, plugins, mcp, sandbox, permissions, skills, agents." + "'claw config {other}' is not a subcommand. To list all config: `claw config`. To inspect a section: `claw config
` where section is one of: env, hooks, model, plugins, mcp, sandbox, permissions, skills, agents, settings." ) } else { format!( - "'{other}' is not a config section. Supported: env, hooks, model, plugins, mcp, sandbox, permissions, skills, agents." + "'{other}' is not a config section. Supported: env, hooks, model, plugins, mcp, sandbox, permissions, skills, agents, settings." ) }; return Ok(serde_json::json!({ @@ -8327,9 +8473,9 @@ fn render_config_json( "error_kind": "unsupported_config_section", "section": other, "ok": false, - "error": format!("Unsupported config section '{other}'. Use: env, hooks, model, plugins, mcp, sandbox, permissions, skills, or agents."), + "error": format!("Unsupported config section '{other}'. Use: env, hooks, model, plugins, mcp, sandbox, permissions, skills, agents, or settings."), "hint": hint, - "supported_sections": ["env", "hooks", "model", "plugins", "mcp", "sandbox", "permissions", "skills", "agents"], + "supported_sections": ["env", "hooks", "model", "plugins", "mcp", "sandbox", "permissions", "skills", "agents", "settings"], "cwd": cwd.display().to_string(), "loaded_files": loaded_paths.len(), "files": files, 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 092f3f7e..38acc5d4 100644 --- a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs +++ b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs @@ -161,6 +161,52 @@ fn status_and_sandbox_emit_json_when_requested() { assert!(sandbox["filesystem_mode"].as_str().is_some()); } +// #831: direct resume-safe slash commands should use the same local CliAction +// JSON surfaces as their bare subcommands, not interactive_only guidance. +#[test] +fn direct_resume_safe_slash_commands_route_to_local_json_actions_831() { + let root = unique_temp_dir("direct-resume-safe-slash-831"); + fs::create_dir_all(&root).expect("temp dir should exist"); + Command::new("git") + .args(["init", "-q"]) + .current_dir(&root) + .output() + .expect("git init should launch"); + + for (command, expected_kind, expected_status) in [ + ("/version", "version", "ok"), + ("/sandbox", "sandbox", "warn"), + ("/diff", "diff", "ok"), + ("/status", "status", "ok"), + ] { + let output = run_claw(&root, &["--output-format", "json", command], &[]); + assert!( + output.status.success(), + "{command} should route to a local CliAction, stdout:\n{}\n\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let parsed: Value = serde_json::from_str(stdout.trim()) + .unwrap_or_else(|_| panic!("{command} must emit JSON (#831), got: {stdout:?}")); + + assert_eq!(parsed["kind"], expected_kind, "{command} kind: {parsed}"); + assert_eq!( + parsed["status"], expected_status, + "{command} status: {parsed}" + ); + assert_ne!( + parsed["error_kind"], "interactive_only", + "{command} must not emit interactive_only (#831): {parsed}" + ); + assert!( + stderr.is_empty(), + "{command} JSON mode must keep stderr empty (#831): {stderr:?}" + ); + } +} + #[test] fn status_json_surfaces_permission_mode_override_for_security_audit() { let root = unique_temp_dir("status-json-permission-mode"); @@ -1320,8 +1366,8 @@ fn config_json_reports_deprecations_structurally_without_stderr_duplicate_815() } #[test] -fn local_json_surfaces_suppress_config_deprecation_stderr_816() { - let root = unique_temp_dir("global-json-warning-816"); +fn global_json_surfaces_suppress_config_deprecation_stderr_810_821_824() { + let root = unique_temp_dir("global-json-warning-810-821-824"); let config_home = root.join("config-home"); let home = root.join("home"); fs::create_dir_all(&config_home).expect("config home should exist"); @@ -1340,30 +1386,65 @@ fn local_json_surfaces_suppress_config_deprecation_stderr_816() { ("HOME", home.to_str().expect("utf8 home")), ]; + let session_path = write_session_fixture(&root, "resume-config-warning-824", Some("config")); + let resume_config = format!("--resume={}", session_path.to_str().expect("utf8 session")); + for (args, expected_kind, expected_action) in [ ( - &["--output-format", "json", "plugins", "list"][..], + vec!["--output-format", "json", "plugins", "list"], "plugin", "list", ), ( - &["--output-format", "json", "mcp", "list"][..], + vec!["--output-format", "json", "mcp", "list"], "mcp", "list", ), ( - &["--output-format", "json", "doctor"][..], + vec!["--output-format", "json", "doctor"], "doctor", "doctor", ), + (vec!["--output-format", "json", "status"], "status", "show"), + ( + vec!["--output-format", "json", "sandbox"], + "sandbox", + "status", + ), + ( + vec!["--output-format", "json", "system-prompt"], + "system-prompt", + "show", + ), + ( + vec!["--output-format", "json", "skills", "list"], + "skills", + "list", + ), + ( + vec!["--output-format", "json", "agents", "list"], + "agents", + "list", + ), + ( + vec!["--output-format", "json", resume_config.as_str(), "/config"], + "config", + "list", + ), ] { - let output = run_claw(&root, args, &envs); + let output = run_claw(&root, &args, &envs); assert!( output.status.success(), "args={args:?}\nstdout:\n{}\n\nstderr:\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); + assert_eq!( + output.stdout.first(), + Some(&b'{'), + "args={args:?} stdout JSON must start at byte 0, got: {}", + String::from_utf8_lossy(&output.stdout) + ); let parsed: Value = serde_json::from_slice(&output.stdout).expect("stdout should be valid JSON"); assert_eq!(parsed["kind"], expected_kind, "args={args:?}"); @@ -1372,10 +1453,10 @@ fn local_json_surfaces_suppress_config_deprecation_stderr_816() { matches!(parsed["status"].as_str(), Some("ok" | "warn")), "args={args:?} should report successful local status: {parsed}" ); - let stderr = String::from_utf8(output.stderr).expect("stderr utf8"); assert!( - !stderr.contains("field \"enabledPlugins\" is deprecated"), - "successful JSON surface must not leak config deprecation prose to stderr for args={args:?}:\n{stderr}" + output.stderr.is_empty(), + "successful JSON surface must keep stderr empty for args={args:?}, got:\n{}", + String::from_utf8_lossy(&output.stderr) ); } } @@ -1680,9 +1761,9 @@ fn diff_json_changed_file_count_deduplication_733() { #[test] fn prompt_no_arg_json_error_kind_750() { - // #751/#750: `claw prompt --output-format json` with no prompt argument must emit - // error_kind:"missing_prompt" and a non-empty hint. Before #750 it returned - // error_kind:"unknown" + hint:null. + // #751/#750/#823: `claw prompt --output-format json` with no prompt argument must emit + // error_kind:"missing_prompt" with stdout JSON, empty stderr, and a non-empty hint. + // Before #823 the structured envelope could be routed to stderr, leaving stdout empty. use std::process::Command; let root = unique_temp_dir("prompt-no-arg"); fs::create_dir_all(&root).expect("temp dir"); @@ -1697,28 +1778,30 @@ fn prompt_no_arg_json_error_kind_750() { !output.status.success(), "claw prompt with no arg must exit non-zero" ); + assert_eq!( + output.status.code(), + Some(1), + "claw prompt with no arg must exit rc=1 (#823)" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_eq!( + stderr, "", + "claw prompt (no arg) --output-format json must keep stderr empty (#823); got: {stderr}" + ); let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr) - .lines() - .filter(|l| l.starts_with('{')) - .collect::>() - .join(""); - let raw = if stdout.trim().starts_with('{') { - stdout.trim().to_string() - } else { - stderr - }; - let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap_or_else(|_| { - panic!("claw prompt (no arg) --output-format json must emit valid JSON; got: {raw}") + let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|_| { + panic!( + "claw prompt (no arg) --output-format json must emit valid stdout JSON; got: {stdout}" + ) }); assert_eq!( parsed["error_kind"], "missing_prompt", - "claw prompt no-arg must have error_kind:missing_prompt (#750); got: {parsed}" + "claw prompt no-arg must have error_kind:missing_prompt (#750/#823); got: {parsed}" ); let hint = parsed["hint"].as_str().unwrap_or(""); assert!( !hint.is_empty(), - "claw prompt no-arg hint must be non-empty (#750)" + "claw prompt no-arg hint must be non-empty (#750/#823)" ); assert!( hint.contains("claw prompt") || hint.contains("echo"), @@ -1726,6 +1809,50 @@ fn prompt_no_arg_json_error_kind_750() { ); } +#[test] +fn prompt_empty_arg_json_stdout_missing_prompt_823() { + // #823: `claw --output-format json prompt ""` must match the missing prompt + // channel contract: rc=1, stdout JSON, error_kind:"missing_prompt", empty stderr. + use std::process::Command; + let root = unique_temp_dir("prompt-empty-arg-823"); + fs::create_dir_all(&root).expect("temp dir"); + let bin = env!("CARGO_BIN_EXE_claw"); + + let output = Command::new(bin) + .current_dir(&root) + .args(["--output-format", "json", "prompt", ""]) + .output() + .expect("claw prompt empty arg should run"); + assert_eq!( + output.status.code(), + Some(1), + "claw prompt empty arg must exit rc=1 (#823)" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_eq!( + stderr, "", + "claw prompt empty arg --output-format json must keep stderr empty (#823); got: {stderr}" + ); + let stdout = String::from_utf8_lossy(&output.stdout); + let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|_| { + panic!( + "claw prompt empty arg --output-format json must emit valid stdout JSON; got: {stdout}" + ) + }); + assert_eq!( + parsed["error_kind"], "missing_prompt", + "claw prompt empty arg must have error_kind:missing_prompt (#823); got: {parsed}" + ); + assert_eq!( + parsed["action"], "abort", + "claw prompt empty arg must retain abort action (#823); got: {parsed}" + ); + assert!( + parsed["hint"].as_str().map_or(false, |h| !h.is_empty()), + "claw prompt empty arg missing_prompt hint must be non-empty (#823)" + ); +} + #[test] fn flag_value_errors_have_error_kind_and_hint_756() { // #756: missing/invalid flag-value errors must emit typed error_kind + non-null hint. @@ -2316,10 +2443,10 @@ fn session_with_unknown_subcommand_returns_interactive_only_not_credentials_767( #[test] fn slash_only_verbs_with_args_return_interactive_only_not_credentials_770() { // #770: `claw cost breakdown`, `claw clear --force`, `claw memory reset`, - // `claw ultraplan bogus`, `claw model opus extra` all fell through to - // CliAction::Prompt and reached the credential gate, returning - // error_kind:"missing_credentials". These are all slash-only commands; - // any multi-token invocation should return interactive_only guidance. + // and `claw ultraplan bogus` all fell through to CliAction::Prompt and + // reached the credential gate, returning error_kind:"missing_credentials". + // These remain slash-only commands; multi-token invocations should return + // interactive_only guidance. `model` is now a local bounded surface (#807). let root = unique_temp_dir("slash-verbs-770"); fs::create_dir_all(&root).expect("temp dir should exist"); @@ -2328,7 +2455,6 @@ fn slash_only_verbs_with_args_return_interactive_only_not_credentials_770() { &["clear", "--force"], &["memory", "reset"], &["ultraplan", "bogus"], - &["model", "opus", "extra"], ]; for args in cases { @@ -2503,13 +2629,35 @@ fn interactive_only_guard_batch_769_to_771() { &["clear", "--force"], &["memory", "reset"], &["ultraplan", "bogus"], - &["model", "opus", "extra"], // #771: usage/stats/fork &["usage", "extra"], &["stats", "extra"], &["fork", "newbranch"], ]; + let model_output = run_claw( + &root, + &["--output-format", "json", "model", "opus", "extra"], + &[], + ); + assert!( + !model_output.status.success(), + "claw model opus extra should exit non-zero" + ); + let model_stdout = String::from_utf8_lossy(&model_output.stdout); + let model_json: serde_json::Value = serde_json::from_str(model_stdout.trim()) + .unwrap_or_else(|_| panic!("claw model opus extra should emit JSON, got: {model_stdout}")); + assert_eq!( + model_json["error_kind"], "unexpected_extra_args", + "claw model opus extra should now stay local and typed (#807), not missing_credentials: {model_json}" + ); + assert!( + model_json["hint"] + .as_str() + .is_some_and(|hint| !hint.is_empty()), + "claw model opus extra should include a usage hint: {model_json}" + ); + for args in cases { let full_args: Vec<&str> = std::iter::once("--output-format") .chain(std::iter::once("json")) @@ -3899,6 +4047,86 @@ fn diff_non_git_dir_has_error_kind_and_hint_801() { ); } +fn assert_local_json_without_missing_credentials( + output: &std::process::Output, + expected_kind: &str, +) -> serde_json::Value { + assert_eq!( + output.status.code(), + Some(0), + "local JSON command should exit 0" + ); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !stdout.trim().is_empty(), + "local JSON command must emit stdout JSON" + ); + assert!( + stderr.is_empty(), + "local JSON command must keep stderr empty, got: {stderr:?}" + ); + assert!( + !stdout.contains("missing_credentials"), + "local JSON command must not hit provider credential startup: {stdout}" + ); + let j: serde_json::Value = serde_json::from_str(stdout.trim()) + .unwrap_or_else(|_| panic!("stdout must be parseable JSON, got: {stdout:?}")); + assert_eq!(j["status"], "ok", "local JSON status: {j}"); + assert_eq!(j["kind"], expected_kind, "local JSON kind: {j}"); + j +} + +// #807: model/model(s) JSON/help surfaces must stay bounded and local. +#[test] +fn models_json_and_model_help_json_are_local_807() { + let root = unique_temp_dir("models-local-json-807"); + std::fs::create_dir_all(&root).expect("create temp dir"); + + let models = run_claw(&root, &["models", "--output-format", "json"], &[]); + let models_json = assert_local_json_without_missing_credentials(&models, "models"); + assert_eq!( + models_json["action"], "list", + "models action: {models_json}" + ); + assert_eq!( + models_json["requires_provider_request"], false, + "models must be local: {models_json}" + ); + + let help = run_claw(&root, &["model", "help", "--output-format", "json"], &[]); + let help_json = assert_local_json_without_missing_credentials(&help, "help"); + assert_eq!( + help_json["command"], "models", + "model help command: {help_json}" + ); +} + +// #808: settings JSON/help surfaces must stay bounded and local. +#[test] +fn settings_json_and_help_json_are_local_808() { + let root = unique_temp_dir("settings-local-json-808"); + std::fs::create_dir_all(&root).expect("create temp dir"); + + let settings = run_claw(&root, &["settings", "--output-format", "json"], &[]); + let settings_json = assert_local_json_without_missing_credentials(&settings, "config"); + assert_eq!( + settings_json["action"], "show", + "settings action: {settings_json}" + ); + assert_eq!( + settings_json["section"], "settings", + "settings section: {settings_json}" + ); + + let help = run_claw(&root, &["settings", "help", "--output-format", "json"], &[]); + let help_json = assert_local_json_without_missing_credentials(&help, "help"); + assert_eq!( + help_json["command"], "settings", + "settings help command: {help_json}" + ); +} + // #825: unknown single-word subcommand must return command_not_found, not // fall through to missing_credentials after provider startup. #[test] @@ -4111,13 +4339,13 @@ fn non_resume_safe_interactive_only_hint_omits_resume_suggestion() { fn resume_safe_interactive_only_hint_includes_resume_suggestion() { let root = unique_temp_dir("resume-hint-829"); std::fs::create_dir_all(&root).expect("create temp dir"); - let output = run_claw(&root, &["--output-format", "json", "/diff"], &[]); + let output = run_claw(&root, &["--output-format", "json", "/compact"], &[]); let stdout = String::from_utf8_lossy(&output.stdout); let j: serde_json::Value = serde_json::from_str(stdout.trim()) - .unwrap_or_else(|_| panic!("/diff must emit JSON (#829), got: {stdout:?}")); + .unwrap_or_else(|_| panic!("/compact must emit JSON (#829), got: {stdout:?}")); let hint = j["hint"].as_str().unwrap_or(""); assert!( hint.contains("--resume"), - "/diff hint must suggest --resume (it is resume-safe) (#829): hint={hint:?}" + "/compact hint must suggest --resume (it is resume-safe and not a local direct action) (#829): hint={hint:?}" ); }