1 Commits

Author SHA1 Message Date
Yeachan-Heo
0e722fa013 auto: save WIP progress from rcc session 2026-04-01 04:01:37 +00:00
59 changed files with 1751 additions and 12770 deletions

View File

@@ -1,5 +0,0 @@
{
"permissions": {
"defaultMode": "dontAsk"
}
}

3
.gitignore vendored
View File

@@ -2,6 +2,3 @@ __pycache__/
archive/
.omx/
.clawd-agents/
# Claude Code local artifacts
.claude/settings.local.json
.claude/sessions/

View File

@@ -1,21 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Detected stack
- Languages: Rust.
- Frameworks: none detected from the supported starter markers.
## Verification
- Run Rust verification from `rust/`: `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`
- `src/` and `tests/` are both present; update both surfaces together when behavior changes.
## Repository shape
- `rust/` contains the Rust workspace and active CLI/runtime implementation.
- `src/` contains source files that should stay consistent with generated guidance and tests.
- `tests/` contains validation surfaces that should be reviewed alongside code changes.
## Working agreement
- Prefer small, reviewable changes and keep generated bootstrap files aligned with actual repo workflows.
- Keep shared defaults in `.claude.json`; reserve `.claude/settings.local.json` for machine-local overrides.
- Do not overwrite existing `CLAUDE.md` content automatically; update it intentionally when repo workflows change.

343
PARITY.md
View File

@@ -1,214 +1,253 @@
# PARITY GAP ANALYSIS
# PARITY Gap Analysis
Scope: read-only comparison between the original TypeScript source at `/home/bellman/Workspace/claude-code/src/` and the Rust port under `rust/crates/`.
Date: 2026-04-01
Method: compared feature surfaces, registries, entrypoints, and runtime plumbing only. No TypeScript source was copied.
Scope compared:
- Upstream TypeScript: `/home/bellman/Workspace/claude-code/src/`
- Rust port: `rust/crates/`
Method:
- Read-only comparison only.
- No upstream source was copied into this repo.
- This is a focused feature-gap report for `tools`, `hooks`, `plugins`, `skills`, `cli`, `assistant`, and `services`.
## Executive summary
The Rust port has a good foundation for:
- Anthropic API/OAuth basics
- local conversation/session state
- a core tool loop
- MCP stdio/bootstrap support
The Rust port has a solid core for:
- basic prompt/REPL flow
- session/runtime state
- Anthropic API/OAuth plumbing
- a compact MVP tool registry
- CLAUDE.md discovery
- a small but usable built-in tool set
- MCP config parsing/bootstrap primitives
It is **not feature-parity** with the TypeScript CLI.
But it is still materially behind the TypeScript implementation in six major areas:
1. **Tools surface area** is much smaller.
2. **Hook execution** is largely missing; Rust mostly loads hook config but does not run a TS-style PreToolUse/PostToolUse pipeline.
3. **Plugins** are effectively absent in Rust.
4. **Skills** are only partially implemented in Rust via direct `SKILL.md` loading; there is no comparable skills command/discovery/registration surface.
5. **CLI** breadth is much narrower in Rust.
6. **Assistant/tool orchestration** lacks the richer streaming concurrency, hook integration, and orchestration behavior present in TS.
7. **Services** in Rust cover API/auth/runtime basics, but many higher-level TS services are missing.
Largest gaps:
- **plugins** are effectively absent in Rust
- **hooks** are parsed but not executed in Rust
- **CLI breadth** is much narrower in Rust
- **skills** are local-file only in Rust, without the TS registry/bundled pipeline
- **assistant orchestration** lacks TS hook-aware orchestration and remote/structured transports
- **services** beyond core API/OAuth/MCP are mostly missing in Rust
## Critical bug status on this branch
Targeted critical items requested by the user:
- **Prompt mode tools enabled**: fixed in `rust/crates/rusty-claude-cli/src/main.rs:75-82`
- **Default permission mode = danger-full-access**: fixed in `rust/crates/rusty-claude-cli/src/args.rs:12-16`, `rust/crates/rusty-claude-cli/src/main.rs:348-353`, and starter config `rust/crates/rusty-claude-cli/src/init.rs:4-9`
- **Tool input `{}` prefix bug**: fixed/guarded in streaming vs non-stream paths at `rust/crates/rusty-claude-cli/src/main.rs:2211-2256`
- **Unlimited max_iterations**: already present at `rust/crates/runtime/src/conversation.rs:143-148` with `usize::MAX` initialization at `rust/crates/runtime/src/conversation.rs:119`
Build/test/manual verification is tracked separately below and must pass before the branch is considered done.
---
## tools/
## 1) tools/
### TS exists
Evidence:
- `src/tools/` contains broad tool families including `AgentTool`, `AskUserQuestionTool`, `BashTool`, `ConfigTool`, `FileReadTool`, `FileWriteTool`, `GlobTool`, `GrepTool`, `LSPTool`, `ListMcpResourcesTool`, `MCPTool`, `McpAuthTool`, `ReadMcpResourceTool`, `RemoteTriggerTool`, `ScheduleCronTool`, `SkillTool`, `Task*`, `Team*`, `TodoWriteTool`, `ToolSearchTool`, `WebFetchTool`, `WebSearchTool`.
- Tool execution/orchestration is split across `src/services/tools/StreamingToolExecutor.ts`, `src/services/tools/toolExecution.ts`, `src/services/tools/toolHooks.ts`, and `src/services/tools/toolOrchestration.ts`.
### Upstream TS has
- Large per-tool module surface under `src/tools/`, including agent/task tools, AskUserQuestion, MCP tools, plan/worktree tools, REPL, schedule/task tools, synthetic output, brief/upload, and more.
- Evidence:
- `src/tools/AgentTool/AgentTool.tsx`
- `src/tools/AskUserQuestionTool/AskUserQuestionTool.tsx`
- `src/tools/ListMcpResourcesTool/ListMcpResourcesTool.ts`
- `src/tools/ReadMcpResourceTool/ReadMcpResourceTool.ts`
- `src/tools/EnterPlanModeTool/EnterPlanModeTool.ts`
- `src/tools/ExitPlanModeTool/ExitPlanModeV2Tool.ts`
- `src/tools/EnterWorktreeTool/EnterWorktreeTool.ts`
- `src/tools/ExitWorktreeTool/ExitWorktreeTool.ts`
- `src/tools/RemoteTriggerTool/RemoteTriggerTool.ts`
- `src/tools/ScheduleCronTool/*`
- `src/tools/TaskCreateTool/*`, `TaskGetTool/*`, `TaskListTool/*`, `TaskOutputTool/*`
### Rust exists
Evidence:
- Tool registry is centralized in `rust/crates/tools/src/lib.rs` via `mvp_tool_specs()`.
- Current built-ins include shell/file/search/web/todo/skill/agent/config/notebook/repl/powershell primitives.
- Runtime execution is wired through `rust/crates/tools/src/lib.rs` and `rust/crates/runtime/src/conversation.rs`.
### Rust currently has
- A single MVP registry in `rust/crates/tools/src/lib.rs:53-371`.
- Implemented tools include `bash`, `read_file`, `write_file`, `edit_file`, `glob_search`, `grep_search`, `WebFetch`, `WebSearch`, `TodoWrite`, `Skill`, `Agent`, `ToolSearch`, `NotebookEdit`, `Sleep`, `SendUserMessage`, `Config`, `StructuredOutput`, `REPL`, `PowerShell`.
### Missing or broken in Rust
- No Rust equivalents for major TS tools such as `AskUserQuestionTool`, `LSPTool`, `ListMcpResourcesTool`, `MCPTool`, `McpAuthTool`, `ReadMcpResourceTool`, `RemoteTriggerTool`, `ScheduleCronTool`, `Task*`, `Team*`, and several workflow/system tools.
- Rust tool surface is still explicitly an MVP registry, not a parity registry.
- Rust lacks TSs layered tool orchestration split.
**Status:** partial core only.
- **Missing large chunks of the upstream tool catalog**: I did not find Rust equivalents for AskUserQuestion, MCP resource listing/reading tools, plan/worktree entry/exit tools, task management tools, remote trigger, synthetic output, or schedule/cron tools.
- **Tool decomposition is much coarser**: TS isolates tool-specific validation/security/UI behavior per tool module; Rust centralizes almost everything in one file (`rust/crates/tools/src/lib.rs`).
- **Likely parity impact**: lower fidelity tool prompting, weaker per-tool behavior specialization, and fewer native tool choices exposed to the model.
---
## hooks/
## 2) hooks/
### TS exists
Evidence:
- Hook command surface under `src/commands/hooks/`.
- Runtime hook machinery in `src/services/tools/toolHooks.ts` and `src/services/tools/toolExecution.ts`.
- TS supports `PreToolUse`, `PostToolUse`, and broader hook-driven behaviors configured through settings and documented in `src/skills/bundled/updateConfig.ts`.
### Upstream TS has
- A full permission and tool-hook system with **PermissionRequest**, **PreToolUse**, **PostToolUse**, and failure/cancellation handling.
- Evidence:
- `src/hooks/toolPermission/PermissionContext.ts:25,222`
- `src/hooks/toolPermission/handlers/coordinatorHandler.ts:32-38`
- `src/hooks/toolPermission/handlers/interactiveHandler.ts:412-429`
- `src/services/tools/toolHooks.ts:39,435`
- `src/services/tools/toolExecution.ts:800,1074,1483`
- `src/commands/hooks/index.ts:5-8`
### Rust exists
Evidence:
- Hook config is parsed and merged in `rust/crates/runtime/src/config.rs`.
- Hook config can be inspected via Rust config reporting in `rust/crates/commands/src/lib.rs` and `rust/crates/rusty-claude-cli/src/main.rs`.
- Prompt guidance mentions hooks in `rust/crates/runtime/src/prompt.rs`.
### Rust currently has
- Hook data is **loaded/merged from config** and visible in reports:
- `rust/crates/runtime/src/config.rs:786-797,829-838`
- `rust/crates/rusty-claude-cli/src/main.rs:1665-1669`
- The system prompt acknowledges user-configured hooks:
- `rust/crates/runtime/src/prompt.rs:452-459`
### Missing or broken in Rust
- No actual hook execution pipeline in `rust/crates/runtime/src/conversation.rs`.
- No PreToolUse/PostToolUse mutation/deny/rewrite/result-hook behavior.
- No Rust `/hooks` parity command.
**Status:** config-only; runtime behavior missing.
- **No comparable hook execution pipeline found** in the Rust runtime conversation/tool execution path.
- `rust/crates/runtime/src/conversation.rs:151-208` goes straight from assistant tool_use -> permission check -> tool execute -> tool_result, without TS-style PreToolUse/PostToolUse processing.
- I did **not** find Rust counterparts to TS files like `toolHooks.ts` or `PermissionContext.ts` that execute hook callbacks and alter/block tool behavior.
- Result: Rust appears to support **hook configuration visibility**, but not full **hook behavior parity**.
---
## plugins/
## 3) plugins/
### TS exists
Evidence:
- Built-in plugin scaffolding in `src/plugins/builtinPlugins.ts` and `src/plugins/bundled/index.ts`.
- Plugin lifecycle/services in `src/services/plugins/PluginInstallationManager.ts` and `src/services/plugins/pluginOperations.ts`.
- CLI/plugin command surface under `src/commands/plugin/` and `src/commands/reload-plugins/`.
### Upstream TS has
- Built-in and bundled plugin registration plus CLI/service support for validate/list/install/uninstall/enable/disable/update flows.
- Evidence:
- `src/plugins/builtinPlugins.ts:7-17,149-150`
- `src/plugins/bundled/index.ts:7-22`
- `src/cli/handlers/plugins.ts:51,101,157,668`
- `src/services/plugins/pluginOperations.ts:16,54,306,435,713`
- `src/services/plugins/pluginCliCommands.ts:7,36`
### Rust exists
Evidence:
- No dedicated plugin subsystem appears under `rust/crates/`.
- Repo-wide Rust references to plugins are effectively absent beyond text/help mentions.
### Rust currently has
- I did **not** find a dedicated plugin crate/module/handler under `rust/crates/`.
- The Rust crate layout is only `api`, `commands`, `compat-harness`, `runtime`, `rusty-claude-cli`, and `tools`.
### Missing or broken in Rust
- No plugin loader.
- No marketplace install/update/enable/disable flow.
- No `/plugin` or `/reload-plugins` parity.
- No plugin-provided hook/tool/command/MCP extension path.
**Status:** missing.
- **Plugin loading/install/update/validation is missing.**
- **No plugin CLI surface found** comparable to `claude plugin ...`.
- **No plugin runtime refresh/reconciliation layer found**.
- This is one of the largest parity gaps.
---
## skills/ and CLAUDE.md discovery
## 4) skills/
### TS exists
Evidence:
- Skill loading/registry pipeline in `src/skills/loadSkillsDir.ts`, `src/skills/bundledSkills.ts`, and `src/skills/mcpSkillBuilders.ts`.
- Bundled skills under `src/skills/bundled/`.
- Skills command surface under `src/commands/skills/`.
### Upstream TS has
- Bundled skills registry and loader integration, plus a `skills` command.
- Evidence:
- `src/commands/skills/index.ts:6`
- `src/skills/bundledSkills.ts:44,99,107,114`
- `src/skills/loadSkillsDir.ts:65`
- `src/skills/mcpSkillBuilders.ts:4-21,40`
### Rust exists
Evidence:
- `Skill` tool in `rust/crates/tools/src/lib.rs` resolves and reads local `SKILL.md` files.
- CLAUDE.md discovery is implemented in `rust/crates/runtime/src/prompt.rs`.
- Rust supports `/memory` and `/init` via `rust/crates/commands/src/lib.rs` and `rust/crates/rusty-claude-cli/src/main.rs`.
### Rust currently has
- A `Skill` tool that loads local `SKILL.md` files directly:
- `rust/crates/tools/src/lib.rs:1244-1255`
- `rust/crates/tools/src/lib.rs:1288-1323`
- CLAUDE.md / instruction discovery exists in runtime prompt loading:
- `rust/crates/runtime/src/prompt.rs:203-208`
### Missing or broken in Rust
- No bundled skill registry equivalent.
- No `/skills` command.
- No MCP skill-builder pipeline.
- No TS-style live skill discovery/reload/change handling.
- No comparable session-memory / team-memory integration around skills.
**Status:** basic local skill loading only.
- **No Rust `/skills` slash command** in `rust/crates/commands/src/lib.rs:41-166`.
- **No visible bundled-skill registry equivalent** to TS `bundledSkills.ts` / `loadSkillsDir.ts` / `mcpSkillBuilders.ts`.
- Current Rust skill support is closer to **direct file loading** than full upstream **skill discovery/registration/command integration**.
---
## cli/
## 5) cli/
### TS exists
Evidence:
- Large command surface under `src/commands/` including `agents`, `hooks`, `mcp`, `memory`, `model`, `permissions`, `plan`, `plugin`, `resume`, `review`, `skills`, `tasks`, and many more.
- Structured/remote transport stack in `src/cli/structuredIO.ts`, `src/cli/remoteIO.ts`, and `src/cli/transports/*`.
- CLI handler split in `src/cli/handlers/*`.
### Upstream TS has
- Broad CLI handler and transport surface.
- Evidence:
- `src/cli/handlers/agents.ts:2-32`
- `src/cli/handlers/auth.ts`
- `src/cli/handlers/autoMode.ts:24,35,73`
- `src/cli/handlers/plugins.ts:2-3,101,157,668`
- `src/cli/remoteIO.ts:25-35,118-127`
- `src/cli/transports/SSETransport.ts`
- `src/cli/transports/WebSocketTransport.ts`
- `src/cli/transports/HybridTransport.ts`
- `src/cli/transports/SerialBatchEventUploader.ts`
- `src/cli/transports/WorkerStateUploader.ts`
### Rust exists
Evidence:
- Shared slash command registry in `rust/crates/commands/src/lib.rs`.
- Rust slash commands currently cover `help`, `status`, `compact`, `model`, `permissions`, `clear`, `cost`, `resume`, `config`, `memory`, `init`, `diff`, `version`, `export`, `session`.
- Main CLI/repl/prompt handling lives in `rust/crates/rusty-claude-cli/src/main.rs`.
### Rust currently has
- Minimal top-level subcommands in `rust/crates/rusty-claude-cli/src/args.rs:29-39` and `rust/crates/rusty-claude-cli/src/main.rs:67-90,242-261`.
- Slash command surface is 15 commands total in `rust/crates/commands/src/lib.rs:41-166,389`.
### Missing or broken in Rust
- Missing major TS command families: `/agents`, `/hooks`, `/mcp`, `/plugin`, `/skills`, `/plan`, `/review`, `/tasks`, and many others.
- No Rust equivalent to TS structured IO / remote transport layers.
- No TS-style handler decomposition for auth/plugins/MCP/agents.
- JSON prompt mode is improved on this branch, but still not clean transport parity: empirical verification shows tool-capable JSON output can emit human-readable tool-result lines before the final JSON object.
**Status:** functional local CLI core, much narrower than TS.
- **Missing major CLI subcommand families**: agents, plugins, mcp management, auto-mode tooling, and many other TS commands.
- **Missing remote/transport stack parity**: I did not find Rust equivalents to TS remote structured IO / SSE / websocket / CCR transport layers.
- **Slash command breadth is much narrower** than TS command inventory under `src/commands/`.
- **Prompt-mode parity bug** was present and is now fixed for this branchs prompt path.
---
## assistant/ (agentic loop, streaming, tool calling)
## 6) assistant/
### TS exists
Evidence:
- Assistant/session surface at `src/assistant/sessionHistory.ts`.
- Tool orchestration in `src/services/tools/StreamingToolExecutor.ts`, `src/services/tools/toolExecution.ts`, `src/services/tools/toolOrchestration.ts`.
- Remote/structured streaming layers in `src/cli/structuredIO.ts` and `src/cli/remoteIO.ts`.
### Upstream TS has
- Rich tool orchestration and streaming execution behavior, including concurrency/cancellation/fallback logic.
- Evidence:
- `src/services/tools/StreamingToolExecutor.ts:35-214`
- `src/services/tools/toolExecution.ts:455-569,800-918,1483`
- `src/services/tools/toolOrchestration.ts:134-167`
- `src/assistant/sessionHistory.ts`
### Rust exists
Evidence:
- Core loop in `rust/crates/runtime/src/conversation.rs`.
- Stream/tool event translation in `rust/crates/rusty-claude-cli/src/main.rs`.
- Session persistence in `rust/crates/runtime/src/session.rs`.
### Rust currently has
- A straightforward agentic loop in `rust/crates/runtime/src/conversation.rs:130-214`.
- Streaming API adaptation in `rust/crates/rusty-claude-cli/src/main.rs:1998-2058`.
- Tool-use block assembly and non-stream fallback handling in `rust/crates/rusty-claude-cli/src/main.rs:2211-2256`.
### Missing or broken in Rust
- No TS-style hook-aware orchestration layer.
- No TS structured/remote assistant transport stack.
- No richer TS assistant/session-history/background-task integration.
- JSON output path is no longer single-turn only on this branch, but output cleanliness still lags TS transport expectations.
**Status:** strong core loop, missing orchestration layers.
- **No TS-style streaming tool executor** with sibling cancellation / fallback discard semantics.
- **No integrated PreToolUse/PostToolUse hook participation** in assistant execution.
- **No comparable orchestration layer for richer tool event semantics** found.
- Historically broken parity items in prompt mode were:
- prompt tool enablement (`main.rs:75-82`) — now fixed on this branch
- streamed `{}` tool-input prefix behavior (`main.rs:2211-2256`) — now fixed/guarded on this branch
---
## services/ (API client, auth, models, MCP)
## 7) services/
### TS exists
Evidence:
- API services under `src/services/api/*`.
- OAuth services under `src/services/oauth/*`.
- MCP services under `src/services/mcp/*`.
- Additional service layers for analytics, prompt suggestion, session memory, plugin operations, settings sync, policy limits, team memory sync, notifier, voice, and more under `src/services/*`.
### Upstream TS has
- Very broad service layer, including API, analytics, compact/session memory, prompt suggestions, plugin services, MCP service helpers, LSP management, policy limits, team memory sync, notifier/tips, etc.
- Evidence:
- `src/services/api/client.ts`, `src/services/api/claude.ts`, `src/services/api/withRetry.ts`
- `src/services/oauth/client.ts`, `src/services/oauth/index.ts`
- `src/services/mcp/*`
- `src/services/plugins/*`
- `src/services/lsp/*`
- `src/services/compact/*`
- `src/services/SessionMemory/*`
- `src/services/PromptSuggestion/*`
- `src/services/analytics/*`
- `src/services/teamMemorySync/*`
### Rust exists
Evidence:
- Core Anthropic API client in `rust/crates/api/src/{client,error,sse,types}.rs`.
- OAuth support in `rust/crates/runtime/src/oauth.rs`.
- MCP config/bootstrap/client support in `rust/crates/runtime/src/{config,mcp,mcp_client,mcp_stdio}.rs`.
- Usage accounting in `rust/crates/runtime/src/usage.rs`.
- Remote upstream-proxy support in `rust/crates/runtime/src/remote.rs`.
### Rust currently has
- Core service equivalents for:
- API client + SSE: `rust/crates/api/src/client.rs`, `rust/crates/api/src/sse.rs`, `rust/crates/api/src/types.rs`
- OAuth: `rust/crates/runtime/src/oauth.rs`
- MCP config/bootstrap primitives: `rust/crates/runtime/src/mcp.rs`, `rust/crates/runtime/src/mcp_client.rs`, `rust/crates/runtime/src/mcp_stdio.rs`, `rust/crates/runtime/src/config.rs`
- prompt/context loading: `rust/crates/runtime/src/prompt.rs`
- session compaction/runtime usage: `rust/crates/runtime/src/compact.rs`, `rust/crates/runtime/src/usage.rs`
### Missing or broken in Rust
- Most TS service ecosystem beyond core messaging/auth/MCP is absent.
- No TS-equivalent plugin service layer.
- No TS-equivalent analytics/settings-sync/policy-limit/team-memory subsystems.
- No TS-style MCP connection-manager/UI layer.
- Model/provider ergonomics remain thinner than TS.
**Status:** core foundation exists; broader service ecosystem missing.
- **Missing many higher-level services**: analytics, plugin services, prompt suggestion, team memory sync, richer LSP service management, notifier/tips ecosystem, and much of the surrounding product/service scaffolding.
- Rust is closer to a **runtime/API core** than a full parity implementation of the TS service layer.
---
## Critical bug status in this worktree
## Highest-priority parity gaps after the critical bug fixes
### Fixed
- **Prompt mode tools enabled**
- `rust/crates/rusty-claude-cli/src/main.rs` now constructs prompt mode with `LiveCli::new(model, true, ...)`.
- **Default permission mode = DangerFullAccess**
- Runtime default now resolves to `DangerFullAccess` in `rust/crates/rusty-claude-cli/src/main.rs`.
- Clap default also uses `DangerFullAccess` in `rust/crates/rusty-claude-cli/src/args.rs`.
- Init template writes `dontAsk` in `rust/crates/rusty-claude-cli/src/init.rs`.
- **Streaming `{}` tool-input prefix bug**
- `rust/crates/rusty-claude-cli/src/main.rs` now strips the initial empty object only for streaming tool input, while preserving legitimate `{}` in non-stream responses.
- **Unlimited max_iterations**
- Verified at `rust/crates/runtime/src/conversation.rs` with `usize::MAX`.
1. **Hook execution parity**
- Config exists, execution does not appear to.
- This affects permissions, tool interception, and continuation behavior.
### Remaining notable parity issue
- **JSON prompt output cleanliness**
- Tool-capable JSON mode now loops, but empirical verification still shows pre-JSON human-readable tool-result output when tools fire.
2. **Plugin system parity**
- Entire install/load/manage surface appears missing.
3. **CLI breadth parity**
- Missing many upstream command families and remote transports.
4. **Tool surface parity**
- MVP tool registry exists, but a large number of upstream tool types are absent.
5. **Assistant orchestration parity**
- Core loop exists, but advanced streaming/execution behaviors from TS are missing.
## Recommended next work after current critical fixes
1. Finish build/test/manual verification of the critical bug patch.
2. Implement **hook execution** before broadening the tool surface further.
3. Decide whether **plugins** are in-scope for parity; if yes, this likely needs dedicated design work, not a small patch.
4. Expand the CLI/tool matrix deliberately rather than adding one-off commands without shared orchestration support.

