From be08a46e48530762b59ea48ae8060e22913d2614 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Wed, 1 Apr 2026 06:25:27 +0000 Subject: [PATCH] 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, + #[serde(rename = "Shutdown", default)] + pub shutdown: Vec, +} + +impl PluginLifecycle { + #[must_use] + pub fn is_empty(&self) -> bool { + self.init.is_empty() && self.shutdown.is_empty() + } +} + #[derive(Debug, Clone, PartialEq, 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 { 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 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 { 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 41e0620..7f55947 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, }; @@ -97,6 +99,8 @@ pub struct ConversationRuntime { max_iterations: usize, usage_tracker: UsageTracker, hook_runner: HookRunner, + plugin_registry: Option, + plugins_shutdown: bool, } impl ConversationRuntime @@ -130,7 +134,7 @@ where tool_executor: T, permission_policy: PermissionPolicy, system_prompt: Vec, - feature_config: RuntimeFeatureConfig, + feature_config: RuntimeFeatureConfig, ) -> Self { let usage_tracker = UsageTracker::from_session(&session); Self { @@ -142,9 +146,36 @@ where max_iterations: usize::MAX, usage_tracker, hook_runner: HookRunner::from_feature_config(&feature_config), + 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, + feature_config: RuntimeFeatureConfig, + plugin_registry: PluginRegistry, + ) -> Result { + 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; @@ -284,8 +315,28 @@ 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(()) + } +} + +impl Drop for ConversationRuntime { + fn drop(&mut self) { + let _ = self.shutdown_plugins(); } } @@ -409,7 +460,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, @@ -471,6 +526,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 }; @@ -711,6 +798,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 c42e192..f1efca6 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, @@ -2052,20 +2052,22 @@ fn build_system_prompt() -> Result, Box> { )?) } -fn build_runtime_feature_config( -) -> Result> { +fn build_runtime_plugin_state( +) -> Result<(runtime::RuntimeFeatureConfig, PluginRegistry), Box> { 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( @@ -2114,14 +2116,16 @@ fn build_runtime( permission_mode: PermissionMode, ) -> Result, Box> { - 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 {