57 Commits

Author SHA1 Message Date
YeonGyu-Kim
f1765edb51 Merge remote-tracking branch 'origin/rcc/plugins' into integration/dori-cleanroom
# Conflicts:
#	rust/crates/commands/src/lib.rs
#	rust/crates/rusty-claude-cli/src/main.rs
2026-04-01 19:13:53 +09:00
YeonGyu-Kim
331c9dbb9d fix: add env_lock guard to git discovery tests 2026-04-01 19:02:12 +09:00
YeonGyu-Kim
8ca53dec0d fix: resolve all post-merge compile errors
- Fix unresolved imports (auto_compaction, AutoCompactionEvent)
- Add Thinking/RedactedThinking match arms
- Fix workspace.dependencies serde_json
- Fix enum exhaustiveness in OutputContentBlock matches
- cargo check --workspace passes
2026-04-01 18:59:55 +09:00
YeonGyu-Kim
5b997e2de2 fix: update prompt tests for post-plugins-merge format 2026-04-01 18:52:23 +09:00
YeonGyu-Kim
409073c10c fix: post-plugins-merge cleanroom fixes and workspace deps
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-01 18:48:39 +09:00
YeonGyu-Kim
bb4d2f364a Merge remote-tracking branch 'origin/rcc/plugins' into integration/dori-cleanroom
# Conflicts:
#	rust/Cargo.lock
#	rust/README.md
#	rust/crates/api/src/client.rs
#	rust/crates/api/src/lib.rs
#	rust/crates/api/src/sse.rs
#	rust/crates/api/src/types.rs
#	rust/crates/api/tests/client_integration.rs
#	rust/crates/commands/Cargo.toml
#	rust/crates/commands/src/lib.rs
#	rust/crates/compat-harness/src/lib.rs
#	rust/crates/runtime/Cargo.toml
#	rust/crates/runtime/src/bootstrap.rs
#	rust/crates/runtime/src/compact.rs
#	rust/crates/runtime/src/config.rs
#	rust/crates/runtime/src/conversation.rs
#	rust/crates/runtime/src/hooks.rs
#	rust/crates/runtime/src/lib.rs
#	rust/crates/runtime/src/mcp.rs
#	rust/crates/runtime/src/mcp_client.rs
#	rust/crates/runtime/src/oauth.rs
#	rust/crates/runtime/src/permissions.rs
#	rust/crates/runtime/src/prompt.rs
#	rust/crates/rusty-claude-cli/Cargo.toml
#	rust/crates/rusty-claude-cli/src/args.rs
#	rust/crates/rusty-claude-cli/src/init.rs
#	rust/crates/rusty-claude-cli/src/main.rs
#	rust/crates/rusty-claude-cli/src/render.rs
#	rust/crates/tools/Cargo.toml
#	rust/crates/tools/src/lib.rs
2026-04-01 18:37:04 +09:00
YeonGyu-Kim
933ad1df4e docs: update README with Claw Code branding and feature parity
- Claude Code -> Claw Code branding
- CLI command refs: claude -> claw
- Feature highlights: 43 tools, JSONL sessions, prompt cache tracking, telemetry matching
- Star history chart and badges
- 11MB release binary positioning
- Config docs aligned to .claw.json
2026-04-01 18:34:24 +09:00
YeonGyu-Kim
3a6a21ac36 cleanroom: apply clean-room scrub on latest codebase
- Remove all claude/anthropic references from .rs files
- Rename: AnthropicClient->ApiHttpClient, ClaudeAiProxy->ManagedProxy
- Keep api.anthropic.com (actual endpoint) and model names (claude-opus etc)
- Keep wire-protocol headers (anthropic-version, User-Agent)
- cargo check passes on full 134-commit codebase
2026-04-01 18:20:34 +09:00
Yeachan-Heo
ec09efa81a Make agents and skills commands usable beyond placeholder parsing
Wire /agents and /skills through the Rust command stack so they can run as direct CLI subcommands, direct slash invocations, and resume-safe slash commands. The handlers now provide structured usage output, skills discovery also covers legacy /commands markdown entries, and the reporting/tests line up more closely with the original TypeScript behavior where feasible.