View File

@@ -1 +0,0 @@
{"messages":[{"blocks":[{"text":"hello use bash tool for testing","type":"text"}],"role":"user"},{"blocks":[{"text":"\n\nHello! I'm ready to help. Let me run a quick bash command to confirm everything is working:","type":"text"},{"id":"toolu_01EuTzVfUK7iPRBvjZAovzfV","input":"{\"command\": \"echo \\\"Hello! Bash tool is working. 🎉\\\" && date && pwd\"}","name":"bash","type":"tool_use"}],"role":"assistant","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":4277,"output_tokens":92}},{"blocks":[{"is_error":false,"output":"{\n \"stdout\": \"Hello! Bash tool is working. 🎉\\nWed Apr 1 02:25:46 AM UTC 2026\\n/home/bellman/Workspace/clawd-code/rust\\n\",\n \"stderr\": \"\",\n \"rawOutputPath\": null,\n \"interrupted\": false,\n \"isImage\": null,\n \"backgroundTaskId\": null,\n \"backgroundedByUser\": null,\n \"assistantAutoBackgrounded\": null,\n \"dangerouslyDisableSandbox\": null,\n \"returnCodeInterpretation\": null,\n \"noOutputExpected\": false,\n \"structuredContent\": null,\n \"persistedOutputPath\": null,\n \"persistedOutputSize\": null,\n \"sandboxStatus\": {\n \"enabled\": true,\n \"requested\": {\n \"enabled\": true,\n \"namespace_restrictions\": true,\n \"network_isolation\": false,\n \"filesystem_mode\": \"workspace-only\",\n \"allowed_mounts\": []\n },\n \"supported\": true,\n \"active\": true,\n \"namespace_supported\": true,\n \"namespace_active\": true,\n \"network_supported\": true,\n \"network_active\": false,\n \"filesystem_mode\": \"workspace-only\",\n \"filesystem_active\": true,\n \"allowed_mounts\": [],\n \"in_container\": false,\n \"container_markers\": [],\n \"fallback_reason\": null\n }\n}","tool_name":"bash","tool_use_id":"toolu_01EuTzVfUK7iPRBvjZAovzfV","type":"tool_result"}],"role":"tool"},{"blocks":[{"text":"Bash tool is working perfectly! ✅\n\nHere's a quick summary:\n- **Status**: Operational 🎉\n- **Date**: Wed Apr 1, 2026\n- **Working directory**: `/home/bellman/Workspace/clawd-code/rust`\n- **Sandbox**: Enabled and active\n\nHow can I help you today?","type":"text"}],"role":"assistant","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":4746,"output_tokens":84}}],"version":1}

View File

@@ -1 +0,0 @@
{"messages":[{"blocks":[{"text":"who are you?","type":"text"}],"role":"user"},{"blocks":[{"text":"I'm **Claude**, an AI assistant built by Anthropic, currently running as a **Claude Code agent** (Claude Opus 4.6). I'm set up in your workspace at `/home/bellman/Workspace/clawd-code/rust` with the **oh-my-claudecode (OMC)** orchestration layer active.\n\nHere's what I can help you with:\n\n- **Software engineering tasks** — reading, writing, and refactoring code\n- **Debugging** — diagnosing issues, running tests, tracing errors\n- **Research & planning** — exploring codebases, understanding architecture, planning changes\n- **Shell commands** — building, testing, running tools in your environment\n- **Multi-agent workflows** — delegating specialized work (reviews, deep analysis, etc.) via OMC\n\nI have access to your local filesystem, can execute shell commands, search the web, and coordinate specialized sub-agents for complex tasks.\n\nWhat can I help you with today?","type":"text"}],"role":"assistant","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":4292,"output_tokens":220}}],"version":1}

View File

@@ -1 +0,0 @@
{"messages":[],"version":1}

View File

@@ -1 +0,0 @@
{"messages":[],"version":1}

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
{"messages":[{"blocks":[{"text":"clear","type":"text"}],"role":"user"},{"blocks":[{"text":"\n\nI've cleared the conversation. How can I help you today?","type":"text"}],"role":"assistant","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":4272,"output_tokens":17}}],"version":1}

View File

@@ -1 +0,0 @@
{"messages":[{"blocks":[{"text":"exit","type":"text"}],"role":"user"},{"blocks":[{"text":"\n\nGoodbye! 👋","type":"text"}],"role":"assistant","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":4272,"output_tokens":10}}],"version":1}

View File

@@ -1 +0,0 @@
{"messages":[],"version":1}

View File

@@ -1,27 +0,0 @@
[
{
"content": "Architecture & dependency analysis",
"activeForm": "Complete",
"status": "completed"
},
{
"content": "Runtime crate deep analysis",
"activeForm": "Complete",
"status": "completed"
},
{
"content": "CLI & Tools analysis",
"activeForm": "Complete",
"status": "completed"
},
{
"content": "Code quality verification",
"activeForm": "Complete",
"status": "completed"
},
{
"content": "Synthesize findings into unified report",
"activeForm": "Writing report",
"status": "in_progress"
}
]

View File

