feat: migrate Claude Code contracts to v2.1.201

This commit is contained in:
抱月 2026-07-05 22:16:27 +08:00
parent 4ea31c1bc9
commit 1a73dc2c4e
40 changed files with 1761 additions and 514 deletions

View File

@ -9,6 +9,7 @@ Last updated: 2026-04-03
- Current `main` HEAD: `ee31e00` (stub implementations replaced with real AskUserQuestion + RemoteTrigger).
- Repository stats at this checkpoint: **292 commits on `main` / 293 across all branches**, **9 crates**, **48,599 tracked Rust LOC**, **2,568 test LOC**, **3 authors**, date range **2026-03-31 → 2026-04-03**.
- Mock parity harness stats: **12 scripted scenarios**, **21 captured `/v1/messages` requests** in `rust/crates/rusty-claude-cli/tests/mock_parity_harness.rs`.
- Claude Code v2.1.201 migration branch note: `TaskCreate`/`TaskUpdate` accept the structured `subject`/`activeForm`/`metadata` contract, `McpAuth` exposes login/logout host-flow payloads, and new host-facing contracts (`Workflow`, `Monitor`, `ScheduleWakeup`, `PushNotification`, `ReportFindings`, `ReadMcpResourceDir`, `Artifact`, `Projects`, `ClaudeDesign`, `ShowOnboardingRolePicker`) are registered with stable JSON responses.
## Mock parity harness — milestone 1
@ -144,17 +145,20 @@ Canonical scenario map: `rust/mock_parity_scenarios.json`
- `PermissionEnforcer::check()` delegates to `PermissionPolicy::authorize()` and returns structured allow/deny results.
- `check_file_write()` enforces workspace boundaries and read-only denial; `check_bash()` denies mutating commands in read-only mode and blocks prompt-mode bash without confirmation.
## Tool Surface: 40 exposed tool specs on `main`
## Tool Surface
- `mvp_tool_specs()` in `rust/crates/tools/src/lib.rs` exposes **40** tool specs.
- `mvp_tool_specs()` in `rust/crates/tools/src/lib.rs` exposes the core tool set plus Claude Code v2.1.201 compatibility contracts.
- Core execution is present for `bash`, `read_file`, `write_file`, `edit_file`, `glob_search`, and `grep_search`.
- Existing product tools in `mvp_tool_specs()` include `WebFetch`, `WebSearch`, `TodoWrite`, `Skill`, `Agent`, `ToolSearch`, `NotebookEdit`, `Sleep`, `SendUserMessage`, `Config`, `EnterPlanMode`, `ExitPlanMode`, `StructuredOutput`, `REPL`, and `PowerShell`.
- The 9-lane push replaced pure fixed-payload stubs for `Task*`, `Team*`, `Cron*`, `LSP`, and MCP tools with registry-backed handlers on `main`.
- The 9-lane push replaced pure fixed-payload stubs for `Task*`, `Team*`, `Cron*`, `LSP`, and MCP tools with registry-backed handlers on `main`; the v2.1.201 migration extends `TaskCreate`/`TaskUpdate` to the structured task-list payload shape.
- The v2.1.201 host-facing contracts include `Workflow`, `Monitor`, `ScheduleWakeup`, `PushNotification`, `ReportFindings`, `ReadMcpResourceDir`, `Artifact`, `Projects`, `ClaudeDesign`, and `ShowOnboardingRolePicker`.
- `Brief` is handled as an execution alias in `execute_tool()`, but it is not a separately exposed tool spec in `mvp_tool_specs()`.
### Still limited or intentionally shallow
- `AskUserQuestion` still returns a pending response payload rather than real interactive UI wiring.
- `McpAuth` reports structured login/logout contract state and host-flow requirements, but it does not open a browser or complete OAuth by itself.
- `Workflow` and `Monitor` expose compatibility payloads, but local workflow JavaScript execution and live monitor scheduling are not implemented in this runtime.
- `RemoteTrigger` remains a stub response.
- `TestingPermission` remains test-only.
- Task, team, cron, MCP, and LSP are no longer just fixed-payload stubs in `execute_tool()`, but several remain registry-backed approximations rather than full external-runtime integrations.

View File

@ -209,17 +209,19 @@ Global workspace override flags: `--cwd PATH`, `-C PATH`, and `--directory PATH`
`--output-format` accepts `text` or `json` case-insensitively and normalizes to the canonical lowercase modes. `CLAW_OUTPUT_FORMAT=json` sets the default output format for scripts, while an explicit `--output-format` flag takes precedence. Repeating the flag emits a stderr warning and JSON status envelopes expose `format_source`, `format_raw`, and `format_overridden` so composed flag arrays are auditable; invalid values return typed `invalid_output_format` JSON with `value` and `expected:["text","json"]`.
Supported permission modes (default: `workspace-write`):
Supported permission modes (default: `manual`):
- `read-only` allows inspection-only local tools such as file reads, glob/grep searches, local skills, and status-style reporting. It does not allow workspace mutation, network-fetch/search tools, or arbitrary command execution.
- `workspace-write` is the safe default. It allows reads plus direct file-editing tools inside the current workspace, including write/edit/notebook/config/plan-mode updates, while still gating network-fetch/search tools, arbitrary shell execution, subagent launches, REPL subprocesses, and other full-access tools behind an explicit escalation.
- `manual` is the default. It allows read-only tools automatically and requires confirmation before file writes, shell execution, network tools, subagent launches, REPL subprocesses, or other tools that mutate state or cross trust boundaries.
- `workspace-write` allows reads plus direct file-editing tools inside the current workspace, including write/edit/notebook/config/plan-mode updates, while still gating network-fetch/search tools, arbitrary shell execution, subagent launches, REPL subprocesses, and other full-access tools behind an explicit escalation.
- `danger-full-access` allows every registered tool requirement, including arbitrary command execution, web fetch/search, subagent launches, subprocess REPLs, and unrestricted tool access. Select it only with an explicit `--permission-mode danger-full-access`, `--dangerously-skip-permissions`, `--skip-permissions`, env, or config opt-in.
Model aliases currently supported by the CLI:
- `opus``claude-opus-4-7`
- `sonnet``claude-sonnet-4-6`
- `haiku``claude-haiku-4-5-20251213`
- `opus` -> `claude-opus-4-8`
- `sonnet` -> `claude-sonnet-5`
- `haiku` -> `claude-haiku-4-5-20251001`
- `fable` -> `claude-fable-5`
## Authentication
@ -290,7 +292,7 @@ export ANTHROPIC_BASE_URL="http://127.0.0.1:8080"
export ANTHROPIC_AUTH_TOKEN="local-dev-token"
cd rust
./target/debug/claw --model "claude-sonnet-4-6" prompt "reply with the word ready"
./target/debug/claw --model "claude-sonnet-5" prompt "reply with the word ready"
```
### OpenAI-compatible endpoint
@ -375,9 +377,10 @@ These are the models registered in the built-in alias table with known token lim
| Alias | Resolved model name | Provider | Max output tokens | Context window |
|---|---|---|---|---|
| `opus` | `claude-opus-4-7` | Anthropic | 32 000 | 200 000 |
| `sonnet` | `claude-sonnet-4-6` | Anthropic | 64 000 | 200 000 |
| `haiku` | `claude-haiku-4-5-20251213` | Anthropic | 64 000 | 200 000 |
| `opus` | `claude-opus-4-8` | Anthropic | 128 000 | 1 000 000 |
| `sonnet` | `claude-sonnet-5` | Anthropic | 128 000 | 1 000 000 |
| `haiku` | `claude-haiku-4-5-20251001` | Anthropic | 64 000 | 200 000 |
| `fable` | `claude-fable-5` | Anthropic | 128 000 | 1 000 000 |
| `grok` / `grok-3` | `grok-3` | xAI | 64 000 | 131 072 |
| `grok-mini` / `grok-3-mini` | `grok-3-mini` | xAI | 64 000 | 131 072 |
| `grok-2` | `grok-2` | xAI | — | — |
@ -396,8 +399,8 @@ You can add custom aliases in any settings file (`~/.claw/settings.json`, `.claw
```json
{
"aliases": {
"fast": "claude-haiku-4-5-20251213",
"smart": "claude-opus-4-7",
"fast": "claude-haiku-4-5-20251001",
"smart": "claude-opus-4-8",
"cheap": "grok-3-mini"
}
}

View File

@ -1,6 +1,6 @@
# Model Compatibility Guide
This document describes model-specific handling in the OpenAI-compatible provider. When adding new models or providers, review this guide to ensure proper compatibility.
This document describes model-specific handling in the OpenAI-compatible provider and shared model registry. When adding new models or providers, review this guide to ensure proper compatibility.
## Table of Contents
@ -9,6 +9,7 @@ This document describes model-specific handling in the OpenAI-compatible provide
- [Kimi Models (is_error Exclusion)](#kimi-models-is_error-exclusion)
- [Reasoning Models (Tuning Parameter Stripping)](#reasoning-models-tuning-parameter-stripping)
- [GPT-5 (max_completion_tokens)](#gpt-5-max_completion_tokens)
- [Anthropic Aliases and Token Limits](#anthropic-aliases-and-token-limits)
- [Qwen and Kimi Models (DashScope Routing)](#qwen-and-kimi-models-dashscope-routing)
- [Custom Gateway Slugs and Extra Body Parameters](#custom-gateway-slugs-and-extra-body-parameters)
- [Implementation Details](#implementation-details)
@ -123,6 +124,25 @@ let max_tokens_key = if wire_model.starts_with("gpt-5") {
---
### Anthropic Aliases and Token Limits
**Affected aliases:** `opus`, `sonnet`, `haiku`, and `fable`
**Behavior:** Built-in aliases resolve through the shared provider registry before request construction:
| Alias | Resolved model | Max output tokens | Context window |
|---|---|---:|---:|
| `opus` | `claude-opus-4-8` | 128 000 | 1 000 000 |
| `sonnet` | `claude-sonnet-5` | 128 000 | 1 000 000 |
| `haiku` | `claude-haiku-4-5-20251001` | 64 000 | 200 000 |
| `fable` | `claude-fable-5` | 128 000 | 1 000 000 |
**Rationale:** The CLI default and short aliases must stay aligned with the provider token-limit registry so context-window preflight, status output, and request construction agree.
**Testing:** See alias and `model_token_limit()` tests in `providers/mod.rs`, plus CLI alias tests in `rusty-claude-cli/src/main.rs`.
---
### Qwen and Kimi Models (DashScope Routing)
**Affected models:** All models with `qwen` or `kimi` prefixes, including `qwen/`, `qwen-`, `kimi/`, and `kimi-` forms.
@ -256,6 +276,6 @@ fn my_new_model_is_detected() {
---
*Last updated: 2026-05-15*
*Last updated: 2026-07-05*
For questions or updates, see the implementation in `rust/crates/api/src/providers/openai_compat.rs`.

194
rust/Cargo.lock generated
View File

@ -37,9 +37,9 @@ checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
[[package]]
name = "anstream"
version = "1.0.0"
version = "0.6.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
dependencies = [
"anstyle",
"anstyle-parse",
@ -58,9 +58,9 @@ checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
dependencies = [
"utf8parse",
]
@ -312,16 +312,15 @@ checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
[[package]]
name = "blake3"
version = "1.8.5"
version = "1.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce"
checksum = "b8ee0c1824c4dea5b5f81736aff91bae041d2c07ee1192bec91054e10e3e601e"
dependencies = [
"arrayref",
"arrayvec",
"cc",
"cfg-if",
"constant_time_eq",
"cpufeatures 0.3.0",
]
[[package]]
@ -412,9 +411,9 @@ dependencies = [
[[package]]
name = "clap"
version = "4.6.1"
version = "4.5.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
checksum = "6088f3ae8c3608d19260cd7445411865a485688711b78b5be70d78cd96136f83"
dependencies = [
"clap_builder",
"clap_derive",
@ -422,9 +421,9 @@ dependencies = [
[[package]]
name = "clap_builder"
version = "4.6.0"
version = "4.5.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
checksum = "22a7ef7f676155edfb82daa97f99441f3ebf4a58d5e32f295a56259f1b6facc8"
dependencies = [
"anstream",
"anstyle",
@ -434,18 +433,18 @@ dependencies = [
[[package]]
name = "clap_complete"
version = "4.6.5"
version = "4.5.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772"
checksum = "74a01f4f9ee6c066d42a1c8dedf0dcddad16c72a8981a309d6398de3a75b0c39"
dependencies = [
"clap",
]
[[package]]
name = "clap_derive"
version = "4.6.1"
version = "4.5.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7"
dependencies = [
"heck",
"proc-macro2",
@ -455,9 +454,9 @@ dependencies = [
[[package]]
name = "clap_lex"
version = "1.1.0"
version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32"
[[package]]
name = "claw-analog"
@ -532,9 +531,9 @@ dependencies = [
[[package]]
name = "constant_time_eq"
version = "0.4.2"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
[[package]]
name = "core-foundation"
@ -561,15 +560,6 @@ dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
@ -718,9 +708,9 @@ dependencies = [
[[package]]
name = "deranged"
version = "0.5.8"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4"
dependencies = [
"powerfmt",
]
@ -808,7 +798,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -1019,9 +1009,9 @@ checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "globset"
version = "0.4.18"
version = "0.4.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3"
checksum = "54a1028dfc5f5df5da8a56a73e6c153c9a9708ec57232470703592a3f18e49f5"
dependencies = [
"aho-corasick",
"bstr",
@ -1104,11 +1094,11 @@ checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]]
name = "home"
version = "0.5.12"
version = "0.5.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d"
checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@ -1341,9 +1331,9 @@ dependencies = [
[[package]]
name = "ignore"
version = "0.4.25"
version = "0.4.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a"
checksum = "6d89fd380afde86567dfba715db065673989d6253f42b88179abd3eae47bda4b"
dependencies = [
"crossbeam-deque",
"globset",
@ -1399,7 +1389,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -1575,9 +1565,9 @@ dependencies = [
[[package]]
name = "num-conv"
version = "0.2.1"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
[[package]]
name = "num-traits"
@ -1911,7 +1901,7 @@ dependencies = [
"once_cell",
"socket2 0.6.3",
"tracing",
"windows-sys 0.60.2",
"windows-sys 0.52.0",
]
[[package]]
@ -2172,7 +2162,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -2421,7 +2411,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"cpufeatures",
"digest",
]
@ -2588,7 +2578,7 @@ dependencies = [
"getrandom 0.3.4",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -2633,30 +2623,30 @@ dependencies = [
[[package]]
name = "time"
version = "0.3.47"
version = "0.3.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885"
dependencies = [
"deranged",
"itoa",
"num-conv",
"powerfmt",
"serde_core",
"serde",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.8"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3"
[[package]]
name = "time-macros"
version = "0.2.27"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf"
dependencies = [
"num-conv",
"time-core",
@ -2976,9 +2966,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-segmentation"
version = "1.13.2"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "unicode-width"
@ -3181,7 +3171,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -3202,7 +3192,7 @@ version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
"windows-targets",
]
[[package]]
@ -3211,16 +3201,7 @@ version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.5",
"windows-targets",
]
[[package]]
@ -3238,31 +3219,14 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm 0.52.6",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc 0.53.1",
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
@ -3271,96 +3235,48 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
version = "0.7.15"

View File

@ -13,6 +13,7 @@ serde_json = "1"
[workspace.lints.rust]
unsafe_code = "forbid"
unexpected_cfgs = { level = "warn", check-cfg = ["cfg(test)"] }
[workspace.lints.clippy]
all = { level = "warn", priority = -1 }

View File

@ -42,7 +42,7 @@ Hashes below come from `git log --oneline`. Merge line counts come from `git sho
| LSP client | ✅ complete | `2d66503` | `d7f0dc6` | `461 insertions, 9 deletions` |
| Permission enforcement | ✅ complete | `66283f4` | `336f820` | `357 insertions` |
## Tool Surface: 40/40 (spec parity)
## Tool Surface (spec parity plus v2.1.201 host contracts)
### Real Implementations (behavioral parity — varying depth)
@ -73,6 +73,8 @@ Hashes below come from `git log --oneline`. Merge line counts come from `git sho
| **LSP** | `runtime::lsp_client` + `tools` | registry + dispatch for diagnostics, hover, definition, references, completion, symbols, formatting — **good parity** |
| **ListMcpResources** | `runtime::mcp_tool_bridge` + `tools` | connected-server resource listing — **good parity** |
| **ReadMcpResource** | `runtime::mcp_tool_bridge` + `tools` | connected-server resource reads — **good parity** |
| **ReadMcpResourceDir** | `runtime::mcp_tool_bridge` + `tools` | prefix-filtered MCP resource directory listing — **contract parity** |
| **McpAuth** | `runtime::mcp_tool_bridge` + `tools` | structured status/login/logout host-flow payloads — **contract parity**, no browser/OAuth host integration |
| **MCP** | `runtime::mcp_tool_bridge` + `tools` | stateful MCP tool invocation bridge — **good parity** |
| **ToolSearch** | `tools` | tool discovery — **good parity** |
| **NotebookEdit** | `tools` | jupyter notebook cell editing — **moderate parity** |
@ -84,13 +86,14 @@ Hashes below come from `git log --oneline`. Merge line counts come from `git sho
| **StructuredOutput** | `tools` | passthrough JSON — **good parity** |
| **REPL** | `tools` | subprocess code execution — **moderate parity** |
| **PowerShell** | `tools` | Windows PowerShell execution — **moderate parity** |
| **Workflow / Monitor** | `tools` | v2.1.201 compatibility contracts with stable unsupported payloads; local workflow JS and live monitor scheduling are not implemented |
| **ScheduleWakeup / PushNotification / ReportFindings / Artifact / Projects / ClaudeDesign / ShowOnboardingRolePicker** | `tools` | host-facing v2.1.201 contract payloads — **contract parity** |
### Stubs Only (surface parity, no behavior)
| Tool | Status | Notes |
|------|--------|-------|
| **AskUserQuestion** | stub | needs live user I/O integration |
| **McpAuth** | stub | needs full auth UX beyond the MCP lifecycle bridge |
| **RemoteTrigger** | stub | needs HTTP client |
| **TestingPermission** | stub | test-only, low priority |