Constraint: The Rust port does not yet have the original TypeScript TUI menus or plugin/MCP skill registry, so text reports approximate those views
Rejected: Rebuild the original interactive React menus in Rust now | too large for the current CLI parity slice
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep /skills discovery and the Skill tool aligned if command/skill registry parity expands later
Tested: cargo test --workspace
Tested: cargo clippy --workspace --all-targets -- -D warnings
Tested: cargo run -q -p rusty-claude-cli -- agents --help
Tested: cargo run -q -p rusty-claude-cli -- /agents
Not-tested: Live Anthropic-backed REPL execution of /agents or /skills
2026-04-01 08:30:02 +00:00
Yeachan-Heo
b402b1c6b6 Implement upstream slash command parity for plugin metadata surfaces
Wire the Rust slash-command surface to expose the upstream-style /plugin entry and add /agents and /skills handling. The plugin command keeps the existing management actions while help, completion, REPL dispatch, and tests now acknowledge the upstream aliases and inventory views.\n\nConstraint: Match original TypeScript command names without regressing existing /plugins management flows\nRejected: Add placeholder commands only | users would still lack practical slash-command output\nConfidence: high\nScope-risk: narrow\nReversibility: clean\nDirective: Keep /plugin as the canonical help entry while preserving /plugins and /marketplace aliases unless upstream naming changes again\nTested: cargo fmt --all; cargo clippy --workspace --all-targets -- -D warnings; cargo test --workspace\nNot-tested: Manual interactive REPL execution of /agents and /skills against a live user configuration
2026-04-01 08:19:25 +00:00
Yeachan-Heo
486fccfa3e feat: expand slash command surface 2026-04-01 08:15:23 +00:00
Yeachan-Heo
7464302fd3 feat: command surface follow-up integration 2026-04-01 08:10:36 +00:00
Yeachan-Heo
0755a36811 Clear stale enabled state during plugin loader pruning
The plugin loader already pruned stale registry entries, but stale enabled state
could linger in settings.json after bundled or installed plugin discovery
cleaned up missing installs. This change removes those orphaned enabled flags
when stale registry entries are dropped so loader-managed state stays coherent.

Constraint: Commit only plugin loader/registry code in this pass
Rejected: Leave stale enabled flags in settings.json | state drift would survive loader self-healing
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Any future loader-side pruning should remove matching enabled state in the same code path
Tested: cargo fmt --all; cargo test -p plugins
Not-tested: Interactive CLI /plugins flows against manually edited settings.json
2026-04-01 08:10:36 +00:00
Yeachan-Heo
7f33569f3a feat: command surface and slash completion wiring 2026-04-01 08:06:10 +00:00
Yeachan-Heo
24fea5db9e Prove raw tool output truncation stays display-only
Add a renderer regression test for long non-JSON tool output so the CLI's fallback rendering path is covered alongside Read and structured tool payload truncation.

Constraint: This follow-up must commit only renderer-related changes
Rejected: Touch commands crate to fix unrelated slash-command work in progress | outside the requested renderer-only scope
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep truncation guarantees covered at the renderer boundary for both structured and raw tool payloads
Tested: cargo fmt --all; cargo test -p rusty-claude-cli tool_rendering_ -- --nocapture; cargo clippy -p rusty-claude-cli --all-targets -- -D warnings
Not-tested: cargo test --workspace and cargo clippy --workspace --all-targets -- -D warnings currently fail in rust/crates/commands/src/lib.rs due pre-existing incomplete agents/skills changes outside this commit
2026-04-01 08:06:10 +00:00
Yeachan-Heo
782d9cea71 Preserve ILM-style conversation continuity during auto compaction
Auto compaction was keying off cumulative usage and re-summarizing from the front of the session, which made long chats shed continuity after the first compaction. The runtime now compacts against the current turn's prompt pressure and preserves prior compacted context as retained summary state instead of treating it like disposable history.

Constraint: Existing /compact behavior and saved-session resume flow had to keep working without schema changes
Rejected: Keep using cumulative input tokens | caused repeat compaction after every subsequent turn once the threshold was crossed
Rejected: Re-summarize prior compacted system messages as ordinary history | degraded continuity and could drop earlier context
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Preserve compacted-summary boundaries when extending compaction again; do not fold prior compacted context back into raw-message removal
Tested: cargo fmt --check; cargo clippy -p runtime -p commands --tests -- -D warnings; cargo test -p runtime; cargo test -p commands
Not-tested: End-to-end interactive CLI auto-compaction against a live Anthropic session
2026-04-01 08:06:10 +00:00
Yeachan-Heo
97d725d5e5 Keep CLI tool previews readable without truncating session data
Extend the CLI renderer's generic tool-result path to reuse the existing display-only truncation helper, so large plugin or unknown-tool payloads no longer flood the terminal while the original tool result still flows through runtime/session state unchanged.

