From 77427245c1f3dfab64d5b3b97f841a312ef12ab3 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Wed, 1 Apr 2026 03:45:42 +0000 Subject: [PATCH 01/25] rebrand: Claude Code -> Claw Code in all prompts and source text --- rust/README.md | 2 +- rust/crates/compat-harness/src/lib.rs | 6 +++--- rust/crates/runtime/src/prompt.rs | 2 +- rust/crates/rusty-claude-cli/src/init.rs | 4 ++-- rust/crates/rusty-claude-cli/src/main.rs | 4 ++-- rust/crates/tools/src/lib.rs | 6 +++--- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/rust/README.md b/rust/README.md index e3b5924..eff924b 100644 --- a/rust/README.md +++ b/rust/README.md @@ -1,6 +1,6 @@ # 🦞 Claw Code — Rust Implementation -A high-performance Rust rewrite of the Claude Code CLI agent harness. Built for speed, safety, and native tool execution. +A high-performance Rust rewrite of the Claw Code CLI agent harness. Built for speed, safety, and native tool execution. ## Quick Start diff --git a/rust/crates/compat-harness/src/lib.rs b/rust/crates/compat-harness/src/lib.rs index e4e5a82..7176c27 100644 --- a/rust/crates/compat-harness/src/lib.rs +++ b/rust/crates/compat-harness/src/lib.rs @@ -70,16 +70,16 @@ fn upstream_repo_candidates(primary_repo_root: &Path) -> Vec { } for ancestor in primary_repo_root.ancestors().take(4) { - candidates.push(ancestor.join("claude-code")); + candidates.push(ancestor.join("claw-code")); candidates.push(ancestor.join("clawd-code")); } candidates.push( primary_repo_root .join("reference-source") - .join("claude-code"), + .join("claw-code"), ); - candidates.push(primary_repo_root.join("vendor").join("claude-code")); + candidates.push(primary_repo_root.join("vendor").join("claw-code")); let mut deduped = Vec::new(); for candidate in candidates { diff --git a/rust/crates/runtime/src/prompt.rs b/rust/crates/runtime/src/prompt.rs index 7192412..6cfda44 100644 --- a/rust/crates/runtime/src/prompt.rs +++ b/rust/crates/runtime/src/prompt.rs @@ -421,7 +421,7 @@ fn render_config_section(config: &RuntimeConfig) -> String { let mut lines = vec!["# Runtime config".to_string()]; if config.loaded_entries().is_empty() { lines.extend(prepend_bullets(vec![ - "No Claude Code settings files loaded.".to_string(), + "No Claw Code settings files loaded.".to_string(), ])); return lines.join("\n"); } diff --git a/rust/crates/rusty-claude-cli/src/init.rs b/rust/crates/rusty-claude-cli/src/init.rs index 8b30d29..67d1187 100644 --- a/rust/crates/rusty-claude-cli/src/init.rs +++ b/rust/crates/rusty-claude-cli/src/init.rs @@ -8,7 +8,7 @@ const STARTER_CLAUDE_JSON: &str = concat!( " }\n", "}\n", ); -const GITIGNORE_COMMENT: &str = "# Claude Code local artifacts"; +const GITIGNORE_COMMENT: &str = "# Claw Code local artifacts"; const GITIGNORE_ENTRIES: [&str; 2] = [".claude/settings.local.json", ".claude/sessions/"]; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -164,7 +164,7 @@ pub(crate) fn render_init_claude_md(cwd: &Path) -> String { let mut lines = vec![ "# CLAUDE.md".to_string(), String::new(), - "This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.".to_string(), + "This file provides guidance to Claw Code (clawcode.dev) when working with code in this repository.".to_string(), String::new(), ]; diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 5f8a7a6..9450ca4 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -197,7 +197,7 @@ fn parse_args(args: &[String]) -> Result { index += 1; } "-p" => { - // Claude Code compat: -p "prompt" = one-shot prompt + // Claw Code compat: -p "prompt" = one-shot prompt let prompt = args[index + 1..].join(" "); if prompt.trim().is_empty() { return Err("-p requires a prompt string".to_string()); @@ -211,7 +211,7 @@ fn parse_args(args: &[String]) -> Result { }); } "--print" => { - // Claude Code compat: --print makes output non-interactive + // Claw Code compat: --print makes output non-interactive output_format = CliOutputFormat::Text; index += 1; } diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index 8dcd33d..4071c9b 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -323,7 +323,7 @@ pub fn mvp_tool_specs() -> Vec { }, ToolSpec { name: "Config", - description: "Get or set Claude Code settings.", + description: "Get or set Claw Code settings.", input_schema: json!({ "type": "object", "properties": { @@ -1537,7 +1537,7 @@ fn allowed_tools_for_subagent(subagent_type: &str) -> BTreeSet { "SendUserMessage", "PowerShell", ], - "claude-code-guide" => vec![ + "claw-code-guide" => vec![ "read_file", "glob_search", "grep_search", @@ -2087,7 +2087,7 @@ fn normalize_subagent_type(subagent_type: Option<&str>) -> String { "verification" | "verificationagent" | "verify" | "verifier" => { String::from("Verification") } - "claudecodeguide" | "claudecodeguideagent" | "guide" => String::from("claude-code-guide"), + "claudecodeguide" | "claudecodeguideagent" | "guide" => String::from("claw-code-guide"), "statusline" | "statuslinesetup" => String::from("statusline-setup"), _ => trimmed.to_string(), } From 992681c4fd291d8c77925132c4ba4f037550d995 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Wed, 1 Apr 2026 03:48:50 +0000 Subject: [PATCH 02/25] Prevent long sessions from stalling and expose the requested internal command surface The runtime now auto-compacts completed conversations once cumulative input usage crosses a configurable threshold, preserving recent context while surfacing an explicit user notice. The CLI also publishes the requested ant-only slash commands through the shared commands crate and main dispatch, using meaningful local implementations for commit/PR/issue/teleport/debug workflows. Constraint: Reuse the existing Rust compaction pipeline instead of introducing a new summarization stack Constraint: No new dependencies or broad command-framework rewrite Rejected: Implement API-driven compaction inside ConversationRuntime now | too much new plumbing for this delivery Rejected: Expose new commands as parse-only stubs | would not satisfy the requested command availability Confidence: medium Scope-risk: moderate Reversibility: clean Directive: If runtime later gains true API-backed compaction, preserve the TurnSummary auto-compaction metadata shape so CLI call sites stay stable Tested: cargo test; cargo build --release; cargo fmt --all; git diff --check; LSP diagnostics directory check Not-tested: Live Anthropic-backed specialist command flows; gh-authenticated PR/issue creation in a real repo --- rust/crates/commands/src/lib.rs | 152 ++++++++- rust/crates/runtime/src/conversation.rs | 175 +++++++++- rust/crates/runtime/src/lib.rs | 4 +- rust/crates/rusty-claude-cli/src/main.rs | 411 ++++++++++++++++++++++- 4 files changed, 733 insertions(+), 9 deletions(-) diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index b396bb0..e7f8d13 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -117,6 +117,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", @@ -136,6 +178,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, }, @@ -180,6 +239,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), }, @@ -212,6 +288,15 @@ impl SlashCommand { } } +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 @@ -279,6 +364,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 { .. } @@ -307,6 +399,41 @@ mod tests { 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 { @@ -374,6 +501,13 @@ 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]")); @@ -386,7 +520,7 @@ mod tests { assert!(help.contains("/version")); assert!(help.contains("/export [file]")); assert!(help.contains("/session [list|switch ]")); - assert_eq!(slash_command_specs().len(), 15); + assert_eq!(slash_command_specs().len(), 22); assert_eq!(resume_supported_slash_commands().len(), 11); } @@ -434,6 +568,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() ); diff --git a/rust/crates/runtime/src/conversation.rs b/rust/crates/runtime/src/conversation.rs index b979e61..1db2e77 100644 --- a/rust/crates/runtime/src/conversation.rs +++ b/rust/crates/runtime/src/conversation.rs @@ -8,6 +8,9 @@ use crate::permissions::{PermissionOutcome, PermissionPolicy, PermissionPrompter use crate::session::{ContentBlock, ConversationMessage, Session}; use crate::usage::{TokenUsage, UsageTracker}; +const DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD: u32 = 100_000; +const AUTO_COMPACTION_THRESHOLD_ENV_VAR: &str = "CLAUDE_CODE_AUTO_COMPACT_INPUT_TOKENS"; + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ApiRequest { pub system_prompt: Vec, @@ -84,6 +87,12 @@ pub struct TurnSummary { pub tool_results: Vec, pub iterations: usize, pub usage: TokenUsage, + pub auto_compaction: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AutoCompactionEvent { + pub removed_message_count: usize, } pub struct ConversationRuntime { @@ -94,6 +103,7 @@ pub struct ConversationRuntime { system_prompt: Vec, max_iterations: usize, usage_tracker: UsageTracker, + auto_compaction_input_tokens_threshold: u32, } impl ConversationRuntime @@ -118,6 +128,7 @@ where system_prompt, max_iterations: usize::MAX, usage_tracker, + auto_compaction_input_tokens_threshold: auto_compaction_threshold_from_env(), } } @@ -127,6 +138,12 @@ where self } + #[must_use] + pub fn with_auto_compaction_input_tokens_threshold(mut self, threshold: u32) -> Self { + self.auto_compaction_input_tokens_threshold = threshold; + self + } + pub fn run_turn( &mut self, user_input: impl Into, @@ -209,11 +226,14 @@ where } } + let auto_compaction = self.maybe_auto_compact(); + Ok(TurnSummary { assistant_messages, tool_results, iterations, usage: self.usage_tracker.cumulative_usage(), + auto_compaction, }) } @@ -241,6 +261,48 @@ where pub fn into_session(self) -> Session { self.session } + + fn maybe_auto_compact(&mut self) -> Option { + if self.usage_tracker.cumulative_usage().input_tokens + < self.auto_compaction_input_tokens_threshold + { + return None; + } + + let result = compact_session( + &self.session, + CompactionConfig { + max_estimated_tokens: 0, + ..CompactionConfig::default() + }, + ); + + if result.removed_message_count == 0 { + return None; + } + + self.session = result.compacted_session; + Some(AutoCompactionEvent { + removed_message_count: result.removed_message_count, + }) + } +} + +#[must_use] +pub fn auto_compaction_threshold_from_env() -> u32 { + parse_auto_compaction_threshold( + std::env::var(AUTO_COMPACTION_THRESHOLD_ENV_VAR) + .ok() + .as_deref(), + ) +} + +#[must_use] +fn parse_auto_compaction_threshold(value: Option<&str>) -> u32 { + value + .and_then(|raw| raw.trim().parse::().ok()) + .filter(|threshold| *threshold > 0) + .unwrap_or(DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD) } fn build_assistant_message( @@ -325,8 +387,9 @@ impl ToolExecutor for StaticToolExecutor { #[cfg(test)] mod tests { use super::{ - ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, - StaticToolExecutor, + parse_auto_compaction_threshold, ApiClient, ApiRequest, AssistantEvent, + AutoCompactionEvent, ConversationRuntime, RuntimeError, StaticToolExecutor, + DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD, }; use crate::compact::CompactionConfig; use crate::permissions::{ @@ -436,6 +499,7 @@ mod tests { assert_eq!(summary.tool_results.len(), 1); assert_eq!(runtime.session().messages.len(), 4); assert_eq!(summary.usage.output_tokens, 10); + assert_eq!(summary.auto_compaction, None); assert!(matches!( runtime.session().messages[1].blocks[1], ContentBlock::ToolUse { .. } @@ -581,4 +645,111 @@ mod tests { MessageRole::System ); } + + #[test] + fn auto_compacts_when_cumulative_input_threshold_is_crossed() { + struct SimpleApi; + impl ApiClient for SimpleApi { + fn stream( + &mut self, + _request: ApiRequest, + ) -> Result, RuntimeError> { + Ok(vec![ + AssistantEvent::TextDelta("done".to_string()), + AssistantEvent::Usage(TokenUsage { + input_tokens: 120_000, + output_tokens: 4, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }), + AssistantEvent::MessageStop, + ]) + } + } + + let session = Session { + version: 1, + messages: vec![ + crate::session::ConversationMessage::user_text("one"), + crate::session::ConversationMessage::assistant(vec![ContentBlock::Text { + text: "two".to_string(), + }]), + crate::session::ConversationMessage::user_text("three"), + crate::session::ConversationMessage::assistant(vec![ContentBlock::Text { + text: "four".to_string(), + }]), + ], + }; + + let mut runtime = ConversationRuntime::new( + session, + SimpleApi, + StaticToolExecutor::new(), + PermissionPolicy::new(PermissionMode::DangerFullAccess), + vec!["system".to_string()], + ) + .with_auto_compaction_input_tokens_threshold(100_000); + + let summary = runtime + .run_turn("trigger", None) + .expect("turn should succeed"); + + assert_eq!( + summary.auto_compaction, + Some(AutoCompactionEvent { + removed_message_count: 2, + }) + ); + assert_eq!(runtime.session().messages[0].role, MessageRole::System); + } + + #[test] + fn skips_auto_compaction_below_threshold() { + struct SimpleApi; + impl ApiClient for SimpleApi { + fn stream( + &mut self, + _request: ApiRequest, + ) -> Result, RuntimeError> { + Ok(vec![ + AssistantEvent::TextDelta("done".to_string()), + AssistantEvent::Usage(TokenUsage { + input_tokens: 99_999, + output_tokens: 4, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }), + AssistantEvent::MessageStop, + ]) + } + } + + let mut runtime = ConversationRuntime::new( + Session::new(), + SimpleApi, + StaticToolExecutor::new(), + PermissionPolicy::new(PermissionMode::DangerFullAccess), + vec!["system".to_string()], + ) + .with_auto_compaction_input_tokens_threshold(100_000); + + let summary = runtime + .run_turn("trigger", None) + .expect("turn should succeed"); + assert_eq!(summary.auto_compaction, None); + assert_eq!(runtime.session().messages.len(), 2); + } + + #[test] + fn auto_compaction_threshold_defaults_and_parses_values() { + assert_eq!( + parse_auto_compaction_threshold(None), + DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD + ); + assert_eq!(parse_auto_compaction_threshold(Some("4321")), 4321); + assert_eq!( + parse_auto_compaction_threshold(Some("not-a-number")), + DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD + ); + } } diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index 2861d47..8193d57 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -30,8 +30,8 @@ pub use config::{ CLAUDE_CODE_SETTINGS_SCHEMA_NAME, }; pub use conversation::{ - ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, StaticToolExecutor, - ToolError, ToolExecutor, TurnSummary, + auto_compaction_threshold_from_env, ApiClient, ApiRequest, AssistantEvent, AutoCompactionEvent, + ConversationRuntime, RuntimeError, StaticToolExecutor, ToolError, ToolExecutor, TurnSummary, }; pub use file_ops::{ edit_file, glob_search, grep_search, read_file, write_file, EditFileOutput, GlobSearchOutput, diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 6a1fdc1..6c65202 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -27,8 +27,8 @@ use runtime::{ clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt, parse_oauth_callback_request_target, save_oauth_credentials, ApiClient, ApiRequest, AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock, - ConversationMessage, ConversationRuntime, MessageRole, OAuthAuthorizationRequest, - OAuthConfig, OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError, + ConversationMessage, ConversationRuntime, MessageRole, OAuthAuthorizationRequest, OAuthConfig, + OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError, Session, TokenUsage, ToolError, ToolExecutor, UsageTracker, }; use serde_json::json; @@ -775,6 +775,10 @@ fn format_compact_report(removed: usize, resulting_messages: usize, skipped: boo } } +fn format_auto_compaction_notice(removed: usize) -> String { + format!("[auto-compacted: removed {removed} messages]") +} + fn parse_git_status_metadata(status: Option<&str>) -> (Option, Option) { let Some(status) = status else { return (None, None); @@ -913,7 +917,14 @@ fn run_resume_command( )), }) } - SlashCommand::Resume { .. } + SlashCommand::Bughunter { .. } + | SlashCommand::Commit + | SlashCommand::Pr { .. } + | SlashCommand::Issue { .. } + | SlashCommand::Ultraplan { .. } + | SlashCommand::Teleport { .. } + | SlashCommand::DebugToolCall + | SlashCommand::Resume { .. } | SlashCommand::Model { .. } | SlashCommand::Permissions { .. } | SlashCommand::Session { .. } @@ -1050,13 +1061,19 @@ impl LiveCli { let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); let result = self.runtime.run_turn(input, Some(&mut permission_prompter)); match result { - Ok(_) => { + Ok(summary) => { spinner.finish( "✨ Done", TerminalRenderer::new().color_theme(), &mut stdout, )?; println!(); + if let Some(event) = summary.auto_compaction { + println!( + "{}", + format_auto_compaction_notice(event.removed_message_count) + ); + } self.persist_session()?; Ok(()) } @@ -1103,6 +1120,10 @@ impl LiveCli { "message": final_assistant_text(&summary), "model": self.model, "iterations": summary.iterations, + "auto_compaction": summary.auto_compaction.map(|event| json!({ + "removed_messages": event.removed_message_count, + "notice": format_auto_compaction_notice(event.removed_message_count), + })), "tool_uses": collect_tool_uses(&summary), "tool_results": collect_tool_results(&summary), "usage": { @@ -1129,6 +1150,34 @@ impl LiveCli { self.print_status(); false } + SlashCommand::Bughunter { scope } => { + self.run_bughunter(scope.as_deref())?; + false + } + SlashCommand::Commit => { + self.run_commit()?; + true + } + SlashCommand::Pr { context } => { + self.run_pr(context.as_deref())?; + false + } + SlashCommand::Issue { context } => { + self.run_issue(context.as_deref())?; + false + } + SlashCommand::Ultraplan { task } => { + self.run_ultraplan(task.as_deref())?; + false + } + SlashCommand::Teleport { target } => { + self.run_teleport(target.as_deref())?; + false + } + SlashCommand::DebugToolCall => { + self.run_debug_tool_call()?; + false + } SlashCommand::Compact => { self.compact()?; false @@ -1448,6 +1497,160 @@ impl LiveCli { println!("{}", format_compact_report(removed, kept, skipped)); Ok(()) } + + fn run_internal_prompt_text( + &self, + prompt: &str, + enable_tools: bool, + ) -> Result> { + let session = self.runtime.session().clone(); + let mut runtime = build_runtime( + session, + self.model.clone(), + self.system_prompt.clone(), + enable_tools, + false, + self.allowed_tools.clone(), + self.permission_mode, + )?; + let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); + let summary = runtime.run_turn(prompt, Some(&mut permission_prompter))?; + Ok(final_assistant_text(&summary).trim().to_string()) + } + + fn run_bughunter(&self, scope: Option<&str>) -> Result<(), Box> { + let scope = scope.unwrap_or("the current repository"); + let prompt = format!( + "You are /bughunter. Inspect {scope} and identify the most likely bugs or correctness issues. Prioritize concrete findings with file paths, severity, and suggested fixes. Use tools if needed." + ); + println!("{}", self.run_internal_prompt_text(&prompt, true)?); + Ok(()) + } + + fn run_ultraplan(&self, task: Option<&str>) -> Result<(), Box> { + let task = task.unwrap_or("the current repo work"); + let prompt = format!( + "You are /ultraplan. Produce a deep multi-step execution plan for {task}. Include goals, risks, implementation sequence, verification steps, and rollback considerations. Use tools if needed." + ); + println!("{}", self.run_internal_prompt_text(&prompt, true)?); + Ok(()) + } + + fn run_teleport(&self, target: Option<&str>) -> Result<(), Box> { + let Some(target) = target.map(str::trim).filter(|value| !value.is_empty()) else { + println!("Usage: /teleport "); + return Ok(()); + }; + + println!("{}", render_teleport_report(target)?); + Ok(()) + } + + fn run_debug_tool_call(&self) -> Result<(), Box> { + println!("{}", render_last_tool_debug_report(self.runtime.session())?); + Ok(()) + } + + fn run_commit(&mut self) -> Result<(), Box> { + let status = git_output(&["status", "--short"])?; + if status.trim().is_empty() { + println!("Commit\n Result skipped\n Reason no workspace changes"); + return Ok(()); + } + + git_status_ok(&["add", "-A"])?; + let staged_stat = git_output(&["diff", "--cached", "--stat"])?; + let prompt = format!( + "Generate a git commit message in plain text Lore format only. Base it on this staged diff summary:\n\n{}\n\nRecent conversation context:\n{}", + truncate_for_prompt(&staged_stat, 8_000), + recent_user_context(self.runtime.session(), 6) + ); + let message = sanitize_generated_message(&self.run_internal_prompt_text(&prompt, false)?); + if message.trim().is_empty() { + return Err("generated commit message was empty".into()); + } + + let path = write_temp_text_file("claw-commit-message.txt", &message)?; + let output = Command::new("git") + .args(["commit", "--file"]) + .arg(&path) + .current_dir(env::current_dir()?) + .output()?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(format!("git commit failed: {stderr}").into()); + } + + println!( + "Commit\n Result created\n Message file {}\n\n{}", + path.display(), + message.trim() + ); + Ok(()) + } + + fn run_pr(&self, context: Option<&str>) -> Result<(), Box> { + let staged = git_output(&["diff", "--stat"])?; + let prompt = format!( + "Generate a pull request title and body from this conversation and diff summary. Output plain text in this format exactly:\nTITLE: \nBODY:\n<body markdown>\n\nContext hint: {}\n\nDiff summary:\n{}", + context.unwrap_or("none"), + truncate_for_prompt(&staged, 10_000) + ); + let draft = sanitize_generated_message(&self.run_internal_prompt_text(&prompt, false)?); + let (title, body) = parse_titled_body(&draft) + .ok_or_else(|| "failed to parse generated PR title/body".to_string())?; + + if command_exists("gh") { + let body_path = write_temp_text_file("claw-pr-body.md", &body)?; + let output = Command::new("gh") + .args(["pr", "create", "--title", &title, "--body-file"]) + .arg(&body_path) + .current_dir(env::current_dir()?) + .output()?; + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + println!( + "PR\n Result created\n Title {title}\n URL {}", + if stdout.is_empty() { "<unknown>" } else { &stdout } + ); + return Ok(()); + } + } + + println!("PR draft\n Title {title}\n\n{body}"); + Ok(()) + } + + fn run_issue(&self, context: Option<&str>) -> Result<(), Box<dyn std::error::Error>> { + let prompt = format!( + "Generate a GitHub issue title and body from this conversation. Output plain text in this format exactly:\nTITLE: <title>\nBODY:\n<body markdown>\n\nContext hint: {}\n\nConversation context:\n{}", + context.unwrap_or("none"), + truncate_for_prompt(&recent_user_context(self.runtime.session(), 10), 10_000) + ); + let draft = sanitize_generated_message(&self.run_internal_prompt_text(&prompt, false)?); + let (title, body) = parse_titled_body(&draft) + .ok_or_else(|| "failed to parse generated issue title/body".to_string())?; + + if command_exists("gh") { + let body_path = write_temp_text_file("claw-issue-body.md", &body)?; + let output = Command::new("gh") + .args(["issue", "create", "--title", &title, "--body-file"]) + .arg(&body_path) + .current_dir(env::current_dir()?) + .output()?; + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + println!( + "Issue\n Result created\n Title {title}\n URL {}", + if stdout.is_empty() { "<unknown>" } else { &stdout } + ); + return Ok(()); + } + } + + println!("Issue draft\n Title {title}\n\n{body}"); + Ok(()) + } } fn sessions_dir() -> Result<PathBuf, Box<dyn std::error::Error>> { @@ -1799,6 +2002,206 @@ fn render_diff_report() -> Result<String, Box<dyn std::error::Error>> { Ok(format!("Diff\n\n{}", diff.trim_end())) } +fn render_teleport_report(target: &str) -> Result<String, Box<dyn std::error::Error>> { + let cwd = env::current_dir()?; + + let file_list = Command::new("rg") + .args(["--files"]) + .current_dir(&cwd) + .output()?; + let file_matches = if file_list.status.success() { + String::from_utf8(file_list.stdout)? + .lines() + .filter(|line| line.contains(target)) + .take(10) + .map(ToOwned::to_owned) + .collect::<Vec<_>>() + } else { + Vec::new() + }; + + let content_output = Command::new("rg") + .args(["-n", "-S", "--color", "never", target, "."]) + .current_dir(&cwd) + .output()?; + + let mut lines = vec![format!("Teleport\n Target {target}")]; + if !file_matches.is_empty() { + lines.push(String::new()); + lines.push("File matches".to_string()); + lines.extend(file_matches.into_iter().map(|path| format!(" {path}"))); + } + + if content_output.status.success() { + let matches = String::from_utf8(content_output.stdout)?; + if !matches.trim().is_empty() { + lines.push(String::new()); + lines.push("Content matches".to_string()); + lines.push(truncate_for_prompt(&matches, 4_000)); + } + } + + if lines.len() == 1 { + lines.push(" Result no matches found".to_string()); + } + + Ok(lines.join("\n")) +} + +fn render_last_tool_debug_report(session: &Session) -> Result<String, Box<dyn std::error::Error>> { + let last_tool_use = session + .messages + .iter() + .rev() + .find_map(|message| { + message.blocks.iter().rev().find_map(|block| match block { + ContentBlock::ToolUse { id, name, input } => { + Some((id.clone(), name.clone(), input.clone())) + } + _ => None, + }) + }) + .ok_or_else(|| "no prior tool call found in session".to_string())?; + + let tool_result = session.messages.iter().rev().find_map(|message| { + message.blocks.iter().rev().find_map(|block| match block { + ContentBlock::ToolResult { + tool_use_id, + tool_name, + output, + is_error, + } if tool_use_id == &last_tool_use.0 => { + Some((tool_name.clone(), output.clone(), *is_error)) + } + _ => None, + }) + }); + + let mut lines = vec![ + "Debug tool call".to_string(), + format!(" Tool id {}", last_tool_use.0), + format!(" Tool name {}", last_tool_use.1), + " Input".to_string(), + indent_block(&last_tool_use.2, 4), + ]; + + match tool_result { + Some((tool_name, output, is_error)) => { + lines.push(" Result".to_string()); + lines.push(format!(" name {tool_name}")); + lines.push(format!( + " status {}", + if is_error { "error" } else { "ok" } + )); + lines.push(indent_block(&output, 4)); + } + None => lines.push(" Result missing tool result".to_string()), + } + + Ok(lines.join("\n")) +} + +fn indent_block(value: &str, spaces: usize) -> String { + let indent = " ".repeat(spaces); + value + .lines() + .map(|line| format!("{indent}{line}")) + .collect::<Vec<_>>() + .join("\n") +} + +fn git_output(args: &[&str]) -> Result<String, Box<dyn std::error::Error>> { + let output = Command::new("git") + .args(args) + .current_dir(env::current_dir()?) + .output()?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(format!("git {} failed: {stderr}", args.join(" ")).into()); + } + Ok(String::from_utf8(output.stdout)?) +} + +fn git_status_ok(args: &[&str]) -> Result<(), Box<dyn std::error::Error>> { + let output = Command::new("git") + .args(args) + .current_dir(env::current_dir()?) + .output()?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(format!("git {} failed: {stderr}", args.join(" ")).into()); + } + Ok(()) +} + +fn command_exists(name: &str) -> bool { + Command::new("which") + .arg(name) + .output() + .map(|output| output.status.success()) + .unwrap_or(false) +} + +fn write_temp_text_file( + filename: &str, + contents: &str, +) -> Result<PathBuf, Box<dyn std::error::Error>> { + let path = env::temp_dir().join(filename); + fs::write(&path, contents)?; + Ok(path) +} + +fn recent_user_context(session: &Session, limit: usize) -> String { + let requests = session + .messages + .iter() + .filter(|message| message.role == MessageRole::User) + .filter_map(|message| { + message.blocks.iter().find_map(|block| match block { + ContentBlock::Text { text } => Some(text.trim().to_string()), + _ => None, + }) + }) + .rev() + .take(limit) + .collect::<Vec<_>>(); + + if requests.is_empty() { + "<no prior user messages>".to_string() + } else { + requests + .into_iter() + .rev() + .enumerate() + .map(|(index, text)| format!("{}. {}", index + 1, text)) + .collect::<Vec<_>>() + .join("\n") + } +} + +fn truncate_for_prompt(value: &str, limit: usize) -> String { + if value.chars().count() <= limit { + value.trim().to_string() + } else { + let truncated = value.chars().take(limit).collect::<String>(); + format!("{}\n…[truncated]", truncated.trim_end()) + } +} + +fn sanitize_generated_message(value: &str) -> String { + value.trim().trim_matches('`').trim().replace("\r\n", "\n") +} + +fn parse_titled_body(value: &str) -> Option<(String, String)> { + let normalized = sanitize_generated_message(value); + let title = normalized + .lines() + .find_map(|line| line.strip_prefix("TITLE:").map(str::trim))?; + let body_start = normalized.find("BODY:")?; + let body = normalized[body_start + "BODY:".len()..].trim(); + Some((title.to_string(), body.to_string())) +} + fn render_version_report() -> String { let git_sha = GIT_SHA.unwrap_or("unknown"); let target = BUILD_TARGET.unwrap_or("unknown"); From 9ade3a70d70ae690ae15d3c8f1de7e6d03d87a2a Mon Sep 17 00:00:00 2001 From: Yeachan-Heo <hurrc04@gmail.com> Date: Wed, 1 Apr 2026 03:55:00 +0000 Subject: [PATCH 03/25] fix: auto compaction threshold default 200k tokens --- rust/crates/runtime/src/conversation.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/crates/runtime/src/conversation.rs b/rust/crates/runtime/src/conversation.rs index 3ff1f63..f7a7741 100644 --- a/rust/crates/runtime/src/conversation.rs +++ b/rust/crates/runtime/src/conversation.rs @@ -10,7 +10,7 @@ use crate::permissions::{PermissionOutcome, PermissionPolicy, PermissionPrompter use crate::session::{ContentBlock, ConversationMessage, Session}; use crate::usage::{TokenUsage, UsageTracker}; -const DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD: u32 = 100_000; +const DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD: u32 = 200_000; const AUTO_COMPACTION_THRESHOLD_ENV_VAR: &str = "CLAUDE_CODE_AUTO_COMPACT_INPUT_TOKENS"; #[derive(Debug, Clone, PartialEq, Eq)] From b905b611f041dd2af227b5fa65eed7f71cf974f4 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo <hurrc04@gmail.com> Date: Wed, 1 Apr 2026 04:30:28 +0000 Subject: [PATCH 04/25] wip: plugins progress --- rust/Cargo.lock | 9 + rust/crates/commands/src/lib.rs | 36 +- rust/crates/compat-harness/src/lib.rs | 6 +- rust/crates/plugins/Cargo.toml | 13 + .../.claude-plugin/plugin.json | 10 + .../bundled/example-bundled/hooks/post.sh | 2 + .../bundled/example-bundled/hooks/pre.sh | 2 + .../sample-hooks/.claude-plugin/plugin.json | 10 + .../bundled/sample-hooks/hooks/post.sh | 2 + .../plugins/bundled/sample-hooks/hooks/pre.sh | 2 + rust/crates/plugins/src/lib.rs | 983 ++++++++++++++++++ rust/crates/plugins/src/manager.rs | 642 ++++++++++++ rust/crates/plugins/src/manifest.rs | 175 ++++ rust/crates/plugins/src/registry.rs | 91 ++ rust/crates/plugins/src/settings.rs | 106 ++ rust/crates/runtime/src/config.rs | 246 ++++- rust/crates/runtime/src/lib.rs | 2 +- rust/crates/runtime/src/prompt.rs | 2 +- rust/crates/rusty-claude-cli/Cargo.toml | 1 + rust/crates/rusty-claude-cli/src/main.rs | 143 ++- 20 files changed, 2462 insertions(+), 21 deletions(-) create mode 100644 rust/crates/plugins/Cargo.toml create mode 100644 rust/crates/plugins/bundled/example-bundled/.claude-plugin/plugin.json create mode 100755 rust/crates/plugins/bundled/example-bundled/hooks/post.sh create mode 100755 rust/crates/plugins/bundled/example-bundled/hooks/pre.sh create mode 100644 rust/crates/plugins/bundled/sample-hooks/.claude-plugin/plugin.json create mode 100755 rust/crates/plugins/bundled/sample-hooks/hooks/post.sh create mode 100755 rust/crates/plugins/bundled/sample-hooks/hooks/pre.sh create mode 100644 rust/crates/plugins/src/lib.rs create mode 100644 rust/crates/plugins/src/manager.rs create mode 100644 rust/crates/plugins/src/manifest.rs create mode 100644 rust/crates/plugins/src/registry.rs create mode 100644 rust/crates/plugins/src/settings.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 5507dca..4d45e5e 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -825,6 +825,14 @@ dependencies = [ "time", ] +[[package]] +name = "plugins" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -1181,6 +1189,7 @@ dependencies = [ "commands", "compat-harness", "crossterm", + "plugins", "pulldown-cmark", "runtime", "rustyline", diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index e7f8d13..d761645 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -90,7 +90,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 { @@ -171,6 +171,14 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ argument_hint: Some("[list|switch <session-id>]"), resume_supported: false, }, + SlashCommandSpec { + name: "plugins", + summary: "List or manage plugins", + argument_hint: Some( + "[list|install <source>|enable <id>|disable <id>|uninstall <id>|update <id>]", + ), + resume_supported: false, + }, ]; #[derive(Debug, Clone, PartialEq, Eq)] @@ -222,6 +230,10 @@ pub enum SlashCommand { action: Option<String>, target: Option<String>, }, + Plugins { + action: Option<String>, + target: Option<String>, + }, Unknown(String), } @@ -283,6 +295,10 @@ 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: parts.next().map(ToOwned::to_owned), + }, other => Self::Unknown(other.to_string()), }) } @@ -383,6 +399,7 @@ pub fn handle_slash_command( | SlashCommand::Version | SlashCommand::Export { .. } | SlashCommand::Session { .. } + | SlashCommand::Plugins { .. } | SlashCommand::Unknown(_) => None, } } @@ -492,6 +509,13 @@ 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()) + }) + ); } #[test] @@ -513,14 +537,17 @@ mod tests { assert!(help.contains("/clear [--confirm]")); assert!(help.contains("/cost")); assert!(help.contains("/resume <session-path>")); - assert!(help.contains("/config [env|hooks|model]")); + assert!(help.contains("/config [env|hooks|model|plugins]")); assert!(help.contains("/memory")); assert!(help.contains("/init")); assert!(help.contains("/diff")); assert!(help.contains("/version")); assert!(help.contains("/export [file]")); assert!(help.contains("/session [list|switch <session-id>]")); - assert_eq!(slash_command_specs().len(), 22); + assert!(help.contains( + "/plugins [list|install <source>|enable <id>|disable <id>|uninstall <id>|update <id>]" + )); + assert_eq!(slash_command_specs().len(), 23); assert_eq!(resume_supported_slash_commands().len(), 11); } @@ -618,5 +645,8 @@ mod tests { assert!( handle_slash_command("/session list", &session, CompactionConfig::default()).is_none() ); + assert!( + handle_slash_command("/plugins list", &session, CompactionConfig::default()).is_none() + ); } } diff --git a/rust/crates/compat-harness/src/lib.rs b/rust/crates/compat-harness/src/lib.rs index 7176c27..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<PathBuf> { 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(); 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/lib.rs b/rust/crates/plugins/src/lib.rs new file mode 100644 index 0000000..319ebe9 --- /dev/null +++ b/rust/crates/plugins/src/lib.rs @@ -0,0 +1,983 @@ +use std::collections::BTreeMap; +use std::fmt::{Display, Formatter}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +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_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"), + } + } +} + +#[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<PathBuf>, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginHooks { + #[serde(rename = "PreToolUse", default)] + pub pre_tool_use: Vec<String>, + #[serde(rename = "PostToolUse", default)] + pub post_tool_use: Vec<String>, +} + +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, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginManifest { + pub name: String, + pub version: String, + pub description: String, + #[serde(rename = "defaultEnabled", default)] + pub default_enabled: bool, + #[serde(default)] + pub hooks: PluginHooks, +} + +#[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 { + 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<String, InstalledPluginRecord>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BuiltinPlugin { + metadata: PluginMetadata, + hooks: PluginHooks, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BundledPlugin { + metadata: PluginMetadata, + hooks: PluginHooks, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExternalPlugin { + metadata: PluginMetadata, + hooks: PluginHooks, +} + +pub trait Plugin { + fn metadata(&self) -> &PluginMetadata; + fn hooks(&self) -> &PluginHooks; + fn validate(&self) -> Result<(), PluginError>; +} + +#[derive(Debug, Clone, PartialEq, Eq)] +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 validate(&self) -> Result<(), PluginError> { + Ok(()) + } +} + +impl Plugin for BundledPlugin { + fn metadata(&self) -> &PluginMetadata { + &self.metadata + } + + fn hooks(&self) -> &PluginHooks { + &self.hooks + } + + fn validate(&self) -> Result<(), PluginError> { + validate_hook_paths(self.metadata.root.as_deref(), &self.hooks) + } +} + +impl Plugin for ExternalPlugin { + fn metadata(&self) -> &PluginMetadata { + &self.metadata + } + + fn hooks(&self) -> &PluginHooks { + &self.hooks + } + + fn validate(&self) -> Result<(), PluginError> { + validate_hook_paths(self.metadata.root.as_deref(), &self.hooks) + } +} + +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 validate(&self) -> Result<(), PluginError> { + match self { + Self::Builtin(plugin) => plugin.validate(), + Self::Bundled(plugin) => plugin.validate(), + Self::External(plugin) => plugin.validate(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginSummary { + pub metadata: PluginMetadata, + pub enabled: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginManagerConfig { + pub config_home: PathBuf, + pub enabled_plugins: BTreeMap<String, bool>, + pub external_dirs: Vec<PathBuf>, + pub install_root: Option<PathBuf>, + pub registry_path: Option<PathBuf>, + pub bundled_root: Option<PathBuf>, +} + +impl PluginManagerConfig { + #[must_use] + pub fn new(config_home: impl Into<PathBuf>) -> 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)] +pub enum PluginError { + Io(std::io::Error), + Json(serde_json::Error), + 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::InvalidManifest(message) + | Self::NotFound(message) + | Self::CommandFailed(message) => write!(f, "{message}"), + } + } +} + +impl std::error::Error for PluginError {} + +impl From<std::io::Error> for PluginError { + fn from(value: std::io::Error) -> Self { + Self::Io(value) + } +} + +impl From<serde_json::Error> 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 list_plugins(&self) -> Result<Vec<PluginSummary>, PluginError> { + let mut plugins = self + .discover_plugins()? + .into_iter() + .map(|plugin| PluginSummary { + enabled: self.is_enabled(plugin.metadata()), + metadata: plugin.metadata().clone(), + }) + .collect::<Vec<_>>(); + plugins.sort_by(|left, right| left.metadata.id.cmp(&right.metadata.id)); + Ok(plugins) + } + + pub fn discover_plugins(&self) -> Result<Vec<PluginDefinition>, PluginError> { + let mut plugins = builtin_plugins(); + plugins.extend(self.discover_bundled_plugins()?); + plugins.extend(self.discover_external_plugins()?); + Ok(plugins) + } + + pub fn aggregated_hooks(&self) -> Result<PluginHooks, PluginError> { + self.discover_plugins()? + .into_iter() + .filter(|plugin| self.is_enabled(plugin.metadata())) + .try_fold(PluginHooks::default(), |acc, plugin| { + plugin.validate()?; + Ok(acc.merged_with(plugin.hooks())) + }) + } + + pub fn validate_plugin_source(&self, source: &str) -> Result<PluginManifest, PluginError> { + let path = resolve_local_source(source)?; + load_manifest_from_root(&path) + } + + pub fn install(&mut self, source: &str) -> Result<InstallOutcome, PluginError> { + 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_manifest_from_root(&staged_source)?; + validate_manifest(&manifest)?; + + 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 { + 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.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<UpdateOutcome, PluginError> { + 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_manifest_from_root(&staged_source)?; + validate_manifest(&manifest)?; + + 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_bundled_plugins(&self) -> Result<Vec<PluginDefinition>, PluginError> { + discover_plugin_dirs( + &self + .config + .bundled_root + .clone() + .unwrap_or_else(Self::bundled_root), + )? + .into_iter() + .map(|root| { + load_plugin_definition( + &root, + PluginKind::Bundled, + format!("{BUNDLED_MARKETPLACE}:{}", root.display()), + BUNDLED_MARKETPLACE, + ) + }) + .collect() + } + + fn discover_external_plugins(&self) -> Result<Vec<PluginDefinition>, PluginError> { + let registry = self.load_registry()?; + let mut plugins = registry + .plugins + .values() + .map(|record| { + load_plugin_definition( + &record.install_path, + PluginKind::External, + describe_install_source(&record.source), + EXTERNAL_MARKETPLACE, + ) + }) + .collect::<Result<Vec<_>, _>>()?; + + 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 plugins + .iter() + .all(|existing| existing.metadata().id != plugin.metadata().id) + { + plugins.push(plugin); + } + } + } + + Ok(plugins) + } + + 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 + .list_plugins()? + .iter() + .any(|plugin| plugin.metadata.id == plugin_id) + { + Ok(()) + } else { + Err(PluginError::NotFound(format!( + "plugin `{plugin_id}` is not installed or discoverable" + ))) + } + } + + fn load_registry(&self) -> Result<InstalledPluginRegistry, PluginError> { + 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<bool>, + ) -> 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<PluginDefinition> { + 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(), + })] +} + +fn load_plugin_definition( + root: &Path, + kind: PluginKind, + source: String, + marketplace: &str, +) -> Result<PluginDefinition, PluginError> { + let manifest = load_manifest_from_root(root)?; + validate_manifest(&manifest)?; + 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); + Ok(match kind { + PluginKind::Builtin => PluginDefinition::Builtin(BuiltinPlugin { metadata, hooks }), + PluginKind::Bundled => PluginDefinition::Bundled(BundledPlugin { metadata, hooks }), + PluginKind::External => PluginDefinition::External(ExternalPlugin { metadata, hooks }), + }) +} + +fn validate_manifest(manifest: &PluginManifest) -> Result<(), PluginError> { + if manifest.name.trim().is_empty() { + return Err(PluginError::InvalidManifest( + "plugin manifest name cannot be empty".to_string(), + )); + } + if manifest.version.trim().is_empty() { + return Err(PluginError::InvalidManifest( + "plugin manifest version cannot be empty".to_string(), + )); + } + if manifest.description.trim().is_empty() { + return Err(PluginError::InvalidManifest( + "plugin manifest description cannot be empty".to_string(), + )); + } + Ok(()) +} + +fn load_manifest_from_root(root: &Path) -> Result<PluginManifest, PluginError> { + let manifest_path = root.join(MANIFEST_RELATIVE_PATH); + let contents = fs::read_to_string(&manifest_path).map_err(|error| { + PluginError::NotFound(format!( + "plugin manifest not found at {}: {error}", + manifest_path.display() + )) + })?; + Ok(serde_json::from_str(&contents)?) +} + +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 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()) { + if is_literal_command(entry) { + continue; + } + let path = if Path::new(entry).is_absolute() { + PathBuf::from(entry) + } else { + root.join(entry) + }; + if !path.exists() { + return Err(PluginError::InvalidManifest(format!( + "hook 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("../") +} + +fn resolve_local_source(source: &str) -> Result<PathBuf, PluginError> { + 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<PluginInstallSource, PluginError> { + if source.starts_with("http://") + || source.starts_with("https://") + || source.starts_with("git@") + || source.ends_with(".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<PathBuf, PluginError> { + 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<Vec<PathBuf>, PluginError> { + match fs::read_dir(root) { + Ok(entries) => { + let mut paths = Vec::new(); + for entry in entries { + let path = entry?.path(); + if path.join(MANIFEST_RELATIVE_PATH).exists() { + paths.push(path); + } + } + 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<String, Value>), +) -> 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::<Value>(&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<String, Value>, key: &str) -> &'a mut Map<String, Value> { + 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_external_plugin(root: &Path, name: &str, version: &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"), + "#!/bin/sh\nprintf 'pre'\n", + ) + .expect("write pre hook"); + fs::write( + root.join("hooks").join("post.sh"), + "#!/bin/sh\nprintf 'post'\n", + ) + .expect("write post hook"); + fs::write( + root.join(MANIFEST_RELATIVE_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}}" + ), + ) + .expect("write manifest"); + } + + #[test] + fn validates_manifest_shape() { + let error = validate_manifest(&PluginManifest { + name: String::new(), + version: "1.0.0".to_string(), + description: "desc".to_string(), + default_enabled: false, + hooks: PluginHooks::default(), + }) + .expect_err("empty name should fail"); + assert!(error.to_string().contains("name cannot be empty")); + } + + #[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")); + + fs::remove_dir_all(config_home).expect("cleanup home"); + fs::remove_dir_all(source_root).expect("cleanup source"); + } + + #[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"); + fs::remove_dir_all(config_home).expect("cleanup home"); + fs::remove_dir_all(source_root).expect("cleanup source"); + } +} diff --git a/rust/crates/plugins/src/manager.rs b/rust/crates/plugins/src/manager.rs new file mode 100644 index 0000000..85a7cd0 --- /dev/null +++ b/rust/crates/plugins/src/manager.rs @@ -0,0 +1,642 @@ +use std::fmt::{Display, Formatter}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use runtime::{RuntimeConfig, RuntimeHookConfig}; +use serde::{Deserialize, Serialize}; + +use crate::manifest::{LoadedPlugin, Plugin, PluginHooks, PluginManifest}; +use crate::registry::PluginRegistry; +use crate::settings::{read_settings_file, write_plugin_state, write_settings_file}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PluginSourceKind { + Builtin, + Bundled, + External, +} + +impl PluginSourceKind { + fn suffix(self) -> &'static str { + match self { + Self::Builtin => "builtin", + Self::Bundled => "bundled", + Self::External => "external", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct InstalledPluginRecord { + pub id: String, + pub name: String, + pub version: String, + pub description: String, + pub source_kind: PluginSourceKind, + pub source_path: String, + pub install_path: String, + pub installed_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginListEntry { + pub plugin: LoadedPlugin, + pub enabled: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginOperationResult { + pub plugin_id: String, + pub message: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginError { + message: String, +} + +impl PluginError { + #[must_use] + pub fn new(message: impl Into<String>) -> Self { + Self { + message: message.into(), + } + } +} + +impl Display for PluginError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for PluginError {} + +impl From<String> for PluginError { + fn from(value: String) -> Self { + Self::new(value) + } +} + +impl From<std::io::Error> for PluginError { + fn from(value: std::io::Error) -> Self { + Self::new(value.to_string()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginLoader { + registry_path: PathBuf, +} + +impl PluginLoader { + #[must_use] + pub fn new(config_home: impl Into<PathBuf>) -> Self { + let config_home = config_home.into(); + Self { + registry_path: config_home.join("plugins").join("installed.json"), + } + } + + pub fn discover(&self) -> Result<Vec<LoadedPlugin>, PluginError> { + let mut plugins = builtin_plugins(); + plugins.extend(bundled_plugins()); + plugins.extend(self.load_external_plugins()?); + plugins.sort_by(|left, right| left.id.cmp(&right.id)); + Ok(plugins) + } + + fn load_external_plugins(&self) -> Result<Vec<LoadedPlugin>, PluginError> { + let registry = PluginRegistry::load(&self.registry_path)?; + registry + .plugins + .into_iter() + .map(|record| { + let install_path = PathBuf::from(&record.install_path); + let (manifest, root) = load_manifest_from_source(&install_path)?; + Ok(LoadedPlugin::new( + record.id, + PluginSourceKind::External, + manifest, + Some(root), + Some(PathBuf::from(record.source_path)), + )) + }) + .collect() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginManager { + cwd: PathBuf, + config_home: PathBuf, +} + +impl PluginManager { + #[must_use] + pub fn new(cwd: impl Into<PathBuf>, config_home: impl Into<PathBuf>) -> Self { + Self { + cwd: cwd.into(), + config_home: config_home.into(), + } + } + + #[must_use] + pub fn default_for(cwd: impl Into<PathBuf>) -> Self { + let cwd = cwd.into(); + let config_home = std::env::var_os("CLAUDE_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".claude"))) + .unwrap_or_else(|| PathBuf::from(".claude")); + Self { cwd, config_home } + } + + #[must_use] + pub fn loader(&self) -> PluginLoader { + PluginLoader::new(&self.config_home) + } + + pub fn discover_plugins(&self) -> Result<Vec<LoadedPlugin>, PluginError> { + self.loader().discover() + } + + pub fn list_plugins( + &self, + runtime_config: &RuntimeConfig, + ) -> Result<Vec<PluginListEntry>, PluginError> { + self.discover_plugins().map(|plugins| { + plugins + .into_iter() + .map(|plugin| { + let enabled = is_plugin_enabled(&plugin, runtime_config); + PluginListEntry { plugin, enabled } + }) + .collect() + }) + } + + pub fn active_hook_config( + &self, + runtime_config: &RuntimeConfig, + ) -> Result<RuntimeHookConfig, PluginError> { + let mut hooks = PluginHooks::default(); + for plugin in self.list_plugins(runtime_config)? { + if plugin.enabled { + let resolved = plugin.plugin.resolved_hooks(); + hooks.pre_tool_use.extend(resolved.pre_tool_use); + hooks.post_tool_use.extend(resolved.post_tool_use); + } + } + Ok(RuntimeHookConfig::new(hooks.pre_tool_use, hooks.post_tool_use)) + } + + pub fn validate_plugin(&self, source: impl AsRef<Path>) -> Result<PluginManifest, PluginError> { + let (manifest, _) = load_manifest_from_source(source.as_ref())?; + Ok(manifest) + } + + pub fn install_plugin( + &self, + source: impl AsRef<Path>, + ) -> Result<PluginOperationResult, PluginError> { + let (manifest, root) = load_manifest_from_source(source.as_ref())?; + let plugin_id = external_plugin_id(&manifest.name); + let install_path = self.installs_root().join(sanitize_plugin_id(&plugin_id)); + let canonical_source = fs::canonicalize(root)?; + + copy_dir_recursive(&canonical_source, &install_path)?; + + let now = iso8601_now(); + let mut registry = self.load_registry()?; + let installed_at = registry + .find(&plugin_id) + .map(|record| record.installed_at.clone()) + .unwrap_or_else(|| now.clone()); + registry.upsert(InstalledPluginRecord { + id: plugin_id.clone(), + name: manifest.name.clone(), + version: manifest.version.clone(), + description: manifest.description.clone(), + source_kind: PluginSourceKind::External, + source_path: canonical_source.display().to_string(), + install_path: install_path.display().to_string(), + installed_at, + updated_at: now, + }); + self.save_registry(®istry)?; + self.write_enabled_state(&plugin_id, Some(true))?; + + Ok(PluginOperationResult { + plugin_id: plugin_id.clone(), + message: format!( + "Installed plugin {} from {}", + plugin_id, + canonical_source.display() + ), + }) + } + + pub fn enable_plugin(&self, plugin_ref: &str) -> Result<PluginOperationResult, PluginError> { + let plugin = self.resolve_plugin(plugin_ref)?; + self.write_enabled_state(plugin.id(), Some(true))?; + Ok(PluginOperationResult { + plugin_id: plugin.id().to_string(), + message: format!("Enabled plugin {}", plugin.id()), + }) + } + + pub fn disable_plugin(&self, plugin_ref: &str) -> Result<PluginOperationResult, PluginError> { + let plugin = self.resolve_plugin(plugin_ref)?; + self.write_enabled_state(plugin.id(), Some(false))?; + Ok(PluginOperationResult { + plugin_id: plugin.id().to_string(), + message: format!("Disabled plugin {}", plugin.id()), + }) + } + + pub fn uninstall_plugin( + &self, + plugin_ref: &str, + ) -> Result<PluginOperationResult, PluginError> { + let plugin = self.resolve_plugin(plugin_ref)?; + if plugin.source_kind != PluginSourceKind::External { + return Err(PluginError::new(format!( + "plugin {} is {} and cannot be uninstalled", + plugin.id(), + plugin.source_kind.suffix() + ))); + } + + let mut registry = self.load_registry()?; + let Some(record) = registry.remove(plugin.id()) else { + return Err(PluginError::new(format!( + "plugin {} is not installed", + plugin.id() + ))); + }; + self.save_registry(®istry)?; + self.write_enabled_state(plugin.id(), None)?; + + let install_path = PathBuf::from(record.install_path); + if install_path.exists() { + fs::remove_dir_all(install_path)?; + } + + Ok(PluginOperationResult { + plugin_id: plugin.id().to_string(), + message: format!("Uninstalled plugin {}", plugin.id()), + }) + } + + pub fn update_plugin(&self, plugin_ref: &str) -> Result<PluginOperationResult, PluginError> { + let plugin = self.resolve_plugin(plugin_ref)?; + match plugin.source_kind { + PluginSourceKind::Builtin | PluginSourceKind::Bundled => Ok(PluginOperationResult { + plugin_id: plugin.id().to_string(), + message: format!( + "Plugin {} is {} and already managed by the CLI", + plugin.id(), + plugin.source_kind.suffix() + ), + }), + PluginSourceKind::External => { + let registry = self.load_registry()?; + let record = registry.find(plugin.id()).ok_or_else(|| { + PluginError::new(format!("plugin {} is not installed", plugin.id())) + })?; + self.install_plugin(PathBuf::from(&record.source_path)).map(|_| PluginOperationResult { + plugin_id: plugin.id().to_string(), + message: format!("Updated plugin {}", plugin.id()), + }) + } + } + } + + fn resolve_plugin(&self, plugin_ref: &str) -> Result<LoadedPlugin, PluginError> { + let plugins = self.discover_plugins()?; + if let Some(plugin) = plugins.iter().find(|plugin| plugin.id == plugin_ref) { + return Ok(plugin.clone()); + } + let mut matches = plugins + .into_iter() + .filter(|plugin| plugin.name() == plugin_ref) + .collect::<Vec<_>>(); + match matches.len() { + 0 => Err(PluginError::new(format!("plugin {plugin_ref} was not found"))), + 1 => Ok(matches.remove(0)), + _ => Err(PluginError::new(format!( + "plugin name {plugin_ref} is ambiguous; use a full plugin id" + ))), + } + } + + fn settings_path(&self) -> PathBuf { + let _ = &self.cwd; + self.config_home.join("settings.json") + } + + fn installs_root(&self) -> PathBuf { + self.config_home.join("plugins").join("installs") + } + + fn registry_path(&self) -> PathBuf { + self.config_home.join("plugins").join("installed.json") + } + + fn load_registry(&self) -> Result<PluginRegistry, PluginError> { + PluginRegistry::load(&self.registry_path()).map_err(PluginError::from) + } + + fn save_registry(&self, registry: &PluginRegistry) -> Result<(), PluginError> { + registry.save(&self.registry_path()).map_err(PluginError::from) + } + + fn write_enabled_state( + &self, + plugin_id: &str, + enabled: Option<bool>, + ) -> Result<(), PluginError> { + let settings_path = self.settings_path(); + let mut settings = read_settings_file(&settings_path)?; + write_plugin_state(&mut settings, plugin_id, enabled); + write_settings_file(&settings_path, &settings)?; + Ok(()) + } +} + +fn builtin_plugins() -> Vec<LoadedPlugin> { + let manifest = PluginManifest { + name: "tool-guard".to_string(), + description: "Example built-in plugin with optional tool hook messages".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + default_enabled: false, + hooks: PluginHooks { + pre_tool_use: vec!["printf 'builtin tool-guard saw %s' \"$HOOK_TOOL_NAME\"".to_string()], + post_tool_use: Vec::new(), + }, + }; + vec![LoadedPlugin::new( + format!("{}@builtin", manifest.name), + PluginSourceKind::Builtin, + manifest, + None, + None, + )] +} + +fn bundled_plugins() -> Vec<LoadedPlugin> { + let manifest = PluginManifest { + name: "tool-audit".to_string(), + description: "Example bundled plugin with optional post-tool hooks".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + default_enabled: false, + hooks: PluginHooks { + pre_tool_use: Vec::new(), + post_tool_use: vec!["printf 'bundled tool-audit saw %s' \"$HOOK_TOOL_NAME\"".to_string()], + }, + }; + vec![LoadedPlugin::new( + format!("{}@bundled", manifest.name), + PluginSourceKind::Bundled, + manifest, + None, + None, + )] +} + +fn is_plugin_enabled(plugin: &LoadedPlugin, runtime_config: &RuntimeConfig) -> bool { + runtime_config.plugins().state_for(&plugin.id, plugin.manifest.default_enabled) +} + +fn external_plugin_id(name: &str) -> String { + format!("{}@external", name.trim()) +} + +fn sanitize_plugin_id(plugin_id: &str) -> String { + plugin_id + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || matches!(character, '-' | '_') { + character + } else { + '-' + } + }) + .collect() +} + +fn load_manifest_from_source(source: &Path) -> Result<(PluginManifest, PathBuf), PluginError> { + let (manifest_path, root) = resolve_manifest_path(source)?; + let contents = fs::read_to_string(&manifest_path).map_err(|error| { + PluginError::new(format!( + "failed to read plugin manifest {}: {error}", + manifest_path.display() + )) + })?; + let manifest: PluginManifest = serde_json::from_str(&contents).map_err(|error| { + PluginError::new(format!( + "failed to parse plugin manifest {}: {error}", + manifest_path.display() + )) + })?; + manifest.validate().map_err(PluginError::new)?; + Ok((manifest, root)) +} + +fn resolve_manifest_path(source: &Path) -> Result<(PathBuf, PathBuf), PluginError> { + if source.is_file() { + let file_name = source.file_name().and_then(|name| name.to_str()).unwrap_or_default(); + if file_name != "plugin.json" { + return Err(PluginError::new(format!( + "plugin manifest file must be named plugin.json: {}", + source.display() + ))); + } + let root = source + .parent() + .and_then(|parent| parent.parent().filter(|candidate| parent.file_name() == Some(std::ffi::OsStr::new(".claude-plugin")))) + .map_or_else( + || source.parent().unwrap_or_else(|| Path::new(".")).to_path_buf(), + Path::to_path_buf, + ); + return Ok((source.to_path_buf(), root)); + } + + let nested = source.join(".claude-plugin").join("plugin.json"); + if nested.exists() { + return Ok((nested, source.to_path_buf())); + } + + let direct = source.join("plugin.json"); + if direct.exists() { + return Ok((direct, source.to_path_buf())); + } + + Err(PluginError::new(format!( + "plugin manifest not found in {}", + source.display() + ))) +} + +fn copy_dir_recursive(source: &Path, destination: &Path) -> Result<(), PluginError> { + if destination.exists() { + fs::remove_dir_all(destination)?; + } + fs::create_dir_all(destination)?; + for entry in fs::read_dir(source)? { + let entry = entry?; + let path = entry.path(); + let target = destination.join(entry.file_name()); + if entry.file_type()?.is_dir() { + copy_dir_recursive(&path, &target)?; + } else { + fs::copy(&path, &target)?; + } + } + Ok(()) +} + +fn iso8601_now() -> String { + let seconds = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + format!("{seconds}") +} + +#[cfg(test)] +mod tests { + use super::{PluginLoader, PluginManager, PluginSourceKind}; + use runtime::ConfigLoader; + use std::fs; + use std::path::Path; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn temp_dir() -> std::path::PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time should be after epoch") + .as_nanos(); + std::env::temp_dir().join(format!("plugins-manager-{nanos}")) + } + + fn write_external_plugin(root: &Path, version: &str, hook_body: &str) { + fs::create_dir_all(root.join(".claude-plugin")).expect("plugin dir should exist"); + fs::write( + root.join(".claude-plugin").join("plugin.json"), + format!( + r#"{{ + "name": "sample-plugin", + "description": "sample external plugin", + "version": "{version}", + "hooks": {{ + "PreToolUse": ["printf 'pre from ${PLUGIN_DIR} {hook_body}'"] + }} +}}"# + ), + ) + .expect("plugin manifest should write"); + fs::write(root.join("README.md"), "sample").expect("payload should write"); + } + + #[test] + fn discovers_builtin_and_bundled_plugins() { + let root = temp_dir(); + let home = root.join("home").join(".claude"); + let loader = PluginLoader::new(&home); + let plugins = loader.discover().expect("plugins should load"); + assert!(plugins.iter().any(|plugin| plugin.source_kind == PluginSourceKind::Builtin)); + assert!(plugins.iter().any(|plugin| plugin.source_kind == PluginSourceKind::Bundled)); + fs::remove_dir_all(root).expect("cleanup"); + } + + #[test] + fn installs_and_lists_external_plugins() { + let root = temp_dir(); + let cwd = root.join("project"); + let home = root.join("home").join(".claude"); + let source = root.join("source-plugin"); + fs::create_dir_all(&cwd).expect("cwd should exist"); + write_external_plugin(&source, "1.0.0", "v1"); + + let manager = PluginManager::new(&cwd, &home); + let result = manager.install_plugin(&source).expect("install should succeed"); + assert_eq!(result.plugin_id, "sample-plugin@external"); + + let runtime_config = ConfigLoader::new(&cwd, &home) + .load() + .expect("config should load"); + let plugins = manager + .list_plugins(&runtime_config) + .expect("plugins should list"); + let external = plugins + .iter() + .find(|plugin| plugin.plugin.id == "sample-plugin@external") + .expect("external plugin should exist"); + assert!(external.enabled); + + let hook_config = manager + .active_hook_config(&runtime_config) + .expect("hook config should build"); + assert_eq!(hook_config.pre_tool_use().len(), 1); + assert!(hook_config.pre_tool_use()[0].contains("sample-plugin-external")); + + fs::remove_dir_all(root).expect("cleanup"); + } + + #[test] + fn disables_enables_updates_and_uninstalls_external_plugins() { + let root = temp_dir(); + let cwd = root.join("project"); + let home = root.join("home").join(".claude"); + let source = root.join("source-plugin"); + fs::create_dir_all(&cwd).expect("cwd should exist"); + write_external_plugin(&source, "1.0.0", "v1"); + + let manager = PluginManager::new(&cwd, &home); + manager.install_plugin(&source).expect("install should succeed"); + manager + .disable_plugin("sample-plugin") + .expect("disable should succeed"); + let runtime_config = ConfigLoader::new(&cwd, &home) + .load() + .expect("config should load"); + let plugins = manager + .list_plugins(&runtime_config) + .expect("plugins should list"); + assert!(!plugins + .iter() + .find(|plugin| plugin.plugin.id == "sample-plugin@external") + .expect("external plugin should exist") + .enabled); + + manager + .enable_plugin("sample-plugin@external") + .expect("enable should succeed"); + write_external_plugin(&source, "2.0.0", "v2"); + manager + .update_plugin("sample-plugin@external") + .expect("update should succeed"); + + let loader = PluginLoader::new(&home); + let plugins = loader.discover().expect("plugins should load"); + let external = plugins + .iter() + .find(|plugin| plugin.id == "sample-plugin@external") + .expect("external plugin should exist"); + assert_eq!(external.manifest.version, "2.0.0"); + + manager + .uninstall_plugin("sample-plugin@external") + .expect("uninstall should succeed"); + let plugins = loader.discover().expect("plugins should reload"); + assert!(!plugins + .iter() + .any(|plugin| plugin.id == "sample-plugin@external")); + + fs::remove_dir_all(root).expect("cleanup"); + } +} diff --git a/rust/crates/plugins/src/manifest.rs b/rust/crates/plugins/src/manifest.rs new file mode 100644 index 0000000..449b0be --- /dev/null +++ b/rust/crates/plugins/src/manifest.rs @@ -0,0 +1,175 @@ +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::PluginSourceKind; + +pub trait Plugin { + fn id(&self) -> &str; + fn manifest(&self) -> &PluginManifest; + fn source_kind(&self) -> PluginSourceKind; + fn root(&self) -> Option<&Path>; + + fn resolved_hooks(&self) -> PluginHooks { + self.manifest().hooks.resolve(self.root()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct PluginHooks { + #[serde(rename = "PreToolUse", alias = "preToolUse", default)] + pub pre_tool_use: Vec<String>, + #[serde(rename = "PostToolUse", alias = "postToolUse", default)] + pub post_tool_use: Vec<String>, +} + +impl PluginHooks { + #[must_use] + pub fn resolve(&self, root: Option<&Path>) -> Self { + let Some(root) = root else { + return self.clone(); + }; + let replacement = root.display().to_string(); + Self { + pre_tool_use: self + .pre_tool_use + .iter() + .map(|value| value.replace("${PLUGIN_DIR}", &replacement)) + .collect(), + post_tool_use: self + .post_tool_use + .iter() + .map(|value| value.replace("${PLUGIN_DIR}", &replacement)) + .collect(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginManifest { + pub name: String, + pub description: String, + #[serde(default = "default_version")] + pub version: String, + #[serde(default)] + pub default_enabled: bool, + #[serde(default)] + pub hooks: PluginHooks, +} + +impl PluginManifest { + pub fn validate(&self) -> Result<(), String> { + if self.name.trim().is_empty() { + return Err("plugin manifest name must not be empty".to_string()); + } + if self.description.trim().is_empty() { + return Err(format!( + "plugin manifest description must not be empty for {}", + self.name + )); + } + if self.version.trim().is_empty() { + return Err(format!( + "plugin manifest version must not be empty for {}", + self.name + )); + } + if self + .hooks + .pre_tool_use + .iter() + .chain(self.hooks.post_tool_use.iter()) + .any(|hook| hook.trim().is_empty()) + { + return Err(format!( + "plugin manifest hook entries must not be empty for {}", + self.name + )); + } + Ok(()) + } +} + +fn default_version() -> String { + "0.1.0".to_string() +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LoadedPlugin { + pub id: String, + pub source_kind: PluginSourceKind, + pub manifest: PluginManifest, + pub root: Option<PathBuf>, + pub origin: Option<PathBuf>, +} + +impl LoadedPlugin { + #[must_use] + pub fn new( + id: String, + source_kind: PluginSourceKind, + manifest: PluginManifest, + root: Option<PathBuf>, + origin: Option<PathBuf>, + ) -> Self { + Self { + id, + source_kind, + manifest, + root, + origin, + } + } + + #[must_use] + pub fn name(&self) -> &str { + &self.manifest.name + } +} + +impl Plugin for LoadedPlugin { + fn id(&self) -> &str { + &self.id + } + + fn manifest(&self) -> &PluginManifest { + &self.manifest + } + + fn source_kind(&self) -> PluginSourceKind { + self.source_kind + } + + fn root(&self) -> Option<&Path> { + self.root.as_deref() + } +} + +#[cfg(test)] +mod tests { + use super::{PluginHooks, PluginManifest}; + use std::path::Path; + + #[test] + fn validates_manifest_fields() { + let manifest = PluginManifest { + name: "demo".to_string(), + description: "demo plugin".to_string(), + version: "1.2.3".to_string(), + default_enabled: false, + hooks: PluginHooks::default(), + }; + assert!(manifest.validate().is_ok()); + } + + #[test] + fn resolves_plugin_dir_placeholders() { + let hooks = PluginHooks { + pre_tool_use: vec!["echo ${PLUGIN_DIR}/pre".to_string()], + post_tool_use: vec!["echo ${PLUGIN_DIR}/post".to_string()], + }; + let resolved = hooks.resolve(Some(Path::new("/tmp/plugin"))); + assert_eq!(resolved.pre_tool_use, vec!["echo /tmp/plugin/pre"]); + assert_eq!(resolved.post_tool_use, vec!["echo /tmp/plugin/post"]); + } +} diff --git a/rust/crates/plugins/src/registry.rs b/rust/crates/plugins/src/registry.rs new file mode 100644 index 0000000..a2f021d --- /dev/null +++ b/rust/crates/plugins/src/registry.rs @@ -0,0 +1,91 @@ +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +use crate::InstalledPluginRecord; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct PluginRegistry { + #[serde(default)] + pub plugins: Vec<InstalledPluginRecord>, +} + +impl PluginRegistry { + pub fn load(path: &Path) -> Result<Self, String> { + match std::fs::read_to_string(path) { + Ok(contents) => { + if contents.trim().is_empty() { + return Ok(Self::default()); + } + serde_json::from_str(&contents).map_err(|error| error.to_string()) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()), + Err(error) => Err(error.to_string()), + } + } + + pub fn save(&self, path: &Path) -> Result<(), String> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; + } + std::fs::write( + path, + serde_json::to_string_pretty(self).map_err(|error| error.to_string())?, + ) + .map_err(|error| error.to_string()) + } + + #[must_use] + pub fn find(&self, plugin_id: &str) -> Option<&InstalledPluginRecord> { + self.plugins.iter().find(|plugin| plugin.id == plugin_id) + } + + pub fn upsert(&mut self, record: InstalledPluginRecord) { + if let Some(existing) = self.plugins.iter_mut().find(|plugin| plugin.id == record.id) { + *existing = record; + } else { + self.plugins.push(record); + } + self.plugins.sort_by(|left, right| left.id.cmp(&right.id)); + } + + pub fn remove(&mut self, plugin_id: &str) -> Option<InstalledPluginRecord> { + let index = self.plugins.iter().position(|plugin| plugin.id == plugin_id)?; + Some(self.plugins.remove(index)) + } +} + +#[cfg(test)] +mod tests { + use super::PluginRegistry; + use crate::{InstalledPluginRecord, PluginSourceKind}; + + #[test] + fn upsert_replaces_existing_entries() { + let mut registry = PluginRegistry::default(); + registry.upsert(InstalledPluginRecord { + id: "demo@external".to_string(), + name: "demo".to_string(), + version: "1.0.0".to_string(), + description: "demo".to_string(), + source_kind: PluginSourceKind::External, + source_path: "/src".to_string(), + install_path: "/install".to_string(), + installed_at: "t1".to_string(), + updated_at: "t1".to_string(), + }); + registry.upsert(InstalledPluginRecord { + id: "demo@external".to_string(), + name: "demo".to_string(), + version: "1.0.1".to_string(), + description: "updated".to_string(), + source_kind: PluginSourceKind::External, + source_path: "/src".to_string(), + install_path: "/install".to_string(), + installed_at: "t1".to_string(), + updated_at: "t2".to_string(), + }); + assert_eq!(registry.plugins.len(), 1); + assert_eq!(registry.plugins[0].version, "1.0.1"); + } +} diff --git a/rust/crates/plugins/src/settings.rs b/rust/crates/plugins/src/settings.rs new file mode 100644 index 0000000..5ce0367 --- /dev/null +++ b/rust/crates/plugins/src/settings.rs @@ -0,0 +1,106 @@ +use std::path::Path; + +use runtime::RuntimePluginConfig; +use serde_json::{Map, Value}; + +pub fn read_settings_file(path: &Path) -> Result<Map<String, Value>, String> { + match std::fs::read_to_string(path) { + Ok(contents) => { + if contents.trim().is_empty() { + return Ok(Map::new()); + } + serde_json::from_str::<Value>(&contents) + .map_err(|error| error.to_string())? + .as_object() + .cloned() + .ok_or_else(|| "settings file must contain a JSON object".to_string()) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Map::new()), + Err(error) => Err(error.to_string()), + } +} + +pub fn write_settings_file(path: &Path, root: &Map<String, Value>) -> Result<(), String> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; + } + std::fs::write( + path, + serde_json::to_string_pretty(root).map_err(|error| error.to_string())?, + ) + .map_err(|error| error.to_string()) +} + +pub fn read_enabled_plugin_map(root: &Map<String, Value>) -> Map<String, Value> { + root.get("enabledPlugins") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default() +} + +pub fn write_plugin_state( + root: &mut Map<String, Value>, + plugin_id: &str, + enabled: Option<bool>, +) { + let mut enabled_plugins = read_enabled_plugin_map(root); + match enabled { + Some(value) => { + enabled_plugins.insert(plugin_id.to_string(), Value::Bool(value)); + } + None => { + enabled_plugins.remove(plugin_id); + } + } + if enabled_plugins.is_empty() { + root.remove("enabledPlugins"); + } else { + root.insert("enabledPlugins".to_string(), Value::Object(enabled_plugins)); + } +} + +pub fn config_from_settings(root: &Map<String, Value>) -> RuntimePluginConfig { + let mut config = RuntimePluginConfig::default(); + if let Some(enabled_plugins) = root.get("enabledPlugins").and_then(Value::as_object) { + for (plugin_id, enabled) in enabled_plugins { + match enabled.as_bool() { + Some(value) => config.set_plugin_state(plugin_id.clone(), value), + None => {} + } + } + } + config +} + +#[cfg(test)] +mod tests { + use super::{config_from_settings, write_plugin_state}; + use serde_json::{json, Map, Value}; + + #[test] + fn writes_and_removes_enabled_plugin_state() { + let mut root = Map::new(); + write_plugin_state(&mut root, "demo@external", Some(true)); + assert_eq!( + root.get("enabledPlugins"), + Some(&json!({"demo@external": true})) + ); + write_plugin_state(&mut root, "demo@external", None); + assert_eq!(root.get("enabledPlugins"), None); + } + + #[test] + fn converts_settings_to_runtime_plugin_config() { + let mut root = Map::<String, Value>::new(); + root.insert( + "enabledPlugins".to_string(), + json!({"demo@external": true, "off@bundled": false}), + ); + let config = config_from_settings(&root); + assert_eq!( + config.enabled_plugins().get("demo@external"), + Some(&true) + ); + assert_eq!(config.enabled_plugins().get("off@bundled"), Some(&false)); + } +} diff --git a/rust/crates/runtime/src/config.rs b/rust/crates/runtime/src/config.rs index 368e7c5..dfc4d1a 100644 --- a/rust/crates/runtime/src/config.rs +++ b/rust/crates/runtime/src/config.rs @@ -35,9 +35,19 @@ pub struct RuntimeConfig { feature_config: RuntimeFeatureConfig, } +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct RuntimePluginConfig { + enabled_plugins: BTreeMap<String, bool>, + external_directories: Vec<String>, + install_root: Option<String>, + registry_path: Option<String>, + bundled_root: Option<String>, +} + #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct RuntimeFeatureConfig { hooks: RuntimeHookConfig, + plugins: RuntimePluginConfig, mcp: McpConfigCollection, oauth: Option<OAuthConfig>, model: Option<String>, @@ -174,13 +184,15 @@ impl ConfigLoader { #[must_use] pub fn default_for(cwd: impl Into<PathBuf>) -> Self { let cwd = cwd.into(); - let config_home = std::env::var_os("CLAUDE_CONFIG_HOME") - .map(PathBuf::from) - .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".claude"))) - .unwrap_or_else(|| PathBuf::from(".claude")); + let config_home = default_config_home(); Self { cwd, config_home } } + #[must_use] + pub fn config_home(&self) -> &Path { + &self.config_home + } + #[must_use] pub fn discover(&self) -> Vec<ConfigEntry> { let user_legacy_path = self.config_home.parent().map_or_else( @@ -229,6 +241,7 @@ 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, }, @@ -291,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() @@ -319,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 @@ -350,6 +379,53 @@ impl RuntimeFeatureConfig { } } +impl RuntimePluginConfig { + #[must_use] + pub fn enabled_plugins(&self) -> &BTreeMap<String, bool> { + &self.enabled_plugins + } + + #[must_use] + pub fn external_directories(&self) -> &[String] { + &self.external_directories + } + + #[must_use] + pub fn install_root(&self) -> Option<&str> { + self.install_root.as_deref() + } + + #[must_use] + pub fn registry_path(&self) -> Option<&str> { + self.registry_path.as_deref() + } + + #[must_use] + pub fn bundled_root(&self) -> Option<&str> { + self.bundled_root.as_deref() + } + + pub fn set_plugin_state(&mut self, plugin_id: String, enabled: bool) { + self.enabled_plugins.insert(plugin_id, enabled); + } + + #[must_use] + pub fn state_for(&self, plugin_id: &str, default_enabled: bool) -> bool { + self.enabled_plugins + .get(plugin_id) + .copied() + .unwrap_or(default_enabled) + } +} + +#[must_use] +pub fn default_config_home() -> PathBuf { + std::env::var_os("CLAUDE_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".claude"))) + .unwrap_or_else(|| PathBuf::from(".claude")) +} + impl RuntimeHookConfig { #[must_use] pub fn new(pre_tool_use: Vec<String>, post_tool_use: Vec<String>) -> Self { @@ -368,6 +444,18 @@ impl RuntimeHookConfig { pub fn post_tool_use(&self) -> &[String] { &self.post_tool_use } + + #[must_use] + pub fn merged(&self, other: &Self) -> Self { + let mut merged = self.clone(); + merged.extend(other); + merged + } + + pub fn extend(&mut self, other: &Self) { + extend_unique(&mut self.pre_tool_use, other.pre_tool_use()); + extend_unique(&mut self.post_tool_use, other.post_tool_use()); + } } impl McpConfigCollection { @@ -484,6 +572,36 @@ fn parse_optional_hooks_config(root: &JsonValue) -> Result<RuntimeHookConfig, Co }) } +fn parse_optional_plugin_config(root: &JsonValue) -> Result<RuntimePluginConfig, ConfigError> { + let Some(object) = root.as_object() else { + return Ok(RuntimePluginConfig::default()); + }; + + let mut config = RuntimePluginConfig::default(); + if let Some(enabled_plugins) = object.get("enabledPlugins") { + config.enabled_plugins = parse_bool_map(enabled_plugins, "merged settings.enabledPlugins")?; + } + + let Some(plugins_value) = object.get("plugins") else { + return Ok(config); + }; + let plugins = expect_object(plugins_value, "merged settings.plugins")?; + + if let Some(enabled_value) = plugins.get("enabled") { + config.enabled_plugins = parse_bool_map(enabled_value, "merged settings.plugins.enabled")?; + } + config.external_directories = + optional_string_array(plugins, "externalDirectories", "merged settings.plugins")? + .unwrap_or_default(); + config.install_root = + optional_string(plugins, "installRoot", "merged settings.plugins")?.map(str::to_string); + config.registry_path = + optional_string(plugins, "registryPath", "merged settings.plugins")?.map(str::to_string); + config.bundled_root = + optional_string(plugins, "bundledRoot", "merged settings.plugins")?.map(str::to_string); + Ok(config) +} + fn parse_optional_permission_mode( root: &JsonValue, ) -> Result<Option<ResolvedPermissionMode>, ConfigError> { @@ -716,6 +834,24 @@ fn optional_u16( } } +fn parse_bool_map(value: &JsonValue, context: &str) -> Result<BTreeMap<String, bool>, ConfigError> { + let Some(map) = value.as_object() else { + return Err(ConfigError::Parse(format!( + "{context}: expected JSON object" + ))); + }; + map.iter() + .map(|(key, value)| { + value + .as_bool() + .map(|enabled| (key.clone(), enabled)) + .ok_or_else(|| { + ConfigError::Parse(format!("{context}: field {key} must be a boolean")) + }) + }) + .collect() +} + fn optional_string_array( object: &BTreeMap<String, JsonValue>, key: &str, @@ -790,6 +926,18 @@ fn deep_merge_objects( } } +fn extend_unique(target: &mut Vec<String>, values: &[String]) { + for value in values { + push_unique(target, value.clone()); + } +} + +fn push_unique(target: &mut Vec<String>, value: String) { + if !target.iter().any(|existing| existing == &value) { + target.push(value); + } +} + #[cfg(test)] mod tests { use super::{ @@ -1033,6 +1181,96 @@ mod tests { fs::remove_dir_all(root).expect("cleanup temp dir"); } + #[test] + fn parses_plugin_config_from_enabled_plugins() { + let root = temp_dir(); + let cwd = root.join("project"); + let home = root.join("home").join(".claude"); + fs::create_dir_all(cwd.join(".claude")).expect("project config dir"); + fs::create_dir_all(&home).expect("home config dir"); + + fs::write( + home.join("settings.json"), + r#"{ + "enabledPlugins": { + "tool-guard@builtin": true, + "sample-plugin@external": false + } + }"#, + ) + .expect("write user settings"); + + let loaded = ConfigLoader::new(&cwd, &home) + .load() + .expect("config should load"); + + assert_eq!( + loaded.plugins().enabled_plugins().get("tool-guard@builtin"), + Some(&true) + ); + assert_eq!( + loaded + .plugins() + .enabled_plugins() + .get("sample-plugin@external"), + Some(&false) + ); + + fs::remove_dir_all(root).expect("cleanup temp dir"); + } + + #[test] + fn parses_plugin_config() { + let root = temp_dir(); + let cwd = root.join("project"); + let home = root.join("home").join(".claude"); + fs::create_dir_all(cwd.join(".claude")).expect("project config dir"); + fs::create_dir_all(&home).expect("home config dir"); + + fs::write( + home.join("settings.json"), + r#"{ + "enabledPlugins": { + "core-helpers@builtin": true + }, + "plugins": { + "externalDirectories": ["./external-plugins"], + "installRoot": "plugin-cache/installed", + "registryPath": "plugin-cache/installed.json", + "bundledRoot": "./bundled-plugins" + } + }"#, + ) + .expect("write plugin settings"); + + let loaded = ConfigLoader::new(&cwd, &home) + .load() + .expect("config should load"); + + assert_eq!( + loaded + .plugins() + .enabled_plugins() + .get("core-helpers@builtin"), + Some(&true) + ); + assert_eq!( + loaded.plugins().external_directories(), + &["./external-plugins".to_string()] + ); + assert_eq!( + loaded.plugins().install_root(), + Some("plugin-cache/installed") + ); + assert_eq!( + loaded.plugins().registry_path(), + Some("plugin-cache/installed.json") + ); + assert_eq!(loaded.plugins().bundled_root(), Some("./bundled-plugins")); + + fs::remove_dir_all(root).expect("cleanup temp dir"); + } + #[test] fn rejects_invalid_mcp_server_shapes() { let root = temp_dir(); diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index 618923f..edac666 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -28,7 +28,7 @@ pub use config::{ McpConfigCollection, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig, McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig, ResolvedPermissionMode, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookConfig, - ScopedMcpServerConfig, CLAUDE_CODE_SETTINGS_SCHEMA_NAME, + RuntimePluginConfig, ScopedMcpServerConfig, CLAUDE_CODE_SETTINGS_SCHEMA_NAME, }; pub use conversation::{ auto_compaction_threshold_from_env, ApiClient, ApiRequest, AssistantEvent, AutoCompactionEvent, diff --git a/rust/crates/runtime/src/prompt.rs b/rust/crates/runtime/src/prompt.rs index 6cfda44..91a3afc 100644 --- a/rust/crates/runtime/src/prompt.rs +++ b/rust/crates/runtime/src/prompt.rs @@ -421,7 +421,7 @@ fn render_config_section(config: &RuntimeConfig) -> String { let mut lines = vec!["# Runtime config".to_string()]; if config.loaded_entries().is_empty() { lines.extend(prepend_bullets(vec![ - "No Claw Code settings files loaded.".to_string(), + "No Claw Code settings files loaded.".to_string() ])); return lines.join("\n"); } diff --git a/rust/crates/rusty-claude-cli/Cargo.toml b/rust/crates/rusty-claude-cli/Cargo.toml index 2ac6701..242ec0f 100644 --- a/rust/crates/rusty-claude-cli/Cargo.toml +++ b/rust/crates/rusty-claude-cli/Cargo.toml @@ -17,6 +17,7 @@ crossterm = "0.28" pulldown-cmark = "0.13" rustyline = "15" runtime = { path = "../runtime" } +plugins = { path = "../plugins" } serde_json = "1" syntect = "5" tokio = { version = "1", features = ["rt-multi-thread", "time"] } diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 7cb70de..313706f 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -2,7 +2,7 @@ mod init; mod input; mod render; -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeSet; use std::env; use std::fs; use std::io::{self, Read, Write}; @@ -22,6 +22,7 @@ use commands::{ }; use compat_harness::{extract_manifest, UpstreamPaths}; use init::initialize_repo; +use plugins::{PluginListEntry, PluginManager}; use render::{MarkdownStreamState, Spinner, TerminalRenderer}; use runtime::{ clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt, @@ -928,6 +929,7 @@ fn run_resume_command( | SlashCommand::Model { .. } | SlashCommand::Permissions { .. } | SlashCommand::Session { .. } + | SlashCommand::Plugins { .. } | SlashCommand::Unknown(_) => Err("unsupported resumed slash command".into()), } } @@ -1217,6 +1219,9 @@ impl LiveCli { SlashCommand::Session { action, target } => { self.handle_session_command(action.as_deref(), target.as_deref())? } + SlashCommand::Plugins { action, target } => { + self.handle_plugins_command(action.as_deref(), target.as_deref())? + } SlashCommand::Unknown(name) => { eprintln!("unknown slash command: /{name}"); false @@ -1479,6 +1484,87 @@ impl LiveCli { } } + fn handle_plugins_command( + &mut self, + action: Option<&str>, + target: Option<&str>, + ) -> Result<bool, Box<dyn std::error::Error>> { + let cwd = env::current_dir()?; + let runtime_config = ConfigLoader::default_for(&cwd).load()?; + let manager = PluginManager::default_for(&cwd); + + match action { + None | Some("list") => { + let plugins = manager.list_plugins(&runtime_config)?; + println!("{}", render_plugins_report(&plugins)); + } + Some("install") => { + let Some(target) = target else { + println!("Usage: /plugins install <path>"); + return Ok(false); + }; + let result = manager.install_plugin(PathBuf::from(target))?; + println!("Plugins\n Result {}", result.message); + self.reload_runtime_features()?; + } + Some("enable") => { + let Some(target) = target else { + println!("Usage: /plugins enable <plugin-id>"); + return Ok(false); + }; + let result = manager.enable_plugin(target)?; + println!("Plugins\n Result {}", result.message); + self.reload_runtime_features()?; + } + Some("disable") => { + let Some(target) = target else { + println!("Usage: /plugins disable <plugin-id>"); + return Ok(false); + }; + let result = manager.disable_plugin(target)?; + println!("Plugins\n Result {}", result.message); + self.reload_runtime_features()?; + } + Some("uninstall") => { + let Some(target) = target else { + println!("Usage: /plugins uninstall <plugin-id>"); + return Ok(false); + }; + let result = manager.uninstall_plugin(target)?; + println!("Plugins\n Result {}", result.message); + self.reload_runtime_features()?; + } + Some("update") => { + let Some(target) = target else { + println!("Usage: /plugins update <plugin-id>"); + return Ok(false); + }; + let result = manager.update_plugin(target)?; + println!("Plugins\n Result {}", result.message); + self.reload_runtime_features()?; + } + Some(other) => { + println!( + "Unknown /plugins action '{other}'. Use list, install, enable, disable, uninstall, or update." + ); + } + } + Ok(false) + } + + fn reload_runtime_features(&mut self) -> Result<(), Box<dyn std::error::Error>> { + self.runtime = build_runtime( + self.runtime.session().clone(), + self.model.clone(), + self.system_prompt.clone(), + true, + true, + self.allowed_tools.clone(), + self.permission_mode, + )?; + self.persist_session() + } + fn compact(&mut self) -> Result<(), Box<dyn std::error::Error>> { let result = self.runtime.compact(CompactionConfig::default()); let removed = result.removed_message_count; @@ -1536,6 +1622,7 @@ impl LiveCli { Ok(()) } + #[allow(clippy::unused_self)] fn run_teleport(&self, target: Option<&str>) -> Result<(), Box<dyn std::error::Error>> { let Some(target) = target.map(str::trim).filter(|value| !value.is_empty()) else { println!("Usage: /teleport <symbol-or-path>"); @@ -1771,6 +1858,34 @@ fn render_repl_help() -> String { ) } +fn render_plugins_report(plugins: &[PluginListEntry]) -> String { + let mut lines = vec!["Plugins".to_string()]; + if plugins.is_empty() { + lines.push(" No plugins discovered.".to_string()); + return lines.join("\n"); + } + for plugin in plugins { + let kind = format!("{:?}", plugin.plugin.source_kind).to_lowercase(); + let location = plugin + .plugin + .root + .as_ref() + .map_or_else(|| kind.clone(), |root| root.display().to_string()); + let enabled = if plugin.enabled { + "enabled" + } else { + "disabled" + }; + lines.push(format!( + " {id:<24} {kind:<8} {enabled:<8} v{version:<8} {location}", + id = plugin.plugin.id, + kind = kind, + version = plugin.plugin.manifest.version, + )); + } + lines.join("\n") +} + fn status_context( session_path: Option<&Path>, ) -> Result<StatusContext, Box<dyn std::error::Error>> { @@ -1894,9 +2009,12 @@ fn render_config_report(section: Option<&str>) -> Result<String, Box<dyn std::er "env" => runtime_config.get("env"), "hooks" => runtime_config.get("hooks"), "model" => runtime_config.get("model"), + "plugins" => runtime_config + .get("plugins") + .or_else(|| runtime_config.get("enabledPlugins")), other => { lines.push(format!( - " Unsupported config section '{other}'. Use env, hooks, or model." + " Unsupported config section '{other}'. Use env, hooks, model, or plugins." )); return Ok(lines.join( " @@ -2309,12 +2427,17 @@ fn build_system_prompt() -> Result<Vec<String>, Box<dyn std::error::Error>> { fn build_runtime_feature_config( ) -> Result<runtime::RuntimeFeatureConfig, Box<dyn std::error::Error>> { let cwd = env::current_dir()?; - Ok(ConfigLoader::default_for(cwd) - .load()? + let loader = ConfigLoader::default_for(&cwd); + let runtime_config = loader.load()?; + let plugin_manager = PluginManager::default_for(&cwd); + let plugin_hooks = plugin_manager.active_hook_config(&runtime_config)?; + Ok(runtime_config .feature_config() - .clone()) + .clone() + .with_hooks(runtime_config.hooks().merged(&plugin_hooks))) } +#[allow(clippy::needless_pass_by_value)] fn build_runtime( session: Session, model: String, @@ -3449,7 +3572,7 @@ mod tests { .into_iter() .map(str::to_string) .collect(); - let filtered = filter_tool_specs(Some(&allowed)); + let filtered = filter_tool_specs(&GlobalToolRegistry::builtin(), Some(&allowed)); let names = filtered .into_iter() .map(|spec| spec.name) @@ -3475,13 +3598,16 @@ mod tests { assert!(help.contains("/clear [--confirm]")); assert!(help.contains("/cost")); assert!(help.contains("/resume <session-path>")); - assert!(help.contains("/config [env|hooks|model]")); + assert!(help.contains("/config [env|hooks|model|plugins]")); assert!(help.contains("/memory")); assert!(help.contains("/init")); assert!(help.contains("/diff")); assert!(help.contains("/version")); assert!(help.contains("/export [file]")); assert!(help.contains("/session [list|switch <session-id>]")); + assert!(help.contains( + "/plugins [list|install <source>|enable <id>|disable <id>|uninstall <id>|update <id>]" + )); assert!(help.contains("/exit")); } @@ -3632,6 +3758,9 @@ mod tests { fn config_report_supports_section_views() { let report = render_config_report(Some("env")).expect("config report should render"); assert!(report.contains("Merged section: env")); + let plugins_report = + render_config_report(Some("plugins")).expect("plugins config report should render"); + assert!(plugins_report.contains("Merged section: plugins")); } #[test] From 7b17b037cbdbf1176b8a353e5ac6bbc9b80c3e11 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo <hurrc04@gmail.com> Date: Wed, 1 Apr 2026 04:40:19 +0000 Subject: [PATCH 05/25] wip: plugins progress --- rust/crates/commands/src/lib.rs | 5 +- rust/crates/plugins/src/lib.rs | 12 +- rust/crates/plugins/src/manager.rs | 642 --------------------- rust/crates/plugins/src/manifest.rs | 175 ------ rust/crates/plugins/src/registry.rs | 91 --- rust/crates/plugins/src/settings.rs | 106 ---- rust/crates/runtime/src/conversation.rs | 3 +- rust/crates/runtime/src/hooks.rs | 1 + rust/crates/rusty-claude-cli/src/main.rs | 148 +++-- rust/crates/rusty-claude-cli/src/render.rs | 3 +- 10 files changed, 125 insertions(+), 1061 deletions(-) delete mode 100644 rust/crates/plugins/src/manager.rs delete mode 100644 rust/crates/plugins/src/manifest.rs delete mode 100644 rust/crates/plugins/src/registry.rs delete mode 100644 rust/crates/plugins/src/settings.rs diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index d761645..be84455 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -297,7 +297,10 @@ impl SlashCommand { }, "plugins" => Self::Plugins { action: parts.next().map(ToOwned::to_owned), - target: parts.next().map(ToOwned::to_owned), + target: { + let remainder = parts.collect::<Vec<_>>().join(" "); + (!remainder.is_empty()).then_some(remainder) + }, }, other => Self::Unknown(other.to_string()), }) diff --git a/rust/crates/plugins/src/lib.rs b/rust/crates/plugins/src/lib.rs index 319ebe9..a2631ff 100644 --- a/rust/crates/plugins/src/lib.rs +++ b/rust/crates/plugins/src/lib.rs @@ -732,7 +732,9 @@ fn parse_install_source(source: &str) -> Result<PluginInstallSource, PluginError if source.starts_with("http://") || source.starts_with("https://") || source.starts_with("git@") - || source.ends_with(".git") + || Path::new(source) + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("git")) { Ok(PluginInstallSource::GitUrl { url: source.to_string(), @@ -963,8 +965,8 @@ mod tests { .iter() .any(|plugin| plugin.metadata.id == "demo@external")); - fs::remove_dir_all(config_home).expect("cleanup home"); - fs::remove_dir_all(source_root).expect("cleanup source"); + let _ = fs::remove_dir_all(config_home); + let _ = fs::remove_dir_all(source_root); } #[test] @@ -977,7 +979,7 @@ mod tests { .validate_plugin_source(source_root.to_str().expect("utf8 path")) .expect("manifest should validate"); assert_eq!(manifest.name, "validator"); - fs::remove_dir_all(config_home).expect("cleanup home"); - fs::remove_dir_all(source_root).expect("cleanup source"); + let _ = fs::remove_dir_all(config_home); + let _ = fs::remove_dir_all(source_root); } } diff --git a/rust/crates/plugins/src/manager.rs b/rust/crates/plugins/src/manager.rs deleted file mode 100644 index 85a7cd0..0000000 --- a/rust/crates/plugins/src/manager.rs +++ /dev/null @@ -1,642 +0,0 @@ -use std::fmt::{Display, Formatter}; -use std::fs; -use std::path::{Path, PathBuf}; -use std::time::{SystemTime, UNIX_EPOCH}; - -use runtime::{RuntimeConfig, RuntimeHookConfig}; -use serde::{Deserialize, Serialize}; - -use crate::manifest::{LoadedPlugin, Plugin, PluginHooks, PluginManifest}; -use crate::registry::PluginRegistry; -use crate::settings::{read_settings_file, write_plugin_state, write_settings_file}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum PluginSourceKind { - Builtin, - Bundled, - External, -} - -impl PluginSourceKind { - fn suffix(self) -> &'static str { - match self { - Self::Builtin => "builtin", - Self::Bundled => "bundled", - Self::External => "external", - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct InstalledPluginRecord { - pub id: String, - pub name: String, - pub version: String, - pub description: String, - pub source_kind: PluginSourceKind, - pub source_path: String, - pub install_path: String, - pub installed_at: String, - pub updated_at: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PluginListEntry { - pub plugin: LoadedPlugin, - pub enabled: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PluginOperationResult { - pub plugin_id: String, - pub message: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PluginError { - message: String, -} - -impl PluginError { - #[must_use] - pub fn new(message: impl Into<String>) -> Self { - Self { - message: message.into(), - } - } -} - -impl Display for PluginError { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.message) - } -} - -impl std::error::Error for PluginError {} - -impl From<String> for PluginError { - fn from(value: String) -> Self { - Self::new(value) - } -} - -impl From<std::io::Error> for PluginError { - fn from(value: std::io::Error) -> Self { - Self::new(value.to_string()) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PluginLoader { - registry_path: PathBuf, -} - -impl PluginLoader { - #[must_use] - pub fn new(config_home: impl Into<PathBuf>) -> Self { - let config_home = config_home.into(); - Self { - registry_path: config_home.join("plugins").join("installed.json"), - } - } - - pub fn discover(&self) -> Result<Vec<LoadedPlugin>, PluginError> { - let mut plugins = builtin_plugins(); - plugins.extend(bundled_plugins()); - plugins.extend(self.load_external_plugins()?); - plugins.sort_by(|left, right| left.id.cmp(&right.id)); - Ok(plugins) - } - - fn load_external_plugins(&self) -> Result<Vec<LoadedPlugin>, PluginError> { - let registry = PluginRegistry::load(&self.registry_path)?; - registry - .plugins - .into_iter() - .map(|record| { - let install_path = PathBuf::from(&record.install_path); - let (manifest, root) = load_manifest_from_source(&install_path)?; - Ok(LoadedPlugin::new( - record.id, - PluginSourceKind::External, - manifest, - Some(root), - Some(PathBuf::from(record.source_path)), - )) - }) - .collect() - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PluginManager { - cwd: PathBuf, - config_home: PathBuf, -} - -impl PluginManager { - #[must_use] - pub fn new(cwd: impl Into<PathBuf>, config_home: impl Into<PathBuf>) -> Self { - Self { - cwd: cwd.into(), - config_home: config_home.into(), - } - } - - #[must_use] - pub fn default_for(cwd: impl Into<PathBuf>) -> Self { - let cwd = cwd.into(); - let config_home = std::env::var_os("CLAUDE_CONFIG_HOME") - .map(PathBuf::from) - .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".claude"))) - .unwrap_or_else(|| PathBuf::from(".claude")); - Self { cwd, config_home } - } - - #[must_use] - pub fn loader(&self) -> PluginLoader { - PluginLoader::new(&self.config_home) - } - - pub fn discover_plugins(&self) -> Result<Vec<LoadedPlugin>, PluginError> { - self.loader().discover() - } - - pub fn list_plugins( - &self, - runtime_config: &RuntimeConfig, - ) -> Result<Vec<PluginListEntry>, PluginError> { - self.discover_plugins().map(|plugins| { - plugins - .into_iter() - .map(|plugin| { - let enabled = is_plugin_enabled(&plugin, runtime_config); - PluginListEntry { plugin, enabled } - }) - .collect() - }) - } - - pub fn active_hook_config( - &self, - runtime_config: &RuntimeConfig, - ) -> Result<RuntimeHookConfig, PluginError> { - let mut hooks = PluginHooks::default(); - for plugin in self.list_plugins(runtime_config)? { - if plugin.enabled { - let resolved = plugin.plugin.resolved_hooks(); - hooks.pre_tool_use.extend(resolved.pre_tool_use); - hooks.post_tool_use.extend(resolved.post_tool_use); - } - } - Ok(RuntimeHookConfig::new(hooks.pre_tool_use, hooks.post_tool_use)) - } - - pub fn validate_plugin(&self, source: impl AsRef<Path>) -> Result<PluginManifest, PluginError> { - let (manifest, _) = load_manifest_from_source(source.as_ref())?; - Ok(manifest) - } - - pub fn install_plugin( - &self, - source: impl AsRef<Path>, - ) -> Result<PluginOperationResult, PluginError> { - let (manifest, root) = load_manifest_from_source(source.as_ref())?; - let plugin_id = external_plugin_id(&manifest.name); - let install_path = self.installs_root().join(sanitize_plugin_id(&plugin_id)); - let canonical_source = fs::canonicalize(root)?; - - copy_dir_recursive(&canonical_source, &install_path)?; - - let now = iso8601_now(); - let mut registry = self.load_registry()?; - let installed_at = registry - .find(&plugin_id) - .map(|record| record.installed_at.clone()) - .unwrap_or_else(|| now.clone()); - registry.upsert(InstalledPluginRecord { - id: plugin_id.clone(), - name: manifest.name.clone(), - version: manifest.version.clone(), - description: manifest.description.clone(), - source_kind: PluginSourceKind::External, - source_path: canonical_source.display().to_string(), - install_path: install_path.display().to_string(), - installed_at, - updated_at: now, - }); - self.save_registry(®istry)?; - self.write_enabled_state(&plugin_id, Some(true))?; - - Ok(PluginOperationResult { - plugin_id: plugin_id.clone(), - message: format!( - "Installed plugin {} from {}", - plugin_id, - canonical_source.display() - ), - }) - } - - pub fn enable_plugin(&self, plugin_ref: &str) -> Result<PluginOperationResult, PluginError> { - let plugin = self.resolve_plugin(plugin_ref)?; - self.write_enabled_state(plugin.id(), Some(true))?; - Ok(PluginOperationResult { - plugin_id: plugin.id().to_string(), - message: format!("Enabled plugin {}", plugin.id()), - }) - } - - pub fn disable_plugin(&self, plugin_ref: &str) -> Result<PluginOperationResult, PluginError> { - let plugin = self.resolve_plugin(plugin_ref)?; - self.write_enabled_state(plugin.id(), Some(false))?; - Ok(PluginOperationResult { - plugin_id: plugin.id().to_string(), - message: format!("Disabled plugin {}", plugin.id()), - }) - } - - pub fn uninstall_plugin( - &self, - plugin_ref: &str, - ) -> Result<PluginOperationResult, PluginError> { - let plugin = self.resolve_plugin(plugin_ref)?; - if plugin.source_kind != PluginSourceKind::External { - return Err(PluginError::new(format!( - "plugin {} is {} and cannot be uninstalled", - plugin.id(), - plugin.source_kind.suffix() - ))); - } - - let mut registry = self.load_registry()?; - let Some(record) = registry.remove(plugin.id()) else { - return Err(PluginError::new(format!( - "plugin {} is not installed", - plugin.id() - ))); - }; - self.save_registry(®istry)?; - self.write_enabled_state(plugin.id(), None)?; - - let install_path = PathBuf::from(record.install_path); - if install_path.exists() { - fs::remove_dir_all(install_path)?; - } - - Ok(PluginOperationResult { - plugin_id: plugin.id().to_string(), - message: format!("Uninstalled plugin {}", plugin.id()), - }) - } - - pub fn update_plugin(&self, plugin_ref: &str) -> Result<PluginOperationResult, PluginError> { - let plugin = self.resolve_plugin(plugin_ref)?; - match plugin.source_kind { - PluginSourceKind::Builtin | PluginSourceKind::Bundled => Ok(PluginOperationResult { - plugin_id: plugin.id().to_string(), - message: format!( - "Plugin {} is {} and already managed by the CLI", - plugin.id(), - plugin.source_kind.suffix() - ), - }), - PluginSourceKind::External => { - let registry = self.load_registry()?; - let record = registry.find(plugin.id()).ok_or_else(|| { - PluginError::new(format!("plugin {} is not installed", plugin.id())) - })?; - self.install_plugin(PathBuf::from(&record.source_path)).map(|_| PluginOperationResult { - plugin_id: plugin.id().to_string(), - message: format!("Updated plugin {}", plugin.id()), - }) - } - } - } - - fn resolve_plugin(&self, plugin_ref: &str) -> Result<LoadedPlugin, PluginError> { - let plugins = self.discover_plugins()?; - if let Some(plugin) = plugins.iter().find(|plugin| plugin.id == plugin_ref) { - return Ok(plugin.clone()); - } - let mut matches = plugins - .into_iter() - .filter(|plugin| plugin.name() == plugin_ref) - .collect::<Vec<_>>(); - match matches.len() { - 0 => Err(PluginError::new(format!("plugin {plugin_ref} was not found"))), - 1 => Ok(matches.remove(0)), - _ => Err(PluginError::new(format!( - "plugin name {plugin_ref} is ambiguous; use a full plugin id" - ))), - } - } - - fn settings_path(&self) -> PathBuf { - let _ = &self.cwd; - self.config_home.join("settings.json") - } - - fn installs_root(&self) -> PathBuf { - self.config_home.join("plugins").join("installs") - } - - fn registry_path(&self) -> PathBuf { - self.config_home.join("plugins").join("installed.json") - } - - fn load_registry(&self) -> Result<PluginRegistry, PluginError> { - PluginRegistry::load(&self.registry_path()).map_err(PluginError::from) - } - - fn save_registry(&self, registry: &PluginRegistry) -> Result<(), PluginError> { - registry.save(&self.registry_path()).map_err(PluginError::from) - } - - fn write_enabled_state( - &self, - plugin_id: &str, - enabled: Option<bool>, - ) -> Result<(), PluginError> { - let settings_path = self.settings_path(); - let mut settings = read_settings_file(&settings_path)?; - write_plugin_state(&mut settings, plugin_id, enabled); - write_settings_file(&settings_path, &settings)?; - Ok(()) - } -} - -fn builtin_plugins() -> Vec<LoadedPlugin> { - let manifest = PluginManifest { - name: "tool-guard".to_string(), - description: "Example built-in plugin with optional tool hook messages".to_string(), - version: env!("CARGO_PKG_VERSION").to_string(), - default_enabled: false, - hooks: PluginHooks { - pre_tool_use: vec!["printf 'builtin tool-guard saw %s' \"$HOOK_TOOL_NAME\"".to_string()], - post_tool_use: Vec::new(), - }, - }; - vec![LoadedPlugin::new( - format!("{}@builtin", manifest.name), - PluginSourceKind::Builtin, - manifest, - None, - None, - )] -} - -fn bundled_plugins() -> Vec<LoadedPlugin> { - let manifest = PluginManifest { - name: "tool-audit".to_string(), - description: "Example bundled plugin with optional post-tool hooks".to_string(), - version: env!("CARGO_PKG_VERSION").to_string(), - default_enabled: false, - hooks: PluginHooks { - pre_tool_use: Vec::new(), - post_tool_use: vec!["printf 'bundled tool-audit saw %s' \"$HOOK_TOOL_NAME\"".to_string()], - }, - }; - vec![LoadedPlugin::new( - format!("{}@bundled", manifest.name), - PluginSourceKind::Bundled, - manifest, - None, - None, - )] -} - -fn is_plugin_enabled(plugin: &LoadedPlugin, runtime_config: &RuntimeConfig) -> bool { - runtime_config.plugins().state_for(&plugin.id, plugin.manifest.default_enabled) -} - -fn external_plugin_id(name: &str) -> String { - format!("{}@external", name.trim()) -} - -fn sanitize_plugin_id(plugin_id: &str) -> String { - plugin_id - .chars() - .map(|character| { - if character.is_ascii_alphanumeric() || matches!(character, '-' | '_') { - character - } else { - '-' - } - }) - .collect() -} - -fn load_manifest_from_source(source: &Path) -> Result<(PluginManifest, PathBuf), PluginError> { - let (manifest_path, root) = resolve_manifest_path(source)?; - let contents = fs::read_to_string(&manifest_path).map_err(|error| { - PluginError::new(format!( - "failed to read plugin manifest {}: {error}", - manifest_path.display() - )) - })?; - let manifest: PluginManifest = serde_json::from_str(&contents).map_err(|error| { - PluginError::new(format!( - "failed to parse plugin manifest {}: {error}", - manifest_path.display() - )) - })?; - manifest.validate().map_err(PluginError::new)?; - Ok((manifest, root)) -} - -fn resolve_manifest_path(source: &Path) -> Result<(PathBuf, PathBuf), PluginError> { - if source.is_file() { - let file_name = source.file_name().and_then(|name| name.to_str()).unwrap_or_default(); - if file_name != "plugin.json" { - return Err(PluginError::new(format!( - "plugin manifest file must be named plugin.json: {}", - source.display() - ))); - } - let root = source - .parent() - .and_then(|parent| parent.parent().filter(|candidate| parent.file_name() == Some(std::ffi::OsStr::new(".claude-plugin")))) - .map_or_else( - || source.parent().unwrap_or_else(|| Path::new(".")).to_path_buf(), - Path::to_path_buf, - ); - return Ok((source.to_path_buf(), root)); - } - - let nested = source.join(".claude-plugin").join("plugin.json"); - if nested.exists() { - return Ok((nested, source.to_path_buf())); - } - - let direct = source.join("plugin.json"); - if direct.exists() { - return Ok((direct, source.to_path_buf())); - } - - Err(PluginError::new(format!( - "plugin manifest not found in {}", - source.display() - ))) -} - -fn copy_dir_recursive(source: &Path, destination: &Path) -> Result<(), PluginError> { - if destination.exists() { - fs::remove_dir_all(destination)?; - } - fs::create_dir_all(destination)?; - for entry in fs::read_dir(source)? { - let entry = entry?; - let path = entry.path(); - let target = destination.join(entry.file_name()); - if entry.file_type()?.is_dir() { - copy_dir_recursive(&path, &target)?; - } else { - fs::copy(&path, &target)?; - } - } - Ok(()) -} - -fn iso8601_now() -> String { - let seconds = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - format!("{seconds}") -} - -#[cfg(test)] -mod tests { - use super::{PluginLoader, PluginManager, PluginSourceKind}; - use runtime::ConfigLoader; - use std::fs; - use std::path::Path; - use std::time::{SystemTime, UNIX_EPOCH}; - - fn temp_dir() -> std::path::PathBuf { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("time should be after epoch") - .as_nanos(); - std::env::temp_dir().join(format!("plugins-manager-{nanos}")) - } - - fn write_external_plugin(root: &Path, version: &str, hook_body: &str) { - fs::create_dir_all(root.join(".claude-plugin")).expect("plugin dir should exist"); - fs::write( - root.join(".claude-plugin").join("plugin.json"), - format!( - r#"{{ - "name": "sample-plugin", - "description": "sample external plugin", - "version": "{version}", - "hooks": {{ - "PreToolUse": ["printf 'pre from ${PLUGIN_DIR} {hook_body}'"] - }} -}}"# - ), - ) - .expect("plugin manifest should write"); - fs::write(root.join("README.md"), "sample").expect("payload should write"); - } - - #[test] - fn discovers_builtin_and_bundled_plugins() { - let root = temp_dir(); - let home = root.join("home").join(".claude"); - let loader = PluginLoader::new(&home); - let plugins = loader.discover().expect("plugins should load"); - assert!(plugins.iter().any(|plugin| plugin.source_kind == PluginSourceKind::Builtin)); - assert!(plugins.iter().any(|plugin| plugin.source_kind == PluginSourceKind::Bundled)); - fs::remove_dir_all(root).expect("cleanup"); - } - - #[test] - fn installs_and_lists_external_plugins() { - let root = temp_dir(); - let cwd = root.join("project"); - let home = root.join("home").join(".claude"); - let source = root.join("source-plugin"); - fs::create_dir_all(&cwd).expect("cwd should exist"); - write_external_plugin(&source, "1.0.0", "v1"); - - let manager = PluginManager::new(&cwd, &home); - let result = manager.install_plugin(&source).expect("install should succeed"); - assert_eq!(result.plugin_id, "sample-plugin@external"); - - let runtime_config = ConfigLoader::new(&cwd, &home) - .load() - .expect("config should load"); - let plugins = manager - .list_plugins(&runtime_config) - .expect("plugins should list"); - let external = plugins - .iter() - .find(|plugin| plugin.plugin.id == "sample-plugin@external") - .expect("external plugin should exist"); - assert!(external.enabled); - - let hook_config = manager - .active_hook_config(&runtime_config) - .expect("hook config should build"); - assert_eq!(hook_config.pre_tool_use().len(), 1); - assert!(hook_config.pre_tool_use()[0].contains("sample-plugin-external")); - - fs::remove_dir_all(root).expect("cleanup"); - } - - #[test] - fn disables_enables_updates_and_uninstalls_external_plugins() { - let root = temp_dir(); - let cwd = root.join("project"); - let home = root.join("home").join(".claude"); - let source = root.join("source-plugin"); - fs::create_dir_all(&cwd).expect("cwd should exist"); - write_external_plugin(&source, "1.0.0", "v1"); - - let manager = PluginManager::new(&cwd, &home); - manager.install_plugin(&source).expect("install should succeed"); - manager - .disable_plugin("sample-plugin") - .expect("disable should succeed"); - let runtime_config = ConfigLoader::new(&cwd, &home) - .load() - .expect("config should load"); - let plugins = manager - .list_plugins(&runtime_config) - .expect("plugins should list"); - assert!(!plugins - .iter() - .find(|plugin| plugin.plugin.id == "sample-plugin@external") - .expect("external plugin should exist") - .enabled); - - manager - .enable_plugin("sample-plugin@external") - .expect("enable should succeed"); - write_external_plugin(&source, "2.0.0", "v2"); - manager - .update_plugin("sample-plugin@external") - .expect("update should succeed"); - - let loader = PluginLoader::new(&home); - let plugins = loader.discover().expect("plugins should load"); - let external = plugins - .iter() - .find(|plugin| plugin.id == "sample-plugin@external") - .expect("external plugin should exist"); - assert_eq!(external.manifest.version, "2.0.0"); - - manager - .uninstall_plugin("sample-plugin@external") - .expect("uninstall should succeed"); - let plugins = loader.discover().expect("plugins should reload"); - assert!(!plugins - .iter() - .any(|plugin| plugin.id == "sample-plugin@external")); - - fs::remove_dir_all(root).expect("cleanup"); - } -} diff --git a/rust/crates/plugins/src/manifest.rs b/rust/crates/plugins/src/manifest.rs deleted file mode 100644 index 449b0be..0000000 --- a/rust/crates/plugins/src/manifest.rs +++ /dev/null @@ -1,175 +0,0 @@ -use std::path::{Path, PathBuf}; - -use serde::{Deserialize, Serialize}; - -use crate::PluginSourceKind; - -pub trait Plugin { - fn id(&self) -> &str; - fn manifest(&self) -> &PluginManifest; - fn source_kind(&self) -> PluginSourceKind; - fn root(&self) -> Option<&Path>; - - fn resolved_hooks(&self) -> PluginHooks { - self.manifest().hooks.resolve(self.root()) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] -pub struct PluginHooks { - #[serde(rename = "PreToolUse", alias = "preToolUse", default)] - pub pre_tool_use: Vec<String>, - #[serde(rename = "PostToolUse", alias = "postToolUse", default)] - pub post_tool_use: Vec<String>, -} - -impl PluginHooks { - #[must_use] - pub fn resolve(&self, root: Option<&Path>) -> Self { - let Some(root) = root else { - return self.clone(); - }; - let replacement = root.display().to_string(); - Self { - pre_tool_use: self - .pre_tool_use - .iter() - .map(|value| value.replace("${PLUGIN_DIR}", &replacement)) - .collect(), - post_tool_use: self - .post_tool_use - .iter() - .map(|value| value.replace("${PLUGIN_DIR}", &replacement)) - .collect(), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct PluginManifest { - pub name: String, - pub description: String, - #[serde(default = "default_version")] - pub version: String, - #[serde(default)] - pub default_enabled: bool, - #[serde(default)] - pub hooks: PluginHooks, -} - -impl PluginManifest { - pub fn validate(&self) -> Result<(), String> { - if self.name.trim().is_empty() { - return Err("plugin manifest name must not be empty".to_string()); - } - if self.description.trim().is_empty() { - return Err(format!( - "plugin manifest description must not be empty for {}", - self.name - )); - } - if self.version.trim().is_empty() { - return Err(format!( - "plugin manifest version must not be empty for {}", - self.name - )); - } - if self - .hooks - .pre_tool_use - .iter() - .chain(self.hooks.post_tool_use.iter()) - .any(|hook| hook.trim().is_empty()) - { - return Err(format!( - "plugin manifest hook entries must not be empty for {}", - self.name - )); - } - Ok(()) - } -} - -fn default_version() -> String { - "0.1.0".to_string() -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct LoadedPlugin { - pub id: String, - pub source_kind: PluginSourceKind, - pub manifest: PluginManifest, - pub root: Option<PathBuf>, - pub origin: Option<PathBuf>, -} - -impl LoadedPlugin { - #[must_use] - pub fn new( - id: String, - source_kind: PluginSourceKind, - manifest: PluginManifest, - root: Option<PathBuf>, - origin: Option<PathBuf>, - ) -> Self { - Self { - id, - source_kind, - manifest, - root, - origin, - } - } - - #[must_use] - pub fn name(&self) -> &str { - &self.manifest.name - } -} - -impl Plugin for LoadedPlugin { - fn id(&self) -> &str { - &self.id - } - - fn manifest(&self) -> &PluginManifest { - &self.manifest - } - - fn source_kind(&self) -> PluginSourceKind { - self.source_kind - } - - fn root(&self) -> Option<&Path> { - self.root.as_deref() - } -} - -#[cfg(test)] -mod tests { - use super::{PluginHooks, PluginManifest}; - use std::path::Path; - - #[test] - fn validates_manifest_fields() { - let manifest = PluginManifest { - name: "demo".to_string(), - description: "demo plugin".to_string(), - version: "1.2.3".to_string(), - default_enabled: false, - hooks: PluginHooks::default(), - }; - assert!(manifest.validate().is_ok()); - } - - #[test] - fn resolves_plugin_dir_placeholders() { - let hooks = PluginHooks { - pre_tool_use: vec!["echo ${PLUGIN_DIR}/pre".to_string()], - post_tool_use: vec!["echo ${PLUGIN_DIR}/post".to_string()], - }; - let resolved = hooks.resolve(Some(Path::new("/tmp/plugin"))); - assert_eq!(resolved.pre_tool_use, vec!["echo /tmp/plugin/pre"]); - assert_eq!(resolved.post_tool_use, vec!["echo /tmp/plugin/post"]); - } -} diff --git a/rust/crates/plugins/src/registry.rs b/rust/crates/plugins/src/registry.rs deleted file mode 100644 index a2f021d..0000000 --- a/rust/crates/plugins/src/registry.rs +++ /dev/null @@ -1,91 +0,0 @@ -use std::path::Path; - -use serde::{Deserialize, Serialize}; - -use crate::InstalledPluginRecord; - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] -pub struct PluginRegistry { - #[serde(default)] - pub plugins: Vec<InstalledPluginRecord>, -} - -impl PluginRegistry { - pub fn load(path: &Path) -> Result<Self, String> { - match std::fs::read_to_string(path) { - Ok(contents) => { - if contents.trim().is_empty() { - return Ok(Self::default()); - } - serde_json::from_str(&contents).map_err(|error| error.to_string()) - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()), - Err(error) => Err(error.to_string()), - } - } - - pub fn save(&self, path: &Path) -> Result<(), String> { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; - } - std::fs::write( - path, - serde_json::to_string_pretty(self).map_err(|error| error.to_string())?, - ) - .map_err(|error| error.to_string()) - } - - #[must_use] - pub fn find(&self, plugin_id: &str) -> Option<&InstalledPluginRecord> { - self.plugins.iter().find(|plugin| plugin.id == plugin_id) - } - - pub fn upsert(&mut self, record: InstalledPluginRecord) { - if let Some(existing) = self.plugins.iter_mut().find(|plugin| plugin.id == record.id) { - *existing = record; - } else { - self.plugins.push(record); - } - self.plugins.sort_by(|left, right| left.id.cmp(&right.id)); - } - - pub fn remove(&mut self, plugin_id: &str) -> Option<InstalledPluginRecord> { - let index = self.plugins.iter().position(|plugin| plugin.id == plugin_id)?; - Some(self.plugins.remove(index)) - } -} - -#[cfg(test)] -mod tests { - use super::PluginRegistry; - use crate::{InstalledPluginRecord, PluginSourceKind}; - - #[test] - fn upsert_replaces_existing_entries() { - let mut registry = PluginRegistry::default(); - registry.upsert(InstalledPluginRecord { - id: "demo@external".to_string(), - name: "demo".to_string(), - version: "1.0.0".to_string(), - description: "demo".to_string(), - source_kind: PluginSourceKind::External, - source_path: "/src".to_string(), - install_path: "/install".to_string(), - installed_at: "t1".to_string(), - updated_at: "t1".to_string(), - }); - registry.upsert(InstalledPluginRecord { - id: "demo@external".to_string(), - name: "demo".to_string(), - version: "1.0.1".to_string(), - description: "updated".to_string(), - source_kind: PluginSourceKind::External, - source_path: "/src".to_string(), - install_path: "/install".to_string(), - installed_at: "t1".to_string(), - updated_at: "t2".to_string(), - }); - assert_eq!(registry.plugins.len(), 1); - assert_eq!(registry.plugins[0].version, "1.0.1"); - } -} diff --git a/rust/crates/plugins/src/settings.rs b/rust/crates/plugins/src/settings.rs deleted file mode 100644 index 5ce0367..0000000 --- a/rust/crates/plugins/src/settings.rs +++ /dev/null @@ -1,106 +0,0 @@ -use std::path::Path; - -use runtime::RuntimePluginConfig; -use serde_json::{Map, Value}; - -pub fn read_settings_file(path: &Path) -> Result<Map<String, Value>, String> { - match std::fs::read_to_string(path) { - Ok(contents) => { - if contents.trim().is_empty() { - return Ok(Map::new()); - } - serde_json::from_str::<Value>(&contents) - .map_err(|error| error.to_string())? - .as_object() - .cloned() - .ok_or_else(|| "settings file must contain a JSON object".to_string()) - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Map::new()), - Err(error) => Err(error.to_string()), - } -} - -pub fn write_settings_file(path: &Path, root: &Map<String, Value>) -> Result<(), String> { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; - } - std::fs::write( - path, - serde_json::to_string_pretty(root).map_err(|error| error.to_string())?, - ) - .map_err(|error| error.to_string()) -} - -pub fn read_enabled_plugin_map(root: &Map<String, Value>) -> Map<String, Value> { - root.get("enabledPlugins") - .and_then(Value::as_object) - .cloned() - .unwrap_or_default() -} - -pub fn write_plugin_state( - root: &mut Map<String, Value>, - plugin_id: &str, - enabled: Option<bool>, -) { - let mut enabled_plugins = read_enabled_plugin_map(root); - match enabled { - Some(value) => { - enabled_plugins.insert(plugin_id.to_string(), Value::Bool(value)); - } - None => { - enabled_plugins.remove(plugin_id); - } - } - if enabled_plugins.is_empty() { - root.remove("enabledPlugins"); - } else { - root.insert("enabledPlugins".to_string(), Value::Object(enabled_plugins)); - } -} - -pub fn config_from_settings(root: &Map<String, Value>) -> RuntimePluginConfig { - let mut config = RuntimePluginConfig::default(); - if let Some(enabled_plugins) = root.get("enabledPlugins").and_then(Value::as_object) { - for (plugin_id, enabled) in enabled_plugins { - match enabled.as_bool() { - Some(value) => config.set_plugin_state(plugin_id.clone(), value), - None => {} - } - } - } - config -} - -#[cfg(test)] -mod tests { - use super::{config_from_settings, write_plugin_state}; - use serde_json::{json, Map, Value}; - - #[test] - fn writes_and_removes_enabled_plugin_state() { - let mut root = Map::new(); - write_plugin_state(&mut root, "demo@external", Some(true)); - assert_eq!( - root.get("enabledPlugins"), - Some(&json!({"demo@external": true})) - ); - write_plugin_state(&mut root, "demo@external", None); - assert_eq!(root.get("enabledPlugins"), None); - } - - #[test] - fn converts_settings_to_runtime_plugin_config() { - let mut root = Map::<String, Value>::new(); - root.insert( - "enabledPlugins".to_string(), - json!({"demo@external": true, "off@bundled": false}), - ); - let config = config_from_settings(&root); - assert_eq!( - config.enabled_plugins().get("demo@external"), - Some(&true) - ); - assert_eq!(config.enabled_plugins().get("off@bundled"), Some(&false)); - } -} diff --git a/rust/crates/runtime/src/conversation.rs b/rust/crates/runtime/src/conversation.rs index f7a7741..1e9072d 100644 --- a/rust/crates/runtime/src/conversation.rs +++ b/rust/crates/runtime/src/conversation.rs @@ -133,6 +133,7 @@ where } #[must_use] + #[allow(clippy::needless_pass_by_value)] pub fn new_with_features( session: Session, api_client: C, @@ -761,7 +762,7 @@ mod tests { "post hook should preserve non-error result: {output:?}" ); assert!( - output.contains("4"), + output.contains('4'), "tool output missing value: {output:?}" ); assert!( diff --git a/rust/crates/runtime/src/hooks.rs b/rust/crates/runtime/src/hooks.rs index 36756a0..40da0d6 100644 --- a/rust/crates/runtime/src/hooks.rs +++ b/rust/crates/runtime/src/hooks.rs @@ -149,6 +149,7 @@ impl HookRunner { HookRunResult::allow(messages) } + #[allow(clippy::too_many_arguments, clippy::unused_self)] fn run_command( &self, command: &str, diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 313706f..c4d4396 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -4,6 +4,7 @@ mod render; use std::collections::BTreeSet; use std::env; +use std::fmt::Write as _; use std::fs; use std::io::{self, Read, Write}; use std::net::TcpListener; @@ -22,7 +23,7 @@ use commands::{ }; use compat_harness::{extract_manifest, UpstreamPaths}; use init::initialize_repo; -use plugins::{PluginListEntry, PluginManager}; +use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginSummary}; use render::{MarkdownStreamState, Spinner, TerminalRenderer}; use runtime::{ clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt, @@ -30,7 +31,7 @@ use runtime::{ AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock, ConversationMessage, ConversationRuntime, MessageRole, OAuthAuthorizationRequest, OAuthConfig, OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError, - Session, TokenUsage, ToolError, ToolExecutor, UsageTracker, + RuntimeHookConfig, Session, TokenUsage, ToolError, ToolExecutor, UsageTracker, }; use serde_json::json; use tools::{execute_tool, mvp_tool_specs, ToolSpec}; @@ -1490,21 +1491,30 @@ impl LiveCli { target: Option<&str>, ) -> Result<bool, Box<dyn std::error::Error>> { let cwd = env::current_dir()?; - let runtime_config = ConfigLoader::default_for(&cwd).load()?; - let manager = PluginManager::default_for(&cwd); + let loader = ConfigLoader::default_for(&cwd); + let runtime_config = loader.load()?; + let mut manager = build_plugin_manager(&cwd, &loader, &runtime_config); match action { None | Some("list") => { - let plugins = manager.list_plugins(&runtime_config)?; + let plugins = manager.list_plugins()?; println!("{}", render_plugins_report(&plugins)); } Some("install") => { let Some(target) = target else { - println!("Usage: /plugins install <path>"); + println!("Usage: /plugins install <path-or-git-url>"); return Ok(false); }; - let result = manager.install_plugin(PathBuf::from(target))?; - println!("Plugins\n Result {}", result.message); + let result = manager.install(target)?; + println!( + "Plugins + Result installed {} + Version {} + Path {}", + result.plugin_id, + result.version, + result.install_path.display(), + ); self.reload_runtime_features()?; } Some("enable") => { @@ -1512,8 +1522,11 @@ impl LiveCli { println!("Usage: /plugins enable <plugin-id>"); return Ok(false); }; - let result = manager.enable_plugin(target)?; - println!("Plugins\n Result {}", result.message); + manager.enable(target)?; + println!( + "Plugins + Result enabled {target}" + ); self.reload_runtime_features()?; } Some("disable") => { @@ -1521,8 +1534,11 @@ impl LiveCli { println!("Usage: /plugins disable <plugin-id>"); return Ok(false); }; - let result = manager.disable_plugin(target)?; - println!("Plugins\n Result {}", result.message); + manager.disable(target)?; + println!( + "Plugins + Result disabled {target}" + ); self.reload_runtime_features()?; } Some("uninstall") => { @@ -1530,8 +1546,11 @@ impl LiveCli { println!("Usage: /plugins uninstall <plugin-id>"); return Ok(false); }; - let result = manager.uninstall_plugin(target)?; - println!("Plugins\n Result {}", result.message); + manager.uninstall(target)?; + println!( + "Plugins + Result uninstalled {target}" + ); self.reload_runtime_features()?; } Some("update") => { @@ -1539,8 +1558,18 @@ impl LiveCli { println!("Usage: /plugins update <plugin-id>"); return Ok(false); }; - let result = manager.update_plugin(target)?; - println!("Plugins\n Result {}", result.message); + let result = manager.update(target)?; + println!( + "Plugins + Result updated {} + Old version {} + New version {} + Path {}", + result.plugin_id, + result.old_version, + result.new_version, + result.install_path.display(), + ); self.reload_runtime_features()?; } Some(other) => { @@ -1858,19 +1887,22 @@ fn render_repl_help() -> String { ) } -fn render_plugins_report(plugins: &[PluginListEntry]) -> String { +fn render_plugins_report(plugins: &[PluginSummary]) -> String { let mut lines = vec!["Plugins".to_string()]; if plugins.is_empty() { lines.push(" No plugins discovered.".to_string()); return lines.join("\n"); } for plugin in plugins { - let kind = format!("{:?}", plugin.plugin.source_kind).to_lowercase(); - let location = plugin - .plugin - .root - .as_ref() - .map_or_else(|| kind.clone(), |root| root.display().to_string()); + let kind = match plugin.metadata.kind { + PluginKind::Builtin => "builtin", + PluginKind::Bundled => "bundled", + PluginKind::External => "external", + }; + let location = plugin.metadata.root.as_ref().map_or_else( + || plugin.metadata.source.clone(), + |root| root.display().to_string(), + ); let enabled = if plugin.enabled { "enabled" } else { @@ -1878,9 +1910,9 @@ fn render_plugins_report(plugins: &[PluginListEntry]) -> String { }; lines.push(format!( " {id:<24} {kind:<8} {enabled:<8} v{version:<8} {location}", - id = plugin.plugin.id, + id = plugin.metadata.id, kind = kind, - version = plugin.plugin.manifest.version, + version = plugin.metadata.version, )); } lines.join("\n") @@ -2429,12 +2461,51 @@ fn build_runtime_feature_config( let cwd = env::current_dir()?; let loader = ConfigLoader::default_for(&cwd); let runtime_config = loader.load()?; - let plugin_manager = PluginManager::default_for(&cwd); - let plugin_hooks = plugin_manager.active_hook_config(&runtime_config)?; + let plugin_manager = build_plugin_manager(&cwd, &loader, &runtime_config); + let plugin_hooks = plugin_manager.aggregated_hooks()?; Ok(runtime_config .feature_config() .clone() - .with_hooks(runtime_config.hooks().merged(&plugin_hooks))) + .with_hooks(runtime_config.hooks().merged(&RuntimeHookConfig::new( + plugin_hooks.pre_tool_use, + plugin_hooks.post_tool_use, + )))) +} + +fn build_plugin_manager( + cwd: &Path, + loader: &ConfigLoader, + runtime_config: &runtime::RuntimeConfig, +) -> PluginManager { + let plugin_settings = runtime_config.plugins(); + let mut plugin_config = PluginManagerConfig::new(loader.config_home().to_path_buf()); + plugin_config.enabled_plugins = plugin_settings.enabled_plugins().clone(); + plugin_config.external_dirs = plugin_settings + .external_directories() + .iter() + .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)) + .collect(); + plugin_config.install_root = plugin_settings + .install_root() + .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)); + plugin_config.registry_path = plugin_settings + .registry_path() + .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)); + plugin_config.bundled_root = plugin_settings + .bundled_root() + .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)); + PluginManager::new(plugin_config) +} + +fn resolve_plugin_path(cwd: &Path, config_home: &Path, value: &str) -> PathBuf { + let path = PathBuf::from(value); + if path.is_absolute() { + path + } else if value.starts_with('.') { + cwd.join(path) + } else { + config_home.join(path) + } } #[allow(clippy::needless_pass_by_value)] @@ -2890,13 +2961,13 @@ fn format_bash_result(icon: &str, parsed: &serde_json::Value) -> String { .get("backgroundTaskId") .and_then(|value| value.as_str()) { - lines[0].push_str(&format!(" backgrounded ({task_id})")); + write!(&mut lines[0], " backgrounded ({task_id})").expect("write to string"); } else if let Some(status) = parsed .get("returnCodeInterpretation") .and_then(|value| value.as_str()) .filter(|status| !status.is_empty()) { - lines[0].push_str(&format!(" {status}")); + write!(&mut lines[0], " {status}").expect("write to string"); } if let Some(stdout) = parsed.get("stdout").and_then(|value| value.as_str()) { @@ -2918,15 +2989,15 @@ fn format_read_result(icon: &str, parsed: &serde_json::Value) -> String { let path = extract_tool_path(file); let start_line = file .get("startLine") - .and_then(|value| value.as_u64()) + .and_then(serde_json::Value::as_u64) .unwrap_or(1); let num_lines = file .get("numLines") - .and_then(|value| value.as_u64()) + .and_then(serde_json::Value::as_u64) .unwrap_or(0); let total_lines = file .get("totalLines") - .and_then(|value| value.as_u64()) + .and_then(serde_json::Value::as_u64) .unwrap_or(num_lines); let content = file .get("content") @@ -2952,8 +3023,7 @@ fn format_write_result(icon: &str, parsed: &serde_json::Value) -> String { let line_count = parsed .get("content") .and_then(|value| value.as_str()) - .map(|content| content.lines().count()) - .unwrap_or(0); + .map_or(0, |content| content.lines().count()); format!( "{icon} \x1b[1;32m✏️ {} {path}\x1b[0m \x1b[2m({line_count} lines)\x1b[0m", if kind == "create" { "Wrote" } else { "Updated" }, @@ -2984,7 +3054,7 @@ fn format_edit_result(icon: &str, parsed: &serde_json::Value) -> String { let path = extract_tool_path(parsed); let suffix = if parsed .get("replaceAll") - .and_then(|value| value.as_bool()) + .and_then(serde_json::Value::as_bool) .unwrap_or(false) { " (replace all)" @@ -3012,7 +3082,7 @@ fn format_edit_result(icon: &str, parsed: &serde_json::Value) -> String { fn format_glob_result(icon: &str, parsed: &serde_json::Value) -> String { let num_files = parsed .get("numFiles") - .and_then(|value| value.as_u64()) + .and_then(serde_json::Value::as_u64) .unwrap_or(0); let filenames = parsed .get("filenames") @@ -3036,11 +3106,11 @@ fn format_glob_result(icon: &str, parsed: &serde_json::Value) -> String { fn format_grep_result(icon: &str, parsed: &serde_json::Value) -> String { let num_matches = parsed .get("numMatches") - .and_then(|value| value.as_u64()) + .and_then(serde_json::Value::as_u64) .unwrap_or(0); let num_files = parsed .get("numFiles") - .and_then(|value| value.as_u64()) + .and_then(serde_json::Value::as_u64) .unwrap_or(0); let content = parsed .get("content") diff --git a/rust/crates/rusty-claude-cli/src/render.rs b/rust/crates/rusty-claude-cli/src/render.rs index 465c5a4..01751fd 100644 --- a/rust/crates/rusty-claude-cli/src/render.rs +++ b/rust/crates/rusty-claude-cli/src/render.rs @@ -286,7 +286,7 @@ impl TerminalRenderer { ) { match event { Event::Start(Tag::Heading { level, .. }) => { - self.start_heading(state, level as u8, output) + self.start_heading(state, level as u8, output); } Event::End(TagEnd::Paragraph) => output.push_str("\n\n"), Event::Start(Tag::BlockQuote(..)) => self.start_quote(state, output), @@ -426,6 +426,7 @@ impl TerminalRenderer { } } + #[allow(clippy::unused_self)] fn start_heading(&self, state: &mut RenderState, level: u8, output: &mut String) { state.heading_level = Some(level); if !output.is_empty() { From 8f6d8db95867873e04c6c89a5b645f3b6f29eea9 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo <hurrc04@gmail.com> Date: Wed, 1 Apr 2026 06:00:49 +0000 Subject: [PATCH 06/25] feat: plugin registry + validation + hooks --- rust/crates/plugins/src/lib.rs | 203 ++++++++++++++++++++++++++++----- 1 file changed, 174 insertions(+), 29 deletions(-) diff --git a/rust/crates/plugins/src/lib.rs b/rust/crates/plugins/src/lib.rs index a2631ff..6853b71 100644 --- a/rust/crates/plugins/src/lib.rs +++ b/rust/crates/plugins/src/lib.rs @@ -207,12 +207,100 @@ impl Plugin for PluginDefinition { } } +#[derive(Debug, Clone, PartialEq, Eq)] +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 is_enabled(&self) -> bool { + self.enabled + } + + pub fn validate(&self) -> Result<(), PluginError> { + self.definition.validate() + } + + #[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, Eq)] +pub struct PluginRegistry { + plugins: Vec<RegisteredPlugin>, +} + +impl PluginRegistry { + #[must_use] + pub fn new(mut plugins: Vec<RegisteredPlugin>) -> 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<PluginSummary> { + self.plugins.iter().map(RegisteredPlugin::summary).collect() + } + + pub fn aggregated_hooks(&self) -> Result<PluginHooks, PluginError> { + self.plugins + .iter() + .filter(|plugin| plugin.is_enabled()) + .try_fold(PluginHooks::default(), |acc, plugin| { + plugin.validate()?; + Ok(acc.merged_with(plugin.hooks())) + }) + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct PluginManagerConfig { pub config_home: PathBuf, @@ -326,17 +414,20 @@ impl PluginManager { self.config.config_home.join(SETTINGS_FILE_NAME) } + pub fn plugin_registry(&self) -> Result<PluginRegistry, PluginError> { + 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<Vec<PluginSummary>, PluginError> { - let mut plugins = self - .discover_plugins()? - .into_iter() - .map(|plugin| PluginSummary { - enabled: self.is_enabled(plugin.metadata()), - metadata: plugin.metadata().clone(), - }) - .collect::<Vec<_>>(); - plugins.sort_by(|left, right| left.metadata.id.cmp(&right.metadata.id)); - Ok(plugins) + Ok(self.plugin_registry()?.summaries()) } pub fn discover_plugins(&self) -> Result<Vec<PluginDefinition>, PluginError> { @@ -347,18 +438,12 @@ impl PluginManager { } pub fn aggregated_hooks(&self) -> Result<PluginHooks, PluginError> { - self.discover_plugins()? - .into_iter() - .filter(|plugin| self.is_enabled(plugin.metadata())) - .try_fold(PluginHooks::default(), |acc, plugin| { - plugin.validate()?; - Ok(acc.merged_with(plugin.hooks())) - }) + self.plugin_registry()?.aggregated_hooks() } pub fn validate_plugin_source(&self, source: &str) -> Result<PluginManifest, PluginError> { let path = resolve_local_source(source)?; - load_manifest_from_root(&path) + load_validated_manifest_from_root(&path) } pub fn install(&mut self, source: &str) -> Result<InstallOutcome, PluginError> { @@ -366,8 +451,7 @@ impl PluginManager { 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_manifest_from_root(&staged_source)?; - validate_manifest(&manifest)?; + let manifest = load_validated_manifest_from_root(&staged_source)?; let plugin_id = plugin_id(&manifest.name, EXTERNAL_MARKETPLACE); let install_path = self.install_root().join(sanitize_plugin_id(&plugin_id)); @@ -445,8 +529,7 @@ impl PluginManager { 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_manifest_from_root(&staged_source)?; - validate_manifest(&manifest)?; + let manifest = load_validated_manifest_from_root(&staged_source)?; if record.install_path.exists() { fs::remove_dir_all(&record.install_path)?; @@ -542,11 +625,7 @@ impl PluginManager { } fn ensure_known_plugin(&self, plugin_id: &str) -> Result<(), PluginError> { - if self - .list_plugins()? - .iter() - .any(|plugin| plugin.metadata.id == plugin_id) - { + if self.plugin_registry()?.contains(plugin_id) { Ok(()) } else { Err(PluginError::NotFound(format!( @@ -617,8 +696,7 @@ fn load_plugin_definition( source: String, marketplace: &str, ) -> Result<PluginDefinition, PluginError> { - let manifest = load_manifest_from_root(root)?; - validate_manifest(&manifest)?; + let manifest = load_validated_manifest_from_root(root)?; let metadata = PluginMetadata { id: plugin_id(&manifest.name, marketplace), name: manifest.name, @@ -637,6 +715,13 @@ fn load_plugin_definition( }) } +fn load_validated_manifest_from_root(root: &Path) -> Result<PluginManifest, PluginError> { + let manifest = load_manifest_from_root(root)?; + validate_manifest(&manifest)?; + validate_hook_paths(Some(root), &manifest.hooks)?; + Ok(manifest) +} + fn validate_manifest(manifest: &PluginManifest) -> Result<(), PluginError> { if manifest.name.trim().is_empty() { return Err(PluginError::InvalidManifest( @@ -896,6 +981,17 @@ mod tests { .expect("write manifest"); } + fn write_broken_plugin(root: &Path, name: &str) { + fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir"); + fs::write( + root.join(MANIFEST_RELATIVE_PATH), + format!( + "{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"broken plugin\",\n \"hooks\": {{\n \"PreToolUse\": [\"./hooks/missing.sh\"]\n }}\n}}" + ), + ) + .expect("write broken manifest"); + } + #[test] fn validates_manifest_shape() { let error = validate_manifest(&PluginManifest { @@ -982,4 +1078,53 @@ mod tests { 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); + } } From f8d4da3e6826e047a0cfec7033267e482c26e726 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo <hurrc04@gmail.com> Date: Wed, 1 Apr 2026 06:25:27 +0000 Subject: [PATCH 07/25] feat: plugins progress --- rust/crates/plugins/src/lib.rs | 292 +++++++++++++++++++++-- rust/crates/runtime/Cargo.toml | 1 + rust/crates/runtime/src/conversation.rs | 129 +++++++++- rust/crates/rusty-claude-cli/src/main.rs | 22 +- 4 files changed, 413 insertions(+), 31 deletions(-) diff --git a/rust/crates/plugins/src/lib.rs b/rust/crates/plugins/src/lib.rs index 6853b71..8016d44 100644 --- a/rust/crates/plugins/src/lib.rs +++ b/rust/crates/plugins/src/lib.rs @@ -72,6 +72,21 @@ impl PluginHooks { } } +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginLifecycle { + #[serde(rename = "Init", default)] + pub init: Vec<String>, + #[serde(rename = "Shutdown", default)] + pub shutdown: Vec<String>, +} + +impl PluginLifecycle { + #[must_use] + pub fn is_empty(&self) -> bool { + self.init.is_empty() && self.shutdown.is_empty() + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PluginManifest { pub name: String, @@ -81,6 +96,8 @@ pub struct PluginManifest { pub default_enabled: bool, #[serde(default)] pub hooks: PluginHooks, + #[serde(default)] + pub lifecycle: PluginLifecycle, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -112,24 +129,30 @@ pub struct InstalledPluginRegistry { pub struct BuiltinPlugin { metadata: PluginMetadata, hooks: PluginHooks, + lifecycle: PluginLifecycle, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct BundledPlugin { metadata: PluginMetadata, hooks: PluginHooks, + lifecycle: PluginLifecycle, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct ExternalPlugin { metadata: PluginMetadata, hooks: PluginHooks, + lifecycle: PluginLifecycle, } pub trait Plugin { fn metadata(&self) -> &PluginMetadata; fn hooks(&self) -> &PluginHooks; + fn lifecycle(&self) -> &PluginLifecycle; fn validate(&self) -> Result<(), PluginError>; + fn initialize(&self) -> Result<(), PluginError>; + fn shutdown(&self) -> Result<(), PluginError>; } #[derive(Debug, Clone, PartialEq, Eq)] @@ -148,9 +171,21 @@ impl Plugin for BuiltinPlugin { &self.hooks } + fn lifecycle(&self) -> &PluginLifecycle { + &self.lifecycle + } + fn validate(&self) -> Result<(), PluginError> { Ok(()) } + + fn initialize(&self) -> Result<(), PluginError> { + Ok(()) + } + + fn shutdown(&self) -> Result<(), PluginError> { + Ok(()) + } } impl Plugin for BundledPlugin { @@ -162,8 +197,26 @@ impl Plugin for BundledPlugin { &self.hooks } + fn lifecycle(&self) -> &PluginLifecycle { + &self.lifecycle + } + fn validate(&self) -> Result<(), PluginError> { - validate_hook_paths(self.metadata.root.as_deref(), &self.hooks) + validate_hook_paths(self.metadata.root.as_deref(), &self.hooks)?; + validate_lifecycle_paths(self.metadata.root.as_deref(), &self.lifecycle) + } + + 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, + ) } } @@ -176,8 +229,26 @@ impl Plugin for ExternalPlugin { &self.hooks } + fn lifecycle(&self) -> &PluginLifecycle { + &self.lifecycle + } + fn validate(&self) -> Result<(), PluginError> { - validate_hook_paths(self.metadata.root.as_deref(), &self.hooks) + validate_hook_paths(self.metadata.root.as_deref(), &self.hooks)?; + validate_lifecycle_paths(self.metadata.root.as_deref(), &self.lifecycle) + } + + 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, + ) } } @@ -198,6 +269,14 @@ impl Plugin for PluginDefinition { } } + fn lifecycle(&self) -> &PluginLifecycle { + match self { + Self::Builtin(plugin) => plugin.lifecycle(), + Self::Bundled(plugin) => plugin.lifecycle(), + Self::External(plugin) => plugin.lifecycle(), + } + } + fn validate(&self) -> Result<(), PluginError> { match self { Self::Builtin(plugin) => plugin.validate(), @@ -205,6 +284,22 @@ impl Plugin for PluginDefinition { 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, Eq)] @@ -241,6 +336,14 @@ impl RegisteredPlugin { 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 { @@ -299,6 +402,21 @@ impl PluginRegistry { Ok(acc.merged_with(plugin.hooks())) }) } + + 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)] @@ -687,6 +805,7 @@ pub fn builtin_plugins() -> Vec<PluginDefinition> { root: None, }, hooks: PluginHooks::default(), + lifecycle: PluginLifecycle::default(), })] } @@ -708,10 +827,23 @@ fn load_plugin_definition( root: Some(root.to_path_buf()), }; let hooks = resolve_hooks(root, &manifest.hooks); + let lifecycle = resolve_lifecycle(root, &manifest.lifecycle); Ok(match kind { - PluginKind::Builtin => PluginDefinition::Builtin(BuiltinPlugin { metadata, hooks }), - PluginKind::Bundled => PluginDefinition::Bundled(BundledPlugin { metadata, hooks }), - PluginKind::External => PluginDefinition::External(ExternalPlugin { metadata, hooks }), + PluginKind::Builtin => PluginDefinition::Builtin(BuiltinPlugin { + metadata, + hooks, + lifecycle, + }), + PluginKind::Bundled => PluginDefinition::Bundled(BundledPlugin { + metadata, + hooks, + lifecycle, + }), + PluginKind::External => PluginDefinition::External(ExternalPlugin { + metadata, + hooks, + lifecycle, + }), }) } @@ -719,6 +851,7 @@ fn load_validated_manifest_from_root(root: &Path) -> Result<PluginManifest, Plug let manifest = load_manifest_from_root(root)?; validate_manifest(&manifest)?; validate_hook_paths(Some(root), &manifest.hooks)?; + validate_lifecycle_paths(Some(root), &manifest.lifecycle)?; Ok(manifest) } @@ -767,25 +900,58 @@ fn resolve_hooks(root: &Path, hooks: &PluginHooks) -> PluginHooks { } } +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 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()) { - if is_literal_command(entry) { - continue; - } - let path = if Path::new(entry).is_absolute() { - PathBuf::from(entry) - } else { - root.join(entry) - }; - if !path.exists() { - return Err(PluginError::InvalidManifest(format!( - "hook path `{}` does not exist", - path.display() - ))); - } + 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_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(()) } @@ -802,6 +968,48 @@ fn is_literal_command(entry: &str) -> bool { !entry.starts_with("./") && !entry.starts_with("../") } +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 output = if Path::new(command).exists() { + if cfg!(windows) { + Command::new("cmd").arg("/C").arg(command).output()? + } else { + Command::new("sh").arg(command).output()? + } + } else if cfg!(windows) { + Command::new("cmd").arg("/C").arg(command).output()? + } else { + Command::new("sh").arg("-lc").arg(command).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<PathBuf, PluginError> { let path = PathBuf::from(source); if path.exists() { @@ -992,6 +1200,30 @@ mod tests { .expect("write broken manifest"); } + fn write_lifecycle_plugin(root: &Path, name: &str, version: &str) -> PathBuf { + fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir"); + fs::create_dir_all(root.join("lifecycle")).expect("lifecycle dir"); + let log_path = root.join("lifecycle.log"); + fs::write( + root.join("lifecycle").join("init.sh"), + "#!/bin/sh\nprintf 'init\\n' >> \"$(dirname \"$0\")/../lifecycle.log\"\n", + ) + .expect("write init hook"); + fs::write( + root.join("lifecycle").join("shutdown.sh"), + "#!/bin/sh\nprintf 'shutdown\\n' >> \"$(dirname \"$0\")/../lifecycle.log\"\n", + ) + .expect("write shutdown hook"); + fs::write( + root.join(MANIFEST_RELATIVE_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}}" + ), + ) + .expect("write manifest"); + log_path + } + #[test] fn validates_manifest_shape() { let error = validate_manifest(&PluginManifest { @@ -1127,4 +1359,26 @@ mod tests { 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 log_path = write_lifecycle_plugin(&source_root, "lifecycle-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 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); + } } 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/conversation.rs b/rust/crates/runtime/src/conversation.rs index 1e9072d..2f0dc89 100644 --- a/rust/crates/runtime/src/conversation.rs +++ b/rust/crates/runtime/src/conversation.rs @@ -1,6 +1,8 @@ use std::collections::BTreeMap; use std::fmt::{Display, Formatter}; +use plugins::PluginRegistry; + use crate::compact::{ compact_session, estimate_session_tokens, CompactionConfig, CompactionResult, }; @@ -107,6 +109,8 @@ pub struct ConversationRuntime<C, T> { usage_tracker: UsageTracker, hook_runner: HookRunner, auto_compaction_input_tokens_threshold: u32, + plugin_registry: Option<PluginRegistry>, + plugins_shutdown: bool, } impl<C, T> ConversationRuntime<C, T> @@ -140,7 +144,7 @@ where tool_executor: T, permission_policy: PermissionPolicy, system_prompt: Vec<String>, - feature_config: RuntimeFeatureConfig, + feature_config: RuntimeFeatureConfig, ) -> Self { let usage_tracker = UsageTracker::from_session(&session); Self { @@ -153,9 +157,36 @@ where usage_tracker, hook_runner: HookRunner::from_feature_config(&feature_config), auto_compaction_input_tokens_threshold: auto_compaction_threshold_from_env(), + plugin_registry: None, + plugins_shutdown: false, } } + #[allow(clippy::needless_pass_by_value)] + pub fn new_with_plugins( + session: Session, + api_client: C, + tool_executor: T, + permission_policy: PermissionPolicy, + system_prompt: Vec<String>, + feature_config: RuntimeFeatureConfig, + plugin_registry: PluginRegistry, + ) -> Result<Self, RuntimeError> { + plugin_registry + .initialize() + .map_err(|error| RuntimeError::new(format!("plugin initialization failed: {error}")))?; + let mut runtime = Self::new_with_features( + session, + api_client, + tool_executor, + permission_policy, + system_prompt, + feature_config, + ); + runtime.plugin_registry = Some(plugin_registry); + Ok(runtime) + } + #[must_use] pub fn with_max_iterations(mut self, max_iterations: usize) -> Self { self.max_iterations = max_iterations; @@ -304,8 +335,22 @@ where } #[must_use] - pub fn into_session(self) -> Session { - self.session + pub fn into_session(mut self) -> Session { + let _ = self.shutdown_plugins(); + std::mem::take(&mut self.session) + } + + pub fn shutdown_plugins(&mut self) -> Result<(), RuntimeError> { + if self.plugins_shutdown { + return Ok(()); + } + if let Some(registry) = &self.plugin_registry { + registry + .shutdown() + .map_err(|error| RuntimeError::new(format!("plugin shutdown failed: {error}")))?; + } + self.plugins_shutdown = true; + Ok(()) } fn maybe_auto_compact(&mut self) -> Option<AutoCompactionEvent> { @@ -334,6 +379,12 @@ where } } +impl<C, T> Drop for ConversationRuntime<C, T> { + fn drop(&mut self) { + let _ = self.shutdown_plugins(); + } +} + #[must_use] pub fn auto_compaction_threshold_from_env() -> u32 { parse_auto_compaction_threshold( @@ -472,7 +523,11 @@ mod tests { use crate::prompt::{ProjectContext, SystemPromptBuilder}; use crate::session::{ContentBlock, MessageRole, Session}; use crate::usage::TokenUsage; + use plugins::{PluginManager, PluginManagerConfig}; + use std::fs; + use std::path::Path; use std::path::PathBuf; + use std::time::{SystemTime, UNIX_EPOCH}; struct ScriptedApiClient { call_count: usize, @@ -534,6 +589,38 @@ mod tests { } } + fn temp_dir(label: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time should be after epoch") + .as_nanos(); + std::env::temp_dir().join(format!("runtime-plugin-{label}-{nanos}")) + } + + fn write_lifecycle_plugin(root: &Path, name: &str) -> PathBuf { + fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir"); + fs::create_dir_all(root.join("lifecycle")).expect("lifecycle dir"); + let log_path = root.join("lifecycle.log"); + fs::write( + root.join("lifecycle").join("init.sh"), + "#!/bin/sh\nprintf 'init\\n' >> \"$(dirname \"$0\")/../lifecycle.log\"\n", + ) + .expect("write init script"); + fs::write( + root.join("lifecycle").join("shutdown.sh"), + "#!/bin/sh\nprintf 'shutdown\\n' >> \"$(dirname \"$0\")/../lifecycle.log\"\n", + ) + .expect("write shutdown script"); + fs::write( + root.join(".claude-plugin").join("plugin.json"), + format!( + "{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"runtime lifecycle plugin\",\n \"lifecycle\": {{\n \"Init\": [\"./lifecycle/init.sh\"],\n \"Shutdown\": [\"./lifecycle/shutdown.sh\"]\n }}\n}}" + ), + ) + .expect("write plugin manifest"); + log_path + } + #[test] fn runs_user_to_tool_to_result_loop_end_to_end_and_tracks_usage() { let api_client = ScriptedApiClient { call_count: 0 }; @@ -775,6 +862,42 @@ mod tests { ); } + #[test] + fn initializes_and_shuts_down_plugins_with_runtime_lifecycle() { + let config_home = temp_dir("config"); + let source_root = temp_dir("source"); + let log_path = write_lifecycle_plugin(&source_root, "runtime-lifecycle"); + + let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home)); + manager + .install(source_root.to_str().expect("utf8 path")) + .expect("install should succeed"); + let registry = manager.plugin_registry().expect("registry should load"); + + { + let runtime = ConversationRuntime::new_with_plugins( + Session::new(), + ScriptedApiClient { call_count: 0 }, + StaticToolExecutor::new().register("add", |_input| Ok("4".to_string())), + PermissionPolicy::new(PermissionMode::WorkspaceWrite), + vec!["system".to_string()], + RuntimeFeatureConfig::default(), + registry, + ) + .expect("runtime should initialize plugins"); + + let log = fs::read_to_string(&log_path).expect("init log should exist"); + assert_eq!(log, "init\n"); + drop(runtime); + } + + let log = fs::read_to_string(&log_path).expect("shutdown log should exist"); + assert_eq!(log, "init\nshutdown\n"); + + let _ = fs::remove_dir_all(config_home); + let _ = fs::remove_dir_all(source_root); + } + #[test] fn reconstructs_usage_tracker_from_restored_session() { struct SimpleApi; diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index c4d4396..fad96c4 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -23,7 +23,7 @@ use commands::{ }; use compat_harness::{extract_manifest, UpstreamPaths}; use init::initialize_repo; -use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginSummary}; +use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginRegistry, PluginSummary}; use render::{MarkdownStreamState, Spinner, TerminalRenderer}; use runtime::{ clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt, @@ -2456,20 +2456,22 @@ fn build_system_prompt() -> Result<Vec<String>, Box<dyn std::error::Error>> { )?) } -fn build_runtime_feature_config( -) -> Result<runtime::RuntimeFeatureConfig, Box<dyn std::error::Error>> { +fn build_runtime_plugin_state( +) -> Result<(runtime::RuntimeFeatureConfig, PluginRegistry), Box<dyn std::error::Error>> { let cwd = env::current_dir()?; let loader = ConfigLoader::default_for(&cwd); let runtime_config = loader.load()?; let plugin_manager = build_plugin_manager(&cwd, &loader, &runtime_config); - let plugin_hooks = plugin_manager.aggregated_hooks()?; - Ok(runtime_config + let plugin_registry = plugin_manager.plugin_registry()?; + let plugin_hooks = plugin_registry.aggregated_hooks()?; + let feature_config = runtime_config .feature_config() .clone() .with_hooks(runtime_config.hooks().merged(&RuntimeHookConfig::new( plugin_hooks.pre_tool_use, plugin_hooks.post_tool_use, - )))) + ))); + Ok((feature_config, plugin_registry)) } fn build_plugin_manager( @@ -2519,14 +2521,16 @@ fn build_runtime( permission_mode: PermissionMode, ) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>> { - Ok(ConversationRuntime::new_with_features( + let (feature_config, plugin_registry) = build_runtime_plugin_state()?; + Ok(ConversationRuntime::new_with_plugins( session, AnthropicRuntimeClient::new(model, enable_tools, emit_output, allowed_tools.clone())?, CliToolExecutor::new(allowed_tools, emit_output), permission_policy(permission_mode), system_prompt, - build_runtime_feature_config()?, - )) + feature_config, + plugin_registry, + )?) } struct CliPermissionPrompter { From e488e943077cbd3681becb2dc9d8bbbb2329c68e Mon Sep 17 00:00:00 2001 From: Yeachan-Heo <hurrc04@gmail.com> Date: Wed, 1 Apr 2026 06:45:13 +0000 Subject: [PATCH 08/25] =?UTF-8?q?feat:=20plugin=20subsystem=20=E2=80=94=20?= =?UTF-8?q?loader,=20hooks,=20tools,=20bundled,=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rust/Cargo.lock | 2 + rust/crates/commands/Cargo.toml | 1 + rust/crates/commands/src/lib.rs | 250 +++++++++++- rust/crates/plugins/src/lib.rs | 467 +++++++++++++++++++++-- rust/crates/runtime/src/conversation.rs | 189 ++++++++- rust/crates/runtime/src/hooks.rs | 99 ++++- rust/crates/rusty-claude-cli/src/main.rs | 135 +------ 7 files changed, 965 insertions(+), 178 deletions(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 4d45e5e..41e2d35 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -111,6 +111,7 @@ dependencies = [ name = "commands" version = "0.1.0" dependencies = [ + "plugins", "runtime", ] @@ -1100,6 +1101,7 @@ name = "runtime" version = "0.1.0" dependencies = [ "glob", + "plugins", "regex", "serde", "serde_json", 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 be84455..8e7ef9d 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -1,3 +1,4 @@ +use plugins::{PluginError, PluginManager, PluginSummary}; use runtime::{compact_session, CompactionConfig, Session}; #[derive(Debug, Clone, PartialEq, Eq)] @@ -356,6 +357,151 @@ pub struct SlashCommandResult { pub session: Session, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginsCommandResult { + pub message: String, + pub reload_runtime: bool, +} + +pub fn handle_plugins_slash_command( + action: Option<&str>, + target: Option<&str>, + manager: &mut PluginManager, +) -> Result<PluginsCommandResult, PluginError> { + match action { + None | Some("list") => Ok(PluginsCommandResult { + message: render_plugins_report(&manager.list_plugins()?), + reload_runtime: false, + }), + Some("install") => { + let Some(target) = target else { + return Ok(PluginsCommandResult { + message: "Usage: /plugins install <path>".to_string(), + reload_runtime: false, + }); + }; + let install = manager.install(target)?; + let plugin = manager + .list_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 <plugin-id>".to_string(), + reload_runtime: false, + }); + }; + manager.enable(target)?; + Ok(PluginsCommandResult { + message: format!( + "Plugins\n Result enabled {target}\n Status enabled" + ), + reload_runtime: true, + }) + } + Some("disable") => { + let Some(target) = target else { + return Ok(PluginsCommandResult { + message: "Usage: /plugins disable <plugin-id>".to_string(), + reload_runtime: false, + }); + }; + manager.disable(target)?; + Ok(PluginsCommandResult { + message: format!( + "Plugins\n Result disabled {target}\n Status disabled" + ), + reload_runtime: true, + }) + } + Some("uninstall") => { + let Some(target) = target else { + return Ok(PluginsCommandResult { + message: "Usage: /plugins uninstall <plugin-id>".to_string(), + reload_runtime: false, + }); + }; + manager.uninstall(target)?; + Ok(PluginsCommandResult { + message: format!("Plugins\n Result uninstalled {target}"), + reload_runtime: true, + }) + } + Some("update") => { + let Some(target) = target else { + return Ok(PluginsCommandResult { + message: "Usage: /plugins update <plugin-id>".to_string(), + reload_runtime: false, + }); + }; + let update = manager.update(target)?; + let plugin = manager + .list_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 discovered.".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" } + ) +} + #[must_use] pub fn handle_slash_command( input: &str, @@ -410,10 +556,34 @@ 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, render_plugins_report, + render_slash_command_help, resume_supported_slash_commands, slash_command_specs, + SlashCommand, }; + use plugins::{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"); + } #[test] fn parses_supported_slash_commands() { @@ -519,6 +689,13 @@ mod tests { target: Some("demo".to_string()) }) ); + assert_eq!( + SlashCommand::parse("/plugins list"), + Some(SlashCommand::Plugins { + action: Some("list".to_string()), + target: None + }) + ); } #[test] @@ -652,4 +829,73 @@ mod tests { 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: plugins::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: plugins::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 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); + } } diff --git a/rust/crates/plugins/src/lib.rs b/rust/crates/plugins/src/lib.rs index 8016d44..e539add 100644 --- a/rust/crates/plugins/src/lib.rs +++ b/rust/crates/plugins/src/lib.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; use std::fmt::{Display, Formatter}; use std::fs; use std::path::{Path, PathBuf}; -use std::process::Command; +use std::process::{Command, Stdio}; use std::time::{SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; @@ -13,7 +13,9 @@ 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"; +const PACKAGE_MANIFEST_RELATIVE_PATH: &str = MANIFEST_RELATIVE_PATH; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] @@ -87,17 +89,150 @@ impl PluginLifecycle { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct PluginManifest { pub name: String, pub version: String, pub description: String, + #[serde(default)] + pub permissions: Vec<String>, #[serde(rename = "defaultEnabled", default)] pub default_enabled: bool, #[serde(default)] pub hooks: PluginHooks, #[serde(default)] pub lifecycle: PluginLifecycle, + #[serde(default)] + pub tools: Vec<PluginToolManifest>, + #[serde(default)] + pub commands: Vec<PluginCommandManifest>, +} + +#[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<String>, + #[serde(rename = "requiredPermission", default = "default_tool_permission")] + pub required_permission: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PluginToolDefinition { + pub name: String, + #[serde(default)] + pub description: Option<String>, + #[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, +} + +type PluginPackageManifest = PluginManifest; + +#[derive(Debug, Clone, PartialEq)] +pub struct PluginTool { + plugin_id: String, + plugin_name: String, + definition: PluginToolDefinition, + command: String, + args: Vec<String>, + required_permission: String, + root: Option<PathBuf>, +} + +impl PluginTool { + #[must_use] + pub fn new( + plugin_id: impl Into<String>, + plugin_name: impl Into<String>, + definition: PluginToolDefinition, + command: impl Into<String>, + args: Vec<String>, + required_permission: impl Into<String>, + root: Option<PathBuf>, + ) -> Self { + Self { + plugin_id: plugin_id.into(), + plugin_name: plugin_name.into(), + definition, + command: command.into(), + args, + required_permission: required_permission.into(), + 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 + } + + pub fn execute(&self, input: &Value) -> Result<String, PluginError> { + 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() -> String { + "danger-full-access".to_string() } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -125,37 +260,41 @@ pub struct InstalledPluginRegistry { pub plugins: BTreeMap<String, InstalledPluginRecord>, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub struct BuiltinPlugin { metadata: PluginMetadata, hooks: PluginHooks, lifecycle: PluginLifecycle, + tools: Vec<PluginTool>, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub struct BundledPlugin { metadata: PluginMetadata, hooks: PluginHooks, lifecycle: PluginLifecycle, + tools: Vec<PluginTool>, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub struct ExternalPlugin { metadata: PluginMetadata, hooks: PluginHooks, lifecycle: PluginLifecycle, + tools: Vec<PluginTool>, } 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, Eq)] +#[derive(Debug, Clone, PartialEq)] pub enum PluginDefinition { Builtin(BuiltinPlugin), Bundled(BundledPlugin), @@ -175,6 +314,10 @@ impl Plugin for BuiltinPlugin { &self.lifecycle } + fn tools(&self) -> &[PluginTool] { + &self.tools + } + fn validate(&self) -> Result<(), PluginError> { Ok(()) } @@ -201,13 +344,23 @@ impl Plugin for BundledPlugin { &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_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) + run_lifecycle_commands( + self.metadata(), + self.lifecycle(), + "init", + &self.lifecycle.init, + ) } fn shutdown(&self) -> Result<(), PluginError> { @@ -233,13 +386,23 @@ impl Plugin for ExternalPlugin { &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_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) + run_lifecycle_commands( + self.metadata(), + self.lifecycle(), + "init", + &self.lifecycle.init, + ) } fn shutdown(&self) -> Result<(), PluginError> { @@ -277,6 +440,14 @@ impl Plugin for PluginDefinition { } } + 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(), @@ -302,7 +473,7 @@ impl Plugin for PluginDefinition { } } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub struct RegisteredPlugin { definition: PluginDefinition, enabled: bool, @@ -327,6 +498,11 @@ impl RegisteredPlugin { self.definition.hooks() } + #[must_use] + pub fn tools(&self) -> &[PluginTool] { + self.definition.tools() + } + #[must_use] pub fn is_enabled(&self) -> bool { self.enabled @@ -359,7 +535,7 @@ pub struct PluginSummary { pub enabled: bool, } -#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq)] pub struct PluginRegistry { plugins: Vec<RegisteredPlugin>, } @@ -403,6 +579,27 @@ impl PluginRegistry { }) } + pub fn aggregated_tools(&self) -> Result<Vec<PluginTool>, 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()?; @@ -412,7 +609,12 @@ impl PluginRegistry { } pub fn shutdown(&self) -> Result<(), PluginError> { - for plugin in self.plugins.iter().rev().filter(|plugin| plugin.is_enabled()) { + for plugin in self + .plugins + .iter() + .rev() + .filter(|plugin| plugin.is_enabled()) + { plugin.shutdown()?; } Ok(()) @@ -561,7 +763,7 @@ impl PluginManager { pub fn validate_plugin_source(&self, source: &str) -> Result<PluginManifest, PluginError> { let path = resolve_local_source(source)?; - load_validated_manifest_from_root(&path) + load_plugin_from_directory(&path) } pub fn install(&mut self, source: &str) -> Result<InstallOutcome, PluginError> { @@ -569,7 +771,7 @@ impl PluginManager { 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_validated_manifest_from_root(&staged_source)?; + let manifest = load_validated_package_manifest_from_root(&staged_source)?; let plugin_id = plugin_id(&manifest.name, EXTERNAL_MARKETPLACE); let install_path = self.install_root().join(sanitize_plugin_id(&plugin_id)); @@ -647,7 +849,7 @@ impl PluginManager { 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_validated_manifest_from_root(&staged_source)?; + let manifest = load_validated_package_manifest_from_root(&staged_source)?; if record.install_path.exists() { fs::remove_dir_all(&record.install_path)?; @@ -806,6 +1008,7 @@ pub fn builtin_plugins() -> Vec<PluginDefinition> { }, hooks: PluginHooks::default(), lifecycle: PluginLifecycle::default(), + tools: Vec::new(), })] } @@ -815,7 +1018,7 @@ fn load_plugin_definition( source: String, marketplace: &str, ) -> Result<PluginDefinition, PluginError> { - let manifest = load_validated_manifest_from_root(root)?; + let manifest = load_validated_package_manifest_from_root(root)?; let metadata = PluginMetadata { id: plugin_id(&manifest.name, marketplace), name: manifest.name, @@ -828,34 +1031,46 @@ fn load_plugin_definition( }; 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, }), }) } -fn load_validated_manifest_from_root(root: &Path) -> Result<PluginManifest, PluginError> { - let manifest = load_manifest_from_root(root)?; - validate_manifest(&manifest)?; +pub fn load_plugin_from_directory(root: &Path) -> Result<PluginManifest, PluginError> { + let manifest = load_manifest_from_directory(root)?; + validate_plugin_manifest(root, &manifest)?; + Ok(manifest) +} + +fn load_validated_package_manifest_from_root( + root: &Path, +) -> Result<PluginPackageManifest, PluginError> { + let manifest = load_package_manifest_from_root(root)?; + validate_package_manifest(root, &manifest)?; validate_hook_paths(Some(root), &manifest.hooks)?; validate_lifecycle_paths(Some(root), &manifest.lifecycle)?; Ok(manifest) } -fn validate_manifest(manifest: &PluginManifest) -> Result<(), PluginError> { +fn validate_plugin_manifest(root: &Path, manifest: &PluginManifest) -> Result<(), PluginError> { if manifest.name.trim().is_empty() { return Err(PluginError::InvalidManifest( "plugin manifest name cannot be empty".to_string(), @@ -871,10 +1086,45 @@ fn validate_manifest(manifest: &PluginManifest) -> Result<(), PluginError> { "plugin manifest description cannot be empty".to_string(), )); } + validate_named_strings(&manifest.permissions, "permission")?; + validate_hook_paths(Some(root), &manifest.hooks)?; + validate_named_commands(root, &manifest.tools, "tool")?; + validate_named_commands(root, &manifest.commands, "command")?; Ok(()) } -fn load_manifest_from_root(root: &Path) -> Result<PluginManifest, PluginError> { +fn validate_package_manifest(root: &Path, manifest: &PluginPackageManifest) -> Result<(), PluginError> { + if manifest.name.trim().is_empty() { + return Err(PluginError::InvalidManifest( + "plugin manifest name cannot be empty".to_string(), + )); + } + if manifest.version.trim().is_empty() { + return Err(PluginError::InvalidManifest( + "plugin manifest version cannot be empty".to_string(), + )); + } + if manifest.description.trim().is_empty() { + return Err(PluginError::InvalidManifest( + "plugin manifest description cannot be empty".to_string(), + )); + } + validate_named_commands(root, &manifest.tools, "tool")?; + Ok(()) +} + +fn load_manifest_from_directory(root: &Path) -> Result<PluginManifest, PluginError> { + let manifest_path = plugin_manifest_path(root)?; + let contents = fs::read_to_string(&manifest_path).map_err(|error| { + PluginError::NotFound(format!( + "plugin manifest not found at {}: {error}", + manifest_path.display() + )) + })?; + Ok(serde_json::from_str(&contents)?) +} + +fn load_package_manifest_from_root(root: &Path) -> Result<PluginPackageManifest, PluginError> { let manifest_path = root.join(MANIFEST_RELATIVE_PATH); let contents = fs::read_to_string(&manifest_path).map_err(|error| { PluginError::NotFound(format!( @@ -885,6 +1135,109 @@ fn load_manifest_from_root(root: &Path) -> Result<PluginManifest, PluginError> { Ok(serde_json::from_str(&contents)?) } +fn plugin_manifest_path(root: &Path) -> Result<PathBuf, PluginError> { + 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 validate_named_strings(entries: &[String], kind: &str) -> Result<(), PluginError> { + let mut seen = BTreeMap::<&str, ()>::new(); + for entry in entries { + let trimmed = entry.trim(); + if trimmed.is_empty() { + return Err(PluginError::InvalidManifest(format!( + "plugin manifest {kind} cannot be empty" + ))); + } + if seen.insert(trimmed, ()).is_some() { + return Err(PluginError::InvalidManifest(format!( + "plugin manifest {kind} `{trimmed}` is duplicated" + ))); + } + } + Ok(()) +} + +fn validate_named_commands( + root: &Path, + entries: &[impl NamedCommand], + kind: &str, +) -> Result<(), PluginError> { + let mut seen = BTreeMap::<&str, ()>::new(); + for entry in entries { + let name = entry.name().trim(); + if name.is_empty() { + return Err(PluginError::InvalidManifest(format!( + "plugin {kind} name cannot be empty" + ))); + } + if seen.insert(name, ()).is_some() { + return Err(PluginError::InvalidManifest(format!( + "plugin {kind} `{name}` is duplicated" + ))); + } + if entry.description().trim().is_empty() { + return Err(PluginError::InvalidManifest(format!( + "plugin {kind} `{name}` description cannot be empty" + ))); + } + if entry.command().trim().is_empty() { + return Err(PluginError::InvalidManifest(format!( + "plugin {kind} `{name}` command cannot be empty" + ))); + } + validate_command_path(root, entry.command(), kind)?; + } + Ok(()) +} + +trait NamedCommand { + fn name(&self) -> &str; + fn description(&self) -> &str; + fn command(&self) -> &str; +} + +impl NamedCommand for PluginToolManifest { + fn name(&self) -> &str { + &self.name + } + + fn description(&self) -> &str { + &self.description + } + + fn command(&self) -> &str { + &self.command + } +} + +impl NamedCommand for PluginCommandManifest { + fn name(&self) -> &str { + &self.name + } + + fn description(&self) -> &str { + &self.description + } + + fn command(&self) -> &str { + &self.command + } +} + fn resolve_hooks(root: &Path, hooks: &PluginHooks) -> PluginHooks { PluginHooks { pre_tool_use: hooks @@ -915,6 +1268,32 @@ fn resolve_lifecycle(root: &Path, lifecycle: &PluginLifecycle) -> PluginLifecycl } } +fn resolve_tools( + root: &Path, + plugin_id: &str, + plugin_name: &str, + tools: &[PluginToolManifest], +) -> Vec<PluginTool> { + 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.clone(), + Some(root.to_path_buf()), + ) + }) + .collect() +} + fn validate_hook_paths(root: Option<&Path>, hooks: &PluginHooks) -> Result<(), PluginError> { let Some(root) = root else { return Ok(()); @@ -938,6 +1317,16 @@ fn validate_lifecycle_paths( 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(()); @@ -965,7 +1354,7 @@ fn resolve_hook_entry(root: &Path, entry: &str) -> String { } fn is_literal_command(entry: &str) -> bool { - !entry.starts_with("./") && !entry.starts_with("../") + !entry.starts_with("./") && !entry.starts_with("../") && !Path::new(entry).is_absolute() } fn run_lifecycle_commands( @@ -979,17 +1368,29 @@ fn run_lifecycle_commands( } for command in commands { - let output = if Path::new(command).exists() { + let mut process = if Path::new(command).exists() { if cfg!(windows) { - Command::new("cmd").arg("/C").arg(command).output()? + let mut process = Command::new("cmd"); + process.arg("/C").arg(command); + process } else { - Command::new("sh").arg(command).output()? + let mut process = Command::new("sh"); + process.arg(command); + process } } else if cfg!(windows) { - Command::new("cmd").arg("/C").arg(command).output()? + let mut process = Command::new("cmd"); + process.arg("/C").arg(command); + process } else { - Command::new("sh").arg("-lc").arg(command).output()? + 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(); @@ -1206,12 +1607,12 @@ mod tests { let log_path = root.join("lifecycle.log"); fs::write( root.join("lifecycle").join("init.sh"), - "#!/bin/sh\nprintf 'init\\n' >> \"$(dirname \"$0\")/../lifecycle.log\"\n", + "#!/bin/sh\nprintf 'init\\n' >> lifecycle.log\n", ) .expect("write init hook"); fs::write( root.join("lifecycle").join("shutdown.sh"), - "#!/bin/sh\nprintf 'shutdown\\n' >> \"$(dirname \"$0\")/../lifecycle.log\"\n", + "#!/bin/sh\nprintf 'shutdown\\n' >> lifecycle.log\n", ) .expect("write shutdown hook"); fs::write( @@ -1232,6 +1633,7 @@ mod tests { description: "desc".to_string(), default_enabled: false, hooks: PluginHooks::default(), + lifecycle: PluginLifecycle::default(), }) .expect_err("empty name should fail"); assert!(error.to_string().contains("name cannot be empty")); @@ -1364,12 +1766,13 @@ mod tests { 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 log_path = write_lifecycle_plugin(&source_root, "lifecycle-demo", "1.0.0"); + let _ = write_lifecycle_plugin(&source_root, "lifecycle-demo", "1.0.0"); let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home)); - manager + 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"); diff --git a/rust/crates/runtime/src/conversation.rs b/rust/crates/runtime/src/conversation.rs index 2f0dc89..c66cd13 100644 --- a/rust/crates/runtime/src/conversation.rs +++ b/rust/crates/runtime/src/conversation.rs @@ -113,6 +113,21 @@ pub struct ConversationRuntime<C, T> { plugins_shutdown: bool, } +impl<C, T> ConversationRuntime<C, T> { + fn shutdown_registered_plugins(&mut self) -> Result<(), RuntimeError> { + if self.plugins_shutdown { + return Ok(()); + } + if let Some(registry) = &self.plugin_registry { + registry + .shutdown() + .map_err(|error| RuntimeError::new(format!("plugin shutdown failed: {error}")))?; + } + self.plugins_shutdown = true; + Ok(()) + } +} + impl<C, T> ConversationRuntime<C, T> where C: ApiClient, @@ -144,7 +159,7 @@ where tool_executor: T, permission_policy: PermissionPolicy, system_prompt: Vec<String>, - feature_config: RuntimeFeatureConfig, + feature_config: RuntimeFeatureConfig, ) -> Self { let usage_tracker = UsageTracker::from_session(&session); Self { @@ -172,6 +187,11 @@ where feature_config: RuntimeFeatureConfig, plugin_registry: PluginRegistry, ) -> Result<Self, RuntimeError> { + let hook_runner = + HookRunner::from_feature_config_and_plugins(&feature_config, &plugin_registry) + .map_err(|error| { + RuntimeError::new(format!("plugin hook registration failed: {error}")) + })?; plugin_registry .initialize() .map_err(|error| RuntimeError::new(format!("plugin initialization failed: {error}")))?; @@ -183,6 +203,7 @@ where system_prompt, feature_config, ); + runtime.hook_runner = hook_runner; runtime.plugin_registry = Some(plugin_registry); Ok(runtime) } @@ -336,21 +357,12 @@ where #[must_use] pub fn into_session(mut self) -> Session { - let _ = self.shutdown_plugins(); + let _ = self.shutdown_registered_plugins(); std::mem::take(&mut self.session) } pub fn shutdown_plugins(&mut self) -> Result<(), RuntimeError> { - if self.plugins_shutdown { - return Ok(()); - } - if let Some(registry) = &self.plugin_registry { - registry - .shutdown() - .map_err(|error| RuntimeError::new(format!("plugin shutdown failed: {error}")))?; - } - self.plugins_shutdown = true; - Ok(()) + self.shutdown_registered_plugins() } fn maybe_auto_compact(&mut self) -> Option<AutoCompactionEvent> { @@ -381,7 +393,7 @@ where impl<C, T> Drop for ConversationRuntime<C, T> { fn drop(&mut self) { - let _ = self.shutdown_plugins(); + let _ = self.shutdown_registered_plugins(); } } @@ -525,6 +537,8 @@ mod tests { use crate::usage::TokenUsage; use plugins::{PluginManager, PluginManagerConfig}; use std::fs; + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; use std::path::Path; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; @@ -603,12 +617,12 @@ mod tests { let log_path = root.join("lifecycle.log"); fs::write( root.join("lifecycle").join("init.sh"), - "#!/bin/sh\nprintf 'init\\n' >> \"$(dirname \"$0\")/../lifecycle.log\"\n", + "#!/bin/sh\nprintf 'init\\n' >> lifecycle.log\n", ) .expect("write init script"); fs::write( root.join("lifecycle").join("shutdown.sh"), - "#!/bin/sh\nprintf 'shutdown\\n' >> \"$(dirname \"$0\")/../lifecycle.log\"\n", + "#!/bin/sh\nprintf 'shutdown\\n' >> lifecycle.log\n", ) .expect("write shutdown script"); fs::write( @@ -621,6 +635,36 @@ mod tests { log_path } + fn write_hook_plugin(root: &Path, name: &str, pre_message: &str, post_message: &str) { + fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir"); + fs::create_dir_all(root.join("hooks")).expect("hooks dir"); + fs::write( + root.join("hooks").join("pre.sh"), + format!("#!/bin/sh\nprintf '%s\\n' '{pre_message}'\n"), + ) + .expect("write pre hook"); + fs::write( + root.join("hooks").join("post.sh"), + format!("#!/bin/sh\nprintf '%s\\n' '{post_message}'\n"), + ) + .expect("write post hook"); + #[cfg(unix)] + { + let exec_mode = fs::Permissions::from_mode(0o755); + fs::set_permissions(root.join("hooks").join("pre.sh"), exec_mode.clone()) + .expect("chmod pre hook"); + fs::set_permissions(root.join("hooks").join("post.sh"), exec_mode) + .expect("chmod post hook"); + } + fs::write( + root.join(".claude-plugin").join("plugin.json"), + format!( + "{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"runtime hook plugin\",\n \"hooks\": {{\n \"PreToolUse\": [\"./hooks/pre.sh\"],\n \"PostToolUse\": [\"./hooks/post.sh\"]\n }}\n}}" + ), + ) + .expect("write plugin manifest"); + } + #[test] fn runs_user_to_tool_to_result_loop_end_to_end_and_tracks_usage() { let api_client = ScriptedApiClient { call_count: 0 }; @@ -866,12 +910,13 @@ mod tests { fn initializes_and_shuts_down_plugins_with_runtime_lifecycle() { let config_home = temp_dir("config"); let source_root = temp_dir("source"); - let log_path = write_lifecycle_plugin(&source_root, "runtime-lifecycle"); + let _ = write_lifecycle_plugin(&source_root, "runtime-lifecycle"); let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home)); - manager + let install = manager .install(source_root.to_str().expect("utf8 path")) .expect("install should succeed"); + let log_path = install.install_path.join("lifecycle.log"); let registry = manager.plugin_registry().expect("registry should load"); { @@ -898,6 +943,116 @@ mod tests { let _ = fs::remove_dir_all(source_root); } + #[test] + fn executes_hooks_from_installed_plugins_during_tool_use() { + struct TwoCallApiClient { + calls: usize, + } + + impl ApiClient for TwoCallApiClient { + fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> { + self.calls += 1; + match self.calls { + 1 => Ok(vec![ + AssistantEvent::ToolUse { + id: "tool-1".to_string(), + name: "add".to_string(), + input: r#"{"lhs":2,"rhs":2}"#.to_string(), + }, + AssistantEvent::MessageStop, + ]), + 2 => { + assert!(request + .messages + .iter() + .any(|message| message.role == MessageRole::Tool)); + Ok(vec![ + AssistantEvent::TextDelta("done".to_string()), + AssistantEvent::MessageStop, + ]) + } + _ => Err(RuntimeError::new("unexpected extra API call")), + } + } + } + + let config_home = temp_dir("hook-config"); + let first_source_root = temp_dir("hook-source-a"); + let second_source_root = temp_dir("hook-source-b"); + write_hook_plugin( + &first_source_root, + "first", + "plugin pre one", + "plugin post one", + ); + write_hook_plugin( + &second_source_root, + "second", + "plugin pre two", + "plugin post two", + ); + + let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home)); + manager + .install(first_source_root.to_str().expect("utf8 path")) + .expect("first plugin install should succeed"); + manager + .install(second_source_root.to_str().expect("utf8 path")) + .expect("second plugin install should succeed"); + let registry = manager.plugin_registry().expect("registry should load"); + + let mut runtime = ConversationRuntime::new_with_plugins( + Session::new(), + TwoCallApiClient { calls: 0 }, + StaticToolExecutor::new().register("add", |_input| Ok("4".to_string())), + PermissionPolicy::new(PermissionMode::DangerFullAccess), + vec!["system".to_string()], + RuntimeFeatureConfig::default(), + registry, + ) + .expect("runtime should load plugin hooks"); + + let summary = runtime + .run_turn("use add", None) + .expect("tool loop succeeds"); + + assert_eq!(summary.tool_results.len(), 1); + let ContentBlock::ToolResult { + is_error, output, .. + } = &summary.tool_results[0].blocks[0] + else { + panic!("expected tool result block"); + }; + assert!( + !*is_error, + "plugin hooks should not force an error: {output:?}" + ); + assert!( + output.contains('4'), + "tool output missing value: {output:?}" + ); + assert!( + output.contains("plugin pre one"), + "tool output missing first pre hook feedback: {output:?}" + ); + assert!( + output.contains("plugin pre two"), + "tool output missing second pre hook feedback: {output:?}" + ); + assert!( + output.contains("plugin post one"), + "tool output missing first post hook feedback: {output:?}" + ); + assert!( + output.contains("plugin post two"), + "tool output missing second post hook feedback: {output:?}" + ); + + let _ = fs::remove_dir_all(config_home); + let _ = fs::remove_dir_all(first_source_root); + let _ = fs::remove_dir_all(second_source_root); + } + #[test] fn reconstructs_usage_tracker_from_restored_session() { struct SimpleApi; diff --git a/rust/crates/runtime/src/hooks.rs b/rust/crates/runtime/src/hooks.rs index 40da0d6..3e3e8f1 100644 --- a/rust/crates/runtime/src/hooks.rs +++ b/rust/crates/runtime/src/hooks.rs @@ -1,6 +1,8 @@ use std::ffi::OsStr; +use std::path::Path; use std::process::Command; +use plugins::{PluginError, PluginRegistry}; use serde_json::json; use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig}; @@ -62,6 +64,19 @@ impl HookRunner { Self::new(feature_config.hooks().clone()) } + pub fn from_feature_config_and_plugins( + feature_config: &RuntimeFeatureConfig, + plugin_registry: &PluginRegistry, + ) -> Result<Self, PluginError> { + let mut config = feature_config.hooks().clone(); + let plugin_hooks = plugin_registry.aggregated_hooks()?; + config.extend(&RuntimeHookConfig::new( + plugin_hooks.pre_tool_use, + plugin_hooks.post_tool_use, + )); + Ok(Self::new(config)) + } + #[must_use] pub fn run_pre_tool_use(&self, tool_name: &str, tool_input: &str) -> HookRunResult { self.run_commands( @@ -238,7 +253,11 @@ fn shell_command(command: &str) -> CommandWithStdin { }; #[cfg(not(windows))] - let command_builder = { + let command_builder = if Path::new(command).exists() { + let mut command_builder = Command::new("sh"); + command_builder.arg(command); + CommandWithStdin::new(command_builder) + } else { let mut command_builder = Command::new("sh"); command_builder.arg("-lc").arg(command); CommandWithStdin::new(command_builder) @@ -294,6 +313,50 @@ impl CommandWithStdin { mod tests { use super::{HookRunResult, HookRunner}; use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig}; + use plugins::{PluginManager, PluginManagerConfig}; + use std::fs; + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + 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!("hook-runner-{label}-{nanos}")) + } + + fn write_hook_plugin(root: &Path, name: &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"), + "#!/bin/sh\nprintf 'plugin pre'\n", + ) + .expect("write pre hook"); + fs::write( + root.join("hooks").join("post.sh"), + "#!/bin/sh\nprintf 'plugin post'\n", + ) + .expect("write post hook"); + #[cfg(unix)] + { + let exec_mode = fs::Permissions::from_mode(0o755); + fs::set_permissions(root.join("hooks").join("pre.sh"), exec_mode.clone()) + .expect("chmod pre hook"); + fs::set_permissions(root.join("hooks").join("post.sh"), exec_mode) + .expect("chmod post hook"); + } + fs::write( + root.join(".claude-plugin").join("plugin.json"), + format!( + "{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"hook plugin\",\n \"hooks\": {{\n \"PreToolUse\": [\"./hooks/pre.sh\"],\n \"PostToolUse\": [\"./hooks/post.sh\"]\n }}\n}}" + ), + ) + .expect("write plugin manifest"); + } #[test] fn allows_exit_code_zero_and_captures_stdout() { @@ -338,6 +401,40 @@ mod tests { .any(|message| message.contains("allowing tool execution to continue"))); } + #[test] + fn collects_hooks_from_enabled_plugins() { + let config_home = temp_dir("config"); + let source_root = temp_dir("source"); + write_hook_plugin(&source_root, "hooked"); + + let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home)); + manager + .install(source_root.to_str().expect("utf8 path")) + .expect("install should succeed"); + let registry = manager.plugin_registry().expect("registry should build"); + + let runner = HookRunner::from_feature_config_and_plugins( + &RuntimeFeatureConfig::default(), + ®istry, + ) + .expect("plugin hooks should load"); + + let pre_result = runner.run_pre_tool_use("Read", r#"{"path":"README.md"}"#); + let post_result = runner.run_post_tool_use("Read", r#"{"path":"README.md"}"#, "ok", false); + + assert_eq!( + pre_result, + HookRunResult::allow(vec!["plugin pre".to_string()]) + ); + assert_eq!( + post_result, + HookRunResult::allow(vec!["plugin post".to_string()]) + ); + + let _ = fs::remove_dir_all(config_home); + let _ = fs::remove_dir_all(source_root); + } + #[cfg(windows)] fn shell_snippet(script: &str) -> String { script.replace('\'', "\"") diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index fad96c4..a16aa2e 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -19,11 +19,12 @@ use api::{ }; use commands::{ - render_slash_command_help, resume_supported_slash_commands, slash_command_specs, SlashCommand, + handle_plugins_slash_command, render_slash_command_help, resume_supported_slash_commands, + slash_command_specs, SlashCommand, }; use compat_harness::{extract_manifest, UpstreamPaths}; use init::initialize_repo; -use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginRegistry, PluginSummary}; +use plugins::{PluginManager, PluginManagerConfig, PluginRegistry}; use render::{MarkdownStreamState, Spinner, TerminalRenderer}; use runtime::{ clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt, @@ -31,7 +32,7 @@ use runtime::{ AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock, ConversationMessage, ConversationRuntime, MessageRole, OAuthAuthorizationRequest, OAuthConfig, OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError, - RuntimeHookConfig, Session, TokenUsage, ToolError, ToolExecutor, UsageTracker, + Session, TokenUsage, ToolError, ToolExecutor, UsageTracker, }; use serde_json::json; use tools::{execute_tool, mvp_tool_specs, ToolSpec}; @@ -1494,89 +1495,10 @@ impl LiveCli { let loader = ConfigLoader::default_for(&cwd); let runtime_config = loader.load()?; let mut manager = build_plugin_manager(&cwd, &loader, &runtime_config); - - match action { - None | Some("list") => { - let plugins = manager.list_plugins()?; - println!("{}", render_plugins_report(&plugins)); - } - Some("install") => { - let Some(target) = target else { - println!("Usage: /plugins install <path-or-git-url>"); - return Ok(false); - }; - let result = manager.install(target)?; - println!( - "Plugins - Result installed {} - Version {} - Path {}", - result.plugin_id, - result.version, - result.install_path.display(), - ); - self.reload_runtime_features()?; - } - Some("enable") => { - let Some(target) = target else { - println!("Usage: /plugins enable <plugin-id>"); - return Ok(false); - }; - manager.enable(target)?; - println!( - "Plugins - Result enabled {target}" - ); - self.reload_runtime_features()?; - } - Some("disable") => { - let Some(target) = target else { - println!("Usage: /plugins disable <plugin-id>"); - return Ok(false); - }; - manager.disable(target)?; - println!( - "Plugins - Result disabled {target}" - ); - self.reload_runtime_features()?; - } - Some("uninstall") => { - let Some(target) = target else { - println!("Usage: /plugins uninstall <plugin-id>"); - return Ok(false); - }; - manager.uninstall(target)?; - println!( - "Plugins - Result uninstalled {target}" - ); - self.reload_runtime_features()?; - } - Some("update") => { - let Some(target) = target else { - println!("Usage: /plugins update <plugin-id>"); - return Ok(false); - }; - let result = manager.update(target)?; - println!( - "Plugins - Result updated {} - Old version {} - New version {} - Path {}", - result.plugin_id, - result.old_version, - result.new_version, - result.install_path.display(), - ); - self.reload_runtime_features()?; - } - Some(other) => { - println!( - "Unknown /plugins action '{other}'. Use list, install, enable, disable, uninstall, or update." - ); - } + let result = handle_plugins_slash_command(action, target, &mut manager)?; + println!("{}", result.message); + if result.reload_runtime { + self.reload_runtime_features()?; } Ok(false) } @@ -1887,37 +1809,6 @@ fn render_repl_help() -> String { ) } -fn render_plugins_report(plugins: &[PluginSummary]) -> String { - let mut lines = vec!["Plugins".to_string()]; - if plugins.is_empty() { - lines.push(" No plugins discovered.".to_string()); - return lines.join("\n"); - } - for plugin in plugins { - let kind = match plugin.metadata.kind { - PluginKind::Builtin => "builtin", - PluginKind::Bundled => "bundled", - PluginKind::External => "external", - }; - let location = plugin.metadata.root.as_ref().map_or_else( - || plugin.metadata.source.clone(), - |root| root.display().to_string(), - ); - let enabled = if plugin.enabled { - "enabled" - } else { - "disabled" - }; - lines.push(format!( - " {id:<24} {kind:<8} {enabled:<8} v{version:<8} {location}", - id = plugin.metadata.id, - kind = kind, - version = plugin.metadata.version, - )); - } - lines.join("\n") -} - fn status_context( session_path: Option<&Path>, ) -> Result<StatusContext, Box<dyn std::error::Error>> { @@ -2463,15 +2354,7 @@ fn build_runtime_plugin_state( let runtime_config = loader.load()?; let plugin_manager = build_plugin_manager(&cwd, &loader, &runtime_config); let plugin_registry = plugin_manager.plugin_registry()?; - let plugin_hooks = plugin_registry.aggregated_hooks()?; - let feature_config = runtime_config - .feature_config() - .clone() - .with_hooks(runtime_config.hooks().merged(&RuntimeHookConfig::new( - plugin_hooks.pre_tool_use, - plugin_hooks.post_tool_use, - ))); - Ok((feature_config, plugin_registry)) + Ok((runtime_config.feature_config().clone(), plugin_registry)) } fn build_plugin_manager( From 0db9660727b6eeebd67c83370edb2f22bbab0d79 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo <hurrc04@gmail.com> Date: Wed, 1 Apr 2026 06:50:18 +0000 Subject: [PATCH 09/25] feat: plugin subsystem progress --- rust/Cargo.lock | 1 + rust/crates/commands/src/lib.rs | 143 +++++++- rust/crates/plugins/src/hooks.rs | 395 +++++++++++++++++++++ rust/crates/plugins/src/lib.rs | 432 +++++++++++++++++++---- rust/crates/runtime/src/conversation.rs | 68 +++- rust/crates/runtime/src/hooks.rs | 92 ----- rust/crates/rusty-claude-cli/src/main.rs | 141 ++++---- rust/crates/tools/Cargo.toml | 1 + rust/crates/tools/src/lib.rs | 191 ++++++++++ 9 files changed, 1189 insertions(+), 275 deletions(-) create mode 100644 rust/crates/plugins/src/hooks.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 41e2d35..a182255 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1557,6 +1557,7 @@ name = "tools" version = "0.1.0" dependencies = [ "api", + "plugins", "reqwest", "runtime", "serde", diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 8e7ef9d..3caa277 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -1,4 +1,4 @@ -use plugins::{PluginError, PluginManager, PluginSummary}; +use plugins::{PluginError, PluginKind, PluginManager, PluginSummary}; use runtime::{compact_session, CompactionConfig, Session}; #[derive(Debug, Clone, PartialEq, Eq)] @@ -370,7 +370,7 @@ pub fn handle_plugins_slash_command( ) -> Result<PluginsCommandResult, PluginError> { match action { None | Some("list") => Ok(PluginsCommandResult { - message: render_plugins_report(&manager.list_plugins()?), + message: render_plugins_report(&manager.list_installed_plugins()?), reload_runtime: false, }), Some("install") => { @@ -382,7 +382,7 @@ pub fn handle_plugins_slash_command( }; let install = manager.install(target)?; let plugin = manager - .list_plugins()? + .list_installed_plugins()? .into_iter() .find(|plugin| plugin.metadata.id == install.plugin_id); Ok(PluginsCommandResult { @@ -393,14 +393,16 @@ pub fn handle_plugins_slash_command( Some("enable") => { let Some(target) = target else { return Ok(PluginsCommandResult { - message: "Usage: /plugins enable <plugin-id>".to_string(), + message: "Usage: /plugins enable <name>".to_string(), reload_runtime: false, }); }; - manager.enable(target)?; + let plugin = resolve_plugin_target(manager, target)?; + manager.enable(&plugin.metadata.id)?; Ok(PluginsCommandResult { message: format!( - "Plugins\n Result enabled {target}\n Status enabled" + "Plugins\n Result enabled {}\n Name {}\n Version {}\n Status enabled", + plugin.metadata.id, plugin.metadata.name, plugin.metadata.version ), reload_runtime: true, }) @@ -408,14 +410,16 @@ pub fn handle_plugins_slash_command( Some("disable") => { let Some(target) = target else { return Ok(PluginsCommandResult { - message: "Usage: /plugins disable <plugin-id>".to_string(), + message: "Usage: /plugins disable <name>".to_string(), reload_runtime: false, }); }; - manager.disable(target)?; + let plugin = resolve_plugin_target(manager, target)?; + manager.disable(&plugin.metadata.id)?; Ok(PluginsCommandResult { message: format!( - "Plugins\n Result disabled {target}\n Status disabled" + "Plugins\n Result disabled {}\n Name {}\n Version {}\n Status disabled", + plugin.metadata.id, plugin.metadata.name, plugin.metadata.version ), reload_runtime: true, }) @@ -442,7 +446,7 @@ pub fn handle_plugins_slash_command( }; let update = manager.update(target)?; let plugin = manager - .list_plugins()? + .list_installed_plugins()? .into_iter() .find(|plugin| plugin.metadata.id == update.plugin_id); Ok(PluginsCommandResult { @@ -474,18 +478,23 @@ pub fn handle_plugins_slash_command( pub fn render_plugins_report(plugins: &[PluginSummary]) -> String { let mut lines = vec!["Plugins".to_string()]; if plugins.is_empty() { - lines.push(" No plugins discovered.".to_string()); + lines.push(" No plugins installed.".to_string()); return lines.join("\n"); } for plugin in plugins { + let kind = match plugin.metadata.kind { + PluginKind::Builtin => "builtin", + PluginKind::Bundled => "bundled", + PluginKind::External => "external", + }; let enabled = if plugin.enabled { "enabled" } else { "disabled" }; lines.push(format!( - " {name:<20} v{version:<10} {enabled}", - name = plugin.metadata.name, + " {id:<24} {kind:<8} {enabled:<8} v{version}", + id = plugin.metadata.id, version = plugin.metadata.version, )); } @@ -502,6 +511,26 @@ fn render_plugin_install_report(plugin_id: &str, plugin: Option<&PluginSummary>) ) } +fn resolve_plugin_target( + manager: &PluginManager, + target: &str, +) -> Result<PluginSummary, PluginError> { + let mut matches = manager + .list_installed_plugins()? + .into_iter() + .filter(|plugin| plugin.metadata.id == target || plugin.metadata.name == target) + .collect::<Vec<_>>(); + match matches.len() { + 1 => Ok(matches.remove(0)), + 0 => Err(PluginError::NotFound(format!( + "plugin `{target}` is not installed or discoverable" + ))), + _ => Err(PluginError::InvalidManifest(format!( + "plugin name `{target}` is ambiguous; use the full plugin id" + ))), + } +} + #[must_use] pub fn handle_slash_command( input: &str, @@ -560,7 +589,7 @@ mod tests { render_slash_command_help, resume_supported_slash_commands, slash_command_specs, SlashCommand, }; - use plugins::{PluginManager, PluginManagerConfig, PluginMetadata, PluginSummary}; + use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginMetadata, PluginSummary}; use runtime::{CompactionConfig, ContentBlock, ConversationMessage, MessageRole, Session}; use std::fs; use std::path::{Path, PathBuf}; @@ -585,6 +614,18 @@ mod tests { .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"); + } + #[test] fn parses_supported_slash_commands() { assert_eq!(SlashCommand::parse("/help"), Some(SlashCommand::Help)); @@ -839,7 +880,7 @@ mod tests { name: "demo".to_string(), version: "1.2.3".to_string(), description: "demo plugin".to_string(), - kind: plugins::PluginKind::External, + kind: PluginKind::External, source: "demo".to_string(), default_enabled: false, root: None, @@ -852,7 +893,7 @@ mod tests { name: "sample".to_string(), version: "0.9.0".to_string(), description: "sample plugin".to_string(), - kind: plugins::PluginKind::External, + kind: PluginKind::External, source: "sample".to_string(), default_enabled: false, root: None, @@ -861,10 +902,10 @@ mod tests { }, ]); - assert!(rendered.contains("demo")); + assert!(rendered.contains("demo@external")); assert!(rendered.contains("v1.2.3")); assert!(rendered.contains("enabled")); - assert!(rendered.contains("sample")); + assert!(rendered.contains("sample@external")); assert!(rendered.contains("v0.9.0")); assert!(rendered.contains("disabled")); } @@ -891,11 +932,75 @@ mod tests { 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("demo@external")); 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@external")); + 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@external")); + 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@bundled")); + assert!(list.message.contains("bundled")); + assert!(list.message.contains("disabled")); + + let _ = fs::remove_dir_all(config_home); + let _ = fs::remove_dir_all(bundled_root); + } } diff --git a/rust/crates/plugins/src/hooks.rs b/rust/crates/plugins/src/hooks.rs new file mode 100644 index 0000000..feeb762 --- /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<String>, +} + +impl HookRunResult { + #[must_use] + pub fn allow(messages: Vec<String>) -> Self { + Self { + denied: false, + messages, + } + } + + #[must_use] + pub fn is_denied(&self) -> bool { + self.denied + } + + #[must_use] + pub fn messages(&self) -> &[String] { + &self.messages + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct HookRunner { + hooks: PluginHooks, +} + +impl HookRunner { + #[must_use] + pub fn new(hooks: PluginHooks) -> Self { + Self { hooks } + } + + pub fn from_registry(plugin_registry: &PluginRegistry) -> Result<Self, PluginError> { + Ok(Self::new(plugin_registry.aggregated_hooks()?)) + } + + #[must_use] + pub fn run_pre_tool_use(&self, tool_name: &str, tool_input: &str) -> HookRunResult { + self.run_commands( + HookEvent::PreToolUse, + &self.hooks.pre_tool_use, + tool_name, + tool_input, + None, + false, + ) + } + + #[must_use] + pub fn run_post_tool_use( + &self, + tool_name: &str, + tool_input: &str, + tool_output: &str, + is_error: bool, + ) -> HookRunResult { + self.run_commands( + HookEvent::PostToolUse, + &self.hooks.post_tool_use, + tool_name, + tool_input, + Some(tool_output), + is_error, + ) + } + + fn run_commands( + &self, + event: HookEvent, + commands: &[String], + tool_name: &str, + tool_input: &str, + tool_output: Option<&str>, + is_error: bool, + ) -> HookRunResult { + if commands.is_empty() { + return HookRunResult::allow(Vec::new()); + } + + let payload = json!({ + "hook_event_name": event.as_str(), + "tool_name": tool_name, + "tool_input": parse_tool_input(tool_input), + "tool_input_json": tool_input, + "tool_output": tool_output, + "tool_result_is_error": is_error, + }) + .to_string(); + + let mut messages = Vec::new(); + + for command in commands { + match self.run_command( + command, + event, + tool_name, + tool_input, + tool_output, + is_error, + &payload, + ) { + HookCommandOutcome::Allow { message } => { + if let Some(message) = message { + messages.push(message); + } + } + HookCommandOutcome::Deny { message } => { + messages.push(message.unwrap_or_else(|| { + format!("{} hook denied tool `{tool_name}`", event.as_str()) + })); + return HookRunResult { + denied: true, + messages, + }; + } + HookCommandOutcome::Warn { message } => messages.push(message), + } + } + + HookRunResult::allow(messages) + } + + #[allow(clippy::too_many_arguments)] + fn run_command( + &self, + command: &str, + event: HookEvent, + tool_name: &str, + tool_input: &str, + tool_output: Option<&str>, + is_error: bool, + payload: &str, + ) -> HookCommandOutcome { + let mut child = shell_command(command); + child.stdin(std::process::Stdio::piped()); + child.stdout(std::process::Stdio::piped()); + child.stderr(std::process::Stdio::piped()); + child.env("HOOK_EVENT", event.as_str()); + child.env("HOOK_TOOL_NAME", tool_name); + child.env("HOOK_TOOL_INPUT", tool_input); + child.env("HOOK_TOOL_IS_ERROR", if is_error { "1" } else { "0" }); + if let Some(tool_output) = tool_output { + child.env("HOOK_TOOL_OUTPUT", tool_output); + } + + match child.output_with_stdin(payload.as_bytes()) { + Ok(output) => { + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let message = (!stdout.is_empty()).then_some(stdout); + match output.status.code() { + Some(0) => HookCommandOutcome::Allow { message }, + Some(2) => HookCommandOutcome::Deny { message }, + Some(code) => HookCommandOutcome::Warn { + message: format_hook_warning( + command, + code, + message.as_deref(), + stderr.as_str(), + ), + }, + None => HookCommandOutcome::Warn { + message: format!( + "{} hook `{command}` terminated by signal while handling `{tool_name}`", + event.as_str() + ), + }, + } + } + Err(error) => HookCommandOutcome::Warn { + message: format!( + "{} hook `{command}` failed to start for `{tool_name}`: {error}", + event.as_str() + ), + }, + } + } +} + +enum HookCommandOutcome { + Allow { message: Option<String> }, + Deny { message: Option<String> }, + Warn { message: String }, +} + +fn parse_tool_input(tool_input: &str) -> serde_json::Value { + serde_json::from_str(tool_input).unwrap_or_else(|_| json!({ "raw": tool_input })) +} + +fn format_hook_warning(command: &str, code: i32, stdout: Option<&str>, stderr: &str) -> String { + let mut message = + format!("Hook `{command}` exited with status {code}; allowing tool execution to continue"); + if let Some(stdout) = stdout.filter(|stdout| !stdout.is_empty()) { + message.push_str(": "); + message.push_str(stdout); + } else if !stderr.is_empty() { + message.push_str(": "); + message.push_str(stderr); + } + message +} + +fn shell_command(command: &str) -> CommandWithStdin { + #[cfg(windows)] + let command_builder = { + let mut command_builder = Command::new("cmd"); + command_builder.arg("/C").arg(command); + CommandWithStdin::new(command_builder) + }; + + #[cfg(not(windows))] + let command_builder = if Path::new(command).exists() { + let mut command_builder = Command::new("sh"); + command_builder.arg(command); + CommandWithStdin::new(command_builder) + } else { + let mut command_builder = Command::new("sh"); + command_builder.arg("-lc").arg(command); + CommandWithStdin::new(command_builder) + }; + + command_builder +} + +struct CommandWithStdin { + command: Command, +} + +impl CommandWithStdin { + fn new(command: Command) -> Self { + Self { command } + } + + fn stdin(&mut self, cfg: std::process::Stdio) -> &mut Self { + self.command.stdin(cfg); + self + } + + fn stdout(&mut self, cfg: std::process::Stdio) -> &mut Self { + self.command.stdout(cfg); + self + } + + fn stderr(&mut self, cfg: std::process::Stdio) -> &mut Self { + self.command.stderr(cfg); + self + } + + fn env<K, V>(&mut self, key: K, value: V) -> &mut Self + where + K: AsRef<OsStr>, + V: AsRef<OsStr>, + { + self.command.env(key, value); + self + } + + fn output_with_stdin(&mut self, stdin: &[u8]) -> std::io::Result<std::process::Output> { + let mut child = self.command.spawn()?; + if let Some(mut child_stdin) = child.stdin.take() { + use std::io::Write as _; + child_stdin.write_all(stdin)?; + } + child.wait_with_output() + } +} + +#[cfg(test)] +mod tests { + use super::{HookRunResult, HookRunner}; + use crate::{PluginManager, PluginManagerConfig}; + use std::fs; + use std::path::{Path, PathBuf}; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn temp_dir(label: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time should be after epoch") + .as_nanos(); + std::env::temp_dir().join(format!("plugins-hook-runner-{label}-{nanos}")) + } + + fn write_hook_plugin(root: &Path, name: &str, pre_message: &str, post_message: &str) { + fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir"); + fs::create_dir_all(root.join("hooks")).expect("hooks dir"); + fs::write( + root.join("hooks").join("pre.sh"), + format!("#!/bin/sh\nprintf '%s\\n' '{pre_message}'\n"), + ) + .expect("write pre hook"); + fs::write( + root.join("hooks").join("post.sh"), + format!("#!/bin/sh\nprintf '%s\\n' '{post_message}'\n"), + ) + .expect("write post hook"); + fs::write( + root.join(".claude-plugin").join("plugin.json"), + format!( + "{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"hook plugin\",\n \"hooks\": {{\n \"PreToolUse\": [\"./hooks/pre.sh\"],\n \"PostToolUse\": [\"./hooks/post.sh\"]\n }}\n}}" + ), + ) + .expect("write plugin manifest"); + } + + #[test] + fn collects_and_runs_hooks_from_enabled_plugins() { + let config_home = temp_dir("config"); + let first_source_root = temp_dir("source-a"); + let second_source_root = temp_dir("source-b"); + write_hook_plugin( + &first_source_root, + "first", + "plugin pre one", + "plugin post one", + ); + write_hook_plugin( + &second_source_root, + "second", + "plugin pre two", + "plugin post two", + ); + + let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home)); + manager + .install(first_source_root.to_str().expect("utf8 path")) + .expect("first plugin install should succeed"); + manager + .install(second_source_root.to_str().expect("utf8 path")) + .expect("second plugin install should succeed"); + let registry = manager.plugin_registry().expect("registry should build"); + + let runner = HookRunner::from_registry(®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 index e539add..68ba2c4 100644 --- a/rust/crates/plugins/src/lib.rs +++ b/rust/crates/plugins/src/lib.rs @@ -1,3 +1,5 @@ +mod hooks; + use std::collections::BTreeMap; use std::fmt::{Display, Formatter}; use std::fs; @@ -8,6 +10,8 @@ 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"; @@ -15,7 +19,6 @@ 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"; -const PACKAGE_MANIFEST_RELATIVE_PATH: &str = MANIFEST_RELATIVE_PATH; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] @@ -35,6 +38,17 @@ impl Display for PluginKind { } } +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, @@ -244,6 +258,8 @@ pub enum PluginInstallSource { #[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, @@ -260,6 +276,10 @@ pub struct InstalledPluginRegistry { pub plugins: BTreeMap<String, InstalledPluginRecord>, } +fn default_plugin_kind() -> PluginKind { + PluginKind::External +} + #[derive(Debug, Clone, PartialEq)] pub struct BuiltinPlugin { metadata: PluginMetadata, @@ -750,10 +770,15 @@ impl PluginManager { Ok(self.plugin_registry()?.summaries()) } + pub fn list_installed_plugins(&self) -> Result<Vec<PluginSummary>, PluginError> { + Ok(self.installed_plugin_registry()?.summaries()) + } + pub fn discover_plugins(&self) -> Result<Vec<PluginDefinition>, PluginError> { + self.sync_bundled_plugins()?; let mut plugins = builtin_plugins(); - plugins.extend(self.discover_bundled_plugins()?); - plugins.extend(self.discover_external_plugins()?); + plugins.extend(self.discover_installed_plugins()?); + plugins.extend(self.discover_external_directory_plugins(&plugins)?); Ok(plugins) } @@ -761,6 +786,10 @@ impl PluginManager { self.plugin_registry()?.aggregated_hooks() } + pub fn aggregated_tools(&self) -> Result<Vec<PluginTool>, PluginError> { + self.plugin_registry()?.aggregated_tools() + } + pub fn validate_plugin_source(&self, source: &str) -> Result<PluginManifest, PluginError> { let path = resolve_local_source(source)?; load_plugin_from_directory(&path) @@ -785,6 +814,7 @@ impl PluginManager { let now = unix_time_ms(); let record = InstalledPluginRecord { + kind: PluginKind::External, id: plugin_id.clone(), name: manifest.name, version: manifest.version.clone(), @@ -831,6 +861,12 @@ impl PluginManager { 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)?; } @@ -878,40 +914,27 @@ impl PluginManager { }) } - fn discover_bundled_plugins(&self) -> Result<Vec<PluginDefinition>, PluginError> { - discover_plugin_dirs( - &self - .config - .bundled_root - .clone() - .unwrap_or_else(Self::bundled_root), - )? - .into_iter() - .map(|root| { - load_plugin_definition( - &root, - PluginKind::Bundled, - format!("{BUNDLED_MARKETPLACE}:{}", root.display()), - BUNDLED_MARKETPLACE, - ) - }) - .collect() - } - - fn discover_external_plugins(&self) -> Result<Vec<PluginDefinition>, PluginError> { + fn discover_installed_plugins(&self) -> Result<Vec<PluginDefinition>, PluginError> { let registry = self.load_registry()?; - let mut plugins = registry + registry .plugins .values() .map(|record| { load_plugin_definition( &record.install_path, - PluginKind::External, + record.kind, describe_install_source(&record.source), - EXTERNAL_MARKETPLACE, + record.kind.marketplace(), ) }) - .collect::<Result<Vec<_>, _>>()?; + .collect() + } + + fn discover_external_directory_plugins( + &self, + existing_plugins: &[PluginDefinition], + ) -> Result<Vec<PluginDefinition>, PluginError> { + let mut plugins = Vec::new(); for directory in &self.config.external_dirs { for root in discover_plugin_dirs(directory)? { @@ -921,8 +944,9 @@ impl PluginManager { root.display().to_string(), EXTERNAL_MARKETPLACE, )?; - if plugins + if existing_plugins .iter() + .chain(plugins.iter()) .all(|existing| existing.metadata().id != plugin.metadata().id) { plugins.push(plugin); @@ -933,6 +957,84 @@ impl PluginManager { Ok(plugins) } + fn installed_plugin_registry(&self) -> Result<PluginRegistry, PluginError> { + 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)?; + if bundled_plugins.is_empty() { + return Ok(()); + } + + let mut registry = self.load_registry()?; + let mut changed = false; + let install_root = self.install_root(); + + for source_root in bundled_plugins { + let manifest = load_validated_package_manifest_from_root(&source_root)?; + let plugin_id = plugin_id(&manifest.name, BUNDLED_MARKETPLACE); + 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.map_or(true, |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; + } + + if changed { + self.store_registry(®istry)?; + } + + Ok(()) + } + fn is_enabled(&self, metadata: &PluginMetadata) -> bool { self.config .enabled_plugins @@ -1089,11 +1191,15 @@ fn validate_plugin_manifest(root: &Path, manifest: &PluginManifest) -> Result<() validate_named_strings(&manifest.permissions, "permission")?; validate_hook_paths(Some(root), &manifest.hooks)?; validate_named_commands(root, &manifest.tools, "tool")?; + validate_tool_manifest_entries(&manifest.tools)?; validate_named_commands(root, &manifest.commands, "command")?; Ok(()) } -fn validate_package_manifest(root: &Path, manifest: &PluginPackageManifest) -> Result<(), PluginError> { +fn validate_package_manifest( + root: &Path, + manifest: &PluginPackageManifest, +) -> Result<(), PluginError> { if manifest.name.trim().is_empty() { return Err(PluginError::InvalidManifest( "plugin manifest name cannot be empty".to_string(), @@ -1110,6 +1216,7 @@ fn validate_package_manifest(root: &Path, manifest: &PluginPackageManifest) -> R )); } validate_named_commands(root, &manifest.tools, "tool")?; + validate_tool_manifest_entries(&manifest.tools)?; Ok(()) } @@ -1204,6 +1311,27 @@ fn validate_named_commands( Ok(()) } +fn validate_tool_manifest_entries(entries: &[PluginToolManifest]) -> Result<(), PluginError> { + for entry in entries { + if !entry.input_schema.is_object() { + return Err(PluginError::InvalidManifest(format!( + "plugin tool `{}` inputSchema must be a JSON object", + entry.name + ))); + } + if !matches!( + entry.required_permission.as_str(), + "read-only" | "workspace-write" | "danger-full-access" + ) { + return Err(PluginError::InvalidManifest(format!( + "plugin tool `{}` requiredPermission must be read-only, workspace-write, or danger-full-access", + entry.name + ))); + } + } + Ok(()) +} + trait NamedCommand { fn name(&self) -> &str; fn description(&self) -> &str; @@ -1568,75 +1696,225 @@ mod tests { std::env::temp_dir().join(format!("plugins-{label}-{}", unix_time_ms())) } - fn write_external_plugin(root: &Path, name: &str, version: &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"), + 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", - ) - .expect("write pre hook"); - fs::write( - root.join("hooks").join("post.sh"), + ); + 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", - ) - .expect("write post hook"); - fs::write( - root.join(MANIFEST_RELATIVE_PATH), + ); + 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}}" - ), - ) - .expect("write manifest"); + ) + .as_str(), + ); } fn write_broken_plugin(root: &Path, name: &str) { - fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir"); - fs::write( - root.join(MANIFEST_RELATIVE_PATH), + 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}}" - ), - ) - .expect("write broken manifest"); + ) + .as_str(), + ); } fn write_lifecycle_plugin(root: &Path, name: &str, version: &str) -> PathBuf { - fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir"); - fs::create_dir_all(root.join("lifecycle")).expect("lifecycle dir"); let log_path = root.join("lifecycle.log"); - fs::write( - root.join("lifecycle").join("init.sh"), + write_file( + root.join("lifecycle").join("init.sh").as_path(), "#!/bin/sh\nprintf 'init\\n' >> lifecycle.log\n", - ) - .expect("write init hook"); - fs::write( - root.join("lifecycle").join("shutdown.sh"), + ); + write_file( + root.join("lifecycle").join("shutdown.sh").as_path(), "#!/bin/sh\nprintf 'shutdown\\n' >> lifecycle.log\n", - ) - .expect("write shutdown hook"); - fs::write( - root.join(MANIFEST_RELATIVE_PATH), + ); + 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}}" - ), - ) - .expect("write manifest"); + ) + .as_str(), + ); log_path } #[test] - fn validates_manifest_shape() { - let error = validate_manifest(&PluginManifest { - name: String::new(), - version: "1.0.0".to_string(), - description: "desc".to_string(), - default_enabled: false, - hooks: PluginHooks::default(), - lifecycle: PluginLifecycle::default(), - }) - .expect_err("empty name should fail"); + 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, 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.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"); + assert!(error + .to_string() + .contains("permission `read` is duplicated")); + + 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] diff --git a/rust/crates/runtime/src/conversation.rs b/rust/crates/runtime/src/conversation.rs index c66cd13..7e79f9a 100644 --- a/rust/crates/runtime/src/conversation.rs +++ b/rust/crates/runtime/src/conversation.rs @@ -1,13 +1,13 @@ use std::collections::BTreeMap; use std::fmt::{Display, Formatter}; -use plugins::PluginRegistry; +use plugins::{HookRunner as PluginHookRunner, PluginRegistry}; use crate::compact::{ compact_session, estimate_session_tokens, CompactionConfig, CompactionResult, }; use crate::config::RuntimeFeatureConfig; -use crate::hooks::{HookRunResult, HookRunner}; +use crate::hooks::HookRunner; use crate::permissions::{PermissionOutcome, PermissionPolicy, PermissionPrompter}; use crate::session::{ContentBlock, ConversationMessage, Session}; use crate::usage::{TokenUsage, UsageTracker}; @@ -109,6 +109,7 @@ pub struct ConversationRuntime<C, T> { usage_tracker: UsageTracker, hook_runner: HookRunner, auto_compaction_input_tokens_threshold: u32, + plugin_hook_runner: Option<PluginHookRunner>, plugin_registry: Option<PluginRegistry>, plugins_shutdown: bool, } @@ -172,6 +173,7 @@ where usage_tracker, hook_runner: HookRunner::from_feature_config(&feature_config), auto_compaction_input_tokens_threshold: auto_compaction_threshold_from_env(), + plugin_hook_runner: None, plugin_registry: None, plugins_shutdown: false, } @@ -187,11 +189,8 @@ where feature_config: RuntimeFeatureConfig, plugin_registry: PluginRegistry, ) -> Result<Self, RuntimeError> { - let hook_runner = - HookRunner::from_feature_config_and_plugins(&feature_config, &plugin_registry) - .map_err(|error| { - RuntimeError::new(format!("plugin hook registration failed: {error}")) - })?; + let plugin_hook_runner = PluginHookRunner::from_registry(&plugin_registry) + .map_err(|error| RuntimeError::new(format!("plugin hook registration failed: {error}")))?; plugin_registry .initialize() .map_err(|error| RuntimeError::new(format!("plugin initialization failed: {error}")))?; @@ -203,7 +202,7 @@ where system_prompt, feature_config, ); - runtime.hook_runner = hook_runner; + runtime.plugin_hook_runner = Some(plugin_hook_runner); runtime.plugin_registry = Some(plugin_registry); Ok(runtime) } @@ -284,16 +283,36 @@ where ConversationMessage::tool_result( tool_use_id, tool_name, - format_hook_message(&pre_hook_result, &deny_message), + format_hook_message(pre_hook_result.messages(), &deny_message), true, ) } else { + let plugin_pre_hook_result = + self.run_plugin_pre_tool_use(&tool_name, &input); + if plugin_pre_hook_result.is_denied() { + let deny_message = + format!("PreToolUse hook denied tool `{tool_name}`"); + ConversationMessage::tool_result( + tool_use_id, + tool_name, + format_hook_message( + plugin_pre_hook_result.messages(), + &deny_message, + ), + true, + ) + } else { let (mut output, mut is_error) = match self.tool_executor.execute(&tool_name, &input) { Ok(output) => (output, false), Err(error) => (error.to_string(), true), }; output = merge_hook_feedback(pre_hook_result.messages(), output, false); + output = merge_hook_feedback( + plugin_pre_hook_result.messages(), + output, + false, + ); let post_hook_result = self .hook_runner @@ -306,6 +325,16 @@ where output, post_hook_result.is_denied(), ); + let plugin_post_hook_result = + self.run_plugin_post_tool_use(&tool_name, &input, &output, is_error); + if plugin_post_hook_result.is_denied() { + is_error = true; + } + output = merge_hook_feedback( + plugin_post_hook_result.messages(), + output, + plugin_post_hook_result.is_denied(), + ); ConversationMessage::tool_result( tool_use_id, @@ -313,6 +342,7 @@ where output, is_error, ) + } } } PermissionOutcome::Deny { reason } => { @@ -365,6 +395,26 @@ where self.shutdown_registered_plugins() } + fn run_plugin_pre_tool_use(&self, tool_name: &str, input: &str) -> plugins::HookRunResult { + self.plugin_hook_runner.as_ref().map_or_else( + || plugins::HookRunResult::allow(Vec::new()), + |runner| runner.run_pre_tool_use(tool_name, input), + ) + } + + fn run_plugin_post_tool_use( + &self, + tool_name: &str, + input: &str, + output: &str, + is_error: bool, + ) -> plugins::HookRunResult { + self.plugin_hook_runner.as_ref().map_or_else( + || plugins::HookRunResult::allow(Vec::new()), + |runner| runner.run_post_tool_use(tool_name, input, output, is_error), + ) + } + fn maybe_auto_compact(&mut self) -> Option<AutoCompactionEvent> { if self.usage_tracker.cumulative_usage().input_tokens < self.auto_compaction_input_tokens_threshold diff --git a/rust/crates/runtime/src/hooks.rs b/rust/crates/runtime/src/hooks.rs index 3e3e8f1..4aff002 100644 --- a/rust/crates/runtime/src/hooks.rs +++ b/rust/crates/runtime/src/hooks.rs @@ -2,7 +2,6 @@ use std::ffi::OsStr; use std::path::Path; use std::process::Command; -use plugins::{PluginError, PluginRegistry}; use serde_json::json; use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig}; @@ -64,19 +63,6 @@ impl HookRunner { Self::new(feature_config.hooks().clone()) } - pub fn from_feature_config_and_plugins( - feature_config: &RuntimeFeatureConfig, - plugin_registry: &PluginRegistry, - ) -> Result<Self, PluginError> { - let mut config = feature_config.hooks().clone(); - let plugin_hooks = plugin_registry.aggregated_hooks()?; - config.extend(&RuntimeHookConfig::new( - plugin_hooks.pre_tool_use, - plugin_hooks.post_tool_use, - )); - Ok(Self::new(config)) - } - #[must_use] pub fn run_pre_tool_use(&self, tool_name: &str, tool_input: &str) -> HookRunResult { self.run_commands( @@ -313,50 +299,6 @@ impl CommandWithStdin { mod tests { use super::{HookRunResult, HookRunner}; use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig}; - use plugins::{PluginManager, PluginManagerConfig}; - use std::fs; - #[cfg(unix)] - use std::os::unix::fs::PermissionsExt; - 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!("hook-runner-{label}-{nanos}")) - } - - fn write_hook_plugin(root: &Path, name: &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"), - "#!/bin/sh\nprintf 'plugin pre'\n", - ) - .expect("write pre hook"); - fs::write( - root.join("hooks").join("post.sh"), - "#!/bin/sh\nprintf 'plugin post'\n", - ) - .expect("write post hook"); - #[cfg(unix)] - { - let exec_mode = fs::Permissions::from_mode(0o755); - fs::set_permissions(root.join("hooks").join("pre.sh"), exec_mode.clone()) - .expect("chmod pre hook"); - fs::set_permissions(root.join("hooks").join("post.sh"), exec_mode) - .expect("chmod post hook"); - } - fs::write( - root.join(".claude-plugin").join("plugin.json"), - format!( - "{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"hook plugin\",\n \"hooks\": {{\n \"PreToolUse\": [\"./hooks/pre.sh\"],\n \"PostToolUse\": [\"./hooks/post.sh\"]\n }}\n}}" - ), - ) - .expect("write plugin manifest"); - } #[test] fn allows_exit_code_zero_and_captures_stdout() { @@ -401,40 +343,6 @@ mod tests { .any(|message| message.contains("allowing tool execution to continue"))); } - #[test] - fn collects_hooks_from_enabled_plugins() { - let config_home = temp_dir("config"); - let source_root = temp_dir("source"); - write_hook_plugin(&source_root, "hooked"); - - let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home)); - manager - .install(source_root.to_str().expect("utf8 path")) - .expect("install should succeed"); - let registry = manager.plugin_registry().expect("registry should build"); - - let runner = HookRunner::from_feature_config_and_plugins( - &RuntimeFeatureConfig::default(), - ®istry, - ) - .expect("plugin hooks should load"); - - let pre_result = runner.run_pre_tool_use("Read", r#"{"path":"README.md"}"#); - let post_result = runner.run_post_tool_use("Read", r#"{"path":"README.md"}"#, "ok", false); - - assert_eq!( - pre_result, - HookRunResult::allow(vec!["plugin pre".to_string()]) - ); - assert_eq!( - post_result, - HookRunResult::allow(vec!["plugin post".to_string()]) - ); - - let _ = fs::remove_dir_all(config_home); - let _ = fs::remove_dir_all(source_root); - } - #[cfg(windows)] fn shell_snippet(script: &str) -> String { script.replace('\'', "\"") diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index a16aa2e..2442aae 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -35,7 +35,7 @@ use runtime::{ Session, TokenUsage, ToolError, ToolExecutor, UsageTracker, }; use serde_json::json; -use tools::{execute_tool, mvp_tool_specs, ToolSpec}; +use tools::GlobalToolRegistry; const DEFAULT_MODEL: &str = "claude-opus-4-6"; fn max_tokens_for_model(model: &str) -> u32 { @@ -301,51 +301,20 @@ fn resolve_model_alias(model: &str) -> &str { } fn normalize_allowed_tools(values: &[String]) -> Result<Option<AllowedToolSet>, String> { - if values.is_empty() { - return Ok(None); - } - - let canonical_names = mvp_tool_specs() - .into_iter() - .map(|spec| spec.name.to_string()) - .collect::<Vec<_>>(); - let mut name_map = canonical_names - .iter() - .map(|name| (normalize_tool_name(name), name.clone())) - .collect::<BTreeMap<_, _>>(); - - for (alias, canonical) in [ - ("read", "read_file"), - ("write", "write_file"), - ("edit", "edit_file"), - ("glob", "glob_search"), - ("grep", "grep_search"), - ] { - name_map.insert(alias.to_string(), canonical.to_string()); - } - - let mut allowed = AllowedToolSet::new(); - for value in values { - for token in value - .split(|ch: char| ch == ',' || ch.is_whitespace()) - .filter(|token| !token.is_empty()) - { - let normalized = normalize_tool_name(token); - let canonical = name_map.get(&normalized).ok_or_else(|| { - format!( - "unsupported tool in --allowedTools: {token} (expected one of: {})", - canonical_names.join(", ") - ) - })?; - allowed.insert(canonical.clone()); - } - } - - Ok(Some(allowed)) + current_tool_registry() + .unwrap_or_else(|_| GlobalToolRegistry::builtin()) + .normalize_allowed_tools(values) } -fn normalize_tool_name(value: &str) -> String { - value.trim().replace('-', "_").to_ascii_lowercase() +fn current_tool_registry() -> Result<GlobalToolRegistry, String> { + let cwd = env::current_dir().map_err(|error| error.to_string())?; + let loader = ConfigLoader::default_for(&cwd); + let runtime_config = loader.load().map_err(|error| error.to_string())?; + let plugin_manager = build_plugin_manager(&cwd, &loader, &runtime_config); + let plugin_tools = plugin_manager + .aggregated_tools() + .map_err(|error| error.to_string())?; + GlobalToolRegistry::with_plugin_tools(plugin_tools) } fn parse_permission_mode_arg(value: &str) -> Result<PermissionMode, String> { @@ -375,11 +344,11 @@ fn default_permission_mode() -> PermissionMode { .map_or(PermissionMode::DangerFullAccess, permission_mode_from_label) } -fn filter_tool_specs(allowed_tools: Option<&AllowedToolSet>) -> Vec<tools::ToolSpec> { - mvp_tool_specs() - .into_iter() - .filter(|spec| allowed_tools.is_none_or(|allowed| allowed.contains(spec.name))) - .collect() +fn filter_tool_specs( + tool_registry: &GlobalToolRegistry, + allowed_tools: Option<&AllowedToolSet>, +) -> Vec<ToolDefinition> { + tool_registry.definitions(allowed_tools) } fn parse_system_prompt_args(args: &[String]) -> Result<CliAction, String> { @@ -2347,14 +2316,25 @@ fn build_system_prompt() -> Result<Vec<String>, Box<dyn std::error::Error>> { )?) } -fn build_runtime_plugin_state( -) -> Result<(runtime::RuntimeFeatureConfig, PluginRegistry), Box<dyn std::error::Error>> { +fn build_runtime_plugin_state() -> Result< + ( + runtime::RuntimeFeatureConfig, + PluginRegistry, + GlobalToolRegistry, + ), + Box<dyn std::error::Error>, +> { let cwd = env::current_dir()?; let loader = ConfigLoader::default_for(&cwd); let runtime_config = loader.load()?; let plugin_manager = build_plugin_manager(&cwd, &loader, &runtime_config); let plugin_registry = plugin_manager.plugin_registry()?; - Ok((runtime_config.feature_config().clone(), plugin_registry)) + let tool_registry = GlobalToolRegistry::with_plugin_tools(plugin_registry.aggregated_tools()?)?; + Ok(( + runtime_config.feature_config().clone(), + plugin_registry, + tool_registry, + )) } fn build_plugin_manager( @@ -2404,12 +2384,18 @@ fn build_runtime( permission_mode: PermissionMode, ) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>> { - let (feature_config, plugin_registry) = build_runtime_plugin_state()?; + let (feature_config, plugin_registry, tool_registry) = build_runtime_plugin_state()?; Ok(ConversationRuntime::new_with_plugins( session, - AnthropicRuntimeClient::new(model, enable_tools, emit_output, allowed_tools.clone())?, - CliToolExecutor::new(allowed_tools, emit_output), - permission_policy(permission_mode), + AnthropicRuntimeClient::new( + model, + enable_tools, + emit_output, + allowed_tools.clone(), + tool_registry.clone(), + )?, + CliToolExecutor::new(allowed_tools.clone(), emit_output, tool_registry.clone()), + permission_policy(permission_mode, &tool_registry), system_prompt, feature_config, plugin_registry, @@ -2469,6 +2455,7 @@ struct AnthropicRuntimeClient { enable_tools: bool, emit_output: bool, allowed_tools: Option<AllowedToolSet>, + tool_registry: GlobalToolRegistry, } impl AnthropicRuntimeClient { @@ -2477,6 +2464,7 @@ impl AnthropicRuntimeClient { enable_tools: bool, emit_output: bool, allowed_tools: Option<AllowedToolSet>, + tool_registry: GlobalToolRegistry, ) -> Result<Self, Box<dyn std::error::Error>> { Ok(Self { runtime: tokio::runtime::Runtime::new()?, @@ -2486,6 +2474,7 @@ impl AnthropicRuntimeClient { enable_tools, emit_output, allowed_tools, + tool_registry, }) } } @@ -2508,16 +2497,9 @@ impl ApiClient for AnthropicRuntimeClient { max_tokens: max_tokens_for_model(&self.model), messages: convert_messages(&request.messages), system: (!request.system_prompt.is_empty()).then(|| request.system_prompt.join("\n\n")), - tools: self.enable_tools.then(|| { - filter_tool_specs(self.allowed_tools.as_ref()) - .into_iter() - .map(|spec| ToolDefinition { - name: spec.name.to_string(), - description: Some(spec.description.to_string()), - input_schema: spec.input_schema, - }) - .collect() - }), + tools: self + .enable_tools + .then(|| filter_tool_specs(&self.tool_registry, self.allowed_tools.as_ref())), tool_choice: self.enable_tools.then_some(ToolChoice::Auto), stream: true, }; @@ -3108,14 +3090,20 @@ struct CliToolExecutor { renderer: TerminalRenderer, emit_output: bool, allowed_tools: Option<AllowedToolSet>, + tool_registry: GlobalToolRegistry, } impl CliToolExecutor { - fn new(allowed_tools: Option<AllowedToolSet>, emit_output: bool) -> Self { + fn new( + allowed_tools: Option<AllowedToolSet>, + emit_output: bool, + tool_registry: GlobalToolRegistry, + ) -> Self { Self { renderer: TerminalRenderer::new(), emit_output, allowed_tools, + tool_registry, } } } @@ -3133,7 +3121,7 @@ impl ToolExecutor for CliToolExecutor { } let value = serde_json::from_str(input) .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?; - match execute_tool(tool_name, &value) { + match self.tool_registry.execute(tool_name, &value) { Ok(output) => { if self.emit_output { let markdown = format_tool_result(tool_name, &output, false); @@ -3156,16 +3144,13 @@ impl ToolExecutor for CliToolExecutor { } } -fn permission_policy(mode: PermissionMode) -> PermissionPolicy { - tool_permission_specs() - .into_iter() - .fold(PermissionPolicy::new(mode), |policy, spec| { - policy.with_tool_requirement(spec.name, spec.required_permission) - }) -} - -fn tool_permission_specs() -> Vec<ToolSpec> { - mvp_tool_specs() +fn permission_policy(mode: PermissionMode, tool_registry: &GlobalToolRegistry) -> PermissionPolicy { + tool_registry.permission_specs(None).into_iter().fold( + PermissionPolicy::new(mode), + |policy, (name, required_permission)| { + policy.with_tool_requirement(name, required_permission) + }, + ) } fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> { diff --git a/rust/crates/tools/Cargo.toml b/rust/crates/tools/Cargo.toml index dfa003d..9ecbb06 100644 --- a/rust/crates/tools/Cargo.toml +++ b/rust/crates/tools/Cargo.toml @@ -7,6 +7,7 @@ publish.workspace = true [dependencies] api = { path = "../api" } +plugins = { path = "../plugins" } runtime = { path = "../runtime" } reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] } serde = { version = "1", features = ["derive"] } diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index 4071c9b..79294de 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -8,6 +8,7 @@ use api::{ MessageRequest, MessageResponse, OutputContentBlock, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock, }; +use plugins::PluginTool; use reqwest::blocking::Client; use runtime::{ edit_file, execute_bash, glob_search, grep_search, load_system_prompt, read_file, write_file, @@ -55,6 +56,196 @@ pub struct ToolSpec { pub required_permission: PermissionMode, } +#[derive(Debug, Clone, PartialEq)] +pub struct RegisteredTool { + pub definition: ToolDefinition, + pub required_permission: PermissionMode, + handler: RegisteredToolHandler, +} + +#[derive(Debug, Clone, PartialEq)] +enum RegisteredToolHandler { + Builtin, + Plugin(PluginTool), +} + +#[derive(Debug, Clone, PartialEq)] +pub struct GlobalToolRegistry { + entries: Vec<RegisteredTool>, +} + +impl GlobalToolRegistry { + #[must_use] + pub fn builtin() -> Self { + Self { + entries: mvp_tool_specs() + .into_iter() + .map(|spec| RegisteredTool { + definition: ToolDefinition { + name: spec.name.to_string(), + description: Some(spec.description.to_string()), + input_schema: spec.input_schema, + }, + required_permission: spec.required_permission, + handler: RegisteredToolHandler::Builtin, + }) + .collect(), + } + } + + pub fn with_plugin_tools(plugin_tools: Vec<PluginTool>) -> Result<Self, String> { + let mut registry = Self::builtin(); + let mut seen = registry + .entries + .iter() + .map(|entry| { + ( + normalize_registry_tool_name(&entry.definition.name), + entry.definition.name.clone(), + ) + }) + .collect::<BTreeMap<_, _>>(); + + for tool in plugin_tools { + let normalized = normalize_registry_tool_name(&tool.definition().name); + if let Some(existing) = seen.get(&normalized) { + return Err(format!( + "plugin tool `{}` from `{}` conflicts with already-registered tool `{existing}`", + tool.definition().name, + tool.plugin_id() + )); + } + seen.insert(normalized, tool.definition().name.clone()); + registry.entries.push(RegisteredTool { + definition: ToolDefinition { + name: tool.definition().name.clone(), + description: tool.definition().description.clone(), + input_schema: tool.definition().input_schema.clone(), + }, + required_permission: permission_mode_from_plugin_tool(tool.required_permission())?, + handler: RegisteredToolHandler::Plugin(tool), + }); + } + + Ok(registry) + } + + #[must_use] + pub fn entries(&self) -> &[RegisteredTool] { + &self.entries + } + + #[must_use] + pub fn definitions(&self, allowed_tools: Option<&BTreeSet<String>>) -> Vec<ToolDefinition> { + self.entries + .iter() + .filter(|entry| { + allowed_tools.is_none_or(|allowed| allowed.contains(entry.definition.name.as_str())) + }) + .map(|entry| entry.definition.clone()) + .collect() + } + + #[must_use] + pub fn permission_specs( + &self, + allowed_tools: Option<&BTreeSet<String>>, + ) -> Vec<(String, PermissionMode)> { + self.entries + .iter() + .filter(|entry| { + allowed_tools.is_none_or(|allowed| allowed.contains(entry.definition.name.as_str())) + }) + .map(|entry| (entry.definition.name.clone(), entry.required_permission)) + .collect() + } + + pub fn normalize_allowed_tools( + &self, + values: &[String], + ) -> Result<Option<BTreeSet<String>>, String> { + if values.is_empty() { + return Ok(None); + } + + let canonical_names = self + .entries + .iter() + .map(|entry| entry.definition.name.clone()) + .collect::<Vec<_>>(); + let mut name_map = canonical_names + .iter() + .map(|name| (normalize_registry_tool_name(name), name.clone())) + .collect::<BTreeMap<_, _>>(); + + for (alias, canonical) in [ + ("read", "read_file"), + ("write", "write_file"), + ("edit", "edit_file"), + ("glob", "glob_search"), + ("grep", "grep_search"), + ] { + if canonical_names.iter().any(|name| name == canonical) { + name_map.insert(alias.to_string(), canonical.to_string()); + } + } + + let mut allowed = BTreeSet::new(); + for value in values { + for token in value + .split(|ch: char| ch == ',' || ch.is_whitespace()) + .filter(|token| !token.is_empty()) + { + let normalized = normalize_registry_tool_name(token); + let canonical = name_map.get(&normalized).ok_or_else(|| { + format!( + "unsupported tool in --allowedTools: {token} (expected one of: {})", + canonical_names.join(", ") + ) + })?; + allowed.insert(canonical.clone()); + } + } + + Ok(Some(allowed)) + } + + pub fn execute(&self, name: &str, input: &Value) -> Result<String, String> { + let entry = self + .entries + .iter() + .find(|entry| entry.definition.name == name) + .ok_or_else(|| format!("unsupported tool: {name}"))?; + match &entry.handler { + RegisteredToolHandler::Builtin => execute_tool(name, input), + RegisteredToolHandler::Plugin(tool) => { + tool.execute(input).map_err(|error| error.to_string()) + } + } + } +} + +impl Default for GlobalToolRegistry { + fn default() -> Self { + Self::builtin() + } +} + +fn normalize_registry_tool_name(value: &str) -> String { + value.trim().replace('-', "_").to_ascii_lowercase() +} + +fn permission_mode_from_plugin_tool(value: &str) -> Result<PermissionMode, String> { + match value { + "read-only" => Ok(PermissionMode::ReadOnly), + "workspace-write" => Ok(PermissionMode::WorkspaceWrite), + "danger-full-access" => Ok(PermissionMode::DangerFullAccess), + other => Err(format!( + "unsupported plugin tool permission `{other}` (expected read-only, workspace-write, or danger-full-access)" + )), + } +} + #[must_use] #[allow(clippy::too_many_lines)] pub fn mvp_tool_specs() -> Vec<ToolSpec> { From 123a7f4013a1fd08d3fb28bf54e9ad666def79f0 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo <hurrc04@gmail.com> Date: Wed, 1 Apr 2026 06:55:39 +0000 Subject: [PATCH 10/25] feat: plugin hooks + tool registry + CLI integration --- rust/crates/commands/src/lib.rs | 44 +-- rust/crates/plugins/src/hooks.rs | 2 +- rust/crates/plugins/src/lib.rs | 334 ++++++++++++++++++++++- rust/crates/runtime/src/conversation.rs | 105 +++---- rust/crates/rusty-claude-cli/src/main.rs | 3 +- rust/crates/tools/src/lib.rs | 83 +++++- 6 files changed, 489 insertions(+), 82 deletions(-) diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 3caa277..84f1b4a 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -1,4 +1,4 @@ -use plugins::{PluginError, PluginKind, PluginManager, PluginSummary}; +use plugins::{PluginError, PluginManager, PluginSummary}; use runtime::{compact_session, CompactionConfig, Session}; #[derive(Debug, Clone, PartialEq, Eq)] @@ -176,7 +176,7 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ name: "plugins", summary: "List or manage plugins", argument_hint: Some( - "[list|install <source>|enable <id>|disable <id>|uninstall <id>|update <id>]", + "[list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]", ), resume_supported: false, }, @@ -363,6 +363,7 @@ pub struct PluginsCommandResult { pub reload_runtime: bool, } +#[allow(clippy::too_many_lines)] pub fn handle_plugins_slash_command( action: Option<&str>, target: Option<&str>, @@ -482,19 +483,14 @@ pub fn render_plugins_report(plugins: &[PluginSummary]) -> String { return lines.join("\n"); } for plugin in plugins { - let kind = match plugin.metadata.kind { - PluginKind::Builtin => "builtin", - PluginKind::Bundled => "bundled", - PluginKind::External => "external", - }; let enabled = if plugin.enabled { "enabled" } else { "disabled" }; lines.push(format!( - " {id:<24} {kind:<8} {enabled:<8} v{version}", - id = plugin.metadata.id, + " {name:<20} v{version:<10} {enabled}", + name = plugin.metadata.name, version = plugin.metadata.version, )); } @@ -737,6 +733,20 @@ mod tests { 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] @@ -766,7 +776,7 @@ mod tests { assert!(help.contains("/export [file]")); assert!(help.contains("/session [list|switch <session-id>]")); assert!(help.contains( - "/plugins [list|install <source>|enable <id>|disable <id>|uninstall <id>|update <id>]" + "/plugins [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]" )); assert_eq!(slash_command_specs().len(), 23); assert_eq!(resume_supported_slash_commands().len(), 11); @@ -902,10 +912,10 @@ mod tests { }, ]); - assert!(rendered.contains("demo@external")); + assert!(rendered.contains("demo")); assert!(rendered.contains("v1.2.3")); assert!(rendered.contains("enabled")); - assert!(rendered.contains("sample@external")); + assert!(rendered.contains("sample")); assert!(rendered.contains("v0.9.0")); assert!(rendered.contains("disabled")); } @@ -932,7 +942,7 @@ mod tests { 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@external")); + assert!(list.message.contains("demo")); assert!(list.message.contains("v1.0.0")); assert!(list.message.contains("enabled")); @@ -963,7 +973,7 @@ mod tests { let list = handle_plugins_slash_command(Some("list"), None, &mut manager) .expect("list command should succeed"); - assert!(list.message.contains("demo@external")); + assert!(list.message.contains("demo")); assert!(list.message.contains("disabled")); let enable = handle_plugins_slash_command(Some("enable"), Some("demo"), &mut manager) @@ -975,7 +985,7 @@ mod tests { let list = handle_plugins_slash_command(Some("list"), None, &mut manager) .expect("list command should succeed"); - assert!(list.message.contains("demo@external")); + assert!(list.message.contains("demo")); assert!(list.message.contains("enabled")); let _ = fs::remove_dir_all(config_home); @@ -996,8 +1006,8 @@ mod tests { 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@bundled")); - assert!(list.message.contains("bundled")); + assert!(list.message.contains("starter")); + assert!(list.message.contains("v0.1.0")); assert!(list.message.contains("disabled")); let _ = fs::remove_dir_all(config_home); diff --git a/rust/crates/plugins/src/hooks.rs b/rust/crates/plugins/src/hooks.rs index feeb762..d473da8 100644 --- a/rust/crates/plugins/src/hooks.rs +++ b/rust/crates/plugins/src/hooks.rs @@ -148,7 +148,7 @@ impl HookRunner { HookRunResult::allow(messages) } - #[allow(clippy::too_many_arguments)] + #[allow(clippy::too_many_arguments, clippy::unused_self)] fn run_command( &self, command: &str, diff --git a/rust/crates/plugins/src/lib.rs b/rust/crates/plugins/src/lib.rs index 68ba2c4..185a877 100644 --- a/rust/crates/plugins/src/lib.rs +++ b/rust/crates/plugins/src/lib.rs @@ -1,6 +1,6 @@ mod hooks; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::fmt::{Display, Formatter}; use std::fs; use std::path::{Path, PathBuf}; @@ -108,8 +108,7 @@ pub struct PluginManifest { pub name: String, pub version: String, pub description: String, - #[serde(default)] - pub permissions: Vec<String>, + pub permissions: Vec<PluginPermission>, #[serde(rename = "defaultEnabled", default)] pub default_enabled: bool, #[serde(default)] @@ -122,6 +121,34 @@ pub struct PluginManifest { pub commands: Vec<PluginCommandManifest>, } +#[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<Self> { + match value { + "read" => Some(Self::Read), + "write" => Some(Self::Write), + "execute" => Some(Self::Execute), + _ => None, + } + } +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct PluginToolManifest { pub name: String, @@ -131,8 +158,35 @@ pub struct PluginToolManifest { pub command: String, #[serde(default)] pub args: Vec<String>, - #[serde(rename = "requiredPermission", default = "default_tool_permission")] - pub required_permission: String, + 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<Self> { + 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)] @@ -151,6 +205,38 @@ pub struct PluginCommandManifest { 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<String>, + #[serde(rename = "defaultEnabled", default)] + pub default_enabled: bool, + #[serde(default)] + pub hooks: PluginHooks, + #[serde(default)] + pub lifecycle: PluginLifecycle, + #[serde(default)] + pub tools: Vec<RawPluginToolManifest>, + #[serde(default)] + pub commands: Vec<PluginCommandManifest>, +} + +#[derive(Debug, Clone, PartialEq, 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<String>, + #[serde(rename = "requiredPermission", default = "default_raw_tool_permission")] + pub required_permission: String, +} + type PluginPackageManifest = PluginManifest; #[derive(Debug, Clone, PartialEq)] @@ -160,7 +246,7 @@ pub struct PluginTool { definition: PluginToolDefinition, command: String, args: Vec<String>, - required_permission: String, + required_permission: PluginToolPermission, root: Option<PathBuf>, } @@ -172,7 +258,7 @@ impl PluginTool { definition: PluginToolDefinition, command: impl Into<String>, args: Vec<String>, - required_permission: impl Into<String>, + required_permission: PluginToolPermission, root: Option<PathBuf>, ) -> Self { Self { @@ -181,7 +267,7 @@ impl PluginTool { definition, command: command.into(), args, - required_permission: required_permission.into(), + required_permission, root, } } @@ -198,7 +284,7 @@ impl PluginTool { #[must_use] pub fn required_permission(&self) -> &str { - &self.required_permission + self.required_permission.as_str() } pub fn execute(&self, input: &Value) -> Result<String, PluginError> { @@ -245,7 +331,7 @@ impl PluginTool { } } -fn default_tool_permission() -> String { +fn default_raw_tool_permission() -> String { "danger-full-access".to_string() } @@ -685,10 +771,74 @@ pub struct UpdateOutcome { 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<String>, + }, + 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<PluginManifestValidationError>), InvalidManifest(String), NotFound(String), CommandFailed(String), @@ -699,6 +849,15 @@ impl Display for PluginError { 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}"), @@ -991,7 +1150,7 @@ impl PluginManager { 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.map_or(true, |record| { + let needs_sync = existing_record.is_none_or(|record| { record.kind != PluginKind::Bundled || record.version != manifest.version || record.name != manifest.name @@ -1261,7 +1420,7 @@ fn plugin_manifest_path(root: &Path) -> Result<PathBuf, PluginError> { } fn validate_named_strings(entries: &[String], kind: &str) -> Result<(), PluginError> { - let mut seen = BTreeMap::<&str, ()>::new(); + let mut seen = BTreeSet::<&str>::new(); for entry in entries { let trimmed = entry.trim(); if trimmed.is_empty() { @@ -1269,7 +1428,7 @@ fn validate_named_strings(entries: &[String], kind: &str) -> Result<(), PluginEr "plugin manifest {kind} cannot be empty" ))); } - if seen.insert(trimmed, ()).is_some() { + if !seen.insert(trimmed) { return Err(PluginError::InvalidManifest(format!( "plugin manifest {kind} `{trimmed}` is duplicated" ))); @@ -1283,7 +1442,7 @@ fn validate_named_commands( entries: &[impl NamedCommand], kind: &str, ) -> Result<(), PluginError> { - let mut seen = BTreeMap::<&str, ()>::new(); + let mut seen = BTreeSet::<&str>::new(); for entry in entries { let name = entry.name().trim(); if name.is_empty() { @@ -1291,7 +1450,7 @@ fn validate_named_commands( "plugin {kind} name cannot be empty" ))); } - if seen.insert(name, ()).is_some() { + if !seen.insert(name) { return Err(PluginError::InvalidManifest(format!( "plugin {kind} `{name}` is duplicated" ))); @@ -1796,6 +1955,59 @@ mod tests { log_path } + fn write_tool_plugin(root: &Path, name: &str, version: &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\": \"plugin_echo\",\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<String, bool> { + 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"); @@ -1977,6 +2189,70 @@ mod tests { 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 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 validates_plugin_source_before_install() { let config_home = temp_dir("validate-home"); @@ -2062,4 +2338,32 @@ mod tests { 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); + } } diff --git a/rust/crates/runtime/src/conversation.rs b/rust/crates/runtime/src/conversation.rs index 7e79f9a..2fc1872 100644 --- a/rust/crates/runtime/src/conversation.rs +++ b/rust/crates/runtime/src/conversation.rs @@ -189,8 +189,10 @@ where feature_config: RuntimeFeatureConfig, plugin_registry: PluginRegistry, ) -> Result<Self, RuntimeError> { - let plugin_hook_runner = PluginHookRunner::from_registry(&plugin_registry) - .map_err(|error| RuntimeError::new(format!("plugin hook registration failed: {error}")))?; + let plugin_hook_runner = + PluginHookRunner::from_registry(&plugin_registry).map_err(|error| { + RuntimeError::new(format!("plugin hook registration failed: {error}")) + })?; plugin_registry .initialize() .map_err(|error| RuntimeError::new(format!("plugin initialization failed: {error}")))?; @@ -219,6 +221,7 @@ where self } + #[allow(clippy::too_many_lines)] pub fn run_turn( &mut self, user_input: impl Into<String>, @@ -292,56 +295,64 @@ where if plugin_pre_hook_result.is_denied() { let deny_message = format!("PreToolUse hook denied tool `{tool_name}`"); + let mut messages = pre_hook_result.messages().to_vec(); + messages.extend(plugin_pre_hook_result.messages().iter().cloned()); ConversationMessage::tool_result( tool_use_id, tool_name, - format_hook_message( - plugin_pre_hook_result.messages(), - &deny_message, - ), + format_hook_message(&messages, &deny_message), true, ) } else { - let (mut output, mut is_error) = - match self.tool_executor.execute(&tool_name, &input) { - Ok(output) => (output, false), - Err(error) => (error.to_string(), true), - }; - output = merge_hook_feedback(pre_hook_result.messages(), output, false); - output = merge_hook_feedback( - plugin_pre_hook_result.messages(), - output, - false, - ); + let (mut output, mut is_error) = + match self.tool_executor.execute(&tool_name, &input) { + Ok(output) => (output, false), + Err(error) => (error.to_string(), true), + }; + output = + merge_hook_feedback(pre_hook_result.messages(), output, false); + output = merge_hook_feedback( + plugin_pre_hook_result.messages(), + output, + false, + ); - let post_hook_result = self - .hook_runner - .run_post_tool_use(&tool_name, &input, &output, is_error); - if post_hook_result.is_denied() { - is_error = true; - } - output = merge_hook_feedback( - post_hook_result.messages(), - output, - post_hook_result.is_denied(), - ); - let plugin_post_hook_result = - self.run_plugin_post_tool_use(&tool_name, &input, &output, is_error); - if plugin_post_hook_result.is_denied() { - is_error = true; - } - output = merge_hook_feedback( - plugin_post_hook_result.messages(), - output, - plugin_post_hook_result.is_denied(), - ); + let hook_output = output.clone(); + let post_hook_result = self.hook_runner.run_post_tool_use( + &tool_name, + &input, + &hook_output, + is_error, + ); + let plugin_post_hook_result = self.run_plugin_post_tool_use( + &tool_name, + &input, + &hook_output, + is_error, + ); + if post_hook_result.is_denied() { + is_error = true; + } + if plugin_post_hook_result.is_denied() { + is_error = true; + } + output = merge_hook_feedback( + post_hook_result.messages(), + output, + post_hook_result.is_denied(), + ); + output = merge_hook_feedback( + plugin_post_hook_result.messages(), + output, + plugin_post_hook_result.is_denied(), + ); - ConversationMessage::tool_result( - tool_use_id, - tool_name, - output, - is_error, - ) + ConversationMessage::tool_result( + tool_use_id, + tool_name, + output, + is_error, + ) } } } @@ -511,11 +522,11 @@ fn flush_text_block(text: &mut String, blocks: &mut Vec<ContentBlock>) { } } -fn format_hook_message(result: &HookRunResult, fallback: &str) -> String { - if result.messages().is_empty() { +fn format_hook_message(messages: &[String], fallback: &str) -> String { + if messages.is_empty() { fallback.to_string() } else { - result.messages().join("\n") + messages.join("\n") } } diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 2442aae..67e5965 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -3301,6 +3301,7 @@ mod tests { use runtime::{AssistantEvent, ContentBlock, ConversationMessage, MessageRole, PermissionMode}; use serde_json::json; use std::path::PathBuf; + use tools::GlobalToolRegistry; #[test] fn defaults_to_repl_when_no_args() { @@ -3548,7 +3549,7 @@ mod tests { assert!(help.contains("/export [file]")); assert!(help.contains("/session [list|switch <session-id>]")); assert!(help.contains( - "/plugins [list|install <source>|enable <id>|disable <id>|uninstall <id>|update <id>]" + "/plugins [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]" )); assert!(help.contains("/exit")); } diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index 79294de..91a6868 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -3096,8 +3096,9 @@ mod tests { use super::{ agent_permission_policy, allowed_tools_for_subagent, execute_agent_with_spawn, execute_tool, final_assistant_text, mvp_tool_specs, persist_agent_terminal_state, - AgentInput, AgentJob, SubagentToolExecutor, + AgentInput, AgentJob, GlobalToolRegistry, SubagentToolExecutor, }; + use plugins::{PluginTool, PluginToolDefinition}; use runtime::{ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, Session}; use serde_json::json; @@ -3114,6 +3115,17 @@ mod tests { std::env::temp_dir().join(format!("clawd-tools-{unique}-{name}")) } + fn make_executable(path: &PathBuf) { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + let mut permissions = std::fs::metadata(path).expect("metadata").permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(path, permissions).expect("chmod"); + } + } + #[test] fn exposes_mvp_tools() { let names = mvp_tool_specs() @@ -3143,6 +3155,75 @@ mod tests { assert!(error.contains("unsupported tool")); } + #[test] + fn global_registry_registers_and_executes_plugin_tools() { + let script = temp_path("plugin-tool.sh"); + std::fs::write( + &script, + "#!/bin/sh\nINPUT=$(cat)\nprintf '{\"plugin\":\"%s\",\"tool\":\"%s\",\"input\":%s}\\n' \"$CLAWD_PLUGIN_ID\" \"$CLAWD_TOOL_NAME\" \"$INPUT\"\n", + ) + .expect("write script"); + make_executable(&script); + + let registry = GlobalToolRegistry::with_plugin_tools(vec![PluginTool::new( + "demo@external", + "demo", + PluginToolDefinition { + name: "plugin_echo".to_string(), + description: Some("Echo plugin input".to_string()), + input_schema: json!({ + "type": "object", + "properties": { "message": { "type": "string" } }, + "required": ["message"], + "additionalProperties": false + }), + }, + script.display().to_string(), + Vec::new(), + "workspace-write", + script.parent().map(PathBuf::from), + )]) + .expect("registry should build"); + + let names = registry + .definitions(None) + .into_iter() + .map(|definition| definition.name) + .collect::<Vec<_>>(); + assert!(names.contains(&"bash".to_string())); + assert!(names.contains(&"plugin_echo".to_string())); + + let output = registry + .execute("plugin_echo", &json!({ "message": "hello" })) + .expect("plugin tool should execute"); + let payload: serde_json::Value = serde_json::from_str(&output).expect("valid json"); + assert_eq!(payload["plugin"], "demo@external"); + assert_eq!(payload["tool"], "plugin_echo"); + assert_eq!(payload["input"]["message"], "hello"); + + let _ = std::fs::remove_file(script); + } + + #[test] + fn global_registry_rejects_conflicting_plugin_tool_names() { + let error = GlobalToolRegistry::with_plugin_tools(vec![PluginTool::new( + "demo@external", + "demo", + PluginToolDefinition { + name: "read-file".to_string(), + description: Some("Conflicts with builtin".to_string()), + input_schema: json!({ "type": "object" }), + }, + "echo".to_string(), + Vec::new(), + "read-only", + None, + )]) + .expect_err("conflicting plugin tool should fail"); + + assert!(error.contains("conflicts with already-registered tool `read_file`")); + } + #[test] fn web_fetch_returns_prompt_aware_summary() { let server = TestServer::spawn(Arc::new(|request_line: &str| { From 5f66392f45951cab66a0b90a5022a587f9df8222 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo <hurrc04@gmail.com> Date: Wed, 1 Apr 2026 06:58:00 +0000 Subject: [PATCH 11/25] feat: plugin subsystem final in-flight progress --- rust/crates/plugins/src/lib.rs | 397 ++++++++++++++++++++++----------- 1 file changed, 267 insertions(+), 130 deletions(-) diff --git a/rust/crates/plugins/src/lib.rs b/rust/crates/plugins/src/lib.rs index 185a877..802d95b 100644 --- a/rust/crates/plugins/src/lib.rs +++ b/rust/crates/plugins/src/lib.rs @@ -149,6 +149,12 @@ impl PluginPermission { } } +impl AsRef<str> for PluginPermission { + fn as_ref(&self) -> &str { + self.as_str() + } +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct PluginToolManifest { pub name: String, @@ -224,7 +230,7 @@ struct RawPluginManifest { pub commands: Vec<PluginCommandManifest>, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] struct RawPluginToolManifest { pub name: String, pub description: String, @@ -233,7 +239,10 @@ struct RawPluginToolManifest { pub command: String, #[serde(default)] pub args: Vec<String>, - #[serde(rename = "requiredPermission", default = "default_raw_tool_permission")] + #[serde( + rename = "requiredPermission", + default = "default_tool_permission_label" + )] pub required_permission: String, } @@ -331,7 +340,7 @@ impl PluginTool { } } -fn default_raw_tool_permission() -> String { +fn default_tool_permission_label() -> String { "danger-full-access".to_string() } @@ -773,17 +782,31 @@ pub struct UpdateOutcome { #[derive(Debug, Clone, PartialEq, Eq)] pub enum PluginManifestValidationError { - EmptyField { field: &'static str }, + EmptyField { + field: &'static str, + }, EmptyEntryField { kind: &'static str, field: &'static str, name: Option<String>, }, - InvalidPermission { permission: String }, - DuplicatePermission { permission: String }, - DuplicateEntry { kind: &'static str, name: String }, - MissingPath { kind: &'static str, path: PathBuf }, - InvalidToolInputSchema { tool_name: String }, + 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, @@ -1316,89 +1339,34 @@ fn load_plugin_definition( } pub fn load_plugin_from_directory(root: &Path) -> Result<PluginManifest, PluginError> { - let manifest = load_manifest_from_directory(root)?; - validate_plugin_manifest(root, &manifest)?; - Ok(manifest) + load_manifest_from_directory(root) } fn load_validated_package_manifest_from_root( root: &Path, ) -> Result<PluginPackageManifest, PluginError> { - let manifest = load_package_manifest_from_root(root)?; - validate_package_manifest(root, &manifest)?; - validate_hook_paths(Some(root), &manifest.hooks)?; - validate_lifecycle_paths(Some(root), &manifest.lifecycle)?; - Ok(manifest) -} - -fn validate_plugin_manifest(root: &Path, manifest: &PluginManifest) -> Result<(), PluginError> { - if manifest.name.trim().is_empty() { - return Err(PluginError::InvalidManifest( - "plugin manifest name cannot be empty".to_string(), - )); - } - if manifest.version.trim().is_empty() { - return Err(PluginError::InvalidManifest( - "plugin manifest version cannot be empty".to_string(), - )); - } - if manifest.description.trim().is_empty() { - return Err(PluginError::InvalidManifest( - "plugin manifest description cannot be empty".to_string(), - )); - } - validate_named_strings(&manifest.permissions, "permission")?; - validate_hook_paths(Some(root), &manifest.hooks)?; - validate_named_commands(root, &manifest.tools, "tool")?; - validate_tool_manifest_entries(&manifest.tools)?; - validate_named_commands(root, &manifest.commands, "command")?; - Ok(()) -} - -fn validate_package_manifest( - root: &Path, - manifest: &PluginPackageManifest, -) -> Result<(), PluginError> { - if manifest.name.trim().is_empty() { - return Err(PluginError::InvalidManifest( - "plugin manifest name cannot be empty".to_string(), - )); - } - if manifest.version.trim().is_empty() { - return Err(PluginError::InvalidManifest( - "plugin manifest version cannot be empty".to_string(), - )); - } - if manifest.description.trim().is_empty() { - return Err(PluginError::InvalidManifest( - "plugin manifest description cannot be empty".to_string(), - )); - } - validate_named_commands(root, &manifest.tools, "tool")?; - validate_tool_manifest_entries(&manifest.tools)?; - Ok(()) + load_package_manifest_from_root(root) } fn load_manifest_from_directory(root: &Path) -> Result<PluginManifest, PluginError> { let manifest_path = plugin_manifest_path(root)?; - let contents = fs::read_to_string(&manifest_path).map_err(|error| { - PluginError::NotFound(format!( - "plugin manifest not found at {}: {error}", - manifest_path.display() - )) - })?; - Ok(serde_json::from_str(&contents)?) + load_manifest_from_path(root, &manifest_path) } fn load_package_manifest_from_root(root: &Path) -> Result<PluginPackageManifest, PluginError> { let manifest_path = root.join(MANIFEST_RELATIVE_PATH); + load_manifest_from_path(root, &manifest_path) +} + +fn load_manifest_from_path(root: &Path, manifest_path: &Path) -> Result<PluginManifest, PluginError> { let contents = fs::read_to_string(&manifest_path).map_err(|error| { PluginError::NotFound(format!( "plugin manifest not found at {}: {error}", manifest_path.display() )) })?; - Ok(serde_json::from_str(&contents)?) + let raw_manifest: RawPluginManifest = serde_json::from_str(&contents)?; + build_plugin_manifest(root, raw_manifest) } fn plugin_manifest_path(root: &Path) -> Result<PathBuf, PluginError> { @@ -1419,76 +1387,238 @@ fn plugin_manifest_path(root: &Path) -> Result<PathBuf, PluginError> { ))) } -fn validate_named_strings(entries: &[String], kind: &str) -> Result<(), PluginError> { - let mut seen = BTreeSet::<&str>::new(); - for entry in entries { - let trimmed = entry.trim(); - if trimmed.is_empty() { - return Err(PluginError::InvalidManifest(format!( - "plugin manifest {kind} cannot be empty" - ))); - } - if !seen.insert(trimmed) { - return Err(PluginError::InvalidManifest(format!( - "plugin manifest {kind} `{trimmed}` is duplicated" - ))); - } +fn build_plugin_manifest(root: &Path, raw: RawPluginManifest) -> Result<PluginManifest, PluginError> { + 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(()) + + 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_named_commands( +fn validate_required_manifest_field( + field: &'static str, + value: &str, + errors: &mut Vec<PluginManifestValidationError>, +) { + if value.trim().is_empty() { + errors.push(PluginManifestValidationError::EmptyField { field }); + } +} + +fn build_manifest_permissions( + permissions: &[String], + errors: &mut Vec<PluginManifestValidationError>, +) -> Vec<PluginPermission> { + 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, - entries: &[impl NamedCommand], - kind: &str, -) -> Result<(), PluginError> { - let mut seen = BTreeSet::<&str>::new(); - for entry in entries { - let name = entry.name().trim(); + tools: Vec<RawPluginToolManifest>, + errors: &mut Vec<PluginManifestValidationError>, +) -> Vec<PluginToolManifest> { + 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() { - return Err(PluginError::InvalidManifest(format!( - "plugin {kind} name cannot be empty" - ))); + errors.push(PluginManifestValidationError::EmptyEntryField { + kind: "tool", + field: "name", + name: None, + }); + continue; } - if !seen.insert(name) { - return Err(PluginError::InvalidManifest(format!( - "plugin {kind} `{name}` is duplicated" - ))); + if !seen.insert(name.clone()) { + errors.push(PluginManifestValidationError::DuplicateEntry { + kind: "tool", + name, + }); + continue; } - if entry.description().trim().is_empty() { - return Err(PluginError::InvalidManifest(format!( - "plugin {kind} `{name}` description cannot be empty" - ))); + if tool.description.trim().is_empty() { + errors.push(PluginManifestValidationError::EmptyEntryField { + kind: "tool", + field: "description", + name: Some(name.clone()), + }); } - if entry.command().trim().is_empty() { - return Err(PluginError::InvalidManifest(format!( - "plugin {kind} `{name}` command cannot be empty" - ))); + 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); } - validate_command_path(root, entry.command(), kind)?; + 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, + }); } - Ok(()) + + validated } -fn validate_tool_manifest_entries(entries: &[PluginToolManifest]) -> Result<(), PluginError> { - for entry in entries { - if !entry.input_schema.is_object() { - return Err(PluginError::InvalidManifest(format!( - "plugin tool `{}` inputSchema must be a JSON object", - entry.name - ))); +fn build_manifest_commands( + root: &Path, + commands: Vec<PluginCommandManifest>, + errors: &mut Vec<PluginManifestValidationError>, +) -> Vec<PluginCommandManifest> { + 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 !matches!( - entry.required_permission.as_str(), - "read-only" | "workspace-write" | "danger-full-access" - ) { - return Err(PluginError::InvalidManifest(format!( - "plugin tool `{}` requiredPermission must be read-only, workspace-write, or danger-full-access", - entry.name - ))); + 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<Item = &'a String>, + kind: &'static str, + errors: &mut Vec<PluginManifestValidationError>, +) { + 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<PluginManifestValidationError>, +) { + 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 }); } - Ok(()) } trait NamedCommand { @@ -1574,7 +1704,7 @@ fn resolve_tools( }, resolve_hook_entry(root, &tool.command), tool.args.clone(), - tool.required_permission.clone(), + tool.required_permission, Some(root.to_path_buf()), ) }) @@ -2030,7 +2160,14 @@ mod tests { 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, vec!["read", "write"]); + assert_eq!( + manifest + .permissions + .iter() + .map(|permission| permission.as_str()) + .collect::<Vec<_>>(), + 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"); From 6520cf8c3fd622ebf84adae45deaf8119a4b999b Mon Sep 17 00:00:00 2001 From: Yeachan-Heo <hurrc04@gmail.com> Date: Wed, 1 Apr 2026 07:16:13 +0000 Subject: [PATCH 12/25] test: cover installed plugin directory scanning --- rust/crates/plugins/src/lib.rs | 209 ++++++++++++++++++++------------- 1 file changed, 129 insertions(+), 80 deletions(-) diff --git a/rust/crates/plugins/src/lib.rs b/rust/crates/plugins/src/lib.rs index 802d95b..8c859a5 100644 --- a/rust/crates/plugins/src/lib.rs +++ b/rust/crates/plugins/src/lib.rs @@ -246,8 +246,6 @@ struct RawPluginToolManifest { pub required_permission: String, } -type PluginPackageManifest = PluginManifest; - #[derive(Debug, Clone, PartialEq)] pub struct PluginTool { plugin_id: String, @@ -982,7 +980,7 @@ impl PluginManager { 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_validated_package_manifest_from_root(&staged_source)?; + 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)); @@ -1067,7 +1065,7 @@ impl PluginManager { 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_validated_package_manifest_from_root(&staged_source)?; + let manifest = load_plugin_from_directory(&staged_source)?; if record.install_path.exists() { fs::remove_dir_all(&record.install_path)?; @@ -1098,18 +1096,26 @@ impl PluginManager { fn discover_installed_plugins(&self) -> Result<Vec<PluginDefinition>, PluginError> { let registry = self.load_registry()?; - registry - .plugins - .values() - .map(|record| { - load_plugin_definition( - &record.install_path, - record.kind, - describe_install_source(&record.source), - record.kind.marketplace(), - ) - }) - .collect() + let mut plugins = Vec::new(); + let mut seen_ids = BTreeSet::<String>::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()) { + plugins.push(plugin); + } + } + + Ok(plugins) } fn discover_external_directory_plugins( @@ -1168,7 +1174,7 @@ impl PluginManager { let install_root = self.install_root(); for source_root in bundled_plugins { - let manifest = load_validated_package_manifest_from_root(&source_root)?; + let manifest = load_plugin_from_directory(&source_root)?; let plugin_id = plugin_id(&manifest.name, BUNDLED_MARKETPLACE); let install_path = install_root.join(sanitize_plugin_id(&plugin_id)); let now = unix_time_ms(); @@ -1302,7 +1308,7 @@ fn load_plugin_definition( source: String, marketplace: &str, ) -> Result<PluginDefinition, PluginError> { - let manifest = load_validated_package_manifest_from_root(root)?; + let manifest = load_plugin_from_directory(root)?; let metadata = PluginMetadata { id: plugin_id(&manifest.name, marketplace), name: manifest.name, @@ -1342,24 +1348,16 @@ pub fn load_plugin_from_directory(root: &Path) -> Result<PluginManifest, PluginE load_manifest_from_directory(root) } -fn load_validated_package_manifest_from_root( - root: &Path, -) -> Result<PluginPackageManifest, PluginError> { - load_package_manifest_from_root(root) -} - fn load_manifest_from_directory(root: &Path) -> Result<PluginManifest, PluginError> { let manifest_path = plugin_manifest_path(root)?; load_manifest_from_path(root, &manifest_path) } -fn load_package_manifest_from_root(root: &Path) -> Result<PluginPackageManifest, PluginError> { - let manifest_path = root.join(MANIFEST_RELATIVE_PATH); - load_manifest_from_path(root, &manifest_path) -} - -fn load_manifest_from_path(root: &Path, manifest_path: &Path) -> Result<PluginManifest, PluginError> { - let contents = fs::read_to_string(&manifest_path).map_err(|error| { +fn load_manifest_from_path( + root: &Path, + manifest_path: &Path, +) -> Result<PluginManifest, PluginError> { + let contents = fs::read_to_string(manifest_path).map_err(|error| { PluginError::NotFound(format!( "plugin manifest not found at {}: {error}", manifest_path.display() @@ -1387,7 +1385,10 @@ fn plugin_manifest_path(root: &Path) -> Result<PathBuf, PluginError> { ))) } -fn build_plugin_manifest(root: &Path, raw: RawPluginManifest) -> Result<PluginManifest, PluginError> { +fn build_plugin_manifest( + root: &Path, + raw: RawPluginManifest, +) -> Result<PluginManifest, PluginError> { let mut errors = Vec::new(); validate_required_manifest_field("name", &raw.name, &mut errors); @@ -1397,7 +1398,12 @@ fn build_plugin_manifest(root: &Path, raw: RawPluginManifest) -> Result<PluginMa 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.init.iter(), + "lifecycle command", + &mut errors, + ); validate_command_entries( root, raw.lifecycle.shutdown.iter(), @@ -1487,10 +1493,7 @@ fn build_manifest_tools( continue; } if !seen.insert(name.clone()) { - errors.push(PluginManifestValidationError::DuplicateEntry { - kind: "tool", - name, - }); + errors.push(PluginManifestValidationError::DuplicateEntry { kind: "tool", name }); continue; } if tool.description.trim().is_empty() { @@ -1514,11 +1517,15 @@ fn build_manifest_tools( 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(), - }); + 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; }; @@ -1621,40 +1628,6 @@ fn validate_command_entry( } } -trait NamedCommand { - fn name(&self) -> &str; - fn description(&self) -> &str; - fn command(&self) -> &str; -} - -impl NamedCommand for PluginToolManifest { - fn name(&self) -> &str { - &self.name - } - - fn description(&self) -> &str { - &self.description - } - - fn command(&self) -> &str { - &self.command - } -} - -impl NamedCommand for PluginCommandManifest { - fn name(&self) -> &str { - &self.name - } - - fn description(&self) -> &str { - &self.description - } - - fn command(&self) -> &str { - &self.command - } -} - fn resolve_hooks(root: &Path, hooks: &PluginHooks) -> PluginHooks { PluginHooks { pre_tool_use: hooks @@ -1890,10 +1863,11 @@ fn discover_plugin_dirs(root: &Path) -> Result<Vec<PathBuf>, PluginError> { let mut paths = Vec::new(); for entry in entries { let path = entry?.path(); - if path.join(MANIFEST_RELATIVE_PATH).exists() { + 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()), @@ -2171,6 +2145,10 @@ mod tests { 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"); @@ -2233,9 +2211,21 @@ mod tests { ); let error = load_plugin_from_directory(&root).expect_err("duplicates should fail"); - assert!(error - .to_string() - .contains("permission `read` is duplicated")); + 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); } @@ -2266,6 +2256,34 @@ mod tests { 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 discovers_builtin_and_bundled_plugins() { let manager = PluginManager::new(PluginManagerConfig::new(temp_dir("discover"))); @@ -2503,4 +2521,35 @@ mod tests { 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); + } } From f967484b9a1de4c6219d2288e2cd5ec29009041d Mon Sep 17 00:00:00 2001 From: Yeachan-Heo <hurrc04@gmail.com> Date: Wed, 1 Apr 2026 07:20:13 +0000 Subject: [PATCH 13/25] feat: plugin system follow-up progress --- rust/crates/tools/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index 91a6868..83b0b03 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -63,6 +63,7 @@ pub struct RegisteredTool { handler: RegisteredToolHandler, } +#[allow(clippy::large_enum_variant)] #[derive(Debug, Clone, PartialEq)] enum RegisteredToolHandler { Builtin, From a10bbaf8de6287a72259616045347fb9ef1029b6 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo <hurrc04@gmail.com> Date: Wed, 1 Apr 2026 07:22:41 +0000 Subject: [PATCH 14/25] Keep plugin-aware CLI validation aligned with the shared registry The shared /plugins command flow already routes through the plugin registry, but allowed-tool normalization still fell back to builtin tools when registry construction failed. This keeps plugin-related validation errors visible at the CLI boundary and updates tools tests to use the enum-based plugin permission API so workspace verification remains green. Constraint: Plugin tool permissions are now strongly typed in the plugins crate Rejected: Restore string-based permission arguments in tests | weakens the plugin API contract Rejected: Keep builtin fallback in normalize_allowed_tools | masks plugin registry integration failures Confidence: high Scope-risk: narrow Reversibility: clean Directive: Do not silently bypass current_tool_registry() failures unless plugin-aware allowed-tool validation is intentionally being disabled Tested: cargo test -p commands -- --nocapture; cargo test --workspace Not-tested: Manual REPL /plugins interaction in a live session --- rust/crates/rusty-claude-cli/src/main.rs | 55 +++++++++++++++++++++--- rust/crates/tools/src/lib.rs | 6 +-- 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 67e5965..b7188c2 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -301,9 +301,7 @@ fn resolve_model_alias(model: &str) -> &str { } fn normalize_allowed_tools(values: &[String]) -> Result<Option<AllowedToolSet>, String> { - current_tool_registry() - .unwrap_or_else(|_| GlobalToolRegistry::builtin()) - .normalize_allowed_tools(values) + current_tool_registry()?.normalize_allowed_tools(values) } fn current_tool_registry() -> Result<GlobalToolRegistry, String> { @@ -3292,17 +3290,42 @@ mod tests { filter_tool_specs, format_compact_report, format_cost_report, format_model_report, format_model_switch_report, format_permissions_report, format_permissions_switch_report, format_resume_report, format_status_report, format_tool_call_start, format_tool_result, - normalize_permission_mode, parse_args, parse_git_status_metadata, print_help_to, - push_output_block, render_config_report, render_memory_report, render_repl_help, - resolve_model_alias, response_to_events, resume_supported_slash_commands, status_context, - CliAction, CliOutputFormat, SlashCommand, StatusUsage, DEFAULT_MODEL, + normalize_permission_mode, parse_args, parse_git_status_metadata, permission_policy, + print_help_to, push_output_block, render_config_report, render_memory_report, + render_repl_help, resolve_model_alias, response_to_events, resume_supported_slash_commands, + status_context, CliAction, CliOutputFormat, SlashCommand, StatusUsage, DEFAULT_MODEL, }; use api::{MessageResponse, OutputContentBlock, Usage}; + use plugins::{PluginTool, PluginToolDefinition, PluginToolPermission}; use runtime::{AssistantEvent, ContentBlock, ConversationMessage, MessageRole, PermissionMode}; use serde_json::json; use std::path::PathBuf; use tools::GlobalToolRegistry; + fn registry_with_plugin_tool() -> GlobalToolRegistry { + GlobalToolRegistry::with_plugin_tools(vec![PluginTool::new( + "plugin-demo@external", + "plugin-demo", + PluginToolDefinition { + name: "plugin_echo".to_string(), + description: Some("Echo plugin payload".to_string()), + input_schema: json!({ + "type": "object", + "properties": { + "message": { "type": "string" } + }, + "required": ["message"], + "additionalProperties": false + }), + }, + "echo".to_string(), + Vec::new(), + PluginToolPermission::WorkspaceWrite, + None, + )]) + .expect("plugin tool registry should build") + } + #[test] fn defaults_to_repl_when_no_args() { assert_eq!( @@ -3523,6 +3546,24 @@ mod tests { assert_eq!(names, vec!["read_file", "grep_search"]); } + #[test] + fn filtered_tool_specs_include_plugin_tools() { + let filtered = filter_tool_specs(®istry_with_plugin_tool(), None); + let names = filtered + .into_iter() + .map(|definition| definition.name) + .collect::<Vec<_>>(); + assert!(names.contains(&"bash".to_string())); + assert!(names.contains(&"plugin_echo".to_string())); + } + + #[test] + fn permission_policy_uses_plugin_tool_permissions() { + let policy = permission_policy(PermissionMode::ReadOnly, ®istry_with_plugin_tool()); + let required = policy.required_mode_for("plugin_echo"); + assert_eq!(required, PermissionMode::WorkspaceWrite); + } + #[test] fn shared_help_uses_resume_annotation_copy() { let help = commands::render_slash_command_help(); diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index 83b0b03..fea9ce1 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -3099,7 +3099,7 @@ mod tests { execute_tool, final_assistant_text, mvp_tool_specs, persist_agent_terminal_state, AgentInput, AgentJob, GlobalToolRegistry, SubagentToolExecutor, }; - use plugins::{PluginTool, PluginToolDefinition}; + use plugins::{PluginTool, PluginToolDefinition, PluginToolPermission}; use runtime::{ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, Session}; use serde_json::json; @@ -3181,7 +3181,7 @@ mod tests { }, script.display().to_string(), Vec::new(), - "workspace-write", + PluginToolPermission::WorkspaceWrite, script.parent().map(PathBuf::from), )]) .expect("registry should build"); @@ -3217,7 +3217,7 @@ mod tests { }, "echo".to_string(), Vec::new(), - "read-only", + PluginToolPermission::ReadOnly, None, )]) .expect_err("conflicting plugin tool should fail"); From 28be7b3e24c53b2d99e1fcb0d2a5beb47f0ea26b Mon Sep 17 00:00:00 2001 From: Yeachan-Heo <hurrc04@gmail.com> Date: Wed, 1 Apr 2026 07:23:10 +0000 Subject: [PATCH 15/25] Tighten plugin manifest validation and installed-plugin discovery Expanded the Rust plugin loader coverage around manifest parsing so invalid permission values, invalid tool permissions, and multi-error manifests are validated in a structured way. Added scan-path coverage for installed plugin directories so both root and packaged manifests are discovered from the install root, independent of registry entries. Constraint: Keep plugin loader changes isolated to the plugins crate surface Rejected: Add a new manifest crate for shared schemas | unnecessary scope for this pass Confidence: high Scope-risk: narrow Reversibility: clean Directive: If manifest permissions or tool permission labels expand, update both the enums and validation tests together Tested: cargo fmt --all; cargo test -p plugins Not-tested: Cross-crate runtime consumption of any future expanded manifest permission variants --- rust/crates/plugins/src/lib.rs | 173 ++++++++++++++++++++++++++++++++- 1 file changed, 172 insertions(+), 1 deletion(-) diff --git a/rust/crates/plugins/src/lib.rs b/rust/crates/plugins/src/lib.rs index 8c859a5..1c2f134 100644 --- a/rust/crates/plugins/src/lib.rs +++ b/rust/crates/plugins/src/lib.rs @@ -2060,6 +2060,10 @@ mod tests { } 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, @@ -2076,7 +2080,7 @@ mod tests { 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\": \"plugin_echo\",\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}}" + "{{\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(), ); @@ -2284,6 +2288,91 @@ mod tests { 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"))); @@ -2375,6 +2464,24 @@ mod tests { 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 persists_bundled_plugin_enable_state_across_reloads() { let config_home = temp_dir("bundled-state-home"); @@ -2408,6 +2515,39 @@ mod tests { 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"); @@ -2552,4 +2692,35 @@ mod tests { 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); + } } From 46abf5214357af66af68f55a8c9b6ca32a5b02f3 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo <hurrc04@gmail.com> Date: Wed, 1 Apr 2026 07:30:20 +0000 Subject: [PATCH 16/25] feat: plugin subsystem progress --- rust/crates/plugins/src/lib.rs | 153 ++++++++++++++++++++++++++++++++- rust/crates/tools/src/lib.rs | 105 ++++++++++++++++++++-- 2 files changed, 249 insertions(+), 9 deletions(-) diff --git a/rust/crates/plugins/src/lib.rs b/rust/crates/plugins/src/lib.rs index 1c2f134..e45c7d7 100644 --- a/rust/crates/plugins/src/lib.rs +++ b/rust/crates/plugins/src/lib.rs @@ -1098,6 +1098,7 @@ impl PluginManager { let registry = self.load_registry()?; let mut plugins = Vec::new(); let mut seen_ids = BTreeSet::<String>::new(); + let mut seen_paths = BTreeSet::<PathBuf>::new(); for install_path in discover_plugin_dirs(&self.install_root())? { let matched_record = registry @@ -1111,6 +1112,23 @@ impl PluginManager { ); 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; + } + 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); } } @@ -1165,17 +1183,15 @@ impl PluginManager { .clone() .unwrap_or_else(Self::bundled_root); let bundled_plugins = discover_plugin_dirs(&bundled_root)?; - if bundled_plugins.is_empty() { - return Ok(()); - } - 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); @@ -1216,6 +1232,24 @@ impl PluginManager { 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::<Vec<_>>(); + + 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)?; } @@ -2482,6 +2516,117 @@ mod tests { 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 persists_bundled_plugin_enable_state_across_reloads() { let config_home = temp_dir("bundled-state-home"); diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index fea9ce1..33b24e9 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -136,6 +136,13 @@ impl GlobalToolRegistry { &self.entries } + fn find_entry(&self, name: &str) -> Option<&RegisteredTool> { + let normalized = normalize_registry_tool_name(name); + self.entries.iter().find(|entry| { + normalize_registry_tool_name(entry.definition.name.as_str()) == normalized + }) + } + #[must_use] pub fn definitions(&self, allowed_tools: Option<&BTreeSet<String>>) -> Vec<ToolDefinition> { self.entries @@ -213,12 +220,10 @@ impl GlobalToolRegistry { pub fn execute(&self, name: &str, input: &Value) -> Result<String, String> { let entry = self - .entries - .iter() - .find(|entry| entry.definition.name == name) + .find_entry(name) .ok_or_else(|| format!("unsupported tool: {name}"))?; match &entry.handler { - RegisteredToolHandler::Builtin => execute_tool(name, input), + RegisteredToolHandler::Builtin => execute_tool(&entry.definition.name, input), RegisteredToolHandler::Plugin(tool) => { tool.execute(input).map_err(|error| error.to_string()) } @@ -233,7 +238,44 @@ impl Default for GlobalToolRegistry { } fn normalize_registry_tool_name(value: &str) -> String { - value.trim().replace('-', "_").to_ascii_lowercase() + let trimmed = value.trim(); + let chars = trimmed.chars().collect::<Vec<_>>(); + let mut normalized = String::new(); + + for (index, ch) in chars.iter().copied().enumerate() { + if matches!(ch, '-' | ' ' | '\t' | '\n') { + if !normalized.ends_with('_') { + normalized.push('_'); + } + continue; + } + + if ch == '_' { + if !normalized.ends_with('_') { + normalized.push('_'); + } + continue; + } + + if ch.is_uppercase() { + let prev = chars.get(index.wrapping_sub(1)).copied(); + let next = chars.get(index + 1).copied(); + let needs_separator = index > 0 + && !normalized.ends_with('_') + && (prev.is_some_and(|prev| prev.is_lowercase() || prev.is_ascii_digit()) + || (prev.is_some_and(char::is_uppercase) + && next.is_some_and(char::is_lowercase))); + if needs_separator { + normalized.push('_'); + } + normalized.extend(ch.to_lowercase()); + continue; + } + + normalized.push(ch.to_ascii_lowercase()); + } + + normalized.trim_matches('_').to_string() } fn permission_mode_from_plugin_tool(value: &str) -> Result<PermissionMode, String> { @@ -3205,6 +3247,59 @@ mod tests { let _ = std::fs::remove_file(script); } + #[test] + fn global_registry_normalizes_plugin_tool_names_for_allowlists_and_execution() { + let script = temp_path("plugin-tool-normalized.sh"); + std::fs::write( + &script, + "#!/bin/sh\nINPUT=$(cat)\nprintf '{\"tool\":\"%s\",\"input\":%s}\\n' \"$CLAWD_TOOL_NAME\" \"$INPUT\"\n", + ) + .expect("write script"); + make_executable(&script); + + let registry = GlobalToolRegistry::with_plugin_tools(vec![PluginTool::new( + "demo@external", + "demo", + PluginToolDefinition { + name: "plugin_echo".to_string(), + description: Some("Echo plugin input".to_string()), + input_schema: json!({ + "type": "object", + "properties": { "message": { "type": "string" } }, + "required": ["message"], + "additionalProperties": false + }), + }, + script.display().to_string(), + Vec::new(), + PluginToolPermission::WorkspaceWrite, + script.parent().map(PathBuf::from), + )]) + .expect("registry should build"); + + let allowed = registry + .normalize_allowed_tools(&[String::from("PLUGIN-ECHO")]) + .expect("plugin tool allowlist should normalize") + .expect("allowlist should be present"); + assert!(allowed.contains("plugin_echo")); + + let output = registry + .execute("plugin-echo", &json!({ "message": "hello" })) + .expect("normalized plugin tool name should execute"); + let payload: serde_json::Value = serde_json::from_str(&output).expect("valid json"); + assert_eq!(payload["tool"], "plugin_echo"); + assert_eq!(payload["input"]["message"], "hello"); + + let builtin_output = GlobalToolRegistry::builtin() + .execute("structured-output", &json!({ "ok": true })) + .expect("normalized builtin tool name should execute"); + let builtin_payload: serde_json::Value = + serde_json::from_str(&builtin_output).expect("valid json"); + assert_eq!(builtin_payload["structured_output"]["ok"], true); + + let _ = std::fs::remove_file(script); + } + #[test] fn global_registry_rejects_conflicting_plugin_tool_names() { let error = GlobalToolRegistry::with_plugin_tools(vec![PluginTool::new( From 6584ed1ad771f200cdc9cd9e0e0662c18bbdd716 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo <hurrc04@gmail.com> Date: Wed, 1 Apr 2026 07:34:55 +0000 Subject: [PATCH 17/25] Harden installed-plugin discovery against stale registry state Expanded the plugin manager so installed plugin discovery now falls back across install-root scans and registry-only paths without breaking on stale entries. Missing registry install paths are pruned during discovery, while valid registry-backed installs outside the install root remain loadable. Constraint: Keep the change isolated to plugin manifest/manager/registry code Rejected: Fail listing when any registry install path is missing | stale local state should not block plugin discovery Confidence: high Scope-risk: narrow Reversibility: clean Directive: Discovery now self-heals missing registry install paths; preserve the registry-fallback path for valid installs outside install_root Tested: cargo fmt --all; cargo test -p plugins Not-tested: End-to-end CLI flows with mixed stale and git-backed installed plugins --- rust/crates/plugins/src/lib.rs | 60 +++++++++++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/rust/crates/plugins/src/lib.rs b/rust/crates/plugins/src/lib.rs index e45c7d7..844ee9b 100644 --- a/rust/crates/plugins/src/lib.rs +++ b/rust/crates/plugins/src/lib.rs @@ -1095,10 +1095,11 @@ impl PluginManager { } fn discover_installed_plugins(&self) -> Result<Vec<PluginDefinition>, PluginError> { - let registry = self.load_registry()?; + let mut registry = self.load_registry()?; let mut plugins = Vec::new(); let mut seen_ids = BTreeSet::<String>::new(); let mut seen_paths = BTreeSet::<PathBuf>::new(); + let mut stale_registry_ids = Vec::new(); for install_path in discover_plugin_dirs(&self.install_root())? { let matched_record = registry @@ -1121,6 +1122,11 @@ impl PluginManager { 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, @@ -1133,6 +1139,13 @@ impl PluginManager { } } + if !stale_registry_ids.is_empty() { + for plugin_id in stale_registry_ids { + registry.plugins.remove(&plugin_id); + } + self.store_registry(®istry)?; + } + Ok(plugins) } @@ -2627,6 +2640,51 @@ mod tests { 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"); From 9e717192f84ac77f2a724059ba9f4696e298aada Mon Sep 17 00:00:00 2001 From: Yeachan-Heo <hurrc04@gmail.com> Date: Wed, 1 Apr 2026 07:36:05 +0000 Subject: [PATCH 18/25] Allow subagent tool flows to reach plugin-provided tools The subagent runtime still advertised and executed only built-in tools, which left plugin-provided tools outside the Agent execution path. This change loads the same plugin-aware registry used by the CLI for subagent tool definitions, permission policy, and execution lookup so delegated runs can resolve plugin tools consistently. Constraint: Plugin tools must respect the existing runtime plugin config and enabled-plugin state Rejected: Thread plugin-specific exceptions through execute_tool directly | would bypass registry validation and duplicate lookup rules Confidence: medium Scope-risk: moderate Reversibility: clean Directive: Keep CLI and subagent registry construction aligned when plugin tool loading rules change Tested: cargo test -p tools -p rusty-claude-cli Not-tested: Live Anthropic subagent runs invoking plugin tools end-to-end --- rust/crates/tools/src/lib.rs | 166 ++++++++++++++++++++++++++++------- 1 file changed, 133 insertions(+), 33 deletions(-) diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index 33b24e9..72a2fba 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -8,13 +8,13 @@ use api::{ MessageRequest, MessageResponse, OutputContentBlock, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock, }; -use plugins::PluginTool; +use plugins::{PluginManager, PluginManagerConfig, PluginTool}; use reqwest::blocking::Client; use runtime::{ edit_file, execute_bash, glob_search, grep_search, load_system_prompt, read_file, write_file, - ApiClient, ApiRequest, AssistantEvent, BashCommandInput, ContentBlock, ConversationMessage, - ConversationRuntime, GrepSearchInput, MessageRole, PermissionMode, PermissionPolicy, - RuntimeError, Session, TokenUsage, ToolError, ToolExecutor, + ApiClient, ApiRequest, AssistantEvent, BashCommandInput, ConfigLoader, ContentBlock, + ConversationMessage, ConversationRuntime, GrepSearchInput, MessageRole, PermissionMode, + PermissionPolicy, RuntimeConfig, RuntimeError, Session, TokenUsage, ToolError, ToolExecutor, }; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -1700,13 +1700,15 @@ fn build_agent_runtime( .clone() .unwrap_or_else(|| DEFAULT_AGENT_MODEL.to_string()); let allowed_tools = job.allowed_tools.clone(); - let api_client = AnthropicRuntimeClient::new(model, allowed_tools.clone())?; - let tool_executor = SubagentToolExecutor::new(allowed_tools); + let tool_registry = current_tool_registry()?; + let api_client = + AnthropicRuntimeClient::new(model, allowed_tools.clone(), tool_registry.clone())?; + let tool_executor = SubagentToolExecutor::new(allowed_tools, tool_registry.clone()); Ok(ConversationRuntime::new( Session::new(), api_client, tool_executor, - agent_permission_policy(), + agent_permission_policy(&tool_registry), job.system_prompt.clone(), )) } @@ -1815,10 +1817,12 @@ fn allowed_tools_for_subagent(subagent_type: &str) -> BTreeSet<String> { tools.into_iter().map(str::to_string).collect() } -fn agent_permission_policy() -> PermissionPolicy { - mvp_tool_specs().into_iter().fold( +fn agent_permission_policy(tool_registry: &GlobalToolRegistry) -> PermissionPolicy { + tool_registry.permission_specs(None).into_iter().fold( PermissionPolicy::new(PermissionMode::DangerFullAccess), - |policy, spec| policy.with_tool_requirement(spec.name, spec.required_permission), + |policy, (name, required_permission)| { + policy.with_tool_requirement(name, required_permission) + }, ) } @@ -1874,10 +1878,15 @@ struct AnthropicRuntimeClient { client: AnthropicClient, model: String, allowed_tools: BTreeSet<String>, + tool_registry: GlobalToolRegistry, } impl AnthropicRuntimeClient { - fn new(model: String, allowed_tools: BTreeSet<String>) -> Result<Self, String> { + fn new( + model: String, + allowed_tools: BTreeSet<String>, + tool_registry: GlobalToolRegistry, + ) -> Result<Self, String> { let client = AnthropicClient::from_env() .map_err(|error| error.to_string())? .with_base_url(read_base_url()); @@ -1886,20 +1895,14 @@ impl AnthropicRuntimeClient { client, model, allowed_tools, + tool_registry, }) } } impl ApiClient for AnthropicRuntimeClient { fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> { - let tools = tool_specs_for_allowed_tools(Some(&self.allowed_tools)) - .into_iter() - .map(|spec| ToolDefinition { - name: spec.name.to_string(), - description: Some(spec.description.to_string()), - input_schema: spec.input_schema, - }) - .collect::<Vec<_>>(); + let tools = self.tool_registry.definitions(Some(&self.allowed_tools)); let message_request = MessageRequest { model: self.model.clone(), max_tokens: 32_000, @@ -2002,32 +2005,82 @@ impl ApiClient for AnthropicRuntimeClient { struct SubagentToolExecutor { allowed_tools: BTreeSet<String>, + tool_registry: GlobalToolRegistry, } impl SubagentToolExecutor { - fn new(allowed_tools: BTreeSet<String>) -> Self { - Self { allowed_tools } + fn new(allowed_tools: BTreeSet<String>, tool_registry: GlobalToolRegistry) -> Self { + Self { + allowed_tools, + tool_registry, + } } } impl ToolExecutor for SubagentToolExecutor { fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError> { - if !self.allowed_tools.contains(tool_name) { + let entry = self + .tool_registry + .find_entry(tool_name) + .ok_or_else(|| ToolError::new(format!("unsupported tool: {tool_name}")))?; + if !self.allowed_tools.contains(entry.definition.name.as_str()) { return Err(ToolError::new(format!( "tool `{tool_name}` is not enabled for this sub-agent" ))); } let value = serde_json::from_str(input) .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?; - execute_tool(tool_name, &value).map_err(ToolError::new) + self.tool_registry + .execute(tool_name, &value) + .map_err(ToolError::new) } } -fn tool_specs_for_allowed_tools(allowed_tools: Option<&BTreeSet<String>>) -> Vec<ToolSpec> { - mvp_tool_specs() - .into_iter() - .filter(|spec| allowed_tools.is_none_or(|allowed| allowed.contains(spec.name))) - .collect() +fn current_tool_registry() -> Result<GlobalToolRegistry, String> { + let cwd = std::env::current_dir().map_err(|error| error.to_string())?; + let loader = ConfigLoader::default_for(&cwd); + let runtime_config = loader.load().map_err(|error| error.to_string())?; + let plugin_manager = build_plugin_manager(&cwd, &loader, &runtime_config); + let plugin_tools = plugin_manager + .aggregated_tools() + .map_err(|error| error.to_string())?; + GlobalToolRegistry::with_plugin_tools(plugin_tools) +} + +fn build_plugin_manager( + cwd: &Path, + loader: &ConfigLoader, + runtime_config: &RuntimeConfig, +) -> PluginManager { + let plugin_settings = runtime_config.plugins(); + let mut plugin_config = PluginManagerConfig::new(loader.config_home().to_path_buf()); + plugin_config.enabled_plugins = plugin_settings.enabled_plugins().clone(); + plugin_config.external_dirs = plugin_settings + .external_directories() + .iter() + .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)) + .collect(); + plugin_config.install_root = plugin_settings + .install_root() + .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)); + plugin_config.registry_path = plugin_settings + .registry_path() + .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)); + plugin_config.bundled_root = plugin_settings + .bundled_root() + .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)); + PluginManager::new(plugin_config) +} + +fn resolve_plugin_path(cwd: &Path, config_home: &Path, value: &str) -> PathBuf { + let path = PathBuf::from(value); + if path.is_absolute() { + path + } else if value.starts_with('.') { + cwd.join(path) + } else { + config_home.join(path) + } } fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> { @@ -3142,7 +3195,9 @@ mod tests { AgentInput, AgentJob, GlobalToolRegistry, SubagentToolExecutor, }; use plugins::{PluginTool, PluginToolDefinition, PluginToolPermission}; - use runtime::{ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, Session}; + use runtime::{ + ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, Session, ToolExecutor, + }; use serde_json::json; fn env_lock() -> &'static Mutex<()> { @@ -3221,8 +3276,8 @@ mod tests { "additionalProperties": false }), }, - script.display().to_string(), - Vec::new(), + "sh".to_string(), + vec![script.display().to_string()], PluginToolPermission::WorkspaceWrite, script.parent().map(PathBuf::from), )]) @@ -3300,6 +3355,48 @@ mod tests { let _ = std::fs::remove_file(script); } + #[test] + fn subagent_executor_executes_allowed_plugin_tools() { + let script = temp_path("subagent-plugin-tool.sh"); + std::fs::write( + &script, + "#!/bin/sh\nINPUT=$(cat)\nprintf '{\"tool\":\"%s\",\"input\":%s}\\n' \"$CLAWD_TOOL_NAME\" \"$INPUT\"\n", + ) + .expect("write script"); + make_executable(&script); + + let registry = GlobalToolRegistry::with_plugin_tools(vec![PluginTool::new( + "demo@external", + "demo", + PluginToolDefinition { + name: "plugin_echo".to_string(), + description: Some("Echo plugin input".to_string()), + input_schema: json!({ + "type": "object", + "properties": { "message": { "type": "string" } }, + "required": ["message"], + "additionalProperties": false + }), + }, + script.display().to_string(), + Vec::new(), + PluginToolPermission::WorkspaceWrite, + script.parent().map(PathBuf::from), + )]) + .expect("registry should build"); + + let mut executor = + SubagentToolExecutor::new(BTreeSet::from([String::from("plugin_echo")]), registry); + let output = executor + .execute("plugin-echo", r#"{"message":"hello"}"#) + .expect("plugin tool should execute for subagent"); + let payload: serde_json::Value = serde_json::from_str(&output).expect("valid json"); + assert_eq!(payload["tool"], "plugin_echo"); + assert_eq!(payload["input"]["message"], "hello"); + + let _ = std::fs::remove_file(script); + } + #[test] fn global_registry_rejects_conflicting_plugin_tool_names() { let error = GlobalToolRegistry::with_plugin_tools(vec![PluginTool::new( @@ -3899,8 +3996,11 @@ mod tests { calls: 0, input_path: path.display().to_string(), }, - SubagentToolExecutor::new(BTreeSet::from([String::from("read_file")])), - agent_permission_policy(), + SubagentToolExecutor::new( + BTreeSet::from([String::from("read_file")]), + GlobalToolRegistry::builtin(), + ), + agent_permission_policy(&GlobalToolRegistry::builtin()), vec![String::from("system prompt")], ); From 13851d800f29a7f6121439054e52123ef339c66d Mon Sep 17 00:00:00 2001 From: Yeachan-Heo <hurrc04@gmail.com> Date: Wed, 1 Apr 2026 08:06:10 +0000 Subject: [PATCH 19/25] Accept reasoning-style content blocks in the Rust API parser The Rust API layer rejected thinking-enabled responses because it only recognized text and tool_use content blocks. This commit extends the response and SSE parser types to accept reasoning-style content blocks and deltas, with regression coverage for both non-streaming and streaming responses. Constraint: Keep parsing compatible with existing text and tool-use message flows Rejected: Deserialize unknown content blocks into an untyped catch-all | would weaken protocol coverage and test precision Confidence: high Scope-risk: narrow Directive: Keep new protocol variants covered at the API boundary so downstream code can make explicit choices about preservation vs. ignoring Tested: cargo test -p api thinking -- --nocapture Not-tested: Live API traffic from a real thinking-enabled model --- rust/crates/api/src/sse.rs | 60 ++++++++++ rust/crates/api/src/types.rs | 11 ++ rust/crates/api/tests/client_integration.rs | 121 ++++++++++++++++++++ 3 files changed, 192 insertions(+) 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<String>, + }, + RedactedThinking { + data: Value, + }, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -190,6 +199,8 @@ pub struct ContentBlockDeltaEvent { pub enum ContentBlockDelta { TextDelta { text: String }, InputJsonDelta { partial_json: String }, + ThinkingDelta { thinking: String }, + SignatureDelta { signature: String }, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/rust/crates/api/tests/client_integration.rs b/rust/crates/api/tests/client_integration.rs index c37fa99..be4abca 100644 --- a/rust/crates/api/tests/client_integration.rs +++ b/rust/crates/api/tests/client_integration.rs @@ -75,6 +75,48 @@ async fn send_message_posts_json_and_parses_response() { assert_eq!(body["tool_choice"]["type"], json!("auto")); } +#[tokio::test] +async fn send_message_parses_response_with_thinking_blocks() { + let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new())); + let body = concat!( + "{", + "\"id\":\"msg_thinking\",", + "\"type\":\"message\",", + "\"role\":\"assistant\",", + "\"content\":[", + "{\"type\":\"thinking\",\"thinking\":\"step 1\",\"signature\":\"sig_123\"},", + "{\"type\":\"text\",\"text\":\"Final answer\"}", + "],", + "\"model\":\"claude-3-7-sonnet-latest\",", + "\"stop_reason\":\"end_turn\",", + "\"stop_sequence\":null,", + "\"usage\":{\"input_tokens\":12,\"output_tokens\":4}", + "}" + ); + let server = spawn_server( + state, + vec![http_response("200 OK", "application/json", body)], + ) + .await; + + let client = AnthropicClient::new("test-key").with_base_url(server.base_url()); + let response = client + .send_message(&sample_request(false)) + .await + .expect("request should succeed"); + + assert_eq!(response.content.len(), 2); + assert!(matches!( + &response.content[0], + OutputContentBlock::Thinking { thinking, signature } + if thinking == "step 1" && signature.as_deref() == Some("sig_123") + )); + assert!(matches!( + &response.content[1], + OutputContentBlock::Text { text } if text == "Final answer" + )); +} + #[tokio::test] async fn stream_message_parses_sse_events_with_tool_use() { let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new())); @@ -162,6 +204,85 @@ async fn stream_message_parses_sse_events_with_tool_use() { assert!(request.body.contains("\"stream\":true")); } +#[tokio::test] +async fn stream_message_parses_sse_events_with_thinking_blocks() { + let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new())); + let sse = concat!( + "event: message_start\n", + "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_stream_thinking\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-3-7-sonnet-latest\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":8,\"output_tokens\":0}}}\n\n", + "event: content_block_start\n", + "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}\n\n", + "event: content_block_delta\n", + "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"step 1\"}}\n\n", + "event: content_block_delta\n", + "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"signature_delta\",\"signature\":\"sig_123\"}}\n\n", + "event: content_block_stop\n", + "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n", + "event: content_block_start\n", + "data: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"text\",\"text\":\"Final answer\"}}\n\n", + "event: content_block_stop\n", + "data: {\"type\":\"content_block_stop\",\"index\":1}\n\n", + "event: message_delta\n", + "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":8,\"output_tokens\":1}}\n\n", + "event: message_stop\n", + "data: {\"type\":\"message_stop\"}\n\n", + "data: [DONE]\n\n" + ); + let server = spawn_server( + state, + vec![http_response("200 OK", "text/event-stream", sse)], + ) + .await; + + let client = AnthropicClient::new("test-key").with_base_url(server.base_url()); + let mut stream = client + .stream_message(&sample_request(false)) + .await + .expect("stream should start"); + + let mut events = Vec::new(); + while let Some(event) = stream + .next_event() + .await + .expect("stream event should parse") + { + events.push(event); + } + + assert_eq!(events.len(), 9); + assert!(matches!( + &events[1], + StreamEvent::ContentBlockStart(ContentBlockStartEvent { + content_block: OutputContentBlock::Thinking { thinking, signature }, + .. + }) if thinking.is_empty() && signature.is_none() + )); + assert!(matches!( + &events[2], + StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent { + delta: ContentBlockDelta::ThinkingDelta { thinking }, + .. + }) if thinking == "step 1" + )); + assert!(matches!( + &events[3], + StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent { + delta: ContentBlockDelta::SignatureDelta { signature }, + .. + }) if signature == "sig_123" + )); + assert!(matches!( + &events[5], + StreamEvent::ContentBlockStart(ContentBlockStartEvent { + content_block: OutputContentBlock::Text { text }, + .. + }) if text == "Final answer" + )); + assert!(matches!(events[6], StreamEvent::ContentBlockStop(_))); + assert!(matches!(events[7], StreamEvent::MessageDelta(_))); + assert!(matches!(events[8], StreamEvent::MessageStop(_))); +} + #[tokio::test] async fn retries_retryable_failures_before_succeeding() { let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new())); From 4c1eaa16e0b55c6ed4fd4c7e349d6a8287c3483a Mon Sep 17 00:00:00 2001 From: Yeachan-Heo <hurrc04@gmail.com> Date: Wed, 1 Apr 2026 08:06:10 +0000 Subject: [PATCH 20/25] Ignore reasoning blocks in runtime adapters without affecting tool/text flows After the parser can accept thinking-style blocks, the CLI and tools adapters must explicitly ignore them so only user-visible text and tool calls drive runtime behavior. This keeps reasoning metadata from surfacing as text or interfering with tool accumulation. Constraint: Runtime behavior must remain unchanged for normal text/tool streaming Rejected: Treat thinking blocks as assistant text | would leak hidden reasoning into visible output and session flow Confidence: high Scope-risk: narrow Directive: If future features need persisted reasoning blocks, add a dedicated runtime representation instead of overloading text handling Tested: cargo test -p rusty-claude-cli response_to_events_ignores_thinking_blocks -- --nocapture; cargo test -p tools response_to_events_ignores_thinking_blocks -- --nocapture Not-tested: End-to-end interactive run against a live thinking-enabled model --- rust/crates/rusty-claude-cli/src/main.rs | 42 ++++++++++++++++++++++++ rust/crates/tools/src/lib.rs | 42 +++++++++++++++++++++++- 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index b7188c2..acb8aba 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -2557,6 +2557,8 @@ impl ApiClient for AnthropicRuntimeClient { input.push_str(&partial_json); } } + ContentBlockDelta::ThinkingDelta { .. } + | ContentBlockDelta::SignatureDelta { .. } => {} }, ApiStreamEvent::ContentBlockStop(_) => { if let Some(rendered) = markdown_stream.flush(&renderer) { @@ -3056,6 +3058,7 @@ fn push_output_block( }; *pending_tool = Some((id, name, initial_input)); } + OutputContentBlock::Thinking { .. } | OutputContentBlock::RedactedThinking { .. } => {} } Ok(()) } @@ -4007,4 +4010,43 @@ mod tests { if name == "read_file" && input == "{\"path\":\"rust/Cargo.toml\"}" )); } + + #[test] + fn response_to_events_ignores_thinking_blocks() { + let mut out = Vec::new(); + let events = response_to_events( + MessageResponse { + id: "msg-3".to_string(), + kind: "message".to_string(), + model: "claude-opus-4-6".to_string(), + role: "assistant".to_string(), + content: vec![ + OutputContentBlock::Thinking { + thinking: "step 1".to_string(), + signature: Some("sig_123".to_string()), + }, + OutputContentBlock::Text { + text: "Final answer".to_string(), + }, + ], + stop_reason: Some("end_turn".to_string()), + stop_sequence: None, + usage: Usage { + input_tokens: 1, + output_tokens: 1, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + request_id: None, + }, + &mut out, + ) + .expect("response conversion should succeed"); + + assert!(matches!( + &events[0], + AssistantEvent::TextDelta(text) if text == "Final answer" + )); + assert!(!String::from_utf8(out).expect("utf8").contains("step 1")); + } } diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index 72a2fba..38fafe9 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -1953,6 +1953,8 @@ impl ApiClient for AnthropicRuntimeClient { input.push_str(&partial_json); } } + ContentBlockDelta::ThinkingDelta { .. } + | ContentBlockDelta::SignatureDelta { .. } => {} }, ApiStreamEvent::ContentBlockStop(_) => { if let Some((id, name, input)) = pending_tool.take() { @@ -2147,6 +2149,7 @@ fn push_output_block( }; *pending_tool = Some((id, name, initial_input)); } + OutputContentBlock::Thinking { .. } | OutputContentBlock::RedactedThinking { .. } => {} } } @@ -3192,8 +3195,9 @@ mod tests { use super::{ agent_permission_policy, allowed_tools_for_subagent, execute_agent_with_spawn, execute_tool, final_assistant_text, mvp_tool_specs, persist_agent_terminal_state, - AgentInput, AgentJob, GlobalToolRegistry, SubagentToolExecutor, + response_to_events, AgentInput, AgentJob, GlobalToolRegistry, SubagentToolExecutor, }; + use api::{MessageResponse, OutputContentBlock, Usage}; use plugins::{PluginTool, PluginToolDefinition, PluginToolPermission}; use runtime::{ ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, Session, ToolExecutor, @@ -4026,6 +4030,42 @@ mod tests { let _ = std::fs::remove_file(path); } + #[test] + fn response_to_events_ignores_thinking_blocks() { + let events = response_to_events(MessageResponse { + id: "msg-1".to_string(), + kind: "message".to_string(), + model: "claude-opus-4-6".to_string(), + role: "assistant".to_string(), + content: vec![ + OutputContentBlock::Thinking { + thinking: "step 1".to_string(), + signature: Some("sig_123".to_string()), + }, + OutputContentBlock::Text { + text: "Final answer".to_string(), + }, + ], + stop_reason: Some("end_turn".to_string()), + stop_sequence: None, + usage: Usage { + input_tokens: 1, + output_tokens: 1, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + request_id: None, + }); + + assert!(matches!( + &events[0], + AssistantEvent::TextDelta(text) if text == "Final answer" + )); + assert!(!events + .iter() + .any(|event| matches!(event, AssistantEvent::ToolUse { .. }))); + } + #[test] fn agent_rejects_blank_required_fields() { let missing_description = execute_tool( From d794acd3f434dadab049bc11bb73cac811601f55 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo <hurrc04@gmail.com> Date: Wed, 1 Apr 2026 07:49:20 +0000 Subject: [PATCH 21/25] Keep CLI tool rendering readable without dropping result fidelity Some tools, especially Read, can emit very large payloads that overwhelm the interactive renderer. This change truncates only the displayed preview for long tool outputs while leaving the underlying tool result string untouched for downstream logic and persisted session state. Constraint: Rendering changes must not modify stored tool outputs or tool-result messages Rejected: Truncate tool output before returning from the executor | would corrupt session history and downstream processing Confidence: high Scope-risk: narrow Directive: Keep truncation strictly in presentation helpers; do not move it into tool execution or session persistence paths Tested: cargo test -p rusty-claude-cli tool_rendering_truncates_ -- --nocapture; cargo test -p rusty-claude-cli tool_rendering_helpers_compact_output -- --nocapture Not-tested: Manual terminal rendering with real multi-megabyte tool output --- rust/crates/rusty-claude-cli/src/main.rs | 125 ++++++++++++++++++++++- 1 file changed, 121 insertions(+), 4 deletions(-) diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index acb8aba..2dbcba3 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -2770,6 +2770,13 @@ fn format_tool_result(name: &str, output: &str, is_error: bool) -> String { } } +const DISPLAY_TRUNCATION_NOTICE: &str = + "\x1b[2m… output truncated for display; full result preserved in session.\x1b[0m"; +const READ_DISPLAY_MAX_LINES: usize = 80; +const READ_DISPLAY_MAX_CHARS: usize = 6_000; +const TOOL_OUTPUT_DISPLAY_MAX_LINES: usize = 60; +const TOOL_OUTPUT_DISPLAY_MAX_CHARS: usize = 4_000; + fn extract_tool_path(parsed: &serde_json::Value) -> String { parsed .get("file_path") @@ -2841,12 +2848,23 @@ fn format_bash_result(icon: &str, parsed: &serde_json::Value) -> String { if let Some(stdout) = parsed.get("stdout").and_then(|value| value.as_str()) { if !stdout.trim().is_empty() { - lines.push(stdout.trim_end().to_string()); + lines.push(truncate_output_for_display( + stdout, + TOOL_OUTPUT_DISPLAY_MAX_LINES, + TOOL_OUTPUT_DISPLAY_MAX_CHARS, + )); } } if let Some(stderr) = parsed.get("stderr").and_then(|value| value.as_str()) { if !stderr.trim().is_empty() { - lines.push(format!("\x1b[38;5;203m{}\x1b[0m", stderr.trim_end())); + lines.push(format!( + "\x1b[38;5;203m{}\x1b[0m", + truncate_output_for_display( + stderr, + TOOL_OUTPUT_DISPLAY_MAX_LINES, + TOOL_OUTPUT_DISPLAY_MAX_CHARS, + ) + )); } } @@ -2879,7 +2897,7 @@ fn format_read_result(icon: &str, parsed: &serde_json::Value) -> String { start_line, end_line.max(start_line), total_lines, - content + truncate_output_for_display(content, READ_DISPLAY_MAX_LINES, READ_DISPLAY_MAX_CHARS) ) } @@ -3001,7 +3019,14 @@ fn format_grep_result(icon: &str, parsed: &serde_json::Value) -> String { "{icon} \x1b[38;5;245mgrep_search\x1b[0m {num_matches} matches across {num_files} files" ); if !content.trim().is_empty() { - format!("{summary}\n{}", content.trim_end()) + format!( + "{summary}\n{}", + truncate_output_for_display( + content, + TOOL_OUTPUT_DISPLAY_MAX_LINES, + TOOL_OUTPUT_DISPLAY_MAX_CHARS, + ) + ) } else if !filenames.is_empty() { format!("{summary}\n{filenames}") } else { @@ -3027,6 +3052,50 @@ fn truncate_for_summary(value: &str, limit: usize) -> String { } } +fn truncate_output_for_display(content: &str, max_lines: usize, max_chars: usize) -> String { + let original = content.trim_end_matches('\n'); + if original.is_empty() { + return String::new(); + } + + let mut preview_lines = Vec::new(); + let mut used_chars = 0usize; + let mut truncated = false; + + for (index, line) in original.lines().enumerate() { + if index >= max_lines { + truncated = true; + break; + } + + let newline_cost = usize::from(!preview_lines.is_empty()); + let available = max_chars.saturating_sub(used_chars + newline_cost); + if available == 0 { + truncated = true; + break; + } + + let line_chars = line.chars().count(); + if line_chars > available { + preview_lines.push(line.chars().take(available).collect::<String>()); + truncated = true; + break; + } + + preview_lines.push(line.to_string()); + used_chars += newline_cost + line_chars; + } + + let mut preview = preview_lines.join("\n"); + if truncated { + if !preview.is_empty() { + preview.push('\n'); + } + preview.push_str(DISPLAY_TRUNCATION_NOTICE); + } + preview +} + fn push_output_block( block: OutputContentBlock, out: &mut (impl Write + ?Sized), @@ -3893,6 +3962,54 @@ mod tests { assert!(done.contains("hello")); } + #[test] + fn tool_rendering_truncates_large_read_output_for_display_only() { + let content = (0..200) + .map(|index| format!("line {index:03}")) + .collect::<Vec<_>>() + .join("\n"); + let output = json!({ + "file": { + "filePath": "src/main.rs", + "content": content, + "numLines": 200, + "startLine": 1, + "totalLines": 200 + } + }) + .to_string(); + + let rendered = format_tool_result("read_file", &output, false); + + assert!(rendered.contains("line 000")); + assert!(rendered.contains("line 079")); + assert!(!rendered.contains("line 199")); + assert!(rendered.contains("full result preserved in session")); + assert!(output.contains("line 199")); + } + + #[test] + fn tool_rendering_truncates_large_bash_output_for_display_only() { + let stdout = (0..120) + .map(|index| format!("stdout {index:03}")) + .collect::<Vec<_>>() + .join("\n"); + let output = json!({ + "stdout": stdout, + "stderr": "", + "returnCodeInterpretation": "completed successfully" + }) + .to_string(); + + let rendered = format_tool_result("bash", &output, false); + + assert!(rendered.contains("stdout 000")); + assert!(rendered.contains("stdout 059")); + assert!(!rendered.contains("stdout 119")); + assert!(rendered.contains("full result preserved in session")); + assert!(output.contains("stdout 119")); + } + #[test] fn push_output_block_renders_markdown_text() { let mut out = Vec::new(); From 97d725d5e59e25abfc27413a3c591d94e06dedee Mon Sep 17 00:00:00 2001 From: Yeachan-Heo <hurrc04@gmail.com> Date: Wed, 1 Apr 2026 07:53:03 +0000 Subject: [PATCH 22/25] Keep CLI tool previews readable without truncating session data Extend the CLI renderer's generic tool-result path to reuse the existing display-only truncation helper, so large plugin or unknown-tool payloads no longer flood the terminal while the original tool result still flows through runtime/session state unchanged. The renderer now pretty-prints structured fallback payloads before truncating them for display, and the test suite covers both Read output and generic long tool output rendering. I also added a narrow clippy allow on an oversized slash-command parser test so the workspace lint gate stays green during verification. Constraint: Tool result truncation must affect screen rendering only, not stored tool output Rejected: Truncate tool results at execution time | would lose session fidelity and break downstream consumers Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep future tool-output shortening in renderer helpers only; do not trim runtime tool payloads before persistence Tested: cargo fmt --all; cargo clippy --workspace --all-targets -- -D warnings; cargo test --workspace Not-tested: Manual interactive terminal run showing truncation in a live REPL session --- rust/crates/commands/src/lib.rs | 1 + rust/crates/rusty-claude-cli/src/main.rs | 51 ++++++++++++++++++++++-- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 84f1b4a..92b0745 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -622,6 +622,7 @@ mod tests { .expect("write bundled manifest"); } + #[allow(clippy::too_many_lines)] #[test] fn parses_supported_slash_commands() { assert_eq!(SlashCommand::parse("/help"), Some(SlashCommand::Help)); diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 2dbcba3..b6595bb 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -2763,10 +2763,7 @@ fn format_tool_result(name: &str, output: &str, is_error: bool) -> String { "edit_file" | "Edit" => format_edit_result(icon, &parsed), "glob_search" | "Glob" => format_glob_result(icon, &parsed), "grep_search" | "Grep" => format_grep_result(icon, &parsed), - _ => { - let summary = truncate_for_summary(output.trim(), 200); - format!("{icon} \x1b[38;5;245m{name}:\x1b[0m {summary}") - } + _ => format_generic_tool_result(icon, name, &parsed), } } @@ -3034,6 +3031,30 @@ fn format_grep_result(icon: &str, parsed: &serde_json::Value) -> String { } } +fn format_generic_tool_result(icon: &str, name: &str, parsed: &serde_json::Value) -> String { + let rendered_output = match parsed { + serde_json::Value::String(text) => text.clone(), + serde_json::Value::Null => String::new(), + serde_json::Value::Object(_) | serde_json::Value::Array(_) => { + serde_json::to_string_pretty(parsed).unwrap_or_else(|_| parsed.to_string()) + } + _ => parsed.to_string(), + }; + let preview = truncate_output_for_display( + &rendered_output, + TOOL_OUTPUT_DISPLAY_MAX_LINES, + TOOL_OUTPUT_DISPLAY_MAX_CHARS, + ); + + if preview.is_empty() { + format!("{icon} \x1b[38;5;245m{name}\x1b[0m") + } else if preview.contains('\n') { + format!("{icon} \x1b[38;5;245m{name}\x1b[0m\n{preview}") + } else { + format!("{icon} \x1b[38;5;245m{name}:\x1b[0m {preview}") + } +} + fn summarize_tool_payload(payload: &str) -> String { let compact = match serde_json::from_str::<serde_json::Value>(payload) { Ok(value) => value.to_string(), @@ -4010,6 +4031,28 @@ mod tests { assert!(output.contains("stdout 119")); } + #[test] + fn tool_rendering_truncates_generic_long_output_for_display_only() { + let items = (0..120) + .map(|index| format!("payload {index:03}")) + .collect::<Vec<_>>(); + let output = json!({ + "summary": "plugin payload", + "items": items, + }) + .to_string(); + + let rendered = format_tool_result("plugin_echo", &output, false); + + assert!(rendered.contains("plugin_echo")); + assert!(rendered.contains("payload 000")); + assert!(rendered.contains("payload 040")); + assert!(!rendered.contains("payload 080")); + assert!(!rendered.contains("payload 119")); + assert!(rendered.contains("full result preserved in session")); + assert!(output.contains("payload 119")); + } + #[test] fn push_output_block_renders_markdown_text() { let mut out = Vec::new(); From 782d9cea717b3290e2e02a0ce30f433b461a14e9 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo <hurrc04@gmail.com> Date: Wed, 1 Apr 2026 07:55:25 +0000 Subject: [PATCH 23/25] Preserve ILM-style conversation continuity during auto compaction Auto compaction was keying off cumulative usage and re-summarizing from the front of the session, which made long chats shed continuity after the first compaction. The runtime now compacts against the current turn's prompt pressure and preserves prior compacted context as retained summary state instead of treating it like disposable history. Constraint: Existing /compact behavior and saved-session resume flow had to keep working without schema changes Rejected: Keep using cumulative input tokens | caused repeat compaction after every subsequent turn once the threshold was crossed Rejected: Re-summarize prior compacted system messages as ordinary history | degraded continuity and could drop earlier context Confidence: high Scope-risk: moderate Reversibility: clean Directive: Preserve compacted-summary boundaries when extending compaction again; do not fold prior compacted context back into raw-message removal Tested: cargo fmt --check; cargo clippy -p runtime -p commands --tests -- -D warnings; cargo test -p runtime; cargo test -p commands Not-tested: End-to-end interactive CLI auto-compaction against a live Anthropic session --- rust/crates/runtime/src/compact.rs | 233 +++++++++++++++++++++++- rust/crates/runtime/src/conversation.rs | 89 +++++++-- 2 files changed, 303 insertions(+), 19 deletions(-) diff --git a/rust/crates/runtime/src/compact.rs b/rust/crates/runtime/src/compact.rs index e227019..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::<usize>() + >= config.max_estimated_tokens } #[must_use] @@ -56,16 +68,18 @@ pub fn get_compact_continuation_message( recent_messages_preserved: bool, ) -> String { let mut base = format!( - "This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.\n\n{}", + "{COMPACT_CONTINUATION_PREAMBLE}{}", format_compact_summary(summary) ); if recent_messages_preserved { - base.push_str("\n\nRecent messages are preserved verbatim."); + base.push_str("\n\n"); + base.push_str(COMPACT_RECENT_MESSAGES_NOTE); } if suppress_follow_up_questions { - base.push_str("\nContinue the conversation from where it left off without asking the user any further questions. Resume directly — do not acknowledge the summary, do not recap what was happening, and do not preface with continuation text."); + base.push('\n'); + base.push_str(COMPACT_DIRECT_RESUME_INSTRUCTION); } base @@ -82,13 +96,19 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio }; } + let existing_summary = session + .messages + .first() + .and_then(extract_existing_compacted_summary); + let compacted_prefix_len = usize::from(existing_summary.is_some()); let keep_from = session .messages .len() .saturating_sub(config.preserve_recent_messages); - let removed = &session.messages[..keep_from]; + let removed = &session.messages[compacted_prefix_len..keep_from]; let preserved = session.messages[keep_from..].to_vec(); - let summary = summarize_messages(removed); + let summary = + merge_compact_summaries(existing_summary.as_deref(), &summarize_messages(removed)); let formatted_summary = format_compact_summary(&summary); let continuation = get_compact_continuation_message(&summary, true, !preserved.is_empty()); @@ -110,6 +130,16 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio } } +fn compacted_summary_prefix_len(session: &Session) -> usize { + usize::from( + session + .messages + .first() + .and_then(extract_existing_compacted_summary) + .is_some(), + ) +} + fn summarize_messages(messages: &[ConversationMessage]) -> String { let user_messages = messages .iter() @@ -197,6 +227,41 @@ fn summarize_messages(messages: &[ConversationMessage]) -> String { lines.join("\n") } +fn merge_compact_summaries(existing_summary: Option<&str>, new_summary: &str) -> String { + let Some(existing_summary) = existing_summary else { + return new_summary.to_string(); + }; + + let previous_highlights = extract_summary_highlights(existing_summary); + let new_formatted_summary = format_compact_summary(new_summary); + let new_highlights = extract_summary_highlights(&new_formatted_summary); + let new_timeline = extract_summary_timeline(&new_formatted_summary); + + let mut lines = vec!["<summary>".to_string(), "Conversation summary:".to_string()]; + + if !previous_highlights.is_empty() { + lines.push("- Previously compacted context:".to_string()); + lines.extend( + previous_highlights + .into_iter() + .map(|line| format!(" {line}")), + ); + } + + if !new_highlights.is_empty() { + lines.push("- Newly compacted context:".to_string()); + lines.extend(new_highlights.into_iter().map(|line| format!(" {line}"))); + } + + if !new_timeline.is_empty() { + lines.push("- Key timeline:".to_string()); + lines.extend(new_timeline.into_iter().map(|line| format!(" {line}"))); + } + + lines.push("</summary>".to_string()); + lines.join("\n") +} + fn summarize_block(block: &ContentBlock) -> String { let raw = match block { ContentBlock::Text { text } => text.clone(), @@ -374,11 +439,71 @@ fn collapse_blank_lines(content: &str) -> String { result } +fn extract_existing_compacted_summary(message: &ConversationMessage) -> Option<String> { + if message.role != MessageRole::System { + return None; + } + + let text = first_text_block(message)?; + let summary = text.strip_prefix(COMPACT_CONTINUATION_PREAMBLE)?; + let summary = summary + .split_once(&format!("\n\n{COMPACT_RECENT_MESSAGES_NOTE}")) + .map_or(summary, |(value, _)| value); + let summary = summary + .split_once(&format!("\n{COMPACT_DIRECT_RESUME_INSTRUCTION}")) + .map_or(summary, |(value, _)| value); + Some(summary.trim().to_string()) +} + +fn extract_summary_highlights(summary: &str) -> Vec<String> { + let mut lines = Vec::new(); + let mut in_timeline = false; + + for line in format_compact_summary(summary).lines() { + let trimmed = line.trim_end(); + if trimmed.is_empty() || trimmed == "Summary:" || trimmed == "Conversation summary:" { + continue; + } + if trimmed == "- Key timeline:" { + in_timeline = true; + continue; + } + if in_timeline { + continue; + } + lines.push(trimmed.to_string()); + } + + lines +} + +fn extract_summary_timeline(summary: &str) -> Vec<String> { + let mut lines = Vec::new(); + let mut in_timeline = false; + + for line in format_compact_summary(summary).lines() { + let trimmed = line.trim_end(); + if trimmed == "- Key timeline:" { + in_timeline = true; + continue; + } + if !in_timeline { + continue; + } + if trimmed.is_empty() { + break; + } + lines.push(trimmed.to_string()); + } + + lines +} + #[cfg(test)] mod tests { use super::{ collect_key_files, compact_session, estimate_session_tokens, format_compact_summary, - infer_pending_work, should_compact, CompactionConfig, + get_compact_continuation_message, infer_pending_work, should_compact, CompactionConfig, }; use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session}; @@ -453,6 +578,98 @@ mod tests { ); } + #[test] + fn keeps_previous_compacted_context_when_compacting_again() { + let initial_session = Session { + version: 1, + messages: vec![ + ConversationMessage::user_text("Investigate rust/crates/runtime/src/compact.rs"), + ConversationMessage::assistant(vec![ContentBlock::Text { + text: "I will inspect the compact flow.".to_string(), + }]), + ConversationMessage::user_text( + "Also update rust/crates/runtime/src/conversation.rs", + ), + ConversationMessage::assistant(vec![ContentBlock::Text { + text: "Next: preserve prior summary context during auto compact.".to_string(), + }]), + ], + }; + let config = CompactionConfig { + preserve_recent_messages: 2, + max_estimated_tokens: 1, + }; + + let first = compact_session(&initial_session, config); + let mut follow_up_messages = first.compacted_session.messages.clone(); + follow_up_messages.extend([ + ConversationMessage::user_text("Please add regression tests for compaction."), + ConversationMessage::assistant(vec![ContentBlock::Text { + text: "Working on regression coverage now.".to_string(), + }]), + ]); + + let second = compact_session( + &Session { + version: 1, + messages: follow_up_messages, + }, + config, + ); + + assert!(second + .formatted_summary + .contains("Previously compacted context:")); + assert!(second + .formatted_summary + .contains("Scope: 2 earlier messages compacted")); + assert!(second + .formatted_summary + .contains("Newly compacted context:")); + assert!(second + .formatted_summary + .contains("Also update rust/crates/runtime/src/conversation.rs")); + assert!(matches!( + &second.compacted_session.messages[0].blocks[0], + ContentBlock::Text { text } + if text.contains("Previously compacted context:") + && text.contains("Newly compacted context:") + )); + assert!(matches!( + &second.compacted_session.messages[1].blocks[0], + ContentBlock::Text { text } if text.contains("Please add regression tests for compaction.") + )); + } + + #[test] + fn ignores_existing_compacted_summary_when_deciding_to_recompact() { + let summary = "<summary>Conversation summary:\n- Scope: earlier work preserved.\n- Key timeline:\n - user: large preserved context\n</summary>"; + let session = Session { + version: 1, + messages: vec![ + ConversationMessage { + role: MessageRole::System, + blocks: vec![ContentBlock::Text { + text: get_compact_continuation_message(summary, true, true), + }], + usage: None, + }, + ConversationMessage::user_text("tiny"), + ConversationMessage::assistant(vec![ContentBlock::Text { + text: "recent".to_string(), + }]), + ], + }; + + assert!(!should_compact( + &session, + CompactionConfig { + preserve_recent_messages: 2, + max_estimated_tokens: 1, + } + )); + } + #[test] fn truncates_long_blocks_in_summary() { let summary = super::summarize_block(&ContentBlock::Text { diff --git a/rust/crates/runtime/src/conversation.rs b/rust/crates/runtime/src/conversation.rs index 2fc1872..a73f2f4 100644 --- a/rust/crates/runtime/src/conversation.rs +++ b/rust/crates/runtime/src/conversation.rs @@ -234,6 +234,7 @@ where let mut assistant_messages = Vec::new(); let mut tool_results = Vec::new(); let mut iterations = 0; + let mut max_turn_input_tokens = 0; loop { iterations += 1; @@ -250,6 +251,7 @@ where let events = self.api_client.stream(request)?; let (assistant_message, usage) = build_assistant_message(events)?; if let Some(usage) = usage { + max_turn_input_tokens = max_turn_input_tokens.max(usage.input_tokens); self.usage_tracker.record(usage); } let pending_tool_uses = assistant_message @@ -365,7 +367,7 @@ where } } - let auto_compaction = self.maybe_auto_compact(); + let auto_compaction = self.maybe_auto_compact(max_turn_input_tokens); Ok(TurnSummary { assistant_messages, @@ -426,17 +428,16 @@ where ) } - fn maybe_auto_compact(&mut self) -> Option<AutoCompactionEvent> { - if self.usage_tracker.cumulative_usage().input_tokens - < self.auto_compaction_input_tokens_threshold - { + fn maybe_auto_compact(&mut self, turn_input_tokens: u32) -> Option<AutoCompactionEvent> { + if turn_input_tokens < self.auto_compaction_input_tokens_threshold { return None; } let result = compact_session( &self.session, CompactionConfig { - max_estimated_tokens: 0, + max_estimated_tokens: usize::try_from(self.auto_compaction_input_tokens_threshold) + .unwrap_or(usize::MAX), ..CompactionConfig::default() }, ); @@ -1204,7 +1205,7 @@ mod tests { } #[test] - fn auto_compacts_when_cumulative_input_threshold_is_crossed() { + fn auto_compacts_when_turn_input_threshold_is_crossed() { struct SimpleApi; impl ApiClient for SimpleApi { fn stream( @@ -1227,13 +1228,13 @@ mod tests { let session = Session { version: 1, messages: vec![ - crate::session::ConversationMessage::user_text("one"), + crate::session::ConversationMessage::user_text("one ".repeat(30_000)), crate::session::ConversationMessage::assistant(vec![ContentBlock::Text { - text: "two".to_string(), + text: "two ".repeat(30_000), }]), - crate::session::ConversationMessage::user_text("three"), + crate::session::ConversationMessage::user_text("three ".repeat(30_000)), crate::session::ConversationMessage::assistant(vec![ContentBlock::Text { - text: "four".to_string(), + text: "four ".repeat(30_000), }]), ], }; @@ -1260,6 +1261,72 @@ mod tests { assert_eq!(runtime.session().messages[0].role, MessageRole::System); } + #[test] + fn auto_compaction_does_not_repeat_after_context_is_already_compacted() { + struct SequentialUsageApi { + call_count: usize, + } + + impl ApiClient for SequentialUsageApi { + fn stream( + &mut self, + _request: ApiRequest, + ) -> Result<Vec<AssistantEvent>, RuntimeError> { + self.call_count += 1; + let input_tokens = if self.call_count == 1 { 120_000 } else { 64 }; + Ok(vec![ + AssistantEvent::TextDelta("done".to_string()), + AssistantEvent::Usage(TokenUsage { + input_tokens, + output_tokens: 4, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }), + AssistantEvent::MessageStop, + ]) + } + } + + let session = Session { + version: 1, + messages: vec![ + crate::session::ConversationMessage::user_text("one ".repeat(30_000)), + crate::session::ConversationMessage::assistant(vec![ContentBlock::Text { + text: "two ".repeat(30_000), + }]), + crate::session::ConversationMessage::user_text("three ".repeat(30_000)), + crate::session::ConversationMessage::assistant(vec![ContentBlock::Text { + text: "four ".repeat(30_000), + }]), + ], + }; + + let mut runtime = ConversationRuntime::new( + session, + SequentialUsageApi { call_count: 0 }, + StaticToolExecutor::new(), + PermissionPolicy::new(PermissionMode::DangerFullAccess), + vec!["system".to_string()], + ) + .with_auto_compaction_input_tokens_threshold(100_000); + + let first = runtime + .run_turn("trigger", None) + .expect("first turn should succeed"); + assert_eq!( + first.auto_compaction, + Some(AutoCompactionEvent { + removed_message_count: 2, + }) + ); + + let second = runtime + .run_turn("continue", None) + .expect("second turn should succeed"); + assert_eq!(second.auto_compaction, None); + assert_eq!(runtime.session().messages[0].role, MessageRole::System); + } + #[test] fn skips_auto_compaction_below_threshold() { struct SimpleApi; From 24fea5db9eb85a9a7eadce95c57d255b12f367d7 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo <hurrc04@gmail.com> Date: Wed, 1 Apr 2026 08:03:22 +0000 Subject: [PATCH 24/25] Prove raw tool output truncation stays display-only Add a renderer regression test for long non-JSON tool output so the CLI's fallback rendering path is covered alongside Read and structured tool payload truncation. Constraint: This follow-up must commit only renderer-related changes Rejected: Touch commands crate to fix unrelated slash-command work in progress | outside the requested renderer-only scope Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep truncation guarantees covered at the renderer boundary for both structured and raw tool payloads Tested: cargo fmt --all; cargo test -p rusty-claude-cli tool_rendering_ -- --nocapture; cargo clippy -p rusty-claude-cli --all-targets -- -D warnings Not-tested: cargo test --workspace and cargo clippy --workspace --all-targets -- -D warnings currently fail in rust/crates/commands/src/lib.rs due pre-existing incomplete agents/skills changes outside this commit --- rust/crates/rusty-claude-cli/src/main.rs | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index b6595bb..d770960 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -10,7 +10,10 @@ use std::io::{self, Read, Write}; use std::net::TcpListener; use std::path::{Path, PathBuf}; use std::process::Command; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::sync::mpsc::{self, RecvTimeoutError}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use api::{ resolve_startup_auth_source, AnthropicClient, AuthSource, ContentBlockDelta, InputContentBlock, @@ -50,6 +53,7 @@ const DEFAULT_OAUTH_CALLBACK_PORT: u16 = 4545; const VERSION: &str = env!("CARGO_PKG_VERSION"); const BUILD_TARGET: Option<&str> = option_env!("TARGET"); const GIT_SHA: Option<&str> = option_env!("GIT_SHA"); +const INTERNAL_PROGRESS_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(3); type AllowedToolSet = BTreeSet<String>; @@ -4053,6 +4057,23 @@ mod tests { assert!(output.contains("payload 119")); } + #[test] + fn tool_rendering_truncates_raw_generic_output_for_display_only() { + let output = (0..120) + .map(|index| format!("raw {index:03}")) + .collect::<Vec<_>>() + .join("\n"); + + let rendered = format_tool_result("plugin_echo", &output, false); + + assert!(rendered.contains("plugin_echo")); + assert!(rendered.contains("raw 000")); + assert!(rendered.contains("raw 059")); + assert!(!rendered.contains("raw 119")); + assert!(rendered.contains("full result preserved in session")); + assert!(output.contains("raw 119")); + } + #[test] fn push_output_block_renders_markdown_text() { let mut out = Vec::new(); From 7f33569f3a0cbfcaf28f9c5045bcd063f45eeaf6 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo <hurrc04@gmail.com> Date: Wed, 1 Apr 2026 08:05:22 +0000 Subject: [PATCH 25/25] feat: command surface and slash completion wiring --- rust/crates/commands/src/lib.rs | 91 +++++- rust/crates/rusty-claude-cli/src/main.rs | 395 ++++++++++++++++++++++- 2 files changed, 474 insertions(+), 12 deletions(-) diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 92b0745..b1aa69c 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -1,3 +1,8 @@ +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}; @@ -34,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, @@ -581,9 +587,10 @@ pub fn handle_slash_command( #[cfg(test)] mod tests { use super::{ - handle_plugins_slash_command, handle_slash_command, render_plugins_report, + 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, - SlashCommand, + DefinitionSource, SlashCommand, }; use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginMetadata, PluginSummary}; use runtime::{CompactionConfig, ContentBlock, ConversationMessage, MessageRole, Session}; @@ -921,6 +928,86 @@ mod tests { 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"); diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index d770960..7c3179c 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -987,6 +987,7 @@ impl LiveCli { true, allowed_tools.clone(), permission_mode, + None, )?; let cli = Self { model, @@ -1084,6 +1085,7 @@ impl LiveCli { false, self.allowed_tools.clone(), self.permission_mode, + None, )?; let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); let summary = runtime.run_turn(input, Some(&mut permission_prompter))?; @@ -1265,6 +1267,7 @@ impl LiveCli { true, self.allowed_tools.clone(), self.permission_mode, + None, )?; self.model.clone_from(&model); println!( @@ -1308,6 +1311,7 @@ impl LiveCli { true, self.allowed_tools.clone(), self.permission_mode, + None, )?; println!( "{}", @@ -1333,6 +1337,7 @@ impl LiveCli { true, self.allowed_tools.clone(), self.permission_mode, + None, )?; println!( "Session cleared\n Mode fresh session\n Preserved model {}\n Permission mode {}\n Session {}", @@ -1368,6 +1373,7 @@ impl LiveCli { true, self.allowed_tools.clone(), self.permission_mode, + None, )?; self.session = handle; println!( @@ -1440,6 +1446,7 @@ impl LiveCli { true, self.allowed_tools.clone(), self.permission_mode, + None, )?; self.session = handle; println!( @@ -1483,6 +1490,7 @@ impl LiveCli { true, self.allowed_tools.clone(), self.permission_mode, + None, )?; self.persist_session() } @@ -1500,16 +1508,18 @@ impl LiveCli { true, self.allowed_tools.clone(), self.permission_mode, + None, )?; self.persist_session()?; println!("{}", format_compact_report(removed, kept, skipped)); Ok(()) } - fn run_internal_prompt_text( + fn run_internal_prompt_text_with_progress( &self, prompt: &str, enable_tools: bool, + progress: Option<InternalPromptProgressReporter>, ) -> Result<String, Box<dyn std::error::Error>> { let session = self.runtime.session().clone(); let mut runtime = build_runtime( @@ -1520,12 +1530,21 @@ impl LiveCli { false, self.allowed_tools.clone(), self.permission_mode, + progress, )?; let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); let summary = runtime.run_turn(prompt, Some(&mut permission_prompter))?; Ok(final_assistant_text(&summary).trim().to_string()) } + fn run_internal_prompt_text( + &self, + prompt: &str, + enable_tools: bool, + ) -> Result<String, Box<dyn std::error::Error>> { + self.run_internal_prompt_text_with_progress(prompt, enable_tools, None) + } + fn run_bughunter(&self, scope: Option<&str>) -> Result<(), Box<dyn std::error::Error>> { let scope = scope.unwrap_or("the current repository"); let prompt = format!( @@ -1540,8 +1559,22 @@ impl LiveCli { let prompt = format!( "You are /ultraplan. Produce a deep multi-step execution plan for {task}. Include goals, risks, implementation sequence, verification steps, and rollback considerations. Use tools if needed." ); - println!("{}", self.run_internal_prompt_text(&prompt, true)?); - Ok(()) + let mut progress = InternalPromptProgressRun::start_ultraplan(task); + match self.run_internal_prompt_text_with_progress( + &prompt, + true, + Some(progress.reporter()), + ) { + Ok(plan) => { + progress.finish_success(); + println!("{plan}"); + Ok(()) + } + Err(error) => { + progress.finish_failure(&error.to_string()); + Err(error) + } + } } #[allow(clippy::unused_self)] @@ -2375,6 +2408,330 @@ fn resolve_plugin_path(cwd: &Path, config_home: &Path, value: &str) -> PathBuf { } } +#[derive(Debug, Clone, PartialEq, Eq)] +struct InternalPromptProgressState { + command_label: &'static str, + task_label: String, + step: usize, + phase: String, + detail: Option<String>, + saw_final_text: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum InternalPromptProgressEvent { + Started, + Update, + Heartbeat, + Complete, + Failed, +} + +#[derive(Debug)] +struct InternalPromptProgressShared { + state: Mutex<InternalPromptProgressState>, + output_lock: Mutex<()>, + started_at: Instant, +} + +#[derive(Debug, Clone)] +struct InternalPromptProgressReporter { + shared: Arc<InternalPromptProgressShared>, +} + +#[derive(Debug)] +struct InternalPromptProgressRun { + reporter: InternalPromptProgressReporter, + heartbeat_stop: Option<mpsc::Sender<()>>, + heartbeat_handle: Option<thread::JoinHandle<()>>, +} + +impl InternalPromptProgressReporter { + fn ultraplan(task: &str) -> Self { + Self { + shared: Arc::new(InternalPromptProgressShared { + state: Mutex::new(InternalPromptProgressState { + command_label: "Ultraplan", + task_label: task.to_string(), + step: 0, + phase: "planning started".to_string(), + detail: Some(format!("task: {task}")), + saw_final_text: false, + }), + output_lock: Mutex::new(()), + started_at: Instant::now(), + }), + } + } + + fn emit(&self, event: InternalPromptProgressEvent, error: Option<&str>) { + let snapshot = self.snapshot(); + let line = + format_internal_prompt_progress_line(event, &snapshot, self.elapsed(), error); + self.write_line(&line); + } + + fn mark_model_phase(&self) { + let snapshot = { + let mut state = self + .shared + .state + .lock() + .expect("internal prompt progress state poisoned"); + state.step += 1; + state.phase = if state.step == 1 { + "analyzing request".to_string() + } else { + "reviewing findings".to_string() + }; + state.detail = Some(format!("task: {}", state.task_label)); + state.clone() + }; + self.write_line(&format_internal_prompt_progress_line( + InternalPromptProgressEvent::Update, + &snapshot, + self.elapsed(), + None, + )); + } + + fn mark_tool_phase(&self, name: &str, input: &str) { + let detail = describe_tool_progress(name, input); + let snapshot = { + let mut state = self + .shared + .state + .lock() + .expect("internal prompt progress state poisoned"); + state.step += 1; + state.phase = format!("running {name}"); + state.detail = Some(detail); + state.clone() + }; + self.write_line(&format_internal_prompt_progress_line( + InternalPromptProgressEvent::Update, + &snapshot, + self.elapsed(), + None, + )); + } + + fn mark_text_phase(&self, text: &str) { + let trimmed = text.trim(); + if trimmed.is_empty() { + return; + } + let detail = truncate_for_summary(first_visible_line(trimmed), 120); + let snapshot = { + let mut state = self + .shared + .state + .lock() + .expect("internal prompt progress state poisoned"); + if state.saw_final_text { + return; + } + state.saw_final_text = true; + state.step += 1; + state.phase = "drafting final plan".to_string(); + state.detail = (!detail.is_empty()).then_some(detail); + state.clone() + }; + self.write_line(&format_internal_prompt_progress_line( + InternalPromptProgressEvent::Update, + &snapshot, + self.elapsed(), + None, + )); + } + + fn emit_heartbeat(&self) { + let snapshot = self.snapshot(); + self.write_line(&format_internal_prompt_progress_line( + InternalPromptProgressEvent::Heartbeat, + &snapshot, + self.elapsed(), + None, + )); + } + + fn snapshot(&self) -> InternalPromptProgressState { + self.shared + .state + .lock() + .expect("internal prompt progress state poisoned") + .clone() + } + + fn elapsed(&self) -> Duration { + self.shared.started_at.elapsed() + } + + fn write_line(&self, line: &str) { + let _guard = self + .shared + .output_lock + .lock() + .expect("internal prompt progress output lock poisoned"); + let mut stdout = io::stdout(); + let _ = writeln!(stdout, "{line}"); + let _ = stdout.flush(); + } +} + +impl InternalPromptProgressRun { + fn start_ultraplan(task: &str) -> Self { + let reporter = InternalPromptProgressReporter::ultraplan(task); + reporter.emit(InternalPromptProgressEvent::Started, None); + + let (heartbeat_stop, heartbeat_rx) = mpsc::channel(); + let heartbeat_reporter = reporter.clone(); + let heartbeat_handle = thread::spawn(move || { + loop { + match heartbeat_rx.recv_timeout(INTERNAL_PROGRESS_HEARTBEAT_INTERVAL) { + Ok(()) | Err(RecvTimeoutError::Disconnected) => break, + Err(RecvTimeoutError::Timeout) => heartbeat_reporter.emit_heartbeat(), + } + } + }); + + Self { + reporter, + heartbeat_stop: Some(heartbeat_stop), + heartbeat_handle: Some(heartbeat_handle), + } + } + + fn reporter(&self) -> InternalPromptProgressReporter { + self.reporter.clone() + } + + fn finish_success(&mut self) { + self.stop_heartbeat(); + self.reporter.emit(InternalPromptProgressEvent::Complete, None); + } + + fn finish_failure(&mut self, error: &str) { + self.stop_heartbeat(); + self.reporter + .emit(InternalPromptProgressEvent::Failed, Some(error)); + } + + fn stop_heartbeat(&mut self) { + if let Some(sender) = self.heartbeat_stop.take() { + let _ = sender.send(()); + } + if let Some(handle) = self.heartbeat_handle.take() { + let _ = handle.join(); + } + } +} + +impl Drop for InternalPromptProgressRun { + fn drop(&mut self) { + self.stop_heartbeat(); + } +} + +fn format_internal_prompt_progress_line( + event: InternalPromptProgressEvent, + snapshot: &InternalPromptProgressState, + elapsed: Duration, + error: Option<&str>, +) -> String { + let elapsed_seconds = elapsed.as_secs(); + let step_label = if snapshot.step == 0 { + "current step pending".to_string() + } else { + format!("current step {}", snapshot.step) + }; + let mut status_bits = vec![step_label, format!("phase {}", snapshot.phase)]; + if let Some(detail) = snapshot.detail.as_deref().filter(|detail| !detail.is_empty()) { + status_bits.push(detail.to_string()); + } + let status = status_bits.join(" · "); + match event { + InternalPromptProgressEvent::Started => { + format!("🧭 {} status · planning started · {status}", snapshot.command_label) + } + InternalPromptProgressEvent::Update => { + format!("… {} status · {status}", snapshot.command_label) + } + InternalPromptProgressEvent::Heartbeat => format!( + "… {} heartbeat · {elapsed_seconds}s elapsed · {status}", + snapshot.command_label + ), + InternalPromptProgressEvent::Complete => format!( + "✔ {} status · completed · {elapsed_seconds}s elapsed · {} steps total", + snapshot.command_label, + snapshot.step + ), + InternalPromptProgressEvent::Failed => format!( + "✘ {} status · failed · {elapsed_seconds}s elapsed · {}", + snapshot.command_label, + error.unwrap_or("unknown error") + ), + } +} + +fn describe_tool_progress(name: &str, input: &str) -> String { + let parsed: serde_json::Value = + serde_json::from_str(input).unwrap_or(serde_json::Value::String(input.to_string())); + match name { + "bash" | "Bash" => { + let command = parsed + .get("command") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + if command.is_empty() { + "running shell command".to_string() + } else { + format!("command {}", truncate_for_summary(command.trim(), 100)) + } + } + "read_file" | "Read" => format!("reading {}", extract_tool_path(&parsed)), + "write_file" | "Write" => format!("writing {}", extract_tool_path(&parsed)), + "edit_file" | "Edit" => format!("editing {}", extract_tool_path(&parsed)), + "glob_search" | "Glob" => { + let pattern = parsed + .get("pattern") + .and_then(|value| value.as_str()) + .unwrap_or("?"); + let scope = parsed + .get("path") + .and_then(|value| value.as_str()) + .unwrap_or("."); + format!("glob `{pattern}` in {scope}") + } + "grep_search" | "Grep" => { + let pattern = parsed + .get("pattern") + .and_then(|value| value.as_str()) + .unwrap_or("?"); + let scope = parsed + .get("path") + .and_then(|value| value.as_str()) + .unwrap_or("."); + format!("grep `{pattern}` in {scope}") + } + "web_search" | "WebSearch" => parsed + .get("query") + .and_then(|value| value.as_str()) + .map_or_else( + || "running web search".to_string(), + |query| format!("query {}", truncate_for_summary(query, 100)), + ), + _ => { + let summary = summarize_tool_payload(input); + if summary.is_empty() { + format!("running {name}") + } else { + format!("{name}: {summary}") + } + } + } +} + #[allow(clippy::needless_pass_by_value)] fn build_runtime( session: Session, @@ -2384,6 +2741,7 @@ fn build_runtime( emit_output: bool, allowed_tools: Option<AllowedToolSet>, permission_mode: PermissionMode, + progress_reporter: Option<InternalPromptProgressReporter>, ) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>> { let (feature_config, plugin_registry, tool_registry) = build_runtime_plugin_state()?; @@ -2395,6 +2753,7 @@ fn build_runtime( emit_output, allowed_tools.clone(), tool_registry.clone(), + progress_reporter, )?, CliToolExecutor::new(allowed_tools.clone(), emit_output, tool_registry.clone()), permission_policy(permission_mode, &tool_registry), @@ -2458,6 +2817,7 @@ struct AnthropicRuntimeClient { emit_output: bool, allowed_tools: Option<AllowedToolSet>, tool_registry: GlobalToolRegistry, + progress_reporter: Option<InternalPromptProgressReporter>, } impl AnthropicRuntimeClient { @@ -2467,6 +2827,7 @@ impl AnthropicRuntimeClient { emit_output: bool, allowed_tools: Option<AllowedToolSet>, tool_registry: GlobalToolRegistry, + progress_reporter: Option<InternalPromptProgressReporter>, ) -> Result<Self, Box<dyn std::error::Error>> { Ok(Self { runtime: tokio::runtime::Runtime::new()?, @@ -2477,6 +2838,7 @@ impl AnthropicRuntimeClient { emit_output, allowed_tools, tool_registry, + progress_reporter, }) } } @@ -2494,6 +2856,9 @@ fn resolve_cli_auth_source() -> Result<AuthSource, Box<dyn std::error::Error>> { impl ApiClient for AnthropicRuntimeClient { #[allow(clippy::too_many_lines)] fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> { + if let Some(progress_reporter) = &self.progress_reporter { + progress_reporter.mark_model_phase(); + } let message_request = MessageRequest { model: self.model.clone(), max_tokens: max_tokens_for_model(&self.model), @@ -2548,6 +2913,9 @@ impl ApiClient for AnthropicRuntimeClient { ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta { ContentBlockDelta::TextDelta { text } => { if !text.is_empty() { + if let Some(progress_reporter) = &self.progress_reporter { + progress_reporter.mark_text_phase(&text); + } if let Some(rendered) = markdown_stream.push(&renderer, &text) { write!(out, "{rendered}") .and_then(|()| out.flush()) @@ -2571,6 +2939,9 @@ impl ApiClient for AnthropicRuntimeClient { .map_err(|error| RuntimeError::new(error.to_string()))?; } if let Some((id, name, input)) = pending_tool.take() { + if let Some(progress_reporter) = &self.progress_reporter { + progress_reporter.mark_tool_phase(&name, &input); + } // Display tool call now that input is fully accumulated writeln!(out, "\n{}", format_tool_call_start(&name, &input)) .and_then(|()| out.flush()) @@ -3384,19 +3755,23 @@ fn print_help() { #[cfg(test)] mod tests { use super::{ - filter_tool_specs, format_compact_report, format_cost_report, format_model_report, - format_model_switch_report, format_permissions_report, format_permissions_switch_report, - format_resume_report, format_status_report, format_tool_call_start, format_tool_result, - normalize_permission_mode, parse_args, parse_git_status_metadata, permission_policy, - print_help_to, push_output_block, render_config_report, render_memory_report, - render_repl_help, resolve_model_alias, response_to_events, resume_supported_slash_commands, - status_context, CliAction, CliOutputFormat, SlashCommand, StatusUsage, DEFAULT_MODEL, + describe_tool_progress, filter_tool_specs, format_compact_report, format_cost_report, + format_internal_prompt_progress_line, format_model_report, + format_model_switch_report, format_permissions_report, + format_permissions_switch_report, format_resume_report, format_status_report, + format_tool_call_start, format_tool_result, normalize_permission_mode, parse_args, + parse_git_status_metadata, permission_policy, print_help_to, push_output_block, + render_config_report, render_memory_report, render_repl_help, resolve_model_alias, + response_to_events, resume_supported_slash_commands, status_context, CliAction, + CliOutputFormat, InternalPromptProgressEvent, InternalPromptProgressState, SlashCommand, + StatusUsage, DEFAULT_MODEL, }; use api::{MessageResponse, OutputContentBlock, Usage}; use plugins::{PluginTool, PluginToolDefinition, PluginToolPermission}; use runtime::{AssistantEvent, ContentBlock, ConversationMessage, MessageRole, PermissionMode}; use serde_json::json; use std::path::PathBuf; + use std::time::Duration; use tools::GlobalToolRegistry; fn registry_with_plugin_tool() -> GlobalToolRegistry {