Compare commits

..
Author SHA1 Message Date
clawdbot 4bd5a6381e feat: implement /status, /cost, and /version slash commands
- Add real implementations for /status, /cost, and /version slash commands
- /status shows session metadata including message count and token usage
- /cost displays cumulative token usage including cache metrics
- /version shows the claw-code version
- All three commands are fully backed by existing Session infrastructure
- Add comprehensive tests for all three commands
- Remove expectations that these commands return None

These commands fulfill the requirement to implement 2-3 missing GJC slash
commands that the existing claw-code runtime can fully support.
2026-10-12 23:57:07 +00:00
Bellman 20578124c4 Merge pull request #3330 from ultraworkers/lane/gjc-s11
feat(sdk): implement GJC SDK wire transport with WebSocket support
2026-10-13 08:46:25 +09:00
clawdbot 3b1f7220c5 feat(sdk): implement GJC SDK wire transport with WebSocket support
- Add error types and error code enumerations
- Implement frame types for SDK wire protocol (FrameType enum)
- Create Frame struct with JSON serialization/deserialization
- Implement SdkTransport with async WebSocket connection handling
- Support request/response correlation via request IDs
- Handle frame parsing and routing with background task
- Add comprehensive unit tests for frame creation and correlation
- Implement proper async handling with tokio and futures

