Compare commits

...

27 Commits

Author SHA1 Message Date
YeonGyu-Kim
ece48c7174 docs: correct agent-code binary name in warning — ROADMAP #53
'cargo install agent-code' installs 'agent.exe' (Windows) / 'agent'
(Unix), NOT 'agent-code'. Previous note said "binary name is 'agent-code'"
which sent users to the wrong command.

Updated the install warning to show the actual binary name.
ROADMAP #53 filed: package vs binary name mismatch in the install path.
2026-04-10 02:36:43 +09:00
YeonGyu-Kim
c8cac7cae8 fix(cli): doctor config check hides non-existent candidate paths
Before: doctor reported 'loaded 0/5' and listed 5 'Discovered file'
entries for paths that don't exist on disk. This looked like 5 files
failed to load, when in fact they are just standard search locations.

After: only paths that actually exist on disk are shown as 'Discovered
file'. 'loaded N/M' denominator is now the count of present files, not
candidate paths. With no config files present: 'loaded 0/0' +
'Discovered files  <none> (defaults active)'.

159 CLI tests pass.
2026-04-10 02:32:47 +09:00
YeonGyu-Kim
57943b17f3 docs: reframe Windows setup — PowerShell is supported, Git Bash/WSL optional
Previous wording said 'recommended shell is Git Bash or WSL (not PowerShell,
not cmd)' which was incorrect — claw builds and runs fine in PowerShell.

New framing:
- PowerShell: supported primary Windows path with PowerShell-style commands
- Git Bash / WSL: optional alternatives, not requirements
- MINGW64 note moved to Git Bash callout (no longer implies it is required)

Source: gaebal-gajae correction 2026-04-10.
2026-04-10 02:25:47 +09:00
YeonGyu-Kim
4730b667c4 docs: warn against 'cargo install claw-code' false-positive — ROADMAP #52
The claw-code crate on crates.io is a deprecated stub. cargo install
claw-code succeeds but places claw-code-deprecated.exe, not claw.
Running it only prints 'claw-code has been renamed to agent-code'.

Previous note only warned about 'clawcode' (no hyphen) — the actual trap
is the hyphenated name.

Updated the warning block with explicit caution: do not use
'cargo install claw-code', install agent-code or build from source.
ROADMAP #52 filed.
2026-04-10 02:16:58 +09:00
YeonGyu-Kim
dc4fa55d64 fix(cli): /status JSON emits null model and correct session_id in resume mode
Two bugs in --output-format json --resume /status:

1. 'model' field emitted 'restored-session' (a run-mode label) instead of
   the actual model or null. Fixed: status_json_value now takes Option<&str>
   for model; resume path passes None; live REPL path passes Some(model).

2. 'session_id' extracted parent dir name ('sessions') instead of the file
   stem. Session files are session-<id>.jsonl directly under .claw/sessions/,
   not in a subdirectory. Fixed: extract file_stem() instead of
   parent().file_name().

159 CLI tests pass.
2026-04-10 02:03:14 +09:00
YeonGyu-Kim
9cf4033fdf docs: add Windows setup section (Git Bash/WSL prereqs) — ROADMAP #51
Users were hitting:
- bash: cargo: command not found (Rust not installed or not on PATH)
- C:\... vs /c/... path confusion in Git Bash
- MINGW64 prompt misread as broken install

New '### Windows setup' section in README covers:
1. Install Rust via rustup.rs
2. Open Git Bash (MINGW64 is normal)
3. Verify cargo --version / run . ~/.cargo/env if missing
4. Use /c/Users/... paths
5. Clone + build + run steps

WSL2 tip added for lower-friction alternative.

ROADMAP #51 filed.
2026-04-10 01:42:43 +09:00
YeonGyu-Kim
a3d0c9e5e7 fix(api): sanitize orphaned tool messages at request-building layer
Adds sanitize_tool_message_pairing() called from build_chat_completion_request()
after translate_message() runs. Drops any role:"tool" message whose
immediately-preceding non-tool message is role:"assistant" but has no
tool_calls entry matching the tool_call_id.

This is the second layer of the tool-pairing invariant defense:
- 6e301c8: compaction boundary fix (producer layer)
- this commit: request-builder sanitizer (sender layer)

Together these close the 400-error loop for resumed/compacted multi-turn
tool sessions on OpenAI-compatible backends.

Sanitization only fires when preceding message is role:assistant (not
user/system) to avoid dropping valid translation artifacts from mixed
user-message content blocks.

Regression tests: sanitize_drops_orphaned_tool_messages covers valid pair,
orphaned tool (no tool_calls in preceding assistant), mismatched id, and
two tool results both referencing the same assistant turn.

116 api + 159 CLI + 431 runtime tests pass. Fmt clean.
2026-04-10 01:35:00 +09:00
YeonGyu-Kim
78dca71f3f fix(cli): JSON parity for /compact and /clear in resume mode
/compact now emits: {kind:compact, skipped, removed_messages, kept_messages}
/clear now emits:  {kind:clear, previous_session_id, new_session_id, backup, session_file}
/clear (no --confirm) now emits: {kind:error, error:..., hint:...}

Previously both returned json:None and fell through to prose output even
in --output-format json --resume mode. 159 CLI tests pass.
2026-04-10 01:31:21 +09:00
YeonGyu-Kim
39a7dd08bb docs(roadmap): file PowerShell permission over-escalation as ROADMAP #50
PowerShell tool is registered as danger-full-access regardless of command
semantics. Workspace-write sessions still require escalation for read-only
in-workspace commands (Get-Content, Get-ChildItem, etc.).

Root cause: mvp_tool_specs registers PowerShell and bash both with
PermissionMode::DangerFullAccess unconditionally. Fix needs command-level
heuristic analysis to classify read-only in-workspace commands at
WorkspaceWrite rather than DangerFullAccess.

Source: tanishq_devil in #claw-code 2026-04-10; traced by gaebal-gajae.
2026-04-10 01:12:39 +09:00
YeonGyu-Kim
d95149b347 fix(cli): surface resolved path in dump-manifests error — ROADMAP #45 partial
Before:
  error: failed to extract manifests: No such file or directory (os error 2)

After:
  error: failed to extract manifests: No such file or directory (os error 2)
    looked in: /Users/yeongyu/clawd/claw-code/rust

The workspace_dir is computed from CARGO_MANIFEST_DIR at compile time and
only resolves correctly when running from the build tree. Surfacing the
resolved path lets users understand immediately why it fails outside the
build context.

ROADMAP #45 root cause (build-tree-only path) remains open.
2026-04-10 01:01:53 +09:00
YeonGyu-Kim
47aa1a57ca fix(cli): surface command name in 'not yet implemented' REPL message
Add SlashCommand::slash_name() to the commands crate — returns the
canonical '/name' string for any variant. Used in the REPL's stub
catch-all arm to surface which command was typed instead of printing
the opaque 'Command registered but not yet implemented.'

Before: typing /rewind → 'Command registered but not yet implemented.'
After:  typing /rewind → '/rewind is not yet implemented in this build.'

Also update the compacts_sessions_via_slash_command test assertion to
tolerate the boundary-guard fix from 6e301c8 (removed_message_count
can be 1 or 2 depending on whether the boundary falls on a tool-result
pair). All 159 CLI + 431 runtime + 115 api tests pass.
2026-04-10 00:39:16 +09:00
YeonGyu-Kim
6e301c8bb3 fix(runtime): prevent orphaned tool-result at compaction boundary; /cost JSON
Two fixes:

1. compact.rs: When the compaction boundary falls at the start of a
   tool-result turn, the preceding assistant turn with ToolUse would be
   removed — leaving an orphaned role:tool message with no preceding
   assistant tool_calls. OpenAI-compat backends reject this with 400.

   Fix: after computing raw_keep_from, walk the boundary back until the
   first preserved message is not a ToolResult (or its preceding assistant
   has been included). Regression test added:
   compaction_does_not_split_tool_use_tool_result_pair.

   Source: gaebal-gajae multi-turn tool-call 400 repro 2026-04-09.

2. /cost resume: add JSON output:
   {kind:cost, input_tokens, output_tokens, cache_creation_input_tokens,
    cache_read_input_tokens, total_tokens}

159 CLI + 431 runtime tests pass. Fmt clean.
2026-04-10 00:13:45 +09:00
YeonGyu-Kim
7587f2c1eb fix(cli): JSON parity for /memory and /providers in resume mode
Two gaps closed:

1. /memory (resume): json field was None, emitting prose regardless of
   --output-format json. Now emits:
     {kind:memory, cwd, instruction_files:N, files:[{path,lines,preview}...]}

2. /providers (resume): had a spec entry but no parse arm, producing the
   circular 'Unknown slash command: /providers — Did you mean /providers'.
   Added 'providers' as an alias for 'doctor' in the parse match so
   /providers dispatches to the same structured diagnostic output.

3. /doctor (resume): also wired json_value() so --output-format json
   returns the structured doctor report instead of None.

Continues ROADMAP #26 resumed-command JSON parity track.
159 CLI tests pass, fmt clean.
2026-04-09 23:35:25 +09:00
YeonGyu-Kim
ed42f8f298 fix(api): surface provider error in SSE stream frames (companion to ff416ff)
Same fix as ff416ff but for the streaming path. Some backends embed an
error JSON object in an SSE data: frame:

  data: {"error":{"message":"context too long","code":400}}

parse_sse_frame() was attempting to deserialize this as ChatCompletionChunk
and failing with 'missing field' / 'invalid type', hiding the actual
backend error message.

Fix: check for an 'error' key before full chunk deserialization, same as
the non-streaming path in ff416ff. Symmetric pair:
- ff416ff: non-streaming path (response body)
- this:    streaming path (SSE data: frame)

