26 Commits

Author SHA1 Message Date
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
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
31 changed files with 7162 additions and 1629 deletions

12
rust/Cargo.lock generated
View File

@@ -111,6 +111,7 @@ dependencies = [
name = "commands"
version = "0.1.0"
dependencies = [
"plugins",
"runtime",
]
@@ -825,6 +826,14 @@ dependencies = [
"time",
]
[[package]]
name = "plugins"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "potential_utf"
version = "0.1.4"
@@ -1092,6 +1101,7 @@ name = "runtime"
version = "0.1.0"
dependencies = [
"glob",
"plugins",
"regex",
"serde",
"serde_json",
@@ -1181,6 +1191,7 @@ dependencies = [
"commands",
"compat-harness",
"crossterm",
"plugins",
"pulldown-cmark",
"runtime",
"rustyline",
@@ -1546,6 +1557,7 @@ name = "tools"
version = "0.1.0"
dependencies = [
"api",
"plugins",
"reqwest",
"runtime",
"serde",

View File

@@ -1,6 +1,6 @@
# 🦞 Claw Code — Rust Implementation
A high-performance Rust rewrite of the Claude Code CLI agent harness. Built for speed, safety, and native tool execution.
A high-performance Rust rewrite of the Claw Code CLI agent harness. Built for speed, safety, and native tool execution.
## Quick Start

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,
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)]
@@ -190,6 +199,8 @@ pub struct ContentBlockDeltaEvent {
pub enum ContentBlockDelta {
TextDelta { text: String },
InputJsonDelta { partial_json: String },
ThinkingDelta { thinking: String },
SignatureDelta { signature: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]

View File

@@ -75,6 +75,48 @@ async fn send_message_posts_json_and_parses_response() {
assert_eq!(body["tool_choice"]["type"], json!("auto"));
}
#[tokio::test]
async fn send_message_parses_response_with_thinking_blocks() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let body = concat!(
"{",
"\"id\":\"msg_thinking\",",
"\"type\":\"message\",",
"\"role\":\"assistant\",",
"\"content\":[",
"{\"type\":\"thinking\",\"thinking\":\"step 1\",\"signature\":\"sig_123\"},",
"{\"type\":\"text\",\"text\":\"Final answer\"}",
"],",
"\"model\":\"claude-3-7-sonnet-latest\",",
"\"stop_reason\":\"end_turn\",",
"\"stop_sequence\":null,",
"\"usage\":{\"input_tokens\":12,\"output_tokens\":4}",
"}"
);
let server = spawn_server(
state,
vec![http_response("200 OK", "application/json", body)],
)
.await;
let client = AnthropicClient::new("test-key").with_base_url(server.base_url());
let response = client
.send_message(&sample_request(false))
.await
.expect("request should succeed");
assert_eq!(response.content.len(), 2);
assert!(matches!(
&response.content[0],
OutputContentBlock::Thinking { thinking, signature }
if thinking == "step 1" && signature.as_deref() == Some("sig_123")
));
assert!(matches!(
&response.content[1],
OutputContentBlock::Text { text } if text == "Final answer"
));
}
#[tokio::test]
async fn stream_message_parses_sse_events_with_tool_use() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
@@ -162,6 +204,85 @@ async fn stream_message_parses_sse_events_with_tool_use() {
assert!(request.body.contains("\"stream\":true"));
}
#[tokio::test]
async fn stream_message_parses_sse_events_with_thinking_blocks() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let sse = concat!(
"event: message_start\n",
"data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_stream_thinking\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-3-7-sonnet-latest\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":8,\"output_tokens\":0}}}\n\n",
"event: content_block_start\n",
"data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}\n\n",
"event: content_block_delta\n",
"data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"step 1\"}}\n\n",
"event: content_block_delta\n",
"data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"signature_delta\",\"signature\":\"sig_123\"}}\n\n",
"event: content_block_stop\n",
"data: {\"type\":\"content_block_stop\",\"index\":0}\n\n",
"event: content_block_start\n",
"data: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"text\",\"text\":\"Final answer\"}}\n\n",
"event: content_block_stop\n",
"data: {\"type\":\"content_block_stop\",\"index\":1}\n\n",
"event: message_delta\n",
"data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":8,\"output_tokens\":1}}\n\n",
"event: message_stop\n",
"data: {\"type\":\"message_stop\"}\n\n",
"data: [DONE]\n\n"
);
let server = spawn_server(
state,
vec![http_response("200 OK", "text/event-stream", sse)],
)
.await;
let client = AnthropicClient::new("test-key").with_base_url(server.base_url());
let mut stream = client
.stream_message(&sample_request(false))
.await
.expect("stream should start");
let mut events = Vec::new();
while let Some(event) = stream
.next_event()
.await
.expect("stream event should parse")
{
events.push(event);
}
assert_eq!(events.len(), 9);
assert!(matches!(
&events[1],
StreamEvent::ContentBlockStart(ContentBlockStartEvent {
content_block: OutputContentBlock::Thinking { thinking, signature },
..
}) if thinking.is_empty() && signature.is_none()
));
assert!(matches!(
&events[2],
StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
delta: ContentBlockDelta::ThinkingDelta { thinking },
..
}) if thinking == "step 1"
));
assert!(matches!(
&events[3],
StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
delta: ContentBlockDelta::SignatureDelta { signature },
..
}) if signature == "sig_123"
));
assert!(matches!(
&events[5],
StreamEvent::ContentBlockStart(ContentBlockStartEvent {
content_block: OutputContentBlock::Text { text },
..
}) if text == "Final answer"
));
assert!(matches!(events[6], StreamEvent::ContentBlockStop(_)));
assert!(matches!(events[7], StreamEvent::MessageDelta(_)));
assert!(matches!(events[8], StreamEvent::MessageStop(_)));
}
#[tokio::test]
async fn retries_retryable_failures_before_succeeding() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));

View File

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

View File