The renderer now pretty-prints structured fallback payloads before truncating them for display, and the test suite covers both Read output and generic long tool output rendering. I also added a narrow clippy allow on an oversized slash-command parser test so the workspace lint gate stays green during verification.

Constraint: Tool result truncation must affect screen rendering only, not stored tool output
Rejected: Truncate tool results at execution time | would lose session fidelity and break downstream consumers
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep future tool-output shortening in renderer helpers only; do not trim runtime tool payloads before persistence
Tested: cargo fmt --all; cargo clippy --workspace --all-targets -- -D warnings; cargo test --workspace
Not-tested: Manual interactive terminal run showing truncation in a live REPL session
2026-04-01 08:06:10 +00:00
Yeachan-Heo
d794acd3f4 Keep CLI tool rendering readable without dropping result fidelity
Some tools, especially Read, can emit very large payloads that overwhelm the interactive renderer. This change truncates only the displayed preview for long tool outputs while leaving the underlying tool result string untouched for downstream logic and persisted session state.

Constraint: Rendering changes must not modify stored tool outputs or tool-result messages
Rejected: Truncate tool output before returning from the executor | would corrupt session history and downstream processing
Confidence: high
Scope-risk: narrow
Directive: Keep truncation strictly in presentation helpers; do not move it into tool execution or session persistence paths
Tested: cargo test -p rusty-claude-cli tool_rendering_truncates_ -- --nocapture; cargo test -p rusty-claude-cli tool_rendering_helpers_compact_output -- --nocapture
Not-tested: Manual terminal rendering with real multi-megabyte tool output
2026-04-01 08:06:10 +00:00
Yeachan-Heo
4c1eaa16e0 Ignore reasoning blocks in runtime adapters without affecting tool/text flows
After the parser can accept thinking-style blocks, the CLI and tools adapters must explicitly ignore them so only user-visible text and tool calls drive runtime behavior. This keeps reasoning metadata from surfacing as text or interfering with tool accumulation.

Constraint: Runtime behavior must remain unchanged for normal text/tool streaming
Rejected: Treat thinking blocks as assistant text | would leak hidden reasoning into visible output and session flow
Confidence: high
Scope-risk: narrow
Directive: If future features need persisted reasoning blocks, add a dedicated runtime representation instead of overloading text handling
Tested: cargo test -p rusty-claude-cli response_to_events_ignores_thinking_blocks -- --nocapture; cargo test -p tools response_to_events_ignores_thinking_blocks -- --nocapture
Not-tested: End-to-end interactive run against a live thinking-enabled model
2026-04-01 08:06:10 +00:00
Yeachan-Heo
13851d800f Accept reasoning-style content blocks in the Rust API parser
The Rust API layer rejected thinking-enabled responses because it only recognized text and tool_use content blocks. This commit extends the response and SSE parser types to accept reasoning-style content blocks and deltas, with regression coverage for both non-streaming and streaming responses.

Constraint: Keep parsing compatible with existing text and tool-use message flows
Rejected: Deserialize unknown content blocks into an untyped catch-all | would weaken protocol coverage and test precision
Confidence: high
Scope-risk: narrow
Directive: Keep new protocol variants covered at the API boundary so downstream code can make explicit choices about preservation vs. ignoring
Tested: cargo test -p api thinking -- --nocapture
Not-tested: Live API traffic from a real thinking-enabled model
2026-04-01 08:06:10 +00:00
Yeachan-Heo
9e717192f8 Allow subagent tool flows to reach plugin-provided tools
The subagent runtime still advertised and executed only built-in tools, which left plugin-provided tools outside the Agent execution path. This change loads the same plugin-aware registry used by the CLI for subagent tool definitions, permission policy, and execution lookup so delegated runs can resolve plugin tools consistently.

Constraint: Plugin tools must respect the existing runtime plugin config and enabled-plugin state

Rejected: Thread plugin-specific exceptions through execute_tool directly | would bypass registry validation and duplicate lookup rules

Confidence: medium

Scope-risk: moderate

Reversibility: clean

Directive: Keep CLI and subagent registry construction aligned when plugin tool loading rules change

Tested: cargo test -p tools -p rusty-claude-cli

Not-tested: Live Anthropic subagent runs invoking plugin tools end-to-end
2026-04-01 07:36:05 +00:00
Yeachan-Heo
6584ed1ad7 Harden installed-plugin discovery against stale registry state
Expanded the plugin manager so installed plugin discovery now falls back across
install-root scans and registry-only paths without breaking on stale entries.
Missing registry install paths are pruned during discovery, while valid
registry-backed installs outside the install root remain loadable.