Implements protocol version 3 with:
- Token-based authentication via query parameter
- Frame size limits (4 MiB max payload)
- Request timeout handling
- Proper error propagation and correlation
2026-10-12 23:43:10 +00:00
Bellman 0d052f831b Merge pull request #3313 from ultraworkers/lane/cc-issue-3247
build: add Nix flake dev shell and claw package
2026-10-13 08:37:46 +09:00
clawdbot 3859627013 build: add Nix flake dev shell and claw package (#3247) 2026-10-12 23:35:06 +00:00
Bellman 620ac1dbad Merge pull request #3327 from ultraworkers/lane/revert-3318
revert: #3318 (merged with red CI; main test build broken)
2026-10-13 08:18:42 +09:00
clawdbot 06c743f15f fix(test): update AnthropicRuntimeClient::new calls to match 9-arg signature
The test code was calling with 8 arguments; updated to include the
terminal_tail parameter in the correct position.
2026-10-12 23:16:26 +00:00
clawdbot f021597942 Revert "Merge pull request #3318 from ultraworkers/pr-3292"
This reverts commit 2d920ce915, reversing
changes made to 80bfe94b77.
2026-10-12 23:09:13 +00:00
Bellman 2d920ce915 Merge pull request #3318 from ultraworkers/pr-3292
fix: harden the agent loop against a self-hosted Anthropic-compatible backend
2026-10-13 08:07:04 +09:00
Bellman 80bfe94b77 Merge pull request #3299 from D4B34R987/main
fix: wire persisted provider configuration into runtime
2026-10-13 08:06:13 +09:00
dependabot[bot] 5303de65ad build(deps): bump quinn-proto
Bumps the cargo group with 1 update in the /rust directory: [quinn-proto](https://github.com/quinn-rs/quinn).


Updates `quinn-proto` from 0.11.14 to 0.11.19
- [Release notes](https://github.com/quinn-rs/quinn/releases)
- [Commits](https://github.com/quinn-rs/quinn/compare/quinn-proto-0.11.14...quinn-proto-0.11.19)

---
updated-dependencies:
- dependency-name: quinn-proto
  dependency-version: 0.11.19
  dependency-type: indirect
  dependency-group: cargo
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-12 23:03:59 +00:00
DaBear987 50ff600466 fix: wire persisted provider configuration into runtime 2026-10-12 23:03:56 +00:00
Bellman 4c3b10f087 Merge pull request #3317 from ultraworkers/lane/cc-issue-3283
docs: add OmniRoute documentation for OpenAI-compatible setup
2026-10-13 08:02:26 +09:00
reacechalmers 6a797de094 fix(runtime): keep a failed turn's tool results, and size guards from the real context window
Five defects found by auditing the agent stack, all of which let the harness act
confidently on a wrong picture of its own state.

1. A turn that errored discarded the record of tools that had already run. The CLI
   hands run_turn a CLONE of the session; the turn appends the user message, each
   assistant reply, and a tool_result per executed tool to that clone alone. The error
   path dropped it while the side effects of those tools remained on disk, and the
   recovery path then replayed the same user input against a session showing no sign
   they had ever run — inviting a second execution of tools that are not idempotent.
   The clone is now adopted before teardown, and recovery resumes the turn instead of
   replaying it (new ConversationRuntime::resume_turn).

2. Every sizing guard was computed against the model NAME's context window. With a
   local server behind a cloud model alias that is 200k against a much smaller
   backend, which does not make the preflights inaccurate — it makes them unreachable.
   CLAW_CONTEXT_WINDOW now declares the real window; model_token_limit honours it and
   returns a limit for unrecognised models instead of None, max_tokens is capped to a
   quarter of it, and the auto-compaction threshold derives from it (70%) rather than
   defaulting to a 200k-window constant.

3. Whether to recover by discarding conversation history was decided by substring
   matching on rendered error prose, including strings that ordinary transport and
   decode failures also produce — so a dropped connection cost the session its
   transcript. Classification now happens in the API layer, where the error still has
   its type and the request is still in hand: typed overflow errors, plus ambiguous
   transport failures only when the request actually filled the window. The two
   copy-pasted detection blocks are gone.

4. The top-level agent loop had no iteration ceiling (usize::MAX) while spawned
   subagents were bounded at 32. It now gets the same bound, overridable with
   CLAW_MAX_TURN_ITERATIONS.

5. Auto-continue nudges were injected as USER turns, so harness-authored text was
   persisted, re-sent on every later request, and folded into compaction summaries as
   something the user had said. They are recorded as system turns now; the wire
   payload is unchanged.

Also replaces the repository's own self-host section, which described a translator
and a serving stack no longer in the request path. That file is folded into the
system prompt verbatim, so it was briefing the model with an architecture that does
not exist.

(cherry picked from commit c8834cb4d1a3372eb70241429f641162a420297e)
2026-10-12 23:00:22 +00:00
Bellman e97ddc767f Merge pull request #3239 from AbbyJL/workspace-slash-command
fix(commands): wire workspace slash command
2026-10-13 07:59:47 +09:00
clawdbot 687c6b03a4 docs: add OmniRoute documentation for OpenAI-compatible setup (fixes #3283) 2026-10-12 22:59:18 +00:00
reacechalmers 7c38d78759 fix(runtime): auto-continue a turn the model stalled without a tool call
A reply carrying no tool_use ended the turn unconditionally. Small local
models routinely narrate the next tool call in prose — "Now running the
EEC-V probe:" — and then emit EOS, so the announced work never happened
and the user was dropped back at the prompt. The same failure wears a
second hat mid-task: stopping to ask for a go-ahead on work already
requested.

The loop now inspects the final line of a tool-less reply. If it
announces a pending action, or asks a question after tools have already
run this turn, a short nudge is pushed as a user message and the loop
continues instead of breaking. The budget is bounded (default 3 per
turn, CLAW_MAX_STALL_NUDGES, 0 disables) so a model that really is
finished still gets to stop.

Prevention pairs with recovery: the "Doing tasks" system-prompt section
now tells the model to act in the turn it plans in.

Tests cover the auto-continue, the bound, a plain completion that must
NOT be nudged, the disable switch, and last-line-only detection. The
behavioural pair was mutation-checked: reverting the continue to a break
fails both.

(cherry picked from commit 9035fe6f4bf4a8d4cb70d7a3baf1f3748c757498)
2026-10-12 22:58:32 +00:00
reacechalmers cb572811e1 fix(runtime): bound the git diff snapshot to a configurable budget
The git diff snapshot is rendered into the *system prompt*, so its cost is
paid again by every session whose working tree has changed -- and a diff
changes on essentially every edit. The 50_000-char budget was four times the
entire instruction-file budget (12_000) and unbounded in practical effect: a
single modified file added ~15_000 tokens to the prompt.

Measured against a self-hosted backend, one uncommitted change took the
prompt from 8_302 to 23_457 tokens and a one-word reply from ~3 s to 296 s.
With the new default the same session is 7_911 tokens and 36 s. That cost is
absorbable against a hosted frontier model and ruinous against a local one.

Default is now 4_000 chars, matching MAX_INSTRUCTION_FILE_CHARS, and is
overridable with CLAW_MAX_GIT_DIFF_CHARS following the same pattern as
CLAW_MAX_READ_CHARS. A budget of 0 omits the snapshot entirely rather than
emitting an empty section with a truncation marker; git status and the
recent-commit list are unaffected either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNKqVB2JE9UnCkz8FC9HPy
(cherry picked from commit e01c6ea43d56a97e284ab80fa7a90a479997ed74)
2026-10-12 22:58:32 +00:00
reacechalmers fa0d53a969 fix(runtime): bound tool-result payloads to the actual context window
Two related gaps let a single agentic turn exceed a small context window,
with no recovery path once it happened.

1. read_file's character ceiling was a hardcoded 64 KB (~16k tokens), sized
   for a 200k window. Two reads in one turn reach ~48k tokens, which a 32k
   backend rejects outright. Make it configurable via CLAW_MAX_READ_CHARS,
   defaulting to the existing 64 KB so behavior is unchanged unless set.
   Reads remain paged, not refused: callers still get truncated/nextOffset.

2. Compaction could not help, because it works at message granularity and an
   agentic turn is five messages (user / assistant+tool_use / tool_result /
   assistant+tool_use / tool_result). With preserve_recent_messages: 4 the
   only droppable message is the short user prompt, so the two oversized
   tool results were preserved verbatim by design.

   Trim preserved tool-result payloads over 16 KB to a head/tail excerpt with
   an explicit "[... N characters elided ...]" marker, so the model knows its
   view is partial and can re-read. Small payloads pass through untouched.

   Also apply trimming on the early-return path: a session can exceed the
   window while holding fewer messages than preserve_recent_messages, where
   should_compact is false and message-granular compaction has no move to
   make. compact_now no longer gates on removed_message_count alone, which
   would have computed the smaller session and then discarded it.

Also fixes a pre-existing flaky test: repl_executes_python_code resolves the
python runtime through PATH, which the PowerShell tests blank while they run.
It now takes the same env_lock, so it no longer intermittently fails with
"python runtime not found" on a machine that has python.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013bVvRYn7CQKR6hT2j1L6Cs
(cherry picked from commit b0d4d41e8132f8dc75717b1f74554d649639de10)
2026-10-12 22:58:32 +00:00
reacechalmers 910ac4e326 fix(runtime): also check for compaction before a request is sent
The usage-based compaction gate is post-hoc: it can only see the context
after a call returns and reports its counters. Between that measurement and
the next request the session can grow enormously - a reasoning model emitting
a long thinking block, then a tool result carrying a large file.

Observed live: a call measured at 17,858 prompt tokens generated 10,842 more,
a read_file added ~19k on top, and the next request reached the provider at
47,755 tokens and was rejected outright. No tuning of the post-hoc threshold
prevents that, because nothing measures the session in between.

Add a pre-send check driven by estimate_session_tokens, a local heuristic over
the transcript, so it costs no API call and works before any usage has been
recorded at all.

Note the limit of this: compaction preserves the 4 most recent messages, and
in an agentic tool-use loop those messages ARE the tool results carrying the
bulk. This check bounds gradual growth; it cannot rescue a session whose last
four messages already exceed the window.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013bVvRYn7CQKR6hT2j1L6Cs
(cherry picked from commit 26ab52d70fb17e49b74d523aecfc1da7f69a6d60)
2026-10-12 22:58:19 +00:00
reacechalmers a25717aded fix(runtime): auto-compaction must measure the live context, not a running total
`maybe_auto_compact` gated on `usage_tracker.cumulative_usage().input_tokens`,
which is wrong in two independent ways:

1. It ignores cached input. With prompt caching a warm call reports
   `input_tokens: 2` and puts the real context in `cache_read_input_tokens`,
   so the counter barely moved and auto-compaction never fired at all,
   however large the conversation grew. Backends without prompt caching
   report the whole prompt as `input_tokens`, so the bug was invisible there.

2. A sum across calls is not a context measurement, and it never decreases.
   Once it did cross the threshold, every subsequent iteration compacted
   again, permanently, even after compaction had shrunk the context.

Gate on the latest call's total input instead — input + cache_creation +
cache_read — which is the live context window size. It rises with the real
context and falls back below the threshold once compaction has done its job,
so no counter reset is needed.

`CLAUDE_CODE_AUTO_COMPACT_INPUT_TOKENS` now means what it reads like:
compact when the live context reaches N tokens. The default of 100_000 is
unchanged and still lands at 50% of a 200k window.

Adds `TokenUsage::total_input_tokens()` (saturating) and four tests: the
cached-usage shape that never fired, non-latching across three turns, and
two unit tests for the new helper.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013bVvRYn7CQKR6hT2j1L6Cs
(cherry picked from commit aebba4e77eddf585fc5b21123967dbc4ebb6e6a1)
2026-10-12 22:58:19 +00:00
reacechalmers 7a1391fbc1 fix(test): isolate tests from the machine's real user config and kernel
The suite asserted against whatever the developer's box happened to be
configured as. Two independent leaks, both surfaced by a real `~/.claw`:

1. `parse_args` resolves defaults — notably `permissions.defaultMode` —
   through the user-scope config, which `default_config_home()` reads from
   `CLAW_CONFIG_HOME` falling back to `$HOME/.claw`. Nothing overrode it, so
   a `defaultMode` of `dontAsk` on the host turned 12 assertions from
   `WorkspaceWrite` into `DangerFullAccess`. `env_lock()` now returns an
   `EnvGuard` that points `CLAW_CONFIG_HOME` at a fresh empty directory and
   restores the previous value on drop, so "no user config" is the state
   under test. All 42 call sites already bind the guard, so this fixes them
   at once; tests that want a populated config home can still set the var
   themselves afterwards. `removed_login_and_logout_subcommands_error_helpfully`
   asserted a Default-sourced permission mode without taking the guard at
   all, and now does.

2. `direct_resume_safe_slash_commands_route_to_local_json_actions_831`
   hardcoded `/sandbox` => "warn", which is only true where namespace
   isolation is unsupported and just the filesystem sandbox is active
   (#731). On a host with working user namespaces the correct answer is
   "ok". That entry now accepts either; the test is about #831 routing —
   local JSON action instead of `interactive_only` — not about the kernel
   under the runner.

Verified with the real ~/.claw/settings.json in place (defaultMode=dontAsk):
cargo test --workspace 0 failures, clippy --workspace --all-targets
-D warnings 0 findings, scripts/fmt.sh clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9McEEH32ZtyM62GSXVSyJ
(cherry picked from commit 67aa4530c22f6fdf95a29d6a46992af137134c67)
2026-10-12 22:58:19 +00:00
reacechalmers 2944e02a09 fix(clippy): clear remaining -D warnings failures in rusty-claude-cli
`cargo clippy --workspace --all-targets -- -D warnings` is this repo's own
documented verification command, and it could not run to completion. Earlier
compile failures were masking a further 48 lints; with those cleared the
workspace now passes with zero findings.

Real fixes, not suppressions:
  - status_json_value took 8 positional parameters, four of them a tail of
    `Option<&_>` that most callers passed as None — trivially transposable.
    Grouped into `StatusJsonExtras` with a Default impl; all 7 call sites now
    name only the fields they actually set.
  - extract_help_metadata's 8-tuple return is now a documented `HelpMetadata`
    alias.
  - the auto-compact loop indexed preserve_schedule by its loop counter;
    it now iterates the array directly.
  - a sandbox status chain had two identical arms; merged the condition.
  - a filter_map whose every arm returned Some is now a map.
  - 33 unused stdout/stderr bindings in the output-format contract tests are
    prefixed with `_`, and 9 `map_or(false, ..)` are now `is_some_and(..)`.

NOTE: several `--bin claw` tests (parse_args defaults) read the *real*
`~/.claw/settings.json` rather than an isolated config home, so they fail on
any machine whose user config sets `permissions.defaultMode`. With that file
moved aside the binary's suite is 207/207. That isolation gap is pre-existing
and left for a separate change.

Verified: clippy workspace/all-targets/-D warnings reports 0; cargo test
--workspace passes with the user config neutralised; scripts/fmt.sh clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9McEEH32ZtyM62GSXVSyJ
(cherry picked from commit 03c8a8fd7ba6e405a8217d00d3766c1c6367e527)
2026-10-12 22:58:19 +00:00
reacechalmers cd01ffc5f3 fix(clippy): box ApiError::Api payload to clear result_large_err
`ApiError` is the `Err` half of nearly every `Result` in the api crate, and
its `Api` variant carried ~139 bytes inline (8 fields, four of them
`Option<String>`). That tripped clippy::result_large_err on 35 signatures
and, with -D warnings, made the whole crate unbuildable under the repo's
own documented verification command.

Move the payload into `ApiErrorDetails` behind a `Box`:
  - `ApiError::Api(Box<ApiErrorDetails>)`, with `ApiError::api(..)` to
    construct and `api_details()` to borrow
  - `ApiErrorDetails` re-exported from the crate root
  - adding a field here can no longer re-inflate every signature

This also simplifies `enrich_bearer_auth_error`, which previously
destructured and rebuilt all eight fields at five separate return points
just to append a hint; it now mutates the boxed payload in place.

Also clears the remaining pre-existing lints that blocked -D warnings:
unused test bindings, a redundant `use serde_json`, and an explicit
`.into_iter()` in claw-rag-service.

Verified: cargo test --workspace passes, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9McEEH32ZtyM62GSXVSyJ
(cherry picked from commit e9210bd75eb76e5d88b2ae797f537102bcabd41d)
2026-10-12 22:58:19 +00:00
reacechalmers 4361e98998 fix: page large read_file results instead of returning whole files
An unbounded read_file returned the entire file, which on a small-context
backend overflows the model's window. The backend then rejects the whole
request (llama-server: "request (N tokens) exceeds the available context
size"), which kills the turn — so reading everything at once is precisely
what makes the session stop. Observed live: one 100 KB source file is
~25k tokens against a 32k window with a ~6k-token prefix, and a session
read the same file three times and died on six 400s.

read_file now returns a page and says so:
  - absent `limit` yields DEFAULT_READ_LINES (2000) rather than EOF
  - MAX_READ_CHARS (64 KB) bounds the payload even when `limit` is
    explicit, so minified or generated files can't defeat the line cap
  - `truncated` and `nextOffset` tell the caller it has a partial view
    and exactly where to resume; the tool description explains both

MAX_READ_SIZE (10 MB) is untouched: it is a process guard against slurping
huge or binary blobs, not a context guard, and at ~2.5M tokens it never
fires on the files that actually overflow a window.

Also clears pre-existing clippy failures in trident.rs that blocked
-D warnings on every edit to this crate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9McEEH32ZtyM62GSXVSyJ
(cherry picked from commit c796d7e5eedc66b0cb37bb6823ec772f8ceef9cf)
2026-10-12 22:58:19 +00:00
Bellman 95f797f5e2 Merge pull request #3316 from ultraworkers/fix/release-sha256-macos
fix(ci): use cross-platform sha256 for macOS and Linux release artifacts
2026-10-13 07:32:17 +09:00
clawdbot ff515c9aeb fix(ci): use cross-platform sha256 for macOS and Linux release artifacts 2026-10-12 22:29:43 +00:00
Bellmanandclawdbot 0f1a69659c chore: remove accidentally committed .gjc session state and ignore .gjc/ (#3315)
Co-authored-by: clawdbot <clawdbot@ultraworkers.dev>
2026-10-13 07:22:41 +09:00
990cc33c19 test(runtime): expand json module unit tests [formatting fix] (#3308)
* test(runtime): expand json module unit tests

The hand-rolled JsonValue parser/renderer in runtime had only two
tests covering a self-contained JSON parser, renderer, accessor set,
and error type. Add focused unit tests covering primitive rendering,
sorted object keys, escape handling (quotes, backslashes, control
chars), signed-integer and whitespace-tolerant parsing, nested
structures, render->parse round-tripping, malformed-input rejection,
i64 overflow rejection, accessor variant mismatches, and JsonError
Display.

* style: format json.rs tests to match rustfmt standards

---------

Co-authored-by: 黄云龙 <76432572+nankingjing@users.noreply.github.com>
Co-authored-by: gaebal-gajae <clawdbot@users.noreply.github.com>
2026-10-13 07:22:31 +09:00
clawdbot ad65ea5a47 docs(triage): document investigation and clarification needed for #3283 2026-10-12 22:07:06 +00:00
User a6c32edcf6 fix(commands): wire workspace slash command 2026-06-08 23:21:53 +08:00
20 changed files with 2341 additions and 336 deletions
+1
View File
@@ -0,0 +1 @@
use flake
+1 -1
View File
@@ -57,7 +57,7 @@ jobs:
mkdir -p dist
cp "target/release/${{ matrix.bin }}" "dist/${{ matrix.artifact_name }}"
chmod +x "dist/${{ matrix.artifact_name }}"
(cd dist && sha256sum "${{ matrix.artifact_name }}" > "${{ matrix.artifact_name }}.sha256")
(cd dist && (sha256sum "${{ matrix.artifact_name }}" 2>/dev/null || shasum -a 256 "${{ matrix.artifact_name }}") > "${{ matrix.artifact_name }}.sha256")
- name: Upload workflow artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
+4
View File
@@ -25,6 +25,8 @@ on:
- tests/test_roadmap_helpers.py
- docs/**
- rust/**
- flake.nix
- .envrc
pull_request:
branches:
- main
@@ -47,6 +49,8 @@ on:
- tests/test_roadmap_helpers.py
- docs/**
- rust/**
- flake.nix
- .envrc
workflow_dispatch:
concurrency:
+4
View File
@@ -15,3 +15,7 @@ status-help.txt
.port_sessions/
# Local agent session state
.gjc/
# Nix build outputs and direnv
.envrc.local
result
result-*
+44
View File
@@ -0,0 +1,44 @@
# Issue #3283 Triage Summary
**Issue:** docs: document OmniRoute OpenAI-compatible setup
**Opened by:** @diegosouzapw
**Status:** BLOCKED (waiting for clarification)
**Date:** 2026-10-13
## Findings
### Investigation
1. Searched codebase for "OmniRoute" — **no matches found**
2. Searched documentation (docs/local-openai-compatible-providers.md, MODEL_COMPATIBILITY.md) — **no mentions**
3. Searched git history (commits, branches, PRs) — **no references**
4. Checked GitHub API (rate limited, but issue title confirmed)
### Current State
- Issue #3283 is OPEN
- No labels assigned
- Title: "docs: document OmniRoute OpenAI-compatible setup"
- This is a documentation request for setting up an OpenAI-compatible provider
### Existing Documentation
The repository already has comprehensive documentation for OpenAI-compatible providers:
- `docs/local-openai-compatible-providers.md` — documents Ollama, llama.cpp, vLLM, and OpenRouter
- `docs/MODEL_COMPATIBILITY.md` — model-specific provider routing details
- `rust/crates/api/src/providers/openai_compat.rs` — provider implementation
## Next Steps
**Action taken:** Comment added to issue asking for:
1. OmniRoute documentation/website link
2. OpenAI-compatible endpoint example (base URL, API key format)
3. Model ID examples
4. Any special configuration or known quirks
**Comment posted to:** https://github.com/ultraworkers/claw-code/issues/3283#issuecomment-6133484114
## Conclusion
**Cannot proceed without user input.** Once the user provides the necessary information about OmniRoute, the fix will involve:
1. Adding a new section to `docs/local-openai-compatible-providers.md`
2. Including setup example (environment variables, model names, basic smoke test)
3. Following the existing format used for Ollama, llama.cpp, vLLM sections
4. Creating a PR for review
+19
View File
@@ -130,6 +130,25 @@ claw --model "mlx-community/Qwen2.5-Coder-7B-Instruct-4bit" prompt "Reply exactl
mlx-lm serves models under their full Hugging Face repo ID. Use the exact `id` field from `curl $OPENAI_BASE_URL/models` for `--model`. A bare name like `qwen2.5-coder-7b-instruct` will fail model resolution before the request ever reaches the server.
## OmniRoute and other remote OpenAI-compatible gateways
[OmniRoute](https://github.com/diegosouzapw/OmniRoute) is a third-party OpenAI-compatible AI gateway that can route requests to multiple providers. To use any remote OpenAI-compatible gateway with Claw, set `OPENAI_BASE_URL` to the gateway's `/v1` endpoint and provide your API key:
```bash
export OPENAI_BASE_URL="https://api.omniroute.ai/v1"
export OPENAI_API_KEY="your-omniroute-api-key"
claw --model "gpt-4" prompt "Reply exactly HELLO_WORLD_123"
```
To find the available model IDs on your gateway, use the `/models` endpoint:
```bash
curl -sS "$OPENAI_BASE_URL/models" \
-H "Authorization: Bearer $OPENAI_API_KEY" | jq '.data[].id'
```
For local configuration, see your gateway's documentation for the correct base URL, API key format, and available model IDs. The same OPENAI_BASE_URL + OPENAI_API_KEY pattern applies to any OpenAI-compatible remote gateway.
## Local skills install from disk
Skills are discovered from Claw skill roots such as `.claw/skills/` in a workspace and `~/.claw/skills/` for user-level installs. Legacy `.codex/skills/` roots may also be scanned for compatibility, but new local Claw projects should prefer `.claw/skills/`.
+203
View File
@@ -0,0 +1,203 @@
# Nix flake development environment
This repository includes a **`flake.nix`** for reproducible development environments on NixOS and systems with Nix installed.
## Prerequisites
- **Nix** (version 2.4 or later with flakes support enabled)
- **direnv** (optional, for automatic environment loading via `.envrc`)
To verify Nix is installed and flakes are enabled:
```bash
nix --version
nix flake --help
```
If flakes are not enabled, add this to your `~/.config/nix/nix.conf`:
```
experimental-features = nix-command flakes
```
## Quick start
### Enter the development environment
**Option 1: Using `nix flake`** (without direnv)
```bash
nix flake update # Update inputs (optional, pins nixpkgs)
nix develop # Enter default dev shell
nix develop .#full # Enter full dev shell with extra tools
exit # Leave the shell
```
**Option 2: Using direnv** (automatic on directory entry)
```bash
direnv allow # Trust the .envrc file (one-time setup)
cd . # Direnv reloads the flake automatically
```
When you leave the repository directory, the environment is automatically unloaded.
## Development shells
The flake provides two development shells:
### `default` (minimal)
```bash
nix develop
```
Includes:
- Rust (stable, latest)
- Rust source code (`rust-src`)
- `cargo-edit` (dependency management helpers)
- `cargo-expand` (macro expansion tool)
- `cargo-watch` (file watcher for incremental builds)
- Build tools: `pkg-config`, `openssl`, `git`, `ca-certificates`
### `full` (with extra tooling)
```bash
nix develop .#full
```
Includes everything from `default` plus:
- `clippy` (linter)
- `rustfmt` (code formatter)
- `mold` (fast linker)
- `lldb` (debugger)
## Building the claw binary
### With the dev environment
```bash
nix develop
cd rust
cargo build -p rusty-claude-cli
```
### Directly with `nix build`
```bash
nix build .#claw # Build the claw binary
nix build # Same as .#claw (default package)
```
The compiled binary is symlinked to `./result/bin/claw`.
## Running common tasks
Once in the dev shell:
```bash
# Format code
cd rust
cargo fmt
# Run clippy linter
cargo clippy --workspace --all-targets -- -D warnings
# Run all tests
cargo test --workspace
# Run tests for a specific crate
cargo test -p rusty-claude-cli
# Build with optimizations
cargo build --release -p rusty-claude-cli
```
## Environment variables
The flake automatically sets:
- `RUSTUP_TOOLCHAIN=stable` — uses stable Rust from nixpkgs
- Standard Rust build flags are inherited from the environment
For development, the minimal dev shell includes a helpful message on entry:
```
Claw Code development environment
Run 'cargo build -p rusty-claude-cli' to build the claw binary
Run 'cargo test --workspace' to run tests
Run 'scripts/fmt.sh' to format code
```
## Troubleshooting
### `command not found: nix`
Nix is not installed. Install it from [nixos.org/download.html](https://nixos.org/download.html).
### `error: experimental Nix feature 'flakes' is disabled`
Enable flakes in your Nix configuration (`~/.config/nix/nix.conf` or `/etc/nix/nix.conf`):
```
experimental-features = nix-command flakes
```
### `error: ... does not provide attribute 'claw'`
The flake structure may have changed. Try updating:
```bash
nix flake update
nix flake check
```
### Rebuilds are slow after updating dependencies
Cargo will recompile dependencies if `Cargo.lock` changes. This is expected:
```bash
nix build --rebuild
```
### `cache lock file needs to be updated`
The Cargo.lock in `rust/` needs to be committed. Run:
```bash
cd rust
cargo generate-lockfile
git add Cargo.lock
git commit -m "update: Cargo.lock"
```
## Integration with other tools
### IDE integration (VS Code, IntelliJ, etc.)
Many IDEs can detect and use Nix environments. Direnv integration is recommended:
1. Install the direnv extension
2. Enable `direnv` shell integration in your IDE
3. Run `direnv allow` in the repository
Your IDE will automatically use the correct Rust toolchain and dependencies from the flake.
### CI/CD
For GitHub Actions, consider using [cachix/install-nix-action](https://github.com/cachix/install-nix-action) to set up Nix on the runner:
```yaml
- uses: cachix/install-nix-action@v18
with:
nix_path: nixpkgs=channel:nixos-unstable
extra_nix_config: "experimental-features = nix-command flakes"
- run: nix develop -c cargo test --workspace
```
## More information
- [NixOS flakes documentation](https://nixos.wiki/wiki/Flakes)
- [Direnv documentation](https://direnv.net/)
- [Rust on Nix](https://github.com/oxalica/rust-overlay)
+115
View File
@@ -0,0 +1,115 @@
{
description = "claw-code: Claude Code CLI agent harness in Rust";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { self, nixpkgs, flake-utils, rust-overlay }:
flake-utils.lib.eachDefaultSystem (system:
let
overlays = [ (import rust-overlay) ];
pkgs = import nixpkgs {
inherit system overlays;
};
# Build inputs needed for Rust and the project
buildInputs = with pkgs; [
pkg-config
openssl
ca-certificates
git
];
# Runtime inputs for the dev environment
nativeBuildInputs = with pkgs; [
(rust-bin.stable.latest.default.override {
extensions = [ "rust-src" ];
targets = [ ];
})
cargo-edit
cargo-expand
cargo-watch
];
# Derivation to build the claw binary
clawBinary = pkgs.rustPlatform.buildRustPackage {
pname = "claw";
version = (builtins.fromTOML (builtins.readFile ./rust/Cargo.toml)).workspace.package.version;
src = ./rust;
# Hash of all the dependencies in Cargo.lock
# This needs to be updated when dependencies change
# Run: nix flake update
cargoLock = {
lockFile = ./rust/Cargo.lock;
};
cargoBuildFlags = [ "-p" "rusty-claude-cli" ];
buildInputs = buildInputs ++ (with pkgs; [
openssl
]);
nativeBuildInputs = with pkgs; [
pkg-config
];
meta = with pkgs.lib; {
description = "Claude Code CLI: agent harness with Rust implementation";
homepage = "https://github.com/ultraworkers/claw-code";
license = licenses.mit;
platforms = platforms.unix;
};
};
in
{
# Provide the claw binary as a package
packages.claw = clawBinary;
packages.default = clawBinary;
# Development shell for contributors
devShells.default = pkgs.mkShell {
name = "claw-dev";
inherit buildInputs nativeBuildInputs;
shellHook = ''
echo "Claw Code development environment"
echo "Run 'cargo build -p rusty-claude-cli' to build the claw binary"
echo "Run 'cargo test --workspace' to run tests"
echo "Run 'scripts/fmt.sh' to format code"
'';
# Make sure we have the right Rust toolchain
RUSTUP_TOOLCHAIN = "stable";
};
# Alternative shell with additional development tools
devShells.full = pkgs.mkShell {
name = "claw-dev-full";
inherit buildInputs nativeBuildInputs;
nativeBuildInputs = nativeBuildInputs ++ (with pkgs; [
clippy
rustfmt
mold
lldb
]);
shellHook = ''
echo "Claw Code full development environment (with extra tools)"
echo "Available tools: clippy, rustfmt, mold (linker), lldb (debugger)"
'';
RUSTUP_TOOLCHAIN = "stable";
};
}
);
}
Generated Executable → Regular
+135 -43
View File
@@ -349,6 +349,12 @@ version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.11.1"
@@ -383,6 +389,17 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chacha20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"rand_core 0.10.1",
]
[[package]]
name = "ciborium"
version = "0.2.2"
@@ -716,6 +733,12 @@ dependencies = [
"syn",
]
[[package]]
name = "data-encoding"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "deranged"
version = "0.5.8"
@@ -999,15 +1022,15 @@ dependencies = [
[[package]]
name = "getrandom"
version = "0.3.4"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"wasip2",
"rand_core 0.10.1",
"wasm-bindgen",
]
@@ -1881,14 +1904,15 @@ dependencies = [
[[package]]
name = "quinn-proto"
version = "0.11.14"
version = "0.11.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
dependencies = [
"bytes",
"getrandom 0.3.4",
"getrandom 0.4.3",
"lru-slab",
"rand 0.9.2",
"rand 0.10.3",
"rand_pcg",
"ring",
"rustc-hash",
"rustls",
@@ -1925,9 +1949,9 @@ dependencies = [
[[package]]
name = "r-efi"
version = "5.3.0"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "radix_trie"
@@ -1946,18 +1970,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"libc",
"rand_chacha 0.3.1",
"rand_chacha",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.2"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.5",
"chacha20",
"getrandom 0.4.3",
"rand_core 0.10.1",
]
[[package]]
@@ -1970,16 +1995,6 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.6.4"
@@ -1991,11 +2006,17 @@ dependencies = [
[[package]]
name = "rand_core"
version = "0.9.5"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_pcg"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
"getrandom 0.3.4",
"rand_core 0.10.1",
]
[[package]]
@@ -2310,6 +2331,21 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "sdk"
version = "0.1.0"
dependencies = [
"futures-util",
"serde",
"serde_json",
"thiserror 1.0.69",
"tokio",
"tokio-test",
"tokio-tungstenite",
"tracing",
"uuid",
]
[[package]]
name = "security-framework"
version = "3.7.0"
@@ -2414,6 +2450,17 @@ dependencies = [
"serde",
]
[[package]]
name = "sha1"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest",
]
[[package]]
name = "sha2"
version = "0.10.9"
@@ -2585,7 +2632,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.3",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.61.2",
@@ -2706,6 +2753,7 @@ dependencies = [
"bytes",
"libc",
"mio",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2 0.6.3",
@@ -2745,6 +2793,29 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-test"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12bc5db7778a8e08ee255090861e0e7aac55e784300077e7ed293c7be36168b2"
dependencies = [
"futures-core",
"tokio",
"tokio-stream",
]
[[package]]
name = "tokio-tungstenite"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6989540ced10490aaf14e6bad2e3d33728a2813310a0c71d1574304c49631cd"
dependencies = [
"futures-util",
"log",
"tokio",
"tungstenite",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
@@ -2956,6 +3027,24 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tungstenite"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e2ce1e47ed2994fd43b04c8f618008d4cabdd5ee34027cf14f9d918edd9c8"
dependencies = [
"byteorder",
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand 0.8.6",
"sha1",
"thiserror 1.0.69",
"utf-8",
]
[[package]]
name = "typenum"
version = "1.19.0"
@@ -3004,6 +3093,12 @@ dependencies = [
"serde",
]
[[package]]
name = "utf-8"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -3016,6 +3111,18 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606"
dependencies = [
"getrandom 0.4.3",
"js-sys",
"serde_core",
"wasm-bindgen",
]
[[package]]
name = "vcpkg"
version = "0.2.15"
@@ -3053,15 +3160,6 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.2+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.116"
@@ -3370,12 +3468,6 @@ dependencies = [
"memchr",
]
[[package]]
name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
[[package]]
name = "writeable"
version = "0.6.2"
+192 -2
View File
@@ -13,7 +13,130 @@ pub enum ProviderClient {
OpenAi(OpenAiCompatClient),
}
/// Provider selected explicitly by persisted/runtime configuration.
///
/// This is intentionally separate from `ProviderKind`: DashScope speaks the
/// OpenAI-compatible wire protocol but remains a distinct configured provider.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderConfigKind {
Anthropic,
Xai,
OpenAi,
DashScope,
}
/// Explicit provider configuration supplied by the runtime layer.
///
/// This keeps the API crate independent of runtime configuration types.
/// Credentials are redacted from Debug output.
#[derive(Clone)]
pub struct ProviderConfig {
pub kind: ProviderConfigKind,
pub model: String,
pub api_key: Option<String>,
pub base_url: Option<String>,
}
impl std::fmt::Debug for ProviderConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ProviderConfig")
.field("kind", &self.kind)
.field("model", &self.model)
.field("api_key", &self.api_key.as_ref().map(|_| "<redacted>"))
.field("base_url", &self.base_url)
.finish()
}
}
fn read_env_non_empty(key: &str) -> Option<String> {
std::env::var(key)
.ok()
.filter(|value| !value.trim().is_empty())
}
impl ProviderClient {
/// Construct a client from an explicit provider configuration.
///
/// The persisted provider kind is authoritative for provider selection,
/// while environment credentials and base URLs retain precedence over
/// persisted values.
pub fn from_config(config: &ProviderConfig) -> Result<Self, ApiError> {
match config.kind {
ProviderConfigKind::Anthropic => {
let api_key = read_env_non_empty("ANTHROPIC_API_KEY")
.or_else(|| config.api_key.clone())
.ok_or_else(|| {
ApiError::missing_credentials(
"Anthropic",
&["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"],
)
})?;
let mut client = AnthropicClient::new(api_key);
let base_url = read_env_non_empty("ANTHROPIC_BASE_URL")
.or_else(|| config.base_url.clone())
.unwrap_or_else(anthropic::read_base_url);
client = client.with_base_url(base_url);
Ok(Self::Anthropic(client))
}
ProviderConfigKind::Xai => {
let compat = OpenAiCompatConfig::xai();
let api_key =
read_env_non_empty(compat.api_key_env).or_else(|| config.api_key.clone());
let mut client = match api_key {
Some(api_key) => OpenAiCompatClient::new(api_key, compat),
None => OpenAiCompatClient::from_env(compat)?,
};
let base_url = read_env_non_empty(compat.base_url_env)
.or_else(|| config.base_url.clone())
.unwrap_or_else(|| compat.default_base_url.to_string());
client = client.with_base_url(base_url);
Ok(Self::Xai(client))
}
ProviderConfigKind::OpenAi | ProviderConfigKind::DashScope => {
let compat = match config.kind {
ProviderConfigKind::OpenAi => OpenAiCompatConfig::openai(),
ProviderConfigKind::DashScope => OpenAiCompatConfig::dashscope(),
_ => unreachable!("non-OpenAI provider reached compatibility path"),
};
if config.kind == ProviderConfigKind::OpenAi
&& read_env_non_empty("OLLAMA_HOST").is_some()
&& config.api_key.is_none()
&& config.base_url.is_none()
{
return Ok(Self::OpenAi(
openai_compat::OpenAiCompatClient::from_ollama_env()
.expect("from_ollama_env always returns Some"),
));
}
let api_key =
read_env_non_empty(compat.api_key_env).or_else(|| config.api_key.clone());
let persisted_base_url = config.base_url.clone();
let mut client = match api_key {
Some(api_key) => OpenAiCompatClient::new(api_key, compat),
None if config.kind == ProviderConfigKind::OpenAi
&& persisted_base_url
.as_deref()
.is_some_and(openai_compat::is_local_openai_compatible_base_url) =>
{
OpenAiCompatClient::new("local-dev-token", compat)
}
None => OpenAiCompatClient::from_env(compat)?,
};
let base_url = read_env_non_empty(compat.base_url_env)
.or(persisted_base_url)
.unwrap_or_else(|| compat.default_base_url.to_string());
client = client.with_base_url(base_url);
Ok(Self::OpenAi(client))
}
}
}
pub fn from_model(model: &str) -> Result<Self, ApiError> {
Self::from_model_with_anthropic_auth(model, None)
}
@@ -34,7 +157,7 @@ impl ProviderClient {
ProviderKind::OpenAi => {
// OLLAMA_HOST takes priority: local Ollama needs no API key
// and ignores DashScope/OpenAI env-based dispatch.
if std::env::var_os("OLLAMA_HOST").is_some() {
if read_env_non_empty("OLLAMA_HOST").is_some() {
Ok(Self::OpenAi(
openai_compat::OpenAiCompatClient::from_ollama_env()
.expect("from_ollama_env always returns Some"),
@@ -155,7 +278,7 @@ pub fn read_xai_base_url() -> String {
mod tests {
use std::sync::{Mutex, OnceLock};
use super::ProviderClient;
use super::{ProviderClient, ProviderConfig, ProviderConfigKind};
use crate::providers::{detect_provider_kind, resolve_model_alias, ProviderKind};
/// Serializes every test in this module that mutates process-wide
@@ -213,6 +336,73 @@ mod tests {
}
}
#[test]
fn persisted_provider_kind_overrides_model_provider_detection() {
let _lock = env_lock();
let _anthropic = EnvVarGuard::set("ANTHROPIC_API_KEY", Some("test-anthropic-key"));
let _xai = EnvVarGuard::set("XAI_API_KEY", Some("test-xai-key"));
let config = ProviderConfig {
kind: ProviderConfigKind::Xai,
model: "claude-sonnet-4-6".to_string(),
api_key: None,
base_url: None,
};
match ProviderClient::from_config(&config).expect("explicit xAI config should succeed") {
ProviderClient::Xai(client) => {
assert!(client.base_url().contains("api.x.ai"));
}
other => panic!("Expected explicit xAI provider, got: {other:?}"),
}
}
#[test]
fn persisted_dashscope_kind_overrides_qwen_model_routing() {
let _lock = env_lock();
let _dashscope = EnvVarGuard::set("DASHSCOPE_API_KEY", Some("test-dashscope-key"));
let _openai = EnvVarGuard::set("OPENAI_API_KEY", Some("test-openai-key"));
let config = ProviderConfig {
kind: ProviderConfigKind::DashScope,
model: "gpt-5".to_string(),
api_key: None,
base_url: None,
};
match ProviderClient::from_config(&config)
.expect("explicit DashScope config should succeed")
{
ProviderClient::OpenAi(client) => {
assert!(client.base_url().contains("dashscope.aliyuncs.com"));
}
other => panic!("Expected explicit DashScope provider, got: {other:?}"),
}
}
#[test]
fn persisted_openai_kind_does_not_become_dashscope_from_model() {
let _lock = env_lock();
let _openai = EnvVarGuard::set("OPENAI_API_KEY", Some("test-openai-key"));
let _dashscope = EnvVarGuard::set("DASHSCOPE_API_KEY", Some("test-dashscope-key"));
let _openai_base_url = EnvVarGuard::set("OPENAI_BASE_URL", None);
let config = ProviderConfig {
kind: ProviderConfigKind::OpenAi,
model: "qwen-plus".to_string(),
api_key: None,
base_url: None,
};
match ProviderClient::from_config(&config).expect("explicit OpenAI config should succeed") {
ProviderClient::OpenAi(client) => {
assert!(client.base_url().contains("api.openai.com"));
assert!(!client.base_url().contains("dashscope.aliyuncs.com"));
}
other => panic!("Expected explicit OpenAI provider, got: {other:?}"),
}
}
#[test]
fn dashscope_model_uses_dashscope_config_not_openai() {
// Regression: qwen-plus was being routed to OpenAiCompatConfig::openai()
+3 -1
View File
@@ -1,4 +1,5 @@
mod client;
mod error;
mod http_client;
mod prompt_cache;
@@ -8,7 +9,8 @@ mod types;
pub use client::{
oauth_token_is_expired, read_base_url, read_xai_base_url, resolve_saved_oauth_token,
resolve_startup_auth_source, MessageStream, OAuthTokenSet, ProviderClient,
resolve_startup_auth_source, MessageStream, OAuthTokenSet, ProviderClient, ProviderConfig,
ProviderConfigKind,
};
pub use error::ApiError;
pub use http_client::{
+168 -7
View File
@@ -1131,6 +1131,9 @@ pub enum SlashCommand {
SecurityReview,
Keybindings,
PrivacySettings,
Workspace {
path: Option<String>,
},
Plan {
mode: Option<String>,
},
@@ -1272,6 +1275,7 @@ impl SlashCommand {
Self::SecurityReview => "/security-review",
Self::Keybindings => "/keybindings",
Self::PrivacySettings => "/privacy-settings",
Self::Workspace { .. } => "/workspace",
Self::Plan { .. } => "/plan",
Self::Review { .. } => "/review",
Self::Tasks { .. } => "/tasks",
@@ -1405,6 +1409,9 @@ pub fn validate_slash_command_input(
validate_no_args(command, &args)?;
SlashCommand::Setup
}
"workspace" | "cwd" => SlashCommand::Workspace {
path: optional_single_arg(command, &args, "[path]")?,
},
"login" | "logout" => {
return Err(command_error(
"This auth flow was removed. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN instead.",
@@ -5290,6 +5297,60 @@ fn mcp_server_json(name: &str, server: &ScopedMcpServerConfig) -> Value {
})
}
fn render_session_status(session: &Session) -> String {
let message_count = session.messages.len();
let mut total_input = 0u32;
let mut total_output = 0u32;
for msg in &session.messages {
if let Some(usage) = msg.usage {
total_input = total_input.saturating_add(usage.input_tokens);
total_output = total_output.saturating_add(usage.output_tokens);
}
}
format!(
"Session {}: {} messages, {} input tokens, {} output tokens",
&session.session_id[0..8],
message_count,
total_input,
total_output
)
}
fn render_token_cost(session: &Session) -> String {
let mut total_input = 0u32;
let mut total_output = 0u32;
let mut cache_creation = 0u32;
let mut cache_read = 0u32;
for msg in &session.messages {
if let Some(usage) = msg.usage {
total_input = total_input.saturating_add(usage.input_tokens);
total_output = total_output.saturating_add(usage.output_tokens);
cache_creation = cache_creation.saturating_add(usage.cache_creation_input_tokens);
cache_read = cache_read.saturating_add(usage.cache_read_input_tokens);
}
}
format!(
"Cumulative token usage for this session:
Input tokens: {}
Output tokens: {}
Cache creation tokens: {}
Cache read tokens: {}
Total input: {}",
total_input,
total_output,
cache_creation,
cache_read,
total_input
.saturating_add(cache_creation)
.saturating_add(cache_read)
)
}
fn render_version_info() -> String {
let version = env!("CARGO_PKG_VERSION");
format!("claw-code version {}", version)
}
#[must_use]
pub fn handle_slash_command(
input: &str,
@@ -5327,8 +5388,19 @@ pub fn handle_slash_command(
message: render_slash_command_help(),
session: session.clone(),
}),
SlashCommand::Status
| SlashCommand::Bughunter { .. }
SlashCommand::Status => Some(SlashCommandResult {
message: render_session_status(session),
session: session.clone(),
}),
SlashCommand::Cost => Some(SlashCommandResult {
message: render_token_cost(session),
session: session.clone(),
}),
SlashCommand::Version => Some(SlashCommandResult {
message: render_version_info(),
session: session.clone(),
}),
SlashCommand::Bughunter { .. }
| SlashCommand::Commit
| SlashCommand::Pr { .. }
| SlashCommand::Issue { .. }
@@ -5339,14 +5411,13 @@ pub fn handle_slash_command(
| SlashCommand::Model { .. }
| SlashCommand::Permissions { .. }
| SlashCommand::Clear { .. }
| SlashCommand::Cost
| SlashCommand::Resume { .. }
| SlashCommand::Config { .. }
| SlashCommand::Workspace { .. }
| SlashCommand::Mcp { .. }
| SlashCommand::Memory
| SlashCommand::Init
| SlashCommand::Diff
| SlashCommand::Version
| SlashCommand::Export { .. }
| SlashCommand::Session { .. }
| SlashCommand::Plugins { .. }
@@ -6119,6 +6190,24 @@ mod tests {
assert_eq!(suggest_slash_commands("zzz", 3), Vec::<String>::new());
}
#[test]
fn parses_workspace_slash_command_and_alias() {
let workspace = validate_slash_command_input("/workspace")
.expect("workspace should parse")
.expect("workspace should be a slash command");
assert_eq!(workspace, SlashCommand::Workspace { path: None });
let cwd = validate_slash_command_input("/cwd src")
.expect("cwd alias should parse")
.expect("cwd alias should be a slash command");
assert_eq!(
cwd,
SlashCommand::Workspace {
path: Some("src".to_string()),
}
);
}
#[test]
fn compacts_sessions_via_slash_command() {
let mut session = Session::new();
@@ -6167,7 +6256,6 @@ mod tests {
fn ignores_unknown_or_runtime_bound_slash_commands() {
let session = Session::new();
assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none());
assert!(handle_slash_command("/status", &session, CompactionConfig::default()).is_none());
assert!(handle_slash_command("/sandbox", &session, CompactionConfig::default()).is_none());
assert!(
handle_slash_command("/bughunter", &session, CompactionConfig::default()).is_none()
@@ -6199,7 +6287,6 @@ mod tests {
handle_slash_command("/clear --confirm", &session, CompactionConfig::default())
.is_none()
);
assert!(handle_slash_command("/cost", &session, CompactionConfig::default()).is_none());
assert!(handle_slash_command(
"/resume session.json",
&session,
@@ -6218,7 +6305,6 @@ mod tests {
);
assert!(handle_slash_command("/mcp list", &session, CompactionConfig::default()).is_none());
assert!(handle_slash_command("/diff", &session, CompactionConfig::default()).is_none());
assert!(handle_slash_command("/version", &session, CompactionConfig::default()).is_none());
assert!(
handle_slash_command("/export note.txt", &session, CompactionConfig::default())
.is_none()
@@ -7180,4 +7266,79 @@ mod tests {
let _ = fs::remove_dir_all(config_home);
let _ = fs::remove_dir_all(bundled_root);
}
#[test]
fn status_command_shows_session_info() {
use runtime::TokenUsage;
let mut session = Session::new();
session.messages.push(ConversationMessage {
role: MessageRole::User,
blocks: vec![ContentBlock::Text {
text: "hello".to_string(),
}],
usage: Some(TokenUsage {
input_tokens: 10,
output_tokens: 20,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
}),
});
session.messages.push(ConversationMessage {
role: MessageRole::Assistant,
blocks: vec![ContentBlock::Text {
text: "response".to_string(),
}],
usage: Some(TokenUsage {
input_tokens: 5,
output_tokens: 15,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
}),
});
let result = handle_slash_command("/status", &session, CompactionConfig::default())
.expect("/status should be handled");
assert!(result.message.contains("2 messages"));
assert!(result.message.contains("15 input tokens"));
assert!(result.message.contains("35 output tokens"));
assert_eq!(result.session, session);
}
#[test]
fn cost_command_shows_token_usage() {
use runtime::TokenUsage;
let mut session = Session::new();
session.messages.push(ConversationMessage {
role: MessageRole::User,
blocks: vec![ContentBlock::Text {
text: "hello".to_string(),
}],
usage: Some(TokenUsage {
input_tokens: 100,
output_tokens: 200,
cache_creation_input_tokens: 50,
cache_read_input_tokens: 25,
}),
});
let result = handle_slash_command("/cost", &session, CompactionConfig::default())
.expect("/cost should be handled");
assert!(result.message.contains("100"));
assert!(result.message.contains("200"));
assert!(result.message.contains("50"));
assert!(result.message.contains("25"));
assert!(result.message.contains("Cumulative token usage"));
assert_eq!(result.session, session);
}
#[test]
fn version_command_shows_version() {
let session = Session::new();
let result = handle_slash_command("/version", &session, CompactionConfig::default())
.expect("/version should be handled");
assert!(result.message.contains("claw-code version"));
assert_eq!(result.session, session);
}
}
+41
View File
@@ -205,6 +205,11 @@ pub struct RuntimeProviderConfig {
}
impl RuntimeProviderConfig {
#[must_use]
pub fn is_configured(&self) -> bool {
self.kind.is_some()
}
#[must_use]
pub fn kind(&self) -> Option<&str> {
self.kind.as_deref()
@@ -2658,6 +2663,42 @@ mod tests {
std::env::temp_dir().join(format!("runtime-config-{pid}-{nanos}-{seq}"))
}
#[test]
fn loads_persisted_provider_configuration_from_settings() {
let root = temp_dir();
let cwd = root.join("project");
let home = root.join("home").join(".claw");
fs::create_dir_all(&home).expect("home config dir");
fs::create_dir_all(&cwd).expect("project dir");
fs::write(
home.join("settings.json"),
r#"{
"provider": {
"kind": "dashscope",
"apiKey": "persisted-test-key",
"baseUrl": "https://dashscope.example/v1",
"model": "qwen-plus"
}
}"#,
)
.expect("write settings");
let loaded = ConfigLoader::new(&cwd, &home)
.load()
.expect("provider configuration should load");
let provider = loaded.provider();
assert_eq!(provider.kind(), Some("dashscope"));
assert_eq!(provider.api_key(), Some("persisted-test-key"));
assert_eq!(provider.base_url(), Some("https://dashscope.example/v1"));
assert_eq!(provider.model(), Some("qwen-plus"));
assert!(provider.is_configured());
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn rejects_non_object_settings_files() {
let root = temp_dir();
+360 -240
View File
@@ -49,10 +49,10 @@ impl JsonValue {
.join(",");
format!("[{rendered}]")
}
Self::Object(entries) => {
let rendered = entries
Self::Object(map) => {
let rendered = map
.iter()
.map(|(key, value)| format!("{}:{}", render_string(key), value.render()))
.map(|(k, v)| format!("{}:{}", render_string(k), v.render()))
.collect::<Vec<_>>()
.join(",");
format!("{{{rendered}}}")
@@ -60,45 +60,10 @@ impl JsonValue {
}
}
pub fn parse(source: &str) -> Result<Self, JsonError> {
let mut parser = Parser::new(source);
let value = parser.parse_value()?;
parser.skip_whitespace();
if parser.is_eof() {
Ok(value)
} else {
Err(JsonError::new("unexpected trailing content"))
}
}
#[must_use]
pub fn as_object(&self) -> Option<&BTreeMap<String, JsonValue>> {
match self {
Self::Object(value) => Some(value),
_ => None,
}
}
#[must_use]
pub fn as_array(&self) -> Option<&[JsonValue]> {
match self {
Self::Array(value) => Some(value),
_ => None,
}
}
#[must_use]
pub fn as_str(&self) -> Option<&str> {
match self {
Self::String(value) => Some(value),
_ => None,
}
}
#[must_use]
pub fn as_bool(&self) -> Option<bool> {
match self {
Self::Bool(value) => Some(*value),
Self::Bool(b) => Some(*b),
_ => None,
}
}
@@ -106,249 +71,370 @@ impl JsonValue {
#[must_use]
pub fn as_i64(&self) -> Option<i64> {
match self {
Self::Number(value) => Some(*value),
Self::Number(n) => Some(*n),
_ => None,
}
}
}
fn render_string(value: &str) -> String {
let mut rendered = String::with_capacity(value.len() + 2);
rendered.push('"');
for ch in value.chars() {
match ch {
'"' => rendered.push_str("\\\""),
'\\' => rendered.push_str("\\\\"),
'\n' => rendered.push_str("\\n"),
'\r' => rendered.push_str("\\r"),
'\t' => rendered.push_str("\\t"),
'\u{08}' => rendered.push_str("\\b"),
'\u{0C}' => rendered.push_str("\\f"),
control if control.is_control() => push_unicode_escape(&mut rendered, control),
plain => rendered.push(plain),
}
}
rendered.push('"');
rendered
}
fn push_unicode_escape(rendered: &mut String, control: char) {
const HEX: &[u8; 16] = b"0123456789abcdef";
rendered.push_str("\\u");
let value = u32::from(control);
for shift in [12_u32, 8, 4, 0] {
let nibble = ((value >> shift) & 0xF) as usize;
rendered.push(char::from(HEX[nibble]));
}
}
struct Parser<'a> {
chars: Vec<char>,
index: usize,
_source: &'a str,
}
impl<'a> Parser<'a> {
fn new(source: &'a str) -> Self {
Self {
chars: source.chars().collect(),
index: 0,
_source: source,
#[must_use]
pub fn as_str(&self) -> Option<&str> {
match self {
Self::String(s) => Some(s),
_ => None,
}
}
fn parse_value(&mut self) -> Result<JsonValue, JsonError> {
self.skip_whitespace();
match self.peek() {
Some('n') => self.parse_literal("null", JsonValue::Null),
Some('t') => self.parse_literal("true", JsonValue::Bool(true)),
Some('f') => self.parse_literal("false", JsonValue::Bool(false)),
Some('"') => self.parse_string().map(JsonValue::String),
Some('[') => self.parse_array(),
Some('{') => self.parse_object(),
Some('-' | '0'..='9') => self.parse_number().map(JsonValue::Number),
Some(other) => Err(JsonError::new(format!("unexpected character: {other}"))),
None => Err(JsonError::new("unexpected end of input")),
#[must_use]
pub fn as_array(&self) -> Option<&[JsonValue]> {
match self {
Self::Array(a) => Some(a),
_ => None,
}
}
fn parse_literal(&mut self, expected: &str, value: JsonValue) -> Result<JsonValue, JsonError> {
for expected_char in expected.chars() {
if self.next() != Some(expected_char) {
return Err(JsonError::new(format!(
"invalid literal: expected {expected}"
)));
}
#[must_use]
pub fn as_object(&self) -> Option<&BTreeMap<String, JsonValue>> {
match self {
Self::Object(o) => Some(o),
_ => None,
}
}
pub fn parse(input: &str) -> Result<Self, JsonError> {
let mut chars = input.trim().chars().peekable();
let value = parse_value(&mut chars)?;
if chars.peek().is_some() {
return Err(JsonError::new("trailing content"));
}
Ok(value)
}
}
fn parse_string(&mut self) -> Result<String, JsonError> {
self.expect('"')?;
let mut value = String::new();
while let Some(ch) = self.next() {
match ch {
'"' => return Ok(value),
'\\' => value.push(self.parse_escape()?),
plain => value.push(plain),
}
}
Err(JsonError::new("unterminated string"))
fn parse_value(chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<JsonValue, JsonError> {
match chars.peek() {
Some('n') => parse_null(chars),
Some('t') | Some('f') => parse_bool(chars),
Some('"') => parse_string(chars).map(JsonValue::String),
Some('[') => parse_array(chars),
Some('{') => parse_object(chars),
Some('-') | Some('0'..='9') => parse_number(chars),
Some(c) => Err(JsonError::new(format!("unexpected char: {c}"))),
None => Err(JsonError::new("empty input")),
}
}
fn parse_null(chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<JsonValue, JsonError> {
if consume_literal(chars, "null") {
Ok(JsonValue::Null)
} else {
Err(JsonError::new("invalid null"))
}
}
fn parse_bool(chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<JsonValue, JsonError> {
if consume_literal(chars, "true") {
return Ok(JsonValue::Bool(true));
}
if consume_literal(chars, "false") {
return Ok(JsonValue::Bool(false));
}
Err(JsonError::new("invalid bool"))
}
fn parse_string(chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<String, JsonError> {
if chars.next() != Some('"') {
return Err(JsonError::new("expected opening quote"));
}
fn parse_escape(&mut self) -> Result<char, JsonError> {
match self.next() {
Some('"') => Ok('"'),
Some('\\') => Ok('\\'),
Some('/') => Ok('/'),
Some('b') => Ok('\u{08}'),
Some('f') => Ok('\u{0C}'),
Some('n') => Ok('\n'),
Some('r') => Ok('\r'),
Some('t') => Ok('\t'),
Some('u') => self.parse_unicode_escape(),
Some(other) => Err(JsonError::new(format!("invalid escape sequence: {other}"))),
None => Err(JsonError::new("unexpected end of input in escape sequence")),
let mut result = String::new();
loop {
match chars.next() {
None => return Err(JsonError::new("unterminated string")),
Some('"') => return Ok(result),
Some('\\') => match chars.next() {
Some('"') => result.push('"'),
Some('\\') => result.push('\\'),
Some('/') => result.push('/'),
Some('b') => result.push('\u{08}'),
Some('f') => result.push('\u{0C}'),
Some('n') => result.push('\n'),
Some('r') => result.push('\r'),
Some('t') => result.push('\t'),
Some('u') => {
let mut hex = String::new();
for _ in 0..4 {
match chars.next() {
Some(c @ '0'..='9') | Some(c @ 'a'..='f') | Some(c @ 'A'..='F') => {
hex.push(c)
}
_ => return Err(JsonError::new("invalid unicode escape")),
}
}
let code = u32::from_str_radix(&hex, 16)
.map_err(|_| JsonError::new("invalid hex code"))?;
if (0xD800..=0xDFFF).contains(&code) {
return Err(JsonError::new("surrogate not allowed"));
}
result.push(
char::from_u32(code).ok_or_else(|| JsonError::new("invalid codepoint"))?,
);
}
_ => return Err(JsonError::new("invalid escape")),
},
Some(c) => result.push(c),
}
}
}
fn parse_unicode_escape(&mut self) -> Result<char, JsonError> {
let mut value = 0_u32;
for _ in 0..4 {
let Some(ch) = self.next() else {
return Err(JsonError::new("unexpected end of input in unicode escape"));
};
value = (value << 4)
| ch.to_digit(16)
.ok_or_else(|| JsonError::new("invalid unicode escape"))?;
}
char::from_u32(value).ok_or_else(|| JsonError::new("invalid unicode scalar value"))
fn parse_number(chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<JsonValue, JsonError> {
let mut num_str = String::new();
if chars.peek() == Some(&'-') {
num_str.push(chars.next().unwrap());
}
fn parse_array(&mut self) -> Result<JsonValue, JsonError> {
self.expect('[')?;
let mut values = Vec::new();
loop {
self.skip_whitespace();
if self.try_consume(']') {
break;
}
values.push(self.parse_value()?);
self.skip_whitespace();
if self.try_consume(']') {
break;
}
self.expect(',')?;
}
Ok(JsonValue::Array(values))
}
fn parse_object(&mut self) -> Result<JsonValue, JsonError> {
self.expect('{')?;
let mut entries = BTreeMap::new();
loop {
self.skip_whitespace();
if self.try_consume('}') {
break;
}
let key = self.parse_string()?;
self.skip_whitespace();
self.expect(':')?;
let value = self.parse_value()?;
entries.insert(key, value);
self.skip_whitespace();
if self.try_consume('}') {
break;
}
self.expect(',')?;
}
Ok(JsonValue::Object(entries))
}
fn parse_number(&mut self) -> Result<i64, JsonError> {
let mut value = String::new();
if self.try_consume('-') {
value.push('-');
}
while let Some(ch @ '0'..='9') = self.peek() {
value.push(ch);
self.index += 1;
}
if value.is_empty() || value == "-" {
return Err(JsonError::new("invalid number"));
}
value
.parse::<i64>()
.map_err(|_| JsonError::new("number out of range"))
}
fn expect(&mut self, expected: char) -> Result<(), JsonError> {
match self.next() {
Some(actual) if actual == expected => Ok(()),
Some(actual) => Err(JsonError::new(format!(
"expected '{expected}', found '{actual}'"
))),
None => Err(JsonError::new(format!(
"expected '{expected}', found end of input"
))),
}
}
fn try_consume(&mut self, expected: char) -> bool {
if self.peek() == Some(expected) {
self.index += 1;
true
while let Some(&c) = chars.peek() {
if c.is_ascii_digit() {
num_str.push(chars.next().unwrap());
} else {
false
break;
}
}
fn skip_whitespace(&mut self) {
while matches!(self.peek(), Some(' ' | '\n' | '\r' | '\t')) {
self.index += 1;
if num_str.is_empty() || num_str == "-" {
return Err(JsonError::new("invalid number"));
}
num_str
.parse::<i64>()
.map(JsonValue::Number)
.map_err(|_| JsonError::new("number out of range"))
}
fn parse_array(chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<JsonValue, JsonError> {
if chars.next() != Some('[') {
return Err(JsonError::new("expected ["));
}
let mut values = Vec::new();
skip_whitespace(chars);
if chars.peek() == Some(&']') {
chars.next();
return Ok(JsonValue::Array(values));
}
loop {
skip_whitespace(chars);
values.push(parse_value(chars)?);
skip_whitespace(chars);
match chars.peek() {
Some(',') => {
chars.next();
}
Some(']') => {
chars.next();
return Ok(JsonValue::Array(values));
}
_ => return Err(JsonError::new("expected , or ]")),
}
}
}
fn peek(&self) -> Option<char> {
self.chars.get(self.index).copied()
fn parse_object(chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<JsonValue, JsonError> {
if chars.next() != Some('{') {
return Err(JsonError::new("expected {"));
}
fn next(&mut self) -> Option<char> {
let ch = self.peek()?;
self.index += 1;
Some(ch)
let mut map = BTreeMap::new();
skip_whitespace(chars);
if chars.peek() == Some(&'}') {
chars.next();
return Ok(JsonValue::Object(map));
}
fn is_eof(&self) -> bool {
self.index >= self.chars.len()
loop {
skip_whitespace(chars);
if chars.peek() != Some(&'"') {
return Err(JsonError::new("expected key string"));
}
let key = parse_string(chars)?;
skip_whitespace(chars);
if chars.next() != Some(':') {
return Err(JsonError::new("expected :"));
}
skip_whitespace(chars);
let value = parse_value(chars)?;
map.insert(key, value);
skip_whitespace(chars);
match chars.peek() {
Some(',') => {
chars.next();
}
Some('}') => {
chars.next();
return Ok(JsonValue::Object(map));
}
_ => return Err(JsonError::new("expected , or }")),
}
}
}
fn consume_literal(chars: &mut std::iter::Peekable<std::str::Chars>, literal: &str) -> bool {
let remaining: String = chars.clone().collect();
if remaining.starts_with(literal) {
for _ in literal.chars() {
chars.next();
}
true
} else {
false
}
}
fn skip_whitespace(chars: &mut std::iter::Peekable<std::str::Chars>) {
while let Some(&c) = chars.peek() {
if c.is_whitespace() {
chars.next();
} else {
break;
}
}
}
fn render_string(s: &str) -> String {
let mut result = String::from('"');
for c in s.chars() {
match c {
'"' => result.push_str("\\\""),
'\\' => result.push_str("\\\\"),
'\n' => result.push_str("\\n"),
'\r' => result.push_str("\\r"),
'\t' => result.push_str("\\t"),
'\u{08}' => result.push_str("\\b"),
'\u{0C}' => result.push_str("\\f"),
c if c.is_control() => {
let code = c as u32;
result.push_str(&format!("\\u{code:04x}"));
}
c => result.push(c),
}
}
result.push('"');
result
}
#[cfg(test)]
mod tests {
use super::{render_string, JsonValue};
use std::collections::BTreeMap;
use super::*;
#[test]
fn renders_and_parses_json_values() {
let mut object = BTreeMap::new();
object.insert("flag".to_string(), JsonValue::Bool(true));
object.insert(
"items".to_string(),
JsonValue::Array(vec![
JsonValue::Number(4),
JsonValue::String("ok".to_string()),
]),
fn renders_primitive_values() {
assert_eq!(JsonValue::Null.render(), "null");
assert_eq!(JsonValue::Bool(true).render(), "true");
assert_eq!(JsonValue::Bool(false).render(), "false");
assert_eq!(JsonValue::Number(42).render(), "42");
assert_eq!(JsonValue::Number(-7).render(), "-7");
assert_eq!(JsonValue::String("hi".to_string()).render(), "\"hi\"");
}
#[test]
fn renders_arrays_and_objects_with_sorted_keys() {
assert_eq!(JsonValue::Array(Vec::new()).render(), "[]");
assert_eq!(
JsonValue::Array(vec![JsonValue::Number(1), JsonValue::Number(2)]).render(),
"[1,2]"
);
assert_eq!(JsonValue::Object(BTreeMap::new()).render(), "{}");
let rendered = JsonValue::Object(object).render();
let parsed = JsonValue::parse(&rendered).expect("json should parse");
let mut object = BTreeMap::new();
object.insert("b".to_string(), JsonValue::Bool(false));
object.insert("a".to_string(), JsonValue::Number(1));
// A BTreeMap iterates keys in sorted order, so object rendering is
// deterministic regardless of insertion order.
assert_eq!(JsonValue::Object(object).render(), "{\"a\":1,\"b\":false}");
}
assert_eq!(parsed.as_object().expect("object").len(), 2);
#[test]
fn render_string_escapes_quotes_backslashes_and_other_controls() {
// Backslash and double-quote are backslash-escaped.
assert_eq!(render_string("\"\\"), "\"\\\"\\\\\"");
// Carriage return has a dedicated short escape.
assert_eq!(render_string("\r"), "\"\\r\"");
// Other control characters fall back to the \uXXXX form (U+0001 here).
assert_eq!(render_string("\u{1}"), "\"\\u0001\"");
}
#[test]
fn parses_primitive_values() {
assert_eq!(
JsonValue::parse("null").expect("null parses"),
JsonValue::Null
);
assert_eq!(
JsonValue::parse("true").expect("true parses"),
JsonValue::Bool(true)
);
assert_eq!(
JsonValue::parse("false").expect("false parses"),
JsonValue::Bool(false)
);
assert_eq!(
JsonValue::parse("\"hello\"").expect("string parses"),
JsonValue::String("hello".to_string())
);
}
#[test]
fn parses_signed_integers_and_ignores_surrounding_whitespace() {
assert_eq!(
JsonValue::parse("-42").expect("negative parses").as_i64(),
Some(-42)
);
assert_eq!(
JsonValue::parse(" 123 ").expect("padded parses").as_i64(),
Some(123)
);
}
#[test]
fn parses_nested_arrays_and_objects() {
let value =
JsonValue::parse("{\"nums\": [1, 2], \"flag\": true}").expect("nested json parses");
let object = value.as_object().expect("top level is an object");
assert_eq!(object.len(), 2);
let nums = object
.get("nums")
.and_then(JsonValue::as_array)
.expect("nums is an array");
assert_eq!(nums.len(), 2);
assert_eq!(nums[0].as_i64(), Some(1));
assert_eq!(object.get("flag").and_then(JsonValue::as_bool), Some(true));
}
#[test]
fn round_trips_rendered_values_through_the_parser() {
let mut object = BTreeMap::new();
object.insert("name".to_string(), JsonValue::String("clawi".to_string()));
object.insert("count".to_string(), JsonValue::Number(-3));
object.insert(
"tags".to_string(),
JsonValue::Array(vec![JsonValue::Bool(true), JsonValue::Null]),
);
let original = JsonValue::Object(object);
let reparsed = JsonValue::parse(&original.render()).expect("rendered json should re-parse");
assert_eq!(reparsed, original);
}
#[test]
@@ -433,6 +519,7 @@ mod tests {
object.insert("a\nb".to_string(), JsonValue::String("c\td".to_string()));
let original = JsonValue::Object(object);
let reparsed = JsonValue::parse(&original.render()).expect("escaped key re-parses");
assert_eq!(reparsed, original);
}
@@ -471,4 +558,37 @@ mod tests {
"i64::MIN - 1"
);
}
#[test]
fn parse_rejects_malformed_input() {
assert!(JsonValue::parse("").is_err(), "empty input");
assert!(JsonValue::parse("nul").is_err(), "truncated literal");
assert!(JsonValue::parse("true false").is_err(), "trailing content");
assert!(
JsonValue::parse("\"unterminated").is_err(),
"unterminated string"
);
assert!(JsonValue::parse("[1, 2").is_err(), "unterminated array");
}
#[test]
fn parse_rejects_integers_outside_the_i64_range() {
// Far beyond i64::MAX (~9.2e18): the parser must reject rather than
// silently truncate the value.
assert!(JsonValue::parse("9999999999999999999999999").is_err());
}
#[test]
fn accessors_return_none_for_mismatched_variants() {
assert_eq!(JsonValue::Null.as_bool(), None);
assert_eq!(JsonValue::Bool(true).as_i64(), None);
assert!(JsonValue::Number(1).as_str().is_none());
assert!(JsonValue::Number(1).as_array().is_none());
assert!(JsonValue::String("x".to_string()).as_object().is_none());
}
#[test]
fn json_error_displays_its_message() {
assert_eq!(JsonError::new("boom").to_string(), "boom");
}
}
+374 -42
View File
@@ -6715,6 +6715,49 @@ fn run_resume_command(
message: Some(render_memory_report()?),
json: Some(render_memory_json()?),
}),
SlashCommand::Workspace { path } => {
let cwd_before = env::current_dir()?;
let workspace_root = session
.workspace_root()
.map(Path::to_path_buf)
.unwrap_or_else(|| cwd_before.clone());
let workspace_root = canonicalize_or_clone(&workspace_root);
let changed = if let Some(path) = path.as_deref() {
let requested_path = Path::new(path);
let resolved_path = if requested_path.is_absolute() {
requested_path.to_path_buf()
} else {
cwd_before.join(requested_path)
};
let resolved_path = canonicalize_or_clone(&resolved_path);
if !resolved_path.starts_with(&workspace_root) {
return Err(format!(
"workspace_change_outside_root: `{}` is outside the current workspace root `{}`.\nUse `claw --cwd {}` to start a new session there.",
resolved_path.display(),
workspace_root.display(),
resolved_path.display(),
)
.into());
}
env::set_current_dir(&resolved_path)?;
true
} else {
false
};
let cwd_after = env::current_dir()?;
let message =
render_workspace_report(&cwd_after, &workspace_root, &session.session_id, changed);
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(message),
json: Some(workspace_report_json(
&cwd_after,
&workspace_root,
&session.session_id,
changed,
)),
})
}
SlashCommand::Init => {
// #142: run the init once, then render both text + structured JSON
// from the same InitReport so both surfaces stay in sync.
@@ -8216,6 +8259,7 @@ impl LiveCli {
Self::print_config(section.as_deref())?;
false
}
SlashCommand::Workspace { path } => self.handle_workspace_command(path.as_deref())?,
SlashCommand::Mcp { action, target } => {
let args = match (action.as_deref(), target.as_deref()) {
(None, None) => None,
@@ -8374,6 +8418,51 @@ impl LiveCli {
);
}
fn handle_workspace_command(
&mut self,
target: Option<&str>,
) -> Result<bool, Box<dyn std::error::Error>> {
let current_dir = env::current_dir()?;
let workspace_root = self
.runtime
.session()
.workspace_root()
.map(Path::to_path_buf)
.unwrap_or_else(|| current_dir.clone());
let workspace_root = canonicalize_or_clone(&workspace_root);
if let Some(target) = target {
let requested_path = Path::new(target);
let resolved_path = if requested_path.is_absolute() {
requested_path.to_path_buf()
} else {
current_dir.join(requested_path)
};
let resolved_path = canonicalize_or_clone(&resolved_path);
if !resolved_path.starts_with(&workspace_root) {
return Err(format!(
"workspace_change_outside_root: `{}` is outside the current workspace root `{}`.\nUse `claw --cwd {}` to start a new session there.",
resolved_path.display(),
workspace_root.display(),
resolved_path.display(),
)
.into());
}
env::set_current_dir(&resolved_path)?;
}
println!(
"{}",
render_workspace_report(
&env::current_dir()?,
&workspace_root,
&self.session.id,
target.is_some(),
)
);
Ok(target.is_some())
}
fn record_prompt_history(&mut self, prompt: &str) {
let timestamp_ms = std::time::SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -9214,6 +9303,41 @@ fn new_cli_session() -> Result<Session, Box<dyn std::error::Error>> {
Ok(Session::new().with_workspace_root(env::current_dir()?))
}
fn canonicalize_or_clone(path: &Path) -> PathBuf {
fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
fn render_workspace_report(
cwd: &Path,
workspace_root: &Path,
session_id: &str,
changed: bool,
) -> String {
let action = if changed { "change" } else { "show" };
format!(
"Workspace\n Action {action}\n Session {session_id}\n Workspace root {}\n Current directory {}",
workspace_root.display(),
cwd.display(),
)
}
fn workspace_report_json(
cwd: &Path,
workspace_root: &Path,
session_id: &str,
changed: bool,
) -> Value {
serde_json::json!({
"kind": "workspace",
"action": if changed { "change" } else { "show" },
"status": "ok",
"session_id": session_id,
"workspace_root": workspace_root.display().to_string(),
"current_directory": cwd.display().to_string(),
"changed": changed,
})
}
fn create_managed_session_handle(
session_id: &str,
) -> Result<SessionHandle, Box<dyn std::error::Error>> {
@@ -12584,6 +12708,7 @@ fn build_runtime_with_plugin_state(
plugin_registry.initialize()?;
let policy = permission_policy(permission_mode, &feature_config, &tool_registry)
.map_err(std::io::Error::other)?;
let provider_config = provider_config_from_runtime(feature_config.provider())?;
let mut runtime = ConversationRuntime::new_with_features(
session,
AnthropicRuntimeClient::new(
@@ -12595,6 +12720,7 @@ fn build_runtime_with_plugin_state(
allowed_tools.clone(),
tool_registry.clone(),
progress_reporter,
provider_config,
)?,
CliToolExecutor::new(
allowed_tools.clone(),
@@ -12767,10 +12893,85 @@ impl<W: Write> Write for TailTrackingWriter<'_, W> {
// NOTE: Despite the historical name `AnthropicRuntimeClient`, this struct
// now holds an `ApiProviderClient` which dispatches to Anthropic, xAI,
// OpenAI, or DashScope at construction time based on
// `detect_provider_kind(&model)`. The struct name is kept to avoid
// churning `BuiltRuntime` and every Deref/DerefMut site that references
// it. See ROADMAP #29 for the provider-dispatch routing fix.
// OpenAI, or DashScope at construction time. Explicit persisted provider
// configuration takes precedence; model-based provider detection remains
// the fallback when no provider configuration is present. The struct name
// is kept to avoid churning `BuiltRuntime` and every Deref/DerefMut site
// that references it.
fn provider_config_from_runtime(
provider: &runtime::RuntimeProviderConfig,
) -> Result<Option<api::ProviderConfig>, Box<dyn std::error::Error>> {
let Some(kind) = provider.kind() else {
return Ok(None);
};
let kind = match kind.trim().to_ascii_lowercase().as_str() {
"anthropic" => api::ProviderConfigKind::Anthropic,
"xai" => api::ProviderConfigKind::Xai,
"openai" => api::ProviderConfigKind::OpenAi,
"dashscope" => api::ProviderConfigKind::DashScope,
other => {
return Err(format!(
"invalid provider configuration: unsupported provider kind `{other}`"
)
.into());
}
};
Ok(Some(api::ProviderConfig {
kind,
model: provider.model().unwrap_or_default().to_string(),
api_key: provider.api_key().map(str::to_string),
base_url: provider.base_url().map(str::to_string),
}))
}
fn explicit_provider_kind_for_model(
model: &str,
persisted_model: &str,
) -> Option<api::ProviderConfigKind> {
let resolved_model = api::resolve_model_alias(model);
let persisted_resolved = api::resolve_model_alias(persisted_model);
if resolved_model.eq_ignore_ascii_case(&persisted_resolved) {
return None;
}
let namespace = model
.trim()
.split_once('/')
.map(|(namespace, _)| namespace.to_ascii_lowercase());
match namespace.as_deref() {
Some("anthropic") => Some(api::ProviderConfigKind::Anthropic),
Some("xai") => Some(api::ProviderConfigKind::Xai),
Some("openai") | Some("local") => Some(api::ProviderConfigKind::OpenAi),
Some("dashscope") | Some("qwen") | Some("kimi") => Some(api::ProviderConfigKind::DashScope),
_ => {
let lower = resolved_model.to_ascii_lowercase();
if lower.starts_with("qwen/")
|| lower.starts_with("qwen-")
|| lower.starts_with("kimi/")
|| lower.starts_with("kimi-")
{
Some(api::ProviderConfigKind::DashScope)
} else if lower.starts_with("grok/") || lower.starts_with("grok-") {
Some(api::ProviderConfigKind::Xai)
} else if lower.starts_with("claude") || lower.starts_with("anthropic/") {
Some(api::ProviderConfigKind::Anthropic)
} else if lower.starts_with("openai/")
|| lower.starts_with("gpt-")
|| lower.starts_with("local/")
{
Some(api::ProviderConfigKind::OpenAi)
} else {
None
}
}
}
}
struct AnthropicRuntimeClient {
runtime: tokio::runtime::Runtime,
client: ApiProviderClient,
@@ -12795,47 +12996,65 @@ impl AnthropicRuntimeClient {
allowed_tools: Option<AllowedToolSet>,
tool_registry: GlobalToolRegistry,
progress_reporter: Option<InternalPromptProgressReporter>,
provider_config: Option<api::ProviderConfig>,
) -> Result<Self, Box<dyn std::error::Error>> {
// Dispatch to the correct provider at construction time.
// `ApiProviderClient` (exposed by the api crate as
// `ProviderClient`) is an enum over Anthropic / xAI / OpenAI
// variants, where xAI and OpenAI both use the OpenAI-compat
// wire format under the hood. We consult
// `detect_provider_kind(&resolved_model)` so model-name prefix
// routing (`openai/`, `gpt-`, `grok`, `qwen/`) wins over
// env-var presence.
//
// For Anthropic we build the client directly instead of going
// through `ApiProviderClient::from_model_with_anthropic_auth`
// so we can explicitly apply `api::read_base_url()` — that
// reads `ANTHROPIC_BASE_URL` and is required for the local
// mock-server test harness
// (`crates/rusty-claude-cli/tests/compact_output.rs`) to point
// claw at its fake Anthropic endpoint. We also attach a
// session-scoped prompt cache on the Anthropic path; the
// prompt cache is Anthropic-only so non-Anthropic variants
// skip it.
// Explicit persisted provider configuration takes precedence.
// When absent, preserve the existing model-based provider detection.
let resolved_model = api::resolve_model_alias(&model);
let client = match detect_provider_kind(&resolved_model) {
ProviderKind::Anthropic => {
let auth = resolve_cli_auth_source()?;
let inner = AnthropicClient::from_auth(auth)
.with_base_url(api::read_base_url())
.with_prompt_cache(PromptCache::new(session_id));
ApiProviderClient::Anthropic(inner)
let provider_config = provider_config.map(|config| {
if let Some(kind) = explicit_provider_kind_for_model(&model, config.model.as_str()) {
api::ProviderConfig {
kind,
model: resolved_model.clone(),
..config
}
} else {
config
}
ProviderKind::Xai | ProviderKind::OpenAi => {
// The api crate's `ProviderClient::from_model_with_anthropic_auth`
// with `None` for the anthropic auth routes via
// `detect_provider_kind` and builds an
// `OpenAiCompatClient::from_env` with the matching
// `OpenAiCompatConfig` (openai / xai / dashscope).
// That reads the correct API-key env var and BASE_URL
// override internally, so this one call covers OpenAI,
// OpenRouter, xAI, DashScope, Ollama, and any other
// OpenAI-compat endpoint users configure via
// `OPENAI_BASE_URL` / `XAI_BASE_URL` / `DASHSCOPE_BASE_URL`.
ApiProviderClient::from_model_with_anthropic_auth(&resolved_model, None)?
});
let client = if let Some(config) = provider_config {
match config.kind {
api::ProviderConfigKind::Anthropic => {
let auth = match resolve_cli_auth_source() {
Ok(auth) => auth,
Err(_) => {
config
.api_key
.clone()
.map(AuthSource::ApiKey)
.ok_or_else(|| {
api::ApiError::missing_credentials(
"Anthropic",
&["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"],
)
})?
}
};
let mut inner = AnthropicClient::from_auth(auth);
let base_url = std::env::var("ANTHROPIC_BASE_URL")
.ok()
.filter(|value| !value.trim().is_empty())
.or_else(|| config.base_url.clone())
.unwrap_or_else(api::read_base_url);
inner = inner.with_base_url(base_url);
ApiProviderClient::Anthropic(
inner.with_prompt_cache(PromptCache::new(session_id)),
)
}
_ => ApiProviderClient::from_config(&config)?,
}
} else {
match detect_provider_kind(&resolved_model) {
ProviderKind::Anthropic => {
let auth = resolve_cli_auth_source()?;
let inner = AnthropicClient::from_auth(auth)
.with_base_url(api::read_base_url())
.with_prompt_cache(PromptCache::new(session_id));
ApiProviderClient::Anthropic(inner)
}
ProviderKind::Xai | ProviderKind::OpenAi => {
ApiProviderClient::from_model_with_anthropic_auth(&resolved_model, None)?
}
}
};
Ok(Self {
@@ -19432,6 +19651,119 @@ UU conflicted.rs",
let _ = fs::remove_dir_all(source_root);
}
#[test]
fn explicit_model_provider_hint_overrides_persisted_provider_kind() {
let config = api::ProviderConfig {
kind: api::ProviderConfigKind::DashScope,
model: "qwen-plus".to_string(),
api_key: Some("persisted-test-key".to_string()),
base_url: None,
};
assert_eq!(
super::explicit_provider_kind_for_model("openai/gpt-5", &config.model),
Some(api::ProviderConfigKind::OpenAi)
);
assert_eq!(
super::explicit_provider_kind_for_model("qwen-plus", &config.model),
None,
"matching persisted model must keep the persisted provider kind"
);
}
#[test]
fn explicit_model_provider_hint_controls_runtime_provider_dispatch() {
let config = api::ProviderConfig {
kind: api::ProviderConfigKind::DashScope,
model: "qwen-plus".to_string(),
api_key: Some("persisted-test-key".to_string()),
base_url: None,
};
let runtime = super::AnthropicRuntimeClient::new(
"provider-resolution-explicit-model-test",
"openai/gpt-5".to_string(),
false,
false,
TerminalTail::new(),
None,
GlobalToolRegistry::builtin(),
None,
Some(config),
)
.expect("runtime client should construct from explicit model provider hint");
match runtime.client {
api::ProviderClient::OpenAi(client) => {
assert!(client.base_url().contains("api.openai.com"));
assert!(!client.base_url().contains("dashscope.aliyuncs.com"));
}
other => panic!(
"explicit OpenAI model prefix must override persisted DashScope configuration, got {other:?}"
),
}
}
#[test]
fn persisted_provider_configuration_controls_runtime_provider_dispatch() {
let config_home = temp_dir();
let workspace = temp_dir();
fs::create_dir_all(&config_home).expect("config home");
fs::create_dir_all(&workspace).expect("workspace");
fs::write(
config_home.join("settings.json"),
r#"{
"provider": {
"kind": "dashscope",
"apiKey": "persisted-test-key",
"baseUrl": "https://dashscope.example/v1",
"model": "qwen-plus"
},
"model": "qwen-plus"
}"#,
)
.expect("write provider settings");
let loader = ConfigLoader::new(&workspace, &config_home);
let runtime_config = loader.load().expect("runtime config should load");
let state = build_runtime_plugin_state_with_loader(&workspace, &loader, &runtime_config)
.expect("runtime plugin state should load");
let provider_config = super::provider_config_from_runtime(state.feature_config.provider())
.expect("provider configuration should adapt")
.expect("persisted provider configuration should be present");
assert_eq!(
provider_config.kind,
api::ProviderConfigKind::DashScope,
"persisted provider kind must survive the runtime configuration boundary"
);
let runtime = super::AnthropicRuntimeClient::new(
"provider-resolution-test",
"qwen-plus".to_string(),
false,
false,
TerminalTail::new(),
None,
GlobalToolRegistry::builtin(),
None,
Some(provider_config),
)
.expect("runtime client should construct from persisted provider configuration");
match runtime.client {
api::ProviderClient::OpenAi(_) => {}
other => panic!(
"persisted DashScope configuration must dispatch to the OpenAI-compatible client, got {other:?}"
),
}
let _ = fs::remove_dir_all(config_home);
let _ = fs::remove_dir_all(workspace);
}
#[test]
#[allow(clippy::too_many_lines)]
fn build_runtime_plugin_state_discovers_mcp_tools_and_surfaces_pending_servers() {
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "sdk"
version = "0.1.0"
edition = "2021"
publish = false
[dependencies]
tokio = { version = "1", features = ["full"] }
tokio-tungstenite = "0.23"
futures-util = "0.3"
serde_json = "1"
serde = { version = "1", features = ["derive"] }
uuid = { version = "1", features = ["v4", "serde"] }
thiserror = "1"
tracing = "0.1"
[dev-dependencies]
tokio-test = "0.4"
+130
View File
@@ -0,0 +1,130 @@
//! SDK error types and result type.
use std::fmt;
/// SDK wire protocol error codes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ErrorCode {
/// Invalid JSON or malformed frame
InvalidInput,
/// Unknown broker operation
UnknownOperation,
/// Payload too large (>4 MiB)
PayloadTooLarge,
/// Authentication token mismatch
Unauthorized,
/// Broker or session unavailable
Unavailable,
/// WebSocket connection error
ConnectionError,
/// Request timeout
Timeout,
/// Frame parsing or validation error
FrameError,
/// Request/response correlation failure
CorrelationError,
/// Other unclassified error
Internal,
}
impl fmt::Display for ErrorCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidInput => write!(f, "invalid_input"),
Self::UnknownOperation => write!(f, "unknown_operation"),
Self::PayloadTooLarge => write!(f, "payload_too_large"),
Self::Unauthorized => write!(f, "unauthorized"),
Self::Unavailable => write!(f, "unavailable"),
Self::ConnectionError => write!(f, "connection_error"),
Self::Timeout => write!(f, "timeout"),
Self::FrameError => write!(f, "frame_error"),
Self::CorrelationError => write!(f, "correlation_error"),
Self::Internal => write!(f, "internal_error"),
}
}
}
/// SDK wire protocol error.
#[derive(Debug)]
pub struct SdkError {
pub code: ErrorCode,
pub message: String,
}
impl SdkError {
pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
}
}
pub fn invalid_input(message: impl Into<String>) -> Self {
Self::new(ErrorCode::InvalidInput, message)
}
pub fn unknown_operation(message: impl Into<String>) -> Self {
Self::new(ErrorCode::UnknownOperation, message)
}
pub fn payload_too_large(message: impl Into<String>) -> Self {
Self::new(ErrorCode::PayloadTooLarge, message)
}
pub fn unauthorized(message: impl Into<String>) -> Self {
Self::new(ErrorCode::Unauthorized, message)
}
pub fn unavailable(message: impl Into<String>) -> Self {
Self::new(ErrorCode::Unavailable, message)
}
pub fn connection_error(message: impl Into<String>) -> Self {
Self::new(ErrorCode::ConnectionError, message)
}
pub fn timeout(message: impl Into<String>) -> Self {
Self::new(ErrorCode::Timeout, message)
}
pub fn frame_error(message: impl Into<String>) -> Self {
Self::new(ErrorCode::FrameError, message)
}
pub fn correlation_error(message: impl Into<String>) -> Self {
Self::new(ErrorCode::CorrelationError, message)
}
pub fn internal(message: impl Into<String>) -> Self {
Self::new(ErrorCode::Internal, message)
}
}
impl fmt::Display for SdkError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}: {}", self.code, self.message)
}
}
impl std::error::Error for SdkError {}
/// Result type for SDK operations.
pub type SdkResult<T> = Result<T, SdkError>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn error_display_formats_code_and_message() {
let err = SdkError::invalid_input("test message");
assert_eq!(err.to_string(), "invalid_input: test message");
}
#[test]
fn error_code_display_works() {
assert_eq!(ErrorCode::InvalidInput.to_string(), "invalid_input");
assert_eq!(ErrorCode::PayloadTooLarge.to_string(), "payload_too_large");
assert_eq!(ErrorCode::Unauthorized.to_string(), "unauthorized");
}
}
+258
View File
@@ -0,0 +1,258 @@
//! Frame types and serialization for the SDK wire protocol.
use serde_json::{Map, Value};
use crate::error::{SdkError, SdkResult};
/// Protocol version implemented.
pub const PROTOCOL_VERSION: u32 = 3;
/// Maximum UTF-8 byte length for a single broker JSON frame.
pub const MAX_FRAME_BYTES: usize = 4 * 1024 * 1024;
/// Frame type identifier.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FrameType {
/// Hello frame sent by broker on connection
BrokerHello,
/// Request frame from client to broker
BrokerRequest,
/// Response frame from broker to client
BrokerResponse,
/// Query request
QueryRequest,
/// Query response
QueryResponse,
/// Control request
ControlRequest,
/// Control response
ControlResponse,
/// Event replay request
EventReplay,
/// Event replay result
EventReplayResult,
/// Session activation
SessionActivate,
/// Session activation result
SessionActivateResult,
}
impl FrameType {
pub fn as_str(&self) -> &'static str {
match self {
Self::BrokerHello => "broker_hello",
Self::BrokerRequest => "broker_request",
Self::BrokerResponse => "broker_response",
Self::QueryRequest => "query_request",
Self::QueryResponse => "query_response",
Self::ControlRequest => "control_request",
Self::ControlResponse => "control_response",
Self::EventReplay => "event_replay",
Self::EventReplayResult => "event_replay_result",
Self::SessionActivate => "session_activate",
Self::SessionActivateResult => "session_activate_result",
}
}
pub fn parse(s: &str) -> SdkResult<Self> {
match s {
"broker_hello" => Ok(Self::BrokerHello),
"broker_request" => Ok(Self::BrokerRequest),
"broker_response" => Ok(Self::BrokerResponse),
"query_request" => Ok(Self::QueryRequest),
"query_response" => Ok(Self::QueryResponse),
"control_request" => Ok(Self::ControlRequest),
"control_response" => Ok(Self::ControlResponse),
"event_replay" => Ok(Self::EventReplay),
"event_replay_result" => Ok(Self::EventReplayResult),
"session_activate" => Ok(Self::SessionActivate),
"session_activate_result" => Ok(Self::SessionActivateResult),
_ => Err(SdkError::frame_error(format!("unknown frame type: {}", s))),
}
}
}
/// A frame in the SDK wire protocol.
///
/// Frames are JSON objects with a `type` field and optional `id` field for correlation.
#[derive(Debug, Clone)]
pub struct Frame {
/// Underlying JSON value
pub data: Map<String, Value>,
}
impl Frame {
/// Create a new frame from a JSON object.
pub fn new(data: Map<String, Value>) -> Self {
Self { data }
}
/// Create an empty frame.
pub fn empty() -> Self {
Self { data: Map::new() }
}
/// Get the frame type.
pub fn frame_type(&self) -> SdkResult<FrameType> {
let frame_type_str = self
.data
.get("type")
.and_then(Value::as_str)
.ok_or_else(|| SdkError::frame_error("missing 'type' field"))?;
FrameType::parse(frame_type_str)
}
/// Get the optional request/response ID for correlation.
pub fn id(&self) -> Option<String> {
self.data
.get("id")
.and_then(Value::as_str)
.map(String::from)
}
/// Get the `ok` boolean field from a response frame.
pub fn ok(&self) -> SdkResult<bool> {
self.data
.get("ok")
.and_then(Value::as_bool)
.ok_or_else(|| SdkError::frame_error("missing or invalid 'ok' field"))
}
/// Get the error object from a response frame.
pub fn error(&self) -> Option<SdkResult<(String, String)>> {
let error_obj = self.data.get("error")?.as_object()?;
let code = error_obj.get("code")?.as_str()?.to_string();
let message = error_obj.get("message")?.as_str()?.to_string();
Some(Ok((code, message)))
}
/// Get the result field from a response frame.
pub fn result(&self) -> Option<Value> {
self.data.get("result").cloned()
}
/// Convert to JSON string.
pub fn to_json(&self) -> SdkResult<String> {
serde_json::to_string(&self.data)
.map_err(|e| SdkError::frame_error(format!("JSON serialization error: {}", e)))
}
/// Parse from JSON string.
pub fn from_json(json_str: &str) -> SdkResult<Self> {
let data: Map<String, Value> = serde_json::from_str(json_str)
.map_err(|e| SdkError::frame_error(format!("JSON parsing error: {}", e)))?;
Ok(Self::new(data))
}
/// Create a broker hello frame.
pub fn broker_hello() -> Self {
let mut data = Map::new();
data.insert(
"type".to_string(),
Value::String("broker_hello".to_string()),
);
data.insert(
"protocolVersion".to_string(),
Value::Number(PROTOCOL_VERSION.into()),
);
Self::new(data)
}
/// Create a broker request frame.
pub fn broker_request(id: String, operation: String, input: Value) -> Self {
let mut data = Map::new();
data.insert(
"type".to_string(),
Value::String("broker_request".to_string()),
);
data.insert("id".to_string(), Value::String(id));
data.insert("operation".to_string(), Value::String(operation));
data.insert("input".to_string(), input);
Self::new(data)
}
/// Create a broker response frame.
pub fn broker_response_ok(id: String, result: Value) -> Self {
let mut data = Map::new();
data.insert(
"type".to_string(),
Value::String("broker_response".to_string()),
);
data.insert("id".to_string(), Value::String(id));
data.insert("ok".to_string(), Value::Bool(true));
data.insert("result".to_string(), result);
Self::new(data)
}
/// Create a broker error response frame.
pub fn broker_response_error(id: String, code: String, message: String) -> Self {
let mut error_data = Map::new();
error_data.insert("code".to_string(), Value::String(code));
error_data.insert("message".to_string(), Value::String(message));
let mut data = Map::new();
data.insert(
"type".to_string(),
Value::String("broker_response".to_string()),
);
data.insert("id".to_string(), Value::String(id));
data.insert("ok".to_string(), Value::Bool(false));
data.insert("error".to_string(), Value::Object(error_data));
Self::new(data)
}
/// Get the payload size in bytes.
pub fn byte_size(&self) -> usize {
self.to_json().map(|s| s.len()).unwrap_or(0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn frame_type_round_trip() {
assert_eq!(FrameType::BrokerHello.as_str(), "broker_hello");
assert_eq!(
FrameType::parse("broker_hello").unwrap(),
FrameType::BrokerHello
);
}
#[test]
fn frame_parsing_extracts_type() {
let frame = Frame::broker_hello();
assert_eq!(frame.frame_type().unwrap(), FrameType::BrokerHello);
}
#[test]
fn frame_with_id_can_be_recovered() {
let frame = Frame::broker_request(
"test-id".to_string(),
"test.operation".to_string(),
Value::Object(Map::new()),
);
assert_eq!(frame.id(), Some("test-id".to_string()));
}
#[test]
fn frame_json_round_trip() {
let frame = Frame::broker_hello();
let json_str = frame.to_json().unwrap();
let parsed = Frame::from_json(&json_str).unwrap();
assert_eq!(parsed.frame_type().unwrap(), FrameType::BrokerHello);
}
#[test]
fn broker_response_error_contains_code_and_message() {
let frame = Frame::broker_response_error(
"id1".to_string(),
"invalid_input".to_string(),
"test error".to_string(),
);
let (code, message) = frame.error().unwrap().unwrap();
assert_eq!(code, "invalid_input");
assert_eq!(message, "test error");
}
}
+20
View File
@@ -0,0 +1,20 @@
//! GJC SDK wire transport implementation.
//!
//! This crate provides a Rust implementation of the GJC SDK wire protocol,
//! compatible with the gajae-code SDK broker framing, request/response correlation,
//! and event stream semantics.
#![forbid(unsafe_code)]
#![allow(
clippy::missing_errors_doc,
clippy::missing_panics_doc,
clippy::module_name_repetitions
)]
pub mod error;
pub mod frame;
pub mod transport;
pub use error::{SdkError, SdkResult};
pub use frame::{Frame, FrameType};
pub use transport::{SdkTransport, SdkTransportConfig};
+251
View File
@@ -0,0 +1,251 @@
//! WebSocket transport implementation for the SDK wire protocol.
use std::collections::HashMap;
use std::sync::Arc;
use futures_util::sink::SinkExt;
use futures_util::stream::StreamExt;
use serde_json::Value;
use tokio::sync::{mpsc, oneshot, Mutex, RwLock};
use tokio_tungstenite::{connect_async, tungstenite::Message};
use uuid::Uuid;
use crate::error::{SdkError, SdkResult};
use crate::frame::{Frame, FrameType, MAX_FRAME_BYTES};
/// Configuration for SDK transport.
#[derive(Debug, Clone)]
pub struct SdkTransportConfig {
/// Server URL (e.g., "ws://127.0.0.1:8080")
pub url: String,
/// Authentication token
pub token: String,
/// Maximum payload size in bytes (default: 4 MiB)
pub max_payload_bytes: usize,
/// Request timeout in seconds (default: 30)
pub timeout_secs: u64,
}
impl Default for SdkTransportConfig {
fn default() -> Self {
Self {
url: "ws://127.0.0.1:8080".to_string(),
token: String::new(),
max_payload_bytes: MAX_FRAME_BYTES,
timeout_secs: 30,
}
}
}
/// Pending request tracking for correlation.
struct PendingRequest {
response_tx: oneshot::Sender<SdkResult<Frame>>,
}
/// SDK WebSocket transport.
pub struct SdkTransport {
/// Channel for sending messages to the writer task
tx: mpsc::UnboundedSender<Message>,
/// Pending requests by ID
pending: Arc<RwLock<HashMap<String, PendingRequest>>>,
/// Configuration
config: SdkTransportConfig,
/// Background task handle
task_handle: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>,
}
impl SdkTransport {
/// Create a new SDK transport and connect to the broker.
pub async fn connect(config: SdkTransportConfig) -> SdkResult<Self> {
// Add token to query params
let url_with_token = if config.url.contains('?') {
format!("{}&token={}", config.url, config.token)
} else {
format!("{}?token={}", config.url, config.token)
};
// Create WebSocket connection
let (ws, _) = connect_async(&url_with_token).await.map_err(|e| {
SdkError::connection_error(format!("WebSocket connection failed: {}", e))
})?;
let (ws_tx, ws_rx) = tokio::sync::mpsc::unbounded_channel();
let pending = Arc::new(RwLock::new(HashMap::new()));
let pending_clone = pending.clone();
// Spawn background task for handling the WebSocket
let task_handle = tokio::spawn(async move {
Self::background_task(ws, ws_rx, pending_clone).await;
});
let transport = Self {
tx: ws_tx,
pending,
config,
task_handle: Arc::new(Mutex::new(Some(task_handle))),
};
Ok(transport)
}
/// Send a request and wait for response.
pub async fn request(&self, operation: String, input: Value) -> SdkResult<Frame> {
// Generate correlation ID
let id = Uuid::new_v4().to_string();
// Create request frame
let request = Frame::broker_request(id.clone(), operation, input);
// Check payload size
if request.byte_size() > self.config.max_payload_bytes {
return Err(SdkError::payload_too_large(format!(
"frame size {} exceeds maximum {}",
request.byte_size(),
self.config.max_payload_bytes
)));
}
// Create response channel
let (response_tx, response_rx) = oneshot::channel();
self.pending
.write()
.await
.insert(id.clone(), PendingRequest { response_tx });
// Send request
let json = request.to_json()?;
self.tx
.send(Message::Text(json))
.map_err(|e| SdkError::connection_error(format!("send failed: {}", e)))?;
// Wait for response with timeout
let timeout_duration = std::time::Duration::from_secs(self.config.timeout_secs);
match tokio::time::timeout(timeout_duration, response_rx).await {
Ok(Ok(frame)) => frame,
Ok(Err(_)) => Err(SdkError::correlation_error("response channel closed")),
Err(_) => Err(SdkError::timeout("response not received within timeout")),
}
}
/// Background task for handling WebSocket communication.
#[allow(unused_mut)]
async fn background_task(
ws: tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
>,
mut ws_rx: mpsc::UnboundedReceiver<Message>,
pending: Arc<RwLock<HashMap<String, PendingRequest>>>,
) {
let (mut ws_sink, mut ws_stream) = ws.split();
loop {
tokio::select! {
// Handle incoming WebSocket messages
msg = ws_stream.next() => {
match msg {
Some(Ok(Message::Text(text))) => {
// Parse frame
match Frame::from_json(&text) {
Ok(frame) => {
// Handle frame type
match frame.frame_type() {
Ok(FrameType::BrokerHello) => {
// Hello frame received, continue
}
Ok(FrameType::BrokerResponse) => {
// Response frame, correlate with request
if let Some(id) = frame.id() {
if let Some(PendingRequest { response_tx }) =
pending.write().await.remove(&id)
{
let _ = response_tx.send(Ok(frame));
}
}
}
_ => {
// Other frame types
tracing::debug!("Unhandled frame type: {:?}", frame.frame_type());
}
}
}
Err(e) => {
tracing::warn!("Failed to parse frame: {}", e);
}
}
}
Some(Ok(Message::Close(_))) => {
break;
}
Some(Ok(_)) => {
// Other message types (binary, etc.), ignore
}
Some(Err(e)) => {
tracing::warn!("WebSocket error: {}", e);
break;
}
None => {
break;
}
}
}
// Handle outgoing messages
Some(msg) = ws_rx.recv() => {
if let Err(e) = ws_sink.send(msg).await {
tracing::warn!("Failed to send message: {}", e);
break;
}
}
else => break,
}
}
// Fail all pending requests
let mut pending = pending.write().await;
for (_, PendingRequest { response_tx }) in pending.drain() {
let _ = response_tx.send(Err(SdkError::connection_error("connection closed")));
}
}
/// Close the transport.
pub async fn close(self) -> SdkResult<()> {
// Send close message
let _ = self.tx.send(Message::Close(None));
// Wait for background task to finish
if let Some(handle) = self.task_handle.lock().await.take() {
let _ = tokio::time::timeout(std::time::Duration::from_secs(5), handle).await;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_default_values() {
let config = SdkTransportConfig::default();
assert_eq!(config.url, "ws://127.0.0.1:8080");
assert_eq!(config.max_payload_bytes, MAX_FRAME_BYTES);
assert_eq!(config.timeout_secs, 30);
}
#[tokio::test]
async fn transport_can_be_created() {
// This test doesn't actually connect, just checks that the struct can be created
let config = SdkTransportConfig::default();
assert_eq!(config.url, "ws://127.0.0.1:8080");
}
#[test]
fn request_frame_has_correlation_id() {
let frame = Frame::broker_request(
"test-id".to_string(),
"test.operation".to_string(),
Value::Object(serde_json::Map::new()),
);
assert_eq!(frame.id(), Some("test-id".to_string()));
}
}