Compare commits

..
Author SHA1 Message Date
clawdbot 2a1c4b29c7 fix(test): sandbox status can be 'ok' or 'warn' depending on kernel namespaces
The /sandbox status check returns 'ok' on systems with working user
namespaces and 'warn' on systems without them (#731). The test was
only accepting 'warn', which caused failures on systems with namespaces.
This changes the test to accept both values, matching the kernel's
actual sandbox support.

Fixes the failure: direct_resume_safe_slash_commands_route_to_local_json_actions_831
2026-10-12 23:04:29 +00:00
clawdbot 0663c80ba1 build: add Nix flake dev shell and claw package (#3247) 2026-10-12 23:04:29 +00:00
14 changed files with 407 additions and 1407 deletions
+1
View File
@@ -0,0 +1 @@
use flake
+4
View File
@@ -25,6 +25,8 @@ on:
- tests/test_roadmap_helpers.py
- docs/**
- rust/**
- flake.nix
- .envrc
pull_request:
branches:
- main
@@ -47,6 +49,8 @@ on:
- tests/test_roadmap_helpers.py
- docs/**
- rust/**
- flake.nix
- .envrc
workflow_dispatch:
concurrency:
+4
View File
@@ -15,3 +15,7 @@ status-help.txt
.port_sessions/
# Local agent session state
.gjc/
# Nix build outputs and direnv
.envrc.local
result
result-*
-44
View File
@@ -1,44 +0,0 @@
# Issue #3283 Triage Summary
**Issue:** docs: document OmniRoute OpenAI-compatible setup
**Opened by:** @diegosouzapw
**Status:** BLOCKED (waiting for clarification)
**Date:** 2026-10-13
## Findings
### Investigation
1. Searched codebase for "OmniRoute" — **no matches found**
2. Searched documentation (docs/local-openai-compatible-providers.md, MODEL_COMPATIBILITY.md) — **no mentions**
3. Searched git history (commits, branches, PRs) — **no references**
4. Checked GitHub API (rate limited, but issue title confirmed)
### Current State
- Issue #3283 is OPEN
- No labels assigned
- Title: "docs: document OmniRoute OpenAI-compatible setup"
- This is a documentation request for setting up an OpenAI-compatible provider
### Existing Documentation
The repository already has comprehensive documentation for OpenAI-compatible providers:
- `docs/local-openai-compatible-providers.md` — documents Ollama, llama.cpp, vLLM, and OpenRouter
- `docs/MODEL_COMPATIBILITY.md` — model-specific provider routing details
- `rust/crates/api/src/providers/openai_compat.rs` — provider implementation
## Next Steps
**Action taken:** Comment added to issue asking for:
1. OmniRoute documentation/website link
2. OpenAI-compatible endpoint example (base URL, API key format)
3. Model ID examples
4. Any special configuration or known quirks
**Comment posted to:** https://github.com/ultraworkers/claw-code/issues/3283#issuecomment-6133484114
## Conclusion
**Cannot proceed without user input.** Once the user provides the necessary information about OmniRoute, the fix will involve:
1. Adding a new section to `docs/local-openai-compatible-providers.md`
2. Including setup example (environment variables, model names, basic smoke test)
3. Following the existing format used for Ollama, llama.cpp, vLLM sections
4. Creating a PR for review
-799
View File
@@ -1,799 +0,0 @@
# 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
-19
View File
@@ -130,25 +130,6 @@ claw --model "mlx-community/Qwen2.5-Coder-7B-Instruct-4bit" prompt "Reply exactl
mlx-lm serves models under their full Hugging Face repo ID. Use the exact `id` field from `curl $OPENAI_BASE_URL/models` for `--model`. A bare name like `qwen2.5-coder-7b-instruct` will fail model resolution before the request ever reaches the server.
## OmniRoute and other remote OpenAI-compatible gateways
[OmniRoute](https://github.com/diegosouzapw/OmniRoute) is a third-party OpenAI-compatible AI gateway that can route requests to multiple providers. To use any remote OpenAI-compatible gateway with Claw, set `OPENAI_BASE_URL` to the gateway's `/v1` endpoint and provide your API key:
```bash
export OPENAI_BASE_URL="https://api.omniroute.ai/v1"
export OPENAI_API_KEY="your-omniroute-api-key"
claw --model "gpt-4" prompt "Reply exactly HELLO_WORLD_123"
```
To find the available model IDs on your gateway, use the `/models` endpoint:
```bash
curl -sS "$OPENAI_BASE_URL/models" \
-H "Authorization: Bearer $OPENAI_API_KEY" | jq '.data[].id'
```
For local configuration, see your gateway's documentation for the correct base URL, API key format, and available model IDs. The same OPENAI_BASE_URL + OPENAI_API_KEY pattern applies to any OpenAI-compatible remote gateway.
## Local skills install from disk
Skills are discovered from Claw skill roots such as `.claw/skills/` in a workspace and `~/.claw/skills/` for user-level installs. Legacy `.codex/skills/` roots may also be scanned for compatibility, but new local Claw projects should prefer `.claw/skills/`.
+203
View File
@@ -0,0 +1,203 @@
# Nix flake development environment
This repository includes a **`flake.nix`** for reproducible development environments on NixOS and systems with Nix installed.
## Prerequisites
- **Nix** (version 2.4 or later with flakes support enabled)
- **direnv** (optional, for automatic environment loading via `.envrc`)
To verify Nix is installed and flakes are enabled:
```bash
nix --version
nix flake --help
```
If flakes are not enabled, add this to your `~/.config/nix/nix.conf`:
```
experimental-features = nix-command flakes
```
## Quick start
### Enter the development environment
**Option 1: Using `nix flake`** (without direnv)
```bash
nix flake update # Update inputs (optional, pins nixpkgs)
nix develop # Enter default dev shell
nix develop .#full # Enter full dev shell with extra tools
exit # Leave the shell
```
**Option 2: Using direnv** (automatic on directory entry)
```bash
direnv allow # Trust the .envrc file (one-time setup)
cd . # Direnv reloads the flake automatically
```
When you leave the repository directory, the environment is automatically unloaded.
## Development shells
The flake provides two development shells:
### `default` (minimal)
```bash
nix develop
```
Includes:
- Rust (stable, latest)
- Rust source code (`rust-src`)
- `cargo-edit` (dependency management helpers)
- `cargo-expand` (macro expansion tool)
- `cargo-watch` (file watcher for incremental builds)
- Build tools: `pkg-config`, `openssl`, `git`, `ca-certificates`
### `full` (with extra tooling)
```bash
nix develop .#full
```
Includes everything from `default` plus:
- `clippy` (linter)
- `rustfmt` (code formatter)
- `mold` (fast linker)
- `lldb` (debugger)
## Building the claw binary
### With the dev environment
```bash
nix develop
cd rust
cargo build -p rusty-claude-cli
```
### Directly with `nix build`
```bash
nix build .#claw # Build the claw binary
nix build # Same as .#claw (default package)
```
The compiled binary is symlinked to `./result/bin/claw`.
## Running common tasks
Once in the dev shell:
```bash
# Format code
cd rust
cargo fmt
# Run clippy linter
cargo clippy --workspace --all-targets -- -D warnings
# Run all tests
cargo test --workspace
# Run tests for a specific crate
cargo test -p rusty-claude-cli
# Build with optimizations
cargo build --release -p rusty-claude-cli
```
## Environment variables
The flake automatically sets:
- `RUSTUP_TOOLCHAIN=stable` — uses stable Rust from nixpkgs
- Standard Rust build flags are inherited from the environment
For development, the minimal dev shell includes a helpful message on entry:
```
Claw Code development environment
Run 'cargo build -p rusty-claude-cli' to build the claw binary
Run 'cargo test --workspace' to run tests
Run 'scripts/fmt.sh' to format code
```
## Troubleshooting
### `command not found: nix`
Nix is not installed. Install it from [nixos.org/download.html](https://nixos.org/download.html).
### `error: experimental Nix feature 'flakes' is disabled`
Enable flakes in your Nix configuration (`~/.config/nix/nix.conf` or `/etc/nix/nix.conf`):
```
experimental-features = nix-command flakes
```
### `error: ... does not provide attribute 'claw'`
The flake structure may have changed. Try updating:
```bash
nix flake update
nix flake check
```
### Rebuilds are slow after updating dependencies
Cargo will recompile dependencies if `Cargo.lock` changes. This is expected:
```bash
nix build --rebuild
```
### `cache lock file needs to be updated`
The Cargo.lock in `rust/` needs to be committed. Run:
```bash
cd rust
cargo generate-lockfile
git add Cargo.lock
git commit -m "update: Cargo.lock"
```
## Integration with other tools
### IDE integration (VS Code, IntelliJ, etc.)
Many IDEs can detect and use Nix environments. Direnv integration is recommended:
1. Install the direnv extension
2. Enable `direnv` shell integration in your IDE
3. Run `direnv allow` in the repository
Your IDE will automatically use the correct Rust toolchain and dependencies from the flake.
### CI/CD
For GitHub Actions, consider using [cachix/install-nix-action](https://github.com/cachix/install-nix-action) to set up Nix on the runner:
```yaml
- uses: cachix/install-nix-action@v18
with:
nix_path: nixpkgs=channel:nixos-unstable
extra_nix_config: "experimental-features = nix-command flakes"
- run: nix develop -c cargo test --workspace
```
## More information
- [NixOS flakes documentation](https://nixos.wiki/wiki/Flakes)
- [Direnv documentation](https://direnv.net/)
- [Rust on Nix](https://github.com/oxalica/rust-overlay)
+115
View File
@@ -0,0 +1,115 @@
{
description = "claw-code: Claude Code CLI agent harness in Rust";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { self, nixpkgs, flake-utils, rust-overlay }:
flake-utils.lib.eachDefaultSystem (system:
let
overlays = [ (import rust-overlay) ];
pkgs = import nixpkgs {
inherit system overlays;
};
# Build inputs needed for Rust and the project
buildInputs = with pkgs; [
pkg-config
openssl
ca-certificates
git
];
# Runtime inputs for the dev environment
nativeBuildInputs = with pkgs; [
(rust-bin.stable.latest.default.override {
extensions = [ "rust-src" ];
targets = [ ];
})
cargo-edit
cargo-expand
cargo-watch
];
# Derivation to build the claw binary
clawBinary = pkgs.rustPlatform.buildRustPackage {
pname = "claw";
version = (builtins.fromTOML (builtins.readFile ./rust/Cargo.toml)).workspace.package.version;
src = ./rust;
# Hash of all the dependencies in Cargo.lock
# This needs to be updated when dependencies change
# Run: nix flake update
cargoLock = {
lockFile = ./rust/Cargo.lock;
};
cargoBuildFlags = [ "-p" "rusty-claude-cli" ];
buildInputs = buildInputs ++ (with pkgs; [
openssl
]);
nativeBuildInputs = with pkgs; [
pkg-config
];
meta = with pkgs.lib; {
description = "Claude Code CLI: agent harness with Rust implementation";
homepage = "https://github.com/ultraworkers/claw-code";
license = licenses.mit;
platforms = platforms.unix;
};
};
in
{
# Provide the claw binary as a package
packages.claw = clawBinary;
packages.default = clawBinary;
# Development shell for contributors
devShells.default = pkgs.mkShell {
name = "claw-dev";
inherit buildInputs nativeBuildInputs;
shellHook = ''
echo "Claw Code development environment"
echo "Run 'cargo build -p rusty-claude-cli' to build the claw binary"
echo "Run 'cargo test --workspace' to run tests"
echo "Run 'scripts/fmt.sh' to format code"
'';
# Make sure we have the right Rust toolchain
RUSTUP_TOOLCHAIN = "stable";
};
# Alternative shell with additional development tools
devShells.full = pkgs.mkShell {
name = "claw-dev-full";
inherit buildInputs nativeBuildInputs;
nativeBuildInputs = nativeBuildInputs ++ (with pkgs; [
clippy
rustfmt
mold
lldb
]);
shellHook = ''
echo "Claw Code full development environment (with extra tools)"
echo "Available tools: clippy, rustfmt, mold (linker), lldb (debugger)"
'';
RUSTUP_TOOLCHAIN = "stable";
};
}
);
}
Generated Regular → Executable
+24 -51
View File
@@ -383,17 +383,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chacha20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"rand_core 0.10.1",
]
[[package]]
name = "ciborium"
version = "0.2.2"
@@ -1013,24 +1002,12 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 6.0.0",
"rand_core 0.10.1",
"r-efi",
"wasip2",
"wasm-bindgen",
]
@@ -1904,15 +1881,14 @@ dependencies = [
[[package]]
name = "quinn-proto"
version = "0.11.19"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"bytes",
"getrandom 0.4.3",
"getrandom 0.3.4",
"lru-slab",
"rand 0.10.3",
"rand_pcg",
"rand 0.9.2",
"ring",
"rustc-hash",
"rustls",
@@ -1953,12 +1929,6 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "radix_trie"
version = "0.2.1"
@@ -1976,19 +1946,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"libc",
"rand_chacha",
"rand_chacha 0.3.1",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.10.3"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"chacha20",
"getrandom 0.4.3",
"rand_core 0.10.1",
"rand_chacha 0.9.0",
"rand_core 0.9.5",
]
[[package]]
@@ -2001,6 +1970,16 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.6.4"
@@ -2012,17 +1991,11 @@ dependencies = [
[[package]]
name = "rand_core"
version = "0.10.1"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_pcg"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"rand_core 0.10.1",
"getrandom 0.3.4",
]
[[package]]
+2 -192
View File
@@ -13,130 +13,7 @@ pub enum ProviderClient {
OpenAi(OpenAiCompatClient),
}
/// Provider selected explicitly by persisted/runtime configuration.
///
/// This is intentionally separate from `ProviderKind`: DashScope speaks the
/// OpenAI-compatible wire protocol but remains a distinct configured provider.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderConfigKind {
Anthropic,
Xai,
OpenAi,
DashScope,
}
/// Explicit provider configuration supplied by the runtime layer.
///
/// This keeps the API crate independent of runtime configuration types.
/// Credentials are redacted from Debug output.
#[derive(Clone)]
pub struct ProviderConfig {
pub kind: ProviderConfigKind,
pub model: String,
pub api_key: Option<String>,
pub base_url: Option<String>,
}
impl std::fmt::Debug for ProviderConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ProviderConfig")
.field("kind", &self.kind)
.field("model", &self.model)
.field("api_key", &self.api_key.as_ref().map(|_| "<redacted>"))
.field("base_url", &self.base_url)
.finish()
}
}
fn read_env_non_empty(key: &str) -> Option<String> {
std::env::var(key)
.ok()
.filter(|value| !value.trim().is_empty())
}
impl ProviderClient {
/// Construct a client from an explicit provider configuration.
///
/// The persisted provider kind is authoritative for provider selection,
/// while environment credentials and base URLs retain precedence over
/// persisted values.
pub fn from_config(config: &ProviderConfig) -> Result<Self, ApiError> {
match config.kind {
ProviderConfigKind::Anthropic => {
let api_key = read_env_non_empty("ANTHROPIC_API_KEY")
.or_else(|| config.api_key.clone())
.ok_or_else(|| {
ApiError::missing_credentials(
"Anthropic",
&["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"],
)
})?;
let mut client = AnthropicClient::new(api_key);
let base_url = read_env_non_empty("ANTHROPIC_BASE_URL")
.or_else(|| config.base_url.clone())
.unwrap_or_else(anthropic::read_base_url);
client = client.with_base_url(base_url);
Ok(Self::Anthropic(client))
}
ProviderConfigKind::Xai => {
let compat = OpenAiCompatConfig::xai();
let api_key =
read_env_non_empty(compat.api_key_env).or_else(|| config.api_key.clone());
let mut client = match api_key {
Some(api_key) => OpenAiCompatClient::new(api_key, compat),
None => OpenAiCompatClient::from_env(compat)?,
};
let base_url = read_env_non_empty(compat.base_url_env)
.or_else(|| config.base_url.clone())
.unwrap_or_else(|| compat.default_base_url.to_string());
client = client.with_base_url(base_url);
Ok(Self::Xai(client))
}
ProviderConfigKind::OpenAi | ProviderConfigKind::DashScope => {
let compat = match config.kind {
ProviderConfigKind::OpenAi => OpenAiCompatConfig::openai(),
ProviderConfigKind::DashScope => OpenAiCompatConfig::dashscope(),
_ => unreachable!("non-OpenAI provider reached compatibility path"),
};
if config.kind == ProviderConfigKind::OpenAi
&& read_env_non_empty("OLLAMA_HOST").is_some()
&& config.api_key.is_none()
&& config.base_url.is_none()
{
return Ok(Self::OpenAi(
openai_compat::OpenAiCompatClient::from_ollama_env()
.expect("from_ollama_env always returns Some"),
));
}
let api_key =
read_env_non_empty(compat.api_key_env).or_else(|| config.api_key.clone());
let persisted_base_url = config.base_url.clone();
let mut client = match api_key {
Some(api_key) => OpenAiCompatClient::new(api_key, compat),
None if config.kind == ProviderConfigKind::OpenAi
&& persisted_base_url
.as_deref()
.is_some_and(openai_compat::is_local_openai_compatible_base_url) =>
{
OpenAiCompatClient::new("local-dev-token", compat)
}
None => OpenAiCompatClient::from_env(compat)?,
};
let base_url = read_env_non_empty(compat.base_url_env)
.or(persisted_base_url)
.unwrap_or_else(|| compat.default_base_url.to_string());
client = client.with_base_url(base_url);
Ok(Self::OpenAi(client))
}
}
}
pub fn from_model(model: &str) -> Result<Self, ApiError> {
Self::from_model_with_anthropic_auth(model, None)
}
@@ -157,7 +34,7 @@ impl ProviderClient {
ProviderKind::OpenAi => {
// OLLAMA_HOST takes priority: local Ollama needs no API key
// and ignores DashScope/OpenAI env-based dispatch.
if read_env_non_empty("OLLAMA_HOST").is_some() {
if std::env::var_os("OLLAMA_HOST").is_some() {
Ok(Self::OpenAi(
openai_compat::OpenAiCompatClient::from_ollama_env()
.expect("from_ollama_env always returns Some"),
@@ -278,7 +155,7 @@ pub fn read_xai_base_url() -> String {
mod tests {
use std::sync::{Mutex, OnceLock};
use super::{ProviderClient, ProviderConfig, ProviderConfigKind};
use super::ProviderClient;
use crate::providers::{detect_provider_kind, resolve_model_alias, ProviderKind};
/// Serializes every test in this module that mutates process-wide
@@ -336,73 +213,6 @@ mod tests {
}
}
#[test]
fn persisted_provider_kind_overrides_model_provider_detection() {
let _lock = env_lock();
let _anthropic = EnvVarGuard::set("ANTHROPIC_API_KEY", Some("test-anthropic-key"));
let _xai = EnvVarGuard::set("XAI_API_KEY", Some("test-xai-key"));
let config = ProviderConfig {
kind: ProviderConfigKind::Xai,
model: "claude-sonnet-4-6".to_string(),
api_key: None,
base_url: None,
};
match ProviderClient::from_config(&config).expect("explicit xAI config should succeed") {
ProviderClient::Xai(client) => {
assert!(client.base_url().contains("api.x.ai"));
}
other => panic!("Expected explicit xAI provider, got: {other:?}"),
}
}
#[test]
fn persisted_dashscope_kind_overrides_qwen_model_routing() {
let _lock = env_lock();
let _dashscope = EnvVarGuard::set("DASHSCOPE_API_KEY", Some("test-dashscope-key"));
let _openai = EnvVarGuard::set("OPENAI_API_KEY", Some("test-openai-key"));
let config = ProviderConfig {
kind: ProviderConfigKind::DashScope,
model: "gpt-5".to_string(),
api_key: None,
base_url: None,
};
match ProviderClient::from_config(&config)
.expect("explicit DashScope config should succeed")
{
ProviderClient::OpenAi(client) => {
assert!(client.base_url().contains("dashscope.aliyuncs.com"));
}
other => panic!("Expected explicit DashScope provider, got: {other:?}"),
}
}
#[test]
fn persisted_openai_kind_does_not_become_dashscope_from_model() {
let _lock = env_lock();
let _openai = EnvVarGuard::set("OPENAI_API_KEY", Some("test-openai-key"));
let _dashscope = EnvVarGuard::set("DASHSCOPE_API_KEY", Some("test-dashscope-key"));
let _openai_base_url = EnvVarGuard::set("OPENAI_BASE_URL", None);
let config = ProviderConfig {
kind: ProviderConfigKind::OpenAi,
model: "qwen-plus".to_string(),
api_key: None,
base_url: None,
};
match ProviderClient::from_config(&config).expect("explicit OpenAI config should succeed") {
ProviderClient::OpenAi(client) => {
assert!(client.base_url().contains("api.openai.com"));
assert!(!client.base_url().contains("dashscope.aliyuncs.com"));
}
other => panic!("Expected explicit OpenAI provider, got: {other:?}"),
}
}
#[test]
fn dashscope_model_uses_dashscope_config_not_openai() {
// Regression: qwen-plus was being routed to OpenAiCompatConfig::openai()
+1 -3
View File
@@ -1,5 +1,4 @@
mod client;
mod error;
mod http_client;
mod prompt_cache;
@@ -9,8 +8,7 @@ mod types;
pub use client::{
oauth_token_is_expired, read_base_url, read_xai_base_url, resolve_saved_oauth_token,
resolve_startup_auth_source, MessageStream, OAuthTokenSet, ProviderClient, ProviderConfig,
ProviderConfigKind,
resolve_startup_auth_source, MessageStream, OAuthTokenSet, ProviderClient,
};
pub use error::ApiError;
pub use http_client::{
-41
View File
@@ -205,11 +205,6 @@ pub struct RuntimeProviderConfig {
}
impl RuntimeProviderConfig {
#[must_use]
pub fn is_configured(&self) -> bool {
self.kind.is_some()
}
#[must_use]
pub fn kind(&self) -> Option<&str> {
self.kind.as_deref()
@@ -2663,42 +2658,6 @@ mod tests {
std::env::temp_dir().join(format!("runtime-config-{pid}-{nanos}-{seq}"))
}
#[test]
fn loads_persisted_provider_configuration_from_settings() {
let root = temp_dir();
let cwd = root.join("project");
let home = root.join("home").join(".claw");
fs::create_dir_all(&home).expect("home config dir");
fs::create_dir_all(&cwd).expect("project dir");
fs::write(
home.join("settings.json"),
r#"{
"provider": {
"kind": "dashscope",
"apiKey": "persisted-test-key",
"baseUrl": "https://dashscope.example/v1",
"model": "qwen-plus"
}
}"#,
)
.expect("write settings");
let loaded = ConfigLoader::new(&cwd, &home)
.load()
.expect("provider configuration should load");
let provider = loaded.provider();
assert_eq!(provider.kind(), Some("dashscope"));
assert_eq!(provider.api_key(), Some("persisted-test-key"));
assert_eq!(provider.base_url(), Some("https://dashscope.example/v1"));
assert_eq!(provider.model(), Some("qwen-plus"));
assert!(provider.is_configured());
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn rejects_non_object_settings_files() {
let root = temp_dir();
+42 -250
View File
@@ -12708,7 +12708,6 @@ fn build_runtime_with_plugin_state(
plugin_registry.initialize()?;
let policy = permission_policy(permission_mode, &feature_config, &tool_registry)
.map_err(std::io::Error::other)?;
let provider_config = provider_config_from_runtime(feature_config.provider())?;
let mut runtime = ConversationRuntime::new_with_features(
session,
AnthropicRuntimeClient::new(
@@ -12720,7 +12719,6 @@ fn build_runtime_with_plugin_state(
allowed_tools.clone(),
tool_registry.clone(),
progress_reporter,
provider_config,
)?,
CliToolExecutor::new(
allowed_tools.clone(),
@@ -12893,85 +12891,10 @@ impl<W: Write> Write for TailTrackingWriter<'_, W> {
// NOTE: Despite the historical name `AnthropicRuntimeClient`, this struct
// now holds an `ApiProviderClient` which dispatches to Anthropic, xAI,
// OpenAI, or DashScope at construction time. Explicit persisted provider
// configuration takes precedence; model-based provider detection remains
// the fallback when no provider configuration is present. The struct name
// is kept to avoid churning `BuiltRuntime` and every Deref/DerefMut site
// that references it.
fn provider_config_from_runtime(
provider: &runtime::RuntimeProviderConfig,
) -> Result<Option<api::ProviderConfig>, Box<dyn std::error::Error>> {
let Some(kind) = provider.kind() else {
return Ok(None);
};
let kind = match kind.trim().to_ascii_lowercase().as_str() {
"anthropic" => api::ProviderConfigKind::Anthropic,
"xai" => api::ProviderConfigKind::Xai,
"openai" => api::ProviderConfigKind::OpenAi,
"dashscope" => api::ProviderConfigKind::DashScope,
other => {
return Err(format!(
"invalid provider configuration: unsupported provider kind `{other}`"
)
.into());
}
};
Ok(Some(api::ProviderConfig {
kind,
model: provider.model().unwrap_or_default().to_string(),
api_key: provider.api_key().map(str::to_string),
base_url: provider.base_url().map(str::to_string),
}))
}
fn explicit_provider_kind_for_model(
model: &str,
persisted_model: &str,
) -> Option<api::ProviderConfigKind> {
let resolved_model = api::resolve_model_alias(model);
let persisted_resolved = api::resolve_model_alias(persisted_model);
if resolved_model.eq_ignore_ascii_case(&persisted_resolved) {
return None;
}
let namespace = model
.trim()
.split_once('/')
.map(|(namespace, _)| namespace.to_ascii_lowercase());
match namespace.as_deref() {
Some("anthropic") => Some(api::ProviderConfigKind::Anthropic),
Some("xai") => Some(api::ProviderConfigKind::Xai),
Some("openai") | Some("local") => Some(api::ProviderConfigKind::OpenAi),
Some("dashscope") | Some("qwen") | Some("kimi") => Some(api::ProviderConfigKind::DashScope),
_ => {
let lower = resolved_model.to_ascii_lowercase();
if lower.starts_with("qwen/")
|| lower.starts_with("qwen-")
|| lower.starts_with("kimi/")
|| lower.starts_with("kimi-")
{
Some(api::ProviderConfigKind::DashScope)
} else if lower.starts_with("grok/") || lower.starts_with("grok-") {
Some(api::ProviderConfigKind::Xai)
} else if lower.starts_with("claude") || lower.starts_with("anthropic/") {
Some(api::ProviderConfigKind::Anthropic)
} else if lower.starts_with("openai/")
|| lower.starts_with("gpt-")
|| lower.starts_with("local/")
{
Some(api::ProviderConfigKind::OpenAi)
} else {
None
}
}
}
}
// OpenAI, or DashScope at construction time based on
// `detect_provider_kind(&model)`. The struct name is kept to avoid
// churning `BuiltRuntime` and every Deref/DerefMut site that references
// it. See ROADMAP #29 for the provider-dispatch routing fix.
struct AnthropicRuntimeClient {
runtime: tokio::runtime::Runtime,
client: ApiProviderClient,
@@ -12996,65 +12919,47 @@ impl AnthropicRuntimeClient {
allowed_tools: Option<AllowedToolSet>,
tool_registry: GlobalToolRegistry,
progress_reporter: Option<InternalPromptProgressReporter>,
provider_config: Option<api::ProviderConfig>,
) -> Result<Self, Box<dyn std::error::Error>> {
// Explicit persisted provider configuration takes precedence.
// When absent, preserve the existing model-based provider detection.
// Dispatch to the correct provider at construction time.
// `ApiProviderClient` (exposed by the api crate as
// `ProviderClient`) is an enum over Anthropic / xAI / OpenAI
// variants, where xAI and OpenAI both use the OpenAI-compat
// wire format under the hood. We consult
// `detect_provider_kind(&resolved_model)` so model-name prefix
// routing (`openai/`, `gpt-`, `grok`, `qwen/`) wins over
// env-var presence.
//
// For Anthropic we build the client directly instead of going
// through `ApiProviderClient::from_model_with_anthropic_auth`
// so we can explicitly apply `api::read_base_url()` — that
// reads `ANTHROPIC_BASE_URL` and is required for the local
// mock-server test harness
// (`crates/rusty-claude-cli/tests/compact_output.rs`) to point
// claw at its fake Anthropic endpoint. We also attach a
// session-scoped prompt cache on the Anthropic path; the
// prompt cache is Anthropic-only so non-Anthropic variants
// skip it.
let resolved_model = api::resolve_model_alias(&model);
let provider_config = provider_config.map(|config| {
if let Some(kind) = explicit_provider_kind_for_model(&model, config.model.as_str()) {
api::ProviderConfig {
kind,
model: resolved_model.clone(),
..config
}
} else {
config
let client = match detect_provider_kind(&resolved_model) {
ProviderKind::Anthropic => {
let auth = resolve_cli_auth_source()?;
let inner = AnthropicClient::from_auth(auth)
.with_base_url(api::read_base_url())
.with_prompt_cache(PromptCache::new(session_id));
ApiProviderClient::Anthropic(inner)
}
});
let client = if let Some(config) = provider_config {
match config.kind {
api::ProviderConfigKind::Anthropic => {
let auth = match resolve_cli_auth_source() {
Ok(auth) => auth,
Err(_) => {
config
.api_key
.clone()
.map(AuthSource::ApiKey)
.ok_or_else(|| {
api::ApiError::missing_credentials(
"Anthropic",
&["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"],
)
})?
}
};
let mut inner = AnthropicClient::from_auth(auth);
let base_url = std::env::var("ANTHROPIC_BASE_URL")
.ok()
.filter(|value| !value.trim().is_empty())
.or_else(|| config.base_url.clone())
.unwrap_or_else(api::read_base_url);
inner = inner.with_base_url(base_url);
ApiProviderClient::Anthropic(
inner.with_prompt_cache(PromptCache::new(session_id)),
)
}
_ => ApiProviderClient::from_config(&config)?,
}
} else {
match detect_provider_kind(&resolved_model) {
ProviderKind::Anthropic => {
let auth = resolve_cli_auth_source()?;
let inner = AnthropicClient::from_auth(auth)
.with_base_url(api::read_base_url())
.with_prompt_cache(PromptCache::new(session_id));
ApiProviderClient::Anthropic(inner)
}
ProviderKind::Xai | ProviderKind::OpenAi => {
ApiProviderClient::from_model_with_anthropic_auth(&resolved_model, None)?
}
ProviderKind::Xai | ProviderKind::OpenAi => {
// The api crate's `ProviderClient::from_model_with_anthropic_auth`
// with `None` for the anthropic auth routes via
// `detect_provider_kind` and builds an
// `OpenAiCompatClient::from_env` with the matching
// `OpenAiCompatConfig` (openai / xai / dashscope).
// That reads the correct API-key env var and BASE_URL
// override internally, so this one call covers OpenAI,
// OpenRouter, xAI, DashScope, Ollama, and any other
// OpenAI-compat endpoint users configure via
// `OPENAI_BASE_URL` / `XAI_BASE_URL` / `DASHSCOPE_BASE_URL`.
ApiProviderClient::from_model_with_anthropic_auth(&resolved_model, None)?
}
};
Ok(Self {
@@ -19651,119 +19556,6 @@ UU conflicted.rs",
let _ = fs::remove_dir_all(source_root);
}
#[test]
fn explicit_model_provider_hint_overrides_persisted_provider_kind() {
let config = api::ProviderConfig {
kind: api::ProviderConfigKind::DashScope,
model: "qwen-plus".to_string(),
api_key: Some("persisted-test-key".to_string()),
base_url: None,
};
assert_eq!(
super::explicit_provider_kind_for_model("openai/gpt-5", &config.model),
Some(api::ProviderConfigKind::OpenAi)
);
assert_eq!(
super::explicit_provider_kind_for_model("qwen-plus", &config.model),
None,
"matching persisted model must keep the persisted provider kind"
);
}
#[test]
fn explicit_model_provider_hint_controls_runtime_provider_dispatch() {
let config = api::ProviderConfig {
kind: api::ProviderConfigKind::DashScope,
model: "qwen-plus".to_string(),
api_key: Some("persisted-test-key".to_string()),
base_url: None,
};
let runtime = super::AnthropicRuntimeClient::new(
"provider-resolution-explicit-model-test",
"openai/gpt-5".to_string(),
false,
false,
TerminalTail::new(),
None,
GlobalToolRegistry::builtin(),
None,
Some(config),
)
.expect("runtime client should construct from explicit model provider hint");
match runtime.client {
api::ProviderClient::OpenAi(client) => {
assert!(client.base_url().contains("api.openai.com"));
assert!(!client.base_url().contains("dashscope.aliyuncs.com"));
}
other => panic!(
"explicit OpenAI model prefix must override persisted DashScope configuration, got {other:?}"
),
}
}
#[test]
fn persisted_provider_configuration_controls_runtime_provider_dispatch() {
let config_home = temp_dir();
let workspace = temp_dir();
fs::create_dir_all(&config_home).expect("config home");
fs::create_dir_all(&workspace).expect("workspace");
fs::write(
config_home.join("settings.json"),
r#"{
"provider": {
"kind": "dashscope",
"apiKey": "persisted-test-key",
"baseUrl": "https://dashscope.example/v1",
"model": "qwen-plus"
},
"model": "qwen-plus"
}"#,
)
.expect("write provider settings");
let loader = ConfigLoader::new(&workspace, &config_home);
let runtime_config = loader.load().expect("runtime config should load");
let state = build_runtime_plugin_state_with_loader(&workspace, &loader, &runtime_config)
.expect("runtime plugin state should load");
let provider_config = super::provider_config_from_runtime(state.feature_config.provider())
.expect("provider configuration should adapt")
.expect("persisted provider configuration should be present");
assert_eq!(
provider_config.kind,
api::ProviderConfigKind::DashScope,
"persisted provider kind must survive the runtime configuration boundary"
);
let runtime = super::AnthropicRuntimeClient::new(
"provider-resolution-test",
"qwen-plus".to_string(),
false,
false,
TerminalTail::new(),
None,
GlobalToolRegistry::builtin(),
None,
Some(provider_config),
)
.expect("runtime client should construct from persisted provider configuration");
match runtime.client {
api::ProviderClient::OpenAi(_) => {}
other => panic!(
"persisted DashScope configuration must dispatch to the OpenAI-compatible client, got {other:?}"
),
}
let _ = fs::remove_dir_all(config_home);
let _ = fs::remove_dir_all(workspace);
}
#[test]
#[allow(clippy::too_many_lines)]
fn build_runtime_plugin_state_discovers_mcp_tools_and_surfaces_pending_servers() {
@@ -441,6 +441,9 @@ fn status_and_sandbox_emit_json_when_requested() {
// #831: direct resume-safe slash commands should use the same local CliAction
// JSON surfaces as their bare subcommands, not interactive_only guidance.
// Note: /sandbox status depends on kernel support for user namespaces.
// On systems with working user namespaces the status is "ok".
// On systems without user namespaces the status is "warn" (#731).
#[test]
fn direct_resume_safe_slash_commands_route_to_local_json_actions_831() {
let root = unique_temp_dir("direct-resume-safe-slash-831");
@@ -451,11 +454,11 @@ fn direct_resume_safe_slash_commands_route_to_local_json_actions_831() {
.output()
.expect("git init should launch");
for (command, expected_kind, expected_status) in [
("/version", "version", "ok"),
("/sandbox", "sandbox", "warn"),
("/diff", "diff", "ok"),
("/status", "status", "ok"),
for (command, expected_kind, expected_statuses) in [
("/version", "version", &["ok"][..]),
("/sandbox", "sandbox", &["ok", "warn"][..]),
("/diff", "diff", &["ok"][..]),
("/status", "status", &["ok"][..]),
] {
let output = run_claw(&root, &["--output-format", "json", command], &[]);
assert!(
@@ -470,9 +473,9 @@ fn direct_resume_safe_slash_commands_route_to_local_json_actions_831() {
.unwrap_or_else(|_| panic!("{command} must emit JSON (#831), got: {stdout:?}"));
assert_eq!(parsed["kind"], expected_kind, "{command} kind: {parsed}");
assert_eq!(
parsed["status"], expected_status,
"{command} status: {parsed}"
assert!(
expected_statuses.contains(&parsed["status"].as_str().unwrap_or("")),
"{command} status must be one of {expected_statuses:?}: {parsed}"
);
assert_ne!(
parsed["error_kind"], "interactive_only",