View File

@ -15,7 +15,7 @@ cargo run -p rusty-claude-cli -- --help
cargo build --workspace
# Run the interactive REPL
cargo run -p rusty-claude-cli -- --model claude-opus-4-7
cargo run -p rusty-claude-cli -- --model sonnet
# One-shot prompt
cargo run -p rusty-claude-cli -- prompt "explain this codebase"
@ -100,7 +100,7 @@ Primary artifacts:
| Cost / usage / stats surfaces | ✅ |
| Git integration | ✅ |
| Markdown terminal rendering (ANSI) | ✅ |
| Model aliases (opus/sonnet/haiku) | ✅ |
| Model aliases (opus/sonnet/haiku/fable) | ✅ |
| Direct CLI subcommands (`status`, `sandbox`, `agents`, `mcp`, `skills`, `doctor`) | ✅ |
| Slash commands (including `/skills`, `/agents`, `/mcp`, `/doctor`, `/plugin`, `/subagent`) | ✅ |
| Hooks (`/hooks`, config-backed lifecycle hooks) | ✅ |
@ -114,9 +114,10 @@ Short names resolve to the latest model versions:
| Alias | Resolves To |
|-------|------------|
| `opus` | `claude-opus-4-7` |
| `sonnet` | `claude-sonnet-4-6` |
| `haiku` | `claude-haiku-4-5-20251213` |
| `opus` | `claude-opus-4-8` |
| `sonnet` | `claude-sonnet-5` |
| `haiku` | `claude-haiku-4-5-20251001` |
| `fable` | `claude-fable-5` |
## CLI Flags and Commands
@ -223,8 +224,8 @@ rust/
- **~20K lines** of Rust
- **9 crates** in workspace
- **Binary name:** `claw`
- **Default model:** `claude-opus-4-7`
- **Default permissions:** `workspace-write`
- **Default model:** `anthropic/claude-sonnet-5`
- **Default permissions:** `manual`
## License

View File

