diff --git a/.gitignore b/.gitignore
index 324ae1d..cfa4cf3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,6 @@ __pycache__/
archive/
.omx/
.clawd-agents/
+# Claude Code local artifacts
+.claude/settings.local.json
+.claude/sessions/
diff --git a/CLAW.md b/CLAW.md
new file mode 100644
index 0000000..676f2e3
--- /dev/null
+++ b/CLAW.md
@@ -0,0 +1,21 @@
+# CLAW.md
+
+此文件为 Claw Code 在此仓库中处理代码时提供指导。
+
+## 检测到的技术栈
+- 语言:Rust。
+- 框架:未从支持的启动标记中检测到任何框架。
+
+## 验证
+- 在 `rust/` 目录下运行 Rust 验证:`cargo fmt`、`cargo clippy --workspace --all-targets -- -D warnings`、`cargo test --workspace`
+- `src/` 和 `tests/` 均已存在;当行为发生变化时,请同时更新这两个部分。
+
+## 仓库结构
+- `rust/` 包含 Rust 工作区以及活跃的 CLI/运行时实现。
+- `src/` 包含应与生成的指导和测试保持一致的源文件。
+- `tests/` 包含应随代码更改而一同审查的验证部分。
+
+## 工作协议
+- 优先采用小而易于审查的更改,并保持生成的引导文件与实际仓库工作流对齐。
+- 将共享的默认值保留在 `.claw.json` 中;`.claw/settings.local.json` 预留给机器本地的覆盖设置。
+- 不要自动覆盖现有的 `CLAW.md` 内容;当仓库工作流更改时,请有目的地更新它。
diff --git a/PARITY.md b/PARITY.md
new file mode 100644
index 0000000..15f6bbd
--- /dev/null
+++ b/PARITY.md
@@ -0,0 +1,214 @@
+# 差异化缺口分析 (PARITY GAP ANALYSIS)
+
+范围:对位于 `/home/bellman/Workspace/claw-code/src/` 的原始 TypeScript 源码与 `rust/crates/` 下的 Rust 移植版进行只读对比。
+
+方法:仅对比功能表面、注册表、入口点和运行时流水线。未复制任何 TypeScript 源码。
+
+## 主要摘要
+
+Rust 移植版在以下方面具有良好的基础:
+- Anthropic API/OAuth 基础
+- 本地对话/会话状态
+- 核心工具循环
+- MCP stdio/引导支持
+- CLAW.md 发现
+- 一套小巧但可用的内置工具
+
+它与 TypeScript CLI **尚未实现功能对等**。
+
+最大的缺口:
+- **插件 (plugins)**:在 Rust 中实际上不存在
+- **挂钩 (hooks)**:Rust 中已解析但未执行
+- **CLI 广度**:Rust 中的功能范围要窄得多
+- **技能 (skills)**:Rust 中仅支持本地文件,没有 TS 的注册表/捆绑流水线
+- **助手编排 (assistant orchestration)**:缺乏 TS 中感知挂钩的编排以及远程/结构化传输
+- **服务 (services)**:除了核心 API/OAuth/MCP 之外,Rust 中大部分服务都缺失
+
+---
+
+## tools/ (工具)
+
+### TS 已存在
+证据:
+- `src/tools/` 包含广泛的工具家族,包括 `AgentTool`、`AskUserQuestionTool`、`BashTool`、`ConfigTool`、`FileReadTool`、`FileWriteTool`、`GlobTool`、`GrepTool`、`LSPTool`、`ListMcpResourcesTool`、`MCPTool`、`McpAuthTool`、`ReadMcpResourceTool`、`RemoteTriggerTool`、`ScheduleCronTool`、`SkillTool`、`Task*`、`Team*`、`TodoWriteTool`、`ToolSearchTool`、`WebFetchTool`、`WebSearchTool`。
+- 工具执行/编排分散在 `src/services/tools/StreamingToolExecutor.ts`、`src/services/tools/toolExecution.ts`、`src/services/tools/toolHooks.ts` 和 `src/services/tools/toolOrchestration.ts` 中。
+
+### Rust 已存在
+证据:
+- 工具注册表通过 `mvp_tool_specs()` 集中在 `rust/crates/tools/src/lib.rs` 中。
+- 当前内置工具包括 shell/文件/搜索/网页/待办事项/技能/代理/配置/笔记本/REPL/PowerShell 原语。
+- 运行时执行通过 `rust/crates/tools/src/lib.rs` 和 `rust/crates/runtime/src/conversation.rs` 连接。
+
+### Rust 中缺失或损坏
+- 重大 TS 工具在 Rust 中没有等效项,例如 `AskUserQuestionTool`、`LSPTool`、`ListMcpResourcesTool`、`MCPTool`、`McpAuthTool`、`ReadMcpResourceTool`、`RemoteTriggerTool`、`ScheduleCronTool`、`Task*`、`Team*` 以及多个工作流/系统工具。
+- Rust 工具表面仍明确是一个 MVP(最小可行产品)注册表,而非对等注册表。
+- Rust 缺乏 TS 的分层工具编排拆分。
+
+**状态:** 仅部分核心功能。
+
+---
+
+## hooks/ (挂钩)
+
+### TS 已存在
+证据:
+- `src/commands/hooks/` 下的挂钩命令表面。
+- `src/services/tools/toolHooks.ts` 和 `src/services/tools/toolExecution.ts` 中的运行时挂钩机制。
+- TS 支持 `PreToolUse`、`PostToolUse` 以及通过设置配置并记录在 `src/skills/bundled/updateConfig.ts` 中的更广泛的挂钩驱动行为。
+
+### Rust 已存在
+证据:
+- 挂钩配置在 `rust/crates/runtime/src/config.rs` 中被解析和合并。
+- 挂钩配置可以通过 `rust/crates/commands/src/lib.rs` 和 `rust/crates/claw-cli/src/main.rs` 中的 Rust 配置报告进行检查。
+- `rust/crates/runtime/src/prompt.rs` 中的提示指南提到了挂钩。
+
+### Rust 中缺失或损坏
+- `rust/crates/runtime/src/conversation.rs` 中没有实际的挂钩执行流水线。
+- 没有 `PreToolUse`/`PostToolUse` 的变异/拒绝/重写/结果挂钩行为。
+- 没有 Rust 的 `/hooks` 对等命令。
+
+**状态:** 仅配置支持;运行时行为缺失。
+
+---
+
+## plugins/ (插件)
+
+### TS 已存在
+证据:
+- `src/plugins/builtinPlugins.ts` 和 `src/plugins/bundled/index.ts` 中的内置插件脚手架。
+- `src/services/plugins/PluginInstallationManager.ts` 和 `src/services/plugins/pluginOperations.ts` 中的插件生命周期/服务。
+- `src/commands/plugin/` 和 `src/commands/reload-plugins/` 下的 CLI/插件命令表面。
+
+### Rust 已存在
+证据:
+- `rust/crates/` 下没有出现专门的插件子系统。
+- 仓库范围内对插件的 Rust 引用除了文本/帮助提到外,实际上不存在。
+
+### Rust 中缺失或损坏
+- 没有插件加载器。
+- 没有市场安装/更新/启用/禁用流程。
+- 没有 `/plugin` 或 `/reload-plugins` 对等命令。
+- 没有插件提供的挂钩/工具/命令/MCP 扩展路径。
+
+**状态:** 缺失。
+
+---
+
+## skills/ 与 CLAW.md 发现
+
+### TS 已存在
+证据:
+- `src/skills/loadSkillsDir.ts`、`src/skills/bundledSkills.ts` 和 `src/skills/mcpSkillBuilders.ts` 中的技能加载/注册流水线。
+- `src/skills/bundled/` 下的捆绑技能。
+- `src/commands/skills/` 下的技能命令表面。
+
+### Rust 已存在
+证据:
+- `rust/crates/tools/src/lib.rs` 中的 `Skill` 工具可以解析并读取本地 `SKILL.md` 文件。
+- `rust/crates/runtime/src/prompt.rs` 中实现了 `CLAW.md` 发现。
+- Rust 通过 `rust/crates/commands/src/lib.rs` 和 `rust/crates/claw-cli/src/main.rs` 支持 `/memory` 和 `/init`。
+
+### Rust 中缺失或损坏
+- 没有等效的捆绑技能注册表。
+- 没有 `/skills` 命令。
+- 没有 MCP 技能构建器流水线。
+- 没有类似 TS 的实时技能发现/重载/更改处理。
+- 没有围绕技能的会话记忆 (session-memory) / 团队记忆 (team-memory) 集成。
+
+**状态:** 仅支持基础的本地技能加载。
+
+---
+
+## cli/ (命令行界面)
+
+### TS 已存在
+证据:
+- `src/commands/` 下庞大的命令表面,包括 `agents`、`hooks`、`mcp`、`memory`、`model`、`permissions`、`plan`、`plugin`、`resume`、`review`、`skills`、`tasks` 等。
+- `src/cli/structuredIO.ts`、`src/cli/remoteIO.ts` 和 `src/cli/transports/*` 中的结构化/远程传输栈。
+- `src/cli/handlers/*` 中的 CLI 处理程序拆分。
+
+### Rust 已存在
+证据:
+- `rust/crates/commands/src/lib.rs` 中共享的斜杠命令注册表。
+- Rust 斜杠命令目前涵盖 `help`、`status`、`compact`、`model`、`permissions`、`clear`、`cost`、`resume`、`config`、`memory`、`init`、`diff`、`version`、`export`、`session`。
+- 主要的 CLI/REPL/提示词处理位于 `rust/crates/claw-cli/src/main.rs`。
+
+### Rust 中缺失或损坏
+- 缺失重大的 TS 命令家族:`/agents`、`/hooks`、`/mcp`、`/plugin`、`/skills`、`/plan`、`/review`、`/tasks` 等等。
+- Rust 没有等效于 TS 结构化 IO / 远程传输层的实现。
+- 没有针对 auth/plugins/MCP/agents 的 TS 风格处理程序分解。
+- JSON 提示模式在此分支上有所改进,但仍未达到纯净的传输对等:实证验证显示,支持工具的 JSON 输出在最终 JSON 对象之前可能会发出人类可读的工具结果行。
+
+**状态:** 功能性本地 CLI 核心,范围比 TS 窄得多。
+
+---
+
+## assistant/ (助手/代理循环、流式、工具调用)
+
+### TS 已存在
+证据:
+- `src/assistant/sessionHistory.ts` 中的助手/会话表面。
+- `src/services/tools/StreamingToolExecutor.ts`、`src/services/tools/toolExecution.ts`、`src/services/tools/toolOrchestration.ts` 中的工具编排。
+- `src/cli/structuredIO.ts` 和 `src/cli/remoteIO.ts` 中的远程/结构化流式层。
+
+### Rust 已存在
+证据:
+- `rust/crates/runtime/src/conversation.rs` 中的核心循环。
+- `rust/crates/claw-cli/src/main.rs` 中的流式/工具事件转换。
+- `rust/crates/runtime/src/session.rs` 中的会话持久化。
+
+### Rust 中缺失或损坏
+- 没有类似 TS 的挂钩感知编排层。
+- 没有 TS 结构化/远程助手传输栈。
+- 没有更丰富的 TS 助手/会话历史/后台任务集成。
+- JSON 输出路径在此分支上不再仅限单次交互,但输出纯净度仍落后于 TS 传输预期。
+
+**状态:** 强大的核心循环,缺失编排层。
+
+---
+
+## services/ (服务:API 客户端、认证、模型、MCP)
+
+### TS 已存在
+证据:
+- `src/services/api/*` 下的 API 服务。
+- `src/services/oauth/*` 下的 OAuth 服务。
+- `src/services/mcp/*` 下的 MCP 服务。
+- `src/services/*` 下的分析、提示建议、会话记忆、插件操作、设置同步、策略限制、团队记忆同步、通知器、语音等附加服务层。
+
+### Rust 已存在
+证据:
+- `rust/crates/api/src/{client,error,sse,types}.rs` 中的核心 Anthropic API 客户端。
+- `rust/crates/runtime/src/oauth.rs` 中的 OAuth 支持。
+- `rust/crates/runtime/src/{config,mcp,mcp_client,mcp_stdio}.rs` 中的 MCP 配置/引导/客户端支持。
+- `rust/crates/runtime/src/usage.rs` 中的用量统计。
+- `rust/crates/runtime/src/remote.rs` 中的远程上游代理支持。
+
+### Rust 中缺失或损坏
+- 除了核心消息传递/认证/MCP 之外,大部分 TS 服务生态系统都缺失。
+- 没有等效于 TS 的插件服务层。
+- 没有等效于 TS 的分析/设置同步/策略限制/团队记忆子系统。
+- 没有 TS 风格的 MCP 连接器/UI 层。
+- 模型/提供商的用户体验(ergonomics)仍比 TS 薄弱。
+
+**状态:** 核心基础已存在;更广泛的服务生态系统缺失。
+
+---
+
+## 正在处理的分支中的关键缺陷状态
+
+### 已修复
+- **已启用提示词模式工具 (Prompt mode tools)**
+ - `rust/crates/claw-cli/src/main.rs` 现在使用 `LiveCli::new(model, true, ...)` 构建提示词模式。
+- **默认权限模式 = DangerFullAccess**
+ - `rust/crates/claw-cli/src/main.rs` 中的运行时默认值现在解析为 `DangerFullAccess`。
+ - `rust/crates/claw-cli/src/args.rs` 中的 Clap 默认值也使用 `DangerFullAccess`。
+ - `rust/crates/claw-cli/src/init.rs` 中的初始化模板写入了 `dontAsk`。
+- **流式 `{}` 工具输入前缀 Bug**
+ - `rust/crates/claw-cli/src/main.rs` 现在仅针对流式工具输入剥离初始空对象,同时保留非流式响应中的合法 `{}`。
+- **无限最大迭代次数 (max_iterations)**
+ - 在 `rust/crates/runtime/src/conversation.rs` 中通过 `usize::MAX` 进行了验证。
+
+### 剩余的显著对等问题
+- **JSON 提示输出纯净度**
+ - 支持工具的 JSON 模式现在可以循环,但实证验证显示,当工具启动时,在 JSON 之前仍会出现人类可读的工具结果输出。
diff --git a/README.md b/README.md
index a74ce3b..934d2a6 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
-# Rewriting Project Claw Code
+# 重写项目:Claw Code
- ⭐ The fastest repo in history to surpass 50K stars, reaching the milestone in just 2 hours after publication ⭐
+ ⭐ 历史上最快超过 5 万颗星的仓库,发布后仅 2 小时即达成里程碑 ⭐
@@ -19,7 +19,7 @@
- Better Harness Tools, not merely storing the archive of leaked Claude Code
+ 更好的 Harness 工具,而不仅仅是存储泄露的 Claw Code 存档
@@ -27,63 +27,86 @@
> [!IMPORTANT]
-> **Rust port is now in progress** on the [`dev/rust`](https://github.com/instructkr/claw-code/tree/dev/rust) branch and is expected to be merged into main today. The Rust implementation aims to deliver a faster, memory-safe harness runtime. Stay tuned — this will be the definitive version of the project.
+> **Rust 移植工作目前正在 [`dev/rust`](https://github.com/instructkr/claw-code/tree/dev/rust) 分支上进行**,预计今天将合并到主分支。Rust 实现旨在提供更快、内存安全的 harness 运行时。敬请期待——这将是该项目的最终版本。
-> If you find this work useful, consider [sponsoring @instructkr on GitHub](https://github.com/sponsors/instructkr) to support continued open-source harness engineering research.
+> 如果你觉得这项工作有用,请考虑在 GitHub 上 [赞助 @instructkr](https://github.com/sponsors/instructkr) 以支持持续的开源 harness 工程研究。
---
-## Backstory
+## Rust 移植
-At 4 AM on March 31, 2026, I woke up to my phone blowing up with notifications. The Claude Code source had been exposed, and the entire dev community was in a frenzy. My girlfriend in Korea was genuinely worried I might face legal action from Anthropic just for having the code on my machine — so I did what any engineer would do under pressure: I sat down, ported the core features to Python from scratch, and pushed it before the sun came up.
+`rust/` 目录下的 Rust 工作区是该项目的当前系统语言移植版本。
-The whole thing was orchestrated end-to-end using [oh-my-codex (OmX)](https://github.com/Yeachan-Heo/oh-my-codex) by [@bellman_ych](https://x.com/bellman_ych) — a workflow layer built on top of OpenAI's Codex ([@OpenAIDevs](https://x.com/OpenAIDevs)). I used `$team` mode for parallel code review and `$ralph` mode for persistent execution loops with architect-level verification. The entire porting session — from reading the original harness structure to producing a working Python tree with tests — was driven through OmX orchestration.
+它目前包括:
-The result is a clean-room Python rewrite that captures the architectural patterns of Claude Code's agent harness without copying any proprietary source. I'm now actively collaborating with [@bellman_ych](https://x.com/bellman_ych) — the creator of OmX himself — to push this further. The basic Python foundation is already in place and functional, but we're just getting started. **Stay tuned — a much more capable version is on the way.**
+- `crates/api-client` — 具有提供商抽象、OAuth 和流式支持的 API 客户端
+- `crates/runtime` — 会话状态、压缩、MCP 编排、提示词构建
+- `crates/tools` — 工具清单定义和执行框架
+- `crates/commands` — 斜杠命令、技能发现和配置检查
+- `crates/plugins` — 插件模型、挂钩管道和内置插件
+- `crates/compat-harness` — 用于上游编辑器集成的兼容层
+- `crates/claw-cli` — 交互式 REPL、Markdown 渲染以及项目引导/初始化流程
+
+运行 Rust 构建:
+
+```bash
+cd rust
+cargo build --release
+```
+
+## 背景故事
+
+2026年3月31日凌晨4点,我被手机弹出的漫天通知震醒。Claw Code 的源代码被曝光了,整个开发者社区都陷入了疯狂。我在韩国的女朋友真的很担心我仅仅因为机器上有这些代码而面临原作者的法律诉讼——于是我在压力之下做了任何工程师都会做的事:我坐下来,从头开始将核心功能移植到 Python,并在日出前提交了代码。
+
+整个过程是使用 [@bellman_ych](https://x.com/bellman_ych) 开发的 [oh-my-codex (OmX)](https://github.com/Yeachan-Heo/oh-my-codex) 端到端编排的——这是一个构建在 OpenAI Codex ([@OpenAIDevs](https://x.com/OpenAIDevs)) 之上的工作流层。我使用了 `$team` 模式进行并行代码审查,并使用了 `$ralph` 模式进行带有架构级验证的持久执行循环。整个移植过程——从阅读原始 harness 结构到生成具有测试的可用 Python 树——都是通过 OmX 编排驱动的。
+
+结果是一个净室(clean-room)Python 重写版本,它捕捉了 Claw Code 代理 harness 的架构模式,而没有复制任何专有源代码。我现在正与 OmX 的创始人 [@bellman_ych](https://x.com/bellman_ych) 本人积极合作,以进一步推进这项工作。基本的 Python 基础已经就绪并可以运行,但我们才刚刚开始。 **敬请期待——一个更强大的版本正在路上。**
+
+Rust 移植版本是同时使用 [oh-my-codex (OmX)](https://github.com/Yeachan-Heo/oh-my-codex) 和 [oh-my-opencode (OmO)](https://github.com/code-yeongyu/oh-my-openagent) 开发的:OmX 驱动了脚手架搭建、编排和架构方向,而 OmO 则用于后期的实现加速和验证支持。
https://github.com/instructkr/claw-code

-## The Creators Featured in Wall Street Journal For Avid Claude Code Fans
+## 《华尔街日报》对 Claw Code 狂热粉丝创建者的报道
-I've been deeply interested in **harness engineering** — studying how agent systems wire tools, orchestrate tasks, and manage runtime context. This isn't a sudden thing. The Wall Street Journal featured my work earlier this month, documenting how I've been one of the most active power users exploring these systems:
+我对 **harness 工程** 深感兴趣——研究代理系统如何连接工具、编排任务以及管理运行时上下文。这并非突发奇想。《华尔街日报》在本月早些时候报道了我的工作,记录了我如何成为探索这些系统最活跃的超级用户之一:
-> AI startup worker Sigrid Jin, who attended the Seoul dinner, single-handedly used 25 billion of Claude Code tokens last year. At the time, usage limits were looser, allowing early enthusiasts to reach tens of billions of tokens at a very low cost.
+> AI 创业公司员工 Sigrid Jin,去年凭一己之力使用了 250 亿个 Claw Code token。当时,使用限制较为宽松,使得早期发烧友能够以极低的成本触及数百亿个 token。
>
-> Despite his countless hours with Claude Code, Jin isn't faithful to any one AI lab. The tools available have different strengths and weaknesses, he said. Codex is better at reasoning, while Claude Code generates cleaner, more shareable code.
+> 尽管 Jin 在 Claw Code 上花费了无数个小时,但他并不忠于任何一家 AI 实验室。他说,现有的工具各有所长。Codex 擅长推理,而 Claw Code 生成的代码更干净、更易于分享。
>
-> Jin flew to San Francisco in February for Claude Code's first birthday party, where attendees waited in line to compare notes with Cherny. The crowd included a practicing cardiologist from Belgium who had built an app to help patients navigate care, and a California lawyer who made a tool for automating building permit approvals using Claude Code.
+> Jin 于 2 月份飞往旧金山参加 Claw Code 的一周年派对,与会者排队与 Cherny 交流心得。人群中包括一名来自比利时的执业心脏病专家(他开发了一个帮助患者就医的应用程序),以及一名加州律师(他使用 Claw Code 制作了一个自动批准建筑许可的工具)。
>
-> "It was basically like a sharing party," Jin said. "There were lawyers, there were doctors, there were dentists. They did not have software engineering backgrounds."
+> “这基本上就像是一个分享派对,”Jin 说。“有律师,有医生,有牙医。他们并没有软件工程背景。”
>
-> — *The Wall Street Journal*, March 21, 2026, [*"The Trillion Dollar Race to Automate Our Entire Lives"*](https://lnkd.in/gs9td3qd)
+> — *《华尔街日报》*,2026年3月21日,[*“这场价值万亿美元的竞赛,旨在自动化我们的整个生活”*](https://lnkd.in/gs9td3qd)

---
-## Porting Status
+## 移植状态
-The main source tree is now Python-first.
+主源码树现在以 Python 为主。
-- `src/` contains the active Python porting workspace
-- `tests/` verifies the current Python workspace
-- the exposed snapshot is no longer part of the tracked repository state
+- `src/` 包含活跃的 Python 移植工作区
+- `tests/` 验证当前的 Python 工作区
+- 曝光的快照不再是受追踪的仓库状态的一部分
-The current Python workspace is not yet a complete one-to-one replacement for the original system, but the primary implementation surface is now Python.
+目前的 Python 工作区尚未完全替代原始系统,但主要的实现界面现在是 Python。
-## Why this rewrite exists
+## 为什么进行这次重写
-I originally studied the exposed codebase to understand its harness, tool wiring, and agent workflow. After spending more time with the legal and ethical questions—and after reading the essay linked below—I did not want the exposed snapshot itself to remain the main tracked source tree.
+我最初研究曝光的代码库是为了了解其 harness、工具连接和代理工作流。在深入思考法律和伦理问题——并在阅读了下面链接的论文后——我不希望曝光的快照本身继续作为主要追踪的源码树。
-This repository now focuses on Python porting work instead.
+因此,本仓库现在专注于 Python 移植工作。
-## Repository Layout
+## 仓库布局
```text
.
-├── src/ # Python porting workspace
+├── src/ # Python 移植工作区
│ ├── __init__.py
│ ├── commands.py
│ ├── main.py
@@ -92,100 +115,114 @@ This repository now focuses on Python porting work instead.
│ ├── query_engine.py
│ ├── task.py
│ └── tools.py
-├── tests/ # Python verification
-├── assets/omx/ # OmX workflow screenshots
+├── rust/ # Rust 移植 (claw CLI)
+│ ├── crates/api/ # API 客户端 + 流式处理
+│ ├── crates/runtime/ # 会话、工具、MCP、配置
+│ ├── crates/claw-cli/ # 交互式 CLI 二进制文件
+│ ├── crates/plugins/ # 插件系统
+│ ├── crates/commands/ # 斜杠命令
+│ ├── crates/server/ # HTTP/SSE 服务器 (axum)
+│ ├── crates/lsp/ # LSP 客户端集成
+│ └── crates/tools/ # 工具规范
+├── tests/ # Python 验证
+├── assets/omx/ # OmX 工作流截图
├── 2026-03-09-is-legal-the-same-as-legitimate-ai-reimplementation-and-the-erosion-of-copyleft.md
└── README.md
```
-## Python Workspace Overview
+## Python 工作区概览
-The new Python `src/` tree currently provides:
+新的 Python `src/` 树目前提供:
-- **`port_manifest.py`** — summarizes the current Python workspace structure
-- **`models.py`** — dataclasses for subsystems, modules, and backlog state
-- **`commands.py`** — Python-side command port metadata
-- **`tools.py`** — Python-side tool port metadata
-- **`query_engine.py`** — renders a Python porting summary from the active workspace
-- **`main.py`** — a CLI entrypoint for manifest and summary output
+- **`port_manifest.py`** — 总结当前 Python 工作区的结构
+- **`models.py`** — 用于子系统、模块和积压状态的数据类
+- **`commands.py`** — Python 侧的命令移植元数据
+- **`tools.py`** — Python 侧的工具移植元数据
+- **`query_engine.py`** — 从活跃工作区渲染 Python 移植摘要
+- **`main.py`** — 用于清单和摘要输出的 CLI 入口点
-## Quickstart
+## 快速开始
-Render the Python porting summary:
+渲染 Python 移植摘要:
```bash
python3 -m src.main summary
```
-Print the current Python workspace manifest:
+打印当前 Python 工作区清单:
```bash
python3 -m src.main manifest
```
-List the current Python modules:
+列出当前的 Python 模块:
```bash
python3 -m src.main subsystems --limit 16
```
-Run verification:
+运行验证:
```bash
python3 -m unittest discover -s tests -v
```
-Run the parity audit against the local ignored archive (when present):
+对本地忽略的存档运行一致性审计(如果存在):
```bash
python3 -m src.main parity-audit
```
-Inspect mirrored command/tool inventories:
+检查镜像的命令/工具库:
```bash
python3 -m src.main commands --limit 10
python3 -m src.main tools --limit 10
```
-## Current Parity Checkpoint
+## 当前对比检查点
-The port now mirrors the archived root-entry file surface, top-level subsystem names, and command/tool inventories much more closely than before. However, it is **not yet** a full runtime-equivalent replacement for the original TypeScript system; the Python tree still contains fewer executable runtime slices than the archived source.
+该移植版本现在比以前更紧密地镜像了存档的根入口文件表面、顶层子系统名称以及命令/工具清单。然而,它**尚未**完全替代原始的 TypeScript 系统;Python 树中可执行的运行时切片仍少于存档源码。
+## 使用 `oh-my-codex` 和 `oh-my-opencode` 构建
-## Built with `oh-my-codex`
+本仓库的移植、净室加固和验证工作流是在 Yeachan Heo 工具栈的 AI 辅助下完成的,其中 **oh-my-codex (OmX)** 是主要的脚手架和编排层。
-The restructuring and documentation work on this repository was AI-assisted and orchestrated with Yeachan Heo's [oh-my-codex (OmX)](https://github.com/Yeachan-Heo/oh-my-codex), layered on top of Codex.
+- [**oh-my-codex (OmX)**](https://github.com/Yeachan-Heo/oh-my-codex) — 脚手架、编排、架构方向和核心移植工作流
+- [**oh-my-opencode (OmO)**](https://github.com/code-yeongyu/oh-my-openagent) — 实现加速、清理和验证支持
-- **`$team` mode:** used for coordinated parallel review and architectural feedback
-- **`$ralph` mode:** used for persistent execution, verification, and completion discipline
-- **Codex-driven workflow:** used to turn the main `src/` tree into a Python-first porting workspace
+移植过程中使用的关键工作流模式:
-### OmX workflow screenshots
+- **`$team` 模式:** 协调的并行审查和架构反馈
+- **`$ralph` 模式:** 持久执行、验证和完成纪律
+- **净室通过:** 跨 Rust 工作区的命名/品牌清理、QA 和发布验证
+- **手动和现场验证:** 构建、测试、手动 QA 以及发布前的真实 API 路径验证
+
+### OmX 工作流截图

-*Ralph/team orchestration view while the README and essay context were being reviewed in terminal panes.*
+*Ralph/team 编排视图,终端面板中正在审查 README 和论文背景。*

-*Split-pane review and verification flow during the final README wording pass.*
+*最后一次 README 措辞审核期间的分屏审查和验证流程。*
-## Community
+## 社区
-Join the [**instructkr Discord**](https://instruct.kr/) — the best Korean language model community. Come chat about LLMs, harness engineering, agent workflows, and everything in between.
+加入 [**instructkr Discord**](https://instruct.kr/) —— 最佳韩国语言模型社区。来这里聊聊 LLM、harness 工程、代理工作流以及其中的一切。
[](https://instruct.kr/)
## Star History
-See the chart at the top of this README.
+见 README 顶部的图表。
-## Ownership / Affiliation Disclaimer
+## 所有权 / 附属免责声明
-- This repository does **not** claim ownership of the original Claude Code source material.
-- This repository is **not affiliated with, endorsed by, or maintained by Anthropic**.
+- 本仓库**不**主张对原始 Claw Code 源码材料的所有权。
+- 本仓库**不隶属于、不被背书、也不由原作者维护**。
diff --git a/rust/.claude/sessions/session-1775007453382.json b/rust/.claude/sessions/session-1775007453382.json
deleted file mode 100644
index d45e491..0000000
--- a/rust/.claude/sessions/session-1775007453382.json
+++ /dev/null
@@ -1 +0,0 @@
-{"messages":[],"version":1}
\ No newline at end of file
diff --git a/rust/.claude/sessions/session-1775007484031.json b/rust/.claude/sessions/session-1775007484031.json
deleted file mode 100644
index d45e491..0000000
--- a/rust/.claude/sessions/session-1775007484031.json
+++ /dev/null
@@ -1 +0,0 @@
-{"messages":[],"version":1}
\ No newline at end of file
diff --git a/rust/.claude/sessions/session-1775007490104.json b/rust/.claude/sessions/session-1775007490104.json
deleted file mode 100644
index d45e491..0000000
--- a/rust/.claude/sessions/session-1775007490104.json
+++ /dev/null
@@ -1 +0,0 @@
-{"messages":[],"version":1}
\ No newline at end of file
diff --git a/rust/.claude/sessions/session-1775007981374.json b/rust/.claude/sessions/session-1775007981374.json
deleted file mode 100644
index d45e491..0000000
--- a/rust/.claude/sessions/session-1775007981374.json
+++ /dev/null
@@ -1 +0,0 @@
-{"messages":[],"version":1}
\ No newline at end of file
diff --git a/rust/.claude/sessions/session-1775008007069.json b/rust/.claude/sessions/session-1775008007069.json
deleted file mode 100644
index d45e491..0000000
--- a/rust/.claude/sessions/session-1775008007069.json
+++ /dev/null
@@ -1 +0,0 @@
-{"messages":[],"version":1}
\ No newline at end of file
diff --git a/rust/.claude/sessions/session-1775008071886.json b/rust/.claude/sessions/session-1775008071886.json
deleted file mode 100644
index d45e491..0000000
--- a/rust/.claude/sessions/session-1775008071886.json
+++ /dev/null
@@ -1 +0,0 @@
-{"messages":[],"version":1}
\ No newline at end of file
diff --git a/rust/.github/workflows/ci.yml b/rust/.github/workflows/ci.yml
new file mode 100644
index 0000000..73459b8
--- /dev/null
+++ b/rust/.github/workflows/ci.yml
@@ -0,0 +1,36 @@
+name: CI
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ branches:
+ - main
+
+jobs:
+ rust:
+ name: ${{ matrix.os }}
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ os:
+ - ubuntu-latest
+ - macos-latest
+
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v4
+
+ - name: Install Rust toolchain
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: Run cargo check
+ run: cargo check --workspace
+
+ - name: Run cargo test
+ run: cargo test --workspace
+
+ - name: Run release build
+ run: cargo build --release
diff --git a/rust/CONTRIBUTING.md b/rust/CONTRIBUTING.md
new file mode 100644
index 0000000..759fb9e
--- /dev/null
+++ b/rust/CONTRIBUTING.md
@@ -0,0 +1,43 @@
+# 贡献指南
+
+感谢你为 Claw Code 做出贡献。
+
+## 开发设置
+
+- 安装稳定的 Rust 工具链。
+- 在此 Rust 工作区的仓库根目录下进行开发。如果你从父仓库根目录开始,请先执行 `cd rust/`。
+
+## 构建
+
+```bash
+cargo build
+cargo build --release
+```
+
+## 测试与验证
+
+在开启 Pull Request 之前,请运行完整的 Rust 验证集:
+
+```bash
+cargo fmt --all --check
+cargo clippy --workspace --all-targets -- -D warnings
+cargo check --workspace
+cargo test --workspace
+```
+
+如果你更改了行为,请在同一个 Pull Request 中添加或更新相关的测试。
+
+## 代码风格
+
+- 遵循所修改 crate 中的现有模式,而不是引入新的风格。
+- 使用 `rustfmt` 格式化代码。
+- 确保你修改的工作区目标的 `clippy` 检查通过。
+- 优先采用针对性的 diff,而不是顺便进行的重构。
+
+## Pull Request
+
+- 从 `main` 分支拉取新分支。
+- 确保每个 Pull Request 的范围仅限于一个明确的更改。
+- 说明更改动机、实现摘要以及你运行的验证。
+- 在请求审查之前,确保本地检查已通过。
+- 如果审查反馈导致行为更改,请重新运行相关的验证命令。
diff --git a/rust/Cargo.lock b/rust/Cargo.lock
index 9030127..443b79d 100644
--- a/rust/Cargo.lock
+++ b/rust/Cargo.lock
@@ -28,12 +28,86 @@ dependencies = [
"tokio",
]
+[[package]]
+name = "async-stream"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
+dependencies = [
+ "async-stream-impl",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-stream-impl"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+[[package]]
+name = "axum"
+version = "0.8.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
+dependencies = [
+ "axum-core",
+ "bytes",
+ "form_urlencoded",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "itoa",
+ "matchit",
+ "memchr",
+ "mime",
+ "percent-encoding",
+ "pin-project-lite",
+ "serde_core",
+ "serde_json",
+ "serde_path_to_error",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "axum-core"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "http-body-util",
+ "mime",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
[[package]]
name = "base64"
version = "0.22.1"
@@ -49,6 +123,12 @@ dependencies = [
"serde",
]
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
[[package]]
name = "bitflags"
version = "2.11.0"
@@ -98,11 +178,40 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
+[[package]]
+name = "claw-cli"
+version = "0.1.0"
+dependencies = [
+ "api",
+ "commands",
+ "compat-harness",
+ "crossterm",
+ "plugins",
+ "pulldown-cmark",
+ "runtime",
+ "rustyline",
+ "serde_json",
+ "syntect",
+ "tokio",
+ "tools",
+]
+
+[[package]]
+name = "clipboard-win"
+version = "5.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
+dependencies = [
+ "error-code",
+]
+
[[package]]
name = "commands"
version = "0.1.0"
dependencies = [
+ "plugins",
"runtime",
+ "serde_json",
]
[[package]]
@@ -138,11 +247,11 @@ version = "0.28.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
dependencies = [
- "bitflags",
+ "bitflags 2.11.0",
"crossterm_winapi",
"mio",
"parking_lot",
- "rustix",
+ "rustix 0.38.44",
"signal-hook",
"signal-hook-mio",
"winapi",
@@ -197,6 +306,12 @@ dependencies = [
"syn",
]
+[[package]]
+name = "endian-type"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d"
+
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -213,6 +328,23 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "error-code"
+version = "3.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
+
+[[package]]
+name = "fd-lock"
+version = "4.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78"
+dependencies = [
+ "cfg-if",
+ "rustix 1.1.4",
+ "windows-sys 0.59.0",
+]
+
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -229,6 +361,15 @@ dependencies = [
"miniz_oxide",
]
+[[package]]
+name = "fluent-uri"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d"
+dependencies = [
+ "bitflags 1.3.2",
+]
+
[[package]]
name = "fnv"
version = "1.0.7"
@@ -266,6 +407,17 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
+[[package]]
+name = "futures-macro"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
[[package]]
name = "futures-sink"
version = "0.3.32"
@@ -286,6 +438,7 @@ checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-core",
"futures-io",
+ "futures-macro",
"futures-sink",
"futures-task",
"memchr",
@@ -351,6 +504,15 @@ version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
+[[package]]
+name = "home"
+version = "0.5.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "http"
version = "1.4.0"
@@ -390,6 +552,12 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+[[package]]
+name = "httpdate"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+
[[package]]
name = "hyper"
version = "1.9.0"
@@ -403,6 +571,7 @@ dependencies = [
"http",
"http-body",
"httparse",
+ "httpdate",
"itoa",
"pin-project-lite",
"smallvec",
@@ -614,6 +783,12 @@ version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
[[package]]
name = "litemap"
version = "0.8.1"
@@ -641,12 +816,48 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
+[[package]]
+name = "lsp"
+version = "0.1.0"
+dependencies = [
+ "lsp-types",
+ "serde",
+ "serde_json",
+ "tokio",
+ "url",
+]
+
+[[package]]
+name = "lsp-types"
+version = "0.97.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071"
+dependencies = [
+ "bitflags 1.3.2",
+ "fluent-uri",
+ "serde",
+ "serde_json",
+ "serde_repr",
+]
+
+[[package]]
+name = "matchit"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
+
[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -669,6 +880,27 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "nibble_vec"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43"
+dependencies = [
+ "smallvec",
+]
+
+[[package]]
+name = "nix"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
+dependencies = [
+ "bitflags 2.11.0",
+ "cfg-if",
+ "cfg_aliases",
+ "libc",
+]
+
[[package]]
name = "num-conv"
version = "0.2.1"
@@ -687,7 +919,7 @@ version = "6.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0"
dependencies = [
- "bitflags",
+ "bitflags 2.11.0",
"libc",
"once_cell",
"onig_sys",
@@ -757,6 +989,14 @@ dependencies = [
"time",
]
+[[package]]
+name = "plugins"
+version = "0.1.0"
+dependencies = [
+ "serde",
+ "serde_json",
+]
+
[[package]]
name = "potential_utf"
version = "0.1.4"
@@ -796,7 +1036,7 @@ version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c3a14896dfa883796f1cb410461aef38810ea05f2b2c33c5aded3649095fdad"
dependencies = [
- "bitflags",
+ "bitflags 2.11.0",
"getopts",
"memchr",
"pulldown-cmark-escape",
@@ -888,6 +1128,16 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+[[package]]
+name = "radix_trie"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd"
+dependencies = [
+ "endian-type",
+ "nibble_vec",
+]
+
[[package]]
name = "rand"
version = "0.9.2"
@@ -923,7 +1173,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
- "bitflags",
+ "bitflags 2.11.0",
]
[[package]]
@@ -985,12 +1235,14 @@ dependencies = [
"sync_wrapper",
"tokio",
"tokio-rustls",
+ "tokio-util",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
+ "wasm-streams",
"web-sys",
"webpki-roots",
]
@@ -1014,6 +1266,8 @@ name = "runtime"
version = "0.1.0"
dependencies = [
"glob",
+ "lsp",
+ "plugins",
"regex",
"serde",
"serde_json",
@@ -1034,11 +1288,24 @@ version = "0.38.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
dependencies = [
- "bitflags",
+ "bitflags 2.11.0",
"errno",
"libc",
- "linux-raw-sys",
- "windows-sys 0.52.0",
+ "linux-raw-sys 0.4.15",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags 2.11.0",
+ "errno",
+ "libc",
+ "linux-raw-sys 0.12.1",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -1098,6 +1365,28 @@ dependencies = [
"tools",
]
+[[package]]
+name = "rustyline"
+version = "15.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ee1e066dc922e513bda599c6ccb5f3bb2b0ea5870a579448f2622993f0a9a2f"
+dependencies = [
+ "bitflags 2.11.0",
+ "cfg-if",
+ "clipboard-win",
+ "fd-lock",
+ "home",
+ "libc",
+ "log",
+ "memchr",
+ "nix",
+ "radix_trie",
+ "unicode-segmentation",
+ "unicode-width",
+ "utf8parse",
+ "windows-sys 0.59.0",
+]
+
[[package]]
name = "ryu"
version = "1.0.23"
@@ -1162,6 +1451,28 @@ dependencies = [
"zmij",
]
+[[package]]
+name = "serde_path_to_error"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
+dependencies = [
+ "itoa",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "serde_repr"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
@@ -1174,6 +1485,19 @@ dependencies = [
"serde",
]
+[[package]]
+name = "server"
+version = "0.1.0"
+dependencies = [
+ "async-stream",
+ "axum",
+ "reqwest",
+ "runtime",
+ "serde",
+ "serde_json",
+ "tokio",
+]
+
[[package]]
name = "sha2"
version = "0.10.9"
@@ -1427,14 +1751,30 @@ dependencies = [
"tokio",
]
+[[package]]
+name = "tokio-util"
+version = "0.7.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "pin-project-lite",
+ "tokio",
+]
+
[[package]]
name = "tools"
version = "0.1.0"
dependencies = [
+ "api",
+ "plugins",
"reqwest",
"runtime",
"serde",
"serde_json",
+ "tokio",
]
[[package]]
@@ -1450,6 +1790,7 @@ dependencies = [
"tokio",
"tower-layer",
"tower-service",
+ "tracing",
]
[[package]]
@@ -1458,7 +1799,7 @@ version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
dependencies = [
- "bitflags",
+ "bitflags 2.11.0",
"bytes",
"futures-util",
"http",
@@ -1488,6 +1829,7 @@ version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
+ "log",
"pin-project-lite",
"tracing-core",
]
@@ -1525,6 +1867,12 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+[[package]]
+name = "unicode-segmentation"
+version = "1.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
+
[[package]]
name = "unicode-width"
version = "0.2.2"
@@ -1555,6 +1903,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
[[package]]
name = "version_check"
version = "0.9.5"
@@ -1650,6 +2004,19 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "wasm-streams"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
+dependencies = [
+ "futures-util",
+ "js-sys",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
[[package]]
name = "web-sys"
version = "0.3.93"
@@ -1725,6 +2092,15 @@ dependencies = [
"windows-targets 0.52.6",
]
+[[package]]
+name = "windows-sys"
+version = "0.59.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
[[package]]
name = "windows-sys"
version = "0.60.2"
diff --git a/rust/Cargo.toml b/rust/Cargo.toml
index 4a2f4d4..aa2f4ea 100644
--- a/rust/Cargo.toml
+++ b/rust/Cargo.toml
@@ -8,6 +8,10 @@ edition = "2021"
license = "MIT"
publish = false
+[workspace.dependencies]
+lsp-types = "0.97"
+serde_json = "1"
+
[workspace.lints.rust]
unsafe_code = "forbid"
diff --git a/rust/README.md b/rust/README.md
index 26a0d4a..fb4ef6b 100644
--- a/rust/README.md
+++ b/rust/README.md
@@ -1,230 +1,122 @@
-# Rusty Claude CLI
+# Claw Code
-`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.
+Claw Code 是一个使用安全 Rust 实现的本地编程代理(coding-agent)命令行工具。它的设计灵感来自 **Claude Code**,并作为一个**净室实现(clean-room implementation)**开发:旨在提供强大的本地代理体验,但它**不是** Claude Code 的直接移植或复制。
-## Workspace layout
+Rust 工作区是当前主要的产品界面。`claw` 二进制文件在单个工作区内提供交互式会话、单次提示、工作区感知工具、本地代理工作流以及支持插件的操作。
-```text
-rust/
-├── Cargo.toml
-├── Cargo.lock
-├── README.md
-└── crates/
- ├── 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
-```
+## 当前状态
-## Prerequisites
+- **版本:** `0.1.0`
+- **发布阶段:** 初始公开发布,源码编译分发
+- **主要实现:** 本仓库中的 Rust 工作区
+- **平台焦点:** macOS 和 Linux 开发工作站
-- Rust toolchain installed (`rustup`, stable toolchain)
-- Network access and Anthropic credentials for live prompt/REPL usage
+## 安装、构建与运行
-## Build
+### 准备工作
-From the repository root:
+- Rust 稳定版工具链
+- Cargo
+- 你想使用的模型的提供商凭据
+
+### 身份验证
+
+兼容 Anthropic 的模型:
```bash
-cd rust
-cargo build --release -p rusty-claude-cli
+export ANTHROPIC_API_KEY="..."
+# 使用兼容的端点时可选
+export ANTHROPIC_BASE_URL="https://api.anthropic.com"
```
-The optimized binary will be written to:
+Grok 模型:
```bash
-./target/release/rusty-claude-cli
+export XAI_API_KEY="..."
+# 使用兼容的端点时可选
+export XAI_BASE_URL="https://api.x.ai"
```
-## Test
-
-Run the verified workspace test suite used for release-readiness:
+也可以使用 OAuth 登录:
```bash
-cd rust
-cargo test --workspace --exclude compat-harness
+cargo run --bin claw -- login
```
-## Quick start
-
-### Show help
+### 本地安装
```bash
-cd rust
-cargo run -p rusty-claude-cli -- --help
+cargo install --path crates/claw-cli --locked
```
-### Print version
+### 从源码构建
```bash
-cd rust
-cargo run -p rusty-claude-cli -- --version
+cargo build --release -p claw-cli
```
-### 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
+cargo run --bin claw -- --help
+cargo run --bin claw --
+cargo run --bin claw -- prompt "总结此工作区"
+cargo run --bin claw -- --model sonnet "审查最新更改"
```
-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
+./target/release/claw
+./target/release/claw prompt "解释 crates/runtime"
```
-This removes only the stored OAuth credentials and preserves unrelated JSON fields in `credentials.json`.
+## 支持的功能
-### Self-update
+- 交互式 REPL 和单次提示执行
+- 已保存会话的检查和恢复流程
+- 内置工作区工具:shell、文件读/写/编辑、搜索、网页获取/搜索、待办事项和笔记本更新
+- 斜杠命令:状态、压缩、配置检查、差异(diff)、导出、会话管理和版本报告
+- 本地代理和技能发现:通过 `claw agents` 和 `claw skills`
+- 通过命令行和斜杠命令界面发现并管理插件
+- OAuth 登录/注销,以及从命令行选择模型/提供商
+- 工作区感知的指令/配置加载(`CLAW.md`、配置文件、权限、插件设置)
-```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.
+- 目前公开发布**仅限源码构建**;此工作区尚未设置 crates.io 发布
+- GitHub CI 验证 `cargo check`、`cargo test` 和发布构建,但尚未提供自动化的发布打包
+- 当前 CI 目标为 Ubuntu 和 macOS;Windows 的发布就绪性仍待建立
+- 一些实时提供商集成覆盖是可选的,因为它们需要外部凭据 and 网络访问
+- 命令界面可能会在 `0.x` 系列期间继续演进
-## Usage examples
+## 实现现状
-### 1) Prompt mode
+Rust 工作区是当前的产品实现。目前包含以下 crate:
-Send one prompt, stream the answer, then exit:
+- `claw-cli` — 面向用户的二进制文件
+- `api` — 提供商客户端和流式处理
+- `runtime` — 会话、配置、权限、提示词和运行时循环
+- `tools` — 内置工具实现
+- `commands` — 斜杠命令注册和处理程序
+- `plugins` — 插件发现、注册和生命周期支持
+- `lsp` — 语言服务器协议支持类型和进程助手
+- `server` 和 `compat-harness` — 支持服务和兼容性工具
-```bash
-cd rust
-cargo run -p rusty-claude-cli -- prompt "Summarize the architecture of this repository"
-```
+## 路线图
-Use a specific model:
+- 发布打包好的构件,用于公共安装
+- 添加可重复的发布工作流和长期维护的变更日志(changelog)规范
+- 将平台验证扩展到当前 CI 矩阵之外
+- 添加更多以任务为中心的示例和操作员文档
+- 继续加强 Rust 实现的功能覆盖并磨炼用户体验(UX)
-```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:
+- 0.1.0 发行说明草案:[`docs/releases/0.1.0.md`](docs/releases/0.1.0.md)
-```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 ` — run one prompt non-interactively
-- `--resume [/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 ` — 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 ` — 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 ]` — 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.
+有关许可详情,请参阅仓库根目录。
diff --git a/rust/crates/api/Cargo.toml b/rust/crates/api/Cargo.toml
index c5e152e..b9923a8 100644
--- a/rust/crates/api/Cargo.toml
+++ b/rust/crates/api/Cargo.toml
@@ -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 = "1"
+serde_json.workspace = true
tokio = { version = "1", features = ["io-util", "macros", "net", "rt-multi-thread", "time"] }
[lints]
diff --git a/rust/crates/api/src/client.rs b/rust/crates/api/src/client.rs
index 110a80b..b596777 100644
--- a/rust/crates/api/src/client.rs
+++ b/rust/crates/api/src/client.rs
@@ -1,1006 +1,141 @@
-use std::collections::VecDeque;
-use std::time::{Duration, SystemTime, UNIX_EPOCH};
-
-use runtime::{
- load_oauth_credentials, save_oauth_credentials, OAuthConfig, OAuthRefreshRequest,
- OAuthTokenExchangeRequest,
-};
-use serde::Deserialize;
-
use crate::error::ApiError;
-use crate::sse::SseParser;
+use crate::providers::claw_provider::{self, AuthSource, ClawApiClient};
+use crate::providers::openai_compat::{self, OpenAiCompatClient, OpenAiCompatConfig};
+use crate::providers::{self, Provider, ProviderKind};
use crate::types::{MessageRequest, MessageResponse, StreamEvent};
-const DEFAULT_BASE_URL: &str = "https://api.anthropic.com";
-const ANTHROPIC_VERSION: &str = "2023-06-01";
-const REQUEST_ID_HEADER: &str = "request-id";
-const ALT_REQUEST_ID_HEADER: &str = "x-request-id";
-const DEFAULT_INITIAL_BACKOFF: Duration = Duration::from_millis(200);
-const DEFAULT_MAX_BACKOFF: Duration = Duration::from_secs(2);
-const DEFAULT_MAX_RETRIES: u32 = 2;
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub enum AuthSource {
- None,
- ApiKey(String),
- BearerToken(String),
- ApiKeyAndBearer {
- api_key: String,
- bearer_token: String,
- },
+async fn send_via_provider(
+ provider: &P,
+ request: &MessageRequest,
+) -> Result {
+ provider.send_message(request).await
}
-impl AuthSource {
- pub fn from_env() -> Result {
- let api_key = read_env_non_empty("ANTHROPIC_API_KEY")?;
- let auth_token = read_env_non_empty("ANTHROPIC_AUTH_TOKEN")?;
- match (api_key, auth_token) {
- (Some(api_key), Some(bearer_token)) => Ok(Self::ApiKeyAndBearer {
- api_key,
- bearer_token,
- }),
- (Some(api_key), None) => Ok(Self::ApiKey(api_key)),
- (None, Some(bearer_token)) => Ok(Self::BearerToken(bearer_token)),
- (None, None) => Err(ApiError::MissingApiKey),
- }
- }
-
- #[must_use]
- pub fn api_key(&self) -> Option<&str> {
- match self {
- Self::ApiKey(api_key) | Self::ApiKeyAndBearer { api_key, .. } => Some(api_key),
- Self::None | Self::BearerToken(_) => None,
- }
- }
-
- #[must_use]
- pub fn bearer_token(&self) -> Option<&str> {
- match self {
- Self::BearerToken(token)
- | Self::ApiKeyAndBearer {
- bearer_token: token,
- ..
- } => Some(token),
- Self::None | Self::ApiKey(_) => None,
- }
- }
-
- #[must_use]
- pub fn masked_authorization_header(&self) -> &'static str {
- if self.bearer_token().is_some() {
- "Bearer [REDACTED]"
- } else {
- ""
- }
- }
-
- pub fn apply(&self, mut request_builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
- if let Some(api_key) = self.api_key() {
- request_builder = request_builder.header("x-api-key", api_key);
- }
- if let Some(token) = self.bearer_token() {
- request_builder = request_builder.bearer_auth(token);
- }
- request_builder
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
-pub struct OAuthTokenSet {
- pub access_token: String,
- pub refresh_token: Option,
- pub expires_at: Option,
- #[serde(default)]
- pub scopes: Vec,
-}
-
-impl From for AuthSource {
- fn from(value: OAuthTokenSet) -> Self {
- Self::BearerToken(value.access_token)
- }
+async fn stream_via_provider(
+ provider: &P,
+ request: &MessageRequest,
+) -> Result {
+ provider.stream_message(request).await
}
#[derive(Debug, Clone)]
-pub struct AnthropicClient {
- http: reqwest::Client,
- auth: AuthSource,
- base_url: String,
- max_retries: u32,
- initial_backoff: Duration,
- max_backoff: Duration,
+pub enum ProviderClient {
+ ClawApi(ClawApiClient),
+ Xai(OpenAiCompatClient),
+ OpenAi(OpenAiCompatClient),
}
-impl AnthropicClient {
- #[must_use]
- pub fn new(api_key: impl Into) -> Self {
- Self {
- http: reqwest::Client::new(),
- auth: AuthSource::ApiKey(api_key.into()),
- base_url: DEFAULT_BASE_URL.to_string(),
- max_retries: DEFAULT_MAX_RETRIES,
- initial_backoff: DEFAULT_INITIAL_BACKOFF,
- max_backoff: DEFAULT_MAX_BACKOFF,
+impl ProviderClient {
+ pub fn from_model(model: &str) -> Result {
+ Self::from_model_with_default_auth(model, None)
+ }
+
+ pub fn from_model_with_default_auth(
+ model: &str,
+ default_auth: Option,
+ ) -> Result {
+ let resolved_model = providers::resolve_model_alias(model);
+ match providers::detect_provider_kind(&resolved_model) {
+ ProviderKind::ClawApi => Ok(Self::ClawApi(match default_auth {
+ Some(auth) => ClawApiClient::from_auth(auth),
+ None => ClawApiClient::from_env()?,
+ })),
+ ProviderKind::Xai => Ok(Self::Xai(OpenAiCompatClient::from_env(
+ OpenAiCompatConfig::xai(),
+ )?)),
+ ProviderKind::OpenAi => Ok(Self::OpenAi(OpenAiCompatClient::from_env(
+ OpenAiCompatConfig::openai(),
+ )?)),
}
}
#[must_use]
- pub fn from_auth(auth: AuthSource) -> Self {
- Self {
- http: reqwest::Client::new(),
- auth,
- base_url: DEFAULT_BASE_URL.to_string(),
- max_retries: DEFAULT_MAX_RETRIES,
- initial_backoff: DEFAULT_INITIAL_BACKOFF,
- max_backoff: DEFAULT_MAX_BACKOFF,
+ pub const fn provider_kind(&self) -> ProviderKind {
+ match self {
+ Self::ClawApi(_) => ProviderKind::ClawApi,
+ Self::Xai(_) => ProviderKind::Xai,
+ Self::OpenAi(_) => ProviderKind::OpenAi,
}
}
- pub fn from_env() -> Result {
- Ok(Self::from_auth(AuthSource::from_env_or_saved()?).with_base_url(read_base_url()))
- }
-
- #[must_use]
- pub fn with_auth_source(mut self, auth: AuthSource) -> Self {
- self.auth = auth;
- self
- }
-
- #[must_use]
- pub fn with_auth_token(mut self, auth_token: Option) -> Self {
- match (
- self.auth.api_key().map(ToOwned::to_owned),
- auth_token.filter(|token| !token.is_empty()),
- ) {
- (Some(api_key), Some(bearer_token)) => {
- self.auth = AuthSource::ApiKeyAndBearer {
- api_key,
- bearer_token,
- };
- }
- (Some(api_key), None) => {
- self.auth = AuthSource::ApiKey(api_key);
- }
- (None, Some(bearer_token)) => {
- self.auth = AuthSource::BearerToken(bearer_token);
- }
- (None, None) => {
- self.auth = AuthSource::None;
- }
- }
- self
- }
-
- #[must_use]
- pub fn with_base_url(mut self, base_url: impl Into) -> Self {
- self.base_url = base_url.into();
- self
- }
-
- #[must_use]
- pub fn with_retry_policy(
- mut self,
- max_retries: u32,
- initial_backoff: Duration,
- max_backoff: Duration,
- ) -> Self {
- self.max_retries = max_retries;
- self.initial_backoff = initial_backoff;
- self.max_backoff = max_backoff;
- self
- }
-
- #[must_use]
- pub fn auth_source(&self) -> &AuthSource {
- &self.auth
- }
-
pub async fn send_message(
&self,
request: &MessageRequest,
) -> Result {
- let request = MessageRequest {
- stream: false,
- ..request.clone()
- };
- let response = self.send_with_retry(&request).await?;
- let request_id = request_id_from_headers(response.headers());
- let mut response = response
- .json::()
- .await
- .map_err(ApiError::from)?;
- if response.request_id.is_none() {
- response.request_id = request_id;
+ match self {
+ Self::ClawApi(client) => send_via_provider(client, request).await,
+ Self::Xai(client) | Self::OpenAi(client) => send_via_provider(client, request).await,
}
- Ok(response)
}
pub async fn stream_message(
&self,
request: &MessageRequest,
) -> Result {
- let response = self
- .send_with_retry(&request.clone().with_streaming())
- .await?;
- Ok(MessageStream {
- request_id: request_id_from_headers(response.headers()),
- response,
- parser: SseParser::new(),
- pending: VecDeque::new(),
- done: false,
- })
- }
-
- pub async fn exchange_oauth_code(
- &self,
- config: &OAuthConfig,
- request: &OAuthTokenExchangeRequest,
- ) -> Result {
- let response = self
- .http
- .post(&config.token_url)
- .header("content-type", "application/x-www-form-urlencoded")
- .form(&request.form_params())
- .send()
- .await
- .map_err(ApiError::from)?;
- let response = expect_success(response).await?;
- response
- .json::()
- .await
- .map_err(ApiError::from)
- }
-
- pub async fn refresh_oauth_token(
- &self,
- config: &OAuthConfig,
- request: &OAuthRefreshRequest,
- ) -> Result {
- let response = self
- .http
- .post(&config.token_url)
- .header("content-type", "application/x-www-form-urlencoded")
- .form(&request.form_params())
- .send()
- .await
- .map_err(ApiError::from)?;
- let response = expect_success(response).await?;
- response
- .json::()
- .await
- .map_err(ApiError::from)
- }
-
- async fn send_with_retry(
- &self,
- request: &MessageRequest,
- ) -> Result {
- let mut attempts = 0;
- let mut last_error: Option;
-
- loop {
- attempts += 1;
- match self.send_raw_request(request).await {
- Ok(response) => match expect_success(response).await {
- Ok(response) => return Ok(response),
- Err(error) if error.is_retryable() && attempts <= self.max_retries + 1 => {
- last_error = Some(error);
- }
- Err(error) => return Err(error),
- },
- Err(error) if error.is_retryable() && attempts <= self.max_retries + 1 => {
- last_error = Some(error);
- }
- Err(error) => return Err(error),
- }
-
- if attempts > self.max_retries {
- break;
- }
-
- tokio::time::sleep(self.backoff_for_attempt(attempts)?).await;
- }
-
- Err(ApiError::RetriesExhausted {
- attempts,
- last_error: Box::new(last_error.expect("retry loop must capture an error")),
- })
- }
-
- async fn send_raw_request(
- &self,
- request: &MessageRequest,
- ) -> Result {
- let request_url = format!("{}/v1/messages", self.base_url.trim_end_matches('/'));
- let resolved_base_url = self.base_url.trim_end_matches('/');
- eprintln!("[anthropic-client] resolved_base_url={resolved_base_url}");
- eprintln!("[anthropic-client] request_url={request_url}");
- let request_builder = self
- .http
- .post(&request_url)
- .header("anthropic-version", ANTHROPIC_VERSION)
- .header("content-type", "application/json");
- let mut request_builder = self.auth.apply(request_builder);
-
- eprintln!(
- "[anthropic-client] headers x-api-key={} authorization={} anthropic-version={ANTHROPIC_VERSION} content-type=application/json",
- if self.auth.api_key().is_some() {
- "[REDACTED]"
- } else {
- ""
- },
- self.auth.masked_authorization_header()
- );
-
- request_builder = request_builder.json(request);
- request_builder.send().await.map_err(ApiError::from)
- }
-
- fn backoff_for_attempt(&self, attempt: u32) -> Result {
- let Some(multiplier) = 1_u32.checked_shl(attempt.saturating_sub(1)) else {
- return Err(ApiError::BackoffOverflow {
- attempt,
- base_delay: self.initial_backoff,
- });
- };
- Ok(self
- .initial_backoff
- .checked_mul(multiplier)
- .map_or(self.max_backoff, |delay| delay.min(self.max_backoff)))
- }
-}
-
-impl AuthSource {
- pub fn from_env_or_saved() -> Result {
- if let Some(api_key) = read_env_non_empty("ANTHROPIC_API_KEY")? {
- return match read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? {
- Some(bearer_token) => Ok(Self::ApiKeyAndBearer {
- api_key,
- bearer_token,
- }),
- None => Ok(Self::ApiKey(api_key)),
- };
- }
- if let Some(bearer_token) = read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? {
- return Ok(Self::BearerToken(bearer_token));
- }
- match load_saved_oauth_token() {
- Ok(Some(token_set)) if oauth_token_is_expired(&token_set) => {
- if token_set.refresh_token.is_some() {
- Err(ApiError::Auth(
- "saved OAuth token is expired; load runtime OAuth config to refresh it"
- .to_string(),
- ))
- } else {
- Err(ApiError::ExpiredOAuthToken)
- }
- }
- Ok(Some(token_set)) => Ok(Self::BearerToken(token_set.access_token)),
- Ok(None) => Err(ApiError::MissingApiKey),
- Err(error) => Err(error),
+ match self {
+ Self::ClawApi(client) => stream_via_provider(client, request)
+ .await
+ .map(MessageStream::ClawApi),
+ Self::Xai(client) | Self::OpenAi(client) => stream_via_provider(client, request)
+ .await
+ .map(MessageStream::OpenAiCompat),
}
}
}
-#[must_use]
-pub fn oauth_token_is_expired(token_set: &OAuthTokenSet) -> bool {
- token_set
- .expires_at
- .is_some_and(|expires_at| expires_at <= now_unix_timestamp())
-}
-
-pub fn resolve_saved_oauth_token(config: &OAuthConfig) -> Result, ApiError> {
- let Some(token_set) = load_saved_oauth_token()? else {
- return Ok(None);
- };
- resolve_saved_oauth_token_set(config, token_set).map(Some)
-}
-
-pub fn resolve_startup_auth_source(load_oauth_config: F) -> Result
-where
- F: FnOnce() -> Result, ApiError>,
-{
- if let Some(api_key) = read_env_non_empty("ANTHROPIC_API_KEY")? {
- return match read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? {
- Some(bearer_token) => Ok(AuthSource::ApiKeyAndBearer {
- api_key,
- bearer_token,
- }),
- None => Ok(AuthSource::ApiKey(api_key)),
- };
- }
- if let Some(bearer_token) = read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? {
- return Ok(AuthSource::BearerToken(bearer_token));
- }
-
- let Some(token_set) = load_saved_oauth_token()? else {
- return Err(ApiError::MissingApiKey);
- };
- if !oauth_token_is_expired(&token_set) {
- return Ok(AuthSource::BearerToken(token_set.access_token));
- }
- if token_set.refresh_token.is_none() {
- return Err(ApiError::ExpiredOAuthToken);
- }
-
- let Some(config) = load_oauth_config()? else {
- return Err(ApiError::Auth(
- "saved OAuth token is expired; runtime OAuth config is missing".to_string(),
- ));
- };
- Ok(AuthSource::from(resolve_saved_oauth_token_set(
- &config, token_set,
- )?))
-}
-
-fn resolve_saved_oauth_token_set(
- config: &OAuthConfig,
- token_set: OAuthTokenSet,
-) -> Result {
- if !oauth_token_is_expired(&token_set) {
- return Ok(token_set);
- }
- let Some(refresh_token) = token_set.refresh_token.clone() else {
- return Err(ApiError::ExpiredOAuthToken);
- };
- let client = AnthropicClient::from_auth(AuthSource::None).with_base_url(read_base_url());
- let refreshed = client_runtime_block_on(async {
- client
- .refresh_oauth_token(
- config,
- &OAuthRefreshRequest::from_config(
- config,
- refresh_token,
- Some(token_set.scopes.clone()),
- ),
- )
- .await
- })?;
- let resolved = OAuthTokenSet {
- access_token: refreshed.access_token,
- refresh_token: refreshed.refresh_token.or(token_set.refresh_token),
- expires_at: refreshed.expires_at,
- scopes: refreshed.scopes,
- };
- save_oauth_credentials(&runtime::OAuthTokenSet {
- access_token: resolved.access_token.clone(),
- refresh_token: resolved.refresh_token.clone(),
- expires_at: resolved.expires_at,
- scopes: resolved.scopes.clone(),
- })
- .map_err(ApiError::from)?;
- Ok(resolved)
-}
-
-fn client_runtime_block_on(future: F) -> Result
-where
- F: std::future::Future>,
-{
- tokio::runtime::Runtime::new()
- .map_err(ApiError::from)?
- .block_on(future)
-}
-
-fn load_saved_oauth_token() -> Result, ApiError> {
- let token_set = load_oauth_credentials().map_err(ApiError::from)?;
- Ok(token_set.map(|token_set| OAuthTokenSet {
- access_token: token_set.access_token,
- refresh_token: token_set.refresh_token,
- expires_at: token_set.expires_at,
- scopes: token_set.scopes,
- }))
-}
-
-fn now_unix_timestamp() -> u64 {
- SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .map_or(0, |duration| duration.as_secs())
-}
-
-fn read_env_non_empty(key: &str) -> Result , ApiError> {
- match std::env::var(key) {
- Ok(value) if !value.is_empty() => Ok(Some(value)),
- Ok(_) | Err(std::env::VarError::NotPresent) => Ok(None),
- Err(error) => Err(ApiError::from(error)),
- }
-}
-
-#[cfg(test)]
-fn read_api_key() -> Result {
- let auth = AuthSource::from_env_or_saved()?;
- auth.api_key()
- .or_else(|| auth.bearer_token())
- .map(ToOwned::to_owned)
- .ok_or(ApiError::MissingApiKey)
-}
-
-#[cfg(test)]
-fn read_auth_token() -> Option {
- read_env_non_empty("ANTHROPIC_AUTH_TOKEN")
- .ok()
- .and_then(std::convert::identity)
-}
-
-pub fn read_base_url() -> String {
- std::env::var("ANTHROPIC_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.to_string())
-}
-
-fn request_id_from_headers(headers: &reqwest::header::HeaderMap) -> Option {
- headers
- .get(REQUEST_ID_HEADER)
- .or_else(|| headers.get(ALT_REQUEST_ID_HEADER))
- .and_then(|value| value.to_str().ok())
- .map(ToOwned::to_owned)
-}
-
#[derive(Debug)]
-pub struct MessageStream {
- request_id: Option,
- response: reqwest::Response,
- parser: SseParser,
- pending: VecDeque,
- done: bool,
+pub enum MessageStream {
+ ClawApi(claw_provider::MessageStream),
+ OpenAiCompat(openai_compat::MessageStream),
}
impl MessageStream {
#[must_use]
pub fn request_id(&self) -> Option<&str> {
- self.request_id.as_deref()
+ match self {
+ Self::ClawApi(stream) => stream.request_id(),
+ Self::OpenAiCompat(stream) => stream.request_id(),
+ }
}
pub async fn next_event(&mut self) -> Result, ApiError> {
- loop {
- if let Some(event) = self.pending.pop_front() {
- return Ok(Some(event));
- }
-
- if self.done {
- let remaining = self.parser.finish()?;
- self.pending.extend(remaining);
- if let Some(event) = self.pending.pop_front() {
- return Ok(Some(event));
- }
- return Ok(None);
- }
-
- match self.response.chunk().await? {
- Some(chunk) => {
- self.pending.extend(self.parser.push(&chunk)?);
- }
- None => {
- self.done = true;
- }
- }
+ match self {
+ Self::ClawApi(stream) => stream.next_event().await,
+ Self::OpenAiCompat(stream) => stream.next_event().await,
}
}
}
-async fn expect_success(response: reqwest::Response) -> Result {
- let status = response.status();
- if status.is_success() {
- return Ok(response);
- }
-
- let body = response.text().await.unwrap_or_else(|_| String::new());
- let parsed_error = serde_json::from_str::(&body).ok();
- let retryable = is_retryable_status(status);
-
- Err(ApiError::Api {
- status,
- error_type: parsed_error
- .as_ref()
- .map(|error| error.error.error_type.clone()),
- message: parsed_error
- .as_ref()
- .map(|error| error.error.message.clone()),
- body,
- retryable,
- })
+pub use claw_provider::{
+ oauth_token_is_expired, resolve_saved_oauth_token, resolve_startup_auth_source, OAuthTokenSet,
+};
+#[must_use]
+pub fn read_base_url() -> String {
+ claw_provider::read_base_url()
}
-const fn is_retryable_status(status: reqwest::StatusCode) -> bool {
- matches!(status.as_u16(), 408 | 409 | 429 | 500 | 502 | 503 | 504)
-}
-
-#[derive(Debug, Deserialize)]
-struct AnthropicErrorEnvelope {
- error: AnthropicErrorBody,
-}
-
-#[derive(Debug, Deserialize)]
-struct AnthropicErrorBody {
- #[serde(rename = "type")]
- error_type: String,
- message: String,
+#[must_use]
+pub fn read_xai_base_url() -> String {
+ openai_compat::read_base_url(OpenAiCompatConfig::xai())
}
#[cfg(test)]
mod tests {
- use super::{ALT_REQUEST_ID_HEADER, REQUEST_ID_HEADER};
- use std::io::{Read, Write};
- use std::net::TcpListener;
- use std::sync::{Mutex, OnceLock};
- use std::thread;
- use std::time::{Duration, SystemTime, UNIX_EPOCH};
+ use crate::providers::{detect_provider_kind, resolve_model_alias, ProviderKind};
- use runtime::{clear_oauth_credentials, save_oauth_credentials, OAuthConfig};
-
- use crate::client::{
- now_unix_timestamp, oauth_token_is_expired, resolve_saved_oauth_token,
- resolve_startup_auth_source, AnthropicClient, AuthSource, OAuthTokenSet,
- };
- use crate::types::{ContentBlockDelta, MessageRequest};
-
- fn env_lock() -> std::sync::MutexGuard<'static, ()> {
- static LOCK: OnceLock> = OnceLock::new();
- LOCK.get_or_init(|| Mutex::new(()))
- .lock()
- .expect("env lock")
- }
-
- fn temp_config_home() -> std::path::PathBuf {
- std::env::temp_dir().join(format!(
- "api-oauth-test-{}-{}",
- std::process::id(),
- SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .expect("time")
- .as_nanos()
- ))
- }
-
- fn sample_oauth_config(token_url: String) -> OAuthConfig {
- OAuthConfig {
- client_id: "runtime-client".to_string(),
- authorize_url: "https://console.test/oauth/authorize".to_string(),
- token_url,
- callback_port: Some(4545),
- manual_redirect_url: Some("https://console.test/oauth/callback".to_string()),
- scopes: vec!["org:read".to_string(), "user:write".to_string()],
- }
- }
-
- fn spawn_token_server(response_body: &'static str) -> String {
- let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener");
- let address = listener.local_addr().expect("local addr");
- thread::spawn(move || {
- let (mut stream, _) = listener.accept().expect("accept connection");
- let mut buffer = [0_u8; 4096];
- let _ = stream.read(&mut buffer).expect("read request");
- let response = format!(
- "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}",
- response_body.len(),
- response_body
- );
- stream
- .write_all(response.as_bytes())
- .expect("write response");
- });
- format!("http://{address}/oauth/token")
+ #[test]
+ fn resolves_existing_and_grok_aliases() {
+ assert_eq!(resolve_model_alias("opus"), "claude-opus-4-6");
+ assert_eq!(resolve_model_alias("grok"), "grok-3");
+ assert_eq!(resolve_model_alias("grok-mini"), "grok-3-mini");
}
#[test]
- fn read_api_key_requires_presence() {
- let _guard = env_lock();
- std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
- std::env::remove_var("ANTHROPIC_API_KEY");
- std::env::remove_var("CLAUDE_CONFIG_HOME");
- let error = super::read_api_key().expect_err("missing key should error");
- assert!(matches!(error, crate::error::ApiError::MissingApiKey));
- }
-
- #[test]
- fn read_api_key_requires_non_empty_value() {
- let _guard = env_lock();
- std::env::set_var("ANTHROPIC_AUTH_TOKEN", "");
- std::env::remove_var("ANTHROPIC_API_KEY");
- let error = super::read_api_key().expect_err("empty key should error");
- assert!(matches!(error, crate::error::ApiError::MissingApiKey));
- std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
- }
-
- #[test]
- fn read_api_key_prefers_api_key_env() {
- let _guard = env_lock();
- std::env::set_var("ANTHROPIC_AUTH_TOKEN", "auth-token");
- std::env::set_var("ANTHROPIC_API_KEY", "legacy-key");
+ fn provider_detection_prefers_model_family() {
+ assert_eq!(detect_provider_kind("grok-3"), ProviderKind::Xai);
assert_eq!(
- super::read_api_key().expect("api key should load"),
- "legacy-key"
- );
- std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
- std::env::remove_var("ANTHROPIC_API_KEY");
- }
-
- #[test]
- fn read_auth_token_reads_auth_token_env() {
- let _guard = env_lock();
- std::env::set_var("ANTHROPIC_AUTH_TOKEN", "auth-token");
- assert_eq!(super::read_auth_token().as_deref(), Some("auth-token"));
- std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
- }
-
- #[test]
- fn oauth_token_maps_to_bearer_auth_source() {
- let auth = AuthSource::from(OAuthTokenSet {
- access_token: "access-token".to_string(),
- refresh_token: Some("refresh".to_string()),
- expires_at: Some(123),
- scopes: vec!["scope:a".to_string()],
- });
- assert_eq!(auth.bearer_token(), Some("access-token"));
- assert_eq!(auth.api_key(), None);
- }
-
- #[test]
- fn auth_source_from_env_combines_api_key_and_bearer_token() {
- let _guard = env_lock();
- std::env::set_var("ANTHROPIC_AUTH_TOKEN", "auth-token");
- std::env::set_var("ANTHROPIC_API_KEY", "legacy-key");
- let auth = AuthSource::from_env().expect("env auth");
- assert_eq!(auth.api_key(), Some("legacy-key"));
- assert_eq!(auth.bearer_token(), Some("auth-token"));
- std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
- std::env::remove_var("ANTHROPIC_API_KEY");
- }
-
- #[test]
- fn auth_source_from_saved_oauth_when_env_absent() {
- let _guard = env_lock();
- let config_home = temp_config_home();
- std::env::set_var("CLAUDE_CONFIG_HOME", &config_home);
- std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
- std::env::remove_var("ANTHROPIC_API_KEY");
- save_oauth_credentials(&runtime::OAuthTokenSet {
- access_token: "saved-access-token".to_string(),
- refresh_token: Some("refresh".to_string()),
- expires_at: Some(now_unix_timestamp() + 300),
- scopes: vec!["scope:a".to_string()],
- })
- .expect("save oauth credentials");
-
- let auth = AuthSource::from_env_or_saved().expect("saved auth");
- assert_eq!(auth.bearer_token(), Some("saved-access-token"));
-
- clear_oauth_credentials().expect("clear credentials");
- std::env::remove_var("CLAUDE_CONFIG_HOME");
- std::fs::remove_dir_all(config_home).expect("cleanup temp dir");
- }
-
- #[test]
- fn oauth_token_expiry_uses_expires_at_timestamp() {
- assert!(oauth_token_is_expired(&OAuthTokenSet {
- access_token: "access-token".to_string(),
- refresh_token: None,
- expires_at: Some(1),
- scopes: Vec::new(),
- }));
- assert!(!oauth_token_is_expired(&OAuthTokenSet {
- access_token: "access-token".to_string(),
- refresh_token: None,
- expires_at: Some(now_unix_timestamp() + 60),
- scopes: Vec::new(),
- }));
- }
-
- #[test]
- fn resolve_saved_oauth_token_refreshes_expired_credentials() {
- let _guard = env_lock();
- let config_home = temp_config_home();
- std::env::set_var("CLAUDE_CONFIG_HOME", &config_home);
- std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
- std::env::remove_var("ANTHROPIC_API_KEY");
- save_oauth_credentials(&runtime::OAuthTokenSet {
- access_token: "expired-access-token".to_string(),
- refresh_token: Some("refresh-token".to_string()),
- expires_at: Some(1),
- scopes: vec!["scope:a".to_string()],
- })
- .expect("save expired oauth credentials");
-
- let token_url = spawn_token_server(
- "{\"access_token\":\"refreshed-token\",\"refresh_token\":\"fresh-refresh\",\"expires_at\":9999999999,\"scopes\":[\"scope:a\"]}",
- );
- let resolved = resolve_saved_oauth_token(&sample_oauth_config(token_url))
- .expect("resolve refreshed token")
- .expect("token set present");
- assert_eq!(resolved.access_token, "refreshed-token");
- let stored = runtime::load_oauth_credentials()
- .expect("load stored credentials")
- .expect("stored token set");
- assert_eq!(stored.access_token, "refreshed-token");
-
- clear_oauth_credentials().expect("clear credentials");
- std::env::remove_var("CLAUDE_CONFIG_HOME");
- std::fs::remove_dir_all(config_home).expect("cleanup temp dir");
- }
-
- #[test]
- fn resolve_startup_auth_source_uses_saved_oauth_without_loading_config() {
- let _guard = env_lock();
- let config_home = temp_config_home();
- std::env::set_var("CLAUDE_CONFIG_HOME", &config_home);
- std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
- std::env::remove_var("ANTHROPIC_API_KEY");
- save_oauth_credentials(&runtime::OAuthTokenSet {
- access_token: "saved-access-token".to_string(),
- refresh_token: Some("refresh".to_string()),
- expires_at: Some(now_unix_timestamp() + 300),
- scopes: vec!["scope:a".to_string()],
- })
- .expect("save oauth credentials");
-
- let auth = resolve_startup_auth_source(|| panic!("config should not be loaded"))
- .expect("startup auth");
- assert_eq!(auth.bearer_token(), Some("saved-access-token"));
-
- clear_oauth_credentials().expect("clear credentials");
- std::env::remove_var("CLAUDE_CONFIG_HOME");
- std::fs::remove_dir_all(config_home).expect("cleanup temp dir");
- }
-
- #[test]
- fn resolve_startup_auth_source_errors_when_refreshable_token_lacks_config() {
- let _guard = env_lock();
- let config_home = temp_config_home();
- std::env::set_var("CLAUDE_CONFIG_HOME", &config_home);
- std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
- std::env::remove_var("ANTHROPIC_API_KEY");
- save_oauth_credentials(&runtime::OAuthTokenSet {
- access_token: "expired-access-token".to_string(),
- refresh_token: Some("refresh-token".to_string()),
- expires_at: Some(1),
- scopes: vec!["scope:a".to_string()],
- })
- .expect("save expired oauth credentials");
-
- let error =
- resolve_startup_auth_source(|| Ok(None)).expect_err("missing config should error");
- assert!(
- matches!(error, crate::error::ApiError::Auth(message) if message.contains("runtime OAuth config is missing"))
- );
-
- let stored = runtime::load_oauth_credentials()
- .expect("load stored credentials")
- .expect("stored token set");
- assert_eq!(stored.access_token, "expired-access-token");
- assert_eq!(stored.refresh_token.as_deref(), Some("refresh-token"));
-
- clear_oauth_credentials().expect("clear credentials");
- std::env::remove_var("CLAUDE_CONFIG_HOME");
- std::fs::remove_dir_all(config_home).expect("cleanup temp dir");
- }
-
- #[test]
- fn resolve_saved_oauth_token_preserves_refresh_token_when_refresh_response_omits_it() {
- let _guard = env_lock();
- let config_home = temp_config_home();
- std::env::set_var("CLAUDE_CONFIG_HOME", &config_home);
- std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
- std::env::remove_var("ANTHROPIC_API_KEY");
- save_oauth_credentials(&runtime::OAuthTokenSet {
- access_token: "expired-access-token".to_string(),
- refresh_token: Some("refresh-token".to_string()),
- expires_at: Some(1),
- scopes: vec!["scope:a".to_string()],
- })
- .expect("save expired oauth credentials");
-
- let token_url = spawn_token_server(
- "{\"access_token\":\"refreshed-token\",\"expires_at\":9999999999,\"scopes\":[\"scope:a\"]}",
- );
- let resolved = resolve_saved_oauth_token(&sample_oauth_config(token_url))
- .expect("resolve refreshed token")
- .expect("token set present");
- assert_eq!(resolved.access_token, "refreshed-token");
- assert_eq!(resolved.refresh_token.as_deref(), Some("refresh-token"));
- let stored = runtime::load_oauth_credentials()
- .expect("load stored credentials")
- .expect("stored token set");
- assert_eq!(stored.refresh_token.as_deref(), Some("refresh-token"));
-
- clear_oauth_credentials().expect("clear credentials");
- std::env::remove_var("CLAUDE_CONFIG_HOME");
- std::fs::remove_dir_all(config_home).expect("cleanup temp dir");
- }
-
- #[test]
- fn message_request_stream_helper_sets_stream_true() {
- let request = MessageRequest {
- model: "claude-opus-4-6".to_string(),
- max_tokens: 64,
- messages: vec![],
- system: None,
- tools: None,
- tool_choice: None,
- stream: false,
- };
-
- assert!(request.with_streaming().stream);
- }
-
- #[test]
- fn backoff_doubles_until_maximum() {
- let client = AnthropicClient::new("test-key").with_retry_policy(
- 3,
- Duration::from_millis(10),
- Duration::from_millis(25),
- );
- assert_eq!(
- client.backoff_for_attempt(1).expect("attempt 1"),
- Duration::from_millis(10)
- );
- assert_eq!(
- client.backoff_for_attempt(2).expect("attempt 2"),
- Duration::from_millis(20)
- );
- assert_eq!(
- client.backoff_for_attempt(3).expect("attempt 3"),
- Duration::from_millis(25)
- );
- }
-
- #[test]
- fn retryable_statuses_are_detected() {
- assert!(super::is_retryable_status(
- reqwest::StatusCode::TOO_MANY_REQUESTS
- ));
- assert!(super::is_retryable_status(
- reqwest::StatusCode::INTERNAL_SERVER_ERROR
- ));
- assert!(!super::is_retryable_status(
- reqwest::StatusCode::UNAUTHORIZED
- ));
- }
-
- #[test]
- fn tool_delta_variant_round_trips() {
- let delta = ContentBlockDelta::InputJsonDelta {
- partial_json: "{\"city\":\"Paris\"}".to_string(),
- };
- let encoded = serde_json::to_string(&delta).expect("delta should serialize");
- let decoded: ContentBlockDelta =
- serde_json::from_str(&encoded).expect("delta should deserialize");
- assert_eq!(decoded, delta);
- }
-
- #[test]
- fn request_id_uses_primary_or_fallback_header() {
- let mut headers = reqwest::header::HeaderMap::new();
- headers.insert(REQUEST_ID_HEADER, "req_primary".parse().expect("header"));
- assert_eq!(
- super::request_id_from_headers(&headers).as_deref(),
- Some("req_primary")
- );
-
- headers.clear();
- headers.insert(
- ALT_REQUEST_ID_HEADER,
- "req_fallback".parse().expect("header"),
- );
- assert_eq!(
- super::request_id_from_headers(&headers).as_deref(),
- Some("req_fallback")
- );
- }
-
- #[test]
- fn auth_source_applies_headers() {
- let auth = AuthSource::ApiKeyAndBearer {
- api_key: "test-key".to_string(),
- bearer_token: "proxy-token".to_string(),
- };
- let request = auth
- .apply(reqwest::Client::new().post("https://example.test"))
- .build()
- .expect("request build");
- let headers = request.headers();
- assert_eq!(
- headers.get("x-api-key").and_then(|v| v.to_str().ok()),
- Some("test-key")
- );
- assert_eq!(
- headers.get("authorization").and_then(|v| v.to_str().ok()),
- Some("Bearer proxy-token")
+ detect_provider_kind("claude-sonnet-4-6"),
+ ProviderKind::ClawApi
);
}
}
diff --git a/rust/crates/api/src/error.rs b/rust/crates/api/src/error.rs
index 2c31691..7649889 100644
--- a/rust/crates/api/src/error.rs
+++ b/rust/crates/api/src/error.rs
@@ -4,7 +4,10 @@ use std::time::Duration;
#[derive(Debug)]
pub enum ApiError {
- MissingApiKey,
+ MissingCredentials {
+ provider: &'static str,
+ env_vars: &'static [&'static str],
+ },
ExpiredOAuthToken,
Auth(String),
InvalidApiKeyEnv(VarError),
@@ -30,13 +33,21 @@ 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::MissingApiKey
+ Self::MissingCredentials { .. }
| Self::ExpiredOAuthToken
| Self::Auth(_)
| Self::InvalidApiKeyEnv(_)
@@ -51,12 +62,11 @@ impl ApiError {
impl Display for ApiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
- Self::MissingApiKey => {
- write!(
- f,
- "ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY is not set; export one before calling the Anthropic API"
- )
- }
+ Self::MissingCredentials { provider, env_vars } => write!(
+ f,
+ "missing {provider} credentials; export {} before calling the {provider} API",
+ env_vars.join(" or ")
+ ),
Self::ExpiredOAuthToken => {
write!(
f,
@@ -65,10 +75,7 @@ impl Display for ApiError {
}
Self::Auth(message) => write!(f, "auth error: {message}"),
Self::InvalidApiKeyEnv(error) => {
- write!(
- f,
- "failed to read ANTHROPIC_AUTH_TOKEN / ANTHROPIC_API_KEY: {error}"
- )
+ write!(f, "failed to read credential environment variable: {error}")
}
Self::Http(error) => write!(f, "http error: {error}"),
Self::Io(error) => write!(f, "io error: {error}"),
@@ -81,20 +88,14 @@ impl Display for ApiError {
..
} => match (error_type, message) {
(Some(error_type), Some(message)) => {
- write!(
- f,
- "anthropic api returned {status} ({error_type}): {message}"
- )
+ write!(f, "api returned {status} ({error_type}): {message}")
}
- _ => write!(f, "anthropic api returned {status}: {body}"),
+ _ => write!(f, "api returned {status}: {body}"),
},
Self::RetriesExhausted {
attempts,
last_error,
- } => write!(
- f,
- "anthropic api failed after {attempts} attempts: {last_error}"
- ),
+ } => write!(f, "api failed after {attempts} attempts: {last_error}"),
Self::InvalidSseFrame(message) => write!(f, "invalid sse frame: {message}"),
Self::BackoffOverflow {
attempt,
diff --git a/rust/crates/api/src/lib.rs b/rust/crates/api/src/lib.rs
index a91344b..3306f53 100644
--- a/rust/crates/api/src/lib.rs
+++ b/rust/crates/api/src/lib.rs
@@ -1,13 +1,19 @@
mod client;
mod error;
+mod providers;
mod sse;
mod types;
pub use client::{
- oauth_token_is_expired, read_base_url, resolve_saved_oauth_token,
- resolve_startup_auth_source, AnthropicClient, AuthSource, MessageStream, OAuthTokenSet,
+ oauth_token_is_expired, read_base_url, read_xai_base_url, resolve_saved_oauth_token,
+ resolve_startup_auth_source, MessageStream, OAuthTokenSet, ProviderClient,
};
pub use error::ApiError;
+pub use providers::claw_provider::{AuthSource, ClawApiClient, ClawApiClient as ApiClient};
+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,
diff --git a/rust/crates/api/src/providers/claw_provider.rs b/rust/crates/api/src/providers/claw_provider.rs
new file mode 100644
index 0000000..d9046cd
--- /dev/null
+++ b/rust/crates/api/src/providers/claw_provider.rs
@@ -0,0 +1,1046 @@
+use std::collections::VecDeque;
+use std::time::{Duration, SystemTime, UNIX_EPOCH};
+
+use runtime::{
+ load_oauth_credentials, save_oauth_credentials, OAuthConfig, OAuthRefreshRequest,
+ OAuthTokenExchangeRequest,
+};
+use serde::Deserialize;
+
+use crate::error::ApiError;
+
+use super::{Provider, ProviderFuture};
+use crate::sse::SseParser;
+use crate::types::{MessageRequest, MessageResponse, StreamEvent};
+
+pub const DEFAULT_BASE_URL: &str = "https://api.anthropic.com";
+const ANTHROPIC_VERSION: &str = "2023-06-01";
+const REQUEST_ID_HEADER: &str = "request-id";
+const ALT_REQUEST_ID_HEADER: &str = "x-request-id";
+const DEFAULT_INITIAL_BACKOFF: Duration = Duration::from_millis(200);
+const DEFAULT_MAX_BACKOFF: Duration = Duration::from_secs(2);
+const DEFAULT_MAX_RETRIES: u32 = 2;
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum AuthSource {
+ None,
+ ApiKey(String),
+ BearerToken(String),
+ ApiKeyAndBearer {
+ api_key: String,
+ bearer_token: String,
+ },
+}
+
+impl AuthSource {
+ pub fn from_env() -> Result {
+ let api_key = read_env_non_empty("ANTHROPIC_API_KEY")?;
+ let auth_token = read_env_non_empty("ANTHROPIC_AUTH_TOKEN")?;
+ match (api_key, auth_token) {
+ (Some(api_key), Some(bearer_token)) => Ok(Self::ApiKeyAndBearer {
+ api_key,
+ bearer_token,
+ }),
+ (Some(api_key), None) => Ok(Self::ApiKey(api_key)),
+ (None, Some(bearer_token)) => Ok(Self::BearerToken(bearer_token)),
+ (None, None) => Err(ApiError::missing_credentials(
+ "Claw",
+ &["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"],
+ )),
+ }
+ }
+
+ #[must_use]
+ pub fn api_key(&self) -> Option<&str> {
+ match self {
+ Self::ApiKey(api_key) | Self::ApiKeyAndBearer { api_key, .. } => Some(api_key),
+ Self::None | Self::BearerToken(_) => None,
+ }
+ }
+
+ #[must_use]
+ pub fn bearer_token(&self) -> Option<&str> {
+ match self {
+ Self::BearerToken(token)
+ | Self::ApiKeyAndBearer {
+ bearer_token: token,
+ ..
+ } => Some(token),
+ Self::None | Self::ApiKey(_) => None,
+ }
+ }
+
+ #[must_use]
+ pub fn masked_authorization_header(&self) -> &'static str {
+ if self.bearer_token().is_some() {
+ "Bearer [REDACTED]"
+ } else {
+ ""
+ }
+ }
+
+ pub fn apply(&self, mut request_builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
+ if let Some(api_key) = self.api_key() {
+ request_builder = request_builder.header("x-api-key", api_key);
+ }
+ if let Some(token) = self.bearer_token() {
+ request_builder = request_builder.bearer_auth(token);
+ }
+ request_builder
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
+pub struct OAuthTokenSet {
+ pub access_token: String,
+ pub refresh_token: Option,
+ pub expires_at: Option,
+ #[serde(default)]
+ pub scopes: Vec,
+}
+
+impl From for AuthSource {
+ fn from(value: OAuthTokenSet) -> Self {
+ Self::BearerToken(value.access_token)
+ }
+}
+
+#[derive(Debug, Clone)]
+pub struct ClawApiClient {
+ http: reqwest::Client,
+ auth: AuthSource,
+ base_url: String,
+ max_retries: u32,
+ initial_backoff: Duration,
+ max_backoff: Duration,
+}
+
+impl ClawApiClient {
+ #[must_use]
+ pub fn new(api_key: impl Into) -> Self {
+ Self {
+ http: reqwest::Client::new(),
+ auth: AuthSource::ApiKey(api_key.into()),
+ base_url: DEFAULT_BASE_URL.to_string(),
+ max_retries: DEFAULT_MAX_RETRIES,
+ initial_backoff: DEFAULT_INITIAL_BACKOFF,
+ max_backoff: DEFAULT_MAX_BACKOFF,
+ }
+ }
+
+ #[must_use]
+ pub fn from_auth(auth: AuthSource) -> Self {
+ Self {
+ http: reqwest::Client::new(),
+ auth,
+ base_url: DEFAULT_BASE_URL.to_string(),
+ max_retries: DEFAULT_MAX_RETRIES,
+ initial_backoff: DEFAULT_INITIAL_BACKOFF,
+ max_backoff: DEFAULT_MAX_BACKOFF,
+ }
+ }
+
+ pub fn from_env() -> Result {
+ Ok(Self::from_auth(AuthSource::from_env_or_saved()?).with_base_url(read_base_url()))
+ }
+
+ #[must_use]
+ pub fn with_auth_source(mut self, auth: AuthSource) -> Self {
+ self.auth = auth;
+ self
+ }
+
+ #[must_use]
+ pub fn with_auth_token(mut self, auth_token: Option) -> Self {
+ match (
+ self.auth.api_key().map(ToOwned::to_owned),
+ auth_token.filter(|token| !token.is_empty()),
+ ) {
+ (Some(api_key), Some(bearer_token)) => {
+ self.auth = AuthSource::ApiKeyAndBearer {
+ api_key,
+ bearer_token,
+ };
+ }
+ (Some(api_key), None) => {
+ self.auth = AuthSource::ApiKey(api_key);
+ }
+ (None, Some(bearer_token)) => {
+ self.auth = AuthSource::BearerToken(bearer_token);
+ }
+ (None, None) => {
+ self.auth = AuthSource::None;
+ }
+ }
+ self
+ }
+
+ #[must_use]
+ pub fn with_base_url(mut self, base_url: impl Into) -> Self {
+ self.base_url = base_url.into();
+ self
+ }
+
+ #[must_use]
+ pub fn with_retry_policy(
+ mut self,
+ max_retries: u32,
+ initial_backoff: Duration,
+ max_backoff: Duration,
+ ) -> Self {
+ self.max_retries = max_retries;
+ self.initial_backoff = initial_backoff;
+ self.max_backoff = max_backoff;
+ self
+ }
+
+ #[must_use]
+ pub fn auth_source(&self) -> &AuthSource {
+ &self.auth
+ }
+
+ pub async fn send_message(
+ &self,
+ request: &MessageRequest,
+ ) -> Result {
+ let request = MessageRequest {
+ stream: false,
+ ..request.clone()
+ };
+ let response = self.send_with_retry(&request).await?;
+ let request_id = request_id_from_headers(response.headers());
+ let mut response = response
+ .json::()
+ .await
+ .map_err(ApiError::from)?;
+ if response.request_id.is_none() {
+ response.request_id = request_id;
+ }
+ Ok(response)
+ }
+
+ pub async fn stream_message(
+ &self,
+ request: &MessageRequest,
+ ) -> Result {
+ let response = self
+ .send_with_retry(&request.clone().with_streaming())
+ .await?;
+ Ok(MessageStream {
+ request_id: request_id_from_headers(response.headers()),
+ response,
+ parser: SseParser::new(),
+ pending: VecDeque::new(),
+ done: false,
+ })
+ }
+
+ pub async fn exchange_oauth_code(
+ &self,
+ config: &OAuthConfig,
+ request: &OAuthTokenExchangeRequest,
+ ) -> Result {
+ let response = self
+ .http
+ .post(&config.token_url)
+ .header("content-type", "application/x-www-form-urlencoded")
+ .form(&request.form_params())
+ .send()
+ .await
+ .map_err(ApiError::from)?;
+ let response = expect_success(response).await?;
+ response
+ .json::()
+ .await
+ .map_err(ApiError::from)
+ }
+
+ pub async fn refresh_oauth_token(
+ &self,
+ config: &OAuthConfig,
+ request: &OAuthRefreshRequest,
+ ) -> Result {
+ let response = self
+ .http
+ .post(&config.token_url)
+ .header("content-type", "application/x-www-form-urlencoded")
+ .form(&request.form_params())
+ .send()
+ .await
+ .map_err(ApiError::from)?;
+ let response = expect_success(response).await?;
+ response
+ .json::()
+ .await
+ .map_err(ApiError::from)
+ }
+
+ async fn send_with_retry(
+ &self,
+ request: &MessageRequest,
+ ) -> Result {
+ let mut attempts = 0;
+ let mut last_error: Option;
+
+ loop {
+ attempts += 1;
+ match self.send_raw_request(request).await {
+ Ok(response) => match expect_success(response).await {
+ Ok(response) => return Ok(response),
+ Err(error) if error.is_retryable() && attempts <= self.max_retries + 1 => {
+ last_error = Some(error);
+ }
+ Err(error) => return Err(error),
+ },
+ Err(error) if error.is_retryable() && attempts <= self.max_retries + 1 => {
+ last_error = Some(error);
+ }
+ Err(error) => return Err(error),
+ }
+
+ if attempts > self.max_retries {
+ break;
+ }
+
+ tokio::time::sleep(self.backoff_for_attempt(attempts)?).await;
+ }
+
+ Err(ApiError::RetriesExhausted {
+ attempts,
+ last_error: Box::new(last_error.expect("retry loop must capture an error")),
+ })
+ }
+
+ async fn send_raw_request(
+ &self,
+ request: &MessageRequest,
+ ) -> Result {
+ let request_url = format!("{}/v1/messages", self.base_url.trim_end_matches('/'));
+ let request_builder = self
+ .http
+ .post(&request_url)
+ .header("anthropic-version", ANTHROPIC_VERSION)
+ .header("content-type", "application/json");
+ let mut request_builder = self.auth.apply(request_builder);
+
+ request_builder = request_builder.json(request);
+ request_builder.send().await.map_err(ApiError::from)
+ }
+
+ fn backoff_for_attempt(&self, attempt: u32) -> Result {
+ let Some(multiplier) = 1_u32.checked_shl(attempt.saturating_sub(1)) else {
+ return Err(ApiError::BackoffOverflow {
+ attempt,
+ base_delay: self.initial_backoff,
+ });
+ };
+ Ok(self
+ .initial_backoff
+ .checked_mul(multiplier)
+ .map_or(self.max_backoff, |delay| delay.min(self.max_backoff)))
+ }
+}
+
+impl AuthSource {
+ pub fn from_env_or_saved() -> Result {
+ if let Some(api_key) = read_env_non_empty("ANTHROPIC_API_KEY")? {
+ return match read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? {
+ Some(bearer_token) => Ok(Self::ApiKeyAndBearer {
+ api_key,
+ bearer_token,
+ }),
+ None => Ok(Self::ApiKey(api_key)),
+ };
+ }
+ if let Some(bearer_token) = read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? {
+ return Ok(Self::BearerToken(bearer_token));
+ }
+ match load_saved_oauth_token() {
+ Ok(Some(token_set)) if oauth_token_is_expired(&token_set) => {
+ if token_set.refresh_token.is_some() {
+ Err(ApiError::Auth(
+ "saved OAuth token is expired; load runtime OAuth config to refresh it"
+ .to_string(),
+ ))
+ } else {
+ Err(ApiError::ExpiredOAuthToken)
+ }
+ }
+ Ok(Some(token_set)) => Ok(Self::BearerToken(token_set.access_token)),
+ Ok(None) => Err(ApiError::missing_credentials(
+ "Claw",
+ &["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"],
+ )),
+ Err(error) => Err(error),
+ }
+ }
+}
+
+#[must_use]
+pub fn oauth_token_is_expired(token_set: &OAuthTokenSet) -> bool {
+ token_set
+ .expires_at
+ .is_some_and(|expires_at| expires_at <= now_unix_timestamp())
+}
+
+pub fn resolve_saved_oauth_token(config: &OAuthConfig) -> Result, ApiError> {
+ let Some(token_set) = load_saved_oauth_token()? else {
+ return Ok(None);
+ };
+ resolve_saved_oauth_token_set(config, token_set).map(Some)
+}
+
+pub fn has_auth_from_env_or_saved() -> Result {
+ Ok(read_env_non_empty("ANTHROPIC_API_KEY")?.is_some()
+ || read_env_non_empty("ANTHROPIC_AUTH_TOKEN")?.is_some()
+ || load_saved_oauth_token()?.is_some())
+}
+
+pub fn resolve_startup_auth_source(load_oauth_config: F) -> Result
+where
+ F: FnOnce() -> Result, ApiError>,
+{
+ if let Some(api_key) = read_env_non_empty("ANTHROPIC_API_KEY")? {
+ return match read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? {
+ Some(bearer_token) => Ok(AuthSource::ApiKeyAndBearer {
+ api_key,
+ bearer_token,
+ }),
+ None => Ok(AuthSource::ApiKey(api_key)),
+ };
+ }
+ if let Some(bearer_token) = read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? {
+ return Ok(AuthSource::BearerToken(bearer_token));
+ }
+
+ let Some(token_set) = load_saved_oauth_token()? else {
+ return Err(ApiError::missing_credentials(
+ "Claw",
+ &["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"],
+ ));
+ };
+ if !oauth_token_is_expired(&token_set) {
+ return Ok(AuthSource::BearerToken(token_set.access_token));
+ }
+ if token_set.refresh_token.is_none() {
+ return Err(ApiError::ExpiredOAuthToken);
+ }
+
+ let Some(config) = load_oauth_config()? else {
+ return Err(ApiError::Auth(
+ "saved OAuth token is expired; runtime OAuth config is missing".to_string(),
+ ));
+ };
+ Ok(AuthSource::from(resolve_saved_oauth_token_set(
+ &config, token_set,
+ )?))
+}
+
+fn resolve_saved_oauth_token_set(
+ config: &OAuthConfig,
+ token_set: OAuthTokenSet,
+) -> Result {
+ if !oauth_token_is_expired(&token_set) {
+ return Ok(token_set);
+ }
+ let Some(refresh_token) = token_set.refresh_token.clone() else {
+ return Err(ApiError::ExpiredOAuthToken);
+ };
+ let client = ClawApiClient::from_auth(AuthSource::None).with_base_url(read_base_url());
+ let refreshed = client_runtime_block_on(async {
+ client
+ .refresh_oauth_token(
+ config,
+ &OAuthRefreshRequest::from_config(
+ config,
+ refresh_token,
+ Some(token_set.scopes.clone()),
+ ),
+ )
+ .await
+ })?;
+ let resolved = OAuthTokenSet {
+ access_token: refreshed.access_token,
+ refresh_token: refreshed.refresh_token.or(token_set.refresh_token),
+ expires_at: refreshed.expires_at,
+ scopes: refreshed.scopes,
+ };
+ save_oauth_credentials(&runtime::OAuthTokenSet {
+ access_token: resolved.access_token.clone(),
+ refresh_token: resolved.refresh_token.clone(),
+ expires_at: resolved.expires_at,
+ scopes: resolved.scopes.clone(),
+ })
+ .map_err(ApiError::from)?;
+ Ok(resolved)
+}
+
+fn client_runtime_block_on(future: F) -> Result
+where
+ F: std::future::Future>,
+{
+ tokio::runtime::Runtime::new()
+ .map_err(ApiError::from)?
+ .block_on(future)
+}
+
+fn load_saved_oauth_token() -> Result, ApiError> {
+ let token_set = load_oauth_credentials().map_err(ApiError::from)?;
+ Ok(token_set.map(|token_set| OAuthTokenSet {
+ access_token: token_set.access_token,
+ refresh_token: token_set.refresh_token,
+ expires_at: token_set.expires_at,
+ scopes: token_set.scopes,
+ }))
+}
+
+fn now_unix_timestamp() -> u64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map_or(0, |duration| duration.as_secs())
+}
+
+fn read_env_non_empty(key: &str) -> Result , ApiError> {
+ match std::env::var(key) {
+ Ok(value) if !value.is_empty() => Ok(Some(value)),
+ Ok(_) | Err(std::env::VarError::NotPresent) => Ok(None),
+ Err(error) => Err(ApiError::from(error)),
+ }
+}
+
+#[cfg(test)]
+fn read_api_key() -> Result {
+ let auth = AuthSource::from_env_or_saved()?;
+ auth.api_key()
+ .or_else(|| auth.bearer_token())
+ .map(ToOwned::to_owned)
+ .ok_or(ApiError::missing_credentials(
+ "Claw",
+ &["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"],
+ ))
+}
+
+#[cfg(test)]
+fn read_auth_token() -> Option {
+ read_env_non_empty("ANTHROPIC_AUTH_TOKEN")
+ .ok()
+ .and_then(std::convert::identity)
+}
+
+#[must_use]
+pub fn read_base_url() -> String {
+ std::env::var("ANTHROPIC_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.to_string())
+}
+
+fn request_id_from_headers(headers: &reqwest::header::HeaderMap) -> Option {
+ headers
+ .get(REQUEST_ID_HEADER)
+ .or_else(|| headers.get(ALT_REQUEST_ID_HEADER))
+ .and_then(|value| value.to_str().ok())
+ .map(ToOwned::to_owned)
+}
+
+impl Provider for ClawApiClient {
+ type Stream = MessageStream;
+
+ fn send_message<'a>(
+ &'a self,
+ request: &'a MessageRequest,
+ ) -> ProviderFuture<'a, MessageResponse> {
+ Box::pin(async move { self.send_message(request).await })
+ }
+
+ fn stream_message<'a>(
+ &'a self,
+ request: &'a MessageRequest,
+ ) -> ProviderFuture<'a, Self::Stream> {
+ Box::pin(async move { self.stream_message(request).await })
+ }
+}
+
+#[derive(Debug)]
+pub struct MessageStream {
+ request_id: Option,
+ response: reqwest::Response,
+ parser: SseParser,
+ pending: VecDeque,
+ done: bool,
+}
+
+impl MessageStream {
+ #[must_use]
+ pub fn request_id(&self) -> Option<&str> {
+ self.request_id.as_deref()
+ }
+
+ pub async fn next_event(&mut self) -> Result, ApiError> {
+ loop {
+ if let Some(event) = self.pending.pop_front() {
+ return Ok(Some(event));
+ }
+
+ if self.done {
+ let remaining = self.parser.finish()?;
+ self.pending.extend(remaining);
+ if let Some(event) = self.pending.pop_front() {
+ return Ok(Some(event));
+ }
+ return Ok(None);
+ }
+
+ match self.response.chunk().await? {
+ Some(chunk) => {
+ self.pending.extend(self.parser.push(&chunk)?);
+ }
+ None => {
+ self.done = true;
+ }
+ }
+ }
+ }
+}
+
+async fn expect_success(response: reqwest::Response) -> Result {
+ let status = response.status();
+ if status.is_success() {
+ return Ok(response);
+ }
+
+ let body = response.text().await.unwrap_or_else(|_| String::new());
+ let parsed_error = serde_json::from_str::(&body).ok();
+ let retryable = is_retryable_status(status);
+
+ Err(ApiError::Api {
+ status,
+ error_type: parsed_error
+ .as_ref()
+ .map(|error| error.error.error_type.clone()),
+ message: parsed_error
+ .as_ref()
+ .map(|error| error.error.message.clone()),
+ body,
+ retryable,
+ })
+}
+
+const fn is_retryable_status(status: reqwest::StatusCode) -> bool {
+ matches!(status.as_u16(), 408 | 409 | 429 | 500 | 502 | 503 | 504)
+}
+
+#[derive(Debug, Deserialize)]
+struct ApiErrorEnvelope {
+ error: ApiErrorBody,
+}
+
+#[derive(Debug, Deserialize)]
+struct ApiErrorBody {
+ #[serde(rename = "type")]
+ error_type: String,
+ message: String,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{ALT_REQUEST_ID_HEADER, REQUEST_ID_HEADER};
+ use std::io::{Read, Write};
+ use std::net::TcpListener;
+ use std::sync::{Mutex, OnceLock};
+ use std::thread;
+ use std::time::{Duration, SystemTime, UNIX_EPOCH};
+
+ use runtime::{clear_oauth_credentials, save_oauth_credentials, OAuthConfig};
+
+ use super::{
+ now_unix_timestamp, oauth_token_is_expired, resolve_saved_oauth_token,
+ resolve_startup_auth_source, AuthSource, ClawApiClient, OAuthTokenSet,
+ };
+ use crate::types::{ContentBlockDelta, MessageRequest};
+
+ fn env_lock() -> std::sync::MutexGuard<'static, ()> {
+ static LOCK: OnceLock> = OnceLock::new();
+ LOCK.get_or_init(|| Mutex::new(()))
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ }
+
+ fn temp_config_home() -> std::path::PathBuf {
+ std::env::temp_dir().join(format!(
+ "api-oauth-test-{}-{}",
+ std::process::id(),
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .expect("time")
+ .as_nanos()
+ ))
+ }
+
+ fn cleanup_temp_config_home(config_home: &std::path::Path) {
+ match std::fs::remove_dir_all(config_home) {
+ Ok(()) => {}
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
+ Err(error) => panic!("cleanup temp dir: {error}"),
+ }
+ }
+
+ fn sample_oauth_config(token_url: String) -> OAuthConfig {
+ OAuthConfig {
+ client_id: "runtime-client".to_string(),
+ authorize_url: "https://console.test/oauth/authorize".to_string(),
+ token_url,
+ callback_port: Some(4545),
+ manual_redirect_url: Some("https://console.test/oauth/callback".to_string()),
+ scopes: vec!["org:read".to_string(), "user:write".to_string()],
+ }
+ }
+
+ fn spawn_token_server(response_body: &'static str) -> String {
+ let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener");
+ let address = listener.local_addr().expect("local addr");
+ thread::spawn(move || {
+ let (mut stream, _) = listener.accept().expect("accept connection");
+ let mut buffer = [0_u8; 4096];
+ let _ = stream.read(&mut buffer).expect("read request");
+ let response = format!(
+ "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}",
+ response_body.len(),
+ response_body
+ );
+ stream
+ .write_all(response.as_bytes())
+ .expect("write response");
+ });
+ format!("http://{address}/oauth/token")
+ }
+
+ #[test]
+ fn read_api_key_requires_presence() {
+ let _guard = env_lock();
+ std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
+ std::env::remove_var("ANTHROPIC_API_KEY");
+ std::env::remove_var("CLAW_CONFIG_HOME");
+ let error = super::read_api_key().expect_err("missing key should error");
+ assert!(matches!(
+ error,
+ crate::error::ApiError::MissingCredentials { .. }
+ ));
+ }
+
+ #[test]
+ fn read_api_key_requires_non_empty_value() {
+ let _guard = env_lock();
+ std::env::set_var("ANTHROPIC_AUTH_TOKEN", "");
+ std::env::remove_var("ANTHROPIC_API_KEY");
+ let error = super::read_api_key().expect_err("empty key should error");
+ assert!(matches!(
+ error,
+ crate::error::ApiError::MissingCredentials { .. }
+ ));
+ std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
+ }
+
+ #[test]
+ fn read_api_key_prefers_api_key_env() {
+ let _guard = env_lock();
+ std::env::set_var("ANTHROPIC_AUTH_TOKEN", "auth-token");
+ std::env::set_var("ANTHROPIC_API_KEY", "legacy-key");
+ assert_eq!(
+ super::read_api_key().expect("api key should load"),
+ "legacy-key"
+ );
+ std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
+ std::env::remove_var("ANTHROPIC_API_KEY");
+ }
+
+ #[test]
+ fn read_auth_token_reads_auth_token_env() {
+ let _guard = env_lock();
+ std::env::set_var("ANTHROPIC_AUTH_TOKEN", "auth-token");
+ assert_eq!(super::read_auth_token().as_deref(), Some("auth-token"));
+ std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
+ }
+
+ #[test]
+ fn oauth_token_maps_to_bearer_auth_source() {
+ let auth = AuthSource::from(OAuthTokenSet {
+ access_token: "access-token".to_string(),
+ refresh_token: Some("refresh".to_string()),
+ expires_at: Some(123),
+ scopes: vec!["scope:a".to_string()],
+ });
+ assert_eq!(auth.bearer_token(), Some("access-token"));
+ assert_eq!(auth.api_key(), None);
+ }
+
+ #[test]
+ fn auth_source_from_env_combines_api_key_and_bearer_token() {
+ let _guard = env_lock();
+ std::env::set_var("ANTHROPIC_AUTH_TOKEN", "auth-token");
+ std::env::set_var("ANTHROPIC_API_KEY", "legacy-key");
+ let auth = AuthSource::from_env().expect("env auth");
+ assert_eq!(auth.api_key(), Some("legacy-key"));
+ assert_eq!(auth.bearer_token(), Some("auth-token"));
+ std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
+ std::env::remove_var("ANTHROPIC_API_KEY");
+ }
+
+ #[test]
+ fn auth_source_from_saved_oauth_when_env_absent() {
+ let _guard = env_lock();
+ let config_home = temp_config_home();
+ std::env::set_var("CLAW_CONFIG_HOME", &config_home);
+ std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
+ std::env::remove_var("ANTHROPIC_API_KEY");
+ save_oauth_credentials(&runtime::OAuthTokenSet {
+ access_token: "saved-access-token".to_string(),
+ refresh_token: Some("refresh".to_string()),
+ expires_at: Some(now_unix_timestamp() + 300),
+ scopes: vec!["scope:a".to_string()],
+ })
+ .expect("save oauth credentials");
+
+ let auth = AuthSource::from_env_or_saved().expect("saved auth");
+ assert_eq!(auth.bearer_token(), Some("saved-access-token"));
+
+ clear_oauth_credentials().expect("clear credentials");
+ std::env::remove_var("CLAW_CONFIG_HOME");
+ cleanup_temp_config_home(&config_home);
+ }
+
+ #[test]
+ fn oauth_token_expiry_uses_expires_at_timestamp() {
+ assert!(oauth_token_is_expired(&OAuthTokenSet {
+ access_token: "access-token".to_string(),
+ refresh_token: None,
+ expires_at: Some(1),
+ scopes: Vec::new(),
+ }));
+ assert!(!oauth_token_is_expired(&OAuthTokenSet {
+ access_token: "access-token".to_string(),
+ refresh_token: None,
+ expires_at: Some(now_unix_timestamp() + 60),
+ scopes: Vec::new(),
+ }));
+ }
+
+ #[test]
+ fn resolve_saved_oauth_token_refreshes_expired_credentials() {
+ let _guard = env_lock();
+ let config_home = temp_config_home();
+ std::env::set_var("CLAW_CONFIG_HOME", &config_home);
+ std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
+ std::env::remove_var("ANTHROPIC_API_KEY");
+ save_oauth_credentials(&runtime::OAuthTokenSet {
+ access_token: "expired-access-token".to_string(),
+ refresh_token: Some("refresh-token".to_string()),
+ expires_at: Some(1),
+ scopes: vec!["scope:a".to_string()],
+ })
+ .expect("save expired oauth credentials");
+
+ let token_url = spawn_token_server(
+ "{\"access_token\":\"refreshed-token\",\"refresh_token\":\"fresh-refresh\",\"expires_at\":9999999999,\"scopes\":[\"scope:a\"]}",
+ );
+ let resolved = resolve_saved_oauth_token(&sample_oauth_config(token_url))
+ .expect("resolve refreshed token")
+ .expect("token set present");
+ assert_eq!(resolved.access_token, "refreshed-token");
+ let stored = runtime::load_oauth_credentials()
+ .expect("load stored credentials")
+ .expect("stored token set");
+ assert_eq!(stored.access_token, "refreshed-token");
+
+ clear_oauth_credentials().expect("clear credentials");
+ std::env::remove_var("CLAW_CONFIG_HOME");
+ cleanup_temp_config_home(&config_home);
+ }
+
+ #[test]
+ fn resolve_startup_auth_source_uses_saved_oauth_without_loading_config() {
+ let _guard = env_lock();
+ let config_home = temp_config_home();
+ std::env::set_var("CLAW_CONFIG_HOME", &config_home);
+ std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
+ std::env::remove_var("ANTHROPIC_API_KEY");
+ save_oauth_credentials(&runtime::OAuthTokenSet {
+ access_token: "saved-access-token".to_string(),
+ refresh_token: Some("refresh".to_string()),
+ expires_at: Some(now_unix_timestamp() + 300),
+ scopes: vec!["scope:a".to_string()],
+ })
+ .expect("save oauth credentials");
+
+ let auth = resolve_startup_auth_source(|| panic!("config should not be loaded"))
+ .expect("startup auth");
+ assert_eq!(auth.bearer_token(), Some("saved-access-token"));
+
+ clear_oauth_credentials().expect("clear credentials");
+ std::env::remove_var("CLAW_CONFIG_HOME");
+ cleanup_temp_config_home(&config_home);
+ }
+
+ #[test]
+ fn resolve_startup_auth_source_errors_when_refreshable_token_lacks_config() {
+ let _guard = env_lock();
+ let config_home = temp_config_home();
+ std::env::set_var("CLAW_CONFIG_HOME", &config_home);
+ std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
+ std::env::remove_var("ANTHROPIC_API_KEY");
+ save_oauth_credentials(&runtime::OAuthTokenSet {
+ access_token: "expired-access-token".to_string(),
+ refresh_token: Some("refresh-token".to_string()),
+ expires_at: Some(1),
+ scopes: vec!["scope:a".to_string()],
+ })
+ .expect("save expired oauth credentials");
+
+ let error =
+ resolve_startup_auth_source(|| Ok(None)).expect_err("missing config should error");
+ assert!(
+ matches!(error, crate::error::ApiError::Auth(message) if message.contains("runtime OAuth config is missing"))
+ );
+
+ let stored = runtime::load_oauth_credentials()
+ .expect("load stored credentials")
+ .expect("stored token set");
+ assert_eq!(stored.access_token, "expired-access-token");
+ assert_eq!(stored.refresh_token.as_deref(), Some("refresh-token"));
+
+ clear_oauth_credentials().expect("clear credentials");
+ std::env::remove_var("CLAW_CONFIG_HOME");
+ cleanup_temp_config_home(&config_home);
+ }
+
+ #[test]
+ fn resolve_saved_oauth_token_preserves_refresh_token_when_refresh_response_omits_it() {
+ let _guard = env_lock();
+ let config_home = temp_config_home();
+ std::env::set_var("CLAW_CONFIG_HOME", &config_home);
+ std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
+ std::env::remove_var("ANTHROPIC_API_KEY");
+ save_oauth_credentials(&runtime::OAuthTokenSet {
+ access_token: "expired-access-token".to_string(),
+ refresh_token: Some("refresh-token".to_string()),
+ expires_at: Some(1),
+ scopes: vec!["scope:a".to_string()],
+ })
+ .expect("save expired oauth credentials");
+
+ let token_url = spawn_token_server(
+ "{\"access_token\":\"refreshed-token\",\"expires_at\":9999999999,\"scopes\":[\"scope:a\"]}",
+ );
+ let resolved = resolve_saved_oauth_token(&sample_oauth_config(token_url))
+ .expect("resolve refreshed token")
+ .expect("token set present");
+ assert_eq!(resolved.access_token, "refreshed-token");
+ assert_eq!(resolved.refresh_token.as_deref(), Some("refresh-token"));
+ let stored = runtime::load_oauth_credentials()
+ .expect("load stored credentials")
+ .expect("stored token set");
+ assert_eq!(stored.refresh_token.as_deref(), Some("refresh-token"));
+
+ clear_oauth_credentials().expect("clear credentials");
+ std::env::remove_var("CLAW_CONFIG_HOME");
+ cleanup_temp_config_home(&config_home);
+ }
+
+ #[test]
+ fn message_request_stream_helper_sets_stream_true() {
+ let request = MessageRequest {
+ model: "claude-opus-4-6".to_string(),
+ max_tokens: 64,
+ messages: vec![],
+ system: None,
+ tools: None,
+ tool_choice: None,
+ stream: false,
+ };
+
+ assert!(request.with_streaming().stream);
+ }
+
+ #[test]
+ fn backoff_doubles_until_maximum() {
+ let client = ClawApiClient::new("test-key").with_retry_policy(
+ 3,
+ Duration::from_millis(10),
+ Duration::from_millis(25),
+ );
+ assert_eq!(
+ client.backoff_for_attempt(1).expect("attempt 1"),
+ Duration::from_millis(10)
+ );
+ assert_eq!(
+ client.backoff_for_attempt(2).expect("attempt 2"),
+ Duration::from_millis(20)
+ );
+ assert_eq!(
+ client.backoff_for_attempt(3).expect("attempt 3"),
+ Duration::from_millis(25)
+ );
+ }
+
+ #[test]
+ fn retryable_statuses_are_detected() {
+ assert!(super::is_retryable_status(
+ reqwest::StatusCode::TOO_MANY_REQUESTS
+ ));
+ assert!(super::is_retryable_status(
+ reqwest::StatusCode::INTERNAL_SERVER_ERROR
+ ));
+ assert!(!super::is_retryable_status(
+ reqwest::StatusCode::UNAUTHORIZED
+ ));
+ }
+
+ #[test]
+ fn tool_delta_variant_round_trips() {
+ let delta = ContentBlockDelta::InputJsonDelta {
+ partial_json: "{\"city\":\"Paris\"}".to_string(),
+ };
+ let encoded = serde_json::to_string(&delta).expect("delta should serialize");
+ let decoded: ContentBlockDelta =
+ serde_json::from_str(&encoded).expect("delta should deserialize");
+ assert_eq!(decoded, delta);
+ }
+
+ #[test]
+ fn request_id_uses_primary_or_fallback_header() {
+ let mut headers = reqwest::header::HeaderMap::new();
+ headers.insert(REQUEST_ID_HEADER, "req_primary".parse().expect("header"));
+ assert_eq!(
+ super::request_id_from_headers(&headers).as_deref(),
+ Some("req_primary")
+ );
+
+ headers.clear();
+ headers.insert(
+ ALT_REQUEST_ID_HEADER,
+ "req_fallback".parse().expect("header"),
+ );
+ assert_eq!(
+ super::request_id_from_headers(&headers).as_deref(),
+ Some("req_fallback")
+ );
+ }
+
+ #[test]
+ fn auth_source_applies_headers() {
+ let auth = AuthSource::ApiKeyAndBearer {
+ api_key: "test-key".to_string(),
+ bearer_token: "proxy-token".to_string(),
+ };
+ let request = auth
+ .apply(reqwest::Client::new().post("https://example.test"))
+ .build()
+ .expect("request build");
+ let headers = request.headers();
+ assert_eq!(
+ headers.get("x-api-key").and_then(|v| v.to_str().ok()),
+ Some("test-key")
+ );
+ assert_eq!(
+ headers.get("authorization").and_then(|v| v.to_str().ok()),
+ Some("Bearer proxy-token")
+ );
+ }
+}
diff --git a/rust/crates/api/src/providers/mod.rs b/rust/crates/api/src/providers/mod.rs
new file mode 100644
index 0000000..192afd6
--- /dev/null
+++ b/rust/crates/api/src/providers/mod.rs
@@ -0,0 +1,239 @@
+use std::future::Future;
+use std::pin::Pin;
+
+use crate::error::ApiError;
+use crate::types::{MessageRequest, MessageResponse};
+
+pub mod claw_provider;
+pub mod openai_compat;
+
+pub type ProviderFuture<'a, T> = Pin> + 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 {
+ ClawApi,
+ 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::ClawApi,
+ auth_env: "ANTHROPIC_API_KEY",
+ base_url_env: "ANTHROPIC_BASE_URL",
+ default_base_url: claw_provider::DEFAULT_BASE_URL,
+ },
+ ),
+ (
+ "sonnet",
+ ProviderMetadata {
+ provider: ProviderKind::ClawApi,
+ auth_env: "ANTHROPIC_API_KEY",
+ base_url_env: "ANTHROPIC_BASE_URL",
+ default_base_url: claw_provider::DEFAULT_BASE_URL,
+ },
+ ),
+ (
+ "haiku",
+ ProviderMetadata {
+ provider: ProviderKind::ClawApi,
+ auth_env: "ANTHROPIC_API_KEY",
+ base_url_env: "ANTHROPIC_BASE_URL",
+ default_base_url: claw_provider::DEFAULT_BASE_URL,
+ },
+ ),
+ (
+ "claude-opus-4-6",
+ ProviderMetadata {
+ provider: ProviderKind::ClawApi,
+ auth_env: "ANTHROPIC_API_KEY",
+ base_url_env: "ANTHROPIC_BASE_URL",
+ default_base_url: claw_provider::DEFAULT_BASE_URL,
+ },
+ ),
+ (
+ "claude-sonnet-4-6",
+ ProviderMetadata {
+ provider: ProviderKind::ClawApi,
+ auth_env: "ANTHROPIC_API_KEY",
+ base_url_env: "ANTHROPIC_BASE_URL",
+ default_base_url: claw_provider::DEFAULT_BASE_URL,
+ },
+ ),
+ (
+ "claude-haiku-4-5-20251213",
+ ProviderMetadata {
+ provider: ProviderKind::ClawApi,
+ auth_env: "ANTHROPIC_API_KEY",
+ base_url_env: "ANTHROPIC_BASE_URL",
+ default_base_url: claw_provider::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::ClawApi => 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 {
+ let canonical = resolve_model_alias(model);
+ let lower = canonical.to_ascii_lowercase();
+ if let Some((_, metadata)) = MODEL_REGISTRY.iter().find(|(alias, _)| *alias == lower) {
+ return Some(*metadata);
+ }
+ if lower.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 claw_provider::has_auth_from_env_or_saved().unwrap_or(false) {
+ return ProviderKind::ClawApi;
+ }
+ 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::ClawApi
+}
+
+#[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::ClawApi
+ );
+ }
+
+ #[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);
+ }
+}
diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs
new file mode 100644
index 0000000..e8210ae
--- /dev/null
+++ b/rust/crates/api/src/providers/openai_compat.rs
@@ -0,0 +1,1050 @@
+use std::collections::{BTreeMap, VecDeque};
+use std::time::Duration;
+
+use serde::Deserialize;
+use serde_json::{json, Value};
+
+use crate::error::ApiError;
+use crate::types::{
+ ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, ContentBlockStopEvent,
+ InputContentBlock, InputMessage, MessageDelta, MessageDeltaEvent, MessageRequest,
+ MessageResponse, MessageStartEvent, MessageStopEvent, OutputContentBlock, StreamEvent,
+ ToolChoice, ToolDefinition, ToolResultContentBlock, Usage,
+};
+
+use super::{Provider, ProviderFuture};
+
+pub const DEFAULT_XAI_BASE_URL: &str = "https://api.x.ai/v1";
+pub const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
+const REQUEST_ID_HEADER: &str = "request-id";
+const ALT_REQUEST_ID_HEADER: &str = "x-request-id";
+const DEFAULT_INITIAL_BACKOFF: Duration = Duration::from_millis(200);
+const DEFAULT_MAX_BACKOFF: Duration = Duration::from_secs(2);
+const DEFAULT_MAX_RETRIES: u32 = 2;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct OpenAiCompatConfig {
+ pub provider_name: &'static str,
+ pub api_key_env: &'static str,
+ pub base_url_env: &'static str,
+ pub default_base_url: &'static str,
+}
+
+const XAI_ENV_VARS: &[&str] = &["XAI_API_KEY"];
+const OPENAI_ENV_VARS: &[&str] = &["OPENAI_API_KEY"];
+
+impl OpenAiCompatConfig {
+ #[must_use]
+ pub const fn xai() -> Self {
+ Self {
+ provider_name: "xAI",
+ api_key_env: "XAI_API_KEY",
+ base_url_env: "XAI_BASE_URL",
+ default_base_url: DEFAULT_XAI_BASE_URL,
+ }
+ }
+
+ #[must_use]
+ pub const fn openai() -> Self {
+ Self {
+ provider_name: "OpenAI",
+ api_key_env: "OPENAI_API_KEY",
+ base_url_env: "OPENAI_BASE_URL",
+ default_base_url: DEFAULT_OPENAI_BASE_URL,
+ }
+ }
+ #[must_use]
+ pub fn credential_env_vars(self) -> &'static [&'static str] {
+ match self.provider_name {
+ "xAI" => XAI_ENV_VARS,
+ "OpenAI" => OPENAI_ENV_VARS,
+ _ => &[],
+ }
+ }
+}
+
+#[derive(Debug, Clone)]
+pub struct OpenAiCompatClient {
+ http: reqwest::Client,
+ api_key: String,
+ base_url: String,
+ max_retries: u32,
+ initial_backoff: Duration,
+ max_backoff: Duration,
+}
+
+impl OpenAiCompatClient {
+ #[must_use]
+ pub fn new(api_key: impl Into, config: OpenAiCompatConfig) -> Self {
+ Self {
+ http: reqwest::Client::new(),
+ api_key: api_key.into(),
+ base_url: read_base_url(config),
+ max_retries: DEFAULT_MAX_RETRIES,
+ initial_backoff: DEFAULT_INITIAL_BACKOFF,
+ max_backoff: DEFAULT_MAX_BACKOFF,
+ }
+ }
+
+ pub fn from_env(config: OpenAiCompatConfig) -> Result {
+ let Some(api_key) = read_env_non_empty(config.api_key_env)? else {
+ return Err(ApiError::missing_credentials(
+ config.provider_name,
+ config.credential_env_vars(),
+ ));
+ };
+ Ok(Self::new(api_key, config))
+ }
+
+ #[must_use]
+ pub fn with_base_url(mut self, base_url: impl Into) -> Self {
+ self.base_url = base_url.into();
+ self
+ }
+
+ #[must_use]
+ pub fn with_retry_policy(
+ mut self,
+ max_retries: u32,
+ initial_backoff: Duration,
+ max_backoff: Duration,
+ ) -> Self {
+ self.max_retries = max_retries;
+ self.initial_backoff = initial_backoff;
+ self.max_backoff = max_backoff;
+ self
+ }
+
+ pub async fn send_message(
+ &self,
+ request: &MessageRequest,
+ ) -> Result {
+ let request = MessageRequest {
+ stream: false,
+ ..request.clone()
+ };
+ let response = self.send_with_retry(&request).await?;
+ let request_id = request_id_from_headers(response.headers());
+ let payload = response.json::().await?;
+ let mut normalized = normalize_response(&request.model, payload)?;
+ if normalized.request_id.is_none() {
+ normalized.request_id = request_id;
+ }
+ Ok(normalized)
+ }
+
+ pub async fn stream_message(
+ &self,
+ request: &MessageRequest,
+ ) -> Result {
+ let response = self
+ .send_with_retry(&request.clone().with_streaming())
+ .await?;
+ Ok(MessageStream {
+ request_id: request_id_from_headers(response.headers()),
+ response,
+ parser: OpenAiSseParser::new(),
+ pending: VecDeque::new(),
+ done: false,
+ state: StreamState::new(request.model.clone()),
+ })
+ }
+
+ async fn send_with_retry(
+ &self,
+ request: &MessageRequest,
+ ) -> Result {
+ let mut attempts = 0;
+
+ let last_error = loop {
+ attempts += 1;
+ let retryable_error = match self.send_raw_request(request).await {
+ Ok(response) => match expect_success(response).await {
+ Ok(response) => return Ok(response),
+ Err(error) if error.is_retryable() && attempts <= self.max_retries + 1 => error,
+ Err(error) => return Err(error),
+ },
+ Err(error) if error.is_retryable() && attempts <= self.max_retries + 1 => error,
+ Err(error) => return Err(error),
+ };
+
+ if attempts > self.max_retries {
+ break retryable_error;
+ }
+
+ tokio::time::sleep(self.backoff_for_attempt(attempts)?).await;
+ };
+
+ Err(ApiError::RetriesExhausted {
+ attempts,
+ last_error: Box::new(last_error),
+ })
+ }
+
+ async fn send_raw_request(
+ &self,
+ request: &MessageRequest,
+ ) -> Result {
+ let request_url = chat_completions_endpoint(&self.base_url);
+ self.http
+ .post(&request_url)
+ .header("content-type", "application/json")
+ .bearer_auth(&self.api_key)
+ .json(&build_chat_completion_request(request))
+ .send()
+ .await
+ .map_err(ApiError::from)
+ }
+
+ fn backoff_for_attempt(&self, attempt: u32) -> Result {
+ let Some(multiplier) = 1_u32.checked_shl(attempt.saturating_sub(1)) else {
+ return Err(ApiError::BackoffOverflow {
+ attempt,
+ base_delay: self.initial_backoff,
+ });
+ };
+ Ok(self
+ .initial_backoff
+ .checked_mul(multiplier)
+ .map_or(self.max_backoff, |delay| delay.min(self.max_backoff)))
+ }
+}
+
+impl Provider for OpenAiCompatClient {
+ type Stream = MessageStream;
+
+ fn send_message<'a>(
+ &'a self,
+ request: &'a MessageRequest,
+ ) -> ProviderFuture<'a, MessageResponse> {
+ Box::pin(async move { self.send_message(request).await })
+ }
+
+ fn stream_message<'a>(
+ &'a self,
+ request: &'a MessageRequest,
+ ) -> ProviderFuture<'a, Self::Stream> {
+ Box::pin(async move { self.stream_message(request).await })
+ }
+}
+
+#[derive(Debug)]
+pub struct MessageStream {
+ request_id: Option,
+ response: reqwest::Response,
+ parser: OpenAiSseParser,
+ pending: VecDeque,
+ done: bool,
+ state: StreamState,
+}
+
+impl MessageStream {
+ #[must_use]
+ pub fn request_id(&self) -> Option<&str> {
+ self.request_id.as_deref()
+ }
+
+ pub async fn next_event(&mut self) -> Result, ApiError> {
+ loop {
+ if let Some(event) = self.pending.pop_front() {
+ return Ok(Some(event));
+ }
+
+ if self.done {
+ self.pending.extend(self.state.finish()?);
+ if let Some(event) = self.pending.pop_front() {
+ return Ok(Some(event));
+ }
+ return Ok(None);
+ }
+
+ match self.response.chunk().await? {
+ Some(chunk) => {
+ for parsed in self.parser.push(&chunk)? {
+ self.pending.extend(self.state.ingest_chunk(parsed)?);
+ }
+ }
+ None => {
+ self.done = true;
+ }
+ }
+ }
+ }
+}
+
+#[derive(Debug, Default)]
+struct OpenAiSseParser {
+ buffer: Vec,
+}
+
+impl OpenAiSseParser {
+ fn new() -> Self {
+ Self::default()
+ }
+
+ fn push(&mut self, chunk: &[u8]) -> Result, ApiError> {
+ self.buffer.extend_from_slice(chunk);
+ let mut events = Vec::new();
+
+ while let Some(frame) = next_sse_frame(&mut self.buffer) {
+ if let Some(event) = parse_sse_frame(&frame)? {
+ events.push(event);
+ }
+ }
+
+ Ok(events)
+ }
+}
+
+#[derive(Debug)]
+struct StreamState {
+ model: String,
+ message_started: bool,
+ text_started: bool,
+ text_finished: bool,
+ finished: bool,
+ stop_reason: Option,
+ usage: Option,
+ tool_calls: BTreeMap,
+}
+
+impl StreamState {
+ fn new(model: String) -> Self {
+ Self {
+ model,
+ message_started: false,
+ text_started: false,
+ text_finished: false,
+ finished: false,
+ stop_reason: None,
+ usage: None,
+ tool_calls: BTreeMap::new(),
+ }
+ }
+
+ fn ingest_chunk(&mut self, chunk: ChatCompletionChunk) -> Result, ApiError> {
+ let mut events = Vec::new();
+ if !self.message_started {
+ self.message_started = true;
+ events.push(StreamEvent::MessageStart(MessageStartEvent {
+ message: MessageResponse {
+ id: chunk.id.clone(),
+ kind: "message".to_string(),
+ role: "assistant".to_string(),
+ content: Vec::new(),
+ model: chunk.model.clone().unwrap_or_else(|| self.model.clone()),
+ stop_reason: None,
+ stop_sequence: None,
+ usage: Usage {
+ input_tokens: 0,
+ cache_creation_input_tokens: 0,
+ cache_read_input_tokens: 0,
+ output_tokens: 0,
+ },
+ request_id: None,
+ },
+ }));
+ }
+
+ if let Some(usage) = chunk.usage {
+ self.usage = Some(Usage {
+ input_tokens: usage.prompt_tokens,
+ cache_creation_input_tokens: 0,
+ cache_read_input_tokens: 0,
+ output_tokens: usage.completion_tokens,
+ });
+ }
+
+ for choice in chunk.choices {
+ if let Some(content) = choice.delta.content.filter(|value| !value.is_empty()) {
+ if !self.text_started {
+ self.text_started = true;
+ events.push(StreamEvent::ContentBlockStart(ContentBlockStartEvent {
+ index: 0,
+ content_block: OutputContentBlock::Text {
+ text: String::new(),
+ },
+ }));
+ }
+ events.push(StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
+ index: 0,
+ delta: ContentBlockDelta::TextDelta { text: content },
+ }));
+ }
+
+ for tool_call in choice.delta.tool_calls {
+ let state = self.tool_calls.entry(tool_call.index).or_default();
+ state.apply(tool_call);
+ let block_index = state.block_index();
+ if !state.started {
+ if let Some(start_event) = state.start_event()? {
+ state.started = true;
+ events.push(StreamEvent::ContentBlockStart(start_event));
+ } else {
+ continue;
+ }
+ }
+ if let Some(delta_event) = state.delta_event() {
+ events.push(StreamEvent::ContentBlockDelta(delta_event));
+ }
+ if choice.finish_reason.as_deref() == Some("tool_calls") && !state.stopped {
+ state.stopped = true;
+ events.push(StreamEvent::ContentBlockStop(ContentBlockStopEvent {
+ index: block_index,
+ }));
+ }
+ }
+
+ if let Some(finish_reason) = choice.finish_reason {
+ self.stop_reason = Some(normalize_finish_reason(&finish_reason));
+ if finish_reason == "tool_calls" {
+ for state in self.tool_calls.values_mut() {
+ if state.started && !state.stopped {
+ state.stopped = true;
+ events.push(StreamEvent::ContentBlockStop(ContentBlockStopEvent {
+ index: state.block_index(),
+ }));
+ }
+ }
+ }
+ }
+ }
+
+ Ok(events)
+ }
+
+ fn finish(&mut self) -> Result, ApiError> {
+ if self.finished {
+ return Ok(Vec::new());
+ }
+ self.finished = true;
+
+ let mut events = Vec::new();
+ if self.text_started && !self.text_finished {
+ self.text_finished = true;
+ events.push(StreamEvent::ContentBlockStop(ContentBlockStopEvent {
+ index: 0,
+ }));
+ }
+
+ for state in self.tool_calls.values_mut() {
+ if !state.started {
+ if let Some(start_event) = state.start_event()? {
+ state.started = true;
+ events.push(StreamEvent::ContentBlockStart(start_event));
+ if let Some(delta_event) = state.delta_event() {
+ events.push(StreamEvent::ContentBlockDelta(delta_event));
+ }
+ }
+ }
+ if state.started && !state.stopped {
+ state.stopped = true;
+ events.push(StreamEvent::ContentBlockStop(ContentBlockStopEvent {
+ index: state.block_index(),
+ }));
+ }
+ }
+
+ if self.message_started {
+ events.push(StreamEvent::MessageDelta(MessageDeltaEvent {
+ delta: MessageDelta {
+ stop_reason: Some(
+ self.stop_reason
+ .clone()
+ .unwrap_or_else(|| "end_turn".to_string()),
+ ),
+ stop_sequence: None,
+ },
+ usage: self.usage.clone().unwrap_or(Usage {
+ input_tokens: 0,
+ cache_creation_input_tokens: 0,
+ cache_read_input_tokens: 0,
+ output_tokens: 0,
+ }),
+ }));
+ events.push(StreamEvent::MessageStop(MessageStopEvent {}));
+ }
+ Ok(events)
+ }
+}
+
+#[derive(Debug, Default)]
+struct ToolCallState {
+ openai_index: u32,
+ id: Option,
+ name: Option,
+ arguments: String,
+ emitted_len: usize,
+ started: bool,
+ stopped: bool,
+}
+
+impl ToolCallState {
+ fn apply(&mut self, tool_call: DeltaToolCall) {
+ self.openai_index = tool_call.index;
+ if let Some(id) = tool_call.id {
+ self.id = Some(id);
+ }
+ if let Some(name) = tool_call.function.name {
+ self.name = Some(name);
+ }
+ if let Some(arguments) = tool_call.function.arguments {
+ self.arguments.push_str(&arguments);
+ }
+ }
+
+ const fn block_index(&self) -> u32 {
+ self.openai_index + 1
+ }
+
+ fn start_event(&self) -> Result, ApiError> {
+ let Some(name) = self.name.clone() else {
+ return Ok(None);
+ };
+ let id = self
+ .id
+ .clone()
+ .unwrap_or_else(|| format!("tool_call_{}", self.openai_index));
+ Ok(Some(ContentBlockStartEvent {
+ index: self.block_index(),
+ content_block: OutputContentBlock::ToolUse {
+ id,
+ name,
+ input: json!({}),
+ },
+ }))
+ }
+
+ fn delta_event(&mut self) -> Option {
+ if self.emitted_len >= self.arguments.len() {
+ return None;
+ }
+ let delta = self.arguments[self.emitted_len..].to_string();
+ self.emitted_len = self.arguments.len();
+ Some(ContentBlockDeltaEvent {
+ index: self.block_index(),
+ delta: ContentBlockDelta::InputJsonDelta {
+ partial_json: delta,
+ },
+ })
+ }
+}
+
+#[derive(Debug, Deserialize)]
+struct ChatCompletionResponse {
+ id: String,
+ model: String,
+ choices: Vec,
+ #[serde(default)]
+ usage: Option,
+}
+
+#[derive(Debug, Deserialize)]
+struct ChatChoice {
+ message: ChatMessage,
+ #[serde(default)]
+ finish_reason: Option,
+}
+
+#[derive(Debug, Deserialize)]
+struct ChatMessage {
+ role: String,
+ #[serde(default)]
+ content: Option,
+ #[serde(default)]
+ tool_calls: Vec,
+}
+
+#[derive(Debug, Deserialize)]
+struct ResponseToolCall {
+ id: String,
+ function: ResponseToolFunction,
+}
+
+#[derive(Debug, Deserialize)]
+struct ResponseToolFunction {
+ name: String,
+ arguments: String,
+}
+
+#[derive(Debug, Deserialize)]
+struct OpenAiUsage {
+ #[serde(default)]
+ prompt_tokens: u32,
+ #[serde(default)]
+ completion_tokens: u32,
+}
+
+#[derive(Debug, Deserialize)]
+struct ChatCompletionChunk {
+ id: String,
+ #[serde(default)]
+ model: Option,
+ #[serde(default)]
+ choices: Vec,
+ #[serde(default)]
+ usage: Option,
+}
+
+#[derive(Debug, Deserialize)]
+struct ChunkChoice {
+ delta: ChunkDelta,
+ #[serde(default)]
+ finish_reason: Option,
+}
+
+#[derive(Debug, Default, Deserialize)]
+struct ChunkDelta {
+ #[serde(default)]
+ content: Option,
+ #[serde(default)]
+ tool_calls: Vec,
+}
+
+#[derive(Debug, Deserialize)]
+struct DeltaToolCall {
+ #[serde(default)]
+ index: u32,
+ #[serde(default)]
+ id: Option,
+ #[serde(default)]
+ function: DeltaFunction,
+}
+
+#[derive(Debug, Default, Deserialize)]
+struct DeltaFunction {
+ #[serde(default)]
+ name: Option,
+ #[serde(default)]
+ arguments: Option,
+}
+
+#[derive(Debug, Deserialize)]
+struct ErrorEnvelope {
+ error: ErrorBody,
+}
+
+#[derive(Debug, Deserialize)]
+struct ErrorBody {
+ #[serde(rename = "type")]
+ error_type: Option,
+ message: Option,
+}
+
+fn build_chat_completion_request(request: &MessageRequest) -> Value {
+ let mut messages = Vec::new();
+ if let Some(system) = request.system.as_ref().filter(|value| !value.is_empty()) {
+ messages.push(json!({
+ "role": "system",
+ "content": system,
+ }));
+ }
+ for message in &request.messages {
+ messages.extend(translate_message(message));
+ }
+
+ let mut payload = json!({
+ "model": request.model,
+ "max_tokens": request.max_tokens,
+ "messages": messages,
+ "stream": request.stream,
+ });
+
+ if let Some(tools) = &request.tools {
+ payload["tools"] =
+ Value::Array(tools.iter().map(openai_tool_definition).collect::>());
+ }
+ if let Some(tool_choice) = &request.tool_choice {
+ payload["tool_choice"] = openai_tool_choice(tool_choice);
+ }
+
+ payload
+}
+
+fn translate_message(message: &InputMessage) -> Vec {
+ match message.role.as_str() {
+ "assistant" => {
+ let mut text = String::new();
+ let mut tool_calls = Vec::new();
+ for block in &message.content {
+ match block {
+ InputContentBlock::Text { text: value } => text.push_str(value),
+ InputContentBlock::ToolUse { id, name, input } => tool_calls.push(json!({
+ "id": id,
+ "type": "function",
+ "function": {
+ "name": name,
+ "arguments": input.to_string(),
+ }
+ })),
+ InputContentBlock::ToolResult { .. } => {}
+ }
+ }
+ if text.is_empty() && tool_calls.is_empty() {
+ Vec::new()
+ } else {
+ vec![json!({
+ "role": "assistant",
+ "content": (!text.is_empty()).then_some(text),
+ "tool_calls": tool_calls,
+ })]
+ }
+ }
+ _ => message
+ .content
+ .iter()
+ .filter_map(|block| match block {
+ InputContentBlock::Text { text } => Some(json!({
+ "role": "user",
+ "content": text,
+ })),
+ InputContentBlock::ToolResult {
+ tool_use_id,
+ content,
+ is_error,
+ } => Some(json!({
+ "role": "tool",
+ "tool_call_id": tool_use_id,
+ "content": flatten_tool_result_content(content),
+ "is_error": is_error,
+ })),
+ InputContentBlock::ToolUse { .. } => None,
+ })
+ .collect(),
+ }
+}
+
+fn flatten_tool_result_content(content: &[ToolResultContentBlock]) -> String {
+ content
+ .iter()
+ .map(|block| match block {
+ ToolResultContentBlock::Text { text } => text.clone(),
+ ToolResultContentBlock::Json { value } => value.to_string(),
+ })
+ .collect::>()
+ .join("\n")
+}
+
+fn openai_tool_definition(tool: &ToolDefinition) -> Value {
+ json!({
+ "type": "function",
+ "function": {
+ "name": tool.name,
+ "description": tool.description,
+ "parameters": tool.input_schema,
+ }
+ })
+}
+
+fn openai_tool_choice(tool_choice: &ToolChoice) -> Value {
+ match tool_choice {
+ ToolChoice::Auto => Value::String("auto".to_string()),
+ ToolChoice::Any => Value::String("required".to_string()),
+ ToolChoice::Tool { name } => json!({
+ "type": "function",
+ "function": { "name": name },
+ }),
+ }
+}
+
+fn normalize_response(
+ model: &str,
+ response: ChatCompletionResponse,
+) -> Result {
+ let choice = response
+ .choices
+ .into_iter()
+ .next()
+ .ok_or(ApiError::InvalidSseFrame(
+ "chat completion response missing choices",
+ ))?;
+ let mut content = Vec::new();
+ if let Some(text) = choice.message.content.filter(|value| !value.is_empty()) {
+ content.push(OutputContentBlock::Text { text });
+ }
+ for tool_call in choice.message.tool_calls {
+ content.push(OutputContentBlock::ToolUse {
+ id: tool_call.id,
+ name: tool_call.function.name,
+ input: parse_tool_arguments(&tool_call.function.arguments),
+ });
+ }
+
+ Ok(MessageResponse {
+ id: response.id,
+ kind: "message".to_string(),
+ role: choice.message.role,
+ content,
+ model: response.model.if_empty_then(model.to_string()),
+ stop_reason: choice
+ .finish_reason
+ .map(|value| normalize_finish_reason(&value)),
+ stop_sequence: None,
+ usage: Usage {
+ input_tokens: response
+ .usage
+ .as_ref()
+ .map_or(0, |usage| usage.prompt_tokens),
+ cache_creation_input_tokens: 0,
+ cache_read_input_tokens: 0,
+ output_tokens: response
+ .usage
+ .as_ref()
+ .map_or(0, |usage| usage.completion_tokens),
+ },
+ request_id: None,
+ })
+}
+
+fn parse_tool_arguments(arguments: &str) -> Value {
+ serde_json::from_str(arguments).unwrap_or_else(|_| json!({ "raw": arguments }))
+}
+
+fn next_sse_frame(buffer: &mut Vec) -> Option {
+ let separator = buffer
+ .windows(2)
+ .position(|window| window == b"\n\n")
+ .map(|position| (position, 2))
+ .or_else(|| {
+ buffer
+ .windows(4)
+ .position(|window| window == b"\r\n\r\n")
+ .map(|position| (position, 4))
+ })?;
+
+ let (position, separator_len) = separator;
+ let frame = buffer.drain(..position + separator_len).collect::>();
+ let frame_len = frame.len().saturating_sub(separator_len);
+ Some(String::from_utf8_lossy(&frame[..frame_len]).into_owned())
+}
+
+fn parse_sse_frame(frame: &str) -> Result, ApiError> {
+ let trimmed = frame.trim();
+ if trimmed.is_empty() {
+ return Ok(None);
+ }
+
+ let mut data_lines = Vec::new();
+ for line in trimmed.lines() {
+ if line.starts_with(':') {
+ continue;
+ }
+ if let Some(data) = line.strip_prefix("data:") {
+ data_lines.push(data.trim_start());
+ }
+ }
+ if data_lines.is_empty() {
+ return Ok(None);
+ }
+ let payload = data_lines.join("\n");
+ if payload == "[DONE]" {
+ return Ok(None);
+ }
+ serde_json::from_str(&payload)
+ .map(Some)
+ .map_err(ApiError::from)
+}
+
+fn read_env_non_empty(key: &str) -> Result , ApiError> {
+ match std::env::var(key) {
+ Ok(value) if !value.is_empty() => Ok(Some(value)),
+ Ok(_) | Err(std::env::VarError::NotPresent) => Ok(None),
+ Err(error) => Err(ApiError::from(error)),
+ }
+}
+
+#[must_use]
+pub fn has_api_key(key: &str) -> bool {
+ read_env_non_empty(key)
+ .ok()
+ .and_then(std::convert::identity)
+ .is_some()
+}
+
+#[must_use]
+pub fn read_base_url(config: OpenAiCompatConfig) -> String {
+ std::env::var(config.base_url_env).unwrap_or_else(|_| config.default_base_url.to_string())
+}
+
+fn chat_completions_endpoint(base_url: &str) -> String {
+ let trimmed = base_url.trim_end_matches('/');
+ if trimmed.ends_with("/chat/completions") {
+ trimmed.to_string()
+ } else {
+ format!("{trimmed}/chat/completions")
+ }
+}
+
+fn request_id_from_headers(headers: &reqwest::header::HeaderMap) -> Option {
+ headers
+ .get(REQUEST_ID_HEADER)
+ .or_else(|| headers.get(ALT_REQUEST_ID_HEADER))
+ .and_then(|value| value.to_str().ok())
+ .map(ToOwned::to_owned)
+}
+
+async fn expect_success(response: reqwest::Response) -> Result {
+ let status = response.status();
+ if status.is_success() {
+ return Ok(response);
+ }
+
+ let body = response.text().await.unwrap_or_default();
+ let parsed_error = serde_json::from_str::(&body).ok();
+ let retryable = is_retryable_status(status);
+
+ Err(ApiError::Api {
+ status,
+ error_type: parsed_error
+ .as_ref()
+ .and_then(|error| error.error.error_type.clone()),
+ message: parsed_error
+ .as_ref()
+ .and_then(|error| error.error.message.clone()),
+ body,
+ retryable,
+ })
+}
+
+const fn is_retryable_status(status: reqwest::StatusCode) -> bool {
+ matches!(status.as_u16(), 408 | 409 | 429 | 500 | 502 | 503 | 504)
+}
+
+fn normalize_finish_reason(value: &str) -> String {
+ match value {
+ "stop" => "end_turn",
+ "tool_calls" => "tool_use",
+ other => other,
+ }
+ .to_string()
+}
+
+trait StringExt {
+ fn if_empty_then(self, fallback: String) -> String;
+}
+
+impl StringExt for String {
+ fn if_empty_then(self, fallback: String) -> String {
+ if self.is_empty() {
+ fallback
+ } else {
+ self
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{
+ build_chat_completion_request, chat_completions_endpoint, normalize_finish_reason,
+ openai_tool_choice, parse_tool_arguments, OpenAiCompatClient, OpenAiCompatConfig,
+ };
+ use crate::error::ApiError;
+ use crate::types::{
+ InputContentBlock, InputMessage, MessageRequest, ToolChoice, ToolDefinition,
+ ToolResultContentBlock,
+ };
+ use serde_json::json;
+ use std::sync::{Mutex, OnceLock};
+
+ #[test]
+ fn request_translation_uses_openai_compatible_shape() {
+ let payload = build_chat_completion_request(&MessageRequest {
+ model: "grok-3".to_string(),
+ max_tokens: 64,
+ messages: vec![InputMessage {
+ role: "user".to_string(),
+ content: vec![
+ InputContentBlock::Text {
+ text: "hello".to_string(),
+ },
+ InputContentBlock::ToolResult {
+ tool_use_id: "tool_1".to_string(),
+ content: vec![ToolResultContentBlock::Json {
+ value: json!({"ok": true}),
+ }],
+ is_error: false,
+ },
+ ],
+ }],
+ system: Some("be helpful".to_string()),
+ tools: Some(vec![ToolDefinition {
+ name: "weather".to_string(),
+ description: Some("Get weather".to_string()),
+ input_schema: json!({"type": "object"}),
+ }]),
+ tool_choice: Some(ToolChoice::Auto),
+ stream: false,
+ });
+
+ assert_eq!(payload["messages"][0]["role"], json!("system"));
+ assert_eq!(payload["messages"][1]["role"], json!("user"));
+ assert_eq!(payload["messages"][2]["role"], json!("tool"));
+ assert_eq!(payload["tools"][0]["type"], json!("function"));
+ assert_eq!(payload["tool_choice"], json!("auto"));
+ }
+
+ #[test]
+ fn tool_choice_translation_supports_required_function() {
+ assert_eq!(openai_tool_choice(&ToolChoice::Any), json!("required"));
+ assert_eq!(
+ openai_tool_choice(&ToolChoice::Tool {
+ name: "weather".to_string(),
+ }),
+ json!({"type": "function", "function": {"name": "weather"}})
+ );
+ }
+
+ #[test]
+ fn parses_tool_arguments_fallback() {
+ assert_eq!(
+ parse_tool_arguments("{\"city\":\"Paris\"}"),
+ json!({"city": "Paris"})
+ );
+ assert_eq!(parse_tool_arguments("not-json"), json!({"raw": "not-json"}));
+ }
+
+ #[test]
+ fn missing_xai_api_key_is_provider_specific() {
+ let _lock = env_lock();
+ std::env::remove_var("XAI_API_KEY");
+ let error = OpenAiCompatClient::from_env(OpenAiCompatConfig::xai())
+ .expect_err("missing key should error");
+ assert!(matches!(
+ error,
+ ApiError::MissingCredentials {
+ provider: "xAI",
+ ..
+ }
+ ));
+ }
+
+ #[test]
+ fn endpoint_builder_accepts_base_urls_and_full_endpoints() {
+ assert_eq!(
+ chat_completions_endpoint("https://api.x.ai/v1"),
+ "https://api.x.ai/v1/chat/completions"
+ );
+ assert_eq!(
+ chat_completions_endpoint("https://api.x.ai/v1/"),
+ "https://api.x.ai/v1/chat/completions"
+ );
+ assert_eq!(
+ chat_completions_endpoint("https://api.x.ai/v1/chat/completions"),
+ "https://api.x.ai/v1/chat/completions"
+ );
+ }
+
+ fn env_lock() -> std::sync::MutexGuard<'static, ()> {
+ static LOCK: OnceLock> = OnceLock::new();
+ LOCK.get_or_init(|| Mutex::new(()))
+ .lock()
+ .expect("env lock")
+ }
+
+ #[test]
+ fn normalizes_stop_reasons() {
+ assert_eq!(normalize_finish_reason("stop"), "end_turn");
+ assert_eq!(normalize_finish_reason("tool_calls"), "tool_use");
+ }
+}
diff --git a/rust/crates/api/src/sse.rs b/rust/crates/api/src/sse.rs
index d7334cd..5f54e50 100644
--- a/rust/crates/api/src/sse.rs
+++ b/rust/crates/api/src/sse.rs
@@ -216,4 +216,64 @@ 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(),
+ },
+ }
+ ))
+ );
+ }
}
diff --git a/rust/crates/api/src/types.rs b/rust/crates/api/src/types.rs
index 45d5c08..c060be6 100644
--- a/rust/crates/api/src/types.rs
+++ b/rust/crates/api/src/types.rs
@@ -135,6 +135,15 @@ pub enum OutputContentBlock {
name: String,
input: Value,
},
+ Thinking {
+ #[serde(default)]
+ thinking: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ signature: Option,
+ },
+ RedactedThinking {
+ data: Value,
+ },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -190,6 +199,8 @@ 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)]
diff --git a/rust/crates/api/tests/client_integration.rs b/rust/crates/api/tests/client_integration.rs
index c37fa99..3b6a3c3 100644
--- a/rust/crates/api/tests/client_integration.rs
+++ b/rust/crates/api/tests/client_integration.rs
@@ -3,9 +3,9 @@ use std::sync::Arc;
use std::time::Duration;
use api::{
- AnthropicClient, ApiError, ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent,
- InputContentBlock, InputMessage, MessageDeltaEvent, MessageRequest, OutputContentBlock,
- StreamEvent, ToolChoice, ToolDefinition,
+ ApiClient, ApiError, AuthSource, ContentBlockDelta, ContentBlockDeltaEvent,
+ ContentBlockStartEvent, InputContentBlock, InputMessage, MessageDeltaEvent, MessageRequest,
+ OutputContentBlock, ProviderClient, StreamEvent, ToolChoice, ToolDefinition,
};
use serde_json::json;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
@@ -20,8 +20,8 @@ async fn send_message_posts_json_and_parses_response() {
"\"id\":\"msg_test\",",
"\"type\":\"message\",",
"\"role\":\"assistant\",",
- "\"content\":[{\"type\":\"text\",\"text\":\"Hello from Claude\"}],",
- "\"model\":\"claude-3-7-sonnet-latest\",",
+ "\"content\":[{\"type\":\"text\",\"text\":\"Hello from Claw\"}],",
+ "\"model\":\"claude-sonnet-4-6\",",
"\"stop_reason\":\"end_turn\",",
"\"stop_sequence\":null,",
"\"usage\":{\"input_tokens\":12,\"output_tokens\":4},",
@@ -34,7 +34,7 @@ async fn send_message_posts_json_and_parses_response() {
)
.await;
- let client = AnthropicClient::new("test-key")
+ let client = ApiClient::new("test-key")
.with_auth_token(Some("proxy-token".to_string()))
.with_base_url(server.base_url());
let response = client
@@ -48,7 +48,7 @@ async fn send_message_posts_json_and_parses_response() {
assert_eq!(
response.content,
vec![OutputContentBlock::Text {
- text: "Hello from Claude".to_string(),
+ text: "Hello from Claw".to_string(),
}]
);
@@ -68,7 +68,7 @@ async fn send_message_posts_json_and_parses_response() {
serde_json::from_str(&request.body).expect("request body should be json");
assert_eq!(
body.get("model").and_then(serde_json::Value::as_str),
- Some("claude-3-7-sonnet-latest")
+ Some("claude-sonnet-4-6")
);
assert!(body.get("stream").is_none());
assert_eq!(body["tools"][0]["name"], json!("get_weather"));
@@ -80,7 +80,7 @@ async fn stream_message_parses_sse_events_with_tool_use() {
let state = Arc::new(Mutex::new(Vec::::new()));
let sse = concat!(
"event: message_start\n",
- "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_stream\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-3-7-sonnet-latest\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":8,\"output_tokens\":0}}}\n\n",
+ "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_stream\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-sonnet-4-6\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":8,\"output_tokens\":0}}}\n\n",
"event: content_block_start\n",
"data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_123\",\"name\":\"get_weather\",\"input\":{}}}\n\n",
"event: content_block_delta\n",
@@ -104,7 +104,7 @@ async fn stream_message_parses_sse_events_with_tool_use() {
)
.await;
- let client = AnthropicClient::new("test-key")
+ let client = ApiClient::new("test-key")
.with_auth_token(Some("proxy-token".to_string()))
.with_base_url(server.base_url());
let mut stream = client
@@ -176,13 +176,13 @@ async fn retries_retryable_failures_before_succeeding() {
http_response(
"200 OK",
"application/json",
- "{\"id\":\"msg_retry\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Recovered\"}],\"model\":\"claude-3-7-sonnet-latest\",\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"usage\":{\"input_tokens\":3,\"output_tokens\":2}}",
+ "{\"id\":\"msg_retry\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Recovered\"}],\"model\":\"claude-sonnet-4-6\",\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"usage\":{\"input_tokens\":3,\"output_tokens\":2}}",
),
],
)
.await;
- let client = AnthropicClient::new("test-key")
+ let client = ApiClient::new("test-key")
.with_base_url(server.base_url())
.with_retry_policy(2, Duration::from_millis(1), Duration::from_millis(2));
@@ -195,6 +195,47 @@ async fn retries_retryable_failures_before_succeeding() {
assert_eq!(state.lock().await.len(), 2);
}
+#[tokio::test]
+async fn provider_client_dispatches_api_requests() {
+ let state = Arc::new(Mutex::new(Vec::::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-sonnet-4-6\",\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"usage\":{\"input_tokens\":3,\"output_tokens\":2}}",
+ )],
+ )
+ .await;
+
+ let client = ProviderClient::from_model_with_default_auth(
+ "claude-sonnet-4-6",
+ Some(AuthSource::ApiKey("test-key".to_string())),
+ )
+ .expect("api provider client should be constructed");
+ let client = match client {
+ ProviderClient::ClawApi(client) => {
+ ProviderClient::ClawApi(client.with_base_url(server.base_url()))
+ }
+ other => panic!("expected default 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::::new()));
@@ -215,7 +256,7 @@ async fn surfaces_retry_exhaustion_for_persistent_retryable_errors() {
)
.await;
- let client = AnthropicClient::new("test-key")
+ let client = ApiClient::new("test-key")
.with_base_url(server.base_url())
.with_retry_policy(1, Duration::from_millis(1), Duration::from_millis(2));
@@ -246,11 +287,10 @@ 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 = AnthropicClient::from_env().expect("ANTHROPIC_API_KEY must be set");
+ let client = ApiClient::from_env().expect("ANTHROPIC_API_KEY must be set");
let mut stream = client
.stream_message(&MessageRequest {
- model: std::env::var("ANTHROPIC_MODEL")
- .unwrap_or_else(|_| "claude-3-7-sonnet-latest".to_string()),
+ model: std::env::var("CLAW_MODEL").unwrap_or_else(|_| "claude-sonnet-4-6".to_string()),
max_tokens: 32,
messages: vec![InputMessage::user_text(
"Reply with exactly: hello from rust",
@@ -410,7 +450,7 @@ fn http_response_with_headers(
fn sample_request(stream: bool) -> MessageRequest {
MessageRequest {
- model: "claude-3-7-sonnet-latest".to_string(),
+ model: "claude-sonnet-4-6".to_string(),
max_tokens: 64,
messages: vec![InputMessage {
role: "user".to_string(),
diff --git a/rust/crates/api/tests/openai_compat_integration.rs b/rust/crates/api/tests/openai_compat_integration.rs
new file mode 100644
index 0000000..b345b1f
--- /dev/null
+++ b/rust/crates/api/tests/openai_compat_integration.rs
@@ -0,0 +1,415 @@
+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::::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::::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::::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::::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,
+ 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>>,
+ responses: Vec,
+) -> 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 {
+ 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> = OnceLock::new();
+ LOCK.get_or_init(|| StdMutex::new(()))
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+}
+
+struct ScopedEnvVar {
+ key: &'static str,
+ previous: Option,
+}
+
+impl ScopedEnvVar {
+ fn set(key: &'static str, value: impl AsRef) -> 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),
+ }
+ }
+}
diff --git a/rust/crates/api/tests/provider_client_integration.rs b/rust/crates/api/tests/provider_client_integration.rs
new file mode 100644
index 0000000..abeebdd
--- /dev/null
+++ b/rust/crates/api/tests/provider_client_integration.rs
@@ -0,0 +1,86 @@
+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_auth_without_env_lookup() {
+ let _lock = env_lock();
+ let _api_key = EnvVarGuard::set("ANTHROPIC_API_KEY", None);
+ let _auth_token = EnvVarGuard::set("ANTHROPIC_AUTH_TOKEN", None);
+
+ let client = ProviderClient::from_model_with_default_auth(
+ "claude-sonnet-4-6",
+ Some(AuthSource::ApiKey("claw-test-key".to_string())),
+ )
+ .expect("explicit auth should avoid env lookup");
+
+ assert_eq!(client.provider_kind(), ProviderKind::ClawApi);
+}
+
+#[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> = OnceLock::new();
+ LOCK.get_or_init(|| Mutex::new(()))
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+}
+
+struct EnvVarGuard {
+ key: &'static str,
+ original: Option,
+}
+
+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),
+ }
+ }
+}
diff --git a/rust/crates/claw-cli/Cargo.toml b/rust/crates/claw-cli/Cargo.toml
new file mode 100644
index 0000000..074718a
--- /dev/null
+++ b/rust/crates/claw-cli/Cargo.toml
@@ -0,0 +1,27 @@
+[package]
+name = "claw-cli"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+publish.workspace = true
+
+[[bin]]
+name = "claw"
+path = "src/main.rs"
+
+[dependencies]
+api = { path = "../api" }
+commands = { path = "../commands" }
+compat-harness = { path = "../compat-harness" }
+crossterm = "0.28"
+pulldown-cmark = "0.13"
+rustyline = "15"
+runtime = { path = "../runtime" }
+plugins = { path = "../plugins" }
+serde_json.workspace = true
+syntect = "5"
+tokio = { version = "1", features = ["rt-multi-thread", "time"] }
+tools = { path = "../tools" }
+
+[lints]
+workspace = true
diff --git a/rust/crates/claw-cli/src/app.rs b/rust/crates/claw-cli/src/app.rs
new file mode 100644
index 0000000..85e754f
--- /dev/null
+++ b/rust/crates/claw-cli/src/app.rs
@@ -0,0 +1,402 @@
+use std::io::{self, Write};
+use std::path::PathBuf;
+
+use crate::args::{OutputFormat, PermissionMode};
+use crate::input::{LineEditor, ReadOutcome};
+use crate::render::{Spinner, TerminalRenderer};
+use runtime::{ConversationClient, ConversationMessage, RuntimeError, StreamEvent, UsageSummary};
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct SessionConfig {
+ pub model: String,
+ pub permission_mode: PermissionMode,
+ pub config: Option,
+ pub output_format: OutputFormat,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct SessionState {
+ pub turns: usize,
+ pub compacted_messages: usize,
+ pub last_model: String,
+ pub last_usage: UsageSummary,
+}
+
+impl SessionState {
+ #[must_use]
+ pub fn new(model: impl Into) -> Self {
+ Self {
+ turns: 0,
+ compacted_messages: 0,
+ last_model: model.into(),
+ last_usage: UsageSummary::default(),
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum CommandResult {
+ Continue,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum SlashCommand {
+ Help,
+ Status,
+ Compact,
+ Unknown(String),
+}
+
+impl SlashCommand {
+ #[must_use]
+ pub fn parse(input: &str) -> Option {
+ let trimmed = input.trim();
+ if !trimmed.starts_with('/') {
+ return None;
+ }
+
+ let command = trimmed
+ .trim_start_matches('/')
+ .split_whitespace()
+ .next()
+ .unwrap_or_default();
+ Some(match command {
+ "help" => Self::Help,
+ "status" => Self::Status,
+ "compact" => Self::Compact,
+ other => Self::Unknown(other.to_string()),
+ })
+ }
+}
+
+struct SlashCommandHandler {
+ command: SlashCommand,
+ summary: &'static str,
+}
+
+const SLASH_COMMAND_HANDLERS: &[SlashCommandHandler] = &[
+ SlashCommandHandler {
+ command: SlashCommand::Help,
+ summary: "Show command help",
+ },
+ SlashCommandHandler {
+ command: SlashCommand::Status,
+ summary: "Show current session status",
+ },
+ SlashCommandHandler {
+ command: SlashCommand::Compact,
+ summary: "Compact local session history",
+ },
+];
+
+pub struct CliApp {
+ config: SessionConfig,
+ renderer: TerminalRenderer,
+ state: SessionState,
+ conversation_client: ConversationClient,
+ conversation_history: Vec,
+}
+
+impl CliApp {
+ pub fn new(config: SessionConfig) -> Result {
+ let state = SessionState::new(config.model.clone());
+ let conversation_client = ConversationClient::from_env(config.model.clone())?;
+ Ok(Self {
+ config,
+ renderer: TerminalRenderer::new(),
+ state,
+ conversation_client,
+ conversation_history: Vec::new(),
+ })
+ }
+
+ pub fn run_repl(&mut self) -> io::Result<()> {
+ let mut editor = LineEditor::new("› ", Vec::new());
+ println!("Claw Code interactive mode");
+ println!("Type /help for commands. Shift+Enter or Ctrl+J inserts a newline.");
+
+ loop {
+ match editor.read_line()? {
+ ReadOutcome::Submit(input) => {
+ if input.trim().is_empty() {
+ continue;
+ }
+ self.handle_submission(&input, &mut io::stdout())?;
+ }
+ ReadOutcome::Cancel => continue,
+ ReadOutcome::Exit => break,
+ }
+ }
+
+ Ok(())
+ }
+
+ pub fn run_prompt(&mut self, prompt: &str, out: &mut impl Write) -> io::Result<()> {
+ self.render_response(prompt, out)
+ }
+
+ pub fn handle_submission(
+ &mut self,
+ input: &str,
+ out: &mut impl Write,
+ ) -> io::Result {
+ if let Some(command) = SlashCommand::parse(input) {
+ return self.dispatch_slash_command(command, out);
+ }
+
+ self.state.turns += 1;
+ self.render_response(input, out)?;
+ Ok(CommandResult::Continue)
+ }
+
+ fn dispatch_slash_command(
+ &mut self,
+ command: SlashCommand,
+ out: &mut impl Write,
+ ) -> io::Result {
+ match command {
+ SlashCommand::Help => Self::handle_help(out),
+ SlashCommand::Status => self.handle_status(out),
+ SlashCommand::Compact => self.handle_compact(out),
+ SlashCommand::Unknown(name) => {
+ writeln!(out, "Unknown slash command: /{name}")?;
+ Ok(CommandResult::Continue)
+ }
+ _ => {
+ writeln!(out, "Slash command unavailable in this mode")?;
+ Ok(CommandResult::Continue)
+ }
+ }
+ }
+
+ fn handle_help(out: &mut impl Write) -> io::Result {
+ writeln!(out, "Available commands:")?;
+ for handler in SLASH_COMMAND_HANDLERS {
+ let name = match handler.command {
+ SlashCommand::Help => "/help",
+ SlashCommand::Status => "/status",
+ SlashCommand::Compact => "/compact",
+ _ => continue,
+ };
+ writeln!(out, " {name:<9} {}", handler.summary)?;
+ }
+ Ok(CommandResult::Continue)
+ }
+
+ fn handle_status(&mut self, out: &mut impl Write) -> io::Result {
+ writeln!(
+ out,
+ "status: turns={} model={} permission-mode={:?} output-format={:?} last-usage={} in/{} out config={}",
+ self.state.turns,
+ self.state.last_model,
+ self.config.permission_mode,
+ self.config.output_format,
+ self.state.last_usage.input_tokens,
+ self.state.last_usage.output_tokens,
+ self.config
+ .config
+ .as_ref()
+ .map_or_else(|| String::from(""), |path| path.display().to_string())
+ )?;
+ Ok(CommandResult::Continue)
+ }
+
+ fn handle_compact(&mut self, out: &mut impl Write) -> io::Result {
+ self.state.compacted_messages += self.state.turns;
+ self.state.turns = 0;
+ self.conversation_history.clear();
+ writeln!(
+ out,
+ "Compacted session history into a local summary ({} messages total compacted).",
+ self.state.compacted_messages
+ )?;
+ Ok(CommandResult::Continue)
+ }
+
+ fn handle_stream_event(
+ renderer: &TerminalRenderer,
+ event: StreamEvent,
+ stream_spinner: &mut Spinner,
+ tool_spinner: &mut Spinner,
+ saw_text: &mut bool,
+ turn_usage: &mut UsageSummary,
+ out: &mut impl Write,
+ ) {
+ match event {
+ StreamEvent::TextDelta(delta) => {
+ if !*saw_text {
+ let _ =
+ stream_spinner.finish("Streaming response", renderer.color_theme(), out);
+ *saw_text = true;
+ }
+ let _ = write!(out, "{delta}");
+ let _ = out.flush();
+ }
+ StreamEvent::ToolCallStart { name, input } => {
+ if *saw_text {
+ let _ = writeln!(out);
+ }
+ let _ = tool_spinner.tick(
+ &format!("Running tool `{name}` with {input}"),
+ renderer.color_theme(),
+ out,
+ );
+ }
+ StreamEvent::ToolCallResult {
+ name,
+ output,
+ is_error,
+ } => {
+ let label = if is_error {
+ format!("Tool `{name}` failed")
+ } else {
+ format!("Tool `{name}` completed")
+ };
+ let _ = tool_spinner.finish(&label, renderer.color_theme(), out);
+ let rendered_output = format!("### Tool `{name}`\n\n```text\n{output}\n```\n");
+ let _ = renderer.stream_markdown(&rendered_output, out);
+ }
+ StreamEvent::Usage(usage) => {
+ *turn_usage = usage;
+ }
+ }
+ }
+
+ fn write_turn_output(
+ &self,
+ summary: &runtime::TurnSummary,
+ out: &mut impl Write,
+ ) -> io::Result<()> {
+ match self.config.output_format {
+ OutputFormat::Text => {
+ writeln!(
+ out,
+ "\nToken usage: {} input / {} output",
+ self.state.last_usage.input_tokens, self.state.last_usage.output_tokens
+ )?;
+ }
+ OutputFormat::Json => {
+ writeln!(
+ out,
+ "{}",
+ serde_json::json!({
+ "message": summary.assistant_text,
+ "usage": {
+ "input_tokens": self.state.last_usage.input_tokens,
+ "output_tokens": self.state.last_usage.output_tokens,
+ }
+ })
+ )?;
+ }
+ OutputFormat::Ndjson => {
+ writeln!(
+ out,
+ "{}",
+ serde_json::json!({
+ "type": "message",
+ "text": summary.assistant_text,
+ "usage": {
+ "input_tokens": self.state.last_usage.input_tokens,
+ "output_tokens": self.state.last_usage.output_tokens,
+ }
+ })
+ )?;
+ }
+ }
+ Ok(())
+ }
+
+ fn render_response(&mut self, input: &str, out: &mut impl Write) -> io::Result<()> {
+ let mut stream_spinner = Spinner::new();
+ stream_spinner.tick(
+ "Opening conversation stream",
+ self.renderer.color_theme(),
+ out,
+ )?;
+
+ let mut turn_usage = UsageSummary::default();
+ let mut tool_spinner = Spinner::new();
+ let mut saw_text = false;
+ let renderer = &self.renderer;
+
+ let result =
+ self.conversation_client
+ .run_turn(&mut self.conversation_history, input, |event| {
+ Self::handle_stream_event(
+ renderer,
+ event,
+ &mut stream_spinner,
+ &mut tool_spinner,
+ &mut saw_text,
+ &mut turn_usage,
+ out,
+ );
+ });
+
+ let summary = match result {
+ Ok(summary) => summary,
+ Err(error) => {
+ stream_spinner.fail(
+ "Streaming response failed",
+ self.renderer.color_theme(),
+ out,
+ )?;
+ return Err(io::Error::other(error));
+ }
+ };
+ self.state.last_usage = summary.usage.clone();
+ if saw_text {
+ writeln!(out)?;
+ } else {
+ stream_spinner.finish("Streaming response", self.renderer.color_theme(), out)?;
+ }
+
+ self.write_turn_output(&summary, out)?;
+ let _ = turn_usage;
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::path::PathBuf;
+
+ use crate::args::{OutputFormat, PermissionMode};
+
+ use super::{CommandResult, SessionConfig, SlashCommand};
+
+ #[test]
+ fn parses_required_slash_commands() {
+ assert_eq!(SlashCommand::parse("/help"), Some(SlashCommand::Help));
+ assert_eq!(SlashCommand::parse(" /status "), Some(SlashCommand::Status));
+ assert_eq!(
+ SlashCommand::parse("/compact now"),
+ Some(SlashCommand::Compact)
+ );
+ }
+
+ #[test]
+ fn help_output_lists_commands() {
+ let mut out = Vec::new();
+ let result = super::CliApp::handle_help(&mut out).expect("help succeeds");
+ assert_eq!(result, CommandResult::Continue);
+ let output = String::from_utf8_lossy(&out);
+ assert!(output.contains("/help"));
+ assert!(output.contains("/status"));
+ assert!(output.contains("/compact"));
+ }
+
+ #[test]
+ fn session_state_tracks_config_values() {
+ let config = SessionConfig {
+ model: "sonnet".into(),
+ permission_mode: PermissionMode::DangerFullAccess,
+ config: Some(PathBuf::from("settings.toml")),
+ output_format: OutputFormat::Text,
+ };
+
+ assert_eq!(config.model, "sonnet");
+ assert_eq!(config.permission_mode, PermissionMode::DangerFullAccess);
+ assert_eq!(config.config, Some(PathBuf::from("settings.toml")));
+ }
+}
diff --git a/rust/crates/claw-cli/src/args.rs b/rust/crates/claw-cli/src/args.rs
new file mode 100644
index 0000000..3c204a9
--- /dev/null
+++ b/rust/crates/claw-cli/src/args.rs
@@ -0,0 +1,104 @@
+use std::path::PathBuf;
+
+use clap::{Parser, Subcommand, ValueEnum};
+
+#[derive(Debug, Clone, Parser, PartialEq, Eq)]
+#[command(name = "claw-cli", version, about = "Claw Code CLI")]
+pub struct Cli {
+ #[arg(long, default_value = "claude-opus-4-6")]
+ pub model: String,
+
+ #[arg(long, value_enum, default_value_t = PermissionMode::DangerFullAccess)]
+ pub permission_mode: PermissionMode,
+
+ #[arg(long)]
+ pub config: Option,
+
+ #[arg(long, value_enum, default_value_t = OutputFormat::Text)]
+ pub output_format: OutputFormat,
+
+ #[command(subcommand)]
+ pub command: Option,
+}
+
+#[derive(Debug, Clone, Subcommand, PartialEq, Eq)]
+pub enum Command {
+ /// Read upstream TS sources and print extracted counts
+ DumpManifests,
+ /// Print the current bootstrap phase skeleton
+ BootstrapPlan,
+ /// Start the OAuth login flow
+ Login,
+ /// Clear saved OAuth credentials
+ Logout,
+ /// Run a non-interactive prompt and exit
+ Prompt { prompt: Vec },
+}
+
+#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
+pub enum PermissionMode {
+ ReadOnly,
+ WorkspaceWrite,
+ DangerFullAccess,
+}
+
+#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
+pub enum OutputFormat {
+ Text,
+ Json,
+ Ndjson,
+}
+
+#[cfg(test)]
+mod tests {
+ use clap::Parser;
+
+ use super::{Cli, Command, OutputFormat, PermissionMode};
+
+ #[test]
+ fn parses_requested_flags() {
+ let cli = Cli::parse_from([
+ "claw-cli",
+ "--model",
+ "claude-haiku-4-5-20251213",
+ "--permission-mode",
+ "read-only",
+ "--config",
+ "/tmp/config.toml",
+ "--output-format",
+ "ndjson",
+ "prompt",
+ "hello",
+ "world",
+ ]);
+
+ assert_eq!(cli.model, "claude-haiku-4-5-20251213");
+ assert_eq!(cli.permission_mode, PermissionMode::ReadOnly);
+ assert_eq!(
+ cli.config.as_deref(),
+ Some(std::path::Path::new("/tmp/config.toml"))
+ );
+ assert_eq!(cli.output_format, OutputFormat::Ndjson);
+ assert_eq!(
+ cli.command,
+ Some(Command::Prompt {
+ prompt: vec!["hello".into(), "world".into()]
+ })
+ );
+ }
+
+ #[test]
+ fn parses_login_and_logout_commands() {
+ let login = Cli::parse_from(["claw-cli", "login"]);
+ assert_eq!(login.command, Some(Command::Login));
+
+ let logout = Cli::parse_from(["claw-cli", "logout"]);
+ assert_eq!(logout.command, Some(Command::Logout));
+ }
+
+ #[test]
+ fn defaults_to_danger_full_access_permission_mode() {
+ let cli = Cli::parse_from(["claw-cli"]);
+ assert_eq!(cli.permission_mode, PermissionMode::DangerFullAccess);
+ }
+}
diff --git a/rust/crates/claw-cli/src/init.rs b/rust/crates/claw-cli/src/init.rs
new file mode 100644
index 0000000..f4db53a
--- /dev/null
+++ b/rust/crates/claw-cli/src/init.rs
@@ -0,0 +1,432 @@
+use std::fs;
+use std::path::{Path, PathBuf};
+
+const STARTER_CLAW_JSON: &str = concat!(
+ "{\n",
+ " \"permissions\": {\n",
+ " \"defaultMode\": \"dontAsk\"\n",
+ " }\n",
+ "}\n",
+);
+const GITIGNORE_COMMENT: &str = "# Claw Code local artifacts";
+const GITIGNORE_ENTRIES: [&str; 2] = [".claw/settings.local.json", ".claw/sessions/"];
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum InitStatus {
+ Created,
+ Updated,
+ Skipped,
+}
+
+impl InitStatus {
+ #[must_use]
+ pub(crate) fn label(self) -> &'static str {
+ match self {
+ Self::Created => "created",
+ Self::Updated => "updated",
+ Self::Skipped => "skipped (already exists)",
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct InitArtifact {
+ pub(crate) name: &'static str,
+ pub(crate) status: InitStatus,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct InitReport {
+ pub(crate) project_root: PathBuf,
+ pub(crate) artifacts: Vec,
+}
+
+impl InitReport {
+ #[must_use]
+ pub(crate) fn render(&self) -> String {
+ let mut lines = vec![
+ "Init".to_string(),
+ format!(" Project {}", self.project_root.display()),
+ ];
+ for artifact in &self.artifacts {
+ lines.push(format!(
+ " {:<16} {}",
+ artifact.name,
+ artifact.status.label()
+ ));
+ }
+ lines.push(" Next step Review and tailor the generated guidance".to_string());
+ lines.join("\n")
+ }
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+#[allow(clippy::struct_excessive_bools)]
+struct RepoDetection {
+ rust_workspace: bool,
+ rust_root: bool,
+ python: bool,
+ package_json: bool,
+ typescript: bool,
+ nextjs: bool,
+ react: bool,
+ vite: bool,
+ nest: bool,
+ src_dir: bool,
+ tests_dir: bool,
+ rust_dir: bool,
+}
+
+pub(crate) fn initialize_repo(cwd: &Path) -> Result> {
+ let mut artifacts = Vec::new();
+
+ let claw_dir = cwd.join(".claw");
+ artifacts.push(InitArtifact {
+ name: ".claw/",
+ status: ensure_dir(&claw_dir)?,
+ });
+
+ let claw_json = cwd.join(".claw.json");
+ artifacts.push(InitArtifact {
+ name: ".claw.json",
+ status: write_file_if_missing(&claw_json, STARTER_CLAW_JSON)?,
+ });
+
+ let gitignore = cwd.join(".gitignore");
+ artifacts.push(InitArtifact {
+ name: ".gitignore",
+ status: ensure_gitignore_entries(&gitignore)?,
+ });
+
+ let claw_md = cwd.join("CLAW.md");
+ let content = render_init_claw_md(cwd);
+ artifacts.push(InitArtifact {
+ name: "CLAW.md",
+ status: write_file_if_missing(&claw_md, &content)?,
+ });
+
+ Ok(InitReport {
+ project_root: cwd.to_path_buf(),
+ artifacts,
+ })
+}
+
+fn ensure_dir(path: &Path) -> Result {
+ if path.is_dir() {
+ return Ok(InitStatus::Skipped);
+ }
+ fs::create_dir_all(path)?;
+ Ok(InitStatus::Created)
+}
+
+fn write_file_if_missing(path: &Path, content: &str) -> Result {
+ if path.exists() {
+ return Ok(InitStatus::Skipped);
+ }
+ fs::write(path, content)?;
+ Ok(InitStatus::Created)
+}
+
+fn ensure_gitignore_entries(path: &Path) -> Result {
+ if !path.exists() {
+ let mut lines = vec![GITIGNORE_COMMENT.to_string()];
+ lines.extend(GITIGNORE_ENTRIES.iter().map(|entry| (*entry).to_string()));
+ fs::write(path, format!("{}\n", lines.join("\n")))?;
+ return Ok(InitStatus::Created);
+ }
+
+ let existing = fs::read_to_string(path)?;
+ let mut lines = existing.lines().map(ToOwned::to_owned).collect::>();
+ let mut changed = false;
+
+ if !lines.iter().any(|line| line == GITIGNORE_COMMENT) {
+ lines.push(GITIGNORE_COMMENT.to_string());
+ changed = true;
+ }
+
+ for entry in GITIGNORE_ENTRIES {
+ if !lines.iter().any(|line| line == entry) {
+ lines.push(entry.to_string());
+ changed = true;
+ }
+ }
+
+ if !changed {
+ return Ok(InitStatus::Skipped);
+ }
+
+ fs::write(path, format!("{}\n", lines.join("\n")))?;
+ Ok(InitStatus::Updated)
+}
+
+pub(crate) fn render_init_claw_md(cwd: &Path) -> String {
+ let detection = detect_repo(cwd);
+ let mut lines = vec![
+ "# CLAW.md".to_string(),
+ String::new(),
+ "This file provides guidance to Claw Code (clawcode.dev) when working with code in this repository.".to_string(),
+ String::new(),
+ ];
+
+ let detected_languages = detected_languages(&detection);
+ let detected_frameworks = detected_frameworks(&detection);
+ lines.push("## Detected stack".to_string());
+ if detected_languages.is_empty() {
+ lines.push("- No specific language markers were detected yet; document the primary language and verification commands once the project structure settles.".to_string());
+ } else {
+ lines.push(format!("- Languages: {}.", detected_languages.join(", ")));
+ }
+ if detected_frameworks.is_empty() {
+ lines.push("- Frameworks: none detected from the supported starter markers.".to_string());
+ } else {
+ lines.push(format!(
+ "- Frameworks/tooling markers: {}.",
+ detected_frameworks.join(", ")
+ ));
+ }
+ lines.push(String::new());
+
+ let verification_lines = verification_lines(cwd, &detection);
+ if !verification_lines.is_empty() {
+ lines.push("## Verification".to_string());
+ lines.extend(verification_lines);
+ lines.push(String::new());
+ }
+
+ let structure_lines = repository_shape_lines(&detection);
+ if !structure_lines.is_empty() {
+ lines.push("## Repository shape".to_string());
+ lines.extend(structure_lines);
+ lines.push(String::new());
+ }
+
+ let framework_lines = framework_notes(&detection);
+ if !framework_lines.is_empty() {
+ lines.push("## Framework notes".to_string());
+ lines.extend(framework_lines);
+ lines.push(String::new());
+ }
+
+ lines.push("## Working agreement".to_string());
+ lines.push("- Prefer small, reviewable changes and keep generated bootstrap files aligned with actual repo workflows.".to_string());
+ lines.push("- Keep shared defaults in `.claw.json`; reserve `.claw/settings.local.json` for machine-local overrides.".to_string());
+ lines.push("- Do not overwrite existing `CLAW.md` content automatically; update it intentionally when repo workflows change.".to_string());
+ lines.push(String::new());
+
+ lines.join("\n")
+}
+
+fn detect_repo(cwd: &Path) -> RepoDetection {
+ let package_json_contents = fs::read_to_string(cwd.join("package.json"))
+ .unwrap_or_default()
+ .to_ascii_lowercase();
+ RepoDetection {
+ rust_workspace: cwd.join("rust").join("Cargo.toml").is_file(),
+ rust_root: cwd.join("Cargo.toml").is_file(),
+ python: cwd.join("pyproject.toml").is_file()
+ || cwd.join("requirements.txt").is_file()
+ || cwd.join("setup.py").is_file(),
+ package_json: cwd.join("package.json").is_file(),
+ typescript: cwd.join("tsconfig.json").is_file()
+ || package_json_contents.contains("typescript"),
+ nextjs: package_json_contents.contains("\"next\""),
+ react: package_json_contents.contains("\"react\""),
+ vite: package_json_contents.contains("\"vite\""),
+ nest: package_json_contents.contains("@nestjs"),
+ src_dir: cwd.join("src").is_dir(),
+ tests_dir: cwd.join("tests").is_dir(),
+ rust_dir: cwd.join("rust").is_dir(),
+ }
+}
+
+fn detected_languages(detection: &RepoDetection) -> Vec<&'static str> {
+ let mut languages = Vec::new();
+ if detection.rust_workspace || detection.rust_root {
+ languages.push("Rust");
+ }
+ if detection.python {
+ languages.push("Python");
+ }
+ if detection.typescript {
+ languages.push("TypeScript");
+ } else if detection.package_json {
+ languages.push("JavaScript/Node.js");
+ }
+ languages
+}
+
+fn detected_frameworks(detection: &RepoDetection) -> Vec<&'static str> {
+ let mut frameworks = Vec::new();
+ if detection.nextjs {
+ frameworks.push("Next.js");
+ }
+ if detection.react {
+ frameworks.push("React");
+ }
+ if detection.vite {
+ frameworks.push("Vite");
+ }
+ if detection.nest {
+ frameworks.push("NestJS");
+ }
+ frameworks
+}
+
+fn verification_lines(cwd: &Path, detection: &RepoDetection) -> Vec {
+ let mut lines = Vec::new();
+ if detection.rust_workspace {
+ lines.push("- Run Rust verification from `rust/`: `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`".to_string());
+ } else if detection.rust_root {
+ lines.push("- Run Rust verification from the repo root: `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`".to_string());
+ }
+ if detection.python {
+ if cwd.join("pyproject.toml").is_file() {
+ lines.push("- Run the Python project checks declared in `pyproject.toml` (for example: `pytest`, `ruff check`, and `mypy` when configured).".to_string());
+ } else {
+ lines.push(
+ "- Run the repo's Python test/lint commands before shipping changes.".to_string(),
+ );
+ }
+ }
+ if detection.package_json {
+ lines.push("- Run the JavaScript/TypeScript checks from `package.json` before shipping changes (`npm test`, `npm run lint`, `npm run build`, or the repo equivalent).".to_string());
+ }
+ if detection.tests_dir && detection.src_dir {
+ lines.push("- `src/` and `tests/` are both present; update both surfaces together when behavior changes.".to_string());
+ }
+ lines
+}
+
+fn repository_shape_lines(detection: &RepoDetection) -> Vec {
+ let mut lines = Vec::new();
+ if detection.rust_dir {
+ lines.push(
+ "- `rust/` contains the Rust workspace and active CLI/runtime implementation."
+ .to_string(),
+ );
+ }
+ if detection.src_dir {
+ lines.push("- `src/` contains source files that should stay consistent with generated guidance and tests.".to_string());
+ }
+ if detection.tests_dir {
+ lines.push("- `tests/` contains validation surfaces that should be reviewed alongside code changes.".to_string());
+ }
+ lines
+}
+
+fn framework_notes(detection: &RepoDetection) -> Vec {
+ let mut lines = Vec::new();
+ if detection.nextjs {
+ lines.push("- Next.js detected: preserve routing/data-fetching conventions and verify production builds after changing app structure.".to_string());
+ }
+ if detection.react && !detection.nextjs {
+ lines.push("- React detected: keep component behavior covered with focused tests and avoid unnecessary prop/API churn.".to_string());
+ }
+ if detection.vite {
+ lines.push("- Vite detected: validate the production bundle after changing build-sensitive configuration or imports.".to_string());
+ }
+ if detection.nest {
+ lines.push("- NestJS detected: keep module/provider boundaries explicit and verify controller/service wiring after refactors.".to_string());
+ }
+ lines
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{initialize_repo, render_init_claw_md};
+ use std::fs;
+ use std::path::Path;
+ use std::time::{SystemTime, UNIX_EPOCH};
+
+ fn temp_dir() -> std::path::PathBuf {
+ let nanos = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .expect("time should be after epoch")
+ .as_nanos();
+ std::env::temp_dir().join(format!("claw-init-{nanos}"))
+ }
+
+ #[test]
+ fn initialize_repo_creates_expected_files_and_gitignore_entries() {
+ let root = temp_dir();
+ fs::create_dir_all(root.join("rust")).expect("create rust dir");
+ fs::write(root.join("rust").join("Cargo.toml"), "[workspace]\n").expect("write cargo");
+
+ let report = initialize_repo(&root).expect("init should succeed");
+ let rendered = report.render();
+ assert!(rendered.contains(".claw/ created"));
+ assert!(rendered.contains(".claw.json created"));
+ assert!(rendered.contains(".gitignore created"));
+ assert!(rendered.contains("CLAW.md created"));
+ assert!(root.join(".claw").is_dir());
+ assert!(root.join(".claw.json").is_file());
+ assert!(root.join("CLAW.md").is_file());
+ assert_eq!(
+ fs::read_to_string(root.join(".claw.json")).expect("read claw json"),
+ concat!(
+ "{\n",
+ " \"permissions\": {\n",
+ " \"defaultMode\": \"dontAsk\"\n",
+ " }\n",
+ "}\n",
+ )
+ );
+ let gitignore = fs::read_to_string(root.join(".gitignore")).expect("read gitignore");
+ assert!(gitignore.contains(".claw/settings.local.json"));
+ assert!(gitignore.contains(".claw/sessions/"));
+ let claw_md = fs::read_to_string(root.join("CLAW.md")).expect("read claw md");
+ assert!(claw_md.contains("Languages: Rust."));
+ assert!(claw_md.contains("cargo clippy --workspace --all-targets -- -D warnings"));
+
+ fs::remove_dir_all(root).expect("cleanup temp dir");
+ }
+
+ #[test]
+ fn initialize_repo_is_idempotent_and_preserves_existing_files() {
+ let root = temp_dir();
+ fs::create_dir_all(&root).expect("create root");
+ fs::write(root.join("CLAW.md"), "custom guidance\n").expect("write existing claw md");
+ fs::write(root.join(".gitignore"), ".claw/settings.local.json\n").expect("write gitignore");
+
+ let first = initialize_repo(&root).expect("first init should succeed");
+ assert!(first
+ .render()
+ .contains("CLAW.md skipped (already exists)"));
+ let second = initialize_repo(&root).expect("second init should succeed");
+ let second_rendered = second.render();
+ assert!(second_rendered.contains(".claw/ skipped (already exists)"));
+ assert!(second_rendered.contains(".claw.json skipped (already exists)"));
+ assert!(second_rendered.contains(".gitignore skipped (already exists)"));
+ assert!(second_rendered.contains("CLAW.md skipped (already exists)"));
+ assert_eq!(
+ fs::read_to_string(root.join("CLAW.md")).expect("read existing claw md"),
+ "custom guidance\n"
+ );
+ let gitignore = fs::read_to_string(root.join(".gitignore")).expect("read gitignore");
+ assert_eq!(gitignore.matches(".claw/settings.local.json").count(), 1);
+ assert_eq!(gitignore.matches(".claw/sessions/").count(), 1);
+
+ fs::remove_dir_all(root).expect("cleanup temp dir");
+ }
+
+ #[test]
+ fn render_init_template_mentions_detected_python_and_nextjs_markers() {
+ let root = temp_dir();
+ fs::create_dir_all(&root).expect("create root");
+ fs::write(root.join("pyproject.toml"), "[project]\nname = \"demo\"\n")
+ .expect("write pyproject");
+ fs::write(
+ root.join("package.json"),
+ r#"{"dependencies":{"next":"14.0.0","react":"18.0.0"},"devDependencies":{"typescript":"5.0.0"}}"#,
+ )
+ .expect("write package json");
+
+ let rendered = render_init_claw_md(Path::new(&root));
+ assert!(rendered.contains("Languages: Python, TypeScript."));
+ assert!(rendered.contains("Frameworks/tooling markers: Next.js, React."));
+ assert!(rendered.contains("pyproject.toml"));
+ assert!(rendered.contains("Next.js detected"));
+
+ fs::remove_dir_all(root).expect("cleanup temp dir");
+ }
+}
diff --git a/rust/crates/claw-cli/src/input.rs b/rust/crates/claw-cli/src/input.rs
new file mode 100644
index 0000000..a718cd7
--- /dev/null
+++ b/rust/crates/claw-cli/src/input.rs
@@ -0,0 +1,1195 @@
+use std::borrow::Cow;
+use std::io::{self, IsTerminal, Write};
+
+use crossterm::cursor::{MoveToColumn, MoveUp};
+use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
+use crossterm::queue;
+use crossterm::terminal::{self, Clear, ClearType};
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum ReadOutcome {
+ Submit(String),
+ Cancel,
+ Exit,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum EditorMode {
+ Plain,
+ Insert,
+ Normal,
+ Visual,
+ Command,
+}
+
+impl EditorMode {
+ fn indicator(self, vim_enabled: bool) -> Option<&'static str> {
+ if !vim_enabled {
+ return None;
+ }
+
+ Some(match self {
+ Self::Plain => "PLAIN",
+ Self::Insert => "INSERT",
+ Self::Normal => "NORMAL",
+ Self::Visual => "VISUAL",
+ Self::Command => "COMMAND",
+ })
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Default)]
+struct YankBuffer {
+ text: String,
+ linewise: bool,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct EditSession {
+ text: String,
+ cursor: usize,
+ mode: EditorMode,
+ pending_operator: Option,
+ visual_anchor: Option,
+ command_buffer: String,
+ command_cursor: usize,
+ history_index: Option,
+ history_backup: Option,
+ rendered_cursor_row: usize,
+ rendered_lines: usize,
+}
+
+impl EditSession {
+ fn new(vim_enabled: bool) -> Self {
+ Self {
+ text: String::new(),
+ cursor: 0,
+ mode: if vim_enabled {
+ EditorMode::Insert
+ } else {
+ EditorMode::Plain
+ },
+ pending_operator: None,
+ visual_anchor: None,
+ command_buffer: String::new(),
+ command_cursor: 0,
+ history_index: None,
+ history_backup: None,
+ rendered_cursor_row: 0,
+ rendered_lines: 1,
+ }
+ }
+
+ fn active_text(&self) -> &str {
+ if self.mode == EditorMode::Command {
+ &self.command_buffer
+ } else {
+ &self.text
+ }
+ }
+
+ fn current_len(&self) -> usize {
+ self.active_text().len()
+ }
+
+ fn has_input(&self) -> bool {
+ !self.active_text().is_empty()
+ }
+
+ fn current_line(&self) -> String {
+ self.active_text().to_string()
+ }
+
+ fn set_text_from_history(&mut self, entry: String) {
+ self.text = entry;
+ self.cursor = self.text.len();
+ self.pending_operator = None;
+ self.visual_anchor = None;
+ if self.mode != EditorMode::Plain && self.mode != EditorMode::Insert {
+ self.mode = EditorMode::Normal;
+ }
+ }
+
+ fn enter_insert_mode(&mut self) {
+ self.mode = EditorMode::Insert;
+ self.pending_operator = None;
+ self.visual_anchor = None;
+ }
+
+ fn enter_normal_mode(&mut self) {
+ self.mode = EditorMode::Normal;
+ self.pending_operator = None;
+ self.visual_anchor = None;
+ }
+
+ fn enter_visual_mode(&mut self) {
+ self.mode = EditorMode::Visual;
+ self.pending_operator = None;
+ self.visual_anchor = Some(self.cursor);
+ }
+
+ fn enter_command_mode(&mut self) {
+ self.mode = EditorMode::Command;
+ self.pending_operator = None;
+ self.visual_anchor = None;
+ self.command_buffer.clear();
+ self.command_buffer.push(':');
+ self.command_cursor = self.command_buffer.len();
+ }
+
+ fn exit_command_mode(&mut self) {
+ self.command_buffer.clear();
+ self.command_cursor = 0;
+ self.enter_normal_mode();
+ }
+
+ fn visible_buffer(&self) -> Cow<'_, str> {
+ if self.mode != EditorMode::Visual {
+ return Cow::Borrowed(self.active_text());
+ }
+
+ let Some(anchor) = self.visual_anchor else {
+ return Cow::Borrowed(self.active_text());
+ };
+ let Some((start, end)) = selection_bounds(&self.text, anchor, self.cursor) else {
+ return Cow::Borrowed(self.active_text());
+ };
+
+ Cow::Owned(render_selected_text(&self.text, start, end))
+ }
+
+ fn prompt<'a>(&self, base_prompt: &'a str, vim_enabled: bool) -> Cow<'a, str> {
+ match self.mode.indicator(vim_enabled) {
+ Some(mode) => Cow::Owned(format!("[{mode}] {base_prompt}")),
+ None => Cow::Borrowed(base_prompt),
+ }
+ }
+
+ fn clear_render(&self, out: &mut impl Write) -> io::Result<()> {
+ if self.rendered_cursor_row > 0 {
+ queue!(out, MoveUp(to_u16(self.rendered_cursor_row)?))?;
+ }
+ queue!(out, MoveToColumn(0), Clear(ClearType::FromCursorDown))?;
+ out.flush()
+ }
+
+ fn render(
+ &mut self,
+ out: &mut impl Write,
+ base_prompt: &str,
+ vim_enabled: bool,
+ ) -> io::Result<()> {
+ self.clear_render(out)?;
+
+ let prompt = self.prompt(base_prompt, vim_enabled);
+ let buffer = self.visible_buffer();
+ write!(out, "{prompt}{buffer}")?;
+
+ let (cursor_row, cursor_col, total_lines) = self.cursor_layout(prompt.as_ref());
+ let rows_to_move_up = total_lines.saturating_sub(cursor_row + 1);
+ if rows_to_move_up > 0 {
+ queue!(out, MoveUp(to_u16(rows_to_move_up)?))?;
+ }
+ queue!(out, MoveToColumn(to_u16(cursor_col)?))?;
+ out.flush()?;
+
+ self.rendered_cursor_row = cursor_row;
+ self.rendered_lines = total_lines;
+ Ok(())
+ }
+
+ fn finalize_render(
+ &self,
+ out: &mut impl Write,
+ base_prompt: &str,
+ vim_enabled: bool,
+ ) -> io::Result<()> {
+ self.clear_render(out)?;
+ let prompt = self.prompt(base_prompt, vim_enabled);
+ let buffer = self.visible_buffer();
+ write!(out, "{prompt}{buffer}")?;
+ writeln!(out)
+ }
+
+ fn cursor_layout(&self, prompt: &str) -> (usize, usize, usize) {
+ let active_text = self.active_text();
+ let cursor = if self.mode == EditorMode::Command {
+ self.command_cursor
+ } else {
+ self.cursor
+ };
+
+ let cursor_prefix = &active_text[..cursor];
+ let cursor_row = cursor_prefix.bytes().filter(|byte| *byte == b'\n').count();
+ let cursor_col = match cursor_prefix.rsplit_once('\n') {
+ Some((_, suffix)) => suffix.chars().count(),
+ None => prompt.chars().count() + cursor_prefix.chars().count(),
+ };
+ let total_lines = active_text.bytes().filter(|byte| *byte == b'\n').count() + 1;
+ (cursor_row, cursor_col, total_lines)
+ }
+}
+
+enum KeyAction {
+ Continue,
+ Submit(String),
+ Cancel,
+ Exit,
+ ToggleVim,
+}
+
+pub struct LineEditor {
+ prompt: String,
+ completions: Vec,
+ history: Vec,
+ yank_buffer: YankBuffer,
+ vim_enabled: bool,
+ completion_state: Option,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct CompletionState {
+ prefix: String,
+ matches: Vec,
+ next_index: usize,
+}
+
+impl LineEditor {
+ #[must_use]
+ pub fn new(prompt: impl Into, completions: Vec) -> Self {
+ Self {
+ prompt: prompt.into(),
+ completions,
+ history: Vec::new(),
+ yank_buffer: YankBuffer::default(),
+ vim_enabled: false,
+ completion_state: None,
+ }
+ }
+
+ pub fn push_history(&mut self, entry: impl Into) {
+ let entry = entry.into();
+ if entry.trim().is_empty() {
+ return;
+ }
+
+ self.history.push(entry);
+ }
+
+ pub fn read_line(&mut self) -> io::Result {
+ if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
+ return self.read_line_fallback();
+ }
+
+ let _raw_mode = RawModeGuard::new()?;
+ let mut stdout = io::stdout();
+ let mut session = EditSession::new(self.vim_enabled);
+ session.render(&mut stdout, &self.prompt, self.vim_enabled)?;
+
+ loop {
+ let Event::Key(key) = event::read()? else {
+ continue;
+ };
+ if !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
+ continue;
+ }
+
+ match self.handle_key_event(&mut session, key) {
+ KeyAction::Continue => {
+ session.render(&mut stdout, &self.prompt, self.vim_enabled)?;
+ }
+ KeyAction::Submit(line) => {
+ session.finalize_render(&mut stdout, &self.prompt, self.vim_enabled)?;
+ return Ok(ReadOutcome::Submit(line));
+ }
+ KeyAction::Cancel => {
+ session.clear_render(&mut stdout)?;
+ writeln!(stdout)?;
+ return Ok(ReadOutcome::Cancel);
+ }
+ KeyAction::Exit => {
+ session.clear_render(&mut stdout)?;
+ writeln!(stdout)?;
+ return Ok(ReadOutcome::Exit);
+ }
+ KeyAction::ToggleVim => {
+ session.clear_render(&mut stdout)?;
+ self.vim_enabled = !self.vim_enabled;
+ writeln!(
+ stdout,
+ "Vim mode {}.",
+ if self.vim_enabled {
+ "enabled"
+ } else {
+ "disabled"
+ }
+ )?;
+ session = EditSession::new(self.vim_enabled);
+ session.render(&mut stdout, &self.prompt, self.vim_enabled)?;
+ }
+ }
+ }
+ }
+
+ fn read_line_fallback(&mut self) -> io::Result {
+ loop {
+ let mut stdout = io::stdout();
+ write!(stdout, "{}", self.prompt)?;
+ stdout.flush()?;
+
+ let mut buffer = String::new();
+ let bytes_read = io::stdin().read_line(&mut buffer)?;
+ if bytes_read == 0 {
+ return Ok(ReadOutcome::Exit);
+ }
+
+ while matches!(buffer.chars().last(), Some('\n' | '\r')) {
+ buffer.pop();
+ }
+
+ if self.handle_submission(&buffer) == Submission::ToggleVim {
+ self.vim_enabled = !self.vim_enabled;
+ writeln!(
+ stdout,
+ "Vim mode {}.",
+ if self.vim_enabled {
+ "enabled"
+ } else {
+ "disabled"
+ }
+ )?;
+ continue;
+ }
+
+ return Ok(ReadOutcome::Submit(buffer));
+ }
+ }
+
+ fn handle_key_event(&mut self, session: &mut EditSession, key: KeyEvent) -> KeyAction {
+ if key.code != KeyCode::Tab {
+ self.completion_state = None;
+ }
+
+ if key.modifiers.contains(KeyModifiers::CONTROL) {
+ match key.code {
+ KeyCode::Char('c') | KeyCode::Char('C') => {
+ return if session.has_input() {
+ KeyAction::Cancel
+ } else {
+ KeyAction::Exit
+ };
+ }
+ KeyCode::Char('j') | KeyCode::Char('J') => {
+ if session.mode != EditorMode::Normal && session.mode != EditorMode::Visual {
+ self.insert_active_text(session, "\n");
+ }
+ return KeyAction::Continue;
+ }
+ KeyCode::Char('d') | KeyCode::Char('D') => {
+ if session.current_len() == 0 {
+ return KeyAction::Exit;
+ }
+ self.delete_char_under_cursor(session);
+ return KeyAction::Continue;
+ }
+ _ => {}
+ }
+ }
+
+ match key.code {
+ KeyCode::Enter if key.modifiers.contains(KeyModifiers::SHIFT) => {
+ if session.mode != EditorMode::Normal && session.mode != EditorMode::Visual {
+ self.insert_active_text(session, "\n");
+ }
+ KeyAction::Continue
+ }
+ KeyCode::Enter => self.submit_or_toggle(session),
+ KeyCode::Esc => self.handle_escape(session),
+ KeyCode::Backspace => {
+ self.handle_backspace(session);
+ KeyAction::Continue
+ }
+ KeyCode::Delete => {
+ self.delete_char_under_cursor(session);
+ KeyAction::Continue
+ }
+ KeyCode::Left => {
+ self.move_left(session);
+ KeyAction::Continue
+ }
+ KeyCode::Right => {
+ self.move_right(session);
+ KeyAction::Continue
+ }
+ KeyCode::Up => {
+ self.history_up(session);
+ KeyAction::Continue
+ }
+ KeyCode::Down => {
+ self.history_down(session);
+ KeyAction::Continue
+ }
+ KeyCode::Home => {
+ self.move_line_start(session);
+ KeyAction::Continue
+ }
+ KeyCode::End => {
+ self.move_line_end(session);
+ KeyAction::Continue
+ }
+ KeyCode::Tab => {
+ self.complete_slash_command(session);
+ KeyAction::Continue
+ }
+ KeyCode::Char(ch) => {
+ self.handle_char(session, ch);
+ KeyAction::Continue
+ }
+ _ => KeyAction::Continue,
+ }
+ }
+
+ fn handle_char(&mut self, session: &mut EditSession, ch: char) {
+ match session.mode {
+ EditorMode::Plain => self.insert_active_char(session, ch),
+ EditorMode::Insert => self.insert_active_char(session, ch),
+ EditorMode::Normal => self.handle_normal_char(session, ch),
+ EditorMode::Visual => self.handle_visual_char(session, ch),
+ EditorMode::Command => self.insert_active_char(session, ch),
+ }
+ }
+
+ fn handle_normal_char(&mut self, session: &mut EditSession, ch: char) {
+ if let Some(operator) = session.pending_operator.take() {
+ match (operator, ch) {
+ ('d', 'd') => {
+ self.delete_current_line(session);
+ return;
+ }
+ ('y', 'y') => {
+ self.yank_current_line(session);
+ return;
+ }
+ _ => {}
+ }
+ }
+
+ match ch {
+ 'h' => self.move_left(session),
+ 'j' => self.move_down(session),
+ 'k' => self.move_up(session),
+ 'l' => self.move_right(session),
+ 'd' | 'y' => session.pending_operator = Some(ch),
+ 'p' => self.paste_after(session),
+ 'i' => session.enter_insert_mode(),
+ 'v' => session.enter_visual_mode(),
+ ':' => session.enter_command_mode(),
+ _ => {}
+ }
+ }
+
+ fn handle_visual_char(&mut self, session: &mut EditSession, ch: char) {
+ match ch {
+ 'h' => self.move_left(session),
+ 'j' => self.move_down(session),
+ 'k' => self.move_up(session),
+ 'l' => self.move_right(session),
+ 'v' => session.enter_normal_mode(),
+ _ => {}
+ }
+ }
+
+ fn handle_escape(&mut self, session: &mut EditSession) -> KeyAction {
+ match session.mode {
+ EditorMode::Plain => KeyAction::Continue,
+ EditorMode::Insert => {
+ if session.cursor > 0 {
+ session.cursor = previous_boundary(&session.text, session.cursor);
+ }
+ session.enter_normal_mode();
+ KeyAction::Continue
+ }
+ EditorMode::Normal => KeyAction::Continue,
+ EditorMode::Visual => {
+ session.enter_normal_mode();
+ KeyAction::Continue
+ }
+ EditorMode::Command => {
+ session.exit_command_mode();
+ KeyAction::Continue
+ }
+ }
+ }
+
+ fn handle_backspace(&mut self, session: &mut EditSession) {
+ match session.mode {
+ EditorMode::Normal | EditorMode::Visual => self.move_left(session),
+ EditorMode::Command => {
+ if session.command_cursor <= 1 {
+ session.exit_command_mode();
+ } else {
+ remove_previous_char(&mut session.command_buffer, &mut session.command_cursor);
+ }
+ }
+ EditorMode::Plain | EditorMode::Insert => {
+ remove_previous_char(&mut session.text, &mut session.cursor);
+ }
+ }
+ }
+
+ fn submit_or_toggle(&mut self, session: &EditSession) -> KeyAction {
+ let line = session.current_line();
+ match self.handle_submission(&line) {
+ Submission::Submit => KeyAction::Submit(line),
+ Submission::ToggleVim => KeyAction::ToggleVim,
+ }
+ }
+
+ fn handle_submission(&mut self, line: &str) -> Submission {
+ if line.trim() == "/vim" {
+ Submission::ToggleVim
+ } else {
+ Submission::Submit
+ }
+ }
+
+ fn insert_active_char(&mut self, session: &mut EditSession, ch: char) {
+ let mut buffer = [0; 4];
+ self.insert_active_text(session, ch.encode_utf8(&mut buffer));
+ }
+
+ fn insert_active_text(&mut self, session: &mut EditSession, text: &str) {
+ if session.mode == EditorMode::Command {
+ session
+ .command_buffer
+ .insert_str(session.command_cursor, text);
+ session.command_cursor += text.len();
+ } else {
+ session.text.insert_str(session.cursor, text);
+ session.cursor += text.len();
+ }
+ }
+
+ fn move_left(&self, session: &mut EditSession) {
+ if session.mode == EditorMode::Command {
+ session.command_cursor =
+ previous_command_boundary(&session.command_buffer, session.command_cursor);
+ } else {
+ session.cursor = previous_boundary(&session.text, session.cursor);
+ }
+ }
+
+ fn move_right(&self, session: &mut EditSession) {
+ if session.mode == EditorMode::Command {
+ session.command_cursor = next_boundary(&session.command_buffer, session.command_cursor);
+ } else {
+ session.cursor = next_boundary(&session.text, session.cursor);
+ }
+ }
+
+ fn move_line_start(&self, session: &mut EditSession) {
+ if session.mode == EditorMode::Command {
+ session.command_cursor = 1;
+ } else {
+ session.cursor = line_start(&session.text, session.cursor);
+ }
+ }
+
+ fn move_line_end(&self, session: &mut EditSession) {
+ if session.mode == EditorMode::Command {
+ session.command_cursor = session.command_buffer.len();
+ } else {
+ session.cursor = line_end(&session.text, session.cursor);
+ }
+ }
+
+ fn move_up(&self, session: &mut EditSession) {
+ if session.mode == EditorMode::Command {
+ return;
+ }
+ session.cursor = move_vertical(&session.text, session.cursor, -1);
+ }
+
+ fn move_down(&self, session: &mut EditSession) {
+ if session.mode == EditorMode::Command {
+ return;
+ }
+ session.cursor = move_vertical(&session.text, session.cursor, 1);
+ }
+
+ fn delete_char_under_cursor(&self, session: &mut EditSession) {
+ match session.mode {
+ EditorMode::Command => {
+ if session.command_cursor < session.command_buffer.len() {
+ let end = next_boundary(&session.command_buffer, session.command_cursor);
+ session.command_buffer.drain(session.command_cursor..end);
+ }
+ }
+ _ => {
+ if session.cursor < session.text.len() {
+ let end = next_boundary(&session.text, session.cursor);
+ session.text.drain(session.cursor..end);
+ }
+ }
+ }
+ }
+
+ fn delete_current_line(&mut self, session: &mut EditSession) {
+ let (line_start_idx, line_end_idx, delete_start_idx) =
+ current_line_delete_range(&session.text, session.cursor);
+ self.yank_buffer.text = session.text[line_start_idx..line_end_idx].to_string();
+ self.yank_buffer.linewise = true;
+ session.text.drain(delete_start_idx..line_end_idx);
+ session.cursor = delete_start_idx.min(session.text.len());
+ }
+
+ fn yank_current_line(&mut self, session: &mut EditSession) {
+ let (line_start_idx, line_end_idx, _) =
+ current_line_delete_range(&session.text, session.cursor);
+ self.yank_buffer.text = session.text[line_start_idx..line_end_idx].to_string();
+ self.yank_buffer.linewise = true;
+ }
+
+ fn paste_after(&mut self, session: &mut EditSession) {
+ if self.yank_buffer.text.is_empty() {
+ return;
+ }
+
+ if self.yank_buffer.linewise {
+ let line_end_idx = line_end(&session.text, session.cursor);
+ let insert_at = if line_end_idx < session.text.len() {
+ line_end_idx + 1
+ } else {
+ session.text.len()
+ };
+ let mut insertion = self.yank_buffer.text.clone();
+ if insert_at == session.text.len()
+ && !session.text.is_empty()
+ && !session.text.ends_with('\n')
+ {
+ insertion.insert(0, '\n');
+ }
+ if insert_at < session.text.len() && !insertion.ends_with('\n') {
+ insertion.push('\n');
+ }
+ session.text.insert_str(insert_at, &insertion);
+ session.cursor = if insertion.starts_with('\n') {
+ insert_at + 1
+ } else {
+ insert_at
+ };
+ return;
+ }
+
+ let insert_at = next_boundary(&session.text, session.cursor);
+ session.text.insert_str(insert_at, &self.yank_buffer.text);
+ session.cursor = insert_at + self.yank_buffer.text.len();
+ }
+
+ fn complete_slash_command(&mut self, session: &mut EditSession) {
+ if session.mode == EditorMode::Command {
+ self.completion_state = None;
+ return;
+ }
+ if let Some(state) = self
+ .completion_state
+ .as_mut()
+ .filter(|_| session.cursor == session.text.len())
+ .filter(|state| {
+ state
+ .matches
+ .iter()
+ .any(|candidate| candidate == &session.text)
+ })
+ {
+ let candidate = state.matches[state.next_index % state.matches.len()].clone();
+ state.next_index += 1;
+ session.text.replace_range(..session.cursor, &candidate);
+ session.cursor = candidate.len();
+ return;
+ }
+ let Some(prefix) = slash_command_prefix(&session.text, session.cursor) else {
+ self.completion_state = None;
+ return;
+ };
+ let matches = self
+ .completions
+ .iter()
+ .filter(|candidate| candidate.starts_with(prefix) && candidate.as_str() != prefix)
+ .cloned()
+ .collect::>();
+ if matches.is_empty() {
+ self.completion_state = None;
+ return;
+ }
+
+ let candidate = if let Some(state) = self
+ .completion_state
+ .as_mut()
+ .filter(|state| state.prefix == prefix && state.matches == matches)
+ {
+ let index = state.next_index % state.matches.len();
+ state.next_index += 1;
+ state.matches[index].clone()
+ } else {
+ let candidate = matches[0].clone();
+ self.completion_state = Some(CompletionState {
+ prefix: prefix.to_string(),
+ matches,
+ next_index: 1,
+ });
+ candidate
+ };
+
+ session.text.replace_range(..session.cursor, &candidate);
+ session.cursor = candidate.len();
+ }
+
+ fn history_up(&self, session: &mut EditSession) {
+ if session.mode == EditorMode::Command || self.history.is_empty() {
+ return;
+ }
+
+ let next_index = match session.history_index {
+ Some(index) => index.saturating_sub(1),
+ None => {
+ session.history_backup = Some(session.text.clone());
+ self.history.len() - 1
+ }
+ };
+
+ session.history_index = Some(next_index);
+ session.set_text_from_history(self.history[next_index].clone());
+ }
+
+ fn history_down(&self, session: &mut EditSession) {
+ if session.mode == EditorMode::Command {
+ return;
+ }
+
+ let Some(index) = session.history_index else {
+ return;
+ };
+
+ if index + 1 < self.history.len() {
+ let next_index = index + 1;
+ session.history_index = Some(next_index);
+ session.set_text_from_history(self.history[next_index].clone());
+ return;
+ }
+
+ session.history_index = None;
+ let restored = session.history_backup.take().unwrap_or_default();
+ session.set_text_from_history(restored);
+ if self.vim_enabled {
+ session.enter_insert_mode();
+ } else {
+ session.mode = EditorMode::Plain;
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum Submission {
+ Submit,
+ ToggleVim,
+}
+
+struct RawModeGuard;
+
+impl RawModeGuard {
+ fn new() -> io::Result {
+ terminal::enable_raw_mode().map_err(io::Error::other)?;
+ Ok(Self)
+ }
+}
+
+impl Drop for RawModeGuard {
+ fn drop(&mut self) {
+ let _ = terminal::disable_raw_mode();
+ }
+}
+
+fn previous_boundary(text: &str, cursor: usize) -> usize {
+ if cursor == 0 {
+ return 0;
+ }
+
+ text[..cursor]
+ .char_indices()
+ .next_back()
+ .map_or(0, |(index, _)| index)
+}
+
+fn previous_command_boundary(text: &str, cursor: usize) -> usize {
+ previous_boundary(text, cursor).max(1)
+}
+
+fn next_boundary(text: &str, cursor: usize) -> usize {
+ if cursor >= text.len() {
+ return text.len();
+ }
+
+ text[cursor..]
+ .chars()
+ .next()
+ .map_or(text.len(), |ch| cursor + ch.len_utf8())
+}
+
+fn remove_previous_char(text: &mut String, cursor: &mut usize) {
+ if *cursor == 0 {
+ return;
+ }
+
+ let start = previous_boundary(text, *cursor);
+ text.drain(start..*cursor);
+ *cursor = start;
+}
+
+fn line_start(text: &str, cursor: usize) -> usize {
+ text[..cursor].rfind('\n').map_or(0, |index| index + 1)
+}
+
+fn line_end(text: &str, cursor: usize) -> usize {
+ text[cursor..]
+ .find('\n')
+ .map_or(text.len(), |index| cursor + index)
+}
+
+fn move_vertical(text: &str, cursor: usize, delta: isize) -> usize {
+ let starts = line_starts(text);
+ let current_row = text[..cursor].bytes().filter(|byte| *byte == b'\n').count();
+ let current_start = starts[current_row];
+ let current_col = text[current_start..cursor].chars().count();
+
+ let max_row = starts.len().saturating_sub(1) as isize;
+ let target_row = (current_row as isize + delta).clamp(0, max_row) as usize;
+ if target_row == current_row {
+ return cursor;
+ }
+
+ let target_start = starts[target_row];
+ let target_end = if target_row + 1 < starts.len() {
+ starts[target_row + 1] - 1
+ } else {
+ text.len()
+ };
+ byte_index_for_char_column(&text[target_start..target_end], current_col) + target_start
+}
+
+fn line_starts(text: &str) -> Vec {
+ let mut starts = vec![0];
+ for (index, ch) in text.char_indices() {
+ if ch == '\n' {
+ starts.push(index + 1);
+ }
+ }
+ starts
+}
+
+fn byte_index_for_char_column(text: &str, column: usize) -> usize {
+ let mut current = 0;
+ for (index, _) in text.char_indices() {
+ if current == column {
+ return index;
+ }
+ current += 1;
+ }
+ text.len()
+}
+
+fn current_line_delete_range(text: &str, cursor: usize) -> (usize, usize, usize) {
+ let line_start_idx = line_start(text, cursor);
+ let line_end_core = line_end(text, cursor);
+ let line_end_idx = if line_end_core < text.len() {
+ line_end_core + 1
+ } else {
+ line_end_core
+ };
+ let delete_start_idx = if line_end_idx == text.len() && line_start_idx > 0 {
+ line_start_idx - 1
+ } else {
+ line_start_idx
+ };
+ (line_start_idx, line_end_idx, delete_start_idx)
+}
+
+fn selection_bounds(text: &str, anchor: usize, cursor: usize) -> Option<(usize, usize)> {
+ if text.is_empty() {
+ return None;
+ }
+
+ if cursor >= anchor {
+ let end = next_boundary(text, cursor);
+ Some((anchor.min(text.len()), end.min(text.len())))
+ } else {
+ let end = next_boundary(text, anchor);
+ Some((cursor.min(text.len()), end.min(text.len())))
+ }
+}
+
+fn render_selected_text(text: &str, start: usize, end: usize) -> String {
+ let mut rendered = String::new();
+ let mut in_selection = false;
+
+ for (index, ch) in text.char_indices() {
+ if !in_selection && index == start {
+ rendered.push_str("\x1b[7m");
+ in_selection = true;
+ }
+ if in_selection && index == end {
+ rendered.push_str("\x1b[0m");
+ in_selection = false;
+ }
+ rendered.push(ch);
+ }
+
+ if in_selection {
+ rendered.push_str("\x1b[0m");
+ }
+
+ rendered
+}
+
+fn slash_command_prefix(line: &str, pos: usize) -> Option<&str> {
+ if pos != line.len() {
+ return None;
+ }
+
+ let prefix = &line[..pos];
+ if prefix.contains(char::is_whitespace) || !prefix.starts_with('/') {
+ return None;
+ }
+
+ Some(prefix)
+}
+
+fn to_u16(value: usize) -> io::Result {
+ u16::try_from(value).map_err(|_| {
+ io::Error::new(
+ io::ErrorKind::InvalidInput,
+ "terminal position overflowed u16",
+ )
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{
+ selection_bounds, slash_command_prefix, EditSession, EditorMode, KeyAction, LineEditor,
+ };
+ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+
+ #[test]
+ fn extracts_only_terminal_slash_command_prefixes() {
+ // given
+ let complete_prefix = slash_command_prefix("/he", 3);
+ let whitespace_prefix = slash_command_prefix("/help me", 5);
+ let plain_text_prefix = slash_command_prefix("hello", 5);
+ let mid_buffer_prefix = slash_command_prefix("/help", 2);
+
+ // when
+ let result = (
+ complete_prefix,
+ whitespace_prefix,
+ plain_text_prefix,
+ mid_buffer_prefix,
+ );
+
+ // then
+ assert_eq!(result, (Some("/he"), None, None, None));
+ }
+
+ #[test]
+ fn toggle_submission_flips_vim_mode() {
+ // given
+ let mut editor = LineEditor::new("> ", vec!["/help".to_string(), "/vim".to_string()]);
+
+ // when
+ let first = editor.handle_submission("/vim");
+ editor.vim_enabled = true;
+ let second = editor.handle_submission("/vim");
+
+ // then
+ assert!(matches!(first, super::Submission::ToggleVim));
+ assert!(matches!(second, super::Submission::ToggleVim));
+ }
+
+ #[test]
+ fn normal_mode_supports_motion_and_insert_transition() {
+ // given
+ let mut editor = LineEditor::new("> ", vec![]);
+ editor.vim_enabled = true;
+ let mut session = EditSession::new(true);
+ session.text = "hello".to_string();
+ session.cursor = session.text.len();
+ let _ = editor.handle_escape(&mut session);
+
+ // when
+ editor.handle_char(&mut session, 'h');
+ editor.handle_char(&mut session, 'i');
+ editor.handle_char(&mut session, '!');
+
+ // then
+ assert_eq!(session.mode, EditorMode::Insert);
+ assert_eq!(session.text, "hel!lo");
+ }
+
+ #[test]
+ fn yy_and_p_paste_yanked_line_after_current_line() {
+ // given
+ let mut editor = LineEditor::new("> ", vec![]);
+ editor.vim_enabled = true;
+ let mut session = EditSession::new(true);
+ session.text = "alpha\nbeta\ngamma".to_string();
+ session.cursor = 0;
+ let _ = editor.handle_escape(&mut session);
+
+ // when
+ editor.handle_char(&mut session, 'y');
+ editor.handle_char(&mut session, 'y');
+ editor.handle_char(&mut session, 'p');
+
+ // then
+ assert_eq!(session.text, "alpha\nalpha\nbeta\ngamma");
+ }
+
+ #[test]
+ fn dd_and_p_paste_deleted_line_after_current_line() {
+ // given
+ let mut editor = LineEditor::new("> ", vec![]);
+ editor.vim_enabled = true;
+ let mut session = EditSession::new(true);
+ session.text = "alpha\nbeta\ngamma".to_string();
+ session.cursor = 0;
+ let _ = editor.handle_escape(&mut session);
+
+ // when
+ editor.handle_char(&mut session, 'j');
+ editor.handle_char(&mut session, 'd');
+ editor.handle_char(&mut session, 'd');
+ editor.handle_char(&mut session, 'p');
+
+ // then
+ assert_eq!(session.text, "alpha\ngamma\nbeta\n");
+ }
+
+ #[test]
+ fn visual_mode_tracks_selection_with_motions() {
+ // given
+ let mut editor = LineEditor::new("> ", vec![]);
+ editor.vim_enabled = true;
+ let mut session = EditSession::new(true);
+ session.text = "alpha\nbeta".to_string();
+ session.cursor = 0;
+ let _ = editor.handle_escape(&mut session);
+
+ // when
+ editor.handle_char(&mut session, 'v');
+ editor.handle_char(&mut session, 'j');
+ editor.handle_char(&mut session, 'l');
+
+ // then
+ assert_eq!(session.mode, EditorMode::Visual);
+ assert_eq!(
+ selection_bounds(
+ &session.text,
+ session.visual_anchor.unwrap_or(0),
+ session.cursor
+ ),
+ Some((0, 8))
+ );
+ }
+
+ #[test]
+ fn command_mode_submits_colon_prefixed_input() {
+ // given
+ let mut editor = LineEditor::new("> ", vec![]);
+ editor.vim_enabled = true;
+ let mut session = EditSession::new(true);
+ session.text = "draft".to_string();
+ session.cursor = session.text.len();
+ let _ = editor.handle_escape(&mut session);
+
+ // when
+ editor.handle_char(&mut session, ':');
+ editor.handle_char(&mut session, 'q');
+ editor.handle_char(&mut session, '!');
+ let action = editor.submit_or_toggle(&session);
+
+ // then
+ assert_eq!(session.mode, EditorMode::Command);
+ assert_eq!(session.command_buffer, ":q!");
+ assert!(matches!(action, KeyAction::Submit(line) if line == ":q!"));
+ }
+
+ #[test]
+ fn push_history_ignores_blank_entries() {
+ // given
+ let mut editor = LineEditor::new("> ", vec!["/help".to_string()]);
+
+ // when
+ editor.push_history(" ");
+ editor.push_history("/help");
+
+ // then
+ assert_eq!(editor.history, vec!["/help".to_string()]);
+ }
+
+ #[test]
+ fn tab_completes_matching_slash_commands() {
+ // given
+ let mut editor = LineEditor::new("> ", vec!["/help".to_string(), "/hello".to_string()]);
+ let mut session = EditSession::new(false);
+ session.text = "/he".to_string();
+ session.cursor = session.text.len();
+
+ // when
+ editor.complete_slash_command(&mut session);
+
+ // then
+ assert_eq!(session.text, "/help");
+ assert_eq!(session.cursor, 5);
+ }
+
+ #[test]
+ fn tab_cycles_between_matching_slash_commands() {
+ // given
+ let mut editor = LineEditor::new(
+ "> ",
+ vec!["/permissions".to_string(), "/plugin".to_string()],
+ );
+ let mut session = EditSession::new(false);
+ session.text = "/p".to_string();
+ session.cursor = session.text.len();
+
+ // when
+ editor.complete_slash_command(&mut session);
+ let first = session.text.clone();
+ session.cursor = session.text.len();
+ editor.complete_slash_command(&mut session);
+ let second = session.text.clone();
+
+ // then
+ assert_eq!(first, "/permissions");
+ assert_eq!(second, "/plugin");
+ }
+
+ #[test]
+ fn ctrl_c_cancels_when_input_exists() {
+ // given
+ let mut editor = LineEditor::new("> ", vec![]);
+ let mut session = EditSession::new(false);
+ session.text = "draft".to_string();
+ session.cursor = session.text.len();
+
+ // when
+ let action = editor.handle_key_event(
+ &mut session,
+ KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
+ );
+
+ // then
+ assert!(matches!(action, KeyAction::Cancel));
+ }
+}
diff --git a/rust/crates/claw-cli/src/main.rs b/rust/crates/claw-cli/src/main.rs
new file mode 100644
index 0000000..2b7d6f1
--- /dev/null
+++ b/rust/crates/claw-cli/src/main.rs
@@ -0,0 +1,5090 @@
+mod init;
+mod input;
+mod render;
+
+use std::collections::BTreeSet;
+use std::env;
+use std::fmt::Write as _;
+use std::fs;
+use std::io::{self, IsTerminal, Read, Write};
+use std::net::TcpListener;
+use std::path::{Path, PathBuf};
+use std::process::Command;
+use std::sync::mpsc::{self, RecvTimeoutError};
+use std::sync::{Arc, Mutex};
+use std::thread;
+use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
+
+use api::{
+ resolve_startup_auth_source, AuthSource, ClawApiClient, ContentBlockDelta, InputContentBlock,
+ InputMessage, MessageRequest, MessageResponse, OutputContentBlock,
+ StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock,
+};
+
+use commands::{
+ handle_agents_slash_command, handle_plugins_slash_command, handle_skills_slash_command,
+ render_slash_command_help, resume_supported_slash_commands, slash_command_specs,
+ suggest_slash_commands, SlashCommand,
+};
+use compat_harness::{extract_manifest, UpstreamPaths};
+use init::initialize_repo;
+use plugins::{PluginManager, PluginManagerConfig};
+use render::{MarkdownStreamState, Spinner, TerminalRenderer};
+use runtime::{
+ clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt,
+ parse_oauth_callback_request_target, save_oauth_credentials, ApiClient, ApiRequest,
+ AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock,
+ ConversationMessage, ConversationRuntime, MessageRole, OAuthAuthorizationRequest, OAuthConfig,
+ OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError,
+ Session, TokenUsage, ToolError, ToolExecutor, UsageTracker,
+};
+use serde_json::json;
+use tools::GlobalToolRegistry;
+
+const DEFAULT_MODEL: &str = "claude-opus-4-6";
+fn max_tokens_for_model(model: &str) -> u32 {
+ if model.contains("opus") {
+ 32_000
+ } else {
+ 64_000
+ }
+}
+const DEFAULT_DATE: &str = "2026-03-31";
+const DEFAULT_OAUTH_CALLBACK_PORT: u16 = 4545;
+const VERSION: &str = env!("CARGO_PKG_VERSION");
+const BUILD_TARGET: Option<&str> = option_env!("TARGET");
+const GIT_SHA: Option<&str> = option_env!("GIT_SHA");
+const INTERNAL_PROGRESS_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(3);
+
+type AllowedToolSet = BTreeSet;
+
+fn main() {
+ if let Err(error) = run() {
+ eprintln!("{}", render_cli_error(&error.to_string()));
+ std::process::exit(1);
+ }
+}
+
+fn render_cli_error(problem: &str) -> String {
+ let mut lines = vec!["Error".to_string()];
+ for (index, line) in problem.lines().enumerate() {
+ let label = if index == 0 {
+ " Problem "
+ } else {
+ " "
+ };
+ lines.push(format!("{label}{line}"));
+ }
+ lines.push(" Help claw --help".to_string());
+ lines.join("\n")
+}
+
+fn run() -> Result<(), Box> {
+ let args: Vec = env::args().skip(1).collect();
+ match parse_args(&args)? {
+ CliAction::DumpManifests => dump_manifests(),
+ CliAction::BootstrapPlan => print_bootstrap_plan(),
+ CliAction::Agents { args } => LiveCli::print_agents(args.as_deref())?,
+ CliAction::Skills { args } => LiveCli::print_skills(args.as_deref())?,
+ CliAction::PrintSystemPrompt { cwd, date } => print_system_prompt(cwd, date),
+ CliAction::Version => print_version(),
+ CliAction::ResumeSession {
+ session_path,
+ commands,
+ } => resume_session(&session_path, &commands),
+ CliAction::Prompt {
+ prompt,
+ model,
+ output_format,
+ allowed_tools,
+ permission_mode,
+ } => LiveCli::new(model, true, allowed_tools, permission_mode)?
+ .run_turn_with_output(&prompt, output_format)?,
+ CliAction::Login => run_login()?,
+ CliAction::Logout => run_logout()?,
+ CliAction::Init => run_init()?,
+ CliAction::Repl {
+ model,
+ allowed_tools,
+ permission_mode,
+ } => run_repl(model, allowed_tools, permission_mode)?,
+ CliAction::Help => print_help(),
+ }
+ Ok(())
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+enum CliAction {
+ DumpManifests,
+ BootstrapPlan,
+ Agents {
+ args: Option,
+ },
+ Skills {
+ args: Option,
+ },
+ PrintSystemPrompt {
+ cwd: PathBuf,
+ date: String,
+ },
+ Version,
+ ResumeSession {
+ session_path: PathBuf,
+ commands: Vec,
+ },
+ Prompt {
+ prompt: String,
+ model: String,
+ output_format: CliOutputFormat,
+ allowed_tools: Option,
+ permission_mode: PermissionMode,
+ },
+ Login,
+ Logout,
+ Init,
+ Repl {
+ model: String,
+ allowed_tools: Option,
+ permission_mode: PermissionMode,
+ },
+ // prompt-mode formatting is only supported for non-interactive runs
+ Help,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum CliOutputFormat {
+ Text,
+ Json,
+}
+
+impl CliOutputFormat {
+ fn parse(value: &str) -> Result {
+ match value {
+ "text" => Ok(Self::Text),
+ "json" => Ok(Self::Json),
+ other => Err(format!(
+ "unsupported value for --output-format: {other} (expected text or json)"
+ )),
+ }
+ }
+}
+
+#[allow(clippy::too_many_lines)]
+fn parse_args(args: &[String]) -> Result {
+ let mut model = DEFAULT_MODEL.to_string();
+ let mut output_format = CliOutputFormat::Text;
+ let mut permission_mode = default_permission_mode();
+ let mut wants_version = false;
+ let mut allowed_tool_values = Vec::new();
+ let mut rest = Vec::new();
+ let mut index = 0;
+
+ while index < args.len() {
+ match args[index].as_str() {
+ "--version" | "-V" => {
+ wants_version = true;
+ index += 1;
+ }
+ "--model" => {
+ let value = args
+ .get(index + 1)
+ .ok_or_else(|| "missing value for --model".to_string())?;
+ model = resolve_model_alias(value).to_string();
+ index += 2;
+ }
+ flag if flag.starts_with("--model=") => {
+ model = resolve_model_alias(&flag[8..]).to_string();
+ index += 1;
+ }
+ "--output-format" => {
+ let value = args
+ .get(index + 1)
+ .ok_or_else(|| "missing value for --output-format".to_string())?;
+ output_format = CliOutputFormat::parse(value)?;
+ index += 2;
+ }
+ "--permission-mode" => {
+ let value = args
+ .get(index + 1)
+ .ok_or_else(|| "missing value for --permission-mode".to_string())?;
+ permission_mode = parse_permission_mode_arg(value)?;
+ index += 2;
+ }
+ flag if flag.starts_with("--output-format=") => {
+ output_format = CliOutputFormat::parse(&flag[16..])?;
+ index += 1;
+ }
+ flag if flag.starts_with("--permission-mode=") => {
+ permission_mode = parse_permission_mode_arg(&flag[18..])?;
+ index += 1;
+ }
+ "--dangerously-skip-permissions" => {
+ permission_mode = PermissionMode::DangerFullAccess;
+ index += 1;
+ }
+ "-p" => {
+ // Claw Code compat: -p "prompt" = one-shot prompt
+ let prompt = args[index + 1..].join(" ");
+ if prompt.trim().is_empty() {
+ return Err("-p requires a prompt string".to_string());
+ }
+ return Ok(CliAction::Prompt {
+ prompt,
+ model: resolve_model_alias(&model).to_string(),
+ output_format,
+ allowed_tools: normalize_allowed_tools(&allowed_tool_values)?,
+ permission_mode,
+ });
+ }
+ "--print" => {
+ // Claw Code compat: --print makes output non-interactive
+ output_format = CliOutputFormat::Text;
+ index += 1;
+ }
+ "--allowedTools" | "--allowed-tools" => {
+ let value = args
+ .get(index + 1)
+ .ok_or_else(|| "missing value for --allowedTools".to_string())?;
+ allowed_tool_values.push(value.clone());
+ index += 2;
+ }
+ flag if flag.starts_with("--allowedTools=") => {
+ allowed_tool_values.push(flag[15..].to_string());
+ index += 1;
+ }
+ flag if flag.starts_with("--allowed-tools=") => {
+ allowed_tool_values.push(flag[16..].to_string());
+ index += 1;
+ }
+ other => {
+ rest.push(other.to_string());
+ index += 1;
+ }
+ }
+ }
+
+ if wants_version {
+ return Ok(CliAction::Version);
+ }
+
+ let allowed_tools = normalize_allowed_tools(&allowed_tool_values)?;
+
+ if rest.is_empty() {
+ return Ok(CliAction::Repl {
+ model,
+ allowed_tools,
+ permission_mode,
+ });
+ }
+ if matches!(rest.first().map(String::as_str), Some("--help" | "-h")) {
+ return Ok(CliAction::Help);
+ }
+ if rest.first().map(String::as_str) == Some("--resume") {
+ return parse_resume_args(&rest[1..]);
+ }
+
+ match rest[0].as_str() {
+ "dump-manifests" => Ok(CliAction::DumpManifests),
+ "bootstrap-plan" => Ok(CliAction::BootstrapPlan),
+ "agents" => Ok(CliAction::Agents {
+ args: join_optional_args(&rest[1..]),
+ }),
+ "skills" => Ok(CliAction::Skills {
+ args: join_optional_args(&rest[1..]),
+ }),
+ "system-prompt" => parse_system_prompt_args(&rest[1..]),
+ "login" => Ok(CliAction::Login),
+ "logout" => Ok(CliAction::Logout),
+ "init" => Ok(CliAction::Init),
+ "prompt" => {
+ let prompt = rest[1..].join(" ");
+ if prompt.trim().is_empty() {
+ return Err("prompt subcommand requires a prompt string".to_string());
+ }
+ Ok(CliAction::Prompt {
+ prompt,
+ model,
+ output_format,
+ allowed_tools,
+ permission_mode,
+ })
+ }
+ other if other.starts_with('/') => parse_direct_slash_cli_action(&rest),
+ _other => Ok(CliAction::Prompt {
+ prompt: rest.join(" "),
+ model,
+ output_format,
+ allowed_tools,
+ permission_mode,
+ }),
+ }
+}
+
+fn join_optional_args(args: &[String]) -> Option {
+ let joined = args.join(" ");
+ let trimmed = joined.trim();
+ (!trimmed.is_empty()).then(|| trimmed.to_string())
+}
+
+fn parse_direct_slash_cli_action(rest: &[String]) -> Result {
+ let raw = rest.join(" ");
+ match SlashCommand::parse(&raw) {
+ Some(SlashCommand::Help) => Ok(CliAction::Help),
+ Some(SlashCommand::Agents { args }) => Ok(CliAction::Agents { args }),
+ Some(SlashCommand::Skills { args }) => Ok(CliAction::Skills { args }),
+ Some(command) => Err(format_direct_slash_command_error(
+ match &command {
+ SlashCommand::Unknown(name) => format!("/{name}"),
+ _ => rest[0].clone(),
+ }
+ .as_str(),
+ matches!(command, SlashCommand::Unknown(_)),
+ )),
+ None => Err(format!("unknown subcommand: {}", rest[0])),
+ }
+}
+
+fn format_direct_slash_command_error(command: &str, is_unknown: bool) -> String {
+ let trimmed = command.trim().trim_start_matches('/');
+ let mut lines = vec![
+ "Direct slash command unavailable".to_string(),
+ format!(" Command /{trimmed}"),
+ ];
+ if is_unknown {
+ append_slash_command_suggestions(&mut lines, trimmed);
+ } else {
+ lines.push(" Try Start `claw` to use interactive slash commands".to_string());
+ lines.push(
+ " Tip Resume-safe commands also work with `claw --resume SESSION.json ...`"
+ .to_string(),
+ );
+ }
+ lines.join("\n")
+}
+
+fn resolve_model_alias(model: &str) -> &str {
+ match model {
+ "opus" => "claude-opus-4-6",
+ "sonnet" => "claude-sonnet-4-6",
+ "haiku" => "claude-haiku-4-5-20251213",
+ _ => model,
+ }
+}
+
+fn normalize_allowed_tools(values: &[String]) -> Result, String> {
+ current_tool_registry()?.normalize_allowed_tools(values)
+}
+
+fn current_tool_registry() -> Result {
+ let cwd = env::current_dir().map_err(|error| error.to_string())?;
+ let loader = ConfigLoader::default_for(&cwd);
+ let runtime_config = loader.load().map_err(|error| error.to_string())?;
+ let plugin_manager = build_plugin_manager(&cwd, &loader, &runtime_config);
+ let plugin_tools = plugin_manager
+ .aggregated_tools()
+ .map_err(|error| error.to_string())?;
+ GlobalToolRegistry::with_plugin_tools(plugin_tools)
+}
+
+fn parse_permission_mode_arg(value: &str) -> Result {
+ normalize_permission_mode(value)
+ .ok_or_else(|| {
+ format!(
+ "unsupported permission mode '{value}'. Use read-only, workspace-write, or danger-full-access."
+ )
+ })
+ .map(permission_mode_from_label)
+}
+
+fn permission_mode_from_label(mode: &str) -> PermissionMode {
+ match mode {
+ "read-only" => PermissionMode::ReadOnly,
+ "workspace-write" => PermissionMode::WorkspaceWrite,
+ "danger-full-access" => PermissionMode::DangerFullAccess,
+ other => panic!("unsupported permission mode label: {other}"),
+ }
+}
+
+fn default_permission_mode() -> PermissionMode {
+ env::var("CLAW_PERMISSION_MODE")
+ .ok()
+ .as_deref()
+ .and_then(normalize_permission_mode)
+ .map_or(PermissionMode::DangerFullAccess, permission_mode_from_label)
+}
+
+fn filter_tool_specs(
+ tool_registry: &GlobalToolRegistry,
+ allowed_tools: Option<&AllowedToolSet>,
+) -> Vec {
+ tool_registry.definitions(allowed_tools)
+}
+
+fn parse_system_prompt_args(args: &[String]) -> Result {
+ let mut cwd = env::current_dir().map_err(|error| error.to_string())?;
+ let mut date = DEFAULT_DATE.to_string();
+ let mut index = 0;
+
+ while index < args.len() {
+ match args[index].as_str() {
+ "--cwd" => {
+ let value = args
+ .get(index + 1)
+ .ok_or_else(|| "missing value for --cwd".to_string())?;
+ cwd = PathBuf::from(value);
+ index += 2;
+ }
+ "--date" => {
+ let value = args
+ .get(index + 1)
+ .ok_or_else(|| "missing value for --date".to_string())?;
+ date.clone_from(value);
+ index += 2;
+ }
+ other => return Err(format!("unknown system-prompt option: {other}")),
+ }
+ }
+
+ Ok(CliAction::PrintSystemPrompt { cwd, date })
+}
+
+fn parse_resume_args(args: &[String]) -> Result {
+ let session_path = args
+ .first()
+ .ok_or_else(|| "missing session path for --resume".to_string())
+ .map(PathBuf::from)?;
+ let commands = args[1..].to_vec();
+ if commands
+ .iter()
+ .any(|command| !command.trim_start().starts_with('/'))
+ {
+ return Err("--resume trailing arguments must be slash commands".to_string());
+ }
+ Ok(CliAction::ResumeSession {
+ session_path,
+ commands,
+ })
+}
+
+fn dump_manifests() {
+ let workspace_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
+ let paths = UpstreamPaths::from_workspace_dir(&workspace_dir);
+ match extract_manifest(&paths) {
+ Ok(manifest) => {
+ println!("commands: {}", manifest.commands.entries().len());
+ println!("tools: {}", manifest.tools.entries().len());
+ println!("bootstrap phases: {}", manifest.bootstrap.phases().len());
+ }
+ Err(error) => {
+ eprintln!("failed to extract manifests: {error}");
+ std::process::exit(1);
+ }
+ }
+}
+
+fn print_bootstrap_plan() {
+ for phase in runtime::BootstrapPlan::claw_default().phases() {
+ println!("- {phase:?}");
+ }
+}
+
+fn default_oauth_config() -> OAuthConfig {
+ OAuthConfig {
+ client_id: String::from("9d1c250a-e61b-44d9-88ed-5944d1962f5e"),
+ authorize_url: String::from("https://platform.claw.dev/oauth/authorize"),
+ token_url: String::from("https://platform.claw.dev/v1/oauth/token"),
+ callback_port: None,
+ manual_redirect_url: None,
+ scopes: vec![
+ String::from("user:profile"),
+ String::from("user:inference"),
+ String::from("user:sessions:claw_code"),
+ ],
+ }
+}
+
+fn run_login() -> Result<(), Box> {
+ let cwd = env::current_dir()?;
+ let config = ConfigLoader::default_for(&cwd).load()?;
+ let default_oauth = default_oauth_config();
+ let oauth = config.oauth().unwrap_or(&default_oauth);
+ let callback_port = oauth.callback_port.unwrap_or(DEFAULT_OAUTH_CALLBACK_PORT);
+ let redirect_uri = runtime::loopback_redirect_uri(callback_port);
+ let pkce = generate_pkce_pair()?;
+ let state = generate_state()?;
+ let authorize_url =
+ OAuthAuthorizationRequest::from_config(oauth, redirect_uri.clone(), state.clone(), &pkce)
+ .build_url();
+
+ println!("Starting Claw OAuth login...");
+ println!("Listening for callback on {redirect_uri}");
+ if let Err(error) = open_browser(&authorize_url) {
+ eprintln!("warning: failed to open browser automatically: {error}");
+ println!("Open this URL manually:\n{authorize_url}");
+ }
+
+ let callback = wait_for_oauth_callback(callback_port)?;
+ if let Some(error) = callback.error {
+ let description = callback
+ .error_description
+ .unwrap_or_else(|| "authorization failed".to_string());
+ return Err(io::Error::other(format!("{error}: {description}")).into());
+ }
+ let code = callback.code.ok_or_else(|| {
+ io::Error::new(io::ErrorKind::InvalidData, "callback did not include code")
+ })?;
+ let returned_state = callback.state.ok_or_else(|| {
+ io::Error::new(io::ErrorKind::InvalidData, "callback did not include state")
+ })?;
+ if returned_state != state {
+ return Err(io::Error::new(io::ErrorKind::InvalidData, "oauth state mismatch").into());
+ }
+
+ let client = ClawApiClient::from_auth(AuthSource::None).with_base_url(api::read_base_url());
+ let exchange_request =
+ OAuthTokenExchangeRequest::from_config(oauth, code, state, pkce.verifier, redirect_uri);
+ let runtime = tokio::runtime::Runtime::new()?;
+ let token_set = runtime.block_on(client.exchange_oauth_code(oauth, &exchange_request))?;
+ save_oauth_credentials(&runtime::OAuthTokenSet {
+ access_token: token_set.access_token,
+ refresh_token: token_set.refresh_token,
+ expires_at: token_set.expires_at,
+ scopes: token_set.scopes,
+ })?;
+ println!("Claw OAuth login complete.");
+ Ok(())
+}
+
+fn run_logout() -> Result<(), Box> {
+ clear_oauth_credentials()?;
+ println!("Claw OAuth credentials cleared.");
+ Ok(())
+}
+
+fn open_browser(url: &str) -> io::Result<()> {
+ let commands = if cfg!(target_os = "macos") {
+ vec![("open", vec![url])]
+ } else if cfg!(target_os = "windows") {
+ vec![("cmd", vec!["/C", "start", "", url])]
+ } else {
+ vec![("xdg-open", vec![url])]
+ };
+ for (program, args) in commands {
+ match Command::new(program).args(args).spawn() {
+ Ok(_) => return Ok(()),
+ Err(error) if error.kind() == io::ErrorKind::NotFound => {}
+ Err(error) => return Err(error),
+ }
+ }
+ Err(io::Error::new(
+ io::ErrorKind::NotFound,
+ "no supported browser opener command found",
+ ))
+}
+
+fn wait_for_oauth_callback(
+ port: u16,
+) -> Result> {
+ let listener = TcpListener::bind(("127.0.0.1", port))?;
+ let (mut stream, _) = listener.accept()?;
+ let mut buffer = [0_u8; 4096];
+ let bytes_read = stream.read(&mut buffer)?;
+ let request = String::from_utf8_lossy(&buffer[..bytes_read]);
+ let request_line = request.lines().next().ok_or_else(|| {
+ io::Error::new(io::ErrorKind::InvalidData, "missing callback request line")
+ })?;
+ let target = request_line.split_whitespace().nth(1).ok_or_else(|| {
+ io::Error::new(
+ io::ErrorKind::InvalidData,
+ "missing callback request target",
+ )
+ })?;
+ let callback = parse_oauth_callback_request_target(target)
+ .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
+ let body = if callback.error.is_some() {
+ "Claw OAuth login failed. You can close this window."
+ } else {
+ "Claw OAuth login succeeded. You can close this window."
+ };
+ let response = format!(
+ "HTTP/1.1 200 OK\r\ncontent-type: text/plain; charset=utf-8\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
+ body.len(),
+ body
+ );
+ stream.write_all(response.as_bytes())?;
+ Ok(callback)
+}
+
+fn print_system_prompt(cwd: PathBuf, date: String) {
+ match load_system_prompt(cwd, date, env::consts::OS, "unknown") {
+ Ok(sections) => println!("{}", sections.join("\n\n")),
+ Err(error) => {
+ eprintln!("failed to build system prompt: {error}");
+ std::process::exit(1);
+ }
+ }
+}
+
+fn print_version() {
+ println!("{}", render_version_report());
+}
+
+fn resume_session(session_path: &Path, commands: &[String]) {
+ let session = match Session::load_from_path(session_path) {
+ Ok(session) => session,
+ Err(error) => {
+ eprintln!("failed to restore session: {error}");
+ std::process::exit(1);
+ }
+ };
+
+ if commands.is_empty() {
+ println!(
+ "Restored session from {} ({} messages).",
+ session_path.display(),
+ session.messages.len()
+ );
+ return;
+ }
+
+ let mut session = session;
+ for raw_command in commands {
+ let Some(command) = SlashCommand::parse(raw_command) else {
+ eprintln!("unsupported resumed command: {raw_command}");
+ std::process::exit(2);
+ };
+ match run_resume_command(session_path, &session, &command) {
+ Ok(ResumeCommandOutcome {
+ session: next_session,
+ message,
+ }) => {
+ session = next_session;
+ if let Some(message) = message {
+ println!("{message}");
+ }
+ }
+ Err(error) => {
+ eprintln!("{error}");
+ std::process::exit(2);
+ }
+ }
+ }
+}
+
+#[derive(Debug, Clone)]
+struct ResumeCommandOutcome {
+ session: Session,
+ message: Option,
+}
+
+#[derive(Debug, Clone)]
+struct StatusContext {
+ cwd: PathBuf,
+ session_path: Option,
+ loaded_config_files: usize,
+ discovered_config_files: usize,
+ memory_file_count: usize,
+ project_root: Option,
+ git_branch: Option,
+}
+
+#[derive(Debug, Clone, Copy)]
+struct StatusUsage {
+ message_count: usize,
+ turns: u32,
+ latest: TokenUsage,
+ cumulative: TokenUsage,
+ estimated_tokens: usize,
+}
+
+fn format_model_report(model: &str, message_count: usize, turns: u32) -> String {
+ format!(
+ "Model
+ Current {model}
+ Session {message_count} messages · {turns} turns
+
+Aliases
+ opus claude-opus-4-6
+ sonnet claude-sonnet-4-6
+ haiku claude-haiku-4-5-20251213
+
+Next
+ /model Show the current model
+ /model Switch models for this REPL session"
+ )
+}
+
+fn format_model_switch_report(previous: &str, next: &str, message_count: usize) -> String {
+ format!(
+ "Model updated
+ Previous {previous}
+ Current {next}
+ Preserved {message_count} messages
+ Tip Existing conversation context stayed attached"
+ )
+}
+
+fn format_permissions_report(mode: &str) -> String {
+ let modes = [
+ ("read-only", "Read/search tools only", mode == "read-only"),
+ (
+ "workspace-write",
+ "Edit files inside the workspace",
+ mode == "workspace-write",
+ ),
+ (
+ "danger-full-access",
+ "Unrestricted tool access",
+ mode == "danger-full-access",
+ ),
+ ]
+ .into_iter()
+ .map(|(name, description, is_current)| {
+ let marker = if is_current {
+ "● current"
+ } else {
+ "○ available"
+ };
+ format!(" {name:<18} {marker:<11} {description}")
+ })
+ .collect::>()
+ .join(
+ "
+",
+ );
+
+ let effect = match mode {
+ "read-only" => "Only read/search tools can run automatically",
+ "workspace-write" => "Editing tools can modify files in the workspace",
+ "danger-full-access" => "All tools can run without additional sandbox limits",
+ _ => "Unknown permission mode",
+ };
+
+ format!(
+ "Permissions
+ Active mode {mode}
+ Effect {effect}
+
+Modes
+{modes}
+
+Next
+ /permissions Show the current mode
+ /permissions Switch modes for subsequent tool calls"
+ )
+}
+
+fn format_permissions_switch_report(previous: &str, next: &str) -> String {
+ format!(
+ "Permissions updated
+ Previous mode {previous}
+ Active mode {next}
+ Applies to Subsequent tool calls in this REPL
+ Tip Run /permissions to review all available modes"
+ )
+}
+
+fn format_cost_report(usage: TokenUsage) -> String {
+ format!(
+ "Cost
+ Input tokens {}
+ Output tokens {}
+ Cache create {}
+ Cache read {}
+ Total tokens {}
+
+Next
+ /status See session + workspace context
+ /compact Trim local history if the session is getting large",
+ usage.input_tokens,
+ usage.output_tokens,
+ usage.cache_creation_input_tokens,
+ usage.cache_read_input_tokens,
+ usage.total_tokens(),
+ )
+}
+
+fn format_resume_report(session_path: &str, message_count: usize, turns: u32) -> String {
+ format!(
+ "Session resumed
+ Session file {session_path}
+ History {message_count} messages · {turns} turns
+ Next /status · /diff · /export"
+ )
+}
+
+fn format_compact_report(removed: usize, resulting_messages: usize, skipped: bool) -> String {
+ if skipped {
+ format!(
+ "Compact
+ Result skipped
+ Reason Session is already below the compaction threshold
+ Messages kept {resulting_messages}"
+ )
+ } else {
+ format!(
+ "Compact
+ Result compacted
+ Messages removed {removed}
+ Messages kept {resulting_messages}
+ Tip Use /status to review the trimmed session"
+ )
+ }
+}
+
+fn parse_git_status_metadata(status: Option<&str>) -> (Option, Option) {
+ let Some(status) = status else {
+ return (None, None);
+ };
+ let branch = status.lines().next().and_then(|line| {
+ line.strip_prefix("## ")
+ .map(|line| {
+ line.split(['.', ' '])
+ .next()
+ .unwrap_or_default()
+ .to_string()
+ })
+ .filter(|value| !value.is_empty())
+ });
+ let project_root = find_git_root().ok();
+ (project_root, branch)
+}
+
+fn find_git_root() -> Result> {
+ let output = std::process::Command::new("git")
+ .args(["rev-parse", "--show-toplevel"])
+ .current_dir(env::current_dir()?)
+ .output()?;
+ if !output.status.success() {
+ return Err("not a git repository".into());
+ }
+ let path = String::from_utf8(output.stdout)?.trim().to_string();
+ if path.is_empty() {
+ return Err("empty git root".into());
+ }
+ Ok(PathBuf::from(path))
+}
+
+#[allow(clippy::too_many_lines)]
+fn run_resume_command(
+ session_path: &Path,
+ session: &Session,
+ command: &SlashCommand,
+) -> Result> {
+ match command {
+ SlashCommand::Help => Ok(ResumeCommandOutcome {
+ session: session.clone(),
+ message: Some(render_repl_help()),
+ }),
+ SlashCommand::Compact => {
+ let result = runtime::compact_session(
+ session,
+ CompactionConfig {
+ max_estimated_tokens: 0,
+ ..CompactionConfig::default()
+ },
+ );
+ let removed = result.removed_message_count;
+ let kept = result.compacted_session.messages.len();
+ let skipped = removed == 0;
+ result.compacted_session.save_to_path(session_path)?;
+ Ok(ResumeCommandOutcome {
+ session: result.compacted_session,
+ message: Some(format_compact_report(removed, kept, skipped)),
+ })
+ }
+ SlashCommand::Clear { confirm } => {
+ if !confirm {
+ return Ok(ResumeCommandOutcome {
+ session: session.clone(),
+ message: Some(
+ "clear: confirmation required; rerun with /clear --confirm".to_string(),
+ ),
+ });
+ }
+ let cleared = Session::new();
+ cleared.save_to_path(session_path)?;
+ Ok(ResumeCommandOutcome {
+ session: cleared,
+ message: Some(format!(
+ "Cleared resumed session file {}.",
+ session_path.display()
+ )),
+ })
+ }
+ SlashCommand::Status => {
+ let tracker = UsageTracker::from_session(session);
+ let usage = tracker.cumulative_usage();
+ Ok(ResumeCommandOutcome {
+ session: session.clone(),
+ message: Some(format_status_report(
+ "restored-session",
+ StatusUsage {
+ message_count: session.messages.len(),
+ turns: tracker.turns(),
+ latest: tracker.current_turn_usage(),
+ cumulative: usage,
+ estimated_tokens: 0,
+ },
+ default_permission_mode().as_str(),
+ &status_context(Some(session_path))?,
+ )),
+ })
+ }
+ SlashCommand::Cost => {
+ let usage = UsageTracker::from_session(session).cumulative_usage();
+ Ok(ResumeCommandOutcome {
+ session: session.clone(),
+ message: Some(format_cost_report(usage)),
+ })
+ }
+ SlashCommand::Config { section } => Ok(ResumeCommandOutcome {
+ session: session.clone(),
+ message: Some(render_config_report(section.as_deref())?),
+ }),
+ SlashCommand::Memory => Ok(ResumeCommandOutcome {
+ session: session.clone(),
+ message: Some(render_memory_report()?),
+ }),
+ SlashCommand::Init => Ok(ResumeCommandOutcome {
+ session: session.clone(),
+ message: Some(init_claw_md()?),
+ }),
+ SlashCommand::Diff => Ok(ResumeCommandOutcome {
+ session: session.clone(),
+ message: Some(render_diff_report()?),
+ }),
+ SlashCommand::Version => Ok(ResumeCommandOutcome {
+ session: session.clone(),
+ message: Some(render_version_report()),
+ }),
+ SlashCommand::Export { path } => {
+ let export_path = resolve_export_path(path.as_deref(), session)?;
+ fs::write(&export_path, render_export_text(session))?;
+ Ok(ResumeCommandOutcome {
+ session: session.clone(),
+ message: Some(format!(
+ "Export\n Result wrote transcript\n File {}\n Messages {}",
+ export_path.display(),
+ session.messages.len(),
+ )),
+ })
+ }
+ SlashCommand::Agents { args } => {
+ let cwd = env::current_dir()?;
+ Ok(ResumeCommandOutcome {
+ session: session.clone(),
+ message: Some(handle_agents_slash_command(args.as_deref(), &cwd)?),
+ })
+ }
+ SlashCommand::Skills { args } => {
+ let cwd = env::current_dir()?;
+ Ok(ResumeCommandOutcome {
+ session: session.clone(),
+ message: Some(handle_skills_slash_command(args.as_deref(), &cwd)?),
+ })
+ }
+ SlashCommand::Bughunter { .. }
+ | SlashCommand::Branch { .. }
+ | SlashCommand::Worktree { .. }
+ | SlashCommand::CommitPushPr { .. }
+ | SlashCommand::Commit
+ | SlashCommand::Pr { .. }
+ | SlashCommand::Issue { .. }
+ | SlashCommand::Ultraplan { .. }
+ | SlashCommand::Teleport { .. }
+ | SlashCommand::DebugToolCall
+ | SlashCommand::Resume { .. }
+ | SlashCommand::Model { .. }
+ | SlashCommand::Permissions { .. }
+ | SlashCommand::Session { .. }
+ | SlashCommand::Plugins { .. }
+ | SlashCommand::Unknown(_) => Err("unsupported resumed slash command".into()),
+ }
+}
+
+fn run_repl(
+ model: String,
+ allowed_tools: Option,
+ permission_mode: PermissionMode,
+) -> Result<(), Box> {
+ let mut cli = LiveCli::new(model, true, allowed_tools, permission_mode)?;
+ let mut editor = input::LineEditor::new("> ", slash_command_completion_candidates());
+ println!("{}", cli.startup_banner());
+
+ loop {
+ match editor.read_line()? {
+ input::ReadOutcome::Submit(input) => {
+ let trimmed = input.trim();
+ if trimmed.is_empty() {
+ continue;
+ }
+ if matches!(trimmed, "/exit" | "/quit") {
+ cli.persist_session()?;
+ break;
+ }
+ if let Some(command) = SlashCommand::parse(trimmed) {
+ if cli.handle_repl_command(command)? {
+ cli.persist_session()?;
+ }
+ continue;
+ }
+ editor.push_history(&input);
+ cli.run_turn(&input)?;
+ }
+ input::ReadOutcome::Cancel => {}
+ input::ReadOutcome::Exit => {
+ cli.persist_session()?;
+ break;
+ }
+ }
+ }
+
+ Ok(())
+}
+
+#[derive(Debug, Clone)]
+struct SessionHandle {
+ id: String,
+ path: PathBuf,
+}
+
+#[derive(Debug, Clone)]
+struct ManagedSessionSummary {
+ id: String,
+ path: PathBuf,
+ modified_epoch_secs: u64,
+ message_count: usize,
+}
+
+struct LiveCli {
+ model: String,
+ allowed_tools: Option,
+ permission_mode: PermissionMode,
+ system_prompt: Vec,
+ runtime: ConversationRuntime,
+ session: SessionHandle,
+}
+
+impl LiveCli {
+ fn new(
+ model: String,
+ enable_tools: bool,
+ allowed_tools: Option,
+ permission_mode: PermissionMode,
+ ) -> Result> {
+ let system_prompt = build_system_prompt()?;
+ let session = create_managed_session_handle()?;
+ let runtime = build_runtime(
+ Session::new(),
+ model.clone(),
+ system_prompt.clone(),
+ enable_tools,
+ true,
+ allowed_tools.clone(),
+ permission_mode,
+ None,
+ )?;
+ let cli = Self {
+ model,
+ allowed_tools,
+ permission_mode,
+ system_prompt,
+ runtime,
+ session,
+ };
+ cli.persist_session()?;
+ Ok(cli)
+ }
+
+ fn startup_banner(&self) -> String {
+ let color = io::stdout().is_terminal();
+ let cwd = env::current_dir().ok();
+ let cwd_display = cwd.as_ref().map_or_else(
+ || "".to_string(),
+ |path| path.display().to_string(),
+ );
+ let workspace_name = cwd
+ .as_ref()
+ .and_then(|path| path.file_name())
+ .and_then(|name| name.to_str())
+ .unwrap_or("workspace");
+ let git_branch = status_context(Some(&self.session.path))
+ .ok()
+ .and_then(|context| context.git_branch);
+ let workspace_summary = git_branch.as_deref().map_or_else(
+ || workspace_name.to_string(),
+ |branch| format!("{workspace_name} · {branch}"),
+ );
+ let has_claw_md = cwd
+ .as_ref()
+ .is_some_and(|path| path.join("CLAW.md").is_file());
+ let mut lines = vec![
+ format!(
+ "{} {}",
+ if color {
+ "\x1b[1;38;5;45m🦞 Claw Code\x1b[0m"
+ } else {
+ "Claw Code"
+ },
+ if color {
+ "\x1b[2m· ready\x1b[0m"
+ } else {
+ "· ready"
+ }
+ ),
+ format!(" Workspace {workspace_summary}"),
+ format!(" Directory {cwd_display}"),
+ format!(" Model {}", self.model),
+ format!(" Permissions {}", self.permission_mode.as_str()),
+ format!(" Session {}", self.session.id),
+ format!(
+ " Quick start {}",
+ if has_claw_md {
+ "/help · /status · ask for a task"
+ } else {
+ "/init · /help · /status"
+ }
+ ),
+ " Editor Tab completes slash commands · /vim toggles modal editing"
+ .to_string(),
+ " Multiline Shift+Enter or Ctrl+J inserts a newline".to_string(),
+ ];
+ if !has_claw_md {
+ lines.push(
+ " First run /init scaffolds CLAW.md, .claw.json, and local session files"
+ .to_string(),
+ );
+ }
+ lines.join("\n")
+ }
+
+ fn run_turn(&mut self, input: &str) -> Result<(), Box> {
+ let mut spinner = Spinner::new();
+ let mut stdout = io::stdout();
+ spinner.tick(
+ "🦀 Thinking...",
+ TerminalRenderer::new().color_theme(),
+ &mut stdout,
+ )?;
+ let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
+ let result = self.runtime.run_turn(input, Some(&mut permission_prompter));
+ match result {
+ Ok(_) => {
+ spinner.finish(
+ "✨ Done",
+ TerminalRenderer::new().color_theme(),
+ &mut stdout,
+ )?;
+ println!();
+ self.persist_session()?;
+ Ok(())
+ }
+ Err(error) => {
+ spinner.fail(
+ "❌ Request failed",
+ TerminalRenderer::new().color_theme(),
+ &mut stdout,
+ )?;
+ Err(Box::new(error))
+ }
+ }
+ }
+
+ fn run_turn_with_output(
+ &mut self,
+ input: &str,
+ output_format: CliOutputFormat,
+ ) -> Result<(), Box> {
+ match output_format {
+ CliOutputFormat::Text => self.run_turn(input),
+ CliOutputFormat::Json => self.run_prompt_json(input),
+ }
+ }
+
+ fn run_prompt_json(&mut self, input: &str) -> Result<(), Box> {
+ let session = self.runtime.session().clone();
+ let mut runtime = build_runtime(
+ session,
+ self.model.clone(),
+ self.system_prompt.clone(),
+ true,
+ false,
+ self.allowed_tools.clone(),
+ self.permission_mode,
+ None,
+ )?;
+ let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
+ let summary = runtime.run_turn(input, Some(&mut permission_prompter))?;
+ self.runtime = runtime;
+ self.persist_session()?;
+ println!(
+ "{}",
+ json!({
+ "message": final_assistant_text(&summary),
+ "model": self.model,
+ "iterations": summary.iterations,
+ "tool_uses": collect_tool_uses(&summary),
+ "tool_results": collect_tool_results(&summary),
+ "usage": {
+ "input_tokens": summary.usage.input_tokens,
+ "output_tokens": summary.usage.output_tokens,
+ "cache_creation_input_tokens": summary.usage.cache_creation_input_tokens,
+ "cache_read_input_tokens": summary.usage.cache_read_input_tokens,
+ }
+ })
+ );
+ Ok(())
+ }
+
+ fn handle_repl_command(
+ &mut self,
+ command: SlashCommand,
+ ) -> Result> {
+ Ok(match command {
+ SlashCommand::Help => {
+ println!("{}", render_repl_help());
+ false
+ }
+ SlashCommand::Status => {
+ self.print_status();
+ false
+ }
+ SlashCommand::Bughunter { scope } => {
+ self.run_bughunter(scope.as_deref())?;
+ false
+ }
+ SlashCommand::Commit => {
+ self.run_commit()?;
+ true
+ }
+ SlashCommand::Pr { context } => {
+ self.run_pr(context.as_deref())?;
+ false
+ }
+ SlashCommand::Issue { context } => {
+ self.run_issue(context.as_deref())?;
+ false
+ }
+ SlashCommand::Ultraplan { task } => {
+ self.run_ultraplan(task.as_deref())?;
+ false
+ }
+ SlashCommand::Teleport { target } => {
+ self.run_teleport(target.as_deref())?;
+ false
+ }
+ SlashCommand::DebugToolCall => {
+ self.run_debug_tool_call()?;
+ false
+ }
+ SlashCommand::Compact => {
+ self.compact()?;
+ false
+ }
+ SlashCommand::Model { model } => self.set_model(model)?,
+ SlashCommand::Permissions { mode } => self.set_permissions(mode)?,
+ SlashCommand::Clear { confirm } => self.clear_session(confirm)?,
+ SlashCommand::Cost => {
+ self.print_cost();
+ false
+ }
+ SlashCommand::Resume { session_path } => self.resume_session(session_path)?,
+ SlashCommand::Config { section } => {
+ Self::print_config(section.as_deref())?;
+ false
+ }
+ SlashCommand::Memory => {
+ Self::print_memory()?;
+ false
+ }
+ SlashCommand::Init => {
+ run_init()?;
+ false
+ }
+ SlashCommand::Diff => {
+ Self::print_diff()?;
+ false
+ }
+ SlashCommand::Version => {
+ Self::print_version();
+ false
+ }
+ SlashCommand::Export { path } => {
+ self.export_session(path.as_deref())?;
+ false
+ }
+ SlashCommand::Session { action, target } => {
+ self.handle_session_command(action.as_deref(), target.as_deref())?
+ }
+ SlashCommand::Plugins { action, target } => {
+ self.handle_plugins_command(action.as_deref(), target.as_deref())?
+ }
+ SlashCommand::Agents { args } => {
+ Self::print_agents(args.as_deref())?;
+ false
+ }
+ SlashCommand::Skills { args } => {
+ Self::print_skills(args.as_deref())?;
+ false
+ }
+ SlashCommand::Branch { .. } => {
+ eprintln!(
+ "{}",
+ render_mode_unavailable("branch", "git branch commands")
+ );
+ false
+ }
+ SlashCommand::Worktree { .. } => {
+ eprintln!(
+ "{}",
+ render_mode_unavailable("worktree", "git worktree commands")
+ );
+ false
+ }
+ SlashCommand::CommitPushPr { .. } => {
+ eprintln!(
+ "{}",
+ render_mode_unavailable("commit-push-pr", "commit + push + PR automation")
+ );
+ false
+ }
+ SlashCommand::Unknown(name) => {
+ eprintln!("{}", render_unknown_repl_command(&name));
+ false
+ }
+ })
+ }
+
+ fn persist_session(&self) -> Result<(), Box> {
+ self.runtime.session().save_to_path(&self.session.path)?;
+ Ok(())
+ }
+
+ fn print_status(&self) {
+ let cumulative = self.runtime.usage().cumulative_usage();
+ let latest = self.runtime.usage().current_turn_usage();
+ println!(
+ "{}",
+ format_status_report(
+ &self.model,
+ StatusUsage {
+ message_count: self.runtime.session().messages.len(),
+ turns: self.runtime.usage().turns(),
+ latest,
+ cumulative,
+ estimated_tokens: self.runtime.estimated_tokens(),
+ },
+ self.permission_mode.as_str(),
+ &status_context(Some(&self.session.path)).expect("status context should load"),
+ )
+ );
+ }
+
+ fn set_model(&mut self, model: Option) -> Result> {
+ let Some(model) = model else {
+ println!(
+ "{}",
+ format_model_report(
+ &self.model,
+ self.runtime.session().messages.len(),
+ self.runtime.usage().turns(),
+ )
+ );
+ return Ok(false);
+ };
+
+ let model = resolve_model_alias(&model).to_string();
+
+ if model == self.model {
+ println!(
+ "{}",
+ format_model_report(
+ &self.model,
+ self.runtime.session().messages.len(),
+ self.runtime.usage().turns(),
+ )
+ );
+ return Ok(false);
+ }
+
+ let previous = self.model.clone();
+ let session = self.runtime.session().clone();
+ let message_count = session.messages.len();
+ self.runtime = build_runtime(
+ session,
+ model.clone(),
+ self.system_prompt.clone(),
+ true,
+ true,
+ self.allowed_tools.clone(),
+ self.permission_mode,
+ None,
+ )?;
+ self.model.clone_from(&model);
+ println!(
+ "{}",
+ format_model_switch_report(&previous, &model, message_count)
+ );
+ Ok(true)
+ }
+
+ fn set_permissions(
+ &mut self,
+ mode: Option,
+ ) -> Result> {
+ let Some(mode) = mode else {
+ println!(
+ "{}",
+ format_permissions_report(self.permission_mode.as_str())
+ );
+ return Ok(false);
+ };
+
+ let normalized = normalize_permission_mode(&mode).ok_or_else(|| {
+ format!(
+ "unsupported permission mode '{mode}'. Use read-only, workspace-write, or danger-full-access."
+ )
+ })?;
+
+ if normalized == self.permission_mode.as_str() {
+ println!("{}", format_permissions_report(normalized));
+ return Ok(false);
+ }
+
+ let previous = self.permission_mode.as_str().to_string();
+ let session = self.runtime.session().clone();
+ self.permission_mode = permission_mode_from_label(normalized);
+ self.runtime = build_runtime(
+ session,
+ self.model.clone(),
+ self.system_prompt.clone(),
+ true,
+ true,
+ self.allowed_tools.clone(),
+ self.permission_mode,
+ None,
+ )?;
+ println!(
+ "{}",
+ format_permissions_switch_report(&previous, normalized)
+ );
+ Ok(true)
+ }
+
+ fn clear_session(&mut self, confirm: bool) -> Result