mirror of
https://github.com/instructkr/claw-code.git
synced 2026-06-09 10:47:03 -04:00
Compare commits
1 Commits
9c8375da99
...
fix/issue-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0197fa2020 |
2
.github/hooks/pre-push
vendored
2
.github/hooks/pre-push
vendored
@@ -13,7 +13,7 @@ cd "$repo_root"
|
||||
|
||||
if [[ -x scripts/roadmap-check-ids.sh ]]; then
|
||||
echo "pre-push: scripts/roadmap-check-ids.sh" >&2
|
||||
scripts/roadmap-check-ids.sh >&2
|
||||
scripts/roadmap-check-ids.sh
|
||||
fi
|
||||
|
||||
if [[ "${SKIP_CLAW_PRE_PUSH_BUILD:-}" == "1" ]]; then
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,7 +8,6 @@ archive/
|
||||
# Claw Code local artifacts
|
||||
.claw/settings.local.json
|
||||
.claw/sessions/
|
||||
.claw/rules.local/
|
||||
.clawhip/
|
||||
status-help.txt
|
||||
# Legacy Python port session scratch artifacts
|
||||
|
||||
@@ -55,15 +55,8 @@ REQUIRED_ITEM_FIELDS = [
|
||||
|
||||
|
||||
def load_board(path: Path) -> dict[str, Any]:
|
||||
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
|
||||
with path.open() as f:
|
||||
board = json.load(f)
|
||||
if not isinstance(board, dict):
|
||||
raise ValueError("board JSON root must be an object")
|
||||
items = board.get("items")
|
||||
@@ -233,11 +226,7 @@ def main() -> int:
|
||||
parser.add_argument("--check", action="store_true", help="fail if board_md is not up to date")
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
board = load_board(args.board_json)
|
||||
except ValueError as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
board = load_board(args.board_json)
|
||||
errors = validate_board(board)
|
||||
if errors:
|
||||
for error in errors:
|
||||
@@ -245,22 +234,14 @@ def main() -> int:
|
||||
return 1
|
||||
rendered = render(board)
|
||||
if args.check:
|
||||
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
|
||||
existing = args.board_md.read_text() if args.board_md.exists() else ""
|
||||
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)
|
||||
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
|
||||
args.board_md.write_text(rendered)
|
||||
print(f"wrote {args.board_md}")
|
||||
return 0
|
||||
|
||||
|
||||
240
ROADMAP.md
240
ROADMAP.md
@@ -7759,11 +7759,7 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed)
|
||||
795. **`claw skills install /nonexistent` returned `skill_not_found + hint:null` and `claw skills uninstall x` returned `unsupported_skills_action + hint:null`** — dogfooded 2026-05-27 on `491f179a`. Both error kinds were missing from `fallback_hint_for_error_kind` table, so even though classify returned a typed kind, the hint field was always null. Fix: added `"skill_not_found"` → hint suggesting `claw skills list` / `claw skills install`; added `"unsupported_skills_action"` → hint listing supported actions. Integration test `skills_install_not_found_and_unsupported_action_have_hints_795` covers both paths. 57 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori skills lifecycle probe on `491f179a`, 2026-05-27.
|
||||
|
||||
796. **`claw agents show <name> <extra>` and `claw skills show <name> <extra>` returned confusing `agent_not_found`/`skill_not_found` for the concatenated "name extra" string** — dogfooded 2026-05-27 on `18b4cee5`. `join_optional_args` passes all tokens as a space-joined string; both `show` handlers called `split_once(' ')` to extract the name but did not check if the remainder (after the first split) contained additional tokens. Extra positional args (including `--flags`) became part of the "name", silently mangling the lookup. Fix: added second `split_once(' ')` on the extracted name; if the result has two parts, return `unexpected_extra_args` with a usage hint. Valid single-name lookups are unaffected. Two new integration tests `agents_show_extra_positional_arg_returns_unexpected_extra_796`, `skills_show_extra_positional_arg_returns_unexpected_extra_796`. 59 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori agents/skills show extra-arg probe on `18b4cee5`, 2026-05-27.
|
||||
797. **DONE — Installed `claw version --output-format json` reports `git_sha:null` / `Git SHA unknown`, so dogfood cannot tie the binary under test to a source revision** — dogfooded 2026-05-27 from `#clawcode-building-in-public` using an installed binary in a clean `ultraworkers/claw-code` checkout. The gap was that version/status/doctor did not provide a structured executable-vs-workspace provenance object when build metadata was missing or stale. [SCOPE: claw-code]
|
||||
|
||||
**Fix applied.** `version --output-format json` now includes a `binary_provenance` object with `status:"known"|"unknown"`, build git SHA, target, build date, executable path, workspace HEAD SHA, `workspace_match`, and a structured hint when provenance is missing or mismatched. `status --output-format json` exposes the same object, and `doctor --output-format json` includes it in the `system` check so dogfood reports can distinguish current-source failures from stale or unknown binary lineage.
|
||||
|
||||
**Verification.** `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli version_status_doctor_include_binary_provenance_797 -- --nocapture`; `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli version_emits_json_when_requested -- --nocapture`; `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli doctor_and_resume_status_emit_json_when_requested -- --nocapture`; `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli --test output_format_contract -- --nocapture`; direct probes `cargo run --manifest-path rust/Cargo.toml -q -p rusty-claude-cli -- --output-format json version` and `cargo run --manifest-path rust/Cargo.toml -q -p rusty-claude-cli -- --output-format json status`; `cargo build --manifest-path rust/Cargo.toml --workspace --locked`.
|
||||
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]
|
||||
|
||||
@@ -7778,237 +7774,3 @@ 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. **DONE — `claw models` / `claw model` with `--output-format json` hang with zero stdout instead of returning bounded model discovery/help JSON or a typed unsupported response** — dogfooded 2026-05-27 on `ae6a207` while checking docs/usage model-alias surface after PR #3162 opened. Both `cargo run -q -p rusty-claude-cli -- models --output-format json` and the actual rebuilt `./rust/target/debug/claw models --output-format json` timed out under an 8s outer timeout with stdout `0`; stderr only contained config deprecation warnings. The same silent timeout reproduced for `models help --output-format json`, `model --output-format json`, and `model help --output-format json`. **Required fix shape:** (a) make `model(s)` help/list/discovery commands return bounded stdout JSON without entering prompt/provider/auth paths; (b) if the command is unsupported, return a standard typed JSON error envelope with `error_kind`, non-null `hint`, and `message`; (c) ensure docs model-alias tables and CLI model discovery surfaces do not diverge; (d) add regression coverage for `models --output-format json`, `models help --output-format json`, `model --output-format json`, and `model help --output-format json` proving they do not hang or emit zero-byte stdout. **Why this matters:** model selection is a setup/control-plane surface. If the natural model discovery commands hang silently, claws cannot verify aliases like `qwen-max` / `qwen-plus`, distinguish unsupported command spelling from provider startup, or safely guide users during first-run model setup. Source: gaebal-gajae 13:30/14:00 dogfood probe; GitHub issue creation was blocked by API rate limit, so the finding was recorded directly in ROADMAP.
|
||||
|
||||
**Fix applied.** `model` and `models` now route to a local `CliAction::Models` surface. Bare `models --output-format json` emits bounded local model metadata (default model, built-in aliases, optional configured model) without provider startup, while `model help --output-format json` routes through the structured local help envelope.
|
||||
|
||||
**Verification.** Regression test `models_json_and_model_help_json_are_local_807` asserts bounded exit, parseable stdout JSON, empty stderr, no `missing_credentials`, and `requires_provider_request:false` for the models list envelope.
|
||||
808. **DONE — Control-plane commands `claw config`, `claw settings`, `claw status`, and `claw doctor` with `--output-format json` hang with zero stdout instead of returning bounded JSON/help or a typed unsupported envelope** — dogfooded 2026-05-27 on `86f45a1` after ROADMAP #807 landed. Each of `./rust/target/debug/claw config --output-format json`, `config help --output-format json`, `settings --output-format json`, `settings help --output-format json`, `status --output-format json`, and `doctor --output-format json` timed out under an 8s outer timeout with stdout `0`; stderr only contained the local deprecated `enabledPlugins` settings warning. **Required fix shape:** keep non-interactive control-plane/info commands out of prompt/provider startup paths; return bounded JSON stdout for supported status/config/help surfaces, or a standard typed JSON error envelope with `error_kind`, non-null `hint`, and `message` for unsupported spellings; add timeout/nonzero-stdout regression coverage for the six repro commands. **Why this matters:** claws and users need first-run diagnostics/config/status surfaces that are safe to call from scripts. Silent hangs make setup triage indistinguishable from provider startup, auth, or model discovery failures. Source: gaebal-gajae 17:00 dogfood probe; rechecked 17:30 after `cargo build --manifest-path rust/Cargo.toml -p rusty-claude-cli` produced `claw --version` Git SHA `23a7de6`, and the same timeout reproduced for current HOME and a clean `HOME=/tmp/claw-clean-home-1730` (clean HOME produced rc 124, stdout 0, stderr 0 for `config`, `status`, and `doctor`). [SCOPE: claw-code]
|
||||
|
||||
**Fix applied.** `settings` now routes locally: bare `settings --output-format json` reuses the config JSON envelope for the synthetic `settings` section, and `settings help --output-format json` returns a structured local help envelope. Existing `config`, `status`, and `doctor` JSON routes remain local.
|
||||
|
||||
**Verification.** Regression test `settings_json_and_help_json_are_local_808` asserts bounded exit, parseable stdout JSON, empty stderr, no `missing_credentials`, `section:"settings"` for bare settings, and structured help for `settings help --output-format json`.
|
||||
809. **DONE — Top-level help/version/MCP/plugin JSON spellings hang with zero stdout in trailing `--output-format json` form instead of returning bounded JSON/help or typed unsupported envelopes** — dogfooded 2026-05-27 on rebuilt main `db81598` (`cargo build --manifest-path rust/Cargo.toml -p rusty-claude-cli`; `claw --version` Git SHA `db81598`). `help --output-format json`, `version --output-format json`, `mcp --output-format json`, `mcp help --output-format json`, `plugins --output-format json`, and `plugins help --output-format json` each timed out under an 8s outer timeout with stdout `0`; stderr only contained the local deprecated `enabledPlugins` settings warning. The current parser routes these as bounded local surfaces.
|
||||
|
||||
**Fix applied.** `help`, `version`, `mcp`, and `plugins` now resolve to local `CliAction` paths with parsed `CliOutputFormat::Json`; `parse_local_help_action()` maps `mcp` and `plugins` help topics directly to local JSON help envelopes.
|
||||
|
||||
**Verification.** Static evidence in `rust/crates/rusty-claude-cli/src/main.rs`: `wants_help`/`wants_version` preserve `CliOutputFormat::Json`, `parse_local_help_action()` maps `mcp` and `plugins` to local help topics, and match arms route `mcp`/`plugins` to local handlers before prompt/provider startup.
|
||||
810. **DONE — TTY JSON success for `config`/`plugins --output-format json` contaminates stdout with deprecated-settings warnings before the JSON object** — dogfooded 2026-05-27 on rebuilt main `db81598` after #809. Under pseudo-TTY (`script -q -c "./rust/target/debug/claw config --output-format json"` and `plugins --output-format json`), the commands return rc `0` and bounded JSON, but stdout begins with `warning: /home/bellman/.claw/settings.json: field "enabledPlugins" is deprecated ...` before the JSON object (`first_json_index=121`). Parsing succeeds only after manually stripping the warning/prefix; raw stdout is not valid JSON. **Required fix shape:** in JSON mode, keep diagnostics/warnings on stderr or include structured warning fields inside the JSON envelope, but never prepend human warnings to stdout; add regression coverage that raw stdout from JSON commands parses from byte 0 under TTY and non-TTY modes. **Why this matters:** even when the TTY path avoids the hang from #807/#808/#809, claws and scripts still cannot safely `json.loads(stdout)` if configuration warnings are mixed into stdout. Source: gaebal-gajae 20:00 pseudo-TTY dogfood probe. [SCOPE: claw-code]
|
||||
|
||||
**Fix applied.** Existing global JSON-mode settings warning suppression now prevents deprecated `enabledPlugins` prose from prefixing JSON stdout, and the regression matrix asserts stdout starts with `{` at byte 0 for representative local JSON surfaces under an isolated deprecated settings fixture.
|
||||
|
||||
**Verification.** `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli global_json_surfaces_suppress_config_deprecation_stderr_810_821_824 -- --nocapture`.
|
||||
811. **DONE — Previously typed JSON error/list surfaces hang in plain non-TTY trailing `--output-format json` form instead of emitting their JSON envelopes** — dogfooded 2026-05-27 on rebuilt main `b0e94c9` after #810. In plain non-TTY automation, `agents list --bogus --output-format json`, `skills show does-not-exist --output-format json`, `plugins show does-not-exist --output-format json`, `diff --output-format json`, `sessions show does-not-exist --output-format json`, and `resume bogus --output-format json` each timed out under an 8s outer timeout with stdout `0`; stderr only contained the local deprecated `enabledPlugins` settings warning. Current code parses trailing JSON mode before local dispatch and routes JSON abort envelopes to stdout.
|
||||
|
||||
**Fix applied.** Trailing `--output-format json` is parsed globally before local command matching, so inventory/error surfaces keep their typed local JSON envelopes instead of falling through to runtime/provider startup. The top-level JSON abort handler routes structured errors to stdout.
|
||||
|
||||
**Verification.** Static evidence in `parse_args()` shows global `--output-format` parsing before local command matching; focused tests cover representative affected surfaces including `agents_list_flag_shaped_filter_returns_unknown_option_792`, `plugins_list_flag_shaped_filter_returns_cli_parse_on_stdout_793_817`, `diff_non_git_dir_has_error_kind_and_hint_801`, and resume/export abort-envelope checks around #819/#820/#823.
|
||||
|
||||
|
||||
812. **DONE — `claw --output-format json doctor --help` must stay a local help fast path and never fall through into runtime/provider startup** — dogfooded 2026-05-28 04:01 UTC after #701 worktree drift. The reported repro was `cargo run -q --bin claw -- --output-format json doctor --help`, which did not produce local help promptly and had to be killed, while the positive control `cargo run -q --bin claw -- --output-format json --help` emitted valid JSON help. Fresh bounded repro on branch `fix/doctor-help-json-local` did not reproduce the hang on current code (`timeout 5s cargo run -q --bin claw -- --output-format json doctor --help` exited 0 with a `kind:"help"`/`status:"ok"` doctor help envelope), and current regression coverage preserves this fast path.
|
||||
|
||||
**Pinpoint.** The guarded path is `rust/crates/rusty-claude-cli/src/main.rs`: global `--output-format json` is parsed before `rest`, `parse_local_help_action()` maps `doctor --help` to `CliAction::HelpTopic { topic: Doctor }`, and `print_help_topic()` must return without calling `run_doctor()`, `LiveCli`, provider setup, session resume, or runtime startup. The previous risk class is help fallthrough: treating `doctor --help` as prompt text or as `doctor` diagnostics would either hit provider/session startup or run checks instead of local help.
|
||||
|
||||
**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. **DONE — Dogfood probe shell-string loops can fabricate CLI argv failures for JSON help surfaces** — dogfooded 2026-05-28 after #3185 merged. A verification loop used a single shell string variable (`cmd="--output-format json doctor --help"`; then `cargo run -q --bin claw -- $cmd | python3 -c 'json.load(...)'`). The resulting channel transcript showed `unknown option: --output-format json doctor --help` and Python JSON parse stack noise, even though explicit argv invocations on fresh `main` all returned valid JSON: `cargo run -q --bin claw -- --output-format json doctor --help`, `cargo run -q --bin claw -- doctor --help --output-format json`, and `cargo run -q --bin claw -- help doctor --output-format json`. This is a dogfood-harness test-brittleness / event-log opacity gap, not a product parser regression. The log made a probe-construction mistake look like a claw-code failure.
|
||||
|
||||
**Fix applied.** Added `scripts/dogfood-probe.py`, an argv-safe helper that accepts a target executable plus arguments after `--`, invokes `subprocess.run` without shell interpolation, records the exact argv vector, captures rc/stdout/stderr as separate fields, and labels `timeout`, `probe_error`, and `product_error` separately. Its optional `--stdout-json-byte0` assertion requires stdout to be parseable JSON starting at byte 0, so JSON parser stack noise is replaced by a structured probe result.
|
||||
|
||||
**Verification.** `python3 -m unittest tests.test_roadmap_helpers.RoadmapHelperTests.test_dogfood_probe_runs_explicit_argv_and_separates_channels tests.test_roadmap_helpers.RoadmapHelperTests.test_dogfood_probe_labels_timeout_separately_from_product_error tests.test_roadmap_helpers.RoadmapHelperTests.test_dogfood_probe_labels_probe_construction_failure tests.test_roadmap_helpers.RoadmapHelperTests.test_dogfood_probe_labels_stdout_json_prefix_failure_as_product_error` passes. The fixture tests cover explicit argv preservation for `--output-format json doctor --help`, separated stdout/stderr capture, timeout classification, construction-error classification, and byte-0 JSON product-error classification. [SCOPE: claw-code dogfood harness]
|
||||
|
||||
814. **DONE — Plain non-TTY trailing `--output-format json` still times out for inventory/error surfaces after #3186** — dogfooded 2026-05-28 07:00 on fresh `main` `0e6d48d9d` after #3186 merged. Using explicit argv probes with separated stdout/stderr (per #813) reproduced the older #811 class on current main: `cargo run -q --bin claw -- agents list --bogus --output-format json`, `skills show does-not-exist --output-format json`, and `plugins show does-not-exist --output-format json` each hit the 5s timeout (`rc=124`) with `stdout` length 0; stderr contained only compile warnings plus the local deprecated `enabledPlugins` settings warning. Follow-up evidence showed the product path fixed upstream and current tests preserve local JSON error routing.
|
||||
|
||||
**Required fix shape.** Parse trailing `--output-format json` for local inventory/error commands before any REPL/provider startup in plain non-TTY mode, matching the already-working leading global form where applicable. Add timeout regression coverage for at least `agents list --bogus --output-format json`, `skills show does-not-exist --output-format json`, and `plugins show does-not-exist --output-format json` asserting nonzero stdout with a single parseable JSON envelope containing `status:"error"`, `error_kind`, and non-null `hint`. Keep deprecation/config warnings out of stdout in JSON mode.
|
||||
|
||||
**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.
|
||||
|
||||
**Fix applied.** Current trailing JSON-mode routing reaches local inventory handlers for these argv-safe probes; handled JSON errors emit parseable stdout envelopes and do not require provider credentials/session startup.
|
||||
|
||||
**Verification.** Existing follow-up probe evidence records `agents list --bogus --output-format json`, `skills show does-not-exist --output-format json`, and `plugins show does-not-exist --output-format json` returning parseable JSON envelopes; current contract tests additionally cover agents/plugin local parse/error envelopes with stdout JSON.
|
||||
|
||||
815. **DONE — `claw --output-format json config` reports the same deprecated-settings warning twice: once structurally in `warnings[]` and once as prose on stderr** — dogfooded 2026-05-28 08:00 on current `main` after #3188. `timeout 5s cargo run -q --bin claw -- --output-format json config >out 2>err` exits 0 with parseable stdout JSON (`kind:"config", action:"list", status:"ok"`) and `warnings.length == 1`, but stderr still contains the same `enabledPlugins is deprecated` warning once. Current config JSON keeps that diagnostic structured without duplicating it on stderr.
|
||||
|
||||
**Required fix shape.** In JSON mode for config/list surfaces that already include `warnings[]`, suppress eager prose emission of the same config warning on stderr or mark it as already collected. Text mode should keep the human stderr warning. Add regression coverage asserting `claw --output-format json config` returns exactly one structured warning and zero duplicate `enabledPlugins` prose lines on stderr.
|
||||
|
||||
**Acceptance.** With a deprecated `enabledPlugins` key present, `claw --output-format json config` exits 0, stdout parses from byte 0 and includes `warnings[]`, and stderr has no duplicate deprecation warning for the same file/key. [SCOPE: claw-code]
|
||||
|
||||
**Fix applied.** JSON-mode config rendering collects deprecated settings diagnostics into `warnings[]` and suppresses the duplicate prose `enabledPlugins` warning on stderr; text mode preserves the human stderr warning.
|
||||
|
||||
**Verification.** `config_json_reports_deprecations_structurally_without_stderr_duplicate_815` asserts a deprecated `enabledPlugins` fixture appears in JSON `warnings[]`, does not appear on stderr for `--output-format json config`, and still appears on stderr for text `config`.
|
||||
|
||||
816. **DONE — JSON-mode local/list surfaces still leak deprecated config prose warnings on stderr outside `config`** — dogfooded 2026-05-28 09:30 on `main` `89e7f415a` after #3190. `./target/debug/claw --output-format json config` was fixed, but sibling JSON surfaces still emitted the same app-level config warning to stderr when `~/.claw/settings.json` contained deprecated `enabledPlugins`: `plugins list`, `mcp list`, and `doctor`. Current global JSON-mode suppression covers these local/list surfaces.
|
||||
|
||||
**Required fix shape.** Treat JSON output mode as a global app-level diagnostic routing contract: local/list/status surfaces that successfully return structured JSON should not write config deprecation prose to stderr. Either collect those warnings into each relevant JSON envelope where a warnings field exists, or suppress config-warning emission during JSON-mode preloading/default resolution for surfaces that cannot represent warnings yet. Preserve human stderr warnings in text mode.
|
||||
|
||||
**Acceptance.** With deprecated `enabledPlugins` present, `claw --output-format json plugins list`, `claw --output-format json mcp list`, and `claw --output-format json doctor` exit 0, stdout parses from byte 0, and stderr contains zero `enabledPlugins is deprecated` app-level warning lines. Text mode still prints the warning. [SCOPE: claw-code]
|
||||
|
||||
**Fix applied.** JSON-mode config-warning suppression is applied globally before local JSON surfaces load settings, covering sibling list/status/diagnostic commands while preserving text-mode stderr warnings.
|
||||
|
||||
**Verification.** `global_json_surfaces_suppress_config_deprecation_stderr_810_821_824` covers `plugins list`, `mcp list`, `doctor`, and additional JSON surfaces under a deprecated `enabledPlugins` fixture with empty stderr; `local_text_surface_preserves_config_deprecation_stderr_816` verifies text mode still emits the warning.
|
||||
|
||||
817. **DONE — `claw --output-format json plugins list --` writes its JSON error envelope to stderr while sibling local inventory commands use stdout** — dogfooded 2026-05-28 12:30 on `main` `9494e3c26`. Trailing bare `--` is a useful parser edge because automation sometimes injects delimiter sentinels. `plugins list --` returned rc 1, stdout empty, and wrote the JSON error envelope to stderr. Current plugin list parse-error routing matches sibling JSON inventory/local surfaces.
|
||||
|
||||
**Required fix shape.** Align `plugins list` parse-error routing with the other JSON inventory/local surfaces: in JSON mode, print the structured CLI error envelope to stdout and keep stderr empty for this handled parse error. Preserve text-mode stderr behavior. Add regression coverage for `claw --output-format json plugins list --` asserting rc 1, stdout parseable JSON with `error_kind:"cli_parse"`, and empty stderr.
|
||||
|
||||
**Acceptance.** `claw --output-format json plugins list --` exits 1, stdout parses from byte 0 as the existing JSON error envelope, stderr is empty, and text mode still reports the parse error to stderr. [SCOPE: claw-code]
|
||||
|
||||
**Fix applied.** The `plugins list` flag/filter guard emits handled JSON parse errors directly to stdout in JSON mode while keeping text-mode parse errors on stderr.
|
||||
|
||||
**Verification.** `plugins_list_trailing_dash_json_error_uses_stdout_817`, `plugins_list_trailing_dash_text_error_stays_on_stderr_817`, and `plugins_list_flag_shaped_filter_returns_cli_parse_on_stdout_793_817` cover rc 1, stdout JSON with `error_kind:"cli_parse"`, empty stderr in JSON mode, and preserved text stderr behavior.
|
||||
|
||||
818. **DONE — `AGENTS.md` and `.claude/CLAUDE.md` silently omitted from instruction file cascade** — dogfooded 2026-05-29 08:00. When a repo contains `AGENTS.md` (OpenAI Codex / multi-agent convention) or `.claude/CLAUDE.md` (scoped Claude Code convention), claw-code does not load either file as part of the instruction/context cascade on startup. Users following either convention discover this only by noticing their persona/context instructions have no effect — no warning, no missing-file diagnostic, no documentation note. This is a friction gap for any team migrating to or simultaneously using claw-code alongside Claude Code or Codex workflows, since the two most common non-CLAUDE.md instruction files are silently ignored.
|
||||
|
||||
**Required fix shape.** Add `AGENTS.md` (project root) and `.claude/CLAUDE.md` (`.claude/` subdirectory) to the instruction file cascade that already loads `CLAUDE.md`. Apply the same merge-and-precedence semantics as existing instruction files. Log a debug trace (not stderr noise) when either file is loaded. Add test coverage: a fixture repo with `AGENTS.md` only, `.claude/CLAUDE.md` only, and both present alongside `CLAUDE.md` should each have the relevant content visible in the resolved instruction context.
|
||||
|
||||
**Acceptance.** `claw` launched in a repo containing `AGENTS.md` or `.claude/CLAUDE.md` loads those files into the instruction context. No warning emitted for absent optional files. Existing `CLAUDE.md`-only repos unaffected. PR #3195. [SCOPE: claw-code]
|
||||
|
||||
**Fix applied.** The instruction cascade now loads `AGENTS.md` and `.claude/CLAUDE.md` from the same ancestor walk that already loads `CLAUDE.md`, `CLAUDE.local.md`, `.claw/CLAUDE.md`, and `.claw/instructions.md`, preserving the existing merge/dedupe semantics and avoiding warnings for absent optional files.
|
||||
|
||||
**Verification.** `cargo test --manifest-path rust/Cargo.toml -p runtime discovers_agents_markdown_instruction_file -- --nocapture`; `cargo test --manifest-path rust/Cargo.toml -p runtime discovers_scoped_dot_claude_claude_markdown_instruction_file -- --nocapture`; `cargo test --manifest-path rust/Cargo.toml -p runtime discovers_claude_agents_and_dot_claude_instruction_files_together -- --nocapture`.
|
||||
|
||||
819. **DONE — `claw --output-format json export --session <missing>` writes JSON error envelope to stderr, stdout empty** — dogfooded 2026-05-29 09:30 on `main` `37a9a543`. `claw --output-format json export --session does-not-exist` exits rc=1 with stdout length 0 and the full JSON error envelope on stderr: `{"action":"abort","error":"session not found: does-not-exist","error_kind":"session_not_found",...}`. This is the same channel-routing inconsistency class as #817 (plugins list trailing-dash, fixed in #3194): handled errors in JSON mode should go to stdout, not stderr, so machine consumers can parse the envelope from stdout byte 0 regardless of which surface triggered the error.
|
||||
|
||||
**Required fix shape.** Align `export --session <missing>` error routing with the inventory surfaces fixed in #817: in JSON mode, write the `session_not_found` error envelope to stdout (rc=1) and keep stderr empty. Preserve text-mode behavior (stderr message). Add regression coverage asserting rc=1, stdout parseable JSON with `error_kind:"session_not_found"`, and empty stderr.
|
||||
|
||||
**Acceptance.** `claw --output-format json export --session does-not-exist` exits 1, stdout contains the JSON error envelope from byte 0, stderr is empty. Text mode still prints the error to stderr. [SCOPE: claw-code]
|
||||
|
||||
**Fix applied.** The JSON abort handler now emits export/session-not-found envelopes on stdout in JSON mode while preserving text-mode stderr behavior. The explicit missing-session regression asserts rc 1, `error_kind:"session_not_found"`, abort envelope, and empty stderr.
|
||||
|
||||
**Verification.** `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli export_missing_session_json_error_uses_stdout_819 -- --nocapture`.
|
||||
|
||||
820. **DONE — `interactive_only` error class always routes JSON envelope to stderr (stdout empty)** — dogfooded 2026-05-29 10:00 on `main` `efe59c22`. All `interactive_only` errors shared the same routing gap as #819: JSON envelopes were well-formed but written to stderr. Current JSON abort handling routes `interactive_only` envelopes to stdout.
|
||||
|
||||
**Required fix shape.** In the top-level error handler (or the `interactive_only` classifier arm in `main.rs`), detect JSON output mode and write the structured error envelope to stdout (rc=1) instead of stderr. Scope the fix to the `interactive_only` error_kind so all affected surfaces are repaired in one pass. Add regression coverage for at least `claw --output-format json session list` asserting rc=1, stdout parseable JSON with `error_kind:"interactive_only"`, stderr empty.
|
||||
|
||||
**Acceptance.** All `claw --output-format json session <subcommand>` invocations exit 1 with the JSON envelope on stdout and empty stderr. Text mode continues to print the error to stderr. [SCOPE: claw-code]
|
||||
|
||||
**Fix applied.** The JSON abort handler now classifies `interactive_only` and prints the structured envelope to stdout in JSON mode; text-mode errors still use stderr.
|
||||
|
||||
**Verification.** Session/abort contract assertions in `output_format_contract.rs` around #819/#820/#823 require JSON-mode interactive-only failures to provide a stdout JSON envelope and no JSON envelope on stderr.
|
||||
|
||||
821. **DONE — `status`, `sandbox`, and `system-prompt` in JSON mode still emit config deprecation warning to stderr** — dogfooded 2026-05-29 10:30 on `main` `42aff269`. After #816 fixed config deprecation stderr leakage for `plugins list`, `mcp list`, `doctor`, and `config`, three JSON-mode surfaces continue to emit the `enabledPlugins is deprecated` prose warning to stderr: `claw --output-format json status` (122 bytes stderr), `claw --output-format json sandbox` (122 bytes stderr), `claw --output-format json system-prompt` (122 bytes stderr). These surfaces return well-formed JSON on stdout (rc=0) but leak the config warning to stderr, leaving machine consumers with mixed-channel output. `version`, `acp`, `agents`, `skills`, `mcp`, `plugins`, and `doctor` all have clean stderr after #816.
|
||||
|
||||
**Required fix shape.** Extend the JSON-mode config-warning suppression applied in #816 to cover `status`, `sandbox`, and `system-prompt`. The fix should apply globally: any JSON-mode surface that completes successfully should not emit config deprecation prose to stderr. Text mode should keep the human stderr warning.
|
||||
|
||||
**Acceptance.** With deprecated `enabledPlugins` in `~/.claw/settings.json`, `claw --output-format json status`, `claw --output-format json sandbox`, and `claw --output-format json system-prompt` each exit 0, stdout parses from byte 0, and stderr is empty. Text mode still prints the deprecation warning. [SCOPE: claw-code]
|
||||
|
||||
**Follow-up (2026-05-29 12:00, `main` `3dbb35c3`).** Broader sweep confirms additional surfaces with the same 122-byte stderr leak in JSON mode: `--resume latest /config` (all subforms: bare, `env`, `hooks`, `model`, `plugins`) and `--resume latest /providers` (doctor alias). The fix must apply to all config-loading paths, not just the three originally documented surfaces. The suppression guard should fire at the settings-load level so any JSON-mode invocation benefits without per-surface patching.
|
||||
|
||||
**Fix applied.** The warning-suppression regression matrix now covers `status`, `sandbox`, and `system-prompt` with deprecated `enabledPlugins` settings, asserting successful JSON, stdout JSON from byte 0, and empty stderr while preserving the existing text-mode warning assertion.
|
||||
|
||||
**Verification.** `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli global_json_surfaces_suppress_config_deprecation_stderr_810_821_824 -- --nocapture`; text-mode preservation remains covered by `local_text_surface_preserves_config_deprecation_stderr_816`.
|
||||
|
||||
822. **DONE — Unknown top-level subcommand falls through to REPL/provider startup instead of returning a `command_not_found` error** — dogfooded 2026-05-29 11:00 on `main` `69b59079`. `claw --output-format json foobar` returned `missing_credentials` after prompt/provider fallthrough instead of a structured `command_not_found`. Current command-shaped unknown tokens are rejected before provider startup.
|
||||
|
||||
**Required fix shape.** Before falling through to the REPL/prompt path, check whether the first positional arg matches any known subcommand. If not, return a typed error: `{"error_kind":"command_not_found","message":"unknown command: foobar","hint":"Run `claw --help` for available commands.","status":"error"}` on stdout (JSON mode, rc=1) or stderr (text mode). This mirrors the behavior of `--bogus-flag` (which correctly returns `cli_parse`) but for unknown positional commands.
|
||||
|
||||
**Acceptance.** `claw --output-format json foobar` exits 1, stdout contains JSON with `error_kind:"command_not_found"`, stderr empty. Text mode prints the error to stderr. No provider startup attempted. [SCOPE: claw-code]
|
||||
|
||||
**Fix applied.** Unknown command-shaped top-level tokens now trip the pre-provider `command_not_found:` guard, and the classifier maps that prefix to `error_kind:"command_not_found"` with JSON-mode output on stdout.
|
||||
|
||||
**Verification.** `unknown_subcommand_json_emits_command_not_found`, `unknown_subcommand_text_emits_command_not_found_on_stderr`, `unknown_subcommand_typo_with_suggestions_json_emits_command_not_found`, and updated `unknown_subcommand_returns_typed_kind_785` cover JSON stdout, text stderr, suggestion hints, and no `missing_credentials` fallthrough.
|
||||
|
||||
823. **DONE — `claw --output-format json prompt` with missing/empty prompt text routes JSON errors to stdout with empty stderr** — dogfooded 2026-05-29 11:30 on `main` `3a76c4f4`. `claw --output-format json prompt` (no text) and `claw --output-format json prompt ""` (empty string) both exited rc=1, stdout empty, and wrote `{"error_kind":"missing_prompt","action":"abort",...}` to stderr. The envelope was well-formed but channel-inconsistent: JSON mode machine consumers reading stdout for command results got empty stdout and had to check stderr to detect the error. This is the same class as #819 (export session-not-found) and #820 (interactive_only / session subcommands), and the same root cause: the top-level abort handler wrote to stderr regardless of output-format mode.
|
||||
|
||||
**Required fix shape.** In JSON mode, route `missing_prompt` abort errors to stdout (rc=1) and keep stderr empty. This is the same fix pattern as #817/#819/#820: detect JSON output mode in the abort handler and redirect the structured envelope to stdout. Add regression coverage for `claw --output-format json prompt` (no arg) and `claw --output-format json prompt ""` asserting rc=1, stdout parseable JSON with `error_kind:"missing_prompt"`, stderr empty.
|
||||
|
||||
**Acceptance.** Both invocations exit 1 with JSON envelope on stdout and empty stderr. Text mode still prints to stderr. [SCOPE: claw-code]
|
||||
|
||||
**Fix applied.** The top-level JSON abort handler now emits structured error envelopes to stdout, so both missing `prompt` text paths keep the existing `missing_prompt` classification while preserving empty stderr. Regression coverage now asserts `claw --output-format json prompt` and `claw --output-format json prompt ""` exit rc=1, parse stdout JSON with `error_kind:"missing_prompt"`, and leave stderr empty.
|
||||
|
||||
**Verification.** `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli prompt_no_arg_json_error_kind_750 -- --nocapture`; `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli prompt_empty_arg_json_stdout_missing_prompt_823 -- --nocapture`.
|
||||
|
||||
824. **DONE — Global settings-load deprecation warning still leaks to stderr in JSON mode for `status`, `sandbox`, `system-prompt`, `skills`, `mcp`, `agents` surfaces** — dogfooded 2026-05-29 13:30 on `main` `b4b1ba10`. After #816 and #821 (doc), the `enabledPlugins is deprecated` config warning still reaches stderr on every JSON-mode surface that loads settings: `claw --output-format json status`, `sandbox`, `system-prompt`, `mcp list`, `skills list`, `agents list` all emit `warning: /path/.claw/settings.json: field "enabledPlugins" is deprecated (line 2)...` to stderr. Root cause: `emit_config_warning_once()` in `runtime/src/config.rs` always uses `eprintln!` with no output-format awareness. The `config` surface avoids the duplicate by collecting warnings into a structured `warnings[]` field, but all other surfaces hit the raw `eprintln!` path.
|
||||
|
||||
**Required fix shape.** Add a global `SUPPRESS_CONFIG_WARNINGS_STDERR: AtomicBool` flag in `config.rs`. Set it to `true` immediately when `--output-format json` is detected in `main.rs` (before any settings load). Gate `emit_config_warning_once` on that flag. Text-mode invocations continue to print to stderr; JSON-mode invocations silently suppress the prose warning (warnings remain available via structured `config` output).
|
||||
|
||||
**Acceptance.** With deprecated `enabledPlugins` in `~/.claw/settings.json`, all JSON-mode surfaces (`status`, `sandbox`, `system-prompt`, `mcp list`, `skills list`, `agents list`, plus all `--resume /config*` forms) exit with empty stderr. Text-mode output is unchanged. [SCOPE: claw-code]
|
||||
|
||||
**Fix applied.** The focused matrix now exercises the global settings-load path for `status`, `sandbox`, `system-prompt`, `mcp list`, `skills list`, `agents list`, and a generated-session resume `/config` invocation under deprecated `enabledPlugins`, requiring empty stderr for every JSON-mode surface.
|
||||
|
||||
**Verification.** `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli global_json_surfaces_suppress_config_deprecation_stderr_810_821_824 -- --nocapture`; `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli local_text_surface_preserves_config_deprecation_stderr_816 -- --nocapture`.
|
||||
|
||||
825. **DONE — Unknown single-word subcommand falls through to provider startup and surfaces `missing_credentials` instead of `command_not_found`** — dogfooded 2026-05-29 14:00 on `main` `de7edd5b`. `claw foobar` (and `claw --output-format json foobar`) hit the `looks_like_subcommand_typo` guard, then fell through to provider startup when no suggestions matched. Current code emits `command_not_found` for this path.
|
||||
|
||||
**Required fix shape.** When `looks_like_subcommand_typo` fires on a single-word positional arg with no close suggestions, emit `command_not_found:` rather than falling through. Add `command_not_found:` prefix classifier to `classify_error_kind`. Result: clean `{"error_kind":"command_not_found",...}` envelope on stdout (JSON mode), error on stderr (text mode), zero provider startup.
|
||||
|
||||
**Acceptance.** `claw --output-format json foobar` exits 1, stdout `error_kind:"command_not_found"`, stderr empty, no Anthropic call. Typo with suggestions (`claw statuz`) also gets `command_not_found` plus `hint` with suggestions. [SCOPE: claw-code]
|
||||
|
||||
**Fix applied.** The `looks_like_subcommand_typo` path emits `command_not_found:` for unknown single-word command-shaped tokens even when there are no close suggestions, and typo suggestions are unified under the same typed error kind.
|
||||
|
||||
**Verification.** `unknown_subcommand_json_emits_command_not_found`, `unknown_subcommand_text_emits_command_not_found_on_stderr`, `unknown_subcommand_typo_with_suggestions_json_emits_command_not_found`, and classifier coverage in `classify_error_kind_returns_correct_discriminants` verify `command_not_found` instead of `missing_credentials` before provider startup.
|
||||
|
||||
826. **DONE — Multi-word unknown subcommand still falls through to `missing_credentials`** — dogfooded 2026-05-29 14:38 on `main` `70d64be0`. After #825 fixed single-word unknown subcommands, multi-word invocations (`claw foobar baz`) are still undetected: the `looks_like_subcommand_typo` guard only fires when `rest.len() == 1`. When there are two or more positional args, the first word is treated as a prompt and all args join into a prompt string → provider startup → `missing_credentials`. Same misleading-error class as #825 but for multi-word cases.
|
||||
|
||||
**Required fix shape.** Extend the command-not-found guard to also fire when `rest.len() > 1` and `rest[0]` passes `looks_like_subcommand_typo` but does not match any known subcommand. The multi-arg case should also emit `command_not_found` — with a note that if literal multi-word prompt was intended, use `claw prompt <text>` or `echo 'text' | claw`.
|
||||
|
||||
**Acceptance.** `claw --output-format json foobar baz` exits 1, stdout `error_kind:"command_not_found"`, stderr empty, no provider startup. `claw "write a haiku"` (valid prompt passthrough) is unaffected. [SCOPE: claw-code]
|
||||
|
||||
**Fix applied.** JSON-mode command-shaped unknown subcommands now emit `command_not_found:` before provider startup even when additional tokens follow. Text-mode multi-word prompt shorthand remains available, but JSON automation no longer turns `claw --output-format json foobar baz` into a credential-gated prompt request.
|
||||
|
||||
**Verification.** `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli multi_word_unknown_subcommand_json_emits_command_not_found_826 -- --nocapture`; `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli unknown_subcommand_json_emits_command_not_found -- --nocapture`; direct probe `cargo run --manifest-path rust/Cargo.toml -q -p rusty-claude-cli -- --output-format json foobar baz`.
|
||||
|
||||
827. **DONE — `--resume <session> /unknown-slash-command` emits `error_kind:"unknown"` instead of a typed kind** — dogfooded 2026-05-29 14:58 on `main` `d47b0151`. `claw --resume latest --output-format json /bogus` exits rc=2 with `{"error_kind":"unknown",...}` on stdout (correct channel, correct rc) but the opaque `"unknown"` kind gives machine consumers no way to distinguish "unrecognized slash command" from other error classes. The error has a useful `hint` with suggestions, but the `error_kind` field is `"unknown"` across all unrecognized resume slash commands.
|
||||
|
||||
**Required fix shape.** Introduce a typed `error_kind` for unrecognized slash commands (e.g. `unknown_slash_command` or `command_not_found`). Update the JSON emit in the `--resume` unknown-command handler to use the typed kind. Add regression coverage asserting the typed kind.
|
||||
|
||||
**Acceptance.** `claw --resume latest --output-format json /bogus` exits rc=2, stdout `error_kind:"unknown_slash_command"` (or similar typed constant), stderr empty. [SCOPE: claw-code]
|
||||
|
||||
**Fix applied.** Unknown direct and resumed slash commands now use the classifier-friendly `unknown_slash_command:` prefix, so the JSON resume-command error path emits `error_kind:"unknown_slash_command"` instead of falling back to `unknown`. Direct slash command coverage and the new resumed-session regression both assert stdout JSON and empty stderr.
|
||||
|
||||
**Verification.** `cargo fmt --manifest-path rust/Cargo.toml --all -- --check`; `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli direct_unknown_slash_command_emits_typed_error_kind -- --nocapture`; `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli resume_unknown_slash_command_emits_typed_error_kind_827 -- --nocapture`; direct probe `cargo run --manifest-path rust/Cargo.toml -q -p rusty-claude-cli -- --resume .claw/sessions/491726c4e6bde42d/session-1778832949219-0.jsonl --output-format json /boguscommand`.
|
||||
|
||||
828. **DONE — `/approve` and `/deny` outside REPL emit `unknown_slash_command` instead of `interactive_only`** — dogfooded 2026-05-29 16:05 on `main` `9d05573f`. `claw --output-format json /approve` exited rc=1 with `error_kind:"unknown_slash_command"` — these are valid REPL-only slash commands but are not `SlashCommand` enum variants, so they fell through to `format_unknown_direct_slash_command`. Machine consumers saw the wrong error class.
|
||||
|
||||
**Fix applied.** `SlashCommand::Unknown` arm now special-cases `approve | yes | y | deny | no | n` and emits `interactive_only:` prefix before falling through to `format_unknown_direct_slash_command`. Both `error_kind` and hint are correct.
|
||||
|
||||
**Acceptance.** `claw --output-format json /approve` exits rc=1, stdout `error_kind:"interactive_only"`, stderr empty. [SCOPE: claw-code]
|
||||
|
||||
**Verification.** `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli approve_deny_outside_repl_emits_interactive_only -- --nocapture`; direct probes `cargo run --manifest-path rust/Cargo.toml -q -p rusty-claude-cli -- --output-format json /approve` and `cargo run --manifest-path rust/Cargo.toml -q -p rusty-claude-cli -- --output-format json /deny`.
|
||||
|
||||
829. **DONE — `interactive_only` hint incorrectly suggests `--resume` for commands that are not resume-safe** — dogfooded 2026-05-29 16:40 on `main` `187aebd7`. `claw --output-format json /commit` hint says `"use claw --resume SESSION.jsonl /commit"` but `/commit` is not resume-safe (no `[resume]` marker in `/help`). Same for `/pr`, `/issue`, `/bughunter`, `/ultraplan`. The generic `interactive_only` hint template does not distinguish resume-safe from live-REPL-only commands, so it always suggests `--resume` regardless. Users who follow the hint will get `interactive_only` again.
|
||||
|
||||
**Required fix shape.** The generic `interactive_only:` message formatter (line ~1745 in `main.rs`) currently always appends `or use claw --resume SESSION.jsonl {command_name}`. This should be conditioned on whether the slash command appears in the resume-safe command list. Non-resume-safe interactive commands should only say `Start claw and run it there.`
|
||||
|
||||
**Acceptance.** `claw --output-format json /commit` hint does NOT mention `--resume`. `claw --output-format json /status` (resume-safe) hint still mentions `--resume`. [SCOPE: claw-code]
|
||||
|
||||
**Fix applied.** The direct slash-command guidance now consults the shared `resume_supported` command metadata before adding a `--resume` remediation. Non-resume-safe commands such as `/commit`, `/pr`, `/issue`, `/bughunter`, and `/ultraplan` only point users to the live REPL, while resume-safe commands keep the `--resume SESSION.jsonl` hint.
|
||||
|
||||
**Verification.** `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli non_resume_safe_interactive_only_hint_omits_resume_suggestion -- --nocapture`; `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli resume_safe_interactive_only_hint_includes_resume_suggestion -- --nocapture`; direct probes `cargo run --manifest-path rust/Cargo.toml -q -p rusty-claude-cli -- --output-format json /commit` and `cargo run --manifest-path rust/Cargo.toml -q -p rusty-claude-cli -- --output-format json /status`.
|
||||
|
||||
830. **DONE — `claw mcp show` (missing server name arg) emits `error_kind:"unknown_mcp_action"` instead of `missing_argument`** — dogfooded 2026-05-29 17:00 on `main` `ac5b19de`. `claw --output-format json mcp show` (no server name supplied) exits with `error_kind:"unknown_mcp_action"`. However `show` IS a known MCP action — the error is a missing required argument (server name), not an unknown action. Machine consumers inspecting `error_kind` cannot distinguish "I don't know this action" from "I know this action but a required arg is missing".
|
||||
|
||||
**Required fix shape.** The MCP subcommand parser should detect `show` with no following token and emit `missing_argument: mcp show requires a server name.\nUsage: claw mcp show <server>` with a distinct `error_kind`. Update the classifier arm to return `missing_argument` for this prefix.
|
||||
|
||||
**Acceptance.** `claw --output-format json mcp show` exits rc=1, stdout `error_kind:"missing_argument"`, stderr empty. Hint contains usage example. [SCOPE: claw-code]
|
||||
|
||||
**Fix applied.** `mcp show` without a server name now emits a typed `missing_argument` response instead of reusing `unknown_mcp_action`. The direct JSON path returns `{kind:"mcp", action:"show", status:"error", error_kind:"missing_argument"}` with a usage hint on stdout and an empty stderr stream; the slash-command parser also classifies `/mcp show` as `missing_argument` via the shared error-kind classifier.
|
||||
|
||||
**Verification.** `cargo fmt --manifest-path rust/Cargo.toml --all -- --check`; `cargo test --manifest-path rust/Cargo.toml -p commands mcp -- --nocapture`; `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli mcp_show_missing_server_name_returns_missing_argument_830 -- --nocapture`; `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli classify_error_kind_returns_correct_discriminants -- --nocapture`; direct probe `cargo run --manifest-path rust/Cargo.toml -q -p rusty-claude-cli -- --output-format json mcp show`.
|
||||
|
||||
831. **DONE — Direct resume-safe slash commands route to `interactive_only` instead of local JSON actions** — PR #3205 showed that direct slash invocations such as `claw --output-format json /status`, `/diff`, `/version`, `/doctor`, and `/sandbox` were parsed successfully as resume-safe slash commands, but the direct CLI parser still fell through to generic `interactive_only` guidance instead of dispatching to the same pure-local `CliAction` handlers as the bare subcommands.
|
||||
|
||||
**Required fix shape.** In the direct slash CLI parser, map resume-safe local slash command variants to the corresponding local `CliAction` variants. Preserve non-resume-safe slash command guidance from #829.
|
||||
|
||||
**Acceptance.** `claw --output-format json /version`, `/sandbox`, `/diff`, and `/status` succeed with their expected local JSON `kind` and environment-dependent local `status`, stdout JSON, and empty stderr; non-resume-safe slash commands still emit `interactive_only` without bogus local routing. [SCOPE: claw-code]
|
||||
|
||||
**Fix applied.** `parse_direct_slash_cli_action` now routes `/status`, `/diff`, `/version`, `/doctor`, and `/sandbox` directly to the same local `CliAction` variants as `status`, `diff`, `version`, `doctor`, and `sandbox`. The generic `interactive_only` branch remains the fallback for valid but live-REPL-only slash commands, preserving the #829 non-resume-safe hint behavior.
|
||||
|
||||
**Verification.** `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli direct_resume_safe_slash_commands_route_to_local_json_actions_831 -- --nocapture`.
|
||||
|
||||
832. **DONE — roadmap-next-id helper missing explicit ROADMAP path behavior lacked regression coverage** — follow-up to #725 and PR #3117 after dogfood showed `scripts/roadmap-next-id.sh /tmp/nonexistent-roadmap` already failed correctly but the helper tests did not pin that behavior. The missing-path case is important because docs-only PRs can otherwise regress back to printing a next id for an absent explicit file.
|
||||
|
||||
**Fix applied.** Added `test_roadmap_next_id_fails_when_explicit_roadmap_path_is_missing`, proving an explicit missing ROADMAP path exits nonzero, keeps stdout empty, and reports both `ROADMAP not found` and the requested path on stderr. Added `tests/__init__.py` so `python3 -m unittest tests.test_roadmap_helpers` resolves this repository's tests package consistently.
|
||||
|
||||
**Verification.** `python3 -m unittest tests.test_roadmap_helpers`; `scripts/roadmap-check-ids.sh`; `scripts/roadmap-next-id.sh`.
|
||||
|
||||
19
USAGE.md
19
USAGE.md
@@ -349,8 +349,6 @@ These are the models registered in the built-in alias table with known token lim
|
||||
| `grok-mini` / `grok-3-mini` | `grok-3-mini` | xAI | 64 000 | 131 072 |
|
||||
| `grok-2` | `grok-2` | xAI | — | — |
|
||||
| `kimi` | `kimi-k2.5` | DashScope | 16 384 | 256 000 |
|
||||
| `qwen-max` | `qwen-max` | DashScope | 8 192 | 131 072 |
|
||||
| `qwen-plus` | `qwen-plus` | DashScope | 8 192 | 131 072 |
|
||||
| `gpt-4.1` / `gpt-4.1-mini` / `gpt-4.1-nano` | same | OpenAI-compatible | 32 768 | 1 047 576 |
|
||||
| `gpt-5.4` / `gpt-5.4-mini` / `gpt-5.4-nano` | same | OpenAI-compatible | 128 000 | 1 000 000 / 400 000 |
|
||||
|
||||
@@ -519,23 +517,6 @@ Runtime config is loaded in this order, with later entries overriding earlier on
|
||||
4. `<repo>/.claw/settings.json`
|
||||
5. `<repo>/.claw/settings.local.json`
|
||||
|
||||
## Project instruction rules
|
||||
|
||||
In addition to root instruction files such as `CLAUDE.md`, `AGENTS.md`, `.claw/CLAUDE.md`, `.claude/CLAUDE.md`, and `.claw/instructions.md`, `claw` loads sorted Markdown/text rule files from:
|
||||
|
||||
- `<repo>/.claw/rules/` (`.md`, `.txt`, `.mdc`) for shared project rules.
|
||||
- `<repo>/.claw/rules.local/` for personal local rules; this path is gitignored.
|
||||
|
||||
By default, `claw` also imports detected rules from common AI coding tools such as Cursor (`.cursorrules`, `.cursor/rules/`), GitHub Copilot (`.github/copilot-instructions.md`), Windsurf, Plandex, and Crush. Control this with `rulesImport` in any settings file:
|
||||
|
||||
```json
|
||||
{
|
||||
"rulesImport": "none"
|
||||
}
|
||||
```
|
||||
|
||||
Use `"auto"` (the default) to import every supported framework, `"none"` to load only Claw instruction/rules files, or an array such as `["cursor", "copilot"]` to import selected frameworks.
|
||||
|
||||
## Mock parity harness
|
||||
|
||||
The workspace includes a deterministic Anthropic-compatible mock service and parity harness.
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::path::{Path, PathBuf};
|
||||
use plugins::{PluginError, PluginLoadFailure, PluginManager, PluginSummary};
|
||||
use runtime::{
|
||||
compact_session, CompactionConfig, ConfigLoader, ConfigSource, McpOAuthConfig, McpServerConfig,
|
||||
RuntimeConfig, ScopedMcpServerConfig, Session,
|
||||
ScopedMcpServerConfig, Session,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
@@ -1670,11 +1670,7 @@ fn parse_mcp_command(args: &[&str]) -> Result<SlashCommand, SlashCommandParseErr
|
||||
target: None,
|
||||
}),
|
||||
["list", ..] => Err(usage_error("mcp list", "")),
|
||||
["show"] => Err(command_error(
|
||||
"missing_argument: mcp show requires a server name.",
|
||||
"mcp",
|
||||
"/mcp show <server>",
|
||||
)),
|
||||
["show"] => Err(usage_error("mcp show", "<server>")),
|
||||
["show", target] => Ok(SlashCommand::Mcp {
|
||||
action: Some("show".to_string()),
|
||||
target: Some((*target).to_string()),
|
||||
@@ -2546,14 +2542,6 @@ 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) {
|
||||
@@ -2618,13 +2606,6 @@ 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"))),
|
||||
@@ -2922,12 +2903,12 @@ fn render_mcp_report_for(
|
||||
}
|
||||
}
|
||||
Some(args) if is_help_arg(args) => Ok(render_mcp_usage(None)),
|
||||
Some("show") => Ok(render_mcp_missing_argument_text("show")),
|
||||
Some("show") => Ok(render_mcp_usage(Some("show"))),
|
||||
Some(args) if args.split_whitespace().next() == Some("show") => {
|
||||
let mut parts = args.split_whitespace();
|
||||
let _ = parts.next();
|
||||
let Some(server_name) = parts.next() else {
|
||||
return Ok(render_mcp_missing_argument_text("show"));
|
||||
return Ok(render_mcp_usage(Some("show")));
|
||||
};
|
||||
if parts.next().is_some() {
|
||||
return Ok(render_mcp_usage(Some(args)));
|
||||
@@ -3006,7 +2987,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 load_runtime_config_without_stderr_warnings(loader) {
|
||||
match loader.load() {
|
||||
Ok(runtime_config) => {
|
||||
let mut value =
|
||||
render_mcp_summary_report_json(cwd, runtime_config.mcp().servers());
|
||||
@@ -3031,18 +3012,18 @@ fn render_mcp_report_json_for(
|
||||
}
|
||||
}
|
||||
Some(args) if is_help_arg(args) => Ok(render_mcp_usage_json(None)),
|
||||
Some("show") => Ok(render_mcp_missing_argument_json("show")),
|
||||
Some("show") => Ok(render_mcp_usage_json(Some("show"))),
|
||||
Some(args) if args.split_whitespace().next() == Some("show") => {
|
||||
let mut parts = args.split_whitespace();
|
||||
let _ = parts.next();
|
||||
let Some(server_name) = parts.next() else {
|
||||
return Ok(render_mcp_missing_argument_json("show"));
|
||||
return Ok(render_mcp_usage_json(Some("show")));
|
||||
};
|
||||
if parts.next().is_some() {
|
||||
return Ok(render_mcp_usage_json(Some(args)));
|
||||
}
|
||||
// #144: same degradation pattern for show action.
|
||||
match load_runtime_config_without_stderr_warnings(loader) {
|
||||
match loader.load() {
|
||||
Ok(runtime_config) => {
|
||||
let mut value = render_mcp_server_report_json(
|
||||
cwd,
|
||||
@@ -4273,44 +4254,6 @@ fn render_mcp_usage(unexpected: Option<&str>) -> String {
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn render_mcp_missing_argument_text(action: &str) -> String {
|
||||
let hint = match action {
|
||||
"show" => "use `claw mcp show <server>` to inspect a server",
|
||||
_ => "provide the required argument for this MCP action",
|
||||
};
|
||||
format!(
|
||||
"MCP\n Error missing argument for '{action}'\n Hint {hint}\n Usage /mcp [list|show <server>|help]"
|
||||
)
|
||||
}
|
||||
|
||||
fn render_mcp_missing_argument_json(action: &str) -> Value {
|
||||
let (message, hint) = match action {
|
||||
"show" => (
|
||||
"mcp show requires a server name",
|
||||
"Usage: claw mcp show <server>",
|
||||
),
|
||||
_ => (
|
||||
"mcp action requires an argument",
|
||||
"Usage: claw mcp [list|show <server>|help]",
|
||||
),
|
||||
};
|
||||
json!({
|
||||
"kind": "mcp",
|
||||
"action": action,
|
||||
"ok": false,
|
||||
"status": "error",
|
||||
"error_kind": "missing_argument",
|
||||
"message": message,
|
||||
"hint": hint,
|
||||
"usage": {
|
||||
"slash_command": "/mcp [list|show <server>|help]",
|
||||
"direct_cli": "claw mcp [list|show <server>|help]",
|
||||
"sources": [".claw/settings.json", ".claw/settings.local.json"],
|
||||
},
|
||||
"unexpected": Value::Null,
|
||||
})
|
||||
}
|
||||
|
||||
fn render_mcp_usage_json(unexpected: Option<&str>) -> Value {
|
||||
// #748: add error_kind when unexpected is set, matching agents/plugins unknown-subcommand shape.
|
||||
let error_kind: Value = if unexpected.is_some() {
|
||||
|
||||
@@ -330,24 +330,20 @@ fn prepare_tokio_command(
|
||||
prepare_sandbox_dirs(cwd);
|
||||
}
|
||||
|
||||
let mut prepared =
|
||||
if let Some(launcher) = build_linux_sandbox_command(command, cwd, sandbox_status) {
|
||||
let mut cmd = TokioCommand::new(launcher.program);
|
||||
cmd.args(launcher.args);
|
||||
cmd.envs(launcher.env);
|
||||
cmd
|
||||
} else {
|
||||
let mut cmd = TokioCommand::new("sh");
|
||||
cmd.arg("-lc").arg(command);
|
||||
if sandbox_status.filesystem_active {
|
||||
cmd.env("HOME", cwd.join(".sandbox-home"));
|
||||
cmd.env("TMPDIR", cwd.join(".sandbox-tmp"));
|
||||
}
|
||||
cmd
|
||||
};
|
||||
if let Some(launcher) = build_linux_sandbox_command(command, cwd, sandbox_status) {
|
||||
let mut prepared = TokioCommand::new(launcher.program);
|
||||
prepared.args(launcher.args);
|
||||
prepared.current_dir(cwd);
|
||||
prepared.envs(launcher.env);
|
||||
return prepared;
|
||||
}
|
||||
|
||||
prepared.current_dir(cwd);
|
||||
prepared.stdin(Stdio::null());
|
||||
let mut prepared = TokioCommand::new("sh");
|
||||
prepared.arg("-lc").arg(command).current_dir(cwd);
|
||||
if sandbox_status.filesystem_active {
|
||||
prepared.env("HOME", cwd.join(".sandbox-home"));
|
||||
prepared.env("TMPDIR", cwd.join(".sandbox-tmp"));
|
||||
}
|
||||
prepared
|
||||
}
|
||||
|
||||
@@ -423,27 +419,6 @@ mod tests {
|
||||
assert_eq!(structured[0]["event"], "test.hung");
|
||||
assert_eq!(structured[0]["data"]["provenance"], "bash.timeout");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prevents_stdin_hangs_by_redirecting_to_null() {
|
||||
let output = execute_bash(BashCommandInput {
|
||||
command: String::from("cat"),
|
||||
timeout: Some(2_000),
|
||||
description: None,
|
||||
run_in_background: Some(false),
|
||||
dangerously_disable_sandbox: Some(true),
|
||||
namespace_restrictions: None,
|
||||
isolate_network: None,
|
||||
filesystem_mode: None,
|
||||
allowed_mounts: None,
|
||||
})
|
||||
.expect("bash command should execute cleanly");
|
||||
|
||||
assert!(
|
||||
!output.interrupted,
|
||||
"Command hung and was cut off by the timeout!"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximum output bytes before truncation (16 KiB, matching upstream).
|
||||
|
||||
@@ -10,22 +10,7 @@ use std::sync::Mutex;
|
||||
static EMITTED_CONFIG_WARNINGS: std::sync::OnceLock<Mutex<HashSet<String>>> =
|
||||
std::sync::OnceLock::new();
|
||||
|
||||
/// When set to `true`, `emit_config_warning_once` silently drops all prose
|
||||
/// deprecation warnings instead of writing them to stderr. Set this flag
|
||||
/// before any settings load when `--output-format json` is active so that
|
||||
/// JSON-mode machine consumers see empty stderr on success. (#824)
|
||||
static SUPPRESS_CONFIG_WARNINGS_STDERR: std::sync::atomic::AtomicBool =
|
||||
std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
/// Call this once at startup when `--output-format json` is active.
|
||||
pub fn suppress_config_warnings_for_json_mode() {
|
||||
SUPPRESS_CONFIG_WARNINGS_STDERR.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn emit_config_warning_once(warning: &str) {
|
||||
if SUPPRESS_CONFIG_WARNINGS_STDERR.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let set = EMITTED_CONFIG_WARNINGS.get_or_init(|| Mutex::new(HashSet::new()));
|
||||
let mut guard = set.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if guard.insert(warning.to_string()) {
|
||||
@@ -95,32 +80,6 @@ pub struct RuntimeFeatureConfig {
|
||||
sandbox: SandboxConfig,
|
||||
provider_fallbacks: ProviderFallbackConfig,
|
||||
trusted_roots: Vec<String>,
|
||||
rules_import: RulesImportConfig,
|
||||
}
|
||||
|
||||
/// Controls which external AI coding framework rules are imported into the system prompt.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub enum RulesImportConfig {
|
||||
/// Import from all supported frameworks when files are detected.
|
||||
#[default]
|
||||
Auto,
|
||||
/// Do not import external framework rules; keep Claw instruction files only.
|
||||
None,
|
||||
/// Import only the named frameworks.
|
||||
List(Vec<String>),
|
||||
}
|
||||
|
||||
impl RulesImportConfig {
|
||||
#[must_use]
|
||||
pub fn should_import(&self, framework: &str) -> bool {
|
||||
match self {
|
||||
Self::Auto => true,
|
||||
Self::None => false,
|
||||
Self::List(frameworks) => frameworks
|
||||
.iter()
|
||||
.any(|candidate| candidate.eq_ignore_ascii_case(framework)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ordered chain of fallback model identifiers used when the primary
|
||||
@@ -379,7 +338,6 @@ impl ConfigLoader {
|
||||
sandbox: parse_optional_sandbox_config(&merged_value)?,
|
||||
provider_fallbacks: parse_optional_provider_fallbacks(&merged_value)?,
|
||||
trusted_roots: parse_optional_trusted_roots(&merged_value)?,
|
||||
rules_import: parse_optional_rules_import(&merged_value)?,
|
||||
};
|
||||
|
||||
Ok(RuntimeConfig {
|
||||
@@ -421,6 +379,12 @@ 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 {
|
||||
@@ -437,7 +401,6 @@ impl ConfigLoader {
|
||||
sandbox: parse_optional_sandbox_config(&merged_value)?,
|
||||
provider_fallbacks: parse_optional_provider_fallbacks(&merged_value)?,
|
||||
trusted_roots: parse_optional_trusted_roots(&merged_value)?,
|
||||
rules_import: parse_optional_rules_import(&merged_value)?,
|
||||
};
|
||||
|
||||
let config = RuntimeConfig {
|
||||
@@ -539,11 +502,6 @@ impl RuntimeConfig {
|
||||
&self.feature_config.trusted_roots
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn rules_import(&self) -> &RulesImportConfig {
|
||||
&self.feature_config.rules_import
|
||||
}
|
||||
|
||||
/// Merge config-level default trusted roots with per-call roots.
|
||||
///
|
||||
/// Config roots are defaults and are kept first; per-call roots extend the
|
||||
@@ -624,11 +582,6 @@ impl RuntimeFeatureConfig {
|
||||
&self.trusted_roots
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn rules_import(&self) -> &RulesImportConfig {
|
||||
&self.rules_import
|
||||
}
|
||||
|
||||
/// Merge this config's default trusted roots with per-call roots.
|
||||
#[must_use]
|
||||
pub fn trusted_roots_with_overrides(&self, per_call_roots: &[String]) -> Vec<String> {
|
||||
@@ -1200,37 +1153,6 @@ fn parse_optional_trusted_roots(root: &JsonValue) -> Result<Vec<String>, ConfigE
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_optional_rules_import(root: &JsonValue) -> Result<RulesImportConfig, ConfigError> {
|
||||
let Some(object) = root.as_object() else {
|
||||
return Ok(RulesImportConfig::default());
|
||||
};
|
||||
let Some(value) = object.get("rulesImport") else {
|
||||
return Ok(RulesImportConfig::default());
|
||||
};
|
||||
|
||||
match value {
|
||||
JsonValue::String(value) if value.eq_ignore_ascii_case("auto") => Ok(RulesImportConfig::Auto),
|
||||
JsonValue::String(value) if value.eq_ignore_ascii_case("none") => Ok(RulesImportConfig::None),
|
||||
JsonValue::String(value) => Err(ConfigError::Parse(format!(
|
||||
"merged settings.rulesImport: expected \"auto\", \"none\", or an array of framework names, got \"{value}\""
|
||||
))),
|
||||
JsonValue::Array(values) => values
|
||||
.iter()
|
||||
.map(|item| {
|
||||
item.as_str().map(str::to_string).ok_or_else(|| {
|
||||
ConfigError::Parse(
|
||||
"merged settings.rulesImport: array entries must be strings".to_string(),
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map(RulesImportConfig::List),
|
||||
_ => Err(ConfigError::Parse(
|
||||
"merged settings.rulesImport: expected \"auto\", \"none\", or an array of framework names".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_filesystem_mode_label(value: &str) -> Result<FilesystemIsolationMode, ConfigError> {
|
||||
match value {
|
||||
"off" => Ok(FilesystemIsolationMode::Off),
|
||||
@@ -1793,72 +1715,6 @@ mod tests {
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_rules_import_config() {
|
||||
let root = temp_dir();
|
||||
let cwd = root.join("project");
|
||||
let home = root.join("home").join(".claw");
|
||||
fs::create_dir_all(&home).expect("home config dir");
|
||||
fs::create_dir_all(&cwd).expect("project dir");
|
||||
fs::write(
|
||||
home.join("settings.json"),
|
||||
r#"{"rulesImport": ["cursor", "copilot"]}"#,
|
||||
)
|
||||
.expect("write settings");
|
||||
|
||||
let loaded = ConfigLoader::new(&cwd, &home)
|
||||
.load()
|
||||
.expect("config should load");
|
||||
|
||||
assert!(loaded.rules_import().should_import("cursor"));
|
||||
assert!(loaded.rules_import().should_import("copilot"));
|
||||
assert!(!loaded.rules_import().should_import("windsurf"));
|
||||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rules_import_none_disables_external_frameworks() {
|
||||
let root = temp_dir();
|
||||
let cwd = root.join("project");
|
||||
let home = root.join("home").join(".claw");
|
||||
fs::create_dir_all(&home).expect("home config dir");
|
||||
fs::create_dir_all(&cwd).expect("project dir");
|
||||
fs::write(home.join("settings.json"), r#"{"rulesImport": "none"}"#)
|
||||
.expect("write settings");
|
||||
|
||||
let loaded = ConfigLoader::new(&cwd, &home)
|
||||
.load()
|
||||
.expect("config should load");
|
||||
|
||||
assert!(!loaded.rules_import().should_import("cursor"));
|
||||
assert!(!loaded.rules_import().should_import("copilot"));
|
||||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_rules_import_array_with_non_string_entries() {
|
||||
let root = temp_dir();
|
||||
let cwd = root.join("project");
|
||||
let home = root.join("home").join(".claw");
|
||||
fs::create_dir_all(&home).expect("home config dir");
|
||||
fs::create_dir_all(&cwd).expect("project dir");
|
||||
fs::write(
|
||||
home.join("settings.json"),
|
||||
r#"{"rulesImport": ["cursor", 42]}"#,
|
||||
)
|
||||
.expect("write settings");
|
||||
|
||||
let error = ConfigLoader::new(&cwd, &home)
|
||||
.load()
|
||||
.expect_err("config should fail");
|
||||
|
||||
assert!(error.to_string().contains("rulesImport"));
|
||||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_trusted_roots_from_settings() {
|
||||
// given
|
||||
|
||||
@@ -92,7 +92,6 @@ enum FieldType {
|
||||
Bool,
|
||||
Object,
|
||||
StringArray,
|
||||
RulesImport,
|
||||
Number,
|
||||
}
|
||||
|
||||
@@ -103,7 +102,6 @@ impl FieldType {
|
||||
Self::Bool => "a boolean",
|
||||
Self::Object => "an object",
|
||||
Self::StringArray => "an array of strings",
|
||||
Self::RulesImport => "a string or an array of strings",
|
||||
Self::Number => "a number",
|
||||
}
|
||||
}
|
||||
@@ -116,12 +114,6 @@ impl FieldType {
|
||||
Self::StringArray => value
|
||||
.as_array()
|
||||
.is_some_and(|arr| arr.iter().all(|v| v.as_str().is_some())),
|
||||
Self::RulesImport => {
|
||||
value.as_str().is_some()
|
||||
|| value
|
||||
.as_array()
|
||||
.is_some_and(|arr| arr.iter().all(|v| v.as_str().is_some()))
|
||||
}
|
||||
Self::Number => value.as_i64().is_some(),
|
||||
}
|
||||
}
|
||||
@@ -209,10 +201,6 @@ const TOP_LEVEL_FIELDS: &[FieldSpec] = &[
|
||||
name: "provider",
|
||||
expected: FieldType::Object,
|
||||
},
|
||||
FieldSpec {
|
||||
name: "rulesImport",
|
||||
expected: FieldType::RulesImport,
|
||||
},
|
||||
];
|
||||
|
||||
const HOOKS_FIELDS: &[FieldSpec] = &[
|
||||
@@ -717,34 +705,6 @@ mod tests {
|
||||
assert_eq!(result.errors[0].field, "hooks.BadHook");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_rules_import_string_and_array_forms() {
|
||||
for source in [
|
||||
r#"{"rulesImport":"auto"}"#,
|
||||
r#"{"rulesImport":"none"}"#,
|
||||
r#"{"rulesImport":["cursor","copilot"]}"#,
|
||||
] {
|
||||
let parsed = JsonValue::parse(source).expect("valid json");
|
||||
let object = parsed.as_object().expect("object");
|
||||
|
||||
let result = validate_config_file(object, source, &test_path());
|
||||
|
||||
assert!(result.errors.is_empty(), "{source}: {:?}", result.errors);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_rules_import_wrong_type() {
|
||||
let source = r#"{"rulesImport":42}"#;
|
||||
let parsed = JsonValue::parse(source).expect("valid json");
|
||||
let object = parsed.as_object().expect("object");
|
||||
|
||||
let result = validate_config_file(object, source, &test_path());
|
||||
|
||||
assert_eq!(result.errors.len(), 1);
|
||||
assert_eq!(result.errors[0].field, "rulesImport");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_nested_permissions_keys() {
|
||||
// given
|
||||
|
||||
@@ -737,7 +737,7 @@ fn format_hook_failure(command: &str, code: i32, stdout: Option<&str>, stderr: &
|
||||
|
||||
fn shell_command(command: &str) -> CommandWithStdin {
|
||||
#[cfg(windows)]
|
||||
let command_builder = {
|
||||
let mut command_builder = {
|
||||
let mut command_builder = Command::new("cmd");
|
||||
command_builder.arg("/C").arg(command);
|
||||
CommandWithStdin::new(command_builder)
|
||||
|
||||
@@ -65,12 +65,11 @@ pub use compact::{
|
||||
get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult,
|
||||
};
|
||||
pub use config::{
|
||||
suppress_config_warnings_for_json_mode, ConfigEntry, ConfigError, ConfigLoader, ConfigSource,
|
||||
McpConfigCollection, McpManagedProxyServerConfig, McpOAuthConfig, McpRemoteServerConfig,
|
||||
McpSdkServerConfig, McpServerConfig, McpStdioServerConfig, McpTransport,
|
||||
McpWebSocketServerConfig, OAuthConfig, ProviderFallbackConfig, ResolvedPermissionMode,
|
||||
RulesImportConfig, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookConfig,
|
||||
RuntimePermissionRuleConfig, RuntimePluginConfig, ScopedMcpServerConfig,
|
||||
ConfigEntry, ConfigError, ConfigLoader, ConfigSource, McpConfigCollection,
|
||||
McpManagedProxyServerConfig, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig,
|
||||
McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig,
|
||||
ProviderFallbackConfig, ResolvedPermissionMode, RuntimeConfig, RuntimeFeatureConfig,
|
||||
RuntimeHookConfig, RuntimePermissionRuleConfig, RuntimePluginConfig, ScopedMcpServerConfig,
|
||||
CLAW_SETTINGS_SCHEMA_NAME,
|
||||
};
|
||||
pub use config_validate::{
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::hash::{Hash, Hasher};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use crate::config::{ConfigError, ConfigLoader, RulesImportConfig, RuntimeConfig};
|
||||
use crate::config::{ConfigError, ConfigLoader, RuntimeConfig};
|
||||
use crate::git_context::GitContext;
|
||||
|
||||
/// Errors raised while assembling the final system prompt.
|
||||
@@ -86,24 +86,7 @@ impl ProjectContext {
|
||||
current_date: impl Into<String>,
|
||||
) -> std::io::Result<Self> {
|
||||
let cwd = cwd.into();
|
||||
let instruction_files = discover_instruction_files(&cwd, &RulesImportConfig::default())?;
|
||||
Ok(Self {
|
||||
cwd,
|
||||
current_date: current_date.into(),
|
||||
git_status: None,
|
||||
git_diff: None,
|
||||
git_context: None,
|
||||
instruction_files,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn discover_with_rules_import(
|
||||
cwd: impl Into<PathBuf>,
|
||||
current_date: impl Into<String>,
|
||||
rules_import: &RulesImportConfig,
|
||||
) -> std::io::Result<Self> {
|
||||
let cwd = cwd.into();
|
||||
let instruction_files = discover_instruction_files(&cwd, rules_import)?;
|
||||
let instruction_files = discover_instruction_files(&cwd)?;
|
||||
Ok(Self {
|
||||
cwd,
|
||||
current_date: current_date.into(),
|
||||
@@ -126,18 +109,6 @@ impl ProjectContext {
|
||||
}
|
||||
}
|
||||
|
||||
fn discover_with_git_and_rules_import(
|
||||
cwd: impl Into<PathBuf>,
|
||||
current_date: impl Into<String>,
|
||||
rules_import: &RulesImportConfig,
|
||||
) -> std::io::Result<ProjectContext> {
|
||||
let mut context = ProjectContext::discover_with_rules_import(cwd, current_date, rules_import)?;
|
||||
context.git_status = read_git_status(&context.cwd);
|
||||
context.git_diff = read_git_diff(&context.cwd);
|
||||
context.git_context = GitContext::detect(&context.cwd);
|
||||
Ok(context)
|
||||
}
|
||||
|
||||
/// Builder for the runtime system prompt and dynamic environment sections.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct SystemPromptBuilder {
|
||||
@@ -256,10 +227,7 @@ pub fn prepend_bullets(items: Vec<String>) -> Vec<String> {
|
||||
items.into_iter().map(|item| format!(" - {item}")).collect()
|
||||
}
|
||||
|
||||
fn discover_instruction_files(
|
||||
cwd: &Path,
|
||||
rules_import: &RulesImportConfig,
|
||||
) -> std::io::Result<Vec<ContextFile>> {
|
||||
fn discover_instruction_files(cwd: &Path) -> std::io::Result<Vec<ContextFile>> {
|
||||
let mut directories = Vec::new();
|
||||
let mut cursor = Some(cwd);
|
||||
while let Some(dir) = cursor {
|
||||
@@ -272,25 +240,17 @@ fn discover_instruction_files(
|
||||
for dir in directories {
|
||||
for candidate in [
|
||||
dir.join("CLAUDE.md"),
|
||||
dir.join("AGENTS.md"),
|
||||
dir.join("CLAUDE.local.md"),
|
||||
dir.join(".claw").join("CLAUDE.md"),
|
||||
dir.join(".claude").join("CLAUDE.md"),
|
||||
dir.join(".claw").join("instructions.md"),
|
||||
] {
|
||||
push_context_file(&mut files, candidate)?;
|
||||
}
|
||||
push_rules_dir(&mut files, dir.join(".claw").join("rules"))?;
|
||||
push_rules_dir(&mut files, dir.join(".claw").join("rules.local"))?;
|
||||
push_framework_imports(&mut files, &dir, rules_import)?
|
||||
}
|
||||
Ok(dedupe_instruction_files(files))
|
||||
}
|
||||
|
||||
fn push_context_file(files: &mut Vec<ContextFile>, path: PathBuf) -> std::io::Result<()> {
|
||||
if path.is_dir() {
|
||||
return Ok(());
|
||||
}
|
||||
match fs::read_to_string(&path) {
|
||||
Ok(content) if !content.trim().is_empty() => {
|
||||
files.push(ContextFile { path, content });
|
||||
@@ -302,64 +262,6 @@ fn push_context_file(files: &mut Vec<ContextFile>, path: PathBuf) -> std::io::Re
|
||||
}
|
||||
}
|
||||
|
||||
fn push_rules_dir(files: &mut Vec<ContextFile>, dir: PathBuf) -> std::io::Result<()> {
|
||||
if dir.is_file() {
|
||||
return Ok(());
|
||||
}
|
||||
let entries = match fs::read_dir(&dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let mut paths = entries
|
||||
.filter_map(Result::ok)
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| path.is_file() && is_supported_rule_file(path))
|
||||
.collect::<Vec<_>>();
|
||||
paths.sort();
|
||||
for path in paths {
|
||||
push_context_file(files, path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_supported_rule_file(path: &Path) -> bool {
|
||||
path.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
.is_some_and(|extension| {
|
||||
matches!(
|
||||
extension.to_ascii_lowercase().as_str(),
|
||||
"md" | "txt" | "mdc"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn push_framework_imports(
|
||||
files: &mut Vec<ContextFile>,
|
||||
dir: &Path,
|
||||
rules_import: &RulesImportConfig,
|
||||
) -> std::io::Result<()> {
|
||||
if rules_import.should_import("cursor") {
|
||||
push_context_file(files, dir.join(".cursorrules"))?;
|
||||
push_rules_dir(files, dir.join(".cursor").join("rules"))?;
|
||||
}
|
||||
if rules_import.should_import("copilot") {
|
||||
push_context_file(files, dir.join(".github").join("copilot-instructions.md"))?;
|
||||
}
|
||||
if rules_import.should_import("windsurf") {
|
||||
push_context_file(files, dir.join(".windsurfrules"))?;
|
||||
push_rules_dir(files, dir.join(".windsurfrules"))?;
|
||||
}
|
||||
if rules_import.should_import("plandex") {
|
||||
push_context_file(files, dir.join(".plandex").join("instructions.md"))?;
|
||||
}
|
||||
if rules_import.should_import("crush") {
|
||||
push_context_file(files, dir.join(".crush").join("CLAUDE.md"))?;
|
||||
push_rules_dir(files, dir.join(".crush").join("rules"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_git_status(cwd: &Path) -> Option<String> {
|
||||
let output = Command::new("git")
|
||||
.args(["--no-optional-locks", "status", "--short", "--branch"])
|
||||
@@ -574,9 +476,8 @@ pub fn load_system_prompt(
|
||||
model_family: ModelFamilyIdentity,
|
||||
) -> Result<Vec<String>, PromptBuildError> {
|
||||
let cwd = cwd.into();
|
||||
let project_context = ProjectContext::discover_with_git(&cwd, current_date.into())?;
|
||||
let config = ConfigLoader::default_for(&cwd).load()?;
|
||||
let project_context =
|
||||
discover_with_git_and_rules_import(&cwd, current_date.into(), config.rules_import())?;
|
||||
Ok(SystemPromptBuilder::new()
|
||||
.with_os(os_name, os_version)
|
||||
.with_model_family(model_family)
|
||||
@@ -689,78 +590,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovers_claw_rules_files_in_sorted_order() {
|
||||
let root = temp_dir();
|
||||
let rules = root.join(".claw").join("rules");
|
||||
let local_rules = root.join(".claw").join("rules.local");
|
||||
fs::create_dir_all(&rules).expect("rules dir");
|
||||
fs::create_dir_all(&local_rules).expect("local rules dir");
|
||||
fs::write(rules.join("b.txt"), "b rule").expect("write b rule");
|
||||
fs::write(rules.join("a.md"), "a rule").expect("write a rule");
|
||||
fs::write(rules.join("ignored.json"), "ignored rule").expect("write ignored");
|
||||
fs::write(local_rules.join("c.mdc"), "c local rule").expect("write local rule");
|
||||
|
||||
let context = ProjectContext::discover(&root, "2026-03-31").expect("context should load");
|
||||
let contents = context
|
||||
.instruction_files
|
||||
.iter()
|
||||
.map(|file| file.content.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(contents, vec!["a rule", "b rule", "c local rule"]);
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rules_import_none_suppresses_external_framework_rules() {
|
||||
let root = temp_dir();
|
||||
fs::create_dir_all(root.join(".claw").join("rules")).expect("rules dir");
|
||||
fs::write(
|
||||
root.join(".claw").join("rules").join("project.md"),
|
||||
"claw rule",
|
||||
)
|
||||
.expect("write claw rule");
|
||||
fs::write(root.join(".cursorrules"), "cursor rule").expect("write cursor rule");
|
||||
|
||||
let context = ProjectContext::discover_with_rules_import(
|
||||
&root,
|
||||
"2026-03-31",
|
||||
&crate::config::RulesImportConfig::None,
|
||||
)
|
||||
.expect("context should load");
|
||||
let rendered = render_instruction_files(&context.instruction_files);
|
||||
|
||||
assert!(rendered.contains("claw rule"));
|
||||
assert!(!rendered.contains("cursor rule"));
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rules_import_list_loads_only_selected_framework_rules() {
|
||||
let root = temp_dir();
|
||||
fs::create_dir_all(&root).expect("root dir");
|
||||
fs::write(root.join(".cursorrules"), "cursor rule").expect("write cursor rule");
|
||||
fs::create_dir_all(root.join(".github")).expect("github dir");
|
||||
fs::write(
|
||||
root.join(".github").join("copilot-instructions.md"),
|
||||
"copilot rule",
|
||||
)
|
||||
.expect("write copilot rule");
|
||||
|
||||
let context = ProjectContext::discover_with_rules_import(
|
||||
&root,
|
||||
"2026-03-31",
|
||||
&crate::config::RulesImportConfig::List(vec!["copilot".to_string()]),
|
||||
)
|
||||
.expect("context should load");
|
||||
let rendered = render_instruction_files(&context.instruction_files);
|
||||
|
||||
assert!(rendered.contains("copilot rule"));
|
||||
assert!(!rendered.contains("cursor rule"));
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovers_instruction_files_from_ancestor_chain() {
|
||||
let root = temp_dir();
|
||||
@@ -807,63 +636,6 @@ mod tests {
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovers_agents_markdown_instruction_file() {
|
||||
let root = temp_dir();
|
||||
fs::create_dir_all(&root).expect("root dir");
|
||||
fs::write(root.join("AGENTS.md"), "agents-only instructions").expect("write AGENTS.md");
|
||||
|
||||
let context = ProjectContext::discover(&root, "2026-03-31").expect("context should load");
|
||||
|
||||
assert_eq!(context.instruction_files.len(), 1);
|
||||
assert!(context.instruction_files[0].path.ends_with("AGENTS.md"));
|
||||
assert!(render_instruction_files(&context.instruction_files)
|
||||
.contains("agents-only instructions"));
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovers_scoped_dot_claude_claude_markdown_instruction_file() {
|
||||
let root = temp_dir();
|
||||
fs::create_dir_all(root.join(".claude")).expect("dot claude dir");
|
||||
fs::write(
|
||||
root.join(".claude").join("CLAUDE.md"),
|
||||
"dot-claude-only instructions",
|
||||
)
|
||||
.expect("write .claude/CLAUDE.md");
|
||||
|
||||
let context = ProjectContext::discover(&root, "2026-03-31").expect("context should load");
|
||||
|
||||
assert_eq!(context.instruction_files.len(), 1);
|
||||
assert!(context.instruction_files[0]
|
||||
.path
|
||||
.ends_with(".claude/CLAUDE.md"));
|
||||
assert!(render_instruction_files(&context.instruction_files)
|
||||
.contains("dot-claude-only instructions"));
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovers_claude_agents_and_dot_claude_instruction_files_together() {
|
||||
let root = temp_dir();
|
||||
fs::create_dir_all(root.join(".claude")).expect("dot claude dir");
|
||||
fs::write(root.join("CLAUDE.md"), "claude instructions").expect("write CLAUDE.md");
|
||||
fs::write(root.join("AGENTS.md"), "agents instructions").expect("write AGENTS.md");
|
||||
fs::write(
|
||||
root.join(".claude").join("CLAUDE.md"),
|
||||
"dot claude instructions",
|
||||
)
|
||||
.expect("write .claude/CLAUDE.md");
|
||||
|
||||
let context = ProjectContext::discover(&root, "2026-03-31").expect("context should load");
|
||||
let rendered = render_instruction_files(&context.instruction_files);
|
||||
|
||||
assert!(rendered.contains("claude instructions"));
|
||||
assert!(rendered.contains("agents instructions"));
|
||||
assert!(rendered.contains("dot claude instructions"));
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedupes_identical_instruction_content_across_scopes() {
|
||||
let root = temp_dir();
|
||||
@@ -1104,51 +876,6 @@ mod tests {
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_system_prompt_respects_rules_import_config() {
|
||||
let root = temp_dir();
|
||||
fs::create_dir_all(root.join(".claw")).expect("claw dir");
|
||||
fs::write(root.join(".cursorrules"), "cursor rule").expect("write cursor rule");
|
||||
fs::write(
|
||||
root.join(".claw").join("settings.json"),
|
||||
r#"{"rulesImport":"none"}"#,
|
||||
)
|
||||
.expect("write settings");
|
||||
|
||||
let _guard = env_lock();
|
||||
ensure_valid_cwd();
|
||||
let previous = std::env::current_dir().expect("cwd");
|
||||
let original_home = std::env::var("HOME").ok();
|
||||
let original_claw_home = std::env::var("CLAW_CONFIG_HOME").ok();
|
||||
std::env::set_var("HOME", &root);
|
||||
std::env::set_var("CLAW_CONFIG_HOME", root.join("missing-home"));
|
||||
std::env::set_current_dir(&root).expect("change cwd");
|
||||
let prompt = super::load_system_prompt(
|
||||
&root,
|
||||
"2026-03-31",
|
||||
"linux",
|
||||
"6.8",
|
||||
ModelFamilyIdentity::Claude,
|
||||
)
|
||||
.expect("system prompt should load")
|
||||
.join("\n\n");
|
||||
std::env::set_current_dir(previous).expect("restore cwd");
|
||||
if let Some(value) = original_home {
|
||||
std::env::set_var("HOME", value);
|
||||
} else {
|
||||
std::env::remove_var("HOME");
|
||||
}
|
||||
if let Some(value) = original_claw_home {
|
||||
std::env::set_var("CLAW_CONFIG_HOME", value);
|
||||
} else {
|
||||
std::env::remove_var("CLAW_CONFIG_HOME");
|
||||
}
|
||||
|
||||
assert!(!prompt.contains("cursor rule"));
|
||||
assert!(prompt.contains("rulesImport"));
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_default_claude_model_family_identity() {
|
||||
// given: a prompt builder without an explicit model family override
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -266,15 +266,13 @@ fn compact_subcommand_json_help_fails_fast_when_stdin_closed() {
|
||||
!output.status.success(),
|
||||
"compact json help should fail non-zero"
|
||||
);
|
||||
// #819/#820/#823: JSON abort envelopes route to stdout
|
||||
let stderr = String::from_utf8(output.stderr).expect("stderr should be utf8");
|
||||
assert!(
|
||||
stderr.trim().is_empty() || !stderr.trim_start().starts_with('{'),
|
||||
"compact json help should not emit JSON envelope to stderr (#819/#820/#823): {stderr}"
|
||||
output.stdout.is_empty(),
|
||||
"compact json help should not start a prompt/spinner on stdout: {}",
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
);
|
||||
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
||||
let parsed: Value =
|
||||
serde_json::from_str(stdout.trim()).expect("stdout should be JSON error envelope");
|
||||
let stderr = String::from_utf8(output.stderr).expect("stderr should be utf8");
|
||||
let parsed: Value = serde_json::from_str(stderr.trim()).expect("stderr should be JSON error");
|
||||
assert_eq!(parsed["status"], "error");
|
||||
assert_eq!(parsed["error_kind"], "interactive_only");
|
||||
assert_eq!(parsed["action"], "abort");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -521,9 +521,8 @@ fn resumed_stub_command_emits_not_implemented_json() {
|
||||
|
||||
// Stub commands exit with code 2
|
||||
assert!(!output.status.success());
|
||||
// #819/#820/#823: JSON abort envelopes route to stdout
|
||||
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
||||
let parsed: Value = serde_json::from_str(stdout.trim()).expect("should be json");
|
||||
let stderr = String::from_utf8(output.stderr).expect("utf8");
|
||||
let parsed: Value = serde_json::from_str(stderr.trim()).expect("should be json");
|
||||
assert_eq!(
|
||||
parsed["status"], "error",
|
||||
"stub command should emit status:error"
|
||||
|
||||
@@ -1225,14 +1225,13 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
|
||||
},
|
||||
ToolSpec {
|
||||
name: "GitShow",
|
||||
description: "Show a commit, tag, or tree object. Use format to control output: patch (default) shows the full diff, stat shows a diffstat summary, and metadata shows commit info without the diff. Supports showing a specific file at a commit (commit:path) for patch/stat output. Use this instead of running git show via bash to get structured output.",
|
||||
description: "Show a commit, tag, or tree object with its diff. Supports showing a specific file at a commit (commit:path) and stat-only mode. Use this instead of running git show via bash to get structured output.",
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"commit": { "type": "string" },
|
||||
"path": { "type": "string" },
|
||||
"stat": { "type": "boolean" },
|
||||
"format": { "type": "string", "enum": ["patch", "stat", "metadata"] },
|
||||
"stat": { "type": "boolean" }
|
||||
},
|
||||
"required": ["commit"],
|
||||
"additionalProperties": false
|
||||
@@ -2009,37 +2008,14 @@ fn run_git_log(input: GitLogInput) -> Result<String, String> {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::needless_pass_by_value)]
|
||||
/// Execute `git show` for a given commit, optionally with --stat or a file path.
|
||||
/// Uses the `commit:path` syntax when a path is specified.
|
||||
fn run_git_show(input: GitShowInput) -> Result<String, String> {
|
||||
let mut args: Vec<String> = vec!["show".to_string()];
|
||||
|
||||
match input.format.as_deref() {
|
||||
Some("metadata") if input.path.is_some() => {
|
||||
return Err(
|
||||
"GitShow format \"metadata\" cannot be combined with path; metadata describes a commit, not a blob. Use format \"patch\" or \"stat\" with path, or omit path."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
Some("metadata") => {
|
||||
args.push("--format=medium".to_string());
|
||||
args.push("--no-patch".to_string());
|
||||
}
|
||||
Some("stat") => {
|
||||
args.push("--stat".to_string());
|
||||
}
|
||||
Some("patch") | None => {
|
||||
if input.format.is_none() && input.stat.unwrap_or(false) {
|
||||
args.push("--stat".to_string());
|
||||
}
|
||||
}
|
||||
Some(other) => {
|
||||
return Err(format!(
|
||||
"unknown GitShow format: \"{other}\". Supported values: \"patch\" (default), \"stat\", \"metadata\"."
|
||||
));
|
||||
}
|
||||
if input.stat.unwrap_or(false) {
|
||||
args.push("--stat".to_string());
|
||||
}
|
||||
|
||||
if let Some(ref path) = input.path {
|
||||
args.push(format!("{}:{}", input.commit, path));
|
||||
} else {
|
||||
@@ -2988,9 +2964,6 @@ struct GitShowInput {
|
||||
#[serde(default)]
|
||||
/// If true, show diffstat summary instead of full diff.
|
||||
stat: Option<bool>,
|
||||
#[serde(default)]
|
||||
/// Output format: "patch" (default) shows the full diff, "stat" shows a diffstat summary, and "metadata" shows commit info without the diff. When set, takes priority over `stat`.
|
||||
format: Option<String>,
|
||||
}
|
||||
|
||||
/// Input for the GitBlame tool: shows per-line author/revision info for a file.
|
||||
@@ -6806,87 +6779,6 @@ mod tests {
|
||||
assert!(names.contains(&"WorkerSendPrompt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_show_schema_exposes_format_enum() {
|
||||
let spec = mvp_tool_specs()
|
||||
.into_iter()
|
||||
.find(|spec| spec.name == "GitShow")
|
||||
.expect("GitShow spec");
|
||||
assert_eq!(
|
||||
spec.input_schema["properties"]["format"]["enum"],
|
||||
json!(["patch", "stat", "metadata"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_show_supports_patch_stat_metadata_and_rejects_metadata_path() {
|
||||
let _guard = env_guard();
|
||||
let root = temp_path("git-show-format");
|
||||
init_git_repo(&root);
|
||||
commit_file(&root, "README.md", "initial\nupdated\n", "update readme");
|
||||
let previous = std::env::current_dir().expect("cwd");
|
||||
std::env::set_current_dir(&root).expect("set cwd");
|
||||
|
||||
let patch = execute_tool("GitShow", &json!({"commit": "HEAD", "format": "patch"}))
|
||||
.expect("patch git show");
|
||||
let patch: serde_json::Value = serde_json::from_str(&patch).expect("patch json");
|
||||
assert!(patch["output"]
|
||||
.as_str()
|
||||
.expect("patch output")
|
||||
.contains("diff --git"));
|
||||
|
||||
let stat = execute_tool("GitShow", &json!({"commit": "HEAD", "format": "stat"}))
|
||||
.expect("stat git show");
|
||||
let stat: serde_json::Value = serde_json::from_str(&stat).expect("stat json");
|
||||
assert!(stat["output"]
|
||||
.as_str()
|
||||
.expect("stat output")
|
||||
.contains("README.md"));
|
||||
|
||||
let legacy_stat = execute_tool("GitShow", &json!({"commit": "HEAD", "stat": true}))
|
||||
.expect("legacy stat git show");
|
||||
let legacy_stat: serde_json::Value =
|
||||
serde_json::from_str(&legacy_stat).expect("legacy stat json");
|
||||
assert!(legacy_stat["output"]
|
||||
.as_str()
|
||||
.expect("legacy stat output")
|
||||
.contains("README.md"));
|
||||
|
||||
let metadata = execute_tool("GitShow", &json!({"commit": "HEAD", "format": "metadata"}))
|
||||
.expect("metadata git show");
|
||||
let metadata: serde_json::Value = serde_json::from_str(&metadata).expect("metadata json");
|
||||
let metadata_output = metadata["output"].as_str().expect("metadata output");
|
||||
assert!(metadata_output.contains("commit "));
|
||||
assert!(metadata_output.contains("update readme"));
|
||||
assert!(!metadata_output.contains("diff --git"));
|
||||
|
||||
let file_patch = execute_tool(
|
||||
"GitShow",
|
||||
&json!({"commit": "HEAD", "path": "README.md", "format": "patch"}),
|
||||
)
|
||||
.expect("file patch git show");
|
||||
let file_patch: serde_json::Value =
|
||||
serde_json::from_str(&file_patch).expect("file patch json");
|
||||
assert_eq!(
|
||||
file_patch["output"].as_str().expect("file patch output"),
|
||||
"initial\nupdated"
|
||||
);
|
||||
|
||||
let metadata_path = execute_tool(
|
||||
"GitShow",
|
||||
&json!({"commit": "HEAD", "path": "README.md", "format": "metadata"}),
|
||||
)
|
||||
.expect_err("metadata with path should be rejected");
|
||||
assert!(metadata_path.contains("cannot be combined with path"));
|
||||
|
||||
let invalid = execute_tool("GitShow", &json!({"commit": "HEAD", "format": "bogus"}))
|
||||
.expect_err("invalid format should be rejected");
|
||||
assert!(invalid.contains("unknown GitShow format"));
|
||||
|
||||
std::env::set_current_dir(&previous).expect("restore cwd");
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_tool_names() {
|
||||
let error = execute_tool("nope", &json!({})).expect_err("tool should be rejected");
|
||||
|
||||
@@ -16,16 +16,6 @@ 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"])
|
||||
@@ -36,13 +26,11 @@ 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 = script_root / "generate_cc2_board.py"
|
||||
validator = script_root / "validate_cc2_board.py"
|
||||
renderer = tool_root / ".omx" / "cc2" / "render_board_md.py"
|
||||
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"
|
||||
|
||||
if args.command == "generate":
|
||||
rc = run([sys.executable, str(generator), "--repo-root", str(repo_root), "--out-dir", str(board_json.parent)], repo_root)
|
||||
@@ -55,7 +43,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_quiet_until_failure(cmd, repo_root)
|
||||
rc = run(cmd, repo_root)
|
||||
if rc:
|
||||
return rc
|
||||
print(f"CC2 board validation PASS: {board_json} and {board_md} are canonical and in sync")
|
||||
|
||||
@@ -13,24 +13,6 @@
|
||||
#
|
||||
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"
|
||||
@@ -78,8 +60,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
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Sequence
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProbeResult:
|
||||
kind: str
|
||||
argv: list[str]
|
||||
returncode: int | None
|
||||
stdout: bytes
|
||||
stderr: bytes
|
||||
message: str | None = None
|
||||
|
||||
@property
|
||||
def stdout_text(self) -> str:
|
||||
return self.stdout.decode('utf-8', errors='replace')
|
||||
|
||||
@property
|
||||
def stderr_text(self) -> str:
|
||||
return self.stderr.decode('utf-8', errors='replace')
|
||||
|
||||
def to_json_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
'kind': self.kind,
|
||||
'argv': self.argv,
|
||||
'returncode': self.returncode,
|
||||
'stdout': self.stdout_text,
|
||||
'stderr': self.stderr_text,
|
||||
'message': self.message,
|
||||
}
|
||||
|
||||
|
||||
def run_probe(argv: Sequence[str], *, timeout: float = 10.0, require_stdout_json_byte0: bool = False) -> ProbeResult:
|
||||
explicit_argv = [str(arg) for arg in argv]
|
||||
if not explicit_argv:
|
||||
return ProbeResult(
|
||||
kind='probe_error',
|
||||
argv=[],
|
||||
returncode=None,
|
||||
stdout=b'',
|
||||
stderr=b'',
|
||||
message='argv must contain at least the executable path',
|
||||
)
|
||||
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
explicit_argv,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=timeout,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
return ProbeResult(
|
||||
kind='timeout',
|
||||
argv=explicit_argv,
|
||||
returncode=None,
|
||||
stdout=exc.stdout or b'',
|
||||
stderr=exc.stderr or b'',
|
||||
message=f'probe timed out after {timeout:g}s',
|
||||
)
|
||||
except (OSError, ValueError) as exc:
|
||||
return ProbeResult(
|
||||
kind='probe_error',
|
||||
argv=explicit_argv,
|
||||
returncode=None,
|
||||
stdout=b'',
|
||||
stderr=b'',
|
||||
message=str(exc),
|
||||
)
|
||||
|
||||
if require_stdout_json_byte0:
|
||||
if not completed.stdout:
|
||||
return ProbeResult(
|
||||
kind='product_error',
|
||||
argv=explicit_argv,
|
||||
returncode=completed.returncode,
|
||||
stdout=completed.stdout,
|
||||
stderr=completed.stderr,
|
||||
message='stdout is empty; expected JSON at byte 0',
|
||||
)
|
||||
if completed.stdout[:1] not in (b'{', b'['):
|
||||
return ProbeResult(
|
||||
kind='product_error',
|
||||
argv=explicit_argv,
|
||||
returncode=completed.returncode,
|
||||
stdout=completed.stdout,
|
||||
stderr=completed.stderr,
|
||||
message='stdout JSON does not start at byte 0',
|
||||
)
|
||||
try:
|
||||
json.loads(completed.stdout.decode('utf-8'))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
return ProbeResult(
|
||||
kind='product_error',
|
||||
argv=explicit_argv,
|
||||
returncode=completed.returncode,
|
||||
stdout=completed.stdout,
|
||||
stderr=completed.stderr,
|
||||
message=f'stdout is not parseable JSON: {exc}',
|
||||
)
|
||||
|
||||
if completed.returncode != 0:
|
||||
return ProbeResult(
|
||||
kind='product_error',
|
||||
argv=explicit_argv,
|
||||
returncode=completed.returncode,
|
||||
stdout=completed.stdout,
|
||||
stderr=completed.stderr,
|
||||
message=f'process exited with code {completed.returncode}',
|
||||
)
|
||||
|
||||
return ProbeResult(
|
||||
kind='ok',
|
||||
argv=explicit_argv,
|
||||
returncode=completed.returncode,
|
||||
stdout=completed.stdout,
|
||||
stderr=completed.stderr,
|
||||
)
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description='Run an argv-safe dogfood probe and emit separated channels as JSON.')
|
||||
parser.add_argument('--timeout', type=float, default=10.0)
|
||||
parser.add_argument('--stdout-json-byte0', action='store_true', help='Require stdout to be parseable JSON starting at byte 0.')
|
||||
parser.add_argument('command', nargs=argparse.REMAINDER, help='Executable and arguments to run. Use -- before the target argv.')
|
||||
args = parser.parse_args(argv)
|
||||
command = args.command
|
||||
if command and command[0] == '--':
|
||||
command = command[1:]
|
||||
|
||||
result = run_probe(command, timeout=args.timeout, require_stdout_json_byte0=args.stdout_json_byte0)
|
||||
print(json.dumps(result.to_json_dict(), sort_keys=True))
|
||||
return 0 if result.kind == 'ok' else 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -503,12 +503,8 @@ 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,7 +11,6 @@ set -euo pipefail
|
||||
|
||||
MIN_ID=723
|
||||
ROADMAP="ROADMAP.md"
|
||||
ROADMAP_PATH_SEEN=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
@@ -32,12 +31,7 @@ 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,31 +17,7 @@
|
||||
# and resolve any append collision at git-push time.
|
||||
set -euo pipefail
|
||||
|
||||
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
|
||||
|
||||
ROADMAP="${1:-ROADMAP.md}"
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
CHECKER="$SCRIPT_DIR/roadmap-check-ids.sh"
|
||||
|
||||
|
||||
@@ -44,17 +44,7 @@ def main() -> int:
|
||||
args = parser.parse_args()
|
||||
repo_root = args.repo_root.resolve()
|
||||
board_path = args.board or (repo_root / ".omx" / "cc2" / "board.json")
|
||||
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
|
||||
board = json.loads(board_path.read_text(encoding="utf-8"))
|
||||
errors: list[str] = []
|
||||
ids = set()
|
||||
for index, item in enumerate(board.get("items", []), 1):
|
||||
|
||||
@@ -9,9 +9,6 @@ from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
NEXT_ID = REPO_ROOT / 'scripts' / 'roadmap-next-id.sh'
|
||||
DOGFOOD_PROBE = REPO_ROOT / 'scripts' / 'dogfood-probe.py'
|
||||
|
||||
|
||||
|
||||
|
||||
def run_next_id(roadmap: Path, script: Path = NEXT_ID) -> subprocess.CompletedProcess[str]:
|
||||
@@ -24,16 +21,6 @@ def run_next_id(roadmap: Path, script: Path = NEXT_ID) -> subprocess.CompletedPr
|
||||
)
|
||||
|
||||
|
||||
def run_dogfood_probe(args: list[str]) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
['python3', str(DOGFOOD_PROBE), *args],
|
||||
cwd=REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
class RoadmapHelperTests(unittest.TestCase):
|
||||
def test_roadmap_next_id_prints_only_next_id_after_duplicate_check(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
@@ -59,17 +46,6 @@ class RoadmapHelperTests(unittest.TestCase):
|
||||
self.assertIn('999', result.stderr)
|
||||
self.assertNotIn('1000', result.stdout)
|
||||
|
||||
def test_roadmap_next_id_fails_when_explicit_roadmap_path_is_missing(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
roadmap = Path(temp_dir) / 'missing-ROADMAP.md'
|
||||
|
||||
result = run_next_id(roadmap)
|
||||
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertEqual('', result.stdout)
|
||||
self.assertIn('ROADMAP not found', result.stderr)
|
||||
self.assertIn(str(roadmap), result.stderr)
|
||||
|
||||
def test_roadmap_next_id_fails_closed_when_checker_is_unavailable(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
script_dir = Path(temp_dir) / 'scripts'
|
||||
@@ -86,78 +62,6 @@ class RoadmapHelperTests(unittest.TestCase):
|
||||
self.assertIn('required ROADMAP id checker not found or not readable', result.stderr)
|
||||
self.assertIn('refusing to print a next id', result.stderr)
|
||||
|
||||
def test_dogfood_probe_runs_explicit_argv_and_separates_channels(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
fixture = Path(temp_dir) / 'fixture.py'
|
||||
fixture.write_text(
|
||||
'from __future__ import annotations\n'
|
||||
'import json\n'
|
||||
'import sys\n'
|
||||
'print(json.dumps({"argv": sys.argv[1:]}))\n'
|
||||
'print("diagnostic", file=sys.stderr)\n'
|
||||
)
|
||||
|
||||
result = run_dogfood_probe([
|
||||
'--stdout-json-byte0',
|
||||
'--',
|
||||
'python3',
|
||||
str(fixture),
|
||||
'--output-format',
|
||||
'json',
|
||||
'doctor',
|
||||
'--help',
|
||||
])
|
||||
|
||||
self.assertEqual(0, result.returncode)
|
||||
payload = __import__('json').loads(result.stdout)
|
||||
self.assertEqual('ok', payload['kind'])
|
||||
self.assertEqual([
|
||||
'python3',
|
||||
str(fixture),
|
||||
'--output-format',
|
||||
'json',
|
||||
'doctor',
|
||||
'--help',
|
||||
], payload['argv'])
|
||||
self.assertEqual(0, payload['returncode'])
|
||||
self.assertEqual('{"argv": ["--output-format", "json", "doctor", "--help"]}\n', payload['stdout'])
|
||||
self.assertEqual('diagnostic\n', payload['stderr'])
|
||||
|
||||
def test_dogfood_probe_labels_timeout_separately_from_product_error(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
fixture = Path(temp_dir) / 'sleep.py'
|
||||
fixture.write_text('import time\ntime.sleep(2)\n')
|
||||
|
||||
result = run_dogfood_probe(['--timeout', '0.1', '--', 'python3', str(fixture)])
|
||||
|
||||
self.assertEqual(1, result.returncode)
|
||||
payload = __import__('json').loads(result.stdout)
|
||||
self.assertEqual('timeout', payload['kind'])
|
||||
self.assertIsNone(payload['returncode'])
|
||||
self.assertIn('timed out', payload['message'])
|
||||
|
||||
def test_dogfood_probe_labels_probe_construction_failure(self) -> None:
|
||||
result = run_dogfood_probe([])
|
||||
|
||||
self.assertEqual(1, result.returncode)
|
||||
payload = __import__('json').loads(result.stdout)
|
||||
self.assertEqual('probe_error', payload['kind'])
|
||||
self.assertEqual([], payload['argv'])
|
||||
self.assertIsNone(payload['returncode'])
|
||||
self.assertIn('argv must contain', payload['message'])
|
||||
|
||||
def test_dogfood_probe_labels_stdout_json_prefix_failure_as_product_error(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
fixture = Path(temp_dir) / 'prefixed.py'
|
||||
fixture.write_text('print("warning before json")\nprint("{}")\n')
|
||||
|
||||
result = run_dogfood_probe(['--stdout-json-byte0', '--', 'python3', str(fixture)])
|
||||
|
||||
self.assertEqual(1, result.returncode)
|
||||
payload = __import__('json').loads(result.stdout)
|
||||
self.assertEqual('product_error', payload['kind'])
|
||||
self.assertEqual(0, payload['returncode'])
|
||||
self.assertIn('byte 0', payload['message'])
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user