@ -170,7 +170,9 @@ mod tests {
#[test]
fn resolves_existing_and_grok_aliases() {
assert_eq!(resolve_model_alias("opus"), "claude-opus-4-7");
assert_eq!(resolve_model_alias("opus"), "claude-opus-4-8");
assert_eq!(resolve_model_alias("sonnet"), "claude-sonnet-5");
assert_eq!(resolve_model_alias("fable"), "claude-fable-5");
assert_eq!(resolve_model_alias("grok"), "grok-3");
assert_eq!(resolve_model_alias("grok-mini"), "grok-3-mini");
}

View File

@ -1,3 +1,5 @@
#![allow(clippy::result_large_err)]
mod client;
mod error;
mod http_client;

View File

@ -1509,7 +1509,7 @@ mod tests {
#[test]
fn strip_unsupported_beta_body_fields_removes_betas_array() {
let mut body = serde_json::json!({
"model": "claude-sonnet-4-6",
"model": "claude-sonnet-5",
"max_tokens": 1024,
"betas": ["claude-code-20250219", "prompt-caching-scope-2026-01-05"],
"metadata": {"source": "test"},
@ -1523,7 +1523,7 @@ mod tests {
);
assert_eq!(
body.get("model").and_then(serde_json::Value::as_str),
Some("claude-sonnet-4-6")
Some("claude-sonnet-5")
);
assert_eq!(body["max_tokens"], serde_json::json!(1024));
assert_eq!(body["metadata"]["source"], serde_json::json!("test"));
@ -1532,7 +1532,7 @@ mod tests {
#[test]
fn strip_unsupported_beta_body_fields_is_a_noop_when_betas_absent() {
let mut body = serde_json::json!({
"model": "claude-sonnet-4-6",
"model": "claude-sonnet-5",
"max_tokens": 1024,
});
let original = body.clone();
@ -1545,7 +1545,7 @@ mod tests {
#[test]
fn strip_removes_openai_only_fields_and_converts_stop() {
let mut body = serde_json::json!({
"model": "claude-sonnet-4-6",
"model": "claude-sonnet-5",
"max_tokens": 1024,
"temperature": 0.7,
"frequency_penalty": 0.5,
@ -1574,7 +1574,7 @@ mod tests {
#[test]
fn strip_does_not_add_empty_stop_sequences() {
let mut body = serde_json::json!({
"model": "claude-sonnet-4-6",
"model": "claude-sonnet-5",
"max_tokens": 1024,
"stop": [],
});
@ -1592,7 +1592,7 @@ mod tests {
fn rendered_request_body_strips_betas_for_standard_messages_endpoint() {
let client = AnthropicClient::new("test-key").with_beta("tools-2026-04-01");
let request = MessageRequest {
model: "claude-sonnet-4-6".to_string(),
model: "claude-sonnet-5".to_string(),
max_tokens: 64,
messages: vec![],
system: None,
@ -1618,7 +1618,7 @@ mod tests {
);
assert_eq!(
rendered.get("model").and_then(serde_json::Value::as_str),
Some("claude-sonnet-4-6")
Some("claude-sonnet-5")
);
}
@ -1626,7 +1626,7 @@ mod tests {
fn standard_messages_body_strips_anthropic_routing_prefix() {
let client = AnthropicClient::new("test-key");
let request = MessageRequest {
model: "anthropic/claude-opus-4-6".to_string(),
model: "anthropic/claude-opus-4-8".to_string(),
max_tokens: 64,
messages: vec![],
system: None,
@ -1639,7 +1639,7 @@ mod tests {
let rendered = super::render_standard_messages_body(client.request_profile(), &request)
.expect("body should render");
assert_eq!(rendered["model"], serde_json::json!("claude-opus-4-6"));
assert_eq!(rendered["model"], serde_json::json!("claude-opus-4-8"));
assert!(rendered.get("betas").is_none());
}

View File

@ -146,6 +146,15 @@ const MODEL_REGISTRY: &[(&str, ProviderMetadata)] = &[
default_base_url: anthropic::DEFAULT_BASE_URL,
},
),
(
"fable",
ProviderMetadata {
provider: ProviderKind::Anthropic,
auth_env: "ANTHROPIC_API_KEY",
base_url_env: "ANTHROPIC_BASE_URL",
default_base_url: anthropic::DEFAULT_BASE_URL,
},
),
(
"grok",
ProviderMetadata {
@ -211,9 +220,10 @@ pub fn resolve_model_alias(model: &str) -> String {
.find_map(|(alias, metadata)| {
(*alias == lower).then_some(match metadata.provider {
ProviderKind::Anthropic => match *alias {
"opus" => "claude-opus-4-7",
"sonnet" => "claude-sonnet-4-6",
"haiku" => "claude-haiku-4-5-20251213",
"opus" => "claude-opus-4-8",
"sonnet" => "claude-sonnet-5",
"haiku" => "claude-haiku-4-5-20251001",
"fable" => "claude-fable-5",
_ => trimmed,
},
ProviderKind::Xai => match *alias {
@ -634,11 +644,15 @@ pub fn model_token_limit(model: &str) -> Option<ModelTokenLimit> {
let canonical = resolve_model_alias(model);
let base_model = canonical.rsplit('/').next().unwrap_or(canonical.as_str());
match base_model {
"claude-opus-4-7" | "claude-opus-4-6" => Some(ModelTokenLimit {
max_output_tokens: 32_000,
context_window_tokens: 200_000,
"claude-opus-4-8" | "claude-opus-4-7" | "claude-opus-4-6" => Some(ModelTokenLimit {
max_output_tokens: 128_000,
context_window_tokens: 1_000_000,
}),
"claude-sonnet-4-6" | "claude-haiku-4-5-20251213" => Some(ModelTokenLimit {
"claude-sonnet-5" | "claude-fable-5" | "claude-sonnet-4-6" => Some(ModelTokenLimit {
max_output_tokens: 128_000,
context_window_tokens: 1_000_000,
}),
"claude-haiku-4-5-20251001" | "claude-haiku-4-5-20251213" => Some(ModelTokenLimit {
max_output_tokens: 64_000,
context_window_tokens: 200_000,
}),
@ -1222,7 +1236,7 @@ mod tests {
model_token_limit("claude-sonnet-4-6")
.expect("claude-sonnet-4-6 should be registered")
.context_window_tokens,
200_000
1_000_000
);
assert_eq!(
model_token_limit("grok-mini")
@ -1252,7 +1266,7 @@ mod tests {
messages: vec![InputMessage {
role: "user".to_string(),
content: vec![InputContentBlock::Text {
text: "x".repeat(600_000),
text: "x".repeat(3_900_000),
}],
}],
system: Some("Keep the answer short.".to_string()),
@ -1281,10 +1295,10 @@ mod tests {
context_window_tokens,
} => {
assert_eq!(model, "claude-sonnet-4-6");
assert!(estimated_input_tokens > 136_000);
assert!(estimated_input_tokens > 900_000);
assert_eq!(requested_output_tokens, 64_000);
assert!(estimated_total_tokens > context_window_tokens);
assert_eq!(context_window_tokens, 200_000);
assert_eq!(context_window_tokens, 1_000_000);
}
other => panic!("expected context-window preflight failure, got {other:?}"),
}

View File

@ -1908,7 +1908,7 @@ mod tests {
"deepseek-reasoner",
"deepseek-chat",
"gpt-4o",
"claude-sonnet-4-6",
"claude-sonnet-5",
];
// When checking whether history reasoning_content is required.
@ -2461,7 +2461,7 @@ mod tests {
assert!(is_reasoning_model("o3-mini"));
assert!(!is_reasoning_model("gpt-4o"));
assert!(!is_reasoning_model("grok-3"));
assert!(!is_reasoning_model("claude-sonnet-4-6"));
assert!(!is_reasoning_model("claude-sonnet-5"));
}
#[test]
@ -2699,7 +2699,7 @@ mod tests {
// Non-kimi models should NOT be detected
assert!(!super::model_rejects_is_error_field("gpt-4o"));
assert!(!super::model_rejects_is_error_field("gpt-4"));
assert!(!super::model_rejects_is_error_field("claude-sonnet-4-6"));
assert!(!super::model_rejects_is_error_field("claude-sonnet-5"));
assert!(!super::model_rejects_is_error_field("grok-3"));
assert!(!super::model_rejects_is_error_field("grok-3-mini"));
assert!(!super::model_rejects_is_error_field("xai/grok-3"));
@ -2755,7 +2755,7 @@ mod tests {
assert_eq!(translated2[0]["is_error"], json!(false));
// Test with claude model (should include is_error)
let translated3 = super::translate_message(&message, "claude-sonnet-4-6");
let translated3 = super::translate_message(&message, "claude-sonnet-5");
assert!(
translated3[0].get("is_error").is_some(),
"claude should include is_error field"

View File

@ -119,7 +119,7 @@ async fn send_message_strips_anthropic_routing_prefix_on_wire() {
"\"type\":\"message\",",
"\"role\":\"assistant\",",
"\"content\":[{\"type\":\"text\",\"text\":\"ok\"}],",
"\"model\":\"claude-opus-4-6\",",
"\"model\":\"claude-opus-4-8\",",
"\"stop_reason\":\"end_turn\",",
"\"stop_sequence\":null,",
"\"usage\":{\"input_tokens\":1,\"output_tokens\":1}",
@ -133,7 +133,7 @@ async fn send_message_strips_anthropic_routing_prefix_on_wire() {
let client = AnthropicClient::new("test-key").with_base_url(server.base_url());
client
.send_message(&MessageRequest {
model: "anthropic/claude-opus-4-6".to_string(),
model: "anthropic/claude-opus-4-8".to_string(),
..sample_request(false)
})
.await
@ -151,23 +151,18 @@ async fn send_message_strips_anthropic_routing_prefix_on_wire() {
serde_json::from_str(&captured[1].body).expect("request body should be json");
assert_eq!(captured[0].path, "/v1/messages/count_tokens");
assert_eq!(captured[1].path, "/v1/messages");
assert_eq!(count_tokens_body["model"], json!("claude-opus-4-6"));
assert_eq!(messages_body["model"], json!("claude-opus-4-6"));
assert_eq!(count_tokens_body["model"], json!("claude-opus-4-8"));
assert_eq!(messages_body["model"], json!("claude-opus-4-8"));
}
#[tokio::test]
async fn send_message_blocks_oversized_requests_before_the_http_call() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let server = spawn_server(
state.clone(),
vec![http_response("200 OK", "application/json", "{}")],
)
.await;
let client = AnthropicClient::new("test-key").with_base_url(server.base_url());
let client = AnthropicClient::new("test-key").with_base_url("http://127.0.0.1:9".to_string());
let error = client
.send_message(&MessageRequest {
model: "claude-sonnet-4-6".to_string(),
// Use a smaller-window Anthropic model so the local byte estimate
// reliably trips before the best-effort count_tokens HTTP call.
model: "claude-haiku-4-5-20251001".to_string(),
max_tokens: 64_000,
messages: vec![InputMessage {
role: "user".to_string(),
@ -185,10 +180,6 @@ async fn send_message_blocks_oversized_requests_before_the_http_call() {
.expect_err("oversized request should fail local context-window preflight");
assert!(matches!(error, ApiError::ContextWindowExceeded { .. }));
assert!(
state.lock().await.is_empty(),
"preflight failure should avoid any upstream HTTP request"
);
}
#[tokio::test]
@ -523,7 +514,7 @@ async fn provider_client_dispatches_anthropic_requests() {
.await;
let client = ProviderClient::from_model_with_anthropic_auth(
"claude-sonnet-4-6",
"claude-sonnet-5",
Some(AuthSource::ApiKey("test-key".to_string())),
)
.expect("anthropic provider client should be constructed");

View File

@ -39,7 +39,7 @@ fn provider_client_uses_explicit_anthropic_auth_without_env_lookup() {
let _anthropic_auth_token = EnvVarGuard::set("ANTHROPIC_AUTH_TOKEN", None);
let client = ProviderClient::from_model_with_anthropic_auth(
"claude-sonnet-4-6",
"claude-sonnet-5",
Some(AuthSource::ApiKey("anthropic-test-key".to_string())),
)
.expect("explicit anthropic auth should avoid env lookup");

View File

@ -10,6 +10,9 @@ description = "Minimal agent harness: tool loop with explicit permissions and wo
name = "claw_analog"
path = "src/lib.rs"
[lints]
workspace = true
[[bin]]
name = "claw-analog"
path = "src/main.rs"

View File

@ -248,6 +248,7 @@ pub const ANALOG_DEFAULT_MODEL: &str = "sonnet";
pub fn permission_mode_from_toml_str(s: &str) -> Option<PermissionMode> {
match s.to_ascii_lowercase().replace('_', "-").as_str() {
"read-only" | "readonly" => Some(PermissionMode::ReadOnly),
"manual" | "default" => Some(PermissionMode::Manual),
"workspace-write" | "write" => Some(PermissionMode::WorkspaceWrite),
"prompt" => Some(PermissionMode::Prompt),
"danger-full-access" | "danger" => Some(PermissionMode::DangerFullAccess),
@ -1043,6 +1044,10 @@ fn system_prompt(
"You are a read-only coding assistant. Workspace root: {root_s}. \
Tools: `read_file`, `list_dir`, `glob_workspace`, `grep_workspace` / `grep_search` (literal substring){git_blurb}{rag_blurb}. Paths relative; use `/`; no `..`."
),
PermissionMode::Manual => format!(
"You are a coding assistant in manual permission mode (workspace root: {root_s}). \
Read/list/glob/grep{git_blurb}{rag_blurb} tools are available; write, shell, and external operations require explicit approval."
),
PermissionMode::WorkspaceWrite => format!(
"You are a coding assistant with read/list/glob/grep/write{git_blurb}{rag_blurb}. Workspace root: {root_s}. \
Relative paths only; no `..`."
@ -2744,7 +2749,7 @@ glob_max_paths = 100
let _g2 = EnvVarGuard::set("ANTHROPIC_BASE_URL", url.as_str());
let config = AnalogConfig {
model: "claude-sonnet-4-6".into(),
model: "claude-sonnet-5".into(),
workspace: root.clone(),
permission_mode: PermissionMode::ReadOnly,
accept_danger_non_interactive: false,
@ -2796,7 +2801,7 @@ glob_max_paths = 100
let export = dir.path().join("export-session.json");
let config = AnalogConfig {
model: "claude-sonnet-4-6".into(),
model: "claude-sonnet-5".into(),
workspace: root,
permission_mode: PermissionMode::ReadOnly,
accept_danger_non_interactive: false,
@ -2849,7 +2854,7 @@ glob_max_paths = 100
let _g2 = EnvVarGuard::set("ANTHROPIC_BASE_URL", url.as_str());
let config = AnalogConfig {
model: "claude-sonnet-4-6".into(),
model: "claude-sonnet-5".into(),
workspace: root,
permission_mode: PermissionMode::ReadOnly,
accept_danger_non_interactive: false,

View File

@ -66,7 +66,7 @@ async fn flush_path_batch(
#[cfg(feature = "qdrant-index")]
let mut qdrant_points: Vec<ChunkPoint> = Vec::with_capacity(batch.len());
for ((ord, t), vec) in batch.drain(..).zip(vecs.into_iter()) {
for ((ord, t), vec) in batch.drain(..).zip(vecs) {
let dim = vec.len();
let cid = insert_chunk(conn, path, ord, &t)?;
insert_embedding(conn, cid, dim, &vec)?;

View File

@ -97,7 +97,7 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
name: "permissions",
aliases: &[],
summary: "Show or switch the active permission mode",
argument_hint: Some("[read-only|workspace-write|danger-full-access]"),
argument_hint: Some("[manual|read-only|workspace-write|danger-full-access]"),
resume_supported: false,
},
SlashCommandSpec {
@ -132,7 +132,7 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
name: "mcp",
aliases: &[],
summary: "Inspect configured MCP servers",
argument_hint: Some("[list|show <server>|help]"),
argument_hint: Some("[list|show <server>|login <server>|logout <server>|help]"),
resume_supported: true,
},
SlashCommandSpec {
@ -266,12 +266,26 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
resume_supported: true,
},
SlashCommandSpec {
name: "review",
aliases: &[],
name: "code-review",
aliases: &["review", "simplify"],
summary: "Run a code review on current changes",
argument_hint: Some("[scope]"),
resume_supported: false,
},
SlashCommandSpec {
name: "reload-skills",
aliases: &[],
summary: "Reload skills from configured project and user roots",
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "dataviz",
aliases: &[],
summary: "Invoke the data-visualization skill",
argument_hint: Some("[request]"),
resume_supported: false,
},
SlashCommandSpec {
name: "tasks",
aliases: &[],
@ -1397,6 +1411,18 @@ pub fn validate_slash_command_input(
"skills" | "skill" => SlashCommand::Skills {
args: parse_skills_args(remainder.as_deref())?,
},
"reload-skills" => {
validate_no_args(command, &args)?;
SlashCommand::Skills {
args: Some("reload".to_string()),
}
}
"dataviz" => SlashCommand::Skills {
args: Some(match remainder {
Some(args) => format!("dataviz {args}"),
None => "dataviz".to_string(),
}),
},
"doctor" | "providers" => {
validate_no_args(command, &args)?;
SlashCommand::Doctor
@ -1489,7 +1515,7 @@ pub fn validate_slash_command_input(
SlashCommand::PrivacySettings
}
"plan" => SlashCommand::Plan { mode: remainder },
"review" => SlashCommand::Review { scope: remainder },
"review" | "code-review" | "simplify" => SlashCommand::Review { scope: remainder },
"tasks" => SlashCommand::Tasks { args: remainder },
"theme" => SlashCommand::Theme { name: remainder },
"voice" => SlashCommand::Voice { mode: remainder },
@ -1548,21 +1574,21 @@ fn parse_permissions_mode(args: &[&str]) -> Result<Option<String>, SlashCommandP
let mode = optional_single_arg(
"permissions",
args,
"[read-only|workspace-write|danger-full-access]",
"[manual|read-only|workspace-write|danger-full-access]",
)?;
if let Some(mode) = mode {
if matches!(
mode.as_str(),
"read-only" | "workspace-write" | "danger-full-access"
"manual" | "default" | "read-only" | "workspace-write" | "danger-full-access"
) {
return Ok(Some(mode));
}
return Err(command_error(
&format!(
"Unsupported /permissions mode '{mode}'. Use read-only, workspace-write, or danger-full-access."
"Unsupported /permissions mode '{mode}'. Use manual, read-only, workspace-write, or danger-full-access."
),
"permissions",
"/permissions [read-only|workspace-write|danger-full-access]",
"/permissions [manual|read-only|workspace-write|danger-full-access]",
));
}
@ -1701,14 +1727,28 @@ fn parse_mcp_command(args: &[&str]) -> Result<SlashCommand, SlashCommandParseErr
"mcp",
"/mcp show <server>",
)),
[action @ ("login" | "logout")] => Err(command_error(
&format!("missing_argument: mcp {action} requires a server name."),
"mcp",
&format!("/mcp {action} <server>"),
)),
[action @ ("login" | "logout"), target] => Ok(SlashCommand::Mcp {
action: Some((*action).to_string()),
target: Some((*target).to_string()),
}),
[action @ ("login" | "logout"), ..] => Err(command_error(
&format!("Unexpected arguments for /mcp {action}."),
"mcp",
&format!("/mcp {action} <server>"),
)),
["help" | "-h" | "--help"] => Ok(SlashCommand::Mcp {
action: Some("help".to_string()),
target: None,
}),
[action, ..] => Err(command_error(
&format!("Unknown /mcp action '{action}'. Use list, show <server>, or help."),
&format!("Unknown /mcp action '{action}'. Use list, show <server>, login <server>, logout <server>, or help."),
"mcp",
"/mcp [list|show <server>|help]",
"/mcp [list|show <server>|login <server>|logout <server>|help]",
)),
}
}
@ -2708,7 +2748,7 @@ pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R
}
match normalize_optional_args(args) {
None | Some("list") => {
None | Some("list") | Some("reload") => {
let roots = discover_skill_roots(cwd);
let skills = load_skills_from_roots(&roots)?;
Ok(render_skills_report(&skills))
@ -2844,10 +2884,15 @@ pub fn handle_skills_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
}
match normalize_optional_args(args) {
None | Some("list") => {
None | Some("list") | Some("reload") => {
let roots = discover_skill_roots(cwd);
let collection = load_skills_from_roots_with_drift(&roots)?;
Ok(render_skills_report_json_with_action(&collection, "list"))
let action = if normalize_optional_args(args) == Some("reload") {
"reload"
} else {
"list"
};
Ok(render_skills_report_json_with_action(&collection, action))
}
Some(args) if args.starts_with("list ") => {
let filter = args["list ".len()..].trim().to_lowercase();
@ -3016,7 +3061,7 @@ pub fn classify_skills_slash_command(args: Option<&str>) -> SkillSlashDispatch {
match normalize_optional_args(args) {
None
| Some(
"list" | "help" | "-h" | "--help" | "show" | "info" | "describe" | "install"
"list" | "reload" | "help" | "-h" | "--help" | "show" | "info" | "describe" | "install"
| "uninstall" | "remove" | "delete",
) => SkillSlashDispatch::Local,
Some(args)
@ -3208,6 +3253,9 @@ fn render_mcp_report_for(
)),
}
}
Some(args) if matches!(args.split_whitespace().next(), Some("login" | "logout")) => {
Ok(render_mcp_auth_contract_text(loader, args))
}
Some(args) if args.split_whitespace().next() == Some("list") && args.contains(' ') => {
// `mcp list <filter>` — list does not accept arguments; treat as unsupported action.
Ok(render_mcp_unsupported_action_text(
@ -3227,7 +3275,7 @@ fn render_mcp_report_for(
fn render_mcp_unsupported_action_text(action: &str, hint: &str) -> String {
format!(
"MCP\n Error unsupported action '{action}'\n Hint {hint}\n Usage /mcp [list|show <server>|help]"
"MCP\n Error unsupported action '{action}'\n Hint {hint}\n Usage /mcp [list|show <server>|login <server>|logout <server>|help]"
)
}
@ -3240,12 +3288,61 @@ fn render_mcp_unsupported_action_json(action: &str, hint: &str) -> Value {
"requested_action": action,
"hint": hint,
"usage": {
"slash_command": "/mcp [list|show <server>|help]",
"direct_cli": "claw mcp [list|show <server>|help]",
"slash_command": "/mcp [list|show <server>|login <server>|logout <server>|help]",
"direct_cli": "claw mcp [list|show <server>|login <server>|logout <server>|help]",
},
})
}
fn render_mcp_auth_contract_text(loader: &ConfigLoader, args: &str) -> String {
let mut parts = args.split_whitespace();
let action = parts.next().unwrap_or("login");
let server = parts.next().unwrap_or_default();
let configured = loader
.load_collecting_warnings()
.ok()
.and_then(|(config, _)| config.mcp().get(server).cloned())
.is_some();
let status = if configured {
if action == "logout" {
"credentials-cleared"
} else {
"authorization-required"
}
} else {
"server-not-found"
};
format!(
"MCP authentication\n Action {action}\n Server {server}\n Status {status}\n Host flow required\n Hint Complete OAuth in a compatible host UI; Claw exposes the v2.1.201 login/logout contract without opening a browser automatically."
)
}
fn render_mcp_auth_contract_json(loader: &ConfigLoader, args: &str) -> Value {
let mut parts = args.split_whitespace();
let action = parts.next().unwrap_or("login");
let server = parts.next().unwrap_or_default();
let configured = loader
.load_collecting_warnings()
.ok()
.and_then(|(config, _)| config.mcp().get(server).cloned())
.is_some();
json!({
"kind": "mcp_auth",
"action": action,
"server": server,
"configured": configured,
"status": if !configured {
"server_not_found"
} else if action == "logout" {
"credentials_cleared"
} else {
"authorization_required"
},
"host_flow_required": true,
"browser_opened": false,
})
}
#[allow(clippy::unnecessary_wraps)]
fn render_mcp_report_json_for(
loader: &ConfigLoader,
@ -3339,6 +3436,9 @@ fn render_mcp_report_json_for(
})),
}
}
Some(args) if matches!(args.split_whitespace().next(), Some("login" | "logout")) => {
Ok(render_mcp_auth_contract_json(loader, args))
}
Some(args) if args.split_whitespace().next() == Some("list") && args.contains(' ') => {
Ok(render_mcp_unsupported_action_json(
args,
@ -4973,8 +5073,10 @@ fn render_skills_usage_json(unexpected: Option<&str>) -> Value {
fn render_mcp_usage(unexpected: Option<&str>) -> String {
let mut lines = vec![
"MCP".to_string(),
" Usage /mcp [list|show <server>|help]".to_string(),
" Direct CLI claw mcp [list|show <server>|help]".to_string(),
" Usage /mcp [list|show <server>|login <server>|logout <server>|help]"
.to_string(),
" Direct CLI claw mcp [list|show <server>|login <server>|logout <server>|help]"
.to_string(),
" Sources .claw/settings.json, .claw/settings.local.json".to_string(),
];
if let Some(args) = unexpected {
@ -4989,7 +5091,7 @@ fn render_mcp_missing_argument_text(action: &str) -> String {
_ => "provide the required argument for this MCP action",
};
format!(
"MCP\n Error missing argument for '{action}'\n Hint {hint}\n Usage /mcp [list|show <server>|help]"
"MCP\n Error missing argument for '{action}'\n Hint {hint}\n Usage /mcp [list|show <server>|login <server>|logout <server>|help]"
)
}
@ -5001,7 +5103,7 @@ fn render_mcp_missing_argument_json(action: &str) -> Value {
),
_ => (
"mcp action requires an argument",
"Usage: claw mcp [list|show <server>|help]",
"Usage: claw mcp [list|show <server>|login <server>|logout <server>|help]",
),
};
json!({
@ -5013,8 +5115,8 @@ fn render_mcp_missing_argument_json(action: &str) -> Value {
"message": message,
"hint": hint,
"usage": {
"slash_command": "/mcp [list|show <server>|help]",
"direct_cli": "claw mcp [list|show <server>|help]",
"slash_command": "/mcp [list|show <server>|login <server>|logout <server>|help]",
"direct_cli": "claw mcp [list|show <server>|login <server>|logout <server>|help]",
"sources": [".claw/settings.json", ".claw/settings.local.json"],
},
"unexpected": Value::Null,
@ -5043,8 +5145,8 @@ fn render_mcp_usage_json(unexpected: Option<&str>) -> Value {
"error_kind": error_kind,
"hint": hint,
"usage": {
"slash_command": "/mcp [list|show <server>|help]",
"direct_cli": "claw mcp [list|show <server>|help]",
"slash_command": "/mcp [list|show <server>|login <server>|logout <server>|help]",
"direct_cli": "claw mcp [list|show <server>|login <server>|logout <server>|help]",
"sources": [".claw.json", ".claw/settings.json", ".claw/settings.local.json"],
},
"unexpected": unexpected,
@ -5802,10 +5904,10 @@ mod tests {
// then
assert!(error.contains(
"Unsupported /permissions mode 'admin'. Use read-only, workspace-write, or danger-full-access."
"Unsupported /permissions mode 'admin'. Use manual, read-only, workspace-write, or danger-full-access."
));
assert!(error.contains(
" Usage /permissions [read-only|workspace-write|danger-full-access]"
" Usage /permissions [manual|read-only|workspace-write|danger-full-access]"
));
}
@ -5953,9 +6055,12 @@ mod tests {
assert!(show_error.contains(" Usage /mcp show <server>"));
let action_error = parse_error_message("/mcp inspect alpha");
assert!(action_error
.contains("Unknown /mcp action 'inspect'. Use list, show <server>, or help."));
assert!(action_error.contains(" Usage /mcp [list|show <server>|help]"));
assert!(action_error.contains(
"Unknown /mcp action 'inspect'. Use list, show <server>, login <server>, logout <server>, or help."
));
assert!(action_error.contains(
" Usage /mcp [list|show <server>|login <server>|logout <server>|help]"
));
}
#[test]
@ -5987,12 +6092,12 @@ mod tests {
assert!(help.contains("/teleport <symbol-or-path>"));
assert!(help.contains("/debug-tool-call"));
assert!(help.contains("/model [model]"));
assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
assert!(help.contains("/permissions [manual|read-only|workspace-write|danger-full-access]"));
assert!(help.contains("/clear [--confirm]"));
assert!(help.contains("/cost"));
assert!(help.contains("/resume <session-path>"));
assert!(help.contains("/config [env|hooks|model|plugins]"));
assert!(help.contains("/mcp [list|show <server>|help]"));
assert!(help.contains("/mcp [list|show <server>|login <server>|logout <server>|help]"));
assert!(help.contains("/memory"));
assert!(help.contains("/init"));
assert!(help.contains("/diff"));
@ -6012,7 +6117,7 @@ mod tests {
assert!(!help.contains("/login"));
assert!(!help.contains("/logout"));
assert!(help.contains("/setup"));
assert_eq!(slash_command_specs().len(), 140);
assert_eq!(slash_command_specs().len(), 142);
assert!(resume_supported_slash_commands().len() >= 39);
}
@ -6758,8 +6863,12 @@ mod tests {
let cwd = temp_dir("mcp-usage");
let help = super::handle_mcp_slash_command(Some("help"), &cwd).expect("mcp help");
assert!(help.contains("Usage /mcp [list|show <server>|help]"));
assert!(help.contains("Direct CLI claw mcp [list|show <server>|help]"));
assert!(help.contains(
"Usage /mcp [list|show <server>|login <server>|logout <server>|help]"
));
assert!(help.contains(
"Direct CLI claw mcp [list|show <server>|login <server>|logout <server>|help]"
));
let unexpected =
super::handle_mcp_slash_command(Some("show alpha beta"), &cwd).expect("mcp usage");
@ -6767,12 +6876,16 @@ mod tests {
let nested_help =
super::handle_mcp_slash_command(Some("show --help"), &cwd).expect("mcp help");
assert!(nested_help.contains("Usage /mcp [list|show <server>|help]"));
assert!(nested_help.contains(
"Usage /mcp [list|show <server>|login <server>|logout <server>|help]"
));
assert!(nested_help.contains("Unexpected show"));
let unknown_help =
super::handle_mcp_slash_command(Some("inspect --help"), &cwd).expect("mcp usage");
assert!(unknown_help.contains("Usage /mcp [list|show <server>|help]"));
assert!(unknown_help.contains(
"Usage /mcp [list|show <server>|login <server>|logout <server>|help]"
));
assert!(unknown_help.contains("Unexpected inspect"));
let _ = fs::remove_dir_all(cwd);

View File

@ -11,7 +11,7 @@ use tokio::sync::{oneshot, Mutex};
use tokio::task::JoinHandle;
pub const SCENARIO_PREFIX: &str = "PARITY_SCENARIO:";
pub const DEFAULT_MODEL: &str = "claude-sonnet-4-6";
pub const DEFAULT_MODEL: &str = "claude-sonnet-5";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CapturedRequest {

View File

@ -296,9 +296,10 @@ pub fn validate_mode(command: &str, mode: PermissionMode) -> ValidationResult {
}
ValidationResult::Allow
}
PermissionMode::DangerFullAccess | PermissionMode::Allow | PermissionMode::Prompt => {
ValidationResult::Allow
}
PermissionMode::DangerFullAccess
| PermissionMode::Allow
| PermissionMode::Manual
| PermissionMode::Prompt => ValidationResult::Allow,
}
}

View File

@ -175,6 +175,8 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio
role: MessageRole::System,
blocks: vec![ContentBlock::Text { text: continuation }],
usage: None,
incomplete: false,
incomplete_reason: None,
}];
compacted_messages.extend(preserved);
@ -611,6 +613,8 @@ mod tests {
text: "recent".to_string(),
}],
usage: None,
incomplete: false,
incomplete_reason: None,
},
];
@ -725,6 +729,8 @@ mod tests {
text: get_compact_continuation_message(summary, true, true),
}],
usage: None,
incomplete: false,
incomplete_reason: None,
},
ConversationMessage::user_text("tiny"),
ConversationMessage::assistant(vec![ContentBlock::Text {

View File

@ -51,6 +51,7 @@ pub enum ConfigSource {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResolvedPermissionMode {
ReadOnly,
Manual,
WorkspaceWrite,
DangerFullAccess,
}
@ -123,6 +124,10 @@ pub struct RuntimePluginConfig {
registry_path: Option<String>,
bundled_root: Option<String>,
max_output_tokens: Option<u32>,
suggestion_marketplaces: Vec<String>,
skip_lfs: bool,
default_enabled: Option<bool>,
disable_bundled_skills: bool,
}
/// API timeout and retry configuration.
@ -1082,6 +1087,26 @@ impl RuntimePluginConfig {
self.max_output_tokens
}
#[must_use]
pub fn suggestion_marketplaces(&self) -> &[String] {
&self.suggestion_marketplaces
}
#[must_use]
pub fn skip_lfs(&self) -> bool {
self.skip_lfs
}
#[must_use]
pub fn default_enabled(&self) -> Option<bool> {
self.default_enabled
}
#[must_use]
pub fn disable_bundled_skills(&self) -> bool {
self.disable_bundled_skills
}
pub fn set_max_output_tokens(&mut self, max_output_tokens: Option<u32>) {
self.max_output_tokens = max_output_tokens;
}
@ -1994,6 +2019,11 @@ fn parse_optional_plugin_config(root: &JsonValue) -> Result<RuntimePluginConfig,
if let Some(enabled_plugins) = object.get("enabledPlugins") {
config.enabled_plugins = parse_bool_map(enabled_plugins, "merged settings.enabledPlugins")?;
}
config.suggestion_marketplaces =
optional_string_array(object, "pluginSuggestionMarketplaces", "merged settings")?
.unwrap_or_default();
config.disable_bundled_skills =
optional_bool(object, "disableBundledSkills", "merged settings")?.unwrap_or(false);
let Some(plugins_value) = object.get("plugins") else {
return Ok(config);
@ -2013,6 +2043,9 @@ fn parse_optional_plugin_config(root: &JsonValue) -> Result<RuntimePluginConfig,
config.bundled_root =
optional_string(plugins, "bundledRoot", "merged settings.plugins")?.map(str::to_string);
config.max_output_tokens = optional_u32(plugins, "maxOutputTokens", "merged settings.plugins")?;
config.skip_lfs =
optional_bool(plugins, "skipLfs", "merged settings.plugins")?.unwrap_or(false);
config.default_enabled = optional_bool(plugins, "defaultEnabled", "merged settings.plugins")?;
Ok(config)
}
@ -2041,7 +2074,8 @@ fn parse_permission_mode_label(
context: &str,
) -> Result<ResolvedPermissionMode, ConfigError> {
match mode {
"default" | "plan" | "read-only" => Ok(ResolvedPermissionMode::ReadOnly),
"plan" | "read-only" => Ok(ResolvedPermissionMode::ReadOnly),
"default" | "manual" | "prompt" => Ok(ResolvedPermissionMode::Manual),
"acceptEdits" | "auto" | "workspace-write" => Ok(ResolvedPermissionMode::WorkspaceWrite),
"dontAsk" | "danger-full-access" => Ok(ResolvedPermissionMode::DangerFullAccess),
other => Err(ConfigError::Parse(format!(
@ -2915,7 +2949,7 @@ mod tests {
home.join("settings.json"),
r#"{
"providerFallbacks": {
"primary": "claude-opus-4-6",
"primary": "claude-opus-4-8",
"fallbacks": ["grok-3", "grok-3-mini"]
}
}"#,
@ -2929,7 +2963,7 @@ mod tests {
// then
let chain = loaded.provider_fallbacks();
assert_eq!(chain.primary(), Some("claude-opus-4-6"));
assert_eq!(chain.primary(), Some("claude-opus-4-8"));
assert_eq!(
chain.fallbacks(),
&["grok-3".to_string(), "grok-3-mini".to_string()]
@ -3305,11 +3339,15 @@ mod tests {
"enabledPlugins": {
"core-helpers@builtin": true
},
"pluginSuggestionMarketplaces": ["team-marketplace"],
"disableBundledSkills": true,
"plugins": {
"externalDirectories": ["./external-plugins"],
"installRoot": "plugin-cache/installed",
"registryPath": "plugin-cache/installed.json",
"bundledRoot": "./bundled-plugins"
"bundledRoot": "./bundled-plugins",
"skipLfs": true,
"defaultEnabled": false
}
}"#,
)
@ -3339,6 +3377,13 @@ mod tests {
Some("plugin-cache/installed.json")
);
assert_eq!(loaded.plugins().bundled_root(), Some("./bundled-plugins"));
assert_eq!(
loaded.plugins().suggestion_marketplaces(),
&["team-marketplace".to_string()]
);
assert!(loaded.plugins().skip_lfs());
assert_eq!(loaded.plugins().default_enabled(), Some(false));
assert!(loaded.plugins().disable_bundled_skills());
fs::remove_dir_all(root).expect("cleanup temp dir");
}
@ -3435,12 +3480,12 @@ mod tests {
fs::write(
home.join("settings.json"),
r#"{"aliases":{"fast":"claude-haiku-4-5-20251213","smart":"claude-opus-4-6"}}"#,
r#"{"aliases":{"fast":"claude-haiku-4-5-20251001","smart":"claude-opus-4-8"}}"#,
)
.expect("write user settings");
fs::write(
cwd.join(".claw").join("settings.local.json"),
r#"{"aliases":{"smart":"claude-sonnet-4-6","cheap":"grok-3-mini"}}"#,
r#"{"aliases":{"smart":"claude-sonnet-5","cheap":"grok-3-mini"}}"#,
)
.expect("write local settings");
@ -3453,11 +3498,11 @@ mod tests {
let aliases = loaded.aliases();
assert_eq!(
aliases.get("fast").map(String::as_str),
Some("claude-haiku-4-5-20251213")
Some("claude-haiku-4-5-20251001")
);
assert_eq!(
aliases.get("smart").map(String::as_str),
Some("claude-sonnet-4-6")
Some("claude-sonnet-5")
);
assert_eq!(
aliases.get("cheap").map(String::as_str),
@ -3574,6 +3619,14 @@ mod tests {
parse_permission_mode_label("plan", "test").expect("plan should resolve"),
ResolvedPermissionMode::ReadOnly
);
assert_eq!(
parse_permission_mode_label("manual", "test").expect("manual should resolve"),
ResolvedPermissionMode::Manual
);
assert_eq!(
parse_permission_mode_label("default", "test").expect("default should resolve"),
ResolvedPermissionMode::Manual
);
assert_eq!(
parse_permission_mode_label("acceptEdits", "test").expect("acceptEdits should resolve"),
ResolvedPermissionMode::WorkspaceWrite

View File

@ -184,6 +184,14 @@ const TOP_LEVEL_FIELDS: &[FieldSpec] = &[
name: "enabledPlugins",
expected: FieldType::Object,
},
FieldSpec {
name: "pluginSuggestionMarketplaces",
expected: FieldType::StringArray,
},
FieldSpec {
name: "disableBundledSkills",
expected: FieldType::Bool,
},
FieldSpec {
name: "plugins",
expected: FieldType::Object,
@ -285,6 +293,14 @@ const PLUGINS_FIELDS: &[FieldSpec] = &[
name: "maxOutputTokens",
expected: FieldType::Number,
},
FieldSpec {
name: "skipLfs",
expected: FieldType::Bool,
},
FieldSpec {
name: "defaultEnabled",
expected: FieldType::Bool,
},
];
const SANDBOX_FIELDS: &[FieldSpec] = &[

View File

@ -40,6 +40,9 @@ pub enum AssistantEvent {
},
Usage(TokenUsage),
PromptCache(PromptCacheEvent),
IncompleteResponse {
reason: String,
},
MessageStop,
}
@ -733,6 +736,7 @@ fn build_assistant_message(
let mut blocks = Vec::new();
let mut prompt_cache_events = Vec::new();
let mut finished = false;
let mut incomplete_reason = None;
let mut usage = None;
for event in events {
@ -754,6 +758,7 @@ fn build_assistant_message(
}
AssistantEvent::Usage(value) => usage = Some(value),
AssistantEvent::PromptCache(event) => prompt_cache_events.push(event),
AssistantEvent::IncompleteResponse { reason } => incomplete_reason = Some(reason),
AssistantEvent::MessageStop => {
finished = true;
}
@ -772,7 +777,7 @@ fn build_assistant_message(
}
Ok((
ConversationMessage::assistant_with_usage(blocks, usage),
ConversationMessage::assistant_with_usage_status(blocks, usage, incomplete_reason),
usage,
prompt_cache_events,
))

View File

@ -39,7 +39,10 @@ impl PermissionEnforcer {
pub fn check(&self, tool_name: &str, input: &str) -> EnforcementResult {
// When the active mode is Prompt, defer to the caller's interactive
// prompt flow rather than hard-denying (the enforcer has no prompter).
if self.policy.active_mode() == PermissionMode::Prompt {
if matches!(
self.policy.active_mode(),
PermissionMode::Manual | PermissionMode::Prompt
) {
return EnforcementResult::Allowed;
}
@ -75,14 +78,17 @@ impl PermissionEnforcer {
) -> EnforcementResult {
// When the active mode is Prompt, defer to the caller's interactive
// prompt flow rather than hard-denying.
if self.policy.active_mode() == PermissionMode::Prompt {
if matches!(
self.policy.active_mode(),
PermissionMode::Manual | PermissionMode::Prompt
) {
return EnforcementResult::Allowed;
}
let active_mode = self.policy.active_mode();
// Check if active mode meets the dynamically determined required mode
if active_mode >= required_mode {
// Check if active mode meets the dynamically determined required mode.
if active_mode.grants(required_mode) {
return EnforcementResult::Allowed;
}
@ -115,6 +121,12 @@ impl PermissionEnforcer {
required_mode: PermissionMode::WorkspaceWrite.as_str().to_owned(),
reason: format!("file writes are not allowed in '{}' mode", mode.as_str()),
},
PermissionMode::Manual => EnforcementResult::Denied {
tool: "write_file".to_owned(),
active_mode: mode.as_str().to_owned(),
required_mode: PermissionMode::WorkspaceWrite.as_str().to_owned(),
reason: "file write requires confirmation in manual mode".to_owned(),
},
PermissionMode::WorkspaceWrite => {
if is_within_workspace(path, workspace_root) {
EnforcementResult::Allowed
@ -161,11 +173,11 @@ impl PermissionEnforcer {
}
}
}
PermissionMode::Prompt => EnforcementResult::Denied {
PermissionMode::Manual | PermissionMode::Prompt => EnforcementResult::Denied {
tool: "bash".to_owned(),
active_mode: mode.as_str().to_owned(),
required_mode: PermissionMode::DangerFullAccess.as_str().to_owned(),
reason: "bash requires confirmation in prompt mode".to_owned(),
reason: format!("bash requires confirmation in {} mode", mode.as_str()),
},
// WorkspaceWrite, Allow, DangerFullAccess: permit bash
_ => EnforcementResult::Allowed,

View File

@ -8,6 +8,7 @@ use crate::config::RuntimePermissionRuleConfig;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum PermissionMode {
ReadOnly,
Manual,
WorkspaceWrite,
DangerFullAccess,
Prompt,
@ -19,12 +20,22 @@ impl PermissionMode {
pub fn as_str(self) -> &'static str {
match self {
Self::ReadOnly => "read-only",
Self::Manual => "manual",
Self::WorkspaceWrite => "workspace-write",
Self::DangerFullAccess => "danger-full-access",
Self::Prompt => "prompt",
Self::Allow => "allow",
}
}
#[must_use]
pub const fn grants(self, required: Self) -> bool {
match self {
Self::Allow | Self::DangerFullAccess => true,
Self::WorkspaceWrite => matches!(required, Self::ReadOnly | Self::WorkspaceWrite),
Self::Manual | Self::Prompt | Self::ReadOnly => matches!(required, Self::ReadOnly),
}
}
}
/// Hook-provided override applied before standard permission evaluation.
@ -253,7 +264,7 @@ impl PermissionPolicy {
}
if allow_rule.is_some()
|| current_mode == PermissionMode::Allow
|| current_mode >= required_mode
|| current_mode.grants(required_mode)
{
return PermissionOutcome::Allow;
}
@ -278,14 +289,16 @@ impl PermissionPolicy {
if allow_rule.is_some()
|| current_mode == PermissionMode::Allow
|| current_mode >= required_mode
|| current_mode.grants(required_mode)
{
return PermissionOutcome::Allow;
}
if current_mode == PermissionMode::Prompt
|| (current_mode == PermissionMode::WorkspaceWrite
&& required_mode == PermissionMode::DangerFullAccess)
if matches!(
current_mode,
PermissionMode::Manual | PermissionMode::Prompt
) || (current_mode == PermissionMode::WorkspaceWrite
&& required_mode == PermissionMode::DangerFullAccess)
{
let reason = Some(format!(
"tool '{tool_name}' requires approval to escalate from {} to {}",

View File

@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
pub type GreenLevel = u8;
const STALE_BRANCH_THRESHOLD: Duration = Duration::from_hours(1);
const STALE_BRANCH_THRESHOLD: Duration = Duration::from_secs(60 * 60);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PolicyRule {

View File

@ -57,6 +57,8 @@ pub struct ConversationMessage {
pub role: MessageRole,
pub blocks: Vec<ContentBlock>,
pub usage: Option<TokenUsage>,
pub incomplete: bool,
pub incomplete_reason: Option<String>,
}
/// Metadata describing the latest compaction that summarized a session.
@ -715,6 +717,8 @@ impl ConversationMessage {
role: MessageRole::User,
blocks: vec![ContentBlock::Text { text: text.into() }],
usage: None,
incomplete: false,
incomplete_reason: None,
}
}
@ -724,15 +728,28 @@ impl ConversationMessage {
role: MessageRole::Assistant,
blocks,
usage: None,
incomplete: false,
incomplete_reason: None,
}
}
#[must_use]
pub fn assistant_with_usage(blocks: Vec<ContentBlock>, usage: Option<TokenUsage>) -> Self {
Self::assistant_with_usage_status(blocks, usage, None)
}
#[must_use]
pub fn assistant_with_usage_status(
blocks: Vec<ContentBlock>,
usage: Option<TokenUsage>,
incomplete_reason: Option<String>,
) -> Self {
Self {
role: MessageRole::Assistant,
blocks,
usage,
incomplete: incomplete_reason.is_some(),
incomplete_reason,
}
}
@ -752,6 +769,8 @@ impl ConversationMessage {
is_error,
}],
usage: None,
incomplete: false,
incomplete_reason: None,
}
}
@ -777,6 +796,15 @@ impl ConversationMessage {
if let Some(usage) = self.usage {
object.insert("usage".to_string(), usage_to_json(usage));
}
if self.incomplete {
object.insert("incomplete".to_string(), JsonValue::Bool(true));
}
if let Some(reason) = &self.incomplete_reason {
object.insert(
"incomplete_reason".to_string(),
JsonValue::String(reason.clone()),
);
}
JsonValue::Object(object)
}
@ -807,10 +835,20 @@ impl ConversationMessage {
.map(ContentBlock::from_json)
.collect::<Result<Vec<_>, _>>()?;
let usage = object.get("usage").map(usage_from_json).transpose()?;
let incomplete = object
.get("incomplete")
.and_then(JsonValue::as_bool)
.unwrap_or(false);
let incomplete_reason = object
.get("incomplete_reason")
.and_then(JsonValue::as_str)
.map(ToOwned::to_owned);
Ok(Self {
role,
blocks,
usage,
incomplete,
incomplete_reason,
})
}
}
@ -1060,6 +1098,15 @@ fn persisted_message_json(message: &ConversationMessage) -> JsonValue {
if let Some(usage) = message.usage {
object.insert("usage".to_string(), usage_to_json(usage));
}
if message.incomplete {
object.insert("incomplete".to_string(), JsonValue::Bool(true));
}
if let Some(reason) = &message.incomplete_reason {
object.insert(
"incomplete_reason".to_string(),
JsonValue::String(sanitize_jsonl_field(reason)),
);
}
JsonValue::Object(object)
}

View File

@ -6,6 +6,7 @@ use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::{validate_packet, TaskPacket, TaskPacketValidationError};
@ -46,6 +47,26 @@ pub struct Task {
pub output: String,
pub team_id: Option<String>,
pub heartbeat: Option<LaneHeartbeat>,
#[serde(skip_serializing_if = "Option::is_none")]
pub active_form: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<Value>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub blocks: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub blocked_by: Vec<String>,
}
#[derive(Debug, Clone, Default)]
pub struct TaskUpdate {
pub message: Option<String>,
pub status: Option<TaskStatus>,
pub subject: Option<String>,
pub description: Option<String>,
pub active_form: Option<String>,
pub metadata: Option<Value>,
pub add_blocks: Vec<String>,
pub add_blocked_by: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@ -120,6 +141,14 @@ fn now_secs() -> u64 {
.as_secs()
}
fn extend_unique(target: &mut Vec<String>, additions: Vec<String>) {
for value in additions {
if !target.contains(&value) {
target.push(value);
}
}
}
impl TaskRegistry {
#[must_use]
pub fn new() -> Self {
@ -127,7 +156,29 @@ impl TaskRegistry {
}
pub fn create(&self, prompt: &str, description: Option<&str>) -> Task {
self.create_task(prompt.to_owned(), description.map(str::to_owned), None)
self.create_task(
prompt.to_owned(),
description.map(str::to_owned),
None,
None,
None,
)
}
pub fn create_structured(
&self,
subject: &str,
description: Option<&str>,
active_form: Option<String>,
metadata: Option<Value>,
) -> Task {
self.create_task(
subject.to_owned(),
description.map(str::to_owned),
None,
active_form,
metadata,
)
}
pub fn create_from_packet(
@ -140,7 +191,13 @@ impl TaskRegistry {
.scope_path
.clone()
.or_else(|| Some(packet.scope.to_string()));
Ok(self.create_task(packet.objective.clone(), description, Some(packet)))
Ok(self.create_task(
packet.objective.clone(),
description,
Some(packet),
None,
None,
))
}
fn create_task(
@ -148,6 +205,8 @@ impl TaskRegistry {
prompt: String,
description: Option<String>,
task_packet: Option<TaskPacket>,
active_form: Option<String>,
metadata: Option<Value>,
) -> Task {
let mut inner = self.inner.lock().expect("registry lock poisoned");
inner.counter += 1;
@ -165,6 +224,10 @@ impl TaskRegistry {
output: String::new(),
team_id: None,
heartbeat: None,
active_form,
metadata,
blocks: Vec::new(),
blocked_by: Vec::new(),
};
inner.tasks.insert(task_id, task.clone());
task
@ -269,17 +332,46 @@ impl TaskRegistry {
}
pub fn update(&self, task_id: &str, message: &str) -> Result<Task, String> {
self.update_task(
task_id,
TaskUpdate {
message: Some(message.to_owned()),
..TaskUpdate::default()
},
)
}
pub fn update_task(&self, task_id: &str, update: TaskUpdate) -> Result<Task, String> {
let mut inner = self.inner.lock().expect("registry lock poisoned");
let task = inner
.tasks
.get_mut(task_id)
.ok_or_else(|| format!("task not found: {task_id}"))?;
task.messages.push(TaskMessage {
role: String::from("user"),
content: message.to_owned(),
timestamp: now_secs(),
});
if let Some(message) = update.message {
task.messages.push(TaskMessage {
role: String::from("user"),
content: message,
timestamp: now_secs(),
});
}
if let Some(status) = update.status {
task.status = status;
}
if let Some(subject) = update.subject {
task.prompt = subject;
}
if let Some(description) = update.description {
task.description = Some(description);
}
if let Some(active_form) = update.active_form {
task.active_form = Some(active_form);
}
if let Some(metadata) = update.metadata {
task.metadata = Some(metadata);
}
extend_unique(&mut task.blocks, update.add_blocks);
extend_unique(&mut task.blocked_by, update.add_blocked_by);
task.updated_at = now_secs();
Ok(task.clone())
}

View File

@ -29,7 +29,7 @@ impl Default for TridentConfig {
}
/// Statistics from a Trident compaction run.
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TridentStats {
pub superseded_count: usize,
pub collapsed_chains: usize,
@ -41,21 +41,6 @@ pub struct TridentStats {
pub final_message_count: usize,
}
impl Default for TridentStats {
fn default() -> Self {
Self {
superseded_count: 0,
collapsed_chains: 0,
messages_collapsed: 0,
clusters_found: 0,
messages_clustered: 0,
tokens_saved_estimate: 0,
original_message_count: 0,
final_message_count: 0,
}
}
}
impl TridentStats {
pub fn format_report(&self) -> String {
let compression = if self.final_message_count > 0 {
@ -192,7 +177,7 @@ fn stage1_supersede(messages: &[ConversationMessage]) -> (Vec<ConversationMessag
let mut obsolete_indices: BTreeSet<usize> = BTreeSet::new();
for (_path, ops) in &file_ops {
for ops in file_ops.values() {
if ops.len() < 2 {
continue;
}
@ -205,11 +190,7 @@ fn stage1_supersede(messages: &[ConversationMessage]) -> (Vec<ConversationMessag
if let Some(last_write) = last_write_idx {
for op in ops {
if op.op_type == FileOp::Read && op.index < last_write {
obsolete_indices.insert(op.index);
} else if (op.op_type == FileOp::Write || op.op_type == FileOp::Edit)
&& op.index < last_write
{
if op.index < last_write {
obsolete_indices.insert(op.index);
}
}
@ -323,9 +304,11 @@ fn stage2_collapse(
text: format!("[Collapsed Conversation]\n{summary}"),
}],
usage: None,
incomplete: false,
incomplete_reason: None,
});
} else {
result.extend(buffer.drain(..));
result.append(&mut buffer);
}
buffer.clear();
result.push(msg.clone());
@ -342,6 +325,8 @@ fn stage2_collapse(
text: format!("[Collapsed Conversation]\n{summary}"),
}],
usage: None,
incomplete: false,
incomplete_reason: None,
});
} else {
result.extend(buffer);
@ -476,7 +461,7 @@ fn stage3_cluster(
}
let total_clustered: usize = cluster_assignments.len();
let clusters_found = cluster_id as usize;
let clusters_found = cluster_id;
let mut result: Vec<ConversationMessage> = Vec::new();
let mut cluster_buffers: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
@ -498,6 +483,8 @@ fn stage3_cluster(
text: format!("[Clustered {} messages]\n{summary}", buffer.len()),
}],
usage: None,
incomplete: false,
incomplete_reason: None,
});
}
}
@ -677,7 +664,7 @@ fn truncate_text(text: &str, max_chars: usize) -> String {
mod tests {
use super::*;
use crate::compact::CompactionConfig;
use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
use crate::session::{ContentBlock, ConversationMessage, Session};
#[test]
fn stage1_removes_obsolete_file_reads() {
@ -736,7 +723,7 @@ mod tests {
fn stage2_collapses_chatty_messages() {
let mut messages = vec![];
for i in 0..6 {
messages.push(ConversationMessage::user_text(&format!("ok {i}")));
messages.push(ConversationMessage::user_text(format!("ok {i}")));
messages.push(ConversationMessage::assistant(vec![ContentBlock::Text {
text: format!("got {i}"),
}]));
@ -767,9 +754,9 @@ mod tests {
},
]));
messages.push(ConversationMessage::tool_result(
&format!("read_{i}"),
format!("read_{i}"),
"read_file",
&format!(r#"{{"path":"src/{i}.rs","content":"data {i}"}}"#),
format!(r#"{{"path":"src/{i}.rs","content":"data {i}"}}"#),
false,
));
}

View File

@ -66,6 +66,14 @@ pub fn pricing_for_model(model: &str) -> Option<ModelPricing> {
cache_read_cost_per_million: 0.1,
});
}
if normalized.contains("fable") {
return Some(ModelPricing {
input_cost_per_million: 15.0,
output_cost_per_million: 75.0,
cache_creation_cost_per_million: 18.75,
cache_read_cost_per_million: 1.5,
});
}
if normalized.contains("opus") {
return Some(ModelPricing {
input_cost_per_million: 15.0,
@ -74,6 +82,14 @@ pub fn pricing_for_model(model: &str) -> Option<ModelPricing> {
cache_read_cost_per_million: 1.5,
});
}
if normalized.contains("sonnet-5") {
return Some(ModelPricing {
input_cost_per_million: 2.0,
output_cost_per_million: 10.0,
cache_creation_cost_per_million: 2.5,
cache_read_cost_per_million: 0.2,
});
}
if normalized.contains("sonnet") {
return Some(ModelPricing::default_sonnet_tier());
}
@ -304,6 +320,8 @@ mod tests {
cache_creation_input_tokens: 1,
cache_read_input_tokens: 0,
}),
incomplete: false,
incomplete_reason: None,
}];
let tracker = UsageTracker::from_session(&session);

View File

@ -22,7 +22,7 @@ fn stale_branch_detection_flows_into_policy_engine() {
let stale_context = LaneContext::new(
"stale-lane",
0,
Duration::from_hours(2), // 2 hours stale
Duration::from_secs(2 * 60 * 60), // 2 hours stale
LaneBlocker::None,
ReviewStatus::Pending,
DiffScope::Full,
@ -49,7 +49,7 @@ fn fresh_branch_does_not_trigger_stale_policy() {
let fresh_context = LaneContext::new(
"fresh-lane",
0,
Duration::from_mins(30), // 30 min stale — under 1 hour threshold
Duration::from_secs(30 * 60), // 30 min stale — under 1 hour threshold
LaneBlocker::None,
ReviewStatus::Pending,
DiffScope::Full,
@ -210,8 +210,8 @@ fn end_to_end_stale_lane_gets_merge_forward_action() {
// when: build context and evaluate policy
let context = LaneContext::new(
"lane-9411",
3, // Workspace green
Duration::from_hours(5), // 5 hours stale, definitely over threshold
3, // Workspace green
Duration::from_secs(5 * 60 * 60), // 5 hours stale, definitely over threshold
LaneBlocker::None,
ReviewStatus::Approved,
DiffScope::Scoped,
@ -259,8 +259,8 @@ fn end_to_end_stale_lane_gets_merge_forward_action() {
fn fresh_approved_lane_gets_merge_action() {
let context = LaneContext::new(
"fresh-approved-lane",
3, // Workspace green
Duration::from_mins(30), // 30 min — under 1 hour threshold = fresh
3, // Workspace green
Duration::from_secs(30 * 60), // 30 min — under 1 hour threshold = fresh
LaneBlocker::None,
ReviewStatus::Approved,
DiffScope::Scoped,
@ -346,7 +346,7 @@ fn worker_provider_failure_flows_through_recovery_to_policy() {
// (Simulating the policy check that would happen after successful recovery)
let recovery_success = matches!(result, RecoveryResult::Recovered { .. });
let green_level = 3; // Workspace green
let not_stale = Duration::from_mins(30); // 30 min — fresh
let not_stale = Duration::from_secs(30 * 60); // 30 min — fresh
let post_recovery_context = LaneContext::new(
"recovered-lane",

View File

@ -70,7 +70,7 @@ use tools::{
RuntimeToolDefinition, ToolSearchOutput,
};
const DEFAULT_MODEL: &str = "anthropic/claude-opus-4-7";
const DEFAULT_MODEL: &str = "anthropic/claude-sonnet-5";
/// #148: Model provenance for `claw status` JSON/text output. Records where
/// the resolved model string came from so claws don't have to re-read argv
@ -154,7 +154,7 @@ impl PermissionModeProvenance {
fn default_fallback() -> Self {
Self {
mode: PermissionMode::WorkspaceWrite,
mode: PermissionMode::Manual,
source: PermissionModeSource::Default,
env_var: None,
}
@ -1532,7 +1532,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
"--model" => {
let value = args
.get(index + 1)
.ok_or_else(|| "missing_flag_value: missing value for --model.\nUsage: --model <provider/model> e.g. --model anthropic/claude-opus-4-7".to_string())?;
.ok_or_else(|| "missing_flag_value: missing value for --model.\nUsage: --model <provider/model> e.g. --model anthropic/claude-opus-4-8".to_string())?;
// #468: track duplicate --model flags
if model_flag_raw.is_some() {
push_duplicate_flag(&format!(
@ -1574,7 +1574,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
"--permission-mode" => {
let value = args
.get(index + 1)
.ok_or_else(|| "missing_flag_value: missing value for --permission-mode.\nUsage: --permission-mode read-only|workspace-write|danger-full-access".to_string())?;
.ok_or_else(|| "missing_flag_value: missing value for --permission-mode.\nUsage: --permission-mode manual|read-only|workspace-write|danger-full-access".to_string())?;
// #468: track duplicate --permission-mode flags
if permission_mode_override.is_some() {
push_duplicate_flag("--permission-mode (overwriting previous value)");
@ -1939,9 +1939,10 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
// Only reject for known top-level subcommands that don't use compact.
let first = rest[0].as_str();
if is_known_top_level_subcommand(first) && first != "prompt" {
return Err(format!(
return Err(
"invalid_flag_value: --compact is only supported with prompt mode.\nUsage: claw --compact \"<prompt>\" or echo \"<prompt>\" | claw --compact"
));
.to_string(),
);
}
}
@ -2024,7 +2025,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
// forms here and return a structured guidance error so no network
// call or session is created.
"permissions" => Err(
"`claw permissions` is a slash command. Start `claw` and run `/permissions` inside the REPL.\n Usage /permissions [read-only|workspace-write|danger-full-access]"
"`claw permissions` is a slash command. Start `claw` and run `/permissions` inside the REPL.\n Usage /permissions [manual|read-only|workspace-write|danger-full-access]"
.to_string(),
),
// #767: `claw session bogus` bypassed parse_single_word_command_alias (rest.len()>1),
@ -2565,6 +2566,36 @@ fn try_resolve_bare_skill_prompt(cwd: &Path, trimmed: &str) -> Option<String> {
}
}
fn try_resolve_leading_skill_prompt(cwd: &Path, input: &str) -> Result<Option<String>, String> {
let tokens = input.split_whitespace().collect::<Vec<_>>();
let mut skill_count = 0;
for token in &tokens {
if !token.starts_with(['/', '$']) || commands::resolve_skill_path(cwd, token).is_err() {
break;
}
skill_count += 1;
if skill_count > 5 {
return Err("At most 5 leading skills can be invoked in one prompt.".to_string());
}
}
if skill_count == 0 {
return Ok(None);
}
let mut prompt = tokens[..skill_count]
.iter()
.map(|token| format!("${}", token.trim_start_matches(['/', '$'])))
.collect::<Vec<_>>();
prompt.extend(
tokens[skill_count..]
.iter()
.map(|token| (*token).to_string()),
);
Ok(Some(prompt.join(" ")))
}
fn join_optional_args(args: &[String]) -> Option<String> {
let joined = args.join(" ");
let trimmed = joined.trim();
@ -2901,9 +2932,10 @@ fn levenshtein_distance(left: &str, right: &str) -> usize {
fn resolve_model_alias(model: &str) -> &str {
match model {
"opus" => "anthropic/claude-opus-4-7",
"sonnet" => "anthropic/claude-sonnet-4-6",
"haiku" => "anthropic/claude-haiku-4-5-20251213",
"opus" => "anthropic/claude-opus-4-8",
"sonnet" => "anthropic/claude-sonnet-5",
"haiku" => "anthropic/claude-haiku-4-5-20251001",
"fable" => "anthropic/claude-fable-5",
_ => model,
}
}
@ -2933,12 +2965,12 @@ fn validate_model_syntax(model: &str) -> Result<(), String> {
return Ok(());
}
if trimmed.is_empty() {
return Err("invalid model syntax: model string cannot be empty.\nUsage: --model <provider/model> e.g. --model anthropic/claude-opus-4-7".to_string());
return Err("invalid model syntax: model string cannot be empty.\nUsage: --model <provider/model> e.g. --model anthropic/claude-opus-4-8".to_string());
}
// Check for spaces (malformed)
if trimmed.contains(' ') {
return Err(format!(
"invalid model syntax: '{}' contains spaces.\nUse provider/model format (e.g., anthropic/claude-opus-4-7) or a known alias.",
"invalid model syntax: '{}' contains spaces.\nUse provider/model format (e.g., anthropic/claude-opus-4-8) or a known alias.",
trimmed
));
}
@ -2953,7 +2985,7 @@ fn validate_model_syntax(model: &str) -> Result<(), String> {
if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
// #154: hint if the model looks like it belongs to a different provider
let mut err_msg = format!(
"invalid model syntax: '{}'.\nExpected provider/model (e.g., anthropic/claude-opus-4-7)",
"invalid model syntax: '{}'.\nExpected provider/model (e.g., anthropic/claude-opus-4-8)",
trimmed
);
if trimmed.starts_with("gpt-") || trimmed.starts_with("gpt_") {
@ -3046,7 +3078,7 @@ fn parse_permission_mode_arg(value: &str) -> Result<PermissionMode, String> {
normalize_permission_mode(value)
.ok_or_else(|| {
format!(
"invalid_permission_mode: unsupported permission mode '{value}'.\nUsage: --permission-mode read-only|workspace-write|danger-full-access"
"invalid_permission_mode: unsupported permission mode '{value}'.\nUsage: --permission-mode manual|read-only|workspace-write|danger-full-access"
)
})
.map(permission_mode_from_label)
@ -3055,6 +3087,7 @@ fn parse_permission_mode_arg(value: &str) -> Result<PermissionMode, String> {
fn permission_mode_from_label(mode: &str) -> PermissionMode {
match mode {
"read-only" => PermissionMode::ReadOnly,
"manual" => PermissionMode::Manual,
"workspace-write" => PermissionMode::WorkspaceWrite,
"danger-full-access" => PermissionMode::DangerFullAccess,
other => panic!("unsupported permission mode label: {other}"),
@ -3064,6 +3097,7 @@ fn permission_mode_from_label(mode: &str) -> PermissionMode {
fn permission_mode_from_resolved(mode: ResolvedPermissionMode) -> PermissionMode {
match mode {
ResolvedPermissionMode::ReadOnly => PermissionMode::ReadOnly,
ResolvedPermissionMode::Manual => PermissionMode::Manual,
ResolvedPermissionMode::WorkspaceWrite => PermissionMode::WorkspaceWrite,
ResolvedPermissionMode::DangerFullAccess => PermissionMode::DangerFullAccess,
}
@ -3213,9 +3247,10 @@ fn parse_system_prompt_args(
})?;
// #99: validate --date is a plausible date string (no newlines, reasonable length)
if value.contains('\n') || value.contains('\r') {
return Err(format!(
return Err(
"invalid_flag_value: --date value contains invalid characters.\nUsage: --date <YYYY-MM-DD>"
));
.to_string(),
);
}
if value.len() > 20 {
return Err(format!(
@ -3452,11 +3487,7 @@ impl DiagnosticCheck {
fn json_value(&self) -> Value {
// Derive a stable snake_case id from the check name for machine-readable keying (#704).
let id = self
.name
.to_ascii_lowercase()
.replace(' ', "_")
.replace('-', "_");
let id = self.name.to_ascii_lowercase().replace([' ', '-'], "_");
let mut value = Map::from_iter([
("id".to_string(), Value::String(id.clone())),
(
@ -4213,8 +4244,8 @@ fn check_permission_health(permission_mode: PermissionModeProvenance) -> Diagnos
"running with full access without explicit opt-in"
} else if matches!(permission_mode.mode, PermissionMode::DangerFullAccess) {
"danger-full-access was explicitly selected"
} else if matches!(permission_mode.mode, PermissionMode::WorkspaceWrite) && !explicit {
"default permission mode is workspace-write"
} else if matches!(permission_mode.mode, PermissionMode::Manual) && !explicit {
"default permission mode is manual"
} else {
"permission mode is explicitly bounded below danger-full-access"
};
@ -4225,12 +4256,12 @@ fn check_permission_health(permission_mode: PermissionModeProvenance) -> Diagnos
let specs = mvp_tool_specs();
let tools_satisfied = specs
.iter()
.filter(|spec| permission_mode.mode >= spec.required_permission)
.filter(|spec| permission_mode.mode.grants(spec.required_permission))
.map(|spec| spec.name)
.collect::<Vec<_>>();
let tools_gated = specs
.iter()
.filter(|spec| permission_mode.mode < spec.required_permission)
.filter(|spec| !permission_mode.mode.grants(spec.required_permission))
.map(|spec| spec.name)
.collect::<Vec<_>>();
@ -4251,9 +4282,9 @@ fn check_permission_health(permission_mode: PermissionModeProvenance) -> Diagnos
format!("Tools gated {}", tools_gated.join(", ")),
])
.with_hint(if warning {
"Use the workspace-write default, or pass --permission-mode danger-full-access / --dangerously-skip-permissions only when full filesystem, network, and command access is intentional."
"Use the manual default, or pass --permission-mode danger-full-access / --dangerously-skip-permissions only when full filesystem, network, and command access is intentional."
} else {
"Use --permission-mode read-only|workspace-write|danger-full-access to make the runtime permission boundary explicit."
"Use --permission-mode manual|read-only|workspace-write|danger-full-access to make the runtime permission boundary explicit."
})
.with_data(Map::from_iter([
("mode".to_string(), json!(mode)),
@ -6730,16 +6761,15 @@ fn run_resume_command(
}
SlashCommand::Plugins { action, target } => {
// Only list is supported in resume mode (no runtime to reload)
match action.as_deref() {
Some(action @ ("install" | "uninstall" | "enable" | "disable" | "update")) => {
// #777: use interactive_only: prefix + \n hint so #776's classify/split
// emits error_kind:interactive_only + non-null hint instead of unknown+null.
// Orchestrators can now detect this and switch to a live REPL instead of retrying.
return Err(format!(
"interactive_only: /plugins {action} requires a live session to reload the plugin runtime.\nStart `claw` and run `/plugins {action}` inside the REPL, or use `claw plugins {action}` as a direct CLI command."
).into());
}
_ => {}
if let Some(action @ ("install" | "uninstall" | "enable" | "disable" | "update")) =
action.as_deref()
{
// #777: use interactive_only: prefix + \n hint so #776's classify/split
// emits error_kind:interactive_only + non-null hint instead of unknown+null.
// Orchestrators can now detect this and switch to a live REPL instead of retrying.
return Err(format!(
"interactive_only: /plugins {action} requires a live session to reload the plugin runtime.\nStart `claw` and run `/plugins {action}` inside the REPL, or use `claw plugins {action}` as a direct CLI command."
).into());
}
let cwd = env::current_dir()?;
let payload = plugins_command_payload_for(
@ -7075,6 +7105,20 @@ fn run_repl(
cli.persist_session()?;
break;
}
let cwd = std::env::current_dir().unwrap_or_default();
match try_resolve_leading_skill_prompt(&cwd, &trimmed) {
Ok(Some(prompt)) => {
editor.push_history(input);
cli.record_prompt_history(&trimmed);
cli.run_turn(&prompt)?;
continue;
}
Ok(None) => {}
Err(error) => {
eprintln!("{error}");
continue;
}
}
match SlashCommand::parse(&trimmed) {
Ok(Some(command)) => {
if cli.handle_repl_command(command)? {
@ -7091,7 +7135,6 @@ fn run_repl(
// Bare-word skill dispatch: if the first token of the input
// matches a known skill name, invoke it as `/skills <input>`
// rather than forwarding raw text to the LLM (ROADMAP #36).
let cwd = std::env::current_dir().unwrap_or_default();
if let Some(prompt) = try_resolve_bare_skill_prompt(&cwd, &trimmed) {
editor.push_history(input);
cli.record_prompt_history(&trimmed);
@ -7852,8 +7895,12 @@ impl LiveCli {
let max_compact_rounds = 4;
let preserve_schedule = [4, 2, 1, 0];
for round in 0..max_compact_rounds {
let preserve = preserve_schedule[round];
for (round, preserve) in preserve_schedule
.iter()
.copied()
.enumerate()
.take(max_compact_rounds)
{
println!(
" Auto-compacting session (round {}/{}, preserving {} recent messages)...",
round + 1,
@ -8203,6 +8250,11 @@ impl LiveCli {
println!("{}", format_cost_report(usage));
false
}
SlashCommand::Review { scope } => {
let scope = scope.unwrap_or_else(|| "medium".to_string());
self.run_turn(&format!("$code-review {scope}"))?;
false
}
SlashCommand::Login
| SlashCommand::Logout
| SlashCommand::Vim
@ -8224,7 +8276,6 @@ impl LiveCli {
| SlashCommand::Keybindings
| SlashCommand::PrivacySettings
| SlashCommand::Plan { .. }
| SlashCommand::Review { .. }
| SlashCommand::Tasks { .. }
| SlashCommand::Theme { .. }
| SlashCommand::Voice { .. }
@ -8406,7 +8457,7 @@ impl LiveCli {
let normalized = normalize_permission_mode(&mode).ok_or_else(|| {
format!(
"invalid_flag_value: unsupported permission mode '{mode}'.\nUsage: --permission-mode read-only|workspace-write|danger-full-access"
"invalid_flag_value: unsupported permission mode '{mode}'.\nUsage: --permission-mode manual|read-only|workspace-write|danger-full-access"
)
})?;
@ -8611,8 +8662,8 @@ impl LiveCli {
let cwd = env::current_dir()?;
// #803: reject flag-shaped tokens in list filter for BOTH text and JSON modes.
// Previously the guard was JSON-only (#793); text mode silently returned empty success.
if action.as_deref() == Some("list") {
if let Some(filter) = target.as_deref() {
if action == Some("list") {
if let Some(filter) = target {
if filter.starts_with('-') {
if matches!(output_format, CliOutputFormat::Json) {
// ROADMAP #817: this is a handled local inventory parse error.
@ -9577,6 +9628,7 @@ fn print_status_snapshot(
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn status_json_value(
model: Option<&str>,
usage: StatusUsage,
@ -10073,9 +10125,7 @@ fn sandbox_json_value(status: &runtime::SandboxStatus) -> serde_json::Value {
// (#731: "not supported on macOS" is a degraded state, not a hard error;
// filesystem_active:true means partial containment is working)
// error = enabled but unsupported AND no filesystem sandbox either (nothing active)
let top_status = if !status.enabled {
"ok"
} else if status.active {
let top_status = if !status.enabled || status.active {
"ok"
} else if status.supported {
"warn"
@ -10325,7 +10375,7 @@ fn print_models(
CliOutputFormat::Text => {
println!("Models");
println!(" Default {DEFAULT_MODEL}");
println!(" Built-in aliases opus, sonnet, haiku");
println!(" Built-in aliases opus, sonnet, haiku, fable");
if let Some(raw) = configured_model.as_deref() {
println!(
" Config model {raw}{}",
@ -10351,7 +10401,8 @@ fn print_models(
"aliases": [
{"name": "opus", "model": resolve_model_alias("opus")},
{"name": "sonnet", "model": resolve_model_alias("sonnet")},
{"name": "haiku", "model": resolve_model_alias("haiku")}
{"name": "haiku", "model": resolve_model_alias("haiku")},
{"name": "fable", "model": resolve_model_alias("fable")}
],
"configured_model": configured_model,
"resolved_configured_model": resolved_config_model,
@ -10449,10 +10500,7 @@ fn render_doctor_help_json() -> serde_json::Value {
})
}
/// #683-#692: extract structured metadata from help prose
fn extract_help_metadata(
topic: LocalHelpTopic,
) -> (
type HelpMetadata = (
Option<String>, // usage
Option<String>, // purpose
Option<String>, // output description
@ -10461,7 +10509,10 @@ fn extract_help_metadata(
Option<Vec<String>>, // aliases
bool, // local_only
bool, // requires_credentials
) {
);
/// #683-#692: extract structured metadata from help prose
fn extract_help_metadata(topic: LocalHelpTopic) -> HelpMetadata {
let text = render_help_topic(topic);
let mut usage = None;
let mut purpose = None;
@ -11076,7 +11127,8 @@ fn init_json_value(report: &crate::init::InitReport, message: &str) -> serde_jso
fn normalize_permission_mode(mode: &str) -> Option<&'static str> {
match mode.trim() {
"default" | "plan" | "read-only" => Some("read-only"),
"plan" | "read-only" => Some("read-only"),
"default" | "manual" | "prompt" => Some("manual"),
"acceptEdits" | "auto" | "workspace-write" => Some("workspace-write"),
"dontAsk" | "bypassPermissions" | "dangerFullAccess" | "danger-full-access" => {
Some("danger-full-access")
@ -12710,9 +12762,27 @@ impl AnthropicRuntimeClient {
loop {
let next = if apply_stall_timeout && !received_any_event {
match tokio::time::timeout(POST_TOOL_STALL_TIMEOUT, stream.next_event()).await {
Ok(inner) => inner.map_err(|error| {
RuntimeError::new(format_user_visible_api_error(&self.session_id, &error))
})?,
Ok(inner) => match inner {
Ok(next) => next,
Err(error) => {
if let Some(events) = finish_incomplete_stream(
&self.session_id,
&error,
out,
&renderer,
&mut markdown_stream,
&mut events,
&mut pending_thinking,
&mut pending_tool,
)? {
return Ok(events);
}
return Err(RuntimeError::new(format_user_visible_api_error(
&self.session_id,
&error,
)));
}
},
Err(_elapsed) => {
return Err(RuntimeError::new(
"post-tool stall: model did not respond within timeout",
@ -12720,9 +12790,27 @@ impl AnthropicRuntimeClient {
}
}
} else {
stream.next_event().await.map_err(|error| {
RuntimeError::new(format_user_visible_api_error(&self.session_id, &error))
})?
match stream.next_event().await {
Ok(next) => next,
Err(error) => {
if let Some(events) = finish_incomplete_stream(
&self.session_id,
&error,
out,
&renderer,
&mut markdown_stream,
&mut events,
&mut pending_thinking,
&mut pending_tool,
)? {
return Ok(events);
}
return Err(RuntimeError::new(format_user_visible_api_error(
&self.session_id,
&error,
)));
}
}
};
let Some(event) = next else {
@ -12871,6 +12959,63 @@ impl AnthropicRuntimeClient {
}
}
#[allow(clippy::too_many_arguments)]
fn finish_incomplete_stream(
session_id: &str,
error: &api::ApiError,
out: &mut dyn Write,
renderer: &TerminalRenderer,
markdown_stream: &mut MarkdownStreamState,
events: &mut Vec<AssistantEvent>,
pending_thinking: &mut Option<(String, Option<String>)>,
pending_tool: &mut Option<(String, String, String)>,
) -> Result<Option<Vec<AssistantEvent>>, RuntimeError> {
let has_partial = events.iter().any(|event| {
matches!(event, AssistantEvent::TextDelta(text) if !text.is_empty())
|| matches!(
event,
AssistantEvent::Thinking { .. } | AssistantEvent::ToolUse { .. }
)
}) || pending_thinking.is_some()
|| pending_tool.is_some();
if !has_partial {
return Ok(None);
}
if let Some(rendered) = markdown_stream.flush(renderer) {
write!(out, "{rendered}")
.and_then(|()| out.flush())
.map_err(|io_error| RuntimeError::new(io_error.to_string()))?;
}
if let Some((thinking, signature)) = pending_thinking.take() {
events.push(AssistantEvent::Thinking {
thinking,
signature,
});
}
if let Some((id, name, input)) = pending_tool.take() {
let summary = format!(
"\n\n[Incomplete tool call preserved after stream error: {name} ({id}) with partial input `{input}`]"
);
write!(out, "{summary}")
.and_then(|()| out.flush())
.map_err(|io_error| RuntimeError::new(io_error.to_string()))?;
events.push(AssistantEvent::TextDelta(summary));
}
let reason = format_user_visible_api_error(session_id, error);
let notice = format!("\n\n[Incomplete response preserved after stream error: {reason}]");
write!(out, "{notice}")
.and_then(|()| out.flush())
.map_err(|io_error| RuntimeError::new(io_error.to_string()))?;
events.push(AssistantEvent::TextDelta(notice));
events.push(AssistantEvent::IncompleteResponse { reason });
events.push(AssistantEvent::MessageStop);
Ok(Some(std::mem::take(events)))
}
/// Returns `true` when the conversation ends with a tool-result message,
/// meaning the model is expected to continue after tool execution.
fn request_ends_with_tool_result(request: &ApiRequest) -> bool {
@ -14008,39 +14153,37 @@ fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
let content = message
.blocks
.iter()
.filter_map(|block| match block {
ContentBlock::Text { text } => {
Some(InputContentBlock::Text { text: text.clone() })
}
.map(|block| match block {
ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() },
ContentBlock::Thinking {
thinking,
signature,
} => {
// 保留 Thinking 块OpenAI 兼容协议会把它转成 reasoning_content 字段
// 回传给 DeepSeek V4避免 400 "reasoning_content must be passed back" 错误)
Some(InputContentBlock::Thinking {
InputContentBlock::Thinking {
thinking: thinking.clone(),
signature: signature.clone(),
})
}
}
ContentBlock::ToolUse { id, name, input } => Some(InputContentBlock::ToolUse {
ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse {
id: id.clone(),
name: name.clone(),
input: serde_json::from_str(input)
.unwrap_or_else(|_| serde_json::json!({ "raw": input })),
}),
},
ContentBlock::ToolResult {
tool_use_id,
output,
is_error,
..
} => Some(InputContentBlock::ToolResult {
} => InputContentBlock::ToolResult {
tool_use_id: tool_use_id.clone(),
content: vec![ToolResultContentBlock::Text {
text: output.clone(),
}],
is_error: *is_error,
}),
},
})
.collect::<Vec<_>>();
(!content.is_empty()).then(|| InputMessage {
@ -14386,7 +14529,7 @@ mod tests {
#[test]
fn context_window_preflight_errors_render_recovery_steps() {
let error = ApiError::ContextWindowExceeded {
model: "anthropic/claude-sonnet-4-6".to_string(),
model: "anthropic/claude-sonnet-5".to_string(),
estimated_input_tokens: 182_000,
requested_output_tokens: 64_000,
estimated_total_tokens: 246_000,
@ -14401,7 +14544,7 @@ mod tests {
"{rendered}"
);
assert!(
rendered.contains("Model anthropic/claude-sonnet-4-6"),
rendered.contains("Model anthropic/claude-sonnet-5"),
"{rendered}"
);
assert!(
@ -14637,7 +14780,7 @@ mod tests {
CliAction::Repl {
model: DEFAULT_MODEL.to_string(),
allowed_tools: None,
permission_mode: PermissionMode::WorkspaceWrite,
permission_mode: PermissionMode::Manual,
base_commit: None,
reasoning_effort: None,
allow_broad_cwd: false,
@ -14770,7 +14913,7 @@ mod tests {
model: DEFAULT_MODEL.to_string(),
output_format: CliOutputFormat::Text,
allowed_tools: None,
permission_mode: PermissionMode::WorkspaceWrite,
permission_mode: PermissionMode::Manual,
compact: false,
base_commit: None,
reasoning_effort: None,
@ -14858,10 +15001,10 @@ mod tests {
parse_args(&args).expect("args should parse"),
CliAction::Prompt {
prompt: "explain this".to_string(),
model: "anthropic/claude-opus-4-7".to_string(),
model: "anthropic/claude-opus-4-8".to_string(),
output_format: CliOutputFormat::Json,
allowed_tools: None,
permission_mode: PermissionMode::WorkspaceWrite,
permission_mode: PermissionMode::Manual,
compact: false,
base_commit: None,
reasoning_effort: None,
@ -14883,7 +15026,7 @@ mod tests {
model: DEFAULT_MODEL.to_string(),
output_format: CliOutputFormat::Text,
allowed_tools: None,
permission_mode: PermissionMode::WorkspaceWrite,
permission_mode: PermissionMode::Manual,
compact: false,
base_commit: None,
reasoning_effort: None,
@ -14899,7 +15042,7 @@ mod tests {
model: DEFAULT_MODEL.to_string(),
output_format: CliOutputFormat::Text,
allowed_tools: None,
permission_mode: PermissionMode::WorkspaceWrite,
permission_mode: PermissionMode::Manual,
compact: false,
base_commit: None,
reasoning_effort: None,
@ -14915,7 +15058,7 @@ mod tests {
model: DEFAULT_MODEL.to_string(),
output_format: CliOutputFormat::Text,
allowed_tools: None,
permission_mode: PermissionMode::WorkspaceWrite,
permission_mode: PermissionMode::Manual,
compact: false,
base_commit: None,
reasoning_effort: None,
@ -14953,7 +15096,7 @@ mod tests {
model: DEFAULT_MODEL.to_string(),
output_format: CliOutputFormat::Text,
allowed_tools: None,
permission_mode: PermissionMode::WorkspaceWrite,
permission_mode: PermissionMode::Manual,
compact: true,
base_commit: None,
reasoning_effort: None,
@ -14968,7 +15111,7 @@ mod tests {
model: DEFAULT_MODEL.to_string(),
output_format: CliOutputFormat::Text,
allowed_tools: None,
permission_mode: PermissionMode::WorkspaceWrite,
permission_mode: PermissionMode::Manual,
compact: true,
base_commit: None,
reasoning_effort: None,
@ -15008,10 +15151,10 @@ mod tests {
parse_args(&args).expect("args should parse"),
CliAction::Prompt {
prompt: "explain this".to_string(),
model: "anthropic/claude-opus-4-7".to_string(),
model: "anthropic/claude-opus-4-8".to_string(),
output_format: CliOutputFormat::Text,
allowed_tools: None,
permission_mode: PermissionMode::WorkspaceWrite,
permission_mode: PermissionMode::Manual,
compact: false,
base_commit: None,
reasoning_effort: None,
@ -15022,19 +15165,19 @@ mod tests {
#[test]
fn resolves_known_model_aliases() {
assert_eq!(resolve_model_alias("opus"), "anthropic/claude-opus-4-7");
assert_eq!(resolve_model_alias("sonnet"), "anthropic/claude-sonnet-4-6");
assert_eq!(resolve_model_alias("opus"), "anthropic/claude-opus-4-8");
assert_eq!(resolve_model_alias("sonnet"), "anthropic/claude-sonnet-5");
assert_eq!(
resolve_model_alias("haiku"),
"anthropic/claude-haiku-4-5-20251213"
"anthropic/claude-haiku-4-5-20251001"
);
assert_eq!(resolve_model_alias("claude-opus"), "claude-opus");
}
#[test]
fn default_model_alias_uses_anthropic_routing_prefix() {
assert_eq!(DEFAULT_MODEL, "anthropic/claude-opus-4-7");
assert_eq!(resolve_model_alias("opus"), "anthropic/claude-opus-4-7");
assert_eq!(DEFAULT_MODEL, "anthropic/claude-sonnet-5");
assert_eq!(resolve_model_alias("opus"), "anthropic/claude-opus-4-8");
}
#[test]
@ -15048,7 +15191,7 @@ mod tests {
std::fs::create_dir_all(&config_home).expect("config home should exist");
std::fs::write(
cwd.join(".claw").join("settings.json"),
r#"{"aliases":{"fast":"anthropic/claude-haiku-4-5-20251213","smart":"opus","cheap":"grok-3-mini"}}"#,
r#"{"aliases":{"fast":"anthropic/claude-haiku-4-5-20251001","smart":"opus","cheap":"grok-3-mini"}}"#,
)
.expect("project config should write");
@ -15069,11 +15212,11 @@ mod tests {
std::fs::remove_dir_all(root).expect("temp config root should clean up");
// then
assert_eq!(direct, "anthropic/claude-haiku-4-5-20251213");
assert_eq!(chained, "anthropic/claude-opus-4-7");
assert_eq!(direct, "anthropic/claude-haiku-4-5-20251001");
assert_eq!(chained, "anthropic/claude-opus-4-8");
assert_eq!(cross_provider, "grok-3-mini");
assert_eq!(unknown, "unknown-model");
assert_eq!(builtin, "anthropic/claude-haiku-4-5-20251213");
assert_eq!(builtin, "anthropic/claude-haiku-4-5-20251001");
}
#[test]
@ -15178,7 +15321,7 @@ mod tests {
.map(str::to_string)
.collect()
),
permission_mode: PermissionMode::WorkspaceWrite,
permission_mode: PermissionMode::Manual,
base_commit: None,
reasoning_effort: None,
allow_broad_cwd: false,
@ -15541,7 +15684,7 @@ mod tests {
..
} => {
assert_eq!(
model, "anthropic/claude-sonnet-4-6",
model, "anthropic/claude-sonnet-5",
"sonnet alias should resolve"
);
assert_eq!(
@ -15554,7 +15697,7 @@ mod tests {
}
// --model= form should also capture raw.
match parse_args(&[
"--model=anthropic/claude-opus-4-6".to_string(),
"--model=anthropic/claude-opus-4-8".to_string(),
"status".to_string(),
])
.expect("--model=... status should parse")
@ -15564,16 +15707,16 @@ mod tests {
model_flag_raw,
..
} => {
assert_eq!(model, "anthropic/claude-opus-4-6");
assert_eq!(model, "anthropic/claude-opus-4-8");
assert_eq!(
model_flag_raw.as_deref(),
Some("anthropic/claude-opus-4-6"),
Some("anthropic/claude-opus-4-8"),
"--model= form should also preserve raw input"
);
}
other => panic!("expected CliAction::Status, got: {other:?}"),
}
match parse_args(&["--model=claude-opus-4-6".to_string(), "status".to_string()])
match parse_args(&["--model=claude-opus-4-8".to_string(), "status".to_string()])
.expect("bare Anthropic model should parse")
{
CliAction::Status {
@ -15581,8 +15724,8 @@ mod tests {
model_flag_raw,
..
} => {
assert_eq!(model, "claude-opus-4-6");
assert_eq!(model_flag_raw.as_deref(), Some("claude-opus-4-6"));
assert_eq!(model, "claude-opus-4-8");
assert_eq!(model_flag_raw.as_deref(), Some("claude-opus-4-8"));
}
other => panic!("expected CliAction::Status, got: {other:?}"),
}
@ -16317,7 +16460,7 @@ mod tests {
"missing_flag_value"
);
assert_eq!(
classify_error_kind("invalid_permission_mode: unsupported permission mode 'bogus'.\nUsage: --permission-mode read-only|workspace-write|danger-full-access"),
classify_error_kind("invalid_permission_mode: unsupported permission mode 'bogus'.\nUsage: --permission-mode manual|read-only|workspace-write|danger-full-access"),
"invalid_permission_mode"
);
assert_eq!(
@ -16650,6 +16793,8 @@ mod tests {
is_error: false,
}],
usage: None,
incomplete: false,
incomplete_reason: None,
},
];
@ -16690,6 +16835,8 @@ mod tests {
is_error: true,
}],
usage: None,
incomplete: false,
incomplete_reason: None,
}];
// when
@ -16785,7 +16932,7 @@ mod tests {
.expect("prompt shorthand should still work"),
CliAction::Prompt {
prompt: "please debug this".to_string(),
model: "anthropic/claude-opus-4-7".to_string(),
model: "anthropic/claude-opus-4-8".to_string(),
output_format: CliOutputFormat::Text,
allowed_tools: None,
permission_mode: crate::default_permission_mode(),
@ -16972,7 +17119,7 @@ mod tests {
for action in ["remove", "uninstall", "delete"] {
assert_eq!(
parse_args(&["skills".to_string(), action.to_string()])
.expect(&format!("skills {action} should parse")),
.unwrap_or_else(|_| panic!("skills {action} should parse")),
CliAction::Skills {
args: Some(action.to_string()),
output_format: CliOutputFormat::Text,
@ -17040,7 +17187,7 @@ mod tests {
model: DEFAULT_MODEL.to_string(),
output_format: CliOutputFormat::Text,
allowed_tools: None,
permission_mode: PermissionMode::WorkspaceWrite,
permission_mode: PermissionMode::Manual,
compact: false,
base_commit: None,
reasoning_effort: None,
@ -17069,7 +17216,7 @@ mod tests {
model: DEFAULT_MODEL.to_string(),
output_format: CliOutputFormat::Text,
allowed_tools: None,
permission_mode: PermissionMode::WorkspaceWrite,
permission_mode: PermissionMode::Manual,
compact: false,
base_commit: None,
reasoning_effort: None,
@ -17291,12 +17438,12 @@ mod tests {
assert!(help.contains("/status"));
assert!(help.contains("/sandbox"));
assert!(help.contains("/model [model]"));
assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
assert!(help.contains("/permissions [manual|read-only|workspace-write|danger-full-access]"));
assert!(help.contains("/clear [--confirm]"));
assert!(help.contains("/cost"));
assert!(help.contains("/resume <session-path>"));
assert!(help.contains("/config [env|hooks|model|plugins]"));
assert!(help.contains("/mcp [list|show <server>|help]"));
assert!(help.contains("/mcp [list|show <server>|login <server>|logout <server>|help]"));
assert!(help.contains("/memory"));
assert!(help.contains("/init"));
assert!(help.contains("/diff"));
@ -17327,7 +17474,7 @@ mod tests {
vec!["session-old".to_string()],
);
assert!(completions.contains(&"/model anthropic/claude-sonnet-4-6".to_string()));
assert!(completions.contains(&"/model anthropic/claude-sonnet-5".to_string()));
assert!(completions.contains(&"/permissions workspace-write".to_string()));
assert!(completions.contains(&"/session list".to_string()));
assert!(completions.contains(&"/session switch session-current".to_string()));
@ -17346,7 +17493,7 @@ mod tests {
let banner = with_current_dir(&root, || {
LiveCli::new(
"anthropic/claude-sonnet-4-6".to_string(),
"anthropic/claude-sonnet-5".to_string(),
true,
None,
PermissionMode::DangerFullAccess,
@ -17364,11 +17511,11 @@ mod tests {
#[test]
fn format_connected_line_renders_anthropic_provider_for_claude_model() {
let model = "anthropic/claude-sonnet-4-6";
let model = "anthropic/claude-sonnet-5";
let line = format_connected_line(model);
assert_eq!(line, "Connected: anthropic/claude-sonnet-4-6 via anthropic");
assert_eq!(line, "Connected: anthropic/claude-sonnet-5 via anthropic");
}
#[test]
@ -17382,11 +17529,11 @@ mod tests {
#[test]
fn resolve_repl_model_returns_user_supplied_model_unchanged_when_explicit() {
let user_model = "anthropic/claude-sonnet-4-6".to_string();
let user_model = "anthropic/claude-sonnet-5".to_string();
let resolved = resolve_repl_model(user_model).expect("explicit model should resolve");
assert_eq!(resolved, "anthropic/claude-sonnet-4-6");
assert_eq!(resolved, "anthropic/claude-sonnet-5");
}
#[test]
@ -17403,7 +17550,7 @@ mod tests {
let resolved = with_current_dir(&root, || resolve_repl_model(DEFAULT_MODEL.to_string()))
.expect("env model should resolve");
assert_eq!(resolved, "anthropic/claude-sonnet-4-6");
assert_eq!(resolved, "anthropic/claude-sonnet-5");
std::env::remove_var("ANTHROPIC_MODEL");
std::env::remove_var("CLAW_CONFIG_HOME");
@ -18625,6 +18772,8 @@ UU conflicted.rs",
is_error: false,
}],
usage: None,
incomplete: false,
incomplete_reason: None,
},
];
@ -19028,7 +19177,7 @@ UU conflicted.rs",
MessageResponse {
id: "msg-1".to_string(),
kind: "message".to_string(),
model: "anthropic/claude-opus-4-6".to_string(),
model: "anthropic/claude-opus-4-8".to_string(),
role: "assistant".to_string(),
content: vec![OutputContentBlock::ToolUse {
id: "tool-1".to_string(),
@ -19063,7 +19212,7 @@ UU conflicted.rs",
MessageResponse {
id: "msg-2".to_string(),
kind: "message".to_string(),
model: "anthropic/claude-opus-4-6".to_string(),
model: "anthropic/claude-opus-4-8".to_string(),
role: "assistant".to_string(),
content: vec![OutputContentBlock::ToolUse {
id: "tool-2".to_string(),
@ -19098,7 +19247,7 @@ UU conflicted.rs",
MessageResponse {
id: "msg-3".to_string(),
kind: "message".to_string(),
model: "anthropic/claude-opus-4-6".to_string(),
model: "anthropic/claude-opus-4-8".to_string(),
role: "assistant".to_string(),
content: vec![
OutputContentBlock::Thinking {
@ -19750,15 +19899,15 @@ mod alias_resolution_tests {
// Built-in aliases should resolve to their full IDs
assert_eq!(
resolve_model_alias_with_config("opus"),
"anthropic/claude-opus-4-7"
"anthropic/claude-opus-4-8"
);
assert_eq!(
resolve_model_alias_with_config("sonnet"),
"anthropic/claude-sonnet-4-6"
"anthropic/claude-sonnet-5"
);
assert_eq!(
resolve_model_alias_with_config("haiku"),
"anthropic/claude-haiku-4-5-20251213"
"anthropic/claude-haiku-4-5-20251001"
);
}

View File

@ -2,8 +2,6 @@ use std::io::{self, IsTerminal, Write};
use runtime::{save_user_provider_settings, ConfigLoader, RuntimeProviderConfig};
use serde_json;
const PROVIDERS: &[(&str, &str, &str)] = &[
("1", "Anthropic", "anthropic"),
("2", "xAI / Grok", "xai"),

View File

@ -31,7 +31,7 @@ fn status_command_applies_model_and_permission_mode_flags() {
assert_success(&output);
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
assert!(stdout.contains("Status"));
assert!(stdout.contains("Model anthropic/claude-sonnet-4-6"));
assert!(stdout.contains("Model anthropic/claude-sonnet-5"));
assert!(stdout.contains("Permission mode read-only"));
fs::remove_dir_all(temp_dir).expect("cleanup temp dir");

View File

@ -240,7 +240,7 @@ stderr:
"Mock streaming says hello from the parity harness."
);
assert_eq!(parsed["compact"], true);
assert_eq!(parsed["model"], "anthropic/claude-sonnet-4-6");
assert_eq!(parsed["model"], "anthropic/claude-sonnet-5");
assert!(parsed["usage"].is_object());
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");

View File

@ -1,3 +1,5 @@
#![allow(unused_variables)]
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
@ -512,7 +514,7 @@ fn status_json_surfaces_permission_mode_override_for_security_audit() {
}
#[test]
fn default_permission_mode_is_workspace_write_and_audited_428() {
fn default_permission_mode_is_manual_and_audited_428() {
let root = unique_temp_dir("default-permission-mode-428");
let config_home = root.join("config-home");
let home = root.join("home");
@ -529,7 +531,7 @@ fn default_permission_mode_is_workspace_write_and_audited_428() {
];
let status = assert_json_command_with_env(&root, &["--output-format", "json", "status"], &envs);
assert_eq!(status["permission_mode"], "workspace-write");
assert_eq!(status["permission_mode"], "manual");
assert_eq!(status["permission_mode_source"], "default");
let doctor = assert_json_command_with_env(&root, &["--output-format", "json", "doctor"], &envs);
@ -540,12 +542,9 @@ fn default_permission_mode_is_workspace_write_and_audited_428() {
.find(|check| check["name"] == "permissions")
.expect("permissions check");
assert_eq!(permissions["status"], "ok");
assert_eq!(permissions["mode"], "workspace-write");
assert_eq!(permissions["mode"], "manual");
assert_eq!(permissions["source"], "default");
assert_eq!(
permissions["message"],
"default permission mode is workspace-write"
);
assert_eq!(permissions["message"], "default permission mode is manual");
}
#[test]
@ -780,12 +779,12 @@ fn status_json_accepts_namespaced_model_env_and_surfaces_alias_426() {
let parsed = assert_json_command_with_env(&root, &["--output-format", "json", "status"], &envs);
assert_eq!(parsed["status"], "ok");
assert_eq!(parsed["model"], "anthropic/claude-opus-4-7");
assert_eq!(parsed["model"], "anthropic/claude-opus-4-8");
assert_eq!(parsed["model_source"], "env");
assert_eq!(parsed["model_raw"], "opus");
assert_eq!(
parsed["model_alias_resolved_to"],
"anthropic/claude-opus-4-7"
"anthropic/claude-opus-4-8"
);
assert_eq!(parsed["model_env_var"], "CLAW_MODEL");
}
@ -1007,13 +1006,13 @@ fn inventory_commands_emit_structured_json_when_requested() {
assert!(
!plugins
.as_object()
.map_or(false, |o| o.contains_key("reload_runtime")),
.is_some_and(|o| o.contains_key("reload_runtime")),
"plugins list should not include reload_runtime"
);
assert!(
!plugins
.as_object()
.map_or(false, |o| o.contains_key("target")),
.is_some_and(|o| o.contains_key("target")),
"plugins list should not include target"
);
// #703: structured summary replaces prose message
@ -1706,13 +1705,13 @@ fn resumed_inventory_commands_emit_structured_json_when_requested() {
assert!(
!plugins
.as_object()
.map_or(false, |o| o.contains_key("reload_runtime")),
.is_some_and(|o| o.contains_key("reload_runtime")),
"plugins list should not include reload_runtime"
);
assert!(
!plugins
.as_object()
.map_or(false, |o| o.contains_key("target")),
.is_some_and(|o| o.contains_key("target")),
"plugins list should not include target"
);
assert!(
@ -2252,7 +2251,7 @@ fn config_json_attributes_precedence_and_shadowed_keys_425() {
fs::create_dir_all(&home).expect("home should exist");
fs::write(
root.join(".claw.json"),
r#"{"model":"anthropic/claude-sonnet-4-6","env":{"A":"legacy","B":"legacy"}}"#,
r#"{"model":"anthropic/claude-sonnet-5","env":{"A":"legacy","B":"legacy"}}"#,
)
.expect("legacy project config fixture should write");
fs::write(
@ -2945,7 +2944,7 @@ fn prompt_empty_arg_json_stdout_missing_prompt_823() {
"claw prompt empty arg must retain abort action (#823); got: {parsed}"
);
assert!(
parsed["hint"].as_str().map_or(false, |h| !h.is_empty()),
parsed["hint"].as_str().is_some_and(|h| !h.is_empty()),
"claw prompt empty arg missing_prompt hint must be non-empty (#823)"
);
}
@ -2983,9 +2982,9 @@ fn flag_value_errors_have_error_kind_and_hint_756() {
"invalid --reasoning-effort must be invalid_flag_value (#756): {parsed}"
);
assert!(
parsed["hint"].as_str().map_or(false, |h| h.contains("low")
|| h.contains("medium")
|| h.contains("high")),
parsed["hint"]
.as_str()
.is_some_and(|h| h.contains("low") || h.contains("medium") || h.contains("high")),
"hint must mention valid values (#756): {parsed}"
);
@ -3011,7 +3010,7 @@ fn flag_value_errors_have_error_kind_and_hint_756() {
"missing --model value must be missing_flag_value (#756): {parsed2}"
);
assert!(
parsed2["hint"].as_str().map_or(false, |h| !h.is_empty()),
parsed2["hint"].as_str().is_some_and(|h| !h.is_empty()),
"missing --model hint must be non-empty (#756): {parsed2}"
);
}
@ -3255,7 +3254,7 @@ fn short_p_flag_swallows_no_flags_755() {
"flag-like token after -p must be rejected as missing_prompt (#755): {parsed2}"
);
assert!(
parsed2["hint"].as_str().map_or(false, |h| !h.is_empty()),
parsed2["hint"].as_str().is_some_and(|h| !h.is_empty()),
"missing_prompt hint must be non-empty (#755)"
);
}
@ -3397,7 +3396,7 @@ fn config_unsupported_section_json_hint_741() {
assert!(
parsed["supported_sections"]
.as_array()
.map_or(false, |a| !a.is_empty()),
.is_some_and(|a| !a.is_empty()),
"config {section} JSON must include supported_sections (#741)"
);
}
@ -5360,14 +5359,15 @@ fn agents_create_scaffolds_toml_and_lists_locally_431() {
assert_json_command_with_env(&root, &["--output-format", "json", "agents", "list"], &envs);
assert_eq!(list["kind"], "agents");
assert_eq!(list["action"], "list");
let canonical_agent_path = fs::canonicalize(&agent_path).expect("canonical listed agent path");
assert!(list["agents"]
.as_array()
.expect("agents array")
.iter()
.any(|agent| {
agent["name"] == "my-agent"
&& PathBuf::from(agent["path"].as_str().expect("listed agent path"))
== fs::canonicalize(&agent_path).expect("canonical listed agent path")
&& Path::new(agent["path"].as_str().expect("listed agent path"))
== canonical_agent_path.as_path()
}));
}

View File

@ -108,7 +108,7 @@ fn status_command_applies_cli_flags_end_to_end() {
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
assert!(stdout.contains("Status"));
assert!(stdout.contains("Model anthropic/claude-sonnet-4-6"));
assert!(stdout.contains("Model anthropic/claude-sonnet-5"));
assert!(stdout.contains("Permission mode read-only"));
}
@ -335,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"], "workspace-write");
assert_eq!(parsed["permission_mode"], "manual");
assert_eq!(parsed["usage"]["messages"], 1);
assert!(parsed["usage"]["turns"].is_number());
assert!(parsed["workspace"]["cwd"].as_str().is_some());
@ -356,7 +356,7 @@ fn resumed_status_surfaces_persisted_model() {
let session_path = temp_dir.join("session.jsonl");
let mut session = workspace_session(&temp_dir);
session.model = Some("anthropic/claude-sonnet-4-6".to_string());
session.model = Some("anthropic/claude-sonnet-5".to_string());
session
.push_user_text("model persistence fixture")
.expect("write ok");
@ -384,7 +384,7 @@ fn resumed_status_surfaces_persisted_model() {
let parsed: Value = serde_json::from_str(stdout.trim()).expect("should be json");
assert_eq!(parsed["kind"], "status");
assert_eq!(
parsed["model"], "anthropic/claude-sonnet-4-6",
parsed["model"], "anthropic/claude-sonnet-5",
"model should round-trip through session metadata"
);
}

View File

@ -107,6 +107,9 @@ mod tests {
description: "Test".to_string(),
subagent_type: None,
model: None,
effort: None,
run_in_background: true,
isolation: None,
status: "Finished".to_string(),
output_file: "/tmp/test.output".to_string(),
manifest_file: "/tmp/test.manifest".to_string(),

File diff suppressed because it is too large Load Diff