mirror of
https://github.com/instructkr/claude-code.git
synced 2026-05-28 16:36:45 +00:00
Compare commits
52 Commits
fix/roadma
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0800d7ae88 | ||
|
|
69b8b367c1 | ||
|
|
9494e3c26f | ||
|
|
ed3a616e62 | ||
|
|
89e7f415a9 | ||
|
|
c3e7b6af60 | ||
|
|
3af2d9f986 | ||
|
|
09ff1caf42 | ||
|
|
0e6d48d9dc | ||
|
|
b7ea04661a | ||
|
|
73d8d6e638 | ||
|
|
9ac66cbeb3 | ||
|
|
773aa021be | ||
|
|
5c3e1c1444 | ||
|
|
3260258b56 | ||
|
|
a88d52fe88 | ||
|
|
60f44d314b | ||
|
|
d4e9829329 | ||
|
|
e17098cc70 | ||
|
|
e17936158a | ||
|
|
760e69675c | ||
|
|
193f11171a | ||
|
|
f11ac23e1f | ||
|
|
c4770e6571 | ||
|
|
b0e94c996b | ||
|
|
85d63b071c | ||
|
|
db81598525 | ||
|
|
86f45a11ef | ||
|
|
87b7e74770 | ||
|
|
ae6a207d4e | ||
|
|
efd34c151a | ||
|
|
2c3c0f60e7 | ||
|
|
bad1b97f8e | ||
|
|
fcebf64468 | ||
|
|
53953a8157 | ||
|
|
1201dc60ef | ||
|
|
23d7761e50 | ||
|
|
6ee67d6c61 | ||
|
|
efb1542a39 | ||
|
|
bff370003b | ||
|
|
9976585f87 | ||
|
|
18b4cee5fd | ||
|
|
491f179a03 | ||
|
|
57a57ef771 | ||
|
|
abfa2e4cf7 | ||
|
|
93a159dca5 | ||
|
|
9968a27e92 | ||
|
|
e4c3c1aa80 | ||
|
|
abdbf61acf | ||
|
|
113145a42a | ||
|
|
22b423b651 | ||
|
|
87f4334728 |
@@ -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
|
||||
|
||||
|
||||
91
ROADMAP.md
91
ROADMAP.md
@@ -7735,3 +7735,94 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed)
|
||||
783. **`claw --output-format json init` success envelope was missing `hint` field; idempotent re-init was not structurally detectable** — dogfooded 2026-05-27 on `32c9276f`. The init JSON envelope had no `hint` field (absent, not null), and no field to distinguish a fresh init from a re-init without checking `created.len() == 0`. Orchestrators had to inspect `created` array length to detect idempotent behavior. Fix: (1) added `hint` field to init JSON envelope — fresh path points at `CLAUDE.md + doctor`; idempotent path says "already initialised, run doctor"; (2) added `already_initialized: bool` field — `true` when `created` and `updated` are both empty (all artifacts skipped). Both test cases (fresh + re-init) covered by `init_json_envelope_has_hint_and_already_initialized_783`. 42 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori init-envelope probe on `32c9276f`, 2026-05-27.
|
||||
|
||||
784. **`claw export` had two opaque arg-error paths returning `error_kind:"unknown"` + `hint:null`** — dogfooded 2026-05-27 on `81fe0ccb` (pinpoint by Gaebal-gajae). `claw export --output` (missing flag value) emitted plain `"missing value for --output"` with no typed prefix; `claw export a.md b.md` (extra positional) emitted plain `"unexpected export argument: second.md"`. Both classified as `unknown+null`. Fix: (1) `--output` missing-value error now uses `missing_flag_value:` prefix + `\n` usage hint; (2) extra positional now uses `unexpected_extra_args:` prefix + `\n` usage hint; (3) classifier `unexpected_extra_args` arm extended to match both `starts_with("unexpected extra arguments")` (prose form, #766) and `starts_with("unexpected_extra_args:")` (typed prefix form, #784). Integration test `export_arg_errors_have_typed_kind_and_hint_784` covers both paths. 43 CLI contract tests pass. [SCOPE: claw-code] Source: Gaebal-gajae pinpoint + Jobdori implementation on `81fe0ccb`, 2026-05-27.
|
||||
|
||||
785. **`claw dump` (typo/near-miss for dump-manifests) returned `error_kind:"unknown"` — no classifier arm for `"unknown subcommand:"` prose prefix** — dogfooded 2026-05-27 on `e628b4bb`. Any unknown top-level subcommand that triggers the suggestion path emitted `"unknown subcommand: <x>.\nDid you mean <y>"` but `classify_error_kind` had no arm for that prefix; all fell to the `"unknown"` catch-all. The hint was non-null (the suggestion text was extracted by `split_error_hint`) but `error_kind` was undifferentiated. Fix: added `starts_with("unknown subcommand:")` → `"unknown_subcommand"` arm. Unit test assertion + integration test `unknown_subcommand_returns_typed_kind_785` using `claw dump` as the trigger. 44 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori subcommand-classifier probe on `e628b4bb`, 2026-05-27.
|
||||
|
||||
786. **`claw dump-manifests --manifests-dir` (missing value) and `--manifests-dir=` (empty) both returned `error_kind:"unknown"` + `hint:null`** — dogfooded 2026-05-27 on `87f43347` (pinpoint by Gaebal-gajae). Both missing `--manifests-dir` branches in `parse_dump_manifests_args` emitted plain `"--manifests-dir requires a path"` with no typed prefix; `classify_error_kind` had no matching arm so they fell to `"unknown"`. Fix: both branches now use `missing_flag_value:` prefix + `\n` usage hint. Integration test `dump_manifests_missing_dir_has_typed_kind_and_hint_786` covers both cases. 45 CLI contract tests pass. [SCOPE: claw-code] Source: Gaebal-gajae pinpoint + Jobdori implementation on `87f43347`, 2026-05-27.
|
||||
|
||||
787. **`claw --resume /tmp` (directory path) returned `error_kind:"session_load_failed"` + `hint:null`; resume error emission sites didn't apply `fallback_hint_for_error_kind`** — dogfooded 2026-05-27 on `22b423b6`. Two gaps: (1) the OS error `"Is a directory (os error 21)"` had no classifier arm, falling to generic `session_load_failed`; (2) both resume error emission paths (session load at line 3338, command execution at line 3484) called `split_error_hint` but not `fallback_hint_for_error_kind`, so API-layer errors with no `\n` always got `hint:null`. Fix: added `session_path_is_directory` classifier arm for `"Is a directory"` / `"os error 21"` messages; added `fallback_hint_for_error_kind` fallback to both resume error sites; added `session_path_is_directory` and `session_load_failed` to `fallback_hint_for_error_kind`. Unit test + integration test `resume_directory_path_returns_typed_kind_and_hint_787`. 46 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori resume-path probe on `22b423b6`, 2026-05-27.
|
||||
|
||||
788. **`claw --output-format json skills show <not-found>` emitted two JSON objects — one from the skills handler, one duplicate from the top-level error path** — dogfooded 2026-05-27 on `113145a4`. `print_skills` in JSON mode called `println!` to emit the `skill_not_found` error envelope, then returned `Err(...)`. The `?` propagation triggered the top-level error handler which emitted a second `action:"abort"` JSON envelope on stderr. Callers reading both stdout and stderr got two JSON objects with the same `error_kind` but different `action` fields — the first was the authoritative response, the second was a duplicate. Fix: replaced `return Err(...)` with `std::process::exit(1)` after the skills error JSON is emitted, mirroring the existing `is_help_action` guard pattern. Integration test `skills_show_not_found_emits_single_json_object_788` asserts exactly 1 JSON object on stdout and no JSON on stderr. 47 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori skills double-emission probe on `113145a4`, 2026-05-27.
|
||||
|
||||
789. **`claw --output-format json agents show <not-found>` and `plugins show <not-found>` both returned exit 0 despite `status:"error"` in the JSON** — dogfooded 2026-05-27 on `abdbf61a`. Skills was fixed in #788 (exit 1 via process::exit). Agents and plugins had the identical gap: `print_agents` had no error check at all (just println + Ok(())); `print_plugins`'s not-found branch used `return Ok(())`. MCP was already fixed in an earlier cycle (#68). Fix: added `is_error` check in `print_agents` JSON path (exit 1 when status=="error"); changed plugins not-found branch from `return Ok(())` to `std::process::exit(1)`. Existing `inventory_commands_emit_structured_json_when_requested` test updated to use `run_claw` directly for the not-found case. Two new tests added: `agents_show_not_found_exits_nonzero_789`, `plugins_show_not_found_exits_nonzero_789`. 49 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori exit-code consistency probe on `abdbf61a`, 2026-05-27.
|
||||
|
||||
790. **`claw --output-format json system-prompt <unknown-option>` returned `error_kind:"unknown"` + `hint:null`** — dogfooded 2026-05-27 on `e4c3c1aa`. The unknown-option branch in `parse_print_system_prompt_args` emitted plain `"unknown system-prompt option: {other}"` for all unrecognised options except `--json` (which appended a `\n`-delimited suggestion). All non-`--json` cases fell to `unknown+null`. Fix: replaced bare format string with `unknown_option: ... \n<usage hint>` format for all unknown options; `--json` special case preserves its `--output-format json` suggestion in the hint prefix. Integration test `system_prompt_unknown_option_returns_typed_kind_790` covers both paths. 50 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori system-prompt option probe on `e4c3c1aa`, 2026-05-27.
|
||||
|
||||
791. **`claw config show <extra>` and `claw config set <extra1> <extra2>` returned `unexpected_extra_args` + `hint:null`** — dogfooded 2026-05-27 on `9968a27e`. The config arg parser emitted `"unexpected extra arguments after `claw config {}`: {}"` with no `\n` delimiter, so `split_error_hint` returned `None` and `fallback_hint_for_error_kind("unexpected_extra_args")` also returns `None`. Fix: appended `\nUsage: claw config [env|hooks|model|plugins|mcp|settings]` to the error format string. Integration test `config_extra_args_have_non_null_hint_791` covers both paths. 51 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori config-arg probe on `9968a27e`, 2026-05-27.
|
||||
|
||||
792. **`claw agents list --bogus-flag` and `claw skills list --bogus-flag` silently returned `status:"ok" count:0` instead of an error** — dogfooded 2026-05-27 on `93a159dc`. The `list <filter>` arm in both handlers treated flag-shaped tokens (`--something`) as name substring filters. Since no agents/skills have `--bogus` in their name, result was empty success list — a false positive that masks typos and unknown flags. Fix: added flag-prefix guard at the top of both `list <args>` arms in `commands/src/lib.rs`; detected filter tokens starting with `-` return `unknown_option` + usage hint. Two new integration tests `agents_list_flag_shaped_filter_returns_unknown_option_792`, `skills_list_flag_shaped_filter_returns_unknown_option_792`. 53 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori agents/skills list probe on `93a159dc`, 2026-05-27.
|
||||
|
||||
793. **`claw plugins list --bogus-flag` silent empty success + `plugins uninstall <not-found>` had `hint:null`** — dogfooded 2026-05-27 on `abfa2e4c`. Two gaps: (1) `plugins list` filter branch in `print_plugins` treated `--bogus-flag` as an id substring filter, found no matches, returned `status:"ok"` empty list — same false-positive as #792 for agents/skills. (2) `plugins uninstall no-such` propagated `plugin_not_found` error via `?` with no `\n` delimiter; `plugin_not_found` was missing from `fallback_hint_for_error_kind` table. Fix: (1) added flag-prefix guard in `print_plugins` `is_list_action` branch (detects tokens starting with `-`, returns `unknown_option` + usage hint, exits 1); (2) added `"plugin_not_found"` → `"Run 'claw plugins list' to see installed plugins."` to fallback table. Two new tests `plugins_list_flag_shaped_filter_returns_unknown_option_793`, `plugins_uninstall_not_found_has_hint_793`. 55 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori plugins lifecycle probe on `abfa2e4c`, 2026-05-27.
|
||||
|
||||
794. **`claw plugins install /nonexistent/path` returned `error_kind:"unknown"` + `hint:null`** — dogfooded 2026-05-27 on `57a57ef7`. The error message `"plugin source '/path' was not found"` had no classifier arm, falling to `"unknown"`. Fix: added `plugin_source_not_found` classifier arm (`message.contains("plugin source") && message.contains("was not found")`); added `"plugin_source_not_found"` → `"Check that the path or URL is correct..."` to `fallback_hint_for_error_kind`. Unit test assertion added to `test_classify_error_kind`; integration test `plugins_install_not_found_path_returns_typed_kind_794` added. 56 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori plugins install probe on `57a57ef7`, 2026-05-27.
|
||||
|
||||
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.
|
||||
|
||||
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]
|
||||
|
||||
799. **`claw --output-format json ""` and `claw " "` returned `empty_prompt` + `hint:null`** — dogfooded 2026-05-27 on `bff37000`. The empty-prompt guard at the fallthrough path emitted `"empty prompt: provide a subcommand..."` with no `\n` delimiter. Fix: added `\n` + usage hint. Integration test `empty_prompt_has_non_null_hint_798`. Committed as `efb1542a`. 61 CLI contract tests pass. [SCOPE: claw-code]
|
||||
|
||||
800. **`classify_error_kind` unit test coverage gap: `invalid_history_count` and `unknown_option` arms had zero assertions** — found 2026-05-27 on `efb1542a`. Audit of 39 distinct classifier return values vs 37 unit test assertions revealed 2 untested arms. Fix: added 3 `assert_eq!` covering both arms (invalid_history_count prefix + contains paths, unknown_option prefix). Committed as `6ee67d6c`. All 39 return values now have unit test coverage. [SCOPE: claw-code]
|
||||
|
||||
801. **`claw --output-format json diff` in a non-git directory was missing `error_kind`, `hint`, and `message` fields** — dogfooded 2026-05-27 on `1201dc60`. The diff handler's no-git-repo JSON branch constructed a custom object with only `status:"error"` + `result:"no_git_repo"` + `detail`, violating the error envelope contract that every error has `error_kind` + `hint`. Fix: added `error_kind: "no_git_repo"`, `hint: "Run git init..."`, and `message` fields. Integration test `diff_non_git_dir_has_error_kind_and_hint_801`. 62 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori non-git-dir probe on `1201dc60`, 2026-05-27.
|
||||
|
||||
802. **Four `status:"error"` JSON sites in resume-mode and broad-cwd handlers were missing `hint` field** — found 2026-05-27 on `53953a81` via source audit of all `"status": "error"` sites in main.rs. The resume `unsupported_command` (L3433), `unsupported_resumed_command` (L3455), `cli_parse` (L3474), and `broad_cwd` (L4838) handlers all emitted JSON error envelopes with `error_kind` but no `hint` field. Fix: added contextual `hint` string to all four sites. Source audit now shows 0 `status:"error"` JSON objects missing `hint` across entire main.rs. 62 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori source-level audit of all error JSON sites, 2026-05-27.
|
||||
|
||||
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]
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -2365,6 +2365,13 @@ pub fn handle_agents_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R
|
||||
}
|
||||
Some(args) if args.starts_with("list ") => {
|
||||
let filter = args["list ".len()..].trim().to_lowercase();
|
||||
// #803: reject flag-shaped tokens in text mode too (JSON guard was added in #792)
|
||||
if filter.starts_with('-') {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("unknown option for `agents list`: {filter}\nUsage: claw agents list [<filter>]\nFilters are name substrings, not flags."),
|
||||
));
|
||||
}
|
||||
let roots = discover_definition_roots(cwd, "agents");
|
||||
let agents = load_agents_from_roots(&roots)?;
|
||||
let filtered: Vec<_> = agents
|
||||
@@ -2383,22 +2390,30 @@ pub fn handle_agents_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R
|
||||
|| args.starts_with("info ")
|
||||
|| args.starts_with("describe ") =>
|
||||
{
|
||||
let name = args
|
||||
let name_raw = args
|
||||
.split_once(' ')
|
||||
.map(|(_, name)| name)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_lowercase();
|
||||
// #804: detect extra positional args (parity with JSON-mode fix #796)
|
||||
if name_raw.contains(' ') {
|
||||
let extra = name_raw.split_once(' ').map(|(_, e)| e).unwrap_or("");
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("unexpected extra arguments after agent name\nUsage: claw agents show <name>\nUnexpected extra: '{extra}'"),
|
||||
));
|
||||
}
|
||||
let roots = discover_definition_roots(cwd, "agents");
|
||||
let agents = load_agents_from_roots(&roots)?;
|
||||
let matched: Vec<_> = agents
|
||||
.into_iter()
|
||||
.filter(|a| a.name.to_lowercase() == name)
|
||||
.filter(|a| a.name.to_lowercase() == name_raw)
|
||||
.collect();
|
||||
if matched.is_empty() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
format!("agent not found: {name}"),
|
||||
format!("agent not found: {name_raw}"),
|
||||
));
|
||||
}
|
||||
Ok(render_agents_report(&matched))
|
||||
@@ -2429,6 +2444,18 @@ pub fn handle_agents_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
|
||||
}
|
||||
Some(args) if args.starts_with("list ") => {
|
||||
let filter = args["list ".len()..].trim().to_lowercase();
|
||||
// #792: unknown flags (--something) silently became filter strings, returning
|
||||
// empty success list instead of an error. Detect and reject flag-shaped tokens.
|
||||
if filter.starts_with('-') {
|
||||
return Ok(serde_json::json!({
|
||||
"kind": "agents",
|
||||
"action": "list",
|
||||
"status": "error",
|
||||
"error_kind": "unknown_option",
|
||||
"unexpected": filter,
|
||||
"hint": "Usage: claw agents list [<filter>]\nFilters are name substrings, not flags.",
|
||||
}));
|
||||
}
|
||||
let roots = discover_definition_roots(cwd, "agents");
|
||||
let agents = load_agents_from_roots(&roots)?;
|
||||
let filtered: Vec<_> = agents
|
||||
@@ -2447,12 +2474,29 @@ pub fn handle_agents_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
|
||||
|| args.starts_with("info ")
|
||||
|| args.starts_with("describe ") =>
|
||||
{
|
||||
let name = args
|
||||
let name_raw = args
|
||||
.split_once(' ')
|
||||
.map(|(_, name)| name)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_lowercase();
|
||||
// #796: extra positional args after the name (e.g. `agents show foo extra`)
|
||||
// produced a confusing agent_not_found for "foo extra" instead of flagging
|
||||
// the unexpected extra argument.
|
||||
let (name, extra) = name_raw
|
||||
.split_once(' ')
|
||||
.map(|(n, e)| (n.to_string(), Some(e.to_string())))
|
||||
.unwrap_or_else(|| (name_raw.clone(), None));
|
||||
if let Some(extra_token) = extra {
|
||||
return Ok(serde_json::json!({
|
||||
"kind": "agents",
|
||||
"action": "show",
|
||||
"status": "error",
|
||||
"error_kind": "unexpected_extra_args",
|
||||
"unexpected": extra_token,
|
||||
"hint": format!("Usage: claw agents show <name>\nUnexpected extra: '{extra_token}'"),
|
||||
}));
|
||||
}
|
||||
let roots = discover_definition_roots(cwd, "agents");
|
||||
let agents = load_agents_from_roots(&roots)?;
|
||||
let matched: Vec<_> = agents
|
||||
@@ -2498,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) {
|
||||
@@ -2517,6 +2569,13 @@ pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R
|
||||
}
|
||||
Some(args) if args.starts_with("list ") => {
|
||||
let filter = args["list ".len()..].trim().to_lowercase();
|
||||
// #803: reject flag-shaped tokens in text mode too (JSON guard was added in #792)
|
||||
if filter.starts_with('-') {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("unknown option for `skills list`: {filter}\nUsage: claw skills list [<filter>]\nFilters are name substrings, not flags."),
|
||||
));
|
||||
}
|
||||
let roots = discover_skill_roots(cwd);
|
||||
let skills = load_skills_from_roots(&roots)?;
|
||||
let filtered: Vec<_> = skills
|
||||
@@ -2535,18 +2594,33 @@ pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R
|
||||
|| args.starts_with("info ")
|
||||
|| args.starts_with("describe ") =>
|
||||
{
|
||||
let name = args
|
||||
let name_raw = args
|
||||
.split_once(' ')
|
||||
.map(|(_, name)| name)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_lowercase();
|
||||
// #804: detect extra positional args (parity with JSON-mode fix #796)
|
||||
if name_raw.contains(' ') {
|
||||
let extra = name_raw.split_once(' ').map(|(_, e)| e).unwrap_or("");
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("unexpected extra arguments after skill name\nUsage: claw skills show <name>\nUnexpected extra: '{extra}'"),
|
||||
));
|
||||
}
|
||||
let roots = discover_skill_roots(cwd);
|
||||
let skills = load_skills_from_roots(&roots)?;
|
||||
let matched: Vec<_> = skills
|
||||
.into_iter()
|
||||
.filter(|s| s.name.to_lowercase() == name)
|
||||
.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"))),
|
||||
@@ -2582,6 +2656,18 @@ pub fn handle_skills_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
|
||||
}
|
||||
Some(args) if args.starts_with("list ") => {
|
||||
let filter = args["list ".len()..].trim().to_lowercase();
|
||||
// #792: flag-shaped tokens silently became filter strings, returning
|
||||
// empty success list instead of an error. Detect and reject them.
|
||||
if filter.starts_with('-') {
|
||||
return Ok(serde_json::json!({
|
||||
"kind": "skills",
|
||||
"action": "list",
|
||||
"status": "error",
|
||||
"error_kind": "unknown_option",
|
||||
"unexpected": filter,
|
||||
"hint": "Usage: claw skills list [<filter>]\nFilters are name substrings, not flags.",
|
||||
}));
|
||||
}
|
||||
let roots = discover_skill_roots(cwd);
|
||||
let skills = load_skills_from_roots(&roots)?;
|
||||
let filtered: Vec<_> = skills
|
||||
@@ -2600,12 +2686,29 @@ pub fn handle_skills_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
|
||||
|| args.starts_with("info ")
|
||||
|| args.starts_with("describe ") =>
|
||||
{
|
||||
let name = args
|
||||
let name_raw = args
|
||||
.split_once(' ')
|
||||
.map(|(_, name)| name)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_lowercase();
|
||||
// #796: extra positional args after the name (e.g. `skills show foo extra`)
|
||||
// produced a confusing skill_not_found for "foo extra" instead of flagging
|
||||
// the unexpected extra argument.
|
||||
let (name, extra) = name_raw
|
||||
.split_once(' ')
|
||||
.map(|(n, e)| (n.to_string(), Some(e.to_string())))
|
||||
.unwrap_or_else(|| (name_raw.clone(), None));
|
||||
if let Some(extra_token) = extra {
|
||||
return Ok(json!({
|
||||
"kind": "skills",
|
||||
"action": "show",
|
||||
"status": "error",
|
||||
"error_kind": "unexpected_extra_args",
|
||||
"unexpected": extra_token,
|
||||
"hint": format!("Usage: claw skills show <name>\nUnexpected extra: '{extra_token}'"),
|
||||
}));
|
||||
}
|
||||
let roots = discover_skill_roots(cwd);
|
||||
let skills = load_skills_from_roots(&roots)?;
|
||||
let matched: Vec<_> = skills
|
||||
@@ -2899,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());
|
||||
@@ -2935,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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -283,6 +283,9 @@ fn classify_error_kind(message: &str) -> &'static str {
|
||||
// error message is "failed to restore session: legacy session is missing workspace
|
||||
// binding: ...", so the specific arm must be checked first.
|
||||
"legacy_session_no_workspace_binding"
|
||||
} else if message.contains("Is a directory") || message.contains("os error 21") {
|
||||
// #787: --resume given a directory path instead of a .jsonl file
|
||||
"session_path_is_directory"
|
||||
} else if message.contains("failed to restore session") {
|
||||
"session_load_failed"
|
||||
} else if message.contains("unsupported ACP invocation") {
|
||||
@@ -332,8 +335,11 @@ 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"
|
||||
"plugin_source_not_found"
|
||||
} else if (message.contains("skill source") && message.contains("not found"))
|
||||
|| message.starts_with("skill '")
|
||||
{
|
||||
@@ -349,6 +355,9 @@ fn classify_error_kind(message: &str) -> &'static str {
|
||||
} else if message.contains("has been removed.") {
|
||||
// #765: removed subcommands (login, logout) — hint contains migration guidance
|
||||
"removed_subcommand"
|
||||
} else if message.starts_with("unknown subcommand:") {
|
||||
// #785: typo/unknown top-level subcommand (e.g. `claw dump` → did you mean dump-manifests?)
|
||||
"unknown_subcommand"
|
||||
} else if message.starts_with("unexpected extra arguments")
|
||||
|| message.starts_with("unexpected_extra_args:")
|
||||
{
|
||||
@@ -398,6 +407,28 @@ 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.")
|
||||
}
|
||||
// #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/.",
|
||||
),
|
||||
"session_path_is_directory" => Some(
|
||||
"--resume expects a .jsonl session file path, not a directory. Run `claw --output-format json /session list` to list managed sessions.",
|
||||
),
|
||||
// #793: plugins uninstall/enable/disable of non-existing plugin propagates through
|
||||
// the ? operator with no \n delimiter, so split_error_hint returns None.
|
||||
"plugin_not_found" => Some("Run `claw plugins list` to see installed plugins."),
|
||||
// #794: plugins install with a path that doesn't exist
|
||||
"plugin_source_not_found" => Some(
|
||||
"Check that the path or URL is correct. Use a local directory or a valid registry id.",
|
||||
),
|
||||
// #795: skills install/show of a non-existing skill path or name
|
||||
"skill_not_found" => Some(
|
||||
"Run `claw skills list` to see available skills, or `claw skills install <path>` to install a new one.",
|
||||
),
|
||||
// #795: unsupported action on skills (e.g. /skills uninstall) with no \n hint
|
||||
"unsupported_skills_action" => Some(
|
||||
"Supported: list, install <path>, show <name>, help. Run `claw skills help` for details.",
|
||||
),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -1126,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),
|
||||
@@ -1154,8 +1189,9 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
let action = tail.first().cloned();
|
||||
let target = tail.get(1).cloned();
|
||||
if tail.len() > 2 {
|
||||
// #797: append \n usage hint so split_error_hint extracts it (parity with #791 config fix)
|
||||
return Err(format!(
|
||||
"unexpected extra arguments after `claw {} {}`: {}",
|
||||
"unexpected extra arguments after `claw {} {}`: {}\nUsage: claw plugins [list|show <id>|install <id>|enable <id>|disable <id>|uninstall <id>|update <id>|help]",
|
||||
rest[0],
|
||||
tail[..2].join(" "),
|
||||
tail[2..].join(" ")
|
||||
@@ -1177,8 +1213,9 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
let tail = &rest[1..];
|
||||
let section = tail.first().cloned();
|
||||
if tail.len() > 1 {
|
||||
// #791: append \n hint so split_error_hint extracts it and hint is non-null
|
||||
return Err(format!(
|
||||
"unexpected extra arguments after `claw config {}`: {}",
|
||||
"unexpected extra arguments after `claw config {}`: {}\nUsage: claw config [env|hooks|model|plugins|mcp|settings]",
|
||||
tail[0],
|
||||
tail[1..].join(" ")
|
||||
));
|
||||
@@ -1192,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 })
|
||||
}
|
||||
@@ -1268,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(),
|
||||
@@ -1305,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(),
|
||||
@@ -1317,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,
|
||||
@@ -1345,8 +1382,9 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
// an empty prompt when credentials are present).
|
||||
let joined = rest.join(" ");
|
||||
if joined.trim().is_empty() {
|
||||
// #798: add \n hint so split_error_hint extracts it (was empty_prompt + null)
|
||||
return Err(
|
||||
"empty prompt: provide a subcommand (run `claw --help`) or a non-empty prompt string"
|
||||
"empty prompt: provide a subcommand or a non-empty prompt string.\nUsage: claw <subcommand> or claw -p <prompt>. Run `claw --help` for the full list."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
@@ -1355,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(),
|
||||
@@ -1591,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 }),
|
||||
@@ -2078,11 +2123,16 @@ fn parse_system_prompt_args(
|
||||
}
|
||||
other => {
|
||||
// #152: hint `--output-format json` when user types `--json`.
|
||||
let mut msg = format!("unknown system-prompt option: {other}");
|
||||
if other == "--json" {
|
||||
msg.push_str("\nDid you mean `--output-format json`?");
|
||||
}
|
||||
return Err(msg);
|
||||
// #790: use unknown_option: prefix + \n hint so classify_error_kind returns
|
||||
// unknown_option and split_error_hint extracts the remediation text.
|
||||
let hint = if other == "--json" {
|
||||
"Did you mean `--output-format json`? Usage: claw system-prompt [--cwd <dir>] [--date <YYYY-MM-DD>] [--output-format text|json]".to_string()
|
||||
} else {
|
||||
"Usage: claw system-prompt [--cwd <dir>] [--date <YYYY-MM-DD>] [--output-format text|json]".to_string()
|
||||
};
|
||||
return Err(format!(
|
||||
"unknown_option: unknown system-prompt option: {other}.\n{hint}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2156,14 +2206,15 @@ fn parse_dump_manifests_args(
|
||||
if arg == "--manifests-dir" {
|
||||
let value = args
|
||||
.get(index + 1)
|
||||
.ok_or_else(|| String::from("--manifests-dir requires a path"))?;
|
||||
.ok_or_else(|| String::from("missing_flag_value: --manifests-dir requires a path.\nUsage: claw dump-manifests --manifests-dir <path> [--output-format json]"))?;
|
||||
manifests_dir = Some(PathBuf::from(value));
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if let Some(value) = arg.strip_prefix("--manifests-dir=") {
|
||||
if value.is_empty() {
|
||||
return Err(String::from("--manifests-dir requires a path"));
|
||||
// #786: empty --manifests-dir= is also a missing value
|
||||
return Err(String::from("missing_flag_value: --manifests-dir requires a path.\nUsage: claw dump-manifests --manifests-dir <path> [--output-format json]"));
|
||||
}
|
||||
manifests_dir = Some(PathBuf::from(value));
|
||||
index += 1;
|
||||
@@ -2363,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>,
|
||||
@@ -2452,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) =
|
||||
@@ -2508,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}"),
|
||||
@@ -3323,7 +3397,10 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu
|
||||
// #77: classify session load errors for downstream consumers
|
||||
let full_message = format!("failed to restore session: {error}");
|
||||
let kind = classify_error_kind(&full_message);
|
||||
let (short_reason, hint) = split_error_hint(&full_message);
|
||||
let (short_reason, inline_hint) = split_error_hint(&full_message);
|
||||
// #787: fall back to kind-derived hint when message has no \n delimiter
|
||||
let hint =
|
||||
inline_hint.or_else(|| fallback_hint_for_error_kind(kind).map(String::from));
|
||||
eprintln!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
@@ -3390,6 +3467,7 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu
|
||||
"status": "error",
|
||||
"error_kind": "unsupported_command",
|
||||
"error": format!("/{cmd_root} is not yet implemented in this build"),
|
||||
"hint": "This command is not available in the current build. Update claw or use a different command.",
|
||||
"exit_code": 2,
|
||||
"command": raw_command,
|
||||
})
|
||||
@@ -3412,6 +3490,7 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu
|
||||
"status": "error",
|
||||
"error_kind": "unsupported_resumed_command",
|
||||
"error": format!("unsupported resumed command: {raw_command}"),
|
||||
"hint": "This command cannot be used with --resume. Use it in an interactive REPL session instead.",
|
||||
"exit_code": 2,
|
||||
"command": raw_command,
|
||||
})
|
||||
@@ -3431,6 +3510,7 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu
|
||||
"status": "error",
|
||||
"error_kind": "cli_parse",
|
||||
"error": error.to_string(),
|
||||
"hint": "Run `claw --help` for usage.",
|
||||
"exit_code": 2,
|
||||
"command": raw_command,
|
||||
})
|
||||
@@ -3468,7 +3548,10 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu
|
||||
// hardcoded "resume_command_error" + prose in the error field
|
||||
let full_error = error.to_string();
|
||||
let error_kind = classify_error_kind(&full_error);
|
||||
let (short_reason, hint) = split_error_hint(&full_error);
|
||||
let (short_reason, inline_hint) = split_error_hint(&full_error);
|
||||
// #787: fall back to kind-derived hint when error has no \n delimiter
|
||||
let hint = inline_hint
|
||||
.or_else(|| fallback_hint_for_error_kind(error_kind).map(String::from));
|
||||
eprintln!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
@@ -4581,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
|
||||
@@ -4615,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()),
|
||||
@@ -4792,6 +4880,7 @@ fn enforce_broad_cwd_policy(
|
||||
"status": "error",
|
||||
"error_kind": "broad_cwd",
|
||||
"error": message,
|
||||
"hint": "Change to a more specific project directory, or use --cwd to set the workspace root.",
|
||||
"exit_code": 1,
|
||||
})
|
||||
);
|
||||
@@ -5920,7 +6009,10 @@ impl LiveCli {
|
||||
false
|
||||
}
|
||||
SlashCommand::Doctor => {
|
||||
println!("{}", render_doctor_report()?.render());
|
||||
println!(
|
||||
"{}",
|
||||
render_doctor_report(ConfigWarningMode::EmitStderr)?.render()
|
||||
);
|
||||
false
|
||||
}
|
||||
SlashCommand::History { count } => {
|
||||
@@ -6261,10 +6353,17 @@ impl LiveCli {
|
||||
let cwd = env::current_dir()?;
|
||||
match output_format {
|
||||
CliOutputFormat::Text => println!("{}", handle_agents_slash_command(args, &cwd)?),
|
||||
CliOutputFormat::Json => println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&handle_agents_slash_command_json(args, &cwd)?)?
|
||||
),
|
||||
CliOutputFormat::Json => {
|
||||
let value = handle_agents_slash_command_json(args, &cwd)?;
|
||||
// #789: parity with print_mcp/#788 print_skills — exit 1 when envelope
|
||||
// reports an error so automation can rely on exit code instead of
|
||||
// parsing the JSON status field.
|
||||
let is_error = value.get("status").and_then(|v| v.as_str()) == Some("error");
|
||||
println!("{}", serde_json::to_string_pretty(&value)?);
|
||||
if is_error {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -6313,12 +6412,10 @@ impl LiveCli {
|
||||
let is_help_action = result.get("action").and_then(|v| v.as_str()) == Some("help");
|
||||
println!("{}", serde_json::to_string_pretty(&result)?);
|
||||
if is_error && !is_help_action {
|
||||
return Err(result
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("skills command failed")
|
||||
.to_string()
|
||||
.into());
|
||||
// #788: the error JSON is already emitted above; returning Err here
|
||||
// would cause the top-level handler to emit a second error envelope.
|
||||
// Exit directly to signal failure without a duplicate envelope.
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6331,9 +6428,69 @@ impl LiveCli {
|
||||
output_format: CliOutputFormat,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let cwd = env::current_dir()?;
|
||||
let payload = plugins_command_payload_for(&cwd, action, target)?;
|
||||
// #803: reject flag-shaped tokens in list filter for BOTH text and JSON modes.
|
||||
// Previously the guard was JSON-only (#793); text mode silently returned empty success.
|
||||
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,
|
||||
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.
|
||||
@@ -6410,7 +6567,8 @@ impl LiveCli {
|
||||
"hint": "Run `claw plugins list` to see available plugins.",
|
||||
});
|
||||
println!("{}", serde_json::to_string_pretty(&obj)?);
|
||||
return Ok(());
|
||||
// #789: exit 1 on not-found so automation can rely on exit code
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6622,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()?;
|
||||
@@ -7776,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",
|
||||
@@ -8265,11 +8465,15 @@ fn render_diff_json_for(cwd: &Path) -> Result<serde_json::Value, Box<dyn std::er
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false);
|
||||
if !in_git_repo {
|
||||
// #801: add error_kind, hint, message fields for envelope parity with other error paths
|
||||
return Ok(serde_json::json!({
|
||||
"kind": "diff",
|
||||
"action": "diff",
|
||||
"status": "error",
|
||||
"error_kind": "no_git_repo",
|
||||
"result": "no_git_repo",
|
||||
"message": format!("{} is not inside a git project", cwd.display()),
|
||||
"hint": "Run `git init` to create a repository, or change to a directory that is inside a git project.",
|
||||
"working_directory": cwd.display().to_string(),
|
||||
"detail": format!("{} is not inside a git project", cwd.display()),
|
||||
}));
|
||||
@@ -8975,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())),
|
||||
};
|
||||
@@ -12646,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"),
|
||||
@@ -13005,10 +13216,20 @@ mod tests {
|
||||
classify_error_kind("failed to restore session: file not found"),
|
||||
"session_load_failed"
|
||||
);
|
||||
// #787: directory-as-session-path gets its own kind (precedes generic session_load_failed)
|
||||
assert_eq!(
|
||||
classify_error_kind("failed to restore session: Is a directory (os error 21)"),
|
||||
"session_path_is_directory"
|
||||
);
|
||||
assert_eq!(
|
||||
classify_error_kind("unrecognized argument `--foo` for subcommand `doctor`"),
|
||||
"cli_parse"
|
||||
);
|
||||
// #785: unknown top-level subcommand (typo or unrecognised command)
|
||||
assert_eq!(
|
||||
classify_error_kind("unknown subcommand: dump.\nDid you mean dump-manifests"),
|
||||
"unknown_subcommand"
|
||||
);
|
||||
assert_eq!(
|
||||
classify_error_kind("unsupported ACP invocation. Use `claw acp`."),
|
||||
"unsupported_acp_invocation"
|
||||
@@ -13119,6 +13340,11 @@ mod tests {
|
||||
classify_error_kind("my-plugin is not installed"),
|
||||
"plugin_not_found"
|
||||
);
|
||||
// #794: plugins install with missing source path
|
||||
assert_eq!(
|
||||
classify_error_kind("plugin source `/nonexistent/path` was not found"),
|
||||
"plugin_source_not_found"
|
||||
);
|
||||
assert_eq!(
|
||||
classify_error_kind("skill source /path/to/skill not found"),
|
||||
"skill_not_found"
|
||||
@@ -13178,6 +13404,20 @@ mod tests {
|
||||
),
|
||||
"invalid_resume_argument"
|
||||
);
|
||||
// coverage: invalid_history_count arm
|
||||
assert_eq!(
|
||||
classify_error_kind("invalid_history_count: abc is not a valid count"),
|
||||
"invalid_history_count"
|
||||
);
|
||||
assert_eq!(
|
||||
classify_error_kind("something invalid count something"),
|
||||
"invalid_history_count"
|
||||
);
|
||||
// coverage: unknown_option arm (#790)
|
||||
assert_eq!(
|
||||
classify_error_kind("unknown_option: unknown system-prompt option: --foo."),
|
||||
"unknown_option"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user