mirror of
https://github.com/instructkr/claw-code.git
synced 2026-10-15 00:00:14 +08:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
089abe753b | ||
|
|
e54d832dfc |
@@ -25,8 +25,6 @@ on:
|
||||
- tests/test_roadmap_helpers.py
|
||||
- docs/**
|
||||
- rust/**
|
||||
- flake.nix
|
||||
- .envrc
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
@@ -49,8 +47,6 @@ on:
|
||||
- tests/test_roadmap_helpers.py
|
||||
- docs/**
|
||||
- rust/**
|
||||
- flake.nix
|
||||
- .envrc
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -15,7 +15,3 @@ status-help.txt
|
||||
.port_sessions/
|
||||
# Local agent session state
|
||||
.gjc/
|
||||
# Nix build outputs and direnv
|
||||
.envrc.local
|
||||
result
|
||||
result-*
|
||||
|
||||
@@ -5,44 +5,6 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.1.4] - 2026-10-13
|
||||
|
||||
### Added
|
||||
|
||||
- GJC-compatible line range selectors for read_file tool (`:50-200`, `:50+150` syntax)
|
||||
- GJC-compatible `:conflicts` selector for git merge conflict detection
|
||||
- SDK wire transport with WebSocket support
|
||||
- Nix flake development environment
|
||||
- `/status`, `/cost`, and `/version` slash commands
|
||||
- Persistent provider configuration loading
|
||||
- Pagination support for large read_file results
|
||||
- Comprehensive GJC compatibility documentation and analysis
|
||||
|
||||
### Fixed
|
||||
|
||||
- Windows path containment checks for security boundary enforcement
|
||||
- Git bash detection on Windows with proper probe caching
|
||||
- Unresolvable path denial to prevent escape attempts
|
||||
- Auto-compaction context window measurement
|
||||
- Tool result preservation on failed turns
|
||||
- Model stall recovery without tool calls
|
||||
- Streaming markdown formatting with preserved block separators
|
||||
- Local model validation bypass generalization beyond Ollama
|
||||
- Release artifact checksums on macOS and Linux
|
||||
- Duplicate assistant text rendering
|
||||
- Workspace slash command wire-up
|
||||
|
||||
### Changed
|
||||
|
||||
- Enhanced path scope validation for multiple Windows path spellings
|
||||
- Improved error handling and configuration defaults
|
||||
- Upgraded GitHub Actions to immutable commit SHAs for CI security
|
||||
|
||||
### Known Limitations
|
||||
|
||||
- ACP / Zed integration is not yet implemented (status stub available via `claw acp`)
|
||||
- Python porting workspace is for reference only, not used in production
|
||||
|
||||
## [0.1.3] - 2026-10-13
|
||||
|
||||
This is the first public binary release of Claw Code.
|
||||
|
||||
@@ -0,0 +1,799 @@
|
||||
# GJC Compatibility Gap Matrix
|
||||
|
||||
**Document Status**: Phase 1 Analysis (2026-10-13)
|
||||
**Scope**: SDK wire protocol, session lifecycle, tools, skills, settings, hooks, commands, and AGENTS.md rules
|
||||
**Target**: Rust fork-in-spirit of gajae-code (GJC) SDK-compatible and capability-compatible
|
||||
|
||||
---
|
||||
|
||||
## 1. EXECUTIVE SUMMARY
|
||||
|
||||
This matrix maps GJC capabilities (from gajae-code) against claw-code's current Rust implementation, identifying gaps and proposing crate/module ownership for each slice.
|
||||
|
||||
### Key Findings
|
||||
|
||||
| Area | Status | Priority |
|
||||
|------|--------|----------|
|
||||
| **SDK Wire Protocol** | Missing | P0 (Foundation) |
|
||||
| **Session Lifecycle** | Partial (no SDK) | P0 (Foundation) |
|
||||
| **Tool Catalog (55 tools)** | Partial (35/55) | P1 (Core) |
|
||||
| **Skill Discovery** | Partial (basic) | P1 (Core) |
|
||||
| **Settings & Config** | Partial | P2 (Enhancing) |
|
||||
| **Hooks & Plugins** | Existing | P3 (Polish) |
|
||||
| **AGENTS.md Rules** | Existing | P3 (Polish) |
|
||||
| **Slash Commands (120+)** | Partial (40/120+) | P2 (Core) |
|
||||
| **Subagent Delegation** | Existing | P2 (Core) |
|
||||
|
||||
---
|
||||
|
||||
## 2. SDK WIRE PROTOCOL
|
||||
|
||||
### GJC Behavior (gajae-code Reference)
|
||||
|
||||
**Source**: `packages/coding-agent/src/sdk/client/client.ts`, `sdk/host/session-runtime.ts`
|
||||
|
||||
GJC uses a **WebSocket-based JSON-RPC-like protocol** with:
|
||||
- **Transport**: WebSocket over TCP
|
||||
- **Frame Format**: JSON object with typed fields
|
||||
- **Correlation**: Request/response matched by `frame.id` (UUID)
|
||||
- **Operations**: 65+ operation types (Q1-Q32, C01-C06, reverse ops)
|
||||
- **Error Handling**: Typed `SdkErrorCode` with `uncertain_after_send` recovery
|
||||
- **Reconnect**: Exponential backoff with deadline management
|
||||
- **Request Timeout**: 10s default (`DEFAULT_SDK_REQUEST_TIMEOUT_MS`), per-request override
|
||||
- **Handshake**: `client.hello` with capabilities advertised; `server.hello` response
|
||||
|
||||
**Operations** (sample):
|
||||
- **Control**: `turn.create`, `turn.prompt`, `turn.steer`, `turn.abort_and_prompt`
|
||||
- **Queries**: `turn.list`, `turn.get_one`, `turn.message_list`, `skill.source`, `artifact.content`
|
||||
- **Reverse**: `ask.answer`, `tool.dispatch`, `tool.fetch_capability`
|
||||
|
||||
**Event Schema**:
|
||||
```json
|
||||
{
|
||||
"id": "uuid-string",
|
||||
"operation": "turn.create",
|
||||
"input": { ... },
|
||||
"connectionId": "optional",
|
||||
"settings": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### claw-code Status
|
||||
|
||||
**Current**: Missing entirely. claw-code has no SDK wire protocol or external session API.
|
||||
|
||||
**Files**: None
|
||||
**Lines**: 0
|
||||
|
||||
**Reality**:
|
||||
- claw-code operates as a local CLI only (stdin/stdout/stderr).
|
||||
- Sessions are file-based (`.claw/sessions/`).
|
||||
- No remote access or external SDK clients.
|
||||
- No WebSocket transport.
|
||||
- No frame correlation or reconnect logic.
|
||||
|
||||
### Gap Assessment
|
||||
|
||||
| Component | GJC | claw-code | Gap | Complexity |
|
||||
|-----------|-----|-----------|-----|------------|
|
||||
| WebSocket Transport | 1500 LOC | — | 100% | High |
|
||||
| Frame Correlation | 800 LOC | — | 100% | Medium |
|
||||
| Operation Registry | 400 LOC | 0 | 100% | Medium |
|
||||
| Error Handling & Recovery | 600 LOC | — | 100% | High |
|
||||
| Handshake & Negotiation | 200 LOC | — | 100% | Low |
|
||||
| **Total** | **3,500 LOC** | **0** | **3,500 LOC** | — |
|
||||
|
||||
### Proposed Implementation
|
||||
|
||||
**Owner Crate**: `sdk` (new)
|
||||
|
||||
**Module Structure**:
|
||||
```
|
||||
rust/crates/sdk/
|
||||
├── Cargo.toml
|
||||
└── src/
|
||||
├── lib.rs (exports + top-level API)
|
||||
├── client/
|
||||
│ ├── mod.rs (SdkClient, WebSocket transport)
|
||||
│ ├── correlation.rs (frame ID tracking, reconnect)
|
||||
│ └── errors.rs (SdkErrorCode, recovery)
|
||||
├── protocol/
|
||||
│ ├── mod.rs (Operation registry)
|
||||
│ ├── operations.rs (operation definitions)
|
||||
│ └── schema.rs (JSON schemas, frame validation)
|
||||
├── session/
|
||||
│ ├── mod.rs (session lifecycle over SDK)
|
||||
│ ├── router.rs (session routing, durable state)
|
||||
│ └── reconciliation.rs (uncertain outcome recovery)
|
||||
└── tests/
|
||||
├── mod.rs
|
||||
└── fixtures/ (mock server scenarios)
|
||||
```
|
||||
|
||||
**Size Estimate**: 4,500 LOC (Rust + test fixtures)
|
||||
|
||||
**Dependencies**:
|
||||
- `tokio` (async I/O, WebSocket)
|
||||
- `tungstenite` (WebSocket protocol)
|
||||
- `serde_json` (frame serialization)
|
||||
- `uuid` (correlation IDs)
|
||||
- `runtime` crate (session persistence bridge)
|
||||
|
||||
---
|
||||
|
||||
## 3. SESSION LIFECYCLE
|
||||
|
||||
### GJC Behavior (gajae-code Reference)
|
||||
|
||||
**Source**: `packages/coding-agent/src/sdk/host/session-runtime.ts`, `sdk/client/`, `sdk/lifecycle/`
|
||||
|
||||
GJC sessions have **three entry points**:
|
||||
1. **Native SDK** (via WebSocket): typed `SdkClient` with `session.create`, `session.prompt`, etc.
|
||||
2. **Browser ACP** (via iframe): same protocol, browser sandbox isolate
|
||||
3. **CLI Daemon** (stdio): JSON-RPC over stdio for local CLI access
|
||||
|
||||
**Session Operations**:
|
||||
- **create**: `turn.create(model, input)` → returns `sessionId`, persistent state written to broker DB
|
||||
- **prompt**: `turn.prompt(request, tools, settings)` → streams events: `agent_begin`, `tool_use`, `tool_result`, `agent_end`
|
||||
- **steer**: `turn.steer(overrides)` → mid-flight direction change (model, settings, system prompt)
|
||||
- **abort**: `turn.abort_and_prompt()` → terminal failure + new prompt in one operation
|
||||
- **list**: `turn.list(filters)` → paginated session history
|
||||
- **get_one**: `turn.get_one(sessionId, turnId)` → single turn with full details
|
||||
|
||||
**State Persistence**:
|
||||
- Invocation record (prompt request, outcome, correlation)
|
||||
- Message buffer (system, assistant, tool results, agent events)
|
||||
- Reconciliation store (uncertain → durable mapping)
|
||||
- Terminal scope (last session per correlation key)
|
||||
|
||||
**Lifecycle Events**:
|
||||
1. `turn_accepted` (immediate, transient)
|
||||
2. `agent_begin` (before first model call)
|
||||
3. `tool_use` + `tool_result` (interleaved)
|
||||
4. `agent_end` (final model output)
|
||||
5. `turn_ended` (durable, terminal outcome recorded)
|
||||
|
||||
### claw-code Status
|
||||
|
||||
**Current**: Partial. Local session lifecycle exists but no SDK transport or remote access.
|
||||
|
||||
**Files**:
|
||||
- `rust/crates/runtime/src/session.rs` (1,961 LOC)
|
||||
- `rust/crates/runtime/src/conversation.rs` (1,878 LOC)
|
||||
- `rust/crates/runtime/src/session_control.rs` (file ops, migration)
|
||||
- `rust/crates/runtime/src/lib.rs` (re-exports `Session`, `SessionStore`, `ConversationRuntime`)
|
||||
|
||||
**Lines**: ~3,900 LOC (session + conversation)
|
||||
|
||||
**Reality**:
|
||||
- `Session` struct holds in-memory state (messages, usage, context).
|
||||
- `SessionStore` reads/writes `.claw/sessions/{id}/` directory.
|
||||
- `ConversationRuntime` drives the local agent loop (sync).
|
||||
- No WebSocket or remote access.
|
||||
- No invocation reconciliation or uncertain outcome recovery.
|
||||
- No terminal scope tracking (per-session-per-correlation).
|
||||
|
||||
### Gap Assessment
|
||||
|
||||
| Component | GJC | claw-code | Gap | Complexity |
|
||||
|-----------|-----|-----------|-----|------------|
|
||||
| Remote Session Router | 2,500 LOC | — | 100% | High |
|
||||
| Invocation Reconciliation | 1,200 LOC | — | 100% | High |
|
||||
| Terminal Scope Tracking | 600 LOC | — | 100% | Medium |
|
||||
| Async Event Streaming | 800 LOC | Partial | 60% | Medium |
|
||||
| Uncertain Outcome Recovery | 1,000 LOC | — | 100% | High |
|
||||
| **Total** | **6,100 LOC** | **3,900** | **2,200 LOC** | — |
|
||||
|
||||
### Proposed Implementation
|
||||
|
||||
**Owner Crate**: `sdk` (new, or extend `runtime`)
|
||||
|
||||
**Module Structure** (add to `sdk/session/`):
|
||||
```
|
||||
sdk/session/
|
||||
├── router.rs (session routing, durable lookup)
|
||||
├── reconciliation.rs (uncertain → terminal recovery)
|
||||
├── terminal_scope.rs (per-correlation state retention)
|
||||
└── events.rs (event publishing, reconciliation)
|
||||
```
|
||||
|
||||
**Integration Points**:
|
||||
- Bridge `runtime::Session` into `sdk::Session` wrapper
|
||||
- Add `runtime::SessionStore` extensions for reconciliation data
|
||||
- Extend `ConversationRuntime` with event emission
|
||||
|
||||
**Size Estimate**: 2,500 LOC (Rust, new + modifications)
|
||||
|
||||
---
|
||||
|
||||
## 4. TOOL CATALOG (55 Tools)
|
||||
|
||||
### GJC Behavior (gajae-code Reference)
|
||||
|
||||
**Source**: `packages/coding-agent/src/tools/` (55 tool files + shared harness)
|
||||
|
||||
GJC implements **55 distinct tools** across multiple domains:
|
||||
- **File I/O**: `read`, `write`, `edit`, `search`, `find`
|
||||
- **Shell**: `bash`, `sleep`
|
||||
- **Task Delegation**: `task` (subagent spawning)
|
||||
- **Workflow**: `skill`, `skill_discovery`, `goal`, `ask`
|
||||
- **Search**: `search_tool_bm25` (semantic search discovery)
|
||||
- **Image**: `image_gen` (DALL-E), `image_tag_scan`
|
||||
- **Integration**: `web_fetch`, `web_search`, `custom_tool_fetch`
|
||||
- **Developer**: `notebook_edit`, `command_repl`, `shell_repl`, `powershell`
|
||||
- **Config**: `config_read`, `config_write` (settings, modes)
|
||||
- **Structured**: `model_registry`, `cost_calculator`, `settings_apply`
|
||||
- **Workflow Gates**: `workflow_gate_answer`, `workflow_plan_approve`
|
||||
- **Move**: `move_cwd`, `move_session`
|
||||
|
||||
**Tool Execution Model**:
|
||||
1. Input deserialized from JSON schema
|
||||
2. Permission check (read-only vs. destructive)
|
||||
3. Tool invocation with typed inputs
|
||||
4. Structured output or error
|
||||
5. Result published to agent loop
|
||||
|
||||
**Permission Gating**: Every tool has a `required_permission` (read-only, approve, unsafe).
|
||||
|
||||
### claw-code Status
|
||||
|
||||
**Current**: Partial. 35 tools implemented, 20 missing or incomplete.
|
||||
|
||||
**Files**: `rust/crates/tools/src/lib.rs` (10,892 LOC, single file with all 55 spec + implementations)
|
||||
|
||||
**Implemented Tools** (~35):
|
||||
- `read`, `write`, `edit`, `search`, `find`
|
||||
- `bash`, `sleep`
|
||||
- `task` (subagent spawning)
|
||||
- `skill`, `skill_discovery` (basic)
|
||||
- `bash_list_*, bash_check_*` (validation helpers)
|
||||
- `web_fetch`, `search_bm25` (basic)
|
||||
- `config_*` (read/write settings)
|
||||
- `notebooks` (edit, execute)
|
||||
- `goal` (basic ledger)
|
||||
- `move_*` (cwd, session)
|
||||
- Various validation and utility tools
|
||||
|
||||
**Missing / Incomplete Tools** (~20):
|
||||
- `image_gen` (DALL-E)
|
||||
- `image_tag_scan`
|
||||
- `custom_tool_fetch`
|
||||
- `command_repl`, `shell_repl`, `powershell`
|
||||
- `model_registry`, `cost_calculator`
|
||||
- `workflow_gate_answer`, `workflow_plan_approve`
|
||||
- `ask` (interactive prompt)
|
||||
- `irc` (live coordination)
|
||||
- Search/discovery integration tools
|
||||
- Advanced structured output handling
|
||||
|
||||
### Gap Assessment
|
||||
|
||||
| Component | GJC | claw-code | Gap | Complexity |
|
||||
|-----------|-----|-----------|-----|------------|
|
||||
| Tool Registry | 400 LOC | 400 LOC | 0% | — |
|
||||
| Spec Table (55 entries) | 1,800 LOC | 1,800 LOC | 0% | — |
|
||||
| image_gen | 600 LOC | — | 100% | High |
|
||||
| command_repl, shell_repl | 900 LOC | — | 100% | Medium |
|
||||
| workflow_gate_* | 800 LOC | — | 100% | Medium |
|
||||
| custom_tool_fetch | 400 LOC | — | 100% | Low |
|
||||
| ask (interactive) | 1,200 LOC | Stub | 80% | High |
|
||||
| cost_calculator | 300 LOC | — | 100% | Low |
|
||||
| **Total** | **11,500 LOC** | **8,000** | **3,500 LOC** | — |
|
||||
|
||||
### Proposed Implementation
|
||||
|
||||
**Owner Crate**: `tools` (expand existing)
|
||||
|
||||
**New Modules**:
|
||||
```
|
||||
rust/crates/tools/src/
|
||||
├── tools/
|
||||
│ ├── image_gen.rs (DALL-E integration)
|
||||
│ ├── ask_interactive.rs (blocking user prompts)
|
||||
│ ├── workflow_gates.rs (plan approval, gate answer)
|
||||
│ ├── repl/*.rs (command_repl, shell_repl, powershell)
|
||||
│ └── cost_calc.rs (pricing lookup)
|
||||
└── vendors/
|
||||
├── dalle.rs (image generation API)
|
||||
└── integrations.rs (custom tool fetch)
|
||||
```
|
||||
|
||||
**Size Estimate**: 3,500 LOC (new tools + vendor integration)
|
||||
|
||||
**Dependencies**:
|
||||
- `reqwest` (image generation API calls)
|
||||
- `api` crate (provider client reuse)
|
||||
- Existing: `runtime`, `commands`, `serde_json`
|
||||
|
||||
---
|
||||
|
||||
## 5. SKILL DISCOVERY & INVOCATION
|
||||
|
||||
### GJC Behavior (gajae-code Reference)
|
||||
|
||||
**Source**: `packages/coding-agent/src/tools/skill-discovery.ts`, `packages/coding-agent/src/tools/skill.ts`, `packages/coding-agent/src/sdk-skills/`
|
||||
|
||||
GJC supports **dynamic skill loading** with:
|
||||
- **Discovery**: `skill_discovery` tool scans `.gjc/skills/`, project `.gjc/skills/`, bundled skills
|
||||
- **Frontmatter Parsing**: YAML headers in `SKILL.md` define metadata (name, description, use conditions)
|
||||
- **Invocation**: `skill` tool loads and executes a named skill (skill name + args)
|
||||
- **Skill Execution**: Skill = subprocess call to `gjc /skill:<name>` with args
|
||||
- **Bundled Skills**: `autoresearch`, `deep-interview`, `ralplan`, `ultragoal`
|
||||
- **Project Skills**: Discoverable from `.gjc/skills/*/SKILL.md`
|
||||
- **User Skills**: Discoverable from `~/.gjc/skills/*/SKILL.md`
|
||||
|
||||
**Skill Metadata**:
|
||||
```yaml
|
||||
# /skill:my-skill
|
||||
# description: What this skill does
|
||||
# scope: project | user | bundled
|
||||
# requires: List of capabilities
|
||||
```
|
||||
|
||||
**Skill Discovery Result**:
|
||||
```json
|
||||
{
|
||||
"name": "skill-name",
|
||||
"description": "...",
|
||||
"path": "...",
|
||||
"bundled": true | false,
|
||||
"scope": "project" | "user" | "bundled"
|
||||
}
|
||||
```
|
||||
|
||||
### claw-code Status
|
||||
|
||||
**Current**: Basic. Skill discovery exists but limited to filesystem scanning.
|
||||
|
||||
**Files**:
|
||||
- `rust/crates/tools/src/lib.rs` (skill_discovery + skill dispatch, ~200 LOC)
|
||||
- `rust/crates/runtime/src/prompt.rs` (skill loading in prompts, ~100 LOC)
|
||||
|
||||
**Implementation**:
|
||||
- Scans `.claw/skills/` directory for `SKILL.md` files
|
||||
- Parses frontmatter (basic YAML)
|
||||
- Returns discovery results as JSON
|
||||
- `skill` tool invokes skill as subprocess
|
||||
|
||||
**Missing**:
|
||||
- Bundled skill registry (autoresearch, deep-interview, ralplan, ultragoal)
|
||||
- Project + user skill discovery hierarchy
|
||||
- Frontmatter validation and standardization
|
||||
- Skill chaining (`skill()` tool to invoke another skill from within a skill)
|
||||
- Skill state persistence (workflow handoff)
|
||||
|
||||
### Gap Assessment
|
||||
|
||||
| Component | GJC | claw-code | Gap | Complexity |
|
||||
|-----------|-----|-----------|-----|------------|
|
||||
| Bundled Skill Registry | 200 LOC | — | 100% | Low |
|
||||
| Frontmatter Parser | 150 LOC | 100 LOC | 30% | Low |
|
||||
| Project + User Discovery | 300 LOC | Basic | 70% | Low |
|
||||
| Skill Chaining | 250 LOC | — | 100% | Medium |
|
||||
| Workflow State Handoff | 400 LOC | — | 100% | Medium |
|
||||
| Skill Execution Harness | 200 LOC | Partial | 40% | Low |
|
||||
| **Total** | **1,500 LOC** | **100** | **1,400 LOC** | — |
|
||||
|
||||
### Proposed Implementation
|
||||
|
||||
**Owner Crate**: `tools` (expand existing) or `skills` (new, lightweight)
|
||||
|
||||
**Module Structure**:
|
||||
```
|
||||
rust/crates/tools/src/
|
||||
├── skill_discovery.rs (enhanced parsing, project/user/bundled hierarchy)
|
||||
├── skill_execution.rs (subprocess dispatch, state handoff)
|
||||
├── bundled_skills.rs (registry of autoresearch, deep-interview, ralplan, ultragoal)
|
||||
└── skill_state.rs (workflow handoff serialization)
|
||||
```
|
||||
|
||||
**Size Estimate**: 1,400 LOC (Rust)
|
||||
|
||||
**Integration**:
|
||||
- Extend `skill_discovery` tool to search three scopes (bundled, project, user)
|
||||
- Add `skill` tool support for chaining (returning another skill invocation)
|
||||
- Store workflow state in durable files for handoff
|
||||
|
||||
---
|
||||
|
||||
## 6. SETTINGS & CONFIGURATION
|
||||
|
||||
### GJC Behavior (gajae-code Reference)
|
||||
|
||||
**Source**: `packages/coding-agent/src/config.ts`, `packages/coding-agent/src/sdk/host/session-runtime.ts`
|
||||
|
||||
GJC supports **settings override** at multiple levels:
|
||||
1. **Global Settings**: `~/.gjc/config.json` (user defaults)
|
||||
2. **Session Settings**: per-session override via `turn.create` or `turn.prompt` input
|
||||
3. **Runtime Override**: `settings` field in SDK request frames
|
||||
4. **Setting Categories**:
|
||||
- **Model**: `modelName`, `modelTemperature`, `modelMaxTokens`
|
||||
- **Behavior**: `allowance`, `permissions.*`, `features.*`
|
||||
- **UX**: `theme`, `shell`, `notification*`
|
||||
- **Advanced**: `memory.context`, `memory.compression`, `cost.*`
|
||||
|
||||
**Config Schema**: Typed with JSON schema validation per setting
|
||||
|
||||
**Permission Modes**:
|
||||
- `read_only` (no execution)
|
||||
- `approve` (interactive approval)
|
||||
- `unsafe` (no gates)
|
||||
|
||||
### claw-code Status
|
||||
|
||||
**Current**: Partial. Config exists but no session-level override or setting propagation.
|
||||
|
||||
**Files**:
|
||||
- `rust/crates/runtime/src/config.rs` (3,894 LOC, largest file in runtime)
|
||||
- `rust/crates/runtime/src/config_validate.rs` (validation helpers)
|
||||
|
||||
**Implementation**:
|
||||
- Reads `.claw/config.json` at startup
|
||||
- Stores global settings (model, permissions, plugins, features)
|
||||
- No session-level override
|
||||
- No setting propagation through tool execution
|
||||
- No typed setting validation against schema
|
||||
|
||||
**Missing**:
|
||||
- Per-session setting override (session-specific model, permissions)
|
||||
- Setting propagation to all tool invocations
|
||||
- Dynamic setting application (e.g., changing model mid-session)
|
||||
- Setting schema validation
|
||||
- Config hotreload (changes without restart)
|
||||
|
||||
### Gap Assessment
|
||||
|
||||
| Component | GJC | claw-code | Gap | Complexity |
|
||||
|-----------|-----|-----------|-----|------------|
|
||||
| Setting Schema Definition | 300 LOC | — | 100% | Low |
|
||||
| Session-Level Override | 400 LOC | — | 100% | Medium |
|
||||
| Setting Propagation | 300 LOC | — | 100% | Low |
|
||||
| Dynamic Application | 200 LOC | — | 100% | Medium |
|
||||
| Config Hotreload | 150 LOC | — | 100% | Low |
|
||||
| **Total** | **1,350 LOC** | **3,894** | **-2,544 LOC** | — |
|
||||
|
||||
### Proposed Implementation
|
||||
|
||||
**Owner Crate**: `runtime` (enhance existing `config.rs`)
|
||||
|
||||
**Additions to config.rs**:
|
||||
- Setting schema registry
|
||||
- Session setting override struct
|
||||
- Setting applicator (merge global + session + override)
|
||||
- Hotreload support (watch config file, signal on change)
|
||||
|
||||
**Size Estimate**: 500 LOC (new code, mostly schema + applicators)
|
||||
|
||||
---
|
||||
|
||||
## 7. HOOKS & PLUGINS
|
||||
|
||||
### GJC Behavior (gajae-code Reference)
|
||||
|
||||
**Source**: `packages/coding-agent/src/hooks.ts`, `packages/coding-agent/src/plugin_lifecycle.ts`
|
||||
|
||||
GJC provides **5 hook points**:
|
||||
- `on_session_created`: After session starts
|
||||
- `on_prompt_received`: Before agent loop begins
|
||||
- `on_tool_use`: Before tool invocation
|
||||
- `on_tool_result`: After tool completes
|
||||
- `on_session_ended`: Before session cleanup
|
||||
|
||||
**Plugin System**:
|
||||
- Plugins are shell scripts or binaries in `.claw/plugins/`
|
||||
- Each plugin implements hook handlers (exit code + JSON output)
|
||||
- Plugin registry stored in config
|
||||
|
||||
### claw-code Status
|
||||
|
||||
**Current**: Existing and comprehensive.
|
||||
|
||||
**Files**:
|
||||
- `rust/crates/runtime/src/hooks.rs` (L155)
|
||||
- `rust/crates/runtime/src/plugin_lifecycle.rs`
|
||||
- `rust/crates/plugins/src/` (plugin manager)
|
||||
|
||||
**Implementation**: Hooks fully implemented; plugin system working.
|
||||
|
||||
### Gap Assessment
|
||||
|
||||
| Component | GJC | claw-code | Gap |
|
||||
|-----------|-----|-----------|-----|
|
||||
| Hook Points | 5 | 5 | 0% |
|
||||
| Plugin Registry | ✓ | ✓ | 0% |
|
||||
| Plugin Execution | ✓ | ✓ | 0% |
|
||||
|
||||
**Status**: **No gaps**. Hooks and plugins are fully compatible.
|
||||
|
||||
---
|
||||
|
||||
## 8. AGENTS.MD RULES & CONTEXT
|
||||
|
||||
### GJC Behavior (gajae-code Reference)
|
||||
|
||||
**Source**: gajae-code root + `docs/` (AGENTS.md files at multiple levels)
|
||||
|
||||
GJC loads **directory-specific rules** from `AGENTS.md` files:
|
||||
- **Root AGENTS.md**: Project-level knowledge base
|
||||
- **Directory AGENTS.md**: Directory-specific rules override parent
|
||||
- **Rules Loaded**: Automatically prepended to system prompt during agent reasoning
|
||||
- **Hierarchy**: Deepest (most specific) rules override higher level
|
||||
|
||||
**Rule Content**:
|
||||
- Code organization and conventions
|
||||
- Tool restrictions (never use X in Y context)
|
||||
- Testing requirements
|
||||
- Deployment/safety gates
|
||||
- Architecture decisions
|
||||
|
||||
### claw-code Status
|
||||
|
||||
**Current**: Existing and matching GJC.
|
||||
|
||||
**Files**:
|
||||
- Root: `AGENTS.md`
|
||||
- Crate-level: `rust/crates/*/AGENTS.md`
|
||||
- Implementation: `rust/crates/runtime/src/` (loads rules)
|
||||
|
||||
**Implementation**: Rules loaded and applied to prompts.
|
||||
|
||||
### Gap Assessment
|
||||
|
||||
| Component | GJC | claw-code | Gap |
|
||||
|-----------|-----|-----------|-----|
|
||||
| Rule Loading | ✓ | ✓ | 0% |
|
||||
| Hierarchy | ✓ | ✓ | 0% |
|
||||
| Prompt Integration | ✓ | ✓ | 0% |
|
||||
|
||||
**Status**: **No gaps**. AGENTS.md system is compatible.
|
||||
|
||||
---
|
||||
|
||||
## 9. SLASH COMMANDS (120+ Commands)
|
||||
|
||||
### GJC Behavior (gajae-code Reference)
|
||||
|
||||
**Source**: `packages/coding-agent/src/commands/lib.ts` (7,200 LOC with 120+ command specs)
|
||||
|
||||
GJC implements **120+ slash commands** for REPL interaction:
|
||||
- **Session**: `/session list`, `/session create`, `/session restore`, `/session delete`
|
||||
- **Model**: `/model set`, `/model list`, `/model info`
|
||||
- **Permissions**: `/permissions set`, `/permissions show`
|
||||
- **Config**: `/config get`, `/config set`, `/config reload`
|
||||
- **Skills**: `/skills list`, `/skills install`, `/skills create`
|
||||
- **Plugins**: `/plugins list`, `/plugins install`
|
||||
- **Workflow**: `/plan`, `/review`, `/approve`, `/ultragoal`
|
||||
- **Debugging**: `/debug`, `/dump`, `/trace`
|
||||
- **Utilities**: `/help`, `/clear`, `/quit`, `/export`
|
||||
|
||||
**Command Format**: `/command [arg1] [arg2] ...` with arg parsing per command
|
||||
|
||||
### claw-code Status
|
||||
|
||||
**Current**: Partial. ~40 commands implemented out of 120+.
|
||||
|
||||
**Files**:
|
||||
- `rust/crates/commands/src/lib.rs` (7,183 LOC)
|
||||
- Command specs in `SLASH_COMMAND_SPECS` static table
|
||||
|
||||
**Implemented Commands** (~40):
|
||||
- Session mgmt (list, create, etc.)
|
||||
- Help, status, version
|
||||
- Config (get, set, reload)
|
||||
- Skills (list, install)
|
||||
- Plugins (list, install)
|
||||
- Workspace/debug (clear, compact, etc.)
|
||||
|
||||
**Missing Commands** (~80):
|
||||
- Advanced workflow (`/plan`, `/review`, `/ultragoal`)
|
||||
- Model selection and info
|
||||
- Cost calculator (`/cost`)
|
||||
- Advanced debugging (`/dump`, `/trace`)
|
||||
- Theme management
|
||||
- Advanced permission controls
|
||||
- Task management (`/tasks list`, `/tasks cancel`)
|
||||
- IRC/coordination (`/irc`)
|
||||
- Many others
|
||||
|
||||
### Gap Assessment
|
||||
|
||||
| Component | GJC | claw-code | Gap |
|
||||
|-----------|-----|-----------|-----|
|
||||
| Command Parser | 800 LOC | 800 LOC | 0% |
|
||||
| Spec Table (120+ entries) | 2,000 LOC | 2,000 LOC | 0% |
|
||||
| Handlers (80 missing) | 3,200 LOC | 1,800 LOC | 56% |
|
||||
| Help/suggestion | 600 LOC | 400 LOC | 30% |
|
||||
| **Total** | **7,200 LOC** | **4,000** | **3,200 LOC** | — |
|
||||
|
||||
### Proposed Implementation
|
||||
|
||||
**Owner Crate**: `commands` (expand existing)
|
||||
|
||||
**New Modules**:
|
||||
```
|
||||
rust/crates/commands/src/
|
||||
├── handlers/
|
||||
│ ├── workflow.rs (/plan, /review, /approve, /ultragoal)
|
||||
│ ├── model.rs (/model set, /model list, /model info)
|
||||
│ ├── cost.rs (/cost)
|
||||
│ ├── advanced_debug.rs (/dump, /trace, /debug advanced)
|
||||
│ ├── theme.rs (/theme set, /theme list)
|
||||
│ ├── task.rs (/tasks list, /tasks cancel)
|
||||
│ └── advanced_perms.rs (advanced permission commands)
|
||||
```
|
||||
|
||||
**Size Estimate**: 3,200 LOC (new command handlers)
|
||||
|
||||
**Dependencies**: `runtime`, `tools`, `plugins`
|
||||
|
||||
---
|
||||
|
||||
## 10. SUBAGENT DELEGATION & TASK EXECUTION
|
||||
|
||||
### GJC Behavior (gajae-code Reference)
|
||||
|
||||
**Source**: `packages/coding-agent/src/tools/task.ts`, `packages/coding-agent/src/task/`
|
||||
|
||||
GJC's **subagent system** enables parallel task execution:
|
||||
- **task Tool**: Spawn parallel subtasks with independent context
|
||||
- **Task States**: spawning, running, succeeded, failed
|
||||
- **Task Isolation**: Each task has isolated session, permissions, context
|
||||
- **Task Coordination**: `irc` tool for live inter-task communication
|
||||
- **Task Monitoring**: `task.inspect`, `task.list`, `task.cancel`
|
||||
- **Artifact Sharing**: Tasks share artifact URLs but not direct state
|
||||
|
||||
**Task Spawning**:
|
||||
```json
|
||||
{
|
||||
"agent": "executor|architect|planner",
|
||||
"tasks": [
|
||||
{
|
||||
"id": "task-1",
|
||||
"description": "...",
|
||||
"assignment": "..."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### claw-code Status
|
||||
|
||||
**Current**: Existing and working.
|
||||
|
||||
**Files**:
|
||||
- `rust/crates/tools/src/lib.rs` (task tool, ~300 LOC)
|
||||
- `rust/crates/runtime/src/task_*.rs` (task execution, state mgmt)
|
||||
|
||||
**Implementation**: Task tool works; spawning, monitoring, and coordination functional.
|
||||
|
||||
### Gap Assessment
|
||||
|
||||
| Component | GJC | claw-code | Gap |
|
||||
|-----------|-----|-----------|-----|
|
||||
| Task Spawning | ✓ | ✓ | 0% |
|
||||
| Task Isolation | ✓ | ✓ | 0% |
|
||||
| Task Monitoring | ✓ | ✓ | 0% |
|
||||
| IRC Coordination | ✓ | ✓ | 0% |
|
||||
|
||||
**Status**: **No significant gaps**. Task execution is compatible.
|
||||
|
||||
---
|
||||
|
||||
## 11. IMPLEMENTATION ROADMAP
|
||||
|
||||
### Phase 1: Foundation (SDK Wire Protocol)
|
||||
|
||||
**Slice 1.1: WebSocket Transport & Frame Correlation**
|
||||
- Create `sdk` crate structure
|
||||
- Implement `SdkClient` with WebSocket connection
|
||||
- Add frame ID correlation and response matching
|
||||
- Add reconnect logic with exponential backoff
|
||||
- Tests: transport connectivity, frame correlation, reconnect behavior
|
||||
|
||||
**Slice 1.2: Operation Registry & Schema**
|
||||
- Define Operation enum (65+ operation types)
|
||||
- JSON schema validation for requests
|
||||
- Error code definitions and handlers
|
||||
- Uncertain outcome recovery protocol
|
||||
- Tests: operation parsing, schema validation, error scenarios
|
||||
|
||||
**Slice 1.3: Session SDK Integration**
|
||||
- Bridge `runtime::Session` to SDK session model
|
||||
- Implement session router (durable lookup)
|
||||
- Add invocation reconciliation store
|
||||
- Terminal scope tracking per correlation
|
||||
- Tests: session creation, routing, reconciliation
|
||||
|
||||
### Phase 2: Core Capabilities (Tools & Skills)
|
||||
|
||||
**Slice 2.1: Missing Tool Implementation**
|
||||
- `image_gen` (DALL-E integration)
|
||||
- `ask` (interactive blocking prompts)
|
||||
- `workflow_gate_*` (plan approval gates)
|
||||
- `command_repl`, `shell_repl`, `powershell`
|
||||
- `cost_calculator`
|
||||
|
||||
**Slice 2.2: Skill Enhancement**
|
||||
- Bundled skill registry (autoresearch, deep-interview, ralplan, ultragoal)
|
||||
- Enhanced skill discovery (project, user, bundled hierarchy)
|
||||
- Skill chaining support (skill calling skill)
|
||||
- Workflow state handoff
|
||||
|
||||
### Phase 3: Polish (Settings, Commands, Integration)
|
||||
|
||||
**Slice 3.1: Settings & Configuration**
|
||||
- Session-level setting override
|
||||
- Setting schema validation
|
||||
- Setting propagation to tools
|
||||
- Config hotreload
|
||||
|
||||
**Slice 3.2: Slash Commands**
|
||||
- Workflow commands (/plan, /review, /ultragoal)
|
||||
- Model selection (/model set, /model list)
|
||||
- Cost calculator (/cost)
|
||||
- Advanced debugging (/dump, /trace)
|
||||
- Task management (/tasks list, /tasks cancel)
|
||||
- Theme management (/theme)
|
||||
|
||||
---
|
||||
|
||||
## 12. DEPENDENCY & CRATE RELATIONSHIPS
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────┐
|
||||
│ cli (rusty-claude-cli: main entry, REPL, TUI) │
|
||||
└────────────┬───────────────────────────────────────────┘
|
||||
│
|
||||
┌────────────▼──────────────────────────────────────────┐
|
||||
│ commands (slash command specs & handlers) │
|
||||
└────────┬────────────────────────────────────────┬──────┘
|
||||
│ │
|
||||
│ ┌───▼────────────────────┐
|
||||
│ │ tools (55 tool impl) │
|
||||
│ └──────┬─────────────────┘
|
||||
│ │
|
||||
┌────────▼─────────────────────────────────────────▼──────────────────┐
|
||||
│ runtime (session, conversation, config, mcp, hooks, permissions) │
|
||||
└────────┬──────────────────────────────┬────────────────────────────┘
|
||||
│ │
|
||||
┌────▼──────────────┐ ┌──────▼─────────────────┐
|
||||
│ api (providers) │ │ plugins (plugin mgmt) │
|
||||
└───────────────────┘ └───────────────────────┘
|
||||
|
||||
NEW: sdk (WebSocket, session routing, reconciliation)
|
||||
│
|
||||
└─→ depends on: runtime, serde_json, tokio, tungstenite
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. RISK & MITIGATION
|
||||
|
||||
| Risk | Probability | Severity | Mitigation |
|
||||
|------|-------------|----------|-----------|
|
||||
| SDK protocol complexity | High | High | Start with mock transport tests, iterate on real scenarios |
|
||||
| Session state inconsistency | Medium | High | Comprehensive reconciliation tests, durable state verification |
|
||||
| Tool compatibility gaps | Medium | Medium | Tool-by-tool comparison with GJC, shadow testing |
|
||||
| Breaking changes to runtime | Medium | High | Version gates, feature flags, phased rollout |
|
||||
| Performance regression | Medium | Medium | Benchmark key paths (session create, tool dispatch), profile |
|
||||
|
||||
---
|
||||
|
||||
## 14. VERIFICATION & SIGN-OFF
|
||||
|
||||
- [ ] SDK transport connects and communicates with mock server
|
||||
- [ ] Session lifecycle operations (create, prompt, steer, abort) work end-to-end
|
||||
- [ ] Tool execution with SDK matches GJC behavior
|
||||
- [ ] Skill discovery finds all three scopes (bundled, project, user)
|
||||
- [ ] Settings override works per-session
|
||||
- [ ] Slash commands parse and dispatch correctly
|
||||
- [ ] All AGENTS.md tests pass
|
||||
- [ ] Hooks fire at correct lifecycle points
|
||||
- [ ] Subagent tasks spawn and communicate via IRC
|
||||
- [ ] Public documentation has no internal paths, hosts, or private infra
|
||||
|
||||
---
|
||||
|
||||
**Document Version**: 1.0
|
||||
**Last Updated**: 2026-10-13
|
||||
**Next Review**: After Slice 1.1 implementation complete
|
||||
@@ -1,338 +0,0 @@
|
||||
# GJC Tool Semantics Alignment: Phase 1 (read/edit/search/find)
|
||||
|
||||
**Status**: Implementation plan
|
||||
**Issue**: #3323
|
||||
**Scope**: Slice 2.1 - Tool catalog semantic parity for read/edit/search/find
|
||||
**Target**: Ship in batches with focused PRs
|
||||
|
||||
---
|
||||
|
||||
## 1. EXECUTIVE SUMMARY
|
||||
|
||||
This document details the semantic gaps between claw-code's current tool implementations and GJC's (gajae-code) reference implementations for the highest-value tools: **read**, **edit**, **search**, and **find**.
|
||||
|
||||
### Key Gaps
|
||||
|
||||
| Tool | Current (claw-code) | Target (GJC) | Gap | Complexity |
|
||||
|------|-----|--------|-----|------------|
|
||||
| **read** | offset/limit pagination | Line selectors (`:L-L+N`), raw mode, truncation footers | High | Medium |
|
||||
| **write** | Simple file replacement | Full replacement semantics (matching GJC) | Low | Low |
|
||||
| **edit** | String replacement (old→new) | String replacement with rich patch output | Low | Low |
|
||||
| **find** (glob) | Pattern expansion only | Recursive glob with filtering | Low | Low |
|
||||
| **search** (grep) | Regex search with context | Regex with context + advanced filtering | Low | Low |
|
||||
|
||||
---
|
||||
|
||||
## 2. GJC TOOL SEMANTICS REFERENCE
|
||||
|
||||
### 2.1 READ Tool
|
||||
|
||||
**Source**: `gajae-code/packages/coding-agent/src/tools/read.ts` (3,500+ LOC)
|
||||
|
||||
#### Input Schema
|
||||
```typescript
|
||||
{
|
||||
path: string; // File path (relative or absolute)
|
||||
offset?: number; // Line offset (for pagination)
|
||||
limit?: number; // Lines to read (for pagination)
|
||||
// GJC extensions (missing in claw-code):
|
||||
selector?: string; // Line range syntax:
|
||||
// ":50" = line 50 onward
|
||||
// ":50-200" = lines 50-200 inclusive
|
||||
// ":50+150" = 150 lines starting at line 50
|
||||
// ":raw" = full file verbatim
|
||||
// ":conflicts" = git merge conflicts only
|
||||
truncationDirection?: "head" | "tail" | "both"; // Which end to keep
|
||||
maxSummaryBytes?: number; // Summary budget (default 50KB)
|
||||
maxSummaryLines?: number; // Summary budget (default 20K lines)
|
||||
}
|
||||
```
|
||||
|
||||
#### Output Structure
|
||||
```json
|
||||
{
|
||||
"file": {
|
||||
"path": "src/main.rs",
|
||||
"content": "...",
|
||||
"totalLines": 500,
|
||||
"totalBytes": 12345,
|
||||
"truncatedBy": "lines" | "bytes" | "middle" | null,
|
||||
"displayMode": "summarized" | "raw" | "receipt"
|
||||
},
|
||||
"truncationFooter": "[Showing lines 1-50 of 500; re-read ...]"
|
||||
}
|
||||
```
|
||||
|
||||
#### Key Behaviors
|
||||
|
||||
1. **Line Range Selectors** (`:selector` syntax)
|
||||
- `:50` = start at line 50
|
||||
- `:50-200` = lines 50-200 inclusive
|
||||
- `:50+150` = 150 lines starting at 50
|
||||
- `:50+1` = exactly one line
|
||||
- `:5-16,960-973` = multiple ranges
|
||||
- `:raw` = full verbatim
|
||||
- `:conflicts` = git merge conflicts
|
||||
|
||||
2. **Truncation Modes**
|
||||
- Default: 50KB bytes limit, 20K lines limit
|
||||
- Direction: `head` (keep start), `tail` (keep end), `both` (keep both ends)
|
||||
- Footer includes recovery hint (e.g., "`re-read path:1-9999` for verbatim")
|
||||
|
||||
3. **Structural Summarization**
|
||||
- Code: collapse function bodies to `..` or `…` (merged brace pairs)
|
||||
- Prose: preserve full lines, truncate at word boundaries
|
||||
- Special files: documents (PDF, DOCX, XLSX) converted via markit
|
||||
|
||||
4. **Archive Support**
|
||||
- `.tar`, `.tar.gz`, `.tgz`, `.zip`: member read with `archive.ext:path/inside`
|
||||
- SQLite: `.sqlite:table` or `.sqlite:table:key` queries
|
||||
- Line ranges apply to archive members
|
||||
|
||||
5. **Output Formatting**
|
||||
- Anchored output: Line prefixes with source line numbers and hash anchors
|
||||
- Optional hash lines: Marks structural boundaries (`# pragma`, function start, etc.)
|
||||
- Prose column cap: Long lines wrapped/truncated at column limit
|
||||
|
||||
### 2.2 EDIT Tool
|
||||
|
||||
**Source**: `gajae-code/packages/coding-agent/src/tools/edit.ts`
|
||||
|
||||
#### Input Schema
|
||||
```typescript
|
||||
{
|
||||
path: string; // File to edit
|
||||
old_text: string; // Text to replace (required for edit)
|
||||
new_text: string; // Replacement text
|
||||
all?: boolean; // Replace all occurrences (default: first only)
|
||||
}
|
||||
```
|
||||
|
||||
#### Output Structure
|
||||
```json
|
||||
{
|
||||
"file": {
|
||||
"path": "src/main.rs",
|
||||
"editCount": 1,
|
||||
"beforeSize": 12345,
|
||||
"afterSize": 12400
|
||||
},
|
||||
"patch": [
|
||||
{
|
||||
"type": "replace",
|
||||
"lineStart": 50,
|
||||
"lineEnd": 55,
|
||||
"before": "old content",
|
||||
"after": "new content",
|
||||
"context": {"before": 3, "after": 3}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Key Behaviors
|
||||
|
||||
1. **Exact Match Required**: `old_text` must match exactly (no fuzzy matching)
|
||||
2. **Smallest Replacement**: Use smallest unique old_text that identifies the change
|
||||
3. **All-or-Nothing**: Either all occurrences replaced or none (no partial)
|
||||
4. **Patch Metadata**: Line numbers, context, before/after preserved
|
||||
|
||||
### 2.3 SEARCH Tool
|
||||
|
||||
**Source**: `gajae-code/packages/coding-agent/src/tools/search.ts`
|
||||
|
||||
#### Input Schema
|
||||
```typescript
|
||||
{
|
||||
pattern: string; // Regex pattern
|
||||
paths?: string[]; // Files/dirs to search
|
||||
context?: number; // Context lines before/after (default: 2)
|
||||
i?: boolean; // Case-insensitive
|
||||
glob_exclude?: string[]; // Patterns to skip
|
||||
file_type?: string; // Filter by extension
|
||||
limit?: number; // Max results
|
||||
truncation?: "head" | "tail" | "both";
|
||||
}
|
||||
```
|
||||
|
||||
#### Output Structure
|
||||
```json
|
||||
{
|
||||
"matches": [
|
||||
{
|
||||
"file": "src/main.rs",
|
||||
"lineNumber": 42,
|
||||
"line": "fn process() {",
|
||||
"before": ["// Process data", ""],
|
||||
"after": [" // Implementation", ""]
|
||||
}
|
||||
],
|
||||
"summary": "Found 5 matches in 3 files"
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 FIND Tool
|
||||
|
||||
**Source**: `gajae-code/packages/coding-agent/src/tools/find.ts`
|
||||
|
||||
#### Input Schema
|
||||
```typescript
|
||||
{
|
||||
paths: string[]; // Glob patterns
|
||||
timeout?: number; // Timeout in seconds (default: 5)
|
||||
gitignore?: boolean; // Respect .gitignore (default: true)
|
||||
hidden?: boolean; // Include hidden files (default: true)
|
||||
limit?: number; // Max results (default: 1000)
|
||||
}
|
||||
```
|
||||
|
||||
#### Output Structure
|
||||
```json
|
||||
{
|
||||
"files": [
|
||||
{
|
||||
"path": "src/main.rs",
|
||||
"modified": "2026-10-13T12:34:56Z",
|
||||
"size": 12345
|
||||
}
|
||||
],
|
||||
"summary": "Found 42 files matching pattern",
|
||||
"truncated": false
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. CURRENT CLAW-CODE IMPLEMENTATION GAPS
|
||||
|
||||
### 3.1 READ Tool Gaps
|
||||
|
||||
**File**: `rust/crates/runtime/src/file_ops.rs:713`
|
||||
|
||||
**Current**:
|
||||
```rust
|
||||
pub fn read_file_in_workspace(
|
||||
path: &str,
|
||||
offset: Option<usize>, // Line offset only
|
||||
limit: Option<usize>, // Lines only
|
||||
workspace_root: &Path,
|
||||
) -> io::Result<ReadFileOutput>
|
||||
```
|
||||
|
||||
**Missing**:
|
||||
- ✗ Line range selectors (`:L-L+N` syntax)
|
||||
- ✗ Raw mode (`:raw`)
|
||||
- ✓ Git merge conflict extraction (`:conflicts`)
|
||||
- ✗ Truncation footers with recovery hints
|
||||
- ✗ Structural summarization (collapse braces)
|
||||
- ✗ Archive/SQLite support
|
||||
- ✗ Multi-range selectors (`:5-16,960-973`)
|
||||
- ✗ Prose column cap
|
||||
- ✗ Hash line anchors
|
||||
|
||||
**Estimated LOC to Add**: 1,200-1,500 (including tests)
|
||||
|
||||
### 3.2 EDIT Tool Gaps
|
||||
|
||||
**File**: `rust/crates/runtime/src/file_ops.rs:760`
|
||||
|
||||
**Current**: Basic string replacement works
|
||||
**Missing**:
|
||||
- ✗ Rich patch metadata (line numbers, context lines)
|
||||
|
||||
**Estimated LOC to Add**: 200-300
|
||||
|
||||
### 3.3 SEARCH Tool Gaps
|
||||
|
||||
**File**: `rust/crates/tools/src/lib.rs` (grep search)
|
||||
|
||||
**Current**: Basic regex with context
|
||||
**Missing**:
|
||||
- ✗ Advanced filtering (file type, glob exclude)
|
||||
- ✗ Structured output format
|
||||
|
||||
**Estimated LOC to Add**: 300-400
|
||||
|
||||
### 3.4 FIND Tool Gaps
|
||||
|
||||
**File**: `rust/crates/tools/src/lib.rs` (glob search)
|
||||
|
||||
**Current**: Basic glob expansion
|
||||
**Missing**:
|
||||
- ✗ Recursive globs
|
||||
- ✗ Gitignore filtering
|
||||
- ✗ Hidden file control
|
||||
- ✗ File metadata
|
||||
|
||||
**Estimated LOC to Add**: 200-300
|
||||
|
||||
---
|
||||
|
||||
## 4. IMPLEMENTATION PLAN (BATCHED)
|
||||
|
||||
### Batch 1: READ Tool (Priority P0)
|
||||
- Add line range selector parsing
|
||||
- Implement `:raw` mode
|
||||
- ✓ Implement `:conflicts` mode
|
||||
- Add truncation footers with recovery hints
|
||||
- Implement structural summarization
|
||||
- Add tests for all selector combinations
|
||||
|
||||
**Files to Modify**:
|
||||
- `rust/crates/runtime/src/file_ops.rs` (read_file_in_workspace)
|
||||
- `rust/crates/tools/src/lib.rs` (ReadFileInput struct)
|
||||
- Tests in both
|
||||
|
||||
**Size Estimate**: 1,200 LOC
|
||||
**Complexity**: Medium (string parsing, output formatting)
|
||||
|
||||
### Batch 2: EDIT Tool (Priority P1)
|
||||
- Add patch metadata to output
|
||||
- Ensure exact match semantics
|
||||
- Add comprehensive tests
|
||||
|
||||
**Files to Modify**:
|
||||
- `rust/crates/runtime/src/file_ops.rs` (edit_file_in_workspace)
|
||||
- Tests
|
||||
|
||||
**Size Estimate**: 200 LOC
|
||||
**Complexity**: Low
|
||||
|
||||
### Batch 3: SEARCH/FIND Tools (Priority P1)
|
||||
- Enhance grep/find with GJC-compatible filtering
|
||||
- Add structured output format
|
||||
- Add metadata (file type, modified time)
|
||||
|
||||
**Files to Modify**:
|
||||
- `rust/crates/tools/src/lib.rs` (glob_search, grep_search)
|
||||
- `rust/crates/runtime/src/file_ops.rs`
|
||||
|
||||
**Size Estimate**: 500 LOC
|
||||
**Complexity**: Low-Medium
|
||||
|
||||
---
|
||||
|
||||
## 5. VERIFICATION STRATEGY
|
||||
|
||||
Each batch will include:
|
||||
1. **Semantic Tests**: Verify tool inputs/outputs match GJC reference
|
||||
2. **Edge Case Tests**: Line ranges, truncation, archives, etc.
|
||||
3. **Integration Tests**: End-to-end with claw CLI
|
||||
4. **Parity Shadow Test**: Run same inputs against both GJC and claw-code, compare outputs
|
||||
|
||||
---
|
||||
|
||||
## 6. ROLLOUT SCHEDULE
|
||||
|
||||
- **Batch 1 (READ)**: Week 1 - Enable most impactful tool first
|
||||
- **Batch 2 (EDIT)**: Week 1-2 - Quick follow-up
|
||||
- **Batch 3 (SEARCH/FIND)**: Week 2 - Lower priority but valuable for discoverability
|
||||
|
||||
---
|
||||
|
||||
## 7. REFERENCES
|
||||
|
||||
- GJC Read Tool: `/home/bellman/Workspace/gajae-code/packages/coding-agent/src/tools/read.ts`
|
||||
- claw-code file_ops: `rust/crates/runtime/src/file_ops.rs`
|
||||
- claw-code tools: `rust/crates/tools/src/lib.rs`
|
||||
- Gap Matrix: `docs/gjc-compat/gap-matrix.md`
|
||||
@@ -1,203 +0,0 @@
|
||||
# Nix flake development environment
|
||||
|
||||
This repository includes a **`flake.nix`** for reproducible development environments on NixOS and systems with Nix installed.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Nix** (version 2.4 or later with flakes support enabled)
|
||||
- **direnv** (optional, for automatic environment loading via `.envrc`)
|
||||
|
||||
To verify Nix is installed and flakes are enabled:
|
||||
|
||||
```bash
|
||||
nix --version
|
||||
nix flake --help
|
||||
```
|
||||
|
||||
If flakes are not enabled, add this to your `~/.config/nix/nix.conf`:
|
||||
|
||||
```
|
||||
experimental-features = nix-command flakes
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
### Enter the development environment
|
||||
|
||||
**Option 1: Using `nix flake`** (without direnv)
|
||||
|
||||
```bash
|
||||
nix flake update # Update inputs (optional, pins nixpkgs)
|
||||
nix develop # Enter default dev shell
|
||||
nix develop .#full # Enter full dev shell with extra tools
|
||||
exit # Leave the shell
|
||||
```
|
||||
|
||||
**Option 2: Using direnv** (automatic on directory entry)
|
||||
|
||||
```bash
|
||||
direnv allow # Trust the .envrc file (one-time setup)
|
||||
cd . # Direnv reloads the flake automatically
|
||||
```
|
||||
|
||||
When you leave the repository directory, the environment is automatically unloaded.
|
||||
|
||||
## Development shells
|
||||
|
||||
The flake provides two development shells:
|
||||
|
||||
### `default` (minimal)
|
||||
|
||||
```bash
|
||||
nix develop
|
||||
```
|
||||
|
||||
Includes:
|
||||
- Rust (stable, latest)
|
||||
- Rust source code (`rust-src`)
|
||||
- `cargo-edit` (dependency management helpers)
|
||||
- `cargo-expand` (macro expansion tool)
|
||||
- `cargo-watch` (file watcher for incremental builds)
|
||||
- Build tools: `pkg-config`, `openssl`, `git`, `ca-certificates`
|
||||
|
||||
### `full` (with extra tooling)
|
||||
|
||||
```bash
|
||||
nix develop .#full
|
||||
```
|
||||
|
||||
Includes everything from `default` plus:
|
||||
- `clippy` (linter)
|
||||
- `rustfmt` (code formatter)
|
||||
- `mold` (fast linker)
|
||||
- `lldb` (debugger)
|
||||
|
||||
## Building the claw binary
|
||||
|
||||
### With the dev environment
|
||||
|
||||
```bash
|
||||
nix develop
|
||||
cd rust
|
||||
cargo build -p rusty-claude-cli
|
||||
```
|
||||
|
||||
### Directly with `nix build`
|
||||
|
||||
```bash
|
||||
nix build .#claw # Build the claw binary
|
||||
nix build # Same as .#claw (default package)
|
||||
```
|
||||
|
||||
The compiled binary is symlinked to `./result/bin/claw`.
|
||||
|
||||
## Running common tasks
|
||||
|
||||
Once in the dev shell:
|
||||
|
||||
```bash
|
||||
# Format code
|
||||
cd rust
|
||||
cargo fmt
|
||||
|
||||
# Run clippy linter
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
|
||||
# Run all tests
|
||||
cargo test --workspace
|
||||
|
||||
# Run tests for a specific crate
|
||||
cargo test -p rusty-claude-cli
|
||||
|
||||
# Build with optimizations
|
||||
cargo build --release -p rusty-claude-cli
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
|
||||
The flake automatically sets:
|
||||
|
||||
- `RUSTUP_TOOLCHAIN=stable` — uses stable Rust from nixpkgs
|
||||
- Standard Rust build flags are inherited from the environment
|
||||
|
||||
For development, the minimal dev shell includes a helpful message on entry:
|
||||
|
||||
```
|
||||
Claw Code development environment
|
||||
Run 'cargo build -p rusty-claude-cli' to build the claw binary
|
||||
Run 'cargo test --workspace' to run tests
|
||||
Run 'scripts/fmt.sh' to format code
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### `command not found: nix`
|
||||
|
||||
Nix is not installed. Install it from [nixos.org/download.html](https://nixos.org/download.html).
|
||||
|
||||
### `error: experimental Nix feature 'flakes' is disabled`
|
||||
|
||||
Enable flakes in your Nix configuration (`~/.config/nix/nix.conf` or `/etc/nix/nix.conf`):
|
||||
|
||||
```
|
||||
experimental-features = nix-command flakes
|
||||
```
|
||||
|
||||
### `error: ... does not provide attribute 'claw'`
|
||||
|
||||
The flake structure may have changed. Try updating:
|
||||
|
||||
```bash
|
||||
nix flake update
|
||||
nix flake check
|
||||
```
|
||||
|
||||
### Rebuilds are slow after updating dependencies
|
||||
|
||||
Cargo will recompile dependencies if `Cargo.lock` changes. This is expected:
|
||||
|
||||
```bash
|
||||
nix build --rebuild
|
||||
```
|
||||
|
||||
### `cache lock file needs to be updated`
|
||||
|
||||
The Cargo.lock in `rust/` needs to be committed. Run:
|
||||
|
||||
```bash
|
||||
cd rust
|
||||
cargo generate-lockfile
|
||||
git add Cargo.lock
|
||||
git commit -m "update: Cargo.lock"
|
||||
```
|
||||
|
||||
## Integration with other tools
|
||||
|
||||
### IDE integration (VS Code, IntelliJ, etc.)
|
||||
|
||||
Many IDEs can detect and use Nix environments. Direnv integration is recommended:
|
||||
|
||||
1. Install the direnv extension
|
||||
2. Enable `direnv` shell integration in your IDE
|
||||
3. Run `direnv allow` in the repository
|
||||
|
||||
Your IDE will automatically use the correct Rust toolchain and dependencies from the flake.
|
||||
|
||||
### CI/CD
|
||||
|
||||
For GitHub Actions, consider using [cachix/install-nix-action](https://github.com/cachix/install-nix-action) to set up Nix on the runner:
|
||||
|
||||
```yaml
|
||||
- uses: cachix/install-nix-action@v18
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
extra_nix_config: "experimental-features = nix-command flakes"
|
||||
|
||||
- run: nix develop -c cargo test --workspace
|
||||
```
|
||||
|
||||
## More information
|
||||
|
||||
- [NixOS flakes documentation](https://nixos.wiki/wiki/Flakes)
|
||||
- [Direnv documentation](https://direnv.net/)
|
||||
- [Rust on Nix](https://github.com/oxalica/rust-overlay)
|
||||
@@ -1,115 +0,0 @@
|
||||
{
|
||||
description = "claw-code: Claude Code CLI agent harness in Rust";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
rust-overlay = {
|
||||
url = "github:oxalica/rust-overlay";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils, rust-overlay }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
overlays = [ (import rust-overlay) ];
|
||||
pkgs = import nixpkgs {
|
||||
inherit system overlays;
|
||||
};
|
||||
|
||||
# Build inputs needed for Rust and the project
|
||||
buildInputs = with pkgs; [
|
||||
pkg-config
|
||||
openssl
|
||||
ca-certificates
|
||||
git
|
||||
];
|
||||
|
||||
# Runtime inputs for the dev environment
|
||||
nativeBuildInputs = with pkgs; [
|
||||
(rust-bin.stable.latest.default.override {
|
||||
extensions = [ "rust-src" ];
|
||||
targets = [ ];
|
||||
})
|
||||
cargo-edit
|
||||
cargo-expand
|
||||
cargo-watch
|
||||
];
|
||||
|
||||
# Derivation to build the claw binary
|
||||
clawBinary = pkgs.rustPlatform.buildRustPackage {
|
||||
pname = "claw";
|
||||
version = (builtins.fromTOML (builtins.readFile ./rust/Cargo.toml)).workspace.package.version;
|
||||
|
||||
src = ./rust;
|
||||
|
||||
# Hash of all the dependencies in Cargo.lock
|
||||
# This needs to be updated when dependencies change
|
||||
# Run: nix flake update
|
||||
cargoLock = {
|
||||
lockFile = ./rust/Cargo.lock;
|
||||
};
|
||||
|
||||
cargoBuildFlags = [ "-p" "rusty-claude-cli" ];
|
||||
|
||||
buildInputs = buildInputs ++ (with pkgs; [
|
||||
openssl
|
||||
]);
|
||||
|
||||
nativeBuildInputs = with pkgs; [
|
||||
pkg-config
|
||||
];
|
||||
|
||||
meta = with pkgs.lib; {
|
||||
description = "Claude Code CLI: agent harness with Rust implementation";
|
||||
homepage = "https://github.com/ultraworkers/claw-code";
|
||||
license = licenses.mit;
|
||||
platforms = platforms.unix;
|
||||
};
|
||||
};
|
||||
|
||||
in
|
||||
{
|
||||
# Provide the claw binary as a package
|
||||
packages.claw = clawBinary;
|
||||
packages.default = clawBinary;
|
||||
|
||||
# Development shell for contributors
|
||||
devShells.default = pkgs.mkShell {
|
||||
name = "claw-dev";
|
||||
inherit buildInputs nativeBuildInputs;
|
||||
|
||||
shellHook = ''
|
||||
echo "Claw Code development environment"
|
||||
echo "Run 'cargo build -p rusty-claude-cli' to build the claw binary"
|
||||
echo "Run 'cargo test --workspace' to run tests"
|
||||
echo "Run 'scripts/fmt.sh' to format code"
|
||||
'';
|
||||
|
||||
# Make sure we have the right Rust toolchain
|
||||
RUSTUP_TOOLCHAIN = "stable";
|
||||
};
|
||||
|
||||
# Alternative shell with additional development tools
|
||||
devShells.full = pkgs.mkShell {
|
||||
name = "claw-dev-full";
|
||||
inherit buildInputs nativeBuildInputs;
|
||||
|
||||
nativeBuildInputs = nativeBuildInputs ++ (with pkgs; [
|
||||
clippy
|
||||
rustfmt
|
||||
mold
|
||||
lldb
|
||||
]);
|
||||
|
||||
shellHook = ''
|
||||
echo "Claw Code full development environment (with extra tools)"
|
||||
echo "Available tools: clippy, rustfmt, mold (linker), lldb (debugger)"
|
||||
'';
|
||||
|
||||
RUSTUP_TOOLCHAIN = "stable";
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -41,7 +41,7 @@ TUI rule: formatting fns take `&mut impl Write`, never stdout directly. Never mi
|
||||
|
||||
Library crates don't carry the `claw-` prefix. Binary crates do (except legacy `rusty-claude-cli`).
|
||||
|
||||
Workspace version is `0.1.4`, `publish = false`, MIT license.
|
||||
Workspace version is `0.1.3`, `publish = false`, MIT license.
|
||||
|
||||
No rust-toolchain file, no MSRV. CI pins `dtolnay/rust-toolchain@stable`.
|
||||
|
||||
|
||||
Generated
+46
-111
@@ -93,7 +93,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "api"
|
||||
version = "0.1.4"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"reqwest",
|
||||
@@ -349,12 +349,6 @@ version = "3.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.1"
|
||||
@@ -478,7 +472,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
|
||||
[[package]]
|
||||
name = "claw-analog"
|
||||
version = "0.1.4"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"api",
|
||||
"clap",
|
||||
@@ -498,7 +492,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "claw-rag-service"
|
||||
version = "0.1.4"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"blake3",
|
||||
@@ -531,7 +525,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "commands"
|
||||
version = "0.1.4"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"plugins",
|
||||
"runtime",
|
||||
@@ -540,7 +534,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "compat-harness"
|
||||
version = "0.1.4"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"commands",
|
||||
"runtime",
|
||||
@@ -733,12 +727,6 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
@@ -1020,6 +1008,18 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi 5.3.0",
|
||||
"wasip2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
@@ -1029,7 +1029,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core 0.10.1",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
@@ -1568,7 +1568,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mock-anthropic-service"
|
||||
version = "0.1.4"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"api",
|
||||
"serde_json",
|
||||
@@ -1761,7 +1761,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "plugins"
|
||||
version = "0.1.4"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1947,6 +1947,12 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
@@ -2136,7 +2142,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "runtime"
|
||||
version = "0.1.4"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"glob",
|
||||
"plugins",
|
||||
@@ -2261,7 +2267,7 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "rusty-claude-cli"
|
||||
version = "0.1.4"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"api",
|
||||
"commands",
|
||||
@@ -2331,21 +2337,6 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "sdk"
|
||||
version = "0.1.4"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tokio-test",
|
||||
"tokio-tungstenite",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
@@ -2450,17 +2441,6 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
@@ -2619,7 +2599,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "telemetry"
|
||||
version = "0.1.4"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -2632,7 +2612,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.3",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.61.2",
|
||||
@@ -2753,7 +2733,6 @@ dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2 0.6.3",
|
||||
@@ -2793,29 +2772,6 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-test"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12bc5db7778a8e08ee255090861e0e7aac55e784300077e7ed293c7be36168b2"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6989540ced10490aaf14e6bad2e3d33728a2813310a0c71d1574304c49631cd"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"tokio",
|
||||
"tungstenite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
@@ -2906,7 +2862,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tools"
|
||||
version = "0.1.4"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"api",
|
||||
"aspect-core",
|
||||
@@ -3027,24 +2983,6 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e2e2ce1e47ed2994fd43b04c8f618008d4cabdd5ee34027cf14f9d918edd9c8"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.8.6",
|
||||
"sha1",
|
||||
"thiserror 1.0.69",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.19.0"
|
||||
@@ -3093,12 +3031,6 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf-8"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
@@ -3111,18 +3043,6 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606"
|
||||
dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"js-sys",
|
||||
"serde_core",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
@@ -3160,6 +3080,15 @@ version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.2+wasi-0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.116"
|
||||
@@ -3468,6 +3397,12 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.2"
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ members = ["crates/*"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.4"
|
||||
version = "0.1.3"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
|
||||
@@ -82,7 +82,7 @@ async fn send_message_posts_json_and_parses_response() {
|
||||
);
|
||||
assert_eq!(
|
||||
request.headers.get("user-agent").map(String::as_str),
|
||||
Some("claude-code/0.1.4")
|
||||
Some("claude-code/0.1.3")
|
||||
);
|
||||
assert_eq!(
|
||||
request.headers.get("anthropic-beta").map(String::as_str),
|
||||
|
||||
@@ -5297,60 +5297,6 @@ fn mcp_server_json(name: &str, server: &ScopedMcpServerConfig) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn render_session_status(session: &Session) -> String {
|
||||
let message_count = session.messages.len();
|
||||
let mut total_input = 0u32;
|
||||
let mut total_output = 0u32;
|
||||
for msg in &session.messages {
|
||||
if let Some(usage) = msg.usage {
|
||||
total_input = total_input.saturating_add(usage.input_tokens);
|
||||
total_output = total_output.saturating_add(usage.output_tokens);
|
||||
}
|
||||
}
|
||||
format!(
|
||||
"Session {}: {} messages, {} input tokens, {} output tokens",
|
||||
&session.session_id[0..8],
|
||||
message_count,
|
||||
total_input,
|
||||
total_output
|
||||
)
|
||||
}
|
||||
|
||||
fn render_token_cost(session: &Session) -> String {
|
||||
let mut total_input = 0u32;
|
||||
let mut total_output = 0u32;
|
||||
let mut cache_creation = 0u32;
|
||||
let mut cache_read = 0u32;
|
||||
for msg in &session.messages {
|
||||
if let Some(usage) = msg.usage {
|
||||
total_input = total_input.saturating_add(usage.input_tokens);
|
||||
total_output = total_output.saturating_add(usage.output_tokens);
|
||||
cache_creation = cache_creation.saturating_add(usage.cache_creation_input_tokens);
|
||||
cache_read = cache_read.saturating_add(usage.cache_read_input_tokens);
|
||||
}
|
||||
}
|
||||
format!(
|
||||
"Cumulative token usage for this session:
|
||||
Input tokens: {}
|
||||
Output tokens: {}
|
||||
Cache creation tokens: {}
|
||||
Cache read tokens: {}
|
||||
Total input: {}",
|
||||
total_input,
|
||||
total_output,
|
||||
cache_creation,
|
||||
cache_read,
|
||||
total_input
|
||||
.saturating_add(cache_creation)
|
||||
.saturating_add(cache_read)
|
||||
)
|
||||
}
|
||||
|
||||
fn render_version_info() -> String {
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
format!("claw-code version {}", version)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn handle_slash_command(
|
||||
input: &str,
|
||||
@@ -5388,19 +5334,8 @@ pub fn handle_slash_command(
|
||||
message: render_slash_command_help(),
|
||||
session: session.clone(),
|
||||
}),
|
||||
SlashCommand::Status => Some(SlashCommandResult {
|
||||
message: render_session_status(session),
|
||||
session: session.clone(),
|
||||
}),
|
||||
SlashCommand::Cost => Some(SlashCommandResult {
|
||||
message: render_token_cost(session),
|
||||
session: session.clone(),
|
||||
}),
|
||||
SlashCommand::Version => Some(SlashCommandResult {
|
||||
message: render_version_info(),
|
||||
session: session.clone(),
|
||||
}),
|
||||
SlashCommand::Bughunter { .. }
|
||||
SlashCommand::Status
|
||||
| SlashCommand::Bughunter { .. }
|
||||
| SlashCommand::Commit
|
||||
| SlashCommand::Pr { .. }
|
||||
| SlashCommand::Issue { .. }
|
||||
@@ -5411,6 +5346,7 @@ pub fn handle_slash_command(
|
||||
| SlashCommand::Model { .. }
|
||||
| SlashCommand::Permissions { .. }
|
||||
| SlashCommand::Clear { .. }
|
||||
| SlashCommand::Cost
|
||||
| SlashCommand::Resume { .. }
|
||||
| SlashCommand::Config { .. }
|
||||
| SlashCommand::Workspace { .. }
|
||||
@@ -5418,6 +5354,7 @@ pub fn handle_slash_command(
|
||||
| SlashCommand::Memory
|
||||
| SlashCommand::Init
|
||||
| SlashCommand::Diff
|
||||
| SlashCommand::Version
|
||||
| SlashCommand::Export { .. }
|
||||
| SlashCommand::Session { .. }
|
||||
| SlashCommand::Plugins { .. }
|
||||
@@ -6256,6 +6193,7 @@ mod tests {
|
||||
fn ignores_unknown_or_runtime_bound_slash_commands() {
|
||||
let session = Session::new();
|
||||
assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none());
|
||||
assert!(handle_slash_command("/status", &session, CompactionConfig::default()).is_none());
|
||||
assert!(handle_slash_command("/sandbox", &session, CompactionConfig::default()).is_none());
|
||||
assert!(
|
||||
handle_slash_command("/bughunter", &session, CompactionConfig::default()).is_none()
|
||||
@@ -6287,6 +6225,7 @@ mod tests {
|
||||
handle_slash_command("/clear --confirm", &session, CompactionConfig::default())
|
||||
.is_none()
|
||||
);
|
||||
assert!(handle_slash_command("/cost", &session, CompactionConfig::default()).is_none());
|
||||
assert!(handle_slash_command(
|
||||
"/resume session.json",
|
||||
&session,
|
||||
@@ -6305,6 +6244,7 @@ mod tests {
|
||||
);
|
||||
assert!(handle_slash_command("/mcp list", &session, CompactionConfig::default()).is_none());
|
||||
assert!(handle_slash_command("/diff", &session, CompactionConfig::default()).is_none());
|
||||
assert!(handle_slash_command("/version", &session, CompactionConfig::default()).is_none());
|
||||
assert!(
|
||||
handle_slash_command("/export note.txt", &session, CompactionConfig::default())
|
||||
.is_none()
|
||||
@@ -7266,79 +7206,4 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(config_home);
|
||||
let _ = fs::remove_dir_all(bundled_root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_command_shows_session_info() {
|
||||
use runtime::TokenUsage;
|
||||
|
||||
let mut session = Session::new();
|
||||
session.messages.push(ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
blocks: vec![ContentBlock::Text {
|
||||
text: "hello".to_string(),
|
||||
}],
|
||||
usage: Some(TokenUsage {
|
||||
input_tokens: 10,
|
||||
output_tokens: 20,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
}),
|
||||
});
|
||||
session.messages.push(ConversationMessage {
|
||||
role: MessageRole::Assistant,
|
||||
blocks: vec![ContentBlock::Text {
|
||||
text: "response".to_string(),
|
||||
}],
|
||||
usage: Some(TokenUsage {
|
||||
input_tokens: 5,
|
||||
output_tokens: 15,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
}),
|
||||
});
|
||||
|
||||
let result = handle_slash_command("/status", &session, CompactionConfig::default())
|
||||
.expect("/status should be handled");
|
||||
assert!(result.message.contains("2 messages"));
|
||||
assert!(result.message.contains("15 input tokens"));
|
||||
assert!(result.message.contains("35 output tokens"));
|
||||
assert_eq!(result.session, session);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_command_shows_token_usage() {
|
||||
use runtime::TokenUsage;
|
||||
|
||||
let mut session = Session::new();
|
||||
session.messages.push(ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
blocks: vec![ContentBlock::Text {
|
||||
text: "hello".to_string(),
|
||||
}],
|
||||
usage: Some(TokenUsage {
|
||||
input_tokens: 100,
|
||||
output_tokens: 200,
|
||||
cache_creation_input_tokens: 50,
|
||||
cache_read_input_tokens: 25,
|
||||
}),
|
||||
});
|
||||
|
||||
let result = handle_slash_command("/cost", &session, CompactionConfig::default())
|
||||
.expect("/cost should be handled");
|
||||
assert!(result.message.contains("100"));
|
||||
assert!(result.message.contains("200"));
|
||||
assert!(result.message.contains("50"));
|
||||
assert!(result.message.contains("25"));
|
||||
assert!(result.message.contains("Cumulative token usage"));
|
||||
assert_eq!(result.session, session);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_command_shows_version() {
|
||||
let session = Session::new();
|
||||
let result = handle_slash_command("/version", &session, CompactionConfig::default())
|
||||
.expect("/version should be handled");
|
||||
assert!(result.message.contains("claw-code version"));
|
||||
assert_eq!(result.session, session);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,6 @@ use regex::RegexBuilder;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use walkdir::{DirEntry, WalkDir};
|
||||
|
||||
use crate::line_range_selector::{self, LineRangeSelector};
|
||||
|
||||
/// Maximum file size that can be read (10 MB).
|
||||
const MAX_READ_SIZE: u64 = 10 * 1024 * 1024;
|
||||
|
||||
@@ -90,9 +88,6 @@ pub struct ReadFileOutput {
|
||||
#[serde(rename = "type")]
|
||||
pub kind: String,
|
||||
pub file: TextFilePayload,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "truncationFooter")]
|
||||
pub truncation_footer: Option<String>,
|
||||
}
|
||||
|
||||
/// Structured patch hunk emitted by write and edit operations.
|
||||
@@ -206,19 +201,6 @@ pub fn read_file(
|
||||
path: &str,
|
||||
offset: Option<usize>,
|
||||
limit: Option<usize>,
|
||||
) -> io::Result<ReadFileOutput> {
|
||||
read_file_with_selector(path, offset, limit, None)
|
||||
}
|
||||
|
||||
/// Reads a text file with optional GJC-style line range selector.
|
||||
///
|
||||
/// The selector takes precedence over offset/limit if provided.
|
||||
/// Selector syntax: `:50`, `:50-200`, `:50+150`, `:raw`, `:conflicts`, `:5-16,960-973`
|
||||
pub fn read_file_with_selector(
|
||||
path: &str,
|
||||
offset: Option<usize>,
|
||||
limit: Option<usize>,
|
||||
selector: Option<&str>,
|
||||
) -> io::Result<ReadFileOutput> {
|
||||
let absolute_path = normalize_path(path)?;
|
||||
|
||||
@@ -245,56 +227,11 @@ pub fn read_file_with_selector(
|
||||
|
||||
let content = fs::read_to_string(&absolute_path)?;
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
let total_lines = lines.len();
|
||||
|
||||
// Special handling for conflicts selector
|
||||
if selector == Some("conflicts") {
|
||||
let conflicts = extract_merge_conflicts(&lines);
|
||||
let (selected, num_lines) = if conflicts.is_empty() {
|
||||
(format!("No unresolved git merge conflicts in {}.", path), 1)
|
||||
} else {
|
||||
let display_path = absolute_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or(path);
|
||||
let summary = format_conflict_summary(&conflicts, display_path);
|
||||
let summary_lines = summary.lines().count();
|
||||
(summary, summary_lines)
|
||||
};
|
||||
|
||||
return Ok(ReadFileOutput {
|
||||
kind: String::from("text"),
|
||||
file: TextFilePayload {
|
||||
file_path: absolute_path.to_string_lossy().into_owned(),
|
||||
content: selected,
|
||||
num_lines,
|
||||
start_line: 1,
|
||||
total_lines,
|
||||
},
|
||||
truncation_footer: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Determine which lines to select
|
||||
let (start_index, end_index, truncation_footer) = if let Some(sel) = selector {
|
||||
// Parse and apply selector
|
||||
let parsed = line_range_selector::parse_selector(sel)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e.to_string()))?;
|
||||
apply_line_selector(&lines, parsed, path)
|
||||
} else {
|
||||
// Use offset/limit for backward compatibility
|
||||
let start = offset.unwrap_or(0).min(lines.len());
|
||||
let end = limit.map_or(lines.len(), |lim| {
|
||||
start.saturating_add(lim).min(lines.len())
|
||||
});
|
||||
(start, end, None)
|
||||
};
|
||||
|
||||
let selected = if start_index < end_index {
|
||||
lines[start_index..end_index].join("\n")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let start_index = offset.unwrap_or(0).min(lines.len());
|
||||
let end_index = limit.map_or(lines.len(), |limit| {
|
||||
start_index.saturating_add(limit).min(lines.len())
|
||||
});
|
||||
let selected = lines[start_index..end_index].join("\n");
|
||||
|
||||
Ok(ReadFileOutput {
|
||||
kind: String::from("text"),
|
||||
@@ -303,255 +240,11 @@ pub fn read_file_with_selector(
|
||||
content: selected,
|
||||
num_lines: end_index.saturating_sub(start_index),
|
||||
start_line: start_index.saturating_add(1),
|
||||
total_lines,
|
||||
total_lines: lines.len(),
|
||||
},
|
||||
truncation_footer,
|
||||
})
|
||||
}
|
||||
|
||||
/// Represents a merge conflict block in a file.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct ConflictBlock {
|
||||
start_line: usize, // 1-based line number where <<<<<<< starts
|
||||
separator_line: usize, // 1-based line number where ======= is
|
||||
end_line: usize, // 1-based line number where >>>>>>> is
|
||||
base_line: Option<usize>, // 1-based line number where ||||||| is (for 3-way)
|
||||
ours_label: Option<String>,
|
||||
base_label: Option<String>,
|
||||
theirs_label: Option<String>,
|
||||
}
|
||||
|
||||
/// Scans lines for unresolved git merge conflict blocks.
|
||||
fn extract_merge_conflicts(lines: &[&str]) -> Vec<ConflictBlock> {
|
||||
let mut blocks = Vec::new();
|
||||
let mut i = 0;
|
||||
|
||||
const OURS_PREFIX: &str = "<<<<<<<";
|
||||
const BASE_PREFIX: &str = "|||||||";
|
||||
const SEPARATOR: &str = "=======";
|
||||
const THEIRS_PREFIX: &str = ">>>>>>>";
|
||||
|
||||
while i < lines.len() {
|
||||
let line = lines[i];
|
||||
|
||||
// Check for start of conflict block
|
||||
if let Some(label) = extract_marker_label(line, OURS_PREFIX) {
|
||||
let ours_label = if label.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(label.to_string())
|
||||
};
|
||||
let start_line = i + 1; // Convert to 1-based
|
||||
let mut separator_line = None;
|
||||
let mut base_line = None;
|
||||
let mut base_label = None;
|
||||
let mut end_line = None;
|
||||
let mut theirs_label = None;
|
||||
let mut j = i + 1;
|
||||
|
||||
// Look for separator, optional base marker, and end marker
|
||||
while j < lines.len() {
|
||||
let current = lines[j];
|
||||
|
||||
// Check for base marker (3-way)
|
||||
if let Some(label) = extract_marker_label(current, BASE_PREFIX) {
|
||||
if base_line.is_none() {
|
||||
base_line = Some(j + 1);
|
||||
base_label = if label.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(label.to_string())
|
||||
};
|
||||
j += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for separator
|
||||
if current.trim() == SEPARATOR {
|
||||
if separator_line.is_none() {
|
||||
separator_line = Some(j + 1);
|
||||
j += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for end marker
|
||||
if let Some(label) = extract_marker_label(current, THEIRS_PREFIX) {
|
||||
if separator_line.is_some() {
|
||||
end_line = Some(j + 1);
|
||||
theirs_label = if label.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(label.to_string())
|
||||
};
|
||||
j += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
j += 1;
|
||||
}
|
||||
|
||||
// Only add if we found both separator and end marker
|
||||
if let (Some(sep_line), Some(end)) = (separator_line, end_line) {
|
||||
blocks.push(ConflictBlock {
|
||||
start_line,
|
||||
separator_line: sep_line,
|
||||
end_line: end,
|
||||
base_line,
|
||||
ours_label,
|
||||
base_label,
|
||||
theirs_label,
|
||||
});
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
i += 1;
|
||||
}
|
||||
|
||||
blocks
|
||||
}
|
||||
|
||||
/// Extract the optional label from a marker line.
|
||||
/// Returns the label text (stripped of leading/trailing spaces) if the line matches the prefix,
|
||||
/// or None otherwise.
|
||||
fn extract_marker_label<'a>(line: &'a str, prefix: &str) -> Option<&'a str> {
|
||||
if line.starts_with(prefix) {
|
||||
let rest = &line[prefix.len()..];
|
||||
// Accept either just the prefix or prefix + single space + label
|
||||
if rest.is_empty() {
|
||||
Some("")
|
||||
} else if rest.starts_with(' ') {
|
||||
Some(rest[1..].trim())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Format conflict blocks as a summary index, like GJC's implementation.
|
||||
fn format_conflict_summary(conflicts: &[ConflictBlock], display_path: &str) -> String {
|
||||
let mut output = Vec::new();
|
||||
let total = conflicts.len();
|
||||
let word = if total == 1 { "conflict" } else { "conflicts" };
|
||||
|
||||
output.push(format!(
|
||||
"⚠ {} unresolved {} in {}",
|
||||
total, word, display_path
|
||||
));
|
||||
|
||||
// Extract labels for header
|
||||
let ours_label = conflicts.iter().find_map(|c| c.ours_label.as_ref());
|
||||
let theirs_label = conflicts.iter().find_map(|c| c.theirs_label.as_ref());
|
||||
let base_label = conflicts.iter().find_map(|c| c.base_label.as_ref());
|
||||
let any_base = conflicts.iter().any(|c| c.base_line.is_some());
|
||||
|
||||
if let Some(label) = ours_label {
|
||||
output.push(format!("- ours = {}", label));
|
||||
}
|
||||
if let Some(label) = theirs_label {
|
||||
output.push(format!("- theirs = {}", label));
|
||||
}
|
||||
if any_base {
|
||||
let base_name = base_label
|
||||
.map(|l| l.to_string())
|
||||
.unwrap_or_else(|| "(no label)".to_string());
|
||||
output.push(format!("- base = {}", base_name));
|
||||
}
|
||||
|
||||
output.push(
|
||||
"NOTICE: Bulk-resolve with `write({ path: \"conflict://*\", content })`, or address a single block with `write({ path: \"conflict://<N>\", content })`. Inspect a block by reading `conflict://<N>` (add `/ours` / `/theirs` / `/base` for a single side).".to_string()
|
||||
);
|
||||
output.push(
|
||||
"`content` shorthand: `@ours` / `@theirs` / `@base` / `@both` lines expand to the recorded sections; `@both` = ours-then-theirs. Non-token lines pass through verbatim.".to_string()
|
||||
);
|
||||
output.push(String::new());
|
||||
|
||||
// Format each conflict as a one-liner index
|
||||
let id_width = total.to_string().len();
|
||||
for (idx, conflict) in conflicts.iter().enumerate() {
|
||||
let id = idx + 1;
|
||||
let range = if conflict.start_line == conflict.end_line {
|
||||
format!("L{}", conflict.start_line)
|
||||
} else {
|
||||
format!("L{}-{}", conflict.start_line, conflict.end_line)
|
||||
};
|
||||
let kind = if conflict.base_line.is_some() {
|
||||
" (3-way)"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
output.push(format!(
|
||||
"#{:>width$} {}{}",
|
||||
id,
|
||||
range,
|
||||
kind,
|
||||
width = id_width
|
||||
));
|
||||
}
|
||||
|
||||
output.join("\n")
|
||||
}
|
||||
|
||||
/// Apply a parsed line selector to file lines and return the selected range.
|
||||
///
|
||||
/// Returns (start_index, end_index, truncation_footer).
|
||||
fn apply_line_selector(
|
||||
lines: &[&str],
|
||||
selector: LineRangeSelector,
|
||||
_file_path: &str,
|
||||
) -> (usize, usize, Option<String>) {
|
||||
match selector {
|
||||
LineRangeSelector::Raw => {
|
||||
// Return entire file without truncation footer for raw mode
|
||||
(0, lines.len(), None)
|
||||
}
|
||||
LineRangeSelector::Conflicts => {
|
||||
// Conflicts are handled specially in read_file_with_selector, so this shouldn't be reached
|
||||
(0, lines.len(), None)
|
||||
}
|
||||
LineRangeSelector::Lines(ranges) => {
|
||||
// Collect all lines specified by ranges
|
||||
let mut selected_indices = std::collections::BTreeSet::new();
|
||||
for range in ranges {
|
||||
let (start_zero, end_zero) = range.zero_indexed();
|
||||
for i in start_zero..=end_zero.min(lines.len().saturating_sub(1)) {
|
||||
selected_indices.insert(i);
|
||||
}
|
||||
}
|
||||
|
||||
if selected_indices.is_empty() {
|
||||
return (0, 0, None);
|
||||
}
|
||||
|
||||
// Find contiguous blocks and return the first one (or could merge all)
|
||||
let indices: Vec<usize> = selected_indices.iter().copied().collect();
|
||||
let first = indices[0];
|
||||
let last = indices[indices.len() - 1];
|
||||
|
||||
// Check if it's a contiguous range
|
||||
let is_contiguous = (last - first + 1) == indices.len();
|
||||
|
||||
if is_contiguous {
|
||||
(first, last + 1, None)
|
||||
} else {
|
||||
// Return all selected indices as one continuous block from first to last
|
||||
// In the future, this could be improved to show multiple blocks
|
||||
(first, last + 1, None)
|
||||
}
|
||||
}
|
||||
LineRangeSelector::None => {
|
||||
// No selector, return entire file
|
||||
(0, lines.len(), None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces a file's contents and returns patch metadata.
|
||||
pub fn write_file(path: &str, content: &str) -> io::Result<WriteFileOutput> {
|
||||
if content.len() > MAX_WRITE_SIZE {
|
||||
@@ -1022,23 +715,11 @@ pub fn read_file_in_workspace(
|
||||
offset: Option<usize>,
|
||||
limit: Option<usize>,
|
||||
workspace_root: &Path,
|
||||
) -> io::Result<ReadFileOutput> {
|
||||
read_file_in_workspace_with_selector(path, offset, limit, None, workspace_root)
|
||||
}
|
||||
|
||||
/// Read a file with workspace boundary enforcement and optional line range selector.
|
||||
#[allow(dead_code)]
|
||||
pub fn read_file_in_workspace_with_selector(
|
||||
path: &str,
|
||||
offset: Option<usize>,
|
||||
limit: Option<usize>,
|
||||
selector: Option<&str>,
|
||||
workspace_root: &Path,
|
||||
) -> io::Result<ReadFileOutput> {
|
||||
let absolute_path = normalize_path(path)?;
|
||||
let canonical_root = canonicalize_workspace_root(workspace_root);
|
||||
validate_workspace_boundary(&absolute_path, &canonical_root)?;
|
||||
read_file_with_selector(path, offset, limit, selector)
|
||||
read_file(path, offset, limit)
|
||||
}
|
||||
|
||||
/// Write a file with workspace boundary enforcement.
|
||||
@@ -1502,189 +1183,4 @@ mod tests {
|
||||
assert!(component_contains_glob("*.rs"));
|
||||
assert!(!component_contains_glob("src"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_with_selector_single_line() {
|
||||
let path = temp_path("selector-single.txt");
|
||||
write_file(
|
||||
path.to_string_lossy().as_ref(),
|
||||
"line1\nline2\nline3\nline4\nline5",
|
||||
)
|
||||
.expect("write should succeed");
|
||||
|
||||
let output =
|
||||
super::read_file_with_selector(path.to_string_lossy().as_ref(), None, None, Some("2"))
|
||||
.expect("read with selector should succeed");
|
||||
assert_eq!(output.file.content, "line2");
|
||||
assert_eq!(output.file.start_line, 2);
|
||||
assert_eq!(output.file.num_lines, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_with_selector_range() {
|
||||
let path = temp_path("selector-range.txt");
|
||||
write_file(
|
||||
path.to_string_lossy().as_ref(),
|
||||
"line1\nline2\nline3\nline4\nline5",
|
||||
)
|
||||
.expect("write should succeed");
|
||||
|
||||
let output = super::read_file_with_selector(
|
||||
path.to_string_lossy().as_ref(),
|
||||
None,
|
||||
None,
|
||||
Some("2-4"),
|
||||
)
|
||||
.expect("read with selector should succeed");
|
||||
assert_eq!(output.file.content, "line2\nline3\nline4");
|
||||
assert_eq!(output.file.start_line, 2);
|
||||
assert_eq!(output.file.num_lines, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_with_selector_plus_count() {
|
||||
let path = temp_path("selector-plus.txt");
|
||||
write_file(
|
||||
path.to_string_lossy().as_ref(),
|
||||
"line1\nline2\nline3\nline4\nline5",
|
||||
)
|
||||
.expect("write should succeed");
|
||||
|
||||
let output = super::read_file_with_selector(
|
||||
path.to_string_lossy().as_ref(),
|
||||
None,
|
||||
None,
|
||||
Some("2+2"),
|
||||
)
|
||||
.expect("read with selector should succeed");
|
||||
assert_eq!(output.file.content, "line2\nline3");
|
||||
assert_eq!(output.file.start_line, 2);
|
||||
assert_eq!(output.file.num_lines, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_with_selector_raw() {
|
||||
let path = temp_path("selector-raw.txt");
|
||||
write_file(
|
||||
path.to_string_lossy().as_ref(),
|
||||
"line1\nline2\nline3\nline4\nline5",
|
||||
)
|
||||
.expect("write should succeed");
|
||||
|
||||
let output = super::read_file_with_selector(
|
||||
path.to_string_lossy().as_ref(),
|
||||
None,
|
||||
None,
|
||||
Some("raw"),
|
||||
)
|
||||
.expect("read with selector should succeed");
|
||||
assert_eq!(output.file.content, "line1\nline2\nline3\nline4\nline5");
|
||||
assert_eq!(output.file.start_line, 1);
|
||||
assert_eq!(output.file.num_lines, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_with_selector_none() {
|
||||
let path = temp_path("selector-none.txt");
|
||||
write_file(
|
||||
path.to_string_lossy().as_ref(),
|
||||
"line1\nline2\nline3\nline4\nline5",
|
||||
)
|
||||
.expect("write should succeed");
|
||||
|
||||
let output =
|
||||
super::read_file_with_selector(path.to_string_lossy().as_ref(), None, None, None)
|
||||
.expect("read with selector should succeed");
|
||||
assert_eq!(output.file.content, "line1\nline2\nline3\nline4\nline5");
|
||||
assert_eq!(output.file.start_line, 1);
|
||||
assert_eq!(output.file.num_lines, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_with_invalid_selector() {
|
||||
let path = temp_path("selector-invalid.txt");
|
||||
write_file(
|
||||
path.to_string_lossy().as_ref(),
|
||||
"line1\nline2\nline3\nline4\nline5",
|
||||
)
|
||||
.expect("write should succeed");
|
||||
|
||||
let result = super::read_file_with_selector(
|
||||
path.to_string_lossy().as_ref(),
|
||||
None,
|
||||
None,
|
||||
Some("invalid-selector"),
|
||||
);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_with_selector_conflicts_no_conflicts() {
|
||||
let path = temp_path("conflicts-none.txt");
|
||||
write_file(
|
||||
path.to_string_lossy().as_ref(),
|
||||
"const a = 1;\nconst b = 2;\n",
|
||||
)
|
||||
.expect("write should succeed");
|
||||
|
||||
let output = super::read_file_with_selector(
|
||||
path.to_string_lossy().as_ref(),
|
||||
None,
|
||||
None,
|
||||
Some("conflicts"),
|
||||
)
|
||||
.expect("read with conflicts selector should succeed");
|
||||
assert!(output
|
||||
.file
|
||||
.content
|
||||
.contains("No unresolved git merge conflicts"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_with_selector_conflicts_two_way() {
|
||||
let path = temp_path("conflicts-two-way.txt");
|
||||
let content = "<<<<<<< A\na-ours\n=======\na-theirs\n>>>>>>> A\nmiddle\n<<<<<<< B\nb-ours\n=======\nb-theirs\n>>>>>>> B\ntail";
|
||||
write_file(path.to_string_lossy().as_ref(), content).expect("write should succeed");
|
||||
|
||||
let output = super::read_file_with_selector(
|
||||
path.to_string_lossy().as_ref(),
|
||||
None,
|
||||
None,
|
||||
Some("conflicts"),
|
||||
)
|
||||
.expect("read with conflicts selector should succeed");
|
||||
|
||||
let text = &output.file.content;
|
||||
// Should report 2 conflicts
|
||||
assert!(text.contains("2 unresolved conflicts"), "text: {}", text);
|
||||
// Should have indices for both blocks
|
||||
assert!(text.contains("#1"), "missing #1: {}", text);
|
||||
assert!(text.contains("#2"), "missing #2: {}", text);
|
||||
// Should have line ranges
|
||||
assert!(text.contains("L1-5"), "missing L1-5: {}", text);
|
||||
assert!(text.contains("L7-11"), "missing L7-11: {}", text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_with_selector_conflicts_three_way() {
|
||||
let path = temp_path("conflicts-three-way.txt");
|
||||
let content = "head\n<<<<<<< HEAD\nours body\n||||||| common ancestor\nbase body\n=======\ntheirs body\n>>>>>>> feat\ntail";
|
||||
write_file(path.to_string_lossy().as_ref(), content).expect("write should succeed");
|
||||
|
||||
let output = super::read_file_with_selector(
|
||||
path.to_string_lossy().as_ref(),
|
||||
None,
|
||||
None,
|
||||
Some("conflicts"),
|
||||
)
|
||||
.expect("read with conflicts selector should succeed");
|
||||
|
||||
let text = &output.file.content;
|
||||
// Should report 1 conflict
|
||||
assert!(text.contains("1 unresolved conflict"), "text: {}", text);
|
||||
// Should mark as 3-way
|
||||
assert!(text.contains("3-way"), "missing 3-way marker: {}", text);
|
||||
// Should show line range
|
||||
assert!(text.contains("L2-8"), "missing L2-8: {}", text);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ pub mod green_contract;
|
||||
mod hooks;
|
||||
mod json;
|
||||
mod lane_events;
|
||||
pub mod line_range_selector;
|
||||
pub mod lsp_client;
|
||||
mod mcp;
|
||||
mod mcp_client;
|
||||
@@ -88,8 +87,7 @@ pub use conversation::{
|
||||
};
|
||||
pub use file_ops::{
|
||||
edit_file, edit_file_in_workspace, glob_search, glob_search_in_workspace, grep_search,
|
||||
grep_search_in_workspace, read_file, read_file_in_workspace,
|
||||
read_file_in_workspace_with_selector, read_file_with_selector, write_file,
|
||||
grep_search_in_workspace, read_file, read_file_in_workspace, write_file,
|
||||
write_file_in_workspace, EditFileOutput, GlobSearchOutput, GrepSearchInput, GrepSearchOutput,
|
||||
ReadFileOutput, StructuredPatchHunk, TextFilePayload, WriteFileOutput,
|
||||
};
|
||||
|
||||
@@ -1,347 +0,0 @@
|
||||
/// GJC-compatible line range selector parsing for the read tool.
|
||||
///
|
||||
/// Supports selector syntax:
|
||||
/// - `:50` = start at line 50
|
||||
/// - `:50-200` = lines 50-200 inclusive
|
||||
/// - `:50+150` = 150 lines starting at 50
|
||||
/// - `:50+1` = exactly one line
|
||||
/// - `:5-16,960-973` = multiple ranges
|
||||
/// - `:raw` = full verbatim
|
||||
/// - `:conflicts` = git merge conflicts only
|
||||
use std::num::ParseIntError;
|
||||
|
||||
/// Parsed line range specification.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum LineRangeSelector {
|
||||
/// Full file verbatim (`:raw`)
|
||||
Raw,
|
||||
/// Git merge conflict sections only (`:conflicts`)
|
||||
Conflicts,
|
||||
/// Line range(s) (`:L-L+N` syntax)
|
||||
Lines(Vec<LineRange>),
|
||||
/// No selector (full file, may be truncated by default budget)
|
||||
None,
|
||||
}
|
||||
|
||||
/// A single line range, zero-indexed internally but displayed as 1-indexed.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct LineRange {
|
||||
/// Start line (1-indexed, user-facing)
|
||||
pub start_line: usize,
|
||||
/// End line inclusive (1-indexed, user-facing)
|
||||
pub end_line: usize,
|
||||
}
|
||||
|
||||
impl LineRange {
|
||||
/// Create a new line range (1-indexed).
|
||||
pub fn new(start_line: usize, end_line: usize) -> Self {
|
||||
Self {
|
||||
start_line,
|
||||
end_line,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert to zero-indexed range for internal use.
|
||||
pub fn zero_indexed(&self) -> (usize, usize) {
|
||||
(
|
||||
self.start_line.saturating_sub(1),
|
||||
self.end_line.saturating_sub(1),
|
||||
)
|
||||
}
|
||||
|
||||
/// Get the number of lines in this range.
|
||||
pub fn len(&self) -> usize {
|
||||
if self.start_line <= self.end_line {
|
||||
self.end_line - self.start_line + 1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this range is valid.
|
||||
pub fn is_valid(&self) -> bool {
|
||||
self.start_line > 0 && self.end_line > 0 && self.start_line <= self.end_line
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse error types for line range selectors.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ParseError {
|
||||
InvalidFormat(String),
|
||||
InvalidNumber(ParseIntError),
|
||||
InvalidRange(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ParseError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ParseError::InvalidFormat(msg) => write!(f, "Invalid format: {}", msg),
|
||||
ParseError::InvalidNumber(e) => write!(f, "Invalid number: {}", e),
|
||||
ParseError::InvalidRange(msg) => write!(f, "Invalid range: {}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ParseError {}
|
||||
|
||||
impl From<ParseIntError> for ParseError {
|
||||
fn from(e: ParseIntError) -> Self {
|
||||
ParseError::InvalidNumber(e)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a line range selector string.
|
||||
///
|
||||
/// Examples:
|
||||
/// - `"50"` → `LineRangeSelector::Lines(vec![LineRange::new(50, 50)])`
|
||||
/// - `"50-200"` → `LineRangeSelector::Lines(vec![LineRange::new(50, 200)])`
|
||||
/// - `"50+150"` → `LineRangeSelector::Lines(vec![LineRange::new(50, 199)])`
|
||||
/// - `"raw"` → `LineRangeSelector::Raw`
|
||||
/// - `"conflicts"` → `LineRangeSelector::Conflicts`
|
||||
pub fn parse_selector(selector: &str) -> Result<LineRangeSelector, ParseError> {
|
||||
if selector.is_empty() {
|
||||
return Ok(LineRangeSelector::None);
|
||||
}
|
||||
|
||||
match selector {
|
||||
"raw" => Ok(LineRangeSelector::Raw),
|
||||
"conflicts" => Ok(LineRangeSelector::Conflicts),
|
||||
_ => parse_line_ranges(selector),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse comma-separated line ranges.
|
||||
fn parse_line_ranges(selector: &str) -> Result<LineRangeSelector, ParseError> {
|
||||
let mut ranges = Vec::new();
|
||||
|
||||
for range_str in selector.split(',') {
|
||||
let range_str = range_str.trim();
|
||||
if range_str.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let range = parse_single_range(range_str)?;
|
||||
ranges.push(range);
|
||||
}
|
||||
|
||||
if ranges.is_empty() {
|
||||
Ok(LineRangeSelector::None)
|
||||
} else {
|
||||
// Merge overlapping/adjacent ranges
|
||||
ranges.sort_by_key(|r| r.start_line);
|
||||
let merged = merge_ranges(ranges);
|
||||
Ok(LineRangeSelector::Lines(merged))
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a single line range in formats: "50", "50-200", "50+150".
|
||||
fn parse_single_range(range_str: &str) -> Result<LineRange, ParseError> {
|
||||
// Try "50+150" format first (start+count)
|
||||
if let Some(plus_idx) = range_str.find('+') {
|
||||
let start_str = &range_str[..plus_idx];
|
||||
let count_str = &range_str[plus_idx + 1..];
|
||||
|
||||
if start_str.is_empty() || count_str.is_empty() {
|
||||
return Err(ParseError::InvalidFormat(range_str.to_string()));
|
||||
}
|
||||
|
||||
let start = start_str.parse::<usize>()?;
|
||||
let count = count_str.parse::<usize>()?;
|
||||
|
||||
if start == 0 || count == 0 {
|
||||
return Err(ParseError::InvalidRange(
|
||||
"Line numbers and counts must be >= 1".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
return Ok(LineRange::new(start, start + count - 1));
|
||||
}
|
||||
|
||||
// Try "50-200" format (start-end)
|
||||
if let Some(dash_idx) = range_str.find('-') {
|
||||
// Skip leading dash (negative numbers aren't valid here)
|
||||
if dash_idx == 0 {
|
||||
return Err(ParseError::InvalidFormat(range_str.to_string()));
|
||||
}
|
||||
|
||||
let start_str = &range_str[..dash_idx];
|
||||
let end_str = &range_str[dash_idx + 1..];
|
||||
|
||||
if start_str.is_empty() || end_str.is_empty() {
|
||||
return Err(ParseError::InvalidFormat(range_str.to_string()));
|
||||
}
|
||||
|
||||
let start = start_str.parse::<usize>()?;
|
||||
let end = end_str.parse::<usize>()?;
|
||||
|
||||
if start == 0 || end == 0 {
|
||||
return Err(ParseError::InvalidRange(
|
||||
"Line numbers must be >= 1".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if start > end {
|
||||
return Err(ParseError::InvalidRange(format!(
|
||||
"Start line {} cannot be greater than end line {}",
|
||||
start, end
|
||||
)));
|
||||
}
|
||||
|
||||
return Ok(LineRange::new(start, end));
|
||||
}
|
||||
|
||||
// Try plain "50" format (single line)
|
||||
let line_num = range_str.parse::<usize>()?;
|
||||
if line_num == 0 {
|
||||
return Err(ParseError::InvalidRange(
|
||||
"Line number must be >= 1".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(LineRange::new(line_num, line_num))
|
||||
}
|
||||
|
||||
/// Merge overlapping and adjacent ranges.
|
||||
fn merge_ranges(ranges: Vec<LineRange>) -> Vec<LineRange> {
|
||||
let mut merged: Vec<LineRange> = Vec::new();
|
||||
|
||||
for range in ranges {
|
||||
if let Some(last) = merged.last_mut() {
|
||||
// If ranges overlap or are adjacent, merge them
|
||||
if range.start_line <= last.end_line + 1 {
|
||||
last.end_line = last.end_line.max(range.end_line);
|
||||
} else {
|
||||
merged.push(range);
|
||||
}
|
||||
} else {
|
||||
merged.push(range);
|
||||
}
|
||||
}
|
||||
|
||||
merged
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_raw() {
|
||||
assert_eq!(parse_selector("raw").unwrap(), LineRangeSelector::Raw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_conflicts() {
|
||||
assert_eq!(
|
||||
parse_selector("conflicts").unwrap(),
|
||||
LineRangeSelector::Conflicts
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_single_line() {
|
||||
match parse_selector("50").unwrap() {
|
||||
LineRangeSelector::Lines(ranges) => {
|
||||
assert_eq!(ranges.len(), 1);
|
||||
assert_eq!(ranges[0].start_line, 50);
|
||||
assert_eq!(ranges[0].end_line, 50);
|
||||
}
|
||||
_ => panic!("Expected Lines variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_range_start_dash_end() {
|
||||
match parse_selector("50-200").unwrap() {
|
||||
LineRangeSelector::Lines(ranges) => {
|
||||
assert_eq!(ranges.len(), 1);
|
||||
assert_eq!(ranges[0].start_line, 50);
|
||||
assert_eq!(ranges[0].end_line, 200);
|
||||
}
|
||||
_ => panic!("Expected Lines variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_range_start_plus_count() {
|
||||
match parse_selector("50+150").unwrap() {
|
||||
LineRangeSelector::Lines(ranges) => {
|
||||
assert_eq!(ranges.len(), 1);
|
||||
assert_eq!(ranges[0].start_line, 50);
|
||||
assert_eq!(ranges[0].end_line, 199);
|
||||
}
|
||||
_ => panic!("Expected Lines variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_multiple_ranges() {
|
||||
match parse_selector("5-16,960-973").unwrap() {
|
||||
LineRangeSelector::Lines(ranges) => {
|
||||
assert_eq!(ranges.len(), 2);
|
||||
assert_eq!(ranges[0].start_line, 5);
|
||||
assert_eq!(ranges[0].end_line, 16);
|
||||
assert_eq!(ranges[1].start_line, 960);
|
||||
assert_eq!(ranges[1].end_line, 973);
|
||||
}
|
||||
_ => panic!("Expected Lines variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_overlapping_ranges() {
|
||||
// "5-16,10-20" should merge into "5-20"
|
||||
match parse_selector("5-16,10-20").unwrap() {
|
||||
LineRangeSelector::Lines(ranges) => {
|
||||
assert_eq!(ranges.len(), 1);
|
||||
assert_eq!(ranges[0].start_line, 5);
|
||||
assert_eq!(ranges[0].end_line, 20);
|
||||
}
|
||||
_ => panic!("Expected Lines variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_adjacent_ranges() {
|
||||
// "5-16,17-20" should merge into "5-20"
|
||||
match parse_selector("5-16,17-20").unwrap() {
|
||||
LineRangeSelector::Lines(ranges) => {
|
||||
assert_eq!(ranges.len(), 1);
|
||||
assert_eq!(ranges[0].start_line, 5);
|
||||
assert_eq!(ranges[0].end_line, 20);
|
||||
}
|
||||
_ => panic!("Expected Lines variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_range_start_greater_than_end() {
|
||||
let result = parse_selector("200-50");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_zero_line() {
|
||||
let result = parse_selector("0");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_selector() {
|
||||
assert_eq!(parse_selector("").unwrap(), LineRangeSelector::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_line_range_zero_indexed() {
|
||||
let range = LineRange::new(50, 200);
|
||||
let (start, end) = range.zero_indexed();
|
||||
assert_eq!(start, 49);
|
||||
assert_eq!(end, 199);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_line_range_len() {
|
||||
assert_eq!(LineRange::new(50, 200).len(), 151);
|
||||
assert_eq!(LineRange::new(50, 50).len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
[package]
|
||||
name = "sdk"
|
||||
version = "0.1.4"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-tungstenite = "0.23"
|
||||
futures-util = "0.3"
|
||||
serde_json = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
thiserror = "1"
|
||||
tracing = "0.1"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = "0.4"
|
||||
@@ -1,130 +0,0 @@
|
||||
//! SDK error types and result type.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// SDK wire protocol error codes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ErrorCode {
|
||||
/// Invalid JSON or malformed frame
|
||||
InvalidInput,
|
||||
/// Unknown broker operation
|
||||
UnknownOperation,
|
||||
/// Payload too large (>4 MiB)
|
||||
PayloadTooLarge,
|
||||
/// Authentication token mismatch
|
||||
Unauthorized,
|
||||
/// Broker or session unavailable
|
||||
Unavailable,
|
||||
/// WebSocket connection error
|
||||
ConnectionError,
|
||||
/// Request timeout
|
||||
Timeout,
|
||||
/// Frame parsing or validation error
|
||||
FrameError,
|
||||
/// Request/response correlation failure
|
||||
CorrelationError,
|
||||
/// Other unclassified error
|
||||
Internal,
|
||||
}
|
||||
|
||||
impl fmt::Display for ErrorCode {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::InvalidInput => write!(f, "invalid_input"),
|
||||
Self::UnknownOperation => write!(f, "unknown_operation"),
|
||||
Self::PayloadTooLarge => write!(f, "payload_too_large"),
|
||||
Self::Unauthorized => write!(f, "unauthorized"),
|
||||
Self::Unavailable => write!(f, "unavailable"),
|
||||
Self::ConnectionError => write!(f, "connection_error"),
|
||||
Self::Timeout => write!(f, "timeout"),
|
||||
Self::FrameError => write!(f, "frame_error"),
|
||||
Self::CorrelationError => write!(f, "correlation_error"),
|
||||
Self::Internal => write!(f, "internal_error"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// SDK wire protocol error.
|
||||
#[derive(Debug)]
|
||||
pub struct SdkError {
|
||||
pub code: ErrorCode,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl SdkError {
|
||||
pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn invalid_input(message: impl Into<String>) -> Self {
|
||||
Self::new(ErrorCode::InvalidInput, message)
|
||||
}
|
||||
|
||||
pub fn unknown_operation(message: impl Into<String>) -> Self {
|
||||
Self::new(ErrorCode::UnknownOperation, message)
|
||||
}
|
||||
|
||||
pub fn payload_too_large(message: impl Into<String>) -> Self {
|
||||
Self::new(ErrorCode::PayloadTooLarge, message)
|
||||
}
|
||||
|
||||
pub fn unauthorized(message: impl Into<String>) -> Self {
|
||||
Self::new(ErrorCode::Unauthorized, message)
|
||||
}
|
||||
|
||||
pub fn unavailable(message: impl Into<String>) -> Self {
|
||||
Self::new(ErrorCode::Unavailable, message)
|
||||
}
|
||||
|
||||
pub fn connection_error(message: impl Into<String>) -> Self {
|
||||
Self::new(ErrorCode::ConnectionError, message)
|
||||
}
|
||||
|
||||
pub fn timeout(message: impl Into<String>) -> Self {
|
||||
Self::new(ErrorCode::Timeout, message)
|
||||
}
|
||||
|
||||
pub fn frame_error(message: impl Into<String>) -> Self {
|
||||
Self::new(ErrorCode::FrameError, message)
|
||||
}
|
||||
|
||||
pub fn correlation_error(message: impl Into<String>) -> Self {
|
||||
Self::new(ErrorCode::CorrelationError, message)
|
||||
}
|
||||
|
||||
pub fn internal(message: impl Into<String>) -> Self {
|
||||
Self::new(ErrorCode::Internal, message)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for SdkError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}: {}", self.code, self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SdkError {}
|
||||
|
||||
/// Result type for SDK operations.
|
||||
pub type SdkResult<T> = Result<T, SdkError>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn error_display_formats_code_and_message() {
|
||||
let err = SdkError::invalid_input("test message");
|
||||
assert_eq!(err.to_string(), "invalid_input: test message");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_code_display_works() {
|
||||
assert_eq!(ErrorCode::InvalidInput.to_string(), "invalid_input");
|
||||
assert_eq!(ErrorCode::PayloadTooLarge.to_string(), "payload_too_large");
|
||||
assert_eq!(ErrorCode::Unauthorized.to_string(), "unauthorized");
|
||||
}
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
//! Frame types and serialization for the SDK wire protocol.
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::error::{SdkError, SdkResult};
|
||||
|
||||
/// Protocol version implemented.
|
||||
pub const PROTOCOL_VERSION: u32 = 3;
|
||||
|
||||
/// Maximum UTF-8 byte length for a single broker JSON frame.
|
||||
pub const MAX_FRAME_BYTES: usize = 4 * 1024 * 1024;
|
||||
|
||||
/// Frame type identifier.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FrameType {
|
||||
/// Hello frame sent by broker on connection
|
||||
BrokerHello,
|
||||
/// Request frame from client to broker
|
||||
BrokerRequest,
|
||||
/// Response frame from broker to client
|
||||
BrokerResponse,
|
||||
/// Query request
|
||||
QueryRequest,
|
||||
/// Query response
|
||||
QueryResponse,
|
||||
/// Control request
|
||||
ControlRequest,
|
||||
/// Control response
|
||||
ControlResponse,
|
||||
/// Event replay request
|
||||
EventReplay,
|
||||
/// Event replay result
|
||||
EventReplayResult,
|
||||
/// Session activation
|
||||
SessionActivate,
|
||||
/// Session activation result
|
||||
SessionActivateResult,
|
||||
}
|
||||
|
||||
impl FrameType {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::BrokerHello => "broker_hello",
|
||||
Self::BrokerRequest => "broker_request",
|
||||
Self::BrokerResponse => "broker_response",
|
||||
Self::QueryRequest => "query_request",
|
||||
Self::QueryResponse => "query_response",
|
||||
Self::ControlRequest => "control_request",
|
||||
Self::ControlResponse => "control_response",
|
||||
Self::EventReplay => "event_replay",
|
||||
Self::EventReplayResult => "event_replay_result",
|
||||
Self::SessionActivate => "session_activate",
|
||||
Self::SessionActivateResult => "session_activate_result",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> SdkResult<Self> {
|
||||
match s {
|
||||
"broker_hello" => Ok(Self::BrokerHello),
|
||||
"broker_request" => Ok(Self::BrokerRequest),
|
||||
"broker_response" => Ok(Self::BrokerResponse),
|
||||
"query_request" => Ok(Self::QueryRequest),
|
||||
"query_response" => Ok(Self::QueryResponse),
|
||||
"control_request" => Ok(Self::ControlRequest),
|
||||
"control_response" => Ok(Self::ControlResponse),
|
||||
"event_replay" => Ok(Self::EventReplay),
|
||||
"event_replay_result" => Ok(Self::EventReplayResult),
|
||||
"session_activate" => Ok(Self::SessionActivate),
|
||||
"session_activate_result" => Ok(Self::SessionActivateResult),
|
||||
_ => Err(SdkError::frame_error(format!("unknown frame type: {}", s))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A frame in the SDK wire protocol.
|
||||
///
|
||||
/// Frames are JSON objects with a `type` field and optional `id` field for correlation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Frame {
|
||||
/// Underlying JSON value
|
||||
pub data: Map<String, Value>,
|
||||
}
|
||||
|
||||
impl Frame {
|
||||
/// Create a new frame from a JSON object.
|
||||
pub fn new(data: Map<String, Value>) -> Self {
|
||||
Self { data }
|
||||
}
|
||||
|
||||
/// Create an empty frame.
|
||||
pub fn empty() -> Self {
|
||||
Self { data: Map::new() }
|
||||
}
|
||||
|
||||
/// Get the frame type.
|
||||
pub fn frame_type(&self) -> SdkResult<FrameType> {
|
||||
let frame_type_str = self
|
||||
.data
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| SdkError::frame_error("missing 'type' field"))?;
|
||||
FrameType::parse(frame_type_str)
|
||||
}
|
||||
|
||||
/// Get the optional request/response ID for correlation.
|
||||
pub fn id(&self) -> Option<String> {
|
||||
self.data
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(String::from)
|
||||
}
|
||||
|
||||
/// Get the `ok` boolean field from a response frame.
|
||||
pub fn ok(&self) -> SdkResult<bool> {
|
||||
self.data
|
||||
.get("ok")
|
||||
.and_then(Value::as_bool)
|
||||
.ok_or_else(|| SdkError::frame_error("missing or invalid 'ok' field"))
|
||||
}
|
||||
|
||||
/// Get the error object from a response frame.
|
||||
pub fn error(&self) -> Option<SdkResult<(String, String)>> {
|
||||
let error_obj = self.data.get("error")?.as_object()?;
|
||||
let code = error_obj.get("code")?.as_str()?.to_string();
|
||||
let message = error_obj.get("message")?.as_str()?.to_string();
|
||||
Some(Ok((code, message)))
|
||||
}
|
||||
|
||||
/// Get the result field from a response frame.
|
||||
pub fn result(&self) -> Option<Value> {
|
||||
self.data.get("result").cloned()
|
||||
}
|
||||
|
||||
/// Convert to JSON string.
|
||||
pub fn to_json(&self) -> SdkResult<String> {
|
||||
serde_json::to_string(&self.data)
|
||||
.map_err(|e| SdkError::frame_error(format!("JSON serialization error: {}", e)))
|
||||
}
|
||||
|
||||
/// Parse from JSON string.
|
||||
pub fn from_json(json_str: &str) -> SdkResult<Self> {
|
||||
let data: Map<String, Value> = serde_json::from_str(json_str)
|
||||
.map_err(|e| SdkError::frame_error(format!("JSON parsing error: {}", e)))?;
|
||||
Ok(Self::new(data))
|
||||
}
|
||||
|
||||
/// Create a broker hello frame.
|
||||
pub fn broker_hello() -> Self {
|
||||
let mut data = Map::new();
|
||||
data.insert(
|
||||
"type".to_string(),
|
||||
Value::String("broker_hello".to_string()),
|
||||
);
|
||||
data.insert(
|
||||
"protocolVersion".to_string(),
|
||||
Value::Number(PROTOCOL_VERSION.into()),
|
||||
);
|
||||
Self::new(data)
|
||||
}
|
||||
|
||||
/// Create a broker request frame.
|
||||
pub fn broker_request(id: String, operation: String, input: Value) -> Self {
|
||||
let mut data = Map::new();
|
||||
data.insert(
|
||||
"type".to_string(),
|
||||
Value::String("broker_request".to_string()),
|
||||
);
|
||||
data.insert("id".to_string(), Value::String(id));
|
||||
data.insert("operation".to_string(), Value::String(operation));
|
||||
data.insert("input".to_string(), input);
|
||||
Self::new(data)
|
||||
}
|
||||
|
||||
/// Create a broker response frame.
|
||||
pub fn broker_response_ok(id: String, result: Value) -> Self {
|
||||
let mut data = Map::new();
|
||||
data.insert(
|
||||
"type".to_string(),
|
||||
Value::String("broker_response".to_string()),
|
||||
);
|
||||
data.insert("id".to_string(), Value::String(id));
|
||||
data.insert("ok".to_string(), Value::Bool(true));
|
||||
data.insert("result".to_string(), result);
|
||||
Self::new(data)
|
||||
}
|
||||
|
||||
/// Create a broker error response frame.
|
||||
pub fn broker_response_error(id: String, code: String, message: String) -> Self {
|
||||
let mut error_data = Map::new();
|
||||
error_data.insert("code".to_string(), Value::String(code));
|
||||
error_data.insert("message".to_string(), Value::String(message));
|
||||
|
||||
let mut data = Map::new();
|
||||
data.insert(
|
||||
"type".to_string(),
|
||||
Value::String("broker_response".to_string()),
|
||||
);
|
||||
data.insert("id".to_string(), Value::String(id));
|
||||
data.insert("ok".to_string(), Value::Bool(false));
|
||||
data.insert("error".to_string(), Value::Object(error_data));
|
||||
Self::new(data)
|
||||
}
|
||||
|
||||
/// Get the payload size in bytes.
|
||||
pub fn byte_size(&self) -> usize {
|
||||
self.to_json().map(|s| s.len()).unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn frame_type_round_trip() {
|
||||
assert_eq!(FrameType::BrokerHello.as_str(), "broker_hello");
|
||||
assert_eq!(
|
||||
FrameType::parse("broker_hello").unwrap(),
|
||||
FrameType::BrokerHello
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_parsing_extracts_type() {
|
||||
let frame = Frame::broker_hello();
|
||||
assert_eq!(frame.frame_type().unwrap(), FrameType::BrokerHello);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_with_id_can_be_recovered() {
|
||||
let frame = Frame::broker_request(
|
||||
"test-id".to_string(),
|
||||
"test.operation".to_string(),
|
||||
Value::Object(Map::new()),
|
||||
);
|
||||
assert_eq!(frame.id(), Some("test-id".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_json_round_trip() {
|
||||
let frame = Frame::broker_hello();
|
||||
let json_str = frame.to_json().unwrap();
|
||||
let parsed = Frame::from_json(&json_str).unwrap();
|
||||
assert_eq!(parsed.frame_type().unwrap(), FrameType::BrokerHello);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broker_response_error_contains_code_and_message() {
|
||||
let frame = Frame::broker_response_error(
|
||||
"id1".to_string(),
|
||||
"invalid_input".to_string(),
|
||||
"test error".to_string(),
|
||||
);
|
||||
let (code, message) = frame.error().unwrap().unwrap();
|
||||
assert_eq!(code, "invalid_input");
|
||||
assert_eq!(message, "test error");
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
//! GJC SDK wire transport implementation.
|
||||
//!
|
||||
//! This crate provides a Rust implementation of the GJC SDK wire protocol,
|
||||
//! compatible with the gajae-code SDK broker framing, request/response correlation,
|
||||
//! and event stream semantics.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![allow(
|
||||
clippy::missing_errors_doc,
|
||||
clippy::missing_panics_doc,
|
||||
clippy::module_name_repetitions
|
||||
)]
|
||||
|
||||
pub mod error;
|
||||
pub mod frame;
|
||||
pub mod transport;
|
||||
|
||||
pub use error::{SdkError, SdkResult};
|
||||
pub use frame::{Frame, FrameType};
|
||||
pub use transport::{SdkTransport, SdkTransportConfig};
|
||||
@@ -1,251 +0,0 @@
|
||||
//! WebSocket transport implementation for the SDK wire protocol.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures_util::sink::SinkExt;
|
||||
use futures_util::stream::StreamExt;
|
||||
use serde_json::Value;
|
||||
use tokio::sync::{mpsc, oneshot, Mutex, RwLock};
|
||||
use tokio_tungstenite::{connect_async, tungstenite::Message};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{SdkError, SdkResult};
|
||||
use crate::frame::{Frame, FrameType, MAX_FRAME_BYTES};
|
||||
|
||||
/// Configuration for SDK transport.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SdkTransportConfig {
|
||||
/// Server URL (e.g., "ws://127.0.0.1:8080")
|
||||
pub url: String,
|
||||
/// Authentication token
|
||||
pub token: String,
|
||||
/// Maximum payload size in bytes (default: 4 MiB)
|
||||
pub max_payload_bytes: usize,
|
||||
/// Request timeout in seconds (default: 30)
|
||||
pub timeout_secs: u64,
|
||||
}
|
||||
|
||||
impl Default for SdkTransportConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
url: "ws://127.0.0.1:8080".to_string(),
|
||||
token: String::new(),
|
||||
max_payload_bytes: MAX_FRAME_BYTES,
|
||||
timeout_secs: 30,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pending request tracking for correlation.
|
||||
struct PendingRequest {
|
||||
response_tx: oneshot::Sender<SdkResult<Frame>>,
|
||||
}
|
||||
|
||||
/// SDK WebSocket transport.
|
||||
pub struct SdkTransport {
|
||||
/// Channel for sending messages to the writer task
|
||||
tx: mpsc::UnboundedSender<Message>,
|
||||
/// Pending requests by ID
|
||||
pending: Arc<RwLock<HashMap<String, PendingRequest>>>,
|
||||
/// Configuration
|
||||
config: SdkTransportConfig,
|
||||
/// Background task handle
|
||||
task_handle: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>,
|
||||
}
|
||||
|
||||
impl SdkTransport {
|
||||
/// Create a new SDK transport and connect to the broker.
|
||||
pub async fn connect(config: SdkTransportConfig) -> SdkResult<Self> {
|
||||
// Add token to query params
|
||||
let url_with_token = if config.url.contains('?') {
|
||||
format!("{}&token={}", config.url, config.token)
|
||||
} else {
|
||||
format!("{}?token={}", config.url, config.token)
|
||||
};
|
||||
|
||||
// Create WebSocket connection
|
||||
let (ws, _) = connect_async(&url_with_token).await.map_err(|e| {
|
||||
SdkError::connection_error(format!("WebSocket connection failed: {}", e))
|
||||
})?;
|
||||
|
||||
let (ws_tx, ws_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let pending = Arc::new(RwLock::new(HashMap::new()));
|
||||
let pending_clone = pending.clone();
|
||||
|
||||
// Spawn background task for handling the WebSocket
|
||||
let task_handle = tokio::spawn(async move {
|
||||
Self::background_task(ws, ws_rx, pending_clone).await;
|
||||
});
|
||||
|
||||
let transport = Self {
|
||||
tx: ws_tx,
|
||||
pending,
|
||||
config,
|
||||
task_handle: Arc::new(Mutex::new(Some(task_handle))),
|
||||
};
|
||||
|
||||
Ok(transport)
|
||||
}
|
||||
|
||||
/// Send a request and wait for response.
|
||||
pub async fn request(&self, operation: String, input: Value) -> SdkResult<Frame> {
|
||||
// Generate correlation ID
|
||||
let id = Uuid::new_v4().to_string();
|
||||
|
||||
// Create request frame
|
||||
let request = Frame::broker_request(id.clone(), operation, input);
|
||||
|
||||
// Check payload size
|
||||
if request.byte_size() > self.config.max_payload_bytes {
|
||||
return Err(SdkError::payload_too_large(format!(
|
||||
"frame size {} exceeds maximum {}",
|
||||
request.byte_size(),
|
||||
self.config.max_payload_bytes
|
||||
)));
|
||||
}
|
||||
|
||||
// Create response channel
|
||||
let (response_tx, response_rx) = oneshot::channel();
|
||||
self.pending
|
||||
.write()
|
||||
.await
|
||||
.insert(id.clone(), PendingRequest { response_tx });
|
||||
|
||||
// Send request
|
||||
let json = request.to_json()?;
|
||||
self.tx
|
||||
.send(Message::Text(json))
|
||||
.map_err(|e| SdkError::connection_error(format!("send failed: {}", e)))?;
|
||||
|
||||
// Wait for response with timeout
|
||||
let timeout_duration = std::time::Duration::from_secs(self.config.timeout_secs);
|
||||
match tokio::time::timeout(timeout_duration, response_rx).await {
|
||||
Ok(Ok(frame)) => frame,
|
||||
Ok(Err(_)) => Err(SdkError::correlation_error("response channel closed")),
|
||||
Err(_) => Err(SdkError::timeout("response not received within timeout")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Background task for handling WebSocket communication.
|
||||
#[allow(unused_mut)]
|
||||
async fn background_task(
|
||||
ws: tokio_tungstenite::WebSocketStream<
|
||||
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
|
||||
>,
|
||||
mut ws_rx: mpsc::UnboundedReceiver<Message>,
|
||||
pending: Arc<RwLock<HashMap<String, PendingRequest>>>,
|
||||
) {
|
||||
let (mut ws_sink, mut ws_stream) = ws.split();
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
// Handle incoming WebSocket messages
|
||||
msg = ws_stream.next() => {
|
||||
match msg {
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
// Parse frame
|
||||
match Frame::from_json(&text) {
|
||||
Ok(frame) => {
|
||||
// Handle frame type
|
||||
match frame.frame_type() {
|
||||
Ok(FrameType::BrokerHello) => {
|
||||
// Hello frame received, continue
|
||||
}
|
||||
Ok(FrameType::BrokerResponse) => {
|
||||
// Response frame, correlate with request
|
||||
if let Some(id) = frame.id() {
|
||||
if let Some(PendingRequest { response_tx }) =
|
||||
pending.write().await.remove(&id)
|
||||
{
|
||||
let _ = response_tx.send(Ok(frame));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Other frame types
|
||||
tracing::debug!("Unhandled frame type: {:?}", frame.frame_type());
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to parse frame: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Close(_))) => {
|
||||
break;
|
||||
}
|
||||
Some(Ok(_)) => {
|
||||
// Other message types (binary, etc.), ignore
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
tracing::warn!("WebSocket error: {}", e);
|
||||
break;
|
||||
}
|
||||
None => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle outgoing messages
|
||||
Some(msg) = ws_rx.recv() => {
|
||||
if let Err(e) = ws_sink.send(msg).await {
|
||||
tracing::warn!("Failed to send message: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
else => break,
|
||||
}
|
||||
}
|
||||
|
||||
// Fail all pending requests
|
||||
let mut pending = pending.write().await;
|
||||
for (_, PendingRequest { response_tx }) in pending.drain() {
|
||||
let _ = response_tx.send(Err(SdkError::connection_error("connection closed")));
|
||||
}
|
||||
}
|
||||
|
||||
/// Close the transport.
|
||||
pub async fn close(self) -> SdkResult<()> {
|
||||
// Send close message
|
||||
let _ = self.tx.send(Message::Close(None));
|
||||
|
||||
// Wait for background task to finish
|
||||
if let Some(handle) = self.task_handle.lock().await.take() {
|
||||
let _ = tokio::time::timeout(std::time::Duration::from_secs(5), handle).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn config_default_values() {
|
||||
let config = SdkTransportConfig::default();
|
||||
assert_eq!(config.url, "ws://127.0.0.1:8080");
|
||||
assert_eq!(config.max_payload_bytes, MAX_FRAME_BYTES);
|
||||
assert_eq!(config.timeout_secs, 30);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn transport_can_be_created() {
|
||||
// This test doesn't actually connect, just checks that the struct can be created
|
||||
let config = SdkTransportConfig::default();
|
||||
assert_eq!(config.url, "ws://127.0.0.1:8080");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_frame_has_correlation_id() {
|
||||
let frame = Frame::broker_request(
|
||||
"test-id".to_string(),
|
||||
"test.operation".to_string(),
|
||||
Value::Object(serde_json::Map::new()),
|
||||
);
|
||||
assert_eq!(frame.id(), Some("test-id".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ use runtime::{
|
||||
lsp_client::LspRegistry,
|
||||
mcp_tool_bridge::McpToolRegistry,
|
||||
permission_enforcer::{EnforcementResult, PermissionEnforcer},
|
||||
read_file_in_workspace_with_selector,
|
||||
read_file_in_workspace,
|
||||
summary_compression::compress_summary_text,
|
||||
task_registry::TaskRegistry,
|
||||
team_cron_registry::{CronRegistry, TeamRegistry},
|
||||
@@ -2569,14 +2569,8 @@ fn branch_divergence_output(
|
||||
fn run_read_file(input: ReadFileInput) -> Result<String, String> {
|
||||
let workspace = std::env::current_dir().map_err(|error| error.to_string())?;
|
||||
to_pretty_json(
|
||||
read_file_in_workspace_with_selector(
|
||||
&input.path,
|
||||
input.offset,
|
||||
input.limit,
|
||||
input.selector.as_deref(),
|
||||
&workspace,
|
||||
)
|
||||
.map_err(io_to_string)?,
|
||||
read_file_in_workspace(&input.path, input.offset, input.limit, &workspace)
|
||||
.map_err(io_to_string)?,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2891,7 +2885,6 @@ struct ReadFileInput {
|
||||
path: String,
|
||||
offset: Option<usize>,
|
||||
limit: Option<usize>,
|
||||
selector: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -10697,43 +10690,6 @@ printf 'pwsh:%s' "$1"
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn given_read_only_enforcer_when_read_file_with_selector_then_succeeds() {
|
||||
let _guard = env_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let root = temp_path("perm-read-selector");
|
||||
fs::create_dir_all(&root).expect("create root");
|
||||
let file = root.join("multi-line.txt");
|
||||
fs::write(&file, "line1\nline2\nline3\nline4\nline5\n").expect("write test file");
|
||||
let original_dir = std::env::current_dir().expect("cwd");
|
||||
std::env::set_current_dir(&root).expect("set cwd");
|
||||
|
||||
let registry = read_only_registry();
|
||||
let result = registry.execute(
|
||||
"read_file",
|
||||
&json!({
|
||||
"path": file.display().to_string(),
|
||||
"selector": "2-4"
|
||||
}),
|
||||
);
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"read_file with selector should succeed: {result:?}"
|
||||
);
|
||||
|
||||
// Verify the output contains the expected lines
|
||||
let output: serde_json::Value =
|
||||
serde_json::from_str(&result.unwrap()).expect("json output");
|
||||
let content = output["file"]["content"].as_str().expect("content field");
|
||||
assert!(content.contains("line2"));
|
||||
assert!(content.contains("line3"));
|
||||
assert!(content.contains("line4"));
|
||||
|
||||
std::env::set_current_dir(&original_dir).expect("restore cwd");
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn given_read_only_enforcer_when_glob_search_then_not_permission_denied() {
|
||||
let registry = read_only_registry();
|
||||
|
||||
Reference in New Issue
Block a user