mirror of
https://github.com/instructkr/claw-code.git
synced 2026-06-09 16:58:25 +02:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a13b1c2825 | |||
| 27e46d7ea6 | |||
| da3c231640 | |||
| 44d75cccdb | |||
| ca5fb61d42 | |||
| 3a1833e444 | |||
| 4599c39a28 | |||
| efac48ae2a | |||
| ebef38e844 | |||
| 090350c374 | |||
| 9b8c44285b |
@@ -33,27 +33,6 @@
|
||||
|
||||
---
|
||||
|
||||
## Rust Port
|
||||
|
||||
The Rust workspace under `rust/` is the current systems-language port of the project.
|
||||
|
||||
It currently includes:
|
||||
|
||||
- `crates/api-client` — API client with provider abstraction, OAuth, and streaming support
|
||||
- `crates/runtime` — session state, compaction, MCP orchestration, prompt construction
|
||||
- `crates/tools` — tool manifest definitions and execution framework
|
||||
- `crates/commands` — slash commands, skills discovery, and config inspection
|
||||
- `crates/plugins` — plugin model, hook pipeline, and bundled plugins
|
||||
- `crates/compat-harness` — compatibility layer for upstream editor integration
|
||||
- `crates/claw-cli` — interactive REPL, markdown rendering, and project bootstrap/init flows
|
||||
|
||||
Run the Rust build:
|
||||
|
||||
```bash
|
||||
cd rust
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
## Backstory
|
||||
|
||||
At 4 AM on March 31, 2026, I woke up to my phone blowing up with notifications. The Claw Code source had been exposed, and the entire dev community was in a frenzy. My girlfriend in Korea was genuinely worried I might face legal action from the original authors just for having the code on my machine — so I did what any engineer would do under pressure: I sat down, ported the core features to Python from scratch, and pushed it before the sun came up.
|
||||
@@ -62,8 +41,6 @@ The whole thing was orchestrated end-to-end using [oh-my-codex (OmX)](https://gi
|
||||
|
||||
The result is a clean-room Python rewrite that captures the architectural patterns of Claw Code's agent harness without copying any proprietary source. I'm now actively collaborating with [@bellman_ych](https://x.com/bellman_ych) — the creator of OmX himself — to push this further. The basic Python foundation is already in place and functional, but we're just getting started. **Stay tuned — a much more capable version is on the way.**
|
||||
|
||||
The Rust port was developed with both [oh-my-codex (OmX)](https://github.com/Yeachan-Heo/oh-my-codex) and [oh-my-opencode (OmO)](https://github.com/code-yeongyu/oh-my-openagent): OmX drove scaffolding, orchestration, and architecture direction, while OmO was used for later implementation acceleration and verification support.
|
||||
|
||||
https://github.com/instructkr/claw-code
|
||||
|
||||

|
||||
@@ -115,15 +92,6 @@ This repository now focuses on Python porting work instead.
|
||||
│ ├── query_engine.py
|
||||
│ ├── task.py
|
||||
│ └── tools.py
|
||||
├── rust/ # Rust port (claw CLI)
|
||||
│ ├── crates/api/ # API client + streaming
|
||||
│ ├── crates/runtime/ # Session, tools, MCP, config
|
||||
│ ├── crates/claw-cli/ # Interactive CLI binary
|
||||
│ ├── crates/plugins/ # Plugin system
|
||||
│ ├── crates/commands/ # Slash commands
|
||||
│ ├── crates/server/ # HTTP/SSE server (axum)
|
||||
│ ├── crates/lsp/ # LSP client integration
|
||||
│ └── crates/tools/ # Tool specs
|
||||
├── tests/ # Python verification
|
||||
├── assets/omx/ # OmX workflow screenshots
|
||||
├── 2026-03-09-is-legal-the-same-as-legitimate-ai-reimplementation-and-the-erosion-of-copyleft.md
|
||||
@@ -184,19 +152,14 @@ python3 -m src.main tools --limit 10
|
||||
|
||||
The port now mirrors the archived root-entry file surface, top-level subsystem names, and command/tool inventories much more closely than before. However, it is **not yet** a full runtime-equivalent replacement for the original TypeScript system; the Python tree still contains fewer executable runtime slices than the archived source.
|
||||
|
||||
## Built with `oh-my-codex` and `oh-my-opencode`
|
||||
|
||||
This repository's porting, cleanroom hardening, and verification workflow was AI-assisted with Yeachan Heo's tooling stack, with **oh-my-codex (OmX)** as the primary scaffolding and orchestration layer.
|
||||
## Built with `oh-my-codex`
|
||||
|
||||
- [**oh-my-codex (OmX)**](https://github.com/Yeachan-Heo/oh-my-codex) — scaffolding, orchestration, architecture direction, and core porting workflow
|
||||
- [**oh-my-opencode (OmO)**](https://github.com/code-yeongyu/oh-my-openagent) — implementation acceleration, cleanup, and verification support
|
||||
The restructuring and documentation work on this repository was AI-assisted and orchestrated with Yeachan Heo's [oh-my-codex (OmX)](https://github.com/Yeachan-Heo/oh-my-codex), layered on top of Codex.
|
||||
|
||||
Key workflow patterns used during the port:
|
||||
|
||||
- **`$team` mode:** coordinated parallel review and architectural feedback
|
||||
- **`$ralph` mode:** persistent execution, verification, and completion discipline
|
||||
- **Cleanroom passes:** naming/branding cleanup, QA, and release validation across the Rust workspace
|
||||
- **Manual and live validation:** build, test, manual QA, and real API-path verification before publish
|
||||
- **`$team` mode:** used for coordinated parallel review and architectural feedback
|
||||
- **`$ralph` mode:** used for persistent execution, verification, and completion discipline
|
||||
- **Codex-driven workflow:** used to turn the main `src/` tree into a Python-first porting workspace
|
||||
|
||||
### OmX workflow screenshots
|
||||
|
||||
|
||||
Generated
+11
-222
@@ -28,86 +28,12 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-stream"
|
||||
version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
|
||||
dependencies = [
|
||||
"async-stream-impl",
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-stream-impl"
|
||||
version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atomic-waker"
|
||||
version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
|
||||
dependencies = [
|
||||
"axum-core",
|
||||
"bytes",
|
||||
"form_urlencoded",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"itoa",
|
||||
"matchit",
|
||||
"memchr",
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"serde_core",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-core"
|
||||
version = "0.5.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
"pin-project-lite",
|
||||
"sync_wrapper",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
@@ -123,12 +49,6 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.0"
|
||||
@@ -247,7 +167,7 @@ version = "0.28.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags",
|
||||
"crossterm_winapi",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
@@ -342,7 +262,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -361,15 +281,6 @@ dependencies = [
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fluent-uri"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
@@ -407,17 +318,6 @@ version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.32"
|
||||
@@ -438,7 +338,6 @@ checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-macro",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"memchr",
|
||||
@@ -552,12 +451,6 @@ version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.9.0"
|
||||
@@ -571,7 +464,6 @@ dependencies = [
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"smallvec",
|
||||
@@ -816,48 +708,12 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "lsp"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"lsp-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lsp-types"
|
||||
version = "0.97.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"fluent-uri",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -895,7 +751,7 @@ version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
@@ -919,7 +775,7 @@ version = "6.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"onig_sys",
|
||||
@@ -1036,7 +892,7 @@ version = "0.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c3a14896dfa883796f1cb410461aef38810ea05f2b2c33c5aded3649095fdad"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags",
|
||||
"getopts",
|
||||
"memchr",
|
||||
"pulldown-cmark-escape",
|
||||
@@ -1173,7 +1029,7 @@ version = "0.5.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1235,14 +1091,12 @@ dependencies = [
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
]
|
||||
@@ -1266,7 +1120,6 @@ name = "runtime"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"glob",
|
||||
"lsp",
|
||||
"plugins",
|
||||
"regex",
|
||||
"serde",
|
||||
@@ -1288,11 +1141,11 @@ version = "0.38.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.4.15",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1301,7 +1154,7 @@ version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
@@ -1355,7 +1208,7 @@ version = "15.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ee1e066dc922e513bda599c6ccb5f3bb2b0ea5870a579448f2622993f0a9a2f"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"clipboard-win",
|
||||
"fd-lock",
|
||||
@@ -1435,28 +1288,6 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_path_to_error"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_repr"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
@@ -1469,19 +1300,6 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "server"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"axum",
|
||||
"reqwest",
|
||||
"runtime",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
@@ -1735,25 +1553,11 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tools"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"api",
|
||||
"commands",
|
||||
"plugins",
|
||||
"reqwest",
|
||||
"runtime",
|
||||
@@ -1775,7 +1579,6 @@ dependencies = [
|
||||
"tokio",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1784,7 +1587,7 @@ version = "0.6.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
@@ -1814,7 +1617,6 @@ version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"tracing-core",
|
||||
]
|
||||
@@ -1989,19 +1791,6 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.93"
|
||||
|
||||
@@ -9,7 +9,6 @@ license = "MIT"
|
||||
publish = false
|
||||
|
||||
[workspace.dependencies]
|
||||
lsp-types = "0.97"
|
||||
serde_json = "1"
|
||||
|
||||
[workspace.lints.rust]
|
||||
|
||||
+128
-101
@@ -1,122 +1,149 @@
|
||||
# Claw Code
|
||||
# 🦞 Claw Code — Rust Implementation
|
||||
|
||||
Claw Code is a local coding-agent CLI implemented in safe Rust. It is **Claude Code inspired** and developed as a **clean-room implementation**: it aims for a strong local agent experience, but it is **not** a direct port or copy of Claude Code.
|
||||
A high-performance Rust rewrite of the Claw Code CLI agent harness. Built for speed, safety, and native tool execution.
|
||||
|
||||
The Rust workspace is the current main product surface. The `claw` binary provides interactive sessions, one-shot prompts, workspace-aware tools, local agent workflows, and plugin-capable operation from a single workspace.
|
||||
|
||||
## Current status
|
||||
|
||||
- **Version:** `0.1.0`
|
||||
- **Release stage:** initial public release, source-build distribution
|
||||
- **Primary implementation:** Rust workspace in this repository
|
||||
- **Platform focus:** macOS and Linux developer workstations
|
||||
|
||||
## Install, build, and run
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Rust stable toolchain
|
||||
- Cargo
|
||||
- Provider credentials for the model you want to use
|
||||
|
||||
### Authentication
|
||||
|
||||
Anthropic-compatible models:
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_API_KEY="..."
|
||||
# Optional when using a compatible endpoint
|
||||
export ANTHROPIC_BASE_URL="https://api.anthropic.com"
|
||||
```
|
||||
# Build
|
||||
cd rust/
|
||||
cargo build --release
|
||||
|
||||
Grok models:
|
||||
|
||||
```bash
|
||||
export XAI_API_KEY="..."
|
||||
# Optional when using a compatible endpoint
|
||||
export XAI_BASE_URL="https://api.x.ai"
|
||||
```
|
||||
|
||||
OAuth login is also available:
|
||||
|
||||
```bash
|
||||
cargo run --bin claw -- login
|
||||
```
|
||||
|
||||
### Install locally
|
||||
|
||||
```bash
|
||||
cargo install --path crates/claw-cli --locked
|
||||
```
|
||||
|
||||
### Build from source
|
||||
|
||||
```bash
|
||||
cargo build --release -p claw-cli
|
||||
```
|
||||
|
||||
### Run
|
||||
|
||||
From the workspace:
|
||||
|
||||
```bash
|
||||
cargo run --bin claw -- --help
|
||||
cargo run --bin claw --
|
||||
cargo run --bin claw -- prompt "summarize this workspace"
|
||||
cargo run --bin claw -- --model sonnet "review the latest changes"
|
||||
```
|
||||
|
||||
From the release build:
|
||||
|
||||
```bash
|
||||
# Run interactive REPL
|
||||
./target/release/claw
|
||||
./target/release/claw prompt "explain crates/runtime"
|
||||
|
||||
# One-shot prompt
|
||||
./target/release/claw prompt "explain this codebase"
|
||||
|
||||
# With specific model
|
||||
./target/release/claw --model sonnet prompt "fix the bug in main.rs"
|
||||
```
|
||||
|
||||
## Supported capabilities
|
||||
## Configuration
|
||||
|
||||
- Interactive REPL and one-shot prompt execution
|
||||
- Saved-session inspection and resume flows
|
||||
- Built-in workspace tools for shell, file read/write/edit, search, web fetch/search, todos, and notebook updates
|
||||
- Slash commands for status, compaction, config inspection, diff, export, session management, and version reporting
|
||||
- Local agent and skill discovery with `claw agents` and `claw skills`
|
||||
- Plugin discovery and management through the CLI and slash-command surfaces
|
||||
- OAuth login/logout plus model/provider selection from the command line
|
||||
- Workspace-aware instruction/config loading (`CLAW.md`, config files, permissions, plugin settings)
|
||||
Set your API credentials:
|
||||
|
||||
## Current limitations
|
||||
```bash
|
||||
export ANTHROPIC_API_KEY="sk-ant-..."
|
||||
# Or use a proxy
|
||||
export ANTHROPIC_BASE_URL="https://your-proxy.com"
|
||||
```
|
||||
|
||||
- Public distribution is **source-build only** today; this workspace is not set up for crates.io publishing
|
||||
- GitHub CI verifies `cargo check`, `cargo test`, and release builds, but automated release packaging is not yet present
|
||||
- Current CI targets Ubuntu and macOS; Windows release readiness is still to be established
|
||||
- Some live-provider integration coverage is opt-in because it requires external credentials and network access
|
||||
- The command surface may continue to evolve during the `0.x` series
|
||||
Or authenticate via OAuth:
|
||||
|
||||
## Implementation
|
||||
```bash
|
||||
claw login
|
||||
```
|
||||
|
||||
The Rust workspace is the active product implementation. It currently includes these crates:
|
||||
## Features
|
||||
|
||||
- `claw-cli` — user-facing binary
|
||||
- `api` — provider clients and streaming
|
||||
- `runtime` — sessions, config, permissions, prompts, and runtime loop
|
||||
- `tools` — built-in tool implementations
|
||||
- `commands` — slash-command registry and handlers
|
||||
- `plugins` — plugin discovery, registry, and lifecycle support
|
||||
- `lsp` — language-server protocol support types and process helpers
|
||||
- `server` and `compat-harness` — supporting services and compatibility tooling
|
||||
| Feature | Status |
|
||||
|---------|--------|
|
||||
| API + streaming | ✅ |
|
||||
| OAuth login/logout | ✅ |
|
||||
| Interactive REPL (rustyline) | ✅ |
|
||||
| Tool system (bash, read, write, edit, grep, glob) | ✅ |
|
||||
| Web tools (search, fetch) | ✅ |
|
||||
| Sub-agent orchestration | ✅ |
|
||||
| Todo tracking | ✅ |
|
||||
| Notebook editing | ✅ |
|
||||
| CLAW.md / project memory | ✅ |
|
||||
| Config file hierarchy (.claw.json) | ✅ |
|
||||
| Permission system | ✅ |
|
||||
| MCP server lifecycle | ✅ |
|
||||
| Session persistence + resume | ✅ |
|
||||
| Extended thinking (thinking blocks) | ✅ |
|
||||
| Cost tracking + usage display | ✅ |
|
||||
| Git integration | ✅ |
|
||||
| Markdown terminal rendering (ANSI) | ✅ |
|
||||
| Model aliases (opus/sonnet/haiku) | ✅ |
|
||||
| Slash commands (/status, /compact, /clear, etc.) | ✅ |
|
||||
| Hooks (PreToolUse/PostToolUse) | 🔧 Config only |
|
||||
| Plugin system | 📋 Planned |
|
||||
| Skills registry | 📋 Planned |
|
||||
|
||||
## Roadmap
|
||||
## Model Aliases
|
||||
|
||||
- Publish packaged release artifacts for public installs
|
||||
- Add a repeatable release workflow and longer-lived changelog discipline
|
||||
- Expand platform verification beyond the current CI matrix
|
||||
- Add more task-focused examples and operator documentation
|
||||
- Continue tightening feature coverage and UX polish across the Rust implementation
|
||||
Short names resolve to the latest model versions:
|
||||
|
||||
## Release notes
|
||||
| Alias | Resolves To |
|
||||
|-------|------------|
|
||||
| `opus` | `claude-opus-4-6` |
|
||||
| `sonnet` | `claude-sonnet-4-6` |
|
||||
| `haiku` | `claude-haiku-4-5-20251213` |
|
||||
|
||||
- Draft 0.1.0 release notes: [`docs/releases/0.1.0.md`](docs/releases/0.1.0.md)
|
||||
## CLI Flags
|
||||
|
||||
```
|
||||
claw [OPTIONS] [COMMAND]
|
||||
|
||||
Options:
|
||||
--model MODEL Set the model (alias or full name)
|
||||
--dangerously-skip-permissions Skip all permission checks
|
||||
--permission-mode MODE Set read-only, workspace-write, or danger-full-access
|
||||
--allowedTools TOOLS Restrict enabled tools
|
||||
--output-format FORMAT Output format (text or json)
|
||||
--version, -V Print version info
|
||||
|
||||
Commands:
|
||||
prompt <text> One-shot prompt (non-interactive)
|
||||
login Authenticate via OAuth
|
||||
logout Clear stored credentials
|
||||
init Initialize project config
|
||||
doctor Check environment health
|
||||
self-update Update to latest version
|
||||
```
|
||||
|
||||
## Slash Commands (REPL)
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/help` | Show help |
|
||||
| `/status` | Show session status (model, tokens, cost) |
|
||||
| `/cost` | Show cost breakdown |
|
||||
| `/compact` | Compact conversation history |
|
||||
| `/clear` | Clear conversation |
|
||||
| `/model [name]` | Show or switch model |
|
||||
| `/permissions` | Show or switch permission mode |
|
||||
| `/config [section]` | Show config (env, hooks, model) |
|
||||
| `/memory` | Show CLAW.md contents |
|
||||
| `/diff` | Show git diff |
|
||||
| `/export [path]` | Export conversation |
|
||||
| `/session [id]` | Resume a previous session |
|
||||
| `/version` | Show version |
|
||||
|
||||
## Workspace Layout
|
||||
|
||||
```
|
||||
rust/
|
||||
├── Cargo.toml # Workspace root
|
||||
├── Cargo.lock
|
||||
└── crates/
|
||||
├── api/ # API client + SSE streaming
|
||||
├── commands/ # Shared slash-command registry
|
||||
├── compat-harness/ # TS manifest extraction harness
|
||||
├── runtime/ # Session, config, permissions, MCP, prompts
|
||||
├── claw-cli/ # Main CLI binary (`claw`)
|
||||
└── tools/ # Built-in tool implementations
|
||||
```
|
||||
|
||||
### Crate Responsibilities
|
||||
|
||||
- **api** — HTTP client, SSE stream parser, request/response types, auth (API key + OAuth bearer)
|
||||
- **commands** — Slash command definitions and help text generation
|
||||
- **compat-harness** — Extracts tool/prompt manifests from upstream TS source
|
||||
- **runtime** — `ConversationRuntime` agentic loop, `ConfigLoader` hierarchy, `Session` persistence, permission policy, MCP client, system prompt assembly, usage tracking
|
||||
- **claw-cli** — REPL, one-shot prompt, streaming display, tool call rendering, CLI argument parsing
|
||||
- **tools** — Tool specs + execution: Bash, ReadFile, WriteFile, EditFile, GlobSearch, GrepSearch, WebSearch, WebFetch, Agent, TodoWrite, NotebookEdit, Skill, ToolSearch, REPL runtimes
|
||||
|
||||
## Stats
|
||||
|
||||
- **~20K lines** of Rust
|
||||
- **6 crates** in workspace
|
||||
- **Binary name:** `claw`
|
||||
- **Default model:** `claude-opus-4-6`
|
||||
- **Default permissions:** `danger-full-access`
|
||||
|
||||
## License
|
||||
|
||||
See the repository root for licensing details.
|
||||
See repository root.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::error::ApiError;
|
||||
use crate::providers::claw_provider::{self, AuthSource, ClawApiClient};
|
||||
use crate::providers::claw_provider::{self, ClawApiClient, AuthSource};
|
||||
use crate::providers::openai_compat::{self, OpenAiCompatClient, OpenAiCompatConfig};
|
||||
use crate::providers::{self, Provider, ProviderKind};
|
||||
use crate::types::{MessageRequest, MessageResponse, StreamEvent};
|
||||
|
||||
@@ -9,7 +9,7 @@ pub use client::{
|
||||
resolve_startup_auth_source, MessageStream, OAuthTokenSet, ProviderClient,
|
||||
};
|
||||
pub use error::ApiError;
|
||||
pub use providers::claw_provider::{AuthSource, ClawApiClient, ClawApiClient as ApiClient};
|
||||
pub use providers::claw_provider::{ClawApiClient, ClawApiClient as ApiClient, AuthSource};
|
||||
pub use providers::openai_compat::{OpenAiCompatClient, OpenAiCompatConfig};
|
||||
pub use providers::{
|
||||
detect_provider_kind, max_tokens_for_model, resolve_model_alias, ProviderKind,
|
||||
|
||||
@@ -652,7 +652,7 @@ mod tests {
|
||||
|
||||
use super::{
|
||||
now_unix_timestamp, oauth_token_is_expired, resolve_saved_oauth_token,
|
||||
resolve_startup_auth_source, AuthSource, ClawApiClient, OAuthTokenSet,
|
||||
resolve_startup_auth_source, ClawApiClient, AuthSource, OAuthTokenSet,
|
||||
};
|
||||
use crate::types::{ContentBlockDelta, MessageRequest};
|
||||
|
||||
|
||||
@@ -290,7 +290,8 @@ async fn live_stream_smoke_test() {
|
||||
let client = ApiClient::from_env().expect("ANTHROPIC_API_KEY must be set");
|
||||
let mut stream = client
|
||||
.stream_message(&MessageRequest {
|
||||
model: std::env::var("CLAW_MODEL").unwrap_or_else(|_| "claude-sonnet-4-6".to_string()),
|
||||
model: std::env::var("CLAW_MODEL")
|
||||
.unwrap_or_else(|_| "claude-sonnet-4-6".to_string()),
|
||||
max_tokens: 32,
|
||||
messages: vec![InputMessage::user_text(
|
||||
"Reply with exactly: hello from rust",
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::io::{self, Write};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::args::{OutputFormat, PermissionMode};
|
||||
use crate::input::{LineEditor, ReadOutcome};
|
||||
use crate::input::{EditorMode, LineEditor, ReadOutcome};
|
||||
use crate::render::{Spinner, TerminalRenderer};
|
||||
use runtime::{ConversationClient, ConversationMessage, RuntimeError, StreamEvent, UsageSummary};
|
||||
|
||||
@@ -111,7 +111,7 @@ impl CliApp {
|
||||
}
|
||||
|
||||
pub fn run_repl(&mut self) -> io::Result<()> {
|
||||
let mut editor = LineEditor::new("› ", Vec::new());
|
||||
let mut editor = LineEditor::new("› ", Vec::new(), EditorMode::Emacs);
|
||||
println!("Claw Code interactive mode");
|
||||
println!("Type /help for commands. Shift+Enter or Ctrl+J inserts a newline.");
|
||||
|
||||
@@ -162,10 +162,6 @@ impl CliApp {
|
||||
writeln!(out, "Unknown slash command: /{name}")?;
|
||||
Ok(CommandResult::Continue)
|
||||
}
|
||||
_ => {
|
||||
writeln!(out, "Slash command unavailable in this mode")?;
|
||||
Ok(CommandResult::Continue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,7 +172,7 @@ impl CliApp {
|
||||
SlashCommand::Help => "/help",
|
||||
SlashCommand::Status => "/status",
|
||||
SlashCommand::Compact => "/compact",
|
||||
_ => continue,
|
||||
SlashCommand::Unknown(_) => continue,
|
||||
};
|
||||
writeln!(out, " {name:<9} {}", handler.summary)?;
|
||||
}
|
||||
|
||||
@@ -386,7 +386,8 @@ mod tests {
|
||||
let root = temp_dir();
|
||||
fs::create_dir_all(&root).expect("create root");
|
||||
fs::write(root.join("CLAW.md"), "custom guidance\n").expect("write existing claw md");
|
||||
fs::write(root.join(".gitignore"), ".claw/settings.local.json\n").expect("write gitignore");
|
||||
fs::write(root.join(".gitignore"), ".claw/settings.local.json\n")
|
||||
.expect("write gitignore");
|
||||
|
||||
let first = initialize_repo(&root).expect("first init should succeed");
|
||||
assert!(first
|
||||
|
||||
+191
-1067
File diff suppressed because it is too large
Load Diff
+218
-514
File diff suppressed because it is too large
Load Diff
+76
-1320
File diff suppressed because it is too large
Load Diff
@@ -1,16 +0,0 @@
|
||||
[package]
|
||||
name = "lsp"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
lsp-types.workspace = true
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
tokio = { version = "1", features = ["io-util", "macros", "process", "rt", "rt-multi-thread", "sync", "time"] }
|
||||
url = "2"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -1,463 +0,0 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicI64, Ordering};
|
||||
|
||||
use lsp_types::{
|
||||
Diagnostic, GotoDefinitionResponse, Location, LocationLink, Position, PublishDiagnosticsParams,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncReadExt, AsyncWriteExt, BufReader, BufWriter};
|
||||
use tokio::process::{Child, ChildStdin, ChildStdout, Command};
|
||||
use tokio::sync::{oneshot, Mutex};
|
||||
|
||||
use crate::error::LspError;
|
||||
use crate::types::{LspServerConfig, SymbolLocation};
|
||||
|
||||
pub(crate) struct LspClient {
|
||||
config: LspServerConfig,
|
||||
writer: Mutex<BufWriter<ChildStdin>>,
|
||||
child: Mutex<Child>,
|
||||
pending_requests: Arc<Mutex<BTreeMap<i64, oneshot::Sender<Result<Value, LspError>>>>>,
|
||||
diagnostics: Arc<Mutex<BTreeMap<String, Vec<Diagnostic>>>>,
|
||||
open_documents: Mutex<BTreeMap<PathBuf, i32>>,
|
||||
next_request_id: AtomicI64,
|
||||
}
|
||||
|
||||
impl LspClient {
|
||||
pub(crate) async fn connect(config: LspServerConfig) -> Result<Self, LspError> {
|
||||
let mut command = Command::new(&config.command);
|
||||
command
|
||||
.args(&config.args)
|
||||
.current_dir(&config.workspace_root)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.envs(config.env.clone());
|
||||
|
||||
let mut child = command.spawn()?;
|
||||
let stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| LspError::Protocol("missing LSP stdin pipe".to_string()))?;
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| LspError::Protocol("missing LSP stdout pipe".to_string()))?;
|
||||
let stderr = child.stderr.take();
|
||||
|
||||
let client = Self {
|
||||
config,
|
||||
writer: Mutex::new(BufWriter::new(stdin)),
|
||||
child: Mutex::new(child),
|
||||
pending_requests: Arc::new(Mutex::new(BTreeMap::new())),
|
||||
diagnostics: Arc::new(Mutex::new(BTreeMap::new())),
|
||||
open_documents: Mutex::new(BTreeMap::new()),
|
||||
next_request_id: AtomicI64::new(1),
|
||||
};
|
||||
|
||||
client.spawn_reader(stdout);
|
||||
if let Some(stderr) = stderr {
|
||||
client.spawn_stderr_drain(stderr);
|
||||
}
|
||||
client.initialize().await?;
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_document_open(&self, path: &Path) -> Result<(), LspError> {
|
||||
if self.is_document_open(path).await {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let contents = std::fs::read_to_string(path)?;
|
||||
self.open_document(path, &contents).await
|
||||
}
|
||||
|
||||
pub(crate) async fn open_document(&self, path: &Path, text: &str) -> Result<(), LspError> {
|
||||
let uri = file_url(path)?;
|
||||
let language_id = self
|
||||
.config
|
||||
.language_id_for(path)
|
||||
.ok_or_else(|| LspError::UnsupportedDocument(path.to_path_buf()))?;
|
||||
|
||||
self.notify(
|
||||
"textDocument/didOpen",
|
||||
json!({
|
||||
"textDocument": {
|
||||
"uri": uri,
|
||||
"languageId": language_id,
|
||||
"version": 1,
|
||||
"text": text,
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
self.open_documents
|
||||
.lock()
|
||||
.await
|
||||
.insert(path.to_path_buf(), 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn change_document(&self, path: &Path, text: &str) -> Result<(), LspError> {
|
||||
if !self.is_document_open(path).await {
|
||||
return self.open_document(path, text).await;
|
||||
}
|
||||
|
||||
let uri = file_url(path)?;
|
||||
let next_version = {
|
||||
let mut open_documents = self.open_documents.lock().await;
|
||||
let version = open_documents
|
||||
.entry(path.to_path_buf())
|
||||
.and_modify(|value| *value += 1)
|
||||
.or_insert(1);
|
||||
*version
|
||||
};
|
||||
|
||||
self.notify(
|
||||
"textDocument/didChange",
|
||||
json!({
|
||||
"textDocument": {
|
||||
"uri": uri,
|
||||
"version": next_version,
|
||||
},
|
||||
"contentChanges": [{
|
||||
"text": text,
|
||||
}],
|
||||
}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn save_document(&self, path: &Path) -> Result<(), LspError> {
|
||||
if !self.is_document_open(path).await {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.notify(
|
||||
"textDocument/didSave",
|
||||
json!({
|
||||
"textDocument": {
|
||||
"uri": file_url(path)?,
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn close_document(&self, path: &Path) -> Result<(), LspError> {
|
||||
if !self.is_document_open(path).await {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.notify(
|
||||
"textDocument/didClose",
|
||||
json!({
|
||||
"textDocument": {
|
||||
"uri": file_url(path)?,
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
self.open_documents.lock().await.remove(path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn is_document_open(&self, path: &Path) -> bool {
|
||||
self.open_documents.lock().await.contains_key(path)
|
||||
}
|
||||
|
||||
pub(crate) async fn go_to_definition(
|
||||
&self,
|
||||
path: &Path,
|
||||
position: Position,
|
||||
) -> Result<Vec<SymbolLocation>, LspError> {
|
||||
self.ensure_document_open(path).await?;
|
||||
let response = self
|
||||
.request::<Option<GotoDefinitionResponse>>(
|
||||
"textDocument/definition",
|
||||
json!({
|
||||
"textDocument": { "uri": file_url(path)? },
|
||||
"position": position,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(match response {
|
||||
Some(GotoDefinitionResponse::Scalar(location)) => {
|
||||
location_to_symbol_locations(vec![location])
|
||||
}
|
||||
Some(GotoDefinitionResponse::Array(locations)) => location_to_symbol_locations(locations),
|
||||
Some(GotoDefinitionResponse::Link(links)) => location_links_to_symbol_locations(links),
|
||||
None => Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn find_references(
|
||||
&self,
|
||||
path: &Path,
|
||||
position: Position,
|
||||
include_declaration: bool,
|
||||
) -> Result<Vec<SymbolLocation>, LspError> {
|
||||
self.ensure_document_open(path).await?;
|
||||
let response = self
|
||||
.request::<Option<Vec<Location>>>(
|
||||
"textDocument/references",
|
||||
json!({
|
||||
"textDocument": { "uri": file_url(path)? },
|
||||
"position": position,
|
||||
"context": {
|
||||
"includeDeclaration": include_declaration,
|
||||
},
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(location_to_symbol_locations(response.unwrap_or_default()))
|
||||
}
|
||||
|
||||
pub(crate) async fn diagnostics_snapshot(&self) -> BTreeMap<String, Vec<Diagnostic>> {
|
||||
self.diagnostics.lock().await.clone()
|
||||
}
|
||||
|
||||
pub(crate) async fn shutdown(&self) -> Result<(), LspError> {
|
||||
let _ = self.request::<Value>("shutdown", json!({})).await;
|
||||
let _ = self.notify("exit", Value::Null).await;
|
||||
|
||||
let mut child = self.child.lock().await;
|
||||
if child.kill().await.is_err() {
|
||||
let _ = child.wait().await;
|
||||
return Ok(());
|
||||
}
|
||||
let _ = child.wait().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn spawn_reader(&self, stdout: ChildStdout) {
|
||||
let diagnostics = &self.diagnostics;
|
||||
let pending_requests = &self.pending_requests;
|
||||
|
||||
let diagnostics = diagnostics.clone();
|
||||
let pending_requests = pending_requests.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut reader = BufReader::new(stdout);
|
||||
let result = async {
|
||||
while let Some(message) = read_message(&mut reader).await? {
|
||||
if let Some(id) = message.get("id").and_then(Value::as_i64) {
|
||||
let response = if let Some(error) = message.get("error") {
|
||||
Err(LspError::Protocol(error.to_string()))
|
||||
} else {
|
||||
Ok(message.get("result").cloned().unwrap_or(Value::Null))
|
||||
};
|
||||
|
||||
if let Some(sender) = pending_requests.lock().await.remove(&id) {
|
||||
let _ = sender.send(response);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(method) = message.get("method").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
if method != "textDocument/publishDiagnostics" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let params = message.get("params").cloned().unwrap_or(Value::Null);
|
||||
let notification = serde_json::from_value::<PublishDiagnosticsParams>(params)?;
|
||||
let mut diagnostics_map = diagnostics.lock().await;
|
||||
if notification.diagnostics.is_empty() {
|
||||
diagnostics_map.remove(¬ification.uri.to_string());
|
||||
} else {
|
||||
diagnostics_map.insert(notification.uri.to_string(), notification.diagnostics);
|
||||
}
|
||||
}
|
||||
Ok::<(), LspError>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
if let Err(error) = result {
|
||||
let mut pending = pending_requests.lock().await;
|
||||
let drained = pending
|
||||
.iter()
|
||||
.map(|(id, _)| *id)
|
||||
.collect::<Vec<_>>();
|
||||
for id in drained {
|
||||
if let Some(sender) = pending.remove(&id) {
|
||||
let _ = sender.send(Err(LspError::Protocol(error.to_string())));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn spawn_stderr_drain<R>(&self, stderr: R)
|
||||
where
|
||||
R: AsyncRead + Unpin + Send + 'static,
|
||||
{
|
||||
tokio::spawn(async move {
|
||||
let mut reader = BufReader::new(stderr);
|
||||
let mut sink = Vec::new();
|
||||
let _ = reader.read_to_end(&mut sink).await;
|
||||
});
|
||||
}
|
||||
|
||||
async fn initialize(&self) -> Result<(), LspError> {
|
||||
let workspace_uri = file_url(&self.config.workspace_root)?;
|
||||
let _ = self
|
||||
.request::<Value>(
|
||||
"initialize",
|
||||
json!({
|
||||
"processId": std::process::id(),
|
||||
"rootUri": workspace_uri,
|
||||
"rootPath": self.config.workspace_root,
|
||||
"workspaceFolders": [{
|
||||
"uri": workspace_uri,
|
||||
"name": self.config.name,
|
||||
}],
|
||||
"initializationOptions": self.config.initialization_options.clone().unwrap_or(Value::Null),
|
||||
"capabilities": {
|
||||
"textDocument": {
|
||||
"publishDiagnostics": {
|
||||
"relatedInformation": true,
|
||||
},
|
||||
"definition": {
|
||||
"linkSupport": true,
|
||||
},
|
||||
"references": {}
|
||||
},
|
||||
"workspace": {
|
||||
"configuration": false,
|
||||
"workspaceFolders": true,
|
||||
},
|
||||
"general": {
|
||||
"positionEncodings": ["utf-16"],
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
self.notify("initialized", json!({})).await
|
||||
}
|
||||
|
||||
async fn request<T>(&self, method: &str, params: Value) -> Result<T, LspError>
|
||||
where
|
||||
T: for<'de> serde::Deserialize<'de>,
|
||||
{
|
||||
let id = self.next_request_id.fetch_add(1, Ordering::Relaxed);
|
||||
let (sender, receiver) = oneshot::channel();
|
||||
self.pending_requests.lock().await.insert(id, sender);
|
||||
|
||||
if let Err(error) = self
|
||||
.send_message(&json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"method": method,
|
||||
"params": params,
|
||||
}))
|
||||
.await
|
||||
{
|
||||
self.pending_requests.lock().await.remove(&id);
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
let response = receiver
|
||||
.await
|
||||
.map_err(|_| LspError::Protocol(format!("request channel closed for {method}")))??;
|
||||
Ok(serde_json::from_value(response)?)
|
||||
}
|
||||
|
||||
async fn notify(&self, method: &str, params: Value) -> Result<(), LspError> {
|
||||
self.send_message(&json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": method,
|
||||
"params": params,
|
||||
}))
|
||||
.await
|
||||
}
|
||||
|
||||
async fn send_message(&self, payload: &Value) -> Result<(), LspError> {
|
||||
let body = serde_json::to_vec(payload)?;
|
||||
let mut writer = self.writer.lock().await;
|
||||
writer
|
||||
.write_all(format!("Content-Length: {}\r\n\r\n", body.len()).as_bytes())
|
||||
.await?;
|
||||
writer.write_all(&body).await?;
|
||||
writer.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_message<R>(reader: &mut BufReader<R>) -> Result<Option<Value>, LspError>
|
||||
where
|
||||
R: AsyncRead + Unpin,
|
||||
{
|
||||
let mut content_length = None;
|
||||
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
let read = reader.read_line(&mut line).await?;
|
||||
if read == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if line == "\r\n" {
|
||||
break;
|
||||
}
|
||||
|
||||
let trimmed = line.trim_end_matches(['\r', '\n']);
|
||||
if let Some((name, value)) = trimmed.split_once(':') {
|
||||
if name.eq_ignore_ascii_case("Content-Length") {
|
||||
let value = value.trim().to_string();
|
||||
content_length = Some(
|
||||
value
|
||||
.parse::<usize>()
|
||||
.map_err(|_| LspError::InvalidContentLength(value.clone()))?,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return Err(LspError::InvalidHeader(trimmed.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
let content_length = content_length.ok_or(LspError::MissingContentLength)?;
|
||||
let mut body = vec![0_u8; content_length];
|
||||
reader.read_exact(&mut body).await?;
|
||||
Ok(Some(serde_json::from_slice(&body)?))
|
||||
}
|
||||
|
||||
fn file_url(path: &Path) -> Result<String, LspError> {
|
||||
url::Url::from_file_path(path)
|
||||
.map(|url| url.to_string())
|
||||
.map_err(|()| LspError::PathToUrl(path.to_path_buf()))
|
||||
}
|
||||
|
||||
fn location_to_symbol_locations(locations: Vec<Location>) -> Vec<SymbolLocation> {
|
||||
locations
|
||||
.into_iter()
|
||||
.filter_map(|location| {
|
||||
uri_to_path(&location.uri.to_string()).map(|path| SymbolLocation {
|
||||
path,
|
||||
range: location.range,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn location_links_to_symbol_locations(links: Vec<LocationLink>) -> Vec<SymbolLocation> {
|
||||
links.into_iter()
|
||||
.filter_map(|link| {
|
||||
uri_to_path(&link.target_uri.to_string()).map(|path| SymbolLocation {
|
||||
path,
|
||||
range: link.target_selection_range,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn uri_to_path(uri: &str) -> Option<PathBuf> {
|
||||
url::Url::parse(uri).ok()?.to_file_path().ok()
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum LspError {
|
||||
Io(std::io::Error),
|
||||
Json(serde_json::Error),
|
||||
InvalidHeader(String),
|
||||
MissingContentLength,
|
||||
InvalidContentLength(String),
|
||||
UnsupportedDocument(PathBuf),
|
||||
UnknownServer(String),
|
||||
DuplicateExtension {
|
||||
extension: String,
|
||||
existing_server: String,
|
||||
new_server: String,
|
||||
},
|
||||
PathToUrl(PathBuf),
|
||||
Protocol(String),
|
||||
}
|
||||
|
||||
impl Display for LspError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Io(error) => write!(f, "{error}"),
|
||||
Self::Json(error) => write!(f, "{error}"),
|
||||
Self::InvalidHeader(header) => write!(f, "invalid LSP header: {header}"),
|
||||
Self::MissingContentLength => write!(f, "missing LSP Content-Length header"),
|
||||
Self::InvalidContentLength(value) => {
|
||||
write!(f, "invalid LSP Content-Length value: {value}")
|
||||
}
|
||||
Self::UnsupportedDocument(path) => {
|
||||
write!(f, "no LSP server configured for {}", path.display())
|
||||
}
|
||||
Self::UnknownServer(name) => write!(f, "unknown LSP server: {name}"),
|
||||
Self::DuplicateExtension {
|
||||
extension,
|
||||
existing_server,
|
||||
new_server,
|
||||
} => write!(
|
||||
f,
|
||||
"duplicate LSP extension mapping for {extension}: {existing_server} and {new_server}"
|
||||
),
|
||||
Self::PathToUrl(path) => write!(f, "failed to convert path to file URL: {}", path.display()),
|
||||
Self::Protocol(message) => write!(f, "LSP protocol error: {message}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for LspError {}
|
||||
|
||||
impl From<std::io::Error> for LspError {
|
||||
fn from(value: std::io::Error) -> Self {
|
||||
Self::Io(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for LspError {
|
||||
fn from(value: serde_json::Error) -> Self {
|
||||
Self::Json(value)
|
||||
}
|
||||
}
|
||||
@@ -1,283 +0,0 @@
|
||||
mod client;
|
||||
mod error;
|
||||
mod manager;
|
||||
mod types;
|
||||
|
||||
pub use error::LspError;
|
||||
pub use manager::LspManager;
|
||||
pub use types::{
|
||||
FileDiagnostics, LspContextEnrichment, LspServerConfig, SymbolLocation, WorkspaceDiagnostics,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use lsp_types::{DiagnosticSeverity, Position};
|
||||
|
||||
use crate::{LspManager, LspServerConfig};
|
||||
|
||||
fn temp_dir(label: &str) -> PathBuf {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("time should be after epoch")
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!("lsp-{label}-{nanos}"))
|
||||
}
|
||||
|
||||
fn python3_path() -> Option<String> {
|
||||
let candidates = ["python3", "/usr/bin/python3"];
|
||||
candidates.iter().find_map(|candidate| {
|
||||
Command::new(candidate)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|output| output.status.success())
|
||||
.map(|_| (*candidate).to_string())
|
||||
})
|
||||
}
|
||||
|
||||
fn write_mock_server_script(root: &std::path::Path) -> PathBuf {
|
||||
let script_path = root.join("mock_lsp_server.py");
|
||||
fs::write(
|
||||
&script_path,
|
||||
r#"import json
|
||||
import sys
|
||||
|
||||
|
||||
def read_message():
|
||||
headers = {}
|
||||
while True:
|
||||
line = sys.stdin.buffer.readline()
|
||||
if not line:
|
||||
return None
|
||||
if line == b"\r\n":
|
||||
break
|
||||
key, value = line.decode("utf-8").split(":", 1)
|
||||
headers[key.lower()] = value.strip()
|
||||
length = int(headers["content-length"])
|
||||
body = sys.stdin.buffer.read(length)
|
||||
return json.loads(body)
|
||||
|
||||
|
||||
def write_message(payload):
|
||||
raw = json.dumps(payload).encode("utf-8")
|
||||
sys.stdout.buffer.write(f"Content-Length: {len(raw)}\r\n\r\n".encode("utf-8"))
|
||||
sys.stdout.buffer.write(raw)
|
||||
sys.stdout.buffer.flush()
|
||||
|
||||
|
||||
while True:
|
||||
message = read_message()
|
||||
if message is None:
|
||||
break
|
||||
|
||||
method = message.get("method")
|
||||
if method == "initialize":
|
||||
write_message({
|
||||
"jsonrpc": "2.0",
|
||||
"id": message["id"],
|
||||
"result": {
|
||||
"capabilities": {
|
||||
"definitionProvider": True,
|
||||
"referencesProvider": True,
|
||||
"textDocumentSync": 1,
|
||||
}
|
||||
},
|
||||
})
|
||||
elif method == "initialized":
|
||||
continue
|
||||
elif method == "textDocument/didOpen":
|
||||
document = message["params"]["textDocument"]
|
||||
write_message({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "textDocument/publishDiagnostics",
|
||||
"params": {
|
||||
"uri": document["uri"],
|
||||
"diagnostics": [
|
||||
{
|
||||
"range": {
|
||||
"start": {"line": 0, "character": 0},
|
||||
"end": {"line": 0, "character": 3},
|
||||
},
|
||||
"severity": 1,
|
||||
"source": "mock-server",
|
||||
"message": "mock error",
|
||||
}
|
||||
],
|
||||
},
|
||||
})
|
||||
elif method == "textDocument/didChange":
|
||||
continue
|
||||
elif method == "textDocument/didSave":
|
||||
continue
|
||||
elif method == "textDocument/definition":
|
||||
uri = message["params"]["textDocument"]["uri"]
|
||||
write_message({
|
||||
"jsonrpc": "2.0",
|
||||
"id": message["id"],
|
||||
"result": [
|
||||
{
|
||||
"uri": uri,
|
||||
"range": {
|
||||
"start": {"line": 0, "character": 0},
|
||||
"end": {"line": 0, "character": 3},
|
||||
},
|
||||
}
|
||||
],
|
||||
})
|
||||
elif method == "textDocument/references":
|
||||
uri = message["params"]["textDocument"]["uri"]
|
||||
write_message({
|
||||
"jsonrpc": "2.0",
|
||||
"id": message["id"],
|
||||
"result": [
|
||||
{
|
||||
"uri": uri,
|
||||
"range": {
|
||||
"start": {"line": 0, "character": 0},
|
||||
"end": {"line": 0, "character": 3},
|
||||
},
|
||||
},
|
||||
{
|
||||
"uri": uri,
|
||||
"range": {
|
||||
"start": {"line": 1, "character": 4},
|
||||
"end": {"line": 1, "character": 7},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
elif method == "shutdown":
|
||||
write_message({"jsonrpc": "2.0", "id": message["id"], "result": None})
|
||||
elif method == "exit":
|
||||
break
|
||||
"#,
|
||||
)
|
||||
.expect("mock server should be written");
|
||||
script_path
|
||||
}
|
||||
|
||||
async fn wait_for_diagnostics(manager: &LspManager) {
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
loop {
|
||||
if manager
|
||||
.collect_workspace_diagnostics()
|
||||
.await
|
||||
.expect("diagnostics snapshot should load")
|
||||
.total_diagnostics()
|
||||
> 0
|
||||
{
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("diagnostics should arrive from mock server");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn collects_diagnostics_and_symbol_navigation_from_mock_server() {
|
||||
let Some(python) = python3_path() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// given
|
||||
let root = temp_dir("manager");
|
||||
fs::create_dir_all(root.join("src")).expect("workspace root should exist");
|
||||
let script_path = write_mock_server_script(&root);
|
||||
let source_path = root.join("src").join("main.rs");
|
||||
fs::write(&source_path, "fn main() {}\nlet value = 1;\n").expect("source file should exist");
|
||||
let manager = LspManager::new(vec![LspServerConfig {
|
||||
name: "rust-analyzer".to_string(),
|
||||
command: python,
|
||||
args: vec![script_path.display().to_string()],
|
||||
env: BTreeMap::new(),
|
||||
workspace_root: root.clone(),
|
||||
initialization_options: None,
|
||||
extension_to_language: BTreeMap::from([(".rs".to_string(), "rust".to_string())]),
|
||||
}])
|
||||
.expect("manager should build");
|
||||
manager
|
||||
.open_document(&source_path, &fs::read_to_string(&source_path).expect("source read should succeed"))
|
||||
.await
|
||||
.expect("document should open");
|
||||
wait_for_diagnostics(&manager).await;
|
||||
|
||||
// when
|
||||
let diagnostics = manager
|
||||
.collect_workspace_diagnostics()
|
||||
.await
|
||||
.expect("diagnostics should be available");
|
||||
let definitions = manager
|
||||
.go_to_definition(&source_path, Position::new(0, 0))
|
||||
.await
|
||||
.expect("definition request should succeed");
|
||||
let references = manager
|
||||
.find_references(&source_path, Position::new(0, 0), true)
|
||||
.await
|
||||
.expect("references request should succeed");
|
||||
|
||||
// then
|
||||
assert_eq!(diagnostics.files.len(), 1);
|
||||
assert_eq!(diagnostics.total_diagnostics(), 1);
|
||||
assert_eq!(diagnostics.files[0].diagnostics[0].severity, Some(DiagnosticSeverity::ERROR));
|
||||
assert_eq!(definitions.len(), 1);
|
||||
assert_eq!(definitions[0].start_line(), 1);
|
||||
assert_eq!(references.len(), 2);
|
||||
|
||||
manager.shutdown().await.expect("shutdown should succeed");
|
||||
fs::remove_dir_all(root).expect("temp workspace should be removed");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn renders_runtime_context_enrichment_for_prompt_usage() {
|
||||
let Some(python) = python3_path() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// given
|
||||
let root = temp_dir("prompt");
|
||||
fs::create_dir_all(root.join("src")).expect("workspace root should exist");
|
||||
let script_path = write_mock_server_script(&root);
|
||||
let source_path = root.join("src").join("lib.rs");
|
||||
fs::write(&source_path, "pub fn answer() -> i32 { 42 }\n").expect("source file should exist");
|
||||
let manager = LspManager::new(vec![LspServerConfig {
|
||||
name: "rust-analyzer".to_string(),
|
||||
command: python,
|
||||
args: vec![script_path.display().to_string()],
|
||||
env: BTreeMap::new(),
|
||||
workspace_root: root.clone(),
|
||||
initialization_options: None,
|
||||
extension_to_language: BTreeMap::from([(".rs".to_string(), "rust".to_string())]),
|
||||
}])
|
||||
.expect("manager should build");
|
||||
manager
|
||||
.open_document(&source_path, &fs::read_to_string(&source_path).expect("source read should succeed"))
|
||||
.await
|
||||
.expect("document should open");
|
||||
wait_for_diagnostics(&manager).await;
|
||||
|
||||
// when
|
||||
let enrichment = manager
|
||||
.context_enrichment(&source_path, Position::new(0, 0))
|
||||
.await
|
||||
.expect("context enrichment should succeed");
|
||||
let rendered = enrichment.render_prompt_section();
|
||||
|
||||
// then
|
||||
assert!(rendered.contains("# LSP context"));
|
||||
assert!(rendered.contains("Workspace diagnostics: 1 across 1 file(s)"));
|
||||
assert!(rendered.contains("Definitions:"));
|
||||
assert!(rendered.contains("References:"));
|
||||
assert!(rendered.contains("mock error"));
|
||||
|
||||
manager.shutdown().await.expect("shutdown should succeed");
|
||||
fs::remove_dir_all(root).expect("temp workspace should be removed");
|
||||
}
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use lsp_types::Position;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::client::LspClient;
|
||||
use crate::error::LspError;
|
||||
use crate::types::{
|
||||
normalize_extension, FileDiagnostics, LspContextEnrichment, LspServerConfig, SymbolLocation,
|
||||
WorkspaceDiagnostics,
|
||||
};
|
||||
|
||||
pub struct LspManager {
|
||||
server_configs: BTreeMap<String, LspServerConfig>,
|
||||
extension_map: BTreeMap<String, String>,
|
||||
clients: Mutex<BTreeMap<String, Arc<LspClient>>>,
|
||||
}
|
||||
|
||||
impl LspManager {
|
||||
pub fn new(server_configs: Vec<LspServerConfig>) -> Result<Self, LspError> {
|
||||
let mut configs_by_name = BTreeMap::new();
|
||||
let mut extension_map = BTreeMap::new();
|
||||
|
||||
for config in server_configs {
|
||||
for extension in config.extension_to_language.keys() {
|
||||
let normalized = normalize_extension(extension);
|
||||
if let Some(existing_server) = extension_map.insert(normalized.clone(), config.name.clone()) {
|
||||
return Err(LspError::DuplicateExtension {
|
||||
extension: normalized,
|
||||
existing_server,
|
||||
new_server: config.name.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
configs_by_name.insert(config.name.clone(), config);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
server_configs: configs_by_name,
|
||||
extension_map,
|
||||
clients: Mutex::new(BTreeMap::new()),
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn supports_path(&self, path: &Path) -> bool {
|
||||
path.extension().is_some_and(|extension| {
|
||||
let normalized = normalize_extension(extension.to_string_lossy().as_ref());
|
||||
self.extension_map.contains_key(&normalized)
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn open_document(&self, path: &Path, text: &str) -> Result<(), LspError> {
|
||||
self.client_for_path(path).await?.open_document(path, text).await
|
||||
}
|
||||
|
||||
pub async fn sync_document_from_disk(&self, path: &Path) -> Result<(), LspError> {
|
||||
let contents = std::fs::read_to_string(path)?;
|
||||
self.change_document(path, &contents).await?;
|
||||
self.save_document(path).await
|
||||
}
|
||||
|
||||
pub async fn change_document(&self, path: &Path, text: &str) -> Result<(), LspError> {
|
||||
self.client_for_path(path).await?.change_document(path, text).await
|
||||
}
|
||||
|
||||
pub async fn save_document(&self, path: &Path) -> Result<(), LspError> {
|
||||
self.client_for_path(path).await?.save_document(path).await
|
||||
}
|
||||
|
||||
pub async fn close_document(&self, path: &Path) -> Result<(), LspError> {
|
||||
self.client_for_path(path).await?.close_document(path).await
|
||||
}
|
||||
|
||||
pub async fn go_to_definition(
|
||||
&self,
|
||||
path: &Path,
|
||||
position: Position,
|
||||
) -> Result<Vec<SymbolLocation>, LspError> {
|
||||
let mut locations = self.client_for_path(path).await?.go_to_definition(path, position).await?;
|
||||
dedupe_locations(&mut locations);
|
||||
Ok(locations)
|
||||
}
|
||||
|
||||
pub async fn find_references(
|
||||
&self,
|
||||
path: &Path,
|
||||
position: Position,
|
||||
include_declaration: bool,
|
||||
) -> Result<Vec<SymbolLocation>, LspError> {
|
||||
let mut locations = self
|
||||
.client_for_path(path)
|
||||
.await?
|
||||
.find_references(path, position, include_declaration)
|
||||
.await?;
|
||||
dedupe_locations(&mut locations);
|
||||
Ok(locations)
|
||||
}
|
||||
|
||||
pub async fn collect_workspace_diagnostics(&self) -> Result<WorkspaceDiagnostics, LspError> {
|
||||
let clients = self.clients.lock().await.values().cloned().collect::<Vec<_>>();
|
||||
let mut files = Vec::new();
|
||||
|
||||
for client in clients {
|
||||
for (uri, diagnostics) in client.diagnostics_snapshot().await {
|
||||
let Ok(path) = url::Url::parse(&uri)
|
||||
.and_then(|url| url.to_file_path().map_err(|()| url::ParseError::RelativeUrlWithoutBase))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if diagnostics.is_empty() {
|
||||
continue;
|
||||
}
|
||||
files.push(FileDiagnostics {
|
||||
path,
|
||||
uri,
|
||||
diagnostics,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
files.sort_by(|left, right| left.path.cmp(&right.path));
|
||||
Ok(WorkspaceDiagnostics { files })
|
||||
}
|
||||
|
||||
pub async fn context_enrichment(
|
||||
&self,
|
||||
path: &Path,
|
||||
position: Position,
|
||||
) -> Result<LspContextEnrichment, LspError> {
|
||||
Ok(LspContextEnrichment {
|
||||
file_path: path.to_path_buf(),
|
||||
diagnostics: self.collect_workspace_diagnostics().await?,
|
||||
definitions: self.go_to_definition(path, position).await?,
|
||||
references: self.find_references(path, position, true).await?,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn shutdown(&self) -> Result<(), LspError> {
|
||||
let mut clients = self.clients.lock().await;
|
||||
let drained = clients.values().cloned().collect::<Vec<_>>();
|
||||
clients.clear();
|
||||
drop(clients);
|
||||
|
||||
for client in drained {
|
||||
client.shutdown().await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn client_for_path(&self, path: &Path) -> Result<Arc<LspClient>, LspError> {
|
||||
let extension = path
|
||||
.extension()
|
||||
.map(|extension| normalize_extension(extension.to_string_lossy().as_ref()))
|
||||
.ok_or_else(|| LspError::UnsupportedDocument(path.to_path_buf()))?;
|
||||
let server_name = self
|
||||
.extension_map
|
||||
.get(&extension)
|
||||
.cloned()
|
||||
.ok_or_else(|| LspError::UnsupportedDocument(path.to_path_buf()))?;
|
||||
|
||||
let mut clients = self.clients.lock().await;
|
||||
if let Some(client) = clients.get(&server_name) {
|
||||
return Ok(client.clone());
|
||||
}
|
||||
|
||||
let config = self
|
||||
.server_configs
|
||||
.get(&server_name)
|
||||
.cloned()
|
||||
.ok_or_else(|| LspError::UnknownServer(server_name.clone()))?;
|
||||
let client = Arc::new(LspClient::connect(config).await?);
|
||||
clients.insert(server_name, client.clone());
|
||||
Ok(client)
|
||||
}
|
||||
}
|
||||
|
||||
fn dedupe_locations(locations: &mut Vec<SymbolLocation>) {
|
||||
let mut seen = BTreeSet::new();
|
||||
locations.retain(|location| {
|
||||
seen.insert((
|
||||
location.path.clone(),
|
||||
location.range.start.line,
|
||||
location.range.start.character,
|
||||
location.range.end.line,
|
||||
location.range.end.character,
|
||||
))
|
||||
});
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use lsp_types::{Diagnostic, Range};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct LspServerConfig {
|
||||
pub name: String,
|
||||
pub command: String,
|
||||
pub args: Vec<String>,
|
||||
pub env: BTreeMap<String, String>,
|
||||
pub workspace_root: PathBuf,
|
||||
pub initialization_options: Option<Value>,
|
||||
pub extension_to_language: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl LspServerConfig {
|
||||
#[must_use]
|
||||
pub fn language_id_for(&self, path: &Path) -> Option<&str> {
|
||||
let extension = normalize_extension(path.extension()?.to_string_lossy().as_ref());
|
||||
self.extension_to_language
|
||||
.get(&extension)
|
||||
.map(String::as_str)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct FileDiagnostics {
|
||||
pub path: PathBuf,
|
||||
pub uri: String,
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct WorkspaceDiagnostics {
|
||||
pub files: Vec<FileDiagnostics>,
|
||||
}
|
||||
|
||||
impl WorkspaceDiagnostics {
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.files.is_empty()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn total_diagnostics(&self) -> usize {
|
||||
self.files.iter().map(|file| file.diagnostics.len()).sum()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SymbolLocation {
|
||||
pub path: PathBuf,
|
||||
pub range: Range,
|
||||
}
|
||||
|
||||
impl SymbolLocation {
|
||||
#[must_use]
|
||||
pub fn start_line(&self) -> u32 {
|
||||
self.range.start.line + 1
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn start_character(&self) -> u32 {
|
||||
self.range.start.character + 1
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for SymbolLocation {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{}:{}:{}",
|
||||
self.path.display(),
|
||||
self.start_line(),
|
||||
self.start_character()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct LspContextEnrichment {
|
||||
pub file_path: PathBuf,
|
||||
pub diagnostics: WorkspaceDiagnostics,
|
||||
pub definitions: Vec<SymbolLocation>,
|
||||
pub references: Vec<SymbolLocation>,
|
||||
}
|
||||
|
||||
impl LspContextEnrichment {
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.diagnostics.is_empty() && self.definitions.is_empty() && self.references.is_empty()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn render_prompt_section(&self) -> String {
|
||||
const MAX_RENDERED_DIAGNOSTICS: usize = 12;
|
||||
const MAX_RENDERED_LOCATIONS: usize = 12;
|
||||
|
||||
let mut lines = vec!["# LSP context".to_string()];
|
||||
lines.push(format!(" - Focus file: {}", self.file_path.display()));
|
||||
lines.push(format!(
|
||||
" - Workspace diagnostics: {} across {} file(s)",
|
||||
self.diagnostics.total_diagnostics(),
|
||||
self.diagnostics.files.len()
|
||||
));
|
||||
|
||||
if !self.diagnostics.files.is_empty() {
|
||||
lines.push(String::new());
|
||||
lines.push("Diagnostics:".to_string());
|
||||
let mut rendered = 0usize;
|
||||
for file in &self.diagnostics.files {
|
||||
for diagnostic in &file.diagnostics {
|
||||
if rendered == MAX_RENDERED_DIAGNOSTICS {
|
||||
lines.push(" - Additional diagnostics omitted for brevity.".to_string());
|
||||
break;
|
||||
}
|
||||
let severity = diagnostic_severity_label(diagnostic.severity);
|
||||
lines.push(format!(
|
||||
" - {}:{}:{} [{}] {}",
|
||||
file.path.display(),
|
||||
diagnostic.range.start.line + 1,
|
||||
diagnostic.range.start.character + 1,
|
||||
severity,
|
||||
diagnostic.message.replace('\n', " ")
|
||||
));
|
||||
rendered += 1;
|
||||
}
|
||||
if rendered == MAX_RENDERED_DIAGNOSTICS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !self.definitions.is_empty() {
|
||||
lines.push(String::new());
|
||||
lines.push("Definitions:".to_string());
|
||||
lines.extend(
|
||||
self.definitions
|
||||
.iter()
|
||||
.take(MAX_RENDERED_LOCATIONS)
|
||||
.map(|location| format!(" - {location}")),
|
||||
);
|
||||
if self.definitions.len() > MAX_RENDERED_LOCATIONS {
|
||||
lines.push(" - Additional definitions omitted for brevity.".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if !self.references.is_empty() {
|
||||
lines.push(String::new());
|
||||
lines.push("References:".to_string());
|
||||
lines.extend(
|
||||
self.references
|
||||
.iter()
|
||||
.take(MAX_RENDERED_LOCATIONS)
|
||||
.map(|location| format!(" - {location}")),
|
||||
);
|
||||
if self.references.len() > MAX_RENDERED_LOCATIONS {
|
||||
lines.push(" - Additional references omitted for brevity.".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn normalize_extension(extension: &str) -> String {
|
||||
if extension.starts_with('.') {
|
||||
extension.to_ascii_lowercase()
|
||||
} else {
|
||||
format!(".{}", extension.to_ascii_lowercase())
|
||||
}
|
||||
}
|
||||
|
||||
fn diagnostic_severity_label(severity: Option<lsp_types::DiagnosticSeverity>) -> &'static str {
|
||||
match severity {
|
||||
Some(lsp_types::DiagnosticSeverity::ERROR) => "error",
|
||||
Some(lsp_types::DiagnosticSeverity::WARNING) => "warning",
|
||||
Some(lsp_types::DiagnosticSeverity::INFORMATION) => "info",
|
||||
Some(lsp_types::DiagnosticSeverity::HINT) => "hint",
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ publish.workspace = true
|
||||
[dependencies]
|
||||
sha2 = "0.10"
|
||||
glob = "0.3"
|
||||
lsp = { path = "../lsp" }
|
||||
plugins = { path = "../plugins" }
|
||||
regex = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
@@ -284,6 +284,11 @@ impl RuntimeConfig {
|
||||
self.merged.get(key)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get_string(&self, key: &str) -> Option<&str> {
|
||||
self.get(key).and_then(JsonValue::as_str)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn as_json(&self) -> JsonValue {
|
||||
JsonValue::Object(self.merged.clone())
|
||||
|
||||
@@ -17,10 +17,6 @@ pub mod sandbox;
|
||||
mod session;
|
||||
mod usage;
|
||||
|
||||
pub use lsp::{
|
||||
FileDiagnostics, LspContextEnrichment, LspError, LspManager, LspServerConfig,
|
||||
SymbolLocation, WorkspaceDiagnostics,
|
||||
};
|
||||
pub use bash::{execute_bash, BashCommandInput, BashCommandOutput};
|
||||
pub use bootstrap::{BootstrapPhase, BootstrapPlan};
|
||||
pub use compact::{
|
||||
|
||||
@@ -97,10 +97,12 @@ impl McpClientTransport {
|
||||
McpServerConfig::Sdk(config) => Self::Sdk(McpSdkTransport {
|
||||
name: config.name.clone(),
|
||||
}),
|
||||
McpServerConfig::ManagedProxy(config) => Self::ManagedProxy(McpManagedProxyTransport {
|
||||
url: config.url.clone(),
|
||||
id: config.id.clone(),
|
||||
}),
|
||||
McpServerConfig::ManagedProxy(config) => {
|
||||
Self::ManagedProxy(McpManagedProxyTransport {
|
||||
url: config.url.clone(),
|
||||
id: config.id.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1163,12 +1163,8 @@ mod tests {
|
||||
}
|
||||
|
||||
fn cleanup_script(script_path: &Path) {
|
||||
if let Err(error) = fs::remove_file(script_path) {
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::NotFound, "cleanup script");
|
||||
}
|
||||
if let Err(error) = fs::remove_dir_all(script_path.parent().expect("script parent")) {
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::NotFound, "cleanup dir");
|
||||
}
|
||||
fs::remove_file(script_path).expect("cleanup script");
|
||||
fs::remove_dir_all(script_path.parent().expect("script parent")).expect("cleanup dir");
|
||||
}
|
||||
|
||||
fn manager_server_config(
|
||||
|
||||
@@ -4,7 +4,6 @@ use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use crate::config::{ConfigError, ConfigLoader, RuntimeConfig};
|
||||
use lsp::LspContextEnrichment;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum PromptBuildError {
|
||||
@@ -131,15 +130,6 @@ impl SystemPromptBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_lsp_context(mut self, enrichment: &LspContextEnrichment) -> Self {
|
||||
if !enrichment.is_empty() {
|
||||
self.append_sections
|
||||
.push(enrichment.render_prompt_section());
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn build(&self) -> Vec<String> {
|
||||
let mut sections = Vec::new();
|
||||
|
||||
@@ -3,13 +3,10 @@ use std::fmt::{Display, Formatter};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::json::{JsonError, JsonValue};
|
||||
use crate::usage::TokenUsage;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MessageRole {
|
||||
System,
|
||||
User,
|
||||
@@ -17,8 +14,7 @@ pub enum MessageRole {
|
||||
Tool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ContentBlock {
|
||||
Text {
|
||||
text: String,
|
||||
@@ -36,14 +32,14 @@ pub enum ContentBlock {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ConversationMessage {
|
||||
pub role: MessageRole,
|
||||
pub blocks: Vec<ContentBlock>,
|
||||
pub usage: Option<TokenUsage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Session {
|
||||
pub version: u32,
|
||||
pub messages: Vec<ConversationMessage>,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use crate::session::Session;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const DEFAULT_INPUT_COST_PER_MILLION: f64 = 15.0;
|
||||
const DEFAULT_OUTPUT_COST_PER_MILLION: f64 = 75.0;
|
||||
@@ -26,7 +25,7 @@ impl ModelPricing {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct TokenUsage {
|
||||
pub input_tokens: u32,
|
||||
pub output_tokens: u32,
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
[package]
|
||||
name = "server"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
async-stream = "0.3"
|
||||
axum = "0.8"
|
||||
runtime = { path = "../runtime" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "net", "time"] }
|
||||
|
||||
[dev-dependencies]
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -1,442 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::convert::Infallible;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use async_stream::stream;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use runtime::{ConversationMessage, Session as RuntimeSession};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::{broadcast, RwLock};
|
||||
|
||||
pub type SessionId = String;
|
||||
pub type SessionStore = Arc<RwLock<HashMap<SessionId, Session>>>;
|
||||
|
||||
const BROADCAST_CAPACITY: usize = 64;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
sessions: SessionStore,
|
||||
next_session_id: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sessions: Arc::new(RwLock::new(HashMap::new())),
|
||||
next_session_id: Arc::new(AtomicU64::new(1)),
|
||||
}
|
||||
}
|
||||
|
||||
fn allocate_session_id(&self) -> SessionId {
|
||||
let id = self.next_session_id.fetch_add(1, Ordering::Relaxed);
|
||||
format!("session-{id}")
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AppState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Session {
|
||||
pub id: SessionId,
|
||||
pub created_at: u64,
|
||||
pub conversation: RuntimeSession,
|
||||
events: broadcast::Sender<SessionEvent>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
fn new(id: SessionId) -> Self {
|
||||
let (events, _) = broadcast::channel(BROADCAST_CAPACITY);
|
||||
Self {
|
||||
id,
|
||||
created_at: unix_timestamp_millis(),
|
||||
conversation: RuntimeSession::new(),
|
||||
events,
|
||||
}
|
||||
}
|
||||
|
||||
fn subscribe(&self) -> broadcast::Receiver<SessionEvent> {
|
||||
self.events.subscribe()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
enum SessionEvent {
|
||||
Snapshot {
|
||||
session_id: SessionId,
|
||||
session: RuntimeSession,
|
||||
},
|
||||
Message {
|
||||
session_id: SessionId,
|
||||
message: ConversationMessage,
|
||||
},
|
||||
}
|
||||
|
||||
impl SessionEvent {
|
||||
fn event_name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Snapshot { .. } => "snapshot",
|
||||
Self::Message { .. } => "message",
|
||||
}
|
||||
}
|
||||
|
||||
fn to_sse_event(&self) -> Result<Event, serde_json::Error> {
|
||||
Ok(Event::default()
|
||||
.event(self.event_name())
|
||||
.data(serde_json::to_string(self)?))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ErrorResponse {
|
||||
error: String,
|
||||
}
|
||||
|
||||
type ApiError = (StatusCode, Json<ErrorResponse>);
|
||||
type ApiResult<T> = Result<T, ApiError>;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct CreateSessionResponse {
|
||||
pub session_id: SessionId,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SessionSummary {
|
||||
pub id: SessionId,
|
||||
pub created_at: u64,
|
||||
pub message_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ListSessionsResponse {
|
||||
pub sessions: Vec<SessionSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SessionDetailsResponse {
|
||||
pub id: SessionId,
|
||||
pub created_at: u64,
|
||||
pub session: RuntimeSession,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SendMessageRequest {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn app(state: AppState) -> Router {
|
||||
Router::new()
|
||||
.route("/sessions", post(create_session).get(list_sessions))
|
||||
.route("/sessions/{id}", get(get_session))
|
||||
.route("/sessions/{id}/events", get(stream_session_events))
|
||||
.route("/sessions/{id}/message", post(send_message))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn create_session(
|
||||
State(state): State<AppState>,
|
||||
) -> (StatusCode, Json<CreateSessionResponse>) {
|
||||
let session_id = state.allocate_session_id();
|
||||
let session = Session::new(session_id.clone());
|
||||
|
||||
state
|
||||
.sessions
|
||||
.write()
|
||||
.await
|
||||
.insert(session_id.clone(), session);
|
||||
|
||||
(
|
||||
StatusCode::CREATED,
|
||||
Json(CreateSessionResponse { session_id }),
|
||||
)
|
||||
}
|
||||
|
||||
async fn list_sessions(State(state): State<AppState>) -> Json<ListSessionsResponse> {
|
||||
let sessions = state.sessions.read().await;
|
||||
let mut summaries = sessions
|
||||
.values()
|
||||
.map(|session| SessionSummary {
|
||||
id: session.id.clone(),
|
||||
created_at: session.created_at,
|
||||
message_count: session.conversation.messages.len(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
summaries.sort_by(|left, right| left.id.cmp(&right.id));
|
||||
|
||||
Json(ListSessionsResponse {
|
||||
sessions: summaries,
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_session(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<SessionId>,
|
||||
) -> ApiResult<Json<SessionDetailsResponse>> {
|
||||
let sessions = state.sessions.read().await;
|
||||
let session = sessions
|
||||
.get(&id)
|
||||
.ok_or_else(|| not_found(format!("session `{id}` not found")))?;
|
||||
|
||||
Ok(Json(SessionDetailsResponse {
|
||||
id: session.id.clone(),
|
||||
created_at: session.created_at,
|
||||
session: session.conversation.clone(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn send_message(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<SessionId>,
|
||||
Json(payload): Json<SendMessageRequest>,
|
||||
) -> ApiResult<StatusCode> {
|
||||
let message = ConversationMessage::user_text(payload.message);
|
||||
let broadcaster = {
|
||||
let mut sessions = state.sessions.write().await;
|
||||
let session = sessions
|
||||
.get_mut(&id)
|
||||
.ok_or_else(|| not_found(format!("session `{id}` not found")))?;
|
||||
session.conversation.messages.push(message.clone());
|
||||
session.events.clone()
|
||||
};
|
||||
|
||||
let _ = broadcaster.send(SessionEvent::Message {
|
||||
session_id: id,
|
||||
message,
|
||||
});
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn stream_session_events(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<SessionId>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
let (snapshot, mut receiver) = {
|
||||
let sessions = state.sessions.read().await;
|
||||
let session = sessions
|
||||
.get(&id)
|
||||
.ok_or_else(|| not_found(format!("session `{id}` not found")))?;
|
||||
(
|
||||
SessionEvent::Snapshot {
|
||||
session_id: session.id.clone(),
|
||||
session: session.conversation.clone(),
|
||||
},
|
||||
session.subscribe(),
|
||||
)
|
||||
};
|
||||
|
||||
let stream = stream! {
|
||||
if let Ok(event) = snapshot.to_sse_event() {
|
||||
yield Ok::<Event, Infallible>(event);
|
||||
}
|
||||
|
||||
loop {
|
||||
match receiver.recv().await {
|
||||
Ok(event) => {
|
||||
if let Ok(sse_event) = event.to_sse_event() {
|
||||
yield Ok::<Event, Infallible>(sse_event);
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
Err(broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15))))
|
||||
}
|
||||
|
||||
fn unix_timestamp_millis() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("system time should be after epoch")
|
||||
.as_millis() as u64
|
||||
}
|
||||
|
||||
fn not_found(message: String) -> ApiError {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse { error: message }),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
app, AppState, CreateSessionResponse, ListSessionsResponse, SessionDetailsResponse,
|
||||
};
|
||||
use reqwest::Client;
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Duration;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio::time::timeout;
|
||||
|
||||
struct TestServer {
|
||||
address: SocketAddr,
|
||||
handle: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl TestServer {
|
||||
async fn spawn() -> Self {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("test listener should bind");
|
||||
let address = listener
|
||||
.local_addr()
|
||||
.expect("listener should report local address");
|
||||
let handle = tokio::spawn(async move {
|
||||
axum::serve(listener, app(AppState::default()))
|
||||
.await
|
||||
.expect("server should run");
|
||||
});
|
||||
|
||||
Self { address, handle }
|
||||
}
|
||||
|
||||
fn url(&self, path: &str) -> String {
|
||||
format!("http://{}{}", self.address, path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestServer {
|
||||
fn drop(&mut self) {
|
||||
self.handle.abort();
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_session(client: &Client, server: &TestServer) -> CreateSessionResponse {
|
||||
client
|
||||
.post(server.url("/sessions"))
|
||||
.send()
|
||||
.await
|
||||
.expect("create request should succeed")
|
||||
.error_for_status()
|
||||
.expect("create request should return success")
|
||||
.json::<CreateSessionResponse>()
|
||||
.await
|
||||
.expect("create response should parse")
|
||||
}
|
||||
|
||||
async fn next_sse_frame(response: &mut reqwest::Response, buffer: &mut String) -> String {
|
||||
loop {
|
||||
if let Some(index) = buffer.find("\n\n") {
|
||||
let frame = buffer[..index].to_string();
|
||||
let remainder = buffer[index + 2..].to_string();
|
||||
*buffer = remainder;
|
||||
return frame;
|
||||
}
|
||||
|
||||
let next_chunk = timeout(Duration::from_secs(5), response.chunk())
|
||||
.await
|
||||
.expect("SSE stream should yield within timeout")
|
||||
.expect("SSE stream should remain readable")
|
||||
.expect("SSE stream should stay open");
|
||||
buffer.push_str(&String::from_utf8_lossy(&next_chunk));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn creates_and_lists_sessions() {
|
||||
let server = TestServer::spawn().await;
|
||||
let client = Client::new();
|
||||
|
||||
// given
|
||||
let created = create_session(&client, &server).await;
|
||||
|
||||
// when
|
||||
let sessions = client
|
||||
.get(server.url("/sessions"))
|
||||
.send()
|
||||
.await
|
||||
.expect("list request should succeed")
|
||||
.error_for_status()
|
||||
.expect("list request should return success")
|
||||
.json::<ListSessionsResponse>()
|
||||
.await
|
||||
.expect("list response should parse");
|
||||
let details = client
|
||||
.get(server.url(&format!("/sessions/{}", created.session_id)))
|
||||
.send()
|
||||
.await
|
||||
.expect("details request should succeed")
|
||||
.error_for_status()
|
||||
.expect("details request should return success")
|
||||
.json::<SessionDetailsResponse>()
|
||||
.await
|
||||
.expect("details response should parse");
|
||||
|
||||
// then
|
||||
assert_eq!(created.session_id, "session-1");
|
||||
assert_eq!(sessions.sessions.len(), 1);
|
||||
assert_eq!(sessions.sessions[0].id, created.session_id);
|
||||
assert_eq!(sessions.sessions[0].message_count, 0);
|
||||
assert_eq!(details.id, "session-1");
|
||||
assert!(details.session.messages.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn streams_message_events_and_persists_message_flow() {
|
||||
let server = TestServer::spawn().await;
|
||||
let client = Client::new();
|
||||
|
||||
// given
|
||||
let created = create_session(&client, &server).await;
|
||||
let mut response = client
|
||||
.get(server.url(&format!("/sessions/{}/events", created.session_id)))
|
||||
.send()
|
||||
.await
|
||||
.expect("events request should succeed")
|
||||
.error_for_status()
|
||||
.expect("events request should return success");
|
||||
let mut buffer = String::new();
|
||||
let snapshot_frame = next_sse_frame(&mut response, &mut buffer).await;
|
||||
|
||||
// when
|
||||
let send_status = client
|
||||
.post(server.url(&format!("/sessions/{}/message", created.session_id)))
|
||||
.json(&super::SendMessageRequest {
|
||||
message: "hello from test".to_string(),
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
.expect("message request should succeed")
|
||||
.status();
|
||||
let message_frame = next_sse_frame(&mut response, &mut buffer).await;
|
||||
let details = client
|
||||
.get(server.url(&format!("/sessions/{}", created.session_id)))
|
||||
.send()
|
||||
.await
|
||||
.expect("details request should succeed")
|
||||
.error_for_status()
|
||||
.expect("details request should return success")
|
||||
.json::<SessionDetailsResponse>()
|
||||
.await
|
||||
.expect("details response should parse");
|
||||
|
||||
// then
|
||||
assert_eq!(send_status, reqwest::StatusCode::NO_CONTENT);
|
||||
assert!(snapshot_frame.contains("event: snapshot"));
|
||||
assert!(snapshot_frame.contains("\"session_id\":\"session-1\""));
|
||||
assert!(message_frame.contains("event: message"));
|
||||
assert!(message_frame.contains("hello from test"));
|
||||
assert_eq!(details.session.messages.len(), 1);
|
||||
assert_eq!(
|
||||
details.session.messages[0],
|
||||
runtime::ConversationMessage::user_text("hello from test")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
api = { path = "../api" }
|
||||
commands = { path = "../commands" }
|
||||
plugins = { path = "../plugins" }
|
||||
runtime = { path = "../runtime" }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
|
||||
|
||||
@@ -8,7 +8,6 @@ use api::{
|
||||
MessageRequest, MessageResponse, OutputContentBlock, ProviderClient,
|
||||
StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock,
|
||||
};
|
||||
use commands::resolve_skill_path as resolve_workspace_skill_path;
|
||||
use plugins::PluginTool;
|
||||
use reqwest::blocking::Client;
|
||||
use runtime::{
|
||||
@@ -1456,8 +1455,47 @@ fn todo_store_path() -> Result<std::path::PathBuf, String> {
|
||||
}
|
||||
|
||||
fn resolve_skill_path(skill: &str) -> Result<std::path::PathBuf, String> {
|
||||
let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
|
||||
resolve_workspace_skill_path(&cwd, skill).map_err(|error| error.to_string())
|
||||
let requested = skill.trim().trim_start_matches('/').trim_start_matches('$');
|
||||
if requested.is_empty() {
|
||||
return Err(String::from("skill must not be empty"));
|
||||
}
|
||||
|
||||
let mut candidates = Vec::new();
|
||||
if let Ok(codex_home) = std::env::var("CODEX_HOME") {
|
||||
candidates.push(std::path::PathBuf::from(codex_home).join("skills"));
|
||||
}
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
let home = std::path::PathBuf::from(home);
|
||||
candidates.push(home.join(".agents").join("skills"));
|
||||
candidates.push(home.join(".config").join("opencode").join("skills"));
|
||||
candidates.push(home.join(".codex").join("skills"));
|
||||
}
|
||||
candidates.push(std::path::PathBuf::from("/home/bellman/.codex/skills"));
|
||||
|
||||
for root in candidates {
|
||||
let direct = root.join(requested).join("SKILL.md");
|
||||
if direct.exists() {
|
||||
return Ok(direct);
|
||||
}
|
||||
|
||||
if let Ok(entries) = std::fs::read_dir(&root) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path().join("SKILL.md");
|
||||
if !path.exists() {
|
||||
continue;
|
||||
}
|
||||
if entry
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.eq_ignore_ascii_case(requested)
|
||||
{
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!("unknown skill: {requested}"))
|
||||
}
|
||||
|
||||
const DEFAULT_AGENT_MODEL: &str = "claude-opus-4-6";
|
||||
@@ -3411,9 +3449,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn skill_loads_local_skill_prompt() {
|
||||
let _guard = env_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let result = execute_tool(
|
||||
"Skill",
|
||||
&json!({
|
||||
@@ -3450,92 +3485,6 @@ mod tests {
|
||||
.ends_with("/help/SKILL.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_resolves_project_and_plugin_scoped_prompts() {
|
||||
let _guard = env_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let workspace = temp_path("skill-workspace");
|
||||
let home = temp_path("skill-home");
|
||||
let plugin_root = home
|
||||
.join(".claw")
|
||||
.join("plugins")
|
||||
.join("installed")
|
||||
.join("oh-my-claudecode-external");
|
||||
let project_skill_root = workspace.join(".codex").join("skills").join("ralplan");
|
||||
std::fs::create_dir_all(&project_skill_root).expect("project skill dir");
|
||||
std::fs::write(
|
||||
project_skill_root.join("SKILL.md"),
|
||||
"---\nname: ralplan\ndescription: Project skill\n---\n",
|
||||
)
|
||||
.expect("project skill");
|
||||
std::fs::create_dir_all(plugin_root.join(".claw-plugin")).expect("plugin manifest dir");
|
||||
std::fs::write(
|
||||
plugin_root.join(".claw-plugin").join("plugin.json"),
|
||||
r#"{
|
||||
"name": "oh-my-claudecode",
|
||||
"version": "1.0.0",
|
||||
"description": "Plugin skills"
|
||||
}"#,
|
||||
)
|
||||
.expect("plugin manifest");
|
||||
std::fs::create_dir_all(home.join(".claw")).expect("config home");
|
||||
std::fs::write(
|
||||
home.join(".claw").join("settings.json"),
|
||||
r#"{
|
||||
"enabledPlugins": {
|
||||
"oh-my-claudecode@external": true
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.expect("settings");
|
||||
let plugin_skill_root = plugin_root.join("skills").join("ralplan");
|
||||
std::fs::create_dir_all(&plugin_skill_root).expect("plugin skill dir");
|
||||
std::fs::write(
|
||||
plugin_skill_root.join("SKILL.md"),
|
||||
"---\nname: ralplan\ndescription: Plugin skill\n---\n",
|
||||
)
|
||||
.expect("plugin skill");
|
||||
|
||||
let original_dir = std::env::current_dir().expect("cwd");
|
||||
let old_home = std::env::var_os("HOME");
|
||||
let old_codex_home = std::env::var_os("CODEX_HOME");
|
||||
std::env::set_current_dir(&workspace).expect("set cwd");
|
||||
std::env::set_var("HOME", &home);
|
||||
std::env::remove_var("CODEX_HOME");
|
||||
|
||||
let project_result = execute_tool("Skill", &json!({ "skill": "ralplan" }))
|
||||
.expect("project skill should resolve");
|
||||
let project_output: serde_json::Value =
|
||||
serde_json::from_str(&project_result).expect("valid json");
|
||||
assert!(project_output["path"]
|
||||
.as_str()
|
||||
.expect("path")
|
||||
.ends_with(".codex/skills/ralplan/SKILL.md"));
|
||||
|
||||
let plugin_result =
|
||||
execute_tool("Skill", &json!({ "skill": "$oh-my-claudecode:ralplan" }))
|
||||
.expect("plugin skill should resolve");
|
||||
let plugin_output: serde_json::Value =
|
||||
serde_json::from_str(&plugin_result).expect("valid json");
|
||||
assert!(plugin_output["path"]
|
||||
.as_str()
|
||||
.expect("path")
|
||||
.ends_with("skills/ralplan/SKILL.md"));
|
||||
|
||||
std::env::set_current_dir(&original_dir).expect("restore cwd");
|
||||
match old_home {
|
||||
Some(value) => std::env::set_var("HOME", value),
|
||||
None => std::env::remove_var("HOME"),
|
||||
}
|
||||
match old_codex_home {
|
||||
Some(value) => std::env::set_var("CODEX_HOME", value),
|
||||
None => std::env::remove_var("CODEX_HOME"),
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(workspace);
|
||||
let _ = std::fs::remove_dir_all(home);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_search_supports_keyword_and_select_queries() {
|
||||
let keyword = execute_tool(
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
# Claw Code 0.1.0 release notes (draft)
|
||||
|
||||
## Summary
|
||||
|
||||
Claw Code `0.1.0` is the first public release-prep milestone for the current Rust implementation. Claw Code is Claude Code inspired and built as a clean-room Rust implementation; it is not a direct port or copy. This release centers on a usable local CLI experience: interactive sessions, non-interactive prompts, workspace tools, configuration loading, sessions, plugins, and local agent/skill discovery.
|
||||
|
||||
## Highlights
|
||||
|
||||
- Initial public `0.1.0` release candidate for Claw Code
|
||||
- Safe-Rust implementation as the current primary product surface
|
||||
- `claw` CLI for interactive and one-shot coding-agent workflows
|
||||
- Built-in workspace tools for shell, file operations, search, web fetch/search, todo tracking, and notebook updates
|
||||
- Slash-command surface for status, compaction, config inspection, sessions, diff/export, and version info
|
||||
- Local plugin, agent, and skill discovery/management surfaces
|
||||
- OAuth login/logout plus model/provider selection
|
||||
|
||||
## Install and run
|
||||
|
||||
This release is currently intended for source builds:
|
||||
|
||||
```bash
|
||||
cargo install --path crates/claw-cli --locked
|
||||
# or
|
||||
cargo build --release -p claw-cli
|
||||
```
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
claw
|
||||
claw prompt "summarize this repository"
|
||||
```
|
||||
|
||||
## Known limitations
|
||||
|
||||
- Source-build distribution only; packaged release artifacts are not yet published
|
||||
- CI currently covers Ubuntu and macOS release builds, checks, and tests
|
||||
- Windows release readiness is not yet established
|
||||
- Some integration coverage is opt-in because live provider credentials and network access are required
|
||||
- Public interfaces may continue to evolve during the `0.x` release line
|
||||
|
||||
## Recommended release framing
|
||||
|
||||
Position `0.1.0` as the first public release of Claw Code in its current Rust implementation for early adopters who are comfortable building from source. The feature surface is broad enough for real usage, while packaging and release automation can continue to improve in later releases.
|
||||
|
||||
## Verification used for this draft
|
||||
|
||||
- Workspace version verified from `Cargo.toml`
|
||||
- `claw` binary/package path verified from `cargo metadata`
|
||||
- CLI command surface verified from `cargo run --quiet --bin claw -- --help`
|
||||
- CI coverage verified from `.github/workflows/ci.yml`
|
||||
Reference in New Issue
Block a user