@@ -1,221 +0,0 @@
# TUI Enhancement Plan — Claw Code (`rusty-claude-cli`)
## Executive Summary
This plan covers a comprehensive analysis of the current terminal user interface and proposes phased enhancements that will transform the existing REPL/prompt CLI into a polished, modern TUI experience — while preserving the existing clean architecture and test coverage.
---
## 1. Current Architecture Analysis
### Crate Map
| Crate | Purpose | Lines | TUI Relevance |
|---|---|---|---|
| `rusty-claude-cli` | Main binary: REPL loop, arg parsing, rendering, API bridge | ~3,600 | **Primary TUI surface** |
| `runtime` | Session, conversation loop, config, permissions, compaction | ~5,300 | Provides data/state |
| `api` | Anthropic HTTP client + SSE streaming | ~1,500 | Provides stream events |
| `commands` | Slash command metadata/parsing/help | ~470 | Drives command dispatch |
| `tools` | 18 built-in tool implementations | ~3,500 | Tool execution display |
### Current TUI Components
| Component | File | What It Does Today | Quality |
|---|---|---|---|
| **Input** | `input.rs` (269 lines) | `rustyline`-based line editor with slash-command tab completion, Shift+Enter newline, history | ✅ Solid |
| **Rendering** | `render.rs` (641 lines) | Markdown→terminal rendering (headings, lists, tables, code blocks with syntect highlighting, blockquotes), spinner widget | ✅ Good |
| **App/REPL loop** | `main.rs` (3,159 lines) | The monolithic `LiveCli` struct: REPL loop, all slash command handlers, streaming output, tool call display, permission prompting, session management | ⚠️ Monolithic |
| **Alt App** | `app.rs` (398 lines) | An earlier `CliApp` prototype with `ConversationClient`, stream event handling, `TerminalRenderer`, output format support | ⚠️ Appears unused/legacy |
### Key Dependencies
- **crossterm 0.28** — terminal control (cursor, colors, clear)
- **pulldown-cmark 0.13** — Markdown parsing
- **syntect 5** — syntax highlighting
- **rustyline 15** — line editing with completion
- **serde_json** — tool I/O formatting
### Strengths
1. **Clean rendering pipeline**: Markdown rendering is well-structured with state tracking, table rendering, code highlighting
2. **Rich tool display**: Tool calls get box-drawing borders (`╭─ name ─╮`), results show ✓/✗ icons
3. **Comprehensive slash commands**: 15 commands covering model switching, permissions, sessions, config, diff, export
4. **Session management**: Full persistence, resume, list, switch, compaction
5. **Permission prompting**: Interactive Y/N approval for restricted tool calls
6. **Thorough tests**: Every formatting function, every parse path has unit tests
### Weaknesses & Gaps
1. **`main.rs` is a 3,159-line monolith** — all REPL logic, formatting, API bridging, session management, and tests in one file
2. **No alternate-screen / full-screen layout** — everything is inline scrolling output
3. **No progress bars** — only a single braille spinner; no indication of streaming progress or token counts during generation
4. **No visual diff rendering**`/diff` just dumps raw git diff text
5. **No syntax highlighting in streamed output** — markdown rendering only applies to tool results, not to the main assistant response stream
6. **No status bar / HUD** — model, tokens, session info not visible during interaction
7. **No image/attachment preview**`SendUserMessage` resolves attachments but never displays them
8. **Streaming is char-by-char with artificial delay**`stream_markdown` sleeps 8ms per whitespace-delimited chunk
9. **No color theme customization** — hardcoded `ColorTheme::default()`
10. **No resize handling** — no terminal size awareness for wrapping, truncation, or layout
11. **Dual app structs**`app.rs` has a separate `CliApp` that duplicates `LiveCli` from `main.rs`
12. **No pager for long outputs**`/status`, `/config`, `/memory` can overflow the viewport
13. **Tool results not collapsible** — large bash outputs flood the screen
14. **No thinking/reasoning indicator** — when the model is in "thinking" mode, no visual distinction
15. **No auto-complete for tool arguments** — only slash command names complete
---
## 2. Enhancement Plan
### Phase 0: Structural Cleanup (Foundation)
**Goal**: Break the monolith, remove dead code, establish the module structure for TUI work.
| Task | Description | Effort |
|---|---|---|
| 0.1 | **Extract `LiveCli` into `app.rs`** — Move the entire `LiveCli` struct, its impl, and helpers (`format_*`, `render_*`, session management) out of `main.rs` into focused modules: `app.rs` (core), `format.rs` (report formatting), `session_manager.rs` (session CRUD) | M |
| 0.2 | **Remove or merge the legacy `CliApp`** — The existing `app.rs` has an unused `CliApp` with its own `ConversationClient`-based rendering. Either delete it or merge its unique features (stream event handler pattern) into the active `LiveCli` | S |
| 0.3 | **Extract `main.rs` arg parsing** — The current `parse_args()` is a hand-rolled parser that duplicates the clap-based `args.rs`. Consolidate on the hand-rolled parser (it's more feature-complete) and move it to `args.rs`, or adopt clap fully | S |
| 0.4 | **Create a `tui/` module** — Introduce `crates/rusty-claude-cli/src/tui/mod.rs` as the namespace for all new TUI components: `status_bar.rs`, `layout.rs`, `tool_panel.rs`, etc. | S |
### Phase 1: Status Bar & Live HUD
**Goal**: Persistent information display during interaction.
| Task | Description | Effort |
|---|---|---|
| 1.1 | **Terminal-size-aware status line** — Use `crossterm::terminal::size()` to render a bottom-pinned status bar showing: model name, permission mode, session ID, cumulative token count, estimated cost | M |
| 1.2 | **Live token counter** — Update the status bar in real-time as `AssistantEvent::Usage` and `AssistantEvent::TextDelta` events arrive during streaming | M |
| 1.3 | **Turn duration timer** — Show elapsed time for the current turn (the `showTurnDuration` config already exists in Config tool but isn't wired up) | S |
| 1.4 | **Git branch indicator** — Display the current git branch in the status bar (already parsed via `parse_git_status_metadata`) | S |
### Phase 2: Enhanced Streaming Output
**Goal**: Make the main response stream visually rich and responsive.
| Task | Description | Effort |
|---|---|---|
| 2.1 | **Live markdown rendering** — Instead of raw text streaming, buffer text deltas and incrementally render Markdown as it arrives (heading detection, bold/italic, inline code). The existing `TerminalRenderer::render_markdown` can be adapted for incremental use | L |
| 2.2 | **Thinking indicator** — When extended thinking/reasoning is active, show a distinct animated indicator (e.g., `🧠 Reasoning...` with pulsing dots or a different spinner) instead of the generic `🦀 Thinking...` | S |
| 2.3 | **Streaming progress bar** — Add an optional horizontal progress indicator below the spinner showing approximate completion (based on max_tokens vs. output_tokens so far) | M |
| 2.4 | **Remove artificial stream delay** — The current `stream_markdown` sleeps 8ms per chunk. For tool results this is fine, but for the main response stream it should be immediate or configurable | S |
### Phase 3: Tool Call Visualization
**Goal**: Make tool execution legible and navigable.
| Task | Description | Effort |
|---|---|---|
| 3.1 | **Collapsible tool output** — For tool results longer than N lines (configurable, default 15), show a summary with `[+] Expand` hint; pressing a key reveals the full output. Initially implement as truncation with a "full output saved to file" fallback | M |
| 3.2 | **Syntax-highlighted tool results** — When tool results contain code (detected by tool name — `bash` stdout, `read_file` content, `REPL` output), apply syntect highlighting rather than rendering as plain text | M |
| 3.3 | **Tool call timeline** — For multi-tool turns, show a compact summary: `🔧 bash → ✓ | read_file → ✓ | edit_file → ✓ (3 tools, 1.2s)` after all tool calls complete | S |
| 3.4 | **Diff-aware edit_file display** — When `edit_file` succeeds, show a colored unified diff of the change instead of just `✓ edit_file: path` | M |
| 3.5 | **Permission prompt enhancement** — Style the approval prompt with box drawing, color the tool name, show a one-line summary of what the tool will do | S |
### Phase 4: Enhanced Slash Commands & Navigation
**Goal**: Improve information display and add missing features.
| Task | Description | Effort |
|---|---|---|
| 4.1 | **Colored `/diff` output** — Parse the git diff and render it with red/green coloring for removals/additions, similar to `delta` or `diff-so-fancy` | M |
| 4.2 | **Pager for long outputs** — When `/status`, `/config`, `/memory`, or `/diff` produce output longer than the terminal height, pipe through an internal pager (scroll with j/k/q) or external `$PAGER` | M |
| 4.3 | **`/search` command** — Add a new command to search conversation history by keyword | M |
| 4.4 | **`/undo` command** — Undo the last file edit by restoring from the `originalFile` data in `write_file`/`edit_file` tool results | M |
| 4.5 | **Interactive session picker** — Replace the text-based `/session list` with an interactive fuzzy-filterable list (up/down arrows to select, enter to switch) | L |
| 4.6 | **Tab completion for tool arguments** — Extend `SlashCommandHelper` to complete file paths after `/export`, model names after `/model`, session IDs after `/session switch` | M |
### Phase 5: Color Themes & Configuration
**Goal**: User-customizable visual appearance.
| Task | Description | Effort |
|---|---|---|
| 5.1 | **Named color themes** — Add `dark` (current default), `light`, `solarized`, `catppuccin` themes. Wire to the existing `Config` tool's `theme` setting | M |
| 5.2 | **ANSI-256 / truecolor detection** — Detect terminal capabilities and fall back gracefully (no colors → 16 colors → 256 → truecolor) | M |
| 5.3 | **Configurable spinner style** — Allow choosing between braille dots, bar, moon phases, etc. | S |
| 5.4 | **Banner customization** — Make the ASCII art banner optional or configurable via settings | S |
### Phase 6: Full-Screen TUI Mode (Stretch)
**Goal**: Optional alternate-screen layout for power users.
| Task | Description | Effort |
|---|---|---|
| 6.1 | **Add `ratatui` dependency** — Introduce `ratatui` (terminal UI framework) as an optional dependency for the full-screen mode | S |
| 6.2 | **Split-pane layout** — Top pane: conversation with scrollback; Bottom pane: input area; Right sidebar (optional): tool status/todo list | XL |
| 6.3 | **Scrollable conversation view** — Navigate past messages with PgUp/PgDn, search within conversation | L |
| 6.4 | **Keyboard shortcuts panel** — Show `?` help overlay with all keybindings | M |
| 6.5 | **Mouse support** — Click to expand tool results, scroll conversation, select text for copy | L |
---
## 3. Priority Recommendation
### Immediate (High Impact, Moderate Effort)
1. **Phase 0** — Essential cleanup. The 3,159-line `main.rs` is the #1 maintenance risk and blocks clean TUI additions.
2. **Phase 1.11.2** — Status bar with live tokens. Highest-impact UX win: users constantly want to know token usage.
3. **Phase 2.4** — Remove artificial delay. Low effort, immediately noticeable improvement.
4. **Phase 3.1** — Collapsible tool output. Large bash outputs currently wreck readability.
### Near-Term (Next Sprint)
5. **Phase 2.1** — Live markdown rendering. Makes the core interaction feel polished.
6. **Phase 3.2** — Syntax-highlighted tool results.
7. **Phase 3.4** — Diff-aware edit display.
8. **Phase 4.1** — Colored diff for `/diff`.
### Longer-Term
9. **Phase 5** — Color themes (user demand-driven).
10. **Phase 4.24.6** — Enhanced navigation and commands.
11. **Phase 6** — Full-screen mode (major undertaking, evaluate after earlier phases ship).
---
## 4. Architecture Recommendations
### Module Structure After Phase 0
```
crates/rusty-claude-cli/src/
├── main.rs # Entrypoint, arg dispatch only (~100 lines)
├── args.rs # CLI argument parsing (consolidate existing two parsers)
├── app.rs # LiveCli struct, REPL loop, turn execution
├── format.rs # All report formatting (status, cost, model, permissions, etc.)
├── session_mgr.rs # Session CRUD: create, resume, list, switch, persist
├── init.rs # Repo initialization (unchanged)
├── input.rs # Line editor (unchanged, minor extensions)
├── render.rs # TerminalRenderer, Spinner (extended)
└── tui/
├── mod.rs # TUI module root
├── status_bar.rs # Persistent bottom status line
├── tool_panel.rs # Tool call visualization (boxes, timelines, collapsible)
├── diff_view.rs # Colored diff rendering
├── pager.rs # Internal pager for long outputs
└── theme.rs # Color theme definitions and selection
```
### Key Design Principles
1. **Keep the inline REPL as the default** — Full-screen TUI should be opt-in (`--tui` flag)
2. **Everything testable without a terminal** — All formatting functions take `&mut impl Write`, never assume stdout directly
3. **Streaming-first** — Rendering should work incrementally, not buffering the entire response
4. **Respect `crossterm` for all terminal control** — Don't mix raw ANSI escape codes with crossterm (the current codebase does this in the startup banner)
5. **Feature-gate heavy dependencies**`ratatui` should be behind a `full-tui` feature flag
---
## 5. Risk Assessment
| Risk | Mitigation |
|---|---|
| Breaking the working REPL during refactor | Phase 0 is pure restructuring with existing test coverage as safety net |
| Terminal compatibility issues (tmux, SSH, Windows) | Rely on crossterm's abstraction; test in degraded environments |
| Performance regression with rich rendering | Profile before/after; keep the fast path (raw streaming) always available |
| Scope creep into Phase 6 | Ship Phases 03 as a coherent release before starting Phase 6 |
| `app.rs` vs `main.rs` confusion | Phase 0.2 explicitly resolves this by removing the legacy `CliApp` |
---
*Generated: 2026-03-31 | Workspace: `rust/` | Branch: `dev/rust`*

View File

@@ -1,3 +0,0 @@
version = "12"
[overrides]

15
rust/Cargo.lock generated
View File

@@ -111,9 +111,7 @@ dependencies = [
name = "commands"
version = "0.1.0"
dependencies = [
"plugins",
"runtime",
"serde_json",
]
[[package]]
@@ -827,14 +825,6 @@ dependencies = [
"time",
]
[[package]]
name = "plugins"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "potential_utf"
version = "0.1.4"
@@ -1102,7 +1092,6 @@ name = "runtime"
version = "0.1.0"
dependencies = [
"glob",
"plugins",
"regex",
"serde",
"serde_json",
@@ -1192,7 +1181,6 @@ dependencies = [
"commands",
"compat-harness",
"crossterm",
"plugins",
"pulldown-cmark",
"runtime",
"rustyline",
@@ -1557,13 +1545,10 @@ dependencies = [
name = "tools"
version = "0.1.0"
dependencies = [
"api",
"plugins",
"reqwest",
"runtime",
"serde",
"serde_json",
"tokio",
]
[[package]]

View File

@@ -8,9 +8,6 @@ edition = "2021"
license = "MIT"
publish = false
[workspace.dependencies]
serde_json = "1"
[workspace.lints.rust]
unsafe_code = "forbid"

View File

@@ -1,149 +1,230 @@
# 🦞 Claw Code — Rust Implementation
# Rusty Claude CLI
A high-performance Rust rewrite of the Claw Code CLI agent harness. Built for speed, safety, and native tool execution.
`rust/` contains the Rust workspace for the integrated `rusty-claude-cli` deliverable.
It is intended to be something you can clone, build, and run directly.
## Quick Start
## Workspace layout
```bash
# Build
cd rust/
cargo build --release
# Run interactive REPL
./target/release/claw
# One-shot prompt
./target/release/claw prompt "explain this codebase"
# With specific model
./target/release/claw --model sonnet prompt "fix the bug in main.rs"
```
## Configuration
Set your API credentials:
```bash
export ANTHROPIC_API_KEY="sk-ant-..."
# Or use a proxy
export ANTHROPIC_BASE_URL="https://your-proxy.com"
```
Or authenticate via OAuth:
```bash
claw login
```
## Features
| Feature | Status |
|---------|--------|
| Anthropic API + streaming | ✅ |
| OAuth login/logout | ✅ |
| Interactive REPL (rustyline) | ✅ |
| Tool system (bash, read, write, edit, grep, glob) | ✅ |
| Web tools (search, fetch) | ✅ |
| Sub-agent orchestration | ✅ |
| Todo tracking | ✅ |
| Notebook editing | ✅ |
| CLAUDE.md / project memory | ✅ |
| Config file hierarchy (.claude.json) | ✅ |
| Permission system | ✅ |
| MCP server lifecycle | ✅ |
| Session persistence + resume | ✅ |
| Extended thinking (thinking blocks) | ✅ |
| Cost tracking + usage display | ✅ |
| Git integration | ✅ |
| Markdown terminal rendering (ANSI) | ✅ |
| Model aliases (opus/sonnet/haiku) | ✅ |
| Slash commands (/status, /compact, /clear, etc.) | ✅ |
| Hooks (PreToolUse/PostToolUse) | 🔧 Config only |
| Plugin system | 📋 Planned |
| Skills registry | 📋 Planned |
## Model Aliases
Short names resolve to the latest model versions:
| Alias | Resolves To |
|-------|------------|
| `opus` | `claude-opus-4-6` |
| `sonnet` | `claude-sonnet-4-6` |
| `haiku` | `claude-haiku-4-5-20251213` |
## CLI Flags
```
claw [OPTIONS] [COMMAND]
Options:
--model MODEL Set the model (alias or full name)
--dangerously-skip-permissions Skip all permission checks
--permission-mode MODE Set read-only, workspace-write, or danger-full-access
--allowedTools TOOLS Restrict enabled tools
--output-format FORMAT Output format (text or json)
--version, -V Print version info
Commands:
prompt <text> One-shot prompt (non-interactive)
login Authenticate via OAuth
logout Clear stored credentials
init Initialize project config
doctor Check environment health
self-update Update to latest version
```
## Slash Commands (REPL)
| Command | Description |
|---------|-------------|
| `/help` | Show help |
| `/status` | Show session status (model, tokens, cost) |
| `/cost` | Show cost breakdown |
| `/compact` | Compact conversation history |
| `/clear` | Clear conversation |
| `/model [name]` | Show or switch model |
| `/permissions` | Show or switch permission mode |
| `/config [section]` | Show config (env, hooks, model) |
| `/memory` | Show CLAUDE.md contents |
| `/diff` | Show git diff |
| `/export [path]` | Export conversation |
| `/session [id]` | Resume a previous session |
| `/version` | Show version |
## Workspace Layout
```
```text
rust/
├── Cargo.toml # Workspace root
├── Cargo.toml
├── Cargo.lock
├── README.md
└── crates/
├── api/ # Anthropic API client + SSE streaming
├── commands/ # Shared slash-command registry
├── compat-harness/ # TS manifest extraction harness
├── runtime/ # Session, config, permissions, MCP, prompts
├── rusty-claude-cli/ # Main CLI binary (`claw`)
└── tools/ # Built-in tool implementations
├── api/ # Anthropic API client + SSE streaming support
├── commands/ # Shared slash-command metadata/help surfaces
├── compat-harness/ # Upstream TS manifest extraction harness
├── runtime/ # Session/runtime/config/prompt orchestration
├── rusty-claude-cli/ # Main CLI binary
└── tools/ # Built-in tool implementations
```
### Crate Responsibilities
## Prerequisites
- **api** — HTTP client, SSE stream parser, request/response types, auth (API key + OAuth bearer)
- **commands** — Slash command definitions and help text generation
- **compat-harness** — Extracts tool/prompt manifests from upstream TS source
- **runtime** — `ConversationRuntime` agentic loop, `ConfigLoader` hierarchy, `Session` persistence, permission policy, MCP client, system prompt assembly, usage tracking
- **rusty-claude-cli** — REPL, one-shot prompt, streaming display, tool call rendering, CLI argument parsing
- **tools** — Tool specs + execution: Bash, ReadFile, WriteFile, EditFile, GlobSearch, GrepSearch, WebSearch, WebFetch, Agent, TodoWrite, NotebookEdit, Skill, ToolSearch, REPL runtimes
- Rust toolchain installed (`rustup`, stable toolchain)
- Network access and Anthropic credentials for live prompt/REPL usage
## Stats
## Build
- **~20K lines** of Rust
- **6 crates** in workspace
- **Binary name:** `claw`
- **Default model:** `claude-opus-4-6`
- **Default permissions:** `danger-full-access`
From the repository root:
## License
```bash
cd rust
cargo build --release -p rusty-claude-cli
```
See repository root.
The optimized binary will be written to:
```bash
./target/release/rusty-claude-cli
```
## Test
Run the verified workspace test suite used for release-readiness:
```bash
cd rust
cargo test --workspace --exclude compat-harness
```
## Quick start
### Show help
```bash
cd rust
cargo run -p rusty-claude-cli -- --help
```
### Print version
```bash
cd rust
cargo run -p rusty-claude-cli -- --version
```
### Login with OAuth
Configure `settings.json` with an `oauth` block containing `clientId`, `authorizeUrl`, `tokenUrl`, optional `callbackPort`, and optional `scopes`, then run:
```bash
cd rust
cargo run -p rusty-claude-cli -- login
```
This opens the browser, listens on the configured localhost callback, exchanges the auth code for tokens, and stores OAuth credentials in `~/.claude/credentials.json` (or `$CLAUDE_CONFIG_HOME/credentials.json`).
### Logout
```bash
cd rust
cargo run -p rusty-claude-cli -- logout
```
This removes only the stored OAuth credentials and preserves unrelated JSON fields in `credentials.json`.
### Self-update
```bash
cd rust
cargo run -p rusty-claude-cli -- self-update
```
The command checks the latest GitHub release for `instructkr/clawd-code`, compares it to the current binary version, downloads the matching binary asset plus checksum manifest, verifies SHA-256, replaces the current executable, and prints the release changelog. If no published release or matching asset exists, it exits safely with an explanatory message.
## Usage examples
### 1) Prompt mode
Send one prompt, stream the answer, then exit:
```bash
cd rust
cargo run -p rusty-claude-cli -- prompt "Summarize the architecture of this repository"
```
Use a specific model:
```bash
cd rust
cargo run -p rusty-claude-cli -- --model claude-sonnet-4-20250514 prompt "List the key crates in this workspace"
```
Restrict enabled tools in an interactive session:
```bash
cd rust
cargo run -p rusty-claude-cli -- --allowedTools read,glob
```
Bootstrap Claude project files for the current repo:
```bash
cd rust
cargo run -p rusty-claude-cli -- init
```
### 2) REPL mode
Start the interactive shell:
```bash
cd rust
cargo run -p rusty-claude-cli --
```
Inside the REPL, useful commands include:
```text
/help
/status
/model claude-sonnet-4-20250514
/permissions workspace-write
/cost
/compact
/memory
/config
/init
/diff
/version
/export notes.txt
/sessions
/session list
/exit
```
### 3) Resume an existing session
Inspect or maintain a saved session file without entering the REPL:
```bash
cd rust
cargo run -p rusty-claude-cli -- --resume session-123456 /status /compact /cost
```
You can also inspect memory/config state for a restored session:
```bash
cd rust
cargo run -p rusty-claude-cli -- --resume ~/.claude/sessions/session-123456.json /memory /config
```
## Available commands
### Top-level CLI commands
- `prompt <text...>` — run one prompt non-interactively
- `--resume <session-id-or-path> [/commands...]` — inspect or maintain a saved session stored under `~/.claude/sessions/`
- `dump-manifests` — print extracted upstream manifest counts
- `bootstrap-plan` — print the current bootstrap skeleton
- `system-prompt [--cwd PATH] [--date YYYY-MM-DD]` — render the synthesized system prompt
- `self-update` — update the installed binary from the latest GitHub release when a matching asset is available
- `--help` / `-h` — show CLI help
- `--version` / `-V` — print the CLI version and build info locally (no API call)
- `--output-format text|json` — choose non-interactive prompt output rendering
- `--allowedTools <tool[,tool...]>` — restrict enabled tools for interactive sessions and prompt-mode tool use
### Interactive slash commands
- `/help` — show command help
- `/status` — show current session status
- `/compact` — compact local session history
- `/model [model]` — inspect or switch the active model
- `/permissions [read-only|workspace-write|danger-full-access]` — inspect or switch permissions
- `/clear [--confirm]` — clear the current local session
- `/cost` — show token usage totals
- `/resume <session-id-or-path>` — load a saved session into the REPL
- `/config [env|hooks|model]` — inspect discovered Claude config
- `/memory` — inspect loaded instruction memory files
- `/init` — bootstrap `.claude.json`, `.claude/`, `CLAUDE.md`, and local ignore rules
- `/diff` — show the current git diff for the workspace
- `/version` — print version and build metadata locally
- `/export [file]` — export the current conversation transcript
- `/sessions` — list recent managed local sessions from `~/.claude/sessions/`
- `/session [list|switch <session-id>]` — inspect or switch managed local sessions
- `/exit` — leave the REPL
## Environment variables
### Anthropic/API
- `ANTHROPIC_API_KEY` — highest-precedence API credential
- `ANTHROPIC_AUTH_TOKEN` — bearer-token override used when no API key is set
- Persisted OAuth credentials in `~/.claude/credentials.json` — used when neither env var is set
- `ANTHROPIC_BASE_URL` — override the Anthropic API base URL
- `ANTHROPIC_MODEL` — default model used by selected live integration tests
### CLI/runtime
- `RUSTY_CLAUDE_PERMISSION_MODE` — default REPL permission mode (`read-only`, `workspace-write`, or `danger-full-access`)
- `CLAUDE_CONFIG_HOME` — override Claude config discovery root
- `CLAUDE_CODE_REMOTE` — enable remote-session bootstrap handling when supported
- `CLAUDE_CODE_REMOTE_SESSION_ID` — remote session identifier when using remote mode
- `CLAUDE_CODE_UPSTREAM` — override the upstream TS source path for compat-harness extraction
- `CLAWD_WEB_SEARCH_BASE_URL` — override the built-in web search service endpoint used by tooling
## Notes
- `compat-harness` exists to compare the Rust port against the upstream TypeScript codebase and is intentionally excluded from the requested release test run.
- The CLI currently focuses on a practical integrated workflow: prompt execution, REPL operation, session inspection/resume, config discovery, and tool/runtime plumbing.

View File

@@ -1,221 +0,0 @@
# TUI Enhancement Plan — Claw Code (`rusty-claude-cli`)
## Executive Summary
This plan covers a comprehensive analysis of the current terminal user interface and proposes phased enhancements that will transform the existing REPL/prompt CLI into a polished, modern TUI experience — while preserving the existing clean architecture and test coverage.
---
## 1. Current Architecture Analysis
### Crate Map
| Crate | Purpose | Lines | TUI Relevance |
|---|---|---|---|
| `rusty-claude-cli` | Main binary: REPL loop, arg parsing, rendering, API bridge | ~3,600 | **Primary TUI surface** |
| `runtime` | Session, conversation loop, config, permissions, compaction | ~5,300 | Provides data/state |
| `api` | Anthropic HTTP client + SSE streaming | ~1,500 | Provides stream events |
| `commands` | Slash command metadata/parsing/help | ~470 | Drives command dispatch |
| `tools` | 18 built-in tool implementations | ~3,500 | Tool execution display |
### Current TUI Components
| Component | File | What It Does Today | Quality |
|---|---|---|---|
| **Input** | `input.rs` (269 lines) | `rustyline`-based line editor with slash-command tab completion, Shift+Enter newline, history | ✅ Solid |
| **Rendering** | `render.rs` (641 lines) | Markdown→terminal rendering (headings, lists, tables, code blocks with syntect highlighting, blockquotes), spinner widget | ✅ Good |
| **App/REPL loop** | `main.rs` (3,159 lines) | The monolithic `LiveCli` struct: REPL loop, all slash command handlers, streaming output, tool call display, permission prompting, session management | ⚠️ Monolithic |
| **Alt App** | `app.rs` (398 lines) | An earlier `CliApp` prototype with `ConversationClient`, stream event handling, `TerminalRenderer`, output format support | ⚠️ Appears unused/legacy |
### Key Dependencies
- **crossterm 0.28** — terminal control (cursor, colors, clear)
- **pulldown-cmark 0.13** — Markdown parsing
- **syntect 5** — syntax highlighting
- **rustyline 15** — line editing with completion
- **serde_json** — tool I/O formatting
### Strengths
1. **Clean rendering pipeline**: Markdown rendering is well-structured with state tracking, table rendering, code highlighting
2. **Rich tool display**: Tool calls get box-drawing borders (`╭─ name ─╮`), results show ✓/✗ icons
3. **Comprehensive slash commands**: 15 commands covering model switching, permissions, sessions, config, diff, export
4. **Session management**: Full persistence, resume, list, switch, compaction
5. **Permission prompting**: Interactive Y/N approval for restricted tool calls
6. **Thorough tests**: Every formatting function, every parse path has unit tests
### Weaknesses & Gaps
1. **`main.rs` is a 3,159-line monolith** — all REPL logic, formatting, API bridging, session management, and tests in one file
2. **No alternate-screen / full-screen layout** — everything is inline scrolling output
3. **No progress bars** — only a single braille spinner; no indication of streaming progress or token counts during generation
4. **No visual diff rendering**`/diff` just dumps raw git diff text
5. **No syntax highlighting in streamed output** — markdown rendering only applies to tool results, not to the main assistant response stream
6. **No status bar / HUD** — model, tokens, session info not visible during interaction
7. **No image/attachment preview**`SendUserMessage` resolves attachments but never displays them
8. **Streaming is char-by-char with artificial delay**`stream_markdown` sleeps 8ms per whitespace-delimited chunk
9. **No color theme customization** — hardcoded `ColorTheme::default()`
10. **No resize handling** — no terminal size awareness for wrapping, truncation, or layout
11. **Dual app structs**`app.rs` has a separate `CliApp` that duplicates `LiveCli` from `main.rs`
12. **No pager for long outputs**`/status`, `/config`, `/memory` can overflow the viewport
13. **Tool results not collapsible** — large bash outputs flood the screen
14. **No thinking/reasoning indicator** — when the model is in "thinking" mode, no visual distinction
15. **No auto-complete for tool arguments** — only slash command names complete
---
## 2. Enhancement Plan
### Phase 0: Structural Cleanup (Foundation)
**Goal**: Break the monolith, remove dead code, establish the module structure for TUI work.
| Task | Description | Effort |
|---|---|---|
| 0.1 | **Extract `LiveCli` into `app.rs`** — Move the entire `LiveCli` struct, its impl, and helpers (`format_*`, `render_*`, session management) out of `main.rs` into focused modules: `app.rs` (core), `format.rs` (report formatting), `session_manager.rs` (session CRUD) | M |
| 0.2 | **Remove or merge the legacy `CliApp`** — The existing `app.rs` has an unused `CliApp` with its own `ConversationClient`-based rendering. Either delete it or merge its unique features (stream event handler pattern) into the active `LiveCli` | S |
| 0.3 | **Extract `main.rs` arg parsing** — The current `parse_args()` is a hand-rolled parser that duplicates the clap-based `args.rs`. Consolidate on the hand-rolled parser (it's more feature-complete) and move it to `args.rs`, or adopt clap fully | S |
| 0.4 | **Create a `tui/` module** — Introduce `crates/rusty-claude-cli/src/tui/mod.rs` as the namespace for all new TUI components: `status_bar.rs`, `layout.rs`, `tool_panel.rs`, etc. | S |
### Phase 1: Status Bar & Live HUD
**Goal**: Persistent information display during interaction.
| Task | Description | Effort |
|---|---|---|
| 1.1 | **Terminal-size-aware status line** — Use `crossterm::terminal::size()` to render a bottom-pinned status bar showing: model name, permission mode, session ID, cumulative token count, estimated cost | M |
| 1.2 | **Live token counter** — Update the status bar in real-time as `AssistantEvent::Usage` and `AssistantEvent::TextDelta` events arrive during streaming | M |
| 1.3 | **Turn duration timer** — Show elapsed time for the current turn (the `showTurnDuration` config already exists in Config tool but isn't wired up) | S |
| 1.4 | **Git branch indicator** — Display the current git branch in the status bar (already parsed via `parse_git_status_metadata`) | S |
### Phase 2: Enhanced Streaming Output
**Goal**: Make the main response stream visually rich and responsive.
| Task | Description | Effort |
|---|---|---|
| 2.1 | **Live markdown rendering** — Instead of raw text streaming, buffer text deltas and incrementally render Markdown as it arrives (heading detection, bold/italic, inline code). The existing `TerminalRenderer::render_markdown` can be adapted for incremental use | L |
| 2.2 | **Thinking indicator** — When extended thinking/reasoning is active, show a distinct animated indicator (e.g., `🧠 Reasoning...` with pulsing dots or a different spinner) instead of the generic `🦀 Thinking...` | S |
| 2.3 | **Streaming progress bar** — Add an optional horizontal progress indicator below the spinner showing approximate completion (based on max_tokens vs. output_tokens so far) | M |
| 2.4 | **Remove artificial stream delay** — The current `stream_markdown` sleeps 8ms per chunk. For tool results this is fine, but for the main response stream it should be immediate or configurable | S |
### Phase 3: Tool Call Visualization
**Goal**: Make tool execution legible and navigable.
| Task | Description | Effort |
|---|---|---|
| 3.1 | **Collapsible tool output** — For tool results longer than N lines (configurable, default 15), show a summary with `[+] Expand` hint; pressing a key reveals the full output. Initially implement as truncation with a "full output saved to file" fallback | M |
| 3.2 | **Syntax-highlighted tool results** — When tool results contain code (detected by tool name — `bash` stdout, `read_file` content, `REPL` output), apply syntect highlighting rather than rendering as plain text | M |
| 3.3 | **Tool call timeline** — For multi-tool turns, show a compact summary: `🔧 bash → ✓ | read_file → ✓ | edit_file → ✓ (3 tools, 1.2s)` after all tool calls complete | S |
| 3.4 | **Diff-aware edit_file display** — When `edit_file` succeeds, show a colored unified diff of the change instead of just `✓ edit_file: path` | M |
| 3.5 | **Permission prompt enhancement** — Style the approval prompt with box drawing, color the tool name, show a one-line summary of what the tool will do | S |
### Phase 4: Enhanced Slash Commands & Navigation
**Goal**: Improve information display and add missing features.
| Task | Description | Effort |
|---|---|---|
| 4.1 | **Colored `/diff` output** — Parse the git diff and render it with red/green coloring for removals/additions, similar to `delta` or `diff-so-fancy` | M |
| 4.2 | **Pager for long outputs** — When `/status`, `/config`, `/memory`, or `/diff` produce output longer than the terminal height, pipe through an internal pager (scroll with j/k/q) or external `$PAGER` | M |
| 4.3 | **`/search` command** — Add a new command to search conversation history by keyword | M |
| 4.4 | **`/undo` command** — Undo the last file edit by restoring from the `originalFile` data in `write_file`/`edit_file` tool results | M |
| 4.5 | **Interactive session picker** — Replace the text-based `/session list` with an interactive fuzzy-filterable list (up/down arrows to select, enter to switch) | L |
| 4.6 | **Tab completion for tool arguments** — Extend `SlashCommandHelper` to complete file paths after `/export`, model names after `/model`, session IDs after `/session switch` | M |
### Phase 5: Color Themes & Configuration
**Goal**: User-customizable visual appearance.
| Task | Description | Effort |
|---|---|---|
| 5.1 | **Named color themes** — Add `dark` (current default), `light`, `solarized`, `catppuccin` themes. Wire to the existing `Config` tool's `theme` setting | M |
| 5.2 | **ANSI-256 / truecolor detection** — Detect terminal capabilities and fall back gracefully (no colors → 16 colors → 256 → truecolor) | M |
| 5.3 | **Configurable spinner style** — Allow choosing between braille dots, bar, moon phases, etc. | S |
| 5.4 | **Banner customization** — Make the ASCII art banner optional or configurable via settings | S |
### Phase 6: Full-Screen TUI Mode (Stretch)
**Goal**: Optional alternate-screen layout for power users.
| Task | Description | Effort |
|---|---|---|
| 6.1 | **Add `ratatui` dependency** — Introduce `ratatui` (terminal UI framework) as an optional dependency for the full-screen mode | S |
| 6.2 | **Split-pane layout** — Top pane: conversation with scrollback; Bottom pane: input area; Right sidebar (optional): tool status/todo list | XL |
| 6.3 | **Scrollable conversation view** — Navigate past messages with PgUp/PgDn, search within conversation | L |
| 6.4 | **Keyboard shortcuts panel** — Show `?` help overlay with all keybindings | M |
| 6.5 | **Mouse support** — Click to expand tool results, scroll conversation, select text for copy | L |
---
## 3. Priority Recommendation
### Immediate (High Impact, Moderate Effort)
1. **Phase 0** — Essential cleanup. The 3,159-line `main.rs` is the #1 maintenance risk and blocks clean TUI additions.
2. **Phase 1.11.2** — Status bar with live tokens. Highest-impact UX win: users constantly want to know token usage.
3. **Phase 2.4** — Remove artificial delay. Low effort, immediately noticeable improvement.
4. **Phase 3.1** — Collapsible tool output. Large bash outputs currently wreck readability.
### Near-Term (Next Sprint)
5. **Phase 2.1** — Live markdown rendering. Makes the core interaction feel polished.
6. **Phase 3.2** — Syntax-highlighted tool results.
7. **Phase 3.4** — Diff-aware edit display.
8. **Phase 4.1** — Colored diff for `/diff`.
### Longer-Term
9. **Phase 5** — Color themes (user demand-driven).
10. **Phase 4.24.6** — Enhanced navigation and commands.
11. **Phase 6** — Full-screen mode (major undertaking, evaluate after earlier phases ship).
---
## 4. Architecture Recommendations
### Module Structure After Phase 0
```
crates/rusty-claude-cli/src/
├── main.rs # Entrypoint, arg dispatch only (~100 lines)
├── args.rs # CLI argument parsing (consolidate existing two parsers)
├── app.rs # LiveCli struct, REPL loop, turn execution
├── format.rs # All report formatting (status, cost, model, permissions, etc.)
├── session_mgr.rs # Session CRUD: create, resume, list, switch, persist
├── init.rs # Repo initialization (unchanged)
├── input.rs # Line editor (unchanged, minor extensions)
├── render.rs # TerminalRenderer, Spinner (extended)
└── tui/
├── mod.rs # TUI module root
├── status_bar.rs # Persistent bottom status line
├── tool_panel.rs # Tool call visualization (boxes, timelines, collapsible)
├── diff_view.rs # Colored diff rendering
├── pager.rs # Internal pager for long outputs
└── theme.rs # Color theme definitions and selection
```
### Key Design Principles
1. **Keep the inline REPL as the default** — Full-screen TUI should be opt-in (`--tui` flag)
2. **Everything testable without a terminal** — All formatting functions take `&mut impl Write`, never assume stdout directly
3. **Streaming-first** — Rendering should work incrementally, not buffering the entire response
4. **Respect `crossterm` for all terminal control** — Don't mix raw ANSI escape codes with crossterm (the current codebase does this in the startup banner)
5. **Feature-gate heavy dependencies**`ratatui` should be behind a `full-tui` feature flag
---
## 5. Risk Assessment
| Risk | Mitigation |
|---|---|
| Breaking the working REPL during refactor | Phase 0 is pure restructuring with existing test coverage as safety net |
| Terminal compatibility issues (tmux, SSH, Windows) | Rely on crossterm's abstraction; test in degraded environments |
| Performance regression with rich rendering | Profile before/after; keep the fast path (raw streaming) always available |
| Scope creep into Phase 6 | Ship Phases 03 as a coherent release before starting Phase 6 |
| `app.rs` vs `main.rs` confusion | Phase 0.2 explicitly resolves this by removing the legacy `CliApp` |
---
*Generated: 2026-03-31 | Workspace: `rust/` | Branch: `dev/rust`*

View File

@@ -9,7 +9,7 @@ publish.workspace = true
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
runtime = { path = "../runtime" }
serde = { version = "1", features = ["derive"] }
serde_json.workspace = true
serde_json = "1"
tokio = { version = "1", features = ["io-util", "macros", "net", "rt-multi-thread", "time"] }
[lints]

File diff suppressed because it is too large Load Diff

View File

@@ -4,10 +4,7 @@ use std::time::Duration;
#[derive(Debug)]
pub enum ApiError {
MissingCredentials {
provider: &'static str,
env_vars: &'static [&'static str],
},
MissingApiKey,
ExpiredOAuthToken,
Auth(String),
InvalidApiKeyEnv(VarError),
@@ -33,21 +30,13 @@ pub enum ApiError {
}
impl ApiError {
#[must_use]
pub const fn missing_credentials(
provider: &'static str,
env_vars: &'static [&'static str],
) -> Self {
Self::MissingCredentials { provider, env_vars }
}
#[must_use]
pub fn is_retryable(&self) -> bool {
match self {
Self::Http(error) => error.is_connect() || error.is_timeout() || error.is_request(),
Self::Api { retryable, .. } => *retryable,
Self::RetriesExhausted { last_error, .. } => last_error.is_retryable(),
Self::MissingCredentials { .. }
Self::MissingApiKey
| Self::ExpiredOAuthToken
| Self::Auth(_)
| Self::InvalidApiKeyEnv(_)
@@ -62,11 +51,12 @@ impl ApiError {
impl Display for ApiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingCredentials { provider, env_vars } => write!(
f,
"missing {provider} credentials; export {} before calling the {provider} API",
env_vars.join(" or ")
),
Self::MissingApiKey => {
write!(
f,
"ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY is not set; export one before calling the Anthropic API"
)
}
Self::ExpiredOAuthToken => {
write!(
f,
@@ -75,7 +65,10 @@ impl Display for ApiError {
}
Self::Auth(message) => write!(f, "auth error: {message}"),
Self::InvalidApiKeyEnv(error) => {
write!(f, "failed to read credential environment variable: {error}")
write!(
f,
"failed to read ANTHROPIC_AUTH_TOKEN / ANTHROPIC_API_KEY: {error}"
)
}
Self::Http(error) => write!(f, "http error: {error}"),
Self::Io(error) => write!(f, "io error: {error}"),
@@ -88,14 +81,20 @@ impl Display for ApiError {
..
} => match (error_type, message) {
(Some(error_type), Some(message)) => {
write!(f, "api returned {status} ({error_type}): {message}")
write!(
f,
"anthropic api returned {status} ({error_type}): {message}"
)
}
_ => write!(f, "api returned {status}: {body}"),
_ => write!(f, "anthropic api returned {status}: {body}"),
},
Self::RetriesExhausted {
attempts,
last_error,
} => write!(f, "api failed after {attempts} attempts: {last_error}"),
} => write!(
f,
"anthropic api failed after {attempts} attempts: {last_error}"
),
Self::InvalidSseFrame(message) => write!(f, "invalid sse frame: {message}"),
Self::BackoffOverflow {
attempt,

View File

@@ -1,19 +1,13 @@
mod client;
mod error;
mod providers;
mod sse;
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,
oauth_token_is_expired, read_base_url, resolve_saved_oauth_token, resolve_startup_auth_source,
AnthropicClient, AuthSource, MessageStream, OAuthTokenSet,
};
pub use error::ApiError;
pub use providers::anthropic::{AnthropicClient, AnthropicClient as ApiClient, AuthSource};
pub use providers::openai_compat::{OpenAiCompatClient, OpenAiCompatConfig};
pub use providers::{
detect_provider_kind, max_tokens_for_model, resolve_model_alias, ProviderKind,
};
pub use sse::{parse_frame, SseParser};
pub use types::{
ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, ContentBlockStopEvent,

File diff suppressed because it is too large Load Diff

View File

@@ -1,216 +0,0 @@
use std::future::Future;
use std::pin::Pin;
use crate::error::ApiError;
use crate::types::{MessageRequest, MessageResponse};
pub mod anthropic;
pub mod openai_compat;
pub type ProviderFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, ApiError>> + Send + 'a>>;
pub trait Provider {
type Stream;
fn send_message<'a>(
&'a self,
request: &'a MessageRequest,
) -> ProviderFuture<'a, MessageResponse>;
fn stream_message<'a>(
&'a self,
request: &'a MessageRequest,
) -> ProviderFuture<'a, Self::Stream>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderKind {
Anthropic,
Xai,
OpenAi,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProviderMetadata {
pub provider: ProviderKind,
pub auth_env: &'static str,
pub base_url_env: &'static str,
pub default_base_url: &'static str,
}
const MODEL_REGISTRY: &[(&str, ProviderMetadata)] = &[
(
"opus",
ProviderMetadata {
provider: ProviderKind::Anthropic,
auth_env: "ANTHROPIC_API_KEY",
base_url_env: "ANTHROPIC_BASE_URL",
default_base_url: anthropic::DEFAULT_BASE_URL,
},
),
(
"sonnet",
ProviderMetadata {
provider: ProviderKind::Anthropic,
auth_env: "ANTHROPIC_API_KEY",
base_url_env: "ANTHROPIC_BASE_URL",
default_base_url: anthropic::DEFAULT_BASE_URL,
},
),
(
"haiku",
ProviderMetadata {
provider: ProviderKind::Anthropic,
auth_env: "ANTHROPIC_API_KEY",
base_url_env: "ANTHROPIC_BASE_URL",
default_base_url: anthropic::DEFAULT_BASE_URL,
},
),
(
"grok",
ProviderMetadata {
provider: ProviderKind::Xai,
auth_env: "XAI_API_KEY",
base_url_env: "XAI_BASE_URL",
default_base_url: openai_compat::DEFAULT_XAI_BASE_URL,
},
),
(
"grok-3",
ProviderMetadata {
provider: ProviderKind::Xai,
auth_env: "XAI_API_KEY",
base_url_env: "XAI_BASE_URL",
default_base_url: openai_compat::DEFAULT_XAI_BASE_URL,
},
),
(
"grok-mini",
ProviderMetadata {
provider: ProviderKind::Xai,
auth_env: "XAI_API_KEY",
base_url_env: "XAI_BASE_URL",
default_base_url: openai_compat::DEFAULT_XAI_BASE_URL,
},
),
(
"grok-3-mini",
ProviderMetadata {
provider: ProviderKind::Xai,
auth_env: "XAI_API_KEY",
base_url_env: "XAI_BASE_URL",
default_base_url: openai_compat::DEFAULT_XAI_BASE_URL,
},
),
(
"grok-2",
ProviderMetadata {
provider: ProviderKind::Xai,
auth_env: "XAI_API_KEY",
base_url_env: "XAI_BASE_URL",
default_base_url: openai_compat::DEFAULT_XAI_BASE_URL,
},
),
];
#[must_use]
pub fn resolve_model_alias(model: &str) -> String {
let trimmed = model.trim();
let lower = trimmed.to_ascii_lowercase();
MODEL_REGISTRY
.iter()
.find_map(|(alias, metadata)| {
(*alias == lower).then_some(match metadata.provider {
ProviderKind::Anthropic => match *alias {
"opus" => "claude-opus-4-6",
"sonnet" => "claude-sonnet-4-6",
"haiku" => "claude-haiku-4-5-20251213",
_ => trimmed,
},
ProviderKind::Xai => match *alias {
"grok" | "grok-3" => "grok-3",
"grok-mini" | "grok-3-mini" => "grok-3-mini",
"grok-2" => "grok-2",
_ => trimmed,
},
ProviderKind::OpenAi => trimmed,
})
})
.map_or_else(|| trimmed.to_string(), ToOwned::to_owned)
}
#[must_use]
pub fn metadata_for_model(model: &str) -> Option<ProviderMetadata> {
let canonical = resolve_model_alias(model);
if canonical.starts_with("claude") {
return Some(ProviderMetadata {
provider: ProviderKind::Anthropic,
auth_env: "ANTHROPIC_API_KEY",
base_url_env: "ANTHROPIC_BASE_URL",
default_base_url: anthropic::DEFAULT_BASE_URL,
});
}
if canonical.starts_with("grok") {
return Some(ProviderMetadata {
provider: ProviderKind::Xai,
auth_env: "XAI_API_KEY",
base_url_env: "XAI_BASE_URL",
default_base_url: openai_compat::DEFAULT_XAI_BASE_URL,
});
}
None
}
#[must_use]
pub fn detect_provider_kind(model: &str) -> ProviderKind {
if let Some(metadata) = metadata_for_model(model) {
return metadata.provider;
}
if anthropic::has_auth_from_env_or_saved().unwrap_or(false) {
return ProviderKind::Anthropic;
}
if openai_compat::has_api_key("OPENAI_API_KEY") {
return ProviderKind::OpenAi;
}
if openai_compat::has_api_key("XAI_API_KEY") {
return ProviderKind::Xai;
}
ProviderKind::Anthropic
}
#[must_use]
pub fn max_tokens_for_model(model: &str) -> u32 {
let canonical = resolve_model_alias(model);
if canonical.contains("opus") {
32_000
} else {
64_000
}
}
#[cfg(test)]
mod tests {
use super::{detect_provider_kind, max_tokens_for_model, resolve_model_alias, ProviderKind};
#[test]
fn resolves_grok_aliases() {
assert_eq!(resolve_model_alias("grok"), "grok-3");
assert_eq!(resolve_model_alias("grok-mini"), "grok-3-mini");
assert_eq!(resolve_model_alias("grok-2"), "grok-2");
}
#[test]
fn detects_provider_from_model_name_first() {
assert_eq!(detect_provider_kind("grok"), ProviderKind::Xai);
assert_eq!(
detect_provider_kind("claude-sonnet-4-6"),
ProviderKind::Anthropic
);
}
#[test]
fn keeps_existing_max_token_heuristic() {
assert_eq!(max_tokens_for_model("opus"), 32_000);
assert_eq!(max_tokens_for_model("grok-3"), 64_000);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -216,64 +216,4 @@ mod tests {
))
);
}
#[test]
fn parses_thinking_content_block_start() {
let frame = concat!(
"event: content_block_start\n",
"data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\",\"signature\":null}}\n\n"
);
let event = parse_frame(frame).expect("frame should parse");
assert_eq!(
event,
Some(StreamEvent::ContentBlockStart(
crate::types::ContentBlockStartEvent {
index: 0,
content_block: OutputContentBlock::Thinking {
thinking: String::new(),
signature: None,
},
},
))
);
}
#[test]
fn parses_thinking_related_deltas() {
let thinking = concat!(
"event: content_block_delta\n",
"data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"step 1\"}}\n\n"
);
let signature = concat!(
"event: content_block_delta\n",
"data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"signature_delta\",\"signature\":\"sig_123\"}}\n\n"
);
let thinking_event = parse_frame(thinking).expect("thinking delta should parse");
let signature_event = parse_frame(signature).expect("signature delta should parse");
assert_eq!(
thinking_event,
Some(StreamEvent::ContentBlockDelta(
crate::types::ContentBlockDeltaEvent {
index: 0,
delta: ContentBlockDelta::ThinkingDelta {
thinking: "step 1".to_string(),
},
}
))
);
assert_eq!(
signature_event,
Some(StreamEvent::ContentBlockDelta(
crate::types::ContentBlockDeltaEvent {
index: 0,
delta: ContentBlockDelta::SignatureDelta {
signature: "sig_123".to_string(),
},
}
))
);
}
}

View File

@@ -135,15 +135,6 @@ pub enum OutputContentBlock {
name: String,
input: Value,
},
Thinking {
#[serde(default)]
thinking: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
signature: Option<String>,
},
RedactedThinking {
data: Value,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -199,8 +190,6 @@ pub struct ContentBlockDeltaEvent {
pub enum ContentBlockDelta {
TextDelta { text: String },
InputJsonDelta { partial_json: String },
ThinkingDelta { thinking: String },
SignatureDelta { signature: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]

View File

@@ -3,9 +3,9 @@ use std::sync::Arc;
use std::time::Duration;
use api::{
ApiClient, ApiError, AuthSource, ContentBlockDelta, ContentBlockDeltaEvent,
ContentBlockStartEvent, InputContentBlock, InputMessage, MessageDeltaEvent, MessageRequest,
OutputContentBlock, ProviderClient, StreamEvent, ToolChoice, ToolDefinition,
AnthropicClient, ApiError, ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent,
InputContentBlock, InputMessage, MessageDeltaEvent, MessageRequest, OutputContentBlock,
StreamEvent, ToolChoice, ToolDefinition,
};
use serde_json::json;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
@@ -34,7 +34,7 @@ async fn send_message_posts_json_and_parses_response() {
)
.await;
let client = ApiClient::new("test-key")
let client = AnthropicClient::new("test-key")
.with_auth_token(Some("proxy-token".to_string()))
.with_base_url(server.base_url());
let response = client
@@ -104,7 +104,7 @@ async fn stream_message_parses_sse_events_with_tool_use() {
)
.await;
let client = ApiClient::new("test-key")
let client = AnthropicClient::new("test-key")
.with_auth_token(Some("proxy-token".to_string()))
.with_base_url(server.base_url());
let mut stream = client
@@ -182,7 +182,7 @@ async fn retries_retryable_failures_before_succeeding() {
)
.await;
let client = ApiClient::new("test-key")
let client = AnthropicClient::new("test-key")
.with_base_url(server.base_url())
.with_retry_policy(2, Duration::from_millis(1), Duration::from_millis(2));
@@ -195,47 +195,6 @@ async fn retries_retryable_failures_before_succeeding() {
assert_eq!(state.lock().await.len(), 2);
}
#[tokio::test]
async fn provider_client_dispatches_anthropic_requests() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let server = spawn_server(
state.clone(),
vec![http_response(
"200 OK",
"application/json",
"{\"id\":\"msg_provider\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Dispatched\"}],\"model\":\"claude-3-7-sonnet-latest\",\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"usage\":{\"input_tokens\":3,\"output_tokens\":2}}",
)],
)
.await;
let client = ProviderClient::from_model_with_anthropic_auth(
"claude-sonnet-4-6",
Some(AuthSource::ApiKey("test-key".to_string())),
)
.expect("anthropic provider client should be constructed");
let client = match client {
ProviderClient::Anthropic(client) => {
ProviderClient::Anthropic(client.with_base_url(server.base_url()))
}
other => panic!("expected anthropic provider, got {other:?}"),
};
let response = client
.send_message(&sample_request(false))
.await
.expect("provider-dispatched request should succeed");
assert_eq!(response.total_tokens(), 5);
let captured = state.lock().await;
let request = captured.first().expect("server should capture request");
assert_eq!(request.path, "/v1/messages");
assert_eq!(
request.headers.get("x-api-key").map(String::as_str),
Some("test-key")
);
}
#[tokio::test]
async fn surfaces_retry_exhaustion_for_persistent_retryable_errors() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
@@ -256,7 +215,7 @@ async fn surfaces_retry_exhaustion_for_persistent_retryable_errors() {
)
.await;
let client = ApiClient::new("test-key")
let client = AnthropicClient::new("test-key")
.with_base_url(server.base_url())
.with_retry_policy(1, Duration::from_millis(1), Duration::from_millis(2));
@@ -287,7 +246,7 @@ async fn surfaces_retry_exhaustion_for_persistent_retryable_errors() {
#[tokio::test]
#[ignore = "requires ANTHROPIC_API_KEY and network access"]
async fn live_stream_smoke_test() {
let client = ApiClient::from_env().expect("ANTHROPIC_API_KEY must be set");
let client = AnthropicClient::from_env().expect("ANTHROPIC_API_KEY must be set");
let mut stream = client
.stream_message(&MessageRequest {
model: std::env::var("ANTHROPIC_MODEL")

View File

@@ -1,415 +0,0 @@
use std::collections::HashMap;
use std::ffi::OsString;
use std::sync::Arc;
use std::sync::{Mutex as StdMutex, OnceLock};
use api::{
ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, ContentBlockStopEvent,
InputContentBlock, InputMessage, MessageRequest, OpenAiCompatClient, OpenAiCompatConfig,
OutputContentBlock, ProviderClient, StreamEvent, ToolChoice, ToolDefinition,
};
use serde_json::json;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::sync::Mutex;
#[tokio::test]
async fn send_message_uses_openai_compatible_endpoint_and_auth() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let body = concat!(
"{",
"\"id\":\"chatcmpl_test\",",
"\"model\":\"grok-3\",",
"\"choices\":[{",
"\"message\":{\"role\":\"assistant\",\"content\":\"Hello from Grok\",\"tool_calls\":[]},",
"\"finish_reason\":\"stop\"",
"}],",
"\"usage\":{\"prompt_tokens\":11,\"completion_tokens\":5}",
"}"
);
let server = spawn_server(
state.clone(),
vec![http_response("200 OK", "application/json", body)],
)
.await;
let client = OpenAiCompatClient::new("xai-test-key", OpenAiCompatConfig::xai())
.with_base_url(server.base_url());
let response = client
.send_message(&sample_request(false))
.await
.expect("request should succeed");
assert_eq!(response.model, "grok-3");
assert_eq!(response.total_tokens(), 16);
assert_eq!(
response.content,
vec![OutputContentBlock::Text {
text: "Hello from Grok".to_string(),
}]
);
let captured = state.lock().await;
let request = captured.first().expect("server should capture request");
assert_eq!(request.path, "/chat/completions");
assert_eq!(
request.headers.get("authorization").map(String::as_str),
Some("Bearer xai-test-key")
);
let body: serde_json::Value = serde_json::from_str(&request.body).expect("json body");
assert_eq!(body["model"], json!("grok-3"));
assert_eq!(body["messages"][0]["role"], json!("system"));
assert_eq!(body["tools"][0]["type"], json!("function"));
}
#[tokio::test]
async fn send_message_accepts_full_chat_completions_endpoint_override() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let body = concat!(
"{",
"\"id\":\"chatcmpl_full_endpoint\",",
"\"model\":\"grok-3\",",
"\"choices\":[{",
"\"message\":{\"role\":\"assistant\",\"content\":\"Endpoint override works\",\"tool_calls\":[]},",
"\"finish_reason\":\"stop\"",
"}],",
"\"usage\":{\"prompt_tokens\":7,\"completion_tokens\":3}",
"}"
);
let server = spawn_server(
state.clone(),
vec![http_response("200 OK", "application/json", body)],
)
.await;
let endpoint_url = format!("{}/chat/completions", server.base_url());
let client = OpenAiCompatClient::new("xai-test-key", OpenAiCompatConfig::xai())
.with_base_url(endpoint_url);
let response = client
.send_message(&sample_request(false))
.await
.expect("request should succeed");
assert_eq!(response.total_tokens(), 10);
let captured = state.lock().await;
let request = captured.first().expect("server should capture request");
assert_eq!(request.path, "/chat/completions");
}
#[tokio::test]
async fn stream_message_normalizes_text_and_multiple_tool_calls() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let sse = concat!(
"data: {\"id\":\"chatcmpl_stream\",\"model\":\"grok-3\",\"choices\":[{\"delta\":{\"content\":\"Hello\"}}]}\n\n",
"data: {\"id\":\"chatcmpl_stream\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"function\":{\"name\":\"weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}},{\"index\":1,\"id\":\"call_2\",\"function\":{\"name\":\"clock\",\"arguments\":\"{\\\"zone\\\":\\\"UTC\\\"}\"}}]}}]}\n\n",
"data: {\"id\":\"chatcmpl_stream\",\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n",
"data: [DONE]\n\n"
);
let server = spawn_server(
state.clone(),
vec![http_response_with_headers(
"200 OK",
"text/event-stream",
sse,
&[("x-request-id", "req_grok_stream")],
)],
)
.await;
let client = OpenAiCompatClient::new("xai-test-key", OpenAiCompatConfig::xai())
.with_base_url(server.base_url());
let mut stream = client
.stream_message(&sample_request(false))
.await
.expect("stream should start");
assert_eq!(stream.request_id(), Some("req_grok_stream"));
let mut events = Vec::new();
while let Some(event) = stream.next_event().await.expect("event should parse") {
events.push(event);
}
assert!(matches!(events[0], StreamEvent::MessageStart(_)));
assert!(matches!(
events[1],
StreamEvent::ContentBlockStart(ContentBlockStartEvent {
content_block: OutputContentBlock::Text { .. },
..
})
));
assert!(matches!(
events[2],
StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
delta: ContentBlockDelta::TextDelta { .. },
..
})
));
assert!(matches!(
events[3],
StreamEvent::ContentBlockStart(ContentBlockStartEvent {
index: 1,
content_block: OutputContentBlock::ToolUse { .. },
})
));
assert!(matches!(
events[4],
StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
index: 1,
delta: ContentBlockDelta::InputJsonDelta { .. },
})
));
assert!(matches!(
events[5],
StreamEvent::ContentBlockStart(ContentBlockStartEvent {
index: 2,
content_block: OutputContentBlock::ToolUse { .. },
})
));
assert!(matches!(
events[6],
StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
index: 2,
delta: ContentBlockDelta::InputJsonDelta { .. },
})
));
assert!(matches!(
events[7],
StreamEvent::ContentBlockStop(ContentBlockStopEvent { index: 1 })
));
assert!(matches!(
events[8],
StreamEvent::ContentBlockStop(ContentBlockStopEvent { index: 2 })
));
assert!(matches!(
events[9],
StreamEvent::ContentBlockStop(ContentBlockStopEvent { index: 0 })
));
assert!(matches!(events[10], StreamEvent::MessageDelta(_)));
assert!(matches!(events[11], StreamEvent::MessageStop(_)));
let captured = state.lock().await;
let request = captured.first().expect("captured request");
assert_eq!(request.path, "/chat/completions");
assert!(request.body.contains("\"stream\":true"));
}
#[tokio::test]
async fn provider_client_dispatches_xai_requests_from_env() {
let _lock = env_lock();
let _api_key = ScopedEnvVar::set("XAI_API_KEY", "xai-test-key");
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let server = spawn_server(
state.clone(),
vec![http_response(
"200 OK",
"application/json",
"{\"id\":\"chatcmpl_provider\",\"model\":\"grok-3\",\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"Through provider client\",\"tool_calls\":[]},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":9,\"completion_tokens\":4}}",
)],
)
.await;
let _base_url = ScopedEnvVar::set("XAI_BASE_URL", server.base_url());
let client =
ProviderClient::from_model("grok").expect("xAI provider client should be constructed");
assert!(matches!(client, ProviderClient::Xai(_)));
let response = client
.send_message(&sample_request(false))
.await
.expect("provider-dispatched request should succeed");
assert_eq!(response.total_tokens(), 13);
let captured = state.lock().await;
let request = captured.first().expect("captured request");
assert_eq!(request.path, "/chat/completions");
assert_eq!(
request.headers.get("authorization").map(String::as_str),
Some("Bearer xai-test-key")
);
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct CapturedRequest {
path: String,
headers: HashMap<String, String>,
body: String,
}
struct TestServer {
base_url: String,
join_handle: tokio::task::JoinHandle<()>,
}
impl TestServer {
fn base_url(&self) -> String {
self.base_url.clone()
}
}
impl Drop for TestServer {
fn drop(&mut self) {
self.join_handle.abort();
}
}
async fn spawn_server(
state: Arc<Mutex<Vec<CapturedRequest>>>,
responses: Vec<String>,
) -> TestServer {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("listener should bind");
let address = listener.local_addr().expect("listener addr");
let join_handle = tokio::spawn(async move {
for response in responses {
let (mut socket, _) = listener.accept().await.expect("accept");
let mut buffer = Vec::new();
let mut header_end = None;
loop {
let mut chunk = [0_u8; 1024];
let read = socket.read(&mut chunk).await.expect("read request");
if read == 0 {
break;
}
buffer.extend_from_slice(&chunk[..read]);
if let Some(position) = find_header_end(&buffer) {
header_end = Some(position);
break;
}
}
let header_end = header_end.expect("headers should exist");
let (header_bytes, remaining) = buffer.split_at(header_end);
let header_text = String::from_utf8(header_bytes.to_vec()).expect("utf8 headers");
let mut lines = header_text.split("\r\n");
let request_line = lines.next().expect("request line");
let path = request_line
.split_whitespace()
.nth(1)
.expect("path")
.to_string();
let mut headers = HashMap::new();
let mut content_length = 0_usize;
for line in lines {
if line.is_empty() {
continue;
}
let (name, value) = line.split_once(':').expect("header");
let value = value.trim().to_string();
if name.eq_ignore_ascii_case("content-length") {
content_length = value.parse().expect("content length");
}
headers.insert(name.to_ascii_lowercase(), value);
}
let mut body = remaining[4..].to_vec();
while body.len() < content_length {
let mut chunk = vec![0_u8; content_length - body.len()];
let read = socket.read(&mut chunk).await.expect("read body");
if read == 0 {
break;
}
body.extend_from_slice(&chunk[..read]);
}
state.lock().await.push(CapturedRequest {
path,
headers,
body: String::from_utf8(body).expect("utf8 body"),
});
socket
.write_all(response.as_bytes())
.await
.expect("write response");
}
});
TestServer {
base_url: format!("http://{address}"),
join_handle,
}
}
fn find_header_end(bytes: &[u8]) -> Option<usize> {
bytes.windows(4).position(|window| window == b"\r\n\r\n")
}
fn http_response(status: &str, content_type: &str, body: &str) -> String {
http_response_with_headers(status, content_type, body, &[])
}
fn http_response_with_headers(
status: &str,
content_type: &str,
body: &str,
headers: &[(&str, &str)],
) -> String {
let mut extra_headers = String::new();
for (name, value) in headers {
use std::fmt::Write as _;
write!(&mut extra_headers, "{name}: {value}\r\n").expect("header write");
}
format!(
"HTTP/1.1 {status}\r\ncontent-type: {content_type}\r\n{extra_headers}content-length: {}\r\nconnection: close\r\n\r\n{body}",
body.len()
)
}
fn sample_request(stream: bool) -> MessageRequest {
MessageRequest {
model: "grok-3".to_string(),
max_tokens: 64,
messages: vec![InputMessage {
role: "user".to_string(),
content: vec![InputContentBlock::Text {
text: "Say hello".to_string(),
}],
}],
system: Some("Use tools when needed".to_string()),
tools: Some(vec![ToolDefinition {
name: "weather".to_string(),
description: Some("Fetches weather".to_string()),
input_schema: json!({
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"]
}),
}]),
tool_choice: Some(ToolChoice::Auto),
stream,
}
}
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<StdMutex<()>> = OnceLock::new();
LOCK.get_or_init(|| StdMutex::new(()))
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
struct ScopedEnvVar {
key: &'static str,
previous: Option<OsString>,
}
impl ScopedEnvVar {
fn set(key: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
let previous = std::env::var_os(key);
std::env::set_var(key, value);
Self { key, previous }
}
}
impl Drop for ScopedEnvVar {
fn drop(&mut self) {
match &self.previous {
Some(value) => std::env::set_var(self.key, value),
None => std::env::remove_var(self.key),
}
}
}

View File

@@ -1,86 +0,0 @@
use std::ffi::OsString;
use std::sync::{Mutex, OnceLock};
use api::{read_xai_base_url, ApiError, AuthSource, ProviderClient, ProviderKind};
#[test]
fn provider_client_routes_grok_aliases_through_xai() {
let _lock = env_lock();
let _xai_api_key = EnvVarGuard::set("XAI_API_KEY", Some("xai-test-key"));
let client = ProviderClient::from_model("grok-mini").expect("grok alias should resolve");
assert_eq!(client.provider_kind(), ProviderKind::Xai);
}
#[test]
fn provider_client_reports_missing_xai_credentials_for_grok_models() {
let _lock = env_lock();
let _xai_api_key = EnvVarGuard::set("XAI_API_KEY", None);
let error = ProviderClient::from_model("grok-3")
.expect_err("grok requests without XAI_API_KEY should fail fast");
match error {
ApiError::MissingCredentials { provider, env_vars } => {
assert_eq!(provider, "xAI");
assert_eq!(env_vars, &["XAI_API_KEY"]);
}
other => panic!("expected missing xAI credentials, got {other:?}"),
}
}
#[test]
fn provider_client_uses_explicit_anthropic_auth_without_env_lookup() {
let _lock = env_lock();
let _anthropic_api_key = EnvVarGuard::set("ANTHROPIC_API_KEY", None);
let _anthropic_auth_token = EnvVarGuard::set("ANTHROPIC_AUTH_TOKEN", None);
let client = ProviderClient::from_model_with_anthropic_auth(
"claude-sonnet-4-6",
Some(AuthSource::ApiKey("anthropic-test-key".to_string())),
)
.expect("explicit anthropic auth should avoid env lookup");
assert_eq!(client.provider_kind(), ProviderKind::Anthropic);
}
#[test]
fn read_xai_base_url_prefers_env_override() {
let _lock = env_lock();
let _xai_base_url = EnvVarGuard::set("XAI_BASE_URL", Some("https://example.xai.test/v1"));
assert_eq!(read_xai_base_url(), "https://example.xai.test/v1");
}
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
struct EnvVarGuard {
key: &'static str,
original: Option<OsString>,
}
impl EnvVarGuard {
fn set(key: &'static str, value: Option<&str>) -> Self {
let original = std::env::var_os(key);
match value {
Some(value) => std::env::set_var(key, value),
None => std::env::remove_var(key),
}
Self { key, original }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
match &self.original {
Some(value) => std::env::set_var(self.key, value),
None => std::env::remove_var(self.key),
}
}
}

View File

@@ -9,6 +9,4 @@ publish.workspace = true
workspace = true
[dependencies]
plugins = { path = "../plugins" }
runtime = { path = "../runtime" }
serde_json.workspace = true

File diff suppressed because it is too large Load Diff

View File

@@ -70,12 +70,16 @@ fn upstream_repo_candidates(primary_repo_root: &Path) -> Vec<PathBuf> {
}
for ancestor in primary_repo_root.ancestors().take(4) {
candidates.push(ancestor.join("claw-code"));
candidates.push(ancestor.join("claude-code"));
candidates.push(ancestor.join("clawd-code"));
}
candidates.push(primary_repo_root.join("reference-source").join("claw-code"));
candidates.push(primary_repo_root.join("vendor").join("claw-code"));
candidates.push(
primary_repo_root
.join("reference-source")
.join("claude-code"),
);
candidates.push(primary_repo_root.join("vendor").join("claude-code"));
let mut deduped = Vec::new();
for candidate in candidates {

View File

@@ -1,13 +0,0 @@
[package]
name = "plugins"
version.workspace = true
edition.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json.workspace = true
[lints]
workspace = true

View File

@@ -1,10 +0,0 @@
{
"name": "example-bundled",
"version": "0.1.0",
"description": "Example bundled plugin scaffold for the Rust plugin system",
"defaultEnabled": false,
"hooks": {
"PreToolUse": ["./hooks/pre.sh"],
"PostToolUse": ["./hooks/post.sh"]
}
}

View File

@@ -1,2 +0,0 @@
#!/bin/sh
printf '%s\n' 'example bundled post hook'

View File

@@ -1,2 +0,0 @@
#!/bin/sh
printf '%s\n' 'example bundled pre hook'

View File

@@ -1,10 +0,0 @@
{
"name": "sample-hooks",
"version": "0.1.0",
"description": "Bundled sample plugin scaffold for hook integration tests.",
"defaultEnabled": false,
"hooks": {
"PreToolUse": ["./hooks/pre.sh"],
"PostToolUse": ["./hooks/post.sh"]
}
}

View File

@@ -1,2 +0,0 @@
#!/bin/sh
printf 'sample bundled post hook'

View File

@@ -1,2 +0,0 @@
#!/bin/sh
printf 'sample bundled pre hook'

View File

@@ -1,395 +0,0 @@
use std::ffi::OsStr;
use std::path::Path;
use std::process::Command;
use serde_json::json;
use crate::{PluginError, PluginHooks, PluginRegistry};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookEvent {
PreToolUse,
PostToolUse,
}
impl HookEvent {
fn as_str(self) -> &'static str {
match self {
Self::PreToolUse => "PreToolUse",
Self::PostToolUse => "PostToolUse",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HookRunResult {
denied: bool,
messages: Vec<String>,
}
impl HookRunResult {
#[must_use]
pub fn allow(messages: Vec<String>) -> Self {
Self {
denied: false,
messages,
}
}
#[must_use]
pub fn is_denied(&self) -> bool {
self.denied
}
#[must_use]
pub fn messages(&self) -> &[String] {
&self.messages
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct HookRunner {
hooks: PluginHooks,
}
impl HookRunner {
#[must_use]
pub fn new(hooks: PluginHooks) -> Self {
Self { hooks }
}
pub fn from_registry(plugin_registry: &PluginRegistry) -> Result<Self, PluginError> {
Ok(Self::new(plugin_registry.aggregated_hooks()?))
}
#[must_use]
pub fn run_pre_tool_use(&self, tool_name: &str, tool_input: &str) -> HookRunResult {
self.run_commands(
HookEvent::PreToolUse,
&self.hooks.pre_tool_use,
tool_name,
tool_input,
None,
false,
)
}
#[must_use]
pub fn run_post_tool_use(
&self,
tool_name: &str,
tool_input: &str,
tool_output: &str,
is_error: bool,
) -> HookRunResult {
self.run_commands(
HookEvent::PostToolUse,
&self.hooks.post_tool_use,
tool_name,
tool_input,
Some(tool_output),
is_error,
)
}
fn run_commands(
&self,
event: HookEvent,
commands: &[String],
tool_name: &str,
tool_input: &str,
tool_output: Option<&str>,
is_error: bool,
) -> HookRunResult {
if commands.is_empty() {
return HookRunResult::allow(Vec::new());
}
let payload = json!({
"hook_event_name": event.as_str(),
"tool_name": tool_name,
"tool_input": parse_tool_input(tool_input),
"tool_input_json": tool_input,
"tool_output": tool_output,
"tool_result_is_error": is_error,
})
.to_string();
let mut messages = Vec::new();
for command in commands {
match self.run_command(
command,
event,
tool_name,
tool_input,
tool_output,
is_error,
&payload,
) {
HookCommandOutcome::Allow { message } => {
if let Some(message) = message {
messages.push(message);
}
}
HookCommandOutcome::Deny { message } => {
messages.push(message.unwrap_or_else(|| {
format!("{} hook denied tool `{tool_name}`", event.as_str())
}));
return HookRunResult {
denied: true,
messages,
};
}
HookCommandOutcome::Warn { message } => messages.push(message),
}
}
HookRunResult::allow(messages)
}
#[allow(clippy::too_many_arguments, clippy::unused_self)]
fn run_command(
&self,
command: &str,
event: HookEvent,
tool_name: &str,
tool_input: &str,
tool_output: Option<&str>,
is_error: bool,
payload: &str,
) -> HookCommandOutcome {
let mut child = shell_command(command);
child.stdin(std::process::Stdio::piped());
child.stdout(std::process::Stdio::piped());
child.stderr(std::process::Stdio::piped());
child.env("HOOK_EVENT", event.as_str());
child.env("HOOK_TOOL_NAME", tool_name);
child.env("HOOK_TOOL_INPUT", tool_input);
child.env("HOOK_TOOL_IS_ERROR", if is_error { "1" } else { "0" });
if let Some(tool_output) = tool_output {
child.env("HOOK_TOOL_OUTPUT", tool_output);
}
match child.output_with_stdin(payload.as_bytes()) {
Ok(output) => {
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let message = (!stdout.is_empty()).then_some(stdout);
match output.status.code() {
Some(0) => HookCommandOutcome::Allow { message },
Some(2) => HookCommandOutcome::Deny { message },
Some(code) => HookCommandOutcome::Warn {
message: format_hook_warning(
command,
code,
message.as_deref(),
stderr.as_str(),
),
},
None => HookCommandOutcome::Warn {
message: format!(
"{} hook `{command}` terminated by signal while handling `{tool_name}`",
event.as_str()
),
},
}
}
Err(error) => HookCommandOutcome::Warn {
message: format!(
"{} hook `{command}` failed to start for `{tool_name}`: {error}",
event.as_str()
),
},
}
}
}
enum HookCommandOutcome {
Allow { message: Option<String> },
Deny { message: Option<String> },
Warn { message: String },
}
fn parse_tool_input(tool_input: &str) -> serde_json::Value {
serde_json::from_str(tool_input).unwrap_or_else(|_| json!({ "raw": tool_input }))
}
fn format_hook_warning(command: &str, code: i32, stdout: Option<&str>, stderr: &str) -> String {
let mut message =
format!("Hook `{command}` exited with status {code}; allowing tool execution to continue");
if let Some(stdout) = stdout.filter(|stdout| !stdout.is_empty()) {
message.push_str(": ");
message.push_str(stdout);
} else if !stderr.is_empty() {
message.push_str(": ");
message.push_str(stderr);
}
message
}
fn shell_command(command: &str) -> CommandWithStdin {
#[cfg(windows)]
let command_builder = {
let mut command_builder = Command::new("cmd");
command_builder.arg("/C").arg(command);
CommandWithStdin::new(command_builder)
};
#[cfg(not(windows))]
let command_builder = if Path::new(command).exists() {
let mut command_builder = Command::new("sh");
command_builder.arg(command);
CommandWithStdin::new(command_builder)
} else {
let mut command_builder = Command::new("sh");
command_builder.arg("-lc").arg(command);
CommandWithStdin::new(command_builder)
};
command_builder
}
struct CommandWithStdin {
command: Command,
}
impl CommandWithStdin {
fn new(command: Command) -> Self {
Self { command }
}
fn stdin(&mut self, cfg: std::process::Stdio) -> &mut Self {
self.command.stdin(cfg);
self
}
fn stdout(&mut self, cfg: std::process::Stdio) -> &mut Self {
self.command.stdout(cfg);
self
}
fn stderr(&mut self, cfg: std::process::Stdio) -> &mut Self {
self.command.stderr(cfg);
self
}
fn env<K, V>(&mut self, key: K, value: V) -> &mut Self
where
K: AsRef<OsStr>,
V: AsRef<OsStr>,
{
self.command.env(key, value);
self
}
fn output_with_stdin(&mut self, stdin: &[u8]) -> std::io::Result<std::process::Output> {
let mut child = self.command.spawn()?;
if let Some(mut child_stdin) = child.stdin.take() {
use std::io::Write as _;
child_stdin.write_all(stdin)?;
}
child.wait_with_output()
}
}
#[cfg(test)]
mod tests {
use super::{HookRunResult, HookRunner};
use crate::{PluginManager, PluginManagerConfig};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_dir(label: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time should be after epoch")
.as_nanos();
std::env::temp_dir().join(format!("plugins-hook-runner-{label}-{nanos}"))
}
fn write_hook_plugin(root: &Path, name: &str, pre_message: &str, post_message: &str) {
fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
fs::create_dir_all(root.join("hooks")).expect("hooks dir");
fs::write(
root.join("hooks").join("pre.sh"),
format!("#!/bin/sh\nprintf '%s\\n' '{pre_message}'\n"),
)
.expect("write pre hook");
fs::write(
root.join("hooks").join("post.sh"),
format!("#!/bin/sh\nprintf '%s\\n' '{post_message}'\n"),
)
.expect("write post hook");
fs::write(
root.join(".claude-plugin").join("plugin.json"),
format!(
"{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"hook plugin\",\n \"hooks\": {{\n \"PreToolUse\": [\"./hooks/pre.sh\"],\n \"PostToolUse\": [\"./hooks/post.sh\"]\n }}\n}}"
),
)
.expect("write plugin manifest");
}
#[test]
fn collects_and_runs_hooks_from_enabled_plugins() {
let config_home = temp_dir("config");
let first_source_root = temp_dir("source-a");
let second_source_root = temp_dir("source-b");
write_hook_plugin(
&first_source_root,
"first",
"plugin pre one",
"plugin post one",
);
write_hook_plugin(
&second_source_root,
"second",
"plugin pre two",
"plugin post two",
);
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
manager
.install(first_source_root.to_str().expect("utf8 path"))
.expect("first plugin install should succeed");
manager
.install(second_source_root.to_str().expect("utf8 path"))
.expect("second plugin install should succeed");
let registry = manager.plugin_registry().expect("registry should build");
let runner = HookRunner::from_registry(&registry).expect("plugin hooks should load");
assert_eq!(
runner.run_pre_tool_use("Read", r#"{"path":"README.md"}"#),
HookRunResult::allow(vec![
"plugin pre one".to_string(),
"plugin pre two".to_string(),
])
);
assert_eq!(
runner.run_post_tool_use("Read", r#"{"path":"README.md"}"#, "ok", false),
HookRunResult::allow(vec![
"plugin post one".to_string(),
"plugin post two".to_string(),
])
);
let _ = fs::remove_dir_all(config_home);
let _ = fs::remove_dir_all(first_source_root);
let _ = fs::remove_dir_all(second_source_root);
}
#[test]
fn pre_tool_use_denies_when_plugin_hook_exits_two() {
let runner = HookRunner::new(crate::PluginHooks {
pre_tool_use: vec!["printf 'blocked by plugin'; exit 2".to_string()],
post_tool_use: Vec::new(),
});
let result = runner.run_pre_tool_use("Bash", r#"{"command":"pwd"}"#);
assert!(result.is_denied());
assert_eq!(result.messages(), &["blocked by plugin".to_string()]);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -8,10 +8,9 @@ publish.workspace = true
[dependencies]
sha2 = "0.10"
glob = "0.3"
plugins = { path = "../plugins" }
regex = "1"
serde = { version = "1", features = ["derive"] }
serde_json.workspace = true
serde_json = "1"
tokio = { version = "1", features = ["io-util", "macros", "process", "rt", "rt-multi-thread", "time"] }
walkdir = "2"

View File

@@ -1,10 +1,5 @@
use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
const COMPACT_CONTINUATION_PREAMBLE: &str =
"This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.\n\n";
const COMPACT_RECENT_MESSAGES_NOTE: &str = "Recent messages are preserved verbatim.";
const COMPACT_DIRECT_RESUME_INSTRUCTION: &str = "Continue the conversation from where it left off without asking the user any further questions. Resume directly — do not acknowledge the summary, do not recap what was happening, and do not preface with continuation text.";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CompactionConfig {
pub preserve_recent_messages: usize,
@@ -35,15 +30,8 @@ pub fn estimate_session_tokens(session: &Session) -> usize {
#[must_use]
pub fn should_compact(session: &Session, config: CompactionConfig) -> bool {
let start = compacted_summary_prefix_len(session);
let compactable = &session.messages[start..];
compactable.len() > config.preserve_recent_messages
&& compactable
.iter()
.map(estimate_message_tokens)
.sum::<usize>()
>= config.max_estimated_tokens
session.messages.len() > config.preserve_recent_messages
&& estimate_session_tokens(session) >= config.max_estimated_tokens
}
#[must_use]
@@ -68,18 +56,16 @@ pub fn get_compact_continuation_message(
recent_messages_preserved: bool,
) -> String {
let mut base = format!(
"{COMPACT_CONTINUATION_PREAMBLE}{}",
"This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.\n\n{}",
format_compact_summary(summary)
);
if recent_messages_preserved {
base.push_str("\n\n");
base.push_str(COMPACT_RECENT_MESSAGES_NOTE);
base.push_str("\n\nRecent messages are preserved verbatim.");
}
if suppress_follow_up_questions {
base.push('\n');
base.push_str(COMPACT_DIRECT_RESUME_INSTRUCTION);
base.push_str("\nContinue the conversation from where it left off without asking the user any further questions. Resume directly — do not acknowledge the summary, do not recap what was happening, and do not preface with continuation text.");
}
base
@@ -96,19 +82,13 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio
};
}
let existing_summary = session
.messages
.first()
.and_then(extract_existing_compacted_summary);
let compacted_prefix_len = usize::from(existing_summary.is_some());
let keep_from = session
.messages
.len()
.saturating_sub(config.preserve_recent_messages);
let removed = &session.messages[compacted_prefix_len..keep_from];
let removed = &session.messages[..keep_from];
let preserved = session.messages[keep_from..].to_vec();
let summary =
merge_compact_summaries(existing_summary.as_deref(), &summarize_messages(removed));
let summary = summarize_messages(removed);
let formatted_summary = format_compact_summary(&summary);
let continuation = get_compact_continuation_message(&summary, true, !preserved.is_empty());
@@ -130,16 +110,6 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio
}
}
fn compacted_summary_prefix_len(session: &Session) -> usize {
usize::from(
session
.messages
.first()
.and_then(extract_existing_compacted_summary)
.is_some(),
)
}
fn summarize_messages(messages: &[ConversationMessage]) -> String {
let user_messages = messages
.iter()
@@ -227,41 +197,6 @@ fn summarize_messages(messages: &[ConversationMessage]) -> String {
lines.join("\n")
}
fn merge_compact_summaries(existing_summary: Option<&str>, new_summary: &str) -> String {
let Some(existing_summary) = existing_summary else {
return new_summary.to_string();
};
let previous_highlights = extract_summary_highlights(existing_summary);
let new_formatted_summary = format_compact_summary(new_summary);
let new_highlights = extract_summary_highlights(&new_formatted_summary);
let new_timeline = extract_summary_timeline(&new_formatted_summary);
let mut lines = vec!["<summary>".to_string(), "Conversation summary:".to_string()];
if !previous_highlights.is_empty() {
lines.push("- Previously compacted context:".to_string());
lines.extend(
previous_highlights
.into_iter()
.map(|line| format!(" {line}")),
);
}
if !new_highlights.is_empty() {
lines.push("- Newly compacted context:".to_string());
lines.extend(new_highlights.into_iter().map(|line| format!(" {line}")));
}
if !new_timeline.is_empty() {
lines.push("- Key timeline:".to_string());
lines.extend(new_timeline.into_iter().map(|line| format!(" {line}")));
}
lines.push("</summary>".to_string());
lines.join("\n")
}
fn summarize_block(block: &ContentBlock) -> String {
let raw = match block {
ContentBlock::Text { text } => text.clone(),
@@ -439,71 +374,11 @@ fn collapse_blank_lines(content: &str) -> String {
result
}
fn extract_existing_compacted_summary(message: &ConversationMessage) -> Option<String> {
if message.role != MessageRole::System {
return None;
}
let text = first_text_block(message)?;
let summary = text.strip_prefix(COMPACT_CONTINUATION_PREAMBLE)?;
let summary = summary
.split_once(&format!("\n\n{COMPACT_RECENT_MESSAGES_NOTE}"))
.map_or(summary, |(value, _)| value);
let summary = summary
.split_once(&format!("\n{COMPACT_DIRECT_RESUME_INSTRUCTION}"))
.map_or(summary, |(value, _)| value);
Some(summary.trim().to_string())
}
fn extract_summary_highlights(summary: &str) -> Vec<String> {
let mut lines = Vec::new();
let mut in_timeline = false;
for line in format_compact_summary(summary).lines() {
let trimmed = line.trim_end();
if trimmed.is_empty() || trimmed == "Summary:" || trimmed == "Conversation summary:" {
continue;
}
if trimmed == "- Key timeline:" {
in_timeline = true;
continue;
}
if in_timeline {
continue;
}
lines.push(trimmed.to_string());
}
lines
}
fn extract_summary_timeline(summary: &str) -> Vec<String> {
let mut lines = Vec::new();
let mut in_timeline = false;
for line in format_compact_summary(summary).lines() {
let trimmed = line.trim_end();
if trimmed == "- Key timeline:" {
in_timeline = true;
continue;
}
if !in_timeline {
continue;
}
if trimmed.is_empty() {
break;
}
lines.push(trimmed.to_string());
}
lines
}
#[cfg(test)]
mod tests {
use super::{
collect_key_files, compact_session, estimate_session_tokens, format_compact_summary,
get_compact_continuation_message, infer_pending_work, should_compact, CompactionConfig,
infer_pending_work, should_compact, CompactionConfig,
};
use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
@@ -578,98 +453,6 @@ mod tests {
);
}
#[test]
fn keeps_previous_compacted_context_when_compacting_again() {
let initial_session = Session {
version: 1,
messages: vec![
ConversationMessage::user_text("Investigate rust/crates/runtime/src/compact.rs"),
ConversationMessage::assistant(vec![ContentBlock::Text {
text: "I will inspect the compact flow.".to_string(),
}]),
ConversationMessage::user_text(
"Also update rust/crates/runtime/src/conversation.rs",
),
ConversationMessage::assistant(vec![ContentBlock::Text {
text: "Next: preserve prior summary context during auto compact.".to_string(),
}]),
],
};
let config = CompactionConfig {
preserve_recent_messages: 2,
max_estimated_tokens: 1,
};
let first = compact_session(&initial_session, config);
let mut follow_up_messages = first.compacted_session.messages.clone();
follow_up_messages.extend([
ConversationMessage::user_text("Please add regression tests for compaction."),
ConversationMessage::assistant(vec![ContentBlock::Text {
text: "Working on regression coverage now.".to_string(),
}]),
]);
let second = compact_session(
&Session {
version: 1,
messages: follow_up_messages,
},
config,
);
assert!(second
.formatted_summary
.contains("Previously compacted context:"));
assert!(second
.formatted_summary
.contains("Scope: 2 earlier messages compacted"));
assert!(second
.formatted_summary
.contains("Newly compacted context:"));
assert!(second
.formatted_summary
.contains("Also update rust/crates/runtime/src/conversation.rs"));
assert!(matches!(
&second.compacted_session.messages[0].blocks[0],
ContentBlock::Text { text }
if text.contains("Previously compacted context:")
&& text.contains("Newly compacted context:")
));
assert!(matches!(
&second.compacted_session.messages[1].blocks[0],
ContentBlock::Text { text } if text.contains("Please add regression tests for compaction.")
));
}
#[test]
fn ignores_existing_compacted_summary_when_deciding_to_recompact() {
let summary = "<summary>Conversation summary:\n- Scope: earlier work preserved.\n- Key timeline:\n - user: large preserved context\n</summary>";
let session = Session {
version: 1,
messages: vec![
ConversationMessage {
role: MessageRole::System,
blocks: vec![ContentBlock::Text {
text: get_compact_continuation_message(summary, true, true),
}],
usage: None,
},
ConversationMessage::user_text("tiny"),
ConversationMessage::assistant(vec![ContentBlock::Text {
text: "recent".to_string(),
}]),
],
};
assert!(!should_compact(
&session,
CompactionConfig {
preserve_recent_messages: 2,
max_estimated_tokens: 1,
}
));
}
#[test]
fn truncates_long_blocks_in_summary() {
let summary = super::summarize_block(&ContentBlock::Text {

View File

@@ -6,7 +6,7 @@ use std::path::{Path, PathBuf};
use crate::json::JsonValue;
use crate::sandbox::{FilesystemIsolationMode, SandboxConfig};
pub const CLAW_SETTINGS_SCHEMA_NAME: &str = "SettingsSchema";
pub const CLAUDE_CODE_SETTINGS_SCHEMA_NAME: &str = "SettingsSchema";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum ConfigSource {
@@ -35,19 +35,8 @@ pub struct RuntimeConfig {
feature_config: RuntimeFeatureConfig,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct RuntimePluginConfig {
enabled_plugins: BTreeMap<String, bool>,
external_directories: Vec<String>,
install_root: Option<String>,
registry_path: Option<String>,
bundled_root: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct RuntimeFeatureConfig {
hooks: RuntimeHookConfig,
plugins: RuntimePluginConfig,
mcp: McpConfigCollection,
oauth: Option<OAuthConfig>,
model: Option<String>,
@@ -55,12 +44,6 @@ pub struct RuntimeFeatureConfig {
sandbox: SandboxConfig,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct RuntimeHookConfig {
pre_tool_use: Vec<String>,
post_tool_use: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct McpConfigCollection {
servers: BTreeMap<String, ScopedMcpServerConfig>,
@@ -79,7 +62,7 @@ pub enum McpTransport {
Http,
Ws,
Sdk,
ManagedProxy,
ClaudeAiProxy,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -89,7 +72,7 @@ pub enum McpServerConfig {
Http(McpRemoteServerConfig),
Ws(McpWebSocketServerConfig),
Sdk(McpSdkServerConfig),
ManagedProxy(McpManagedProxyServerConfig),
ClaudeAiProxy(McpClaudeAiProxyServerConfig),
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -120,7 +103,7 @@ pub struct McpSdkServerConfig {
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct McpManagedProxyServerConfig {
pub struct McpClaudeAiProxyServerConfig {
pub url: String,
pub id: String,
}
@@ -184,20 +167,18 @@ impl ConfigLoader {
#[must_use]
pub fn default_for(cwd: impl Into<PathBuf>) -> Self {
let cwd = cwd.into();
let config_home = default_config_home();
let config_home = std::env::var_os("CLAUDE_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".claude")))
.unwrap_or_else(|| PathBuf::from(".claude"));
Self { cwd, config_home }
}
#[must_use]
pub fn config_home(&self) -> &Path {
&self.config_home
}
#[must_use]
pub fn discover(&self) -> Vec<ConfigEntry> {
let user_legacy_path = self.config_home.parent().map_or_else(
|| PathBuf::from(".claw.json"),
|parent| parent.join(".claw.json"),
|| PathBuf::from(".claude.json"),
|parent| parent.join(".claude.json"),
);
vec![
ConfigEntry {
@@ -210,15 +191,15 @@ impl ConfigLoader {
},
ConfigEntry {
source: ConfigSource::Project,
path: self.cwd.join(".claw.json"),
path: self.cwd.join(".claude.json"),
},
ConfigEntry {
source: ConfigSource::Project,
path: self.cwd.join(".claw").join("settings.json"),
path: self.cwd.join(".claude").join("settings.json"),
},
ConfigEntry {
source: ConfigSource::Local,
path: self.cwd.join(".claw").join("settings.local.json"),
path: self.cwd.join(".claude").join("settings.local.json"),
},
]
}
@@ -240,8 +221,6 @@ impl ConfigLoader {
let merged_value = JsonValue::Object(merged.clone());
let feature_config = RuntimeFeatureConfig {
hooks: parse_optional_hooks_config(&merged_value)?,
plugins: parse_optional_plugin_config(&merged_value)?,
mcp: McpConfigCollection {
servers: mcp_servers,
},
@@ -299,16 +278,6 @@ impl RuntimeConfig {
&self.feature_config.mcp
}
#[must_use]
pub fn hooks(&self) -> &RuntimeHookConfig {
&self.feature_config.hooks
}
#[must_use]
pub fn plugins(&self) -> &RuntimePluginConfig {
&self.feature_config.plugins
}
#[must_use]
pub fn oauth(&self) -> Option<&OAuthConfig> {
self.feature_config.oauth.as_ref()
@@ -331,28 +300,6 @@ impl RuntimeConfig {
}
impl RuntimeFeatureConfig {
#[must_use]
pub fn with_hooks(mut self, hooks: RuntimeHookConfig) -> Self {
self.hooks = hooks;
self
}
#[must_use]
pub fn with_plugins(mut self, plugins: RuntimePluginConfig) -> Self {
self.plugins = plugins;
self
}
#[must_use]
pub fn hooks(&self) -> &RuntimeHookConfig {
&self.hooks
}
#[must_use]
pub fn plugins(&self) -> &RuntimePluginConfig {
&self.plugins
}
#[must_use]
pub fn mcp(&self) -> &McpConfigCollection {
&self.mcp
@@ -379,85 +326,6 @@ impl RuntimeFeatureConfig {
}
}
impl RuntimePluginConfig {
#[must_use]
pub fn enabled_plugins(&self) -> &BTreeMap<String, bool> {
&self.enabled_plugins
}
#[must_use]
pub fn external_directories(&self) -> &[String] {
&self.external_directories
}
#[must_use]
pub fn install_root(&self) -> Option<&str> {
self.install_root.as_deref()
}
#[must_use]
pub fn registry_path(&self) -> Option<&str> {
self.registry_path.as_deref()
}
#[must_use]
pub fn bundled_root(&self) -> Option<&str> {
self.bundled_root.as_deref()
}
pub fn set_plugin_state(&mut self, plugin_id: String, enabled: bool) {
self.enabled_plugins.insert(plugin_id, enabled);
}
#[must_use]
pub fn state_for(&self, plugin_id: &str, default_enabled: bool) -> bool {
self.enabled_plugins
.get(plugin_id)
.copied()
.unwrap_or(default_enabled)
}
}
#[must_use]
pub fn default_config_home() -> PathBuf {
std::env::var_os("CLAW_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".claw")))
.unwrap_or_else(|| PathBuf::from(".claw"))
}
impl RuntimeHookConfig {
#[must_use]
pub fn new(pre_tool_use: Vec<String>, post_tool_use: Vec<String>) -> Self {
Self {
pre_tool_use,
post_tool_use,
}
}
#[must_use]
pub fn pre_tool_use(&self) -> &[String] {
&self.pre_tool_use
}
#[must_use]
pub fn post_tool_use(&self) -> &[String] {
&self.post_tool_use
}
#[must_use]
pub fn merged(&self, other: &Self) -> Self {
let mut merged = self.clone();
merged.extend(other);
merged
}
pub fn extend(&mut self, other: &Self) {
extend_unique(&mut self.pre_tool_use, other.pre_tool_use());
extend_unique(&mut self.post_tool_use, other.post_tool_use());
}
}
impl McpConfigCollection {
#[must_use]
pub fn servers(&self) -> &BTreeMap<String, ScopedMcpServerConfig> {
@@ -486,7 +354,7 @@ impl McpServerConfig {
Self::Http(_) => McpTransport::Http,
Self::Ws(_) => McpTransport::Ws,
Self::Sdk(_) => McpTransport::Sdk,
Self::ManagedProxy(_) => McpTransport::ManagedProxy,
Self::ClaudeAiProxy(_) => McpTransport::ClaudeAiProxy,
}
}
}
@@ -494,7 +362,7 @@ impl McpServerConfig {
fn read_optional_json_object(
path: &Path,
) -> Result<Option<BTreeMap<String, JsonValue>>, ConfigError> {
let is_legacy_config = path.file_name().and_then(|name| name.to_str()) == Some(".claw.json");
let is_legacy_config = path.file_name().and_then(|name| name.to_str()) == Some(".claude.json");
let contents = match fs::read_to_string(path) {
Ok(contents) => contents,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
@@ -556,52 +424,6 @@ fn parse_optional_model(root: &JsonValue) -> Option<String> {
.map(ToOwned::to_owned)
}
fn parse_optional_hooks_config(root: &JsonValue) -> Result<RuntimeHookConfig, ConfigError> {
let Some(object) = root.as_object() else {
return Ok(RuntimeHookConfig::default());
};
let Some(hooks_value) = object.get("hooks") else {
return Ok(RuntimeHookConfig::default());
};
let hooks = expect_object(hooks_value, "merged settings.hooks")?;
Ok(RuntimeHookConfig {
pre_tool_use: optional_string_array(hooks, "PreToolUse", "merged settings.hooks")?
.unwrap_or_default(),
post_tool_use: optional_string_array(hooks, "PostToolUse", "merged settings.hooks")?
.unwrap_or_default(),
})
}
fn parse_optional_plugin_config(root: &JsonValue) -> Result<RuntimePluginConfig, ConfigError> {
let Some(object) = root.as_object() else {
return Ok(RuntimePluginConfig::default());
};
let mut config = RuntimePluginConfig::default();
if let Some(enabled_plugins) = object.get("enabledPlugins") {
config.enabled_plugins = parse_bool_map(enabled_plugins, "merged settings.enabledPlugins")?;
}
let Some(plugins_value) = object.get("plugins") else {
return Ok(config);
};
let plugins = expect_object(plugins_value, "merged settings.plugins")?;
if let Some(enabled_value) = plugins.get("enabled") {
config.enabled_plugins = parse_bool_map(enabled_value, "merged settings.plugins.enabled")?;
}
config.external_directories =
optional_string_array(plugins, "externalDirectories", "merged settings.plugins")?
.unwrap_or_default();
config.install_root =
optional_string(plugins, "installRoot", "merged settings.plugins")?.map(str::to_string);
config.registry_path =
optional_string(plugins, "registryPath", "merged settings.plugins")?.map(str::to_string);
config.bundled_root =
optional_string(plugins, "bundledRoot", "merged settings.plugins")?.map(str::to_string);
Ok(config)
}
fn parse_optional_permission_mode(
root: &JsonValue,
) -> Result<Option<ResolvedPermissionMode>, ConfigError> {
@@ -724,10 +546,12 @@ fn parse_mcp_server_config(
"sdk" => Ok(McpServerConfig::Sdk(McpSdkServerConfig {
name: expect_string(object, "name", context)?.to_string(),
})),
"claudeai-proxy" => Ok(McpServerConfig::ManagedProxy(McpManagedProxyServerConfig {
url: expect_string(object, "url", context)?.to_string(),
id: expect_string(object, "id", context)?.to_string(),
})),
"claudeai-proxy" => Ok(McpServerConfig::ClaudeAiProxy(
McpClaudeAiProxyServerConfig {
url: expect_string(object, "url", context)?.to_string(),
id: expect_string(object, "id", context)?.to_string(),
},
)),
other => Err(ConfigError::Parse(format!(
"{context}: unsupported MCP server type for {server_name}: {other}"
))),
@@ -832,24 +656,6 @@ fn optional_u16(
}
}
fn parse_bool_map(value: &JsonValue, context: &str) -> Result<BTreeMap<String, bool>, ConfigError> {
let Some(map) = value.as_object() else {
return Err(ConfigError::Parse(format!(
"{context}: expected JSON object"
)));
};
map.iter()
.map(|(key, value)| {
value
.as_bool()
.map(|enabled| (key.clone(), enabled))
.ok_or_else(|| {
ConfigError::Parse(format!("{context}: field {key} must be a boolean"))
})
})
.collect()
}
fn optional_string_array(
object: &BTreeMap<String, JsonValue>,
key: &str,
@@ -924,23 +730,11 @@ fn deep_merge_objects(
}
}
fn extend_unique(target: &mut Vec<String>, values: &[String]) {
for value in values {
push_unique(target, value.clone());
}
}
fn push_unique(target: &mut Vec<String>, value: String) {
if !target.iter().any(|existing| existing == &value) {
target.push(value);
}
}
#[cfg(test)]
mod tests {
use super::{
ConfigLoader, ConfigSource, McpServerConfig, McpTransport, ResolvedPermissionMode,
CLAW_SETTINGS_SCHEMA_NAME,
CLAUDE_CODE_SETTINGS_SCHEMA_NAME,
};
use crate::json::JsonValue;
use crate::sandbox::FilesystemIsolationMode;
@@ -959,7 +753,7 @@ mod tests {
fn rejects_non_object_settings_files() {
let root = temp_dir();
let cwd = root.join("project");
let home = root.join("home").join(".claw");
let home = root.join("home").join(".claude");
fs::create_dir_all(&home).expect("home config dir");
fs::create_dir_all(&cwd).expect("project dir");
fs::write(home.join("settings.json"), "[]").expect("write bad settings");
@@ -978,12 +772,12 @@ mod tests {
fn loads_and_merges_claude_code_config_files_by_precedence() {
let root = temp_dir();
let cwd = root.join("project");
let home = root.join("home").join(".claw");
fs::create_dir_all(cwd.join(".claw")).expect("project config dir");
let home = root.join("home").join(".claude");
fs::create_dir_all(cwd.join(".claude")).expect("project config dir");
fs::create_dir_all(&home).expect("home config dir");
fs::write(
home.parent().expect("home parent").join(".claw.json"),
home.parent().expect("home parent").join(".claude.json"),
r#"{"model":"haiku","env":{"A":"1"},"mcpServers":{"home":{"command":"uvx","args":["home"]}}}"#,
)
.expect("write user compat config");
@@ -993,17 +787,17 @@ mod tests {
)
.expect("write user settings");
fs::write(
cwd.join(".claw.json"),
cwd.join(".claude.json"),
r#"{"model":"project-compat","env":{"B":"2"}}"#,
)
.expect("write project compat config");
fs::write(
cwd.join(".claw").join("settings.json"),
cwd.join(".claude").join("settings.json"),
r#"{"env":{"C":"3"},"hooks":{"PostToolUse":["project"]},"mcpServers":{"project":{"command":"uvx","args":["project"]}}}"#,
)
.expect("write project settings");
fs::write(
cwd.join(".claw").join("settings.local.json"),
cwd.join(".claude").join("settings.local.json"),
r#"{"model":"opus","permissionMode":"acceptEdits"}"#,
)
.expect("write local settings");
@@ -1012,7 +806,7 @@ mod tests {
.load()
.expect("config should load");
assert_eq!(CLAW_SETTINGS_SCHEMA_NAME, "SettingsSchema");
assert_eq!(CLAUDE_CODE_SETTINGS_SCHEMA_NAME, "SettingsSchema");
assert_eq!(loaded.loaded_entries().len(), 5);
assert_eq!(loaded.loaded_entries()[0].source, ConfigSource::User);
assert_eq!(
@@ -1042,8 +836,6 @@ mod tests {
.and_then(JsonValue::as_object)
.expect("hooks object")
.contains_key("PostToolUse"));
assert_eq!(loaded.hooks().pre_tool_use(), &["base".to_string()]);
assert_eq!(loaded.hooks().post_tool_use(), &["project".to_string()]);
assert!(loaded.mcp().get("home").is_some());
assert!(loaded.mcp().get("project").is_some());
@@ -1054,12 +846,12 @@ mod tests {
fn parses_sandbox_config() {
let root = temp_dir();
let cwd = root.join("project");
let home = root.join("home").join(".claw");
fs::create_dir_all(cwd.join(".claw")).expect("project config dir");
let home = root.join("home").join(".claude");
fs::create_dir_all(cwd.join(".claude")).expect("project config dir");
fs::create_dir_all(&home).expect("home config dir");
fs::write(
cwd.join(".claw").join("settings.local.json"),
cwd.join(".claude").join("settings.local.json"),
r#"{
"sandbox": {
"enabled": true,
@@ -1092,8 +884,8 @@ mod tests {
fn parses_typed_mcp_and_oauth_config() {
let root = temp_dir();
let cwd = root.join("project");
let home = root.join("home").join(".claw");
fs::create_dir_all(cwd.join(".claw")).expect("project config dir");
let home = root.join("home").join(".claude");
fs::create_dir_all(cwd.join(".claude")).expect("project config dir");
fs::create_dir_all(&home).expect("home config dir");
fs::write(
@@ -1130,7 +922,7 @@ mod tests {
)
.expect("write user settings");
fs::write(
cwd.join(".claw").join("settings.local.json"),
cwd.join(".claude").join("settings.local.json"),
r#"{
"mcpServers": {
"remote-server": {
@@ -1179,101 +971,11 @@ mod tests {
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn parses_plugin_config_from_enabled_plugins() {
let root = temp_dir();
let cwd = root.join("project");
let home = root.join("home").join(".claw");
fs::create_dir_all(cwd.join(".claw")).expect("project config dir");
fs::create_dir_all(&home).expect("home config dir");
fs::write(
home.join("settings.json"),
r#"{
"enabledPlugins": {
"tool-guard@builtin": true,
"sample-plugin@external": false
}
}"#,
)
.expect("write user settings");
let loaded = ConfigLoader::new(&cwd, &home)
.load()
.expect("config should load");
assert_eq!(
loaded.plugins().enabled_plugins().get("tool-guard@builtin"),
Some(&true)
);
assert_eq!(
loaded
.plugins()
.enabled_plugins()
.get("sample-plugin@external"),
Some(&false)
);
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn parses_plugin_config() {
let root = temp_dir();
let cwd = root.join("project");
let home = root.join("home").join(".claw");
fs::create_dir_all(cwd.join(".claw")).expect("project config dir");
fs::create_dir_all(&home).expect("home config dir");
fs::write(
home.join("settings.json"),
r#"{
"enabledPlugins": {
"core-helpers@builtin": true
},
"plugins": {
"externalDirectories": ["./external-plugins"],
"installRoot": "plugin-cache/installed",
"registryPath": "plugin-cache/installed.json",
"bundledRoot": "./bundled-plugins"
}
}"#,
)
.expect("write plugin settings");
let loaded = ConfigLoader::new(&cwd, &home)
.load()
.expect("config should load");
assert_eq!(
loaded
.plugins()
.enabled_plugins()
.get("core-helpers@builtin"),
Some(&true)
);
assert_eq!(
loaded.plugins().external_directories(),
&["./external-plugins".to_string()]
);
assert_eq!(
loaded.plugins().install_root(),
Some("plugin-cache/installed")
);
assert_eq!(
loaded.plugins().registry_path(),
Some("plugin-cache/installed.json")
);
assert_eq!(loaded.plugins().bundled_root(), Some("./bundled-plugins"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn rejects_invalid_mcp_server_shapes() {
let root = temp_dir();
let cwd = root.join("project");
let home = root.join("home").join(".claw");
let home = root.join("home").join(".claude");
fs::create_dir_all(&home).expect("home config dir");
fs::create_dir_all(&cwd).expect("project dir");
fs::write(

View File

@@ -4,8 +4,6 @@ use std::fmt::{Display, Formatter};
use crate::compact::{
compact_session, estimate_session_tokens, CompactionConfig, CompactionResult,
};
use crate::config::RuntimeFeatureConfig;
use crate::hooks::{HookRunResult, HookRunner};
use crate::permissions::{PermissionOutcome, PermissionPolicy, PermissionPrompter};
use crate::session::{ContentBlock, ConversationMessage, Session};
use crate::usage::{TokenUsage, UsageTracker};
@@ -96,7 +94,6 @@ pub struct ConversationRuntime<C, T> {
system_prompt: Vec<String>,
max_iterations: usize,
usage_tracker: UsageTracker,
hook_runner: HookRunner,
}
impl<C, T> ConversationRuntime<C, T>
@@ -111,25 +108,6 @@ where
tool_executor: T,
permission_policy: PermissionPolicy,
system_prompt: Vec<String>,
) -> Self {
Self::new_with_features(
session,
api_client,
tool_executor,
permission_policy,
system_prompt,
RuntimeFeatureConfig::default(),
)
}
#[must_use]
pub fn new_with_features(
session: Session,
api_client: C,
tool_executor: T,
permission_policy: PermissionPolicy,
system_prompt: Vec<String>,
feature_config: RuntimeFeatureConfig,
) -> Self {
let usage_tracker = UsageTracker::from_session(&session);
Self {
@@ -140,7 +118,6 @@ where
system_prompt,
max_iterations: usize::MAX,
usage_tracker,
hook_runner: HookRunner::from_feature_config(&feature_config),
}
}
@@ -208,41 +185,19 @@ where
let result_message = match permission_outcome {
PermissionOutcome::Allow => {
let pre_hook_result = self.hook_runner.run_pre_tool_use(&tool_name, &input);
if pre_hook_result.is_denied() {
let deny_message = format!("PreToolUse hook denied tool `{tool_name}`");
ConversationMessage::tool_result(
match self.tool_executor.execute(&tool_name, &input) {
Ok(output) => ConversationMessage::tool_result(
tool_use_id,
tool_name,
format_hook_message(&pre_hook_result, &deny_message),
output,
false,
),
Err(error) => ConversationMessage::tool_result(
tool_use_id,
tool_name,
error.to_string(),
true,
)
} else {
let (mut output, mut is_error) =
match self.tool_executor.execute(&tool_name, &input) {
Ok(output) => (output, false),
Err(error) => (error.to_string(), true),
};
output = merge_hook_feedback(pre_hook_result.messages(), output, false);
let post_hook_result = self
.hook_runner
.run_post_tool_use(&tool_name, &input, &output, is_error);
if post_hook_result.is_denied() {
is_error = true;
}
output = merge_hook_feedback(
post_hook_result.messages(),
output,
post_hook_result.is_denied(),
);
ConversationMessage::tool_result(
tool_use_id,
tool_name,
output,
is_error,
)
),
}
}
PermissionOutcome::Deny { reason } => {
@@ -335,32 +290,6 @@ fn flush_text_block(text: &mut String, blocks: &mut Vec<ContentBlock>) {
}
}
fn format_hook_message(result: &HookRunResult, fallback: &str) -> String {
if result.messages().is_empty() {
fallback.to_string()
} else {
result.messages().join("\n")
}
}
fn merge_hook_feedback(messages: &[String], output: String, denied: bool) -> String {
if messages.is_empty() {
return output;
}
let mut sections = Vec::new();
if !output.trim().is_empty() {
sections.push(output);
}
let label = if denied {
"Hook feedback (denied)"
} else {
"Hook feedback"
};
sections.push(format!("{label}:\n{}", messages.join("\n")));
sections.join("\n\n")
}
type ToolHandler = Box<dyn FnMut(&str) -> Result<String, ToolError>>;
#[derive(Default)]
@@ -400,7 +329,6 @@ mod tests {
StaticToolExecutor,
};
use crate::compact::CompactionConfig;
use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
use crate::permissions::{
PermissionMode, PermissionPolicy, PermissionPromptDecision, PermissionPrompter,
PermissionRequest,
@@ -575,141 +503,6 @@ mod tests {
));
}
#[test]
fn denies_tool_use_when_pre_tool_hook_blocks() {
struct SingleCallApiClient;
impl ApiClient for SingleCallApiClient {
fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
if request
.messages
.iter()
.any(|message| message.role == MessageRole::Tool)
{
return Ok(vec![
AssistantEvent::TextDelta("blocked".to_string()),
AssistantEvent::MessageStop,
]);
}
Ok(vec![
AssistantEvent::ToolUse {
id: "tool-1".to_string(),
name: "blocked".to_string(),
input: r#"{"path":"secret.txt"}"#.to_string(),
},
AssistantEvent::MessageStop,
])
}
}
let mut runtime = ConversationRuntime::new_with_features(
Session::new(),
SingleCallApiClient,
StaticToolExecutor::new().register("blocked", |_input| {
panic!("tool should not execute when hook denies")
}),
PermissionPolicy::new(PermissionMode::DangerFullAccess),
vec!["system".to_string()],
RuntimeFeatureConfig::default().with_hooks(RuntimeHookConfig::new(
vec![shell_snippet("printf 'blocked by hook'; exit 2")],
Vec::new(),
)),
);
let summary = runtime
.run_turn("use the tool", None)
.expect("conversation should continue after hook denial");
assert_eq!(summary.tool_results.len(), 1);
let ContentBlock::ToolResult {
is_error, output, ..
} = &summary.tool_results[0].blocks[0]
else {
panic!("expected tool result block");
};
assert!(
*is_error,
"hook denial should produce an error result: {output}"
);
assert!(
output.contains("denied tool") || output.contains("blocked by hook"),
"unexpected hook denial output: {output:?}"
);
}
#[test]
fn appends_post_tool_hook_feedback_to_tool_result() {
struct TwoCallApiClient {
calls: usize,
}
impl ApiClient for TwoCallApiClient {
fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
self.calls += 1;
match self.calls {
1 => Ok(vec![
AssistantEvent::ToolUse {
id: "tool-1".to_string(),
name: "add".to_string(),
input: r#"{"lhs":2,"rhs":2}"#.to_string(),
},
AssistantEvent::MessageStop,
]),
2 => {
assert!(request
.messages
.iter()
.any(|message| message.role == MessageRole::Tool));
Ok(vec![
AssistantEvent::TextDelta("done".to_string()),
AssistantEvent::MessageStop,
])
}
_ => Err(RuntimeError::new("unexpected extra API call")),
}
}
}
let mut runtime = ConversationRuntime::new_with_features(
Session::new(),
TwoCallApiClient { calls: 0 },
StaticToolExecutor::new().register("add", |_input| Ok("4".to_string())),
PermissionPolicy::new(PermissionMode::DangerFullAccess),
vec!["system".to_string()],
RuntimeFeatureConfig::default().with_hooks(RuntimeHookConfig::new(
vec![shell_snippet("printf 'pre hook ran'")],
vec![shell_snippet("printf 'post hook ran'")],
)),
);
let summary = runtime
.run_turn("use add", None)
.expect("tool loop succeeds");
assert_eq!(summary.tool_results.len(), 1);
let ContentBlock::ToolResult {
is_error, output, ..
} = &summary.tool_results[0].blocks[0]
else {
panic!("expected tool result block");
};
assert!(
!*is_error,
"post hook should preserve non-error result: {output:?}"
);
assert!(
output.contains('4'),
"tool output missing value: {output:?}"
);
assert!(
output.contains("pre hook ran"),
"tool output missing pre hook feedback: {output:?}"
);
assert!(
output.contains("post hook ran"),
"tool output missing post hook feedback: {output:?}"
);
}
#[test]
fn reconstructs_usage_tracker_from_restored_session() {
struct SimpleApi;
@@ -788,14 +581,4 @@ mod tests {
MessageRole::System
);
}
#[cfg(windows)]
fn shell_snippet(script: &str) -> String {
script.replace('\'', "\"")
}
#[cfg(not(windows))]
fn shell_snippet(script: &str) -> String {
script.to_string()
}
}

View File

@@ -1,357 +0,0 @@
use std::ffi::OsStr;
use std::process::Command;
use serde_json::json;
use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookEvent {
PreToolUse,
PostToolUse,
}
impl HookEvent {
fn as_str(self) -> &'static str {
match self {
Self::PreToolUse => "PreToolUse",
Self::PostToolUse => "PostToolUse",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HookRunResult {
denied: bool,
messages: Vec<String>,
}
impl HookRunResult {
#[must_use]
pub fn allow(messages: Vec<String>) -> Self {
Self {
denied: false,
messages,
}
}
#[must_use]
pub fn is_denied(&self) -> bool {
self.denied
}
#[must_use]
pub fn messages(&self) -> &[String] {
&self.messages
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct HookRunner {
config: RuntimeHookConfig,
}
#[derive(Debug, Clone, Copy)]
struct HookCommandRequest<'a> {
event: HookEvent,
tool_name: &'a str,
tool_input: &'a str,
tool_output: Option<&'a str>,
is_error: bool,
payload: &'a str,
}
impl HookRunner {
#[must_use]
pub fn new(config: RuntimeHookConfig) -> Self {
Self { config }
}
#[must_use]
pub fn from_feature_config(feature_config: &RuntimeFeatureConfig) -> Self {
Self::new(feature_config.hooks().clone())
}
#[must_use]
pub fn run_pre_tool_use(&self, tool_name: &str, tool_input: &str) -> HookRunResult {
self.run_commands(
HookEvent::PreToolUse,
self.config.pre_tool_use(),
tool_name,
tool_input,
None,
false,
)
}
#[must_use]
pub fn run_post_tool_use(
&self,
tool_name: &str,
tool_input: &str,
tool_output: &str,
is_error: bool,
) -> HookRunResult {
self.run_commands(
HookEvent::PostToolUse,
self.config.post_tool_use(),
tool_name,
tool_input,
Some(tool_output),
is_error,
)
}
fn run_commands(
&self,
event: HookEvent,
commands: &[String],
tool_name: &str,
tool_input: &str,
tool_output: Option<&str>,
is_error: bool,
) -> HookRunResult {
if commands.is_empty() {
return HookRunResult::allow(Vec::new());
}
let payload = json!({
"hook_event_name": event.as_str(),
"tool_name": tool_name,
"tool_input": parse_tool_input(tool_input),
"tool_input_json": tool_input,
"tool_output": tool_output,
"tool_result_is_error": is_error,
})
.to_string();
let mut messages = Vec::new();
for command in commands {
match Self::run_command(
command,
HookCommandRequest {
event,
tool_name,
tool_input,
tool_output,
is_error,
payload: &payload,
},
) {
HookCommandOutcome::Allow { message } => {
if let Some(message) = message {
messages.push(message);
}
}
HookCommandOutcome::Deny { message } => {
let message = message.unwrap_or_else(|| {
format!("{} hook denied tool `{tool_name}`", event.as_str())
});
messages.push(message);
return HookRunResult {
denied: true,
messages,
};
}
HookCommandOutcome::Warn { message } => messages.push(message),
}
}
HookRunResult::allow(messages)
}
fn run_command(command: &str, request: HookCommandRequest<'_>) -> HookCommandOutcome {
let mut child = shell_command(command);
child.stdin(std::process::Stdio::piped());
child.stdout(std::process::Stdio::piped());
child.stderr(std::process::Stdio::piped());
child.env("HOOK_EVENT", request.event.as_str());
child.env("HOOK_TOOL_NAME", request.tool_name);
child.env("HOOK_TOOL_INPUT", request.tool_input);
child.env(
"HOOK_TOOL_IS_ERROR",
if request.is_error { "1" } else { "0" },
);
if let Some(tool_output) = request.tool_output {
child.env("HOOK_TOOL_OUTPUT", tool_output);
}
match child.output_with_stdin(request.payload.as_bytes()) {
Ok(output) => {
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let message = (!stdout.is_empty()).then_some(stdout);
match output.status.code() {
Some(0) => HookCommandOutcome::Allow { message },
Some(2) => HookCommandOutcome::Deny { message },
Some(code) => HookCommandOutcome::Warn {
message: format_hook_warning(
command,
code,
message.as_deref(),
stderr.as_str(),
),
},
None => HookCommandOutcome::Warn {
message: format!(
"{} hook `{command}` terminated by signal while handling `{}`",
request.event.as_str(),
request.tool_name
),
},
}
}
Err(error) => HookCommandOutcome::Warn {
message: format!(
"{} hook `{command}` failed to start for `{}`: {error}",
request.event.as_str(),
request.tool_name
),
},
}
}
}
enum HookCommandOutcome {
Allow { message: Option<String> },
Deny { message: Option<String> },
Warn { message: String },
}
fn parse_tool_input(tool_input: &str) -> serde_json::Value {
serde_json::from_str(tool_input).unwrap_or_else(|_| json!({ "raw": tool_input }))
}
fn format_hook_warning(command: &str, code: i32, stdout: Option<&str>, stderr: &str) -> String {
let mut message =
format!("Hook `{command}` exited with status {code}; allowing tool execution to continue");
if let Some(stdout) = stdout.filter(|stdout| !stdout.is_empty()) {
message.push_str(": ");
message.push_str(stdout);
} else if !stderr.is_empty() {
message.push_str(": ");
message.push_str(stderr);
}
message
}
fn shell_command(command: &str) -> CommandWithStdin {
#[cfg(windows)]
let mut command_builder = {
let mut command_builder = Command::new("cmd");
command_builder.arg("/C").arg(command);
CommandWithStdin::new(command_builder)
};
#[cfg(not(windows))]
let command_builder = {
let mut command_builder = Command::new("sh");
command_builder.arg("-lc").arg(command);
CommandWithStdin::new(command_builder)
};
command_builder
}
struct CommandWithStdin {
command: Command,
}
impl CommandWithStdin {
fn new(command: Command) -> Self {
Self { command }
}
fn stdin(&mut self, cfg: std::process::Stdio) -> &mut Self {
self.command.stdin(cfg);
self
}
fn stdout(&mut self, cfg: std::process::Stdio) -> &mut Self {
self.command.stdout(cfg);
self
}
fn stderr(&mut self, cfg: std::process::Stdio) -> &mut Self {
self.command.stderr(cfg);
self
}
fn env<K, V>(&mut self, key: K, value: V) -> &mut Self
where
K: AsRef<OsStr>,
V: AsRef<OsStr>,
{
self.command.env(key, value);
self
}
fn output_with_stdin(&mut self, stdin: &[u8]) -> std::io::Result<std::process::Output> {
let mut child = self.command.spawn()?;
if let Some(mut child_stdin) = child.stdin.take() {
use std::io::Write;
child_stdin.write_all(stdin)?;
}
child.wait_with_output()
}
}
#[cfg(test)]
mod tests {
use super::{HookRunResult, HookRunner};
use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
#[test]
fn allows_exit_code_zero_and_captures_stdout() {
let runner = HookRunner::new(RuntimeHookConfig::new(
vec![shell_snippet("printf 'pre ok'")],
Vec::new(),
));
let result = runner.run_pre_tool_use("Read", r#"{"path":"README.md"}"#);
assert_eq!(result, HookRunResult::allow(vec!["pre ok".to_string()]));
}
#[test]
fn denies_exit_code_two() {
let runner = HookRunner::new(RuntimeHookConfig::new(
vec![shell_snippet("printf 'blocked by hook'; exit 2")],
Vec::new(),
));
let result = runner.run_pre_tool_use("Bash", r#"{"command":"pwd"}"#);
assert!(result.is_denied());
assert_eq!(result.messages(), &["blocked by hook".to_string()]);
}
#[test]
fn warns_for_other_non_zero_statuses() {
let runner = HookRunner::from_feature_config(&RuntimeFeatureConfig::default().with_hooks(
RuntimeHookConfig::new(
vec![shell_snippet("printf 'warning hook'; exit 1")],
Vec::new(),
),
));
let result = runner.run_pre_tool_use("Edit", r#"{"file":"src/lib.rs"}"#);
assert!(!result.is_denied());
assert!(result
.messages()
.iter()
.any(|message| message.contains("allowing tool execution to continue")));
}
#[cfg(windows)]
fn shell_snippet(script: &str) -> String {
script.replace('\'', "\"")
}
#[cfg(not(windows))]
fn shell_snippet(script: &str) -> String {
script.to_string()
}
}

View File

@@ -4,7 +4,6 @@ mod compact;
mod config;
mod conversation;
mod file_ops;
mod hooks;
mod json;
mod mcp;
mod mcp_client;
@@ -24,11 +23,11 @@ pub use compact::{
get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult,
};
pub use config::{
ConfigEntry, ConfigError, ConfigLoader, ConfigSource, McpManagedProxyServerConfig,
ConfigEntry, ConfigError, ConfigLoader, ConfigSource, McpClaudeAiProxyServerConfig,
McpConfigCollection, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig,
McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig,
ResolvedPermissionMode, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookConfig,
RuntimePluginConfig, ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME,
ResolvedPermissionMode, RuntimeConfig, RuntimeFeatureConfig, ScopedMcpServerConfig,
CLAUDE_CODE_SETTINGS_SCHEMA_NAME,
};
pub use conversation::{
ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, StaticToolExecutor,
@@ -39,13 +38,12 @@ pub use file_ops::{
GrepSearchInput, GrepSearchOutput, ReadFileOutput, StructuredPatchHunk, TextFilePayload,
WriteFileOutput,
};
pub use hooks::{HookEvent, HookRunResult, HookRunner};
pub use mcp::{
mcp_server_signature, mcp_tool_name, mcp_tool_prefix, normalize_name_for_mcp,
scoped_mcp_config_hash, unwrap_ccr_proxy_url,
};
pub use mcp_client::{
McpManagedProxyTransport, McpClientAuth, McpClientBootstrap, McpClientTransport,
McpClaudeAiProxyTransport, McpClientAuth, McpClientBootstrap, McpClientTransport,
McpRemoteTransport, McpSdkTransport, McpStdioTransport,
};
pub use mcp_stdio::{

View File

@@ -73,7 +73,7 @@ pub fn mcp_server_signature(config: &McpServerConfig) -> Option<String> {
Some(format!("url:{}", unwrap_ccr_proxy_url(&config.url)))
}
McpServerConfig::Ws(config) => Some(format!("url:{}", unwrap_ccr_proxy_url(&config.url))),
McpServerConfig::ManagedProxy(config) => {
McpServerConfig::ClaudeAiProxy(config) => {
Some(format!("url:{}", unwrap_ccr_proxy_url(&config.url)))
}
McpServerConfig::Sdk(_) => None,
@@ -110,7 +110,7 @@ pub fn scoped_mcp_config_hash(config: &ScopedMcpServerConfig) -> String {
ws.headers_helper.as_deref().unwrap_or("")
),
McpServerConfig::Sdk(sdk) => format!("sdk|{}", sdk.name),
McpServerConfig::ManagedProxy(proxy) => {
McpServerConfig::ClaudeAiProxy(proxy) => {
format!("claudeai-proxy|{}|{}", proxy.url, proxy.id)
}
};

View File

@@ -10,7 +10,7 @@ pub enum McpClientTransport {
Http(McpRemoteTransport),
WebSocket(McpRemoteTransport),
Sdk(McpSdkTransport),
ManagedProxy(McpManagedProxyTransport),
ClaudeAiProxy(McpClaudeAiProxyTransport),
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -34,7 +34,7 @@ pub struct McpSdkTransport {
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct McpManagedProxyTransport {
pub struct McpClaudeAiProxyTransport {
pub url: String,
pub id: String,
}
@@ -97,8 +97,8 @@ impl McpClientTransport {
McpServerConfig::Sdk(config) => Self::Sdk(McpSdkTransport {
name: config.name.clone(),
}),
McpServerConfig::ManagedProxy(config) => {
Self::ManagedProxy(McpManagedProxyTransport {
McpServerConfig::ClaudeAiProxy(config) => {
Self::ClaudeAiProxy(McpClaudeAiProxyTransport {
url: config.url.clone(),
id: config.id.clone(),
})

View File

@@ -324,12 +324,12 @@ fn generate_random_token(bytes: usize) -> io::Result<String> {
}
fn credentials_home_dir() -> io::Result<PathBuf> {
if let Some(path) = std::env::var_os("CLAW_CONFIG_HOME") {
if let Some(path) = std::env::var_os("CLAUDE_CONFIG_HOME") {
return Ok(PathBuf::from(path));
}
let home = std::env::var_os("HOME")
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "HOME is not set"))?;
Ok(PathBuf::from(home).join(".claw"))
Ok(PathBuf::from(home).join(".claude"))
}
fn read_credentials_root(path: &PathBuf) -> io::Result<Map<String, Value>> {
@@ -541,7 +541,7 @@ mod tests {
fn oauth_credentials_round_trip_and_clear_preserves_other_fields() {
let _guard = env_lock();
let config_home = temp_config_home();
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
std::env::set_var("CLAUDE_CONFIG_HOME", &config_home);
let path = credentials_path().expect("credentials path");
std::fs::create_dir_all(path.parent().expect("parent")).expect("create parent");
std::fs::write(&path, "{\"other\":\"value\"}\n").expect("seed credentials");
@@ -567,7 +567,7 @@ mod tests {
assert!(cleared.contains("\"other\": \"value\""));
assert!(!cleared.contains("\"oauth\""));
std::env::remove_var("CLAW_CONFIG_HOME");
std::env::remove_var("CLAUDE_CONFIG_HOME");
std::fs::remove_dir_all(config_home).expect("cleanup temp dir");
}

View File

@@ -203,8 +203,8 @@ fn discover_instruction_files(cwd: &Path) -> std::io::Result<Vec<ContextFile>> {
for candidate in [
dir.join("CLAUDE.md"),
dir.join("CLAUDE.local.md"),
dir.join(".claw").join("CLAUDE.md"),
dir.join(".claw").join("instructions.md"),
dir.join(".claude").join("CLAUDE.md"),
dir.join(".claude").join("instructions.md"),
] {
push_context_file(&mut files, candidate)?;
}
@@ -421,7 +421,7 @@ fn render_config_section(config: &RuntimeConfig) -> String {
let mut lines = vec!["# Runtime config".to_string()];
if config.loaded_entries().is_empty() {
lines.extend(prepend_bullets(vec![
"No Claw Code settings files loaded.".to_string()
"No Claude Code settings files loaded.".to_string(),
]));
return lines.join("\n");
}
@@ -517,23 +517,23 @@ mod tests {
fn discovers_instruction_files_from_ancestor_chain() {
let root = temp_dir();
let nested = root.join("apps").join("api");
fs::create_dir_all(nested.join(".claw")).expect("nested claw dir");
fs::create_dir_all(nested.join(".claude")).expect("nested claude dir");
fs::write(root.join("CLAUDE.md"), "root instructions").expect("write root instructions");
fs::write(root.join("CLAUDE.local.md"), "local instructions")
.expect("write local instructions");
fs::create_dir_all(root.join("apps")).expect("apps dir");
fs::create_dir_all(root.join("apps").join(".claw")).expect("apps claw dir");
fs::create_dir_all(root.join("apps").join(".claude")).expect("apps claude dir");
fs::write(root.join("apps").join("CLAUDE.md"), "apps instructions")
.expect("write apps instructions");
fs::write(
root.join("apps").join(".claw").join("instructions.md"),
root.join("apps").join(".claude").join("instructions.md"),
"apps dot claude instructions",
)
.expect("write apps dot claude instructions");
fs::write(nested.join(".claw").join("CLAUDE.md"), "nested rules")
fs::write(nested.join(".claude").join("CLAUDE.md"), "nested rules")
.expect("write nested rules");
fs::write(
nested.join(".claw").join("instructions.md"),
nested.join(".claude").join("instructions.md"),
"nested instructions",
)
.expect("write nested instructions");
@@ -593,14 +593,13 @@ mod tests {
#[test]
fn displays_context_paths_compactly() {
assert_eq!(
display_context_path(Path::new("/tmp/project/.claw/CLAUDE.md")),
display_context_path(Path::new("/tmp/project/.claude/CLAUDE.md")),
"CLAUDE.md"
);
}
#[test]
fn discover_with_git_includes_status_snapshot() {
let _guard = env_lock();
let root = temp_dir();
fs::create_dir_all(&root).expect("root dir");
std::process::Command::new("git")
@@ -625,7 +624,6 @@ mod tests {
#[test]
fn discover_with_git_includes_diff_snapshot_for_tracked_changes() {
let _guard = env_lock();
let root = temp_dir();
fs::create_dir_all(&root).expect("root dir");
std::process::Command::new("git")
@@ -669,10 +667,10 @@ mod tests {
#[test]
fn load_system_prompt_reads_claude_files_and_config() {
let root = temp_dir();
fs::create_dir_all(root.join(".claw")).expect("claw dir");
fs::create_dir_all(root.join(".claude")).expect("claude dir");
fs::write(root.join("CLAUDE.md"), "Project rules").expect("write instructions");
fs::write(
root.join(".claw").join("settings.json"),
root.join(".claude").join("settings.json"),
r#"{"permissionMode":"acceptEdits"}"#,
)
.expect("write settings");
@@ -680,9 +678,9 @@ mod tests {
let _guard = env_lock();
let previous = std::env::current_dir().expect("cwd");
let original_home = std::env::var("HOME").ok();
let original_claw_home = std::env::var("CLAW_CONFIG_HOME").ok();
let original_claude_home = std::env::var("CLAUDE_CONFIG_HOME").ok();
std::env::set_var("HOME", &root);
std::env::set_var("CLAW_CONFIG_HOME", root.join("missing-home"));
std::env::set_var("CLAUDE_CONFIG_HOME", root.join("missing-home"));
std::env::set_current_dir(&root).expect("change cwd");
let prompt = super::load_system_prompt(&root, "2026-03-31", "linux", "6.8")
.expect("system prompt should load")
@@ -697,10 +695,10 @@ mod tests {
} else {
std::env::remove_var("HOME");
}
if let Some(value) = original_claw_home {
std::env::set_var("CLAW_CONFIG_HOME", value);
if let Some(value) = original_claude_home {
std::env::set_var("CLAUDE_CONFIG_HOME", value);
} else {
std::env::remove_var("CLAW_CONFIG_HOME");
std::env::remove_var("CLAUDE_CONFIG_HOME");
}
assert!(prompt.contains("Project rules"));
@@ -711,10 +709,10 @@ mod tests {
#[test]
fn renders_claude_code_style_sections_with_project_context() {
let root = temp_dir();
fs::create_dir_all(root.join(".claw")).expect("claw dir");
fs::create_dir_all(root.join(".claude")).expect("claude dir");
fs::write(root.join("CLAUDE.md"), "Project rules").expect("write CLAUDE.md");
fs::write(
root.join(".claw").join("settings.json"),
root.join(".claude").join("settings.json"),
r#"{"permissionMode":"acceptEdits"}"#,
)
.expect("write settings");
@@ -753,9 +751,9 @@ mod tests {
fn discovers_dot_claude_instructions_markdown() {
let root = temp_dir();
let nested = root.join("apps").join("api");
fs::create_dir_all(nested.join(".claw")).expect("nested claw dir");
fs::create_dir_all(nested.join(".claude")).expect("nested claude dir");
fs::write(
nested.join(".claw").join("instructions.md"),
nested.join(".claude").join("instructions.md"),
"instruction markdown",
)
.expect("write instructions.md");
@@ -764,7 +762,7 @@ mod tests {
assert!(context
.instruction_files
.iter()
.any(|file| file.path.ends_with(".claw/instructions.md")));
.any(|file| file.path.ends_with(".claude/instructions.md")));
assert!(
render_instruction_files(&context.instruction_files).contains("instruction markdown")
);

View File

@@ -17,8 +17,7 @@ crossterm = "0.28"
pulldown-cmark = "0.13"
rustyline = "15"
runtime = { path = "../runtime" }
plugins = { path = "../plugins" }
serde_json.workspace = true
serde_json = "1"
syntect = "5"
tokio = { version = "1", features = ["rt-multi-thread", "time"] }
tools = { path = "../tools" }

View File

@@ -8,7 +8,7 @@ const STARTER_CLAUDE_JSON: &str = concat!(
" }\n",
"}\n",
);
const GITIGNORE_COMMENT: &str = "# Claw Code local artifacts";
const GITIGNORE_COMMENT: &str = "# Claude Code local artifacts";
const GITIGNORE_ENTRIES: [&str; 2] = [".claude/settings.local.json", ".claude/sessions/"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -164,7 +164,7 @@ pub(crate) fn render_init_claude_md(cwd: &Path) -> String {
let mut lines = vec![
"# CLAUDE.md".to_string(),
String::new(),
"This file provides guidance to Claw Code (clawcode.dev) when working with code in this repository.".to_string(),
"This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.".to_string(),
String::new(),
];

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,7 @@
use std::fmt::Write as FmtWrite;
use std::io::{self, Write};
use std::thread;
use std::time::Duration;
use crossterm::cursor::{MoveToColumn, RestorePosition, SavePosition};
use crossterm::style::{Color, Print, ResetColor, SetForegroundColor, Stylize};
@@ -20,7 +22,6 @@ pub struct ColorTheme {
link: Color,
quote: Color,
table_border: Color,
code_block_border: Color,
spinner_active: Color,
spinner_done: Color,
spinner_failed: Color,
@@ -36,7 +37,6 @@ impl Default for ColorTheme {
link: Color::Blue,
quote: Color::DarkGrey,
table_border: Color::DarkCyan,
code_block_border: Color::DarkGrey,
spinner_active: Color::Blue,
spinner_done: Color::Green,
spinner_failed: Color::Red,
@@ -154,64 +154,33 @@ impl TableState {
struct RenderState {
emphasis: usize,
strong: usize,
heading_level: Option<u8>,
quote: usize,
list_stack: Vec<ListKind>,
link_stack: Vec<LinkState>,
table: Option<TableState>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct LinkState {
destination: String,
text: String,
}
impl RenderState {
fn style_text(&self, text: &str, theme: &ColorTheme) -> String {
let mut style = text.stylize();
if matches!(self.heading_level, Some(1 | 2)) || self.strong > 0 {
style = style.bold();
let mut styled = text.to_string();
if self.strong > 0 {
styled = format!("{}", styled.bold().with(theme.strong));
}
if self.emphasis > 0 {
style = style.italic();
styled = format!("{}", styled.italic().with(theme.emphasis));
}
if let Some(level) = self.heading_level {
style = match level {
1 => style.with(theme.heading),
2 => style.white(),
3 => style.with(Color::Blue),
_ => style.with(Color::Grey),
};
} else if self.strong > 0 {
style = style.with(theme.strong);
} else if self.emphasis > 0 {
style = style.with(theme.emphasis);
}
if self.quote > 0 {
style = style.with(theme.quote);
styled = format!("{}", styled.with(theme.quote));
}
format!("{style}")
styled
}
fn append_raw(&mut self, output: &mut String, text: &str) {
if let Some(link) = self.link_stack.last_mut() {
link.text.push_str(text);
} else if let Some(table) = self.table.as_mut() {
table.current_cell.push_str(text);
fn capture_target_mut<'a>(&'a mut self, output: &'a mut String) -> &'a mut String {
if let Some(table) = self.table.as_mut() {
&mut table.current_cell
} else {
output.push_str(text);
output
}
}
fn append_styled(&mut self, output: &mut String, text: &str, theme: &ColorTheme) {
let styled = self.style_text(text, theme);
self.append_raw(output, &styled);
}
}
#[derive(Debug)]
@@ -269,11 +238,6 @@ impl TerminalRenderer {
output.trim_end().to_string()
}
#[must_use]
pub fn markdown_to_ansi(&self, markdown: &str) -> String {
self.render_markdown(markdown)
}
#[allow(clippy::too_many_lines)]
fn render_event(
&self,
@@ -285,21 +249,15 @@ impl TerminalRenderer {
in_code_block: &mut bool,
) {
match event {
Event::Start(Tag::Heading { level, .. }) => {
self.start_heading(state, level as u8, output);
}
Event::End(TagEnd::Paragraph) => output.push_str("\n\n"),
Event::Start(Tag::Heading { level, .. }) => self.start_heading(level as u8, output),
Event::End(TagEnd::Heading(..) | TagEnd::Paragraph) => output.push_str("\n\n"),
Event::Start(Tag::BlockQuote(..)) => self.start_quote(state, output),
Event::End(TagEnd::BlockQuote(..)) => {
state.quote = state.quote.saturating_sub(1);
output.push('\n');
}
Event::End(TagEnd::Heading(..)) => {
state.heading_level = None;
output.push_str("\n\n");
}
Event::End(TagEnd::Item) | Event::SoftBreak | Event::HardBreak => {
state.append_raw(output, "\n");
state.capture_target_mut(output).push('\n');
}
Event::Start(Tag::List(first_item)) => {
let kind = match first_item {
@@ -335,52 +293,41 @@ impl TerminalRenderer {
Event::Code(code) => {
let rendered =
format!("{}", format!("`{code}`").with(self.color_theme.inline_code));
state.append_raw(output, &rendered);
state.capture_target_mut(output).push_str(&rendered);
}
Event::Rule => output.push_str("---\n"),
Event::Text(text) => {
self.push_text(text.as_ref(), state, output, code_buffer, *in_code_block);
}
Event::Html(html) | Event::InlineHtml(html) => {
state.append_raw(output, &html);
state.capture_target_mut(output).push_str(&html);
}
Event::FootnoteReference(reference) => {
state.append_raw(output, &format!("[{reference}]"));
let _ = write!(state.capture_target_mut(output), "[{reference}]");
}
Event::TaskListMarker(done) => {
state.append_raw(output, if done { "[x] " } else { "[ ] " });
state
.capture_target_mut(output)
.push_str(if done { "[x] " } else { "[ ] " });
}
Event::InlineMath(math) | Event::DisplayMath(math) => {
state.append_raw(output, &math);
state.capture_target_mut(output).push_str(&math);
}
Event::Start(Tag::Link { dest_url, .. }) => {
state.link_stack.push(LinkState {
destination: dest_url.to_string(),
text: String::new(),
});
}
Event::End(TagEnd::Link) => {
if let Some(link) = state.link_stack.pop() {
let label = if link.text.is_empty() {
link.destination.clone()
} else {
link.text
};
let rendered = format!(
"{}",
format!("[{label}]({})", link.destination)
.underlined()
.with(self.color_theme.link)
);
state.append_raw(output, &rendered);
}
let rendered = format!(
"{}",
format!("[{dest_url}]")
.underlined()
.with(self.color_theme.link)
);
state.capture_target_mut(output).push_str(&rendered);
}
Event::Start(Tag::Image { dest_url, .. }) => {
let rendered = format!(
"{}",
format!("[image:{dest_url}]").with(self.color_theme.link)
);
state.append_raw(output, &rendered);
state.capture_target_mut(output).push_str(&rendered);
}
Event::Start(Tag::Table(..)) => state.table = Some(TableState::default()),
Event::End(TagEnd::Table) => {
@@ -422,16 +369,19 @@ impl TerminalRenderer {
}
}
Event::Start(Tag::Paragraph | Tag::MetadataBlock(..) | _)
| Event::End(TagEnd::Image | TagEnd::MetadataBlock(..) | _) => {}
| Event::End(TagEnd::Link | TagEnd::Image | TagEnd::MetadataBlock(..) | _) => {}
}
}
#[allow(clippy::unused_self)]
fn start_heading(&self, state: &mut RenderState, level: u8, output: &mut String) {
state.heading_level = Some(level);
if !output.is_empty() {
output.push('\n');
}
fn start_heading(&self, level: u8, output: &mut String) {
output.push('\n');
let prefix = match level {
1 => "# ",
2 => "## ",
3 => "### ",
_ => "#### ",
};
let _ = write!(output, "{}", prefix.bold().with(self.color_theme.heading));
}
fn start_quote(&self, state: &mut RenderState, output: &mut String) {
@@ -455,27 +405,20 @@ impl TerminalRenderer {
}
fn start_code_block(&self, code_language: &str, output: &mut String) {
let label = if code_language.is_empty() {
"code".to_string()
} else {
code_language.to_string()
};
let _ = writeln!(
output,
"{}",
format!("╭─ {label}")
.bold()
.with(self.color_theme.code_block_border)
);
if !code_language.is_empty() {
let _ = writeln!(
output,
"{}",
format!("╭─ {code_language}").with(self.color_theme.heading)
);
}
}
fn finish_code_block(&self, code_buffer: &str, code_language: &str, output: &mut String) {
output.push_str(&self.highlight_code(code_buffer, code_language));
let _ = write!(
output,
"{}",
"╰─".bold().with(self.color_theme.code_block_border)
);
if !code_language.is_empty() {
let _ = write!(output, "{}", "╰─".with(self.color_theme.heading));
}
output.push_str("\n\n");
}
@@ -490,7 +433,8 @@ impl TerminalRenderer {
if in_code_block {
code_buffer.push_str(text);
} else {
state.append_styled(output, text, &self.color_theme);
let rendered = state.style_text(text, &self.color_theme);
state.capture_target_mut(output).push_str(&rendered);
}
}
@@ -577,10 +521,9 @@ impl TerminalRenderer {
for line in LinesWithEndings::from(code) {
match syntax_highlighter.highlight_line(line, &self.syntax_set) {
Ok(ranges) => {
let escaped = as_24_bit_terminal_escaped(&ranges[..], false);
colored_output.push_str(&apply_code_block_background(&escaped));
colored_output.push_str(&as_24_bit_terminal_escaped(&ranges[..], false));
}
Err(_) => colored_output.push_str(&apply_code_block_background(line)),
Err(_) => colored_output.push_str(line),
}
}
@@ -588,83 +531,16 @@ impl TerminalRenderer {
}
pub fn stream_markdown(&self, markdown: &str, out: &mut impl Write) -> io::Result<()> {
let rendered_markdown = self.markdown_to_ansi(markdown);
write!(out, "{rendered_markdown}")?;
if !rendered_markdown.ends_with('\n') {
writeln!(out)?;
let rendered_markdown = self.render_markdown(markdown);
for chunk in rendered_markdown.split_inclusive(char::is_whitespace) {
write!(out, "{chunk}")?;
out.flush()?;
thread::sleep(Duration::from_millis(8));
}
out.flush()
writeln!(out)
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct MarkdownStreamState {
pending: String,
}
impl MarkdownStreamState {
#[must_use]
pub fn push(&mut self, renderer: &TerminalRenderer, delta: &str) -> Option<String> {
self.pending.push_str(delta);
let split = find_stream_safe_boundary(&self.pending)?;
let ready = self.pending[..split].to_string();
self.pending.drain(..split);
Some(renderer.markdown_to_ansi(&ready))
}
#[must_use]
pub fn flush(&mut self, renderer: &TerminalRenderer) -> Option<String> {
if self.pending.trim().is_empty() {
self.pending.clear();
None
} else {
let pending = std::mem::take(&mut self.pending);
Some(renderer.markdown_to_ansi(&pending))
}
}
}
fn apply_code_block_background(line: &str) -> String {
let trimmed = line.trim_end_matches('\n');
let trailing_newline = if trimmed.len() == line.len() {
""
} else {
"\n"
};
let with_background = trimmed.replace("\u{1b}[0m", "\u{1b}[0;48;5;236m");
format!("\u{1b}[48;5;236m{with_background}\u{1b}[0m{trailing_newline}")
}
fn find_stream_safe_boundary(markdown: &str) -> Option<usize> {
let mut in_fence = false;
let mut last_boundary = None;
for (offset, line) in markdown.split_inclusive('\n').scan(0usize, |cursor, line| {
let start = *cursor;
*cursor += line.len();
Some((start, line))
}) {
let trimmed = line.trim_start();
if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
in_fence = !in_fence;
if !in_fence {
last_boundary = Some(offset + line.len());
}
continue;
}
if in_fence {
continue;
}
if trimmed.is_empty() {
last_boundary = Some(offset + line.len());
}
}
last_boundary
}
fn visible_width(input: &str) -> usize {
strip_ansi(input).chars().count()
}
@@ -693,7 +569,7 @@ fn strip_ansi(input: &str) -> String {
#[cfg(test)]
mod tests {
use super::{strip_ansi, MarkdownStreamState, Spinner, TerminalRenderer};
use super::{strip_ansi, Spinner, TerminalRenderer};
#[test]
fn renders_markdown_with_styling_and_lists() {
@@ -707,28 +583,16 @@ mod tests {
assert!(markdown_output.contains('\u{1b}'));
}
#[test]
fn renders_links_as_colored_markdown_labels() {
let terminal_renderer = TerminalRenderer::new();
let markdown_output =
terminal_renderer.render_markdown("See [Claw](https://example.com/docs) now.");
let plain_text = strip_ansi(&markdown_output);
assert!(plain_text.contains("[Claw](https://example.com/docs)"));
assert!(markdown_output.contains('\u{1b}'));
}
#[test]
fn highlights_fenced_code_blocks() {
let terminal_renderer = TerminalRenderer::new();
let markdown_output =
terminal_renderer.markdown_to_ansi("```rust\nfn hi() { println!(\"hi\"); }\n```");
terminal_renderer.render_markdown("```rust\nfn hi() { println!(\"hi\"); }\n```");
let plain_text = strip_ansi(&markdown_output);
assert!(plain_text.contains("╭─ rust"));
assert!(plain_text.contains("fn hi"));
assert!(markdown_output.contains('\u{1b}'));
assert!(markdown_output.contains("[48;5;236m"));
}
#[test]
@@ -759,26 +623,6 @@ mod tests {
assert!(markdown_output.contains('\u{1b}'));
}
#[test]
fn streaming_state_waits_for_complete_blocks() {
let renderer = TerminalRenderer::new();
let mut state = MarkdownStreamState::default();
assert_eq!(state.push(&renderer, "# Heading"), None);
let flushed = state
.push(&renderer, "\n\nParagraph\n\n")
.expect("completed block");
let plain_text = strip_ansi(&flushed);
assert!(plain_text.contains("Heading"));
assert!(plain_text.contains("Paragraph"));
assert_eq!(state.push(&renderer, "```rust\nfn main() {}\n"), None);
let code = state
.push(&renderer, "```\n")
.expect("closed code fence flushes");
assert!(strip_ansi(&code).contains("fn main()"));
}
#[test]
fn spinner_advances_frames() {
let terminal_renderer = TerminalRenderer::new();

View File

@@ -6,13 +6,10 @@ license.workspace = true
publish.workspace = true
[dependencies]
api = { path = "../api" }
plugins = { path = "../plugins" }
runtime = { path = "../runtime" }
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json.workspace = true
tokio = { version = "1", features = ["rt-multi-thread"] }
serde_json = "1"
[lints]
workspace = true

File diff suppressed because it is too large Load Diff