Compare commits

...

31 Commits

Author SHA1 Message Date
Bellman
0800d7ae88 Route plugins list JSON parse errors to stdout (#3194) 2026-05-28 22:35:58 +09:00
Bellman
69b8b367c1 docs: record plugins trailing dash json routing (#3193) 2026-05-28 21:35:25 +09:00
Bellman
9494e3c26f Suppress config warnings on JSON local surfaces (#3192) 2026-05-28 20:34:18 +09:00
Bellman
ed3a616e62 docs: record global json warning leak (#3191) 2026-05-28 18:36:30 +09:00
Bellman
89e7f415a9 Avoid duplicate config warnings for JSON consumers (#3190)
JSON config output already carries collected config diagnostics in warnings[], so prose stderr emission must be reserved for text/local paths. Lazy permission-mode default resolution prevents an earlier config load from leaking the same deprecation before the JSON renderer runs.\n\nConstraint: ROADMAP #815 requires text mode to keep human stderr warnings while JSON config/list suppresses duplicate app-level config prose.\nRejected: Filtering all stderr in JSON mode | would hide cargo/compiler or unrelated diagnostics outside the app config warning path.\nConfidence: high\nScope-risk: narrow\nDirective: Keep load_collecting_warnings side-effect-free; use load() for human stderr emission.\nTested: cargo fmt; cargo test -p rusty-claude-cli --test output_format_contract config_json_reports_deprecations_structurally_without_stderr_duplicate_815; cargo test -p rusty-claude-cli --test output_format_contract; manual target/debug/claw JSON config fixture.\nNot-tested: cargo clippy -p rusty-claude-cli --all-targets -- -D warnings is blocked by pre-existing runtime dead_code/trident warnings.
2026-05-28 18:09:59 +09:00
Bellman
c3e7b6af60 docs: record config json warning duplication (#3189) 2026-05-28 17:05:57 +09:00
Bellman
3af2d9f986 docs: verify trailing json inventory gap resolved (#3188) 2026-05-28 16:36:11 +09:00
Bellman
09ff1caf42 docs: record trailing json inventory timeout (#3187) 2026-05-28 16:05:50 +09:00
Bellman
0e6d48d9dc docs: record argv-safe dogfood probe gap (#3186) 2026-05-28 15:34:04 +09:00
Bellman
b7ea04661a test: cover doctor help JSON flag order (#3185) 2026-05-28 14:34:19 +09:00
Bellman
73d8d6e638 Keep doctor help machine-discoverable locally (#3184)
Doctor help was already on the local help path in current source, but the exact #702 dogfood surface lacked a focused guard and the JSON help envelope was still too prose-oriented for wrappers. Strengthen the JSON contract while preserving text help.\n\nConstraint: Preserve unrelated dirty rust/Cargo.lock from prior #701 work.\nRejected: Starting runtime/provider/session to inspect doctor semantics | help must be local and credential-free.\nConfidence: high\nScope-risk: narrow\nDirective: Keep doctor help routed through parse_local_help_action and print_help_topic; do not call run_doctor for --help.\nTested: cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli --test output_format_contract doctor_help -- --nocapture; cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli --test output_format_contract help -- --nocapture; cargo fmt --manifest-path rust/Cargo.toml --all -- --check; cargo check --manifest-path rust/Cargo.toml -p rusty-claude-cli; timeout 5s cargo run -q --bin claw -- --output-format json doctor --help; timeout 5s cargo run -q --bin claw -- doctor --help.\nNot-tested: full workspace test suite.
2026-05-28 13:31:39 +09:00
Bellman
9ac66cbeb3 docs: quote dogfood build trap cleanup guidance (#3183) 2026-05-28 12:35:47 +09:00
Bellman
773aa021be docs: use trap cleanup in dogfood build guidance (#3182) 2026-05-28 12:06:17 +09:00
Bellman
5c3e1c1444 fix: add dogfood build help handling (#3181) 2026-05-28 11:36:13 +09:00
Bellman
3260258b56 fix: make cc2 renderer path errors concise (#3180) 2026-05-28 11:08:26 +09:00
Bellman
a88d52fe88 fix: make cc2 validator directory board error concise (#3179) 2026-05-28 10:38:57 +09:00
Bellman
60f44d314b fix: avoid cc2 generator dirs on missing source (#3178) 2026-05-28 10:07:38 +09:00
Bellman
d4e9829329 fix: suppress partial cc2 wrapper validate pass output (#3177) 2026-05-28 09:36:18 +09:00
Bellman
e17098cc70 fix: resolve cc2 wrapper tools from script root (#3176) 2026-05-28 08:36:16 +09:00
Bellman
e17936158a fix: make cc2 validator board read errors concise (#3175) 2026-05-28 08:06:28 +09:00
Bellman
760e69675c fix: make cc2 generator missing source error concise (#3174) 2026-05-28 07:36:47 +09:00
Bellman
193f11171a fix: reject extra roadmap helper paths (#3173) 2026-05-28 06:34:44 +09:00
Bellman
f11ac23e1f fix: add roadmap next-id help handling (#3172) 2026-05-28 06:06:42 +09:00
Bellman
c4770e6571 docs(roadmap): add #811 json error envelope nontty hangs (#3171) 2026-05-28 05:35:57 +09:00
Bellman
b0e94c996b docs(roadmap): add #810 json stdout warning contamination (#3169) 2026-05-28 05:05:17 +09:00
Bellman
85d63b071c docs(roadmap): add #809 help mcp plugin json hangs (#3168) 2026-05-28 04:30:30 +09:00
Bellman
db81598525 docs(roadmap): add #808 control-plane json hangs (#3166) 2026-05-28 03:32:15 +09:00
Bellman
86f45a11ef docs(roadmap): add #807 model json hang (#3163) 2026-05-28 01:33:01 +09:00
YeonGyu-Kim
87b7e74770 fix(#806): plugins show <not-found> in text mode returned empty success instead of error 2026-05-27 22:34:10 +09:00
Bellman
ae6a207d4e fix(#3129): handle trailing json format for diff errors (#3161)
Keep malformed diff invocations with trailing JSON format flags on the parser error path and lock the contract with focused output-format regressions.

Constraint: Do not touch tracked .omx state files.

Rejected: Repeating direct binary smoke loops | local auth/provider configuration intercepts those invocations and obscures parser behavior.

Confidence: high

Scope-risk: narrow

Tested: git diff --check; cargo fmt --check; cargo test -p rusty-claude-cli diff_extra_args_have_typed_error_kind_and_hint_766 --test output_format_contract; cargo test -p rusty-claude-cli diff_trailing_json_after_malformed_args_is_bounded_json_3129 --test output_format_contract; cargo test -p rusty-claude-cli diff_non_git_dir_has_error_kind_and_hint_801 --test output_format_contract
2026-05-27 21:57:26 +09:00
YeonGyu-Kim
efd34c151a fix(#805): skills show <not-found> in text mode silently returned empty success instead of error 2026-05-27 21:05:41 +09:00
12 changed files with 686 additions and 79 deletions

View File

@@ -55,8 +55,15 @@ REQUIRED_ITEM_FIELDS = [
def load_board(path: Path) -> dict[str, Any]:
with path.open() as f:
board = json.load(f)
try:
with path.open() as f:
board = json.load(f)
except FileNotFoundError:
raise ValueError(f"board not found at {path}") from None
except IsADirectoryError:
raise ValueError(f"board path is a directory: {path}") from None
except json.JSONDecodeError as exc:
raise ValueError(f"invalid board JSON at {path}: {exc}") from None
if not isinstance(board, dict):
raise ValueError("board JSON root must be an object")
items = board.get("items")
@@ -226,7 +233,11 @@ def main() -> int:
parser.add_argument("--check", action="store_true", help="fail if board_md is not up to date")
args = parser.parse_args()
board = load_board(args.board_json)
try:
board = load_board(args.board_json)
except ValueError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
errors = validate_board(board)
if errors:
for error in errors:
@@ -234,14 +245,22 @@ def main() -> int:
return 1
rendered = render(board)
if args.check:
existing = args.board_md.read_text() if args.board_md.exists() else ""
try:
existing = args.board_md.read_text() if args.board_md.exists() else ""
except IsADirectoryError:
print(f"ERROR: board markdown path is a directory: {args.board_md}", file=sys.stderr)
return 1
if existing != rendered:
print(f"ERROR: {args.board_md} is not up to date", file=sys.stderr)
return 1
print(f"PASS: {args.board_md} is up to date and roadmap coverage is complete")
return 0
args.board_md.parent.mkdir(parents=True, exist_ok=True)
args.board_md.write_text(rendered)
try:
args.board_md.write_text(rendered)
except IsADirectoryError:
print(f"ERROR: board markdown path is a directory: {args.board_md}", file=sys.stderr)
return 1
print(f"wrote {args.board_md}")
return 0

View File

@@ -7774,3 +7774,55 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed)
803. **`claw agents list --bogus`, `skills list --bogus`, and `plugins list --bogus` in text mode silently returned empty success** — dogfooded 2026-05-27 on `fcebf644`. The JSON-mode flag guards added in #792/#793 only covered the JSON branch; the text-mode path through `handle_agents_slash_command`, `handle_skills_slash_command`, and `print_plugins` still passed flag-shaped tokens as substring filters. Fix: added flag-prefix guards to all three text-mode list handlers (agents and skills in `commands/src/lib.rs`, plugins in `main.rs print_plugins`). Also removed the now-redundant JSON-only guard from print_plugins (the early guard catches both modes). Updated `plugins_list_flag_shaped_filter_returns_unknown_option_793` test to check stderr. 62 CLI contract tests pass. [SCOPE: claw-code]
804. **`claw agents show <name> <extra>` and `claw skills show <name> <extra>` in text mode returned wrong `agent_not_found`/silent empty instead of catching extra args** — dogfooded 2026-05-27 on `bad1b97f`. Parity gap with JSON-mode fix #796: the text-mode show handlers in `commands/src/lib.rs` still used single-split `split_once(' ')` without checking for spaces in the extracted name. Fix: added `contains(' ')` guard to both text-mode show arms; extra tokens now return `unexpected extra arguments` with usage hint. 62 CLI contract tests pass. [SCOPE: claw-code]
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]
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.
**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.
**Fix.** Keep the local help interception and strengthen the doctor JSON help contract with structured, machine-readable metadata: `usage`, `formats`, `local_only:true`, `requires_credentials:false`, `requires_provider_request:false`, `requires_session_resume:false`, `mutates_workspace:false`, `output_fields`, `check_names`, and `status_values`. Preserve prose-only text help for `claw doctor --help`.
**Acceptance.** `timeout 5s cargo run -q --bin claw -- --output-format json doctor --help` exits 0 and parses as JSON with `.kind=="help"`, `.command=="doctor"`, `.local_only==true`, `.requires_provider_request==false`, and `output_fields` containing `checks`. `timeout 5s cargo run -q --bin claw -- doctor --help` exits 0 with plaintext beginning `Doctor` and no JSON parsing requirement. Neither command starts a provider request or session resume.
**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.
**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.
**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]
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.
**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.
**Acceptance.** All three repro commands exit within 5s under non-TTY automation, produce parseable JSON from stdout byte 0, and never require provider credentials/session startup. [SCOPE: claw-code]
**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.
**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.
**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.
**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]

View File

@@ -7,7 +7,7 @@ use std::path::{Path, PathBuf};
use plugins::{PluginError, PluginLoadFailure, PluginManager, PluginSummary};
use runtime::{
compact_session, CompactionConfig, ConfigLoader, ConfigSource, McpOAuthConfig, McpServerConfig,
ScopedMcpServerConfig, Session,
RuntimeConfig, ScopedMcpServerConfig, Session,
};
use serde_json::{json, Value};
@@ -2542,6 +2542,14 @@ pub fn handle_mcp_slash_command_json(
render_mcp_report_json_for(&loader, cwd, args)
}
fn load_runtime_config_without_stderr_warnings(
loader: &ConfigLoader,
) -> Result<RuntimeConfig, runtime::ConfigError> {
loader
.load_collecting_warnings()
.map(|(runtime_config, _warnings)| runtime_config)
}
pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::Result<String> {
if let Some(args) = normalize_optional_args(args) {
if let Some(help_path) = help_path_from_args(args) {
@@ -2606,6 +2614,13 @@ pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R
.into_iter()
.filter(|s| s.name.to_lowercase() == name_raw)
.collect();
// #805: text-mode show must return an error when skill not found (parity with JSON)
if matched.is_empty() {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("skill '{name_raw}' not found\nRun `claw skills list` to see available skills."),
));
}
Ok(render_skills_report(&matched))
}
Some("install") => Ok(render_skills_usage(Some("install"))),
@@ -2987,7 +3002,7 @@ fn render_mcp_report_json_for(
// failure, emit top-level `status: "degraded"` with
// `config_load_error`, empty servers[], and exit 0. On clean
// runs, the existing serializer adds `status: "ok"` below.
match loader.load() {
match load_runtime_config_without_stderr_warnings(loader) {
Ok(runtime_config) => {
let mut value =
render_mcp_summary_report_json(cwd, runtime_config.mcp().servers());
@@ -3023,7 +3038,7 @@ fn render_mcp_report_json_for(
return Ok(render_mcp_usage_json(Some(args)));
}
// #144: same degradation pattern for show action.
match loader.load() {
match load_runtime_config_without_stderr_warnings(loader) {
Ok(runtime_config) => {
let mut value = render_mcp_server_report_json(
cwd,

View File

@@ -379,12 +379,6 @@ impl ConfigLoader {
loaded_entries.push(entry);
}
// Still emit to stderr for non-JSON callers that go through the normal load() path;
// here we just *also* return them so callers can surface them structurally.
for warning in &all_warnings {
emit_config_warning_once(warning);
}
let merged_value = JsonValue::Object(merged.clone());
let feature_config = RuntimeFeatureConfig {

View File

@@ -335,7 +335,7 @@ fn classify_error_kind(message: &str) -> &'static str {
"unknown_agents_subcommand"
} else if message.starts_with("agent not found:") {
"agent_not_found"
} else if message.contains("is not installed") {
} else if message.contains("is not installed") || message.starts_with("plugin_not_found:") {
"plugin_not_found"
} else if message.contains("plugin source") && message.contains("was not found") {
// #794: `plugins install /nonexistent/path` → "plugin source ... was not found"
@@ -1157,7 +1157,11 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
return action;
}
let permission_mode = permission_mode_override.unwrap_or_else(default_permission_mode);
// Keep config-backed defaults lazy so pure-local JSON surfaces (notably
// `claw --output-format json config`) can report config warnings
// structurally without an earlier default-resolution load writing prose
// warnings to stderr.
let permission_mode = || permission_mode_override.unwrap_or_else(default_permission_mode);
match rest[0].as_str() {
"dump-manifests" => parse_dump_manifests_args(&rest[1..], output_format),
@@ -1225,10 +1229,10 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
// `git diff`). No session needed to inspect the working tree.
"diff" => {
if rest.len() > 1 {
return Err(format!(
"unexpected extra arguments after `claw diff`: {}\nUsage: claw diff",
rest[1..].join(" ")
));
// #3129: keep malformed `diff ... --output-format json` on the
// parser/error path, not the prompt/TUI fallback. The newline
// before Usage is part of the JSON hint contract.
return Err(unexpected_diff_args_error(&rest[1..]));
}
Ok(CliAction::Diff { output_format })
}
@@ -1301,7 +1305,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
model,
output_format,
allowed_tools,
permission_mode,
permission_mode: permission_mode(),
compact,
base_commit,
reasoning_effort: reasoning_effort.clone(),
@@ -1338,7 +1342,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
model,
output_format,
allowed_tools,
permission_mode,
permission_mode: permission_mode(),
compact,
base_commit: base_commit.clone(),
reasoning_effort: reasoning_effort.clone(),
@@ -1350,7 +1354,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
model,
output_format,
allowed_tools,
permission_mode,
permission_mode(),
compact,
base_commit,
reasoning_effort,
@@ -1389,7 +1393,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
model,
output_format,
allowed_tools,
permission_mode,
permission_mode: permission_mode(),
compact,
base_commit,
reasoning_effort: reasoning_effort.clone(),
@@ -1625,6 +1629,13 @@ fn removed_auth_surface_error(command_name: &str) -> String {
)
}
fn unexpected_diff_args_error(extra: &[String]) -> String {
format!(
"unexpected extra arguments after `claw diff`: {}\nUsage: claw diff",
extra.join(" ")
)
}
fn parse_acp_args(args: &[String], output_format: CliOutputFormat) -> Result<CliAction, String> {
match args {
[] => Ok(CliAction::Acp { output_format }),
@@ -2403,6 +2414,24 @@ impl DiagnosticCheck {
}
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
enum ConfigWarningMode {
EmitStderr,
SuppressStderr,
}
fn load_config_with_warning_mode(
loader: &ConfigLoader,
mode: ConfigWarningMode,
) -> Result<runtime::RuntimeConfig, runtime::ConfigError> {
match mode {
ConfigWarningMode::EmitStderr => loader.load(),
ConfigWarningMode::SuppressStderr => loader
.load_collecting_warnings()
.map(|(runtime_config, _warnings)| runtime_config),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct DoctorReport {
checks: Vec<DiagnosticCheck>,
@@ -2492,10 +2521,12 @@ fn render_diagnostic_check(check: &DiagnosticCheck) -> String {
lines.join("\n")
}
fn render_doctor_report() -> Result<DoctorReport, Box<dyn std::error::Error>> {
fn render_doctor_report(
config_warning_mode: ConfigWarningMode,
) -> Result<DoctorReport, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let config_loader = ConfigLoader::default_for(&cwd);
let config = config_loader.load();
let config = load_config_with_warning_mode(&config_loader, config_warning_mode);
let discovered_config = config_loader.discover();
let project_context = ProjectContext::discover_with_git(&cwd, DEFAULT_DATE)?;
let (project_root, git_branch) =
@@ -2548,7 +2579,10 @@ fn render_doctor_report() -> Result<DoctorReport, Box<dyn std::error::Error>> {
}
fn run_doctor(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
let report = render_doctor_report()?;
let report = render_doctor_report(match output_format {
CliOutputFormat::Json => ConfigWarningMode::SuppressStderr,
CliOutputFormat::Text => ConfigWarningMode::EmitStderr,
})?;
let message = report.render();
match output_format {
CliOutputFormat::Text => println!("{message}"),
@@ -4630,7 +4664,12 @@ fn run_resume_command(
_ => {}
}
let cwd = env::current_dir()?;
let payload = plugins_command_payload_for(&cwd, action.as_deref(), target.as_deref())?;
let payload = plugins_command_payload_for(
&cwd,
action.as_deref(),
target.as_deref(),
ConfigWarningMode::EmitStderr,
)?;
let action_str = action.as_deref().unwrap_or("list");
let enabled_count = payload
.plugins
@@ -4664,7 +4703,7 @@ fn run_resume_command(
})
}
SlashCommand::Doctor => {
let report = render_doctor_report()?;
let report = render_doctor_report(ConfigWarningMode::EmitStderr)?;
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(report.render()),
@@ -5970,7 +6009,10 @@ impl LiveCli {
false
}
SlashCommand::Doctor => {
println!("{}", render_doctor_report()?.render());
println!(
"{}",
render_doctor_report(ConfigWarningMode::EmitStderr)?.render()
);
false
}
SlashCommand::History { count } => {
@@ -6391,15 +6433,64 @@ impl LiveCli {
if action.as_deref() == Some("list") {
if let Some(filter) = target.as_deref() {
if filter.starts_with('-') {
if matches!(output_format, CliOutputFormat::Json) {
// ROADMAP #817: this is a handled local inventory parse error.
// Keep it on stdout in JSON mode so `plugins list --` matches the
// sibling JSON inventory/local surfaces instead of falling through
// to the top-level stderr error path.
let obj = json!({
"type": "error",
"kind": "plugin",
"action": "list",
"status": "error",
"error_kind": "cli_parse",
"error": format!("unknown option for `claw plugins list`: {filter}"),
"message": format!("unknown option for `claw plugins list`: {filter}"),
"unexpected": filter,
"hint": "Usage: claw plugins list [<filter>]\nFilters are id substrings, not flags.",
"exit_code": 1,
});
println!("{}", serde_json::to_string_pretty(&obj)?);
std::process::exit(1);
}
return Err(format!(
"unknown option for `claw plugins list`: {filter}\nUsage: claw plugins list [<filter>]\nFilters are id substrings, not flags."
).into());
}
}
}
let payload = plugins_command_payload_for(&cwd, action, target)?;
let payload = plugins_command_payload_for(
&cwd,
action,
target,
match output_format {
CliOutputFormat::Json => ConfigWarningMode::SuppressStderr,
CliOutputFormat::Text => ConfigWarningMode::EmitStderr,
},
)?;
match output_format {
CliOutputFormat::Text => println!("{}", payload.message),
CliOutputFormat::Text => {
// #806: text-mode show must return error when plugin not found (parity with JSON)
let action_str = action.unwrap_or("list");
if matches!(action_str, "show" | "info" | "describe") {
if let Some(name) = target {
let needle = name.to_lowercase();
let found = payload.plugins.iter().any(|p| {
p.get("id")
.and_then(|v| v.as_str())
.map(|id| id.to_lowercase() == needle)
.unwrap_or(false)
});
if !found {
return Err(format!(
"plugin_not_found: plugin '{}' not found\nRun `claw plugins list` to see available plugins.",
name
).into());
}
}
}
println!("{}", payload.message);
}
CliOutputFormat::Json => {
let action_str = action.unwrap_or("list");
// #743/#420: plugins help must return a usage envelope matching agents/mcp/skills help shape.
@@ -6442,20 +6533,6 @@ impl LiveCli {
}
} else if is_list_action {
if let Some(filter) = target {
// #793: flag-shaped tokens silently became substring filters on
// plugins list, returning empty success instead of an error.
if filter.starts_with('-') {
let obj = json!({
"kind": "plugin",
"action": "list",
"status": "error",
"error_kind": "unknown_option",
"unexpected": filter,
"hint": "Usage: claw plugins list [<filter>]\nFilters are id substrings, not flags.",
});
println!("{}", serde_json::to_string_pretty(&obj)?);
std::process::exit(1);
}
let needle = filter.to_lowercase();
payload
.plugins
@@ -6703,7 +6780,8 @@ impl LiveCli {
target: Option<&str>,
) -> Result<bool, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let payload = plugins_command_payload_for(&cwd, action, target)?;
let payload =
plugins_command_payload_for(&cwd, action, target, ConfigWarningMode::EmitStderr)?;
println!("{}", payload.message);
if payload.reload_runtime {
self.reload_runtime_features()?;
@@ -7857,10 +7935,51 @@ fn render_export_help_json() -> serde_json::Value {
})
}
fn render_doctor_help_json() -> serde_json::Value {
json!({
"kind": "help",
"action": "help",
"status": "ok",
"topic": "doctor",
"command": "doctor",
"schema_version": "1.0",
"usage": "claw doctor [--output-format <format>]",
"purpose": "diagnose local auth, config, workspace, sandbox, boot preflight, and build metadata",
"formats": ["text", "json"],
"local_only": true,
"requires_credentials": false,
"requires_provider_request": false,
"requires_session_resume": false,
"mutates_workspace": false,
"output_fields": ["kind", "action", "status", "message", "report", "has_failures", "summary", "checks"],
"check_names": ["auth", "config", "install source", "workspace", "boot preflight", "sandbox", "system"],
"status_values": ["ok", "warn", "fail"],
"options": [
{
"name": "--output-format",
"value": "<format>",
"values": ["text", "json"],
"default": "text",
"description": "format for the doctor report or help envelope"
},
{
"name": "--help",
"aliases": ["-h"],
"description": "show help for the doctor command without running diagnostics"
}
],
"related": ["/doctor", "claw --resume latest /doctor"],
"message": render_help_topic(LocalHelpTopic::Doctor),
})
}
fn render_help_topic_json(topic: LocalHelpTopic) -> serde_json::Value {
if topic == LocalHelpTopic::Export {
return render_export_help_json();
}
if topic == LocalHelpTopic::Doctor {
return render_doctor_help_json();
}
json!({
"kind": "help",
@@ -9060,9 +9179,11 @@ fn plugins_command_payload_for(
cwd: &Path,
action: Option<&str>,
target: Option<&str>,
config_warning_mode: ConfigWarningMode,
) -> Result<PluginsCommandPayload, Box<dyn std::error::Error>> {
let loader = ConfigLoader::default_for(cwd);
let (runtime_config, config_load_error) = match loader.load() {
let loaded_config = load_config_with_warning_mode(&loader, config_warning_mode);
let (runtime_config, config_load_error) = match loaded_config {
Ok(runtime_config) => (runtime_config, None),
Err(error) => (runtime::RuntimeConfig::empty(), Some(error.to_string())),
};
@@ -12731,8 +12852,13 @@ mod tests {
let previous_config_home = std::env::var("CLAW_CONFIG_HOME").ok();
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
let payload = super::plugins_command_payload_for(&cwd, None, None)
.expect("plugins list should not hard-fail on malformed MCP config");
let payload = super::plugins_command_payload_for(
&cwd,
None,
None,
super::ConfigWarningMode::EmitStderr,
)
.expect("plugins list should not hard-fail on malformed MCP config");
match previous_config_home {
Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value),
None => std::env::remove_var("CLAW_CONFIG_HOME"),

View File

@@ -66,6 +66,64 @@ fn export_help_preserves_plaintext_in_text_mode_384() {
serde_json::from_str::<Value>(&stdout).expect_err("text help should remain plaintext");
}
#[test]
fn doctor_help_json_is_local_structured_and_bounded_702() {
let root = unique_temp_dir("doctor-help-json-702");
fs::create_dir_all(&root).expect("temp dir should exist");
let parsed = assert_json_command(&root, &["--output-format", "json", "doctor", "--help"]);
assert_doctor_help_json_contract(&parsed);
let suffix_parsed =
assert_json_command(&root, &["doctor", "--help", "--output-format", "json"]);
assert_doctor_help_json_contract(&suffix_parsed);
let help_topic_parsed =
assert_json_command(&root, &["help", "doctor", "--output-format", "json"]);
assert_doctor_help_json_contract(&help_topic_parsed);
}
fn assert_doctor_help_json_contract(parsed: &Value) {
assert_eq!(parsed["kind"], "help");
assert_eq!(parsed["action"], "help");
assert_eq!(parsed["status"], "ok");
assert_eq!(parsed["topic"], "doctor");
assert_eq!(parsed["command"], "doctor");
assert_eq!(parsed["usage"], "claw doctor [--output-format <format>]");
assert_eq!(parsed["local_only"], true);
assert_eq!(parsed["requires_credentials"], false);
assert_eq!(parsed["requires_provider_request"], false);
assert_eq!(parsed["requires_session_resume"], false);
assert_eq!(parsed["mutates_workspace"], false);
let fields = parsed["output_fields"].as_array().expect("output_fields");
assert!(fields.iter().any(|field| field == "checks"));
let statuses = parsed["status_values"].as_array().expect("status_values");
assert!(statuses.iter().any(|status| status == "warn"));
let checks = parsed["check_names"].as_array().expect("check_names");
assert!(checks.iter().any(|check| check == "auth"));
assert!(checks.iter().any(|check| check == "boot preflight"));
}
#[test]
fn doctor_help_text_stays_plaintext_and_local_702() {
let root = unique_temp_dir("doctor-help-text-702");
fs::create_dir_all(&root).expect("temp dir should exist");
let output = run_claw(&root, &["doctor", "--help"], &[]);
assert!(
output.status.success(),
"stdout:\n{}\n\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).expect("stdout utf8");
assert!(stdout.starts_with("Doctor\n"));
assert!(stdout.contains("Usage claw doctor"));
assert!(stdout.contains("no provider request or session resume required"));
serde_json::from_str::<Value>(&stdout).expect_err("text help should remain plaintext");
}
#[test]
fn version_emits_json_when_requested() {
let root = unique_temp_dir("version-json");
@@ -1201,6 +1259,162 @@ fn inventory_commands_deduplicate_config_deprecation_warnings_per_process() {
}
}
#[test]
fn config_json_reports_deprecations_structurally_without_stderr_duplicate_815() {
let root = unique_temp_dir("config-json-warning-815");
let config_home = root.join("config-home");
let home = root.join("home");
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#"{"enabledPlugins": {}}"#,
)
.expect("deprecated 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 should be valid json");
let warnings = parsed["warnings"]
.as_array()
.expect("config JSON should include warnings[]");
assert!(
warnings.iter().any(|warning| warning
.as_str()
.is_some_and(|text| text.contains("field \"enabledPlugins\" is deprecated"))),
"config JSON warnings[] should include enabledPlugins deprecation: {parsed}"
);
let stderr = String::from_utf8(output.stderr).expect("stderr utf8");
assert!(
!stderr.contains("field \"enabledPlugins\" is deprecated"),
"JSON config should not duplicate collected config deprecations on stderr:\n{stderr}"
);
let text_output = run_claw(&root, &["config"], &envs);
assert!(
text_output.status.success(),
"stdout:\n{}\n\nstderr:\n{}",
String::from_utf8_lossy(&text_output.stdout),
String::from_utf8_lossy(&text_output.stderr)
);
let text_stderr = String::from_utf8(text_output.stderr).expect("stderr utf8");
assert!(
text_stderr.contains("field \"enabledPlugins\" is deprecated"),
"text config should keep human-readable config warnings on stderr"
);
}
#[test]
fn local_json_surfaces_suppress_config_deprecation_stderr_816() {
let root = unique_temp_dir("global-json-warning-816");
let config_home = root.join("config-home");
let home = root.join("home");
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#"{"enabledPlugins": {}}"#,
)
.expect("deprecated config fixture should write");
let envs = [
(
"CLAW_CONFIG_HOME",
config_home.to_str().expect("utf8 config home"),
),
("HOME", home.to_str().expect("utf8 home")),
];
for (args, expected_kind, expected_action) in [
(
&["--output-format", "json", "plugins", "list"][..],
"plugin",
"list",
),
(
&["--output-format", "json", "mcp", "list"][..],
"mcp",
"list",
),
(
&["--output-format", "json", "doctor"][..],
"doctor",
"doctor",
),
] {
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)
);
let parsed: Value =
serde_json::from_slice(&output.stdout).expect("stdout should be valid JSON");
assert_eq!(parsed["kind"], expected_kind, "args={args:?}");
assert_eq!(parsed["action"], expected_action, "args={args:?}");
assert!(
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}"
);
}
}
#[test]
fn local_text_surface_preserves_config_deprecation_stderr_816() {
let root = unique_temp_dir("global-text-warning-816");
let config_home = root.join("config-home");
let home = root.join("home");
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#"{"enabledPlugins": {}}"#,
)
.expect("deprecated 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, &["doctor"], &envs);
assert!(
output.status.success(),
"stdout:\n{}\n\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stderr = String::from_utf8(output.stderr).expect("stderr utf8");
assert!(
stderr.contains("field \"enabledPlugins\" is deprecated"),
"text-mode doctor should preserve human config deprecation warnings on stderr"
);
}
fn assert_json_command(current_dir: &Path, args: &[&str]) -> Value {
assert_json_command_with_env(current_dir, args, &[])
}
@@ -1916,36 +2130,95 @@ fn login_logout_removed_subcommands_have_error_kind_and_hint_765() {
fn diff_extra_args_have_typed_error_kind_and_hint_766() {
// #766: `claw diff --bogus` returned error_kind:"unknown" + hint:null.
// `diff` takes no arguments; extra args were unclassified with no remediation.
let root = unique_temp_dir("diff-extra-args-766");
let root = git_temp_dir("diff-extra-args-766");
assert_diff_unexpected_extra_args_json(
&root,
&["--output-format", "json", "diff", "--bogus"],
"claw diff --bogus",
);
}
#[test]
fn diff_trailing_json_after_malformed_args_is_bounded_json_3129() {
// #3129: when --output-format json appeared after malformed `diff` args,
// the parser fell through to the interactive/prompt path and emitted zero
// JSON stdout. These forms must fail before any provider or TUI path starts.
let root = git_temp_dir("diff-trailing-json-3129");
for (args, label) in [
(
&["diff", "--bogus-flag", "--output-format", "json"][..],
"claw diff --bogus-flag --output-format json",
),
(
&["diff", "does-not-exist", "--output-format", "json"][..],
"claw diff does-not-exist --output-format json",
),
(
&[
"diff",
"--cached",
"--bogus-flag",
"--output-format",
"json",
][..],
"claw diff --cached --bogus-flag --output-format json",
),
] {
assert_diff_unexpected_extra_args_json(&root, args, label);
}
}
fn git_temp_dir(prefix: &str) -> PathBuf {
let root = unique_temp_dir(prefix);
fs::create_dir_all(&root).expect("temp dir should exist");
// Need a git repo for diff to parse past arg validation
// Need a git repo so `diff` reaches argument validation before git checks.
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(&root)
.output()
.ok();
.expect("git init should launch");
root
}
let output = run_claw(&root, &["--output-format", "json", "diff", "--bogus"], &[]);
fn assert_diff_unexpected_extra_args_json(root: &Path, args: &[&str], label: &str) {
let output = run_claw(root, args, &[]);
assert!(
!output.status.success(),
"claw diff --bogus should exit non-zero"
"{label} should exit non-zero; stdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(
output.stdout.is_empty(),
"{label} should not enter the spinner/prompt path; stdout:\n{}",
String::from_utf8_lossy(&output.stdout)
);
let stderr = String::from_utf8_lossy(&output.stderr);
let json_line = stderr
.lines()
.find(|l| l.trim_start().starts_with('{'))
.expect("stderr should contain a JSON error envelope");
.unwrap_or_else(|| {
panic!("{label} stderr should contain a JSON error envelope; stderr:\n{stderr}")
});
let parsed: serde_json::Value =
serde_json::from_str(json_line).expect("error envelope should be valid JSON");
assert_eq!(
parsed["error_kind"], "unexpected_extra_args",
"claw diff --bogus must return error_kind:unexpected_extra_args (#766)"
"{label} must return error_kind:unexpected_extra_args"
);
let hint = parsed["hint"].as_str().unwrap_or("");
assert!(
!hint.is_empty(),
"claw diff --bogus must return non-null hint (#766), got: {hint:?}"
"{label} must return non-null hint, got: {hint:?}"
);
assert!(
parsed["message"]
.as_str()
.is_some_and(|message| !message.is_empty()),
"{label} must return non-empty message"
);
}
@@ -3065,11 +3338,12 @@ fn skills_list_flag_shaped_filter_returns_unknown_option_792() {
}
#[test]
fn plugins_list_flag_shaped_filter_returns_unknown_option_793() {
fn plugins_list_flag_shaped_filter_returns_cli_parse_on_stdout_793_817() {
// #793: `claw plugins list --bogus-flag` silently returned status:"ok" with empty
// plugins list instead of an error. The list filter branch in print_plugins treated
// "--bogus-flag" as an id substring filter and found no matches, producing a false-positive.
// Fix: added flag-prefix guard; filter tokens starting with "-" now return unknown_option.
// #817: in JSON mode, handled local parse errors now return error_kind:"cli_parse"
// on stdout with stderr empty.
let root = unique_temp_dir("plugins-list-flag-793");
fs::create_dir_all(&root).expect("temp dir");
std::process::Command::new("git")
@@ -3093,19 +3367,16 @@ fn plugins_list_flag_shaped_filter_returns_unknown_option_793() {
!output.status.success(),
"plugins list --unknown-flag must exit non-zero (#793)"
);
// #803: the early flag guard now returns Err before the JSON branch,
// so the error envelope goes to stderr via the main error handler.
let stderr = String::from_utf8_lossy(&output.stderr);
let j: serde_json::Value = stderr
.lines()
.find(|l| l.trim_start().starts_with('{'))
.and_then(|l| serde_json::from_str(l).ok())
.expect("plugins list flag-filter should emit valid JSON on stderr");
assert_eq!(output.status.code(), Some(1), "exit code must be 1 (#817)");
// #817: handled JSON local parse errors stay on stdout, with stderr empty.
assert!(
j["error_kind"] == "unknown_option" || j["error_kind"] == "cli_parse",
"plugins list flag-shaped filter must return typed error, got {:?}",
j["error_kind"]
output.stderr.is_empty(),
"plugins list flag-filter JSON error must keep stderr empty (#817), got: {}",
String::from_utf8_lossy(&output.stderr)
);
let j: serde_json::Value = serde_json::from_slice(&output.stdout)
.expect("plugins list flag-filter should emit valid JSON on stdout");
assert_eq!(j["error_kind"], "cli_parse");
assert_eq!(j["status"], "error");
let h = j["hint"]
.as_str()
@@ -3424,6 +3695,62 @@ fn plugins_extra_args_have_non_null_hint_797() {
);
}
#[test]
fn plugins_list_trailing_dash_json_error_uses_stdout_817() {
// ROADMAP #817: JSON inventory/local parse errors are machine-readable on
// stdout. `plugins list --` used to route through the top-level error path,
// leaving stdout empty and writing the JSON envelope to stderr.
let root = unique_temp_dir("plugins-list-dash-817");
fs::create_dir_all(&root).expect("temp dir");
let output = run_claw(
&root,
&["--output-format", "json", "plugins", "list", "--"],
&[],
);
assert!(
!output.status.success(),
"plugins list -- must exit non-zero (#817)"
);
assert_eq!(output.status.code(), Some(1), "exit code must be 1 (#817)");
assert!(
output.stderr.is_empty(),
"JSON parse error must keep stderr empty (#817), got: {}",
String::from_utf8_lossy(&output.stderr)
);
let j: serde_json::Value =
serde_json::from_slice(&output.stdout).expect("stdout should be JSON error (#817)");
assert_eq!(j["kind"], "plugin");
assert_eq!(j["action"], "list");
assert_eq!(j["status"], "error");
assert_eq!(j["error_kind"], "cli_parse");
assert_eq!(j["unexpected"], "--");
}
#[test]
fn plugins_list_trailing_dash_text_error_stays_on_stderr_817() {
let root = unique_temp_dir("plugins-list-dash-text-817");
fs::create_dir_all(&root).expect("temp dir");
let output = run_claw(&root, &["plugins", "list", "--"], &[]);
assert!(
!output.status.success(),
"plugins list -- text mode must exit non-zero (#817)"
);
assert_eq!(output.status.code(), Some(1), "exit code must be 1 (#817)");
assert!(
output.stdout.is_empty(),
"text parse error should not emit stdout (#817), got: {}",
String::from_utf8_lossy(&output.stdout)
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("[error-kind: cli_parse]"), "{stderr}");
assert!(
stderr.contains("unknown option for `claw plugins list`: --"),
"{stderr}"
);
}
#[test]
fn empty_prompt_has_non_null_hint_798() {
// #798: `claw --output-format json ""` returned empty_prompt + hint:null.

View File

@@ -16,6 +16,16 @@ def run(cmd: list[str], cwd: Path) -> int:
return subprocess.run(cmd, cwd=str(cwd)).returncode
def run_quiet_until_failure(cmd: list[str], cwd: Path) -> int:
result = subprocess.run(cmd, cwd=str(cwd), text=True, capture_output=True)
if result.returncode:
if result.stdout:
print(result.stdout, end="")
if result.stderr:
print(result.stderr, end="", file=sys.stderr)
return result.returncode
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("command", choices=["generate", "validate"])
@@ -26,11 +36,13 @@ def main(argv: list[str] | None = None) -> int:
args = parser.parse_args(argv)
repo_root = args.repo_root.resolve()
script_root = Path(__file__).resolve().parent
tool_root = script_root.parent
board_json = repo_root / args.board_json
board_md = repo_root / args.board_md
generator = repo_root / "scripts" / "generate_cc2_board.py"
validator = repo_root / "scripts" / "validate_cc2_board.py"
renderer = repo_root / ".omx" / "cc2" / "render_board_md.py"
generator = script_root / "generate_cc2_board.py"
validator = script_root / "validate_cc2_board.py"
renderer = tool_root / ".omx" / "cc2" / "render_board_md.py"
if args.command == "generate":
rc = run([sys.executable, str(generator), "--repo-root", str(repo_root), "--out-dir", str(board_json.parent)], repo_root)
@@ -43,7 +55,7 @@ def main(argv: list[str] | None = None) -> int:
[sys.executable, str(renderer), str(board_json), str(board_md), "--check"],
]
for cmd in checks:
rc = run(cmd, repo_root)
rc = run_quiet_until_failure(cmd, repo_root)
if rc:
return rc
print(f"CC2 board validation PASS: {board_json} and {board_md} are canonical and in sync")

View File

@@ -13,6 +13,24 @@
#
set -euo pipefail
usage() {
sed -n '2,12p' "$0" | sed 's/^# //; s/^#//'
}
if [[ $# -gt 0 ]]; then
case "$1" in
--help|-h)
usage
exit 0
;;
*)
echo "error: unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
fi
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
RUST_DIR="$REPO_ROOT/rust"
BINARY="$RUST_DIR/target/debug/claw"
@@ -60,8 +78,8 @@ echo " export CLAW=$BINARY" >&2
echo "" >&2
echo " Dogfood with isolated config (no real user config on stderr):" >&2
echo " CLAW_ISOLATED=\$(mktemp -d)" >&2
echo " trap 'rm -rf \"\$CLAW_ISOLATED\"' EXIT" >&2
echo " CLAW_CONFIG_HOME=\$CLAW_ISOLATED \$CLAW plugins list --output-format json" >&2
echo " rm -rf \$CLAW_ISOLATED" >&2
echo "" >&2
echo " cargo run overhead: ~1s/invocation vs 7ms for pre-built binary." >&2
echo " Prefer pre-built binary (\$CLAW) for dogfood loops." >&2

View File

@@ -503,8 +503,12 @@ def main() -> int:
args = parser.parse_args()
repo_root = args.repo_root.resolve()
out_dir = args.out_dir or (repo_root / ".omx" / "cc2")
try:
board = build_board(repo_root)
except FileNotFoundError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
out_dir.mkdir(parents=True, exist_ok=True)
board = build_board(repo_root)
board_json = out_dir / "board.json"
board_md = out_dir / "board.md"
board_json.write_text(json.dumps(board, indent=2, sort_keys=True) + "\n", encoding="utf-8")

View File

@@ -11,6 +11,7 @@ set -euo pipefail
MIN_ID=723
ROADMAP="ROADMAP.md"
ROADMAP_PATH_SEEN=0
while [[ $# -gt 0 ]]; do
case "$1" in
@@ -31,7 +32,12 @@ while [[ $# -gt 0 ]]; do
exit 2
;;
*)
if [[ "$ROADMAP_PATH_SEEN" -ne 0 ]]; then
echo "error: unexpected extra ROADMAP path: $1" >&2
exit 2
fi
ROADMAP="$1"
ROADMAP_PATH_SEEN=1
shift
;;
esac

View File

@@ -17,7 +17,31 @@
# and resolve any append collision at git-push time.
set -euo pipefail
ROADMAP="${1:-ROADMAP.md}"
ROADMAP="ROADMAP.md"
ROADMAP_PATH_SEEN=0
while [[ $# -gt 0 ]]; do
case "$1" in
--help|-h)
sed -n '2,15p' "$0" | sed 's/^# //; s/^#//'
exit 0
;;
--*)
echo "error: unknown option: $1" >&2
exit 2
;;
*)
if [[ "$ROADMAP_PATH_SEEN" -ne 0 ]]; then
echo "error: unexpected extra ROADMAP path: $1" >&2
exit 2
fi
ROADMAP="$1"
ROADMAP_PATH_SEEN=1
shift
;;
esac
done
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
CHECKER="$SCRIPT_DIR/roadmap-check-ids.sh"

View File

@@ -44,7 +44,17 @@ def main() -> int:
args = parser.parse_args()
repo_root = args.repo_root.resolve()
board_path = args.board or (repo_root / ".omx" / "cc2" / "board.json")
board = json.loads(board_path.read_text(encoding="utf-8"))
try:
board = json.loads(board_path.read_text(encoding="utf-8"))
except FileNotFoundError:
print(f"error: board not found at {board_path}")
return 1
except IsADirectoryError:
print(f"error: board path is a directory: {board_path}")
return 1
except json.JSONDecodeError as exc:
print(f"error: invalid board JSON at {board_path}: {exc}")
return 1
errors: list[str] = []
ids = set()
for index, item in enumerate(board.get("items", []), 1):