diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index f23fa60..878607b 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -1,4 +1,11 @@ -#![allow(dead_code, unused_imports, unused_variables, clippy::unneeded_struct_pattern, clippy::unnecessary_wraps, clippy::unused_self)] +#![allow( + dead_code, + unused_imports, + unused_variables, + clippy::unneeded_struct_pattern, + clippy::unnecessary_wraps, + clippy::unused_self +)] mod init; mod input; mod render; @@ -8,6 +15,7 @@ use std::env; use std::fs; use std::io::{self, Read, Write}; use std::net::TcpListener; +use std::ops::{Deref, DerefMut}; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; @@ -28,7 +36,7 @@ use commands::{ }; use compat_harness::{extract_manifest, UpstreamPaths}; use init::initialize_repo; -use plugins::{PluginManager, PluginManagerConfig}; +use plugins::{PluginHooks, PluginManager, PluginManagerConfig, PluginRegistry}; use render::{MarkdownStreamState, Spinner, TerminalRenderer}; use runtime::{ clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt, @@ -1475,10 +1483,76 @@ struct LiveCli { allowed_tools: Option, permission_mode: PermissionMode, system_prompt: Vec, - runtime: ConversationRuntime, + runtime: BuiltRuntime, session: SessionHandle, } +struct RuntimePluginState { + feature_config: runtime::RuntimeFeatureConfig, + tool_registry: GlobalToolRegistry, + plugin_registry: PluginRegistry, +} + +struct BuiltRuntime { + runtime: Option>, + plugin_registry: PluginRegistry, + plugins_active: bool, +} + +impl BuiltRuntime { + fn new( + runtime: ConversationRuntime, + plugin_registry: PluginRegistry, + ) -> Self { + Self { + runtime: Some(runtime), + plugin_registry, + plugins_active: true, + } + } + + fn with_hook_abort_signal(mut self, hook_abort_signal: runtime::HookAbortSignal) -> Self { + let runtime = self + .runtime + .take() + .expect("runtime should exist before installing hook abort signal"); + self.runtime = Some(runtime.with_hook_abort_signal(hook_abort_signal)); + self + } + + fn shutdown_plugins(&mut self) -> Result<(), Box> { + if self.plugins_active { + self.plugin_registry.shutdown()?; + self.plugins_active = false; + } + Ok(()) + } +} + +impl Deref for BuiltRuntime { + type Target = ConversationRuntime; + + fn deref(&self) -> &Self::Target { + self.runtime + .as_ref() + .expect("runtime should exist while built runtime is alive") + } +} + +impl DerefMut for BuiltRuntime { + fn deref_mut(&mut self) -> &mut Self::Target { + self.runtime + .as_mut() + .expect("runtime should exist while built runtime is alive") + } +} + +impl Drop for BuiltRuntime { + fn drop(&mut self) { + let _ = self.shutdown_plugins(); + } +} + struct HookAbortMonitor { stop_tx: Option>, join_handle: Option>, @@ -1625,13 +1699,7 @@ impl LiveCli { fn prepare_turn_runtime( &self, emit_output: bool, - ) -> Result< - ( - ConversationRuntime, - HookAbortMonitor, - ), - Box, - > { + ) -> Result<(BuiltRuntime, HookAbortMonitor), Box> { let hook_abort_signal = runtime::HookAbortSignal::new(); let runtime = build_runtime( self.runtime.session().clone(), @@ -1650,6 +1718,12 @@ impl LiveCli { Ok((runtime, hook_abort_monitor)) } + fn replace_runtime(&mut self, runtime: BuiltRuntime) -> Result<(), Box> { + self.runtime.shutdown_plugins()?; + self.runtime = runtime; + Ok(()) + } + fn run_turn(&mut self, input: &str) -> Result<(), Box> { let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(true)?; let mut spinner = Spinner::new(); @@ -1662,9 +1736,9 @@ impl LiveCli { let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); let result = runtime.run_turn(input, Some(&mut permission_prompter)); hook_abort_monitor.stop(); - self.runtime = runtime; match result { Ok(summary) => { + self.replace_runtime(runtime)?; spinner.finish( "✨ Done", TerminalRenderer::new().color_theme(), @@ -1681,6 +1755,7 @@ impl LiveCli { Ok(()) } Err(error) => { + runtime.shutdown_plugins()?; spinner.fail( "❌ Request failed", TerminalRenderer::new().color_theme(), @@ -1708,7 +1783,7 @@ impl LiveCli { let result = runtime.run_turn(input, Some(&mut permission_prompter)); hook_abort_monitor.stop(); let summary = result?; - self.runtime = runtime; + self.replace_runtime(runtime)?; self.persist_session()?; println!( "{}", @@ -1903,7 +1978,7 @@ impl LiveCli { let previous = self.model.clone(); let session = self.runtime.session().clone(); let message_count = session.messages.len(); - self.runtime = build_runtime( + let runtime = build_runtime( session, &self.session.id, model.clone(), @@ -1914,6 +1989,7 @@ impl LiveCli { self.permission_mode, None, )?; + self.replace_runtime(runtime)?; self.model.clone_from(&model); println!( "{}", @@ -1948,7 +2024,7 @@ impl LiveCli { let previous = self.permission_mode.as_str().to_string(); let session = self.runtime.session().clone(); self.permission_mode = permission_mode_from_label(normalized); - self.runtime = build_runtime( + let runtime = build_runtime( session, &self.session.id, self.model.clone(), @@ -1959,6 +2035,7 @@ impl LiveCli { self.permission_mode, None, )?; + self.replace_runtime(runtime)?; println!( "{}", format_permissions_switch_report(&previous, normalized) @@ -1976,7 +2053,7 @@ impl LiveCli { let session_state = Session::new(); self.session = create_managed_session_handle(&session_state.session_id)?; - self.runtime = build_runtime( + let runtime = build_runtime( session_state.with_persistence_path(self.session.path.clone()), &self.session.id, self.model.clone(), @@ -1987,6 +2064,7 @@ impl LiveCli { self.permission_mode, None, )?; + self.replace_runtime(runtime)?; println!( "Session cleared\n Mode fresh session\n Preserved model {}\n Permission mode {}\n Session {}", self.model, @@ -2014,7 +2092,7 @@ impl LiveCli { let session = Session::load_from_path(&handle.path)?; let message_count = session.messages.len(); let session_id = session.session_id.clone(); - self.runtime = build_runtime( + let runtime = build_runtime( session, &handle.id, self.model.clone(), @@ -2025,6 +2103,7 @@ impl LiveCli { self.permission_mode, None, )?; + self.replace_runtime(runtime)?; self.session = SessionHandle { id: session_id, path: handle.path, @@ -2104,7 +2183,7 @@ impl LiveCli { let session = Session::load_from_path(&handle.path)?; let message_count = session.messages.len(); let session_id = session.session_id.clone(); - self.runtime = build_runtime( + let runtime = build_runtime( session, &handle.id, self.model.clone(), @@ -2115,6 +2194,7 @@ impl LiveCli { self.permission_mode, None, )?; + self.replace_runtime(runtime)?; self.session = SessionHandle { id: session_id, path: handle.path, @@ -2138,7 +2218,7 @@ impl LiveCli { let forked = forked.with_persistence_path(handle.path.clone()); let message_count = forked.messages.len(); forked.save_to_path(&handle.path)?; - self.runtime = build_runtime( + let runtime = build_runtime( forked, &handle.id, self.model.clone(), @@ -2149,6 +2229,7 @@ impl LiveCli { self.permission_mode, None, )?; + self.replace_runtime(runtime)?; self.session = handle; println!( "Session forked\n Parent session {}\n Active session {}\n Branch {}\n File {}\n Messages {}", @@ -2187,7 +2268,7 @@ impl LiveCli { } fn reload_runtime_features(&mut self) -> Result<(), Box> { - self.runtime = build_runtime( + let runtime = build_runtime( self.runtime.session().clone(), &self.session.id, self.model.clone(), @@ -2198,6 +2279,7 @@ impl LiveCli { self.permission_mode, None, )?; + self.replace_runtime(runtime)?; self.persist_session() } @@ -2206,7 +2288,7 @@ impl LiveCli { let removed = result.removed_message_count; let kept = result.compacted_session.messages.len(); let skipped = removed == 0; - self.runtime = build_runtime( + let runtime = build_runtime( result.compacted_session, &self.session.id, self.model.clone(), @@ -2217,6 +2299,7 @@ impl LiveCli { self.permission_mode, None, )?; + self.replace_runtime(runtime)?; self.persist_session()?; println!("{}", format_compact_report(removed, kept, skipped)); Ok(()) @@ -2242,7 +2325,9 @@ impl LiveCli { )?; 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()) + let text = final_assistant_text(&summary).trim().to_string(); + runtime.shutdown_plugins()?; + Ok(text) } fn run_internal_prompt_text( @@ -3270,14 +3355,32 @@ fn build_system_prompt() -> Result, Box> { )?) } -fn build_runtime_plugin_state( -) -> Result<(runtime::RuntimeFeatureConfig, GlobalToolRegistry), Box> { +fn build_runtime_plugin_state() -> Result> { let cwd = env::current_dir()?; let loader = ConfigLoader::default_for(&cwd); let runtime_config = loader.load()?; + build_runtime_plugin_state_with_loader(&cwd, &loader, &runtime_config) +} + +fn build_runtime_plugin_state_with_loader( + cwd: &Path, + loader: &ConfigLoader, + runtime_config: &runtime::RuntimeConfig, +) -> Result> { let plugin_manager = build_plugin_manager(&cwd, &loader, &runtime_config); - let tool_registry = GlobalToolRegistry::with_plugin_tools(plugin_manager.aggregated_tools()?)?; - Ok((runtime_config.feature_config().clone(), tool_registry)) + let plugin_registry = plugin_manager.plugin_registry()?; + let plugin_hook_config = + runtime_hook_config_from_plugin_hooks(plugin_registry.aggregated_hooks()?); + let feature_config = runtime_config + .feature_config() + .clone() + .with_hooks(runtime_config.hooks().merged(&plugin_hook_config)); + let tool_registry = GlobalToolRegistry::with_plugin_tools(plugin_registry.aggregated_tools()?)?; + Ok(RuntimePluginState { + feature_config, + tool_registry, + plugin_registry, + }) } fn build_plugin_manager( @@ -3316,6 +3419,14 @@ fn resolve_plugin_path(cwd: &Path, config_home: &Path, value: &str) -> PathBuf { } } +fn runtime_hook_config_from_plugin_hooks(hooks: PluginHooks) -> runtime::RuntimeHookConfig { + runtime::RuntimeHookConfig::new( + hooks.pre_tool_use, + hooks.post_tool_use, + hooks.post_tool_use_failure, + ) +} + #[derive(Debug, Clone, PartialEq, Eq)] struct InternalPromptProgressState { command_label: &'static str, @@ -3656,9 +3767,42 @@ fn build_runtime( allowed_tools: Option, permission_mode: PermissionMode, progress_reporter: Option, -) -> Result, Box> -{ - let (feature_config, tool_registry) = build_runtime_plugin_state()?; +) -> Result> { + let runtime_plugin_state = build_runtime_plugin_state()?; + build_runtime_with_plugin_state( + session, + session_id, + model, + system_prompt, + enable_tools, + emit_output, + allowed_tools, + permission_mode, + progress_reporter, + runtime_plugin_state, + ) +} + +#[allow(clippy::needless_pass_by_value)] +#[allow(clippy::too_many_arguments)] +fn build_runtime_with_plugin_state( + session: Session, + session_id: &str, + model: String, + system_prompt: Vec, + enable_tools: bool, + emit_output: bool, + allowed_tools: Option, + permission_mode: PermissionMode, + progress_reporter: Option, + runtime_plugin_state: RuntimePluginState, +) -> Result> { + let RuntimePluginState { + feature_config, + tool_registry, + plugin_registry, + } = runtime_plugin_state; + plugin_registry.initialize()?; let mut runtime = ConversationRuntime::new_with_features( session, AnthropicRuntimeClient::new( @@ -3679,7 +3823,7 @@ fn build_runtime( if emit_output { runtime = runtime.with_hook_progress_reporter(Box::new(CliHookProgressReporter)); } - Ok(runtime) + Ok(BuiltRuntime::new(runtime, plugin_registry)) } struct CliHookProgressReporter; @@ -4847,6 +4991,7 @@ fn print_help() { #[cfg(test)] mod tests { use super::{ + build_runtime_plugin_state_with_loader, build_runtime_with_plugin_state, create_managed_session_handle, describe_tool_progress, filter_tool_specs, format_bughunter_report, format_commit_preflight_report, format_commit_skipped_report, format_compact_report, format_cost_report, format_internal_prompt_progress_line, @@ -4865,9 +5010,12 @@ mod tests { InternalPromptProgressState, LiveCli, SlashCommand, StatusUsage, DEFAULT_MODEL, }; use api::{MessageResponse, OutputContentBlock, Usage}; - use plugins::{PluginTool, PluginToolDefinition, PluginToolPermission}; + use plugins::{ + PluginManager, PluginManagerConfig, PluginTool, PluginToolDefinition, PluginToolPermission, + }; use runtime::{ - AssistantEvent, ContentBlock, ConversationMessage, MessageRole, PermissionMode, Session, + AssistantEvent, ConfigLoader, ContentBlock, ConversationMessage, MessageRole, + PermissionMode, Session, }; use serde_json::json; use std::fs; @@ -4936,6 +5084,49 @@ mod tests { std::env::set_current_dir(previous).expect("cwd should restore"); result } + + fn write_plugin_fixture(root: &Path, name: &str, include_hooks: bool, include_lifecycle: bool) { + fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir"); + if include_hooks { + fs::create_dir_all(root.join("hooks")).expect("hooks dir"); + fs::write( + root.join("hooks").join("pre.sh"), + "#!/bin/sh\nprintf 'plugin pre hook'\n", + ) + .expect("write hook"); + } + if include_lifecycle { + fs::create_dir_all(root.join("lifecycle")).expect("lifecycle dir"); + fs::write( + root.join("lifecycle").join("init.sh"), + "#!/bin/sh\nprintf 'init\\n' >> lifecycle.log\n", + ) + .expect("write init lifecycle"); + fs::write( + root.join("lifecycle").join("shutdown.sh"), + "#!/bin/sh\nprintf 'shutdown\\n' >> lifecycle.log\n", + ) + .expect("write shutdown lifecycle"); + } + + let hooks = if include_hooks { + ",\n \"hooks\": {\n \"PreToolUse\": [\"./hooks/pre.sh\"]\n }" + } else { + "" + }; + let lifecycle = if include_lifecycle { + ",\n \"lifecycle\": {\n \"Init\": [\"./lifecycle/init.sh\"],\n \"Shutdown\": [\"./lifecycle/shutdown.sh\"]\n }" + } else { + "" + }; + fs::write( + root.join(".claude-plugin").join("plugin.json"), + format!( + "{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"runtime plugin fixture\"{hooks}{lifecycle}\n}}" + ), + ) + .expect("write plugin manifest"); + } #[test] fn defaults_to_repl_when_no_args() { assert_eq!( @@ -6384,6 +6575,89 @@ UU conflicted.rs", )); assert!(!String::from_utf8(out).expect("utf8").contains("step 1")); } + + #[test] + fn build_runtime_plugin_state_merges_plugin_hooks_into_runtime_features() { + let config_home = temp_dir(); + let workspace = temp_dir(); + let source_root = temp_dir(); + fs::create_dir_all(&config_home).expect("config home"); + fs::create_dir_all(&workspace).expect("workspace"); + fs::create_dir_all(&source_root).expect("source root"); + write_plugin_fixture(&source_root, "hook-runtime-demo", true, false); + + let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home)); + manager + .install(source_root.to_str().expect("utf8 source path")) + .expect("plugin install should succeed"); + let loader = ConfigLoader::new(&workspace, &config_home); + let runtime_config = loader.load().expect("runtime config should load"); + let state = build_runtime_plugin_state_with_loader(&workspace, &loader, &runtime_config) + .expect("plugin state should load"); + let pre_hooks = state.feature_config.hooks().pre_tool_use(); + assert_eq!(pre_hooks.len(), 1); + assert!( + pre_hooks[0].ends_with("hooks/pre.sh"), + "expected installed plugin hook path, got {pre_hooks:?}" + ); + + let _ = fs::remove_dir_all(config_home); + let _ = fs::remove_dir_all(workspace); + let _ = fs::remove_dir_all(source_root); + } + + #[test] + fn build_runtime_runs_plugin_lifecycle_init_and_shutdown() { + let config_home = temp_dir(); + let workspace = temp_dir(); + let source_root = temp_dir(); + fs::create_dir_all(&config_home).expect("config home"); + fs::create_dir_all(&workspace).expect("workspace"); + fs::create_dir_all(&source_root).expect("source root"); + write_plugin_fixture(&source_root, "lifecycle-runtime-demo", false, true); + + let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home)); + let install = manager + .install(source_root.to_str().expect("utf8 source path")) + .expect("plugin install should succeed"); + let log_path = install.install_path.join("lifecycle.log"); + let loader = ConfigLoader::new(&workspace, &config_home); + let runtime_config = loader.load().expect("runtime config should load"); + let runtime_plugin_state = + build_runtime_plugin_state_with_loader(&workspace, &loader, &runtime_config) + .expect("plugin state should load"); + let mut runtime = build_runtime_with_plugin_state( + Session::new(), + "runtime-plugin-lifecycle", + DEFAULT_MODEL.to_string(), + vec!["test system prompt".to_string()], + true, + false, + None, + PermissionMode::DangerFullAccess, + None, + runtime_plugin_state, + ) + .expect("runtime should build"); + + assert_eq!( + fs::read_to_string(&log_path).expect("init log should exist"), + "init\n" + ); + + runtime + .shutdown_plugins() + .expect("plugin shutdown should succeed"); + + assert_eq!( + fs::read_to_string(&log_path).expect("shutdown log should exist"), + "init\nshutdown\n" + ); + + let _ = fs::remove_dir_all(config_home); + let _ = fs::remove_dir_all(workspace); + let _ = fs::remove_dir_all(source_root); + } } #[cfg(test)]