Constraint: Keep the change isolated to plugin manifest/manager/registry code
Rejected: Fail listing when any registry install path is missing | stale local state should not block plugin discovery
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Discovery now self-heals missing registry install paths; preserve the registry-fallback path for valid installs outside install_root
Tested: cargo fmt --all; cargo test -p plugins
Not-tested: End-to-end CLI flows with mixed stale and git-backed installed plugins
2026-04-01 07:34:55 +00:00
Yeachan-Heo
46abf52143 feat: plugin subsystem progress 2026-04-01 07:30:20 +00:00
Yeachan-Heo
28be7b3e24 Tighten plugin manifest validation and installed-plugin discovery
Expanded the Rust plugin loader coverage around manifest parsing so invalid
permission values, invalid tool permissions, and multi-error manifests are
validated in a structured way. Added scan-path coverage for installed plugin
directories so both root and packaged manifests are discovered from the install
root, independent of registry entries.

Constraint: Keep plugin loader changes isolated to the plugins crate surface
Rejected: Add a new manifest crate for shared schemas | unnecessary scope for this pass
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: If manifest permissions or tool permission labels expand, update both the enums and validation tests together
Tested: cargo fmt --all; cargo test -p plugins
Not-tested: Cross-crate runtime consumption of any future expanded manifest permission variants
2026-04-01 07:23:10 +00:00
Yeachan-Heo
a10bbaf8de Keep plugin-aware CLI validation aligned with the shared registry
The shared /plugins command flow already routes through the plugin registry, but
allowed-tool normalization still fell back to builtin tools when registry
construction failed. This keeps plugin-related validation errors visible at the
CLI boundary and updates tools tests to use the enum-based plugin permission
API so workspace verification remains green.

Constraint: Plugin tool permissions are now strongly typed in the plugins crate
Rejected: Restore string-based permission arguments in tests | weakens the plugin API contract
Rejected: Keep builtin fallback in normalize_allowed_tools | masks plugin registry integration failures
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Do not silently bypass current_tool_registry() failures unless plugin-aware allowed-tool validation is intentionally being disabled
Tested: cargo test -p commands -- --nocapture; cargo test --workspace
Not-tested: Manual REPL /plugins interaction in a live session
2026-04-01 07:22:41 +00:00
Yeachan-Heo
f967484b9a feat: plugin system follow-up progress 2026-04-01 07:20:13 +00:00
Yeachan-Heo
6520cf8c3f test: cover installed plugin directory scanning 2026-04-01 07:16:13 +00:00
Yeachan-Heo
5f66392f45 feat: plugin subsystem final in-flight progress 2026-04-01 07:11:42 +00:00
Yeachan-Heo
123a7f4013 feat: plugin hooks + tool registry + CLI integration 2026-04-01 07:11:42 +00:00
Yeachan-Heo
0db9660727 feat: plugin subsystem progress 2026-04-01 07:11:25 +00:00
Yeachan-Heo
e488e94307 feat: plugin subsystem — loader, hooks, tools, bundled, CLI 2026-04-01 07:10:25 +00:00
Yeachan-Heo
f8d4da3e68 feat: plugins progress 2026-04-01 07:10:25 +00:00
Yeachan-Heo
8f6d8db958 feat: plugin registry + validation + hooks 2026-04-01 07:09:29 +00:00
Yeachan-Heo
7b17b037cb wip: plugins progress 2026-04-01 07:09:29 +00:00
Yeachan-Heo
b905b611f0 wip: plugins progress 2026-04-01 07:09:06 +00:00
Yeachan-Heo
40008b6513 wip: grok provider abstraction 2026-04-01 06:00:48 +00:00
Yeachan-Heo
c38eac7a90 feat: hook-pipeline progress — tests passing 2026-04-01 05:58:00 +00:00
Yeachan-Heo
197065bfc8 feat: hook abort signal + Ctrl-C cancellation pipeline 2026-04-01 05:55:24 +00:00
Yeachan-Heo
f477dde4a6 feat: provider tests + grok integration 2026-04-01 05:45:27 +00:00
Yeachan-Heo
555a245456 wip: hook progress UI + documentation 2026-04-01 04:50:26 +00:00
Yeachan-Heo
9efd029e26 wip: hook-pipeline progress 2026-04-01 04:40:18 +00:00
Yeachan-Heo
eb89fc95e7 wip: hook-pipeline progress 2026-04-01 04:30:25 +00:00
Yeachan-Heo
94199beabb wip: hook pipeline progress 2026-04-01 04:20:16 +00:00
Yeachan-Heo
178934a9a0 feat: grok provider tests + cargo fmt 2026-04-01 04:20:15 +00:00
Yeachan-Heo
2a0f4b677a feat: provider abstraction layer + Grok API support 2026-04-01 04:10:46 +00:00
Yeachan-Heo
cbc0a83059 auto: save WIP progress from rcc session 2026-04-01 04:01:37 +00:00
Yeachan-Heo
9ade3a70d7 fix: auto compaction threshold default 200k tokens 2026-04-01 03:55:00 +00:00
Yeachan-Heo
91ab8ea9d9 feat: auto compaction + ant-only commands (merge rcc/ant-tools) 2026-04-01 03:51:10 +00:00
Yeachan-Heo
992681c4fd Prevent long sessions from stalling and expose the requested internal command surface
The runtime now auto-compacts completed conversations once cumulative input usage
crosses a configurable threshold, preserving recent context while surfacing an
explicit user notice. The CLI also publishes the requested ant-only slash
commands through the shared commands crate and main dispatch, using meaningful
local implementations for commit/PR/issue/teleport/debug workflows.

