Compare commits

..
27 changed files with 345 additions and 3718 deletions
-1
View File
@@ -1 +0,0 @@
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 }}" 2>/dev/null || shasum -a 256 "${{ matrix.artifact_name }}") > "${{ matrix.artifact_name }}.sha256")
(cd dist && sha256sum "${{ matrix.artifact_name }}" > "${{ matrix.artifact_name }}.sha256")
- name: Upload workflow artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
-4
View File
@@ -25,8 +25,6 @@ on:
- tests/test_roadmap_helpers.py
- docs/**
- rust/**
- flake.nix
- .envrc
pull_request:
branches:
- main
@@ -49,8 +47,6 @@ on:
- tests/test_roadmap_helpers.py
- docs/**
- rust/**
- flake.nix
- .envrc
workflow_dispatch:
concurrency:
-4
View File
@@ -15,7 +15,3 @@ status-help.txt
.port_sessions/
# Local agent session state
.gjc/
# Nix build outputs and direnv
.envrc.local
result
result-*
-44
View File
@@ -1,44 +0,0 @@
# 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
-338
View File
@@ -1,338 +0,0 @@
# GJC Tool Semantics Alignment: Phase 1 (read/edit/search/find)
**Status**: Implementation plan
**Issue**: #3323
**Scope**: Slice 2.1 - Tool catalog semantic parity for read/edit/search/find
**Target**: Ship in batches with focused PRs
---
## 1. EXECUTIVE SUMMARY
This document details the semantic gaps between claw-code's current tool implementations and GJC's (gajae-code) reference implementations for the highest-value tools: **read**, **edit**, **search**, and **find**.
### Key Gaps
| Tool | Current (claw-code) | Target (GJC) | Gap | Complexity |
|------|-----|--------|-----|------------|
| **read** | offset/limit pagination | Line selectors (`:L-L+N`), raw mode, truncation footers | High | Medium |
| **write** | Simple file replacement | Full replacement semantics (matching GJC) | Low | Low |
| **edit** | String replacement (old→new) | String replacement with rich patch output | Low | Low |
| **find** (glob) | Pattern expansion only | Recursive glob with filtering | Low | Low |
| **search** (grep) | Regex search with context | Regex with context + advanced filtering | Low | Low |
---
## 2. GJC TOOL SEMANTICS REFERENCE
### 2.1 READ Tool
**Source**: `gajae-code/packages/coding-agent/src/tools/read.ts` (3,500+ LOC)
#### Input Schema
```typescript
{
path: string; // File path (relative or absolute)
offset?: number; // Line offset (for pagination)
limit?: number; // Lines to read (for pagination)
// GJC extensions (missing in claw-code):
selector?: string; // Line range syntax:
// ":50" = line 50 onward
// ":50-200" = lines 50-200 inclusive
// ":50+150" = 150 lines starting at line 50
// ":raw" = full file verbatim
// ":conflicts" = git merge conflicts only
truncationDirection?: "head" | "tail" | "both"; // Which end to keep
maxSummaryBytes?: number; // Summary budget (default 50KB)
maxSummaryLines?: number; // Summary budget (default 20K lines)
}
```
#### Output Structure
```json
{
"file": {
"path": "src/main.rs",
"content": "...",
"totalLines": 500,
"totalBytes": 12345,
"truncatedBy": "lines" | "bytes" | "middle" | null,
"displayMode": "summarized" | "raw" | "receipt"
},
"truncationFooter": "[Showing lines 1-50 of 500; re-read ...]"
}
```
#### Key Behaviors
1. **Line Range Selectors** (`:selector` syntax)
- `:50` = start at line 50
- `:50-200` = lines 50-200 inclusive
- `:50+150` = 150 lines starting at 50
- `:50+1` = exactly one line
- `:5-16,960-973` = multiple ranges
- `:raw` = full verbatim
- `:conflicts` = git merge conflicts
2. **Truncation Modes**
- Default: 50KB bytes limit, 20K lines limit
- Direction: `head` (keep start), `tail` (keep end), `both` (keep both ends)
- Footer includes recovery hint (e.g., "`re-read path:1-9999` for verbatim")
3. **Structural Summarization**
- Code: collapse function bodies to `..` or `…` (merged brace pairs)
- Prose: preserve full lines, truncate at word boundaries
- Special files: documents (PDF, DOCX, XLSX) converted via markit
4. **Archive Support**
- `.tar`, `.tar.gz`, `.tgz`, `.zip`: member read with `archive.ext:path/inside`
- SQLite: `.sqlite:table` or `.sqlite:table:key` queries
- Line ranges apply to archive members
5. **Output Formatting**
- Anchored output: Line prefixes with source line numbers and hash anchors
- Optional hash lines: Marks structural boundaries (`# pragma`, function start, etc.)
- Prose column cap: Long lines wrapped/truncated at column limit
### 2.2 EDIT Tool
**Source**: `gajae-code/packages/coding-agent/src/tools/edit.ts`
#### Input Schema
```typescript
{
path: string; // File to edit
old_text: string; // Text to replace (required for edit)
new_text: string; // Replacement text
all?: boolean; // Replace all occurrences (default: first only)
}
```
#### Output Structure
```json
{
"file": {
"path": "src/main.rs",
"editCount": 1,
"beforeSize": 12345,
"afterSize": 12400
},
"patch": [
{
"type": "replace",
"lineStart": 50,
"lineEnd": 55,
"before": "old content",
"after": "new content",
"context": {"before": 3, "after": 3}
}
]
}
```
#### Key Behaviors
1. **Exact Match Required**: `old_text` must match exactly (no fuzzy matching)
2. **Smallest Replacement**: Use smallest unique old_text that identifies the change
3. **All-or-Nothing**: Either all occurrences replaced or none (no partial)
4. **Patch Metadata**: Line numbers, context, before/after preserved
### 2.3 SEARCH Tool
**Source**: `gajae-code/packages/coding-agent/src/tools/search.ts`
#### Input Schema
```typescript
{
pattern: string; // Regex pattern
paths?: string[]; // Files/dirs to search
context?: number; // Context lines before/after (default: 2)
i?: boolean; // Case-insensitive
glob_exclude?: string[]; // Patterns to skip
file_type?: string; // Filter by extension
limit?: number; // Max results
truncation?: "head" | "tail" | "both";
}
```
#### Output Structure
```json
{
"matches": [
{
"file": "src/main.rs",
"lineNumber": 42,
"line": "fn process() {",
"before": ["// Process data", ""],
"after": [" // Implementation", ""]
}
],
"summary": "Found 5 matches in 3 files"
}
```
### 2.4 FIND Tool
**Source**: `gajae-code/packages/coding-agent/src/tools/find.ts`
#### Input Schema
```typescript
{
paths: string[]; // Glob patterns
timeout?: number; // Timeout in seconds (default: 5)
gitignore?: boolean; // Respect .gitignore (default: true)
hidden?: boolean; // Include hidden files (default: true)
limit?: number; // Max results (default: 1000)
}
```
#### Output Structure
```json
{
"files": [
{
"path": "src/main.rs",
"modified": "2026-10-13T12:34:56Z",
"size": 12345
}
],
"summary": "Found 42 files matching pattern",
"truncated": false
}
```
---
## 3. CURRENT CLAW-CODE IMPLEMENTATION GAPS
### 3.1 READ Tool Gaps
**File**: `rust/crates/runtime/src/file_ops.rs:713`
**Current**:
```rust
pub fn read_file_in_workspace(
path: &str,
offset: Option<usize>, // Line offset only
limit: Option<usize>, // Lines only
workspace_root: &Path,
) -> io::Result<ReadFileOutput>
```
**Missing**:
- ✗ Line range selectors (`:L-L+N` syntax)
- ✗ Raw mode (`:raw`)
- ✓ Git merge conflict extraction (`:conflicts`)
- ✗ Truncation footers with recovery hints
- ✗ Structural summarization (collapse braces)
- ✗ Archive/SQLite support
- ✗ Multi-range selectors (`:5-16,960-973`)
- ✗ Prose column cap
- ✗ Hash line anchors
**Estimated LOC to Add**: 1,200-1,500 (including tests)
### 3.2 EDIT Tool Gaps
**File**: `rust/crates/runtime/src/file_ops.rs:760`
**Current**: Basic string replacement works
**Missing**:
- ✗ Rich patch metadata (line numbers, context lines)
**Estimated LOC to Add**: 200-300
### 3.3 SEARCH Tool Gaps
**File**: `rust/crates/tools/src/lib.rs` (grep search)
**Current**: Basic regex with context
**Missing**:
- ✗ Advanced filtering (file type, glob exclude)
- ✗ Structured output format
**Estimated LOC to Add**: 300-400
### 3.4 FIND Tool Gaps
**File**: `rust/crates/tools/src/lib.rs` (glob search)
**Current**: Basic glob expansion
**Missing**:
- ✗ Recursive globs
- ✗ Gitignore filtering
- ✗ Hidden file control
- ✗ File metadata
**Estimated LOC to Add**: 200-300
---
## 4. IMPLEMENTATION PLAN (BATCHED)
### Batch 1: READ Tool (Priority P0)
- Add line range selector parsing
- Implement `:raw` mode
- ✓ Implement `:conflicts` mode
- Add truncation footers with recovery hints
- Implement structural summarization
- Add tests for all selector combinations
**Files to Modify**:
- `rust/crates/runtime/src/file_ops.rs` (read_file_in_workspace)
- `rust/crates/tools/src/lib.rs` (ReadFileInput struct)
- Tests in both
**Size Estimate**: 1,200 LOC
**Complexity**: Medium (string parsing, output formatting)
### Batch 2: EDIT Tool (Priority P1)
- Add patch metadata to output
- Ensure exact match semantics
- Add comprehensive tests
**Files to Modify**:
- `rust/crates/runtime/src/file_ops.rs` (edit_file_in_workspace)
- Tests
**Size Estimate**: 200 LOC
**Complexity**: Low
### Batch 3: SEARCH/FIND Tools (Priority P1)
- Enhance grep/find with GJC-compatible filtering
- Add structured output format
- Add metadata (file type, modified time)
**Files to Modify**:
- `rust/crates/tools/src/lib.rs` (glob_search, grep_search)
- `rust/crates/runtime/src/file_ops.rs`
**Size Estimate**: 500 LOC
**Complexity**: Low-Medium
---
## 5. VERIFICATION STRATEGY
Each batch will include:
1. **Semantic Tests**: Verify tool inputs/outputs match GJC reference
2. **Edge Case Tests**: Line ranges, truncation, archives, etc.
3. **Integration Tests**: End-to-end with claw CLI
4. **Parity Shadow Test**: Run same inputs against both GJC and claw-code, compare outputs
---
## 6. ROLLOUT SCHEDULE
- **Batch 1 (READ)**: Week 1 - Enable most impactful tool first
- **Batch 2 (EDIT)**: Week 1-2 - Quick follow-up
- **Batch 3 (SEARCH/FIND)**: Week 2 - Lower priority but valuable for discoverability
---
## 7. REFERENCES
- GJC Read Tool: `/home/bellman/Workspace/gajae-code/packages/coding-agent/src/tools/read.ts`
- claw-code file_ops: `rust/crates/runtime/src/file_ops.rs`
- claw-code tools: `rust/crates/tools/src/lib.rs`
- Gap Matrix: `docs/gjc-compat/gap-matrix.md`
-19
View File
@@ -130,25 +130,6 @@ 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
@@ -1,203 +0,0 @@
# 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
@@ -1,115 +0,0 @@
{
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 Regular → Executable
+43 -135
View File
@@ -349,12 +349,6 @@ 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"
@@ -389,17 +383,6 @@ 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"
@@ -733,12 +716,6 @@ 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"
@@ -1022,15 +999,15 @@ dependencies = [
[[package]]
name = "getrandom"
version = "0.4.3"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"rand_core 0.10.1",
"wasip2",
"wasm-bindgen",
]
@@ -1904,15 +1881,14 @@ dependencies = [
[[package]]
name = "quinn-proto"
version = "0.11.19"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"bytes",
"getrandom 0.4.3",
"getrandom 0.3.4",
"lru-slab",
"rand 0.10.3",
"rand_pcg",
"rand 0.9.2",
"ring",
"rustc-hash",
"rustls",
@@ -1949,9 +1925,9 @@ dependencies = [
[[package]]
name = "r-efi"
version = "6.0.0"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "radix_trie"
@@ -1970,19 +1946,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"libc",
"rand_chacha",
"rand_chacha 0.3.1",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.10.3"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"chacha20",
"getrandom 0.4.3",
"rand_core 0.10.1",
"rand_chacha 0.9.0",
"rand_core 0.9.5",
]
[[package]]
@@ -1995,6 +1970,16 @@ 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"
@@ -2006,17 +1991,11 @@ dependencies = [
[[package]]
name = "rand_core"
version = "0.10.1"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_pcg"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"rand_core 0.10.1",
"getrandom 0.3.4",
]
[[package]]
@@ -2331,21 +2310,6 @@ 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"
@@ -2450,17 +2414,6 @@ 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"
@@ -2632,7 +2585,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"getrandom 0.3.4",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.61.2",
@@ -2753,7 +2706,6 @@ dependencies = [
"bytes",
"libc",
"mio",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2 0.6.3",
@@ -2793,29 +2745,6 @@ 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"
@@ -3027,24 +2956,6 @@ 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"
@@ -3093,12 +3004,6 @@ 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"
@@ -3111,18 +3016,6 @@ 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"
@@ -3160,6 +3053,15 @@ 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"
@@ -3468,6 +3370,12 @@ 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"
+2 -247
View File
@@ -13,135 +13,7 @@ pub enum ProviderClient {
OpenAi(OpenAiCompatClient),
}
/// Provider selected explicitly by persisted/runtime configuration.
///
/// This is intentionally separate from `ProviderKind`: Custom OpenAI and
/// DashScope speak the OpenAI-compatible wire protocol but remain distinct
/// configured providers.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderConfigKind {
Anthropic,
Xai,
OpenAi,
CustomOpenAi,
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::CustomOpenAi
| ProviderConfigKind::DashScope => {
let compat = match config.kind {
ProviderConfigKind::OpenAi => OpenAiCompatConfig::openai(),
ProviderConfigKind::CustomOpenAi => OpenAiCompatConfig::custom_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)
}
@@ -162,7 +34,7 @@ impl ProviderClient {
ProviderKind::OpenAi => {
// OLLAMA_HOST takes priority: local Ollama needs no API key
// and ignores DashScope/OpenAI env-based dispatch.
if read_env_non_empty("OLLAMA_HOST").is_some() {
if std::env::var_os("OLLAMA_HOST").is_some() {
Ok(Self::OpenAi(
openai_compat::OpenAiCompatClient::from_ollama_env()
.expect("from_ollama_env always returns Some"),
@@ -283,7 +155,7 @@ pub fn read_xai_base_url() -> String {
mod tests {
use std::sync::{Mutex, OnceLock};
use super::{ProviderClient, ProviderConfig, ProviderConfigKind};
use super::ProviderClient;
use crate::providers::{detect_provider_kind, resolve_model_alias, ProviderKind};
/// Serializes every test in this module that mutates process-wide
@@ -341,123 +213,6 @@ 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 persisted_custom_openai_uses_its_own_environment_namespace() {
let _lock = env_lock();
let _custom_key = EnvVarGuard::set("CLAWCUSTOMOPENAI_API_KEY", Some("custom-key"));
let _custom_url = EnvVarGuard::set(
"CLAWCUSTOMOPENAI_BASE_URL",
Some("https://custom.example/v1"),
);
let _openai_key = EnvVarGuard::set("OPENAI_API_KEY", Some("openai-key"));
let _openai_url = EnvVarGuard::set("OPENAI_BASE_URL", Some("https://openai.example/v1"));
let _ollama = EnvVarGuard::set("OLLAMA_HOST", Some("http://localhost:11434"));
let config = ProviderConfig {
kind: ProviderConfigKind::CustomOpenAi,
model: "qwen-plus".to_string(),
api_key: None,
base_url: None,
};
match ProviderClient::from_config(&config).expect("custom OpenAI config should succeed") {
ProviderClient::OpenAi(client) => {
assert_eq!(client.base_url(), "https://custom.example/v1");
}
other => panic!("Expected custom OpenAI provider, got: {other:?}"),
}
}
#[test]
fn persisted_custom_openai_does_not_reuse_openai_credentials() {
let _lock = env_lock();
let _custom_key = EnvVarGuard::set("CLAWCUSTOMOPENAI_API_KEY", None);
let _custom_url = EnvVarGuard::set(
"CLAWCUSTOMOPENAI_BASE_URL",
Some("https://custom.example/v1"),
);
let _openai_key = EnvVarGuard::set("OPENAI_API_KEY", Some("openai-key"));
let _ollama = EnvVarGuard::set("OLLAMA_HOST", Some("http://localhost:11434"));
let config = ProviderConfig {
kind: ProviderConfigKind::CustomOpenAi,
model: "gpt-5".to_string(),
api_key: None,
base_url: None,
};
let error = ProviderClient::from_config(&config)
.expect_err("custom OpenAI must require its own credentials");
assert!(error.to_string().contains("CLAWCUSTOMOPENAI_API_KEY"));
}
#[test]
fn dashscope_model_uses_dashscope_config_not_openai() {
// Regression: qwen-plus was being routed to OpenAiCompatConfig::openai()
+1 -3
View File
@@ -1,5 +1,4 @@
mod client;
mod error;
mod http_client;
mod prompt_cache;
@@ -9,8 +8,7 @@ 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, ProviderConfig,
ProviderConfigKind,
resolve_startup_auth_source, MessageStream, OAuthTokenSet, ProviderClient,
};
pub use error::ApiError;
pub use http_client::{
@@ -42,7 +42,6 @@ pub struct OpenAiCompatConfig {
const XAI_ENV_VARS: &[&str] = &["XAI_API_KEY"];
const OPENAI_ENV_VARS: &[&str] = &["OPENAI_API_KEY"];
const CUSTOM_OPENAI_ENV_VARS: &[&str] = &["CLAWCUSTOMOPENAI_API_KEY"];
const DASHSCOPE_ENV_VARS: &[&str] = &["DASHSCOPE_API_KEY"];
// Provider-specific request body size limits in bytes
@@ -81,18 +80,6 @@ impl OpenAiCompatConfig {
}
}
/// OpenAI-compatible endpoint with its own environment namespace and no default URL.
#[must_use]
pub const fn custom_openai() -> Self {
Self {
provider_name: "Custom OpenAI",
api_key_env: "CLAWCUSTOMOPENAI_API_KEY",
base_url_env: "CLAWCUSTOMOPENAI_BASE_URL",
default_base_url: "",
max_request_body_bytes: OPENAI_MAX_REQUEST_BODY_BYTES,
}
}
/// Alibaba `DashScope` compatible-mode endpoint (Qwen family models).
/// Uses the OpenAI-compatible REST shape at /compatible-mode/v1.
/// Requested via Discord #clawcode-get-help: native Alibaba API for
@@ -113,7 +100,6 @@ impl OpenAiCompatConfig {
match self.provider_name {
"xAI" => XAI_ENV_VARS,
"OpenAI" => OPENAI_ENV_VARS,
"Custom OpenAI" => CUSTOM_OPENAI_ENV_VARS,
"DashScope" => DASHSCOPE_ENV_VARS,
_ => &[],
}
+7 -168
View File
@@ -1131,9 +1131,6 @@ pub enum SlashCommand {
SecurityReview,
Keybindings,
PrivacySettings,
Workspace {
path: Option<String>,
},
Plan {
mode: Option<String>,
},
@@ -1275,7 +1272,6 @@ 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",
@@ -1409,9 +1405,6 @@ 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.",
@@ -5297,60 +5290,6 @@ 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,
@@ -5388,19 +5327,8 @@ pub fn handle_slash_command(
message: render_slash_command_help(),
session: session.clone(),
}),
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::Status
| SlashCommand::Bughunter { .. }
| SlashCommand::Commit
| SlashCommand::Pr { .. }
| SlashCommand::Issue { .. }
@@ -5411,13 +5339,14 @@ 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 { .. }
@@ -6190,24 +6119,6 @@ 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();
@@ -6256,6 +6167,7 @@ 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()
@@ -6287,6 +6199,7 @@ 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,
@@ -6305,6 +6218,7 @@ 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()
@@ -7266,79 +7180,4 @@ 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,11 +205,6 @@ 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()
@@ -2663,42 +2658,6 @@ 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();
+7 -511
View File
@@ -10,8 +10,6 @@ use regex::RegexBuilder;
use serde::{Deserialize, Serialize};
use walkdir::{DirEntry, WalkDir};
use crate::line_range_selector::{self, LineRangeSelector};
/// Maximum file size that can be read (10 MB).
const MAX_READ_SIZE: u64 = 10 * 1024 * 1024;
@@ -90,9 +88,6 @@ pub struct ReadFileOutput {
#[serde(rename = "type")]
pub kind: String,
pub file: TextFilePayload,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "truncationFooter")]
pub truncation_footer: Option<String>,
}
/// Structured patch hunk emitted by write and edit operations.
@@ -206,19 +201,6 @@ pub fn read_file(
path: &str,
offset: Option<usize>,
limit: Option<usize>,
) -> io::Result<ReadFileOutput> {
read_file_with_selector(path, offset, limit, None)
}
/// Reads a text file with optional GJC-style line range selector.
///
/// The selector takes precedence over offset/limit if provided.
/// Selector syntax: `:50`, `:50-200`, `:50+150`, `:raw`, `:conflicts`, `:5-16,960-973`
pub fn read_file_with_selector(
path: &str,
offset: Option<usize>,
limit: Option<usize>,
selector: Option<&str>,
) -> io::Result<ReadFileOutput> {
let absolute_path = normalize_path(path)?;
@@ -245,56 +227,11 @@ pub fn read_file_with_selector(
let content = fs::read_to_string(&absolute_path)?;
let lines: Vec<&str> = content.lines().collect();
let total_lines = lines.len();
// Special handling for conflicts selector
if selector == Some("conflicts") {
let conflicts = extract_merge_conflicts(&lines);
let (selected, num_lines) = if conflicts.is_empty() {
(format!("No unresolved git merge conflicts in {}.", path), 1)
} else {
let display_path = absolute_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(path);
let summary = format_conflict_summary(&conflicts, display_path);
let summary_lines = summary.lines().count();
(summary, summary_lines)
};
return Ok(ReadFileOutput {
kind: String::from("text"),
file: TextFilePayload {
file_path: absolute_path.to_string_lossy().into_owned(),
content: selected,
num_lines,
start_line: 1,
total_lines,
},
truncation_footer: None,
});
}
// Determine which lines to select
let (start_index, end_index, truncation_footer) = if let Some(sel) = selector {
// Parse and apply selector
let parsed = line_range_selector::parse_selector(sel)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e.to_string()))?;
apply_line_selector(&lines, parsed, path)
} else {
// Use offset/limit for backward compatibility
let start = offset.unwrap_or(0).min(lines.len());
let end = limit.map_or(lines.len(), |lim| {
start.saturating_add(lim).min(lines.len())
});
(start, end, None)
};
let selected = if start_index < end_index {
lines[start_index..end_index].join("\n")
} else {
String::new()
};
let start_index = offset.unwrap_or(0).min(lines.len());
let end_index = limit.map_or(lines.len(), |limit| {
start_index.saturating_add(limit).min(lines.len())
});
let selected = lines[start_index..end_index].join("\n");
Ok(ReadFileOutput {
kind: String::from("text"),
@@ -303,255 +240,11 @@ pub fn read_file_with_selector(
content: selected,
num_lines: end_index.saturating_sub(start_index),
start_line: start_index.saturating_add(1),
total_lines,
total_lines: lines.len(),
},
truncation_footer,
})
}
/// Represents a merge conflict block in a file.
#[derive(Debug, Clone, PartialEq, Eq)]
struct ConflictBlock {
start_line: usize, // 1-based line number where <<<<<<< starts
separator_line: usize, // 1-based line number where ======= is
end_line: usize, // 1-based line number where >>>>>>> is
base_line: Option<usize>, // 1-based line number where ||||||| is (for 3-way)
ours_label: Option<String>,
base_label: Option<String>,
theirs_label: Option<String>,
}
/// Scans lines for unresolved git merge conflict blocks.
fn extract_merge_conflicts(lines: &[&str]) -> Vec<ConflictBlock> {
let mut blocks = Vec::new();
let mut i = 0;
const OURS_PREFIX: &str = "<<<<<<<";
const BASE_PREFIX: &str = "|||||||";
const SEPARATOR: &str = "=======";
const THEIRS_PREFIX: &str = ">>>>>>>";
while i < lines.len() {
let line = lines[i];
// Check for start of conflict block
if let Some(label) = extract_marker_label(line, OURS_PREFIX) {
let ours_label = if label.is_empty() {
None
} else {
Some(label.to_string())
};
let start_line = i + 1; // Convert to 1-based
let mut separator_line = None;
let mut base_line = None;
let mut base_label = None;
let mut end_line = None;
let mut theirs_label = None;
let mut j = i + 1;
// Look for separator, optional base marker, and end marker
while j < lines.len() {
let current = lines[j];
// Check for base marker (3-way)
if let Some(label) = extract_marker_label(current, BASE_PREFIX) {
if base_line.is_none() {
base_line = Some(j + 1);
base_label = if label.is_empty() {
None
} else {
Some(label.to_string())
};
j += 1;
continue;
}
}
// Check for separator
if current.trim() == SEPARATOR {
if separator_line.is_none() {
separator_line = Some(j + 1);
j += 1;
continue;
}
}
// Check for end marker
if let Some(label) = extract_marker_label(current, THEIRS_PREFIX) {
if separator_line.is_some() {
end_line = Some(j + 1);
theirs_label = if label.is_empty() {
None
} else {
Some(label.to_string())
};
j += 1;
break;
}
}
j += 1;
}
// Only add if we found both separator and end marker
if let (Some(sep_line), Some(end)) = (separator_line, end_line) {
blocks.push(ConflictBlock {
start_line,
separator_line: sep_line,
end_line: end,
base_line,
ours_label,
base_label,
theirs_label,
});
i = j;
continue;
}
}
i += 1;
}
blocks
}
/// Extract the optional label from a marker line.
/// Returns the label text (stripped of leading/trailing spaces) if the line matches the prefix,
/// or None otherwise.
fn extract_marker_label<'a>(line: &'a str, prefix: &str) -> Option<&'a str> {
if line.starts_with(prefix) {
let rest = &line[prefix.len()..];
// Accept either just the prefix or prefix + single space + label
if rest.is_empty() {
Some("")
} else if rest.starts_with(' ') {
Some(rest[1..].trim())
} else {
None
}
} else {
None
}
}
/// Format conflict blocks as a summary index, like GJC's implementation.
fn format_conflict_summary(conflicts: &[ConflictBlock], display_path: &str) -> String {
let mut output = Vec::new();
let total = conflicts.len();
let word = if total == 1 { "conflict" } else { "conflicts" };
output.push(format!(
"⚠ {} unresolved {} in {}",
total, word, display_path
));
// Extract labels for header
let ours_label = conflicts.iter().find_map(|c| c.ours_label.as_ref());
let theirs_label = conflicts.iter().find_map(|c| c.theirs_label.as_ref());
let base_label = conflicts.iter().find_map(|c| c.base_label.as_ref());
let any_base = conflicts.iter().any(|c| c.base_line.is_some());
if let Some(label) = ours_label {
output.push(format!("- ours = {}", label));
}
if let Some(label) = theirs_label {
output.push(format!("- theirs = {}", label));
}
if any_base {
let base_name = base_label
.map(|l| l.to_string())
.unwrap_or_else(|| "(no label)".to_string());
output.push(format!("- base = {}", base_name));
}
output.push(
"NOTICE: Bulk-resolve with `write({ path: \"conflict://*\", content })`, or address a single block with `write({ path: \"conflict://<N>\", content })`. Inspect a block by reading `conflict://<N>` (add `/ours` / `/theirs` / `/base` for a single side).".to_string()
);
output.push(
"`content` shorthand: `@ours` / `@theirs` / `@base` / `@both` lines expand to the recorded sections; `@both` = ours-then-theirs. Non-token lines pass through verbatim.".to_string()
);
output.push(String::new());
// Format each conflict as a one-liner index
let id_width = total.to_string().len();
for (idx, conflict) in conflicts.iter().enumerate() {
let id = idx + 1;
let range = if conflict.start_line == conflict.end_line {
format!("L{}", conflict.start_line)
} else {
format!("L{}-{}", conflict.start_line, conflict.end_line)
};
let kind = if conflict.base_line.is_some() {
" (3-way)"
} else {
""
};
output.push(format!(
"#{:>width$} {}{}",
id,
range,
kind,
width = id_width
));
}
output.join("\n")
}
/// Apply a parsed line selector to file lines and return the selected range.
///
/// Returns (start_index, end_index, truncation_footer).
fn apply_line_selector(
lines: &[&str],
selector: LineRangeSelector,
_file_path: &str,
) -> (usize, usize, Option<String>) {
match selector {
LineRangeSelector::Raw => {
// Return entire file without truncation footer for raw mode
(0, lines.len(), None)
}
LineRangeSelector::Conflicts => {
// Conflicts are handled specially in read_file_with_selector, so this shouldn't be reached
(0, lines.len(), None)
}
LineRangeSelector::Lines(ranges) => {
// Collect all lines specified by ranges
let mut selected_indices = std::collections::BTreeSet::new();
for range in ranges {
let (start_zero, end_zero) = range.zero_indexed();
for i in start_zero..=end_zero.min(lines.len().saturating_sub(1)) {
selected_indices.insert(i);
}
}
if selected_indices.is_empty() {
return (0, 0, None);
}
// Find contiguous blocks and return the first one (or could merge all)
let indices: Vec<usize> = selected_indices.iter().copied().collect();
let first = indices[0];
let last = indices[indices.len() - 1];
// Check if it's a contiguous range
let is_contiguous = (last - first + 1) == indices.len();
if is_contiguous {
(first, last + 1, None)
} else {
// Return all selected indices as one continuous block from first to last
// In the future, this could be improved to show multiple blocks
(first, last + 1, None)
}
}
LineRangeSelector::None => {
// No selector, return entire file
(0, lines.len(), None)
}
}
}
/// Replaces a file's contents and returns patch metadata.
pub fn write_file(path: &str, content: &str) -> io::Result<WriteFileOutput> {
if content.len() > MAX_WRITE_SIZE {
@@ -1022,23 +715,11 @@ pub fn read_file_in_workspace(
offset: Option<usize>,
limit: Option<usize>,
workspace_root: &Path,
) -> io::Result<ReadFileOutput> {
read_file_in_workspace_with_selector(path, offset, limit, None, workspace_root)
}
/// Read a file with workspace boundary enforcement and optional line range selector.
#[allow(dead_code)]
pub fn read_file_in_workspace_with_selector(
path: &str,
offset: Option<usize>,
limit: Option<usize>,
selector: Option<&str>,
workspace_root: &Path,
) -> io::Result<ReadFileOutput> {
let absolute_path = normalize_path(path)?;
let canonical_root = canonicalize_workspace_root(workspace_root);
validate_workspace_boundary(&absolute_path, &canonical_root)?;
read_file_with_selector(path, offset, limit, selector)
read_file(path, offset, limit)
}
/// Write a file with workspace boundary enforcement.
@@ -1502,189 +1183,4 @@ mod tests {
assert!(component_contains_glob("*.rs"));
assert!(!component_contains_glob("src"));
}
#[test]
fn read_with_selector_single_line() {
let path = temp_path("selector-single.txt");
write_file(
path.to_string_lossy().as_ref(),
"line1\nline2\nline3\nline4\nline5",
)
.expect("write should succeed");
let output =
super::read_file_with_selector(path.to_string_lossy().as_ref(), None, None, Some("2"))
.expect("read with selector should succeed");
assert_eq!(output.file.content, "line2");
assert_eq!(output.file.start_line, 2);
assert_eq!(output.file.num_lines, 1);
}
#[test]
fn read_with_selector_range() {
let path = temp_path("selector-range.txt");
write_file(
path.to_string_lossy().as_ref(),
"line1\nline2\nline3\nline4\nline5",
)
.expect("write should succeed");
let output = super::read_file_with_selector(
path.to_string_lossy().as_ref(),
None,
None,
Some("2-4"),
)
.expect("read with selector should succeed");
assert_eq!(output.file.content, "line2\nline3\nline4");
assert_eq!(output.file.start_line, 2);
assert_eq!(output.file.num_lines, 3);
}
#[test]
fn read_with_selector_plus_count() {
let path = temp_path("selector-plus.txt");
write_file(
path.to_string_lossy().as_ref(),
"line1\nline2\nline3\nline4\nline5",
)
.expect("write should succeed");
let output = super::read_file_with_selector(
path.to_string_lossy().as_ref(),
None,
None,
Some("2+2"),
)
.expect("read with selector should succeed");
assert_eq!(output.file.content, "line2\nline3");
assert_eq!(output.file.start_line, 2);
assert_eq!(output.file.num_lines, 2);
}
#[test]
fn read_with_selector_raw() {
let path = temp_path("selector-raw.txt");
write_file(
path.to_string_lossy().as_ref(),
"line1\nline2\nline3\nline4\nline5",
)
.expect("write should succeed");
let output = super::read_file_with_selector(
path.to_string_lossy().as_ref(),
None,
None,
Some("raw"),
)
.expect("read with selector should succeed");
assert_eq!(output.file.content, "line1\nline2\nline3\nline4\nline5");
assert_eq!(output.file.start_line, 1);
assert_eq!(output.file.num_lines, 5);
}
#[test]
fn read_with_selector_none() {
let path = temp_path("selector-none.txt");
write_file(
path.to_string_lossy().as_ref(),
"line1\nline2\nline3\nline4\nline5",
)
.expect("write should succeed");
let output =
super::read_file_with_selector(path.to_string_lossy().as_ref(), None, None, None)
.expect("read with selector should succeed");
assert_eq!(output.file.content, "line1\nline2\nline3\nline4\nline5");
assert_eq!(output.file.start_line, 1);
assert_eq!(output.file.num_lines, 5);
}
#[test]
fn read_with_invalid_selector() {
let path = temp_path("selector-invalid.txt");
write_file(
path.to_string_lossy().as_ref(),
"line1\nline2\nline3\nline4\nline5",
)
.expect("write should succeed");
let result = super::read_file_with_selector(
path.to_string_lossy().as_ref(),
None,
None,
Some("invalid-selector"),
);
assert!(result.is_err());
}
#[test]
fn read_with_selector_conflicts_no_conflicts() {
let path = temp_path("conflicts-none.txt");
write_file(
path.to_string_lossy().as_ref(),
"const a = 1;\nconst b = 2;\n",
)
.expect("write should succeed");
let output = super::read_file_with_selector(
path.to_string_lossy().as_ref(),
None,
None,
Some("conflicts"),
)
.expect("read with conflicts selector should succeed");
assert!(output
.file
.content
.contains("No unresolved git merge conflicts"));
}
#[test]
fn read_with_selector_conflicts_two_way() {
let path = temp_path("conflicts-two-way.txt");
let content = "<<<<<<< A\na-ours\n=======\na-theirs\n>>>>>>> A\nmiddle\n<<<<<<< B\nb-ours\n=======\nb-theirs\n>>>>>>> B\ntail";
write_file(path.to_string_lossy().as_ref(), content).expect("write should succeed");
let output = super::read_file_with_selector(
path.to_string_lossy().as_ref(),
None,
None,
Some("conflicts"),
)
.expect("read with conflicts selector should succeed");
let text = &output.file.content;
// Should report 2 conflicts
assert!(text.contains("2 unresolved conflicts"), "text: {}", text);
// Should have indices for both blocks
assert!(text.contains("#1"), "missing #1: {}", text);
assert!(text.contains("#2"), "missing #2: {}", text);
// Should have line ranges
assert!(text.contains("L1-5"), "missing L1-5: {}", text);
assert!(text.contains("L7-11"), "missing L7-11: {}", text);
}
#[test]
fn read_with_selector_conflicts_three_way() {
let path = temp_path("conflicts-three-way.txt");
let content = "head\n<<<<<<< HEAD\nours body\n||||||| common ancestor\nbase body\n=======\ntheirs body\n>>>>>>> feat\ntail";
write_file(path.to_string_lossy().as_ref(), content).expect("write should succeed");
let output = super::read_file_with_selector(
path.to_string_lossy().as_ref(),
None,
None,
Some("conflicts"),
)
.expect("read with conflicts selector should succeed");
let text = &output.file.content;
// Should report 1 conflict
assert!(text.contains("1 unresolved conflict"), "text: {}", text);
// Should mark as 3-way
assert!(text.contains("3-way"), "missing 3-way marker: {}", text);
// Should show line range
assert!(text.contains("L2-8"), "missing L2-8: {}", text);
}
}
+236 -356
View File
@@ -49,10 +49,10 @@ impl JsonValue {
.join(",");
format!("[{rendered}]")
}
Self::Object(map) => {
let rendered = map
Self::Object(entries) => {
let rendered = entries
.iter()
.map(|(k, v)| format!("{}:{}", render_string(k), v.render()))
.map(|(key, value)| format!("{}:{}", render_string(key), value.render()))
.collect::<Vec<_>>()
.join(",");
format!("{{{rendered}}}")
@@ -60,26 +60,21 @@ impl JsonValue {
}
}
#[must_use]
pub fn as_bool(&self) -> Option<bool> {
match self {
Self::Bool(b) => Some(*b),
_ => None,
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_i64(&self) -> Option<i64> {
pub fn as_object(&self) -> Option<&BTreeMap<String, JsonValue>> {
match self {
Self::Number(n) => Some(*n),
_ => None,
}
}
#[must_use]
pub fn as_str(&self) -> Option<&str> {
match self {
Self::String(s) => Some(s),
Self::Object(value) => Some(value),
_ => None,
}
}
@@ -87,354 +82,273 @@ impl JsonValue {
#[must_use]
pub fn as_array(&self) -> Option<&[JsonValue]> {
match self {
Self::Array(a) => Some(a),
Self::Array(value) => Some(value),
_ => None,
}
}
#[must_use]
pub fn as_object(&self) -> Option<&BTreeMap<String, JsonValue>> {
pub fn as_str(&self) -> Option<&str> {
match self {
Self::Object(o) => Some(o),
Self::String(value) => Some(value),
_ => 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"));
#[must_use]
pub fn as_bool(&self) -> Option<bool> {
match self {
Self::Bool(value) => Some(*value),
_ => None,
}
}
#[must_use]
pub fn as_i64(&self) -> Option<i64> {
match self {
Self::Number(value) => Some(*value),
_ => 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,
}
}
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")),
}
}
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}"
)));
}
}
Ok(value)
}
}
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_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"))
}
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_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")),
}
}
}
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_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"))
}
while let Some(&c) = chars.peek() {
if c.is_ascii_digit() {
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
} else {
break;
false
}
}
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 skip_whitespace(&mut self) {
while matches!(self.peek(), Some(' ' | '\n' | '\r' | '\t')) {
self.index += 1;
}
}
}
fn parse_object(chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<JsonValue, JsonError> {
if chars.next() != Some('{') {
return Err(JsonError::new("expected {"));
fn peek(&self) -> Option<char> {
self.chars.get(self.index).copied()
}
let mut map = BTreeMap::new();
skip_whitespace(chars);
if chars.peek() == Some(&'}') {
chars.next();
return Ok(JsonValue::Object(map));
fn next(&mut self) -> Option<char> {
let ch = self.peek()?;
self.index += 1;
Some(ch)
}
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 is_eof(&self) -> bool {
self.index >= self.chars.len()
}
}
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::*;
use super::{render_string, JsonValue};
use std::collections::BTreeMap;
#[test]
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(), "{}");
fn renders_and_parses_json_values() {
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}");
}
#[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("flag".to_string(), JsonValue::Bool(true));
object.insert(
"tags".to_string(),
JsonValue::Array(vec![JsonValue::Bool(true), JsonValue::Null]),
"items".to_string(),
JsonValue::Array(vec![
JsonValue::Number(4),
JsonValue::String("ok".to_string()),
]),
);
let original = JsonValue::Object(object);
let reparsed = JsonValue::parse(&original.render()).expect("rendered json should re-parse");
let rendered = JsonValue::Object(object).render();
let parsed = JsonValue::parse(&rendered).expect("json should parse");
assert_eq!(reparsed, original);
assert_eq!(parsed.as_object().expect("object").len(), 2);
}
#[test]
@@ -519,7 +433,6 @@ 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);
}
@@ -558,37 +471,4 @@ 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");
}
}
+1 -3
View File
@@ -20,7 +20,6 @@ pub mod green_contract;
mod hooks;
mod json;
mod lane_events;
pub mod line_range_selector;
pub mod lsp_client;
mod mcp;
mod mcp_client;
@@ -88,8 +87,7 @@ pub use conversation::{
};
pub use file_ops::{
edit_file, edit_file_in_workspace, glob_search, glob_search_in_workspace, grep_search,
grep_search_in_workspace, read_file, read_file_in_workspace,
read_file_in_workspace_with_selector, read_file_with_selector, write_file,
grep_search_in_workspace, read_file, read_file_in_workspace, write_file,
write_file_in_workspace, EditFileOutput, GlobSearchOutput, GrepSearchInput, GrepSearchOutput,
ReadFileOutput, StructuredPatchHunk, TextFilePayload, WriteFileOutput,
};
@@ -1,347 +0,0 @@
/// GJC-compatible line range selector parsing for the read tool.
///
/// Supports selector syntax:
/// - `:50` = start at line 50
/// - `:50-200` = lines 50-200 inclusive
/// - `:50+150` = 150 lines starting at 50
/// - `:50+1` = exactly one line
/// - `:5-16,960-973` = multiple ranges
/// - `:raw` = full verbatim
/// - `:conflicts` = git merge conflicts only
use std::num::ParseIntError;
/// Parsed line range specification.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LineRangeSelector {
/// Full file verbatim (`:raw`)
Raw,
/// Git merge conflict sections only (`:conflicts`)
Conflicts,
/// Line range(s) (`:L-L+N` syntax)
Lines(Vec<LineRange>),
/// No selector (full file, may be truncated by default budget)
None,
}
/// A single line range, zero-indexed internally but displayed as 1-indexed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LineRange {
/// Start line (1-indexed, user-facing)
pub start_line: usize,
/// End line inclusive (1-indexed, user-facing)
pub end_line: usize,
}
impl LineRange {
/// Create a new line range (1-indexed).
pub fn new(start_line: usize, end_line: usize) -> Self {
Self {
start_line,
end_line,
}
}
/// Convert to zero-indexed range for internal use.
pub fn zero_indexed(&self) -> (usize, usize) {
(
self.start_line.saturating_sub(1),
self.end_line.saturating_sub(1),
)
}
/// Get the number of lines in this range.
pub fn len(&self) -> usize {
if self.start_line <= self.end_line {
self.end_line - self.start_line + 1
} else {
0
}
}
/// Check if this range is valid.
pub fn is_valid(&self) -> bool {
self.start_line > 0 && self.end_line > 0 && self.start_line <= self.end_line
}
}
/// Parse error types for line range selectors.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParseError {
InvalidFormat(String),
InvalidNumber(ParseIntError),
InvalidRange(String),
}
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ParseError::InvalidFormat(msg) => write!(f, "Invalid format: {}", msg),
ParseError::InvalidNumber(e) => write!(f, "Invalid number: {}", e),
ParseError::InvalidRange(msg) => write!(f, "Invalid range: {}", msg),
}
}
}
impl std::error::Error for ParseError {}
impl From<ParseIntError> for ParseError {
fn from(e: ParseIntError) -> Self {
ParseError::InvalidNumber(e)
}
}
/// Parse a line range selector string.
///
/// Examples:
/// - `"50"` → `LineRangeSelector::Lines(vec![LineRange::new(50, 50)])`
/// - `"50-200"` → `LineRangeSelector::Lines(vec![LineRange::new(50, 200)])`
/// - `"50+150"` → `LineRangeSelector::Lines(vec![LineRange::new(50, 199)])`
/// - `"raw"` → `LineRangeSelector::Raw`
/// - `"conflicts"` → `LineRangeSelector::Conflicts`
pub fn parse_selector(selector: &str) -> Result<LineRangeSelector, ParseError> {
if selector.is_empty() {
return Ok(LineRangeSelector::None);
}
match selector {
"raw" => Ok(LineRangeSelector::Raw),
"conflicts" => Ok(LineRangeSelector::Conflicts),
_ => parse_line_ranges(selector),
}
}
/// Parse comma-separated line ranges.
fn parse_line_ranges(selector: &str) -> Result<LineRangeSelector, ParseError> {
let mut ranges = Vec::new();
for range_str in selector.split(',') {
let range_str = range_str.trim();
if range_str.is_empty() {
continue;
}
let range = parse_single_range(range_str)?;
ranges.push(range);
}
if ranges.is_empty() {
Ok(LineRangeSelector::None)
} else {
// Merge overlapping/adjacent ranges
ranges.sort_by_key(|r| r.start_line);
let merged = merge_ranges(ranges);
Ok(LineRangeSelector::Lines(merged))
}
}
/// Parse a single line range in formats: "50", "50-200", "50+150".
fn parse_single_range(range_str: &str) -> Result<LineRange, ParseError> {
// Try "50+150" format first (start+count)
if let Some(plus_idx) = range_str.find('+') {
let start_str = &range_str[..plus_idx];
let count_str = &range_str[plus_idx + 1..];
if start_str.is_empty() || count_str.is_empty() {
return Err(ParseError::InvalidFormat(range_str.to_string()));
}
let start = start_str.parse::<usize>()?;
let count = count_str.parse::<usize>()?;
if start == 0 || count == 0 {
return Err(ParseError::InvalidRange(
"Line numbers and counts must be >= 1".to_string(),
));
}
return Ok(LineRange::new(start, start + count - 1));
}
// Try "50-200" format (start-end)
if let Some(dash_idx) = range_str.find('-') {
// Skip leading dash (negative numbers aren't valid here)
if dash_idx == 0 {
return Err(ParseError::InvalidFormat(range_str.to_string()));
}
let start_str = &range_str[..dash_idx];
let end_str = &range_str[dash_idx + 1..];
if start_str.is_empty() || end_str.is_empty() {
return Err(ParseError::InvalidFormat(range_str.to_string()));
}
let start = start_str.parse::<usize>()?;
let end = end_str.parse::<usize>()?;
if start == 0 || end == 0 {
return Err(ParseError::InvalidRange(
"Line numbers must be >= 1".to_string(),
));
}
if start > end {
return Err(ParseError::InvalidRange(format!(
"Start line {} cannot be greater than end line {}",
start, end
)));
}
return Ok(LineRange::new(start, end));
}
// Try plain "50" format (single line)
let line_num = range_str.parse::<usize>()?;
if line_num == 0 {
return Err(ParseError::InvalidRange(
"Line number must be >= 1".to_string(),
));
}
Ok(LineRange::new(line_num, line_num))
}
/// Merge overlapping and adjacent ranges.
fn merge_ranges(ranges: Vec<LineRange>) -> Vec<LineRange> {
let mut merged: Vec<LineRange> = Vec::new();
for range in ranges {
if let Some(last) = merged.last_mut() {
// If ranges overlap or are adjacent, merge them
if range.start_line <= last.end_line + 1 {
last.end_line = last.end_line.max(range.end_line);
} else {
merged.push(range);
}
} else {
merged.push(range);
}
}
merged
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_raw() {
assert_eq!(parse_selector("raw").unwrap(), LineRangeSelector::Raw);
}
#[test]
fn test_parse_conflicts() {
assert_eq!(
parse_selector("conflicts").unwrap(),
LineRangeSelector::Conflicts
);
}
#[test]
fn test_parse_single_line() {
match parse_selector("50").unwrap() {
LineRangeSelector::Lines(ranges) => {
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].start_line, 50);
assert_eq!(ranges[0].end_line, 50);
}
_ => panic!("Expected Lines variant"),
}
}
#[test]
fn test_parse_range_start_dash_end() {
match parse_selector("50-200").unwrap() {
LineRangeSelector::Lines(ranges) => {
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].start_line, 50);
assert_eq!(ranges[0].end_line, 200);
}
_ => panic!("Expected Lines variant"),
}
}
#[test]
fn test_parse_range_start_plus_count() {
match parse_selector("50+150").unwrap() {
LineRangeSelector::Lines(ranges) => {
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].start_line, 50);
assert_eq!(ranges[0].end_line, 199);
}
_ => panic!("Expected Lines variant"),
}
}
#[test]
fn test_parse_multiple_ranges() {
match parse_selector("5-16,960-973").unwrap() {
LineRangeSelector::Lines(ranges) => {
assert_eq!(ranges.len(), 2);
assert_eq!(ranges[0].start_line, 5);
assert_eq!(ranges[0].end_line, 16);
assert_eq!(ranges[1].start_line, 960);
assert_eq!(ranges[1].end_line, 973);
}
_ => panic!("Expected Lines variant"),
}
}
#[test]
fn test_merge_overlapping_ranges() {
// "5-16,10-20" should merge into "5-20"
match parse_selector("5-16,10-20").unwrap() {
LineRangeSelector::Lines(ranges) => {
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].start_line, 5);
assert_eq!(ranges[0].end_line, 20);
}
_ => panic!("Expected Lines variant"),
}
}
#[test]
fn test_merge_adjacent_ranges() {
// "5-16,17-20" should merge into "5-20"
match parse_selector("5-16,17-20").unwrap() {
LineRangeSelector::Lines(ranges) => {
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].start_line, 5);
assert_eq!(ranges[0].end_line, 20);
}
_ => panic!("Expected Lines variant"),
}
}
#[test]
fn test_invalid_range_start_greater_than_end() {
let result = parse_selector("200-50");
assert!(result.is_err());
}
#[test]
fn test_invalid_zero_line() {
let result = parse_selector("0");
assert!(result.is_err());
}
#[test]
fn test_empty_selector() {
assert_eq!(parse_selector("").unwrap(), LineRangeSelector::None);
}
#[test]
fn test_line_range_zero_indexed() {
let range = LineRange::new(50, 200);
let (start, end) = range.zero_indexed();
assert_eq!(start, 49);
assert_eq!(end, 199);
}
#[test]
fn test_line_range_len() {
assert_eq!(LineRange::new(50, 200).len(), 151);
assert_eq!(LineRange::new(50, 50).len(), 1);
}
}
+42 -430
View File
@@ -6715,49 +6715,6 @@ 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.
@@ -8259,7 +8216,6 @@ 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,
@@ -8418,51 +8374,6 @@ 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)
@@ -9303,41 +9214,6 @@ 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>> {
@@ -12708,7 +12584,6 @@ 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(
@@ -12720,7 +12595,6 @@ fn build_runtime_with_plugin_state(
allowed_tools.clone(),
tool_registry.clone(),
progress_reporter,
provider_config,
)?,
CliToolExecutor::new(
allowed_tools.clone(),
@@ -12893,87 +12767,10 @@ 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. 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,
"custom-openai" => api::ProviderConfigKind::CustomOpenAi,
"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("custom-openai") => Some(api::ProviderConfigKind::CustomOpenAi),
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
}
}
}
}
// 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.
struct AnthropicRuntimeClient {
runtime: tokio::runtime::Runtime,
client: ApiProviderClient,
@@ -12998,65 +12795,47 @@ 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>> {
// Explicit persisted provider configuration takes precedence.
// When absent, preserve the existing model-based provider detection.
// 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.
let resolved_model = api::resolve_model_alias(&model);
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
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 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)?
}
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)?
}
};
Ok(Self {
@@ -19653,173 +19432,6 @@ 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("custom-openai/local-model", &config.model),
Some(api::ProviderConfigKind::CustomOpenAi)
);
assert_eq!(
super::explicit_provider_kind_for_model("CUSTOM-OPENAI/local-model", &config.model),
Some(api::ProviderConfigKind::CustomOpenAi)
);
assert_eq!(
super::explicit_provider_kind_for_model(
"custom-openai/local-model",
"custom-openai/local-model"
),
None,
"matching custom model must keep the persisted provider kind"
);
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_custom_openai_configuration_survives_runtime_boundary() {
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": "custom-openai",
"apiKey": "custom-test-key",
"baseUrl": "https://custom.example/v1",
"model": "local-model"
},
"model": "local-model"
}"#,
)
.expect("write provider settings");
let loader = ConfigLoader::new(&workspace, &config_home);
let runtime_config = loader.load().expect("runtime config should load");
let provider_config = super::provider_config_from_runtime(runtime_config.provider())
.expect("custom provider configuration should adapt")
.expect("persisted provider configuration should be present");
assert_eq!(provider_config.kind, api::ProviderConfigKind::CustomOpenAi);
assert_eq!(provider_config.model, "local-model");
assert_eq!(provider_config.api_key.as_deref(), Some("custom-test-key"));
assert_eq!(
provider_config.base_url.as_deref(),
Some("https://custom.example/v1")
);
let _ = fs::remove_dir_all(config_home);
let _ = fs::remove_dir_all(workspace);
}
#[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() {
@@ -9,7 +9,7 @@ const PROVIDERS: &[(&str, &str, &str)] = &[
("2", "xAI / Grok", "xai"),
("3", "OpenAI", "openai"),
("4", "DashScope (Qwen/Kimi)", "dashscope"),
("5", "Custom (OpenAI-compat)", "custom-openai"),
("5", "Custom (OpenAI-compat)", "openai"),
];
const PROVIDER_MODELS: &[(&str, &[&str])] = &[
@@ -27,8 +27,6 @@ const DEFAULT_BASE_URLS: &[(&str, &str)] = &[
"dashscope",
"https://dashscope.aliyuncs.com/compatible-mode/v1",
),
// Custom OpenAI has no default; user must supply.
("custom-openai", ""),
];
const API_KEY_ENV_VARS: &[(&str, &str)] = &[
@@ -36,7 +34,6 @@ const API_KEY_ENV_VARS: &[(&str, &str)] = &[
("xai", "XAI_API_KEY"),
("openai", "OPENAI_API_KEY"),
("dashscope", "DASHSCOPE_API_KEY"),
("custom-openai", "CLAWCUSTOMOPENAI_API_KEY"),
];
pub fn run_setup_wizard() -> Result<(), Box<dyn std::error::Error>> {
@@ -180,7 +177,6 @@ fn prompt_base_url(
"xai" => "XAI_BASE_URL",
"openai" => "OPENAI_BASE_URL",
"dashscope" => "DASHSCOPE_BASE_URL",
"custom-openai" => "CLAWCUSTOMOPENAI_BASE_URL",
_ => "BASE_URL",
};
let env_set = std::env::var(env_var).ok().is_some_and(|v| !v.is_empty());
@@ -218,11 +214,7 @@ fn prompt_model(
if !aliases.is_empty() {
println!(" Common: {}", aliases.join(", "));
}
if kind == "custom-openai" {
println!(" Or enter any model name (e.g. custom/gpt-4.1 for custom routing)");
} else {
println!(" Or enter any model name (e.g. openai/gpt-4.1-mini for custom routing)");
}
println!(" Or enter any model name (e.g. openai/gpt-4.1-mini for custom routing)");
let input = read_line(&format!(" Model [{current_model}]: "))?;
if input.trim().is_empty() {
-18
View File
@@ -1,18 +0,0 @@
[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
@@ -1,130 +0,0 @@
//! 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
@@ -1,258 +0,0 @@
//! 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
@@ -1,20 +0,0 @@
//! 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
@@ -1,251 +0,0 @@
//! 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()));
}
}
+3 -47
View File
@@ -20,7 +20,7 @@ use runtime::{
lsp_client::LspRegistry,
mcp_tool_bridge::McpToolRegistry,
permission_enforcer::{EnforcementResult, PermissionEnforcer},
read_file_in_workspace_with_selector,
read_file_in_workspace,
summary_compression::compress_summary_text,
task_registry::TaskRegistry,
team_cron_registry::{CronRegistry, TeamRegistry},
@@ -2569,14 +2569,8 @@ fn branch_divergence_output(
fn run_read_file(input: ReadFileInput) -> Result<String, String> {
let workspace = std::env::current_dir().map_err(|error| error.to_string())?;
to_pretty_json(
read_file_in_workspace_with_selector(
&input.path,
input.offset,
input.limit,
input.selector.as_deref(),
&workspace,
)
.map_err(io_to_string)?,
read_file_in_workspace(&input.path, input.offset, input.limit, &workspace)
.map_err(io_to_string)?,
)
}
@@ -2891,7 +2885,6 @@ struct ReadFileInput {
path: String,
offset: Option<usize>,
limit: Option<usize>,
selector: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -10697,43 +10690,6 @@ printf 'pwsh:%s' "$1"
let _ = fs::remove_dir_all(root);
}
#[test]
fn given_read_only_enforcer_when_read_file_with_selector_then_succeeds() {
let _guard = env_lock()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let root = temp_path("perm-read-selector");
fs::create_dir_all(&root).expect("create root");
let file = root.join("multi-line.txt");
fs::write(&file, "line1\nline2\nline3\nline4\nline5\n").expect("write test file");
let original_dir = std::env::current_dir().expect("cwd");
std::env::set_current_dir(&root).expect("set cwd");
let registry = read_only_registry();
let result = registry.execute(
"read_file",
&json!({
"path": file.display().to_string(),
"selector": "2-4"
}),
);
assert!(
result.is_ok(),
"read_file with selector should succeed: {result:?}"
);
// Verify the output contains the expected lines
let output: serde_json::Value =
serde_json::from_str(&result.unwrap()).expect("json output");
let content = output["file"]["content"].as_str().expect("content field");
assert!(content.contains("line2"));
assert!(content.contains("line3"));
assert!(content.contains("line4"));
std::env::set_current_dir(&original_dir).expect("restore cwd");
let _ = fs::remove_dir_all(root);
}
#[test]
fn given_read_only_enforcer_when_glob_search_then_not_permission_denied() {
let registry = read_only_registry();