@@ -1,3 +1,9 @@
use std::collections::BTreeMap;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use plugins::{PluginError, PluginManager, PluginSummary};
use runtime::{compact_session, CompactionConfig, Session};
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -33,6 +39,7 @@ impl CommandRegistry {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SlashCommandSpec {
pub name: &'static str,
pub aliases: &'static [&'static str],
pub summary: &'static str,
pub argument_hint: Option<&'static str>,
pub resume_supported: bool,
@@ -90,7 +97,7 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
SlashCommandSpec {
name: "config",
summary: "Inspect Claude config files or merged sections",
argument_hint: Some("[env|hooks|model]"),
argument_hint: Some("[env|hooks|model|plugins]"),
resume_supported: true,
},
SlashCommandSpec {
@@ -117,6 +124,48 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "bughunter",
summary: "Inspect the codebase for likely bugs",
argument_hint: Some("[scope]"),
resume_supported: false,
},
SlashCommandSpec {
name: "commit",
summary: "Generate a commit message and create a git commit",
argument_hint: None,
resume_supported: false,
},
SlashCommandSpec {
name: "pr",
summary: "Draft or create a pull request from the conversation",
argument_hint: Some("[context]"),
resume_supported: false,
},
SlashCommandSpec {
name: "issue",
summary: "Draft or create a GitHub issue from the conversation",
argument_hint: Some("[context]"),
resume_supported: false,
},
SlashCommandSpec {
name: "ultraplan",
summary: "Run a deep planning prompt with multi-step reasoning",
argument_hint: Some("[task]"),
resume_supported: false,
},
SlashCommandSpec {
name: "teleport",
summary: "Jump to a file or symbol by searching the workspace",
argument_hint: Some("<symbol-or-path>"),
resume_supported: false,
},
SlashCommandSpec {
name: "debug-tool-call",
summary: "Replay the last tool call with debug details",
argument_hint: None,
resume_supported: false,
},
SlashCommandSpec {
name: "export",
summary: "Export the current conversation to a file",
@@ -129,6 +178,14 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
argument_hint: Some("[list|switch <session-id>]"),
resume_supported: false,
},
SlashCommandSpec {
name: "plugins",
summary: "List or manage plugins",
argument_hint: Some(
"[list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]",
),
resume_supported: false,
},
];
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -136,6 +193,23 @@ pub enum SlashCommand {
Help,
Status,
Compact,
Bughunter {
scope: Option<String>,
},
Commit,
Pr {
context: Option<String>,
},
Issue {
context: Option<String>,
},
Ultraplan {
task: Option<String>,
},
Teleport {
target: Option<String>,
},
DebugToolCall,
Model {
model: Option<String>,
},
@@ -163,6 +237,10 @@ pub enum SlashCommand {
action: Option<String>,
target: Option<String>,
},
Plugins {
action: Option<String>,
target: Option<String>,
},
Unknown(String),
}
@@ -180,6 +258,23 @@ impl SlashCommand {
"help" => Self::Help,
"status" => Self::Status,
"compact" => Self::Compact,
"bughunter" => Self::Bughunter {
scope: remainder_after_command(trimmed, command),
},
"commit" => Self::Commit,
"pr" => Self::Pr {
context: remainder_after_command(trimmed, command),
},
"issue" => Self::Issue {
context: remainder_after_command(trimmed, command),
},
"ultraplan" => Self::Ultraplan {
task: remainder_after_command(trimmed, command),
},
"teleport" => Self::Teleport {
target: remainder_after_command(trimmed, command),
},
"debug-tool-call" => Self::DebugToolCall,
"model" => Self::Model {
model: parts.next().map(ToOwned::to_owned),
},
@@ -207,11 +302,27 @@ impl SlashCommand {
action: parts.next().map(ToOwned::to_owned),
target: parts.next().map(ToOwned::to_owned),
},
"plugins" => Self::Plugins {
action: parts.next().map(ToOwned::to_owned),
target: {
let remainder = parts.collect::<Vec<_>>().join(" ");
(!remainder.is_empty()).then_some(remainder)
},
},
other => Self::Unknown(other.to_string()),
})
}
}
fn remainder_after_command(input: &str, command: &str) -> Option<String> {
input
.trim()
.strip_prefix(&format!("/{command}"))
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
#[must_use]
pub fn slash_command_specs() -> &'static [SlashCommandSpec] {
SLASH_COMMAND_SPECS
@@ -252,6 +363,176 @@ pub struct SlashCommandResult {
pub session: Session,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginsCommandResult {
pub message: String,
pub reload_runtime: bool,
}
#[allow(clippy::too_many_lines)]
pub fn handle_plugins_slash_command(
action: Option<&str>,
target: Option<&str>,
manager: &mut PluginManager,
) -> Result<PluginsCommandResult, PluginError> {
match action {
None | Some("list") => Ok(PluginsCommandResult {
message: render_plugins_report(&manager.list_installed_plugins()?),
reload_runtime: false,
}),
Some("install") => {
let Some(target) = target else {
return Ok(PluginsCommandResult {
message: "Usage: /plugins install <path>".to_string(),
reload_runtime: false,
});
};
let install = manager.install(target)?;
let plugin = manager
.list_installed_plugins()?
.into_iter()
.find(|plugin| plugin.metadata.id == install.plugin_id);
Ok(PluginsCommandResult {
message: render_plugin_install_report(&install.plugin_id, plugin.as_ref()),
reload_runtime: true,
})
}
Some("enable") => {
let Some(target) = target else {
return Ok(PluginsCommandResult {
message: "Usage: /plugins enable <name>".to_string(),
reload_runtime: false,
});
};
let plugin = resolve_plugin_target(manager, target)?;
manager.enable(&plugin.metadata.id)?;
Ok(PluginsCommandResult {
message: format!(
"Plugins\n Result enabled {}\n Name {}\n Version {}\n Status enabled",
plugin.metadata.id, plugin.metadata.name, plugin.metadata.version
),
reload_runtime: true,
})
}
Some("disable") => {
let Some(target) = target else {
return Ok(PluginsCommandResult {
message: "Usage: /plugins disable <name>".to_string(),
reload_runtime: false,
});
};
let plugin = resolve_plugin_target(manager, target)?;
manager.disable(&plugin.metadata.id)?;
Ok(PluginsCommandResult {
message: format!(
"Plugins\n Result disabled {}\n Name {}\n Version {}\n Status disabled",
plugin.metadata.id, plugin.metadata.name, plugin.metadata.version
),
reload_runtime: true,
})
}
Some("uninstall") => {
let Some(target) = target else {
return Ok(PluginsCommandResult {
message: "Usage: /plugins uninstall <plugin-id>".to_string(),
reload_runtime: false,
});
};
manager.uninstall(target)?;
Ok(PluginsCommandResult {
message: format!("Plugins\n Result uninstalled {target}"),
reload_runtime: true,
})
}
Some("update") => {
let Some(target) = target else {
return Ok(PluginsCommandResult {
message: "Usage: /plugins update <plugin-id>".to_string(),
reload_runtime: false,
});
};
let update = manager.update(target)?;
let plugin = manager
.list_installed_plugins()?
.into_iter()
.find(|plugin| plugin.metadata.id == update.plugin_id);
Ok(PluginsCommandResult {
message: format!(
"Plugins\n Result updated {}\n Name {}\n Old version {}\n New version {}\n Status {}",
update.plugin_id,
plugin
.as_ref()
.map_or_else(|| update.plugin_id.clone(), |plugin| plugin.metadata.name.clone()),
update.old_version,
update.new_version,
plugin
.as_ref()
.map_or("unknown", |plugin| if plugin.enabled { "enabled" } else { "disabled" }),
),
reload_runtime: true,
})
}
Some(other) => Ok(PluginsCommandResult {
message: format!(
"Unknown /plugins action '{other}'. Use list, install, enable, disable, uninstall, or update."
),
reload_runtime: false,
}),
}
}
#[must_use]
pub fn render_plugins_report(plugins: &[PluginSummary]) -> String {
let mut lines = vec!["Plugins".to_string()];
if plugins.is_empty() {
lines.push(" No plugins installed.".to_string());
return lines.join("\n");
}
for plugin in plugins {
let enabled = if plugin.enabled {
"enabled"
} else {
"disabled"
};
lines.push(format!(
" {name:<20} v{version:<10} {enabled}",
name = plugin.metadata.name,
version = plugin.metadata.version,
));
}
lines.join("\n")
}
fn render_plugin_install_report(plugin_id: &str, plugin: Option<&PluginSummary>) -> String {
let name = plugin.map_or(plugin_id, |plugin| plugin.metadata.name.as_str());
let version = plugin.map_or("unknown", |plugin| plugin.metadata.version.as_str());
let enabled = plugin.is_some_and(|plugin| plugin.enabled);
format!(
"Plugins\n Result installed {plugin_id}\n Name {name}\n Version {version}\n Status {}",
if enabled { "enabled" } else { "disabled" }
)
}
fn resolve_plugin_target(
manager: &PluginManager,
target: &str,
) -> Result<PluginSummary, PluginError> {
let mut matches = manager
.list_installed_plugins()?
.into_iter()
.filter(|plugin| plugin.metadata.id == target || plugin.metadata.name == target)
.collect::<Vec<_>>();
match matches.len() {
1 => Ok(matches.remove(0)),
0 => Err(PluginError::NotFound(format!(
"plugin `{target}` is not installed or discoverable"
))),
_ => Err(PluginError::InvalidManifest(format!(
"plugin name `{target}` is ambiguous; use the full plugin id"
))),
}
}
#[must_use]
pub fn handle_slash_command(
input: &str,
@@ -279,6 +560,13 @@ pub fn handle_slash_command(
session: session.clone(),
}),
SlashCommand::Status
| SlashCommand::Bughunter { .. }
| SlashCommand::Commit
| SlashCommand::Pr { .. }
| SlashCommand::Issue { .. }
| SlashCommand::Ultraplan { .. }
| SlashCommand::Teleport { .. }
| SlashCommand::DebugToolCall
| SlashCommand::Model { .. }
| SlashCommand::Permissions { .. }
| SlashCommand::Clear { .. }
@@ -291,6 +579,7 @@ pub fn handle_slash_command(
| SlashCommand::Version
| SlashCommand::Export { .. }
| SlashCommand::Session { .. }
| SlashCommand::Plugins { .. }
| SlashCommand::Unknown(_) => None,
}
}
@@ -298,15 +587,88 @@ pub fn handle_slash_command(
#[cfg(test)]
mod tests {
use super::{
handle_slash_command, render_slash_command_help, resume_supported_slash_commands,
slash_command_specs, SlashCommand,
handle_plugins_slash_command, handle_slash_command, load_agents_from_roots,
load_skills_from_roots, render_agents_report, render_plugins_report, render_skills_report,
render_slash_command_help, resume_supported_slash_commands, slash_command_specs,
DefinitionSource, SlashCommand,
};
use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginMetadata, PluginSummary};
use runtime::{CompactionConfig, ContentBlock, ConversationMessage, MessageRole, Session};
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!("commands-plugin-{label}-{nanos}"))
}
fn write_external_plugin(root: &Path, name: &str, version: &str) {
fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
fs::write(
root.join(".claude-plugin").join("plugin.json"),
format!(
"{{\n \"name\": \"{name}\",\n \"version\": \"{version}\",\n \"description\": \"commands plugin\"\n}}"
),
)
.expect("write manifest");
}
fn write_bundled_plugin(root: &Path, name: &str, version: &str, default_enabled: bool) {
fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
fs::write(
root.join(".claude-plugin").join("plugin.json"),
format!(
"{{\n \"name\": \"{name}\",\n \"version\": \"{version}\",\n \"description\": \"bundled commands plugin\",\n \"defaultEnabled\": {}\n}}",
if default_enabled { "true" } else { "false" }
),
)
.expect("write bundled manifest");
}
#[allow(clippy::too_many_lines)]
#[test]
fn parses_supported_slash_commands() {
assert_eq!(SlashCommand::parse("/help"), Some(SlashCommand::Help));
assert_eq!(SlashCommand::parse(" /status "), Some(SlashCommand::Status));
assert_eq!(
SlashCommand::parse("/bughunter runtime"),
Some(SlashCommand::Bughunter {
scope: Some("runtime".to_string())
})
);
assert_eq!(SlashCommand::parse("/commit"), Some(SlashCommand::Commit));
assert_eq!(
SlashCommand::parse("/pr ready for review"),
Some(SlashCommand::Pr {
context: Some("ready for review".to_string())
})
);
assert_eq!(
SlashCommand::parse("/issue flaky test"),
Some(SlashCommand::Issue {
context: Some("flaky test".to_string())
})
);
assert_eq!(
SlashCommand::parse("/ultraplan ship both features"),
Some(SlashCommand::Ultraplan {
task: Some("ship both features".to_string())
})
);
assert_eq!(
SlashCommand::parse("/teleport conversation.rs"),
Some(SlashCommand::Teleport {
target: Some("conversation.rs".to_string())
})
);
assert_eq!(
SlashCommand::parse("/debug-tool-call"),
Some(SlashCommand::DebugToolCall)
);
assert_eq!(
SlashCommand::parse("/model claude-opus"),
Some(SlashCommand::Model {
@@ -365,6 +727,34 @@ mod tests {
target: Some("abc123".to_string())
})
);
assert_eq!(
SlashCommand::parse("/plugins install demo"),
Some(SlashCommand::Plugins {
action: Some("install".to_string()),
target: Some("demo".to_string())
})
);
assert_eq!(
SlashCommand::parse("/plugins list"),
Some(SlashCommand::Plugins {
action: Some("list".to_string()),
target: None
})
);
assert_eq!(
SlashCommand::parse("/plugins enable demo"),
Some(SlashCommand::Plugins {
action: Some("enable".to_string()),
target: Some("demo".to_string())
})
);
assert_eq!(
SlashCommand::parse("/plugins disable demo"),
Some(SlashCommand::Plugins {
action: Some("disable".to_string()),
target: Some("demo".to_string())
})
);
}
#[test]
@@ -374,19 +764,29 @@ mod tests {
assert!(help.contains("/help"));
assert!(help.contains("/status"));
assert!(help.contains("/compact"));
assert!(help.contains("/bughunter [scope]"));
assert!(help.contains("/commit"));
assert!(help.contains("/pr [context]"));
assert!(help.contains("/issue [context]"));
assert!(help.contains("/ultraplan [task]"));
assert!(help.contains("/teleport <symbol-or-path>"));
assert!(help.contains("/debug-tool-call"));
assert!(help.contains("/model [model]"));
assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
assert!(help.contains("/clear [--confirm]"));
assert!(help.contains("/cost"));
assert!(help.contains("/resume <session-path>"));
assert!(help.contains("/config [env|hooks|model]"));
assert!(help.contains("/config [env|hooks|model|plugins]"));
assert!(help.contains("/memory"));
assert!(help.contains("/init"));
assert!(help.contains("/diff"));
assert!(help.contains("/version"));
assert!(help.contains("/export [file]"));
assert!(help.contains("/session [list|switch <session-id>]"));
assert_eq!(slash_command_specs().len(), 15);
assert!(help.contains(
"/plugins [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]"
));
assert_eq!(slash_command_specs().len(), 23);
assert_eq!(resume_supported_slash_commands().len(), 11);
}
@@ -434,6 +834,22 @@ mod tests {
let session = Session::new();
assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none());
assert!(handle_slash_command("/status", &session, CompactionConfig::default()).is_none());
assert!(
handle_slash_command("/bughunter", &session, CompactionConfig::default()).is_none()
);
assert!(handle_slash_command("/commit", &session, CompactionConfig::default()).is_none());
assert!(handle_slash_command("/pr", &session, CompactionConfig::default()).is_none());
assert!(handle_slash_command("/issue", &session, CompactionConfig::default()).is_none());
assert!(
handle_slash_command("/ultraplan", &session, CompactionConfig::default()).is_none()
);
assert!(
handle_slash_command("/teleport foo", &session, CompactionConfig::default()).is_none()
);
assert!(
handle_slash_command("/debug-tool-call", &session, CompactionConfig::default())
.is_none()
);
assert!(
handle_slash_command("/model claude", &session, CompactionConfig::default()).is_none()
);
@@ -468,5 +884,221 @@ mod tests {
assert!(
handle_slash_command("/session list", &session, CompactionConfig::default()).is_none()
);
assert!(
handle_slash_command("/plugins list", &session, CompactionConfig::default()).is_none()
);
}
#[test]
fn renders_plugins_report_with_name_version_and_status() {
let rendered = render_plugins_report(&[
PluginSummary {
metadata: PluginMetadata {
id: "demo@external".to_string(),
name: "demo".to_string(),
version: "1.2.3".to_string(),
description: "demo plugin".to_string(),
kind: PluginKind::External,
source: "demo".to_string(),
default_enabled: false,
root: None,
},
enabled: true,
},
PluginSummary {
metadata: PluginMetadata {
id: "sample@external".to_string(),
name: "sample".to_string(),
version: "0.9.0".to_string(),
description: "sample plugin".to_string(),
kind: PluginKind::External,
source: "sample".to_string(),
default_enabled: false,
root: None,
},
enabled: false,
},
]);
assert!(rendered.contains("demo"));
assert!(rendered.contains("v1.2.3"));
assert!(rendered.contains("enabled"));
assert!(rendered.contains("sample"));
assert!(rendered.contains("v0.9.0"));
assert!(rendered.contains("disabled"));
}
#[test]
fn lists_agents_from_project_and_user_roots() {
let workspace = temp_dir("agents-workspace");
let project_agents = workspace.join(".codex").join("agents");
let user_home = temp_dir("agents-home");
let user_agents = user_home.join(".codex").join("agents");
write_agent(
&project_agents,
"planner",
"Project planner",
"gpt-5.4",
"medium",
);
write_agent(
&user_agents,
"planner",
"User planner",
"gpt-5.4-mini",
"high",
);
write_agent(
&user_agents,
"verifier",
"Verification agent",
"gpt-5.4-mini",
"high",
);
let roots = vec![
(DefinitionSource::ProjectCodex, project_agents),
(DefinitionSource::UserCodex, user_agents),
];
let report = render_agents_report(
&load_agents_from_roots(&roots).expect("agent roots should load"),
);
assert!(report.contains("Agents"));
assert!(report.contains("2 active agents"));
assert!(report.contains("Project (.codex):"));
assert!(report.contains("planner · Project planner · gpt-5.4 · medium"));
assert!(report.contains("User (~/.codex):"));
assert!(report.contains("(shadowed by Project (.codex)) planner · User planner"));
assert!(report.contains("verifier · Verification agent · gpt-5.4-mini · high"));
let _ = fs::remove_dir_all(workspace);
let _ = fs::remove_dir_all(user_home);
}
#[test]
fn lists_skills_from_project_and_user_roots() {
let workspace = temp_dir("skills-workspace");
let project_skills = workspace.join(".codex").join("skills");
let user_home = temp_dir("skills-home");
let user_skills = user_home.join(".codex").join("skills");
write_skill(&project_skills, "plan", "Project planning guidance");
write_skill(&user_skills, "plan", "User planning guidance");
write_skill(&user_skills, "help", "Help guidance");
let roots = vec![
(DefinitionSource::ProjectCodex, project_skills),
(DefinitionSource::UserCodex, user_skills),
];
let report = render_skills_report(
&load_skills_from_roots(&roots).expect("skill roots should load"),
);
assert!(report.contains("Skills"));
assert!(report.contains("2 available skills"));
assert!(report.contains("Project (.codex):"));
assert!(report.contains("plan · Project planning guidance"));
assert!(report.contains("User (~/.codex):"));
assert!(report.contains("(shadowed by Project (.codex)) plan · User planning guidance"));
assert!(report.contains("help · Help guidance"));
let _ = fs::remove_dir_all(workspace);
let _ = fs::remove_dir_all(user_home);
}
#[test]
fn installs_plugin_from_path_and_lists_it() {
let config_home = temp_dir("home");
let source_root = temp_dir("source");
write_external_plugin(&source_root, "demo", "1.0.0");
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
let install = handle_plugins_slash_command(
Some("install"),
Some(source_root.to_str().expect("utf8 path")),
&mut manager,
)
.expect("install command should succeed");
assert!(install.reload_runtime);
assert!(install.message.contains("installed demo@external"));
assert!(install.message.contains("Name demo"));
assert!(install.message.contains("Version 1.0.0"));
assert!(install.message.contains("Status enabled"));
let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
.expect("list command should succeed");
assert!(!list.reload_runtime);
assert!(list.message.contains("demo"));
assert!(list.message.contains("v1.0.0"));
assert!(list.message.contains("enabled"));
let _ = fs::remove_dir_all(config_home);
let _ = fs::remove_dir_all(source_root);
}
#[test]
fn enables_and_disables_plugin_by_name() {
let config_home = temp_dir("toggle-home");
let source_root = temp_dir("toggle-source");
write_external_plugin(&source_root, "demo", "1.0.0");
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
handle_plugins_slash_command(
Some("install"),
Some(source_root.to_str().expect("utf8 path")),
&mut manager,
)
.expect("install command should succeed");
let disable = handle_plugins_slash_command(Some("disable"), Some("demo"), &mut manager)
.expect("disable command should succeed");
assert!(disable.reload_runtime);
assert!(disable.message.contains("disabled demo@external"));
assert!(disable.message.contains("Name demo"));
assert!(disable.message.contains("Status disabled"));
let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
.expect("list command should succeed");
assert!(list.message.contains("demo"));
assert!(list.message.contains("disabled"));
let enable = handle_plugins_slash_command(Some("enable"), Some("demo"), &mut manager)
.expect("enable command should succeed");
assert!(enable.reload_runtime);
assert!(enable.message.contains("enabled demo@external"));
assert!(enable.message.contains("Name demo"));
assert!(enable.message.contains("Status enabled"));
let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
.expect("list command should succeed");
assert!(list.message.contains("demo"));
assert!(list.message.contains("enabled"));
let _ = fs::remove_dir_all(config_home);
let _ = fs::remove_dir_all(source_root);
}
#[test]
fn lists_auto_installed_bundled_plugins_with_status() {
let config_home = temp_dir("bundled-home");
let bundled_root = temp_dir("bundled-root");
let bundled_plugin = bundled_root.join("starter");
write_bundled_plugin(&bundled_plugin, "starter", "0.1.0", false);
let mut config = PluginManagerConfig::new(&config_home);
config.bundled_root = Some(bundled_root.clone());
let mut manager = PluginManager::new(config);
let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
.expect("list command should succeed");
assert!(!list.reload_runtime);
assert!(list.message.contains("starter"));
assert!(list.message.contains("v0.1.0"));
assert!(list.message.contains("disabled"));
let _ = fs::remove_dir_all(config_home);
let _ = fs::remove_dir_all(bundled_root);
}
}

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) {
candidates.push(ancestor.join("claude-code"));
candidates.push(ancestor.join("claw-code"));
candidates.push(ancestor.join("clawd-code"));
}
candidates.push(
primary_repo_root
.join("reference-source")
.join("claude-code"),
);
candidates.push(primary_repo_root.join("vendor").join("claude-code"));
candidates.push(primary_repo_root.join("reference-source").join("claw-code"));
candidates.push(primary_repo_root.join("vendor").join("claw-code"));
let mut deduped = Vec::new();
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 = "1"
[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,6 +8,7 @@ publish.workspace = true
[dependencies]
sha2 = "0.10"
glob = "0.3"
plugins = { path = "../plugins" }
regex = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"

View File

@@ -1,5 +1,10 @@
use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
const COMPACT_CONTINUATION_PREAMBLE: &str =
"This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.\n\n";
const COMPACT_RECENT_MESSAGES_NOTE: &str = "Recent messages are preserved verbatim.";
const COMPACT_DIRECT_RESUME_INSTRUCTION: &str = "Continue the conversation from where it left off without asking the user any further questions. Resume directly — do not acknowledge the summary, do not recap what was happening, and do not preface with continuation text.";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CompactionConfig {
pub preserve_recent_messages: usize,
@@ -30,8 +35,15 @@ pub fn estimate_session_tokens(session: &Session) -> usize {
#[must_use]
pub fn should_compact(session: &Session, config: CompactionConfig) -> bool {
session.messages.len() > config.preserve_recent_messages
&& estimate_session_tokens(session) >= config.max_estimated_tokens
let start = compacted_summary_prefix_len(session);
let compactable = &session.messages[start..];
compactable.len() > config.preserve_recent_messages
&& compactable
.iter()
.map(estimate_message_tokens)
.sum::<usize>()
>= config.max_estimated_tokens
}
#[must_use]
@@ -56,16 +68,18 @@ pub fn get_compact_continuation_message(
recent_messages_preserved: bool,
) -> String {
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)
);
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 {
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
@@ -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
.messages
.len()
.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 summary = summarize_messages(removed);
let summary =
merge_compact_summaries(existing_summary.as_deref(), &summarize_messages(removed));
let formatted_summary = format_compact_summary(&summary);
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 {
let user_messages = messages
.iter()
@@ -197,6 +227,41 @@ fn summarize_messages(messages: &[ConversationMessage]) -> String {
lines.join("\n")
}
fn merge_compact_summaries(existing_summary: Option<&str>, new_summary: &str) -> String {
let Some(existing_summary) = existing_summary else {
return new_summary.to_string();
};
let previous_highlights = extract_summary_highlights(existing_summary);
let new_formatted_summary = format_compact_summary(new_summary);
let new_highlights = extract_summary_highlights(&new_formatted_summary);
let new_timeline = extract_summary_timeline(&new_formatted_summary);
let mut lines = vec!["<summary>".to_string(), "Conversation summary:".to_string()];
if !previous_highlights.is_empty() {
lines.push("- Previously compacted context:".to_string());
lines.extend(
previous_highlights
.into_iter()
.map(|line| format!(" {line}")),
);
}
if !new_highlights.is_empty() {
lines.push("- Newly compacted context:".to_string());
lines.extend(new_highlights.into_iter().map(|line| format!(" {line}")));
}
if !new_timeline.is_empty() {
lines.push("- Key timeline:".to_string());
lines.extend(new_timeline.into_iter().map(|line| format!(" {line}")));
}
lines.push("</summary>".to_string());
lines.join("\n")
}
fn summarize_block(block: &ContentBlock) -> String {
let raw = match block {
ContentBlock::Text { text } => text.clone(),
@@ -374,11 +439,71 @@ fn collapse_blank_lines(content: &str) -> String {
result
}
fn extract_existing_compacted_summary(message: &ConversationMessage) -> Option<String> {
if message.role != MessageRole::System {
return None;
}
let text = first_text_block(message)?;
let summary = text.strip_prefix(COMPACT_CONTINUATION_PREAMBLE)?;
let summary = summary
.split_once(&format!("\n\n{COMPACT_RECENT_MESSAGES_NOTE}"))
.map_or(summary, |(value, _)| value);
let summary = summary
.split_once(&format!("\n{COMPACT_DIRECT_RESUME_INSTRUCTION}"))
.map_or(summary, |(value, _)| value);
Some(summary.trim().to_string())
}
fn extract_summary_highlights(summary: &str) -> Vec<String> {
let mut lines = Vec::new();
let mut in_timeline = false;
for line in format_compact_summary(summary).lines() {
let trimmed = line.trim_end();
if trimmed.is_empty() || trimmed == "Summary:" || trimmed == "Conversation summary:" {
continue;
}
if trimmed == "- Key timeline:" {
in_timeline = true;
continue;
}
if in_timeline {
continue;
}
lines.push(trimmed.to_string());
}
lines
}
fn extract_summary_timeline(summary: &str) -> Vec<String> {
let mut lines = Vec::new();
let mut in_timeline = false;
for line in format_compact_summary(summary).lines() {
let trimmed = line.trim_end();
if trimmed == "- Key timeline:" {
in_timeline = true;
continue;
}
if !in_timeline {
continue;
}
if trimmed.is_empty() {
break;
}
lines.push(trimmed.to_string());
}
lines
}
#[cfg(test)]
mod tests {
use super::{
collect_key_files, compact_session, estimate_session_tokens, format_compact_summary,
infer_pending_work, should_compact, CompactionConfig,
get_compact_continuation_message, infer_pending_work, should_compact, CompactionConfig,
};
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]
fn truncates_long_blocks_in_summary() {
let summary = super::summarize_block(&ContentBlock::Text {

View File

@@ -35,14 +35,23 @@ pub struct RuntimeConfig {
feature_config: RuntimeFeatureConfig,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct RuntimePluginConfig {
enabled_plugins: BTreeMap<String, bool>,
external_directories: Vec<String>,
install_root: Option<String>,
registry_path: Option<String>,
bundled_root: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct RuntimeFeatureConfig {
hooks: RuntimeHookConfig,
plugins: RuntimePluginConfig,
mcp: McpConfigCollection,
oauth: Option<OAuthConfig>,
model: Option<String>,
permission_mode: Option<ResolvedPermissionMode>,
permission_rules: RuntimePermissionRuleConfig,
sandbox: SandboxConfig,
}
@@ -50,14 +59,6 @@ pub struct RuntimeFeatureConfig {
pub struct RuntimeHookConfig {
pre_tool_use: Vec<String>,
post_tool_use: Vec<String>,
post_tool_use_failure: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct RuntimePermissionRuleConfig {
allow: Vec<String>,
deny: Vec<String>,
ask: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
@@ -183,13 +184,15 @@ impl ConfigLoader {
#[must_use]
pub fn default_for(cwd: impl Into<PathBuf>) -> Self {
let cwd = cwd.into();
let config_home = std::env::var_os("CLAUDE_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".claude")))
.unwrap_or_else(|| PathBuf::from(".claude"));
let config_home = default_config_home();
Self { cwd, config_home }
}
#[must_use]
pub fn config_home(&self) -> &Path {
&self.config_home
}
#[must_use]
pub fn discover(&self) -> Vec<ConfigEntry> {
let user_legacy_path = self.config_home.parent().map_or_else(
@@ -238,13 +241,13 @@ impl ConfigLoader {
let feature_config = RuntimeFeatureConfig {
hooks: parse_optional_hooks_config(&merged_value)?,
plugins: parse_optional_plugin_config(&merged_value)?,
mcp: McpConfigCollection {
servers: mcp_servers,
},
oauth: parse_optional_oauth_config(&merged_value, "merged settings.oauth")?,
model: parse_optional_model(&merged_value),
permission_mode: parse_optional_permission_mode(&merged_value)?,
permission_rules: parse_optional_permission_rules(&merged_value)?,
sandbox: parse_optional_sandbox_config(&merged_value)?,
};
@@ -301,6 +304,11 @@ impl RuntimeConfig {
&self.feature_config.hooks
}
#[must_use]
pub fn plugins(&self) -> &RuntimePluginConfig {
&self.feature_config.plugins
}
#[must_use]
pub fn oauth(&self) -> Option<&OAuthConfig> {
self.feature_config.oauth.as_ref()
@@ -316,11 +324,6 @@ impl RuntimeConfig {
self.feature_config.permission_mode
}
#[must_use]
pub fn permission_rules(&self) -> &RuntimePermissionRuleConfig {
&self.feature_config.permission_rules
}
#[must_use]
pub fn sandbox(&self) -> &SandboxConfig {
&self.feature_config.sandbox
@@ -334,11 +337,22 @@ impl RuntimeFeatureConfig {
self
}
#[must_use]
pub fn with_plugins(mut self, plugins: RuntimePluginConfig) -> Self {
self.plugins = plugins;
self
}
#[must_use]
pub fn hooks(&self) -> &RuntimeHookConfig {
&self.hooks
}
#[must_use]
pub fn plugins(&self) -> &RuntimePluginConfig {
&self.plugins
}
#[must_use]
pub fn mcp(&self) -> &McpConfigCollection {
&self.mcp
@@ -359,28 +373,65 @@ impl RuntimeFeatureConfig {
self.permission_mode
}
#[must_use]
pub fn permission_rules(&self) -> &RuntimePermissionRuleConfig {
&self.permission_rules
}
#[must_use]
pub fn sandbox(&self) -> &SandboxConfig {
&self.sandbox
}
}
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("CLAUDE_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".claude")))
.unwrap_or_else(|| PathBuf::from(".claude"))
}
impl RuntimeHookConfig {
#[must_use]
pub fn new(
pre_tool_use: Vec<String>,
post_tool_use: Vec<String>,
post_tool_use_failure: Vec<String>,
) -> Self {
pub fn new(pre_tool_use: Vec<String>, post_tool_use: Vec<String>) -> Self {
Self {
pre_tool_use,
post_tool_use,
post_tool_use_failure,
}
}
@@ -395,30 +446,15 @@ impl RuntimeHookConfig {
}
#[must_use]
pub fn post_tool_use_failure(&self) -> &[String] {
&self.post_tool_use_failure
}
}
impl RuntimePermissionRuleConfig {
#[must_use]
pub fn new(allow: Vec<String>, deny: Vec<String>, ask: Vec<String>) -> Self {
Self { allow, deny, ask }
pub fn merged(&self, other: &Self) -> Self {
let mut merged = self.clone();
merged.extend(other);
merged
}
#[must_use]
pub fn allow(&self) -> &[String] {
&self.allow
}
#[must_use]
pub fn deny(&self) -> &[String] {
&self.deny
}
#[must_use]
pub fn ask(&self) -> &[String] {
&self.ask
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());
}
}
@@ -533,33 +569,37 @@ fn parse_optional_hooks_config(root: &JsonValue) -> Result<RuntimeHookConfig, Co
.unwrap_or_default(),
post_tool_use: optional_string_array(hooks, "PostToolUse", "merged settings.hooks")?
.unwrap_or_default(),
post_tool_use_failure: optional_string_array(
hooks,
"PostToolUseFailure",
"merged settings.hooks",
)?
.unwrap_or_default(),
})
}
fn parse_optional_permission_rules(
root: &JsonValue,
) -> Result<RuntimePermissionRuleConfig, ConfigError> {
fn parse_optional_plugin_config(root: &JsonValue) -> Result<RuntimePluginConfig, ConfigError> {
let Some(object) = root.as_object() else {
return Ok(RuntimePermissionRuleConfig::default());
};
let Some(permissions) = object.get("permissions").and_then(JsonValue::as_object) else {
return Ok(RuntimePermissionRuleConfig::default());
return Ok(RuntimePluginConfig::default());
};
Ok(RuntimePermissionRuleConfig {
allow: optional_string_array(permissions, "allow", "merged settings.permissions")?
.unwrap_or_default(),
deny: optional_string_array(permissions, "deny", "merged settings.permissions")?
.unwrap_or_default(),
ask: optional_string_array(permissions, "ask", "merged settings.permissions")?
.unwrap_or_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(
@@ -794,6 +834,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(
object: &BTreeMap<String, JsonValue>,
key: &str,
@@ -868,6 +926,18 @@ fn deep_merge_objects(
}
}
fn extend_unique(target: &mut Vec<String>, values: &[String]) {
for value in values {
push_unique(target, value.clone());
}
}
fn push_unique(target: &mut Vec<String>, value: String) {
if !target.iter().any(|existing| existing == &value) {
target.push(value);
}
}
#[cfg(test)]
mod tests {
use super::{
@@ -921,7 +991,7 @@ mod tests {
.expect("write user compat config");
fs::write(
home.join("settings.json"),
r#"{"model":"sonnet","env":{"A2":"1"},"hooks":{"PreToolUse":["base"]},"permissions":{"defaultMode":"plan","allow":["Read"],"deny":["Bash(rm -rf)"]}}"#,
r#"{"model":"sonnet","env":{"A2":"1"},"hooks":{"PreToolUse":["base"]},"permissions":{"defaultMode":"plan"}}"#,
)
.expect("write user settings");
fs::write(
@@ -931,7 +1001,7 @@ mod tests {
.expect("write project compat config");
fs::write(
cwd.join(".claude").join("settings.json"),
r#"{"env":{"C":"3"},"hooks":{"PostToolUse":["project"],"PostToolUseFailure":["project-failure"]},"permissions":{"ask":["Edit"]},"mcpServers":{"project":{"command":"uvx","args":["project"]}}}"#,
r#"{"env":{"C":"3"},"hooks":{"PostToolUse":["project"]},"mcpServers":{"project":{"command":"uvx","args":["project"]}}}"#,
)
.expect("write project settings");
fs::write(
@@ -976,16 +1046,6 @@ mod tests {
.contains_key("PostToolUse"));
assert_eq!(loaded.hooks().pre_tool_use(), &["base".to_string()]);
assert_eq!(loaded.hooks().post_tool_use(), &["project".to_string()]);
assert_eq!(
loaded.hooks().post_tool_use_failure(),
&["project-failure".to_string()]
);
assert_eq!(loaded.permission_rules().allow(), &["Read".to_string()]);
assert_eq!(
loaded.permission_rules().deny(),
&["Bash(rm -rf)".to_string()]
);
assert_eq!(loaded.permission_rules().ask(), &["Edit".to_string()]);
assert!(loaded.mcp().get("home").is_some());
assert!(loaded.mcp().get("project").is_some());
@@ -1121,6 +1181,96 @@ mod tests {
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn parses_plugin_config_from_enabled_plugins() {
let root = temp_dir();
let cwd = root.join("project");
let home = root.join("home").join(".claude");
fs::create_dir_all(cwd.join(".claude")).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(".claude");
fs::create_dir_all(cwd.join(".claude")).expect("project config dir");
fs::create_dir_all(&home).expect("home config dir");
fs::write(
home.join("settings.json"),
r#"{
"enabledPlugins": {
"core-helpers@builtin": true
},
"plugins": {
"externalDirectories": ["./external-plugins"],
"installRoot": "plugin-cache/installed",
"registryPath": "plugin-cache/installed.json",
"bundledRoot": "./bundled-plugins"
}
}"#,
)
.expect("write plugin settings");
let loaded = ConfigLoader::new(&cwd, &home)
.load()
.expect("config should load");
assert_eq!(
loaded
.plugins()
.enabled_plugins()
.get("core-helpers@builtin"),
Some(&true)
);
assert_eq!(
loaded.plugins().external_directories(),
&["./external-plugins".to_string()]
);
assert_eq!(
loaded.plugins().install_root(),
Some("plugin-cache/installed")
);
assert_eq!(
loaded.plugins().registry_path(),
Some("plugin-cache/installed.json")
);
assert_eq!(loaded.plugins().bundled_root(), Some("./bundled-plugins"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn rejects_invalid_mcp_server_shapes() {
let root = temp_dir();

View File

@@ -1,17 +1,20 @@
use std::collections::BTreeMap;
use std::fmt::{Display, Formatter};
use plugins::{HookRunner as PluginHookRunner, PluginRegistry};
use crate::compact::{
compact_session, estimate_session_tokens, CompactionConfig, CompactionResult,
};
use crate::config::RuntimeFeatureConfig;
use crate::hooks::{HookAbortSignal, HookProgressReporter, HookRunResult, HookRunner};
use crate::permissions::{
PermissionContext, PermissionOutcome, PermissionPolicy, PermissionPrompter,
};
use crate::hooks::HookRunner;
use crate::permissions::{PermissionOutcome, PermissionPolicy, PermissionPrompter};
use crate::session::{ContentBlock, ConversationMessage, Session};
use crate::usage::{TokenUsage, UsageTracker};
const DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD: u32 = 200_000;
const AUTO_COMPACTION_THRESHOLD_ENV_VAR: &str = "CLAUDE_CODE_AUTO_COMPACT_INPUT_TOKENS";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ApiRequest {
pub system_prompt: Vec<String>,
@@ -88,6 +91,12 @@ pub struct TurnSummary {
pub tool_results: Vec<ConversationMessage>,
pub iterations: usize,
pub usage: TokenUsage,
pub auto_compaction: Option<AutoCompactionEvent>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AutoCompactionEvent {
pub removed_message_count: usize,
}
pub struct ConversationRuntime<C, T> {
@@ -99,8 +108,25 @@ pub struct ConversationRuntime<C, T> {
max_iterations: usize,
usage_tracker: UsageTracker,
hook_runner: HookRunner,
hook_abort_signal: HookAbortSignal,
hook_progress_reporter: Option<Box<dyn HookProgressReporter>>,
auto_compaction_input_tokens_threshold: u32,
plugin_hook_runner: Option<PluginHookRunner>,
plugin_registry: Option<PluginRegistry>,
plugins_shutdown: bool,
}
impl<C, T> ConversationRuntime<C, T> {
fn shutdown_registered_plugins(&mut self) -> Result<(), RuntimeError> {
if self.plugins_shutdown {
return Ok(());
}
if let Some(registry) = &self.plugin_registry {
registry
.shutdown()
.map_err(|error| RuntimeError::new(format!("plugin shutdown failed: {error}")))?;
}
self.plugins_shutdown = true;
Ok(())
}
}
impl<C, T> ConversationRuntime<C, T>
@@ -146,11 +172,43 @@ where
max_iterations: usize::MAX,
usage_tracker,
hook_runner: HookRunner::from_feature_config(&feature_config),
hook_abort_signal: HookAbortSignal::default(),
hook_progress_reporter: None,
auto_compaction_input_tokens_threshold: auto_compaction_threshold_from_env(),
plugin_hook_runner: None,
plugin_registry: None,
plugins_shutdown: false,
}
}
#[allow(clippy::needless_pass_by_value)]
pub fn new_with_plugins(
session: Session,
api_client: C,
tool_executor: T,
permission_policy: PermissionPolicy,
system_prompt: Vec<String>,
feature_config: RuntimeFeatureConfig,
plugin_registry: PluginRegistry,
) -> Result<Self, RuntimeError> {
let plugin_hook_runner =
PluginHookRunner::from_registry(&plugin_registry).map_err(|error| {
RuntimeError::new(format!("plugin hook registration failed: {error}"))
})?;
plugin_registry
.initialize()
.map_err(|error| RuntimeError::new(format!("plugin initialization failed: {error}")))?;
let mut runtime = Self::new_with_features(
session,
api_client,
tool_executor,
permission_policy,
system_prompt,
feature_config,
);
runtime.plugin_hook_runner = Some(plugin_hook_runner);
runtime.plugin_registry = Some(plugin_registry);
Ok(runtime)
}
#[must_use]
pub fn with_max_iterations(mut self, max_iterations: usize) -> Self {
self.max_iterations = max_iterations;
@@ -158,91 +216,11 @@ where
}
#[must_use]
pub fn with_hook_abort_signal(mut self, hook_abort_signal: HookAbortSignal) -> Self {
self.hook_abort_signal = hook_abort_signal;
pub fn with_auto_compaction_input_tokens_threshold(mut self, threshold: u32) -> Self {
self.auto_compaction_input_tokens_threshold = threshold;
self
}
#[must_use]
pub fn with_hook_progress_reporter(
mut self,
hook_progress_reporter: Box<dyn HookProgressReporter>,
) -> Self {
self.hook_progress_reporter = Some(hook_progress_reporter);
self
}
fn run_pre_tool_use_hook(&mut self, tool_name: &str, input: &str) -> HookRunResult {
if let Some(reporter) = self.hook_progress_reporter.as_mut() {
self.hook_runner.run_pre_tool_use_with_context(
tool_name,
input,
Some(&self.hook_abort_signal),
Some(reporter.as_mut()),
)
} else {
self.hook_runner.run_pre_tool_use_with_context(
tool_name,
input,
Some(&self.hook_abort_signal),
None,
)
}
}
fn run_post_tool_use_hook(
&mut self,
tool_name: &str,
input: &str,
output: &str,
is_error: bool,
) -> HookRunResult {
if let Some(reporter) = self.hook_progress_reporter.as_mut() {
self.hook_runner.run_post_tool_use_with_context(
tool_name,
input,
output,
is_error,
Some(&self.hook_abort_signal),
Some(reporter.as_mut()),
)
} else {
self.hook_runner.run_post_tool_use_with_context(
tool_name,
input,
output,
is_error,
Some(&self.hook_abort_signal),
None,
)
}
}
fn run_post_tool_use_failure_hook(
&mut self,
tool_name: &str,
input: &str,
output: &str,
) -> HookRunResult {
if let Some(reporter) = self.hook_progress_reporter.as_mut() {
self.hook_runner.run_post_tool_use_failure_with_context(
tool_name,
input,
output,
Some(&self.hook_abort_signal),
Some(reporter.as_mut()),
)
} else {
self.hook_runner.run_post_tool_use_failure_with_context(
tool_name,
input,
output,
Some(&self.hook_abort_signal),
None,
)
}
}
#[allow(clippy::too_many_lines)]
pub fn run_turn(
&mut self,
@@ -256,6 +234,7 @@ where
let mut assistant_messages = Vec::new();
let mut tool_results = Vec::new();
let mut iterations = 0;
let mut max_turn_input_tokens = 0;
loop {
iterations += 1;
@@ -272,6 +251,7 @@ where
let events = self.api_client.stream(request)?;
let (assistant_message, usage) = build_assistant_message(events)?;
if let Some(usage) = usage {
max_turn_input_tokens = max_turn_input_tokens.max(usage.input_tokens);
self.usage_tracker.record(usage);
}
let pending_tool_uses = assistant_message
@@ -293,96 +273,108 @@ where
}
for (tool_use_id, tool_name, input) in pending_tool_uses {
let pre_hook_result = self.run_pre_tool_use_hook(&tool_name, &input);
let effective_input = pre_hook_result
.updated_input()
.map_or_else(|| input.clone(), ToOwned::to_owned);
let permission_context = PermissionContext::new(
pre_hook_result.permission_override(),
pre_hook_result.permission_reason().map(ToOwned::to_owned),
);
let permission_outcome = if pre_hook_result.is_cancelled() {
PermissionOutcome::Deny {
reason: format_hook_message(
&pre_hook_result,
&format!("PreToolUse hook cancelled tool `{tool_name}`"),
),
}
} else if pre_hook_result.is_denied() {
PermissionOutcome::Deny {
reason: format_hook_message(
&pre_hook_result,
&format!("PreToolUse hook denied tool `{tool_name}`"),
),
}
} else if let Some(prompt) = prompter.as_mut() {
self.permission_policy.authorize_with_context(
&tool_name,
&effective_input,
&permission_context,
Some(*prompt),
)
let permission_outcome = if let Some(prompt) = prompter.as_mut() {
self.permission_policy
.authorize(&tool_name, &input, Some(*prompt))
} else {
self.permission_policy.authorize_with_context(
&tool_name,
&effective_input,
&permission_context,
None,
)
self.permission_policy.authorize(&tool_name, &input, None)
};
let result_message = match permission_outcome {
PermissionOutcome::Allow => {
let (mut output, mut is_error) =
match self.tool_executor.execute(&tool_name, &effective_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 = if is_error {
self.run_post_tool_use_failure_hook(
&tool_name,
&effective_input,
&output,
let pre_hook_result = self.hook_runner.run_pre_tool_use(&tool_name, &input);
if pre_hook_result.is_denied() {
let deny_message = format!("PreToolUse hook denied tool `{tool_name}`");
ConversationMessage::tool_result(
tool_use_id,
tool_name,
format_hook_message(pre_hook_result.messages(), &deny_message),
true,
)
} else {
self.run_post_tool_use_hook(
&tool_name,
&effective_input,
&output,
false,
)
};
if post_hook_result.is_denied() || post_hook_result.is_cancelled() {
is_error = true;
}
output = merge_hook_feedback(
post_hook_result.messages(),
output,
post_hook_result.is_denied() || post_hook_result.is_cancelled(),
);
let plugin_pre_hook_result =
self.run_plugin_pre_tool_use(&tool_name, &input);
if plugin_pre_hook_result.is_denied() {
let deny_message =
format!("PreToolUse hook denied tool `{tool_name}`");
let mut messages = pre_hook_result.messages().to_vec();
messages.extend(plugin_pre_hook_result.messages().iter().cloned());
ConversationMessage::tool_result(
tool_use_id,
tool_name,
format_hook_message(&messages, &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);
output = merge_hook_feedback(
plugin_pre_hook_result.messages(),
output,
false,
);
ConversationMessage::tool_result(tool_use_id, tool_name, output, is_error)
let hook_output = output.clone();
let post_hook_result = self.hook_runner.run_post_tool_use(
&tool_name,
&input,
&hook_output,
is_error,
);
let plugin_post_hook_result = self.run_plugin_post_tool_use(
&tool_name,
&input,
&hook_output,
is_error,
);
if post_hook_result.is_denied() {
is_error = true;
}
if plugin_post_hook_result.is_denied() {
is_error = true;
}
output = merge_hook_feedback(
post_hook_result.messages(),
output,
post_hook_result.is_denied(),
);
output = merge_hook_feedback(
plugin_post_hook_result.messages(),
output,
plugin_post_hook_result.is_denied(),
);
ConversationMessage::tool_result(
tool_use_id,
tool_name,
output,
is_error,
)
}
}
}
PermissionOutcome::Deny { reason } => {
ConversationMessage::tool_result(tool_use_id, tool_name, reason, true)
}
PermissionOutcome::Deny { reason } => ConversationMessage::tool_result(
tool_use_id,
tool_name,
merge_hook_feedback(pre_hook_result.messages(), reason, true),
true,
),
};
self.session.messages.push(result_message.clone());
tool_results.push(result_message);
}
}
let auto_compaction = self.maybe_auto_compact(max_turn_input_tokens);
Ok(TurnSummary {
assistant_messages,
tool_results,
iterations,
usage: self.usage_tracker.cumulative_usage(),
auto_compaction,
})
}
@@ -407,9 +399,81 @@ where
}
#[must_use]
pub fn into_session(self) -> Session {
self.session
pub fn into_session(mut self) -> Session {
let _ = self.shutdown_registered_plugins();
std::mem::take(&mut self.session)
}
pub fn shutdown_plugins(&mut self) -> Result<(), RuntimeError> {
self.shutdown_registered_plugins()
}
fn run_plugin_pre_tool_use(&self, tool_name: &str, input: &str) -> plugins::HookRunResult {
self.plugin_hook_runner.as_ref().map_or_else(
|| plugins::HookRunResult::allow(Vec::new()),
|runner| runner.run_pre_tool_use(tool_name, input),
)
}
fn run_plugin_post_tool_use(
&self,
tool_name: &str,
input: &str,
output: &str,
is_error: bool,
) -> plugins::HookRunResult {
self.plugin_hook_runner.as_ref().map_or_else(
|| plugins::HookRunResult::allow(Vec::new()),
|runner| runner.run_post_tool_use(tool_name, input, output, is_error),
)
}
fn maybe_auto_compact(&mut self, turn_input_tokens: u32) -> Option<AutoCompactionEvent> {
if turn_input_tokens < self.auto_compaction_input_tokens_threshold {
return None;
}
let result = compact_session(
&self.session,
CompactionConfig {
max_estimated_tokens: usize::try_from(self.auto_compaction_input_tokens_threshold)
.unwrap_or(usize::MAX),
..CompactionConfig::default()
},
);
if result.removed_message_count == 0 {
return None;
}
self.session = result.compacted_session;
Some(AutoCompactionEvent {
removed_message_count: result.removed_message_count,
})
}
}
impl<C, T> Drop for ConversationRuntime<C, T> {
fn drop(&mut self) {
let _ = self.shutdown_registered_plugins();
}
}
#[must_use]
pub fn auto_compaction_threshold_from_env() -> u32 {
parse_auto_compaction_threshold(
std::env::var(AUTO_COMPACTION_THRESHOLD_ENV_VAR)
.ok()
.as_deref(),
)
}
#[must_use]
fn parse_auto_compaction_threshold(value: Option<&str>) -> u32 {
value
.and_then(|raw| raw.trim().parse::<u32>().ok())
.filter(|threshold| *threshold > 0)
.unwrap_or(DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD)
}
fn build_assistant_message(
@@ -459,11 +523,11 @@ 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() {
fn format_hook_message(messages: &[String], fallback: &str) -> String {
if messages.is_empty() {
fallback.to_string()
} else {
result.messages().join("\n")
messages.join("\n")
}
}
@@ -520,8 +584,9 @@ impl ToolExecutor for StaticToolExecutor {
#[cfg(test)]
mod tests {
use super::{
ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError,
StaticToolExecutor,
parse_auto_compaction_threshold, ApiClient, ApiRequest, AssistantEvent,
AutoCompactionEvent, ConversationRuntime, RuntimeError, StaticToolExecutor,
DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD,
};
use crate::compact::CompactionConfig;
use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
@@ -532,7 +597,13 @@ mod tests {
use crate::prompt::{ProjectContext, SystemPromptBuilder};
use crate::session::{ContentBlock, MessageRole, Session};
use crate::usage::TokenUsage;
use plugins::{PluginManager, PluginManagerConfig};
use std::fs;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
struct ScriptedApiClient {
call_count: usize,
@@ -594,6 +665,68 @@ mod tests {
}
}
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!("runtime-plugin-{label}-{nanos}"))
}
fn write_lifecycle_plugin(root: &Path, name: &str) -> PathBuf {
fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
fs::create_dir_all(root.join("lifecycle")).expect("lifecycle dir");
let log_path = root.join("lifecycle.log");
fs::write(
root.join("lifecycle").join("init.sh"),
"#!/bin/sh\nprintf 'init\\n' >> lifecycle.log\n",
)
.expect("write init script");
fs::write(
root.join("lifecycle").join("shutdown.sh"),
"#!/bin/sh\nprintf 'shutdown\\n' >> lifecycle.log\n",
)
.expect("write shutdown script");
fs::write(
root.join(".claude-plugin").join("plugin.json"),
format!(
"{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"runtime lifecycle plugin\",\n \"lifecycle\": {{\n \"Init\": [\"./lifecycle/init.sh\"],\n \"Shutdown\": [\"./lifecycle/shutdown.sh\"]\n }}\n}}"
),
)
.expect("write plugin manifest");
log_path
}
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");
#[cfg(unix)]
{
let exec_mode = fs::Permissions::from_mode(0o755);
fs::set_permissions(root.join("hooks").join("pre.sh"), exec_mode.clone())
.expect("chmod pre hook");
fs::set_permissions(root.join("hooks").join("post.sh"), exec_mode)
.expect("chmod post hook");
}
fs::write(
root.join(".claude-plugin").join("plugin.json"),
format!(
"{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"runtime hook plugin\",\n \"hooks\": {{\n \"PreToolUse\": [\"./hooks/pre.sh\"],\n \"PostToolUse\": [\"./hooks/post.sh\"]\n }}\n}}"
),
)
.expect("write plugin manifest");
}
#[test]
fn runs_user_to_tool_to_result_loop_end_to_end_and_tracks_usage() {
let api_client = ScriptedApiClient { call_count: 0 };
@@ -632,6 +765,7 @@ mod tests {
assert_eq!(summary.tool_results.len(), 1);
assert_eq!(runtime.session().messages.len(), 4);
assert_eq!(summary.usage.output_tokens, 10);
assert_eq!(summary.auto_compaction, None);
assert!(matches!(
runtime.session().messages[1].blocks[1],
ContentBlock::ToolUse { .. }
@@ -736,7 +870,6 @@ mod tests {
RuntimeFeatureConfig::default().with_hooks(RuntimeHookConfig::new(
vec![shell_snippet("printf 'blocked by hook'; exit 2")],
Vec::new(),
Vec::new(),
)),
);
@@ -803,7 +936,6 @@ mod tests {
RuntimeFeatureConfig::default().with_hooks(RuntimeHookConfig::new(
vec![shell_snippet("printf 'pre hook ran'")],
vec![shell_snippet("printf 'post hook ran'")],
Vec::new(),
)),
);
@@ -836,6 +968,153 @@ mod tests {
);
}
#[test]
fn initializes_and_shuts_down_plugins_with_runtime_lifecycle() {
let config_home = temp_dir("config");
let source_root = temp_dir("source");
let _ = write_lifecycle_plugin(&source_root, "runtime-lifecycle");
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
let install = manager
.install(source_root.to_str().expect("utf8 path"))
.expect("install should succeed");
let log_path = install.install_path.join("lifecycle.log");
let registry = manager.plugin_registry().expect("registry should load");
{
let runtime = ConversationRuntime::new_with_plugins(
Session::new(),
ScriptedApiClient { call_count: 0 },
StaticToolExecutor::new().register("add", |_input| Ok("4".to_string())),
PermissionPolicy::new(PermissionMode::WorkspaceWrite),
vec!["system".to_string()],
RuntimeFeatureConfig::default(),
registry,
)
.expect("runtime should initialize plugins");
let log = fs::read_to_string(&log_path).expect("init log should exist");
assert_eq!(log, "init\n");
drop(runtime);
}
let log = fs::read_to_string(&log_path).expect("shutdown log should exist");
assert_eq!(log, "init\nshutdown\n");
let _ = fs::remove_dir_all(config_home);
let _ = fs::remove_dir_all(source_root);
}
#[test]
fn executes_hooks_from_installed_plugins_during_tool_use() {
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 config_home = temp_dir("hook-config");
let first_source_root = temp_dir("hook-source-a");
let second_source_root = temp_dir("hook-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 load");
let mut runtime = ConversationRuntime::new_with_plugins(
Session::new(),
TwoCallApiClient { calls: 0 },
StaticToolExecutor::new().register("add", |_input| Ok("4".to_string())),
PermissionPolicy::new(PermissionMode::DangerFullAccess),
vec!["system".to_string()],
RuntimeFeatureConfig::default(),
registry,
)
.expect("runtime should load plugin hooks");
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,
"plugin hooks should not force an error: {output:?}"
);
assert!(
output.contains('4'),
"tool output missing value: {output:?}"
);
assert!(
output.contains("plugin pre one"),
"tool output missing first pre hook feedback: {output:?}"
);
assert!(
output.contains("plugin pre two"),
"tool output missing second pre hook feedback: {output:?}"
);
assert!(
output.contains("plugin post one"),
"tool output missing first post hook feedback: {output:?}"
);
assert!(
output.contains("plugin post two"),
"tool output missing second post hook feedback: {output:?}"
);
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 reconstructs_usage_tracker_from_restored_session() {
struct SimpleApi;
@@ -924,4 +1203,177 @@ mod tests {
fn shell_snippet(script: &str) -> String {
script.to_string()
}
#[test]
fn auto_compacts_when_turn_input_threshold_is_crossed() {
struct SimpleApi;
impl ApiClient for SimpleApi {
fn stream(
&mut self,
_request: ApiRequest,
) -> Result<Vec<AssistantEvent>, RuntimeError> {
Ok(vec![
AssistantEvent::TextDelta("done".to_string()),
AssistantEvent::Usage(TokenUsage {
input_tokens: 120_000,
output_tokens: 4,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
}),
AssistantEvent::MessageStop,
])
}
}
let session = Session {
version: 1,
messages: vec![
crate::session::ConversationMessage::user_text("one ".repeat(30_000)),
crate::session::ConversationMessage::assistant(vec![ContentBlock::Text {
text: "two ".repeat(30_000),
}]),
crate::session::ConversationMessage::user_text("three ".repeat(30_000)),
crate::session::ConversationMessage::assistant(vec![ContentBlock::Text {
text: "four ".repeat(30_000),
}]),
],
};
let mut runtime = ConversationRuntime::new(
session,
SimpleApi,
StaticToolExecutor::new(),
PermissionPolicy::new(PermissionMode::DangerFullAccess),
vec!["system".to_string()],
)
.with_auto_compaction_input_tokens_threshold(100_000);
let summary = runtime
.run_turn("trigger", None)
.expect("turn should succeed");
assert_eq!(
summary.auto_compaction,
Some(AutoCompactionEvent {
removed_message_count: 2,
})
);
assert_eq!(runtime.session().messages[0].role, MessageRole::System);
}
#[test]
fn auto_compaction_does_not_repeat_after_context_is_already_compacted() {
struct SequentialUsageApi {
call_count: usize,
}
impl ApiClient for SequentialUsageApi {
fn stream(
&mut self,
_request: ApiRequest,
) -> Result<Vec<AssistantEvent>, RuntimeError> {
self.call_count += 1;
let input_tokens = if self.call_count == 1 { 120_000 } else { 64 };
Ok(vec![
AssistantEvent::TextDelta("done".to_string()),
AssistantEvent::Usage(TokenUsage {
input_tokens,
output_tokens: 4,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
}),
AssistantEvent::MessageStop,
])
}
}
let session = Session {
version: 1,
messages: vec![
crate::session::ConversationMessage::user_text("one ".repeat(30_000)),
crate::session::ConversationMessage::assistant(vec![ContentBlock::Text {
text: "two ".repeat(30_000),
}]),
crate::session::ConversationMessage::user_text("three ".repeat(30_000)),
crate::session::ConversationMessage::assistant(vec![ContentBlock::Text {
text: "four ".repeat(30_000),
}]),
],
};
let mut runtime = ConversationRuntime::new(
session,
SequentialUsageApi { call_count: 0 },
StaticToolExecutor::new(),
PermissionPolicy::new(PermissionMode::DangerFullAccess),
vec!["system".to_string()],
)
.with_auto_compaction_input_tokens_threshold(100_000);
let first = runtime
.run_turn("trigger", None)
.expect("first turn should succeed");
assert_eq!(
first.auto_compaction,
Some(AutoCompactionEvent {
removed_message_count: 2,
})
);
let second = runtime
.run_turn("continue", None)
.expect("second turn should succeed");
assert_eq!(second.auto_compaction, None);
assert_eq!(runtime.session().messages[0].role, MessageRole::System);
}
#[test]
fn skips_auto_compaction_below_threshold() {
struct SimpleApi;
impl ApiClient for SimpleApi {
fn stream(
&mut self,
_request: ApiRequest,
) -> Result<Vec<AssistantEvent>, RuntimeError> {
Ok(vec![
AssistantEvent::TextDelta("done".to_string()),
AssistantEvent::Usage(TokenUsage {
input_tokens: 99_999,
output_tokens: 4,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
}),
AssistantEvent::MessageStop,
])
}
}
let mut runtime = ConversationRuntime::new(
Session::new(),
SimpleApi,
StaticToolExecutor::new(),
PermissionPolicy::new(PermissionMode::DangerFullAccess),
vec!["system".to_string()],
)
.with_auto_compaction_input_tokens_threshold(100_000);
let summary = runtime
.run_turn("trigger", None)
.expect("turn should succeed");
assert_eq!(summary.auto_compaction, None);
assert_eq!(runtime.session().messages.len(), 2);
}
#[test]
fn auto_compaction_threshold_defaults_and_parses_values() {
assert_eq!(
parse_auto_compaction_threshold(None),
DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD
);
assert_eq!(parse_auto_compaction_threshold(Some("4321")), 4321);
assert_eq!(
parse_auto_compaction_threshold(Some("not-a-number")),
DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD
);
}
}

View File

@@ -1,90 +1,30 @@
use std::ffi::OsStr;
use std::io::Write;
use std::process::{Command, Stdio};
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
use std::thread;
use std::time::Duration;
use std::path::Path;
use std::process::Command;
use serde_json::{json, Value};
use serde_json::json;
use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
use crate::permissions::PermissionOverride;
pub type HookPermissionDecision = PermissionOverride;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookEvent {
PreToolUse,
PostToolUse,
PostToolUseFailure,
}
impl HookEvent {
#[must_use]
pub fn as_str(self) -> &'static str {
fn as_str(self) -> &'static str {
match self {
Self::PreToolUse => "PreToolUse",
Self::PostToolUse => "PostToolUse",
Self::PostToolUseFailure => "PostToolUseFailure",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HookProgressEvent {
Started {
event: HookEvent,
tool_name: String,
command: String,
},
Completed {
event: HookEvent,
tool_name: String,
command: String,
},
Cancelled {
event: HookEvent,
tool_name: String,
command: String,
},
}
pub trait HookProgressReporter {
fn on_event(&mut self, event: &HookProgressEvent);
}
#[derive(Debug, Clone, Default)]
pub struct HookAbortSignal {
aborted: Arc<AtomicBool>,
}
impl HookAbortSignal {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn abort(&self) {
self.aborted.store(true, Ordering::SeqCst);
}
#[must_use]
pub fn is_aborted(&self) -> bool {
self.aborted.load(Ordering::SeqCst)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HookRunResult {
denied: bool,
cancelled: bool,
messages: Vec<String>,
permission_override: Option<PermissionOverride>,
permission_reason: Option<String>,
updated_input: Option<String>,
}
impl HookRunResult {
@@ -92,11 +32,7 @@ impl HookRunResult {
pub fn allow(messages: Vec<String>) -> Self {
Self {
denied: false,
cancelled: false,
messages,
permission_override: None,
permission_reason: None,
updated_input: None,
}
}
@@ -105,40 +41,10 @@ impl HookRunResult {
self.denied
}
#[must_use]
pub fn is_cancelled(&self) -> bool {
self.cancelled
}
#[must_use]
pub fn messages(&self) -> &[String] {
&self.messages
}
#[must_use]
pub fn permission_override(&self) -> Option<PermissionOverride> {
self.permission_override
}
#[must_use]
pub fn permission_decision(&self) -> Option<HookPermissionDecision> {
self.permission_override
}
#[must_use]
pub fn permission_reason(&self) -> Option<&str> {
self.permission_reason.as_deref()
}
#[must_use]
pub fn updated_input(&self) -> Option<&str> {
self.updated_input.as_deref()
}
#[must_use]
pub fn updated_input_json(&self) -> Option<&str> {
self.updated_input()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
@@ -159,39 +65,16 @@ impl HookRunner {
#[must_use]
pub fn run_pre_tool_use(&self, tool_name: &str, tool_input: &str) -> HookRunResult {
self.run_pre_tool_use_with_context(tool_name, tool_input, None, None)
}
#[must_use]
pub fn run_pre_tool_use_with_context(
&self,
tool_name: &str,
tool_input: &str,
abort_signal: Option<&HookAbortSignal>,
reporter: Option<&mut dyn HookProgressReporter>,
) -> HookRunResult {
Self::run_commands(
self.run_commands(
HookEvent::PreToolUse,
self.config.pre_tool_use(),
tool_name,
tool_input,
None,
false,
abort_signal,
reporter,
)
}
#[must_use]
pub fn run_pre_tool_use_with_signal(
&self,
tool_name: &str,
tool_input: &str,
abort_signal: Option<&HookAbortSignal>,
) -> HookRunResult {
self.run_pre_tool_use_with_context(tool_name, tool_input, abort_signal, None)
}
#[must_use]
pub fn run_post_tool_use(
&self,
@@ -200,147 +83,43 @@ impl HookRunner {
tool_output: &str,
is_error: bool,
) -> HookRunResult {
self.run_post_tool_use_with_context(
tool_name,
tool_input,
tool_output,
is_error,
None,
None,
)
}
#[must_use]
pub fn run_post_tool_use_with_context(
&self,
tool_name: &str,
tool_input: &str,
tool_output: &str,
is_error: bool,
abort_signal: Option<&HookAbortSignal>,
reporter: Option<&mut dyn HookProgressReporter>,
) -> HookRunResult {
Self::run_commands(
self.run_commands(
HookEvent::PostToolUse,
self.config.post_tool_use(),
tool_name,
tool_input,
Some(tool_output),
is_error,
abort_signal,
reporter,
)
}
#[must_use]
pub fn run_post_tool_use_with_signal(
&self,
tool_name: &str,
tool_input: &str,
tool_output: &str,
is_error: bool,
abort_signal: Option<&HookAbortSignal>,
) -> HookRunResult {
self.run_post_tool_use_with_context(
tool_name,
tool_input,
tool_output,
is_error,
abort_signal,
None,
)
}
#[must_use]
pub fn run_post_tool_use_failure(
&self,
tool_name: &str,
tool_input: &str,
tool_error: &str,
) -> HookRunResult {
self.run_post_tool_use_failure_with_context(tool_name, tool_input, tool_error, None, None)
}
#[must_use]
pub fn run_post_tool_use_failure_with_context(
&self,
tool_name: &str,
tool_input: &str,
tool_error: &str,
abort_signal: Option<&HookAbortSignal>,
reporter: Option<&mut dyn HookProgressReporter>,
) -> HookRunResult {
Self::run_commands(
HookEvent::PostToolUseFailure,
self.config.post_tool_use_failure(),
tool_name,
tool_input,
Some(tool_error),
true,
abort_signal,
reporter,
)
}
#[must_use]
pub fn run_post_tool_use_failure_with_signal(
&self,
tool_name: &str,
tool_input: &str,
tool_error: &str,
abort_signal: Option<&HookAbortSignal>,
) -> HookRunResult {
self.run_post_tool_use_failure_with_context(
tool_name,
tool_input,
tool_error,
abort_signal,
None,
)
}
#[allow(clippy::too_many_arguments)]
fn run_commands(
&self,
event: HookEvent,
commands: &[String],
tool_name: &str,
tool_input: &str,
tool_output: Option<&str>,
is_error: bool,
abort_signal: Option<&HookAbortSignal>,
mut reporter: Option<&mut dyn HookProgressReporter>,
) -> HookRunResult {
if commands.is_empty() {
return HookRunResult::allow(Vec::new());
}
if abort_signal.is_some_and(HookAbortSignal::is_aborted) {
return HookRunResult {
denied: false,
cancelled: true,
messages: vec![format!(
"{} hook cancelled before execution",
event.as_str()
)],
permission_override: None,
permission_reason: None,
updated_input: None,
};
}
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 payload = hook_payload(event, tool_name, tool_input, tool_output, is_error).to_string();
let mut result = HookRunResult::allow(Vec::new());
let mut messages = Vec::new();
for command in commands {
if let Some(reporter) = reporter.as_deref_mut() {
reporter.on_event(&HookProgressEvent::Started {
event,
tool_name: tool_name.to_string(),
command: command.clone(),
});
}
match Self::run_command(
match self.run_command(
command,
event,
tool_name,
@@ -348,60 +127,32 @@ impl HookRunner {
tool_output,
is_error,
&payload,
abort_signal,
) {
HookCommandOutcome::Allow { parsed } => {
if let Some(reporter) = reporter.as_deref_mut() {
reporter.on_event(&HookProgressEvent::Completed {
event,
tool_name: tool_name.to_string(),
command: command.clone(),
});
HookCommandOutcome::Allow { message } => {
if let Some(message) = message {
messages.push(message);
}
merge_parsed_hook_output(&mut result, parsed);
}
HookCommandOutcome::Deny { parsed } => {
if let Some(reporter) = reporter.as_deref_mut() {
reporter.on_event(&HookProgressEvent::Completed {
event,
tool_name: tool_name.to_string(),
command: command.clone(),
});
}
merge_parsed_hook_output(&mut result, parsed);
result.denied = true;
return result;
}
HookCommandOutcome::Warn { message } => {
if let Some(reporter) = reporter.as_deref_mut() {
reporter.on_event(&HookProgressEvent::Completed {
event,
tool_name: tool_name.to_string(),
command: command.clone(),
});
}
result.messages.push(message);
}
HookCommandOutcome::Cancelled { message } => {
if let Some(reporter) = reporter.as_deref_mut() {
reporter.on_event(&HookProgressEvent::Cancelled {
event,
tool_name: tool_name.to_string(),
command: command.clone(),
});
}
result.cancelled = true;
result.messages.push(message);
return result;
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),
}
}
result
HookRunResult::allow(messages)
}
#[allow(clippy::too_many_arguments)]
#[allow(clippy::too_many_arguments, clippy::unused_self)]
fn run_command(
&self,
command: &str,
event: HookEvent,
tool_name: &str,
@@ -409,12 +160,11 @@ impl HookRunner {
tool_output: Option<&str>,
is_error: bool,
payload: &str,
abort_signal: Option<&HookAbortSignal>,
) -> HookCommandOutcome {
let mut child = shell_command(command);
child.stdin(Stdio::piped());
child.stdout(Stdio::piped());
child.stderr(Stdio::piped());
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);
@@ -423,30 +173,19 @@ impl HookRunner {
child.env("HOOK_TOOL_OUTPUT", tool_output);
}
match child.output_with_stdin(payload.as_bytes(), abort_signal) {
Ok(CommandExecution::Finished(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 parsed = parse_hook_output(&stdout);
let message = (!stdout.is_empty()).then_some(stdout);
match output.status.code() {
Some(0) => {
if parsed.deny {
HookCommandOutcome::Deny { parsed }
} else {
HookCommandOutcome::Allow { parsed }
}
}
Some(2) => HookCommandOutcome::Deny {
parsed: parsed.with_fallback_message(format!(
"{} hook denied tool `{tool_name}`",
event.as_str()
)),
},
Some(0) => HookCommandOutcome::Allow { message },
Some(2) => HookCommandOutcome::Deny { message },
Some(code) => HookCommandOutcome::Warn {
message: format_hook_warning(
command,
code,
parsed.primary_message(),
message.as_deref(),
stderr.as_str(),
),
},
@@ -458,12 +197,6 @@ impl HookRunner {
},
}
}
Ok(CommandExecution::Cancelled) => HookCommandOutcome::Cancelled {
message: format!(
"{} hook `{command}` cancelled while handling `{tool_name}`",
event.as_str()
),
},
Err(error) => HookCommandOutcome::Warn {
message: format!(
"{} hook `{command}` failed to start for `{tool_name}`: {error}",
@@ -475,131 +208,12 @@ impl HookRunner {
}
enum HookCommandOutcome {
Allow { parsed: ParsedHookOutput },
Deny { parsed: ParsedHookOutput },
Allow { message: Option<String> },
Deny { message: Option<String> },
Warn { message: String },
Cancelled { message: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
struct ParsedHookOutput {
messages: Vec<String>,
deny: bool,
permission_override: Option<PermissionOverride>,
permission_reason: Option<String>,
updated_input: Option<String>,
}
impl ParsedHookOutput {
fn with_fallback_message(mut self, fallback: String) -> Self {
if self.messages.is_empty() {
self.messages.push(fallback);
}
self
}
fn primary_message(&self) -> Option<&str> {
self.messages.first().map(String::as_str)
}
}
fn merge_parsed_hook_output(target: &mut HookRunResult, parsed: ParsedHookOutput) {
target.messages.extend(parsed.messages);
if parsed.permission_override.is_some() {
target.permission_override = parsed.permission_override;
}
if parsed.permission_reason.is_some() {
target.permission_reason = parsed.permission_reason;
}
if parsed.updated_input.is_some() {
target.updated_input = parsed.updated_input;
}
}
fn parse_hook_output(stdout: &str) -> ParsedHookOutput {
if stdout.is_empty() {
return ParsedHookOutput::default();
}
let Ok(Value::Object(root)) = serde_json::from_str::<Value>(stdout) else {
return ParsedHookOutput {
messages: vec![stdout.to_string()],
..ParsedHookOutput::default()
};
};
let mut parsed = ParsedHookOutput::default();
if let Some(message) = root.get("systemMessage").and_then(Value::as_str) {
parsed.messages.push(message.to_string());
}
if let Some(message) = root.get("reason").and_then(Value::as_str) {
parsed.messages.push(message.to_string());
}
if root.get("continue").and_then(Value::as_bool) == Some(false)
|| root.get("decision").and_then(Value::as_str) == Some("block")
{
parsed.deny = true;
}
if let Some(Value::Object(specific)) = root.get("hookSpecificOutput") {
if let Some(Value::String(additional_context)) = specific.get("additionalContext") {
parsed.messages.push(additional_context.clone());
}
if let Some(decision) = specific.get("permissionDecision").and_then(Value::as_str) {
parsed.permission_override = match decision {
"allow" => Some(PermissionOverride::Allow),
"deny" => Some(PermissionOverride::Deny),
"ask" => Some(PermissionOverride::Ask),
_ => None,
};
}
if let Some(reason) = specific
.get("permissionDecisionReason")
.and_then(Value::as_str)
{
parsed.permission_reason = Some(reason.to_string());
}
if let Some(updated_input) = specific.get("updatedInput") {
parsed.updated_input = serde_json::to_string(updated_input).ok();
}
}
if parsed.messages.is_empty() {
parsed.messages.push(stdout.to_string());
}
parsed
}
fn hook_payload(
event: HookEvent,
tool_name: &str,
tool_input: &str,
tool_output: Option<&str>,
is_error: bool,
) -> Value {
match event {
HookEvent::PostToolUseFailure => json!({
"hook_event_name": event.as_str(),
"tool_name": tool_name,
"tool_input": parse_tool_input(tool_input),
"tool_input_json": tool_input,
"tool_error": tool_output,
"tool_result_is_error": true,
}),
_ => 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,
}),
}
}
fn parse_tool_input(tool_input: &str) -> Value {
fn parse_tool_input(tool_input: &str) -> serde_json::Value {
serde_json::from_str(tool_input).unwrap_or_else(|_| json!({ "raw": tool_input }))
}
@@ -625,7 +239,11 @@ fn shell_command(command: &str) -> CommandWithStdin {
};
#[cfg(not(windows))]
let command_builder = {
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)
@@ -643,17 +261,17 @@ impl CommandWithStdin {
Self { command }
}
fn stdin(&mut self, cfg: Stdio) -> &mut Self {
fn stdin(&mut self, cfg: std::process::Stdio) -> &mut Self {
self.command.stdin(cfg);
self
}
fn stdout(&mut self, cfg: Stdio) -> &mut Self {
fn stdout(&mut self, cfg: std::process::Stdio) -> &mut Self {
self.command.stdout(cfg);
self
}
fn stderr(&mut self, cfg: Stdio) -> &mut Self {
fn stderr(&mut self, cfg: std::process::Stdio) -> &mut Self {
self.command.stderr(cfg);
self
}
@@ -667,64 +285,26 @@ impl CommandWithStdin {
self
}
fn output_with_stdin(
&mut self,
stdin: &[u8],
abort_signal: Option<&HookAbortSignal>,
) -> std::io::Result<CommandExecution> {
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)?;
}
loop {
if abort_signal.is_some_and(HookAbortSignal::is_aborted) {
let _ = child.kill();
let _ = child.wait_with_output();
return Ok(CommandExecution::Cancelled);
}
match child.try_wait()? {
Some(_) => return child.wait_with_output().map(CommandExecution::Finished),
None => thread::sleep(Duration::from_millis(20)),
}
}
child.wait_with_output()
}
}
enum CommandExecution {
Finished(std::process::Output),
Cancelled,
}
#[cfg(test)]
mod tests {
use std::thread;
use std::time::Duration;
use super::{
HookAbortSignal, HookEvent, HookProgressEvent, HookProgressReporter, HookRunResult,
HookRunner,
};
use super::{HookRunResult, HookRunner};
use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
use crate::permissions::PermissionOverride;
struct RecordingReporter {
events: Vec<HookProgressEvent>,
}
impl HookProgressReporter for RecordingReporter {
fn on_event(&mut self, event: &HookProgressEvent) {
self.events.push(event.clone());
}
}
#[test]
fn allows_exit_code_zero_and_captures_stdout() {
let runner = HookRunner::new(RuntimeHookConfig::new(
vec![shell_snippet("printf 'pre ok'")],
Vec::new(),
Vec::new(),
));
let result = runner.run_pre_tool_use("Read", r#"{"path":"README.md"}"#);
@@ -737,7 +317,6 @@ mod tests {
let runner = HookRunner::new(RuntimeHookConfig::new(
vec![shell_snippet("printf 'blocked by hook'; exit 2")],
Vec::new(),
Vec::new(),
));
let result = runner.run_pre_tool_use("Bash", r#"{"command":"pwd"}"#);
@@ -752,7 +331,6 @@ mod tests {
RuntimeHookConfig::new(
vec![shell_snippet("printf 'warning hook'; exit 1")],
Vec::new(),
Vec::new(),
),
));
@@ -765,82 +343,6 @@ mod tests {
.any(|message| message.contains("allowing tool execution to continue")));
}
#[test]
fn parses_pre_hook_permission_override_and_updated_input() {
let runner = HookRunner::new(RuntimeHookConfig::new(
vec![shell_snippet(
r#"printf '%s' '{"systemMessage":"updated","hookSpecificOutput":{"permissionDecision":"allow","permissionDecisionReason":"hook ok","updatedInput":{"command":"git status"}}}'"#,
)],
Vec::new(),
Vec::new(),
));
let result = runner.run_pre_tool_use("bash", r#"{"command":"pwd"}"#);
assert_eq!(
result.permission_override(),
Some(PermissionOverride::Allow)
);
assert_eq!(result.permission_reason(), Some("hook ok"));
assert_eq!(result.updated_input(), Some(r#"{"command":"git status"}"#));
assert!(result.messages().iter().any(|message| message == "updated"));
}
#[test]
fn runs_post_tool_use_failure_hooks() {
let runner = HookRunner::new(RuntimeHookConfig::new(
Vec::new(),
Vec::new(),
vec![shell_snippet("printf 'failure hook ran'")],
));
let result =
runner.run_post_tool_use_failure("bash", r#"{"command":"false"}"#, "command failed");
assert!(!result.is_denied());
assert_eq!(result.messages(), &["failure hook ran".to_string()]);
}
#[test]
fn abort_signal_cancels_long_running_hook_and_reports_progress() {
let runner = HookRunner::new(RuntimeHookConfig::new(
vec![shell_snippet("sleep 5")],
Vec::new(),
Vec::new(),
));
let abort_signal = HookAbortSignal::new();
let abort_signal_for_thread = abort_signal.clone();
let mut reporter = RecordingReporter { events: Vec::new() };
thread::spawn(move || {
thread::sleep(Duration::from_millis(100));
abort_signal_for_thread.abort();
});
let result = runner.run_pre_tool_use_with_context(
"bash",
r#"{"command":"sleep 5"}"#,
Some(&abort_signal),
Some(&mut reporter),
);
assert!(result.is_cancelled());
assert!(reporter.events.iter().any(|event| matches!(
event,
HookProgressEvent::Started {
event: HookEvent::PreToolUse,
..
}
)));
assert!(reporter.events.iter().any(|event| matches!(
event,
HookProgressEvent::Cancelled {
event: HookEvent::PreToolUse,
..
}
)));
}
#[cfg(windows)]
fn shell_snippet(script: &str) -> String {
script.replace('\'', "\"")

View File

@@ -28,20 +28,18 @@ pub use config::{
McpConfigCollection, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig,
McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig,
ResolvedPermissionMode, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookConfig,
RuntimePermissionRuleConfig, ScopedMcpServerConfig, CLAUDE_CODE_SETTINGS_SCHEMA_NAME,
RuntimePluginConfig, ScopedMcpServerConfig, CLAUDE_CODE_SETTINGS_SCHEMA_NAME,
};
pub use conversation::{
ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, StaticToolExecutor,
ToolError, ToolExecutor, TurnSummary,
auto_compaction_threshold_from_env, ApiClient, ApiRequest, AssistantEvent, AutoCompactionEvent,
ConversationRuntime, RuntimeError, StaticToolExecutor, ToolError, ToolExecutor, TurnSummary,
};
pub use file_ops::{
edit_file, glob_search, grep_search, read_file, write_file, EditFileOutput, GlobSearchOutput,
GrepSearchInput, GrepSearchOutput, ReadFileOutput, StructuredPatchHunk, TextFilePayload,
WriteFileOutput,
};
pub use hooks::{
HookAbortSignal, HookEvent, HookProgressEvent, HookProgressReporter, HookRunResult, HookRunner,
};
pub use hooks::{HookEvent, HookRunResult, HookRunner};
pub use mcp::{
mcp_server_signature, mcp_tool_name, mcp_tool_prefix, normalize_name_for_mcp,
scoped_mcp_config_hash, unwrap_ccr_proxy_url,
@@ -66,8 +64,8 @@ pub use oauth::{
PkceChallengeMethod, PkceCodePair,
};
pub use permissions::{
PermissionContext, PermissionMode, PermissionOutcome, PermissionOverride, PermissionPolicy,
PermissionPromptDecision, PermissionPrompter, PermissionRequest,
PermissionMode, PermissionOutcome, PermissionPolicy, PermissionPromptDecision,
PermissionPrompter, PermissionRequest,
};
pub use prompt::{
load_system_prompt, prepend_bullets, ContextFile, ProjectContext, PromptBuildError,

View File

@@ -1,9 +1,5 @@
use std::collections::BTreeMap;
use serde_json::Value;
use crate::config::RuntimePermissionRuleConfig;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum PermissionMode {
ReadOnly,
@@ -26,49 +22,12 @@ impl PermissionMode {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PermissionOverride {
Allow,
Deny,
Ask,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PermissionContext {
override_decision: Option<PermissionOverride>,
override_reason: Option<String>,
}
impl PermissionContext {
#[must_use]
pub fn new(
override_decision: Option<PermissionOverride>,
override_reason: Option<String>,
) -> Self {
Self {
override_decision,
override_reason,
}
}
#[must_use]
pub fn override_decision(&self) -> Option<PermissionOverride> {
self.override_decision
}
#[must_use]
pub fn override_reason(&self) -> Option<&str> {
self.override_reason.as_deref()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PermissionRequest {
pub tool_name: String,
pub input: String,
pub current_mode: PermissionMode,
pub required_mode: PermissionMode,
pub reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -91,9 +50,6 @@ pub enum PermissionOutcome {
pub struct PermissionPolicy {
active_mode: PermissionMode,
tool_requirements: BTreeMap<String, PermissionMode>,
allow_rules: Vec<PermissionRule>,
deny_rules: Vec<PermissionRule>,
ask_rules: Vec<PermissionRule>,
}
impl PermissionPolicy {
@@ -102,9 +58,6 @@ impl PermissionPolicy {
Self {
active_mode,
tool_requirements: BTreeMap::new(),
allow_rules: Vec::new(),
deny_rules: Vec::new(),
ask_rules: Vec::new(),
}
}
@@ -119,26 +72,6 @@ impl PermissionPolicy {
self
}
#[must_use]
pub fn with_permission_rules(mut self, config: &RuntimePermissionRuleConfig) -> Self {
self.allow_rules = config
.allow()
.iter()
.map(|rule| PermissionRule::parse(rule))
.collect();
self.deny_rules = config
.deny()
.iter()
.map(|rule| PermissionRule::parse(rule))
.collect();
self.ask_rules = config
.ask()
.iter()
.map(|rule| PermissionRule::parse(rule))
.collect();
self
}
#[must_use]
pub fn active_mode(&self) -> PermissionMode {
self.active_mode
@@ -157,121 +90,38 @@ impl PermissionPolicy {
&self,
tool_name: &str,
input: &str,
prompter: Option<&mut dyn PermissionPrompter>,
mut prompter: Option<&mut dyn PermissionPrompter>,
) -> PermissionOutcome {
self.authorize_with_context(tool_name, input, &PermissionContext::default(), prompter)
}
#[must_use]
#[allow(clippy::too_many_lines)]
pub fn authorize_with_context(
&self,
tool_name: &str,
input: &str,
context: &PermissionContext,
prompter: Option<&mut dyn PermissionPrompter>,
) -> PermissionOutcome {
if let Some(rule) = Self::find_matching_rule(&self.deny_rules, tool_name, input) {
return PermissionOutcome::Deny {
reason: format!(
"Permission to use {tool_name} has been denied by rule '{}'",
rule.raw
),
};
}
let current_mode = self.active_mode();
let required_mode = self.required_mode_for(tool_name);
let ask_rule = Self::find_matching_rule(&self.ask_rules, tool_name, input);
let allow_rule = Self::find_matching_rule(&self.allow_rules, tool_name, input);
match context.override_decision() {
Some(PermissionOverride::Deny) => {
return PermissionOutcome::Deny {
reason: context.override_reason().map_or_else(
|| format!("tool '{tool_name}' denied by hook"),
ToOwned::to_owned,
),
};
}
Some(PermissionOverride::Ask) => {
let reason = context.override_reason().map_or_else(
|| format!("tool '{tool_name}' requires approval due to hook guidance"),
ToOwned::to_owned,
);
return Self::prompt_or_deny(
tool_name,
input,
current_mode,
required_mode,
Some(reason),
prompter,
);
}
Some(PermissionOverride::Allow) => {
if let Some(rule) = ask_rule {
let reason = format!(
"tool '{tool_name}' requires approval due to ask rule '{}'",
rule.raw
);
return Self::prompt_or_deny(
tool_name,
input,
current_mode,
required_mode,
Some(reason),
prompter,
);
}
if allow_rule.is_some()
|| current_mode == PermissionMode::Allow
|| current_mode >= required_mode
{
return PermissionOutcome::Allow;
}
}
None => {}
}
if let Some(rule) = ask_rule {
let reason = format!(
"tool '{tool_name}' requires approval due to ask rule '{}'",
rule.raw
);
return Self::prompt_or_deny(
tool_name,
input,
current_mode,
required_mode,
Some(reason),
prompter,
);
}
if allow_rule.is_some()
|| current_mode == PermissionMode::Allow
|| current_mode >= required_mode
{
if current_mode == PermissionMode::Allow || current_mode >= required_mode {
return PermissionOutcome::Allow;
}
let request = PermissionRequest {
tool_name: tool_name.to_string(),
input: input.to_string(),
current_mode,
required_mode,
};
if current_mode == PermissionMode::Prompt
|| (current_mode == PermissionMode::WorkspaceWrite
&& required_mode == PermissionMode::DangerFullAccess)
{
let reason = Some(format!(
"tool '{tool_name}' requires approval to escalate from {} to {}",
current_mode.as_str(),
required_mode.as_str()
));
return Self::prompt_or_deny(
tool_name,
input,
current_mode,
required_mode,
reason,
prompter,
);
return match prompter.as_mut() {
Some(prompter) => match prompter.decide(&request) {
PermissionPromptDecision::Allow => PermissionOutcome::Allow,
PermissionPromptDecision::Deny { reason } => PermissionOutcome::Deny { reason },
},
None => PermissionOutcome::Deny {
reason: format!(
"tool '{tool_name}' requires approval to escalate from {} to {}",
current_mode.as_str(),
required_mode.as_str()
),
},
};
}
PermissionOutcome::Deny {
@@ -282,191 +132,14 @@ impl PermissionPolicy {
),
}
}
fn prompt_or_deny(
tool_name: &str,
input: &str,
current_mode: PermissionMode,
required_mode: PermissionMode,
reason: Option<String>,
mut prompter: Option<&mut dyn PermissionPrompter>,
) -> PermissionOutcome {
let request = PermissionRequest {
tool_name: tool_name.to_string(),
input: input.to_string(),
current_mode,
required_mode,
reason: reason.clone(),
};
match prompter.as_mut() {
Some(prompter) => match prompter.decide(&request) {
PermissionPromptDecision::Allow => PermissionOutcome::Allow,
PermissionPromptDecision::Deny { reason } => PermissionOutcome::Deny { reason },
},
None => PermissionOutcome::Deny {
reason: reason.unwrap_or_else(|| {
format!(
"tool '{tool_name}' requires approval to run while mode is {}",
current_mode.as_str()
)
}),
},
}
}
fn find_matching_rule<'a>(
rules: &'a [PermissionRule],
tool_name: &str,
input: &str,
) -> Option<&'a PermissionRule> {
rules.iter().find(|rule| rule.matches(tool_name, input))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PermissionRule {
raw: String,
tool_name: String,
matcher: PermissionRuleMatcher,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum PermissionRuleMatcher {
Any,
Exact(String),
Prefix(String),
}
impl PermissionRule {
fn parse(raw: &str) -> Self {
let trimmed = raw.trim();
let open = find_first_unescaped(trimmed, '(');
let close = find_last_unescaped(trimmed, ')');
if let (Some(open), Some(close)) = (open, close) {
if close == trimmed.len() - 1 && open < close {
let tool_name = trimmed[..open].trim();
let content = &trimmed[open + 1..close];
if !tool_name.is_empty() {
let matcher = parse_rule_matcher(content);
return Self {
raw: trimmed.to_string(),
tool_name: tool_name.to_string(),
matcher,
};
}
}
}
Self {
raw: trimmed.to_string(),
tool_name: trimmed.to_string(),
matcher: PermissionRuleMatcher::Any,
}
}
fn matches(&self, tool_name: &str, input: &str) -> bool {
if self.tool_name != tool_name {
return false;
}
match &self.matcher {
PermissionRuleMatcher::Any => true,
PermissionRuleMatcher::Exact(expected) => {
extract_permission_subject(input).is_some_and(|candidate| candidate == *expected)
}
PermissionRuleMatcher::Prefix(prefix) => extract_permission_subject(input)
.is_some_and(|candidate| candidate.starts_with(prefix)),
}
}
}
fn parse_rule_matcher(content: &str) -> PermissionRuleMatcher {
let unescaped = unescape_rule_content(content.trim());
if unescaped.is_empty() || unescaped == "*" {
PermissionRuleMatcher::Any
} else if let Some(prefix) = unescaped.strip_suffix(":*") {
PermissionRuleMatcher::Prefix(prefix.to_string())
} else {
PermissionRuleMatcher::Exact(unescaped)
}
}
fn unescape_rule_content(content: &str) -> String {
content
.replace(r"\(", "(")
.replace(r"\)", ")")
.replace(r"\\", r"\")
}
fn find_first_unescaped(value: &str, needle: char) -> Option<usize> {
let mut escaped = false;
for (idx, ch) in value.char_indices() {
if ch == '\\' {
escaped = !escaped;
continue;
}
if ch == needle && !escaped {
return Some(idx);
}
escaped = false;
}
None
}
fn find_last_unescaped(value: &str, needle: char) -> Option<usize> {
let chars = value.char_indices().collect::<Vec<_>>();
for (pos, (idx, ch)) in chars.iter().enumerate().rev() {
if *ch != needle {
continue;
}
let mut backslashes = 0;
for (_, prev) in chars[..pos].iter().rev() {
if *prev == '\\' {
backslashes += 1;
} else {
break;
}
}
if backslashes % 2 == 0 {
return Some(*idx);
}
}
None
}
fn extract_permission_subject(input: &str) -> Option<String> {
let parsed = serde_json::from_str::<Value>(input).ok();
if let Some(Value::Object(object)) = parsed {
for key in [
"command",
"path",
"file_path",
"filePath",
"notebook_path",
"notebookPath",
"url",
"pattern",
"code",
"message",
] {
if let Some(value) = object.get(key).and_then(Value::as_str) {
return Some(value.to_string());
}
}
}
(!input.trim().is_empty()).then(|| input.to_string())
}
#[cfg(test)]
mod tests {
use super::{
PermissionContext, PermissionMode, PermissionOutcome, PermissionOverride, PermissionPolicy,
PermissionPromptDecision, PermissionPrompter, PermissionRequest,
PermissionMode, PermissionOutcome, PermissionPolicy, PermissionPromptDecision,
PermissionPrompter, PermissionRequest,
};
use crate::config::RuntimePermissionRuleConfig;
struct RecordingPrompter {
seen: Vec<PermissionRequest>,
@@ -556,120 +229,4 @@ mod tests {
PermissionOutcome::Deny { reason } if reason == "not now"
));
}
#[test]
fn applies_rule_based_denials_and_allows() {
let rules = RuntimePermissionRuleConfig::new(
vec!["bash(git:*)".to_string()],
vec!["bash(rm -rf:*)".to_string()],
Vec::new(),
);
let policy = PermissionPolicy::new(PermissionMode::ReadOnly)
.with_tool_requirement("bash", PermissionMode::DangerFullAccess)
.with_permission_rules(&rules);
assert_eq!(
policy.authorize("bash", r#"{"command":"git status"}"#, None),
PermissionOutcome::Allow
);
assert!(matches!(
policy.authorize("bash", r#"{"command":"rm -rf /tmp/x"}"#, None),
PermissionOutcome::Deny { reason } if reason.contains("denied by rule")
));
}
#[test]
fn ask_rules_force_prompt_even_when_mode_allows() {
let rules = RuntimePermissionRuleConfig::new(
Vec::new(),
Vec::new(),
vec!["bash(git:*)".to_string()],
);
let policy = PermissionPolicy::new(PermissionMode::DangerFullAccess)
.with_tool_requirement("bash", PermissionMode::DangerFullAccess)
.with_permission_rules(&rules);
let mut prompter = RecordingPrompter {
seen: Vec::new(),
allow: true,
};
let outcome = policy.authorize("bash", r#"{"command":"git status"}"#, Some(&mut prompter));
assert_eq!(outcome, PermissionOutcome::Allow);
assert_eq!(prompter.seen.len(), 1);
assert!(prompter.seen[0]
.reason
.as_deref()
.is_some_and(|reason| reason.contains("ask rule")));
}
#[test]
fn hook_allow_still_respects_ask_rules() {
let rules = RuntimePermissionRuleConfig::new(
Vec::new(),
Vec::new(),
vec!["bash(git:*)".to_string()],
);
let policy = PermissionPolicy::new(PermissionMode::ReadOnly)
.with_tool_requirement("bash", PermissionMode::DangerFullAccess)
.with_permission_rules(&rules);
let context = PermissionContext::new(
Some(PermissionOverride::Allow),
Some("hook approved".to_string()),
);
let mut prompter = RecordingPrompter {
seen: Vec::new(),
allow: true,
};
let outcome = policy.authorize_with_context(
"bash",
r#"{"command":"git status"}"#,
&context,
Some(&mut prompter),
);
assert_eq!(outcome, PermissionOutcome::Allow);
assert_eq!(prompter.seen.len(), 1);
}
#[test]
fn hook_deny_short_circuits_permission_flow() {
let policy = PermissionPolicy::new(PermissionMode::DangerFullAccess)
.with_tool_requirement("bash", PermissionMode::DangerFullAccess);
let context = PermissionContext::new(
Some(PermissionOverride::Deny),
Some("blocked by hook".to_string()),
);
assert_eq!(
policy.authorize_with_context("bash", "{}", &context, None),
PermissionOutcome::Deny {
reason: "blocked by hook".to_string(),
}
);
}
#[test]
fn hook_ask_forces_prompt() {
let policy = PermissionPolicy::new(PermissionMode::DangerFullAccess)
.with_tool_requirement("bash", PermissionMode::DangerFullAccess);
let context = PermissionContext::new(
Some(PermissionOverride::Ask),
Some("hook requested confirmation".to_string()),
);
let mut prompter = RecordingPrompter {
seen: Vec::new(),
allow: true,
};
let outcome = policy.authorize_with_context("bash", "{}", &context, Some(&mut prompter));
assert_eq!(outcome, PermissionOutcome::Allow);
assert_eq!(prompter.seen.len(), 1);
assert_eq!(
prompter.seen[0].reason.as_deref(),
Some("hook requested confirmation")
);
}
}

View File

@@ -421,7 +421,7 @@ fn render_config_section(config: &RuntimeConfig) -> String {
let mut lines = vec!["# Runtime config".to_string()];
if config.loaded_entries().is_empty() {
lines.extend(prepend_bullets(vec![
"No Claude Code settings files loaded.".to_string(),
"No Claw Code settings files loaded.".to_string()
]));
return lines.join("\n");
}

View File

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

View File

@@ -8,7 +8,7 @@ const STARTER_CLAUDE_JSON: &str = concat!(
" }\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/"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -164,7 +164,7 @@ pub(crate) fn render_init_claude_md(cwd: &Path) -> String {
let mut lines = vec![
"# CLAUDE.md".to_string(),
String::new(),
"This file provides guidance to 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(),
];

File diff suppressed because it is too large Load Diff

View File

@@ -286,7 +286,7 @@ impl TerminalRenderer {
) {
match event {
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::Start(Tag::BlockQuote(..)) => self.start_quote(state, output),
@@ -426,7 +426,8 @@ impl TerminalRenderer {
}
}
fn start_heading(state: &mut RenderState, level: u8, output: &mut String) {
#[allow(clippy::unused_self)]
fn start_heading(&self, state: &mut RenderState, level: u8, output: &mut String) {
state.heading_level = Some(level);
if !output.is_empty() {
output.push('\n');

View File

@@ -7,6 +7,7 @@ publish.workspace = true
[dependencies]
api = { path = "../api" }
plugins = { path = "../plugins" }
runtime = { path = "../runtime" }
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }

View File

@@ -8,12 +8,13 @@ use api::{
MessageRequest, MessageResponse, OutputContentBlock, StreamEvent as ApiStreamEvent, ToolChoice,
ToolDefinition, ToolResultContentBlock,
};
use plugins::{PluginManager, PluginManagerConfig, PluginTool};
use reqwest::blocking::Client;
use runtime::{
edit_file, execute_bash, glob_search, grep_search, load_system_prompt, read_file, write_file,
ApiClient, ApiRequest, AssistantEvent, BashCommandInput, ContentBlock, ConversationMessage,
ConversationRuntime, GrepSearchInput, MessageRole, PermissionMode, PermissionPolicy,
RuntimeError, Session, TokenUsage, ToolError, ToolExecutor,
ApiClient, ApiRequest, AssistantEvent, BashCommandInput, ConfigLoader, ContentBlock,
ConversationMessage, ConversationRuntime, GrepSearchInput, MessageRole, PermissionMode,
PermissionPolicy, RuntimeConfig, RuntimeError, Session, TokenUsage, ToolError, ToolExecutor,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
@@ -55,6 +56,239 @@ pub struct ToolSpec {
pub required_permission: PermissionMode,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RegisteredTool {
pub definition: ToolDefinition,
pub required_permission: PermissionMode,
handler: RegisteredToolHandler,
}
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clone, PartialEq)]
enum RegisteredToolHandler {
Builtin,
Plugin(PluginTool),
}
#[derive(Debug, Clone, PartialEq)]
pub struct GlobalToolRegistry {
entries: Vec<RegisteredTool>,
}
impl GlobalToolRegistry {
#[must_use]
pub fn builtin() -> Self {
Self {
entries: mvp_tool_specs()
.into_iter()
.map(|spec| RegisteredTool {
definition: ToolDefinition {
name: spec.name.to_string(),
description: Some(spec.description.to_string()),
input_schema: spec.input_schema,
},
required_permission: spec.required_permission,
handler: RegisteredToolHandler::Builtin,
})
.collect(),
}
}
pub fn with_plugin_tools(plugin_tools: Vec<PluginTool>) -> Result<Self, String> {
let mut registry = Self::builtin();
let mut seen = registry
.entries
.iter()
.map(|entry| {
(
normalize_registry_tool_name(&entry.definition.name),
entry.definition.name.clone(),
)
})
.collect::<BTreeMap<_, _>>();
for tool in plugin_tools {
let normalized = normalize_registry_tool_name(&tool.definition().name);
if let Some(existing) = seen.get(&normalized) {
return Err(format!(
"plugin tool `{}` from `{}` conflicts with already-registered tool `{existing}`",
tool.definition().name,
tool.plugin_id()
));
}
seen.insert(normalized, tool.definition().name.clone());
registry.entries.push(RegisteredTool {
definition: ToolDefinition {
name: tool.definition().name.clone(),
description: tool.definition().description.clone(),
input_schema: tool.definition().input_schema.clone(),
},
required_permission: permission_mode_from_plugin_tool(tool.required_permission())?,
handler: RegisteredToolHandler::Plugin(tool),
});
}
Ok(registry)
}
#[must_use]
pub fn entries(&self) -> &[RegisteredTool] {
&self.entries
}
fn find_entry(&self, name: &str) -> Option<&RegisteredTool> {
let normalized = normalize_registry_tool_name(name);
self.entries.iter().find(|entry| {
normalize_registry_tool_name(entry.definition.name.as_str()) == normalized
})
}
#[must_use]
pub fn definitions(&self, allowed_tools: Option<&BTreeSet<String>>) -> Vec<ToolDefinition> {
self.entries
.iter()
.filter(|entry| {
allowed_tools.is_none_or(|allowed| allowed.contains(entry.definition.name.as_str()))
})
.map(|entry| entry.definition.clone())
.collect()
}
#[must_use]
pub fn permission_specs(
&self,
allowed_tools: Option<&BTreeSet<String>>,
) -> Vec<(String, PermissionMode)> {
self.entries
.iter()
.filter(|entry| {
allowed_tools.is_none_or(|allowed| allowed.contains(entry.definition.name.as_str()))
})
.map(|entry| (entry.definition.name.clone(), entry.required_permission))
.collect()
}
pub fn normalize_allowed_tools(
&self,
values: &[String],
) -> Result<Option<BTreeSet<String>>, String> {
if values.is_empty() {
return Ok(None);
}
let canonical_names = self
.entries
.iter()
.map(|entry| entry.definition.name.clone())
.collect::<Vec<_>>();
let mut name_map = canonical_names
.iter()
.map(|name| (normalize_registry_tool_name(name), name.clone()))
.collect::<BTreeMap<_, _>>();
for (alias, canonical) in [
("read", "read_file"),
("write", "write_file"),
("edit", "edit_file"),
("glob", "glob_search"),
("grep", "grep_search"),
] {
if canonical_names.iter().any(|name| name == canonical) {
name_map.insert(alias.to_string(), canonical.to_string());
}
}
let mut allowed = BTreeSet::new();
for value in values {
for token in value
.split(|ch: char| ch == ',' || ch.is_whitespace())
.filter(|token| !token.is_empty())
{
let normalized = normalize_registry_tool_name(token);
let canonical = name_map.get(&normalized).ok_or_else(|| {
format!(
"unsupported tool in --allowedTools: {token} (expected one of: {})",
canonical_names.join(", ")
)
})?;
allowed.insert(canonical.clone());
}
}
Ok(Some(allowed))
}
pub fn execute(&self, name: &str, input: &Value) -> Result<String, String> {
let entry = self
.find_entry(name)
.ok_or_else(|| format!("unsupported tool: {name}"))?;
match &entry.handler {
RegisteredToolHandler::Builtin => execute_tool(&entry.definition.name, input),
RegisteredToolHandler::Plugin(tool) => {
tool.execute(input).map_err(|error| error.to_string())
}
}
}
}
impl Default for GlobalToolRegistry {
fn default() -> Self {
Self::builtin()
}
}
fn normalize_registry_tool_name(value: &str) -> String {
let trimmed = value.trim();
let chars = trimmed.chars().collect::<Vec<_>>();
let mut normalized = String::new();
for (index, ch) in chars.iter().copied().enumerate() {
if matches!(ch, '-' | ' ' | '\t' | '\n') {
if !normalized.ends_with('_') {
normalized.push('_');
}
continue;
}
if ch == '_' {
if !normalized.ends_with('_') {
normalized.push('_');
}
continue;
}
if ch.is_uppercase() {
let prev = chars.get(index.wrapping_sub(1)).copied();
let next = chars.get(index + 1).copied();
let needs_separator = index > 0
&& !normalized.ends_with('_')
&& (prev.is_some_and(|prev| prev.is_lowercase() || prev.is_ascii_digit())
|| (prev.is_some_and(char::is_uppercase)
&& next.is_some_and(char::is_lowercase)));
if needs_separator {
normalized.push('_');
}
normalized.extend(ch.to_lowercase());
continue;
}
normalized.push(ch.to_ascii_lowercase());
}
normalized.trim_matches('_').to_string()
}
fn permission_mode_from_plugin_tool(value: &str) -> Result<PermissionMode, String> {
match value {
"read-only" => Ok(PermissionMode::ReadOnly),
"workspace-write" => Ok(PermissionMode::WorkspaceWrite),
"danger-full-access" => Ok(PermissionMode::DangerFullAccess),
other => Err(format!(
"unsupported plugin tool permission `{other}` (expected read-only, workspace-write, or danger-full-access)"
)),
}
}
#[must_use]
#[allow(clippy::too_many_lines)]
pub fn mvp_tool_specs() -> Vec<ToolSpec> {
@@ -323,7 +557,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
},
ToolSpec {
name: "Config",
description: "Get or set Claude Code settings.",
description: "Get or set Claw Code settings.",
input_schema: json!({
"type": "object",
"properties": {
@@ -1466,13 +1700,15 @@ fn build_agent_runtime(
.clone()
.unwrap_or_else(|| DEFAULT_AGENT_MODEL.to_string());
let allowed_tools = job.allowed_tools.clone();
let api_client = AnthropicRuntimeClient::new(model, allowed_tools.clone())?;
let tool_executor = SubagentToolExecutor::new(allowed_tools);
let tool_registry = current_tool_registry()?;
let api_client =
AnthropicRuntimeClient::new(model, allowed_tools.clone(), tool_registry.clone())?;
let tool_executor = SubagentToolExecutor::new(allowed_tools, tool_registry.clone());
Ok(ConversationRuntime::new(
Session::new(),
api_client,
tool_executor,
agent_permission_policy(),
agent_permission_policy(&tool_registry),
job.system_prompt.clone(),
))
}
@@ -1537,7 +1773,7 @@ fn allowed_tools_for_subagent(subagent_type: &str) -> BTreeSet<String> {
"SendUserMessage",
"PowerShell",
],
"claude-code-guide" => vec![
"claw-code-guide" => vec![
"read_file",
"glob_search",
"grep_search",
@@ -1581,10 +1817,12 @@ fn allowed_tools_for_subagent(subagent_type: &str) -> BTreeSet<String> {
tools.into_iter().map(str::to_string).collect()
}
fn agent_permission_policy() -> PermissionPolicy {
mvp_tool_specs().into_iter().fold(
fn agent_permission_policy(tool_registry: &GlobalToolRegistry) -> PermissionPolicy {
tool_registry.permission_specs(None).into_iter().fold(
PermissionPolicy::new(PermissionMode::DangerFullAccess),
|policy, spec| policy.with_tool_requirement(spec.name, spec.required_permission),
|policy, (name, required_permission)| {
policy.with_tool_requirement(name, required_permission)
},
)
}
@@ -1640,10 +1878,15 @@ struct AnthropicRuntimeClient {
client: AnthropicClient,
model: String,
allowed_tools: BTreeSet<String>,
tool_registry: GlobalToolRegistry,
}
impl AnthropicRuntimeClient {
fn new(model: String, allowed_tools: BTreeSet<String>) -> Result<Self, String> {
fn new(
model: String,
allowed_tools: BTreeSet<String>,
tool_registry: GlobalToolRegistry,
) -> Result<Self, String> {
let client = AnthropicClient::from_env()
.map_err(|error| error.to_string())?
.with_base_url(read_base_url());
@@ -1652,20 +1895,14 @@ impl AnthropicRuntimeClient {
client,
model,
allowed_tools,
tool_registry,
})
}
}
impl ApiClient for AnthropicRuntimeClient {
fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
let tools = tool_specs_for_allowed_tools(Some(&self.allowed_tools))
.into_iter()
.map(|spec| ToolDefinition {
name: spec.name.to_string(),
description: Some(spec.description.to_string()),
input_schema: spec.input_schema,
})
.collect::<Vec<_>>();
let tools = self.tool_registry.definitions(Some(&self.allowed_tools));
let message_request = MessageRequest {
model: self.model.clone(),
max_tokens: 32_000,
@@ -1716,6 +1953,8 @@ impl ApiClient for AnthropicRuntimeClient {
input.push_str(&partial_json);
}
}
ContentBlockDelta::ThinkingDelta { .. }
| ContentBlockDelta::SignatureDelta { .. } => {}
},
ApiStreamEvent::ContentBlockStop(_) => {
if let Some((id, name, input)) = pending_tool.take() {
@@ -1768,32 +2007,82 @@ impl ApiClient for AnthropicRuntimeClient {
struct SubagentToolExecutor {
allowed_tools: BTreeSet<String>,
tool_registry: GlobalToolRegistry,
}
impl SubagentToolExecutor {
fn new(allowed_tools: BTreeSet<String>) -> Self {
Self { allowed_tools }
fn new(allowed_tools: BTreeSet<String>, tool_registry: GlobalToolRegistry) -> Self {
Self {
allowed_tools,
tool_registry,
}
}
}
impl ToolExecutor for SubagentToolExecutor {
fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError> {
if !self.allowed_tools.contains(tool_name) {
let entry = self
.tool_registry
.find_entry(tool_name)
.ok_or_else(|| ToolError::new(format!("unsupported tool: {tool_name}")))?;
if !self.allowed_tools.contains(entry.definition.name.as_str()) {
return Err(ToolError::new(format!(
"tool `{tool_name}` is not enabled for this sub-agent"
)));
}
let value = serde_json::from_str(input)
.map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
execute_tool(tool_name, &value).map_err(ToolError::new)
self.tool_registry
.execute(tool_name, &value)
.map_err(ToolError::new)
}
}
fn tool_specs_for_allowed_tools(allowed_tools: Option<&BTreeSet<String>>) -> Vec<ToolSpec> {
mvp_tool_specs()
.into_iter()
.filter(|spec| allowed_tools.is_none_or(|allowed| allowed.contains(spec.name)))
.collect()
fn current_tool_registry() -> Result<GlobalToolRegistry, String> {
let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
let loader = ConfigLoader::default_for(&cwd);
let runtime_config = loader.load().map_err(|error| error.to_string())?;
let plugin_manager = build_plugin_manager(&cwd, &loader, &runtime_config);
let plugin_tools = plugin_manager
.aggregated_tools()
.map_err(|error| error.to_string())?;
GlobalToolRegistry::with_plugin_tools(plugin_tools)
}
fn build_plugin_manager(
cwd: &Path,
loader: &ConfigLoader,
runtime_config: &RuntimeConfig,
) -> PluginManager {
let plugin_settings = runtime_config.plugins();
let mut plugin_config = PluginManagerConfig::new(loader.config_home().to_path_buf());
plugin_config.enabled_plugins = plugin_settings.enabled_plugins().clone();
plugin_config.external_dirs = plugin_settings
.external_directories()
.iter()
.map(|path| resolve_plugin_path(cwd, loader.config_home(), path))
.collect();
plugin_config.install_root = plugin_settings
.install_root()
.map(|path| resolve_plugin_path(cwd, loader.config_home(), path));
plugin_config.registry_path = plugin_settings
.registry_path()
.map(|path| resolve_plugin_path(cwd, loader.config_home(), path));
plugin_config.bundled_root = plugin_settings
.bundled_root()
.map(|path| resolve_plugin_path(cwd, loader.config_home(), path));
PluginManager::new(plugin_config)
}
fn resolve_plugin_path(cwd: &Path, config_home: &Path, value: &str) -> PathBuf {
let path = PathBuf::from(value);
if path.is_absolute() {
path
} else if value.starts_with('.') {
cwd.join(path)
} else {
config_home.join(path)
}
}
fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
@@ -1860,6 +2149,7 @@ fn push_output_block(
};
*pending_tool = Some((id, name, initial_input));
}
OutputContentBlock::Thinking { .. } | OutputContentBlock::RedactedThinking { .. } => {}
}
}
@@ -2087,7 +2377,7 @@ fn normalize_subagent_type(subagent_type: Option<&str>) -> String {
"verification" | "verificationagent" | "verify" | "verifier" => {
String::from("Verification")
}
"claudecodeguide" | "claudecodeguideagent" | "guide" => String::from("claude-code-guide"),
"claudecodeguide" | "claudecodeguideagent" | "guide" => String::from("claw-code-guide"),
"statusline" | "statuslinesetup" => String::from("statusline-setup"),
_ => trimmed.to_string(),
}
@@ -2905,9 +3195,13 @@ mod tests {
use super::{
agent_permission_policy, allowed_tools_for_subagent, execute_agent_with_spawn,
execute_tool, final_assistant_text, mvp_tool_specs, persist_agent_terminal_state,
AgentInput, AgentJob, SubagentToolExecutor,
response_to_events, AgentInput, AgentJob, GlobalToolRegistry, SubagentToolExecutor,
};
use api::{MessageResponse, OutputContentBlock, Usage};
use plugins::{PluginTool, PluginToolDefinition, PluginToolPermission};
use runtime::{
ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, Session, ToolExecutor,
};
use runtime::{ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, Session};
use serde_json::json;
fn env_lock() -> &'static Mutex<()> {
@@ -2923,6 +3217,17 @@ mod tests {
std::env::temp_dir().join(format!("clawd-tools-{unique}-{name}"))
}
fn make_executable(path: &PathBuf) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut permissions = std::fs::metadata(path).expect("metadata").permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(path, permissions).expect("chmod");
}
}
#[test]
fn exposes_mvp_tools() {
let names = mvp_tool_specs()
@@ -2952,6 +3257,170 @@ mod tests {
assert!(error.contains("unsupported tool"));
}
#[test]
fn global_registry_registers_and_executes_plugin_tools() {
let script = temp_path("plugin-tool.sh");
std::fs::write(
&script,
"#!/bin/sh\nINPUT=$(cat)\nprintf '{\"plugin\":\"%s\",\"tool\":\"%s\",\"input\":%s}\\n' \"$CLAWD_PLUGIN_ID\" \"$CLAWD_TOOL_NAME\" \"$INPUT\"\n",
)
.expect("write script");
make_executable(&script);
let registry = GlobalToolRegistry::with_plugin_tools(vec![PluginTool::new(
"demo@external",
"demo",
PluginToolDefinition {
name: "plugin_echo".to_string(),
description: Some("Echo plugin input".to_string()),
input_schema: json!({
"type": "object",
"properties": { "message": { "type": "string" } },
"required": ["message"],
"additionalProperties": false
}),
},
"sh".to_string(),
vec![script.display().to_string()],
PluginToolPermission::WorkspaceWrite,
script.parent().map(PathBuf::from),
)])
.expect("registry should build");
let names = registry
.definitions(None)
.into_iter()
.map(|definition| definition.name)
.collect::<Vec<_>>();
assert!(names.contains(&"bash".to_string()));
assert!(names.contains(&"plugin_echo".to_string()));
let output = registry
.execute("plugin_echo", &json!({ "message": "hello" }))
.expect("plugin tool should execute");
let payload: serde_json::Value = serde_json::from_str(&output).expect("valid json");
assert_eq!(payload["plugin"], "demo@external");
assert_eq!(payload["tool"], "plugin_echo");
assert_eq!(payload["input"]["message"], "hello");
let _ = std::fs::remove_file(script);
}
#[test]
fn global_registry_normalizes_plugin_tool_names_for_allowlists_and_execution() {
let script = temp_path("plugin-tool-normalized.sh");
std::fs::write(
&script,
"#!/bin/sh\nINPUT=$(cat)\nprintf '{\"tool\":\"%s\",\"input\":%s}\\n' \"$CLAWD_TOOL_NAME\" \"$INPUT\"\n",
)
.expect("write script");
make_executable(&script);
let registry = GlobalToolRegistry::with_plugin_tools(vec![PluginTool::new(
"demo@external",
"demo",
PluginToolDefinition {
name: "plugin_echo".to_string(),
description: Some("Echo plugin input".to_string()),
input_schema: json!({
"type": "object",
"properties": { "message": { "type": "string" } },
"required": ["message"],
"additionalProperties": false
}),
},
script.display().to_string(),
Vec::new(),
PluginToolPermission::WorkspaceWrite,
script.parent().map(PathBuf::from),
)])
.expect("registry should build");
let allowed = registry
.normalize_allowed_tools(&[String::from("PLUGIN-ECHO")])
.expect("plugin tool allowlist should normalize")
.expect("allowlist should be present");
assert!(allowed.contains("plugin_echo"));
let output = registry
.execute("plugin-echo", &json!({ "message": "hello" }))
.expect("normalized plugin tool name should execute");
let payload: serde_json::Value = serde_json::from_str(&output).expect("valid json");
assert_eq!(payload["tool"], "plugin_echo");
assert_eq!(payload["input"]["message"], "hello");
let builtin_output = GlobalToolRegistry::builtin()
.execute("structured-output", &json!({ "ok": true }))
.expect("normalized builtin tool name should execute");
let builtin_payload: serde_json::Value =
serde_json::from_str(&builtin_output).expect("valid json");
assert_eq!(builtin_payload["structured_output"]["ok"], true);
let _ = std::fs::remove_file(script);
}
#[test]
fn subagent_executor_executes_allowed_plugin_tools() {
let script = temp_path("subagent-plugin-tool.sh");
std::fs::write(
&script,
"#!/bin/sh\nINPUT=$(cat)\nprintf '{\"tool\":\"%s\",\"input\":%s}\\n' \"$CLAWD_TOOL_NAME\" \"$INPUT\"\n",
)
.expect("write script");
make_executable(&script);
let registry = GlobalToolRegistry::with_plugin_tools(vec![PluginTool::new(
"demo@external",
"demo",
PluginToolDefinition {
name: "plugin_echo".to_string(),
description: Some("Echo plugin input".to_string()),
input_schema: json!({
"type": "object",
"properties": { "message": { "type": "string" } },
"required": ["message"],
"additionalProperties": false
}),
},
script.display().to_string(),
Vec::new(),
PluginToolPermission::WorkspaceWrite,
script.parent().map(PathBuf::from),
)])
.expect("registry should build");
let mut executor =
SubagentToolExecutor::new(BTreeSet::from([String::from("plugin_echo")]), registry);
let output = executor
.execute("plugin-echo", r#"{"message":"hello"}"#)
.expect("plugin tool should execute for subagent");
let payload: serde_json::Value = serde_json::from_str(&output).expect("valid json");
assert_eq!(payload["tool"], "plugin_echo");
assert_eq!(payload["input"]["message"], "hello");
let _ = std::fs::remove_file(script);
}
#[test]
fn global_registry_rejects_conflicting_plugin_tool_names() {
let error = GlobalToolRegistry::with_plugin_tools(vec![PluginTool::new(
"demo@external",
"demo",
PluginToolDefinition {
name: "read-file".to_string(),
description: Some("Conflicts with builtin".to_string()),
input_schema: json!({ "type": "object" }),
},
"echo".to_string(),
Vec::new(),
PluginToolPermission::ReadOnly,
None,
)])
.expect_err("conflicting plugin tool should fail");
assert!(error.contains("conflicts with already-registered tool `read_file`"));
}
#[test]
fn web_fetch_returns_prompt_aware_summary() {
let server = TestServer::spawn(Arc::new(|request_line: &str| {
@@ -3531,8 +4000,11 @@ mod tests {
calls: 0,
input_path: path.display().to_string(),
},
SubagentToolExecutor::new(BTreeSet::from([String::from("read_file")])),
agent_permission_policy(),
SubagentToolExecutor::new(
BTreeSet::from([String::from("read_file")]),
GlobalToolRegistry::builtin(),
),
agent_permission_policy(&GlobalToolRegistry::builtin()),
vec![String::from("system prompt")],
);
@@ -3558,6 +4030,42 @@ mod tests {
let _ = std::fs::remove_file(path);
}
#[test]
fn response_to_events_ignores_thinking_blocks() {
let events = response_to_events(MessageResponse {
id: "msg-1".to_string(),
kind: "message".to_string(),
model: "claude-opus-4-6".to_string(),
role: "assistant".to_string(),
content: vec![
OutputContentBlock::Thinking {
thinking: "step 1".to_string(),
signature: Some("sig_123".to_string()),
},
OutputContentBlock::Text {
text: "Final answer".to_string(),
},
],
stop_reason: Some("end_turn".to_string()),
stop_sequence: None,
usage: Usage {
input_tokens: 1,
output_tokens: 1,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
request_id: None,
});
assert!(matches!(
&events[0],
AssistantEvent::TextDelta(text) if text == "Final answer"
));
assert!(!events
.iter()
.any(|event| matches!(event, AssistantEvent::ToolUse { .. })));
}
#[test]
fn agent_rejects_blank_required_fields() {
let missing_description = execute_tool(