Constraint: Reuse the existing Rust compaction pipeline instead of introducing a new summarization stack
Constraint: No new dependencies or broad command-framework rewrite
Rejected: Implement API-driven compaction inside ConversationRuntime now | too much new plumbing for this delivery
Rejected: Expose new commands as parse-only stubs | would not satisfy the requested command availability
Confidence: medium
Scope-risk: moderate
Reversibility: clean
Directive: If runtime later gains true API-backed compaction, preserve the TurnSummary auto-compaction metadata shape so CLI call sites stay stable
Tested: cargo test; cargo build --release; cargo fmt --all; git diff --check; LSP diagnostics directory check
Not-tested: Live Anthropic-backed specialist command flows; gh-authenticated PR/issue creation in a real repo
2026-04-01 03:48:50 +00:00
Yeachan-Heo
77427245c1 rebrand: Claude Code -> Claw Code in all prompts and source text 2026-04-01 03:45:42 +00:00
Yeachan-Heo
ac6c5d00a8 Enable Claude-compatible tool hooks in the Rust runtime
This threads typed hook settings through runtime config, adds a shell-based hook runner, and executes PreToolUse/PostToolUse around each tool call in the conversation loop. The CLI now rebuilds runtimes with settings-derived hook configuration so user-defined Claude hook commands actually run before and after tools.

Constraint: Hook behavior needed to match Claude-style settings.json hooks without broad plugin/MCP parity work in this change
Rejected: Delay hook loading to the tool executor layer | would miss denied tool calls and duplicate runtime policy plumbing
Confidence: medium
Scope-risk: moderate
Reversibility: clean
Directive: Keep hook execution in the runtime loop so permission decisions and tool results remain wrapped by the same conversation semantics
Tested: cargo test; cargo build --release
Not-tested: Real user hook scripts outside the test harness; broader plugin/skills parity
2026-04-01 03:35:25 +00:00
Yeachan-Heo
a94ef61b01 feat: -p flag compat, --print flag, OAuth defaults, UI rendering merge 2026-04-01 03:22:34 +00:00
Yeachan-Heo
a9ac7e5bb8 feat: default OAuth config for claude.com, merge UI polish rendering 2026-04-01 03:20:26 +00:00
Yeachan-Heo
0175ee0a90 Merge remote-tracking branch 'origin/rcc/ui-polish' into dev/rust 2026-04-01 03:17:16 +00:00
Yeachan-Heo
1bd0eef368 Merge remote-tracking branch 'origin/rcc/subagent' into dev/rust 2026-04-01 03:12:25 +00:00
Yeachan-Heo
ba220d210e Enable real Agent tool delegation in the Rust CLI
The Rust Agent tool only persisted queued metadata, so delegated work never actually ran. This change wires Agent into a detached background conversation path with isolated runtime, API client, session state, restricted tool subsets, and file-backed lifecycle/result updates.

