Compare commits

...
17 Commits
Author SHA1 Message Date
bellman c91a3062d5 fix: normalize Anthropic model routing 2026-06-03 22:20:23 +09:00
bellman 54d785d0c0 fix: preserve DeepSeek V4 thinking history 2026-06-03 21:53:54 +09:00
bellman 36218ac1b1 fix: report config file load statuses 2026-06-03 21:46:47 +09:00
bellman 6388a2ba3f fix: parse object-style hook config 2026-06-03 21:23:00 +09:00
bellman 9c8375da99 feat: import project instruction rules 2026-06-03 21:01:48 +09:00
Heo, Sung 0cef5390f7 fix: resolve clippy pedantic warnings
Apply the bounded clippy pedantic cleanup from PR #3009.
2026-06-03 20:39:05 +09:00
bellman 1bd18be372 feat: add GitShow output formats 2026-06-03 20:28:12 +09:00
bellman d07664b44c fix: keep hooks clean and close bash stdin 2026-06-03 20:20:04 +09:00
bellman ce116d9dfa fix: expose binary provenance in local JSON 2026-06-03 20:03:39 +09:00
bellman 372ec09c47 test: cover roadmap helper missing path 2026-06-03 19:31:45 +09:00
bellman 78f446f68e test: add argv-safe dogfood probes 2026-06-03 19:26:55 +09:00
bellman 55da189315 fix: keep JSON control surfaces local 2026-06-03 19:12:20 +09:00
bellman e752b05425 fix: load common instruction files and typed unknown commands 2026-06-03 18:54:36 +09:00
bellman 0c83a26dc7 test: cover resumed unknown slash command 2026-06-03 18:40:37 +09:00
bellman 286638fa04 docs: close ROADMAP 828 approval slash evidence 2026-06-03 18:29:16 +09:00
bellman 47d6c3d5d3 docs: close ROADMAP 829 interactive hint evidence 2026-06-03 18:26:37 +09:00
bellman f529fb0e55 fix: classify mcp show missing server argument 2026-06-03 18:22:23 +09:00
21 changed files with 2976 additions and 316 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ cd "$repo_root"
if [[ -x scripts/roadmap-check-ids.sh ]]; then
echo "pre-push: scripts/roadmap-check-ids.sh" >&2
scripts/roadmap-check-ids.sh
scripts/roadmap-check-ids.sh >&2
fi
if [[ "${SKIP_CLAW_PRE_PUSH_BUILD:-}" == "1" ]]; then
+1
View File
@@ -8,6 +8,7 @@ archive/
# Claw Code local artifacts
.claw/settings.local.json
.claw/sessions/
.claw/rules.local/
.clawhip/
status-help.txt
# Legacy Python port session scratch artifacts
+135 -29
View File
@@ -6303,7 +6303,7 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed)
422. **`export --output-format json` and `--resume latest` report the same "no managed sessions" scenario using two different `kind` codes — `no_managed_sessions` vs `session_load_failed` — making "no session found" undetectable by a single kind-code check** — dogfooded 2026-04-30 KST (UTC+9) by Jobdori on `e939777f`. Running `claw export --output-format json` with no session present returns (on stderr, exit 1): `{"error":"no managed sessions found in .claw/sessions/<fingerprint>/","hint":"Start \`claw\` to create a session, then rerun with \`--resume latest\`.\nNote: claw partitions sessions per workspace fingerprint; sessions from other CWDs are invisible.","kind":"no_managed_sessions","type":"error"}`. Running `claw --resume latest /status --output-format json` with no session present returns (on stderr, exit 1): `{"error":"failed to restore session: no managed sessions found in .claw/sessions/<fingerprint>/","hint":"Start \`claw\` to create a session, then rerun with \`--resume latest\`.\nNote: claw partitions sessions per workspace fingerprint; sessions from other CWDs are invisible.","kind":"session_load_failed","type":"error"}`. Both describe the same root condition — there are no sessions to operate on — but they expose it via different `kind` discriminants. Automation that checks `kind == "no_managed_sessions"` to detect a cold workspace will miss the `--resume` path's `session_load_failed`, and vice versa. A wrapper that guards "run with --resume only if a session exists" must special-case both codes. The hint text is identical between them, suggesting the messages are logically equivalent. Additionally neither code matches the proposed canonical names `session_not_found` / `session_load_failed` as stable `ErrorKind` discriminants described in ROADMAP #77's fix shape, which explicitly proposes typed error-kind codes for session lifecycle failures. **Required fix shape:** (a) unify "no sessions found for this workspace fingerprint" under a single canonical `kind` code — either `no_managed_sessions` or `session_not_found` — used consistently by every command path that encounters an empty session registry; (b) if `session_load_failed` is a more general category (covering e.g. corrupt session files, IO errors, schema version mismatches), it should nest a concrete `reason:"no_managed_sessions"` or `reason:"session_not_found"` sub-field so callers can distinguish "empty registry" from "found but unreadable"; (c) align with the canonical error-kind contract proposed in #77; (d) add regression coverage proving `export` and `--resume latest` in an empty workspace both return an error with the same top-level `kind` code. **Why this matters:** session guard-rails in orchestration need a single stable `kind` to detect cold workspaces without enumerating all possible no-session synonyms. Two divergent codes for the same condition make defensive automation brittle and contradict the promise of machine-readable error envelopes. Source: Jobdori live dogfood, `e939777f`, 2026-04-30 KST (UTC+9).
407. **`config --output-format json` returns `files[].loaded:false` with no `load_error`, `not_found`, or `skip_reason` field — automation cannot distinguish "file does not exist", "file exists but parse failed", and "file exists but was skipped by policy" from the same `loaded:false` value; also `loaded_files` and `merged_keys` are bare integers with no per-file attribution** — dogfooded 2026-04-30 by Jobdori on `e939777f`. Running `./claw --output-format json config` on a workspace with 5 discovered config files returns `{"kind":"config","cwd":"...","files":[{"loaded":false,"path":"/Users/yeongyu/.claw.json","source":"user"},{"loaded":true,"path":"/Users/yeongyu/.claw/settings.json","source":"user"},{"loaded":true,"path":"/Users/yeongyu/clawd/claw-code/.claw.json","source":"project"},{"loaded":false,"path":"/Users/yeongyu/clawd/claw-code/.claw/settings.json","source":"project"},{"loaded":false,"path":"/Users/yeongyu/clawd/claw-code/.claw/settings.local.json","source":"local"}],"loaded_files":2,"merged_keys":2}`. Three of five files have `loaded:false` with no accompanying `not_found:true`, `parse_error`, `io_error`, or `skip_reason`; automation must stat each path separately to guess why. Also `loaded_files:2` and `merged_keys:2` are bare counts — ambiguous whether `merged_keys:2` means 2 total top-level JSON keys across all files or 2 unique merged settings. **Required fix shape:** (a) add `not_found: bool` and optional `load_error: string` to each `files[]` entry so callers can distinguish missing, parse-broken, and policy-skipped files without filesystem probing; (b) document or rename `merged_keys` as `merged_setting_count` or `total_merged_keys` to remove the int-semantics ambiguity; (c) optionally add `merged_keys_by_file: [{path, keys}]` for attribution; (d) add regression coverage proving `files[]` entries with `loaded:false` carry at minimum `not_found` distinguishing non-existent paths from load failures. Source: Jobdori live dogfood, `e939777f`, 2026-04-30.
407. **DONE — `config --output-format json` returns structured file load states instead of bare `loaded:false` ambiguity**fixed 2026-06-03 in `fix: report config file load statuses`. `claw config --output-format json` now emits `files[].status` (`loaded`, `not_found`, `skipped`, `load_error`), `reason`/`skip_reason`, and `detail` where applicable, plus top-level `load_error`, `merged_key_count`, and `merged_keys_meaning`. The config list surface is best-effort so one broken settings file no longer erases the rest of the discovery report, while section-specific config requests still preserve the typed nonzero parse-error envelope. Regression coverage: `inspect_classifies_missing_loaded_and_legacy_skipped_files`, `inspect_reports_parse_errors_but_keeps_valid_merged_config`, `config_json_reports_structured_unloaded_file_reasons_407`, `config_json_list_reports_parse_errors_without_dropping_file_statuses_407`, and existing `config_parse_error_has_typed_error_kind_and_hint_764`.
408. **`status --output-format json` `workspace.changed_files` is ambiguous — on a workspace with 5 untracked files, `changed_files:5`, `staged_files:0`, `unstaged_files:0`, `untracked_files:5`; it is unclear whether `changed_files` is the sum of all four git-status categories or only a subset; automation cannot tell if `changed_files:5` means "5 tracked modified" or "5 total non-clean files including untracked"** — dogfooded 2026-04-30 by Jobdori on `e939777f`. Running `./claw --output-format json status` returns `{"workspace":{"changed_files":5,"staged_files":0,"unstaged_files":0,"untracked_files":5,...}}``changed_files==untracked_files==5` with staged and unstaged both zero. The field name `changed_files` implies "modified tracked files" but the value equals the untracked count, not `staged+unstaged`. Without a comment or documented definition, automation must probe whether `changed_files = staged + unstaged` (excludes untracked) or `changed_files = staged + unstaged + untracked + conflicted` (total dirty). Also `git_state:"dirty · 5 files · 5 untracked"` repeats the same data as a prose string alongside the structured integer fields — redundant human-readable string alongside machine-readable integers. **Required fix shape:** (a) document and stabilize `changed_files` as either `tracked_dirty_count` (staged+unstaged only) or `total_non-clean_count` (staged+unstaged+untracked+conflicted) and rename to remove the ambiguity; (b) ensure a machine consumer can compute `is_clean` as a single boolean field without interpreting `git_state` prose; (c) deprecate or remove `git_state` prose string now that all its constituent counts are available as integers; (d) add regression coverage proving `changed_files` semantics against a workspace with staged, unstaged, untracked, and conflicted files. Source: Jobdori live dogfood, `e939777f`, 2026-04-30.
@@ -6348,7 +6348,7 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed)
423. **`claw prompt` does not read prompt text from stdin when no positional prompt arg is provided — `echo "what is 2+2" | claw prompt --output-format json` returns `kind:"unknown" error:"prompt subcommand requires a prompt string"` instead of consuming stdin** — dogfooded 2026-05-11 by Jobdori on `3c563fa1` in response to Clawhip pinpoint nudge at `1503222644739276951`. Reproduction: `echo "what is 2+2" | claw prompt --output-format json``{"error":"prompt subcommand requires a prompt string","hint":null,"kind":"unknown","type":"error"}` exit 1. Same for `claw prompt --output-format json` with stdin redirected from a file. The most common Unix automation pattern (`cmd | claw prompt`) is broken because the prompt subcommand only reads the positional argument, never falls through to stdin. **Sibling envelope-kind bug:** the error `kind` is `"unknown"` instead of a typed `"missing_argument"` or `"validation_error"`. The `unknown` discriminator is the catch-all bucket — automation that switches on `kind` to differentiate input-validation errors from runtime errors gets no signal here. **Required fix shape:** (a) when `prompt` subcommand has no positional prompt arg AND stdin is not a TTY (i.e., piped or redirected), read stdin to EOF and use that as the prompt; (b) emit `kind:"missing_argument"` (not `"unknown"`) when both positional arg and stdin are absent; (c) add `--prompt-stdin` or `--stdin` opt-in flag for explicit control; (d) regression tests: `echo X | claw prompt --output-format json` reaches the runtime with prompt=X, AND `claw prompt < /dev/null` returns `kind:"missing_argument"` exit 1. **Why this matters:** Unix pipelines are the foundation of CLI automation. Every other major CLI (curl, jq, gh, kubectl) accepts stdin as the primary input when no positional arg is given. Breaking this convention forces automation to either inline the prompt as a shell-quoted string (escaping nightmare for multiline/code) or write to a temp file first. The `kind:"unknown"` error category compounds the problem by making the failure indistinguishable from a runtime crash. Source: Jobdori live dogfood, `3c563fa1`, 2026-05-11.
424. **`--model` rejects bare canonical Anthropic model names (`claude-opus-4-7`, `claude-opus-4-6`, `claude-sonnet-4-6`) as `invalid_model_syntax` — only short aliases (`opus`, `sonnet`, `haiku`) and full prefixed form (`anthropic/claude-opus-4-7`) work; sibling: error message stale-suggests `claude-opus-4-6` not `4-7`** — dogfooded 2026-05-11 by Jobdori on `6c0c305a` in response to Clawhip pinpoint nudge at `1503230194889134103`. Reproduction: `claw --model claude-opus-4-7 status --output-format json``{"error":"invalid model syntax: 'claude-opus-4-7'. Expected provider/model (e.g., anthropic/claude-opus-4-6) or known alias (opus, sonnet, haiku)","kind":"invalid_model_syntax"}`. Same for `claude-opus-4-6`, `claude-sonnet-4-6`. Forcing `--model anthropic/claude-opus-4-7` works (`model:"anthropic/claude-opus-4-7"`, `model_source:"flag"`). Three problems compounded: (a) Anthropic-canonical model names without provider prefix are rejected even though the `claude-` prefix unambiguously identifies the provider; (b) the error suggests `anthropic/claude-opus-4-6` as the example — `4-7` shipped 2026-04-16 and is the current production Anthropic frontier model, the suggestion is one model behind; (c) the alias list `opus, sonnet, haiku` doesn't disambiguate version (which `opus` does the alias resolve to — `opus-4-6` or `opus-4-7`?). **Required fix shape:** (a) accept bare `claude-*` and `gpt-*` model names as canonical-named-without-prefix and route via name-prefix detection (already implemented for prefix-routed mode); (b) update the example in `invalid_model_syntax` error to current frontier (`anthropic/claude-opus-4-7`); (c) document or expose `opus` → exact-version mapping in the error message and in `claw doctor`/`status` output (`model_alias_resolved_to: "claude-opus-4-7"`); (d) regression test: `claw --model claude-opus-4-7 status --output-format json` returns `model_source:"flag"`, not `kind:"invalid_model_syntax"`. **Sibling bug observed in same probe:** `enabledPlugins` deprecation warning repeats 3 times in stderr for the same `~/.claw/settings.json` load — config file is being loaded/parsed 3 times during a single `status` invocation. **Why this matters:** every Anthropic doc, every CCAPI route, every internal tooling references models by their bare canonical name (`claude-opus-4-7`). Forcing the `anthropic/` prefix breaks copy-paste from Anthropic's own examples and adds a redundant token to every invocation. The stale `4-6` suggestion in the error message actively misdirects users away from the current model. Source: Jobdori live dogfood, `6c0c305a`, 2026-05-11.
424. **DONE — `--model` accepts bare canonical provider model names and Anthropic routing prefixes are stripped before provider calls**fixed 2026-06-03 in `fix: normalize Anthropic model routing`. `validate_model_syntax()` now accepts unambiguous bare `claude-*` and `gpt-*` model IDs while preserving raw model provenance, and Anthropic `/v1/messages` plus `/v1/messages/count_tokens` request bodies strip the CLI-only `anthropic/` routing prefix so default/alias models do not reach Anthropic as `anthropic/claude-*`. Existing `qwen-*`/`grok-*` prefix-hint behavior remains intentionally unchanged for provider families whose bare names are ambiguous with DashScope/xAI routing. Regression coverage: `standard_messages_body_strips_anthropic_routing_prefix`, `send_message_strips_anthropic_routing_prefix_on_wire`, `default_model_alias_uses_anthropic_routing_prefix`, and the bare `--model=claude-opus-4-6 status` / `--model gpt-4 prompt` parser assertions in `parses_single_word_command_aliases_without_falling_back_to_prompt_mode`.
425. **Config file precedence (`.claw/settings.json` always wins over `.claw.json`) is undocumented in user-facing surfaces — `config --output-format json` reports both files as `loaded:true` with no `precedence_rank` or `wins_for_keys` attribution; sibling: deprecation warning fires 4× per status invocation (was 3× in #424, regression upward)** — dogfooded 2026-05-11 by Jobdori on `d7dbe951` in response to Clawhip pinpoint nudge at `1503237744451649537`. Reproduction: create `.claw.json` with `{"model":"anthropic/claude-sonnet-4-6"}` and `.claw/settings.json` with `{"model":"anthropic/claude-opus-4-7"}` in the same workspace. `claw status --output-format json` returns `model:"anthropic/claude-opus-4-7", model_source:"config"`. Reverse the files (.claw.json=opus, settings.json=sonnet) → `model:"anthropic/claude-sonnet-4-6"`. Confirmed: `.claw/settings.json` **always** wins over `.claw.json` for conflicting keys, regardless of file mtime or alphabetical order. `claw config --output-format json` reports both as `loaded:true` with no `precedence_rank`, `effective_for_keys`, or `shadowed_keys` attribution. The only signal of precedence is the final merged value in `status` — automation cannot programmatically discover which file contributed which key without re-implementing the merge logic. **Sibling bug (regression from #424):** the `enabledPlugins` deprecation warning now fires **4 times** in stderr per single `status` invocation (was 3× in #424's probe at HEAD `6c0c305a`; current HEAD `d7dbe951` shows 4×). Config load count went up by 1. **Sibling bug observed in config-section probe:** `claw config model --output-format json` with a `.claw.json` that contains a benign unknown key (e.g., `"alpha":"x"`) returns `{"error":"/path/.claw.json: unknown key \"alpha\" (line 1)","kind":"unknown"}` — the entire config command fails with a generic `unknown` kind instead of (a) tolerating unrecognized keys with a warning, or (b) emitting a typed `kind:"unknown_key"` error scoped to the offending file/key. **Required fix shape:** (a) document precedence order in `USAGE.md` (`.claw/settings.local.json > .claw/settings.json > .claw.json` for project scope; `user`/`system` scope at each layer); (b) add `precedence_rank:int` and optional `wins_for_keys:[string]` / `shadowed_keys:[string]` to each entry in `config --output-format json` `files[]`; (c) dedupe the deprecation warning to fire **once per discovered file** instead of N× per load pass; (d) make `config <section> --output-format json` tolerate unknown keys with warnings, OR emit `kind:"unknown_key"` with `path:` and `key:` fields scoped to the offending file. **Why this matters:** users mixing legacy `.claw.json` with new `.claw/settings.json` have no way to verify which file is actually controlling their runtime. The undocumented precedence + missing per-key attribution forces trial-and-error to debug config drift. Cross-references #407 (config files no load_error) and #415 (config section returns merged_keys count not values). Source: Jobdori live dogfood, `d7dbe951`, 2026-05-11.
@@ -7759,7 +7759,11 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed)
795. **`claw skills install /nonexistent` returned `skill_not_found + hint:null` and `claw skills uninstall x` returned `unsupported_skills_action + hint:null`** — dogfooded 2026-05-27 on `491f179a`. Both error kinds were missing from `fallback_hint_for_error_kind` table, so even though classify returned a typed kind, the hint field was always null. Fix: added `"skill_not_found"` → hint suggesting `claw skills list` / `claw skills install`; added `"unsupported_skills_action"` → hint listing supported actions. Integration test `skills_install_not_found_and_unsupported_action_have_hints_795` covers both paths. 57 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori skills lifecycle probe on `491f179a`, 2026-05-27.
796. **`claw agents show <name> <extra>` and `claw skills show <name> <extra>` returned confusing `agent_not_found`/`skill_not_found` for the concatenated "name extra" string** — dogfooded 2026-05-27 on `18b4cee5`. `join_optional_args` passes all tokens as a space-joined string; both `show` handlers called `split_once(' ')` to extract the name but did not check if the remainder (after the first split) contained additional tokens. Extra positional args (including `--flags`) became part of the "name", silently mangling the lookup. Fix: added second `split_once(' ')` on the extracted name; if the result has two parts, return `unexpected_extra_args` with a usage hint. Valid single-name lookups are unaffected. Two new integration tests `agents_show_extra_positional_arg_returns_unexpected_extra_796`, `skills_show_extra_positional_arg_returns_unexpected_extra_796`. 59 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori agents/skills show extra-arg probe on `18b4cee5`, 2026-05-27.
797. **Installed `claw version --output-format json` reports `git_sha:null` / `Git SHA unknown`, so dogfood cannot tie the binary under test to a source revision** — dogfooded 2026-05-27 from `#clawcode-building-in-public` using the installed `/home/bellman/.cargo/bin/claw` binary in a clean `ultraworkers/claw-code` checkout. `claw version --output-format json` returned `{"kind":"version","version":"0.1.0","git_sha":null,"target":null,"build_date":"2026-03-31"...}` while `claw status --output-format json` only reported workspace state (`git_branch`, clean/dirty counts) and did not provide any executable-vs-workspace provenance comparison. This is a clawability gap in event/log opacity and stale-binary confusion: an operator can run `doctor/status/version` successfully but still cannot prove which commit the installed CLI came from, whether it matches `origin/main`, or whether the observed behavior is from a stale packaged binary. **Required fix shape:** (a) embed build git SHA/target/build provenance in installed/release binaries whenever the source tree is available; (b) when provenance is missing, emit a typed `binary_provenance.status:"unknown"` rather than only `git_sha:null`; (c) have `status`/`doctor` include a redaction-safe comparison between executable provenance and workspace HEAD when running inside a git checkout; (d) add regression/packaging coverage proving release/local install paths preserve or explicitly classify provenance. **Why this matters:** dogfood reports and automation need to distinguish current-source failures from stale or unknown binary lineage before opening/rebasing/closing PRs. Source: gaebal-gajae live dogfood on 2026-05-27; active repo checkout had open PR #3124 DIRTY with no checks and PR #3125 CLEAN, but the installed binary itself could not identify its source revision.
797. **DONE — Installed `claw version --output-format json` reports `git_sha:null` / `Git SHA unknown`, so dogfood cannot tie the binary under test to a source revision** — dogfooded 2026-05-27 from `#clawcode-building-in-public` using an installed binary in a clean `ultraworkers/claw-code` checkout. The gap was that version/status/doctor did not provide a structured executable-vs-workspace provenance object when build metadata was missing or stale. [SCOPE: claw-code]
**Fix applied.** `version --output-format json` now includes a `binary_provenance` object with `status:"known"|"unknown"`, build git SHA, target, build date, executable path, workspace HEAD SHA, `workspace_match`, and a structured hint when provenance is missing or mismatched. `status --output-format json` exposes the same object, and `doctor --output-format json` includes it in the `system` check so dogfood reports can distinguish current-source failures from stale or unknown binary lineage.
**Verification.** `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli version_status_doctor_include_binary_provenance_797 -- --nocapture`; `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli version_emits_json_when_requested -- --nocapture`; `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli doctor_and_resume_status_emit_json_when_requested -- --nocapture`; `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli --test output_format_contract -- --nocapture`; direct probes `cargo run --manifest-path rust/Cargo.toml -q -p rusty-claude-cli -- --output-format json version` and `cargo run --manifest-path rust/Cargo.toml -q -p rusty-claude-cli -- --output-format json status`; `cargo build --manifest-path rust/Cargo.toml --workspace --locked`.
798. **`claw plugins show <name> <extra-arg>` returned `unexpected_extra_args` + `hint:null`** — dogfooded 2026-05-27 on `9976585f`. The plugins arg parser at the top level emitted `"unexpected extra arguments after 'claw plugins show ...': ..."` with no `\n` delimiter (parity gap with #791 config fix). Fix: appended `\nUsage: claw plugins [list|show <id>|...]` to the error format string. Integration test `plugins_extra_args_have_non_null_hint_797`. Committed as `bff37000`. 60 CLI contract tests pass. [SCOPE: claw-code]
@@ -7778,14 +7782,34 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed)
805. **`claw skills show <not-found>` 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 <not-found>` 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]
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]
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]
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. **DONE — 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. The current parser routes these as bounded local surfaces.
**Fix applied.** `help`, `version`, `mcp`, and `plugins` now resolve to local `CliAction` paths with parsed `CliOutputFormat::Json`; `parse_local_help_action()` maps `mcp` and `plugins` help topics directly to local JSON help envelopes.
**Verification.** Static evidence in `rust/crates/rusty-claude-cli/src/main.rs`: `wants_help`/`wants_version` preserve `CliOutputFormat::Json`, `parse_local_help_action()` maps `mcp` and `plugins` to local help topics, and match arms route `mcp`/`plugins` to local handlers before prompt/provider startup.
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. **DONE — 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. Current code parses trailing JSON mode before local dispatch and routes JSON abort envelopes to stdout.
**Fix applied.** Trailing `--output-format json` is parsed globally before local command matching, so inventory/error surfaces keep their typed local JSON envelopes instead of falling through to runtime/provider startup. The top-level JSON abort handler routes structured errors to stdout.
**Verification.** Static evidence in `parse_args()` shows global `--output-format` parsing before local command matching; focused tests cover representative affected surfaces including `agents_list_flag_shaped_filter_returns_unknown_option_792`, `plugins_list_flag_shaped_filter_returns_cli_parse_on_stdout_793_817`, `diff_non_git_dir_has_error_kind_and_hint_801`, and resume/export abort-envelope checks around #819/#820/#823.
812. **`claw --output-format json doctor --help` must stay a local help fast path and never fall through into runtime/provider startup** — dogfooded 2026-05-28 04:01 UTC after #701 worktree drift. The reported repro was `cargo run -q --bin claw -- --output-format json doctor --help`, which did not produce local help promptly and had to be killed, while the positive control `cargo run -q --bin claw -- --output-format json --help` emitted valid JSON help. Fresh bounded repro on branch `fix/doctor-help-json-local` did not reproduce the hang on current code (`timeout 5s cargo run -q --bin claw -- --output-format json doctor --help` exited 0 with a `kind:"help"`/`status:"ok"` doctor help envelope), which means the parser fast path is present but under-tested for this exact dogfood surface.
812. **DONE — `claw --output-format json doctor --help` must stay a local help fast path and never fall through into runtime/provider startup** — dogfooded 2026-05-28 04:01 UTC after #701 worktree drift. The reported repro was `cargo run -q --bin claw -- --output-format json doctor --help`, which did not produce local help promptly and had to be killed, while the positive control `cargo run -q --bin claw -- --output-format json --help` emitted valid JSON help. Fresh bounded repro on branch `fix/doctor-help-json-local` did not reproduce the hang on current code (`timeout 5s cargo run -q --bin claw -- --output-format json doctor --help` exited 0 with a `kind:"help"`/`status:"ok"` doctor help envelope), and current regression coverage preserves this fast path.
**Pinpoint.** The guarded path is `rust/crates/rusty-claude-cli/src/main.rs`: global `--output-format json` is parsed before `rest`, `parse_local_help_action()` maps `doctor --help` to `CliAction::HelpTopic { topic: Doctor }`, and `print_help_topic()` must return without calling `run_doctor()`, `LiveCli`, provider setup, session resume, or runtime startup. The previous risk class is help fallthrough: treating `doctor --help` as prompt text or as `doctor` diagnostics would either hit provider/session startup or run checks instead of local help.
@@ -7795,13 +7819,13 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed)
**Verification.** Regression tests: `doctor_help_json_is_local_structured_and_bounded_702` and `doctor_help_text_stays_plaintext_and_local_702` in `rust/crates/rusty-claude-cli/tests/output_format_contract.rs`; focused command repros recorded in `/tmp/claw_doctor_help_json.out` and `/tmp/claw_doctor_help_json.err` during the doctor-help fix branch.
813. **Dogfood probe shell-string loops can fabricate CLI argv failures for JSON help surfaces** — dogfooded 2026-05-28 after #3185 merged. A verification loop used a single shell string variable (`cmd="--output-format json doctor --help"`; then `cargo run -q --bin claw -- $cmd | python3 -c 'json.load(...)'`). The resulting channel transcript showed `unknown option: --output-format json doctor --help` and Python JSON parse stack noise, even though explicit argv invocations on fresh `main` all returned valid JSON: `cargo run -q --bin claw -- --output-format json doctor --help`, `cargo run -q --bin claw -- doctor --help --output-format json`, and `cargo run -q --bin claw -- help doctor --output-format json`. This is a dogfood-harness test-brittleness / event-log opacity gap, not a product parser regression. The log made a probe-construction mistake look like a claw-code failure.
813. **DONE — Dogfood probe shell-string loops can fabricate CLI argv failures for JSON help surfaces** — dogfooded 2026-05-28 after #3185 merged. A verification loop used a single shell string variable (`cmd="--output-format json doctor --help"`; then `cargo run -q --bin claw -- $cmd | python3 -c 'json.load(...)'`). The resulting channel transcript showed `unknown option: --output-format json doctor --help` and Python JSON parse stack noise, even though explicit argv invocations on fresh `main` all returned valid JSON: `cargo run -q --bin claw -- --output-format json doctor --help`, `cargo run -q --bin claw -- doctor --help --output-format json`, and `cargo run -q --bin claw -- help doctor --output-format json`. This is a dogfood-harness test-brittleness / event-log opacity gap, not a product parser regression. The log made a probe-construction mistake look like a claw-code failure.
**Required fix shape.** Add a tiny argv-safe dogfood helper (script or documented recipe) that runs CLI probes as explicit argv arrays rather than interpolated shell strings, captures stdout/stderr separately, and labels probe-construction failures distinctly from product failures. For ad-hoc shell loops, prefer arrays/functions (`run_probe --output-format json doctor --help`) over `$cmd` strings; never pipe unknown stdout directly into a JSON parser without first recording rc/stdout/stderr.
**Fix applied.** Added `scripts/dogfood-probe.py`, an argv-safe helper that accepts a target executable plus arguments after `--`, invokes `subprocess.run` without shell interpolation, records the exact argv vector, captures rc/stdout/stderr as separate fields, and labels `timeout`, `probe_error`, and `product_error` separately. Its optional `--stdout-json-byte0` assertion requires stdout to be parseable JSON starting at byte 0, so JSON parser stack noise is replaced by a structured probe result.
**Acceptance.** Future dogfood reports for argv-sensitive CLI surfaces include the exact argv vector and can distinguish `probe_error` from `product_error`; reproducing the three doctor-help forms through the helper yields three parseable JSON objects from byte 0 without Python parser stack noise. [SCOPE: claw-code dogfood harness]
**Verification.** `python3 -m unittest tests.test_roadmap_helpers.RoadmapHelperTests.test_dogfood_probe_runs_explicit_argv_and_separates_channels tests.test_roadmap_helpers.RoadmapHelperTests.test_dogfood_probe_labels_timeout_separately_from_product_error tests.test_roadmap_helpers.RoadmapHelperTests.test_dogfood_probe_labels_probe_construction_failure tests.test_roadmap_helpers.RoadmapHelperTests.test_dogfood_probe_labels_stdout_json_prefix_failure_as_product_error` passes. The fixture tests cover explicit argv preservation for `--output-format json doctor --help`, separated stdout/stderr capture, timeout classification, construction-error classification, and byte-0 JSON product-error classification. [SCOPE: claw-code dogfood harness]
814. **Plain non-TTY trailing `--output-format json` still times out for inventory/error surfaces after #3186** — dogfooded 2026-05-28 07:00 on fresh `main` `0e6d48d9d` after #3186 merged. Using explicit argv probes with separated stdout/stderr (per #813) reproduced the older #811 class on current main: `cargo run -q --bin claw -- agents list --bogus --output-format json`, `skills show does-not-exist --output-format json`, and `plugins show does-not-exist --output-format json` each hit the 5s timeout (`rc=124`) with `stdout` length 0; stderr contained only compile warnings plus the local deprecated `enabledPlugins` settings warning. This confirms the argv-safe probe harness can distinguish product failure from probe-construction failure, and the product gap remains for trailing JSON flag forms on inventory/error surfaces.
814. **DONE — Plain non-TTY trailing `--output-format json` still times out for inventory/error surfaces after #3186** — dogfooded 2026-05-28 07:00 on fresh `main` `0e6d48d9d` after #3186 merged. Using explicit argv probes with separated stdout/stderr (per #813) reproduced the older #811 class on current main: `cargo run -q --bin claw -- agents list --bogus --output-format json`, `skills show does-not-exist --output-format json`, and `plugins show does-not-exist --output-format json` each hit the 5s timeout (`rc=124`) with `stdout` length 0; stderr contained only compile warnings plus the local deprecated `enabledPlugins` settings warning. Follow-up evidence showed the product path fixed upstream and current tests preserve local JSON error routing.
**Required fix shape.** Parse trailing `--output-format json` for local inventory/error commands before any REPL/provider startup in plain non-TTY mode, matching the already-working leading global form where applicable. Add timeout regression coverage for at least `agents list --bogus --output-format json`, `skills show does-not-exist --output-format json`, and `plugins show does-not-exist --output-format json` asserting nonzero stdout with a single parseable JSON envelope containing `status:"error"`, `error_kind`, and non-null `hint`. Keep deprecation/config warnings out of stdout in JSON mode.
@@ -7809,43 +7833,71 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed)
**Follow-up verification (2026-05-28 07:30 on `main` `09ff1caf4`).** After #3187 merged, rerunning the same three commands with explicit argv showed the product path had already been fixed upstream: `agents list --bogus --output-format json` returned rc 1 with a JSON `unknown_option` envelope, `skills show does-not-exist --output-format json` returned rc 1 with `skill_not_found`, and `plugins show does-not-exist --output-format json` returned rc 1 with `plugin_not_found`. Stdout was nonzero and parseable in all three cases; warnings stayed on stderr. Remaining actionable lesson is process-level: ROADMAP record #814 is preserved as historical repro + verification, not an open product blocker.
815. **`claw --output-format json config` reports the same deprecated-settings warning twice: once structurally in `warnings[]` and once as prose on stderr** — dogfooded 2026-05-28 08:00 on current `main` after #3188. `timeout 5s cargo run -q --bin claw -- --output-format json config >out 2>err` exits 0 with parseable stdout JSON (`kind:"config", action:"list", status:"ok"`) and `warnings.length == 1`, but stderr still contains the same `enabledPlugins is deprecated` warning once. This is better than older stdout contamination, but still duplicates the same diagnostic across two channels in JSON mode. A machine consumer that reads the structured warning also sees an extra prose warning on stderr; a log scraper may count one config issue twice.
**Fix applied.** Current trailing JSON-mode routing reaches local inventory handlers for these argv-safe probes; handled JSON errors emit parseable stdout envelopes and do not require provider credentials/session startup.
**Verification.** Existing follow-up probe evidence records `agents list --bogus --output-format json`, `skills show does-not-exist --output-format json`, and `plugins show does-not-exist --output-format json` returning parseable JSON envelopes; current contract tests additionally cover agents/plugin local parse/error envelopes with stdout JSON.
815. **DONE — `claw --output-format json config` reports the same deprecated-settings warning twice: once structurally in `warnings[]` and once as prose on stderr** — dogfooded 2026-05-28 08:00 on current `main` after #3188. `timeout 5s cargo run -q --bin claw -- --output-format json config >out 2>err` exits 0 with parseable stdout JSON (`kind:"config", action:"list", status:"ok"`) and `warnings.length == 1`, but stderr still contains the same `enabledPlugins is deprecated` warning once. Current config JSON keeps that diagnostic structured without duplicating it on stderr.
**Required fix shape.** In JSON mode for config/list surfaces that already include `warnings[]`, suppress eager prose emission of the same config warning on stderr or mark it as already collected. Text mode should keep the human stderr warning. Add regression coverage asserting `claw --output-format json config` returns exactly one structured warning and zero duplicate `enabledPlugins` prose lines on stderr.
**Acceptance.** With a deprecated `enabledPlugins` key present, `claw --output-format json config` exits 0, stdout parses from byte 0 and includes `warnings[]`, and stderr has no duplicate deprecation warning for the same file/key. [SCOPE: claw-code]
816. **JSON-mode local/list surfaces still leak deprecated config prose warnings on stderr outside `config`** — dogfooded 2026-05-28 09:30 on `main` `89e7f415a` after #3190. `./target/debug/claw --output-format json config` is now fixed (`rc=0`, parseable stdout, `warnings[]`, stderr empty), but sibling JSON surfaces still emit the same app-level config warning to stderr when `~/.claw/settings.json` contains deprecated `enabledPlugins`: `plugins list` (`kind:"plugin"`), `mcp list` (`kind:"mcp"`), and `doctor` (`kind:"doctor"`) all return parseable JSON with `rc=0` while stderr contains `enabledPlugins is deprecated`. `skills list` and `version` stay clean. This leaves machine consumers with a global JSON-mode cleanliness gap even after the config-specific duplicate was fixed.
**Fix applied.** JSON-mode config rendering collects deprecated settings diagnostics into `warnings[]` and suppresses the duplicate prose `enabledPlugins` warning on stderr; text mode preserves the human stderr warning.
**Verification.** `config_json_reports_deprecations_structurally_without_stderr_duplicate_815` asserts a deprecated `enabledPlugins` fixture appears in JSON `warnings[]`, does not appear on stderr for `--output-format json config`, and still appears on stderr for text `config`.
816. **DONE — JSON-mode local/list surfaces still leak deprecated config prose warnings on stderr outside `config`** — dogfooded 2026-05-28 09:30 on `main` `89e7f415a` after #3190. `./target/debug/claw --output-format json config` was fixed, but sibling JSON surfaces still emitted the same app-level config warning to stderr when `~/.claw/settings.json` contained deprecated `enabledPlugins`: `plugins list`, `mcp list`, and `doctor`. Current global JSON-mode suppression covers these local/list surfaces.
**Required fix shape.** Treat JSON output mode as a global app-level diagnostic routing contract: local/list/status surfaces that successfully return structured JSON should not write config deprecation prose to stderr. Either collect those warnings into each relevant JSON envelope where a warnings field exists, or suppress config-warning emission during JSON-mode preloading/default resolution for surfaces that cannot represent warnings yet. Preserve human stderr warnings in text mode.
**Acceptance.** With deprecated `enabledPlugins` present, `claw --output-format json plugins list`, `claw --output-format json mcp list`, and `claw --output-format json doctor` exit 0, stdout parses from byte 0, and stderr contains zero `enabledPlugins is deprecated` app-level warning lines. Text mode still prints the warning. [SCOPE: claw-code]
817. **`claw --output-format json plugins list --` writes its JSON error envelope to stderr while sibling local inventory commands use stdout** — dogfooded 2026-05-28 12:30 on `main` `9494e3c26`. Trailing bare `--` is a useful parser edge because automation sometimes injects delimiter sentinels. `agents list --` and `skills list --` return rc 1 with parseable JSON on stdout and empty stderr. `mcp list --` also returns a parseable JSON error on stdout. `config --` returns rc 0 with a structured config error on stdout. But `plugins list --` returns rc 1, stdout empty, and writes the JSON error envelope to stderr: `{"action":"abort","error":"unknown option for `claw plugins list`: --", ...}`. This is machine-readable, but channel-inconsistent and surprising for JSON-mode consumers that read stdout for command payloads.
**Fix applied.** JSON-mode config-warning suppression is applied globally before local JSON surfaces load settings, covering sibling list/status/diagnostic commands while preserving text-mode stderr warnings.
**Verification.** `global_json_surfaces_suppress_config_deprecation_stderr_810_821_824` covers `plugins list`, `mcp list`, `doctor`, and additional JSON surfaces under a deprecated `enabledPlugins` fixture with empty stderr; `local_text_surface_preserves_config_deprecation_stderr_816` verifies text mode still emits the warning.
817. **DONE — `claw --output-format json plugins list --` writes its JSON error envelope to stderr while sibling local inventory commands use stdout** — dogfooded 2026-05-28 12:30 on `main` `9494e3c26`. Trailing bare `--` is a useful parser edge because automation sometimes injects delimiter sentinels. `plugins list --` returned rc 1, stdout empty, and wrote the JSON error envelope to stderr. Current plugin list parse-error routing matches sibling JSON inventory/local surfaces.
**Required fix shape.** Align `plugins list` parse-error routing with the other JSON inventory/local surfaces: in JSON mode, print the structured CLI error envelope to stdout and keep stderr empty for this handled parse error. Preserve text-mode stderr behavior. Add regression coverage for `claw --output-format json plugins list --` asserting rc 1, stdout parseable JSON with `error_kind:"cli_parse"`, and empty stderr.
**Acceptance.** `claw --output-format json plugins list --` exits 1, stdout parses from byte 0 as the existing JSON error envelope, stderr is empty, and text mode still reports the parse error to stderr. [SCOPE: claw-code]
818. **`AGENTS.md` and `.claude/CLAUDE.md` silently omitted from instruction file cascade** — dogfooded 2026-05-29 08:00. When a repo contains `AGENTS.md` (OpenAI Codex / multi-agent convention) or `.claude/CLAUDE.md` (scoped Claude Code convention), claw-code does not load either file as part of the instruction/context cascade on startup. Users following either convention discover this only by noticing their persona/context instructions have no effect — no warning, no missing-file diagnostic, no documentation note. This is a friction gap for any team migrating to or simultaneously using claw-code alongside Claude Code or Codex workflows, since the two most common non-CLAUDE.md instruction files are silently ignored.
**Fix applied.** The `plugins list` flag/filter guard emits handled JSON parse errors directly to stdout in JSON mode while keeping text-mode parse errors on stderr.
**Verification.** `plugins_list_trailing_dash_json_error_uses_stdout_817`, `plugins_list_trailing_dash_text_error_stays_on_stderr_817`, and `plugins_list_flag_shaped_filter_returns_cli_parse_on_stdout_793_817` cover rc 1, stdout JSON with `error_kind:"cli_parse"`, empty stderr in JSON mode, and preserved text stderr behavior.
818. **DONE — `AGENTS.md` and `.claude/CLAUDE.md` silently omitted from instruction file cascade** — dogfooded 2026-05-29 08:00. When a repo contains `AGENTS.md` (OpenAI Codex / multi-agent convention) or `.claude/CLAUDE.md` (scoped Claude Code convention), claw-code does not load either file as part of the instruction/context cascade on startup. Users following either convention discover this only by noticing their persona/context instructions have no effect — no warning, no missing-file diagnostic, no documentation note. This is a friction gap for any team migrating to or simultaneously using claw-code alongside Claude Code or Codex workflows, since the two most common non-CLAUDE.md instruction files are silently ignored.
**Required fix shape.** Add `AGENTS.md` (project root) and `.claude/CLAUDE.md` (`.claude/` subdirectory) to the instruction file cascade that already loads `CLAUDE.md`. Apply the same merge-and-precedence semantics as existing instruction files. Log a debug trace (not stderr noise) when either file is loaded. Add test coverage: a fixture repo with `AGENTS.md` only, `.claude/CLAUDE.md` only, and both present alongside `CLAUDE.md` should each have the relevant content visible in the resolved instruction context.
**Acceptance.** `claw` launched in a repo containing `AGENTS.md` or `.claude/CLAUDE.md` loads those files into the instruction context. No warning emitted for absent optional files. Existing `CLAUDE.md`-only repos unaffected. PR #3195. [SCOPE: claw-code]
819. **`claw --output-format json export --session <missing>` writes JSON error envelope to stderr, stdout empty** — dogfooded 2026-05-29 09:30 on `main` `37a9a543`. `claw --output-format json export --session does-not-exist` exits rc=1 with stdout length 0 and the full JSON error envelope on stderr: `{"action":"abort","error":"session not found: does-not-exist","error_kind":"session_not_found",...}`. This is the same channel-routing inconsistency class as #817 (plugins list trailing-dash, fixed in #3194): handled errors in JSON mode should go to stdout, not stderr, so machine consumers can parse the envelope from stdout byte 0 regardless of which surface triggered the error.
**Fix applied.** The instruction cascade now loads `AGENTS.md` and `.claude/CLAUDE.md` from the same ancestor walk that already loads `CLAUDE.md`, `CLAUDE.local.md`, `.claw/CLAUDE.md`, and `.claw/instructions.md`, preserving the existing merge/dedupe semantics and avoiding warnings for absent optional files.
**Verification.** `cargo test --manifest-path rust/Cargo.toml -p runtime discovers_agents_markdown_instruction_file -- --nocapture`; `cargo test --manifest-path rust/Cargo.toml -p runtime discovers_scoped_dot_claude_claude_markdown_instruction_file -- --nocapture`; `cargo test --manifest-path rust/Cargo.toml -p runtime discovers_claude_agents_and_dot_claude_instruction_files_together -- --nocapture`.
819. **DONE — `claw --output-format json export --session <missing>` writes JSON error envelope to stderr, stdout empty** — dogfooded 2026-05-29 09:30 on `main` `37a9a543`. `claw --output-format json export --session does-not-exist` exits rc=1 with stdout length 0 and the full JSON error envelope on stderr: `{"action":"abort","error":"session not found: does-not-exist","error_kind":"session_not_found",...}`. This is the same channel-routing inconsistency class as #817 (plugins list trailing-dash, fixed in #3194): handled errors in JSON mode should go to stdout, not stderr, so machine consumers can parse the envelope from stdout byte 0 regardless of which surface triggered the error.
**Required fix shape.** Align `export --session <missing>` error routing with the inventory surfaces fixed in #817: in JSON mode, write the `session_not_found` error envelope to stdout (rc=1) and keep stderr empty. Preserve text-mode behavior (stderr message). Add regression coverage asserting rc=1, stdout parseable JSON with `error_kind:"session_not_found"`, and empty stderr.
**Acceptance.** `claw --output-format json export --session does-not-exist` exits 1, stdout contains the JSON error envelope from byte 0, stderr is empty. Text mode still prints the error to stderr. [SCOPE: claw-code]
820. **`interactive_only` error class always routes JSON envelope to stderr (stdout empty)** — dogfooded 2026-05-29 10:00 on `main` `efe59c22`. All `interactive_only` errors share the same routing gap as #819 (`export --session <missing>`): `claw --output-format json session list`, `session switch <id>`, `session delete <id>`, and `session fork <id>` each exit rc=1, stdout empty, JSON envelope on stderr. The envelope is well-formed (`error_kind:"interactive_only"`, `hint:...`, `action:"abort"`) but the channel is wrong for JSON mode. Any surface that returns `interactive_only` is affected; these are all the `claw session` subcommands. This is the same root cause as #817 (plugins) and #819 (export): the top-level error handler writes `Err(...)` to stderr instead of routing to stdout when `--output-format json` is active.
**Fix applied.** The JSON abort handler now emits export/session-not-found envelopes on stdout in JSON mode while preserving text-mode stderr behavior. The explicit missing-session regression asserts rc 1, `error_kind:"session_not_found"`, abort envelope, and empty stderr.
**Verification.** `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli export_missing_session_json_error_uses_stdout_819 -- --nocapture`.
820. **DONE — `interactive_only` error class always routes JSON envelope to stderr (stdout empty)** — dogfooded 2026-05-29 10:00 on `main` `efe59c22`. All `interactive_only` errors shared the same routing gap as #819: JSON envelopes were well-formed but written to stderr. Current JSON abort handling routes `interactive_only` envelopes to stdout.
**Required fix shape.** In the top-level error handler (or the `interactive_only` classifier arm in `main.rs`), detect JSON output mode and write the structured error envelope to stdout (rc=1) instead of stderr. Scope the fix to the `interactive_only` error_kind so all affected surfaces are repaired in one pass. Add regression coverage for at least `claw --output-format json session list` asserting rc=1, stdout parseable JSON with `error_kind:"interactive_only"`, stderr empty.
**Acceptance.** All `claw --output-format json session <subcommand>` 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.
**Fix applied.** The JSON abort handler now classifies `interactive_only` and prints the structured envelope to stdout in JSON mode; text-mode errors still use stderr.
**Verification.** Session/abort contract assertions in `output_format_contract.rs` around #819/#820/#823 require JSON-mode interactive-only failures to provide a stdout JSON envelope and no JSON envelope on stderr.
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.
@@ -7853,56 +7905,110 @@ 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.
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.
**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. **DONE — 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` returned `missing_credentials` after prompt/provider fallthrough instead of a structured `command_not_found`. Current command-shaped unknown tokens are rejected before provider startup.
**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.
**Fix applied.** Unknown command-shaped top-level tokens now trip the pre-provider `command_not_found:` guard, and the classifier maps that prefix to `error_kind:"command_not_found"` with JSON-mode output on stdout.
**Verification.** `unknown_subcommand_json_emits_command_not_found`, `unknown_subcommand_text_emits_command_not_found_on_stderr`, `unknown_subcommand_typo_with_suggestions_json_emits_command_not_found`, and updated `unknown_subcommand_returns_typed_kind_785` cover JSON stdout, text stderr, suggestion hints, and no `missing_credentials` fallthrough.
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]
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.
**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. **DONE — 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, then fell through to provider startup when no suggestions matched. Current code emits `command_not_found` for 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.
**Acceptance.** `claw --output-format json foobar` exits 1, stdout `error_kind:"command_not_found"`, stderr empty, no Anthropic call. Typo with suggestions (`claw statuz`) also gets `command_not_found` plus `hint` with suggestions. [SCOPE: claw-code]
826. **Multi-word unknown subcommand still falls through to `missing_credentials`** — dogfooded 2026-05-29 14:38 on `main` `70d64be0`. After #825 fixed single-word unknown subcommands, multi-word invocations (`claw foobar baz`) are still undetected: the `looks_like_subcommand_typo` guard only fires when `rest.len() == 1`. When there are two or more positional args, the first word is treated as a prompt and all args join into a prompt string → provider startup → `missing_credentials`. Same misleading-error class as #825 but for multi-word cases.
**Fix applied.** The `looks_like_subcommand_typo` path emits `command_not_found:` for unknown single-word command-shaped tokens even when there are no close suggestions, and typo suggestions are unified under the same typed error kind.
**Verification.** `unknown_subcommand_json_emits_command_not_found`, `unknown_subcommand_text_emits_command_not_found_on_stderr`, `unknown_subcommand_typo_with_suggestions_json_emits_command_not_found`, and classifier coverage in `classify_error_kind_returns_correct_discriminants` verify `command_not_found` instead of `missing_credentials` before provider startup.
826. **DONE — Multi-word unknown subcommand still falls through to `missing_credentials`** — dogfooded 2026-05-29 14:38 on `main` `70d64be0`. After #825 fixed single-word unknown subcommands, multi-word invocations (`claw foobar baz`) are still undetected: the `looks_like_subcommand_typo` guard only fires when `rest.len() == 1`. When there are two or more positional args, the first word is treated as a prompt and all args join into a prompt string → provider startup → `missing_credentials`. Same misleading-error class as #825 but for multi-word cases.
**Required fix shape.** Extend the command-not-found guard to also fire when `rest.len() > 1` and `rest[0]` passes `looks_like_subcommand_typo` but does not match any known subcommand. The multi-arg case should also emit `command_not_found` — with a note that if literal multi-word prompt was intended, use `claw prompt <text>` or `echo 'text' | claw`.
**Acceptance.** `claw --output-format json foobar baz` exits 1, stdout `error_kind:"command_not_found"`, stderr empty, no provider startup. `claw "write a haiku"` (valid prompt passthrough) is unaffected. [SCOPE: claw-code]
827. **`--resume <session> /unknown-slash-command` emits `error_kind:"unknown"` instead of a typed kind** — dogfooded 2026-05-29 14:58 on `main` `d47b0151`. `claw --resume latest --output-format json /bogus` exits rc=2 with `{"error_kind":"unknown",...}` on stdout (correct channel, correct rc) but the opaque `"unknown"` kind gives machine consumers no way to distinguish "unrecognized slash command" from other error classes. The error has a useful `hint` with suggestions, but the `error_kind` field is `"unknown"` across all unrecognized resume slash commands.
**Fix applied.** JSON-mode command-shaped unknown subcommands now emit `command_not_found:` before provider startup even when additional tokens follow. Text-mode multi-word prompt shorthand remains available, but JSON automation no longer turns `claw --output-format json foobar baz` into a credential-gated prompt request.
**Verification.** `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli multi_word_unknown_subcommand_json_emits_command_not_found_826 -- --nocapture`; `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli unknown_subcommand_json_emits_command_not_found -- --nocapture`; direct probe `cargo run --manifest-path rust/Cargo.toml -q -p rusty-claude-cli -- --output-format json foobar baz`.
827. **DONE — `--resume <session> /unknown-slash-command` emits `error_kind:"unknown"` instead of a typed kind** — dogfooded 2026-05-29 14:58 on `main` `d47b0151`. `claw --resume latest --output-format json /bogus` exits rc=2 with `{"error_kind":"unknown",...}` on stdout (correct channel, correct rc) but the opaque `"unknown"` kind gives machine consumers no way to distinguish "unrecognized slash command" from other error classes. The error has a useful `hint` with suggestions, but the `error_kind` field is `"unknown"` across all unrecognized resume slash commands.
**Required fix shape.** Introduce a typed `error_kind` for unrecognized slash commands (e.g. `unknown_slash_command` or `command_not_found`). Update the JSON emit in the `--resume` unknown-command handler to use the typed kind. Add regression coverage asserting the typed kind.
**Acceptance.** `claw --resume latest --output-format json /bogus` exits rc=2, stdout `error_kind:"unknown_slash_command"` (or similar typed constant), stderr empty. [SCOPE: claw-code]
828. **`/approve` and `/deny` outside REPL emit `unknown_slash_command` instead of `interactive_only`** — dogfooded 2026-05-29 16:05 on `main` `9d05573f`. `claw --output-format json /approve` exited rc=1 with `error_kind:"unknown_slash_command"` — these are valid REPL-only slash commands but are not `SlashCommand` enum variants, so they fell through to `format_unknown_direct_slash_command`. Machine consumers saw the wrong error class.
**Fix applied.** Unknown direct and resumed slash commands now use the classifier-friendly `unknown_slash_command:` prefix, so the JSON resume-command error path emits `error_kind:"unknown_slash_command"` instead of falling back to `unknown`. Direct slash command coverage and the new resumed-session regression both assert stdout JSON and empty stderr.
**Verification.** `cargo fmt --manifest-path rust/Cargo.toml --all -- --check`; `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli direct_unknown_slash_command_emits_typed_error_kind -- --nocapture`; `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli resume_unknown_slash_command_emits_typed_error_kind_827 -- --nocapture`; direct probe `cargo run --manifest-path rust/Cargo.toml -q -p rusty-claude-cli -- --resume .claw/sessions/491726c4e6bde42d/session-1778832949219-0.jsonl --output-format json /boguscommand`.
828. **DONE — `/approve` and `/deny` outside REPL emit `unknown_slash_command` instead of `interactive_only`** — dogfooded 2026-05-29 16:05 on `main` `9d05573f`. `claw --output-format json /approve` exited rc=1 with `error_kind:"unknown_slash_command"` — these are valid REPL-only slash commands but are not `SlashCommand` enum variants, so they fell through to `format_unknown_direct_slash_command`. Machine consumers saw the wrong error class.
**Fix applied.** `SlashCommand::Unknown` arm now special-cases `approve | yes | y | deny | no | n` and emits `interactive_only:` prefix before falling through to `format_unknown_direct_slash_command`. Both `error_kind` and hint are correct.
**Acceptance.** `claw --output-format json /approve` exits rc=1, stdout `error_kind:"interactive_only"`, stderr empty. [SCOPE: claw-code]
829. **`interactive_only` hint incorrectly suggests `--resume` for commands that are not resume-safe** — dogfooded 2026-05-29 16:40 on `main` `187aebd7`. `claw --output-format json /commit` hint says `"use claw --resume SESSION.jsonl /commit"` but `/commit` is not resume-safe (no `[resume]` marker in `/help`). Same for `/pr`, `/issue`, `/bughunter`, `/ultraplan`. The generic `interactive_only` hint template does not distinguish resume-safe from live-REPL-only commands, so it always suggests `--resume` regardless. Users who follow the hint will get `interactive_only` again.
**Verification.** `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli approve_deny_outside_repl_emits_interactive_only -- --nocapture`; direct probes `cargo run --manifest-path rust/Cargo.toml -q -p rusty-claude-cli -- --output-format json /approve` and `cargo run --manifest-path rust/Cargo.toml -q -p rusty-claude-cli -- --output-format json /deny`.
829. **DONE — `interactive_only` hint incorrectly suggests `--resume` for commands that are not resume-safe** — dogfooded 2026-05-29 16:40 on `main` `187aebd7`. `claw --output-format json /commit` hint says `"use claw --resume SESSION.jsonl /commit"` but `/commit` is not resume-safe (no `[resume]` marker in `/help`). Same for `/pr`, `/issue`, `/bughunter`, `/ultraplan`. The generic `interactive_only` hint template does not distinguish resume-safe from live-REPL-only commands, so it always suggests `--resume` regardless. Users who follow the hint will get `interactive_only` again.
**Required fix shape.** The generic `interactive_only:` message formatter (line ~1745 in `main.rs`) currently always appends `or use claw --resume SESSION.jsonl {command_name}`. This should be conditioned on whether the slash command appears in the resume-safe command list. Non-resume-safe interactive commands should only say `Start claw and run it there.`
**Acceptance.** `claw --output-format json /commit` hint does NOT mention `--resume`. `claw --output-format json /status` (resume-safe) hint still mentions `--resume`. [SCOPE: claw-code]
830. **`claw mcp show` (missing server name arg) emits `error_kind:"unknown_mcp_action"` instead of `missing_argument`** — dogfooded 2026-05-29 17:00 on `main` `ac5b19de`. `claw --output-format json mcp show` (no server name supplied) exits with `error_kind:"unknown_mcp_action"`. However `show` IS a known MCP action — the error is a missing required argument (server name), not an unknown action. Machine consumers inspecting `error_kind` cannot distinguish "I don't know this action" from "I know this action but a required arg is missing".
**Fix applied.** The direct slash-command guidance now consults the shared `resume_supported` command metadata before adding a `--resume` remediation. Non-resume-safe commands such as `/commit`, `/pr`, `/issue`, `/bughunter`, and `/ultraplan` only point users to the live REPL, while resume-safe commands keep the `--resume SESSION.jsonl` hint.
**Verification.** `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli non_resume_safe_interactive_only_hint_omits_resume_suggestion -- --nocapture`; `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli resume_safe_interactive_only_hint_includes_resume_suggestion -- --nocapture`; direct probes `cargo run --manifest-path rust/Cargo.toml -q -p rusty-claude-cli -- --output-format json /commit` and `cargo run --manifest-path rust/Cargo.toml -q -p rusty-claude-cli -- --output-format json /status`.
830. **DONE — `claw mcp show` (missing server name arg) emits `error_kind:"unknown_mcp_action"` instead of `missing_argument`** — dogfooded 2026-05-29 17:00 on `main` `ac5b19de`. `claw --output-format json mcp show` (no server name supplied) exits with `error_kind:"unknown_mcp_action"`. However `show` IS a known MCP action — the error is a missing required argument (server name), not an unknown action. Machine consumers inspecting `error_kind` cannot distinguish "I don't know this action" from "I know this action but a required arg is missing".
**Required fix shape.** The MCP subcommand parser should detect `show` with no following token and emit `missing_argument: mcp show requires a server name.\nUsage: claw mcp show <server>` with a distinct `error_kind`. Update the classifier arm to return `missing_argument` for this prefix.
**Acceptance.** `claw --output-format json mcp show` exits rc=1, stdout `error_kind:"missing_argument"`, stderr empty. Hint contains usage example. [SCOPE: claw-code]
**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`.
832. **DONE — roadmap-next-id helper missing explicit ROADMAP path behavior lacked regression coverage** — follow-up to #725 and PR #3117 after dogfood showed `scripts/roadmap-next-id.sh /tmp/nonexistent-roadmap` already failed correctly but the helper tests did not pin that behavior. The missing-path case is important because docs-only PRs can otherwise regress back to printing a next id for an absent explicit file.
**Fix applied.** Added `test_roadmap_next_id_fails_when_explicit_roadmap_path_is_missing`, proving an explicit missing ROADMAP path exits nonzero, keeps stdout empty, and reports both `ROADMAP not found` and the requested path on stderr. Added `tests/__init__.py` so `python3 -m unittest tests.test_roadmap_helpers` resolves this repository's tests package consistently.
**Verification.** `python3 -m unittest tests.test_roadmap_helpers`; `scripts/roadmap-check-ids.sh`; `scripts/roadmap-next-id.sh`.
+41
View File
@@ -349,6 +349,8 @@ These are the models registered in the built-in alias table with known token lim
| `grok-mini` / `grok-3-mini` | `grok-3-mini` | xAI | 64 000 | 131 072 |
| `grok-2` | `grok-2` | xAI | — | — |
| `kimi` | `kimi-k2.5` | DashScope | 16 384 | 256 000 |
| `qwen-max` | `qwen-max` | DashScope | 8 192 | 131 072 |
| `qwen-plus` | `qwen-plus` | DashScope | 8 192 | 131 072 |
| `gpt-4.1` / `gpt-4.1-mini` / `gpt-4.1-nano` | same | OpenAI-compatible | 32 768 | 1 047 576 |
| `gpt-5.4` / `gpt-5.4-mini` / `gpt-5.4-nano` | same | OpenAI-compatible | 128 000 | 1 000 000 / 400 000 |
@@ -517,6 +519,45 @@ Runtime config is loaded in this order, with later entries overriding earlier on
4. `<repo>/.claw/settings.json`
5. `<repo>/.claw/settings.local.json`
## Hook configuration
`hooks.PreToolUse`, `hooks.PostToolUse`, and `hooks.PostToolUseFailure` accept either legacy command strings or object-style entries with a `matcher` and nested command hooks:
```json
{
"hooks": {
"PreToolUse": [
"echo legacy hook",
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "scripts/audit-bash.sh" }
]
}
]
}
}
```
Object-style matchers are optional. When present, they match tool names case-insensitively and support `*` wildcards plus comma or pipe separated alternatives. Nested hook `type` may be omitted or set to `"command"`; each nested command runs in configuration order.
## Project instruction rules
In addition to root instruction files such as `CLAUDE.md`, `AGENTS.md`, `.claw/CLAUDE.md`, `.claude/CLAUDE.md`, and `.claw/instructions.md`, `claw` loads sorted Markdown/text rule files from:
- `<repo>/.claw/rules/` (`.md`, `.txt`, `.mdc`) for shared project rules.
- `<repo>/.claw/rules.local/` for personal local rules; this path is gitignored.
By default, `claw` also imports detected rules from common AI coding tools such as Cursor (`.cursorrules`, `.cursor/rules/`), GitHub Copilot (`.github/copilot-instructions.md`), Windsurf, Plandex, and Crush. Control this with `rulesImport` in any settings file:
```json
{
"rulesImport": "none"
}
```
Use `"auto"` (the default) to import every supported framework, `"none"` to load only Claw instruction/rules files, or an array such as `["cursor", "copilot"]` to import selected frameworks.
## Mock parity harness
The workspace includes a deterministic Anthropic-compatible mock service and parity harness.
+38 -4
View File
@@ -468,8 +468,7 @@ impl AnthropicClient {
request: &MessageRequest,
) -> Result<reqwest::Response, ApiError> {
let request_url = format!("{}/v1/messages", self.base_url.trim_end_matches('/'));
let mut request_body = self.request_profile.render_json_body(request)?;
strip_unsupported_beta_body_fields(&mut request_body);
let request_body = render_standard_messages_body(&self.request_profile, request)?;
let request_builder = self.build_request(&request_url).json(&request_body);
request_builder.send().await.map_err(ApiError::from)
}
@@ -529,8 +528,7 @@ impl AnthropicClient {
"{}/v1/messages/count_tokens",
self.base_url.trim_end_matches('/')
);
let mut request_body = self.request_profile.render_json_body(request)?;
strip_unsupported_beta_body_fields(&mut request_body);
let request_body = render_standard_messages_body(&self.request_profile, request)?;
let response = self
.build_request(&request_url)
.json(&request_body)
@@ -977,6 +975,21 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
}
}
fn anthropic_wire_model(model: &str) -> &str {
model.strip_prefix("anthropic/").unwrap_or(model)
}
fn render_standard_messages_body(
request_profile: &AnthropicRequestProfile,
request: &MessageRequest,
) -> Result<Value, serde_json::Error> {
let mut wire_request = request.clone();
wire_request.model = anthropic_wire_model(&request.model).to_string();
let mut body = request_profile.render_json_body(&wire_request)?;
strip_unsupported_beta_body_fields(&mut body);
Ok(body)
}
/// Remove beta-only body fields that the standard `/v1/messages` and
/// `/v1/messages/count_tokens` endpoints reject as `Extra inputs are not
/// permitted`. The `betas` opt-in is communicated via the `anthropic-beta`
@@ -1550,6 +1563,27 @@ mod tests {
);
}
#[test]
fn standard_messages_body_strips_anthropic_routing_prefix() {
let client = AnthropicClient::new("test-key");
let request = MessageRequest {
model: "anthropic/claude-opus-4-6".to_string(),
max_tokens: 64,
messages: vec![],
system: None,
tools: None,
tool_choice: None,
stream: false,
..Default::default()
};
let rendered = super::render_standard_messages_body(client.request_profile(), &request)
.expect("body should render");
assert_eq!(rendered["model"], serde_json::json!("claude-opus-4-6"));
assert!(rendered.get("betas").is_none());
}
#[test]
fn enrich_bearer_auth_error_appends_sk_ant_hint_on_401_with_pure_bearer_token() {
// given
+83 -5
View File
@@ -1115,6 +1115,13 @@ fn build_chat_completion_request_for_base_url(
payload[key] = value.clone();
}
// DeepSeek V4 Pro/Flash thinking mode requires this provider-specific opt-in
// and also requires assistant reasoning history to be echoed as `reasoning_content`.
// Apply it after extra_body so callers cannot accidentally override the required shape.
if model_requires_reasoning_content_in_history(wire_model) {
payload["thinking"] = json!({"type": "enabled"});
}
payload
}
@@ -1172,16 +1179,19 @@ pub fn translate_message(message: &InputMessage, model: &str) -> Vec<Value> {
InputContentBlock::ToolResult { .. } => {}
}
}
let include_reasoning =
model_requires_reasoning_content_in_history(model) && !reasoning.is_empty();
if text.is_empty() && tool_calls.is_empty() && !include_reasoning {
let needs_reasoning = model_requires_reasoning_content_in_history(model);
if text.is_empty() && tool_calls.is_empty() && reasoning.is_empty() {
Vec::new()
} else {
let mut msg = serde_json::json!({
"role": "assistant",
"content": (!text.is_empty()).then_some(text),
});
if include_reasoning {
if !text.is_empty() {
msg["content"] = json!(text);
} else if !needs_reasoning {
msg["content"] = Value::Null;
}
if needs_reasoning {
msg["reasoning_content"] = json!(reasoning);
}
// Only include tool_calls when non-empty: some providers reject
@@ -1796,6 +1806,31 @@ mod tests {
assert_eq!(assistant["content"], json!("answer"));
}
#[test]
fn deepseek_v4_assistant_with_only_tool_calls_omits_content_and_includes_reasoning() {
let request = MessageRequest {
model: "deepseek-v4-pro".to_string(),
max_tokens: 100,
messages: vec![InputMessage {
role: "assistant".to_string(),
content: vec![InputContentBlock::ToolUse {
id: "call_1".to_string(),
name: "get_weather".to_string(),
input: json!({"city": "Paris"}),
}],
}],
stream: false,
..Default::default()
};
let payload = build_chat_completion_request(&request, OpenAiCompatConfig::openai());
let assistant = &payload["messages"][0];
assert!(assistant.get("content").is_none());
assert_eq!(assistant["reasoning_content"], json!(""));
assert_eq!(assistant["tool_calls"].as_array().map(Vec::len), Some(1));
}
#[test]
fn deepseek_v4_flash_request_includes_reasoning_content_for_assistant_history() {
// Given an assistant history turn containing thinking.
@@ -1982,6 +2017,49 @@ mod tests {
assert_eq!(payload["reasoning_effort"], json!("high"));
}
#[test]
fn deepseek_v4_request_includes_thinking_parameter() {
let payload = build_chat_completion_request(
&MessageRequest {
model: "deepseek-v4-pro".to_string(),
max_tokens: 1024,
messages: vec![InputMessage::user_text("hello")],
..Default::default()
},
OpenAiCompatConfig::openai(),
);
assert_eq!(payload["thinking"], json!({"type": "enabled"}));
assert_eq!(payload["model"], json!("deepseek-v4-pro"));
let mut extra_body = BTreeMap::new();
extra_body.insert("thinking".to_string(), json!({"type": "disabled"}));
let payload_with_override = build_chat_completion_request(
&MessageRequest {
model: "openai/deepseek-v4-flash".to_string(),
max_tokens: 1024,
messages: vec![InputMessage::user_text("hello")],
extra_body,
..Default::default()
},
OpenAiCompatConfig::openai(),
);
assert_eq!(
payload_with_override["thinking"],
json!({"type": "enabled"})
);
let non_deepseek_payload = build_chat_completion_request(
&MessageRequest {
model: "gpt-4o".to_string(),
max_tokens: 64,
messages: vec![InputMessage::user_text("hello")],
..Default::default()
},
OpenAiCompatConfig::openai(),
);
assert!(non_deepseek_payload.get("thinking").is_none());
}
#[test]
fn reasoning_effort_omitted_when_not_set() {
let payload = build_chat_completion_request(
@@ -103,6 +103,58 @@ async fn send_message_posts_json_and_parses_response() {
);
}
#[tokio::test]
async fn send_message_strips_anthropic_routing_prefix_on_wire() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let server = spawn_server(
state.clone(),
vec![
http_response("200 OK", "application/json", "{\"input_tokens\":1}"),
http_response(
"200 OK",
"application/json",
concat!(
"{",
"\"id\":\"msg_prefixed\",",
"\"type\":\"message\",",
"\"role\":\"assistant\",",
"\"content\":[{\"type\":\"text\",\"text\":\"ok\"}],",
"\"model\":\"claude-opus-4-6\",",
"\"stop_reason\":\"end_turn\",",
"\"stop_sequence\":null,",
"\"usage\":{\"input_tokens\":1,\"output_tokens\":1}",
"}"
),
),
],
)
.await;
let client = AnthropicClient::new("test-key").with_base_url(server.base_url());
client
.send_message(&MessageRequest {
model: "anthropic/claude-opus-4-6".to_string(),
..sample_request(false)
})
.await
.expect("request should succeed");
let captured = state.lock().await;
assert_eq!(
captured.len(),
2,
"count_tokens and messages requests should be captured"
);
let count_tokens_body: serde_json::Value =
serde_json::from_str(&captured[0].body).expect("count_tokens body should be json");
let messages_body: serde_json::Value =
serde_json::from_str(&captured[1].body).expect("request body should be json");
assert_eq!(captured[0].path, "/v1/messages/count_tokens");
assert_eq!(captured[1].path, "/v1/messages");
assert_eq!(count_tokens_body["model"], json!("claude-opus-4-6"));
assert_eq!(messages_body["model"], json!("claude-opus-4-6"));
}
#[tokio::test]
async fn send_message_blocks_oversized_requests_before_the_http_call() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
@@ -159,6 +159,11 @@ async fn send_message_preserves_deepseek_reasoning_content_before_text() {
},
]
);
let captured = state.lock().await;
let request = captured.first().expect("server should capture request");
let body: serde_json::Value = serde_json::from_str(&request.body).expect("json body");
assert_eq!(body["thinking"], json!({"type": "enabled"}));
}
#[tokio::test]
+47 -5
View File
@@ -1670,7 +1670,11 @@ fn parse_mcp_command(args: &[&str]) -> Result<SlashCommand, SlashCommandParseErr
target: None,
}),
["list", ..] => Err(usage_error("mcp list", "")),
["show"] => Err(usage_error("mcp show", "<server>")),
["show"] => Err(command_error(
"missing_argument: mcp show requires a server name.",
"mcp",
"/mcp show <server>",
)),
["show", target] => Ok(SlashCommand::Mcp {
action: Some("show".to_string()),
target: Some((*target).to_string()),
@@ -2918,12 +2922,12 @@ fn render_mcp_report_for(
}
}
Some(args) if is_help_arg(args) => Ok(render_mcp_usage(None)),
Some("show") => Ok(render_mcp_usage(Some("show"))),
Some("show") => Ok(render_mcp_missing_argument_text("show")),
Some(args) if args.split_whitespace().next() == Some("show") => {
let mut parts = args.split_whitespace();
let _ = parts.next();
let Some(server_name) = parts.next() else {
return Ok(render_mcp_usage(Some("show")));
return Ok(render_mcp_missing_argument_text("show"));
};
if parts.next().is_some() {
return Ok(render_mcp_usage(Some(args)));
@@ -3027,12 +3031,12 @@ fn render_mcp_report_json_for(
}
}
Some(args) if is_help_arg(args) => Ok(render_mcp_usage_json(None)),
Some("show") => Ok(render_mcp_usage_json(Some("show"))),
Some("show") => Ok(render_mcp_missing_argument_json("show")),
Some(args) if args.split_whitespace().next() == Some("show") => {
let mut parts = args.split_whitespace();
let _ = parts.next();
let Some(server_name) = parts.next() else {
return Ok(render_mcp_usage_json(Some("show")));
return Ok(render_mcp_missing_argument_json("show"));
};
if parts.next().is_some() {
return Ok(render_mcp_usage_json(Some(args)));
@@ -4269,6 +4273,44 @@ fn render_mcp_usage(unexpected: Option<&str>) -> String {
lines.join("\n")
}
fn render_mcp_missing_argument_text(action: &str) -> String {
let hint = match action {
"show" => "use `claw mcp show <server>` to inspect a server",
_ => "provide the required argument for this MCP action",
};
format!(
"MCP\n Error missing argument for '{action}'\n Hint {hint}\n Usage /mcp [list|show <server>|help]"
)
}
fn render_mcp_missing_argument_json(action: &str) -> Value {
let (message, hint) = match action {
"show" => (
"mcp show requires a server name",
"Usage: claw mcp show <server>",
),
_ => (
"mcp action requires an argument",
"Usage: claw mcp [list|show <server>|help]",
),
};
json!({
"kind": "mcp",
"action": action,
"ok": false,
"status": "error",
"error_kind": "missing_argument",
"message": message,
"hint": hint,
"usage": {
"slash_command": "/mcp [list|show <server>|help]",
"direct_cli": "claw mcp [list|show <server>|help]",
"sources": [".claw/settings.json", ".claw/settings.local.json"],
},
"unexpected": Value::Null,
})
}
fn render_mcp_usage_json(unexpected: Option<&str>) -> Value {
// #748: add error_kind when unexpected is set, matching agents/plugins unknown-subcommand shape.
let error_kind: Value = if unexpected.is_some() {
+38 -13
View File
@@ -330,20 +330,24 @@ fn prepare_tokio_command(
prepare_sandbox_dirs(cwd);
}
if let Some(launcher) = build_linux_sandbox_command(command, cwd, sandbox_status) {
let mut prepared = TokioCommand::new(launcher.program);
prepared.args(launcher.args);
prepared.current_dir(cwd);
prepared.envs(launcher.env);
return prepared;
}
let mut prepared =
if let Some(launcher) = build_linux_sandbox_command(command, cwd, sandbox_status) {
let mut cmd = TokioCommand::new(launcher.program);
cmd.args(launcher.args);
cmd.envs(launcher.env);
cmd
} else {
let mut cmd = TokioCommand::new("sh");
cmd.arg("-lc").arg(command);
if sandbox_status.filesystem_active {
cmd.env("HOME", cwd.join(".sandbox-home"));
cmd.env("TMPDIR", cwd.join(".sandbox-tmp"));
}
cmd
};
let mut prepared = TokioCommand::new("sh");
prepared.arg("-lc").arg(command).current_dir(cwd);
if sandbox_status.filesystem_active {
prepared.env("HOME", cwd.join(".sandbox-home"));
prepared.env("TMPDIR", cwd.join(".sandbox-tmp"));
}
prepared.current_dir(cwd);
prepared.stdin(Stdio::null());
prepared
}
@@ -419,6 +423,27 @@ mod tests {
assert_eq!(structured[0]["event"], "test.hung");
assert_eq!(structured[0]["data"]["provenance"], "bash.timeout");
}
#[test]
fn prevents_stdin_hangs_by_redirecting_to_null() {
let output = execute_bash(BashCommandInput {
command: String::from("cat"),
timeout: Some(2_000),
description: None,
run_in_background: Some(false),
dangerously_disable_sandbox: Some(true),
namespace_restrictions: None,
isolate_network: None,
filesystem_mode: None,
allowed_mounts: None,
})
.expect("bash command should execute cleanly");
assert!(
!output.interrupted,
"Command hung and was cut off by the timeout!"
);
}
}
/// Maximum output bytes before truncation (16 KiB, matching upstream).
File diff suppressed because it is too large Load Diff
+72 -3
View File
@@ -92,6 +92,8 @@ enum FieldType {
Bool,
Object,
StringArray,
HookArray,
RulesImport,
Number,
}
@@ -102,6 +104,8 @@ impl FieldType {
Self::Bool => "a boolean",
Self::Object => "an object",
Self::StringArray => "an array of strings",
Self::RulesImport => "a string or an array of strings",
Self::HookArray => "an array of strings or hook objects",
Self::Number => "a number",
}
}
@@ -114,6 +118,16 @@ impl FieldType {
Self::StringArray => value
.as_array()
.is_some_and(|arr| arr.iter().all(|v| v.as_str().is_some())),
Self::HookArray => value.as_array().is_some_and(|arr| {
arr.iter()
.all(|entry| entry.as_str().is_some() || entry.as_object().is_some())
}),
Self::RulesImport => {
value.as_str().is_some()
|| value
.as_array()
.is_some_and(|arr| arr.iter().all(|v| v.as_str().is_some()))
}
Self::Number => value.as_i64().is_some(),
}
}
@@ -201,20 +215,24 @@ const TOP_LEVEL_FIELDS: &[FieldSpec] = &[
name: "provider",
expected: FieldType::Object,
},
FieldSpec {
name: "rulesImport",
expected: FieldType::RulesImport,
},
];
const HOOKS_FIELDS: &[FieldSpec] = &[
FieldSpec {
name: "PreToolUse",
expected: FieldType::StringArray,
expected: FieldType::HookArray,
},
FieldSpec {
name: "PostToolUse",
expected: FieldType::StringArray,
expected: FieldType::HookArray,
},
FieldSpec {
name: "PostToolUseFailure",
expected: FieldType::StringArray,
expected: FieldType::HookArray,
},
];
@@ -705,6 +723,57 @@ mod tests {
assert_eq!(result.errors[0].field, "hooks.BadHook");
}
#[test]
fn validates_object_style_hook_entries() {
let source = r#"{"hooks":{"PreToolUse":["legacy",{"matcher":"Bash","hooks":[{"type":"command","command":"echo ok"}]}]}}"#;
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
let result = validate_config_file(object, source, &test_path());
assert!(result.errors.is_empty(), "{:?}", result.errors);
}
#[test]
fn rejects_wrong_hook_entry_types() {
let source = r#"{"hooks":{"PreToolUse":[42]}}"#;
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
let result = validate_config_file(object, source, &test_path());
assert_eq!(result.errors.len(), 1);
assert_eq!(result.errors[0].field, "hooks.PreToolUse");
}
#[test]
fn validates_rules_import_string_and_array_forms() {
for source in [
r#"{"rulesImport":"auto"}"#,
r#"{"rulesImport":"none"}"#,
r#"{"rulesImport":["cursor","copilot"]}"#,
] {
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
let result = validate_config_file(object, source, &test_path());
assert!(result.errors.is_empty(), "{source}: {:?}", result.errors);
}
}
#[test]
fn rejects_rules_import_wrong_type() {
let source = r#"{"rulesImport":42}"#;
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
let result = validate_config_file(object, source, &test_path());
assert_eq!(result.errors.len(), 1);
assert_eq!(result.errors[0].field, "rulesImport");
}
#[test]
fn validates_nested_permissions_keys() {
// given
+49 -14
View File
@@ -11,7 +11,7 @@ use std::time::Duration;
use serde_json::{json, Value};
use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
use crate::config::{RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig};
use crate::permissions::PermissionOverride;
const HOOK_PREVIEW_CHAR_LIMIT: usize = 160;
@@ -182,7 +182,7 @@ impl HookRunner {
) -> HookRunResult {
Self::run_commands(
HookEvent::PreToolUse,
self.config.pre_tool_use(),
self.config.pre_tool_use_entries(),
tool_name,
tool_input,
None,
@@ -232,7 +232,7 @@ impl HookRunner {
) -> HookRunResult {
Self::run_commands(
HookEvent::PostToolUse,
self.config.post_tool_use(),
self.config.post_tool_use_entries(),
tool_name,
tool_input,
Some(tool_output),
@@ -282,7 +282,7 @@ impl HookRunner {
) -> HookRunResult {
Self::run_commands(
HookEvent::PostToolUseFailure,
self.config.post_tool_use_failure(),
self.config.post_tool_use_failure_entries(),
tool_name,
tool_input,
Some(tool_error),
@@ -312,7 +312,7 @@ impl HookRunner {
#[allow(clippy::too_many_arguments)]
fn run_commands(
event: HookEvent,
commands: &[String],
commands: &[RuntimeHookCommand],
tool_name: &str,
tool_input: &str,
tool_output: Option<&str>,
@@ -342,17 +342,21 @@ impl HookRunner {
let payload = hook_payload(event, tool_name, tool_input, tool_output, is_error).to_string();
let mut result = HookRunResult::allow(Vec::new());
for command in commands {
for command in commands
.iter()
.filter(|command| command.matches_tool(tool_name))
{
let command_text = command.command();
if let Some(reporter) = reporter.as_deref_mut() {
reporter.on_event(&HookProgressEvent::Started {
event,
tool_name: tool_name.to_string(),
command: command.clone(),
command: command_text.to_string(),
});
}
match Self::run_command(
command,
command_text,
event,
tool_name,
tool_input,
@@ -366,7 +370,7 @@ impl HookRunner {
reporter.on_event(&HookProgressEvent::Completed {
event,
tool_name: tool_name.to_string(),
command: command.clone(),
command: command_text.to_string(),
});
}
merge_parsed_hook_output(&mut result, parsed);
@@ -376,7 +380,7 @@ impl HookRunner {
reporter.on_event(&HookProgressEvent::Completed {
event,
tool_name: tool_name.to_string(),
command: command.clone(),
command: command_text.to_string(),
});
}
merge_parsed_hook_output(&mut result, parsed);
@@ -388,7 +392,7 @@ impl HookRunner {
reporter.on_event(&HookProgressEvent::Completed {
event,
tool_name: tool_name.to_string(),
command: command.clone(),
command: command_text.to_string(),
});
}
merge_parsed_hook_output(&mut result, parsed);
@@ -400,7 +404,7 @@ impl HookRunner {
reporter.on_event(&HookProgressEvent::Cancelled {
event,
tool_name: tool_name.to_string(),
command: command.clone(),
command: command_text.to_string(),
});
}
result.cancelled = true;
@@ -737,7 +741,7 @@ fn format_hook_failure(command: &str, code: i32, stdout: Option<&str>, stderr: &
fn shell_command(command: &str) -> CommandWithStdin {
#[cfg(windows)]
let mut command_builder = {
let command_builder = {
let mut command_builder = Command::new("cmd");
command_builder.arg("/C").arg(command);
CommandWithStdin::new(command_builder)
@@ -825,7 +829,7 @@ mod tests {
HookAbortSignal, HookEvent, HookProgressEvent, HookProgressReporter, HookRunResult,
HookRunner,
};
use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
use crate::config::{RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig};
use crate::permissions::PermissionOverride;
struct RecordingReporter {
@@ -851,6 +855,37 @@ mod tests {
assert_eq!(result, HookRunResult::allow(vec!["pre ok".to_string()]));
}
#[test]
fn object_style_hook_matchers_filter_runtime_execution() {
let runner = HookRunner::new(RuntimeHookConfig::from_hook_commands(
vec![
RuntimeHookCommand::new(shell_snippet("printf 'legacy'")),
RuntimeHookCommand::with_matcher(
shell_snippet("printf 'bash only'"),
Some("Bash".to_string()),
),
RuntimeHookCommand::with_matcher(
shell_snippet("printf 'read only'"),
Some("Read*".to_string()),
),
],
Vec::new(),
Vec::new(),
));
let read_result = runner.run_pre_tool_use("ReadFile", r#"{"path":"README.md"}"#);
let bash_result = runner.run_pre_tool_use("Bash", r#"{"command":"pwd"}"#);
assert_eq!(
read_result,
HookRunResult::allow(vec!["legacy".to_string(), "read only".to_string()])
);
assert_eq!(
bash_result,
HookRunResult::allow(vec!["legacy".to_string(), "bash only".to_string()])
);
}
#[test]
fn denies_exit_code_two() {
let runner = HookRunner::new(RuntimeHookConfig::new(
+6 -5
View File
@@ -65,11 +65,12 @@ pub use compact::{
get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult,
};
pub use config::{
suppress_config_warnings_for_json_mode, ConfigEntry, ConfigError, ConfigLoader, ConfigSource,
McpConfigCollection, McpManagedProxyServerConfig, McpOAuthConfig, McpRemoteServerConfig,
McpSdkServerConfig, McpServerConfig, McpStdioServerConfig, McpTransport,
McpWebSocketServerConfig, OAuthConfig, ProviderFallbackConfig, ResolvedPermissionMode,
RuntimeConfig, RuntimeFeatureConfig, RuntimeHookConfig, RuntimePermissionRuleConfig,
suppress_config_warnings_for_json_mode, ConfigEntry, ConfigError, ConfigFileReport,
ConfigFileStatus, ConfigInspection, ConfigLoader, ConfigSource, McpConfigCollection,
McpManagedProxyServerConfig, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig,
McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig,
ProviderFallbackConfig, ResolvedPermissionMode, RulesImportConfig, RuntimeConfig,
RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig, RuntimePermissionRuleConfig,
RuntimePluginConfig, ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME,
};
pub use config_validate::{
+277 -4
View File
@@ -3,7 +3,7 @@ use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::config::{ConfigError, ConfigLoader, RuntimeConfig};
use crate::config::{ConfigError, ConfigLoader, RulesImportConfig, RuntimeConfig};
use crate::git_context::GitContext;
/// Errors raised while assembling the final system prompt.
@@ -86,7 +86,24 @@ impl ProjectContext {
current_date: impl Into<String>,
) -> std::io::Result<Self> {
let cwd = cwd.into();
let instruction_files = discover_instruction_files(&cwd)?;
let instruction_files = discover_instruction_files(&cwd, &RulesImportConfig::default())?;
Ok(Self {
cwd,
current_date: current_date.into(),
git_status: None,
git_diff: None,
git_context: None,
instruction_files,
})
}
pub fn discover_with_rules_import(
cwd: impl Into<PathBuf>,
current_date: impl Into<String>,
rules_import: &RulesImportConfig,
) -> std::io::Result<Self> {
let cwd = cwd.into();
let instruction_files = discover_instruction_files(&cwd, rules_import)?;
Ok(Self {
cwd,
current_date: current_date.into(),
@@ -109,6 +126,18 @@ impl ProjectContext {
}
}
fn discover_with_git_and_rules_import(
cwd: impl Into<PathBuf>,
current_date: impl Into<String>,
rules_import: &RulesImportConfig,
) -> std::io::Result<ProjectContext> {
let mut context = ProjectContext::discover_with_rules_import(cwd, current_date, rules_import)?;
context.git_status = read_git_status(&context.cwd);
context.git_diff = read_git_diff(&context.cwd);
context.git_context = GitContext::detect(&context.cwd);
Ok(context)
}
/// Builder for the runtime system prompt and dynamic environment sections.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SystemPromptBuilder {
@@ -227,7 +256,10 @@ pub fn prepend_bullets(items: Vec<String>) -> Vec<String> {
items.into_iter().map(|item| format!(" - {item}")).collect()
}
fn discover_instruction_files(cwd: &Path) -> std::io::Result<Vec<ContextFile>> {
fn discover_instruction_files(
cwd: &Path,
rules_import: &RulesImportConfig,
) -> std::io::Result<Vec<ContextFile>> {
let mut directories = Vec::new();
let mut cursor = Some(cwd);
while let Some(dir) = cursor {
@@ -240,17 +272,25 @@ fn discover_instruction_files(cwd: &Path) -> std::io::Result<Vec<ContextFile>> {
for dir in directories {
for candidate in [
dir.join("CLAUDE.md"),
dir.join("AGENTS.md"),
dir.join("CLAUDE.local.md"),
dir.join(".claw").join("CLAUDE.md"),
dir.join(".claude").join("CLAUDE.md"),
dir.join(".claw").join("instructions.md"),
] {
push_context_file(&mut files, candidate)?;
}
push_rules_dir(&mut files, dir.join(".claw").join("rules"))?;
push_rules_dir(&mut files, dir.join(".claw").join("rules.local"))?;
push_framework_imports(&mut files, &dir, rules_import)?
}
Ok(dedupe_instruction_files(files))
}
fn push_context_file(files: &mut Vec<ContextFile>, path: PathBuf) -> std::io::Result<()> {
if path.is_dir() {
return Ok(());
}
match fs::read_to_string(&path) {
Ok(content) if !content.trim().is_empty() => {
files.push(ContextFile { path, content });
@@ -262,6 +302,64 @@ fn push_context_file(files: &mut Vec<ContextFile>, path: PathBuf) -> std::io::Re
}
}
fn push_rules_dir(files: &mut Vec<ContextFile>, dir: PathBuf) -> std::io::Result<()> {
if dir.is_file() {
return Ok(());
}
let entries = match fs::read_dir(&dir) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(error),
};
let mut paths = entries
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.is_file() && is_supported_rule_file(path))
.collect::<Vec<_>>();
paths.sort();
for path in paths {
push_context_file(files, path)?;
}
Ok(())
}
fn is_supported_rule_file(path: &Path) -> bool {
path.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| {
matches!(
extension.to_ascii_lowercase().as_str(),
"md" | "txt" | "mdc"
)
})
}
fn push_framework_imports(
files: &mut Vec<ContextFile>,
dir: &Path,
rules_import: &RulesImportConfig,
) -> std::io::Result<()> {
if rules_import.should_import("cursor") {
push_context_file(files, dir.join(".cursorrules"))?;
push_rules_dir(files, dir.join(".cursor").join("rules"))?;
}
if rules_import.should_import("copilot") {
push_context_file(files, dir.join(".github").join("copilot-instructions.md"))?;
}
if rules_import.should_import("windsurf") {
push_context_file(files, dir.join(".windsurfrules"))?;
push_rules_dir(files, dir.join(".windsurfrules"))?;
}
if rules_import.should_import("plandex") {
push_context_file(files, dir.join(".plandex").join("instructions.md"))?;
}
if rules_import.should_import("crush") {
push_context_file(files, dir.join(".crush").join("CLAUDE.md"))?;
push_rules_dir(files, dir.join(".crush").join("rules"))?;
}
Ok(())
}
fn read_git_status(cwd: &Path) -> Option<String> {
let output = Command::new("git")
.args(["--no-optional-locks", "status", "--short", "--branch"])
@@ -476,8 +574,9 @@ pub fn load_system_prompt(
model_family: ModelFamilyIdentity,
) -> Result<Vec<String>, PromptBuildError> {
let cwd = cwd.into();
let project_context = ProjectContext::discover_with_git(&cwd, current_date.into())?;
let config = ConfigLoader::default_for(&cwd).load()?;
let project_context =
discover_with_git_and_rules_import(&cwd, current_date.into(), config.rules_import())?;
Ok(SystemPromptBuilder::new()
.with_os(os_name, os_version)
.with_model_family(model_family)
@@ -590,6 +689,78 @@ mod tests {
}
}
#[test]
fn discovers_claw_rules_files_in_sorted_order() {
let root = temp_dir();
let rules = root.join(".claw").join("rules");
let local_rules = root.join(".claw").join("rules.local");
fs::create_dir_all(&rules).expect("rules dir");
fs::create_dir_all(&local_rules).expect("local rules dir");
fs::write(rules.join("b.txt"), "b rule").expect("write b rule");
fs::write(rules.join("a.md"), "a rule").expect("write a rule");
fs::write(rules.join("ignored.json"), "ignored rule").expect("write ignored");
fs::write(local_rules.join("c.mdc"), "c local rule").expect("write local rule");
let context = ProjectContext::discover(&root, "2026-03-31").expect("context should load");
let contents = context
.instruction_files
.iter()
.map(|file| file.content.as_str())
.collect::<Vec<_>>();
assert_eq!(contents, vec!["a rule", "b rule", "c local rule"]);
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn rules_import_none_suppresses_external_framework_rules() {
let root = temp_dir();
fs::create_dir_all(root.join(".claw").join("rules")).expect("rules dir");
fs::write(
root.join(".claw").join("rules").join("project.md"),
"claw rule",
)
.expect("write claw rule");
fs::write(root.join(".cursorrules"), "cursor rule").expect("write cursor rule");
let context = ProjectContext::discover_with_rules_import(
&root,
"2026-03-31",
&crate::config::RulesImportConfig::None,
)
.expect("context should load");
let rendered = render_instruction_files(&context.instruction_files);
assert!(rendered.contains("claw rule"));
assert!(!rendered.contains("cursor rule"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn rules_import_list_loads_only_selected_framework_rules() {
let root = temp_dir();
fs::create_dir_all(&root).expect("root dir");
fs::write(root.join(".cursorrules"), "cursor rule").expect("write cursor rule");
fs::create_dir_all(root.join(".github")).expect("github dir");
fs::write(
root.join(".github").join("copilot-instructions.md"),
"copilot rule",
)
.expect("write copilot rule");
let context = ProjectContext::discover_with_rules_import(
&root,
"2026-03-31",
&crate::config::RulesImportConfig::List(vec!["copilot".to_string()]),
)
.expect("context should load");
let rendered = render_instruction_files(&context.instruction_files);
assert!(rendered.contains("copilot rule"));
assert!(!rendered.contains("cursor rule"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn discovers_instruction_files_from_ancestor_chain() {
let root = temp_dir();
@@ -636,6 +807,63 @@ mod tests {
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn discovers_agents_markdown_instruction_file() {
let root = temp_dir();
fs::create_dir_all(&root).expect("root dir");
fs::write(root.join("AGENTS.md"), "agents-only instructions").expect("write AGENTS.md");
let context = ProjectContext::discover(&root, "2026-03-31").expect("context should load");
assert_eq!(context.instruction_files.len(), 1);
assert!(context.instruction_files[0].path.ends_with("AGENTS.md"));
assert!(render_instruction_files(&context.instruction_files)
.contains("agents-only instructions"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn discovers_scoped_dot_claude_claude_markdown_instruction_file() {
let root = temp_dir();
fs::create_dir_all(root.join(".claude")).expect("dot claude dir");
fs::write(
root.join(".claude").join("CLAUDE.md"),
"dot-claude-only instructions",
)
.expect("write .claude/CLAUDE.md");
let context = ProjectContext::discover(&root, "2026-03-31").expect("context should load");
assert_eq!(context.instruction_files.len(), 1);
assert!(context.instruction_files[0]
.path
.ends_with(".claude/CLAUDE.md"));
assert!(render_instruction_files(&context.instruction_files)
.contains("dot-claude-only instructions"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn discovers_claude_agents_and_dot_claude_instruction_files_together() {
let root = temp_dir();
fs::create_dir_all(root.join(".claude")).expect("dot claude dir");
fs::write(root.join("CLAUDE.md"), "claude instructions").expect("write CLAUDE.md");
fs::write(root.join("AGENTS.md"), "agents instructions").expect("write AGENTS.md");
fs::write(
root.join(".claude").join("CLAUDE.md"),
"dot claude instructions",
)
.expect("write .claude/CLAUDE.md");
let context = ProjectContext::discover(&root, "2026-03-31").expect("context should load");
let rendered = render_instruction_files(&context.instruction_files);
assert!(rendered.contains("claude instructions"));
assert!(rendered.contains("agents instructions"));
assert!(rendered.contains("dot claude instructions"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn dedupes_identical_instruction_content_across_scopes() {
let root = temp_dir();
@@ -876,6 +1104,51 @@ mod tests {
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn load_system_prompt_respects_rules_import_config() {
let root = temp_dir();
fs::create_dir_all(root.join(".claw")).expect("claw dir");
fs::write(root.join(".cursorrules"), "cursor rule").expect("write cursor rule");
fs::write(
root.join(".claw").join("settings.json"),
r#"{"rulesImport":"none"}"#,
)
.expect("write settings");
let _guard = env_lock();
ensure_valid_cwd();
let previous = std::env::current_dir().expect("cwd");
let original_home = std::env::var("HOME").ok();
let original_claw_home = std::env::var("CLAW_CONFIG_HOME").ok();
std::env::set_var("HOME", &root);
std::env::set_var("CLAW_CONFIG_HOME", root.join("missing-home"));
std::env::set_current_dir(&root).expect("change cwd");
let prompt = super::load_system_prompt(
&root,
"2026-03-31",
"linux",
"6.8",
ModelFamilyIdentity::Claude,
)
.expect("system prompt should load")
.join("\n\n");
std::env::set_current_dir(previous).expect("restore cwd");
if let Some(value) = original_home {
std::env::set_var("HOME", value);
} else {
std::env::remove_var("HOME");
}
if let Some(value) = original_claw_home {
std::env::set_var("CLAW_CONFIG_HOME", value);
} else {
std::env::remove_var("CLAW_CONFIG_HOME");
}
assert!(!prompt.contains("cursor rule"));
assert!(prompt.contains("rulesImport"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn renders_default_claude_model_family_identity() {
// given: a prompt builder without an explicit model family override
+406 -97
View File
@@ -55,8 +55,8 @@ use render::{MarkdownStreamState, Spinner, TerminalRenderer};
use runtime::{
check_base_commit, format_stale_base_warning, format_usd, load_oauth_credentials,
load_system_prompt, pricing_for_model, resolve_expected_base, resolve_sandbox_status,
ApiClient, ApiRequest, AssistantEvent, BaseCommitState, CompactionConfig, ConfigLoader,
ConfigSource, ContentBlock, ConversationMessage, ConversationRuntime, McpServer,
ApiClient, ApiRequest, AssistantEvent, BaseCommitState, CompactionConfig, ConfigFileReport,
ConfigLoader, ConfigSource, ContentBlock, ConversationMessage, ConversationRuntime, McpServer,
McpServerManager, McpServerSpec, McpTool, MessageRole, ModelPricing, PermissionMode,
PermissionPolicy, ProjectContext, PromptCacheEvent, ResolvedPermissionMode, RuntimeError,
Session, TokenUsage, ToolError, ToolExecutor, UsageTracker,
@@ -77,12 +77,12 @@ const DEFAULT_MODEL: &str = "anthropic/claude-opus-4-6";
enum ModelSource {
/// Explicit `--model` / `--model=` CLI flag.
Flag,
/// ANTHROPIC_MODEL environment variable (when no flag was passed).
/// `ANTHROPIC_MODEL` environment variable (when no flag was passed).
Env,
/// `model` key in `.claw.json` / `.claw/settings.json` (when neither
/// flag nor env set it).
Config,
/// Compiled-in DEFAULT_MODEL fallback.
/// Compiled-in `DEFAULT_MODEL` fallback.
Default,
}
@@ -266,7 +266,7 @@ Run `claw --help` for usage."
/// #77: Classify a stringified error message into a machine-readable kind.
///
/// Returns a snake_case token that downstream consumers can switch on instead
/// Returns a `snake_case` token that downstream consumers can switch on instead
/// of regex-scraping the prose. The classification is best-effort prefix/keyword
/// matching against the error messages produced throughout the CLI surface.
fn classify_error_kind(message: &str) -> &'static str {
@@ -297,6 +297,8 @@ fn classify_error_kind(message: &str) -> &'static str {
"session_load_failed"
} else if message.contains("unsupported ACP invocation") {
"unsupported_acp_invocation"
} else if message.starts_with("missing_argument:") {
"missing_argument"
} else if message.contains("unsupported skills action") {
"unsupported_skills_action"
} else if message.contains("unrecognized argument") || message.contains("unknown option") {
@@ -388,9 +390,9 @@ fn classify_error_kind(message: &str) -> &'static str {
}
}
/// #77: Split a multi-line error message into (short_reason, optional_hint).
/// #77: Split a multi-line error message into (`short_reason`, `optional_hint`).
///
/// The short_reason is the first line (up to the first newline), and the hint
/// The `short_reason` is the first line (up to the first newline), and the hint
/// is the remaining text or `None` if there's no newline. This prevents the
/// runbook prose from being stuffed into the `error` field that downstream
/// parsers expect to be the short reason alone.
@@ -415,6 +417,9 @@ fn fallback_hint_for_error_kind(kind: &str) -> Option<&'static str> {
"missing_credentials" => {
Some("Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN before running claw.")
}
"config_parse_error" => Some(
"Fix the JSON syntax or schema in the referenced .claw/settings.json or .claw.json file, then rerun the command.",
),
// #787: session load failures have no \n-delimited hint from the OS error path
"session_load_failed" => Some(
"Pass a path to a .jsonl session file, not a directory. Managed sessions live in .claw/sessions/.",
@@ -645,6 +650,10 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
);
}
},
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()?);
@@ -768,6 +777,10 @@ enum CliAction {
section: Option<String>,
output_format: CliOutputFormat,
},
Models {
action: Option<String>,
output_format: CliOutputFormat,
},
Diff {
output_format: CliOutputFormat,
},
@@ -815,6 +828,8 @@ enum LocalHelpTopic {
Plugins,
Mcp,
Config,
Model,
Settings,
Diff,
}
@@ -1074,6 +1089,8 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
"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,
};
@@ -1290,10 +1307,23 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
"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`."
@@ -1335,6 +1365,25 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
}),
}
}
"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())),
@@ -1379,13 +1428,14 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
allow_broad_cwd,
),
other => {
if rest.len() == 1 && looks_like_subcommand_typo(other) {
// #825: always emit a command_not_found error for
// single-word all-alpha/dash tokens that don't match any
// known subcommand — with or without close suggestions.
// Multi-word cases fall through to CliAction::Prompt so
// natural language prompts like `claw explain this` work.
// (#826 documents the multi-word gap as a known limitation.)
if looks_like_subcommand_typo(other)
&& (rest.len() == 1 || output_format == CliOutputFormat::Json)
{
// #825/#826: emit command_not_found before provider startup for
// command-shaped tokens that do not match known subcommands.
// Text-mode multi-word prompt shorthand remains available, but
// JSON-mode automation must not turn an unknown command into a
// credential-gated prompt request.
let mut message = format!("command_not_found: unknown subcommand: {other}.");
if let Some(suggestions) = suggest_similar_subcommand(other) {
if let Some(line) = render_suggestion_line("Did you mean", &suggestions) {
@@ -1450,6 +1500,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));
@@ -1515,6 +1567,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,
};
@@ -1564,6 +1618,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,
};
@@ -1707,6 +1763,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,
@@ -2001,6 +2068,9 @@ fn validate_model_syntax(model: &str) -> Result<(), String> {
trimmed
));
}
if is_bare_provider_model(trimmed) {
return Ok(());
}
// Check provider/model format: provider_id/model_id
let parts: Vec<&str> = trimmed.split('/').collect();
if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
@@ -2027,6 +2097,10 @@ fn validate_model_syntax(model: &str) -> Result<(), String> {
Ok(())
}
fn is_bare_provider_model(model: &str) -> bool {
model.starts_with("claude-") || model.starts_with("gpt-")
}
fn config_alias_for_current_dir(alias: &str) -> Option<String> {
if alias.is_empty() {
return None;
@@ -2616,6 +2690,7 @@ fn render_doctor_report(
session_lifecycle: classify_session_lifecycle_for(&cwd),
boot_preflight,
sandbox_status: resolve_sandbox_status(sandbox_config.sandbox(), &cwd),
binary_provenance: binary_provenance_for(Some(&cwd)),
// Doctor path has its own config check; StatusContext here is only
// fed into health renderers that don't read config_load_error.
config_load_error: config.as_ref().err().map(ToString::to_string),
@@ -3233,6 +3308,14 @@ fn check_system_health(cwd: &Path, config: Option<&runtime::RuntimeConfig>) -> D
if let Some(model) = default_model {
details.push(format!("Default model {model}"));
}
let binary_provenance = binary_provenance_for(Some(cwd));
details.push(format!(
"Binary provenance status={} workspace_match={}",
binary_provenance.status(),
binary_provenance
.workspace_match
.map_or_else(|| "unknown".to_string(), |matches| matches.to_string())
));
DiagnosticCheck::new(
"System",
DiagnosticLevel::Ok,
@@ -3246,6 +3329,10 @@ fn check_system_health(cwd: &Path, config: Option<&runtime::RuntimeConfig>) -> D
("version".to_string(), json!(VERSION)),
("build_target".to_string(), json!(BUILD_TARGET)),
("git_sha".to_string(), json!(GIT_SHA)),
(
"binary_provenance".to_string(),
binary_provenance.json_value(),
),
("default_model".to_string(), json!(default_model)),
]))
}
@@ -3429,17 +3516,19 @@ fn print_version(output_format: CliOutputFormat) -> Result<(), Box<dyn std::erro
}
fn version_json_value() -> serde_json::Value {
let executable_path = env::current_exe().ok().map(|p| p.display().to_string());
let cwd = env::current_dir().ok();
let binary_provenance = binary_provenance_for(cwd.as_deref());
json!({
"kind": "version",
"action": "show",
"status": "ok",
"message": render_version_report(),
"version": VERSION,
"git_sha": GIT_SHA,
"target": BUILD_TARGET,
"build_date": DEFAULT_DATE,
"executable_path": executable_path,
"git_sha": binary_provenance.git_sha,
"target": binary_provenance.target,
"build_date": binary_provenance.build_date,
"executable_path": binary_provenance.executable_path,
"binary_provenance": binary_provenance.json_value(),
})
}
@@ -3654,6 +3743,7 @@ struct StatusContext {
session_lifecycle: SessionLifecycleSummary,
boot_preflight: BootPreflightSnapshot,
sandbox_status: runtime::SandboxStatus,
binary_provenance: BinaryProvenance,
/// #143: when `.claw.json` (or another loaded config file) fails to parse,
/// we capture the parse error here and still populate every field that
/// doesn't depend on runtime config (workspace, git, sandbox defaults,
@@ -3668,6 +3758,87 @@ struct StatusContext {
config_load_error_kind: Option<&'static str>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct BinaryProvenance {
git_sha: Option<String>,
target: Option<String>,
build_date: String,
executable_path: Option<String>,
workspace_git_sha: Option<String>,
workspace_match: Option<bool>,
hint: Option<String>,
}
impl BinaryProvenance {
fn status(&self) -> &'static str {
if self.git_sha.is_some() {
"known"
} else {
"unknown"
}
}
fn json_value(&self) -> serde_json::Value {
json!({
"status": self.status(),
"git_sha": self.git_sha,
"target": self.target,
"build_date": self.build_date,
"executable_path": self.executable_path,
"workspace_git_sha": self.workspace_git_sha,
"workspace_match": self.workspace_match,
"hint": self.hint,
})
}
}
fn known_build_metadata(value: Option<&str>) -> Option<String> {
let value = value?.trim();
if value.is_empty() || value == "unknown" {
None
} else {
Some(value.to_string())
}
}
fn binary_provenance_for(cwd: Option<&Path>) -> BinaryProvenance {
let git_sha = known_build_metadata(GIT_SHA);
let target = known_build_metadata(BUILD_TARGET);
let workspace_git_sha = cwd.and_then(|cwd| {
run_git_capture_in(cwd, &["rev-parse", "--short", "HEAD"])
.map(|sha| sha.trim().to_string())
.filter(|sha| !sha.is_empty())
});
let workspace_match = git_sha
.as_deref()
.zip(workspace_git_sha.as_deref())
.map(|(binary, workspace)| binary.starts_with(workspace) || workspace.starts_with(binary));
let hint = if git_sha.is_none() {
Some(
"Build metadata did not include a git SHA; rebuild from a git checkout before filing provenance-sensitive dogfood reports."
.to_string(),
)
} else if workspace_match == Some(false) {
Some(
"The running binary was built from a different commit than the current workspace HEAD; rebuild or switch binaries before attributing behavior to this checkout."
.to_string(),
)
} else {
None
};
BinaryProvenance {
git_sha,
target,
build_date: DEFAULT_DATE.to_string(),
executable_path: env::current_exe()
.ok()
.map(|path| path.display().to_string()),
workspace_git_sha,
workspace_match,
hint,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct BranchFreshness {
upstream: Option<String>,
@@ -6444,8 +6615,8 @@ impl LiveCli {
// Propagate ok:false → non-zero exit so automation callers
// can rely on exit code instead of inspecting the envelope.
// (#68: mcp error envelopes previously always exited 0.)
let is_error = value.get("ok").and_then(|v| v.as_bool()) == Some(false)
|| value.get("status").and_then(|v| v.as_str()) == Some("error");
let is_error = value.get("ok").and_then(serde_json::Value::as_bool) == Some(false)
|| value.get("status").and_then(serde_json::Value::as_str) == Some("error");
println!("{}", serde_json::to_string_pretty(&value)?);
if is_error {
std::process::exit(1);
@@ -7436,6 +7607,7 @@ fn status_json_value(
"restricted": allowed_tools.is_some(),
"entries": allowed_tool_entries,
},
"binary_provenance": context.binary_provenance.json_value(),
"usage": {
"messages": usage.message_count,
"turns": usage.turns,
@@ -7563,6 +7735,7 @@ fn status_context(
session_lifecycle: classify_session_lifecycle_for(&cwd),
boot_preflight,
sandbox_status,
binary_provenance: binary_provenance_for(Some(&cwd)),
config_load_error,
config_load_error_kind,
})
@@ -7917,6 +8090,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 <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 <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 <format>]
Purpose show the diff of changes relative to the expected base commit
@@ -7944,10 +8132,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<dyn std::error::Error>> {
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 <unset>");
}
println!(" Usage claw --model <provider/model> prompt <text>");
}
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 <provider/model> or configure a model in claw settings."
}))?
);
}
}
Ok(())
}
fn render_export_help_json() -> serde_json::Value {
json!({
"kind": "help",
@@ -8185,24 +8440,29 @@ fn render_config_report(section: Option<&str>) -> Result<String, Box<dyn std::er
if let Some(section) = section {
lines.push(format!("Merged section: {section}"));
let value = match section {
"env" => 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(
"
@@ -8212,10 +8472,7 @@ fn render_config_report(section: Option<&str>) -> Result<String, Box<dyn std::er
};
lines.push(format!(
" {}",
match value {
Some(value) => value.render(),
None => "<unset>".to_string(),
}
rendered.unwrap_or_else(|| "<unset>".to_string())
));
return Ok(lines.join(
"
@@ -8236,38 +8493,28 @@ fn render_config_json(
) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let loader = ConfigLoader::default_for(&cwd);
let discovered = loader.discover();
// #773: use load_collecting_warnings so deprecation warnings are surfaced in the
// JSON envelope instead of only as unstructured stderr text.
let (runtime_config, config_warnings) = loader.load_collecting_warnings()?;
let loaded_paths: Vec<_> = runtime_config
.loaded_entries()
// #773: keep deprecation warnings in the JSON envelope, and #407: include
// per-file status/reason/detail for every discovered config path.
let inspection = loader.inspect_collecting_warnings();
if section.is_some() {
if let Some(error) = &inspection.load_error {
return Err(error.clone().into());
}
}
let runtime_config = inspection
.runtime_config
.clone()
.unwrap_or_else(runtime::RuntimeConfig::empty);
let loaded_files = runtime_config.loaded_entries().len();
let merged_keys = runtime_config.merged().len();
let files: Vec<_> = inspection
.files
.iter()
.map(|e| e.path.display().to_string())
.map(config_file_report_json)
.collect();
let files: Vec<_> = discovered
.iter()
.map(|e| {
let source = match e.source {
ConfigSource::User => "user",
ConfigSource::Project => "project",
ConfigSource::Local => "local",
};
let is_loaded = runtime_config
.loaded_entries()
.iter()
.any(|le| le.path == e.path);
serde_json::json!({
"path": e.path.display().to_string(),
"source": source,
"loaded": is_loaded,
})
})
.collect();
let warnings_json: Vec<serde_json::Value> = config_warnings
let warnings_json: Vec<serde_json::Value> = inspection
.warnings
.iter()
.map(|w| serde_json::Value::String(w.clone()))
.collect();
@@ -8275,14 +8522,15 @@ fn render_config_json(
let base = serde_json::json!({
"kind": "config",
"action": if section.is_some() { "show" } else { "list" },
"status": "ok",
"status": if inspection.load_error.is_some() { "error" } else { "ok" },
"cwd": cwd.display().to_string(),
"loaded_files": loaded_paths.len(),
"merged_keys": runtime_config.merged().len(),
"loaded_files": loaded_files,
"merged_keys": merged_keys,
"merged_key_count": merged_keys,
"merged_keys_meaning": "count of top-level keys in the effective merged JSON object",
"files": files,
// #773: deprecation warnings surfaced structurally so JSON-mode callers
// don't need to strip unstructured text from stderr
"warnings": warnings_json,
"load_error": inspection.load_error.clone(),
});
if let Some(section) = section {
@@ -8305,16 +8553,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 <section>` 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 <section>` 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!({
@@ -8324,12 +8573,12 @@ 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,
"loaded_files": loaded_files,
"files": base["files"].clone(),
}));
}
};
@@ -8352,6 +8601,45 @@ fn render_config_json(
Ok(base)
}
fn config_file_report_json(file: &ConfigFileReport) -> serde_json::Value {
let source = match file.entry.source {
ConfigSource::User => "user",
ConfigSource::Project => "project",
ConfigSource::Local => "local",
};
let mut object = serde_json::Map::new();
object.insert(
"path".to_string(),
serde_json::Value::String(file.entry.path.display().to_string()),
);
object.insert(
"source".to_string(),
serde_json::Value::String(source.to_string()),
);
object.insert("loaded".to_string(), serde_json::Value::Bool(file.loaded));
object.insert(
"status".to_string(),
serde_json::Value::String(file.status.as_str().to_string()),
);
if let Some(reason) = &file.reason {
object.insert(
"reason".to_string(),
serde_json::Value::String(reason.clone()),
);
object.insert(
"skip_reason".to_string(),
serde_json::Value::String(reason.clone()),
);
}
if let Some(detail) = &file.detail {
object.insert(
"detail".to_string(),
serde_json::Value::String(detail.clone()),
);
}
serde_json::Value::Object(object)
}
fn render_memory_report() -> Result<String, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let project_context = ProjectContext::discover(&cwd, DEFAULT_DATE)?;
@@ -8487,8 +8775,7 @@ fn render_diff_report_for(cwd: &Path) -> Result<String, Box<dyn std::error::Erro
.args(["rev-parse", "--is-inside-work-tree"])
.current_dir(cwd)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
.is_ok_and(|o| o.status.success());
if !in_git_repo {
return Ok(format!(
"Diff\n Result no git repository\n Detail {} is not inside a git project",
@@ -8520,8 +8807,7 @@ fn render_diff_json_for(cwd: &Path) -> Result<serde_json::Value, Box<dyn std::er
.args(["rev-parse", "--is-inside-work-tree"])
.current_dir(cwd)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
.is_ok_and(|o| o.status.success());
if !in_git_repo {
// #801: add error_kind, hint, message fields for envelope parity with other error paths
return Ok(serde_json::json!({
@@ -8772,8 +9058,7 @@ fn command_exists(name: &str) -> bool {
Command::new("which")
.arg(name)
.output()
.map(|output| output.status.success())
.unwrap_or(false)
.is_ok_and(|output| output.status.success())
}
fn write_temp_text_file(
@@ -12171,6 +12456,12 @@ mod tests {
assert_eq!(resolve_model_alias("claude-opus"), "claude-opus");
}
#[test]
fn default_model_alias_uses_anthropic_routing_prefix() {
assert_eq!(DEFAULT_MODEL, "anthropic/claude-opus-4-6");
assert_eq!(resolve_model_alias("opus"), "anthropic/claude-opus-4-6");
}
#[test]
fn user_defined_aliases_resolve_before_provider_dispatch() {
// given
@@ -12678,6 +12969,19 @@ mod tests {
}
other => panic!("expected CliAction::Status, got: {other:?}"),
}
match parse_args(&["--model=claude-opus-4-6".to_string(), "status".to_string()])
.expect("bare Anthropic model should parse")
{
CliAction::Status {
model,
model_flag_raw,
..
} => {
assert_eq!(model, "claude-opus-4-6");
assert_eq!(model_flag_raw.as_deref(), Some("claude-opus-4-6"));
}
other => panic!("expected CliAction::Status, got: {other:?}"),
}
}
#[test]
@@ -13203,22 +13507,19 @@ mod tests {
!err_other.contains("--output-format json"),
"unrelated args should not trigger --json hint: {err_other}"
);
// #154: model syntax error should hint at provider prefix when applicable
let err_gpt = parse_args(&[
// #424: bare canonical GPT model ids should parse and route via provider
// detection instead of forcing the local-only `openai/` routing prefix.
match parse_args(&[
"prompt".to_string(),
"test".to_string(),
"--model".to_string(),
"gpt-4".to_string(),
])
.expect_err("`--model gpt-4` should fail with OpenAI hint");
assert!(
err_gpt.contains("Did you mean `openai/gpt-4`?"),
"GPT model error should hint openai/ prefix: {err_gpt}"
);
assert!(
err_gpt.contains("OPENAI_API_KEY"),
"GPT model error should mention env var: {err_gpt}"
);
.expect("`--model gpt-4` should parse as a bare OpenAI model")
{
CliAction::Prompt { model, .. } => assert_eq!(model, "gpt-4"),
other => panic!("expected CliAction::Prompt, got: {other:?}"),
}
let err_qwen = parse_args(&[
"prompt".to_string(),
"test".to_string(),
@@ -13476,6 +13777,11 @@ mod tests {
classify_error_kind("unknown_option: unknown system-prompt option: --foo."),
"unknown_option"
);
// #830: known command with missing required argument must not collapse to unknown.
assert_eq!(
classify_error_kind("missing_argument: mcp show requires a server name."),
"missing_argument"
);
}
#[test]
@@ -14647,6 +14953,7 @@ mod tests {
},
boot_preflight: test_boot_preflight(),
sandbox_status: runtime::SandboxStatus::default(),
binary_provenance: super::binary_provenance_for(None),
config_load_error: None,
config_load_error_kind: None,
},
@@ -14792,6 +15099,7 @@ mod tests {
},
boot_preflight: test_boot_preflight(),
sandbox_status: runtime::SandboxStatus::default(),
binary_provenance: super::binary_provenance_for(None),
config_load_error: None,
config_load_error_kind: None,
};
@@ -14830,6 +15138,7 @@ mod tests {
},
boot_preflight: test_boot_preflight(),
sandbox_status: runtime::SandboxStatus::default(),
binary_provenance: super::binary_provenance_for(None),
config_load_error: None,
config_load_error_kind: None,
};
@@ -145,6 +145,80 @@ fn version_emits_json_when_requested() {
parsed["executable_path"].is_string(),
"executable_path must be a string in version JSON so callers can identify which binary is running"
);
let binary_provenance = parsed["binary_provenance"]
.as_object()
.expect("version JSON must include binary_provenance object (#797)");
assert!(matches!(
binary_provenance["status"].as_str(),
Some("known" | "unknown")
));
assert_eq!(binary_provenance["git_sha"], parsed["git_sha"]);
assert_eq!(binary_provenance["target"], parsed["target"]);
assert_eq!(binary_provenance["build_date"], parsed["build_date"]);
assert_eq!(
binary_provenance["executable_path"],
parsed["executable_path"]
);
assert!(
binary_provenance["hint"].is_string() || binary_provenance["hint"].is_null(),
"binary provenance must classify missing/stale lineage with a structured hint field"
);
}
#[test]
fn version_status_doctor_include_binary_provenance_797() {
let root = git_temp_dir("binary-provenance-797");
fs::write(root.join("tracked.txt"), "v1").expect("write tracked file");
let git_commands: &[&[&str]] = &[
&["config", "user.email", "test@claw.test"],
&["config", "user.name", "Test"],
&["add", "tracked.txt"],
&["commit", "-m", "init"],
];
for args in git_commands {
let output = Command::new("git")
.args(*args)
.current_dir(&root)
.output()
.expect("git fixture command should launch");
assert!(
output.status.success(),
"git fixture command failed: {args:?}\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
let version = assert_json_command(&root, &["--output-format", "json", "version"]);
assert_eq!(version["kind"], "version");
assert!(matches!(
version["binary_provenance"]["status"].as_str(),
Some("known" | "unknown")
));
assert!(version["binary_provenance"]["workspace_git_sha"].is_string());
assert!(
version["binary_provenance"]["workspace_match"].is_boolean()
|| version["binary_provenance"]["workspace_match"].is_null()
);
let status = assert_json_command(&root, &["--output-format", "json", "status"]);
assert_eq!(status["kind"], "status");
assert_eq!(
status["binary_provenance"]["workspace_git_sha"],
version["binary_provenance"]["workspace_git_sha"]
);
let doctor = assert_json_command(&root, &["--output-format", "json", "doctor"]);
let system = doctor["checks"]
.as_array()
.expect("doctor checks")
.iter()
.find(|check| check["name"] == "system")
.expect("system check");
assert_eq!(
system["binary_provenance"]["workspace_git_sha"],
version["binary_provenance"]["workspace_git_sha"]
);
}
#[test]
@@ -161,6 +235,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");
@@ -721,6 +841,17 @@ fn doctor_and_resume_status_emit_json_when_requested() {
.expect("workspace check");
assert!(workspace["cwd"].as_str().is_some());
assert!(workspace["in_git_repo"].is_boolean());
let status = assert_json_command(&root, &["--output-format", "json", "status"]);
assert_eq!(status["kind"], "status");
assert!(matches!(
status["binary_provenance"]["status"].as_str(),
Some("known" | "unknown")
));
assert!(status["binary_provenance"]["executable_path"].is_string());
assert!(
status["binary_provenance"]["workspace_match"].is_boolean()
|| status["binary_provenance"]["workspace_match"].is_null()
);
let boot_preflight = checks
.iter()
@@ -754,6 +885,14 @@ fn doctor_and_resume_status_emit_json_when_requested() {
assert!(sandbox["enabled"].is_boolean());
assert!(sandbox["fallback_reason"].is_null() || sandbox["fallback_reason"].is_string());
let system = checks
.iter()
.find(|check| check["name"] == "system")
.expect("system check");
assert!(matches!(
system["binary_provenance"]["status"].as_str(),
Some("known" | "unknown")
));
let session_path = write_session_fixture(&root, "resume-json", Some("hello"));
let resumed = assert_json_command(
&root,
@@ -1320,8 +1459,116 @@ 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 config_json_reports_structured_unloaded_file_reasons_407() {
let root = unique_temp_dir("config-file-status-407");
let config_home = root.join("config-home");
let home = root.join("home");
fs::create_dir_all(root.join(".claw")).expect("workspace config should exist");
fs::create_dir_all(&config_home).expect("config home should exist");
fs::create_dir_all(&home).expect("home should exist");
fs::write(root.join(".claw.json"), "{not json").expect("legacy skip fixture should write");
fs::write(
root.join(".claw").join("settings.json"),
r#"{"model":"opus"}"#,
)
.expect("project config fixture should write");
let envs = [
(
"CLAW_CONFIG_HOME",
config_home.to_str().expect("utf8 config home"),
),
("HOME", home.to_str().expect("utf8 home")),
];
let output = run_claw(&root, &["--output-format", "json", "config"], &envs);
assert!(
output.status.success(),
"stdout:\n{}\n\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let parsed: Value = serde_json::from_slice(&output.stdout).expect("stdout valid json");
assert_eq!(parsed["kind"], "config");
assert_eq!(parsed["status"], "ok");
assert_eq!(parsed["loaded_files"], 1);
assert_eq!(parsed["merged_keys"], parsed["merged_key_count"]);
assert_eq!(
parsed["merged_keys_meaning"].as_str(),
Some("count of top-level keys in the effective merged JSON object")
);
assert!(parsed["load_error"].is_null());
let files = parsed["files"].as_array().expect("files array");
let loaded = files
.iter()
.find(|file| file["loaded"] == true)
.expect("loaded config file");
assert_eq!(loaded["status"], "loaded");
assert!(loaded.get("reason").is_none());
let missing = files
.iter()
.find(|file| file["status"] == "not_found")
.expect("missing config file");
assert_eq!(missing["loaded"], false);
assert_eq!(missing["reason"], "not_found");
assert_eq!(missing["skip_reason"], "not_found");
let skipped = files
.iter()
.find(|file| file["status"] == "skipped")
.expect("skipped legacy config file");
assert_eq!(skipped["loaded"], false);
assert_eq!(skipped["reason"], "legacy_invalid_json");
assert_eq!(skipped["skip_reason"], "legacy_invalid_json");
assert!(skipped["detail"].as_str().is_some());
}
#[test]
fn config_json_list_reports_parse_errors_without_dropping_file_statuses_407() {
let root = unique_temp_dir("config-file-load-error-407");
let config_home = root.join("config-home");
let home = root.join("home");
fs::create_dir_all(root.join(".claw")).expect("workspace config should exist");
fs::create_dir_all(&config_home).expect("config home should exist");
fs::create_dir_all(&home).expect("home should exist");
fs::write(config_home.join("settings.json"), r#"{"model":"sonnet"}"#)
.expect("user config fixture should write");
fs::write(root.join(".claw").join("settings.json"), "{not json")
.expect("invalid project config fixture should write");
let envs = [
(
"CLAW_CONFIG_HOME",
config_home.to_str().expect("utf8 config home"),
),
("HOME", home.to_str().expect("utf8 home")),
];
let output = run_claw(&root, &["--output-format", "json", "config"], &envs);
assert!(
output.status.success(),
"config list should be best-effort even with one parse-broken file; stdout:\n{}\n\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let parsed: Value = serde_json::from_slice(&output.stdout).expect("stdout valid json");
assert_eq!(parsed["status"], "error");
assert!(parsed["load_error"].as_str().is_some());
assert_eq!(parsed["loaded_files"], 1);
let files = parsed["files"].as_array().expect("files array");
let error_file = files
.iter()
.find(|file| file["status"] == "load_error")
.expect("load error config file");
assert_eq!(error_file["loaded"], false);
assert_eq!(error_file["reason"], "parse_error");
assert_eq!(error_file["skip_reason"], "parse_error");
assert!(error_file["detail"].as_str().is_some());
}
#[test]
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 +1587,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 +1654,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 +1962,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 +1979,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::<Vec<_>>()
.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 +2010,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.
@@ -2055,6 +2383,46 @@ fn export_json_has_kind_702() {
}
}
#[test]
fn export_missing_session_json_error_uses_stdout_819() {
let root = unique_temp_dir("export-missing-session-819");
fs::create_dir_all(&root).expect("temp dir should exist");
let output = run_claw(
&root,
&[
"--output-format",
"json",
"export",
"--session",
"does-not-exist",
],
&[],
);
assert_eq!(
output.status.code(),
Some(1),
"export missing session should exit rc=1 (#819)"
);
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.is_empty(),
"export missing session JSON mode must keep stderr empty (#819), got: {stderr:?}"
);
let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|_| {
panic!("export missing session must emit valid stdout JSON (#819), got: {stdout:?}")
});
assert_eq!(
parsed["error_kind"], "session_not_found",
"export missing session must emit session_not_found (#819): {parsed}"
);
assert_eq!(
parsed["action"], "abort",
"export missing session should use the abort envelope (#819): {parsed}"
);
}
#[test]
fn config_parse_error_has_typed_error_kind_and_hint_764() {
// #764: Malformed .claw/settings.json must emit error_kind:config_parse_error
@@ -2316,10 +2684,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 +2696,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 {
@@ -2448,6 +2815,38 @@ fn agents_plugins_mcp_unknown_subcommand_have_hint_774() {
}
}
#[test]
fn mcp_show_missing_server_name_returns_missing_argument_830() {
let root = unique_temp_dir("mcp-show-missing-830");
fs::create_dir_all(&root).expect("temp dir");
let output = run_claw(&root, &["--output-format", "json", "mcp", "show"], &[]);
assert!(
!output.status.success(),
"mcp show without server must fail"
);
assert_eq!(output.status.code(), Some(1), "exit code must be 1 (#830)");
assert!(
output.stderr.is_empty(),
"JSON mcp show missing-argument error must keep stderr empty (#830), got: {}",
String::from_utf8_lossy(&output.stderr)
);
let parsed: serde_json::Value = serde_json::from_slice(&output.stdout)
.expect("mcp show missing server should emit valid JSON on stdout");
assert_eq!(parsed["kind"], "mcp");
assert_eq!(parsed["action"], "show");
assert_eq!(parsed["status"], "error");
assert_eq!(parsed["error_kind"], "missing_argument");
assert!(
parsed["hint"]
.as_str()
.unwrap_or_default()
.contains("mcp show <server>"),
"hint should contain usage example, got: {}",
parsed["hint"]
);
}
#[test]
fn interactive_only_guard_batch_769_to_771() {
// #769-#771: a sweep of slash-only verbs with args that previously fell to
@@ -2471,13 +2870,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"))
@@ -3867,6 +4288,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]
@@ -3940,31 +4441,30 @@ fn unknown_subcommand_typo_with_suggestions_json_emits_command_not_found() {
assert!(stderr.is_empty(), "typo JSON must have empty stderr (#825)");
}
// #826: multi-word unknown subcommand is a known gap — falls through to
// CliAction::Prompt (natural language prompt passthrough like `claw explain this`).
// Single-word typos (#825) are caught; multi-word is documented as backlog.
// This test documents the current behaviour (not the desired fix).
// #826: JSON-mode multi-word unknown subcommands must not fall through to
// CliAction::Prompt and hit the provider credential gate.
#[test]
fn multi_word_unknown_subcommand_falls_through_to_prompt_826() {
let root = unique_temp_dir("multi-word-gap-826");
fn multi_word_unknown_subcommand_json_emits_command_not_found_826() {
let root = unique_temp_dir("multi-word-command-not-found-826");
std::fs::create_dir_all(&root).expect("create temp dir");
// "foobar baz" has no fuzzy suggestion → falls through to Prompt path
// (hits missing_credentials since no API key is set, rc=1)
let output = run_claw(&root, &["--output-format", "json", "foobar", "baz"], &[]);
assert_eq!(output.status.code(), Some(1));
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
// Currently emits missing_credentials (fallthrough gap documented in #826)
let j: serde_json::Value =
serde_json::from_str(stdout.trim()).expect("multi-word fallthrough must emit JSON");
serde_json::from_str(stdout.trim()).expect("multi-word unknown subcommand must emit JSON");
assert_eq!(
j["status"], "error",
"multi-word fallthrough must be an error: {j}"
j["error_kind"], "command_not_found",
"multi-word unknown subcommand must emit command_not_found, not missing_credentials (#826): {j}"
);
let hint = j["hint"].as_str().unwrap_or_default();
assert!(
hint.contains("claw prompt") || hint.contains("--help"),
"hint should explain prompt/command recovery, got: {hint:?}"
);
// stderr must be empty regardless (JSON mode)
assert!(
stderr.is_empty(),
"multi-word fallthrough JSON must have empty stderr: {stderr:?}"
"multi-word command_not_found JSON must have empty stderr: {stderr:?}"
);
}
@@ -3994,6 +4494,42 @@ fn direct_unknown_slash_command_emits_typed_error_kind() {
);
}
#[test]
fn resume_unknown_slash_command_emits_typed_error_kind_827() {
let root = unique_temp_dir("resume-unknown-slash-827");
std::fs::create_dir_all(&root).expect("create temp dir");
let session_path = write_session_fixture(&root, "resume-unknown-slash-827", Some("hello"));
let output = run_claw(
&root,
&[
"--resume",
session_path.to_str().expect("session path utf8"),
"--output-format",
"json",
"/boguscommand",
],
&[],
);
assert_eq!(
output.status.code(),
Some(2),
"resume unknown slash should exit 2"
);
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let j: serde_json::Value = serde_json::from_str(stdout.trim())
.unwrap_or_else(|_| panic!("resume unknown slash must emit JSON (#827), got: {stdout:?}"));
assert_eq!(
j["error_kind"], "unknown_slash_command",
"resume unknown slash must emit unknown_slash_command (#827): {j}"
);
assert!(
stderr.is_empty(),
"resume unknown slash JSON must have empty stderr (#827): {stderr:?}"
);
}
// #828: /approve and /deny outside REPL must emit interactive_only, not unknown_slash_command
#[test]
fn approve_deny_outside_repl_emits_interactive_only() {
@@ -4044,13 +4580,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:?}"
);
}
+113 -5
View File
@@ -1225,13 +1225,14 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
},
ToolSpec {
name: "GitShow",
description: "Show a commit, tag, or tree object with its diff. Supports showing a specific file at a commit (commit:path) and stat-only mode. Use this instead of running git show via bash to get structured output.",
description: "Show a commit, tag, or tree object. Use format to control output: patch (default) shows the full diff, stat shows a diffstat summary, and metadata shows commit info without the diff. Supports showing a specific file at a commit (commit:path) for patch/stat output. Use this instead of running git show via bash to get structured output.",
input_schema: json!({
"type": "object",
"properties": {
"commit": { "type": "string" },
"path": { "type": "string" },
"stat": { "type": "boolean" }
"stat": { "type": "boolean" },
"format": { "type": "string", "enum": ["patch", "stat", "metadata"] },
},
"required": ["commit"],
"additionalProperties": false
@@ -2008,14 +2009,37 @@ fn run_git_log(input: GitLogInput) -> Result<String, String> {
}
}
#[allow(clippy::needless_pass_by_value)]
/// Execute `git show` for a given commit, optionally with --stat or a file path.
/// Uses the `commit:path` syntax when a path is specified.
fn run_git_show(input: GitShowInput) -> Result<String, String> {
let mut args: Vec<String> = vec!["show".to_string()];
if input.stat.unwrap_or(false) {
args.push("--stat".to_string());
match input.format.as_deref() {
Some("metadata") if input.path.is_some() => {
return Err(
"GitShow format \"metadata\" cannot be combined with path; metadata describes a commit, not a blob. Use format \"patch\" or \"stat\" with path, or omit path."
.to_string(),
);
}
Some("metadata") => {
args.push("--format=medium".to_string());
args.push("--no-patch".to_string());
}
Some("stat") => {
args.push("--stat".to_string());
}
Some("patch") | None => {
if input.format.is_none() && input.stat.unwrap_or(false) {
args.push("--stat".to_string());
}
}
Some(other) => {
return Err(format!(
"unknown GitShow format: \"{other}\". Supported values: \"patch\" (default), \"stat\", \"metadata\"."
));
}
}
if let Some(ref path) = input.path {
args.push(format!("{}:{}", input.commit, path));
} else {
@@ -2964,6 +2988,9 @@ struct GitShowInput {
#[serde(default)]
/// If true, show diffstat summary instead of full diff.
stat: Option<bool>,
#[serde(default)]
/// Output format: "patch" (default) shows the full diff, "stat" shows a diffstat summary, and "metadata" shows commit info without the diff. When set, takes priority over `stat`.
format: Option<String>,
}
/// Input for the GitBlame tool: shows per-line author/revision info for a file.
@@ -6779,6 +6806,87 @@ mod tests {
assert!(names.contains(&"WorkerSendPrompt"));
}
#[test]
fn git_show_schema_exposes_format_enum() {
let spec = mvp_tool_specs()
.into_iter()
.find(|spec| spec.name == "GitShow")
.expect("GitShow spec");
assert_eq!(
spec.input_schema["properties"]["format"]["enum"],
json!(["patch", "stat", "metadata"])
);
}
#[test]
fn git_show_supports_patch_stat_metadata_and_rejects_metadata_path() {
let _guard = env_guard();
let root = temp_path("git-show-format");
init_git_repo(&root);
commit_file(&root, "README.md", "initial\nupdated\n", "update readme");
let previous = std::env::current_dir().expect("cwd");
std::env::set_current_dir(&root).expect("set cwd");
let patch = execute_tool("GitShow", &json!({"commit": "HEAD", "format": "patch"}))
.expect("patch git show");
let patch: serde_json::Value = serde_json::from_str(&patch).expect("patch json");
assert!(patch["output"]
.as_str()
.expect("patch output")
.contains("diff --git"));
let stat = execute_tool("GitShow", &json!({"commit": "HEAD", "format": "stat"}))
.expect("stat git show");
let stat: serde_json::Value = serde_json::from_str(&stat).expect("stat json");
assert!(stat["output"]
.as_str()
.expect("stat output")
.contains("README.md"));
let legacy_stat = execute_tool("GitShow", &json!({"commit": "HEAD", "stat": true}))
.expect("legacy stat git show");
let legacy_stat: serde_json::Value =
serde_json::from_str(&legacy_stat).expect("legacy stat json");
assert!(legacy_stat["output"]
.as_str()
.expect("legacy stat output")
.contains("README.md"));
let metadata = execute_tool("GitShow", &json!({"commit": "HEAD", "format": "metadata"}))
.expect("metadata git show");
let metadata: serde_json::Value = serde_json::from_str(&metadata).expect("metadata json");
let metadata_output = metadata["output"].as_str().expect("metadata output");
assert!(metadata_output.contains("commit "));
assert!(metadata_output.contains("update readme"));
assert!(!metadata_output.contains("diff --git"));
let file_patch = execute_tool(
"GitShow",
&json!({"commit": "HEAD", "path": "README.md", "format": "patch"}),
)
.expect("file patch git show");
let file_patch: serde_json::Value =
serde_json::from_str(&file_patch).expect("file patch json");
assert_eq!(
file_patch["output"].as_str().expect("file patch output"),
"initial\nupdated"
);
let metadata_path = execute_tool(
"GitShow",
&json!({"commit": "HEAD", "path": "README.md", "format": "metadata"}),
)
.expect_err("metadata with path should be rejected");
assert!(metadata_path.contains("cannot be combined with path"));
let invalid = execute_tool("GitShow", &json!({"commit": "HEAD", "format": "bogus"}))
.expect_err("invalid format should be rejected");
assert!(invalid.contains("unknown GitShow format"));
std::env::set_current_dir(&previous).expect("restore cwd");
let _ = fs::remove_dir_all(root);
}
#[test]
fn rejects_unknown_tool_names() {
let error = execute_tool("nope", &json!({})).expect_err("tool should be rejected");
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Sequence
@dataclass(frozen=True)
class ProbeResult:
kind: str
argv: list[str]
returncode: int | None
stdout: bytes
stderr: bytes
message: str | None = None
@property
def stdout_text(self) -> str:
return self.stdout.decode('utf-8', errors='replace')
@property
def stderr_text(self) -> str:
return self.stderr.decode('utf-8', errors='replace')
def to_json_dict(self) -> dict[str, object]:
return {
'kind': self.kind,
'argv': self.argv,
'returncode': self.returncode,
'stdout': self.stdout_text,
'stderr': self.stderr_text,
'message': self.message,
}
def run_probe(argv: Sequence[str], *, timeout: float = 10.0, require_stdout_json_byte0: bool = False) -> ProbeResult:
explicit_argv = [str(arg) for arg in argv]
if not explicit_argv:
return ProbeResult(
kind='probe_error',
argv=[],
returncode=None,
stdout=b'',
stderr=b'',
message='argv must contain at least the executable path',
)
try:
completed = subprocess.run(
explicit_argv,
capture_output=True,
check=False,
timeout=timeout,
)
except subprocess.TimeoutExpired as exc:
return ProbeResult(
kind='timeout',
argv=explicit_argv,
returncode=None,
stdout=exc.stdout or b'',
stderr=exc.stderr or b'',
message=f'probe timed out after {timeout:g}s',
)
except (OSError, ValueError) as exc:
return ProbeResult(
kind='probe_error',
argv=explicit_argv,
returncode=None,
stdout=b'',
stderr=b'',
message=str(exc),
)
if require_stdout_json_byte0:
if not completed.stdout:
return ProbeResult(
kind='product_error',
argv=explicit_argv,
returncode=completed.returncode,
stdout=completed.stdout,
stderr=completed.stderr,
message='stdout is empty; expected JSON at byte 0',
)
if completed.stdout[:1] not in (b'{', b'['):
return ProbeResult(
kind='product_error',
argv=explicit_argv,
returncode=completed.returncode,
stdout=completed.stdout,
stderr=completed.stderr,
message='stdout JSON does not start at byte 0',
)
try:
json.loads(completed.stdout.decode('utf-8'))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
return ProbeResult(
kind='product_error',
argv=explicit_argv,
returncode=completed.returncode,
stdout=completed.stdout,
stderr=completed.stderr,
message=f'stdout is not parseable JSON: {exc}',
)
if completed.returncode != 0:
return ProbeResult(
kind='product_error',
argv=explicit_argv,
returncode=completed.returncode,
stdout=completed.stdout,
stderr=completed.stderr,
message=f'process exited with code {completed.returncode}',
)
return ProbeResult(
kind='ok',
argv=explicit_argv,
returncode=completed.returncode,
stdout=completed.stdout,
stderr=completed.stderr,
)
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(description='Run an argv-safe dogfood probe and emit separated channels as JSON.')
parser.add_argument('--timeout', type=float, default=10.0)
parser.add_argument('--stdout-json-byte0', action='store_true', help='Require stdout to be parseable JSON starting at byte 0.')
parser.add_argument('command', nargs=argparse.REMAINDER, help='Executable and arguments to run. Use -- before the target argv.')
args = parser.parse_args(argv)
command = args.command
if command and command[0] == '--':
command = command[1:]
result = run_probe(command, timeout=args.timeout, require_stdout_json_byte0=args.stdout_json_byte0)
print(json.dumps(result.to_json_dict(), sort_keys=True))
return 0 if result.kind == 'ok' else 1
if __name__ == '__main__':
raise SystemExit(main())
View File
+96
View File
@@ -9,6 +9,9 @@ from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
NEXT_ID = REPO_ROOT / 'scripts' / 'roadmap-next-id.sh'
DOGFOOD_PROBE = REPO_ROOT / 'scripts' / 'dogfood-probe.py'
def run_next_id(roadmap: Path, script: Path = NEXT_ID) -> subprocess.CompletedProcess[str]:
@@ -21,6 +24,16 @@ def run_next_id(roadmap: Path, script: Path = NEXT_ID) -> subprocess.CompletedPr
)
def run_dogfood_probe(args: list[str]) -> subprocess.CompletedProcess[str]:
return subprocess.run(
['python3', str(DOGFOOD_PROBE), *args],
cwd=REPO_ROOT,
capture_output=True,
text=True,
check=False,
)
class RoadmapHelperTests(unittest.TestCase):
def test_roadmap_next_id_prints_only_next_id_after_duplicate_check(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
@@ -46,6 +59,17 @@ class RoadmapHelperTests(unittest.TestCase):
self.assertIn('999', result.stderr)
self.assertNotIn('1000', result.stdout)
def test_roadmap_next_id_fails_when_explicit_roadmap_path_is_missing(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
roadmap = Path(temp_dir) / 'missing-ROADMAP.md'
result = run_next_id(roadmap)
self.assertNotEqual(0, result.returncode)
self.assertEqual('', result.stdout)
self.assertIn('ROADMAP not found', result.stderr)
self.assertIn(str(roadmap), result.stderr)
def test_roadmap_next_id_fails_closed_when_checker_is_unavailable(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
script_dir = Path(temp_dir) / 'scripts'
@@ -62,6 +86,78 @@ class RoadmapHelperTests(unittest.TestCase):
self.assertIn('required ROADMAP id checker not found or not readable', result.stderr)
self.assertIn('refusing to print a next id', result.stderr)
def test_dogfood_probe_runs_explicit_argv_and_separates_channels(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
fixture = Path(temp_dir) / 'fixture.py'
fixture.write_text(
'from __future__ import annotations\n'
'import json\n'
'import sys\n'
'print(json.dumps({"argv": sys.argv[1:]}))\n'
'print("diagnostic", file=sys.stderr)\n'
)
result = run_dogfood_probe([
'--stdout-json-byte0',
'--',
'python3',
str(fixture),
'--output-format',
'json',
'doctor',
'--help',
])
self.assertEqual(0, result.returncode)
payload = __import__('json').loads(result.stdout)
self.assertEqual('ok', payload['kind'])
self.assertEqual([
'python3',
str(fixture),
'--output-format',
'json',
'doctor',
'--help',
], payload['argv'])
self.assertEqual(0, payload['returncode'])
self.assertEqual('{"argv": ["--output-format", "json", "doctor", "--help"]}\n', payload['stdout'])
self.assertEqual('diagnostic\n', payload['stderr'])
def test_dogfood_probe_labels_timeout_separately_from_product_error(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
fixture = Path(temp_dir) / 'sleep.py'
fixture.write_text('import time\ntime.sleep(2)\n')
result = run_dogfood_probe(['--timeout', '0.1', '--', 'python3', str(fixture)])
self.assertEqual(1, result.returncode)
payload = __import__('json').loads(result.stdout)
self.assertEqual('timeout', payload['kind'])
self.assertIsNone(payload['returncode'])
self.assertIn('timed out', payload['message'])
def test_dogfood_probe_labels_probe_construction_failure(self) -> None:
result = run_dogfood_probe([])
self.assertEqual(1, result.returncode)
payload = __import__('json').loads(result.stdout)
self.assertEqual('probe_error', payload['kind'])
self.assertEqual([], payload['argv'])
self.assertIsNone(payload['returncode'])
self.assertIn('argv must contain', payload['message'])
def test_dogfood_probe_labels_stdout_json_prefix_failure_as_product_error(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
fixture = Path(temp_dir) / 'prefixed.py'
fixture.write_text('print("warning before json")\nprint("{}")\n')
result = run_dogfood_probe(['--stdout-json-byte0', '--', 'python3', str(fixture)])
self.assertEqual(1, result.returncode)
payload = __import__('json').loads(result.stdout)
self.assertEqual('product_error', payload['kind'])
self.assertEqual(0, payload['returncode'])
self.assertIn('byte 0', payload['message'])
if __name__ == '__main__':
unittest.main()