diff --git a/rust/Cargo.lock b/rust/Cargo.lock
index 5507dca..a182255 100644
--- a/rust/Cargo.lock
+++ b/rust/Cargo.lock
@@ -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",
diff --git a/rust/README.md b/rust/README.md
index 801a939..eff924b 100644
--- a/rust/README.md
+++ b/rust/README.md
@@ -1,30 +1,6 @@
# 🦞 Claw Code — Rust Implementation
-
-
-
-
-
-
- Claw Code — a clean-room Rust rewrite of the original agent harness.
-
-
-
- Built for 90K+ stars. 43 tools. JSONL sessions. Prompt caching. Wire-protocol telemetry matching.
- Blazing fast. Memory-safe. Zero-compromise architecture.
-
-
-## Star History
-
-
-
-
-
-
-
-
-
-
+A high-performance Rust rewrite of the Claw Code CLI agent harness. Built for speed, safety, and native tool execution.
## Quick Start
@@ -63,11 +39,6 @@ claw login
| Feature | Status |
|---------|--------|
-| 43 tools with full parity | ✅ |
-| JSONL session persistence | ✅ |
-| Prompt cache tracking | ✅ |
-| Wire-protocol telemetry matching | ✅ |
-| 11MB release binary | ✅ |
| Anthropic API + streaming | ✅ |
| OAuth login/logout | ✅ |
| Interactive REPL (rustyline) | ✅ |
diff --git a/rust/crates/api/src/client.rs b/rust/crates/api/src/client.rs
index 7fc3157..7ef7e83 100644
--- a/rust/crates/api/src/client.rs
+++ b/rust/crates/api/src/client.rs
@@ -101,7 +101,7 @@ impl From for AuthSource {
}
#[derive(Debug, Clone)]
-pub struct ApiClient {
+pub struct AnthropicClient {
http: reqwest::Client,
auth: AuthSource,
base_url: String,
@@ -110,7 +110,7 @@ pub struct ApiClient {
max_backoff: Duration,
}
-impl ApiClient {
+impl AnthropicClient {
#[must_use]
pub fn new(api_key: impl Into) -> Self {
Self {
@@ -429,7 +429,7 @@ fn resolve_saved_oauth_token_set(
let Some(refresh_token) = token_set.refresh_token.clone() else {
return Err(ApiError::ExpiredOAuthToken);
};
- let client = ApiClient::from_auth(AuthSource::None).with_base_url(read_base_url());
+ let client = AnthropicClient::from_auth(AuthSource::None).with_base_url(read_base_url());
let refreshed = client_runtime_block_on(async {
client
.refresh_oauth_token(
@@ -614,7 +614,7 @@ mod tests {
use crate::client::{
now_unix_timestamp, oauth_token_is_expired, resolve_saved_oauth_token,
- resolve_startup_auth_source, ApiClient, AuthSource, OAuthTokenSet,
+ resolve_startup_auth_source, AnthropicClient, AuthSource, OAuthTokenSet,
};
use crate::types::{ContentBlockDelta, MessageRequest};
@@ -671,7 +671,7 @@ mod tests {
let _guard = env_lock();
std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
std::env::remove_var("ANTHROPIC_API_KEY");
- std::env::remove_var("CLAW_CONFIG_HOME");
+ std::env::remove_var("CLAUDE_CONFIG_HOME");
let error = super::read_api_key().expect_err("missing key should error");
assert!(matches!(error, crate::error::ApiError::MissingApiKey));
}
@@ -735,7 +735,7 @@ mod tests {
fn auth_source_from_saved_oauth_when_env_absent() {
let _guard = env_lock();
let config_home = temp_config_home();
- std::env::set_var("CLAW_CONFIG_HOME", &config_home);
+ std::env::set_var("CLAUDE_CONFIG_HOME", &config_home);
std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
std::env::remove_var("ANTHROPIC_API_KEY");
save_oauth_credentials(&runtime::OAuthTokenSet {
@@ -750,7 +750,7 @@ mod tests {
assert_eq!(auth.bearer_token(), Some("saved-access-token"));
clear_oauth_credentials().expect("clear credentials");
- std::env::remove_var("CLAW_CONFIG_HOME");
+ std::env::remove_var("CLAUDE_CONFIG_HOME");
std::fs::remove_dir_all(config_home).expect("cleanup temp dir");
}
@@ -774,7 +774,7 @@ mod tests {
fn resolve_saved_oauth_token_refreshes_expired_credentials() {
let _guard = env_lock();
let config_home = temp_config_home();
- std::env::set_var("CLAW_CONFIG_HOME", &config_home);
+ std::env::set_var("CLAUDE_CONFIG_HOME", &config_home);
std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
std::env::remove_var("ANTHROPIC_API_KEY");
save_oauth_credentials(&runtime::OAuthTokenSet {
@@ -798,7 +798,7 @@ mod tests {
assert_eq!(stored.access_token, "refreshed-token");
clear_oauth_credentials().expect("clear credentials");
- std::env::remove_var("CLAW_CONFIG_HOME");
+ std::env::remove_var("CLAUDE_CONFIG_HOME");
std::fs::remove_dir_all(config_home).expect("cleanup temp dir");
}
@@ -806,7 +806,7 @@ mod tests {
fn resolve_startup_auth_source_uses_saved_oauth_without_loading_config() {
let _guard = env_lock();
let config_home = temp_config_home();
- std::env::set_var("CLAW_CONFIG_HOME", &config_home);
+ std::env::set_var("CLAUDE_CONFIG_HOME", &config_home);
std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
std::env::remove_var("ANTHROPIC_API_KEY");
save_oauth_credentials(&runtime::OAuthTokenSet {
@@ -822,7 +822,7 @@ mod tests {
assert_eq!(auth.bearer_token(), Some("saved-access-token"));
clear_oauth_credentials().expect("clear credentials");
- std::env::remove_var("CLAW_CONFIG_HOME");
+ std::env::remove_var("CLAUDE_CONFIG_HOME");
std::fs::remove_dir_all(config_home).expect("cleanup temp dir");
}
@@ -830,7 +830,7 @@ mod tests {
fn resolve_startup_auth_source_errors_when_refreshable_token_lacks_config() {
let _guard = env_lock();
let config_home = temp_config_home();
- std::env::set_var("CLAW_CONFIG_HOME", &config_home);
+ std::env::set_var("CLAUDE_CONFIG_HOME", &config_home);
std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
std::env::remove_var("ANTHROPIC_API_KEY");
save_oauth_credentials(&runtime::OAuthTokenSet {
@@ -854,7 +854,7 @@ mod tests {
assert_eq!(stored.refresh_token.as_deref(), Some("refresh-token"));
clear_oauth_credentials().expect("clear credentials");
- std::env::remove_var("CLAW_CONFIG_HOME");
+ std::env::remove_var("CLAUDE_CONFIG_HOME");
std::fs::remove_dir_all(config_home).expect("cleanup temp dir");
}
@@ -862,7 +862,7 @@ mod tests {
fn resolve_saved_oauth_token_preserves_refresh_token_when_refresh_response_omits_it() {
let _guard = env_lock();
let config_home = temp_config_home();
- std::env::set_var("CLAW_CONFIG_HOME", &config_home);
+ std::env::set_var("CLAUDE_CONFIG_HOME", &config_home);
std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
std::env::remove_var("ANTHROPIC_API_KEY");
save_oauth_credentials(&runtime::OAuthTokenSet {
@@ -887,7 +887,7 @@ mod tests {
assert_eq!(stored.refresh_token.as_deref(), Some("refresh-token"));
clear_oauth_credentials().expect("clear credentials");
- std::env::remove_var("CLAW_CONFIG_HOME");
+ std::env::remove_var("CLAUDE_CONFIG_HOME");
std::fs::remove_dir_all(config_home).expect("cleanup temp dir");
}
@@ -908,7 +908,7 @@ mod tests {
#[test]
fn backoff_doubles_until_maximum() {
- let client = ApiClient::new("test-key").with_retry_policy(
+ let client = AnthropicClient::new("test-key").with_retry_policy(
3,
Duration::from_millis(10),
Duration::from_millis(25),
diff --git a/rust/crates/api/src/lib.rs b/rust/crates/api/src/lib.rs
index 5cae64e..4108187 100644
--- a/rust/crates/api/src/lib.rs
+++ b/rust/crates/api/src/lib.rs
@@ -5,7 +5,7 @@ mod types;
pub use client::{
oauth_token_is_expired, read_base_url, resolve_saved_oauth_token, resolve_startup_auth_source,
- ApiClient, AuthSource, MessageStream, OAuthTokenSet,
+ AnthropicClient, AuthSource, MessageStream, OAuthTokenSet,
};
pub use error::ApiError;
pub use sse::{parse_frame, SseParser};
diff --git a/rust/crates/api/src/sse.rs b/rust/crates/api/src/sse.rs
index d7334cd..5f54e50 100644
--- a/rust/crates/api/src/sse.rs
+++ b/rust/crates/api/src/sse.rs
@@ -216,4 +216,64 @@ mod tests {
))
);
}
+
+ #[test]
+ fn parses_thinking_content_block_start() {
+ let frame = concat!(
+ "event: content_block_start\n",
+ "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\",\"signature\":null}}\n\n"
+ );
+
+ let event = parse_frame(frame).expect("frame should parse");
+ assert_eq!(
+ event,
+ Some(StreamEvent::ContentBlockStart(
+ crate::types::ContentBlockStartEvent {
+ index: 0,
+ content_block: OutputContentBlock::Thinking {
+ thinking: String::new(),
+ signature: None,
+ },
+ },
+ ))
+ );
+ }
+
+ #[test]
+ fn parses_thinking_related_deltas() {
+ let thinking = concat!(
+ "event: content_block_delta\n",
+ "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"step 1\"}}\n\n"
+ );
+ let signature = concat!(
+ "event: content_block_delta\n",
+ "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"signature_delta\",\"signature\":\"sig_123\"}}\n\n"
+ );
+
+ let thinking_event = parse_frame(thinking).expect("thinking delta should parse");
+ let signature_event = parse_frame(signature).expect("signature delta should parse");
+
+ assert_eq!(
+ thinking_event,
+ Some(StreamEvent::ContentBlockDelta(
+ crate::types::ContentBlockDeltaEvent {
+ index: 0,
+ delta: ContentBlockDelta::ThinkingDelta {
+ thinking: "step 1".to_string(),
+ },
+ }
+ ))
+ );
+ assert_eq!(
+ signature_event,
+ Some(StreamEvent::ContentBlockDelta(
+ crate::types::ContentBlockDeltaEvent {
+ index: 0,
+ delta: ContentBlockDelta::SignatureDelta {
+ signature: "sig_123".to_string(),
+ },
+ }
+ ))
+ );
+ }
}
diff --git a/rust/crates/api/src/types.rs b/rust/crates/api/src/types.rs
index 45d5c08..c060be6 100644
--- a/rust/crates/api/src/types.rs
+++ b/rust/crates/api/src/types.rs
@@ -135,6 +135,15 @@ pub enum OutputContentBlock {
name: String,
input: Value,
},
+ Thinking {
+ #[serde(default)]
+ thinking: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ signature: Option,
+ },
+ RedactedThinking {
+ data: Value,
+ },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -190,6 +199,8 @@ pub struct ContentBlockDeltaEvent {
pub enum ContentBlockDelta {
TextDelta { text: String },
InputJsonDelta { partial_json: String },
+ ThinkingDelta { thinking: String },
+ SignatureDelta { signature: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
diff --git a/rust/crates/api/tests/client_integration.rs b/rust/crates/api/tests/client_integration.rs
index 2ad9417..be4abca 100644
--- a/rust/crates/api/tests/client_integration.rs
+++ b/rust/crates/api/tests/client_integration.rs
@@ -3,7 +3,7 @@ use std::sync::Arc;
use std::time::Duration;
use api::{
- ApiClient, ApiError, ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent,
+ AnthropicClient, ApiError, ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent,
InputContentBlock, InputMessage, MessageDeltaEvent, MessageRequest, OutputContentBlock,
StreamEvent, ToolChoice, ToolDefinition,
};
@@ -34,7 +34,7 @@ async fn send_message_posts_json_and_parses_response() {
)
.await;
- let client = ApiClient::new("test-key")
+ let client = AnthropicClient::new("test-key")
.with_auth_token(Some("proxy-token".to_string()))
.with_base_url(server.base_url());
let response = client
@@ -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::::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::::new()));
@@ -104,7 +146,7 @@ async fn stream_message_parses_sse_events_with_tool_use() {
)
.await;
- let client = ApiClient::new("test-key")
+ let client = AnthropicClient::new("test-key")
.with_auth_token(Some("proxy-token".to_string()))
.with_base_url(server.base_url());
let mut stream = client
@@ -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::::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::::new()));
@@ -182,7 +303,7 @@ async fn retries_retryable_failures_before_succeeding() {
)
.await;
- let client = ApiClient::new("test-key")
+ let client = AnthropicClient::new("test-key")
.with_base_url(server.base_url())
.with_retry_policy(2, Duration::from_millis(1), Duration::from_millis(2));
@@ -215,7 +336,7 @@ async fn surfaces_retry_exhaustion_for_persistent_retryable_errors() {
)
.await;
- let client = ApiClient::new("test-key")
+ let client = AnthropicClient::new("test-key")
.with_base_url(server.base_url())
.with_retry_policy(1, Duration::from_millis(1), Duration::from_millis(2));
@@ -246,7 +367,7 @@ async fn surfaces_retry_exhaustion_for_persistent_retryable_errors() {
#[tokio::test]
#[ignore = "requires ANTHROPIC_API_KEY and network access"]
async fn live_stream_smoke_test() {
- let client = ApiClient::from_env().expect("ANTHROPIC_API_KEY must be set");
+ let client = AnthropicClient::from_env().expect("ANTHROPIC_API_KEY must be set");
let mut stream = client
.stream_message(&MessageRequest {
model: std::env::var("ANTHROPIC_MODEL")
diff --git a/rust/crates/commands/Cargo.toml b/rust/crates/commands/Cargo.toml
index d465bff..b3a68b6 100644
--- a/rust/crates/commands/Cargo.toml
+++ b/rust/crates/commands/Cargo.toml
@@ -9,4 +9,5 @@ publish.workspace = true
workspace = true
[dependencies]
+plugins = { path = "../plugins" }
runtime = { path = "../runtime" }
diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs
index b396bb0..b1aa69c 100644
--- a/rust/crates/commands/src/lib.rs
+++ b/rust/crates/commands/src/lib.rs
@@ -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(""),
+ 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 ]"),
resume_supported: false,
},
+ SlashCommandSpec {
+ name: "plugins",
+ summary: "List or manage plugins",
+ argument_hint: Some(
+ "[list|install |enable |disable |uninstall |update ]",
+ ),
+ resume_supported: false,
+ },
];
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -136,6 +193,23 @@ pub enum SlashCommand {
Help,
Status,
Compact,
+ Bughunter {
+ scope: Option,
+ },
+ Commit,
+ Pr {
+ context: Option,
+ },
+ Issue {
+ context: Option,
+ },
+ Ultraplan {
+ task: Option,
+ },
+ Teleport {
+ target: Option,
+ },
+ DebugToolCall,
Model {
model: Option,
},
@@ -163,6 +237,10 @@ pub enum SlashCommand {
action: Option,
target: Option,
},
+ Plugins {
+ action: Option,
+ target: Option,
+ },
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::>().join(" ");
+ (!remainder.is_empty()).then_some(remainder)
+ },
+ },
other => Self::Unknown(other.to_string()),
})
}
}
+fn remainder_after_command(input: &str, command: &str) -> Option {
+ 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 {
+ 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 ".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 ".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 ".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 ".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 ".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 {
+ let mut matches = manager
+ .list_installed_plugins()?
+ .into_iter()
+ .filter(|plugin| plugin.metadata.id == target || plugin.metadata.name == target)
+ .collect::>();
+ 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 "));
+ 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 "));
- 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 ]"));
- assert_eq!(slash_command_specs().len(), 15);
+ assert!(help.contains(
+ "/plugins [list|install |enable |disable |uninstall |update ]"
+ ));
+ 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);
}
}
diff --git a/rust/crates/compat-harness/src/lib.rs b/rust/crates/compat-harness/src/lib.rs
index 645ac66..1acfec9 100644
--- a/rust/crates/compat-harness/src/lib.rs
+++ b/rust/crates/compat-harness/src/lib.rs
@@ -74,11 +74,7 @@ fn upstream_repo_candidates(primary_repo_root: &Path) -> Vec {
candidates.push(ancestor.join("clawd-code"));
}
- candidates.push(
- primary_repo_root
- .join("reference-source")
- .join("claw-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();
@@ -196,7 +192,7 @@ pub fn extract_bootstrap_plan(source: &str) -> BootstrapPlan {
if source.contains("--dump-system-prompt") {
phases.push(BootstrapPhase::SystemPromptFastPath);
}
- if source.contains("--claw-in-chrome-mcp") {
+ if source.contains("--claude-in-chrome-mcp") {
phases.push(BootstrapPhase::ChromeMcpFastPath);
}
if source.contains("--daemon-worker") {
diff --git a/rust/crates/plugins/Cargo.toml b/rust/crates/plugins/Cargo.toml
new file mode 100644
index 0000000..1771acc
--- /dev/null
+++ b/rust/crates/plugins/Cargo.toml
@@ -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
diff --git a/rust/crates/plugins/bundled/example-bundled/.claude-plugin/plugin.json b/rust/crates/plugins/bundled/example-bundled/.claude-plugin/plugin.json
new file mode 100644
index 0000000..81a4220
--- /dev/null
+++ b/rust/crates/plugins/bundled/example-bundled/.claude-plugin/plugin.json
@@ -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"]
+ }
+}
diff --git a/rust/crates/plugins/bundled/example-bundled/hooks/post.sh b/rust/crates/plugins/bundled/example-bundled/hooks/post.sh
new file mode 100755
index 0000000..c9eb66f
--- /dev/null
+++ b/rust/crates/plugins/bundled/example-bundled/hooks/post.sh
@@ -0,0 +1,2 @@
+#!/bin/sh
+printf '%s\n' 'example bundled post hook'
diff --git a/rust/crates/plugins/bundled/example-bundled/hooks/pre.sh b/rust/crates/plugins/bundled/example-bundled/hooks/pre.sh
new file mode 100755
index 0000000..af6b46b
--- /dev/null
+++ b/rust/crates/plugins/bundled/example-bundled/hooks/pre.sh
@@ -0,0 +1,2 @@
+#!/bin/sh
+printf '%s\n' 'example bundled pre hook'
diff --git a/rust/crates/plugins/bundled/sample-hooks/.claude-plugin/plugin.json b/rust/crates/plugins/bundled/sample-hooks/.claude-plugin/plugin.json
new file mode 100644
index 0000000..555f5df
--- /dev/null
+++ b/rust/crates/plugins/bundled/sample-hooks/.claude-plugin/plugin.json
@@ -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"]
+ }
+}
diff --git a/rust/crates/plugins/bundled/sample-hooks/hooks/post.sh b/rust/crates/plugins/bundled/sample-hooks/hooks/post.sh
new file mode 100755
index 0000000..c968e6d
--- /dev/null
+++ b/rust/crates/plugins/bundled/sample-hooks/hooks/post.sh
@@ -0,0 +1,2 @@
+#!/bin/sh
+printf 'sample bundled post hook'
diff --git a/rust/crates/plugins/bundled/sample-hooks/hooks/pre.sh b/rust/crates/plugins/bundled/sample-hooks/hooks/pre.sh
new file mode 100755
index 0000000..9560881
--- /dev/null
+++ b/rust/crates/plugins/bundled/sample-hooks/hooks/pre.sh
@@ -0,0 +1,2 @@
+#!/bin/sh
+printf 'sample bundled pre hook'
diff --git a/rust/crates/plugins/src/hooks.rs b/rust/crates/plugins/src/hooks.rs
new file mode 100644
index 0000000..d473da8
--- /dev/null
+++ b/rust/crates/plugins/src/hooks.rs
@@ -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,
+}
+
+impl HookRunResult {
+ #[must_use]
+ pub fn allow(messages: Vec) -> 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 {
+ 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 },
+ Deny { message: Option },
+ 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(&mut self, key: K, value: V) -> &mut Self
+ where
+ K: AsRef,
+ V: AsRef,
+ {
+ self.command.env(key, value);
+ self
+ }
+
+ fn output_with_stdin(&mut self, stdin: &[u8]) -> std::io::Result {
+ 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(®istry).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()]);
+ }
+}
diff --git a/rust/crates/plugins/src/lib.rs b/rust/crates/plugins/src/lib.rs
new file mode 100644
index 0000000..844ee9b
--- /dev/null
+++ b/rust/crates/plugins/src/lib.rs
@@ -0,0 +1,2929 @@
+mod hooks;
+
+use std::collections::{BTreeMap, BTreeSet};
+use std::fmt::{Display, Formatter};
+use std::fs;
+use std::path::{Path, PathBuf};
+use std::process::{Command, Stdio};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use serde::{Deserialize, Serialize};
+use serde_json::{Map, Value};
+
+pub use hooks::{HookEvent, HookRunResult, HookRunner};
+
+const EXTERNAL_MARKETPLACE: &str = "external";
+const BUILTIN_MARKETPLACE: &str = "builtin";
+const BUNDLED_MARKETPLACE: &str = "bundled";
+const SETTINGS_FILE_NAME: &str = "settings.json";
+const REGISTRY_FILE_NAME: &str = "installed.json";
+const MANIFEST_FILE_NAME: &str = "plugin.json";
+const MANIFEST_RELATIVE_PATH: &str = ".claude-plugin/plugin.json";
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum PluginKind {
+ Builtin,
+ Bundled,
+ External,
+}
+
+impl Display for PluginKind {
+ fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::Builtin => write!(f, "builtin"),
+ Self::Bundled => write!(f, "bundled"),
+ Self::External => write!(f, "external"),
+ }
+ }
+}
+
+impl PluginKind {
+ #[must_use]
+ fn marketplace(self) -> &'static str {
+ match self {
+ Self::Builtin => BUILTIN_MARKETPLACE,
+ Self::Bundled => BUNDLED_MARKETPLACE,
+ Self::External => EXTERNAL_MARKETPLACE,
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct PluginMetadata {
+ pub id: String,
+ pub name: String,
+ pub version: String,
+ pub description: String,
+ pub kind: PluginKind,
+ pub source: String,
+ pub default_enabled: bool,
+ pub root: Option,
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PluginHooks {
+ #[serde(rename = "PreToolUse", default)]
+ pub pre_tool_use: Vec,
+ #[serde(rename = "PostToolUse", default)]
+ pub post_tool_use: Vec,
+}
+
+impl PluginHooks {
+ #[must_use]
+ pub fn is_empty(&self) -> bool {
+ self.pre_tool_use.is_empty() && self.post_tool_use.is_empty()
+ }
+
+ #[must_use]
+ pub fn merged_with(&self, other: &Self) -> Self {
+ let mut merged = self.clone();
+ merged
+ .pre_tool_use
+ .extend(other.pre_tool_use.iter().cloned());
+ merged
+ .post_tool_use
+ .extend(other.post_tool_use.iter().cloned());
+ merged
+ }
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PluginLifecycle {
+ #[serde(rename = "Init", default)]
+ pub init: Vec,
+ #[serde(rename = "Shutdown", default)]
+ pub shutdown: Vec,
+}
+
+impl PluginLifecycle {
+ #[must_use]
+ pub fn is_empty(&self) -> bool {
+ self.init.is_empty() && self.shutdown.is_empty()
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct PluginManifest {
+ pub name: String,
+ pub version: String,
+ pub description: String,
+ pub permissions: Vec,
+ #[serde(rename = "defaultEnabled", default)]
+ pub default_enabled: bool,
+ #[serde(default)]
+ pub hooks: PluginHooks,
+ #[serde(default)]
+ pub lifecycle: PluginLifecycle,
+ #[serde(default)]
+ pub tools: Vec,
+ #[serde(default)]
+ pub commands: Vec,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum PluginPermission {
+ Read,
+ Write,
+ Execute,
+}
+
+impl PluginPermission {
+ #[must_use]
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Self::Read => "read",
+ Self::Write => "write",
+ Self::Execute => "execute",
+ }
+ }
+
+ fn parse(value: &str) -> Option {
+ match value {
+ "read" => Some(Self::Read),
+ "write" => Some(Self::Write),
+ "execute" => Some(Self::Execute),
+ _ => None,
+ }
+ }
+}
+
+impl AsRef for PluginPermission {
+ fn as_ref(&self) -> &str {
+ self.as_str()
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct PluginToolManifest {
+ pub name: String,
+ pub description: String,
+ #[serde(rename = "inputSchema")]
+ pub input_schema: Value,
+ pub command: String,
+ #[serde(default)]
+ pub args: Vec,
+ pub required_permission: PluginToolPermission,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum PluginToolPermission {
+ ReadOnly,
+ WorkspaceWrite,
+ DangerFullAccess,
+}
+
+impl PluginToolPermission {
+ #[must_use]
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Self::ReadOnly => "read-only",
+ Self::WorkspaceWrite => "workspace-write",
+ Self::DangerFullAccess => "danger-full-access",
+ }
+ }
+
+ fn parse(value: &str) -> Option {
+ match value {
+ "read-only" => Some(Self::ReadOnly),
+ "workspace-write" => Some(Self::WorkspaceWrite),
+ "danger-full-access" => Some(Self::DangerFullAccess),
+ _ => None,
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct PluginToolDefinition {
+ pub name: String,
+ #[serde(default)]
+ pub description: Option,
+ #[serde(rename = "inputSchema")]
+ pub input_schema: Value,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PluginCommandManifest {
+ pub name: String,
+ pub description: String,
+ pub command: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+struct RawPluginManifest {
+ pub name: String,
+ pub version: String,
+ pub description: String,
+ #[serde(default)]
+ pub permissions: Vec,
+ #[serde(rename = "defaultEnabled", default)]
+ pub default_enabled: bool,
+ #[serde(default)]
+ pub hooks: PluginHooks,
+ #[serde(default)]
+ pub lifecycle: PluginLifecycle,
+ #[serde(default)]
+ pub tools: Vec,
+ #[serde(default)]
+ pub commands: Vec,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+struct RawPluginToolManifest {
+ pub name: String,
+ pub description: String,
+ #[serde(rename = "inputSchema")]
+ pub input_schema: Value,
+ pub command: String,
+ #[serde(default)]
+ pub args: Vec,
+ #[serde(
+ rename = "requiredPermission",
+ default = "default_tool_permission_label"
+ )]
+ pub required_permission: String,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct PluginTool {
+ plugin_id: String,
+ plugin_name: String,
+ definition: PluginToolDefinition,
+ command: String,
+ args: Vec,
+ required_permission: PluginToolPermission,
+ root: Option,
+}
+
+impl PluginTool {
+ #[must_use]
+ pub fn new(
+ plugin_id: impl Into,
+ plugin_name: impl Into,
+ definition: PluginToolDefinition,
+ command: impl Into,
+ args: Vec,
+ required_permission: PluginToolPermission,
+ root: Option,
+ ) -> Self {
+ Self {
+ plugin_id: plugin_id.into(),
+ plugin_name: plugin_name.into(),
+ definition,
+ command: command.into(),
+ args,
+ required_permission,
+ root,
+ }
+ }
+
+ #[must_use]
+ pub fn plugin_id(&self) -> &str {
+ &self.plugin_id
+ }
+
+ #[must_use]
+ pub fn definition(&self) -> &PluginToolDefinition {
+ &self.definition
+ }
+
+ #[must_use]
+ pub fn required_permission(&self) -> &str {
+ self.required_permission.as_str()
+ }
+
+ pub fn execute(&self, input: &Value) -> Result {
+ let input_json = input.to_string();
+ let mut process = Command::new(&self.command);
+ process
+ .args(&self.args)
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .env("CLAWD_PLUGIN_ID", &self.plugin_id)
+ .env("CLAWD_PLUGIN_NAME", &self.plugin_name)
+ .env("CLAWD_TOOL_NAME", &self.definition.name)
+ .env("CLAWD_TOOL_INPUT", &input_json);
+ if let Some(root) = &self.root {
+ process
+ .current_dir(root)
+ .env("CLAWD_PLUGIN_ROOT", root.display().to_string());
+ }
+
+ let mut child = process.spawn()?;
+ if let Some(stdin) = child.stdin.as_mut() {
+ use std::io::Write as _;
+ stdin.write_all(input_json.as_bytes())?;
+ }
+
+ let output = child.wait_with_output()?;
+ if output.status.success() {
+ Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
+ } else {
+ let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
+ Err(PluginError::CommandFailed(format!(
+ "plugin tool `{}` from `{}` failed for `{}`: {}",
+ self.definition.name,
+ self.plugin_id,
+ self.command,
+ if stderr.is_empty() {
+ format!("exit status {}", output.status)
+ } else {
+ stderr
+ }
+ )))
+ }
+ }
+}
+
+fn default_tool_permission_label() -> String {
+ "danger-full-access".to_string()
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(tag = "type", rename_all = "snake_case")]
+pub enum PluginInstallSource {
+ LocalPath { path: PathBuf },
+ GitUrl { url: String },
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct InstalledPluginRecord {
+ #[serde(default = "default_plugin_kind")]
+ pub kind: PluginKind,
+ pub id: String,
+ pub name: String,
+ pub version: String,
+ pub description: String,
+ pub install_path: PathBuf,
+ pub source: PluginInstallSource,
+ pub installed_at_unix_ms: u128,
+ pub updated_at_unix_ms: u128,
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct InstalledPluginRegistry {
+ #[serde(default)]
+ pub plugins: BTreeMap,
+}
+
+fn default_plugin_kind() -> PluginKind {
+ PluginKind::External
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct BuiltinPlugin {
+ metadata: PluginMetadata,
+ hooks: PluginHooks,
+ lifecycle: PluginLifecycle,
+ tools: Vec,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct BundledPlugin {
+ metadata: PluginMetadata,
+ hooks: PluginHooks,
+ lifecycle: PluginLifecycle,
+ tools: Vec,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct ExternalPlugin {
+ metadata: PluginMetadata,
+ hooks: PluginHooks,
+ lifecycle: PluginLifecycle,
+ tools: Vec,
+}
+
+pub trait Plugin {
+ fn metadata(&self) -> &PluginMetadata;
+ fn hooks(&self) -> &PluginHooks;
+ fn lifecycle(&self) -> &PluginLifecycle;
+ fn tools(&self) -> &[PluginTool];
+ fn validate(&self) -> Result<(), PluginError>;
+ fn initialize(&self) -> Result<(), PluginError>;
+ fn shutdown(&self) -> Result<(), PluginError>;
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub enum PluginDefinition {
+ Builtin(BuiltinPlugin),
+ Bundled(BundledPlugin),
+ External(ExternalPlugin),
+}
+
+impl Plugin for BuiltinPlugin {
+ fn metadata(&self) -> &PluginMetadata {
+ &self.metadata
+ }
+
+ fn hooks(&self) -> &PluginHooks {
+ &self.hooks
+ }
+
+ fn lifecycle(&self) -> &PluginLifecycle {
+ &self.lifecycle
+ }
+
+ fn tools(&self) -> &[PluginTool] {
+ &self.tools
+ }
+
+ fn validate(&self) -> Result<(), PluginError> {
+ Ok(())
+ }
+
+ fn initialize(&self) -> Result<(), PluginError> {
+ Ok(())
+ }
+
+ fn shutdown(&self) -> Result<(), PluginError> {
+ Ok(())
+ }
+}
+
+impl Plugin for BundledPlugin {
+ fn metadata(&self) -> &PluginMetadata {
+ &self.metadata
+ }
+
+ fn hooks(&self) -> &PluginHooks {
+ &self.hooks
+ }
+
+ fn lifecycle(&self) -> &PluginLifecycle {
+ &self.lifecycle
+ }
+
+ fn tools(&self) -> &[PluginTool] {
+ &self.tools
+ }
+
+ fn validate(&self) -> Result<(), PluginError> {
+ validate_hook_paths(self.metadata.root.as_deref(), &self.hooks)?;
+ validate_lifecycle_paths(self.metadata.root.as_deref(), &self.lifecycle)?;
+ validate_tool_paths(self.metadata.root.as_deref(), &self.tools)
+ }
+
+ fn initialize(&self) -> Result<(), PluginError> {
+ run_lifecycle_commands(
+ self.metadata(),
+ self.lifecycle(),
+ "init",
+ &self.lifecycle.init,
+ )
+ }
+
+ fn shutdown(&self) -> Result<(), PluginError> {
+ run_lifecycle_commands(
+ self.metadata(),
+ self.lifecycle(),
+ "shutdown",
+ &self.lifecycle.shutdown,
+ )
+ }
+}
+
+impl Plugin for ExternalPlugin {
+ fn metadata(&self) -> &PluginMetadata {
+ &self.metadata
+ }
+
+ fn hooks(&self) -> &PluginHooks {
+ &self.hooks
+ }
+
+ fn lifecycle(&self) -> &PluginLifecycle {
+ &self.lifecycle
+ }
+
+ fn tools(&self) -> &[PluginTool] {
+ &self.tools
+ }
+
+ fn validate(&self) -> Result<(), PluginError> {
+ validate_hook_paths(self.metadata.root.as_deref(), &self.hooks)?;
+ validate_lifecycle_paths(self.metadata.root.as_deref(), &self.lifecycle)?;
+ validate_tool_paths(self.metadata.root.as_deref(), &self.tools)
+ }
+
+ fn initialize(&self) -> Result<(), PluginError> {
+ run_lifecycle_commands(
+ self.metadata(),
+ self.lifecycle(),
+ "init",
+ &self.lifecycle.init,
+ )
+ }
+
+ fn shutdown(&self) -> Result<(), PluginError> {
+ run_lifecycle_commands(
+ self.metadata(),
+ self.lifecycle(),
+ "shutdown",
+ &self.lifecycle.shutdown,
+ )
+ }
+}
+
+impl Plugin for PluginDefinition {
+ fn metadata(&self) -> &PluginMetadata {
+ match self {
+ Self::Builtin(plugin) => plugin.metadata(),
+ Self::Bundled(plugin) => plugin.metadata(),
+ Self::External(plugin) => plugin.metadata(),
+ }
+ }
+
+ fn hooks(&self) -> &PluginHooks {
+ match self {
+ Self::Builtin(plugin) => plugin.hooks(),
+ Self::Bundled(plugin) => plugin.hooks(),
+ Self::External(plugin) => plugin.hooks(),
+ }
+ }
+
+ fn lifecycle(&self) -> &PluginLifecycle {
+ match self {
+ Self::Builtin(plugin) => plugin.lifecycle(),
+ Self::Bundled(plugin) => plugin.lifecycle(),
+ Self::External(plugin) => plugin.lifecycle(),
+ }
+ }
+
+ fn tools(&self) -> &[PluginTool] {
+ match self {
+ Self::Builtin(plugin) => plugin.tools(),
+ Self::Bundled(plugin) => plugin.tools(),
+ Self::External(plugin) => plugin.tools(),
+ }
+ }
+
+ fn validate(&self) -> Result<(), PluginError> {
+ match self {
+ Self::Builtin(plugin) => plugin.validate(),
+ Self::Bundled(plugin) => plugin.validate(),
+ Self::External(plugin) => plugin.validate(),
+ }
+ }
+
+ fn initialize(&self) -> Result<(), PluginError> {
+ match self {
+ Self::Builtin(plugin) => plugin.initialize(),
+ Self::Bundled(plugin) => plugin.initialize(),
+ Self::External(plugin) => plugin.initialize(),
+ }
+ }
+
+ fn shutdown(&self) -> Result<(), PluginError> {
+ match self {
+ Self::Builtin(plugin) => plugin.shutdown(),
+ Self::Bundled(plugin) => plugin.shutdown(),
+ Self::External(plugin) => plugin.shutdown(),
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct RegisteredPlugin {
+ definition: PluginDefinition,
+ enabled: bool,
+}
+
+impl RegisteredPlugin {
+ #[must_use]
+ pub fn new(definition: PluginDefinition, enabled: bool) -> Self {
+ Self {
+ definition,
+ enabled,
+ }
+ }
+
+ #[must_use]
+ pub fn metadata(&self) -> &PluginMetadata {
+ self.definition.metadata()
+ }
+
+ #[must_use]
+ pub fn hooks(&self) -> &PluginHooks {
+ self.definition.hooks()
+ }
+
+ #[must_use]
+ pub fn tools(&self) -> &[PluginTool] {
+ self.definition.tools()
+ }
+
+ #[must_use]
+ pub fn is_enabled(&self) -> bool {
+ self.enabled
+ }
+
+ pub fn validate(&self) -> Result<(), PluginError> {
+ self.definition.validate()
+ }
+
+ pub fn initialize(&self) -> Result<(), PluginError> {
+ self.definition.initialize()
+ }
+
+ pub fn shutdown(&self) -> Result<(), PluginError> {
+ self.definition.shutdown()
+ }
+
+ #[must_use]
+ pub fn summary(&self) -> PluginSummary {
+ PluginSummary {
+ metadata: self.metadata().clone(),
+ enabled: self.enabled,
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct PluginSummary {
+ pub metadata: PluginMetadata,
+ pub enabled: bool,
+}
+
+#[derive(Debug, Clone, Default, PartialEq)]
+pub struct PluginRegistry {
+ plugins: Vec,
+}
+
+impl PluginRegistry {
+ #[must_use]
+ pub fn new(mut plugins: Vec) -> Self {
+ plugins.sort_by(|left, right| left.metadata().id.cmp(&right.metadata().id));
+ Self { plugins }
+ }
+
+ #[must_use]
+ pub fn plugins(&self) -> &[RegisteredPlugin] {
+ &self.plugins
+ }
+
+ #[must_use]
+ pub fn get(&self, plugin_id: &str) -> Option<&RegisteredPlugin> {
+ self.plugins
+ .iter()
+ .find(|plugin| plugin.metadata().id == plugin_id)
+ }
+
+ #[must_use]
+ pub fn contains(&self, plugin_id: &str) -> bool {
+ self.get(plugin_id).is_some()
+ }
+
+ #[must_use]
+ pub fn summaries(&self) -> Vec {
+ self.plugins.iter().map(RegisteredPlugin::summary).collect()
+ }
+
+ pub fn aggregated_hooks(&self) -> Result {
+ self.plugins
+ .iter()
+ .filter(|plugin| plugin.is_enabled())
+ .try_fold(PluginHooks::default(), |acc, plugin| {
+ plugin.validate()?;
+ Ok(acc.merged_with(plugin.hooks()))
+ })
+ }
+
+ pub fn aggregated_tools(&self) -> Result, PluginError> {
+ let mut tools = Vec::new();
+ let mut seen_names = BTreeMap::new();
+ for plugin in self.plugins.iter().filter(|plugin| plugin.is_enabled()) {
+ plugin.validate()?;
+ for tool in plugin.tools() {
+ if let Some(existing_plugin) =
+ seen_names.insert(tool.definition().name.clone(), tool.plugin_id().to_string())
+ {
+ return Err(PluginError::InvalidManifest(format!(
+ "plugin tool `{}` is defined by both `{existing_plugin}` and `{}`",
+ tool.definition().name,
+ tool.plugin_id()
+ )));
+ }
+ tools.push(tool.clone());
+ }
+ }
+ Ok(tools)
+ }
+
+ pub fn initialize(&self) -> Result<(), PluginError> {
+ for plugin in self.plugins.iter().filter(|plugin| plugin.is_enabled()) {
+ plugin.validate()?;
+ plugin.initialize()?;
+ }
+ Ok(())
+ }
+
+ pub fn shutdown(&self) -> Result<(), PluginError> {
+ for plugin in self
+ .plugins
+ .iter()
+ .rev()
+ .filter(|plugin| plugin.is_enabled())
+ {
+ plugin.shutdown()?;
+ }
+ Ok(())
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct PluginManagerConfig {
+ pub config_home: PathBuf,
+ pub enabled_plugins: BTreeMap,
+ pub external_dirs: Vec,
+ pub install_root: Option,
+ pub registry_path: Option,
+ pub bundled_root: Option,
+}
+
+impl PluginManagerConfig {
+ #[must_use]
+ pub fn new(config_home: impl Into) -> Self {
+ Self {
+ config_home: config_home.into(),
+ enabled_plugins: BTreeMap::new(),
+ external_dirs: Vec::new(),
+ install_root: None,
+ registry_path: None,
+ bundled_root: None,
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct PluginManager {
+ config: PluginManagerConfig,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct InstallOutcome {
+ pub plugin_id: String,
+ pub version: String,
+ pub install_path: PathBuf,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct UpdateOutcome {
+ pub plugin_id: String,
+ pub old_version: String,
+ pub new_version: String,
+ pub install_path: PathBuf,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum PluginManifestValidationError {
+ EmptyField {
+ field: &'static str,
+ },
+ EmptyEntryField {
+ kind: &'static str,
+ field: &'static str,
+ name: Option,
+ },
+ InvalidPermission {
+ permission: String,
+ },
+ DuplicatePermission {
+ permission: String,
+ },
+ DuplicateEntry {
+ kind: &'static str,
+ name: String,
+ },
+ MissingPath {
+ kind: &'static str,
+ path: PathBuf,
+ },
+ InvalidToolInputSchema {
+ tool_name: String,
+ },
+ InvalidToolRequiredPermission {
+ tool_name: String,
+ permission: String,
+ },
+}
+
+impl Display for PluginManifestValidationError {
+ fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::EmptyField { field } => {
+ write!(f, "plugin manifest {field} cannot be empty")
+ }
+ Self::EmptyEntryField { kind, field, name } => match name {
+ Some(name) if !name.is_empty() => {
+ write!(f, "plugin {kind} `{name}` {field} cannot be empty")
+ }
+ _ => write!(f, "plugin {kind} {field} cannot be empty"),
+ },
+ Self::InvalidPermission { permission } => {
+ write!(
+ f,
+ "plugin manifest permission `{permission}` must be one of read, write, or execute"
+ )
+ }
+ Self::DuplicatePermission { permission } => {
+ write!(f, "plugin manifest permission `{permission}` is duplicated")
+ }
+ Self::DuplicateEntry { kind, name } => {
+ write!(f, "plugin {kind} `{name}` is duplicated")
+ }
+ Self::MissingPath { kind, path } => {
+ write!(f, "{kind} path `{}` does not exist", path.display())
+ }
+ Self::InvalidToolInputSchema { tool_name } => {
+ write!(
+ f,
+ "plugin tool `{tool_name}` inputSchema must be a JSON object"
+ )
+ }
+ Self::InvalidToolRequiredPermission {
+ tool_name,
+ permission,
+ } => write!(
+ f,
+ "plugin tool `{tool_name}` requiredPermission `{permission}` must be read-only, workspace-write, or danger-full-access"
+ ),
+ }
+ }
+}
+
+#[derive(Debug)]
+pub enum PluginError {
+ Io(std::io::Error),
+ Json(serde_json::Error),
+ ManifestValidation(Vec),
+ InvalidManifest(String),
+ NotFound(String),
+ CommandFailed(String),
+}
+
+impl Display for PluginError {
+ fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::Io(error) => write!(f, "{error}"),
+ Self::Json(error) => write!(f, "{error}"),
+ Self::ManifestValidation(errors) => {
+ for (index, error) in errors.iter().enumerate() {
+ if index > 0 {
+ write!(f, "; ")?;
+ }
+ write!(f, "{error}")?;
+ }
+ Ok(())
+ }
+ Self::InvalidManifest(message)
+ | Self::NotFound(message)
+ | Self::CommandFailed(message) => write!(f, "{message}"),
+ }
+ }
+}
+
+impl std::error::Error for PluginError {}
+
+impl From for PluginError {
+ fn from(value: std::io::Error) -> Self {
+ Self::Io(value)
+ }
+}
+
+impl From for PluginError {
+ fn from(value: serde_json::Error) -> Self {
+ Self::Json(value)
+ }
+}
+
+impl PluginManager {
+ #[must_use]
+ pub fn new(config: PluginManagerConfig) -> Self {
+ Self { config }
+ }
+
+ #[must_use]
+ pub fn bundled_root() -> PathBuf {
+ PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("bundled")
+ }
+
+ #[must_use]
+ pub fn install_root(&self) -> PathBuf {
+ self.config
+ .install_root
+ .clone()
+ .unwrap_or_else(|| self.config.config_home.join("plugins").join("installed"))
+ }
+
+ #[must_use]
+ pub fn registry_path(&self) -> PathBuf {
+ self.config.registry_path.clone().unwrap_or_else(|| {
+ self.config
+ .config_home
+ .join("plugins")
+ .join(REGISTRY_FILE_NAME)
+ })
+ }
+
+ #[must_use]
+ pub fn settings_path(&self) -> PathBuf {
+ self.config.config_home.join(SETTINGS_FILE_NAME)
+ }
+
+ pub fn plugin_registry(&self) -> Result {
+ Ok(PluginRegistry::new(
+ self.discover_plugins()?
+ .into_iter()
+ .map(|plugin| {
+ let enabled = self.is_enabled(plugin.metadata());
+ RegisteredPlugin::new(plugin, enabled)
+ })
+ .collect(),
+ ))
+ }
+
+ pub fn list_plugins(&self) -> Result, PluginError> {
+ Ok(self.plugin_registry()?.summaries())
+ }
+
+ pub fn list_installed_plugins(&self) -> Result, PluginError> {
+ Ok(self.installed_plugin_registry()?.summaries())
+ }
+
+ pub fn discover_plugins(&self) -> Result, PluginError> {
+ self.sync_bundled_plugins()?;
+ let mut plugins = builtin_plugins();
+ plugins.extend(self.discover_installed_plugins()?);
+ plugins.extend(self.discover_external_directory_plugins(&plugins)?);
+ Ok(plugins)
+ }
+
+ pub fn aggregated_hooks(&self) -> Result {
+ self.plugin_registry()?.aggregated_hooks()
+ }
+
+ pub fn aggregated_tools(&self) -> Result, PluginError> {
+ self.plugin_registry()?.aggregated_tools()
+ }
+
+ pub fn validate_plugin_source(&self, source: &str) -> Result {
+ let path = resolve_local_source(source)?;
+ load_plugin_from_directory(&path)
+ }
+
+ pub fn install(&mut self, source: &str) -> Result {
+ let install_source = parse_install_source(source)?;
+ let temp_root = self.install_root().join(".tmp");
+ let staged_source = materialize_source(&install_source, &temp_root)?;
+ let cleanup_source = matches!(install_source, PluginInstallSource::GitUrl { .. });
+ let manifest = load_plugin_from_directory(&staged_source)?;
+
+ let plugin_id = plugin_id(&manifest.name, EXTERNAL_MARKETPLACE);
+ let install_path = self.install_root().join(sanitize_plugin_id(&plugin_id));
+ if install_path.exists() {
+ fs::remove_dir_all(&install_path)?;
+ }
+ copy_dir_all(&staged_source, &install_path)?;
+ if cleanup_source {
+ let _ = fs::remove_dir_all(&staged_source);
+ }
+
+ let now = unix_time_ms();
+ let record = InstalledPluginRecord {
+ kind: PluginKind::External,
+ id: plugin_id.clone(),
+ name: manifest.name,
+ version: manifest.version.clone(),
+ description: manifest.description,
+ install_path: install_path.clone(),
+ source: install_source,
+ installed_at_unix_ms: now,
+ updated_at_unix_ms: now,
+ };
+
+ let mut registry = self.load_registry()?;
+ registry.plugins.insert(plugin_id.clone(), record);
+ self.store_registry(®istry)?;
+ self.write_enabled_state(&plugin_id, Some(true))?;
+ self.config.enabled_plugins.insert(plugin_id.clone(), true);
+
+ Ok(InstallOutcome {
+ plugin_id,
+ version: manifest.version,
+ install_path,
+ })
+ }
+
+ pub fn enable(&mut self, plugin_id: &str) -> Result<(), PluginError> {
+ self.ensure_known_plugin(plugin_id)?;
+ self.write_enabled_state(plugin_id, Some(true))?;
+ self.config
+ .enabled_plugins
+ .insert(plugin_id.to_string(), true);
+ Ok(())
+ }
+
+ pub fn disable(&mut self, plugin_id: &str) -> Result<(), PluginError> {
+ self.ensure_known_plugin(plugin_id)?;
+ self.write_enabled_state(plugin_id, Some(false))?;
+ self.config
+ .enabled_plugins
+ .insert(plugin_id.to_string(), false);
+ Ok(())
+ }
+
+ pub fn uninstall(&mut self, plugin_id: &str) -> Result<(), PluginError> {
+ let mut registry = self.load_registry()?;
+ let record = registry.plugins.remove(plugin_id).ok_or_else(|| {
+ PluginError::NotFound(format!("plugin `{plugin_id}` is not installed"))
+ })?;
+ if record.kind == PluginKind::Bundled {
+ registry.plugins.insert(plugin_id.to_string(), record);
+ return Err(PluginError::CommandFailed(format!(
+ "plugin `{plugin_id}` is bundled and managed automatically; disable it instead"
+ )));
+ }
+ if record.install_path.exists() {
+ fs::remove_dir_all(&record.install_path)?;
+ }
+ self.store_registry(®istry)?;
+ self.write_enabled_state(plugin_id, None)?;
+ self.config.enabled_plugins.remove(plugin_id);
+ Ok(())
+ }
+
+ pub fn update(&mut self, plugin_id: &str) -> Result {
+ let mut registry = self.load_registry()?;
+ let record = registry.plugins.get(plugin_id).cloned().ok_or_else(|| {
+ PluginError::NotFound(format!("plugin `{plugin_id}` is not installed"))
+ })?;
+
+ let temp_root = self.install_root().join(".tmp");
+ let staged_source = materialize_source(&record.source, &temp_root)?;
+ let cleanup_source = matches!(record.source, PluginInstallSource::GitUrl { .. });
+ let manifest = load_plugin_from_directory(&staged_source)?;
+
+ if record.install_path.exists() {
+ fs::remove_dir_all(&record.install_path)?;
+ }
+ copy_dir_all(&staged_source, &record.install_path)?;
+ if cleanup_source {
+ let _ = fs::remove_dir_all(&staged_source);
+ }
+
+ let updated_record = InstalledPluginRecord {
+ version: manifest.version.clone(),
+ description: manifest.description,
+ updated_at_unix_ms: unix_time_ms(),
+ ..record.clone()
+ };
+ registry
+ .plugins
+ .insert(plugin_id.to_string(), updated_record);
+ self.store_registry(®istry)?;
+
+ Ok(UpdateOutcome {
+ plugin_id: plugin_id.to_string(),
+ old_version: record.version,
+ new_version: manifest.version,
+ install_path: record.install_path,
+ })
+ }
+
+ fn discover_installed_plugins(&self) -> Result, PluginError> {
+ let mut registry = self.load_registry()?;
+ let mut plugins = Vec::new();
+ let mut seen_ids = BTreeSet::::new();
+ let mut seen_paths = BTreeSet::::new();
+ let mut stale_registry_ids = Vec::new();
+
+ for install_path in discover_plugin_dirs(&self.install_root())? {
+ let matched_record = registry
+ .plugins
+ .values()
+ .find(|record| record.install_path == install_path);
+ let kind = matched_record.map_or(PluginKind::External, |record| record.kind);
+ let source = matched_record.map_or_else(
+ || install_path.display().to_string(),
+ |record| describe_install_source(&record.source),
+ );
+ let plugin = load_plugin_definition(&install_path, kind, source, kind.marketplace())?;
+ if seen_ids.insert(plugin.metadata().id.clone()) {
+ seen_paths.insert(install_path);
+ plugins.push(plugin);
+ }
+ }
+
+ for record in registry.plugins.values() {
+ if seen_paths.contains(&record.install_path) {
+ continue;
+ }
+ if !record.install_path.exists() || plugin_manifest_path(&record.install_path).is_err()
+ {
+ stale_registry_ids.push(record.id.clone());
+ continue;
+ }
+ let plugin = load_plugin_definition(
+ &record.install_path,
+ record.kind,
+ describe_install_source(&record.source),
+ record.kind.marketplace(),
+ )?;
+ if seen_ids.insert(plugin.metadata().id.clone()) {
+ seen_paths.insert(record.install_path.clone());
+ plugins.push(plugin);
+ }
+ }
+
+ if !stale_registry_ids.is_empty() {
+ for plugin_id in stale_registry_ids {
+ registry.plugins.remove(&plugin_id);
+ }
+ self.store_registry(®istry)?;
+ }
+
+ Ok(plugins)
+ }
+
+ fn discover_external_directory_plugins(
+ &self,
+ existing_plugins: &[PluginDefinition],
+ ) -> Result, PluginError> {
+ let mut plugins = Vec::new();
+
+ for directory in &self.config.external_dirs {
+ for root in discover_plugin_dirs(directory)? {
+ let plugin = load_plugin_definition(
+ &root,
+ PluginKind::External,
+ root.display().to_string(),
+ EXTERNAL_MARKETPLACE,
+ )?;
+ if existing_plugins
+ .iter()
+ .chain(plugins.iter())
+ .all(|existing| existing.metadata().id != plugin.metadata().id)
+ {
+ plugins.push(plugin);
+ }
+ }
+ }
+
+ Ok(plugins)
+ }
+
+ fn installed_plugin_registry(&self) -> Result {
+ self.sync_bundled_plugins()?;
+ Ok(PluginRegistry::new(
+ self.discover_installed_plugins()?
+ .into_iter()
+ .map(|plugin| {
+ let enabled = self.is_enabled(plugin.metadata());
+ RegisteredPlugin::new(plugin, enabled)
+ })
+ .collect(),
+ ))
+ }
+
+ fn sync_bundled_plugins(&self) -> Result<(), PluginError> {
+ let bundled_root = self
+ .config
+ .bundled_root
+ .clone()
+ .unwrap_or_else(Self::bundled_root);
+ let bundled_plugins = discover_plugin_dirs(&bundled_root)?;
+ let mut registry = self.load_registry()?;
+ let mut changed = false;
+ let install_root = self.install_root();
+ let mut active_bundled_ids = BTreeSet::new();
+
+ for source_root in bundled_plugins {
+ let manifest = load_plugin_from_directory(&source_root)?;
+ let plugin_id = plugin_id(&manifest.name, BUNDLED_MARKETPLACE);
+ active_bundled_ids.insert(plugin_id.clone());
+ let install_path = install_root.join(sanitize_plugin_id(&plugin_id));
+ let now = unix_time_ms();
+ let existing_record = registry.plugins.get(&plugin_id);
+ let needs_sync = existing_record.is_none_or(|record| {
+ record.kind != PluginKind::Bundled
+ || record.version != manifest.version
+ || record.name != manifest.name
+ || record.description != manifest.description
+ || record.install_path != install_path
+ || !record.install_path.exists()
+ });
+
+ if !needs_sync {
+ continue;
+ }
+
+ if install_path.exists() {
+ fs::remove_dir_all(&install_path)?;
+ }
+ copy_dir_all(&source_root, &install_path)?;
+
+ let installed_at_unix_ms =
+ existing_record.map_or(now, |record| record.installed_at_unix_ms);
+ registry.plugins.insert(
+ plugin_id.clone(),
+ InstalledPluginRecord {
+ kind: PluginKind::Bundled,
+ id: plugin_id,
+ name: manifest.name,
+ version: manifest.version,
+ description: manifest.description,
+ install_path,
+ source: PluginInstallSource::LocalPath { path: source_root },
+ installed_at_unix_ms,
+ updated_at_unix_ms: now,
+ },
+ );
+ changed = true;
+ }
+
+ let stale_bundled_ids = registry
+ .plugins
+ .iter()
+ .filter_map(|(plugin_id, record)| {
+ (record.kind == PluginKind::Bundled && !active_bundled_ids.contains(plugin_id))
+ .then_some(plugin_id.clone())
+ })
+ .collect::>();
+
+ for plugin_id in stale_bundled_ids {
+ if let Some(record) = registry.plugins.remove(&plugin_id) {
+ if record.install_path.exists() {
+ fs::remove_dir_all(&record.install_path)?;
+ }
+ changed = true;
+ }
+ }
+
+ if changed {
+ self.store_registry(®istry)?;
+ }
+
+ Ok(())
+ }
+
+ fn is_enabled(&self, metadata: &PluginMetadata) -> bool {
+ self.config
+ .enabled_plugins
+ .get(&metadata.id)
+ .copied()
+ .unwrap_or(match metadata.kind {
+ PluginKind::External => false,
+ PluginKind::Builtin | PluginKind::Bundled => metadata.default_enabled,
+ })
+ }
+
+ fn ensure_known_plugin(&self, plugin_id: &str) -> Result<(), PluginError> {
+ if self.plugin_registry()?.contains(plugin_id) {
+ Ok(())
+ } else {
+ Err(PluginError::NotFound(format!(
+ "plugin `{plugin_id}` is not installed or discoverable"
+ )))
+ }
+ }
+
+ fn load_registry(&self) -> Result {
+ let path = self.registry_path();
+ match fs::read_to_string(&path) {
+ Ok(contents) => Ok(serde_json::from_str(&contents)?),
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
+ Ok(InstalledPluginRegistry::default())
+ }
+ Err(error) => Err(PluginError::Io(error)),
+ }
+ }
+
+ fn store_registry(&self, registry: &InstalledPluginRegistry) -> Result<(), PluginError> {
+ let path = self.registry_path();
+ if let Some(parent) = path.parent() {
+ fs::create_dir_all(parent)?;
+ }
+ fs::write(path, serde_json::to_string_pretty(registry)?)?;
+ Ok(())
+ }
+
+ fn write_enabled_state(
+ &self,
+ plugin_id: &str,
+ enabled: Option,
+ ) -> Result<(), PluginError> {
+ update_settings_json(&self.settings_path(), |root| {
+ let enabled_plugins = ensure_object(root, "enabledPlugins");
+ match enabled {
+ Some(value) => {
+ enabled_plugins.insert(plugin_id.to_string(), Value::Bool(value));
+ }
+ None => {
+ enabled_plugins.remove(plugin_id);
+ }
+ }
+ })
+ }
+}
+
+#[must_use]
+pub fn builtin_plugins() -> Vec {
+ vec![PluginDefinition::Builtin(BuiltinPlugin {
+ metadata: PluginMetadata {
+ id: plugin_id("example-builtin", BUILTIN_MARKETPLACE),
+ name: "example-builtin".to_string(),
+ version: "0.1.0".to_string(),
+ description: "Example built-in plugin scaffold for the Rust plugin system".to_string(),
+ kind: PluginKind::Builtin,
+ source: BUILTIN_MARKETPLACE.to_string(),
+ default_enabled: false,
+ root: None,
+ },
+ hooks: PluginHooks::default(),
+ lifecycle: PluginLifecycle::default(),
+ tools: Vec::new(),
+ })]
+}
+
+fn load_plugin_definition(
+ root: &Path,
+ kind: PluginKind,
+ source: String,
+ marketplace: &str,
+) -> Result {
+ let manifest = load_plugin_from_directory(root)?;
+ let metadata = PluginMetadata {
+ id: plugin_id(&manifest.name, marketplace),
+ name: manifest.name,
+ version: manifest.version,
+ description: manifest.description,
+ kind,
+ source,
+ default_enabled: manifest.default_enabled,
+ root: Some(root.to_path_buf()),
+ };
+ let hooks = resolve_hooks(root, &manifest.hooks);
+ let lifecycle = resolve_lifecycle(root, &manifest.lifecycle);
+ let tools = resolve_tools(root, &metadata.id, &metadata.name, &manifest.tools);
+ Ok(match kind {
+ PluginKind::Builtin => PluginDefinition::Builtin(BuiltinPlugin {
+ metadata,
+ hooks,
+ lifecycle,
+ tools,
+ }),
+ PluginKind::Bundled => PluginDefinition::Bundled(BundledPlugin {
+ metadata,
+ hooks,
+ lifecycle,
+ tools,
+ }),
+ PluginKind::External => PluginDefinition::External(ExternalPlugin {
+ metadata,
+ hooks,
+ lifecycle,
+ tools,
+ }),
+ })
+}
+
+pub fn load_plugin_from_directory(root: &Path) -> Result {
+ load_manifest_from_directory(root)
+}
+
+fn load_manifest_from_directory(root: &Path) -> Result {
+ let manifest_path = plugin_manifest_path(root)?;
+ load_manifest_from_path(root, &manifest_path)
+}
+
+fn load_manifest_from_path(
+ root: &Path,
+ manifest_path: &Path,
+) -> Result {
+ let contents = fs::read_to_string(manifest_path).map_err(|error| {
+ PluginError::NotFound(format!(
+ "plugin manifest not found at {}: {error}",
+ manifest_path.display()
+ ))
+ })?;
+ let raw_manifest: RawPluginManifest = serde_json::from_str(&contents)?;
+ build_plugin_manifest(root, raw_manifest)
+}
+
+fn plugin_manifest_path(root: &Path) -> Result {
+ let direct_path = root.join(MANIFEST_FILE_NAME);
+ if direct_path.exists() {
+ return Ok(direct_path);
+ }
+
+ let packaged_path = root.join(MANIFEST_RELATIVE_PATH);
+ if packaged_path.exists() {
+ return Ok(packaged_path);
+ }
+
+ Err(PluginError::NotFound(format!(
+ "plugin manifest not found at {} or {}",
+ direct_path.display(),
+ packaged_path.display()
+ )))
+}
+
+fn build_plugin_manifest(
+ root: &Path,
+ raw: RawPluginManifest,
+) -> Result {
+ let mut errors = Vec::new();
+
+ validate_required_manifest_field("name", &raw.name, &mut errors);
+ validate_required_manifest_field("version", &raw.version, &mut errors);
+ validate_required_manifest_field("description", &raw.description, &mut errors);
+
+ let permissions = build_manifest_permissions(&raw.permissions, &mut errors);
+ validate_command_entries(root, raw.hooks.pre_tool_use.iter(), "hook", &mut errors);
+ validate_command_entries(root, raw.hooks.post_tool_use.iter(), "hook", &mut errors);
+ validate_command_entries(
+ root,
+ raw.lifecycle.init.iter(),
+ "lifecycle command",
+ &mut errors,
+ );
+ validate_command_entries(
+ root,
+ raw.lifecycle.shutdown.iter(),
+ "lifecycle command",
+ &mut errors,
+ );
+ let tools = build_manifest_tools(root, raw.tools, &mut errors);
+ let commands = build_manifest_commands(root, raw.commands, &mut errors);
+
+ if !errors.is_empty() {
+ return Err(PluginError::ManifestValidation(errors));
+ }
+
+ Ok(PluginManifest {
+ name: raw.name,
+ version: raw.version,
+ description: raw.description,
+ permissions,
+ default_enabled: raw.default_enabled,
+ hooks: raw.hooks,
+ lifecycle: raw.lifecycle,
+ tools,
+ commands,
+ })
+}
+
+fn validate_required_manifest_field(
+ field: &'static str,
+ value: &str,
+ errors: &mut Vec,
+) {
+ if value.trim().is_empty() {
+ errors.push(PluginManifestValidationError::EmptyField { field });
+ }
+}
+
+fn build_manifest_permissions(
+ permissions: &[String],
+ errors: &mut Vec,
+) -> Vec {
+ let mut seen = BTreeSet::new();
+ let mut validated = Vec::new();
+
+ for permission in permissions {
+ let permission = permission.trim();
+ if permission.is_empty() {
+ errors.push(PluginManifestValidationError::EmptyEntryField {
+ kind: "permission",
+ field: "value",
+ name: None,
+ });
+ continue;
+ }
+ if !seen.insert(permission.to_string()) {
+ errors.push(PluginManifestValidationError::DuplicatePermission {
+ permission: permission.to_string(),
+ });
+ continue;
+ }
+ match PluginPermission::parse(permission) {
+ Some(permission) => validated.push(permission),
+ None => errors.push(PluginManifestValidationError::InvalidPermission {
+ permission: permission.to_string(),
+ }),
+ }
+ }
+
+ validated
+}
+
+fn build_manifest_tools(
+ root: &Path,
+ tools: Vec,
+ errors: &mut Vec,
+) -> Vec {
+ let mut seen = BTreeSet::new();
+ let mut validated = Vec::new();
+
+ for tool in tools {
+ let name = tool.name.trim().to_string();
+ if name.is_empty() {
+ errors.push(PluginManifestValidationError::EmptyEntryField {
+ kind: "tool",
+ field: "name",
+ name: None,
+ });
+ continue;
+ }
+ if !seen.insert(name.clone()) {
+ errors.push(PluginManifestValidationError::DuplicateEntry { kind: "tool", name });
+ continue;
+ }
+ if tool.description.trim().is_empty() {
+ errors.push(PluginManifestValidationError::EmptyEntryField {
+ kind: "tool",
+ field: "description",
+ name: Some(name.clone()),
+ });
+ }
+ if tool.command.trim().is_empty() {
+ errors.push(PluginManifestValidationError::EmptyEntryField {
+ kind: "tool",
+ field: "command",
+ name: Some(name.clone()),
+ });
+ } else {
+ validate_command_entry(root, &tool.command, "tool", errors);
+ }
+ if !tool.input_schema.is_object() {
+ errors.push(PluginManifestValidationError::InvalidToolInputSchema {
+ tool_name: name.clone(),
+ });
+ }
+ let Some(required_permission) =
+ PluginToolPermission::parse(tool.required_permission.trim())
+ else {
+ errors.push(
+ PluginManifestValidationError::InvalidToolRequiredPermission {
+ tool_name: name.clone(),
+ permission: tool.required_permission.trim().to_string(),
+ },
+ );
+ continue;
+ };
+
+ validated.push(PluginToolManifest {
+ name,
+ description: tool.description,
+ input_schema: tool.input_schema,
+ command: tool.command,
+ args: tool.args,
+ required_permission,
+ });
+ }
+
+ validated
+}
+
+fn build_manifest_commands(
+ root: &Path,
+ commands: Vec,
+ errors: &mut Vec,
+) -> Vec {
+ let mut seen = BTreeSet::new();
+ let mut validated = Vec::new();
+
+ for command in commands {
+ let name = command.name.trim().to_string();
+ if name.is_empty() {
+ errors.push(PluginManifestValidationError::EmptyEntryField {
+ kind: "command",
+ field: "name",
+ name: None,
+ });
+ continue;
+ }
+ if !seen.insert(name.clone()) {
+ errors.push(PluginManifestValidationError::DuplicateEntry {
+ kind: "command",
+ name,
+ });
+ continue;
+ }
+ if command.description.trim().is_empty() {
+ errors.push(PluginManifestValidationError::EmptyEntryField {
+ kind: "command",
+ field: "description",
+ name: Some(name.clone()),
+ });
+ }
+ if command.command.trim().is_empty() {
+ errors.push(PluginManifestValidationError::EmptyEntryField {
+ kind: "command",
+ field: "command",
+ name: Some(name.clone()),
+ });
+ } else {
+ validate_command_entry(root, &command.command, "command", errors);
+ }
+ validated.push(command);
+ }
+
+ validated
+}
+
+fn validate_command_entries<'a>(
+ root: &Path,
+ entries: impl Iterator- ,
+ kind: &'static str,
+ errors: &mut Vec,
+) {
+ for entry in entries {
+ validate_command_entry(root, entry, kind, errors);
+ }
+}
+
+fn validate_command_entry(
+ root: &Path,
+ entry: &str,
+ kind: &'static str,
+ errors: &mut Vec,
+) {
+ if entry.trim().is_empty() {
+ errors.push(PluginManifestValidationError::EmptyEntryField {
+ kind,
+ field: "command",
+ name: None,
+ });
+ return;
+ }
+ if is_literal_command(entry) {
+ return;
+ }
+
+ let path = if Path::new(entry).is_absolute() {
+ PathBuf::from(entry)
+ } else {
+ root.join(entry)
+ };
+ if !path.exists() {
+ errors.push(PluginManifestValidationError::MissingPath { kind, path });
+ }
+}
+
+fn resolve_hooks(root: &Path, hooks: &PluginHooks) -> PluginHooks {
+ PluginHooks {
+ pre_tool_use: hooks
+ .pre_tool_use
+ .iter()
+ .map(|entry| resolve_hook_entry(root, entry))
+ .collect(),
+ post_tool_use: hooks
+ .post_tool_use
+ .iter()
+ .map(|entry| resolve_hook_entry(root, entry))
+ .collect(),
+ }
+}
+
+fn resolve_lifecycle(root: &Path, lifecycle: &PluginLifecycle) -> PluginLifecycle {
+ PluginLifecycle {
+ init: lifecycle
+ .init
+ .iter()
+ .map(|entry| resolve_hook_entry(root, entry))
+ .collect(),
+ shutdown: lifecycle
+ .shutdown
+ .iter()
+ .map(|entry| resolve_hook_entry(root, entry))
+ .collect(),
+ }
+}
+
+fn resolve_tools(
+ root: &Path,
+ plugin_id: &str,
+ plugin_name: &str,
+ tools: &[PluginToolManifest],
+) -> Vec {
+ tools
+ .iter()
+ .map(|tool| {
+ PluginTool::new(
+ plugin_id,
+ plugin_name,
+ PluginToolDefinition {
+ name: tool.name.clone(),
+ description: Some(tool.description.clone()),
+ input_schema: tool.input_schema.clone(),
+ },
+ resolve_hook_entry(root, &tool.command),
+ tool.args.clone(),
+ tool.required_permission,
+ Some(root.to_path_buf()),
+ )
+ })
+ .collect()
+}
+
+fn validate_hook_paths(root: Option<&Path>, hooks: &PluginHooks) -> Result<(), PluginError> {
+ let Some(root) = root else {
+ return Ok(());
+ };
+ for entry in hooks.pre_tool_use.iter().chain(hooks.post_tool_use.iter()) {
+ validate_command_path(root, entry, "hook")?;
+ }
+ Ok(())
+}
+
+fn validate_lifecycle_paths(
+ root: Option<&Path>,
+ lifecycle: &PluginLifecycle,
+) -> Result<(), PluginError> {
+ let Some(root) = root else {
+ return Ok(());
+ };
+ for entry in lifecycle.init.iter().chain(lifecycle.shutdown.iter()) {
+ validate_command_path(root, entry, "lifecycle command")?;
+ }
+ Ok(())
+}
+
+fn validate_tool_paths(root: Option<&Path>, tools: &[PluginTool]) -> Result<(), PluginError> {
+ let Some(root) = root else {
+ return Ok(());
+ };
+ for tool in tools {
+ validate_command_path(root, &tool.command, "tool")?;
+ }
+ Ok(())
+}
+
+fn validate_command_path(root: &Path, entry: &str, kind: &str) -> Result<(), PluginError> {
+ if is_literal_command(entry) {
+ return Ok(());
+ }
+ let path = if Path::new(entry).is_absolute() {
+ PathBuf::from(entry)
+ } else {
+ root.join(entry)
+ };
+ if !path.exists() {
+ return Err(PluginError::InvalidManifest(format!(
+ "{kind} path `{}` does not exist",
+ path.display()
+ )));
+ }
+ Ok(())
+}
+
+fn resolve_hook_entry(root: &Path, entry: &str) -> String {
+ if is_literal_command(entry) {
+ entry.to_string()
+ } else {
+ root.join(entry).display().to_string()
+ }
+}
+
+fn is_literal_command(entry: &str) -> bool {
+ !entry.starts_with("./") && !entry.starts_with("../") && !Path::new(entry).is_absolute()
+}
+
+fn run_lifecycle_commands(
+ metadata: &PluginMetadata,
+ lifecycle: &PluginLifecycle,
+ phase: &str,
+ commands: &[String],
+) -> Result<(), PluginError> {
+ if lifecycle.is_empty() || commands.is_empty() {
+ return Ok(());
+ }
+
+ for command in commands {
+ let mut process = if Path::new(command).exists() {
+ if cfg!(windows) {
+ let mut process = Command::new("cmd");
+ process.arg("/C").arg(command);
+ process
+ } else {
+ let mut process = Command::new("sh");
+ process.arg(command);
+ process
+ }
+ } else if cfg!(windows) {
+ let mut process = Command::new("cmd");
+ process.arg("/C").arg(command);
+ process
+ } else {
+ let mut process = Command::new("sh");
+ process.arg("-lc").arg(command);
+ process
+ };
+ if let Some(root) = &metadata.root {
+ process.current_dir(root);
+ }
+ let output = process.output()?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
+ return Err(PluginError::CommandFailed(format!(
+ "plugin `{}` {} failed for `{}`: {}",
+ metadata.id,
+ phase,
+ command,
+ if stderr.is_empty() {
+ format!("exit status {}", output.status)
+ } else {
+ stderr
+ }
+ )));
+ }
+ }
+
+ Ok(())
+}
+
+fn resolve_local_source(source: &str) -> Result {
+ let path = PathBuf::from(source);
+ if path.exists() {
+ Ok(path)
+ } else {
+ Err(PluginError::NotFound(format!(
+ "plugin source `{source}` was not found"
+ )))
+ }
+}
+
+fn parse_install_source(source: &str) -> Result {
+ if source.starts_with("http://")
+ || source.starts_with("https://")
+ || source.starts_with("git@")
+ || Path::new(source)
+ .extension()
+ .is_some_and(|extension| extension.eq_ignore_ascii_case("git"))
+ {
+ Ok(PluginInstallSource::GitUrl {
+ url: source.to_string(),
+ })
+ } else {
+ Ok(PluginInstallSource::LocalPath {
+ path: resolve_local_source(source)?,
+ })
+ }
+}
+
+fn materialize_source(
+ source: &PluginInstallSource,
+ temp_root: &Path,
+) -> Result {
+ fs::create_dir_all(temp_root)?;
+ match source {
+ PluginInstallSource::LocalPath { path } => Ok(path.clone()),
+ PluginInstallSource::GitUrl { url } => {
+ let destination = temp_root.join(format!("plugin-{}", unix_time_ms()));
+ let output = Command::new("git")
+ .arg("clone")
+ .arg("--depth")
+ .arg("1")
+ .arg(url)
+ .arg(&destination)
+ .output()?;
+ if !output.status.success() {
+ return Err(PluginError::CommandFailed(format!(
+ "git clone failed for `{url}`: {}",
+ String::from_utf8_lossy(&output.stderr).trim()
+ )));
+ }
+ Ok(destination)
+ }
+ }
+}
+
+fn discover_plugin_dirs(root: &Path) -> Result, PluginError> {
+ match fs::read_dir(root) {
+ Ok(entries) => {
+ let mut paths = Vec::new();
+ for entry in entries {
+ let path = entry?.path();
+ if path.is_dir() && plugin_manifest_path(&path).is_ok() {
+ paths.push(path);
+ }
+ }
+ paths.sort();
+ Ok(paths)
+ }
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
+ Err(error) => Err(PluginError::Io(error)),
+ }
+}
+
+fn plugin_id(name: &str, marketplace: &str) -> String {
+ format!("{name}@{marketplace}")
+}
+
+fn sanitize_plugin_id(plugin_id: &str) -> String {
+ plugin_id
+ .chars()
+ .map(|ch| match ch {
+ '/' | '\\' | '@' | ':' => '-',
+ other => other,
+ })
+ .collect()
+}
+
+fn describe_install_source(source: &PluginInstallSource) -> String {
+ match source {
+ PluginInstallSource::LocalPath { path } => path.display().to_string(),
+ PluginInstallSource::GitUrl { url } => url.clone(),
+ }
+}
+
+fn unix_time_ms() -> u128 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .expect("time should be after epoch")
+ .as_millis()
+}
+
+fn copy_dir_all(source: &Path, destination: &Path) -> Result<(), PluginError> {
+ fs::create_dir_all(destination)?;
+ for entry in fs::read_dir(source)? {
+ let entry = entry?;
+ let target = destination.join(entry.file_name());
+ if entry.file_type()?.is_dir() {
+ copy_dir_all(&entry.path(), &target)?;
+ } else {
+ fs::copy(entry.path(), target)?;
+ }
+ }
+ Ok(())
+}
+
+fn update_settings_json(
+ path: &Path,
+ mut update: impl FnMut(&mut Map),
+) -> Result<(), PluginError> {
+ if let Some(parent) = path.parent() {
+ fs::create_dir_all(parent)?;
+ }
+ let mut root = match fs::read_to_string(path) {
+ Ok(contents) if !contents.trim().is_empty() => serde_json::from_str::(&contents)?,
+ Ok(_) => Value::Object(Map::new()),
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => Value::Object(Map::new()),
+ Err(error) => return Err(PluginError::Io(error)),
+ };
+
+ let object = root.as_object_mut().ok_or_else(|| {
+ PluginError::InvalidManifest(format!(
+ "settings file {} must contain a JSON object",
+ path.display()
+ ))
+ })?;
+ update(object);
+ fs::write(path, serde_json::to_string_pretty(&root)?)?;
+ Ok(())
+}
+
+fn ensure_object<'a>(root: &'a mut Map, key: &str) -> &'a mut Map {
+ if !root.get(key).is_some_and(Value::is_object) {
+ root.insert(key.to_string(), Value::Object(Map::new()));
+ }
+ root.get_mut(key)
+ .and_then(Value::as_object_mut)
+ .expect("object should exist")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn temp_dir(label: &str) -> PathBuf {
+ std::env::temp_dir().join(format!("plugins-{label}-{}", unix_time_ms()))
+ }
+
+ fn write_file(path: &Path, contents: &str) {
+ if let Some(parent) = path.parent() {
+ fs::create_dir_all(parent).expect("parent dir");
+ }
+ fs::write(path, contents).expect("write file");
+ }
+
+ fn write_loader_plugin(root: &Path) {
+ write_file(
+ root.join("hooks").join("pre.sh").as_path(),
+ "#!/bin/sh\nprintf 'pre'\n",
+ );
+ write_file(
+ root.join("tools").join("echo-tool.sh").as_path(),
+ "#!/bin/sh\ncat\n",
+ );
+ write_file(
+ root.join("commands").join("sync.sh").as_path(),
+ "#!/bin/sh\nprintf 'sync'\n",
+ );
+ write_file(
+ root.join(MANIFEST_FILE_NAME).as_path(),
+ r#"{
+ "name": "loader-demo",
+ "version": "1.2.3",
+ "description": "Manifest loader test plugin",
+ "permissions": ["read", "write"],
+ "hooks": {
+ "PreToolUse": ["./hooks/pre.sh"]
+ },
+ "tools": [
+ {
+ "name": "echo_tool",
+ "description": "Echoes JSON input",
+ "inputSchema": {
+ "type": "object"
+ },
+ "command": "./tools/echo-tool.sh",
+ "requiredPermission": "workspace-write"
+ }
+ ],
+ "commands": [
+ {
+ "name": "sync",
+ "description": "Sync command",
+ "command": "./commands/sync.sh"
+ }
+ ]
+}"#,
+ );
+ }
+
+ fn write_external_plugin(root: &Path, name: &str, version: &str) {
+ write_file(
+ root.join("hooks").join("pre.sh").as_path(),
+ "#!/bin/sh\nprintf 'pre'\n",
+ );
+ write_file(
+ root.join("hooks").join("post.sh").as_path(),
+ "#!/bin/sh\nprintf 'post'\n",
+ );
+ write_file(
+ root.join(MANIFEST_RELATIVE_PATH).as_path(),
+ format!(
+ "{{\n \"name\": \"{name}\",\n \"version\": \"{version}\",\n \"description\": \"test plugin\",\n \"hooks\": {{\n \"PreToolUse\": [\"./hooks/pre.sh\"],\n \"PostToolUse\": [\"./hooks/post.sh\"]\n }}\n}}"
+ )
+ .as_str(),
+ );
+ }
+
+ fn write_broken_plugin(root: &Path, name: &str) {
+ write_file(
+ root.join(MANIFEST_RELATIVE_PATH).as_path(),
+ format!(
+ "{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"broken plugin\",\n \"hooks\": {{\n \"PreToolUse\": [\"./hooks/missing.sh\"]\n }}\n}}"
+ )
+ .as_str(),
+ );
+ }
+
+ fn write_lifecycle_plugin(root: &Path, name: &str, version: &str) -> PathBuf {
+ let log_path = root.join("lifecycle.log");
+ write_file(
+ root.join("lifecycle").join("init.sh").as_path(),
+ "#!/bin/sh\nprintf 'init\\n' >> lifecycle.log\n",
+ );
+ write_file(
+ root.join("lifecycle").join("shutdown.sh").as_path(),
+ "#!/bin/sh\nprintf 'shutdown\\n' >> lifecycle.log\n",
+ );
+ write_file(
+ root.join(MANIFEST_RELATIVE_PATH).as_path(),
+ format!(
+ "{{\n \"name\": \"{name}\",\n \"version\": \"{version}\",\n \"description\": \"lifecycle plugin\",\n \"lifecycle\": {{\n \"Init\": [\"./lifecycle/init.sh\"],\n \"Shutdown\": [\"./lifecycle/shutdown.sh\"]\n }}\n}}"
+ )
+ .as_str(),
+ );
+ log_path
+ }
+
+ fn write_tool_plugin(root: &Path, name: &str, version: &str) {
+ write_tool_plugin_with_name(root, name, version, "plugin_echo");
+ }
+
+ fn write_tool_plugin_with_name(root: &Path, name: &str, version: &str, tool_name: &str) {
+ let script_path = root.join("tools").join("echo-json.sh");
+ write_file(
+ &script_path,
+ "#!/bin/sh\nINPUT=$(cat)\nprintf '{\"plugin\":\"%s\",\"tool\":\"%s\",\"input\":%s}\\n' \"$CLAWD_PLUGIN_ID\" \"$CLAWD_TOOL_NAME\" \"$INPUT\"\n",
+ );
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+
+ let mut permissions = fs::metadata(&script_path).expect("metadata").permissions();
+ permissions.set_mode(0o755);
+ fs::set_permissions(&script_path, permissions).expect("chmod");
+ }
+ write_file(
+ root.join(MANIFEST_RELATIVE_PATH).as_path(),
+ format!(
+ "{{\n \"name\": \"{name}\",\n \"version\": \"{version}\",\n \"description\": \"tool plugin\",\n \"tools\": [\n {{\n \"name\": \"{tool_name}\",\n \"description\": \"Echo JSON input\",\n \"inputSchema\": {{\"type\": \"object\", \"properties\": {{\"message\": {{\"type\": \"string\"}}}}, \"required\": [\"message\"], \"additionalProperties\": false}},\n \"command\": \"./tools/echo-json.sh\",\n \"requiredPermission\": \"workspace-write\"\n }}\n ]\n}}"
+ )
+ .as_str(),
+ );
+ }
+
+ fn write_bundled_plugin(root: &Path, name: &str, version: &str, default_enabled: bool) {
+ write_file(
+ root.join(MANIFEST_RELATIVE_PATH).as_path(),
+ format!(
+ "{{\n \"name\": \"{name}\",\n \"version\": \"{version}\",\n \"description\": \"bundled plugin\",\n \"defaultEnabled\": {}\n}}",
+ if default_enabled { "true" } else { "false" }
+ )
+ .as_str(),
+ );
+ }
+
+ fn load_enabled_plugins(path: &Path) -> BTreeMap {
+ let contents = fs::read_to_string(path).expect("settings should exist");
+ let root: Value = serde_json::from_str(&contents).expect("settings json");
+ root.get("enabledPlugins")
+ .and_then(Value::as_object)
+ .map(|enabled_plugins| {
+ enabled_plugins
+ .iter()
+ .map(|(plugin_id, value)| {
+ (
+ plugin_id.clone(),
+ value.as_bool().expect("plugin state should be a bool"),
+ )
+ })
+ .collect()
+ })
+ .unwrap_or_default()
+ }
+
+ #[test]
+ fn load_plugin_from_directory_validates_required_fields() {
+ let root = temp_dir("manifest-required");
+ write_file(
+ root.join(MANIFEST_FILE_NAME).as_path(),
+ r#"{"name":"","version":"1.0.0","description":"desc"}"#,
+ );
+
+ let error = load_plugin_from_directory(&root).expect_err("empty name should fail");
+ assert!(error.to_string().contains("name cannot be empty"));
+
+ let _ = fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn load_plugin_from_directory_reads_root_manifest_and_validates_entries() {
+ let root = temp_dir("manifest-root");
+ write_loader_plugin(&root);
+
+ let manifest = load_plugin_from_directory(&root).expect("manifest should load");
+ assert_eq!(manifest.name, "loader-demo");
+ assert_eq!(manifest.version, "1.2.3");
+ assert_eq!(
+ manifest
+ .permissions
+ .iter()
+ .map(|permission| permission.as_str())
+ .collect::>(),
+ vec!["read", "write"]
+ );
+ assert_eq!(manifest.hooks.pre_tool_use, vec!["./hooks/pre.sh"]);
+ assert_eq!(manifest.tools.len(), 1);
+ assert_eq!(manifest.tools[0].name, "echo_tool");
+ assert_eq!(
+ manifest.tools[0].required_permission,
+ PluginToolPermission::WorkspaceWrite
+ );
+ assert_eq!(manifest.commands.len(), 1);
+ assert_eq!(manifest.commands[0].name, "sync");
+
+ let _ = fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn load_plugin_from_directory_supports_packaged_manifest_path() {
+ let root = temp_dir("manifest-packaged");
+ write_external_plugin(&root, "packaged-demo", "1.0.0");
+
+ let manifest = load_plugin_from_directory(&root).expect("packaged manifest should load");
+ assert_eq!(manifest.name, "packaged-demo");
+ assert!(manifest.tools.is_empty());
+ assert!(manifest.commands.is_empty());
+
+ let _ = fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn load_plugin_from_directory_defaults_optional_fields() {
+ let root = temp_dir("manifest-defaults");
+ write_file(
+ root.join(MANIFEST_FILE_NAME).as_path(),
+ r#"{
+ "name": "minimal",
+ "version": "0.1.0",
+ "description": "Minimal manifest"
+}"#,
+ );
+
+ let manifest = load_plugin_from_directory(&root).expect("minimal manifest should load");
+ assert!(manifest.permissions.is_empty());
+ assert!(manifest.hooks.is_empty());
+ assert!(manifest.tools.is_empty());
+ assert!(manifest.commands.is_empty());
+
+ let _ = fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn load_plugin_from_directory_rejects_duplicate_permissions_and_commands() {
+ let root = temp_dir("manifest-duplicates");
+ write_file(
+ root.join("commands").join("sync.sh").as_path(),
+ "#!/bin/sh\nprintf 'sync'\n",
+ );
+ write_file(
+ root.join(MANIFEST_FILE_NAME).as_path(),
+ r#"{
+ "name": "duplicate-manifest",
+ "version": "1.0.0",
+ "description": "Duplicate validation",
+ "permissions": ["read", "read"],
+ "commands": [
+ {"name": "sync", "description": "Sync one", "command": "./commands/sync.sh"},
+ {"name": "sync", "description": "Sync two", "command": "./commands/sync.sh"}
+ ]
+}"#,
+ );
+
+ let error = load_plugin_from_directory(&root).expect_err("duplicates should fail");
+ match error {
+ PluginError::ManifestValidation(errors) => {
+ assert!(errors.iter().any(|error| matches!(
+ error,
+ PluginManifestValidationError::DuplicatePermission { permission }
+ if permission == "read"
+ )));
+ assert!(errors.iter().any(|error| matches!(
+ error,
+ PluginManifestValidationError::DuplicateEntry { kind, name }
+ if *kind == "command" && name == "sync"
+ )));
+ }
+ other => panic!("expected manifest validation errors, got {other}"),
+ }
+
+ let _ = fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn load_plugin_from_directory_rejects_missing_tool_or_command_paths() {
+ let root = temp_dir("manifest-paths");
+ write_file(
+ root.join(MANIFEST_FILE_NAME).as_path(),
+ r#"{
+ "name": "missing-paths",
+ "version": "1.0.0",
+ "description": "Missing path validation",
+ "tools": [
+ {
+ "name": "tool_one",
+ "description": "Missing tool script",
+ "inputSchema": {"type": "object"},
+ "command": "./tools/missing.sh"
+ }
+ ]
+}"#,
+ );
+
+ let error = load_plugin_from_directory(&root).expect_err("missing paths should fail");
+ assert!(error.to_string().contains("does not exist"));
+
+ let _ = fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn load_plugin_from_directory_rejects_invalid_permissions() {
+ let root = temp_dir("manifest-invalid-permissions");
+ write_file(
+ root.join(MANIFEST_FILE_NAME).as_path(),
+ r#"{
+ "name": "invalid-permissions",
+ "version": "1.0.0",
+ "description": "Invalid permission validation",
+ "permissions": ["admin"]
+}"#,
+ );
+
+ let error = load_plugin_from_directory(&root).expect_err("invalid permissions should fail");
+ match error {
+ PluginError::ManifestValidation(errors) => {
+ assert!(errors.iter().any(|error| matches!(
+ error,
+ PluginManifestValidationError::InvalidPermission { permission }
+ if permission == "admin"
+ )));
+ }
+ other => panic!("expected manifest validation errors, got {other}"),
+ }
+
+ let _ = fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn load_plugin_from_directory_rejects_invalid_tool_required_permission() {
+ let root = temp_dir("manifest-invalid-tool-permission");
+ write_file(
+ root.join("tools").join("echo.sh").as_path(),
+ "#!/bin/sh\ncat\n",
+ );
+ write_file(
+ root.join(MANIFEST_FILE_NAME).as_path(),
+ r#"{
+ "name": "invalid-tool-permission",
+ "version": "1.0.0",
+ "description": "Invalid tool permission validation",
+ "tools": [
+ {
+ "name": "echo_tool",
+ "description": "Echo tool",
+ "inputSchema": {"type": "object"},
+ "command": "./tools/echo.sh",
+ "requiredPermission": "admin"
+ }
+ ]
+}"#,
+ );
+
+ let error =
+ load_plugin_from_directory(&root).expect_err("invalid tool permission should fail");
+ match error {
+ PluginError::ManifestValidation(errors) => {
+ assert!(errors.iter().any(|error| matches!(
+ error,
+ PluginManifestValidationError::InvalidToolRequiredPermission {
+ tool_name,
+ permission
+ } if tool_name == "echo_tool" && permission == "admin"
+ )));
+ }
+ other => panic!("expected manifest validation errors, got {other}"),
+ }
+
+ let _ = fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn load_plugin_from_directory_accumulates_multiple_validation_errors() {
+ let root = temp_dir("manifest-multi-error");
+ write_file(
+ root.join(MANIFEST_FILE_NAME).as_path(),
+ r#"{
+ "name": "",
+ "version": "1.0.0",
+ "description": "",
+ "permissions": ["admin"],
+ "commands": [
+ {"name": "", "description": "", "command": "./commands/missing.sh"}
+ ]
+}"#,
+ );
+
+ let error =
+ load_plugin_from_directory(&root).expect_err("multiple manifest errors should fail");
+ match error {
+ PluginError::ManifestValidation(errors) => {
+ assert!(errors.len() >= 4);
+ assert!(errors.iter().any(|error| matches!(
+ error,
+ PluginManifestValidationError::EmptyField { field } if *field == "name"
+ )));
+ assert!(errors.iter().any(|error| matches!(
+ error,
+ PluginManifestValidationError::EmptyField { field }
+ if *field == "description"
+ )));
+ assert!(errors.iter().any(|error| matches!(
+ error,
+ PluginManifestValidationError::InvalidPermission { permission }
+ if permission == "admin"
+ )));
+ }
+ other => panic!("expected manifest validation errors, got {other}"),
+ }
+
+ let _ = fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn discovers_builtin_and_bundled_plugins() {
+ let manager = PluginManager::new(PluginManagerConfig::new(temp_dir("discover")));
+ let plugins = manager.list_plugins().expect("plugins should list");
+ assert!(plugins
+ .iter()
+ .any(|plugin| plugin.metadata.kind == PluginKind::Builtin));
+ assert!(plugins
+ .iter()
+ .any(|plugin| plugin.metadata.kind == PluginKind::Bundled));
+ }
+
+ #[test]
+ fn installs_enables_updates_and_uninstalls_external_plugins() {
+ 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 = manager
+ .install(source_root.to_str().expect("utf8 path"))
+ .expect("install should succeed");
+ assert_eq!(install.plugin_id, "demo@external");
+ assert!(manager
+ .list_plugins()
+ .expect("list plugins")
+ .iter()
+ .any(|plugin| plugin.metadata.id == "demo@external" && plugin.enabled));
+
+ let hooks = manager.aggregated_hooks().expect("hooks should aggregate");
+ assert_eq!(hooks.pre_tool_use.len(), 1);
+ assert!(hooks.pre_tool_use[0].contains("pre.sh"));
+
+ manager
+ .disable("demo@external")
+ .expect("disable should work");
+ assert!(manager
+ .aggregated_hooks()
+ .expect("hooks after disable")
+ .is_empty());
+ manager.enable("demo@external").expect("enable should work");
+
+ write_external_plugin(&source_root, "demo", "2.0.0");
+ let update = manager.update("demo@external").expect("update should work");
+ assert_eq!(update.old_version, "1.0.0");
+ assert_eq!(update.new_version, "2.0.0");
+
+ manager
+ .uninstall("demo@external")
+ .expect("uninstall should work");
+ assert!(!manager
+ .list_plugins()
+ .expect("list plugins")
+ .iter()
+ .any(|plugin| plugin.metadata.id == "demo@external"));
+
+ let _ = fs::remove_dir_all(config_home);
+ let _ = fs::remove_dir_all(source_root);
+ }
+
+ #[test]
+ fn auto_installs_bundled_plugins_into_the_registry() {
+ let config_home = temp_dir("bundled-home");
+ let bundled_root = temp_dir("bundled-root");
+ write_bundled_plugin(&bundled_root.join("starter"), "starter", "0.1.0", false);
+
+ let mut config = PluginManagerConfig::new(&config_home);
+ config.bundled_root = Some(bundled_root.clone());
+ let manager = PluginManager::new(config);
+
+ let installed = manager
+ .list_installed_plugins()
+ .expect("bundled plugins should auto-install");
+ assert!(installed.iter().any(|plugin| {
+ plugin.metadata.id == "starter@bundled"
+ && plugin.metadata.kind == PluginKind::Bundled
+ && !plugin.enabled
+ }));
+
+ let registry = manager.load_registry().expect("registry should exist");
+ let record = registry
+ .plugins
+ .get("starter@bundled")
+ .expect("bundled plugin should be recorded");
+ assert_eq!(record.kind, PluginKind::Bundled);
+ assert!(record.install_path.exists());
+
+ let _ = fs::remove_dir_all(config_home);
+ let _ = fs::remove_dir_all(bundled_root);
+ }
+
+ #[test]
+ fn default_bundled_root_loads_repo_bundles_as_installed_plugins() {
+ let config_home = temp_dir("default-bundled-home");
+ let manager = PluginManager::new(PluginManagerConfig::new(&config_home));
+
+ let installed = manager
+ .list_installed_plugins()
+ .expect("default bundled plugins should auto-install");
+ assert!(installed
+ .iter()
+ .any(|plugin| plugin.metadata.id == "example-bundled@bundled"));
+ assert!(installed
+ .iter()
+ .any(|plugin| plugin.metadata.id == "sample-hooks@bundled"));
+
+ let _ = fs::remove_dir_all(config_home);
+ }
+
+ #[test]
+ fn bundled_sync_prunes_removed_bundled_registry_entries() {
+ let config_home = temp_dir("bundled-prune-home");
+ let bundled_root = temp_dir("bundled-prune-root");
+ let stale_install_path = config_home
+ .join("plugins")
+ .join("installed")
+ .join("stale-bundled-external");
+ write_bundled_plugin(&bundled_root.join("active"), "active", "0.1.0", false);
+ write_file(
+ stale_install_path.join(MANIFEST_RELATIVE_PATH).as_path(),
+ r#"{
+ "name": "stale",
+ "version": "0.1.0",
+ "description": "stale bundled plugin"
+}"#,
+ );
+
+ let mut config = PluginManagerConfig::new(&config_home);
+ config.bundled_root = Some(bundled_root.clone());
+ config.install_root = Some(config_home.join("plugins").join("installed"));
+ let manager = PluginManager::new(config);
+
+ let mut registry = InstalledPluginRegistry::default();
+ registry.plugins.insert(
+ "stale@bundled".to_string(),
+ InstalledPluginRecord {
+ kind: PluginKind::Bundled,
+ id: "stale@bundled".to_string(),
+ name: "stale".to_string(),
+ version: "0.1.0".to_string(),
+ description: "stale bundled plugin".to_string(),
+ install_path: stale_install_path.clone(),
+ source: PluginInstallSource::LocalPath {
+ path: bundled_root.join("stale"),
+ },
+ installed_at_unix_ms: 1,
+ updated_at_unix_ms: 1,
+ },
+ );
+ manager.store_registry(®istry).expect("store registry");
+
+ let installed = manager
+ .list_installed_plugins()
+ .expect("bundled sync should succeed");
+ assert!(installed
+ .iter()
+ .any(|plugin| plugin.metadata.id == "active@bundled"));
+ assert!(!installed
+ .iter()
+ .any(|plugin| plugin.metadata.id == "stale@bundled"));
+
+ let registry = manager.load_registry().expect("load registry");
+ assert!(!registry.plugins.contains_key("stale@bundled"));
+ assert!(!stale_install_path.exists());
+
+ let _ = fs::remove_dir_all(config_home);
+ let _ = fs::remove_dir_all(bundled_root);
+ }
+
+ #[test]
+ fn installed_plugin_discovery_keeps_registry_entries_outside_install_root() {
+ let config_home = temp_dir("registry-fallback-home");
+ let bundled_root = temp_dir("registry-fallback-bundled");
+ let install_root = config_home.join("plugins").join("installed");
+ let external_install_path = temp_dir("registry-fallback-external");
+ write_file(
+ external_install_path.join(MANIFEST_FILE_NAME).as_path(),
+ r#"{
+ "name": "registry-fallback",
+ "version": "1.0.0",
+ "description": "Registry fallback plugin"
+}"#,
+ );
+
+ let mut config = PluginManagerConfig::new(&config_home);
+ config.bundled_root = Some(bundled_root.clone());
+ config.install_root = Some(install_root.clone());
+ let manager = PluginManager::new(config);
+
+ let mut registry = InstalledPluginRegistry::default();
+ registry.plugins.insert(
+ "registry-fallback@external".to_string(),
+ InstalledPluginRecord {
+ kind: PluginKind::External,
+ id: "registry-fallback@external".to_string(),
+ name: "registry-fallback".to_string(),
+ version: "1.0.0".to_string(),
+ description: "Registry fallback plugin".to_string(),
+ install_path: external_install_path.clone(),
+ source: PluginInstallSource::LocalPath {
+ path: external_install_path.clone(),
+ },
+ installed_at_unix_ms: 1,
+ updated_at_unix_ms: 1,
+ },
+ );
+ manager.store_registry(®istry).expect("store registry");
+
+ let installed = manager
+ .list_installed_plugins()
+ .expect("registry fallback plugin should load");
+ assert!(installed
+ .iter()
+ .any(|plugin| plugin.metadata.id == "registry-fallback@external"));
+
+ let _ = fs::remove_dir_all(config_home);
+ let _ = fs::remove_dir_all(bundled_root);
+ let _ = fs::remove_dir_all(external_install_path);
+ }
+
+ #[test]
+ fn installed_plugin_discovery_prunes_stale_registry_entries() {
+ let config_home = temp_dir("registry-prune-home");
+ let bundled_root = temp_dir("registry-prune-bundled");
+ let install_root = config_home.join("plugins").join("installed");
+ let missing_install_path = temp_dir("registry-prune-missing");
+
+ let mut config = PluginManagerConfig::new(&config_home);
+ config.bundled_root = Some(bundled_root.clone());
+ config.install_root = Some(install_root);
+ let manager = PluginManager::new(config);
+
+ let mut registry = InstalledPluginRegistry::default();
+ registry.plugins.insert(
+ "stale-external@external".to_string(),
+ InstalledPluginRecord {
+ kind: PluginKind::External,
+ id: "stale-external@external".to_string(),
+ name: "stale-external".to_string(),
+ version: "1.0.0".to_string(),
+ description: "stale external plugin".to_string(),
+ install_path: missing_install_path.clone(),
+ source: PluginInstallSource::LocalPath {
+ path: missing_install_path.clone(),
+ },
+ installed_at_unix_ms: 1,
+ updated_at_unix_ms: 1,
+ },
+ );
+ manager.store_registry(®istry).expect("store registry");
+
+ let installed = manager
+ .list_installed_plugins()
+ .expect("stale registry entries should be pruned");
+ assert!(!installed
+ .iter()
+ .any(|plugin| plugin.metadata.id == "stale-external@external"));
+
+ let registry = manager.load_registry().expect("load registry");
+ assert!(!registry.plugins.contains_key("stale-external@external"));
+
+ let _ = fs::remove_dir_all(config_home);
+ let _ = fs::remove_dir_all(bundled_root);
+ }
+
+ #[test]
+ fn persists_bundled_plugin_enable_state_across_reloads() {
+ let config_home = temp_dir("bundled-state-home");
+ let bundled_root = temp_dir("bundled-state-root");
+ write_bundled_plugin(&bundled_root.join("starter"), "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.clone());
+
+ manager
+ .enable("starter@bundled")
+ .expect("enable bundled plugin should succeed");
+ assert_eq!(
+ load_enabled_plugins(&manager.settings_path()).get("starter@bundled"),
+ Some(&true)
+ );
+
+ let mut reloaded_config = PluginManagerConfig::new(&config_home);
+ reloaded_config.bundled_root = Some(bundled_root.clone());
+ reloaded_config.enabled_plugins = load_enabled_plugins(&manager.settings_path());
+ let reloaded_manager = PluginManager::new(reloaded_config);
+ let reloaded = reloaded_manager
+ .list_installed_plugins()
+ .expect("bundled plugins should still be listed");
+ assert!(reloaded
+ .iter()
+ .any(|plugin| { plugin.metadata.id == "starter@bundled" && plugin.enabled }));
+
+ let _ = fs::remove_dir_all(config_home);
+ let _ = fs::remove_dir_all(bundled_root);
+ }
+
+ #[test]
+ fn persists_bundled_plugin_disable_state_across_reloads() {
+ let config_home = temp_dir("bundled-disabled-home");
+ let bundled_root = temp_dir("bundled-disabled-root");
+ write_bundled_plugin(&bundled_root.join("starter"), "starter", "0.1.0", true);
+
+ let mut config = PluginManagerConfig::new(&config_home);
+ config.bundled_root = Some(bundled_root.clone());
+ let mut manager = PluginManager::new(config);
+
+ manager
+ .disable("starter@bundled")
+ .expect("disable bundled plugin should succeed");
+ assert_eq!(
+ load_enabled_plugins(&manager.settings_path()).get("starter@bundled"),
+ Some(&false)
+ );
+
+ let mut reloaded_config = PluginManagerConfig::new(&config_home);
+ reloaded_config.bundled_root = Some(bundled_root.clone());
+ reloaded_config.enabled_plugins = load_enabled_plugins(&manager.settings_path());
+ let reloaded_manager = PluginManager::new(reloaded_config);
+ let reloaded = reloaded_manager
+ .list_installed_plugins()
+ .expect("bundled plugins should still be listed");
+ assert!(reloaded
+ .iter()
+ .any(|plugin| { plugin.metadata.id == "starter@bundled" && !plugin.enabled }));
+
+ let _ = fs::remove_dir_all(config_home);
+ let _ = fs::remove_dir_all(bundled_root);
+ }
+
+ #[test]
+ fn validates_plugin_source_before_install() {
+ let config_home = temp_dir("validate-home");
+ let source_root = temp_dir("validate-source");
+ write_external_plugin(&source_root, "validator", "1.0.0");
+ let manager = PluginManager::new(PluginManagerConfig::new(&config_home));
+ let manifest = manager
+ .validate_plugin_source(source_root.to_str().expect("utf8 path"))
+ .expect("manifest should validate");
+ assert_eq!(manifest.name, "validator");
+ let _ = fs::remove_dir_all(config_home);
+ let _ = fs::remove_dir_all(source_root);
+ }
+
+ #[test]
+ fn plugin_registry_tracks_enabled_state_and_lookup() {
+ let config_home = temp_dir("registry-home");
+ let source_root = temp_dir("registry-source");
+ write_external_plugin(&source_root, "registry-demo", "1.0.0");
+
+ let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
+ manager
+ .install(source_root.to_str().expect("utf8 path"))
+ .expect("install should succeed");
+ manager
+ .disable("registry-demo@external")
+ .expect("disable should succeed");
+
+ let registry = manager.plugin_registry().expect("registry should build");
+ let plugin = registry
+ .get("registry-demo@external")
+ .expect("installed plugin should be discoverable");
+ assert_eq!(plugin.metadata().name, "registry-demo");
+ assert!(!plugin.is_enabled());
+ assert!(registry.contains("registry-demo@external"));
+ assert!(!registry.contains("missing@external"));
+
+ let _ = fs::remove_dir_all(config_home);
+ let _ = fs::remove_dir_all(source_root);
+ }
+
+ #[test]
+ fn rejects_plugin_sources_with_missing_hook_paths() {
+ let config_home = temp_dir("broken-home");
+ let source_root = temp_dir("broken-source");
+ write_broken_plugin(&source_root, "broken");
+
+ let manager = PluginManager::new(PluginManagerConfig::new(&config_home));
+ let error = manager
+ .validate_plugin_source(source_root.to_str().expect("utf8 path"))
+ .expect_err("missing hook file should fail validation");
+ assert!(error.to_string().contains("does not exist"));
+
+ let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
+ let install_error = manager
+ .install(source_root.to_str().expect("utf8 path"))
+ .expect_err("install should reject invalid hook paths");
+ assert!(install_error.to_string().contains("does not exist"));
+
+ let _ = fs::remove_dir_all(config_home);
+ let _ = fs::remove_dir_all(source_root);
+ }
+
+ #[test]
+ fn plugin_registry_runs_initialize_and_shutdown_for_enabled_plugins() {
+ let config_home = temp_dir("lifecycle-home");
+ let source_root = temp_dir("lifecycle-source");
+ let _ = write_lifecycle_plugin(&source_root, "lifecycle-demo", "1.0.0");
+
+ 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 build");
+ registry.initialize().expect("init should succeed");
+ registry.shutdown().expect("shutdown should succeed");
+
+ let log = fs::read_to_string(&log_path).expect("lifecycle 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 aggregates_and_executes_plugin_tools() {
+ let config_home = temp_dir("tool-home");
+ let source_root = temp_dir("tool-source");
+ write_tool_plugin(&source_root, "tool-demo", "1.0.0");
+
+ let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
+ manager
+ .install(source_root.to_str().expect("utf8 path"))
+ .expect("install should succeed");
+
+ let tools = manager.aggregated_tools().expect("tools should aggregate");
+ assert_eq!(tools.len(), 1);
+ assert_eq!(tools[0].definition().name, "plugin_echo");
+ assert_eq!(tools[0].required_permission(), "workspace-write");
+
+ let output = tools[0]
+ .execute(&serde_json::json!({ "message": "hello" }))
+ .expect("plugin tool should execute");
+ let payload: Value = serde_json::from_str(&output).expect("valid json");
+ assert_eq!(payload["plugin"], "tool-demo@external");
+ assert_eq!(payload["tool"], "plugin_echo");
+ assert_eq!(payload["input"]["message"], "hello");
+
+ let _ = fs::remove_dir_all(config_home);
+ let _ = fs::remove_dir_all(source_root);
+ }
+
+ #[test]
+ fn list_installed_plugins_scans_install_root_without_registry_entries() {
+ let config_home = temp_dir("installed-scan-home");
+ let bundled_root = temp_dir("installed-scan-bundled");
+ let install_root = config_home.join("plugins").join("installed");
+ let installed_plugin_root = install_root.join("scan-demo");
+ write_file(
+ installed_plugin_root.join(MANIFEST_FILE_NAME).as_path(),
+ r#"{
+ "name": "scan-demo",
+ "version": "1.0.0",
+ "description": "Scanned from install root"
+}"#,
+ );
+
+ let mut config = PluginManagerConfig::new(&config_home);
+ config.bundled_root = Some(bundled_root.clone());
+ config.install_root = Some(install_root);
+ let manager = PluginManager::new(config);
+
+ let installed = manager
+ .list_installed_plugins()
+ .expect("installed plugins should scan directories");
+ assert!(installed
+ .iter()
+ .any(|plugin| plugin.metadata.id == "scan-demo@external"));
+
+ let _ = fs::remove_dir_all(config_home);
+ let _ = fs::remove_dir_all(bundled_root);
+ }
+
+ #[test]
+ fn list_installed_plugins_scans_packaged_manifests_in_install_root() {
+ let config_home = temp_dir("installed-packaged-scan-home");
+ let bundled_root = temp_dir("installed-packaged-scan-bundled");
+ let install_root = config_home.join("plugins").join("installed");
+ let installed_plugin_root = install_root.join("scan-packaged");
+ write_file(
+ installed_plugin_root.join(MANIFEST_RELATIVE_PATH).as_path(),
+ r#"{
+ "name": "scan-packaged",
+ "version": "1.0.0",
+ "description": "Packaged manifest in install root"
+}"#,
+ );
+
+ let mut config = PluginManagerConfig::new(&config_home);
+ config.bundled_root = Some(bundled_root.clone());
+ config.install_root = Some(install_root);
+ let manager = PluginManager::new(config);
+
+ let installed = manager
+ .list_installed_plugins()
+ .expect("installed plugins should scan packaged manifests");
+ assert!(installed
+ .iter()
+ .any(|plugin| plugin.metadata.id == "scan-packaged@external"));
+
+ let _ = fs::remove_dir_all(config_home);
+ let _ = fs::remove_dir_all(bundled_root);
+ }
+}
diff --git a/rust/crates/runtime/Cargo.toml b/rust/crates/runtime/Cargo.toml
index 7ce7cd8..30c6d3c 100644
--- a/rust/crates/runtime/Cargo.toml
+++ b/rust/crates/runtime/Cargo.toml
@@ -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"
diff --git a/rust/crates/runtime/src/bootstrap.rs b/rust/crates/runtime/src/bootstrap.rs
index 238fa5a..dfc99ab 100644
--- a/rust/crates/runtime/src/bootstrap.rs
+++ b/rust/crates/runtime/src/bootstrap.rs
@@ -21,7 +21,7 @@ pub struct BootstrapPlan {
impl BootstrapPlan {
#[must_use]
- pub fn default_bootstrap() -> Self {
+ pub fn claude_code_default() -> Self {
Self::from_phases(vec![
BootstrapPhase::CliEntry,
BootstrapPhase::FastPathVersion,
diff --git a/rust/crates/runtime/src/compact.rs b/rust/crates/runtime/src/compact.rs
index 4b664a5..ad6b3f3 100644
--- a/rust/crates/runtime/src/compact.rs
+++ b/rust/crates/runtime/src/compact.rs
@@ -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::()
+ >= 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!["".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("".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 {
+ 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 {
+ 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 {
+ 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 = "Conversation summary:\n- Scope: earlier work preserved.\n- Key timeline:\n - user: large preserved context\n";
+ 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 {
@@ -465,10 +682,10 @@ mod tests {
#[test]
fn extracts_key_files_from_message_content() {
let files = collect_key_files(&[ConversationMessage::user_text(
- "Update rust/crates/runtime/src/compact.rs and rust/crates/claw-cli/src/main.rs next.",
+ "Update rust/crates/runtime/src/compact.rs and rust/crates/rusty-claude-cli/src/main.rs next.",
)]);
assert!(files.contains(&"rust/crates/runtime/src/compact.rs".to_string()));
- assert!(files.contains(&"rust/crates/claw-cli/src/main.rs".to_string()));
+ assert!(files.contains(&"rust/crates/rusty-claude-cli/src/main.rs".to_string()));
}
#[test]
diff --git a/rust/crates/runtime/src/config.rs b/rust/crates/runtime/src/config.rs
index a23928d..dfc4d1a 100644
--- a/rust/crates/runtime/src/config.rs
+++ b/rust/crates/runtime/src/config.rs
@@ -6,7 +6,7 @@ use std::path::{Path, PathBuf};
use crate::json::JsonValue;
use crate::sandbox::{FilesystemIsolationMode, SandboxConfig};
-pub const CLAW_SETTINGS_SCHEMA_NAME: &str = "SettingsSchema";
+pub const CLAUDE_CODE_SETTINGS_SCHEMA_NAME: &str = "SettingsSchema";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum ConfigSource {
@@ -35,14 +35,23 @@ pub struct RuntimeConfig {
feature_config: RuntimeFeatureConfig,
}
+#[derive(Debug, Clone, PartialEq, Eq, Default)]
+pub struct RuntimePluginConfig {
+ enabled_plugins: BTreeMap,
+ external_directories: Vec,
+ install_root: Option,
+ registry_path: Option,
+ bundled_root: Option,
+}
+
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct RuntimeFeatureConfig {
hooks: RuntimeHookConfig,
+ plugins: RuntimePluginConfig,
mcp: McpConfigCollection,
oauth: Option,
model: Option,
permission_mode: Option,
- permission_rules: RuntimePermissionRuleConfig,
sandbox: SandboxConfig,
}
@@ -50,14 +59,6 @@ pub struct RuntimeFeatureConfig {
pub struct RuntimeHookConfig {
pre_tool_use: Vec,
post_tool_use: Vec,
- post_tool_use_failure: Vec,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Default)]
-pub struct RuntimePermissionRuleConfig {
- allow: Vec,
- deny: Vec,
- ask: Vec,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
@@ -78,7 +79,7 @@ pub enum McpTransport {
Http,
Ws,
Sdk,
- ManagedProxy,
+ ClaudeAiProxy,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -88,7 +89,7 @@ pub enum McpServerConfig {
Http(McpRemoteServerConfig),
Ws(McpWebSocketServerConfig),
Sdk(McpSdkServerConfig),
- ManagedProxy(McpManagedProxyServerConfig),
+ ClaudeAiProxy(McpClaudeAiProxyServerConfig),
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -119,7 +120,7 @@ pub struct McpSdkServerConfig {
}
#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct McpManagedProxyServerConfig {
+pub struct McpClaudeAiProxyServerConfig {
pub url: String,
pub id: String,
}
@@ -183,18 +184,20 @@ impl ConfigLoader {
#[must_use]
pub fn default_for(cwd: impl Into) -> Self {
let cwd = cwd.into();
- let config_home = std::env::var_os("CLAW_CONFIG_HOME")
- .map(PathBuf::from)
- .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".claw")))
- .unwrap_or_else(|| PathBuf::from(".claw"));
+ 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 {
let user_legacy_path = self.config_home.parent().map_or_else(
- || PathBuf::from(".claw.json"),
- |parent| parent.join(".claw.json"),
+ || PathBuf::from(".claude.json"),
+ |parent| parent.join(".claude.json"),
);
vec![
ConfigEntry {
@@ -207,15 +210,15 @@ impl ConfigLoader {
},
ConfigEntry {
source: ConfigSource::Project,
- path: self.cwd.join(".claw.json"),
+ path: self.cwd.join(".claude.json"),
},
ConfigEntry {
source: ConfigSource::Project,
- path: self.cwd.join(".claw").join("settings.json"),
+ path: self.cwd.join(".claude").join("settings.json"),
},
ConfigEntry {
source: ConfigSource::Local,
- path: self.cwd.join(".claw").join("settings.local.json"),
+ path: self.cwd.join(".claude").join("settings.local.json"),
},
]
}
@@ -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 {
+ &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,
- post_tool_use: Vec,
- post_tool_use_failure: Vec,
- ) -> Self {
+ pub fn new(pre_tool_use: Vec, post_tool_use: Vec) -> 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, deny: Vec, ask: Vec) -> 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());
}
}
@@ -450,7 +486,7 @@ impl McpServerConfig {
Self::Http(_) => McpTransport::Http,
Self::Ws(_) => McpTransport::Ws,
Self::Sdk(_) => McpTransport::Sdk,
- Self::ManagedProxy(_) => McpTransport::ManagedProxy,
+ Self::ClaudeAiProxy(_) => McpTransport::ClaudeAiProxy,
}
}
}
@@ -458,7 +494,7 @@ impl McpServerConfig {
fn read_optional_json_object(
path: &Path,
) -> Result