mirror of
https://github.com/instructkr/claw-code.git
synced 2026-06-06 01:42:47 -04:00
Compare commits
8 Commits
b5bead9028
...
4619375c14
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4619375c14 | ||
|
|
10fe72498a | ||
|
|
5b22bc0480 | ||
|
|
ae7da0ec74 | ||
|
|
7dd17c6344 | ||
|
|
d8535bf938 | ||
|
|
b45c61eff9 | ||
|
|
7cfd83f66a |
14
ROADMAP.md
14
ROADMAP.md
@@ -6377,25 +6377,25 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed)
|
||||
433. **DONE — `--output-format` selection is typed, case-insensitive, env-configurable, and auditable** — fixed 2026-06-04 in `fix: type output format selection`. `CliOutputFormat::parse` now accepts `text`/`json` in any casing, `CLAW_OUTPUT_FORMAT` seeds the default output format when no CLI output-format flag is present, explicit flags override the env default, and repeated flags emit `warning: --output-format specified multiple times; using last value '...'`. `status --output-format json` exposes `format_source`, `format_raw`, and `format_overridden`; invalid values return typed `invalid_output_format` JSON with `value`, `expected:["text","json"]`, and a recovery hint instead of `kind:"unknown"`. Top-level help documents `CLAW_OUTPUT_FORMAT`, `CLAW_LOG`, and `RUST_LOG`, and doctor system JSON surfaces those env values. Regression coverage: `output_format_flags_and_env_have_typed_contract_433` and `classify_error_kind_returns_correct_discriminants`. Verification: `cargo fmt --manifest-path rust/Cargo.toml --all -- --check`, focused output-format and classifier tests, `scripts/roadmap-check-ids.sh`, `git diff --check`, `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli --no-run`, and `cargo build --manifest-path rust/Cargo.toml --workspace --locked`.
|
||||
|
||||
|
||||
434. **DONE — POSIX `--` and dash-prefixed shorthand prompts stay on the prompt path** — fixed 2026-06-04 in CI recovery after `41678eb` turned Rust CI red. Global argument parsing now treats `--` as an end-of-flags separator, stops JSON-output pre-scans before the separator, and forwards every following token as positional prompt text. Shorthand prompt mode accepts dash-prefixed text that is not a registered or near-miss CLI flag (`-not-a-flag`, `--bogus-flag-like literal`) while still rejecting real typo-like options such as `--resum` with the `--resume` suggestion. Direct `/status` invocation again remains REPL/session-only so the existing parser contract is restored. Help, USAGE, and rust README document the `claw -- "-prompt-with-dash"` form. Regression coverage: `parses_dash_prefixed_prompt_text_434`, plus rerun CI-red parser tests `parses_bare_prompt_and_json_output_flag` and `parses_direct_agents_mcp_and_skills_slash_commands`. Verification: `cargo fmt --manifest-path rust/Cargo.toml --all -- --check`, focused parser tests, `scripts/roadmap-check-ids.sh`, `git diff --check -- USAGE.md rust/README.md rust/crates/rusty-claude-cli/src/main.rs ROADMAP.md`, docs source-of-truth/release-readiness/unit helper checks, `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli --no-run`, `cargo build --manifest-path rust/Cargo.toml --workspace --locked`, and `cargo clippy --manifest-path rust/Cargo.toml --workspace`. Local `cargo test --manifest-path rust/Cargo.toml --workspace` still hits the pre-existing Darwin-only `runtime::worker_boot::startup_preflight_warns_when_git_metadata_is_not_writable` permission assertion after all CLI parser tests pass; the red GitHub jobs were parser failures on head `41678eb`.
|
||||
434. **DONE — POSIX `--` and dash-prefixed shorthand prompts stay on the prompt path** — fixed 2026-06-04 in CI recovery after `41678eb` turned Rust CI red. Global argument parsing now treats `--` as an end-of-flags separator, stops JSON-output pre-scans before the separator, and forwards every following token as positional prompt text. Shorthand prompt mode accepts dash-prefixed text that is not a registered or near-miss CLI flag (`-not-a-flag`, `--bogus-flag-like literal`) while still rejecting real typo-like options such as `--resum` with the `--resume` suggestion. Direct `/status` invocation routes to the local status action per the resume-safe slash command contract, matching the CI-red parser regression tests restored after `7cfd83f`. Help, USAGE, and rust README document the `claw -- "-prompt-with-dash"` form. Regression coverage: `parses_dash_prefixed_prompt_text_434`, plus rerun CI-red parser tests `parses_bare_prompt_and_json_output_flag` and `parses_direct_agents_mcp_and_skills_slash_commands`. Verification: `cargo fmt --manifest-path rust/Cargo.toml --all -- --check`, focused parser tests, `scripts/roadmap-check-ids.sh`, `git diff --check -- USAGE.md rust/README.md rust/crates/rusty-claude-cli/src/main.rs ROADMAP.md`, docs source-of-truth/release-readiness/unit helper checks, `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli --no-run`, `cargo build --manifest-path rust/Cargo.toml --workspace --locked`, and `cargo clippy --manifest-path rust/Cargo.toml --workspace`. Local `cargo test --manifest-path rust/Cargo.toml --workspace` still hits the pre-existing Darwin-only `runtime::worker_boot::startup_preflight_warns_when_git_metadata_is_not_writable` permission assertion after all CLI parser tests pass; the red GitHub jobs were parser failures on head `41678eb`.
|
||||
|
||||
|
||||
435. **`claw --resume latest` on a fresh workspace exit code is 0 in text mode but 1 in JSON mode (text mode lies about success); sibling: failed `--resume` creates the `.claw/sessions/<fingerprint>/` directory tree as a filesystem side effect of the failure** — dogfooded 2026-05-11 by Jobdori on `e29010ed` in response to Clawhip pinpoint nudge at `1503305692566655096`. Reproduction (fresh empty dir, no `.claw/`, no sessions): `claw --resume latest` (text mode) prints `failed to restore session: no managed sessions found in .claw/sessions/0ead448127a2de44/` and exits **0**. Same invocation with `--output-format json` correctly exits **1** with `kind:"session_load_failed"`. Exit-code parity broken on the same input depending on format flag. **Sibling filesystem-side-effect bug:** after the failed `--resume latest` on a fresh empty workspace, the directory `.claw/sessions/0ead448127a2de44/` (the workspace-fingerprint partition) is created on disk despite the operation failing. The user did not opt into creating workspace metadata — they asked to resume an existing session, the resume failed, and now there's a partition directory hanging around. The fingerprint directory ought to be created lazily on first successful session save, not as a side effect of every resume attempt. **Three sibling findings in the same probe:** (a) **`claw --compact` alone (no other args) drops into the interactive REPL with the ANSI welcome banner** — `--compact` is documented as a modifier that strips tool call details in text mode for piping (`--compact ... useful for piping`), not as a verb that activates the REPL. Running `claw --compact` with no positional should be a no-op or an error explaining the flag needs a subcommand or prompt; entering the REPL is the wrong default. (b) **`claw --compact "hello"` (shorthand prompt) returns `{"error":"unknown subcommand: hello.","hint":"Did you mean help","kind":"unknown"}` — `--compact` disables shorthand prompt mode entirely**, treating the positional as a subcommand instead of as prompt text. Users must use the explicit `prompt` verb (`claw --compact prompt "hello"`) which contradicts the `claw [flags] TEXT` usage line in `--help`. (c) `kind:"unknown"` again for the unknown-subcommand error in --compact path — same catch-all bucket bug appearing for the 11th time across pinpoints. **Required fix shape:** (a) exit code 1 for all `failed_to_restore` / `session_load_failed` text-mode failures; text mode should print to stderr and exit non-zero, not print to stdout and exit 0; (b) defer `.claw/sessions/<fingerprint>/` creation to first successful save; failed `--resume` must not leave filesystem droppings; (c) `claw --compact` alone (no positional, no subcommand, stdin is TTY) should emit `kind:"missing_argument"` with `argument:"prompt or subcommand"` rather than activating the REPL; (d) `--compact` must be transparent to shorthand prompt mode parsing — `claw --compact "hello"` is equivalent to `claw --compact prompt "hello"`, both should reach the prompt path; (e) emit typed `kind:"unknown_subcommand"` not `kind:"unknown"` for fallthrough cases. **Why this matters:** scripts that gate on `$?` after `claw --resume latest` see success on text mode and failure on JSON mode — the same operation, two outcomes. The filesystem side effect pollutes a user's worktree with workspace partitions they didn't ask for, and CI pipelines that snapshot `.claw/` size silently grow on every failed `--resume`. Cross-references #422 (exit-code parity across error envelopes), #423 (`kind:"unknown"` for `missing_argument`), #434 (shorthand prompt limitations). Source: Jobdori live dogfood, `e29010ed`, 2026-05-11.
|
||||
435. **DONE — failed resume is non-zero and side-effect free; `--compact` stays a prompt modifier** — fixed 2026-06-04 in `fix: keep failed resume side-effect free`. Fresh-workspace `claw --resume latest` exits 1 in text and JSON modes; text writes the restore failure to stderr, JSON writes a typed `no_managed_sessions` restore envelope to stdout, and failed lookup no longer creates `.claw/sessions/<fingerprint>/`. `SessionStore::from_cwd`/`from_data_dir` now only derive the fingerprinted path; session save remains responsible for creating it. Global `--compact` no longer starts the REPL when it has no prompt or stdin: it returns typed `missing_argument` with `argument:"prompt or subcommand"`. `claw --compact "hello"` remains shorthand prompt mode and reaches provider/auth validation rather than command-not-found. Regression coverage: `session_store_from_cwd_is_side_effect_free_until_save`, `resume_latest_missing_session_fails_without_creating_session_dirs_435`, `compact_flag_missing_argument_and_shorthand_prompt_contract_435`, and `parses_compact_flag_for_prompt_mode`; broader checks reran `runtime session_control`, `resume_slash_commands`, `output_format_contract`, `claw` bin tests, `cargo fmt --all -- --check`, `scripts/roadmap-check-ids.sh`, `git diff --check`, and `cargo build --workspace --locked`.
|
||||
|
||||
|
||||
436. **`claw init` shipped `.claw.json` template explicitly sets `permissions.defaultMode:"dontAsk"` — every user who runs `claw init` gets a config file that disables permission prompts by default; sibling: `init` creates an empty `.claw/` directory with no settings.json template inside, and when `.claw/` already exists it skips the whole artifact (no settings template materialized)** — dogfooded 2026-05-11 by Jobdori on `b8f989b6` in response to Clawhip pinpoint nudge at `1503313241751949335`. Reproduction: `mkdir /tmp/probe && cd /tmp/probe && claw init --output-format json` returns `artifacts:[{name:".claw/",status:"created"},{name:".claw.json",status:"created"},...]`. Inspecting the created `.claw.json`: `{"permissions":{"defaultMode":"dontAsk"}}`. This is the polar opposite of safe-by-default: every user who follows the documented onboarding flow (`claw init` after `curl install.sh`) ships their workspace with permission prompts disabled. Compounds with **#428** (default runtime permission_mode is `danger-full-access`) — between the runtime default and the init template, a fresh claw setup has zero user-facing safety friction. **Sibling: `.claw/` artifact is an empty directory.** After `claw init`, `find .claw -type f` returns nothing. No `settings.json`, no template, no scaffolding — just `mkdir .claw`. The `--help` description implies init produces a usable workspace, but `.claw/settings.json` (the project-scope counterpart of `~/.claw/settings.json`) is never templated. **Sibling: `.claw/` skip-on-exists drops the entire artifact.** If `.claw/` already exists (e.g., from a partial setup, a `--resume` failure side effect per #435, or manual creation), `claw init` returns `.claw/: skipped` and does not materialize any expected sub-content. The other artifacts (`.claw.json`, `.gitignore`, `CLAUDE.md`) are still created, but a future `claw skills install` or `claw plugins enable` may expect `.claw/` to contain template files that are now missing. **Required fix shape:** (a) the shipped `.claw.json` template must default to `permissions.defaultMode:"acceptEdits"` or `"plan"` (safe-by-default modes per #428 spec) — `"dontAsk"` requires explicit opt-in; (b) `claw init` must materialize `.claw/settings.json` with documented schema defaults inside `.claw/` so the directory is useful on its own; (c) when `.claw/` already exists, `init` must report `partial` status (not `skipped`) and still try to create missing sub-files like `.claw/settings.json` without overwriting existing files; (d) emit per-sub-file artifact entries for `.claw/settings.json` and `.claw/sessions/` (skipped status if absent, deferred-to-first-save acceptable) so automation knows what's present; (e) regression test: `claw init` produces a `.claw.json` whose `permissions.defaultMode` is NOT `dontAsk`; `.claw/` contains at least one templated file. **Why this matters:** init is the primary onboarding surface. Every first-time user piping `curl install.sh | sh && claw init` gets a workspace pre-configured to skip permission prompts — and that workspace gets committed to the user's repo via the `init`-added entry. The `.claw/` empty-directory bug means feature discovery (skills, plugins) lacks the scaffolding it implies. Cross-references #428 (runtime default permission_mode), #50/#87/#91/#94/#97/#101/#106/#115/#123 (permission-rule audit), #435 (filesystem side effects on failed resume). Source: Jobdori live dogfood, `b8f989b6`, 2026-05-11.
|
||||
436. **DONE — `claw init` scaffolds safe project settings and reports partial/deferred artifacts** — fixed 2026-06-04 in `fix: scaffold safe init settings`. The starter `.claw.json` and new `.claw/settings.json` template now both use `permissions.defaultMode:"acceptEdits"` instead of unsafe `dontAsk`. Fresh init materializes `.claw/settings.json`, keeps `.claw/sessions/` deferred until the first successful session save, and emits per-artifact entries for `.claw/`, `.claw/settings.json`, `.claw/sessions/`, `.claw.json`, `.gitignore`, and `CLAUDE.md`. When `.claw/` already exists but its settings template is missing, init creates `.claw/settings.json` without overwriting existing files and reports `.claw/` as `partial` rather than `skipped`; idempotent reruns keep existing artifacts skipped and session storage deferred. JSON init output now includes `partial[]` and `deferred[]` alongside `created[]`, `updated[]`, and `skipped[]`, and init help/USAGE document the artifact statuses. Regression coverage: `initialize_repo_creates_expected_files_and_gitignore_entries`, `initialize_repo_is_idempotent_and_preserves_existing_files`, `artifacts_with_status_partitions_fresh_and_idempotent_runs`, and `init_json_envelope_has_hint_and_already_initialized_783`.
|
||||
|
||||
|
||||
437. **`version --output-format json` omits build provenance fields — no `is_dirty`, `branch`, `commit_date`, `commit_timestamp`, `rustc_version`; `git_sha` is truncated to 7 chars instead of full 40-char hash; sibling: `executable_path` leaks the build host's path (`/tmp/claw-dog-0530/...`) into runtime output** — dogfooded 2026-05-11 by Jobdori on `8cf628a5` in response to Clawhip pinpoint nudge at `1503320791582900344`. Reproduction: `claw version --output-format json` returns `{"build_date":"2026-05-11","executable_path":"/tmp/claw-dog-0530/rust/target/release/claw","git_sha":"b98b9a7","kind":"version","message":"Claw Code\n Version 0.1.0\n Git SHA b98b9a7\n Target aarch64-apple-darwin\n Build date 2026-05-11","target":"aarch64-apple-darwin","version":"0.1.0"}`. Critical provenance fields missing: (a) **`is_dirty`** — was the working tree clean at build time? Automation that pins on build provenance cannot tell if the binary was built from a clean commit or includes uncommitted changes; (b) **`branch`** — was this built from `main`, `dev/rust`, a release tag, or a feature branch? The `git_sha` alone doesn't reveal the integration point; (c) **`commit_date` / `commit_timestamp`** — only `build_date` (when the binary was compiled) is exposed; the commit itself might be days/weeks older if the build happened later. Reproducibility audits need both; (d) **`rustc_version`** — what Rust compiler version produced this binary? Critical for security advisories (e.g., known regressions in specific rustc versions); (e) **`git_sha` truncated to 7 chars** ("b98b9a7" instead of full "b98b9a71..."): 7-char shas have known collision rates in large repos and prevent unambiguous git rev-parse round-trip. **Sibling: `executable_path` leaks build-host path.** The `executable_path` field returns `/tmp/claw-dog-0530/rust/target/release/claw` — the directory where the binary was compiled, embedded into the binary metadata. For a binary copied/installed/symlinked to a different location, this field still reports the build path, not the actual invocation path. Either the field should reflect the runtime path via `std::env::current_exe()` at runtime (not compile-time), or it should be dropped to avoid leaking compile-host filesystem layout. **Sibling: prose `message` field duplicates structured data.** The `message` field still contains the entire text-mode prose version block (`"Claw Code\n Version 0.1.0\n Git SHA b98b9a7\n..."`) — every field present as structured JSON (`version`, `git_sha`, `target`, `build_date`) is also embedded in the prose. Same issue as #391 (`version json includes prose message field`) which was closed as "fixed" — the prose remains. **Required fix shape:** (a) add `is_dirty:bool`, `branch:string|null`, `commit_date:string` (ISO-8601), `commit_timestamp:int` (Unix epoch), `rustc_version:string` to the JSON envelope; (b) preserve full 40-char `git_sha` and add `git_sha_short:string` as a derived field if 7-char form is needed for UX; (c) `executable_path` should be `std::env::current_exe()` at runtime, not the compile-time path; (d) drop the prose `message` field from JSON or rename it `human_readable:string` and make it explicitly secondary to the structured fields; (e) re-verify #391 closure — the prose `message` is still present, the fix didn't fully land. **Why this matters:** version surface is the canonical provenance probe for security audits, build reproducibility, and bug-report metadata. Missing `is_dirty` means automated triage cannot distinguish "issue against a clean main commit" from "issue against a developer's uncommitted hack". Truncated `git_sha` blocks unambiguous git lookup. Leaked `executable_path` exposes build-host layout. Cross-references #391 (version prose duplication — apparently not fully fixed), #334 (version json omits build_date — fixed, but partial scope), #100 (commit identity audit). Source: Jobdori live dogfood, `8cf628a5`, 2026-05-11.
|
||||
437. **DONE — `version --output-format json` exposes complete build provenance without duplicating prose** — fixed 2026-06-04 in `fix: expose complete version provenance`. Build metadata now records the full 40-character `git_sha`, separate derived `git_sha_short`, `is_dirty`, `branch`, ISO-8601 `commit_date`, Unix `commit_timestamp`, `rustc_version`, target, and build date. Version JSON exposes those fields at top level and mirrors them under `binary_provenance`; `workspace_git_sha` is also a full SHA and `workspace_match` now compares full commit identities. `executable_path` is resolved at runtime with `std::env::current_exe()` instead of reporting a compile-host path. The prose report is no longer duplicated in JSON as `message`; JSON callers get the secondary text block as `human_readable`. Docs in `USAGE.md` and `rust/README.md` describe the provenance contract. Regression coverage: `version_emits_json_when_requested`, `version_status_doctor_include_binary_provenance_797`, `resumed_version_and_init_emit_structured_json_when_requested`, and `resumed_version_command_emits_structured_json`.
|
||||
|
||||
|
||||
438. **Memory file discovery only recognizes `CLAUDE.md` — `AGENTS.md` (industry convention used by OpenCode/Codex/Aider/Cursor) and `CLAW.md` (project's own brand name) are silently ignored despite being present in the workspace** — dogfooded 2026-05-11 by Jobdori on `d3a982dd` in response to Clawhip pinpoint nudge at `1503328341422244012`. Reproduction (fresh empty dir, isolated `CLAW_CONFIG_HOME`): create three files in cwd — `CLAUDE.md` (marker `MARKER-FROM-CLAUDE-MD`), `AGENTS.md` (marker `MARKER-FROM-AGENTS-MD`), `CLAW.md` (marker `MARKER-FROM-CLAW-MD`). Run `claw status --output-format json` → `workspace.memory_file_count: 1`. Run `claw system-prompt --output-format json` and search the `message` field for each marker: only `MARKER-FROM-CLAUDE-MD` is found; `MARKER-FROM-AGENTS-MD` and `MARKER-FROM-CLAW-MD` are absent. `claw-code` exclusively recognizes the Claude-branded filename inherited from upstream Claude Code; the project's own `CLAW.md` brand name and the cross-tool industry convention `AGENTS.md` are both silently dropped. **Three sibling implications:** (a) **brand-consistency gap**: a project rebranded from Claude Code to Claw Code that introduces `CLAUDE.md` as its only memory file is internally inconsistent. Users naturally expect `claw <subcommand>` to read `CLAW.md`. (b) **industry-convention gap**: `AGENTS.md` is the convergent convention for OpenCode (oh-my-opencode/sisyphus), OpenAI Codex CLI, Aider, Cursor, Continue.dev, and most ACP harnesses. Users with mixed-tool workflows maintain a shared `AGENTS.md` and expect every AI coding tool to honor it. (c) **silent failure mode**: there is no warning when `AGENTS.md` or `CLAW.md` exist but are not loaded. Users who copy-paste `AGENTS.md` from another tool's docs see `memory_file_count` stay at 0 or 1 and have to guess why their instructions aren't applied. **Required fix shape:** (a) discover and load **`CLAUDE.md`, `CLAW.md`, `AGENTS.md`** in that priority order (existing config-precedence pattern); (b) all three contribute to `memory_file_count` with `memory_files:[{path, source:"claude_md"|"claw_md"|"agents_md", chars}]` array exposed in `status --output-format json`; (c) when multiple files exist, merge or document the precedence: project-specific `CLAUDE.md`/`CLAW.md` overrides industry-shared `AGENTS.md`; (d) `claw doctor --output-format json` adds a `memory` check that warns when `AGENTS.md` exists but is not the loaded variant (alerting users that they may be relying on the wrong file); (e) regression test: workspace with all three files results in `memory_file_count >= 1` and the system prompt contains markers from at least the highest-precedence file. **Why this matters:** `AGENTS.md` is the lingua-franca instruction file for cross-tool AI coding workflows. A team using OpenCode for one project and Claw Code for another keeps their conventions in a shared `AGENTS.md`. Forcing them to also maintain a `CLAUDE.md` for claw-code (with identical content) is friction that breaks the value proposition of a fork. Cross-references #438 itself (the multi-file convention), and AGENTS.md ecosystem references in oh-my-opencode/sisyphus docs. Source: Jobdori live dogfood, `d3a982dd`, 2026-05-11.
|
||||
438. **DONE — memory discovery loads `CLAUDE.md`, `CLAW.md`, and `AGENTS.md` with structured provenance** — fixed 2026-06-04 in `fix: load Claw and Agents memory files`. Project memory discovery now checks root instruction files in `CLAUDE.md`, `CLAW.md`, then `AGENTS.md` order for each discovered directory, preserves existing scoped `.claw/CLAUDE.md`, `.claude/CLAUDE.md`, `.claw/instructions.md`, and rules-directory imports, and exposes each loaded file's `path`, `source`, `chars`, and `contributes` in `status --output-format json` as `workspace.memory_files[]`. `system-prompt --output-format json` returns the same memory metadata alongside the rendered `message`/`sections`, and all non-duplicate loaded files contribute to the prompt so CLAUDE/CLAW/AGENTS markers are visible together. `claw doctor --output-format json` now includes a dedicated `memory` check with loaded memory metadata and `unloaded_memory_files[]` warnings for present `CLAW.md`/`AGENTS.md` candidates that were skipped (for example empty or duplicate-content variants). Docs in `USAGE.md` and `rust/README.md` describe the priority and JSON contracts. Regression coverage: `discovers_claude_claw_agents_and_dot_claude_instruction_files_together`, `memory_files_load_claude_claw_agents_and_surface_json_438`, and `memory_health_surfaces_loaded_and_unloaded_files_438`.
|
||||
|
||||
|
||||
439. **Memory file discovery walks ALL ancestor directories up to `$HOME` boundary, silently loading any `CLAUDE.md` it finds — `/tmp/CLAUDE.md` left from a previous test silently bleeds into every project under `/tmp/*/`; no `--no-parent-memory` flag, no `.no-claude-md-boundary` marker file to limit discovery scope** — dogfooded 2026-05-11 by Jobdori on `f4a96740` in response to Clawhip pinpoint nudge at `1503335892461293675`. Reproduction: create three nested `CLAUDE.md` files with unique markers — `/tmp/claw-nested-probe/CLAUDE.md` (`PARENT_CLAUDE`), `subproj/CLAUDE.md` (`CHILD_CLAUDE`), `subproj/deep/CLAUDE.md` (`DEEP_CLAUDE`). Run `claw system-prompt --output-format json` from `subproj/deep/nest/` (note: `nest` has no `CLAUDE.md`). The `message` field contains **all three markers** (PARENT + CHILD + DEEP) and `status --output-format json` reports `memory_file_count: 3`. Boundary tests: (a) `$HOME/CLAUDE.md` is NOT picked up from `/tmp/no-claude-dir` (discovery stops at `$HOME` boundary, good); (b) From `/tmp/deep` (no nested CLAUDE.md), `/tmp/CLAUDE.md` IS picked up (count: 1); (c) git-root is NOT a discovery boundary — running from a git subdir still walks above the git root. **Ambient-context-bleed footgun:** any stale `/tmp/CLAUDE.md` (or `/home/<user>/projects/CLAUDE.md`, or any ancestor-path CLAUDE.md left over from a previous experiment, copy-paste, or AI-generated example) silently bleeds into every workspace nested below it. The user has no signal in `status --output-format json` indicating which ancestor file is contributing — only the aggregate `memory_file_count`. **Three required fixes:** (a) **expose discovery list**: `status --output-format json` and `system-prompt --output-format json` must include `memory_files:[{path, source:"workspace"|"ancestor"|"parent_dir"|"home", chars, contributes:bool}]` so users can see what's leaking in; (b) **add `--no-parent-memory` flag** to limit discovery to cwd only (no ancestor walk), or add a boundary marker (`.claude-no-walk`, `.claw-root`, or honor `.git` as the boundary by default — most users expect repo-root scope); (c) **`doctor` warns** when ancestor `CLAUDE.md` files are loaded from outside the current git repo (suggests they may be unintentional). **Sibling discovery scope question:** discovery walks up to `$HOME` — but for a user with a project at `/Users/foo/work/proj`, that's `/Users/foo/work/CLAUDE.md` + `/Users/foo/CLAUDE.md` (if it exists) both load. The home boundary is exclusive, but the entire `/Users/foo` tree under home is in scope. **Why this matters:** test workspaces, scratch dirs, AI-generated example projects, and shared `/tmp` workdirs are full of stale `CLAUDE.md` files. The current discovery rule means every claw invocation can silently inherit context from arbitrary ancestor paths. Cross-references #438 (memory discovery only finds CLAUDE.md, not AGENTS.md or CLAW.md), #421 (cwd canonicalization leak — the canonicalized form determines which ancestor walk path is used). Source: Jobdori live dogfood, `f4a96740`, 2026-05-11.
|
||||
439. **DONE — memory discovery is git-root bounded and reports memory origins** — fixed 2026-06-04 in `fix: bound parent memory discovery`. Project memory discovery now walks only from the current directory up to the nearest git root when one exists, and otherwise stays cwd-local, so stale parent `CLAUDE.md` files outside the project no longer bleed into scratch workspaces. Loaded memory JSON in both `status --output-format json` and `system-prompt --output-format json` now includes `origin`, `scope_path`, and `outside_project` alongside `path`, `source`, `chars`, and `contributes`; origins distinguish `workspace`, `parent_dir`, `ancestor`, `home`, and `outside_project`. The `memory` doctor check warns if an outside-project memory file is ever loaded, while still listing loaded and skipped memory candidates structurally. Docs in `USAGE.md` and `rust/README.md` describe the git-root boundary and expanded JSON fields. Regression coverage: `discovery_stops_at_git_root_boundary_439`, `discovery_without_git_root_stays_cwd_local_439`, and `memory_discovery_stops_at_git_root_and_reports_origins_439`.
|
||||
|
||||
|
||||
440. **One invalid `mcpServers` entry blocks ALL OTHER valid MCP servers from loading — `mcp list --output-format json` returns `configured_servers: 0, servers: []` when even one server has a missing/invalid `command` field, despite other servers in the same config being well-formed; sibling: config parser halts on first invalid entry, never reports the remaining invalid entries** — dogfooded 2026-05-11 by Jobdori on `bd126905` in response to Clawhip pinpoint nudge at `1503343442904879156`. Reproduction: write `.claw.json` containing six `mcpServers` entries — one valid (`valid-server: {command:"/bin/echo", args:["hello"]}`) and five with progressive defects (missing-command, empty-command, null-command, wrong-type-command, extra-unknown-field). Run `claw mcp list --output-format json` → `{"action":"list","config_load_error":"/private/tmp/claw-mcp-probe/.claw.json: mcpServers.missing-command-server: missing string field command","configured_servers":0,"kind":"mcp","servers":[],"status":"degraded"}`. The error mentions only `missing-command-server` (the first invalid entry in JSON-object iteration order); the other four invalid entries are never surfaced. The valid `valid-server` entry is silently dropped because the parser bails on the first error. `status --output-format json` correctly propagates the same `config_load_error` and sets `status:"degraded"`, but no field tells automation which servers are valid vs broken — `servers:[]` is the only signal. **Three problems compounded:** (a) **all-or-nothing loading**: ROADMAP product principle #5 says "partial success is first-class," but mcp config loading is binary. One bad server kills the entire MCP plane; (b) **first-error-only reporting**: a `.claw.json` with five invalid entries surfaces only one error message — the user fixes that one and runs again, gets the next error, and so on. Five iterations needed to discover all errors; (c) **no per-server status**: even with the partial-success fix, the JSON envelope needs `servers:[{name, valid:bool, error?, command?, args?}]` so automation can see which entries are usable. **Required fix shape:** (a) the MCP config parser must collect ALL invalid entries into an `invalid_servers:[{name, error_field, reason}]` array and load all valid ones into `servers:[]`; do not abort on first error; (b) `configured_servers` reflects the count of *valid* loaded servers (not zero) when there are valid entries alongside invalid ones; (c) expose `total_configured:int` (count of entries in source `.claw.json`) AND `valid_count:int` (loaded), AND `invalid_count:int` (rejected) — three distinct counts; (d) `doctor --output-format json` adds an `mcp_validation` check that lists each invalid entry with its error message; (e) regression test: `.claw.json` with one valid + one invalid entry results in `configured_servers: 1, invalid_servers: [{name:"...", reason:"..."}]`. **Why this matters:** users iterate on MCP server lists during onboarding — one typo kills the entire plane, including servers they got working previously. The first-error-only reporting forces N iterations through N invalid entries instead of a single fix-everything-at-once pass. Cross-references #407 (config files no load_error per-file), #415 (config section merged_keys count only), #416 (plugins list prose), #428 (default permission mode), and Product Principle #5. Source: Jobdori live dogfood, `bd126905`, 2026-05-11.
|
||||
440. **DONE — invalid `mcpServers` siblings no longer drop valid MCP servers** — fixed 2026-06-04 in `fix: load partial MCP configs`. MCP config loading now records every invalid server entry as `invalid_servers:[{name, scope, path, error_field, reason, valid:false}]` while retaining valid siblings in `servers[]`; valid entries carry `valid:true`, `configured_servers` and `valid_count` report loaded valid servers, `invalid_count` reports rejected entries, and `total_configured` reports all discovered entries. `status --output-format json` mirrors the `mcp_validation` summary, and `doctor --output-format json` includes an `mcp validation` check for one-pass repair. Empty stdio commands and unknown per-transport fields are per-server validation errors instead of global config failures. Regression coverage: `loads_valid_mcp_servers_and_collects_all_invalid_siblings_440`, `records_invalid_mcp_server_shapes_without_rejecting_config_440`, `mcp_loads_valid_servers_and_reports_invalid_siblings_440`, and `mcp_degraded_config_and_failed_usage_are_distinct_json_contracts`.
|
||||
|
||||
|
||||
441. **`hooks` config schema diverges from Claude Code documented format — claw-code expects `{"hooks":{"PreToolUse":["command-string"]}}` (array of command strings) while Claude Code documentation specifies `{"hooks":{"PreToolUse":[{"matcher":"Read","hooks":[{"type":"command","command":"..."}]}]}}` (structured matcher objects); users copy-pasting from Claude Code docs see `field "hooks.PreToolUse" must be an array of strings`** — dogfooded 2026-05-11 by Jobdori on `86ff83c2` in response to Clawhip pinpoint nudge at `1503350990680887418`. Reproduction: write `.claw.json` with the Claude-Code-documented hook format `{"hooks":{"PreToolUse":[{"matcher":"Read","hooks":[{"type":"command","command":"/bin/echo pretool"}]}]}}`. Run `claw status --output-format json` → `config_load_error: "/private/tmp/claw-hook-probe/.claw.json: field \"hooks.PreToolUse\" must be an array of strings, got an array (line 3)"`, `status: "degraded"`. The error wording ("must be an array of strings, got an array") is confusingly tautological — the user did provide an array; the parser objects that the array contains objects instead of strings. Replacing with the claw-code-actual format `{"hooks":{"PreToolUse":["/bin/echo pretool"]}}` succeeds: `config_load_error: null, status: "ok"`. The two formats are fundamentally incompatible: claw-code drops the `matcher` field (no tool-specific filtering at the config layer), drops the `type:"command"` discriminator (no future expansion to other hook types), and treats each entry as a bare command string instead of a structured hook spec. **Sibling: PR #3000 (justcode049) was attempting to tolerate object-style hook entries** — that PR's title `fix: tolerate object-style hook entries in config parser` confirms this is a known user complaint, but the PR is still conflicting and unmerged. **Three sibling findings in same probe:** (a) **unknown event names reject entire hooks config**: `.claw.json` with `hooks.InvalidEvent` (not a real event name like `PreToolUse`/`PostToolUse`/`Stop`/`Notification`) triggers `config_load_error: "unknown key \"hooks.InvalidEvent\""` and rejects ALL hooks in the same file, even valid ones — same "one bad apple kills all" pattern as #440 (MCP servers). (b) **`kind:"unknown"` for the validation error** — should be `kind:"invalid_hooks_config"` or `kind:"unknown_hook_event"` (catch-all cluster #422/#423/#424/#428/#430/#431/#432/#433/#435 — 13th occurrence). (c) **first-error-only halting**: a `.claw.json` with `hooks.Stop:"not-an-array"` (type mismatch) AND `hooks.InvalidEvent` (unknown name) AND `hooks.Notification:[{}]` (empty entry) surfaces only the FIRST error in iteration order — user must fix one at a time across 3 iterations. **Required fix shape:** (a) **adopt Claude Code's structured hook format as the canonical**: support `{matcher, hooks:[{type, command}]}` natively, with `matcher` for tool-filtering, `type` for hook-type discriminator (future-proof for `inline`/`webhook`/etc beyond just `command`); (b) **keep backward compat for bare command strings**: legacy `["command-string"]` arrays still load, but emit a deprecation warning suggesting migration to the structured form; (c) **partial-success loading**: invalid hook entries surface in `invalid_hooks:[{event, index, reason}]` while valid ones load — same fix as #440 for MCP; (d) **typed `kind:"invalid_hooks_config"` envelope** instead of `kind:"unknown"`; (e) **rebase and merge PR #3000** which addresses this directly; (f) regression test: Claude-Code-documented hook config loads without error on claw-code. **Why this matters:** users migrating from Claude Code to Claw Code hit this on their first `.claw.json` write. The error message ("array of strings, got an array") is unhelpful; the documentation doesn't surface the schema divergence; and Claude Code's structured format is strictly more expressive (matchers, types) than claw-code's bare-string format. Cross-references #407 (config files no load_error), #410 (list-envelope schema drift), #428 (default permission mode), #440 (one invalid MCP entry blocks all), PR #3000 (justcode049's pending fix). Source: Jobdori live dogfood, `86ff83c2`, 2026-05-11.
|
||||
|
||||
37
USAGE.md
37
USAGE.md
@@ -51,26 +51,27 @@ cd rust
|
||||
```
|
||||
|
||||
**Note:** Diagnostic verbs (`doctor`, `status`, `sandbox`, `version`) support `--output-format json` for machine-readable output. Invalid suffix arguments (e.g., `--json`) are now rejected at parse time rather than falling through to prompt dispatch.
|
||||
`version --output-format json` reports structured build provenance including full `git_sha`, derived `git_sha_short`, `is_dirty`, `branch`, `commit_date`, `commit_timestamp`, `rustc_version`, runtime `executable_path`, and `binary_provenance`; JSON keeps the prose report in `human_readable` instead of duplicating it under `message`. `status --output-format json` exposes `workspace.memory_files[]` with `path`, `source`, `origin`, `scope_path`, `outside_project`, `chars`, and `contributes` for every loaded project memory file.
|
||||
|
||||
### Initialize a repository
|
||||
|
||||
Set up a new repository with `.claw` config, `.claw.json`, `.gitignore` entries, and a `CLAUDE.md` guidance file:
|
||||
Set up a new repository with `.claw/settings.json`, `.claw.json`, `.gitignore` entries, and a `CLAUDE.md` guidance file:
|
||||
|
||||
```bash
|
||||
cd /path/to/your/repo
|
||||
./target/debug/claw init
|
||||
```
|
||||
|
||||
Text mode (human-readable) shows artifact creation summary with project path and next steps. Idempotent — running multiple times in the same repo marks already-created files as "skipped".
|
||||
Text mode (human-readable) shows artifact creation summary with project path and next steps. Idempotent — running multiple times in the same repo marks already-created files as "skipped", reports `.claw/` as "partial" when missing sub-files are materialized, and keeps `.claw/sessions/` deferred until the first successful session save.
|
||||
|
||||
JSON mode for scripting:
|
||||
```bash
|
||||
./target/debug/claw init --output-format json
|
||||
```
|
||||
|
||||
Returns structured output with `project_path`, `created[]`, `updated[]`, `skipped[]` arrays (one per artifact), and `artifacts[]` carrying each file's `name` and machine-stable `status` tag. The legacy `message` field preserves backward compatibility.
|
||||
Returns structured output with `project_path`, `created[]`, `updated[]`, `partial[]`, `deferred[]`, and `skipped[]` arrays (one per artifact status), and `artifacts[]` carrying each file's `name` and machine-stable `status` tag. The legacy `message` field preserves backward compatibility.
|
||||
|
||||
**Why structured fields matter:** Claws can detect per-artifact state (`created` vs `updated` vs `skipped`) without substring-matching human prose. Use the `created[]`, `updated[]`, and `skipped[]` arrays for conditional follow-up logic (e.g., only commit if files were actually created, not just updated).
|
||||
**Why structured fields matter:** Claws can detect per-artifact state (`created`, `updated`, `partial`, `deferred`, or `skipped`) without substring-matching human prose. Use the status arrays for conditional follow-up logic (e.g., only commit if files were actually created, not just updated).
|
||||
|
||||
### Interactive REPL
|
||||
|
||||
@@ -569,6 +570,30 @@ Runtime config is loaded in this order, with later entries overriding earlier on
|
||||
|
||||
The list is also the precedence chain: project-local settings override project settings, project settings override the legacy project `.claw.json`, and project files override user files. `claw --output-format json config` includes each discovered file's `precedence_rank`, `wins_for_keys`, and `shadowed_keys` so automation can see which file controls each effective key without reimplementing the merge order.
|
||||
|
||||
## MCP server validation
|
||||
|
||||
`claw mcp --output-format json` loads valid `mcpServers` entries even when sibling entries are malformed. The JSON list envelope distinguishes the total configured entries from the valid and invalid subsets:
|
||||
|
||||
```json
|
||||
{
|
||||
"configured_servers": 1,
|
||||
"total_configured": 2,
|
||||
"valid_count": 1,
|
||||
"invalid_count": 1,
|
||||
"servers": [{ "name": "valid-server", "valid": true }],
|
||||
"invalid_servers": [
|
||||
{
|
||||
"name": "missing-command",
|
||||
"error_field": "command",
|
||||
"reason": ".claw.json: mcpServers.missing-command: missing string field command",
|
||||
"valid": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`status --output-format json` mirrors this under `mcp_validation`, and `doctor --output-format json` includes an `mcp validation` check so automation can repair every rejected server entry without losing usable MCP servers.
|
||||
|
||||
## Hook configuration
|
||||
|
||||
`hooks.PreToolUse`, `hooks.PostToolUse`, and `hooks.PostToolUseFailure` accept either legacy command strings or object-style entries with a `matcher` and nested command hooks:
|
||||
@@ -593,11 +618,13 @@ Object-style matchers are optional. When present, they match tool names case-ins
|
||||
|
||||
## 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:
|
||||
In addition to root instruction files such as `CLAUDE.md`, `CLAW.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.
|
||||
|
||||
Root instruction-file priority is `CLAUDE.md`, then `CLAW.md`, then `AGENTS.md` for each discovered directory. Discovery is bounded to the current git root when one exists, otherwise to the current directory only, so stale parent files outside the project do not silently bleed into the prompt. All loaded files contribute to the system prompt and to `status --output-format json` as `workspace.memory_files:[{path, source, origin, scope_path, outside_project, chars, contributes}]`; `claw doctor --output-format json` includes a `memory` check so automation can detect loaded and unexpected unloaded memory-file candidates without parsing prompt text.
|
||||
|
||||
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
|
||||
|
||||
@@ -87,7 +87,7 @@ Primary artifacts:
|
||||
| Sub-agent / agent surfaces | ✅ |
|
||||
| Todo tracking | ✅ |
|
||||
| Notebook editing | ✅ |
|
||||
| CLAUDE.md / project memory | ✅ |
|
||||
| CLAUDE.md / CLAW.md / AGENTS.md project memory | ✅ |
|
||||
| Config file hierarchy (`.claw.json` + merged config sections) | ✅ |
|
||||
| Permission system | ✅ |
|
||||
| MCP server lifecycle + inspection | ✅ |
|
||||
@@ -148,6 +148,9 @@ Top-level commands:
|
||||
|
||||
`claw acp` is a local discoverability surface for editor-first users: it reports the current ACP/Zed status without starting the runtime. As of April 16, 2026, claw-code does **not** ship an ACP/Zed daemon or JSON-RPC entrypoint yet, and `claw acp serve` is only a status alias until the real protocol surface lands. Status queries exit 0 and expose the same machine-readable contract via `--output-format json`; malformed ACP invocations exit 1 with `kind: unsupported_acp_invocation`.
|
||||
`--output-format` accepts `text` or `json` in any casing. `CLAW_OUTPUT_FORMAT=json` selects JSON as the default for non-interactive commands, explicit flags override it, repeated flags warn on stderr, and status JSON exposes `format_source`, `format_raw`, and `format_overridden`. Help and doctor output also surface `CLAW_LOG` / `RUST_LOG` as the logging environment knobs.
|
||||
`claw version --output-format json` is the provenance probe for automation: it reports full `git_sha`, derived `git_sha_short`, `is_dirty`, `branch`, `commit_date`, `commit_timestamp`, `rustc_version`, runtime `executable_path`, and `binary_provenance`; the text report is available as `human_readable` instead of a duplicate `message` field.
|
||||
`status --output-format json` reports loaded project memory files under `workspace.memory_files[]` with each file's `path`, `source` (`claude_md`, `claw_md`, `agents_md`, or scoped/rule sources), `origin`, `scope_path`, `outside_project`, `chars`, and `contributes`; `claw doctor --output-format json` includes a dedicated `memory` check. Root instruction-file priority is `CLAUDE.md`, then `CLAW.md`, then `AGENTS.md`, discovery is bounded to the current git root when present (otherwise cwd only), and all non-duplicate loaded files contribute to the rendered system prompt.
|
||||
`claw mcp --output-format json` reports partial MCP config success: valid servers remain in `servers[]` while malformed siblings appear in `invalid_servers[]`, with `total_configured`, `valid_count`, and `invalid_count` split out for automation. `status` mirrors this as `mcp_validation`, and doctor includes an `mcp validation` check.
|
||||
Shorthand prompt mode honors the POSIX `--` end-of-flags separator, so `claw -- "-prompt-with-dash"` and unknown dash-prefixed non-flag text stay on the prompt path instead of being treated as CLI options.
|
||||
`claw dump-manifests` is self-contained: it emits the Rust resolver inventory for the selected workspace (commands, tools, agents, skills, and bootstrap phases) without requiring an upstream Claude Code TypeScript checkout. Use `--manifests-dir PATH` only to scope resolver discovery to another directory.
|
||||
|
||||
|
||||
@@ -6,8 +6,9 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use plugins::{PluginError, PluginLoadFailure, PluginManager, PluginSummary};
|
||||
use runtime::{
|
||||
compact_session, CompactionConfig, ConfigLoader, ConfigSource, McpOAuthConfig, McpServerConfig,
|
||||
RuntimeConfig, ScopedMcpServerConfig, Session,
|
||||
compact_session, CompactionConfig, ConfigLoader, ConfigSource, McpConfigCollection,
|
||||
McpInvalidServerConfig, McpOAuthConfig, McpServerConfig, RuntimeConfig, ScopedMcpServerConfig,
|
||||
Session,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
@@ -3064,24 +3065,16 @@ fn render_mcp_report_for(
|
||||
}
|
||||
|
||||
match normalize_optional_args(args) {
|
||||
None | Some("list") => {
|
||||
// #144: degrade gracefully on config parse failure (same contract
|
||||
// as #143 for `status`). Text mode prepends a "Config load error"
|
||||
// block before the MCP list; the list falls back to empty.
|
||||
match loader.load() {
|
||||
Ok(runtime_config) => Ok(render_mcp_summary_report(
|
||||
cwd,
|
||||
runtime_config.mcp().servers(),
|
||||
)),
|
||||
Err(err) => {
|
||||
let empty = std::collections::BTreeMap::new();
|
||||
Ok(format!(
|
||||
"Config load error\n Status fail\n Summary runtime config failed to load; reporting partial MCP view\n Details {err}\n Hint `claw doctor` classifies config parse errors; fix the listed field and rerun\n\n{}",
|
||||
render_mcp_summary_report(cwd, &empty)
|
||||
))
|
||||
}
|
||||
None | Some("list") => match loader.load() {
|
||||
Ok(runtime_config) => Ok(render_mcp_summary_report(cwd, runtime_config.mcp())),
|
||||
Err(err) => {
|
||||
let empty = McpConfigCollection::default();
|
||||
Ok(format!(
|
||||
"Config load error\n Status fail\n Summary runtime config failed to load; reporting partial MCP view\n Details {err}\n Hint `claw doctor` classifies config parse errors; fix the listed field and rerun\n\n{}",
|
||||
render_mcp_summary_report(cwd, &empty)
|
||||
))
|
||||
}
|
||||
}
|
||||
},
|
||||
Some(args) if is_help_arg(args) => Ok(render_mcp_usage(None)),
|
||||
Some("show") => Ok(render_mcp_missing_argument_text("show")),
|
||||
Some(args) if args.split_whitespace().next() == Some("show") => {
|
||||
@@ -3100,7 +3093,7 @@ fn render_mcp_report_for(
|
||||
Ok(runtime_config) => Ok(render_mcp_server_report(
|
||||
cwd,
|
||||
server_name,
|
||||
runtime_config.mcp().get(server_name),
|
||||
runtime_config.mcp(),
|
||||
)),
|
||||
Err(err) => Ok(format!(
|
||||
"Config load error\n Status fail\n Summary runtime config failed to load; cannot resolve `{server_name}`\n Details {err}\n Hint `claw doctor` classifies config parse errors; fix the listed field and rerun"
|
||||
@@ -3162,35 +3155,38 @@ fn render_mcp_report_json_for(
|
||||
}
|
||||
|
||||
match normalize_optional_args(args) {
|
||||
None | Some("list") => {
|
||||
// #144: match #143's degraded envelope contract. On config parse
|
||||
// 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) {
|
||||
Ok(runtime_config) => {
|
||||
let mut value =
|
||||
render_mcp_summary_report_json(cwd, runtime_config.mcp().servers());
|
||||
if let Some(map) = value.as_object_mut() {
|
||||
map.insert("status".to_string(), Value::String("ok".to_string()));
|
||||
map.insert("config_load_error".to_string(), Value::Null);
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
Err(err) => {
|
||||
let empty = std::collections::BTreeMap::new();
|
||||
let mut value = render_mcp_summary_report_json(cwd, &empty);
|
||||
if let Some(map) = value.as_object_mut() {
|
||||
map.insert("status".to_string(), Value::String("degraded".to_string()));
|
||||
map.insert(
|
||||
"config_load_error".to_string(),
|
||||
Value::String(err.to_string()),
|
||||
);
|
||||
}
|
||||
Ok(value)
|
||||
None | Some("list") => match load_runtime_config_without_stderr_warnings(loader) {
|
||||
Ok(runtime_config) => {
|
||||
let mut value = render_mcp_summary_report_json(cwd, runtime_config.mcp());
|
||||
if let Some(map) = value.as_object_mut() {
|
||||
map.insert(
|
||||
"status".to_string(),
|
||||
Value::String(
|
||||
if runtime_config.mcp().has_invalid_servers() {
|
||||
"degraded"
|
||||
} else {
|
||||
"ok"
|
||||
}
|
||||
.to_string(),
|
||||
),
|
||||
);
|
||||
map.insert("config_load_error".to_string(), Value::Null);
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
let empty = McpConfigCollection::default();
|
||||
let mut value = render_mcp_summary_report_json(cwd, &empty);
|
||||
if let Some(map) = value.as_object_mut() {
|
||||
map.insert("status".to_string(), Value::String("degraded".to_string()));
|
||||
map.insert(
|
||||
"config_load_error".to_string(),
|
||||
Value::String(err.to_string()),
|
||||
);
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
},
|
||||
Some(args) if is_help_arg(args) => Ok(render_mcp_usage_json(None)),
|
||||
Some("show") => Ok(render_mcp_missing_argument_json("show")),
|
||||
Some(args) if args.split_whitespace().next() == Some("show") => {
|
||||
@@ -3205,16 +3201,21 @@ fn render_mcp_report_json_for(
|
||||
// #144: same degradation pattern for show action.
|
||||
match load_runtime_config_without_stderr_warnings(loader) {
|
||||
Ok(runtime_config) => {
|
||||
let mut value = render_mcp_server_report_json(
|
||||
cwd,
|
||||
server_name,
|
||||
runtime_config.mcp().get(server_name),
|
||||
);
|
||||
let mut value =
|
||||
render_mcp_server_report_json(cwd, server_name, runtime_config.mcp());
|
||||
if let Some(map) = value.as_object_mut() {
|
||||
// Only override status to "ok" if the server was found;
|
||||
// render_mcp_server_report_json already sets status:"error" for not-found.
|
||||
if map.get("found") == Some(&Value::Bool(true)) {
|
||||
map.insert("status".to_string(), Value::String("ok".to_string()));
|
||||
map.insert(
|
||||
"status".to_string(),
|
||||
Value::String(
|
||||
if runtime_config.mcp().has_invalid_servers() {
|
||||
"degraded"
|
||||
} else {
|
||||
"ok"
|
||||
}
|
||||
.to_string(),
|
||||
),
|
||||
);
|
||||
}
|
||||
map.insert("config_load_error".to_string(), Value::Null);
|
||||
}
|
||||
@@ -4426,55 +4427,80 @@ fn io_error_reason(error: &std::io::Error) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn render_mcp_summary_report(
|
||||
cwd: &Path,
|
||||
servers: &BTreeMap<String, ScopedMcpServerConfig>,
|
||||
) -> String {
|
||||
fn render_mcp_summary_report(cwd: &Path, mcp: &McpConfigCollection) -> String {
|
||||
let servers = mcp.servers();
|
||||
let mut lines = vec![
|
||||
"MCP".to_string(),
|
||||
format!(" Working directory {}", cwd.display()),
|
||||
format!(" Configured servers {}", servers.len()),
|
||||
format!(" Configured servers {}", mcp.valid_count()),
|
||||
format!(" Total entries {}", mcp.total_configured()),
|
||||
format!(" Invalid entries {}", mcp.invalid_count()),
|
||||
];
|
||||
if servers.is_empty() {
|
||||
lines.push(" No MCP servers configured.".to_string());
|
||||
return lines.join("\n");
|
||||
lines.push(" No valid MCP servers configured.".to_string());
|
||||
}
|
||||
|
||||
lines.push(String::new());
|
||||
for (name, server) in servers {
|
||||
lines.push(format!(
|
||||
" {name:<16} {transport:<13} {scope:<7} {summary}",
|
||||
transport = mcp_transport_label(&server.config),
|
||||
scope = config_source_label(server.scope),
|
||||
summary = mcp_server_summary(&server.config)
|
||||
));
|
||||
if !servers.is_empty() {
|
||||
lines.push(String::new());
|
||||
for (name, server) in servers {
|
||||
lines.push(format!(
|
||||
" {name:<16} {transport:<13} {scope:<7} {summary}",
|
||||
transport = mcp_transport_label(&server.config),
|
||||
scope = config_source_label(server.scope),
|
||||
summary = mcp_server_summary(&server.config)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if !mcp.invalid_servers().is_empty() {
|
||||
lines.push(String::new());
|
||||
lines.push(" Invalid MCP servers".to_string());
|
||||
for invalid in mcp.invalid_servers() {
|
||||
lines.push(format!(" - {}: {}", invalid.name, invalid.reason));
|
||||
}
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn render_mcp_summary_report_json(
|
||||
cwd: &Path,
|
||||
servers: &BTreeMap<String, ScopedMcpServerConfig>,
|
||||
) -> Value {
|
||||
fn render_mcp_summary_report_json(cwd: &Path, mcp: &McpConfigCollection) -> Value {
|
||||
json!({
|
||||
"kind": "mcp",
|
||||
"action": "list",
|
||||
"working_directory": cwd.display().to_string(),
|
||||
"configured_servers": servers.len(),
|
||||
"servers": servers
|
||||
"configured_servers": mcp.valid_count(),
|
||||
"total_configured": mcp.total_configured(),
|
||||
"valid_count": mcp.valid_count(),
|
||||
"invalid_count": mcp.invalid_count(),
|
||||
"invalid_servers": invalid_mcp_servers_json(mcp.invalid_servers()),
|
||||
"servers": mcp
|
||||
.servers()
|
||||
.iter()
|
||||
.map(|(name, server)| mcp_server_json(name, server))
|
||||
.collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
fn render_mcp_server_report(
|
||||
cwd: &Path,
|
||||
server_name: &str,
|
||||
server: Option<&ScopedMcpServerConfig>,
|
||||
) -> String {
|
||||
let Some(server) = server else {
|
||||
fn invalid_mcp_servers_json(invalid_servers: &[McpInvalidServerConfig]) -> Value {
|
||||
Value::Array(
|
||||
invalid_servers
|
||||
.iter()
|
||||
.map(|server| {
|
||||
json!({
|
||||
"name": &server.name,
|
||||
"scope": config_source_json(server.scope),
|
||||
"path": server.path.display().to_string(),
|
||||
"error_field": &server.error_field,
|
||||
"reason": &server.reason,
|
||||
"valid": false,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_mcp_server_report(cwd: &Path, server_name: &str, mcp: &McpConfigCollection) -> String {
|
||||
let Some(server) = mcp.get(server_name) else {
|
||||
return format!(
|
||||
"MCP\n Working directory {}\n Result server `{server_name}` is not configured",
|
||||
cwd.display()
|
||||
@@ -4552,9 +4578,9 @@ fn render_mcp_server_report(
|
||||
fn render_mcp_server_report_json(
|
||||
cwd: &Path,
|
||||
server_name: &str,
|
||||
server: Option<&ScopedMcpServerConfig>,
|
||||
mcp: &McpConfigCollection,
|
||||
) -> Value {
|
||||
match server {
|
||||
match mcp.get(server_name) {
|
||||
Some(server) => json!({
|
||||
"kind": "mcp",
|
||||
"action": "show",
|
||||
@@ -4562,6 +4588,10 @@ fn render_mcp_server_report_json(
|
||||
"working_directory": cwd.display().to_string(),
|
||||
"found": true,
|
||||
"server": mcp_server_json(server_name, server),
|
||||
"total_configured": mcp.total_configured(),
|
||||
"valid_count": mcp.valid_count(),
|
||||
"invalid_count": mcp.invalid_count(),
|
||||
"invalid_servers": invalid_mcp_servers_json(mcp.invalid_servers()),
|
||||
}),
|
||||
None => json!({
|
||||
"kind": "mcp",
|
||||
@@ -4574,6 +4604,10 @@ fn render_mcp_server_report_json(
|
||||
"message": format!("server `{server_name}` is not configured"),
|
||||
// #761: hint so callers know how to enumerate configured MCP servers
|
||||
"hint": "Run `claw mcp list` to see configured servers.",
|
||||
"total_configured": mcp.total_configured(),
|
||||
"valid_count": mcp.valid_count(),
|
||||
"invalid_count": mcp.invalid_count(),
|
||||
"invalid_servers": invalid_mcp_servers_json(mcp.invalid_servers()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -4967,6 +5001,7 @@ fn mcp_server_details_json(config: &McpServerConfig) -> Value {
|
||||
fn mcp_server_json(name: &str, server: &ScopedMcpServerConfig) -> Value {
|
||||
json!({
|
||||
"name": name,
|
||||
"valid": true,
|
||||
"required": server.required,
|
||||
"scope": config_source_json(server.scope),
|
||||
"transport": mcp_transport_json(&server.config),
|
||||
@@ -6619,12 +6654,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_degrades_gracefully_on_malformed_mcp_config_144() {
|
||||
// #144: mirror of #143's partial-success contract for `claw mcp`.
|
||||
// Previously `mcp` hard-failed on any config parse error, hiding
|
||||
// well-formed servers and forcing claws to fall back to `doctor`.
|
||||
// Now `mcp` emits a degraded envelope instead: exit 0, status:
|
||||
// "degraded", config_load_error populated, servers[] empty.
|
||||
fn mcp_loads_valid_servers_and_reports_invalid_siblings_440() {
|
||||
// #440: invalid sibling MCP entries must not drop valid servers, and
|
||||
// the JSON envelope must expose all rejected entries for one-pass repair.
|
||||
let _guard = env_guard();
|
||||
let workspace = temp_dir("mcp-degrades-144");
|
||||
let config_home = temp_dir("mcp-degrades-144-cfg");
|
||||
@@ -6654,17 +6686,19 @@ mod tests {
|
||||
Some("degraded"),
|
||||
"top-level status should be 'degraded': {list}"
|
||||
);
|
||||
let err = list["config_load_error"]
|
||||
assert!(list["config_load_error"].is_null());
|
||||
assert_eq!(list["configured_servers"], 1);
|
||||
assert_eq!(list["total_configured"], 2);
|
||||
assert_eq!(list["valid_count"], 1);
|
||||
assert_eq!(list["invalid_count"], 1);
|
||||
assert_eq!(list["servers"][0]["name"], "everything");
|
||||
assert_eq!(list["servers"][0]["valid"], true);
|
||||
assert_eq!(list["invalid_servers"][0]["name"], "missing-command");
|
||||
assert!(list["invalid_servers"][0]["reason"]
|
||||
.as_str()
|
||||
.expect("config_load_error must be a string on degraded runs");
|
||||
assert!(
|
||||
err.contains("mcpServers.missing-command"),
|
||||
"config_load_error should name the malformed field path: {err}"
|
||||
);
|
||||
assert_eq!(list["configured_servers"], 0);
|
||||
assert!(list["servers"].as_array().unwrap().is_empty());
|
||||
.is_some_and(|reason| reason.contains("missing string field command")));
|
||||
|
||||
// show action: should also degrade (not hard-fail).
|
||||
// show action still resolves valid siblings while carrying validation metadata.
|
||||
let show = render_mcp_report_json_for(&loader, &workspace, Some("show everything"))
|
||||
.expect("mcp show should not hard-fail on config parse errors (#144)");
|
||||
assert_eq!(show["kind"], "mcp");
|
||||
@@ -6674,7 +6708,11 @@ mod tests {
|
||||
Some("degraded"),
|
||||
"show action should also report status: 'degraded': {show}"
|
||||
);
|
||||
assert!(show["config_load_error"].is_string());
|
||||
assert!(show["config_load_error"].is_null());
|
||||
assert_eq!(show["found"], true);
|
||||
assert_eq!(show["server"]["name"], "everything");
|
||||
assert_eq!(show["server"]["valid"], true);
|
||||
assert_eq!(show["invalid_count"], 1);
|
||||
|
||||
// Clean path: status: "ok", config_load_error: null.
|
||||
let clean_ws = temp_dir("mcp-degrades-144-clean");
|
||||
|
||||
@@ -207,9 +207,19 @@ pub struct RuntimePermissionRuleConfig {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct McpConfigCollection {
|
||||
servers: BTreeMap<String, ScopedMcpServerConfig>,
|
||||
invalid_servers: Vec<McpInvalidServerConfig>,
|
||||
total_configured: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct McpInvalidServerConfig {
|
||||
pub name: String,
|
||||
pub scope: ConfigSource,
|
||||
pub path: PathBuf,
|
||||
pub error_field: String,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
/// MCP server config paired with the scope that defined it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ScopedMcpServerConfig {
|
||||
pub required: bool,
|
||||
@@ -386,7 +396,7 @@ impl ConfigLoader {
|
||||
pub fn load(&self) -> Result<RuntimeConfig, ConfigError> {
|
||||
let mut merged = BTreeMap::new();
|
||||
let mut loaded_entries = Vec::new();
|
||||
let mut mcp_servers = BTreeMap::new();
|
||||
let mut mcp = McpConfigCollection::default();
|
||||
let mut all_warnings = Vec::new();
|
||||
|
||||
for entry in self.discover() {
|
||||
@@ -405,7 +415,7 @@ impl ConfigLoader {
|
||||
}
|
||||
all_warnings.extend(validation.warnings);
|
||||
validate_optional_hooks_config(&parsed.object, &entry.path)?;
|
||||
merge_mcp_servers(&mut mcp_servers, entry.source, &parsed.object, &entry.path)?;
|
||||
merge_mcp_servers(&mut mcp, entry.source, &parsed.object, &entry.path)?;
|
||||
deep_merge_objects(&mut merged, &parsed.object);
|
||||
loaded_entries.push(entry);
|
||||
}
|
||||
@@ -414,7 +424,7 @@ impl ConfigLoader {
|
||||
emit_config_warning_once(&warning.to_string());
|
||||
}
|
||||
|
||||
build_runtime_config(merged, loaded_entries, mcp_servers)
|
||||
build_runtime_config(merged, loaded_entries, mcp)
|
||||
}
|
||||
|
||||
/// Like [`load`] but also returns the list of validation warnings collected during
|
||||
@@ -425,7 +435,7 @@ impl ConfigLoader {
|
||||
pub fn load_collecting_warnings(&self) -> Result<(RuntimeConfig, Vec<String>), ConfigError> {
|
||||
let mut merged = BTreeMap::new();
|
||||
let mut loaded_entries = Vec::new();
|
||||
let mut mcp_servers = BTreeMap::new();
|
||||
let mut mcp = McpConfigCollection::default();
|
||||
let mut all_warnings: Vec<String> = Vec::new();
|
||||
|
||||
for entry in self.discover() {
|
||||
@@ -444,12 +454,12 @@ impl ConfigLoader {
|
||||
}
|
||||
all_warnings.extend(validation.warnings.iter().map(|w| w.to_string()));
|
||||
validate_optional_hooks_config(&parsed.object, &entry.path)?;
|
||||
merge_mcp_servers(&mut mcp_servers, entry.source, &parsed.object, &entry.path)?;
|
||||
merge_mcp_servers(&mut mcp, entry.source, &parsed.object, &entry.path)?;
|
||||
deep_merge_objects(&mut merged, &parsed.object);
|
||||
loaded_entries.push(entry);
|
||||
}
|
||||
|
||||
let config = build_runtime_config(merged, loaded_entries, mcp_servers)?;
|
||||
let config = build_runtime_config(merged, loaded_entries, mcp)?;
|
||||
Ok((config, all_warnings))
|
||||
}
|
||||
|
||||
@@ -462,7 +472,7 @@ impl ConfigLoader {
|
||||
pub fn inspect_collecting_warnings(&self) -> ConfigInspection {
|
||||
let mut merged = BTreeMap::new();
|
||||
let mut loaded_entries = Vec::new();
|
||||
let mut mcp_servers = BTreeMap::new();
|
||||
let mut mcp = McpConfigCollection::default();
|
||||
let mut warnings = Vec::new();
|
||||
let mut files = Vec::new();
|
||||
let mut load_error = None;
|
||||
@@ -546,7 +556,7 @@ impl ConfigLoader {
|
||||
}
|
||||
|
||||
if let Err(error) =
|
||||
merge_mcp_servers(&mut mcp_servers, entry.source, &parsed.object, &entry.path)
|
||||
merge_mcp_servers(&mut mcp, entry.source, &parsed.object, &entry.path)
|
||||
{
|
||||
let detail = error.to_string();
|
||||
load_error.get_or_insert_with(|| detail.clone());
|
||||
@@ -567,7 +577,7 @@ impl ConfigLoader {
|
||||
|
||||
annotate_config_file_precedence(&mut files);
|
||||
|
||||
let runtime_config = match build_runtime_config(merged, loaded_entries, mcp_servers) {
|
||||
let runtime_config = match build_runtime_config(merged, loaded_entries, mcp) {
|
||||
Ok(config) => Some(config),
|
||||
Err(error) => {
|
||||
load_error.get_or_insert_with(|| error.to_string());
|
||||
@@ -703,16 +713,14 @@ fn collect_config_key_paths_for_value(prefix: &str, value: &JsonValue, keys: &mu
|
||||
fn build_runtime_config(
|
||||
merged: BTreeMap<String, JsonValue>,
|
||||
loaded_entries: Vec<ConfigEntry>,
|
||||
mcp_servers: BTreeMap<String, ScopedMcpServerConfig>,
|
||||
mcp: McpConfigCollection,
|
||||
) -> Result<RuntimeConfig, ConfigError> {
|
||||
let merged_value = JsonValue::Object(merged.clone());
|
||||
|
||||
let feature_config = RuntimeFeatureConfig {
|
||||
hooks: parse_optional_hooks_config(&merged_value)?,
|
||||
plugins: parse_optional_plugin_config(&merged_value)?,
|
||||
mcp: McpConfigCollection {
|
||||
servers: mcp_servers,
|
||||
},
|
||||
mcp,
|
||||
oauth: parse_optional_oauth_config(&merged_value, "merged settings.oauth")?,
|
||||
model: parse_optional_model(&merged_value),
|
||||
aliases: parse_optional_aliases(&merged_value)?,
|
||||
@@ -1330,6 +1338,31 @@ impl McpConfigCollection {
|
||||
&self.servers
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn invalid_servers(&self) -> &[McpInvalidServerConfig] {
|
||||
&self.invalid_servers
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn total_configured(&self) -> usize {
|
||||
self.total_configured
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn valid_count(&self) -> usize {
|
||||
self.servers.len()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn invalid_count(&self) -> usize {
|
||||
self.invalid_servers.len()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn has_invalid_servers(&self) -> bool {
|
||||
!self.invalid_servers.is_empty()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get(&self, name: &str) -> Option<&ScopedMcpServerConfig> {
|
||||
self.servers.get(name)
|
||||
@@ -1421,7 +1454,7 @@ fn read_optional_json_object(path: &Path) -> Result<OptionalConfigFile, ConfigEr
|
||||
}
|
||||
|
||||
fn merge_mcp_servers(
|
||||
target: &mut BTreeMap<String, ScopedMcpServerConfig>,
|
||||
target: &mut McpConfigCollection,
|
||||
source: ConfigSource,
|
||||
root: &BTreeMap<String, JsonValue>,
|
||||
path: &Path,
|
||||
@@ -1430,21 +1463,48 @@ fn merge_mcp_servers(
|
||||
return Ok(());
|
||||
};
|
||||
let servers = expect_object(mcp_servers, &format!("{}: mcpServers", path.display()))?;
|
||||
target.total_configured += servers.len();
|
||||
for (name, value) in servers {
|
||||
let parsed = parse_mcp_server_config(
|
||||
name,
|
||||
value,
|
||||
&format!("{}: mcpServers.{name}", path.display()),
|
||||
)?;
|
||||
target.insert(
|
||||
let context = format!("{}: mcpServers.{name}", path.display());
|
||||
let Ok(object) = expect_object(value, &context) else {
|
||||
let error = expect_object(value, &context).expect_err("object parse must fail");
|
||||
target.servers.remove(name);
|
||||
target
|
||||
.invalid_servers
|
||||
.push(mcp_invalid_server(name, source, path, &context, &error));
|
||||
continue;
|
||||
};
|
||||
let required = match optional_bool(object, "required", &context) {
|
||||
Ok(required) => required.unwrap_or(false),
|
||||
Err(error) => {
|
||||
target.servers.remove(name);
|
||||
target
|
||||
.invalid_servers
|
||||
.push(mcp_invalid_server(name, source, path, &context, &error));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Err(error) = validate_mcp_server_keys(name, object, &context) {
|
||||
target.servers.remove(name);
|
||||
target
|
||||
.invalid_servers
|
||||
.push(mcp_invalid_server(name, source, path, &context, &error));
|
||||
continue;
|
||||
}
|
||||
let parsed = match parse_mcp_server_config(name, value, &context) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(error) => {
|
||||
target.servers.remove(name);
|
||||
target
|
||||
.invalid_servers
|
||||
.push(mcp_invalid_server(name, source, path, &context, &error));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
target.servers.insert(
|
||||
name.clone(),
|
||||
ScopedMcpServerConfig {
|
||||
required: optional_bool(
|
||||
expect_object(value, &format!("{}: mcpServers.{name}", path.display()))?,
|
||||
"required",
|
||||
&format!("{}: mcpServers.{name}", path.display()),
|
||||
)?
|
||||
.unwrap_or(false),
|
||||
required,
|
||||
scope: source,
|
||||
config: parsed,
|
||||
},
|
||||
@@ -1453,6 +1513,98 @@ fn merge_mcp_servers(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mcp_invalid_server(
|
||||
name: &str,
|
||||
source: ConfigSource,
|
||||
path: &Path,
|
||||
context: &str,
|
||||
error: &ConfigError,
|
||||
) -> McpInvalidServerConfig {
|
||||
let reason = config_error_detail(error);
|
||||
McpInvalidServerConfig {
|
||||
name: name.to_string(),
|
||||
scope: source,
|
||||
path: path.to_path_buf(),
|
||||
error_field: mcp_error_field(name, context, &reason),
|
||||
reason,
|
||||
}
|
||||
}
|
||||
|
||||
fn config_error_detail(error: &ConfigError) -> String {
|
||||
match error {
|
||||
ConfigError::Io(error) => error.to_string(),
|
||||
ConfigError::Parse(reason) => reason.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn mcp_error_field(name: &str, context: &str, reason: &str) -> String {
|
||||
if let Some(field) = reason
|
||||
.split("missing string field ")
|
||||
.nth(1)
|
||||
.and_then(|tail| tail.split_whitespace().next())
|
||||
{
|
||||
return field
|
||||
.trim_matches(|ch: char| !ch.is_ascii_alphanumeric() && ch != '_')
|
||||
.to_string();
|
||||
}
|
||||
if let Some(field) = reason
|
||||
.split("field ")
|
||||
.nth(1)
|
||||
.and_then(|tail| tail.split_whitespace().next())
|
||||
{
|
||||
return field
|
||||
.trim_matches(|ch: char| !ch.is_ascii_alphanumeric() && ch != '_')
|
||||
.to_string();
|
||||
}
|
||||
reason
|
||||
.split_once(context)
|
||||
.and_then(|(_, tail)| tail.trim_start_matches('.').split(':').next())
|
||||
.filter(|field| !field.is_empty())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| format!("mcpServers.{name}"))
|
||||
}
|
||||
|
||||
fn validate_mcp_server_keys(
|
||||
server_name: &str,
|
||||
object: &BTreeMap<String, JsonValue>,
|
||||
context: &str,
|
||||
) -> Result<(), ConfigError> {
|
||||
let server_type =
|
||||
optional_string(object, "type", context)?.unwrap_or_else(|| infer_mcp_server_type(object));
|
||||
let allowed = match server_type {
|
||||
"stdio" => &[
|
||||
"type",
|
||||
"command",
|
||||
"args",
|
||||
"env",
|
||||
"toolCallTimeoutMs",
|
||||
"required",
|
||||
][..],
|
||||
"sse" | "http" => &[
|
||||
"type",
|
||||
"url",
|
||||
"headers",
|
||||
"headersHelper",
|
||||
"oauth",
|
||||
"required",
|
||||
][..],
|
||||
"ws" => &["type", "url", "headers", "headersHelper", "required"][..],
|
||||
"sdk" => &["type", "name", "required"][..],
|
||||
"claudeai-proxy" => &["type", "url", "id", "required"][..],
|
||||
other => {
|
||||
return Err(ConfigError::Parse(format!(
|
||||
"{context}: unsupported MCP server type for {server_name}: {other}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
if let Some(key) = object.keys().find(|key| !allowed.contains(&key.as_str())) {
|
||||
return Err(ConfigError::Parse(format!(
|
||||
"{context}: unknown MCP server field {key}"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_optional_model(root: &JsonValue) -> Option<String> {
|
||||
root.as_object()
|
||||
.and_then(|object| object.get("model"))
|
||||
@@ -1719,7 +1871,7 @@ fn parse_mcp_server_config(
|
||||
optional_string(object, "type", context)?.unwrap_or_else(|| infer_mcp_server_type(object));
|
||||
match server_type {
|
||||
"stdio" => Ok(McpServerConfig::Stdio(McpStdioServerConfig {
|
||||
command: expect_string(object, "command", context)?.to_string(),
|
||||
command: expect_non_empty_string(object, "command", context)?.to_string(),
|
||||
args: optional_string_array(object, "args", context)?.unwrap_or_default(),
|
||||
env: optional_string_map(object, "env", context)?.unwrap_or_default(),
|
||||
tool_call_timeout_ms: optional_u64(object, "toolCallTimeoutMs", context)?,
|
||||
@@ -1794,6 +1946,20 @@ fn expect_object<'a>(
|
||||
.ok_or_else(|| ConfigError::Parse(format!("{context}: expected JSON object")))
|
||||
}
|
||||
|
||||
fn expect_non_empty_string<'a>(
|
||||
object: &'a BTreeMap<String, JsonValue>,
|
||||
key: &str,
|
||||
context: &str,
|
||||
) -> Result<&'a str, ConfigError> {
|
||||
let value = expect_string(object, key, context)?;
|
||||
if value.trim().is_empty() {
|
||||
return Err(ConfigError::Parse(format!(
|
||||
"{context}: field {key} must be a non-empty string"
|
||||
)));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn expect_string<'a>(
|
||||
object: &'a BTreeMap<String, JsonValue>,
|
||||
key: &str,
|
||||
@@ -2843,7 +3009,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_mcp_server_shapes() {
|
||||
fn records_invalid_mcp_server_shapes_without_rejecting_config_440() {
|
||||
// given
|
||||
let root = temp_dir();
|
||||
let cwd = root.join("project");
|
||||
@@ -2857,18 +3023,72 @@ mod tests {
|
||||
.expect("write broken settings");
|
||||
|
||||
// when
|
||||
let error = ConfigLoader::new(&cwd, &home)
|
||||
let loaded = ConfigLoader::new(&cwd, &home)
|
||||
.load()
|
||||
.expect_err("config should fail");
|
||||
.expect("invalid MCP entries should not block otherwise loadable config");
|
||||
|
||||
// then
|
||||
assert!(error
|
||||
.to_string()
|
||||
assert!(loaded.mcp().servers().is_empty());
|
||||
assert_eq!(loaded.mcp().total_configured(), 1);
|
||||
assert_eq!(loaded.mcp().invalid_count(), 1);
|
||||
let invalid = &loaded.mcp().invalid_servers()[0];
|
||||
assert_eq!(invalid.name, "broken");
|
||||
assert_eq!(invalid.error_field, "url");
|
||||
assert!(invalid
|
||||
.reason
|
||||
.contains("mcpServers.broken: missing string field url"));
|
||||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_valid_mcp_servers_and_collects_all_invalid_siblings_440() {
|
||||
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#"{
|
||||
"mcpServers": {
|
||||
"valid-server": {"command": "/bin/echo", "args": ["hello"]},
|
||||
"missing-command": {"args": ["arg-only"]},
|
||||
"empty-command": {"command": ""},
|
||||
"wrong-type-command": {"command": 42},
|
||||
"extra-unknown-field": {"command": "/bin/echo", "extra": true}
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.expect("write mixed settings");
|
||||
|
||||
let loaded = ConfigLoader::new(&cwd, &home)
|
||||
.load()
|
||||
.expect("valid MCP entries should load beside invalid siblings");
|
||||
|
||||
assert_eq!(loaded.mcp().total_configured(), 5);
|
||||
assert_eq!(loaded.mcp().valid_count(), 1);
|
||||
assert_eq!(loaded.mcp().invalid_count(), 4);
|
||||
assert!(loaded.mcp().get("valid-server").is_some());
|
||||
let invalid_names = loaded
|
||||
.mcp()
|
||||
.invalid_servers()
|
||||
.iter()
|
||||
.map(|server| server.name.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
invalid_names,
|
||||
vec![
|
||||
"empty-command",
|
||||
"extra-unknown-field",
|
||||
"missing-command",
|
||||
"wrong-type-command",
|
||||
]
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_user_defined_model_aliases_from_settings() {
|
||||
// given
|
||||
|
||||
@@ -67,11 +67,12 @@ pub use compact::{
|
||||
pub use config::{
|
||||
suppress_config_warnings_for_json_mode, ConfigEntry, ConfigError, ConfigFileReport,
|
||||
ConfigFileStatus, ConfigInspection, ConfigLoader, ConfigSource, McpConfigCollection,
|
||||
McpManagedProxyServerConfig, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig,
|
||||
McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig,
|
||||
ProviderFallbackConfig, ResolvedPermissionMode, RulesImportConfig, RuntimeConfig,
|
||||
RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig, RuntimePermissionRuleConfig,
|
||||
RuntimePluginConfig, ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME,
|
||||
McpInvalidServerConfig, McpManagedProxyServerConfig, McpOAuthConfig, McpRemoteServerConfig,
|
||||
McpSdkServerConfig, McpServerConfig, McpStdioServerConfig, McpTransport,
|
||||
McpWebSocketServerConfig, OAuthConfig, ProviderFallbackConfig, ResolvedPermissionMode,
|
||||
RulesImportConfig, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig,
|
||||
RuntimePermissionRuleConfig, RuntimePluginConfig, ScopedMcpServerConfig,
|
||||
CLAW_SETTINGS_SCHEMA_NAME,
|
||||
};
|
||||
pub use config_validate::{
|
||||
check_unsupported_format, format_diagnostics, validate_config_file, ConfigDiagnostic,
|
||||
@@ -142,8 +143,9 @@ pub use policy_engine::{
|
||||
PolicyEvaluation, PolicyRule, ReconcileReason, ReviewStatus,
|
||||
};
|
||||
pub use prompt::{
|
||||
load_system_prompt, prepend_bullets, ContextFile, ModelFamilyIdentity, ProjectContext,
|
||||
PromptBuildError, SystemPromptBuilder, FRONTIER_MODEL_NAME, SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
|
||||
load_system_prompt, load_system_prompt_with_context, prepend_bullets, ContextFile,
|
||||
ModelFamilyIdentity, ProjectContext, PromptBuildError, SystemPromptBuilder,
|
||||
FRONTIER_MODEL_NAME, SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
|
||||
};
|
||||
pub use recovery_recipes::{
|
||||
attempt_recovery, recipe_for, EscalationPolicy, FailureScenario, RecoveryAttemptState,
|
||||
|
||||
@@ -69,6 +69,18 @@ pub struct ContextFile {
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
impl ContextFile {
|
||||
#[must_use]
|
||||
pub fn source(&self) -> &'static str {
|
||||
instruction_file_source(&self.path)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn char_count(&self) -> usize {
|
||||
self.content.chars().count()
|
||||
}
|
||||
}
|
||||
|
||||
/// Project-local context injected into the rendered system prompt.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ProjectContext {
|
||||
@@ -256,22 +268,36 @@ pub fn prepend_bullets(items: Vec<String>) -> Vec<String> {
|
||||
items.into_iter().map(|item| format!(" - {item}")).collect()
|
||||
}
|
||||
|
||||
fn instruction_file_source(path: &Path) -> &'static str {
|
||||
let file_name = path.file_name().and_then(|name| name.to_str());
|
||||
let parent_name = path
|
||||
.parent()
|
||||
.and_then(|parent| parent.file_name())
|
||||
.and_then(|name| name.to_str());
|
||||
|
||||
match (parent_name, file_name) {
|
||||
(Some(".claw"), Some("CLAUDE.md")) => "claw_claude_md",
|
||||
(Some(".claude"), Some("CLAUDE.md")) => "claude_claude_md",
|
||||
(_, Some("CLAUDE.md")) => "claude_md",
|
||||
(_, Some("CLAW.md")) => "claw_md",
|
||||
(_, Some("AGENTS.md")) => "agents_md",
|
||||
(_, Some("CLAUDE.local.md")) => "claude_local_md",
|
||||
(Some(".claw"), Some("instructions.md")) => "claw_instructions",
|
||||
_ => "rule_file",
|
||||
}
|
||||
}
|
||||
fn discover_instruction_files(
|
||||
cwd: &Path,
|
||||
rules_import: &RulesImportConfig,
|
||||
) -> std::io::Result<Vec<ContextFile>> {
|
||||
let mut directories = Vec::new();
|
||||
let mut cursor = Some(cwd);
|
||||
while let Some(dir) = cursor {
|
||||
directories.push(dir.to_path_buf());
|
||||
cursor = dir.parent();
|
||||
}
|
||||
let mut directories = instruction_discovery_dirs(cwd);
|
||||
directories.reverse();
|
||||
|
||||
let mut files = Vec::new();
|
||||
for dir in directories {
|
||||
for candidate in [
|
||||
dir.join("CLAUDE.md"),
|
||||
dir.join("CLAW.md"),
|
||||
dir.join("AGENTS.md"),
|
||||
dir.join("CLAUDE.local.md"),
|
||||
dir.join(".claw").join("CLAUDE.md"),
|
||||
@@ -287,6 +313,32 @@ fn discover_instruction_files(
|
||||
Ok(dedupe_instruction_files(files))
|
||||
}
|
||||
|
||||
fn instruction_discovery_dirs(cwd: &Path) -> Vec<PathBuf> {
|
||||
let boundary = nearest_git_root(cwd).unwrap_or_else(|| cwd.to_path_buf());
|
||||
let mut directories = Vec::new();
|
||||
let mut cursor = Some(cwd);
|
||||
while let Some(dir) = cursor {
|
||||
directories.push(dir.to_path_buf());
|
||||
if dir == boundary {
|
||||
break;
|
||||
}
|
||||
cursor = dir.parent();
|
||||
}
|
||||
directories
|
||||
}
|
||||
|
||||
fn nearest_git_root(cwd: &Path) -> Option<PathBuf> {
|
||||
let mut cursor = Some(cwd);
|
||||
while let Some(dir) = cursor {
|
||||
let git_marker = dir.join(".git");
|
||||
if git_marker.is_dir() || git_marker.is_file() {
|
||||
return Some(dir.to_path_buf());
|
||||
}
|
||||
cursor = dir.parent();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn push_context_file(files: &mut Vec<ContextFile>, path: PathBuf) -> std::io::Result<()> {
|
||||
if path.is_dir() {
|
||||
return Ok(());
|
||||
@@ -430,7 +482,7 @@ fn render_project_context(project_context: &ProjectContext) -> String {
|
||||
];
|
||||
if !project_context.instruction_files.is_empty() {
|
||||
bullets.push(format!(
|
||||
"Claude instruction files discovered: {}.",
|
||||
"Project instruction files discovered: {}.",
|
||||
project_context.instruction_files.len()
|
||||
));
|
||||
}
|
||||
@@ -465,7 +517,7 @@ fn render_project_context(project_context: &ProjectContext) -> String {
|
||||
}
|
||||
|
||||
fn render_instruction_files(files: &[ContextFile]) -> String {
|
||||
let mut sections = vec!["# Claude instructions".to_string()];
|
||||
let mut sections = vec!["# Project instructions".to_string()];
|
||||
let mut remaining_chars = MAX_TOTAL_INSTRUCTION_CHARS;
|
||||
for file in files {
|
||||
if remaining_chars == 0 {
|
||||
@@ -573,16 +625,31 @@ pub fn load_system_prompt(
|
||||
os_version: impl Into<String>,
|
||||
model_family: ModelFamilyIdentity,
|
||||
) -> Result<Vec<String>, PromptBuildError> {
|
||||
let cwd = cwd.into();
|
||||
let (sections, _) =
|
||||
load_system_prompt_with_context(cwd, current_date, os_name, os_version, model_family)?;
|
||||
Ok(sections)
|
||||
}
|
||||
|
||||
/// Loads config and project context, then renders the system prompt text plus metadata.
|
||||
pub fn load_system_prompt_with_context(
|
||||
cwd: impl Into<PathBuf>,
|
||||
current_date: impl Into<String>,
|
||||
os_name: impl Into<String>,
|
||||
os_version: impl Into<String>,
|
||||
model_family: ModelFamilyIdentity,
|
||||
) -> Result<(Vec<String>, ProjectContext), PromptBuildError> {
|
||||
let cwd = cwd.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()
|
||||
let sections = SystemPromptBuilder::new()
|
||||
.with_os(os_name, os_version)
|
||||
.with_model_family(model_family)
|
||||
.with_project_context(project_context)
|
||||
.with_project_context(project_context.clone())
|
||||
.with_runtime_config(config)
|
||||
.build())
|
||||
.build();
|
||||
Ok((sections, project_context))
|
||||
}
|
||||
|
||||
fn render_config_section(config: &RuntimeConfig) -> String {
|
||||
@@ -766,6 +833,7 @@ mod tests {
|
||||
let root = temp_dir();
|
||||
let nested = root.join("apps").join("api");
|
||||
fs::create_dir_all(nested.join(".claw")).expect("nested claw dir");
|
||||
fs::create_dir(root.join(".git")).expect("git boundary");
|
||||
fs::write(root.join("CLAUDE.md"), "root instructions").expect("write root instructions");
|
||||
fs::write(root.join("CLAUDE.local.md"), "local instructions")
|
||||
.expect("write local instructions");
|
||||
@@ -844,10 +912,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovers_claude_agents_and_dot_claude_instruction_files_together() {
|
||||
fn discovers_claude_claw_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("CLAW.md"), "claw instructions").expect("write CLAW.md");
|
||||
fs::write(root.join("AGENTS.md"), "agents instructions").expect("write AGENTS.md");
|
||||
fs::write(
|
||||
root.join(".claude").join("CLAUDE.md"),
|
||||
@@ -857,8 +926,18 @@ mod tests {
|
||||
|
||||
let context = ProjectContext::discover(&root, "2026-03-31").expect("context should load");
|
||||
let rendered = render_instruction_files(&context.instruction_files);
|
||||
let sources = context
|
||||
.instruction_files
|
||||
.iter()
|
||||
.map(ContextFile::source)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(
|
||||
sources,
|
||||
vec!["claude_md", "claw_md", "agents_md", "claude_claude_md"]
|
||||
);
|
||||
assert!(rendered.contains("claude instructions"));
|
||||
assert!(rendered.contains("claw instructions"));
|
||||
assert!(rendered.contains("agents instructions"));
|
||||
assert!(rendered.contains("dot claude instructions"));
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
@@ -869,6 +948,7 @@ mod tests {
|
||||
let root = temp_dir();
|
||||
let nested = root.join("apps").join("api");
|
||||
fs::create_dir_all(&nested).expect("nested dir");
|
||||
fs::create_dir(root.join(".git")).expect("git boundary");
|
||||
fs::write(root.join("CLAUDE.md"), "same rules\n\n").expect("write root");
|
||||
fs::write(nested.join("CLAUDE.md"), "same rules\n").expect("write nested");
|
||||
|
||||
@@ -881,6 +961,50 @@ mod tests {
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovery_stops_at_git_root_boundary_439() {
|
||||
let root = temp_dir();
|
||||
let repo = root.join("repo");
|
||||
let nested = repo.join("subproj").join("deep").join("nest");
|
||||
fs::create_dir_all(&nested).expect("nested dir");
|
||||
fs::create_dir(repo.join(".git")).expect("git boundary");
|
||||
fs::write(root.join("CLAUDE.md"), "PARENT_CLAUDE").expect("write parent");
|
||||
fs::write(repo.join("CLAUDE.md"), "REPO_CLAUDE").expect("write repo");
|
||||
fs::write(repo.join("subproj").join("CLAUDE.md"), "CHILD_CLAUDE").expect("write child");
|
||||
fs::write(
|
||||
repo.join("subproj").join("deep").join("CLAUDE.md"),
|
||||
"DEEP_CLAUDE",
|
||||
)
|
||||
.expect("write deep");
|
||||
|
||||
let context = ProjectContext::discover(&nested, "2026-03-31").expect("context should load");
|
||||
let rendered = render_instruction_files(&context.instruction_files);
|
||||
|
||||
assert!(!rendered.contains("PARENT_CLAUDE"));
|
||||
assert!(rendered.contains("REPO_CLAUDE"));
|
||||
assert!(rendered.contains("CHILD_CLAUDE"));
|
||||
assert!(rendered.contains("DEEP_CLAUDE"));
|
||||
assert_eq!(context.instruction_files.len(), 3);
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovery_without_git_root_stays_cwd_local_439() {
|
||||
let root = temp_dir();
|
||||
let nested = root.join("scratch");
|
||||
fs::create_dir_all(&nested).expect("nested dir");
|
||||
fs::write(root.join("CLAUDE.md"), "PARENT_CLAUDE").expect("write parent");
|
||||
fs::write(nested.join("CLAUDE.md"), "SCRATCH_CLAUDE").expect("write scratch");
|
||||
|
||||
let context = ProjectContext::discover(&nested, "2026-03-31").expect("context should load");
|
||||
let rendered = render_instruction_files(&context.instruction_files);
|
||||
|
||||
assert!(!rendered.contains("PARENT_CLAUDE"));
|
||||
assert!(rendered.contains("SCRATCH_CLAUDE"));
|
||||
assert_eq!(context.instruction_files.len(), 1);
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncates_large_instruction_content_for_rendering() {
|
||||
let rendered = render_instruction_content(&"x".repeat(4500));
|
||||
@@ -1218,7 +1342,7 @@ mod tests {
|
||||
|
||||
assert!(prompt.contains("# System"));
|
||||
assert!(prompt.contains("# Project context"));
|
||||
assert!(prompt.contains("# Claude instructions"));
|
||||
assert!(prompt.contains("# Project instructions"));
|
||||
assert!(prompt.contains("Project rules"));
|
||||
assert!(prompt.contains("permissionMode"));
|
||||
assert!(prompt.contains(SYSTEM_PROMPT_DYNAMIC_BOUNDARY));
|
||||
@@ -1263,7 +1387,7 @@ mod tests {
|
||||
path: PathBuf::from("/tmp/project/CLAUDE.md"),
|
||||
content: "Project rules".to_string(),
|
||||
}]);
|
||||
assert!(rendered.contains("# Claude instructions"));
|
||||
assert!(rendered.contains("# Project instructions"));
|
||||
assert!(rendered.contains("scope: /tmp/project"));
|
||||
assert!(rendered.contains("Project rules"));
|
||||
}
|
||||
|
||||
@@ -28,7 +28,8 @@ pub struct SessionStore {
|
||||
impl SessionStore {
|
||||
/// Build a store from the server's current working directory.
|
||||
///
|
||||
/// The on-disk layout becomes `<cwd>/.claw/sessions/<workspace_hash>/`.
|
||||
/// The on-disk layout is `<cwd>/.claw/sessions/<workspace_hash>/`,
|
||||
/// created lazily on first successful session save.
|
||||
pub fn from_cwd(cwd: impl AsRef<Path>) -> Result<Self, SessionControlError> {
|
||||
let cwd = cwd.as_ref();
|
||||
// #151: canonicalize so equivalent paths (symlinks, relative vs
|
||||
@@ -40,7 +41,6 @@ impl SessionStore {
|
||||
.join(".claw")
|
||||
.join("sessions")
|
||||
.join(workspace_fingerprint(&canonical_cwd));
|
||||
fs::create_dir_all(&sessions_root)?;
|
||||
Ok(Self {
|
||||
sessions_root,
|
||||
workspace_root: canonical_cwd,
|
||||
@@ -49,7 +49,8 @@ impl SessionStore {
|
||||
|
||||
/// Build a store from an explicit `--data-dir` flag.
|
||||
///
|
||||
/// The on-disk layout becomes `<data_dir>/sessions/<workspace_hash>/`
|
||||
/// The on-disk layout is `<data_dir>/sessions/<workspace_hash>/`,
|
||||
/// created lazily on first successful session save.
|
||||
/// where `<workspace_hash>` is derived from `workspace_root`.
|
||||
pub fn from_data_dir(
|
||||
data_dir: impl AsRef<Path>,
|
||||
@@ -64,7 +65,6 @@ impl SessionStore {
|
||||
.as_ref()
|
||||
.join("sessions")
|
||||
.join(workspace_fingerprint(&canonical_workspace));
|
||||
fs::create_dir_all(&sessions_root)?;
|
||||
Ok(Self {
|
||||
sessions_root,
|
||||
workspace_root: canonical_workspace,
|
||||
@@ -760,14 +760,21 @@ mod tests {
|
||||
use crate::session::Session;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn temp_dir() -> PathBuf {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("time should be after epoch")
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!("runtime-session-control-{nanos}"))
|
||||
let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
std::env::temp_dir().join(format!(
|
||||
"runtime-session-control-{}-{nanos}-{counter}",
|
||||
std::process::id()
|
||||
))
|
||||
}
|
||||
|
||||
fn persist_session(root: &Path, text: &str) -> Session {
|
||||
@@ -981,6 +988,38 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_store_from_cwd_is_side_effect_free_until_save() {
|
||||
// given
|
||||
let base = temp_dir();
|
||||
let workspace = base.join("fresh-workspace");
|
||||
fs::create_dir_all(&workspace).expect("workspace should exist");
|
||||
|
||||
// when
|
||||
let store = SessionStore::from_cwd(&workspace).expect("store should build");
|
||||
|
||||
// then — resolving the store must not create .claw/session partitions.
|
||||
assert!(
|
||||
!workspace.join(".claw").exists(),
|
||||
"session store construction must not create .claw side effects"
|
||||
);
|
||||
assert!(
|
||||
!store.sessions_dir().exists(),
|
||||
"session partition should be created lazily on save"
|
||||
);
|
||||
|
||||
let session = persist_session_via_store(&store, "first saved turn");
|
||||
assert!(
|
||||
store
|
||||
.sessions_dir()
|
||||
.join(format!("{}.jsonl", session.session_id))
|
||||
.exists(),
|
||||
"saving a managed session should create the lazy session partition"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(base).expect("temp dir should clean up");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_store_from_cwd_isolates_sessions_by_workspace() {
|
||||
// given
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
use std::env;
|
||||
use std::process::Command;
|
||||
|
||||
fn main() {
|
||||
// Get git SHA (short hash)
|
||||
let git_sha = Command::new("git")
|
||||
.args(["rev-parse", "--short", "HEAD"])
|
||||
fn command_output(program: &str, args: &[&str]) -> Option<String> {
|
||||
Command::new(program)
|
||||
.args(args)
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|output| {
|
||||
@@ -14,11 +13,37 @@ fn main() {
|
||||
None
|
||||
}
|
||||
})
|
||||
.map_or_else(|| "unknown".to_string(), |s| s.trim().to_string());
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let git_sha =
|
||||
command_output("git", &["rev-parse", "HEAD"]).unwrap_or_else(|| "unknown".to_string());
|
||||
let git_sha_short = command_output("git", &["rev-parse", "--short=12", "HEAD"])
|
||||
.or_else(|| git_sha.get(..git_sha.len().min(12)).map(str::to_string))
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let git_dirty = command_output("git", &["status", "--porcelain"])
|
||||
.map(|status| (!status.trim().is_empty()).to_string())
|
||||
.unwrap_or_else(|| "false".to_string());
|
||||
let git_branch = command_output("git", &["branch", "--show-current"])
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let git_commit_date = command_output("git", &["show", "-s", "--format=%cI", "HEAD"])
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let git_commit_timestamp = command_output("git", &["show", "-s", "--format=%ct", "HEAD"])
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let rustc_version =
|
||||
command_output("rustc", &["--version"]).unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
println!("cargo:rustc-env=GIT_SHA={git_sha}");
|
||||
println!("cargo:rustc-env=GIT_SHA_SHORT={git_sha_short}");
|
||||
println!("cargo:rustc-env=GIT_DIRTY={git_dirty}");
|
||||
println!("cargo:rustc-env=GIT_BRANCH={git_branch}");
|
||||
println!("cargo:rustc-env=GIT_COMMIT_DATE={git_commit_date}");
|
||||
println!("cargo:rustc-env=GIT_COMMIT_TIMESTAMP={git_commit_timestamp}");
|
||||
println!("cargo:rustc-env=RUSTC_VERSION={rustc_version}");
|
||||
|
||||
// TARGET is always set by Cargo during build
|
||||
// TARGET is always set by Cargo during build.
|
||||
let target = env::var("TARGET").unwrap_or_else(|_| "unknown".to_string());
|
||||
println!("cargo:rustc-env=TARGET={target}");
|
||||
|
||||
@@ -35,23 +60,12 @@ fn main() {
|
||||
})
|
||||
.or_else(|| std::env::var("BUILD_DATE").ok())
|
||||
.unwrap_or_else(|| {
|
||||
// Fall back to current date via `date` command
|
||||
Command::new("date")
|
||||
.args(["+%Y-%m-%d"])
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|o| {
|
||||
if o.status.success() {
|
||||
String::from_utf8(o.stdout).ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.map_or_else(|| "unknown".to_string(), |s| s.trim().to_string())
|
||||
command_output("date", &["+%Y-%m-%d"]).unwrap_or_else(|| "unknown".to_string())
|
||||
});
|
||||
println!("cargo:rustc-env=BUILD_DATE={build_date}");
|
||||
|
||||
// Rerun if git state changes
|
||||
println!("cargo:rerun-if-changed=.git/HEAD");
|
||||
println!("cargo:rerun-if-changed=.git/refs");
|
||||
// Rerun if git state changes. Paths are relative to this package root.
|
||||
println!("cargo:rerun-if-changed=../../../.git/HEAD");
|
||||
println!("cargo:rerun-if-changed=../../../.git/refs");
|
||||
println!("cargo:rerun-if-changed=../../../.git/index");
|
||||
}
|
||||
|
||||
@@ -4,7 +4,14 @@ use std::path::{Path, PathBuf};
|
||||
const STARTER_CLAW_JSON: &str = concat!(
|
||||
"{\n",
|
||||
" \"permissions\": {\n",
|
||||
" \"defaultMode\": \"dontAsk\"\n",
|
||||
" \"defaultMode\": \"acceptEdits\"\n",
|
||||
" }\n",
|
||||
"}\n",
|
||||
);
|
||||
const STARTER_SETTINGS_JSON: &str = concat!(
|
||||
"{\n",
|
||||
" \"permissions\": {\n",
|
||||
" \"defaultMode\": \"acceptEdits\"\n",
|
||||
" }\n",
|
||||
"}\n",
|
||||
);
|
||||
@@ -15,6 +22,8 @@ const GITIGNORE_ENTRIES: [&str; 3] = [".claw/settings.local.json", ".claw/sessio
|
||||
pub(crate) enum InitStatus {
|
||||
Created,
|
||||
Updated,
|
||||
Partial,
|
||||
Deferred,
|
||||
Skipped,
|
||||
}
|
||||
|
||||
@@ -24,6 +33,8 @@ impl InitStatus {
|
||||
match self {
|
||||
Self::Created => "created",
|
||||
Self::Updated => "updated",
|
||||
Self::Partial => "partial (created missing sub-files)",
|
||||
Self::Deferred => "deferred (created on first session save)",
|
||||
Self::Skipped => "skipped (already exists)",
|
||||
}
|
||||
}
|
||||
@@ -36,6 +47,8 @@ impl InitStatus {
|
||||
match self {
|
||||
Self::Created => "created",
|
||||
Self::Updated => "updated",
|
||||
Self::Partial => "partial",
|
||||
Self::Deferred => "deferred",
|
||||
Self::Skipped => "skipped",
|
||||
}
|
||||
}
|
||||
@@ -123,9 +136,30 @@ pub(crate) fn initialize_repo(cwd: &Path) -> Result<InitReport, Box<dyn std::err
|
||||
let mut artifacts = Vec::new();
|
||||
|
||||
let claw_dir = cwd.join(".claw");
|
||||
let claw_dir_status = ensure_dir(&claw_dir)?;
|
||||
let settings_json = claw_dir.join("settings.json");
|
||||
let settings_status = write_file_if_missing(&settings_json, STARTER_SETTINGS_JSON)?;
|
||||
let claw_dir_status =
|
||||
if claw_dir_status == InitStatus::Skipped && settings_status == InitStatus::Created {
|
||||
InitStatus::Partial
|
||||
} else {
|
||||
claw_dir_status
|
||||
};
|
||||
artifacts.push(InitArtifact {
|
||||
name: ".claw/",
|
||||
status: ensure_dir(&claw_dir)?,
|
||||
status: claw_dir_status,
|
||||
});
|
||||
artifacts.push(InitArtifact {
|
||||
name: ".claw/settings.json",
|
||||
status: settings_status,
|
||||
});
|
||||
artifacts.push(InitArtifact {
|
||||
name: ".claw/sessions/",
|
||||
status: if claw_dir.join("sessions").is_dir() {
|
||||
InitStatus::Skipped
|
||||
} else {
|
||||
InitStatus::Deferred
|
||||
},
|
||||
});
|
||||
|
||||
let claw_json = cwd.join(".claw.json");
|
||||
@@ -414,11 +448,26 @@ mod tests {
|
||||
concat!(
|
||||
"{\n",
|
||||
" \"permissions\": {\n",
|
||||
" \"defaultMode\": \"dontAsk\"\n",
|
||||
" \"defaultMode\": \"acceptEdits\"\n",
|
||||
" }\n",
|
||||
"}\n",
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(root.join(".claw").join("settings.json"))
|
||||
.expect("read project settings"),
|
||||
concat!(
|
||||
"{\n",
|
||||
" \"permissions\": {\n",
|
||||
" \"defaultMode\": \"acceptEdits\"\n",
|
||||
" }\n",
|
||||
"}\n",
|
||||
)
|
||||
);
|
||||
assert!(
|
||||
!root.join(".claw").join("sessions").exists(),
|
||||
"sessions directory should be deferred until first session save"
|
||||
);
|
||||
let gitignore = fs::read_to_string(root.join(".gitignore")).expect("read gitignore");
|
||||
assert!(gitignore.contains(".claw/settings.local.json"));
|
||||
assert!(gitignore.contains(".claw/sessions/"));
|
||||
@@ -436,14 +485,24 @@ mod tests {
|
||||
fs::create_dir_all(&root).expect("create root");
|
||||
fs::write(root.join("CLAUDE.md"), "custom guidance\n").expect("write existing claude md");
|
||||
fs::write(root.join(".gitignore"), ".claw/settings.local.json\n").expect("write gitignore");
|
||||
fs::create_dir_all(root.join(".claw")).expect("create existing .claw dir");
|
||||
|
||||
let first = initialize_repo(&root).expect("first init should succeed");
|
||||
assert!(first
|
||||
.render()
|
||||
.contains("CLAUDE.md skipped (already exists)"));
|
||||
assert_eq!(
|
||||
first.artifacts_with_status(InitStatus::Partial),
|
||||
vec![".claw/".to_string()],
|
||||
"existing .claw/ should report partial when init creates missing settings.json"
|
||||
);
|
||||
assert!(root.join(".claw").join("settings.json").is_file());
|
||||
|
||||
let second = initialize_repo(&root).expect("second init should succeed");
|
||||
let second_rendered = second.render();
|
||||
assert!(second_rendered.contains(".claw/"));
|
||||
assert!(second_rendered.contains(".claw/settings.json"));
|
||||
assert!(second_rendered.contains(".claw/sessions/"));
|
||||
assert!(second_rendered.contains(".claw.json"));
|
||||
assert!(second_rendered.contains("skipped (already exists)"));
|
||||
assert!(second_rendered.contains(".gitignore skipped (already exists)"));
|
||||
@@ -474,16 +533,22 @@ mod tests {
|
||||
created_names,
|
||||
vec![
|
||||
".claw/".to_string(),
|
||||
".claw/settings.json".to_string(),
|
||||
".claw.json".to_string(),
|
||||
".gitignore".to_string(),
|
||||
"CLAUDE.md".to_string(),
|
||||
],
|
||||
"fresh init should place all four artifacts in created[]"
|
||||
"fresh init should place created artifacts in created[]"
|
||||
);
|
||||
assert!(
|
||||
fresh.artifacts_with_status(InitStatus::Skipped).is_empty(),
|
||||
"fresh init should have no skipped artifacts"
|
||||
);
|
||||
assert_eq!(
|
||||
fresh.artifacts_with_status(InitStatus::Deferred),
|
||||
vec![".claw/sessions/".to_string()],
|
||||
"fresh init should report session storage as deferred"
|
||||
);
|
||||
|
||||
let second = initialize_repo(&root).expect("second init should succeed");
|
||||
let skipped_names = second.artifacts_with_status(InitStatus::Skipped);
|
||||
@@ -491,27 +556,38 @@ mod tests {
|
||||
skipped_names,
|
||||
vec![
|
||||
".claw/".to_string(),
|
||||
".claw/settings.json".to_string(),
|
||||
".claw.json".to_string(),
|
||||
".gitignore".to_string(),
|
||||
"CLAUDE.md".to_string(),
|
||||
],
|
||||
"idempotent init should place all four artifacts in skipped[]"
|
||||
"idempotent init should place existing artifacts in skipped[]"
|
||||
);
|
||||
assert!(
|
||||
second.artifacts_with_status(InitStatus::Created).is_empty(),
|
||||
"idempotent init should have no created artifacts"
|
||||
);
|
||||
assert_eq!(
|
||||
second.artifacts_with_status(InitStatus::Deferred),
|
||||
vec![".claw/sessions/".to_string()],
|
||||
"idempotent init should keep session storage deferred until first save"
|
||||
);
|
||||
|
||||
// artifact_json_entries() uses the machine-stable `json_tag()` which
|
||||
// never changes wording (unlike `label()` which says "skipped (already exists)").
|
||||
let entries = second.artifact_json_entries();
|
||||
assert_eq!(entries.len(), 4);
|
||||
assert_eq!(entries.len(), 6);
|
||||
for entry in &entries {
|
||||
let name = entry.get("name").and_then(|v| v.as_str()).unwrap();
|
||||
let status = entry.get("status").and_then(|v| v.as_str()).unwrap();
|
||||
assert_eq!(
|
||||
status, "skipped",
|
||||
"machine status tag should be the bare word 'skipped', not label()'s 'skipped (already exists)'"
|
||||
);
|
||||
if name == ".claw/sessions/" {
|
||||
assert_eq!(status, "deferred");
|
||||
} else {
|
||||
assert_eq!(
|
||||
status, "skipped",
|
||||
"machine status tag should be the bare word 'skipped', not label()'s 'skipped (already exists)'"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -360,8 +360,8 @@ fn prompt_subcommand_stdin_flag_appends_pipe_context_423() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_subcommand_json_help_fails_fast_when_stdin_closed() {
|
||||
let workspace = unique_temp_dir("compact-nontty-json-help");
|
||||
fn compact_subcommand_json_fails_fast_when_stdin_closed() {
|
||||
let workspace = unique_temp_dir("compact-nontty-json");
|
||||
let config_home = workspace.join("config-home");
|
||||
let home = workspace.join("home");
|
||||
fs::create_dir_all(&workspace).expect("workspace should exist");
|
||||
@@ -372,19 +372,19 @@ fn compact_subcommand_json_help_fails_fast_when_stdin_closed() {
|
||||
&workspace,
|
||||
&config_home,
|
||||
&home,
|
||||
&["compact", "--output-format", "json", "--help"],
|
||||
&["compact", "--output-format", "json"],
|
||||
Duration::from_secs(2),
|
||||
);
|
||||
|
||||
assert!(
|
||||
!output.status.success(),
|
||||
"compact json help should fail non-zero"
|
||||
"compact json 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}"
|
||||
"compact json should not emit JSON envelope to stderr (#819/#820/#823): {stderr}"
|
||||
);
|
||||
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
||||
let parsed: Value =
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use runtime::Session;
|
||||
use serde_json::Value;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
@@ -110,6 +110,8 @@ fn assert_doctor_help_json_contract(parsed: &Value) {
|
||||
let checks = parsed["check_names"].as_array().expect("check_names");
|
||||
assert!(checks.iter().any(|check| check == "auth"));
|
||||
assert!(checks.iter().any(|check| check == "boot preflight"));
|
||||
assert!(checks.iter().any(|check| check == "memory"));
|
||||
assert!(checks.iter().any(|check| check == "mcp validation"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -270,29 +272,88 @@ fn version_emits_json_when_requested() {
|
||||
"version JSON must have action:show (#711)"
|
||||
);
|
||||
assert_eq!(parsed["version"], env!("CARGO_PKG_VERSION"));
|
||||
// Provenance fields must be present for binary identification (#507).
|
||||
// Provenance fields must be present for binary identification (#507/#437).
|
||||
assert!(
|
||||
parsed.get("message").is_none(),
|
||||
"version JSON should not duplicate the text report in legacy message; use human_readable instead: {parsed}"
|
||||
);
|
||||
assert!(
|
||||
parsed["human_readable"]
|
||||
.as_str()
|
||||
.is_some_and(|text| text.contains("Claw Code")),
|
||||
"version JSON should keep text output only in human_readable: {parsed}"
|
||||
);
|
||||
let git_sha = parsed["git_sha"]
|
||||
.as_str()
|
||||
.expect("git_sha must be the full build commit SHA in version JSON");
|
||||
assert_eq!(git_sha.len(), 40, "git_sha must not be truncated: {parsed}");
|
||||
assert!(
|
||||
git_sha.chars().all(|ch| ch.is_ascii_hexdigit()),
|
||||
"git_sha must be a hex commit id: {parsed}"
|
||||
);
|
||||
let git_sha_short = parsed["git_sha_short"]
|
||||
.as_str()
|
||||
.expect("version JSON should expose the short SHA as a separate derived field");
|
||||
assert!(
|
||||
git_sha.starts_with(git_sha_short),
|
||||
"git_sha_short should derive from git_sha: {parsed}"
|
||||
);
|
||||
assert!(
|
||||
parsed["is_dirty"].is_boolean(),
|
||||
"is_dirty should be boolean: {parsed}"
|
||||
);
|
||||
assert!(
|
||||
parsed["branch"].is_string() || parsed["branch"].is_null(),
|
||||
"branch should be string|null: {parsed}"
|
||||
);
|
||||
assert!(
|
||||
parsed["commit_date"]
|
||||
.as_str()
|
||||
.is_some_and(|date| date != "unknown" && date.contains('T')),
|
||||
"commit_date should be an ISO-8601 commit timestamp string: {parsed}"
|
||||
);
|
||||
assert!(
|
||||
parsed["commit_timestamp"].as_i64().is_some_and(|ts| ts > 0),
|
||||
"commit_timestamp should be a positive Unix timestamp: {parsed}"
|
||||
);
|
||||
assert!(
|
||||
parsed["rustc_version"]
|
||||
.as_str()
|
||||
.is_some_and(|version| version.starts_with("rustc ")),
|
||||
"rustc_version should identify the compiler: {parsed}"
|
||||
);
|
||||
assert!(
|
||||
parsed["build_date"].is_string(),
|
||||
"build_date must be a string in version JSON"
|
||||
);
|
||||
assert!(
|
||||
parsed["executable_path"].is_string(),
|
||||
"executable_path must be a string in version JSON so callers can identify which binary is running"
|
||||
parsed["executable_path"].as_str().is_some_and(|path| !path.is_empty()),
|
||||
"executable_path must be a runtime path string so callers can identify which binary is running"
|
||||
);
|
||||
let binary_provenance = parsed["binary_provenance"]
|
||||
.as_object()
|
||||
.expect("version JSON must include binary_provenance object (#797)");
|
||||
.expect("version JSON must include binary_provenance object (#797/#437)");
|
||||
assert!(matches!(
|
||||
binary_provenance["status"].as_str(),
|
||||
Some("known" | "unknown")
|
||||
));
|
||||
assert_eq!(binary_provenance["git_sha"], parsed["git_sha"]);
|
||||
assert_eq!(binary_provenance["target"], parsed["target"]);
|
||||
assert_eq!(binary_provenance["build_date"], parsed["build_date"]);
|
||||
assert_eq!(
|
||||
binary_provenance["executable_path"],
|
||||
parsed["executable_path"]
|
||||
);
|
||||
for key in [
|
||||
"git_sha",
|
||||
"git_sha_short",
|
||||
"is_dirty",
|
||||
"branch",
|
||||
"commit_date",
|
||||
"commit_timestamp",
|
||||
"rustc_version",
|
||||
"target",
|
||||
"build_date",
|
||||
"executable_path",
|
||||
] {
|
||||
assert_eq!(
|
||||
binary_provenance[key], parsed[key],
|
||||
"binary_provenance.{key} should mirror top-level version field"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
binary_provenance["hint"].is_string() || binary_provenance["hint"].is_null(),
|
||||
"binary provenance must classify missing/stale lineage with a structured hint field"
|
||||
@@ -334,6 +395,14 @@ fn version_status_doctor_include_binary_provenance_797() {
|
||||
version["binary_provenance"]["workspace_match"].is_boolean()
|
||||
|| version["binary_provenance"]["workspace_match"].is_null()
|
||||
);
|
||||
let workspace_git_sha = version["binary_provenance"]["workspace_git_sha"]
|
||||
.as_str()
|
||||
.expect("workspace git sha should be a string");
|
||||
assert_eq!(
|
||||
workspace_git_sha.len(),
|
||||
40,
|
||||
"workspace_git_sha should be a full SHA, not a truncated prefix: {version}"
|
||||
);
|
||||
|
||||
let status = assert_json_command(&root, &["--output-format", "json", "status"]);
|
||||
assert_eq!(status["kind"], "status");
|
||||
@@ -1203,6 +1272,138 @@ fn bootstrap_and_system_prompt_emit_json_when_requested() {
|
||||
.contains("interactive agent"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_files_load_claude_claw_agents_and_surface_json_438() {
|
||||
let root = unique_temp_dir("memory-files-438");
|
||||
let config_home = root.join("config-home");
|
||||
let home = root.join("home");
|
||||
fs::create_dir_all(&root).expect("temp dir should exist");
|
||||
fs::create_dir_all(&config_home).expect("config home should exist");
|
||||
fs::create_dir_all(&home).expect("home should exist");
|
||||
fs::write(root.join("CLAUDE.md"), "MARKER-FROM-CLAUDE-MD\n").expect("write CLAUDE.md");
|
||||
fs::write(root.join("CLAW.md"), "MARKER-FROM-CLAW-MD\n").expect("write CLAW.md");
|
||||
fs::write(root.join("AGENTS.md"), "MARKER-FROM-AGENTS-MD\n").expect("write AGENTS.md");
|
||||
let envs = [
|
||||
(
|
||||
"CLAW_CONFIG_HOME",
|
||||
config_home.to_str().expect("utf8 config home"),
|
||||
),
|
||||
("HOME", home.to_str().expect("utf8 home")),
|
||||
];
|
||||
|
||||
let status = assert_json_command_with_env(&root, &["--output-format", "json", "status"], &envs);
|
||||
assert_eq!(status["workspace"]["memory_file_count"], 3);
|
||||
let memory_files = status["workspace"]["memory_files"]
|
||||
.as_array()
|
||||
.expect("status memory files");
|
||||
let sources = memory_files
|
||||
.iter()
|
||||
.map(|file| file["source"].as_str().expect("memory source"))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(sources, vec!["claude_md", "claw_md", "agents_md"]);
|
||||
assert!(memory_files
|
||||
.iter()
|
||||
.all(|file| file["path"].as_str().is_some()));
|
||||
assert!(memory_files
|
||||
.iter()
|
||||
.all(|file| file["chars"].as_u64().unwrap_or(0) > 0));
|
||||
assert!(memory_files
|
||||
.iter()
|
||||
.all(|file| file["contributes"].as_bool() == Some(true)));
|
||||
assert!(memory_files
|
||||
.iter()
|
||||
.all(|file| file["origin"].as_str() == Some("workspace")));
|
||||
assert!(memory_files
|
||||
.iter()
|
||||
.all(|file| file["scope_path"].as_str().is_some()));
|
||||
assert!(memory_files
|
||||
.iter()
|
||||
.all(|file| file["outside_project"].as_bool() == Some(false)));
|
||||
|
||||
let prompt =
|
||||
assert_json_command_with_env(&root, &["--output-format", "json", "system-prompt"], &envs);
|
||||
let message = prompt["message"].as_str().expect("prompt message");
|
||||
assert!(message.contains("MARKER-FROM-CLAUDE-MD"));
|
||||
assert!(message.contains("MARKER-FROM-CLAW-MD"));
|
||||
assert!(message.contains("MARKER-FROM-AGENTS-MD"));
|
||||
assert_eq!(prompt["memory_file_count"], 3);
|
||||
assert_eq!(prompt["memory_files"][1]["source"], "claw_md");
|
||||
|
||||
let doctor = assert_json_command_with_env(&root, &["--output-format", "json", "doctor"], &envs);
|
||||
let memory = doctor["checks"]
|
||||
.as_array()
|
||||
.expect("doctor checks")
|
||||
.iter()
|
||||
.find(|check| check["name"] == "memory")
|
||||
.expect("memory check");
|
||||
assert_eq!(memory["status"], "ok");
|
||||
assert_eq!(memory["memory_file_count"], 3);
|
||||
assert_eq!(memory["memory_files"][2]["source"], "agents_md");
|
||||
assert!(memory["unloaded_memory_files"]
|
||||
.as_array()
|
||||
.expect("unloaded memory files")
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_discovery_stops_at_git_root_and_reports_origins_439() {
|
||||
let root = unique_temp_dir("memory-boundary-439");
|
||||
let repo = root.join("repo");
|
||||
let nested = repo.join("subproj").join("deep").join("nest");
|
||||
let config_home = root.join("config-home");
|
||||
let home = root.join("home");
|
||||
fs::create_dir_all(&nested).expect("nested dir should exist");
|
||||
fs::create_dir_all(&config_home).expect("config home should exist");
|
||||
fs::create_dir_all(&home).expect("home should exist");
|
||||
Command::new("git")
|
||||
.args(["init", "-q"])
|
||||
.current_dir(&repo)
|
||||
.output()
|
||||
.expect("git init should launch");
|
||||
fs::write(root.join("CLAUDE.md"), "PARENT_CLAUDE").expect("write parent");
|
||||
fs::write(repo.join("CLAUDE.md"), "REPO_CLAUDE").expect("write repo");
|
||||
fs::write(repo.join("subproj").join("CLAUDE.md"), "CHILD_CLAUDE").expect("write child");
|
||||
fs::write(
|
||||
repo.join("subproj").join("deep").join("CLAUDE.md"),
|
||||
"DEEP_CLAUDE",
|
||||
)
|
||||
.expect("write deep");
|
||||
let envs = [
|
||||
(
|
||||
"CLAW_CONFIG_HOME",
|
||||
config_home.to_str().expect("utf8 config home"),
|
||||
),
|
||||
("HOME", home.to_str().expect("utf8 home")),
|
||||
];
|
||||
|
||||
let status =
|
||||
assert_json_command_with_env(&nested, &["--output-format", "json", "status"], &envs);
|
||||
assert_eq!(status["workspace"]["memory_file_count"], 3);
|
||||
let memory_files = status["workspace"]["memory_files"]
|
||||
.as_array()
|
||||
.expect("memory files");
|
||||
let origins = memory_files
|
||||
.iter()
|
||||
.map(|file| file["origin"].as_str().expect("origin"))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(origins, vec!["ancestor", "ancestor", "parent_dir"]);
|
||||
let serialized = serde_json::to_string(memory_files).expect("memory files serialize");
|
||||
assert!(!serialized.contains("PARENT_CLAUDE"));
|
||||
assert!(!serialized.contains(root.join("CLAUDE.md").to_str().expect("parent path")));
|
||||
|
||||
let prompt = assert_json_command_with_env(
|
||||
&nested,
|
||||
&["--output-format", "json", "system-prompt"],
|
||||
&envs,
|
||||
);
|
||||
let message = prompt["message"].as_str().expect("prompt message");
|
||||
assert!(!message.contains("PARENT_CLAUDE"));
|
||||
assert!(message.contains("REPO_CLAUDE"));
|
||||
assert!(message.contains("CHILD_CLAUDE"));
|
||||
assert!(message.contains("DEEP_CLAUDE"));
|
||||
assert_eq!(prompt["memory_files"][0]["origin"], "ancestor");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dump_manifests_and_init_emit_json_when_requested() {
|
||||
let root = unique_temp_dir("manifest-init-json");
|
||||
@@ -1258,7 +1459,7 @@ fn doctor_and_resume_status_emit_json_when_requested() {
|
||||
.is_some_and(|available| available.iter().any(|name| name == "web_fetch")));
|
||||
|
||||
let checks = doctor["checks"].as_array().expect("doctor checks");
|
||||
assert_eq!(checks.len(), 8);
|
||||
assert_eq!(checks.len(), 10);
|
||||
let check_names = checks
|
||||
.iter()
|
||||
.map(|check| {
|
||||
@@ -1279,8 +1480,10 @@ fn doctor_and_resume_status_emit_json_when_requested() {
|
||||
vec![
|
||||
"auth",
|
||||
"config",
|
||||
"mcp validation",
|
||||
"install source",
|
||||
"workspace",
|
||||
"memory",
|
||||
"boot preflight",
|
||||
"sandbox",
|
||||
"permissions",
|
||||
@@ -1518,6 +1721,11 @@ fn resumed_version_and_init_emit_structured_json_when_requested() {
|
||||
);
|
||||
assert_eq!(version["kind"], "version");
|
||||
assert_eq!(version["version"], env!("CARGO_PKG_VERSION"));
|
||||
assert!(
|
||||
version.get("message").is_none(),
|
||||
"resumed /version JSON should not include legacy prose message: {version}"
|
||||
);
|
||||
assert!(version["human_readable"].as_str().is_some());
|
||||
|
||||
let init = assert_json_command(
|
||||
&root,
|
||||
@@ -1616,6 +1824,12 @@ fn mcp_json_reports_required_optional_and_redacts_secret_values() {
|
||||
assert_eq!(list["action"], "list");
|
||||
assert_eq!(list["status"], "ok");
|
||||
assert_eq!(list["configured_servers"], 2);
|
||||
assert_eq!(list["total_configured"], 2);
|
||||
assert_eq!(list["valid_count"], 2);
|
||||
assert_eq!(list["invalid_count"], 0);
|
||||
assert!(list["invalid_servers"]
|
||||
.as_array()
|
||||
.is_some_and(Vec::is_empty));
|
||||
let servers = list["servers"].as_array().expect("servers array");
|
||||
let required = servers
|
||||
.iter()
|
||||
@@ -1626,6 +1840,8 @@ fn mcp_json_reports_required_optional_and_redacts_secret_values() {
|
||||
.find(|server| server["name"] == "optional-remote")
|
||||
.expect("optional remote server should be listed");
|
||||
assert_eq!(required["required"], true);
|
||||
assert_eq!(required["valid"], true);
|
||||
assert_eq!(optional["valid"], true);
|
||||
assert_eq!(optional["required"], false);
|
||||
assert_eq!(required["details"]["env_keys"][0], "TOKEN");
|
||||
assert_eq!(optional["details"]["header_keys"][0], "Authorization");
|
||||
@@ -1644,6 +1860,10 @@ fn mcp_json_reports_required_optional_and_redacts_secret_values() {
|
||||
assert_eq!(show["action"], "show");
|
||||
assert_eq!(show["status"], "ok");
|
||||
assert_eq!(show["server"]["required"], false);
|
||||
assert_eq!(show["server"]["valid"], true);
|
||||
assert_eq!(show["total_configured"], 2);
|
||||
assert_eq!(show["valid_count"], 2);
|
||||
assert_eq!(show["invalid_count"], 0);
|
||||
assert_eq!(show["server"]["details"]["header_keys"][0], "Authorization");
|
||||
let show_text = serde_json::to_string(&show).expect("mcp show json should serialize");
|
||||
assert!(!show_text.contains("secret-header-value"));
|
||||
@@ -1653,18 +1873,29 @@ fn mcp_json_reports_required_optional_and_redacts_secret_values() {
|
||||
#[test]
|
||||
fn mcp_degraded_config_and_failed_usage_are_distinct_json_contracts() {
|
||||
let root = unique_temp_dir("mcp-degraded-vs-failed");
|
||||
let workspace = root.join("workspace");
|
||||
let config_home = root.join("config-home");
|
||||
let home = root.join("home");
|
||||
fs::create_dir_all(&root).expect("workspace should exist");
|
||||
fs::create_dir_all(&workspace).expect("workspace should exist");
|
||||
fs::create_dir_all(&config_home).expect("config home should exist");
|
||||
fs::create_dir_all(&home).expect("home should exist");
|
||||
fs::write(
|
||||
root.join(".claw.json"),
|
||||
workspace.join(".claw.json"),
|
||||
r#"{
|
||||
"mcpServers": {
|
||||
"valid-server": {
|
||||
"command": "/bin/echo",
|
||||
"args": ["hello"]
|
||||
},
|
||||
"missing-command": {
|
||||
"args": ["arg-only-no-command"],
|
||||
"required": true
|
||||
},
|
||||
"empty-command": {
|
||||
"command": ""
|
||||
},
|
||||
"wrong-type-command": {
|
||||
"command": 42
|
||||
}
|
||||
}
|
||||
}"#,
|
||||
@@ -1678,18 +1909,56 @@ fn mcp_degraded_config_and_failed_usage_are_distinct_json_contracts() {
|
||||
("HOME", home.to_str().expect("home")),
|
||||
];
|
||||
|
||||
let degraded = assert_json_command_with_env(&root, &["--output-format", "json", "mcp"], &envs);
|
||||
let degraded =
|
||||
assert_json_command_with_env(&workspace, &["--output-format", "json", "mcp"], &envs);
|
||||
assert_eq!(degraded["kind"], "mcp");
|
||||
assert_eq!(degraded["action"], "list");
|
||||
assert_eq!(degraded["status"], "degraded");
|
||||
assert!(degraded["config_load_error"]
|
||||
assert!(degraded["config_load_error"].is_null());
|
||||
assert_eq!(degraded["configured_servers"], 1);
|
||||
assert_eq!(degraded["total_configured"], 4);
|
||||
assert_eq!(degraded["valid_count"], 1);
|
||||
assert_eq!(degraded["invalid_count"], 3);
|
||||
assert_eq!(degraded["servers"][0]["name"], "valid-server");
|
||||
assert_eq!(degraded["servers"][0]["valid"], true);
|
||||
assert_eq!(degraded["invalid_servers"][0]["name"], "empty-command");
|
||||
assert_eq!(degraded["invalid_servers"][0]["error_field"], "command");
|
||||
assert!(degraded["invalid_servers"][0]["reason"]
|
||||
.as_str()
|
||||
.is_some_and(|error| error.contains("mcpServers.missing-command")));
|
||||
assert_eq!(degraded["configured_servers"], 0);
|
||||
assert!(degraded["servers"].as_array().expect("servers").is_empty());
|
||||
.is_some_and(|error| error.contains("non-empty string")));
|
||||
assert_eq!(degraded["invalid_servers"][1]["name"], "missing-command");
|
||||
assert_eq!(degraded["invalid_servers"][1]["error_field"], "command");
|
||||
assert!(degraded["invalid_servers"][1]["reason"]
|
||||
.as_str()
|
||||
.is_some_and(|error| error.contains("missing string field command")));
|
||||
assert_eq!(degraded["invalid_servers"][2]["name"], "wrong-type-command");
|
||||
assert_eq!(degraded["invalid_servers"][2]["error_field"], "command");
|
||||
|
||||
let status =
|
||||
assert_json_command_with_env(&workspace, &["--output-format", "json", "status"], &envs);
|
||||
assert_eq!(status["status"], "degraded");
|
||||
assert!(status["config_load_error"].is_null());
|
||||
assert_eq!(status["mcp_validation"]["total_configured"], 4);
|
||||
assert_eq!(status["mcp_validation"]["valid_count"], 1);
|
||||
assert_eq!(status["mcp_validation"]["invalid_count"], 3);
|
||||
|
||||
let doctor =
|
||||
assert_json_command_with_env(&workspace, &["--output-format", "json", "doctor"], &envs);
|
||||
let mcp_validation = doctor["checks"]
|
||||
.as_array()
|
||||
.expect("doctor checks")
|
||||
.iter()
|
||||
.find(|check| check["name"] == "mcp validation")
|
||||
.expect("mcp validation check");
|
||||
assert_eq!(mcp_validation["status"], "warn");
|
||||
assert_eq!(mcp_validation["invalid_count"], 3);
|
||||
assert_eq!(
|
||||
mcp_validation["invalid_servers"][0]["name"],
|
||||
"empty-command"
|
||||
);
|
||||
|
||||
let failed_output = run_claw(
|
||||
&root,
|
||||
&workspace,
|
||||
&["--output-format", "json", "mcp", "list", "extra"],
|
||||
&envs,
|
||||
);
|
||||
@@ -3928,6 +4197,41 @@ fn init_json_envelope_has_hint_and_already_initialized_783() {
|
||||
hint.contains("CLAUDE.md") || hint.contains("doctor"),
|
||||
"fresh-init hint should mention CLAUDE.md or doctor, got: {hint:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
parsed["created"],
|
||||
json!([
|
||||
".claw/",
|
||||
".claw/settings.json",
|
||||
".claw.json",
|
||||
".gitignore",
|
||||
"CLAUDE.md"
|
||||
]),
|
||||
"fresh init should materialize .claw/settings.json and safe .claw.json"
|
||||
);
|
||||
assert_eq!(
|
||||
parsed["deferred"],
|
||||
json!([".claw/sessions/"]),
|
||||
"session storage should be reported as deferred until first save"
|
||||
);
|
||||
assert_eq!(parsed["partial"], json!([]));
|
||||
let claw_json = fs::read_to_string(root.join(".claw.json")).expect("read .claw.json");
|
||||
assert!(
|
||||
claw_json.contains("\"defaultMode\": \"acceptEdits\""),
|
||||
"init must not scaffold dontAsk in .claw.json: {claw_json}"
|
||||
);
|
||||
assert!(
|
||||
!claw_json.contains("dontAsk"),
|
||||
"init must not scaffold unsafe dontAsk permission mode: {claw_json}"
|
||||
);
|
||||
let settings_json = root.join(".claw").join("settings.json");
|
||||
assert!(
|
||||
settings_json.is_file(),
|
||||
"init should template .claw/settings.json"
|
||||
);
|
||||
assert!(
|
||||
!root.join(".claw").join("sessions").exists(),
|
||||
"sessions directory should remain deferred until first save"
|
||||
);
|
||||
|
||||
// Idempotent re-init — already_initialized should be true
|
||||
let output2 = run_claw(&root, &["--output-format", "json", "init"], &[]);
|
||||
@@ -3954,6 +4258,43 @@ fn init_json_envelope_has_hint_and_already_initialized_783() {
|
||||
hint2.contains("already") || hint2.contains("doctor"),
|
||||
"re-init hint should acknowledge workspace exists, got: {hint2:?}"
|
||||
);
|
||||
|
||||
let existing_claw_root = unique_temp_dir("init-existing-claw-436");
|
||||
fs::create_dir_all(existing_claw_root.join(".claw")).expect("existing .claw dir");
|
||||
let partial_output = run_claw(
|
||||
&existing_claw_root,
|
||||
&["--output-format", "json", "init"],
|
||||
&[],
|
||||
);
|
||||
assert!(
|
||||
partial_output.status.success(),
|
||||
"init with existing .claw should succeed"
|
||||
);
|
||||
let partial_stdout = String::from_utf8_lossy(&partial_output.stdout);
|
||||
let partial: serde_json::Value =
|
||||
serde_json::from_str(partial_stdout.trim()).expect("partial init should emit valid JSON");
|
||||
assert_eq!(
|
||||
partial["partial"],
|
||||
json!([".claw/"]),
|
||||
"existing .claw with newly-created settings should report partial .claw/"
|
||||
);
|
||||
assert_eq!(
|
||||
partial["created"],
|
||||
json!([
|
||||
".claw/settings.json",
|
||||
".claw.json",
|
||||
".gitignore",
|
||||
"CLAUDE.md"
|
||||
]),
|
||||
"init should still create missing sub-files when .claw already exists"
|
||||
);
|
||||
assert!(
|
||||
existing_claw_root
|
||||
.join(".claw")
|
||||
.join("settings.json")
|
||||
.is_file(),
|
||||
"existing .claw must receive missing settings template"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -5424,6 +5765,54 @@ fn multi_word_unknown_subcommand_json_emits_command_not_found_826() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_flag_missing_argument_and_shorthand_prompt_contract_435() {
|
||||
let root = unique_temp_dir("compact-flag-435");
|
||||
let config_home = root.join("config-home");
|
||||
let home = root.join("home");
|
||||
std::fs::create_dir_all(&root).expect("create temp dir");
|
||||
std::fs::create_dir_all(&config_home).expect("create config home");
|
||||
std::fs::create_dir_all(&home).expect("create home");
|
||||
let envs = [
|
||||
(
|
||||
"CLAW_CONFIG_HOME",
|
||||
config_home.to_str().expect("config home utf8"),
|
||||
),
|
||||
("HOME", home.to_str().expect("home utf8")),
|
||||
("ANTHROPIC_API_KEY", ""),
|
||||
("ANTHROPIC_AUTH_TOKEN", ""),
|
||||
("OPENAI_API_KEY", ""),
|
||||
];
|
||||
|
||||
let missing = run_claw(&root, &["--output-format", "json", "--compact"], &envs);
|
||||
assert_eq!(missing.status.code(), Some(1));
|
||||
assert!(
|
||||
missing.stderr.is_empty(),
|
||||
"compact missing-argument JSON should keep stderr empty: {}",
|
||||
String::from_utf8_lossy(&missing.stderr)
|
||||
);
|
||||
let missing_json = parse_json_stdout(&missing, "compact missing argument");
|
||||
assert_eq!(missing_json["error_kind"], "missing_argument");
|
||||
assert_eq!(missing_json["argument"], "prompt or subcommand");
|
||||
|
||||
let prompt = run_claw(
|
||||
&root,
|
||||
&["--output-format", "json", "--compact", "hello"],
|
||||
&envs,
|
||||
);
|
||||
assert_eq!(prompt.status.code(), Some(1));
|
||||
assert!(
|
||||
prompt.stderr.is_empty(),
|
||||
"compact prompt JSON should keep stderr empty: {}",
|
||||
String::from_utf8_lossy(&prompt.stderr)
|
||||
);
|
||||
let prompt_json = parse_json_stdout(&prompt, "compact shorthand prompt");
|
||||
assert_eq!(
|
||||
prompt_json["error_kind"], "missing_credentials",
|
||||
"--compact hello should stay on the prompt/provider path, not command_not_found: {prompt_json}"
|
||||
);
|
||||
}
|
||||
|
||||
// #827: direct /unknown-slash-command must emit typed error_kind, not "unknown"
|
||||
// Uses the direct-slash CLI path (no session load needed; reproducible on CI).
|
||||
#[test]
|
||||
|
||||
@@ -222,6 +222,73 @@ fn resume_latest_restores_the_most_recent_managed_session() {
|
||||
assert!(stdout.contains(newer_path.to_str().expect("utf8 path")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_latest_missing_session_fails_without_creating_session_dirs_435() {
|
||||
// given
|
||||
let temp_dir = unique_temp_dir("resume-latest-missing-435");
|
||||
let project_dir = temp_dir.join("project");
|
||||
let config_home = temp_dir.join("config-home");
|
||||
let home = temp_dir.join("home");
|
||||
fs::create_dir_all(&project_dir).expect("project dir should exist");
|
||||
fs::create_dir_all(&config_home).expect("config home should exist");
|
||||
fs::create_dir_all(&home).expect("home should exist");
|
||||
let envs = [
|
||||
(
|
||||
"CLAW_CONFIG_HOME",
|
||||
config_home.to_str().expect("utf8 config home"),
|
||||
),
|
||||
("HOME", home.to_str().expect("utf8 home")),
|
||||
("ANTHROPIC_API_KEY", ""),
|
||||
("ANTHROPIC_AUTH_TOKEN", ""),
|
||||
("OPENAI_API_KEY", ""),
|
||||
];
|
||||
|
||||
// when — both text and JSON resume failures should be non-zero and read-only.
|
||||
let text = run_claw_with_env(&project_dir, &["--resume", "latest"], &envs);
|
||||
let json = run_claw_with_env(
|
||||
&project_dir,
|
||||
&["--output-format", "json", "--resume", "latest"],
|
||||
&envs,
|
||||
);
|
||||
|
||||
// then
|
||||
assert_eq!(
|
||||
text.status.code(),
|
||||
Some(1),
|
||||
"text resume failure must be non-zero"
|
||||
);
|
||||
assert!(
|
||||
text.stdout.is_empty(),
|
||||
"text resume failure should not claim success on stdout: {}",
|
||||
String::from_utf8_lossy(&text.stdout)
|
||||
);
|
||||
let text_stderr = String::from_utf8_lossy(&text.stderr);
|
||||
assert!(
|
||||
text_stderr.contains("no managed sessions found"),
|
||||
"text failure should explain missing sessions: {text_stderr}"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
json.status.code(),
|
||||
Some(1),
|
||||
"JSON resume failure must be non-zero"
|
||||
);
|
||||
assert!(
|
||||
json.stderr.is_empty(),
|
||||
"JSON resume failure should keep stderr empty: {}",
|
||||
String::from_utf8_lossy(&json.stderr)
|
||||
);
|
||||
let parsed: Value = serde_json::from_slice(&json.stdout)
|
||||
.expect("JSON resume failure should emit JSON to stdout");
|
||||
assert_eq!(parsed["status"], "error");
|
||||
assert_eq!(parsed["action"], "restore");
|
||||
assert_eq!(parsed["error_kind"], "no_managed_sessions");
|
||||
assert!(
|
||||
!project_dir.join(".claw").exists(),
|
||||
"failed resume must not create .claw/session directories"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resumed_status_command_emits_structured_json_when_requested() {
|
||||
// given
|
||||
@@ -268,7 +335,7 @@ fn resumed_status_command_emits_structured_json_when_requested() {
|
||||
assert_eq!(parsed["kind"], "status");
|
||||
// model is null in resume mode (not known without --model flag)
|
||||
assert!(parsed["model"].is_null());
|
||||
assert_eq!(parsed["permission_mode"], "danger-full-access");
|
||||
assert_eq!(parsed["permission_mode"], "workspace-write");
|
||||
assert_eq!(parsed["usage"]["messages"], 1);
|
||||
assert!(parsed["usage"]["turns"].is_number());
|
||||
assert!(parsed["workspace"]["cwd"].as_str().is_some());
|
||||
@@ -396,6 +463,9 @@ fn resumed_version_command_emits_structured_json() {
|
||||
assert!(parsed["version"].as_str().is_some());
|
||||
assert!(parsed["git_sha"].as_str().is_some());
|
||||
assert!(parsed["target"].as_str().is_some());
|
||||
assert!(parsed["git_sha_short"].as_str().is_some());
|
||||
assert!(parsed.get("message").is_none());
|
||||
assert!(parsed["human_readable"].as_str().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user