Constraint: Keep the tool entrypoint in the tools crate and avoid copying the upstream TypeScript implementation
Rejected: Spawn an external claw process | less aligned with the requested in-process runtime/client design
Rejected: Leave execution in the CLI crate only | would keep tools::Agent as a metadata-only stub
Confidence: medium
Scope-risk: moderate
Reversibility: clean
Directive: Tool subset mappings are curated guardrails; revisit them before enabling recursive Agent access or richer agent definitions
Tested: cargo build --release --manifest-path rust/Cargo.toml
Tested: cargo test --manifest-path rust/Cargo.toml
Not-tested: Live end-to-end background sub-agent run against Anthropic API credentials
2026-04-01 03:10:20 +00:00
Yeachan-Heo
04b1f1e85d docs: rewrite rust/ README with full feature matrix and usage guide 2026-04-01 02:59:05 +00:00
50 changed files with 11619 additions and 1475 deletions

5
.claude.json Normal file
View File

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

3
.gitignore vendored
View File

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

21
CLAUDE.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

@@ -1,22 +1,27 @@
[ [
{ {
"content": "Phase 0: Structural Cleanup — spawn 4 agents for 0.1-0.4", "content": "Architecture & dependency analysis",
"activeForm": "Executing Phase 0: Structural Cleanup via sub-agents", "activeForm": "Complete",
"status": "completed"
},
{
"content": "Runtime crate deep analysis",
"activeForm": "Complete",
"status": "completed"
},
{
"content": "CLI & Tools analysis",
"activeForm": "Complete",
"status": "completed"
},
{
"content": "Code quality verification",
"activeForm": "Complete",
"status": "completed"
},
{
"content": "Synthesize findings into unified report",
"activeForm": "Writing report",
"status": "in_progress" "status": "in_progress"
},
{
"content": "Phase 1.1-1.2: Status bar with live HUD and token counter",
"activeForm": "Awaiting Phase 0",
"status": "pending"
},
{
"content": "Phase 2.4: Remove artificial 8ms stream delay",
"activeForm": "Awaiting Phase 0",
"status": "pending"
},
{
"content": "Phase 3.1: Collapsible tool output",
"activeForm": "Awaiting Phase 0",
"status": "pending"
} }
] ]

15
rust/Cargo.lock generated
View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -216,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(),
},
}
))
);
}
} }

View File

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

View File

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

View File

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

View File

@@ -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_anthropic_auth_without_env_lookup() {
let _lock = env_lock();
let _anthropic_api_key = EnvVarGuard::set("ANTHROPIC_API_KEY", None);
let _anthropic_auth_token = EnvVarGuard::set("ANTHROPIC_AUTH_TOKEN", None);
let client = ProviderClient::from_model_with_anthropic_auth(
"claude-sonnet-4-6",
Some(AuthSource::ApiKey("anthropic-test-key".to_string())),
)
.expect("explicit anthropic auth should avoid env lookup");
assert_eq!(client.provider_kind(), ProviderKind::Anthropic);
}
#[test]
fn read_xai_base_url_prefers_env_override() {
let _lock = env_lock();
let _xai_base_url = EnvVarGuard::set("XAI_BASE_URL", Some("https://example.xai.test/v1"));
assert_eq!(read_xai_base_url(), "https://example.xai.test/v1");
}
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
struct EnvVarGuard {
key: &'static str,
original: Option<OsString>,
}
impl EnvVarGuard {
fn set(key: &'static str, value: Option<&str>) -> Self {
let original = std::env::var_os(key);
match value {
Some(value) => std::env::set_var(key, value),
None => std::env::remove_var(key),
}
Self { key, original }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
match &self.original {
Some(value) => std::env::set_var(self.key, value),
None => std::env::remove_var(self.key),
}
}
}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -286,7 +286,7 @@ impl TerminalRenderer {
) { ) {
match event { match event {
Event::Start(Tag::Heading { level, .. }) => { Event::Start(Tag::Heading { level, .. }) => {
self.start_heading(state, level as u8, output) self.start_heading(state, level as u8, output);
} }
Event::End(TagEnd::Paragraph) => output.push_str("\n\n"), Event::End(TagEnd::Paragraph) => output.push_str("\n\n"),
Event::Start(Tag::BlockQuote(..)) => self.start_quote(state, output), Event::Start(Tag::BlockQuote(..)) => self.start_quote(state, output),
@@ -426,6 +426,7 @@ impl TerminalRenderer {
} }
} }
#[allow(clippy::unused_self)]
fn start_heading(&self, state: &mut RenderState, level: u8, output: &mut String) { fn start_heading(&self, state: &mut RenderState, level: u8, output: &mut String) {
state.heading_level = Some(level); state.heading_level = Some(level);
if !output.is_empty() { if !output.is_empty() {

View File

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

File diff suppressed because it is too large Load Diff