115 api + 159 CLI tests pass. Fmt clean.
2026-04-09 23:03:33 +09:00
YeonGyu-Kim
ff416ff3e7 fix(api): surface provider error body before attempting completion parse
When a local/proxy OpenAI-compatible backend returns an error object:
  {"error":{"message":"...","type":"...","code":...}}

claw was trying to deserialize it as a ChatCompletionResponse and
failing with the cryptic 'failed to parse OpenAI response: missing
field id', completely hiding the actual backend error message.

Fix: before full deserialization, check if the parsed JSON has an
'error' key and promote it directly to ApiError::Api so the user
sees the real error (e.g. 'The number of tokens to keep from the
initial prompt is greater than the context length').

Source: devilayu in #claw-code 2026-04-09 — local LM Studio context
limit error was invisible; user saw 'missing field id' instead.
159 CLI + 115 api tests pass. Fmt clean.
2026-04-09 22:33:07 +09:00
YeonGyu-Kim
6ac7d8cd46 fix(api): omit tool_calls field from assistant messages when empty
When serializing a multi-turn conversation for the OpenAI-compatible path,
assistant messages with no tool calls were always emitting 'tool_calls: []'.
Some providers reject requests where a prior assistant turn carries an
explicit empty tool_calls array (400 on subsequent turns after a plain
text assistant response).

Fix: only include 'tool_calls' in the serialized assistant message when
the vec is non-empty. Empty case omits the field entirely.

This is a companion fix to fd7aade (null tool_calls in stream delta).
The two bugs are symmetric: fd7aade handled inbound null -> empty vec;
this handles outbound empty vec -> field omitted.

Two regression tests added:
- assistant_message_without_tool_calls_omits_tool_calls_field
- assistant_message_with_tool_calls_includes_tool_calls_field

115 api tests pass. Fmt clean.

Source: gaebal-gajae repro 2026-04-09 (400 on multi-turn, companion to
null tool_calls stream-delta fix).
2026-04-09 22:06:25 +09:00
YeonGyu-Kim
7ec6860d9a fix(cli): emit JSON for /config in --output-format json --resume mode
/config resumed returned json:None, falling back to prose output even in
--output-format json mode. Adds render_config_json() that produces:

  {
    "kind": "config",
    "cwd": "...",
    "loaded_files": N,
    "merged_keys": N,
    "files": [{"path":"...","source":"user|project|local","loaded":true|false}, ...]
  }

Wires it into the SlashCommand::Config resume arm alongside the existing
prose render. Continues the resumed-command JSON parity track (ROADMAP #26).
159 CLI tests pass, fmt clean.
2026-04-09 22:03:11 +09:00
YeonGyu-Kim
0e12d15daf fix(cli): add --allow-broad-cwd; require confirmation or flag in broad-CWD mode 2026-04-09 21:55:22 +09:00
YeonGyu-Kim
fd7aade5b5 fix(api): tolerate null tool_calls in OpenAI-compat stream delta chunks
Some OpenAI-compatible providers emit 'tool_calls: null' in streaming
delta chunks instead of omitting the field or using an empty array:

  "delta": {"content":"","function_call":null,"tool_calls":null}

serde's #[serde(default)] only handles absent keys — an explicit null
value still fails deserialization with:
  'invalid type: null, expected a sequence'

Fix: replace #[serde(default)] with a custom deserializer helper
deserialize_null_as_empty_vec() that maps null -> Vec::default(),
keeping the existing absent-key default behaviour.

Regression test added: delta_with_null_tool_calls_deserializes_as_empty_vec
uses the exact provider response shape from gaebal-gajae's repro (2026-04-09).

112 api lib tests pass. Fmt clean.

Companion to gaebal-gajae's local 448cf2c — independently reproduced
and landed on main.
2026-04-09 21:39:52 +09:00
YeonGyu-Kim
de916152cb docs(roadmap): file #44-#49 from 2026-04-09 dogfood cycle
#44 — broad-CWD warning-only; policy-level enforcement needed
#45 — claw dump-manifests opaque error (no path context)
#46 — /tokens /cache /stats dead spec (done at 60ec2ae)
#47 — /diff cryptic error outside git repo (done at aef85f8)
#48 — piped stdin triggers REPL instead of prompt (done at 84b77ec)
#49 — resumed slash errors emitted as prose in json mode (done at da42421)
2026-04-09 21:36:09 +09:00
YeonGyu-Kim
60ec2aed9b fix(cli): wire /tokens and /cache as aliases for /stats; implement /stats
Dogfood found that /tokens and /cache had spec entries (resume_supported:
true) but no parse arms in the command parser, resulting in:
  'Unknown slash command: /tokens — Did you mean /tokens'
(the suggestion engine found the spec entry but parsing always failed)

Fix three things:
1. Add 'tokens' | 'cache' as aliases for 'stats' in the parse match so
   the commands actually resolve to SlashCommand::Stats
2. Implement SlashCommand::Stats in the REPL dispatch — previously fell
   through to 'Command registered but not yet implemented'. Now shows
   cumulative token usage for the session.
3. Implement SlashCommand::Stats in run_resume_command — previously
   returned 'unsupported resumed slash command'. Now emits:
   text:  Cost / Input tokens / Output tokens / Cache create / Cache read
   json:  {kind:stats, input_tokens, output_tokens, cache_*, total_tokens}

159 CLI tests pass, fmt clean.
2026-04-09 21:34:36 +09:00
YeonGyu-Kim
5f6f453b8d fix(cli): warn when launched from home dir or filesystem root
Users launching claw from their home directory (or /) have no project
boundary — the agent can read/search the entire machine, often far beyond
the intended scope. kapcomunica in #claw-code reported exactly this:
'it searched my entire computer.'

Add warn_if_broad_cwd() called at prompt and REPL startup:
- checks if CWD == $HOME or CWD has no parent (fs root)
- prints a clear warning to stderr:
    Warning: claw is running from a very broad directory (/home/user).
    The agent can read and search everything under this path.
    Consider running from inside your project: cd /path/to/project && claw

Warning fires on both claw (REPL) and claw prompt '...' paths.
Does not fire from project subdirectories. Uses std::env::var_os("HOME"),
no extra deps.

159 CLI tests pass, fmt clean.
2026-04-09 21:26:51 +09:00
YeonGyu-Kim
da4242198f fix(cli): emit JSON error for unsupported resumed slash commands in JSON mode
When claw --output-format json --resume <session> /commit (or /plugins, etc.)
encountered an 'unsupported resumed slash command' error, it called
eprintln!() and exit(2) directly, bypassing both the main() JSON error
handler and the output_format check.

Fix: in both the slash-command parse-error path and the run_resume_command
Err path, check output_format and emit a structured JSON error:

  {"type":"error","error":"unsupported resumed slash command","command":"/commit"}

Text mode unchanged (still exits 2 with prose to stderr).
Addresses the resumed-command parity gap (gaebal-gajae ROADMAP #26 track).
159 CLI tests pass, fmt clean.
2026-04-09 21:04:50 +09:00
YeonGyu-Kim
84b77ece4d fix(cli): pipe stdin to prompt when no args given (suppress REPL on pipe)
When stdin is not a terminal (pipe or redirect) and no prompt is given on
the command line, claw was starting the interactive REPL and printing the
startup banner, then consuming the pipe without sending anything to the API.

Fix: in parse_args, when rest.is_empty() and stdin is not a terminal, read
stdin synchronously and dispatch as CliAction::Prompt instead of Repl.
Empty pipe still falls through to Repl (interactive launch with no input).

Before: echo 'hello' | claw  -> startup banner + REPL start
After:  echo 'hello' | claw  -> dispatches as one-shot prompt

159 CLI tests pass, fmt clean.
2026-04-09 20:36:14 +09:00
YeonGyu-Kim
aef85f8af5 fix(cli): /diff shows clear error when not in a git repo
Previously claw --resume <session> /diff would produce:
  'git diff --cached failed: error: unknown option `cached\''
when the CWD was not inside a git project, because git falls back to
--no-index mode which does not support --cached.

Two fixes:
1. render_diff_report_for() checks 'git rev-parse --is-inside-work-tree'
   before running git diff, and returns a human-readable message if not
   in a git repo:
     'Diff\n  Result  no git repository\n  Detail  <cwd> is not inside a git project'
2. resume /diff now uses std::env::current_dir() instead of the session
   file's parent directory as the CWD for the diff (session parent dir
   is the .claw/sessions/<id>/ directory, never a git repo).

159 CLI tests pass, fmt clean.
2026-04-09 20:04:21 +09:00
YeonGyu-Kim
3ed27d5cba fix(cli): emit JSON for /history in --output-format json --resume mode
Previously claw --output-format json --resume <session> /history emitted
prose text regardless of the output format flag. Now emits structured JSON:

  {"kind":"history","total":N,"showing":M,"entries":[{"timestamp_ms":...,"text":"..."},...]}

Mirrors the parity pattern established in ROADMAP #26 for other resume commands.
159 CLI tests pass, fmt clean.
2026-04-09 19:33:50 +09:00
YeonGyu-Kim
e1ed30a038 fix(cli): surface session_id in /status JSON output
When running claw --output-format json --resume <session> /status, the
JSON output had 'session' (full file path) but no 'session_id' field,
making it impossible for scripts to extract the loaded session ID.

Now extracts the session-id directory component from the session path
(e.g. .claw/sessions/<session-id>/session-xxx.jsonl → session-id)
and includes it as 'session_id' in the JSON status envelope.

159 CLI tests pass, fmt clean.
2026-04-09 19:06:36 +09:00
6 changed files with 940 additions and 62 deletions

View File

@@ -46,7 +46,12 @@ The canonical implementation lives in [`rust/`](./rust), and the current source
## Quick start
> [!NOTE]
> **`cargo install clawcode` will not work** — this package is not published on crates.io. Build from source as shown below.
> [!WARNING]
> **`cargo install claw-code` installs the wrong thing.** The `claw-code` crate on crates.io is a deprecated stub that places `claw-code-deprecated.exe` — not `claw`. Running it only prints `"claw-code has been renamed to agent-code"`. **Do not use `cargo install claw-code`.** Either build from source (this repo) or install the upstream binary:
> ```bash
> cargo install agent-code # upstream binary — installs 'agent.exe' (Windows) / 'agent' (Unix), NOT 'agent-code'
> ```
> This repo (`ultraworkers/claw-code`) is **build-from-source only** — follow the steps below.
```bash
# 1. Clone and build
@@ -67,6 +72,30 @@ export ANTHROPIC_API_KEY="sk-ant-..."
> [!NOTE]
> **Windows (PowerShell):** the binary is `claw.exe`, not `claw`. Use `.\target\debug\claw.exe` or run `cargo run -- prompt "say hello"` to skip the path lookup.
### Windows setup
**PowerShell is a supported Windows path.** Use whichever shell works for you. The common onboarding issues on Windows are:
1. **Install Rust first** — download from <https://rustup.rs/> and run the installer. Close and reopen your terminal when it finishes.
2. **Verify Rust is on PATH:**
```powershell
cargo --version
```
If this fails, reopen your terminal or run the PATH setup from the Rust installer output, then retry.
3. **Clone and build** (works in PowerShell, Git Bash, or WSL):
```powershell
git clone https://github.com/ultraworkers/claw-code
cd claw-code/rust
cargo build --workspace
```
4. **Run** (PowerShell — note `.exe` and backslash):
```powershell
$env:ANTHROPIC_API_KEY = "sk-ant-..."
.\target\debug\claw.exe prompt "say hello"
```
**Git Bash / WSL** are optional alternatives, not requirements. If you prefer bash-style paths (`/c/Users/you/...` instead of `C:\Users\you\...`), Git Bash (ships with Git for Windows) works well. In Git Bash, the `MINGW64` prompt is expected and normal — not a broken install.
> [!NOTE]
> **Auth:** claw requires an **API key** (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc.) — Claude subscription login is not a supported auth path.

View File

@@ -512,3 +512,23 @@ Model name prefix now wins unconditionally over env-var presence. Regression tes
42. **`--output-format json` errors emitted as prose, not JSON** — dogfooded 2026-04-09. When `claw --output-format json prompt` hits an API error, the error was printed as plain text (`error: api returned 401 ...`) to stderr instead of a JSON object. Any tool or CI step parsing claw's JSON output gets nothing parseable on failure — the error is invisible to the consumer. **Fix (`a...`):** detect `--output-format json` in `main()` at process exit and emit `{"type":"error","error":"<message>"}` to stderr instead of the prose format. Non-JSON path unchanged. **Done** in this nudge cycle.
43. **Hook ingress opacity: typed hook-health/delivery report missing** — dogfooded 2026-04-09 while wiring the agentika timer→hook→session bridge. Debugging hook delivery required manual HTTP probing and inferring state from raw status codes (404 = no route, 405 = route exists, 400 = body missing required field). No typed endpoint exists to report: route present/absent, accepted methods, mapping matched/not matched, target session resolved/not resolved, last delivery failure class. Fix shape: add `GET /hooks/health` (or `/hooks/status`) returning a structured JSON diagnostic — no auth exposure, just routing/matching/session state. Source: gaebal-gajae dogfood 2026-04-09.
44. **Broad-CWD guardrail is warning-only; needs policy-level enforcement** — dogfooded 2026-04-09. `5f6f453` added a stderr warning when claw starts from `$HOME` or filesystem root (live user kapcomunica scanned their whole machine). Warning is a mitigation, not a guardrail: the agent still proceeds with unbounded scope. Follow-up fix shape: (a) add `--allow-broad-cwd` flag to suppress the warning explicitly (for legitimate home-dir use cases); (b) in default interactive mode, prompt "You are running from your home directory — continue? [y/N]" and exit unless confirmed; (c) in `--output-format json` or piped mode, treat broad-CWD as a hard error (exit 1) with `{"type":"error","error":"broad CWD: running from home directory requires --allow-broad-cwd"}`. Source: kapcomunica in #claw-code 2026-04-09; gaebal-gajae ROADMAP note same cycle.
45. **`claw dump-manifests` fails with opaque "No such file or directory"** — dogfooded 2026-04-09. `claw dump-manifests` emits `error: failed to extract manifests: No such file or directory (os error 2)` with no indication of which file or directory is missing. **Partial fix at `47aa1a5`+1**: error message now includes `looked in: <path>` so the build-tree path is visible, what manifests are, or how to fix it. Fix shape: (a) surface the missing path in the error message; (b) add a pre-check that explains what manifests are and where they should be (e.g. `.claw/manifests/` or the plugins directory); (c) if the command is only valid after `claw init` or after installing plugins, say so explicitly. Source: Jobdori dogfood 2026-04-09.
46. **`/tokens`, `/cache`, `/stats` were dead spec — parse arms missing** — dogfooded 2026-04-09. All three had spec entries with `resume_supported: true` but no parse arms, producing the circular error "Unknown slash command: /tokens — Did you mean /tokens". Also `SlashCommand::Stats` existed but was unimplemented in both REPL and resume dispatch. **Done at `60ec2ae` 2026-04-09**: `"tokens" | "cache"` now alias to `SlashCommand::Stats`; `Stats` is wired in both REPL and resume path with full JSON output. Source: Jobdori dogfood 2026-04-09.
47. **`/diff` fails with cryptic "unknown option 'cached'" outside a git repo; resume /diff used wrong CWD** — dogfooded 2026-04-09. `claw --resume <session> /diff` in a non-git directory produced `git diff --cached failed: error: unknown option 'cached'` because git falls back to `--no-index` mode outside a git tree. Also resume `/diff` used `session_path.parent()` (the `.claw/sessions/<id>/` dir) as CWD for the diff — never a git repo. **Done at `aef85f8` 2026-04-09**: `render_diff_report_for()` now checks `git rev-parse --is-inside-work-tree` first and returns a clear "no git repository" message; resume `/diff` uses `std::env::current_dir()`. Source: Jobdori dogfood 2026-04-09.
48. **Piped stdin triggers REPL startup and banner instead of one-shot prompt** — dogfooded 2026-04-09. `echo "hello" | claw` started the interactive REPL, printed the ASCII banner, consumed the pipe without sending anything to the API, then exited. `parse_args` always returned `CliAction::Repl` when no args were given, never checking whether stdin was a pipe. **Done at `84b77ec` 2026-04-09**: when `rest.is_empty()` and stdin is not a terminal, read the pipe and dispatch as `CliAction::Prompt`. Empty pipe still falls through to REPL. Source: Jobdori dogfood 2026-04-09.
49. **Resumed slash command errors emitted as prose in `--output-format json` mode** — dogfooded 2026-04-09. `claw --output-format json --resume <session> /commit` called `eprintln!()` and `exit(2)` directly, bypassing the JSON formatter. Both the slash-command parse-error path and the `run_resume_command` Err path now check `output_format` and emit `{"type":"error","error":"...","command":"..."}`. **Done at `da42421` 2026-04-09**. Source: gaebal-gajae ROADMAP #26 track; Jobdori dogfood.
50. **PowerShell tool is registered as `danger-full-access` — workspace-aware reads still require escalation** — dogfooded 2026-04-10. User running `workspace-write` session mode (tanishq_devil in #claw-code) had to use `danger-full-access` even for simple in-workspace reads via PowerShell (e.g. `Get-Content`). Root cause traced by gaebal-gajae: `PowerShell` tool spec is registered with `required_permission: PermissionMode::DangerFullAccess` (same as the `bash` tool in `mvp_tool_specs`), not with per-command workspace-awareness. Bash shell and PowerShell execute arbitrary commands, so blanket promotion to `danger-full-access` is conservative — but it over-escalates read-only in-workspace operations. Fix shape: (a) add command-level heuristic analysis to the PowerShell executor (read-only commands like `Get-Content`, `Get-ChildItem`, `Test-Path` that target paths inside CWD → `WorkspaceWrite` required; everything else → `DangerFullAccess`); (b) mirror the same workspace-path check that the bash executor uses; (c) add tests covering the permission boundary for PowerShell read vs write vs network commands. Note: the `bash` tool in `mvp_tool_specs` is also `DangerFullAccess` and has the same gap — both should be fixed together. Source: tanishq_devil in #claw-code 2026-04-10; root cause identified by gaebal-gajae.
51. **Windows first-run onboarding missing: no explicit Rust + shell prerequisite branch** — dogfooded 2026-04-10 via #claw-code. User hit `bash: cargo: command not found`, `C:\...` vs `/c/...` path confusion in Git Bash, and misread `MINGW64` prompt as a broken MinGW install rather than normal Git Bash. Root cause: README/docs have no Windows-specific install path that says (1) install Rust first via rustup, (2) open Git Bash or WSL (not PowerShell or cmd), (3) use `/c/Users/...` style paths in bash, (4) then `cargo install claw-code`. Users can reach chat mode confusion before realizing claw was never installed. Fix shape: add a **Windows setup** section to README.md (or INSTALL.md) with explicit prerequisite steps, Git Bash vs WSL guidance, and a note that `MINGW64` in the prompt is expected and normal. Source: tanishq_devil in #claw-code 2026-04-10; traced by gaebal-gajae.
52. **`cargo install claw-code` false-positive install: deprecated stub silently succeeds** — dogfooded 2026-04-10 via #claw-code. User runs `cargo install claw-code`, install succeeds, Cargo places `claw-code-deprecated.exe`, user runs `claw` and gets `command not found`. The deprecated binary only prints `"claw-code has been renamed to agent-code"`. The success signal is false-positive: install appears to work but leaves the user with no working `claw` binary. Fix shape: (a) README must warn explicitly against `cargo install claw-code` with the hyphen (current note only warns about `clawcode` without hyphen); (b) if the deprecated crate is in our control, update its binary to print a clearer redirect message including `cargo install agent-code`; (c) ensure the Windows setup doc path mentions `agent-code` explicitly. Source: user in #claw-code 2026-04-10; traced by gaebal-gajae.
53. **`cargo install agent-code` produces `agent.exe`, not `agent-code.exe` — binary name mismatch in docs** — dogfooded 2026-04-10 via #claw-code. User follows the `claw-code` rename hint to run `cargo install agent-code`, install succeeds, but the installed binary is `agent.exe` (Unix: `agent`), not `agent-code` or `agent-code.exe`. User tries `agent-code --version`, gets `command not found`, concludes install is broken. The package name (`agent-code`), the crate name, and the installed binary name (`agent`) are all different. Fix shape: docs must show the full chain explicitly: `cargo install agent-code` → run via `agent` (Unix) / `agent.exe` (Windows). ROADMAP #52 note updated with corrected binary name. Source: user in #claw-code 2026-04-10; traced by gaebal-gajae.

View File

@@ -157,6 +157,35 @@ impl OpenAiCompatClient {
let response = self.send_with_retry(&request).await?;
let request_id = request_id_from_headers(response.headers());
let body = response.text().await.map_err(ApiError::from)?;
// Some backends return {"error":{"message":"...","type":"...","code":...}}
// instead of a valid completion object. Check for this before attempting
// full deserialization so the user sees the actual error, not a cryptic
// "missing field 'id'" parse failure.
if let Ok(raw) = serde_json::from_str::<serde_json::Value>(&body) {
if let Some(err_obj) = raw.get("error") {
let msg = err_obj
.get("message")
.and_then(|m| m.as_str())
.unwrap_or("provider returned an error")
.to_string();
let code = err_obj
.get("code")
.and_then(|c| c.as_u64())
.map(|c| c as u16);
return Err(ApiError::Api {
status: reqwest::StatusCode::from_u16(code.unwrap_or(400))
.unwrap_or(reqwest::StatusCode::BAD_REQUEST),
error_type: err_obj
.get("type")
.and_then(|t| t.as_str())
.map(str::to_owned),
message: Some(msg),
request_id,
body,
retryable: false,
});
}
}
let payload = serde_json::from_str::<ChatCompletionResponse>(&body).map_err(|error| {
ApiError::json_deserialize(self.config.provider_name, &request.model, &body, error)
})?;
@@ -255,6 +284,19 @@ impl OpenAiCompatClient {
static JITTER_COUNTER: AtomicU64 = AtomicU64::new(0);
/// Returns a random additive jitter in `[0, base]` to decorrelate retries
/// Deserialize a JSON field as a `Vec<T>`, treating an explicit `null` value
/// the same as a missing field (i.e. as an empty vector).
/// Some OpenAI-compatible providers emit `"tool_calls": null` instead of
/// omitting the field or using `[]`, which serde's `#[serde(default)]` alone
/// does not tolerate — `default` only handles absent keys, not null values.
fn deserialize_null_as_empty_vec<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
where
D: serde::Deserializer<'de>,
T: serde::Deserialize<'de>,
{
Ok(Option::<Vec<T>>::deserialize(deserializer)?.unwrap_or_default())
}
/// from multiple concurrent clients. Entropy is drawn from the nanosecond
/// wall clock mixed with a monotonic counter and run through a splitmix64
/// finalizer; adequate for retry jitter (no cryptographic requirement).
@@ -673,7 +715,7 @@ struct ChunkChoice {
struct ChunkDelta {
#[serde(default)]
content: Option<String>,
#[serde(default)]
#[serde(default, deserialize_with = "deserialize_null_as_empty_vec")]
tool_calls: Vec<DeltaToolCall>,
}
@@ -755,6 +797,14 @@ fn build_chat_completion_request(request: &MessageRequest, config: OpenAiCompatC
for message in &request.messages {
messages.extend(translate_message(message));
}
// Sanitize: drop any `role:"tool"` message that does not have a valid
// paired `role:"assistant"` with a `tool_calls` entry carrying the same
// `id` immediately before it (directly or as part of a run of tool
// results). OpenAI-compatible backends return 400 for orphaned tool
// messages regardless of how they were produced (compaction, session
// editing, resume, etc.). We drop rather than error so the request can
// still proceed with the remaining history intact.
messages = sanitize_tool_message_pairing(messages);
// Strip routing prefix (e.g., "openai/gpt-4" → "gpt-4") for the wire.
let wire_model = strip_routing_prefix(&request.model);
@@ -840,11 +890,16 @@ fn translate_message(message: &InputMessage) -> Vec<Value> {
if text.is_empty() && tool_calls.is_empty() {
Vec::new()
} else {
vec![json!({
let mut msg = serde_json::json!({
"role": "assistant",
"content": (!text.is_empty()).then_some(text),
"tool_calls": tool_calls,
})]
});
// Only include tool_calls when non-empty: some providers reject
// assistant messages with an explicit empty tool_calls array.
if !tool_calls.is_empty() {
msg["tool_calls"] = json!(tool_calls);
}
vec![msg]
}
}
_ => message
@@ -871,6 +926,75 @@ fn translate_message(message: &InputMessage) -> Vec<Value> {
}
}
/// Remove `role:"tool"` messages from `messages` that have no valid paired
/// `role:"assistant"` message with a matching `tool_calls[].id` immediately
/// preceding them. This is a last-resort safety net at the request-building
/// layer — the compaction boundary fix (6e301c8) prevents the most common
/// producer path, but resume, session editing, or future compaction variants
/// could still create orphaned tool messages.
///
/// Algorithm: scan left-to-right. For each `role:"tool"` message, check the
/// immediately preceding non-tool message. If it's `role:"assistant"` with a
/// `tool_calls` array containing an entry whose `id` matches the tool
/// message's `tool_call_id`, the pair is valid and both are kept. Otherwise
/// the tool message is dropped.
fn sanitize_tool_message_pairing(messages: Vec<Value>) -> Vec<Value> {
// Collect indices of tool messages that are orphaned.
let mut drop_indices = std::collections::HashSet::new();
for (i, msg) in messages.iter().enumerate() {
if msg.get("role").and_then(|v| v.as_str()) != Some("tool") {
continue;
}
let tool_call_id = msg
.get("tool_call_id")
.and_then(|v| v.as_str())
.unwrap_or("");
// Find the nearest preceding non-tool message.
let preceding = messages[..i]
.iter()
.rev()
.find(|m| m.get("role").and_then(|v| v.as_str()) != Some("tool"));
// A tool message is considered paired when:
// (a) the nearest preceding non-tool message is an assistant message
// whose `tool_calls` array contains an entry with the matching id, OR
// (b) there's no clear preceding context (e.g. the message comes right
// after a user turn — this can happen with translated mixed-content
// user messages). In case (b) we allow the message through rather
// than silently dropping potentially valid history.
let preceding_role = preceding
.and_then(|m| m.get("role"))
.and_then(|v| v.as_str())
.unwrap_or("");
// Only apply sanitization when the preceding message is an assistant
// turn (the invariant is: assistant-with-tool_calls must precede tool).
// If the preceding is something else (user, system) don't drop — it
// may be a valid translation artifact or a path we don't understand.
if preceding_role != "assistant" {
continue;
}
let paired = preceding
.and_then(|m| m.get("tool_calls").and_then(|tc| tc.as_array()))
.map(|tool_calls| {
tool_calls
.iter()
.any(|tc| tc.get("id").and_then(|v| v.as_str()) == Some(tool_call_id))
})
.unwrap_or(false);
if !paired {
drop_indices.insert(i);
}
}
if drop_indices.is_empty() {
return messages;
}
messages
.into_iter()
.enumerate()
.filter(|(i, _)| !drop_indices.contains(i))
.map(|(_, m)| m)
.collect()
}
fn flatten_tool_result_content(content: &[ToolResultContentBlock]) -> String {
content
.iter()
@@ -1037,6 +1161,35 @@ fn parse_sse_frame(
if payload == "[DONE]" {
return Ok(None);
}
// Some backends embed an error object in a data: frame instead of using an
// HTTP error status. Surface the error message directly rather than letting
// ChatCompletionChunk deserialization fail with a cryptic 'missing field' error.
if let Ok(raw) = serde_json::from_str::<serde_json::Value>(&payload) {
if let Some(err_obj) = raw.get("error") {
let msg = err_obj
.get("message")
.and_then(|m| m.as_str())
.unwrap_or("provider returned an error in stream")
.to_string();
let code = err_obj
.get("code")
.and_then(|c| c.as_u64())
.map(|c| c as u16);
let status = reqwest::StatusCode::from_u16(code.unwrap_or(400))
.unwrap_or(reqwest::StatusCode::BAD_REQUEST);
return Err(ApiError::Api {
status,
error_type: err_obj
.get("type")
.and_then(|t| t.as_str())
.map(str::to_owned),
message: Some(msg),
request_id: None,
body: payload.to_string(),
retryable: false,
});
}
}
serde_json::from_str::<ChatCompletionChunk>(&payload)
.map(Some)
.map_err(|error| ApiError::json_deserialize(provider, model, &payload, error))
@@ -1484,6 +1637,147 @@ mod tests {
);
}
/// Regression test: some OpenAI-compatible providers emit `"tool_calls": null`
/// in stream delta chunks instead of omitting the field or using `[]`.
/// Before the fix this produced: `invalid type: null, expected a sequence`.
#[test]
fn delta_with_null_tool_calls_deserializes_as_empty_vec() {
// Simulate the exact shape observed in the wild (gaebal-gajae repro 2026-04-09)
let json = r#"{
"content": "",
"function_call": null,
"refusal": null,
"role": "assistant",
"tool_calls": null
}"#;
use super::deserialize_null_as_empty_vec;
#[derive(serde::Deserialize, Debug)]
struct Delta {
content: Option<String>,
#[serde(default, deserialize_with = "deserialize_null_as_empty_vec")]
tool_calls: Vec<super::DeltaToolCall>,
}
let delta: Delta = serde_json::from_str(json)
.expect("delta with tool_calls:null must deserialize without error");
assert!(
delta.tool_calls.is_empty(),
"tool_calls:null must produce an empty vec, not an error"
);
}
#[test]
/// Regression: when building a multi-turn request where a prior assistant
/// turn has no tool calls, the serialized assistant message must NOT include
/// `tool_calls: []`. Some providers reject requests that carry an empty
/// tool_calls array on assistant turns (gaebal-gajae repro 2026-04-09).
#[test]
fn assistant_message_without_tool_calls_omits_tool_calls_field() {
use crate::types::{InputContentBlock, InputMessage};
let request = MessageRequest {
model: "gpt-4o".to_string(),
max_tokens: 100,
messages: vec![InputMessage {
role: "assistant".to_string(),
content: vec![InputContentBlock::Text {
text: "Hello".to_string(),
}],
}],
stream: false,
..Default::default()
};
let payload = build_chat_completion_request(&request, OpenAiCompatConfig::openai());
let messages = payload["messages"].as_array().unwrap();
let assistant_msg = messages
.iter()
.find(|m| m["role"] == "assistant")
.expect("assistant message must be present");
assert!(
assistant_msg.get("tool_calls").is_none(),
"assistant message without tool calls must omit tool_calls field: {:?}",
assistant_msg
);
}
/// Regression: assistant messages WITH tool calls must still include
/// the tool_calls array (normal multi-turn tool-use flow).
#[test]
fn assistant_message_with_tool_calls_includes_tool_calls_field() {
use crate::types::{InputContentBlock, InputMessage};
let request = MessageRequest {
model: "gpt-4o".to_string(),
max_tokens: 100,
messages: vec![InputMessage {
role: "assistant".to_string(),
content: vec![InputContentBlock::ToolUse {
id: "call_1".to_string(),
name: "read_file".to_string(),
input: serde_json::json!({"path": "/tmp/test"}),
}],
}],
stream: false,
..Default::default()
};
let payload = build_chat_completion_request(&request, OpenAiCompatConfig::openai());
let messages = payload["messages"].as_array().unwrap();
let assistant_msg = messages
.iter()
.find(|m| m["role"] == "assistant")
.expect("assistant message must be present");
let tool_calls = assistant_msg
.get("tool_calls")
.expect("assistant message with tool calls must include tool_calls field");
assert!(tool_calls.is_array());
assert_eq!(tool_calls.as_array().unwrap().len(), 1);
}
/// Orphaned tool messages (no preceding assistant tool_calls) must be
/// dropped by the request-builder sanitizer. Regression for the second
/// layer of the tool-pairing invariant fix (gaebal-gajae 2026-04-10).
#[test]
fn sanitize_drops_orphaned_tool_messages() {
use super::sanitize_tool_message_pairing;
// Valid pair: assistant with tool_calls → tool result
let valid = vec![
json!({"role": "assistant", "content": null, "tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "search", "arguments": "{}"}}]}),
json!({"role": "tool", "tool_call_id": "call_1", "content": "result"}),
];
let out = sanitize_tool_message_pairing(valid);
assert_eq!(out.len(), 2, "valid pair must be preserved");
// Orphaned tool message: no preceding assistant tool_calls
let orphaned = vec![
json!({"role": "assistant", "content": "hi"}),
json!({"role": "tool", "tool_call_id": "call_2", "content": "orphaned"}),
];
let out = sanitize_tool_message_pairing(orphaned);
assert_eq!(out.len(), 1, "orphaned tool message must be dropped");
assert_eq!(out[0]["role"], json!("assistant"));
// Mismatched tool_call_id
let mismatched = vec![
json!({"role": "assistant", "content": null, "tool_calls": [{"id": "call_3", "type": "function", "function": {"name": "f", "arguments": "{}"}}]}),
json!({"role": "tool", "tool_call_id": "call_WRONG", "content": "bad"}),
];
let out = sanitize_tool_message_pairing(mismatched);
assert_eq!(out.len(), 1, "tool message with wrong id must be dropped");
// Two tool results both valid (same preceding assistant)
let two_results = vec![
json!({"role": "assistant", "content": null, "tool_calls": [
{"id": "call_a", "type": "function", "function": {"name": "fa", "arguments": "{}"}},
{"id": "call_b", "type": "function", "function": {"name": "fb", "arguments": "{}"}}
]}),
json!({"role": "tool", "tool_call_id": "call_a", "content": "ra"}),
json!({"role": "tool", "tool_call_id": "call_b", "content": "rb"}),
];
let out = sanitize_tool_message_pairing(two_results);
assert_eq!(out.len(), 3, "both valid tool results must be preserved");
}
#[test]
fn non_gpt5_uses_max_tokens() {
// Older OpenAI models expect `max_tokens`; verify gpt-4o is unaffected.

View File

@@ -1221,6 +1221,84 @@ impl SlashCommand {
pub fn parse(input: &str) -> Result<Option<Self>, SlashCommandParseError> {
validate_slash_command_input(input)
}
/// Returns the canonical slash-command name (e.g. `"/branch"`) for use in
/// error messages and logging. Derived from the spec table so it always
/// matches what the user would have typed.
#[must_use]
pub fn slash_name(&self) -> &'static str {
match self {
Self::Help => "/help",
Self::Clear { .. } => "/clear",
Self::Compact { .. } => "/compact",
Self::Cost => "/cost",
Self::Doctor => "/doctor",
Self::Config { .. } => "/config",
Self::Memory { .. } => "/memory",
Self::History { .. } => "/history",
Self::Diff => "/diff",
Self::Status => "/status",
Self::Stats => "/stats",
Self::Version => "/version",
Self::Commit { .. } => "/commit",
Self::Pr { .. } => "/pr",
Self::Issue { .. } => "/issue",
Self::Init => "/init",
Self::Bughunter { .. } => "/bughunter",
Self::Ultraplan { .. } => "/ultraplan",
Self::Teleport { .. } => "/teleport",
Self::DebugToolCall { .. } => "/debug-tool-call",
Self::Resume { .. } => "/resume",
Self::Model { .. } => "/model",
Self::Permissions { .. } => "/permissions",
Self::Session { .. } => "/session",
Self::Plugins { .. } => "/plugins",
Self::Login => "/login",
Self::Logout => "/logout",
Self::Vim => "/vim",
Self::Upgrade => "/upgrade",
Self::Share => "/share",
Self::Feedback => "/feedback",
Self::Files => "/files",
Self::Fast => "/fast",
Self::Exit => "/exit",
Self::Summary => "/summary",
Self::Desktop => "/desktop",
Self::Brief => "/brief",
Self::Advisor => "/advisor",
Self::Stickers => "/stickers",
Self::Insights => "/insights",
Self::Thinkback => "/thinkback",
Self::ReleaseNotes => "/release-notes",
Self::SecurityReview => "/security-review",
Self::Keybindings => "/keybindings",
Self::PrivacySettings => "/privacy-settings",
Self::Plan { .. } => "/plan",
Self::Review { .. } => "/review",
Self::Tasks { .. } => "/tasks",
Self::Theme { .. } => "/theme",
Self::Voice { .. } => "/voice",
Self::Usage { .. } => "/usage",
Self::Rename { .. } => "/rename",
Self::Copy { .. } => "/copy",
Self::Hooks { .. } => "/hooks",
Self::Context { .. } => "/context",
Self::Color { .. } => "/color",
Self::Effort { .. } => "/effort",
Self::Branch { .. } => "/branch",
Self::Rewind { .. } => "/rewind",
Self::Ide { .. } => "/ide",
Self::Tag { .. } => "/tag",
Self::OutputStyle { .. } => "/output-style",
Self::AddDir { .. } => "/add-dir",
Self::Unknown(_) => "/unknown",
Self::Sandbox => "/sandbox",
Self::Mcp { .. } => "/mcp",
Self::Export { .. } => "/export",
#[allow(unreachable_patterns)]
_ => "/unknown",
}
}
}
#[allow(clippy::too_many_lines)]
@@ -1320,7 +1398,7 @@ pub fn validate_slash_command_input(
"skills" | "skill" => SlashCommand::Skills {
args: parse_skills_args(remainder.as_deref())?,
},
"doctor" => {
"doctor" | "providers" => {
validate_no_args(command, &args)?;
SlashCommand::Doctor
}
@@ -1340,7 +1418,7 @@ pub fn validate_slash_command_input(
validate_no_args(command, &args)?;
SlashCommand::Upgrade
}
"stats" => {
"stats" | "tokens" | "cache" => {
validate_no_args(command, &args)?;
SlashCommand::Stats
}
@@ -4645,7 +4723,14 @@ mod tests {
)
.expect("slash command should be handled");
assert!(result.message.contains("Compacted 2 messages"));
// With the tool-use/tool-result boundary guard the compaction may
// preserve one extra message, so 1 or 2 messages may be removed.
assert!(
result.message.contains("Compacted 1 messages")
|| result.message.contains("Compacted 2 messages"),
"unexpected compaction message: {}",
result.message
);
assert_eq!(result.session.messages[0].role, MessageRole::System);
}

View File

@@ -108,10 +108,55 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio
.first()
.and_then(extract_existing_compacted_summary);
let compacted_prefix_len = usize::from(existing_summary.is_some());
let keep_from = session
let raw_keep_from = session
.messages
.len()
.saturating_sub(config.preserve_recent_messages);
// Ensure we do not split a tool-use / tool-result pair at the compaction
// boundary. If the first preserved message is a user message whose first
// block is a ToolResult, the assistant message with the matching ToolUse
// was slated for removal — that produces an orphaned tool role message on
// the OpenAI-compat path (400: tool message must follow assistant with
// tool_calls). Walk the boundary back until we start at a safe point.
let keep_from = {
let mut k = raw_keep_from;
// If the first preserved message is a tool-result turn, ensure its
// paired assistant tool-use turn is preserved too. Without this fix,
// the OpenAI-compat adapter sends an orphaned 'tool' role message
// with no preceding assistant 'tool_calls', which providers reject
// with a 400. We walk back only if the immediately preceding message
// is NOT an assistant message that contains a ToolUse block (i.e. the
// pair is actually broken at the boundary).
loop {
if k == 0 || k <= compacted_prefix_len {
break;
}
let first_preserved = &session.messages[k];
let starts_with_tool_result = first_preserved
.blocks
.first()
.map(|b| matches!(b, ContentBlock::ToolResult { .. }))
.unwrap_or(false);
if !starts_with_tool_result {
break;
}
// Check the message just before the current boundary.
let preceding = &session.messages[k - 1];
let preceding_has_tool_use = preceding
.blocks
.iter()
.any(|b| matches!(b, ContentBlock::ToolUse { .. }));
if preceding_has_tool_use {
// Pair is intact — walk back one more to include the assistant turn.
k = k.saturating_sub(1);
break;
}
// Preceding message has no ToolUse but we have a ToolResult —
// this is already an orphaned pair; walk back to try to fix it.
k = k.saturating_sub(1);
}
k
};
let removed = &session.messages[compacted_prefix_len..keep_from];
let preserved = session.messages[keep_from..].to_vec();
let summary =
@@ -559,7 +604,14 @@ mod tests {
},
);
assert_eq!(result.removed_message_count, 2);
// With the tool-use/tool-result boundary fix, the compaction preserves
// one extra message to avoid an orphaned tool result at the boundary.
// messages[1] (assistant) must be kept along with messages[2] (tool result).
assert!(
result.removed_message_count <= 2,
"expected at most 2 removed, got {}",
result.removed_message_count
);
assert_eq!(
result.compacted_session.messages[0].role,
MessageRole::System
@@ -577,8 +629,13 @@ mod tests {
max_estimated_tokens: 1,
}
));
// Note: with the tool-use/tool-result boundary guard the compacted session
// may preserve one extra message at the boundary, so token reduction is
// not guaranteed for small sessions. The invariant that matters is that
// the removed_message_count is non-zero (something was compacted).
assert!(
estimate_session_tokens(&result.compacted_session) < estimate_session_tokens(&session)
result.removed_message_count > 0,
"compaction must remove at least one message"
);
}
@@ -682,6 +739,80 @@ mod tests {
assert!(files.contains(&"rust/crates/rusty-claude-cli/src/main.rs".to_string()));
}
/// Regression: compaction must not split an assistant(ToolUse) /
/// user(ToolResult) pair at the boundary. An orphaned tool-result message
/// without the preceding assistant tool_calls causes a 400 on the
/// OpenAI-compat path (gaebal-gajae repro 2026-04-09).
#[test]
fn compaction_does_not_split_tool_use_tool_result_pair() {
use crate::session::{ContentBlock, Session};
let tool_id = "call_abc";
let mut session = Session::default();
// Turn 1: user prompt
session
.push_message(ConversationMessage::user_text("Search for files"))
.unwrap();
// Turn 2: assistant calls a tool
session
.push_message(ConversationMessage::assistant(vec![
ContentBlock::ToolUse {
id: tool_id.to_string(),
name: "search".to_string(),
input: "{\"q\":\"*.rs\"}".to_string(),
},
]))
.unwrap();
// Turn 3: tool result
session
.push_message(ConversationMessage::tool_result(
tool_id,
"search",
"found 5 files",
false,
))
.unwrap();
// Turn 4: assistant final response
session
.push_message(ConversationMessage::assistant(vec![ContentBlock::Text {
text: "Done.".to_string(),
}]))
.unwrap();
// Compact preserving only 1 recent message — without the fix this
// would cut the boundary so that the tool result (turn 3) is first,
// without its preceding assistant tool_calls (turn 2).
let config = CompactionConfig {
preserve_recent_messages: 1,
..CompactionConfig::default()
};
let result = compact_session(&session, config);
// After compaction, no two consecutive messages should have the pattern
// tool_result immediately following a non-assistant message (i.e. an
// orphaned tool result without a preceding assistant ToolUse).
let messages = &result.compacted_session.messages;
for i in 1..messages.len() {
let curr_is_tool_result = messages[i]
.blocks
.first()
.map(|b| matches!(b, ContentBlock::ToolResult { .. }))
.unwrap_or(false);
if curr_is_tool_result {
let prev_has_tool_use = messages[i - 1]
.blocks
.iter()
.any(|b| matches!(b, ContentBlock::ToolUse { .. }));
assert!(
prev_has_tool_use,
"message[{}] is a ToolResult but message[{}] has no ToolUse: {:?}",
i,
i - 1,
&messages[i - 1].blocks
);
}
}
}
#[test]
fn infers_pending_work_from_recent_messages() {
let pending = infer_pending_work(&[

View File

@@ -225,7 +225,9 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
compact,
base_commit,
reasoning_effort,
allow_broad_cwd,
} => {
enforce_broad_cwd_policy(allow_broad_cwd, output_format)?;
run_stale_base_preflight(base_commit.as_deref());
// Only consume piped stdin as prompt context when the permission
// mode is fully unattended. In modes where the permission
@@ -258,12 +260,14 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
permission_mode,
base_commit,
reasoning_effort,
allow_broad_cwd,
} => run_repl(
model,
allowed_tools,
permission_mode,
base_commit,
reasoning_effort,
allow_broad_cwd,
)?,
CliAction::HelpTopic(topic) => print_help_topic(topic),
CliAction::Help { output_format } => print_help(output_format)?,
@@ -326,6 +330,7 @@ enum CliAction {
compact: bool,
base_commit: Option<String>,
reasoning_effort: Option<String>,
allow_broad_cwd: bool,
},
Login {
output_format: CliOutputFormat,
@@ -353,6 +358,7 @@ enum CliAction {
permission_mode: PermissionMode,
base_commit: Option<String>,
reasoning_effort: Option<String>,
allow_broad_cwd: bool,
},
HelpTopic(LocalHelpTopic),
// prompt-mode formatting is only supported for non-interactive runs
@@ -397,6 +403,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
let mut compact = false;
let mut base_commit: Option<String> = None;
let mut reasoning_effort: Option<String> = None;
let mut allow_broad_cwd = false;
let mut rest: Vec<String> = Vec::new();
let mut index = 0;
@@ -509,6 +516,10 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
reasoning_effort = Some(value.to_string());
index += 1;
}
"--allow-broad-cwd" => {
allow_broad_cwd = true;
index += 1;
}
"-p" => {
// Claw Code compat: -p "prompt" = one-shot prompt
let prompt = args[index + 1..].join(" ");
@@ -525,6 +536,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
compact,
base_commit: base_commit.clone(),
reasoning_effort: reasoning_effort.clone(),
allow_broad_cwd,
});
}
"--print" => {
@@ -578,12 +590,35 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
if rest.is_empty() {
let permission_mode = permission_mode_override.unwrap_or_else(default_permission_mode);
// When stdin is not a terminal (pipe/redirect) and no prompt is given on the
// command line, read stdin as the prompt and dispatch as a one-shot Prompt
// rather than starting the interactive REPL (which would consume the pipe and
// print the startup banner, then exit without sending anything to the API).
if !std::io::stdin().is_terminal() {
let mut buf = String::new();
let _ = std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf);
let piped = buf.trim().to_string();
if !piped.is_empty() {
return Ok(CliAction::Prompt {
model,
prompt: piped,
allowed_tools,
permission_mode,
output_format,
compact: false,
base_commit,
reasoning_effort,
allow_broad_cwd,
});
}
}
return Ok(CliAction::Repl {
model,
allowed_tools,
permission_mode,
base_commit,
reasoning_effort: reasoning_effort.clone(),
allow_broad_cwd,
});
}
if rest.first().map(String::as_str) == Some("--resume") {
@@ -623,6 +658,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
compact,
base_commit,
reasoning_effort: reasoning_effort.clone(),
allow_broad_cwd,
}),
SkillSlashDispatch::Local => Ok(CliAction::Skills {
args,
@@ -649,6 +685,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
compact,
base_commit: base_commit.clone(),
reasoning_effort: reasoning_effort.clone(),
allow_broad_cwd,
})
}
other if other.starts_with('/') => parse_direct_slash_cli_action(
@@ -660,6 +697,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
compact,
base_commit,
reasoning_effort,
allow_broad_cwd,
),
_other => Ok(CliAction::Prompt {
prompt: rest.join(" "),
@@ -670,6 +708,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
compact,
base_commit,
reasoning_effort: reasoning_effort.clone(),
allow_broad_cwd,
}),
}
}
@@ -764,6 +803,7 @@ fn parse_direct_slash_cli_action(
compact: bool,
base_commit: Option<String>,
reasoning_effort: Option<String>,
allow_broad_cwd: bool,
) -> Result<CliAction, String> {
let raw = rest.join(" ");
match SlashCommand::parse(&raw) {
@@ -792,6 +832,7 @@ fn parse_direct_slash_cli_action(
compact,
base_commit,
reasoning_effort: reasoning_effort.clone(),
allow_broad_cwd,
}),
SkillSlashDispatch::Local => Ok(CliAction::Skills {
args,
@@ -1616,6 +1657,16 @@ fn check_config_health(
) -> DiagnosticCheck {
let discovered = config_loader.discover();
let discovered_count = discovered.len();
// Separate candidate paths that actually exist from those that don't.
// Showing non-existent paths as "Discovered file" implies they loaded
// but something went wrong, which is confusing. We only surface paths
// that exist on disk as discovered; non-existent ones are silently
// omitted from the display (they are just the standard search locations).
let present_paths: Vec<String> = discovered
.iter()
.filter(|e| e.path.exists())
.map(|e| e.path.display().to_string())
.collect();
let discovered_paths = discovered
.iter()
.map(|entry| entry.path.display().to_string())
@@ -1623,10 +1674,11 @@ fn check_config_health(
match config {
Ok(runtime_config) => {
let loaded_entries = runtime_config.loaded_entries();
let loaded_count = loaded_entries.len();
let present_count = present_paths.len();
let mut details = vec![format!(
"Config files loaded {}/{}",
loaded_entries.len(),
discovered_count
loaded_count, present_count
)];
if let Some(model) = runtime_config.model() {
details.push(format!("Resolved model {model}"));
@@ -1635,39 +1687,29 @@ fn check_config_health(
"MCP servers {}",
runtime_config.mcp().servers().len()
));
if discovered_paths.is_empty() {
details.push("Discovered files <none>".to_string());
if present_paths.is_empty() {
details.push("Discovered files <none> (defaults active)".to_string());
} else {
details.extend(
discovered_paths
present_paths
.iter()
.map(|path| format!("Discovered file {path}")),
);
}
DiagnosticCheck::new(
"Config",
if discovered_count == 0 {
DiagnosticLevel::Warn
} else {
DiagnosticLevel::Ok
},
if discovered_count == 0 {
"no config files were found; defaults are active"
DiagnosticLevel::Ok,
if present_count == 0 {
"no config files present; defaults are active"
} else {
"runtime config loaded successfully"
},
)
.with_details(details)
.with_data(Map::from_iter([
("discovered_files".to_string(), json!(discovered_paths)),
(
"discovered_files_count".to_string(),
json!(discovered_count),
),
(
"loaded_config_files".to_string(),
json!(loaded_entries.len()),
),
("discovered_files".to_string(), json!(present_paths)),
("discovered_files_count".to_string(), json!(present_count)),
("loaded_config_files".to_string(), json!(loaded_count)),
("resolved_model".to_string(), json!(runtime_config.model())),
(
"mcp_servers".to_string(),
@@ -1892,6 +1934,13 @@ fn looks_like_slash_command_token(token: &str) -> bool {
fn dump_manifests(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
let workspace_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
// Surface the resolved path in the error so users can diagnose missing
// manifest files without guessing what path the binary expected.
// ROADMAP #45: this path is only correct when running from the build tree;
// a proper fix would ship manifests alongside the binary.
let resolved = workspace_dir
.canonicalize()
.unwrap_or_else(|_| workspace_dir.clone());
let paths = UpstreamPaths::from_workspace_dir(&workspace_dir);
match extract_manifest(&paths) {
Ok(manifest) => {
@@ -1913,7 +1962,11 @@ fn dump_manifests(output_format: CliOutputFormat) -> Result<(), Box<dyn std::err
}
Ok(())
}
Err(error) => Err(format!("failed to extract manifests: {error}").into()),
Err(error) => Err(format!(
"failed to extract manifests: {error}\n looked in: {}",
resolved.display()
)
.into()),
}
}
@@ -2196,11 +2249,33 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu
let command = match SlashCommand::parse(raw_command) {
Ok(Some(command)) => command,
Ok(None) => {
eprintln!("unsupported resumed command: {raw_command}");
if output_format == CliOutputFormat::Json {
eprintln!(
"{}",
serde_json::json!({
"type": "error",
"error": format!("unsupported resumed command: {raw_command}"),
"command": raw_command,
})
);
} else {
eprintln!("unsupported resumed command: {raw_command}");
}
std::process::exit(2);
}
Err(error) => {
eprintln!("{error}");
if output_format == CliOutputFormat::Json {
eprintln!(
"{}",
serde_json::json!({
"type": "error",
"error": error.to_string(),
"command": raw_command,
})
);
} else {
eprintln!("{error}");
}
std::process::exit(2);
}
};
@@ -2226,7 +2301,18 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu
}
}
Err(error) => {
eprintln!("{error}");
if output_format == CliOutputFormat::Json {
eprintln!(
"{}",
serde_json::json!({
"type": "error",
"error": error.to_string(),
"command": raw_command,
})
);
} else {
eprintln!("{error}");
}
std::process::exit(2);
}
}
@@ -2593,7 +2679,12 @@ fn run_resume_command(
Ok(ResumeCommandOutcome {
session: result.compacted_session,
message: Some(format_compact_report(removed, kept, skipped)),
json: None,
json: Some(serde_json::json!({
"kind": "compact",
"skipped": skipped,
"removed_messages": removed,
"kept_messages": kept,
})),
})
}
SlashCommand::Clear { confirm } => {
@@ -2603,7 +2694,11 @@ fn run_resume_command(
message: Some(
"clear: confirmation required; rerun with /clear --confirm".to_string(),
),
json: None,
json: Some(serde_json::json!({
"kind": "error",
"error": "confirmation required",
"hint": "rerun with /clear --confirm",
})),
});
}
let backup_path = write_session_clear_backup(session, session_path)?;
@@ -2619,7 +2714,13 @@ fn run_resume_command(
backup_path.display(),
session_path.display()
)),
json: None,
json: Some(serde_json::json!({
"kind": "clear",
"previous_session_id": previous_session_id,
"new_session_id": new_session_id,
"backup": backup_path.display().to_string(),
"session_file": session_path.display().to_string(),
})),
})
}
SlashCommand::Status => {
@@ -2641,7 +2742,7 @@ fn run_resume_command(
&context,
)),
json: Some(status_json_value(
"restored-session",
None,
StatusUsage {
message_count: session.messages.len(),
turns: tracker.turns(),
@@ -2670,14 +2771,25 @@ fn run_resume_command(
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(format_cost_report(usage)),
json: None,
json: Some(serde_json::json!({
"kind": "cost",
"input_tokens": usage.input_tokens,
"output_tokens": usage.output_tokens,
"cache_creation_input_tokens": usage.cache_creation_input_tokens,
"cache_read_input_tokens": usage.cache_read_input_tokens,
"total_tokens": usage.total_tokens(),
})),
})
}
SlashCommand::Config { section } => {
let message = render_config_report(section.as_deref())?;
let json = render_config_json(section.as_deref())?;
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(message),
json: Some(json),
})
}
SlashCommand::Config { section } => Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(render_config_report(section.as_deref())?),
json: None,
}),
SlashCommand::Mcp { action, target } => {
let cwd = env::current_dir()?;
let args = match (action.as_deref(), target.as_deref()) {
@@ -2695,7 +2807,7 @@ fn run_resume_command(
SlashCommand::Memory => Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(render_memory_report()?),
json: None,
json: Some(render_memory_json()?),
}),
SlashCommand::Init => {
let message = init_claude_md()?;
@@ -2708,7 +2820,7 @@ fn run_resume_command(
SlashCommand::Diff => Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(render_diff_report_for(
session_path.parent().unwrap_or_else(|| Path::new(".")),
&std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
)?),
json: None,
}),
@@ -2751,19 +2863,46 @@ fn run_resume_command(
json: Some(handle_skills_slash_command_json(args.as_deref(), &cwd)?),
})
}
SlashCommand::Doctor => Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(render_doctor_report()?.render()),
json: None,
}),
SlashCommand::Doctor => {
let report = render_doctor_report()?;
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(report.render()),
json: Some(report.json_value()),
})
}
SlashCommand::Stats => {
let usage = UsageTracker::from_session(session).cumulative_usage();
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(format_cost_report(usage)),
json: Some(serde_json::json!({
"kind": "stats",
"input_tokens": usage.input_tokens,
"output_tokens": usage.output_tokens,
"cache_creation_input_tokens": usage.cache_creation_input_tokens,
"cache_read_input_tokens": usage.cache_read_input_tokens,
"total_tokens": usage.total_tokens(),
})),
})
}
SlashCommand::History { count } => {
let limit = parse_history_count(count.as_deref())
.map_err(|error| -> Box<dyn std::error::Error> { error.into() })?;
let entries = collect_session_prompt_history(session);
let shown: Vec<_> = entries.iter().rev().take(limit).rev().collect();
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(render_prompt_history_report(&entries, limit)),
json: None,
json: Some(serde_json::json!({
"kind": "history",
"total": entries.len(),
"showing": shown.len(),
"entries": shown.iter().map(|e| serde_json::json!({
"timestamp_ms": e.timestamp_ms,
"text": e.text,
})).collect::<Vec<_>>(),
})),
})
}
SlashCommand::Unknown(name) => Err(format_unknown_slash_command(name).into()),
@@ -2783,7 +2922,6 @@ fn run_resume_command(
| SlashCommand::Logout
| SlashCommand::Vim
| SlashCommand::Upgrade
| SlashCommand::Stats
| SlashCommand::Share
| SlashCommand::Feedback
| SlashCommand::Files
@@ -2821,9 +2959,85 @@ fn run_resume_command(
}
}
/// Stale-base preflight: verify the worktree HEAD matches the expected base
/// commit (from `--base-commit` flag or `.claw-base` file). Emits a warning to
/// stderr when the HEAD has diverged.
/// Detect if the current working directory is "broad" (home directory or
/// filesystem root). Returns the cwd path if broad, None otherwise.
fn detect_broad_cwd() -> Option<PathBuf> {
let Ok(cwd) = env::current_dir() else {
return None;
};
let is_home = env::var_os("HOME")
.map(|h| PathBuf::from(h) == cwd)
.unwrap_or(false);
let is_root = cwd.parent().is_none();
if is_home || is_root {
Some(cwd)
} else {
None
}
}
/// Enforce the broad-CWD policy: when running from home or root, either
/// require the --allow-broad-cwd flag, or prompt for confirmation (interactive),
/// or exit with an error (non-interactive).
fn enforce_broad_cwd_policy(
allow_broad_cwd: bool,
output_format: CliOutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
if allow_broad_cwd {
return Ok(());
}
let Some(cwd) = detect_broad_cwd() else {
return Ok(());
};
let is_interactive = io::stdin().is_terminal();
if is_interactive {
// Interactive mode: print warning and ask for confirmation
eprintln!(
"Warning: claw is running from a very broad directory ({}).\n\
The agent can read and search everything under this path.\n\
Consider running from inside your project: cd /path/to/project && claw",
cwd.display()
);
eprint!("Continue anyway? [y/N]: ");
io::stderr().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let trimmed = input.trim().to_lowercase();
if trimmed != "y" && trimmed != "yes" {
eprintln!("Aborted.");
std::process::exit(0);
}
Ok(())
} else {
// Non-interactive mode: exit with error (JSON or text)
let message = format!(
"claw is running from a very broad directory ({}). \
The agent can read and search everything under this path. \
Use --allow-broad-cwd to proceed anyway, \
or run from inside your project: cd /path/to/project && claw",
cwd.display()
);
match output_format {
CliOutputFormat::Json => {
eprintln!(
"{}",
serde_json::json!({
"type": "error",
"error": message,
})
);
}
CliOutputFormat::Text => {
eprintln!("error: {message}");
}
}
std::process::exit(1);
}
}
fn run_stale_base_preflight(flag_value: Option<&str>) {
let cwd = match env::current_dir() {
Ok(cwd) => cwd,
@@ -2842,7 +3056,9 @@ fn run_repl(
permission_mode: PermissionMode,
base_commit: Option<String>,
reasoning_effort: Option<String>,
allow_broad_cwd: bool,
) -> Result<(), Box<dyn std::error::Error>> {
enforce_broad_cwd_policy(allow_broad_cwd, CliOutputFormat::Text)?;
run_stale_base_preflight(base_commit.as_deref());
let resolved_model = resolve_repl_model(model);
let mut cli = LiveCli::new(resolved_model, true, allowed_tools, permission_mode)?;
@@ -3763,11 +3979,15 @@ impl LiveCli {
self.print_prompt_history(count.as_deref());
false
}
SlashCommand::Stats => {
let usage = UsageTracker::from_session(self.runtime.session()).cumulative_usage();
println!("{}", format_cost_report(usage));
false
}
SlashCommand::Login
| SlashCommand::Logout
| SlashCommand::Vim
| SlashCommand::Upgrade
| SlashCommand::Stats
| SlashCommand::Share
| SlashCommand::Feedback
| SlashCommand::Files
@@ -3802,7 +4022,8 @@ impl LiveCli {
| SlashCommand::Tag { .. }
| SlashCommand::OutputStyle { .. }
| SlashCommand::AddDir { .. } => {
eprintln!("Command registered but not yet implemented.");
let cmd_name = command.slash_name();
eprintln!("{cmd_name} is not yet implemented in this build.");
false
}
SlashCommand::Unknown(name) => {
@@ -4786,7 +5007,7 @@ fn print_status_snapshot(
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&status_json_value(
model,
Some(model),
usage,
permission_mode.as_str(),
&context,
@@ -4797,7 +5018,7 @@ fn print_status_snapshot(
}
fn status_json_value(
model: &str,
model: Option<&str>,
usage: StatusUsage,
permission_mode: &str,
context: &StatusContext,
@@ -4825,6 +5046,11 @@ fn status_json_value(
"unstaged_files": context.git_summary.unstaged_files,
"untracked_files": context.git_summary.untracked_files,
"session": context.session_path.as_ref().map_or_else(|| "live-repl".to_string(), |path| path.display().to_string()),
"session_id": context.session_path.as_ref().and_then(|path| {
// Session files are named <session-id>.jsonl directly under
// .claw/sessions/. Extract the stem (drop the .jsonl extension).
path.file_stem().map(|n| n.to_string_lossy().into_owned())
}),
"loaded_config_files": context.loaded_config_files,
"discovered_config_files": context.discovered_config_files,
"memory_file_count": context.memory_file_count,
@@ -5151,6 +5377,49 @@ fn render_config_report(section: Option<&str>) -> Result<String, Box<dyn std::er
))
}
fn render_config_json(
_section: Option<&str>,
) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let loader = ConfigLoader::default_for(&cwd);
let discovered = loader.discover();
let runtime_config = loader.load()?;
let loaded_paths: Vec<_> = runtime_config
.loaded_entries()
.iter()
.map(|e| e.path.display().to_string())
.collect();
let files: Vec<_> = discovered
.iter()
.map(|e| {
let source = match e.source {
ConfigSource::User => "user",
ConfigSource::Project => "project",
ConfigSource::Local => "local",
};
let loaded = runtime_config
.loaded_entries()
.iter()
.any(|le| le.path == e.path);
serde_json::json!({
"path": e.path.display().to_string(),
"source": source,
"loaded": loaded,
})
})
.collect();
Ok(serde_json::json!({
"kind": "config",
"cwd": cwd.display().to_string(),
"loaded_files": loaded_paths.len(),
"merged_keys": runtime_config.merged().len(),
"files": files,
}))
}
fn render_memory_report() -> Result<String, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let project_context = ProjectContext::discover(&cwd, DEFAULT_DATE)?;
@@ -5190,6 +5459,28 @@ fn render_memory_report() -> Result<String, Box<dyn std::error::Error>> {
))
}
fn render_memory_json() -> Result<serde_json::Value, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let project_context = ProjectContext::discover(&cwd, DEFAULT_DATE)?;
let files: Vec<_> = project_context
.instruction_files
.iter()
.map(|f| {
json!({
"path": f.path.display().to_string(),
"lines": f.content.lines().count(),
"preview": f.content.lines().next().unwrap_or("").trim(),
})
})
.collect();
Ok(json!({
"kind": "memory",
"cwd": cwd.display().to_string(),
"instruction_files": files.len(),
"files": files,
}))
}
fn init_claude_md() -> Result<String, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
Ok(initialize_repo(&cwd)?.render())
@@ -5228,6 +5519,21 @@ fn render_diff_report() -> Result<String, Box<dyn std::error::Error>> {
}
fn render_diff_report_for(cwd: &Path) -> Result<String, Box<dyn std::error::Error>> {
// Verify we are inside a git repository before calling `git diff`.
// Running `git diff --cached` outside a git tree produces a misleading
// "unknown option `cached`" error because git falls back to --no-index mode.
let in_git_repo = std::process::Command::new("git")
.args(["rev-parse", "--is-inside-work-tree"])
.current_dir(cwd)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !in_git_repo {
return Ok(format!(
"Diff\n Result no git repository\n Detail {} is not inside a git project",
cwd.display()
));
}
let staged = run_git_diff_command_in(cwd, &["diff", "--cached"])?;
let unstaged = run_git_diff_command_in(cwd, &["diff"])?;
if staged.trim().is_empty() && unstaged.trim().is_empty() {
@@ -8339,6 +8645,7 @@ mod tests {
permission_mode: PermissionMode::DangerFullAccess,
base_commit: None,
reasoning_effort: None,
allow_broad_cwd: false,
}
);
}
@@ -8503,6 +8810,7 @@ mod tests {
compact: false,
base_commit: None,
reasoning_effort: None,
allow_broad_cwd: false,
}
);
}
@@ -8593,6 +8901,7 @@ mod tests {
compact: false,
base_commit: None,
reasoning_effort: None,
allow_broad_cwd: false,
}
);
}
@@ -8623,6 +8932,7 @@ mod tests {
compact: true,
base_commit: None,
reasoning_effort: None,
allow_broad_cwd: false,
}
);
}
@@ -8665,6 +8975,7 @@ mod tests {
compact: false,
base_commit: None,
reasoning_effort: None,
allow_broad_cwd: false,
}
);
}
@@ -8743,6 +9054,7 @@ mod tests {
permission_mode: PermissionMode::ReadOnly,
base_commit: None,
reasoning_effort: None,
allow_broad_cwd: false,
}
);
}
@@ -8763,6 +9075,7 @@ mod tests {
permission_mode: PermissionMode::DangerFullAccess,
base_commit: None,
reasoning_effort: None,
allow_broad_cwd: false,
}
);
}
@@ -8792,6 +9105,7 @@ mod tests {
compact: false,
base_commit: None,
reasoning_effort: None,
allow_broad_cwd: false,
}
);
}
@@ -8818,6 +9132,7 @@ mod tests {
permission_mode: PermissionMode::DangerFullAccess,
base_commit: None,
reasoning_effort: None,
allow_broad_cwd: false,
}
);
}
@@ -8928,6 +9243,7 @@ mod tests {
compact: false,
base_commit: None,
reasoning_effort: None,
allow_broad_cwd: false,
}
);
assert_eq!(
@@ -9312,6 +9628,7 @@ mod tests {
compact: false,
base_commit: None,
reasoning_effort: None,
allow_broad_cwd: false,
}
);
}
@@ -9379,6 +9696,7 @@ mod tests {
compact: false,
base_commit: None,
reasoning_effort: None,
allow_broad_cwd: false,
}
);
assert_eq!(
@@ -9405,6 +9723,7 @@ mod tests {
compact: false,
base_commit: None,
reasoning_effort: None,
allow_broad_cwd: false,
}
);
let error = parse_args(&["/status".to_string()])