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 { + 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 ".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 ".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 ".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 ".to_string(), + reload_runtime: false, + }); + }; + manager.uninstall(target)?; + Ok(PluginsCommandResult { + message: format!("Plugins\n Result uninstalled {target}"), + reload_runtime: true, + }) + } + Some("update") => { + let Some(target) = target else { + return Ok(PluginsCommandResult { + message: "Usage: /plugins update ".to_string(), + reload_runtime: false, + }); + }; + let update = manager.update(target)?; + let plugin = manager + .list_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, #[serde(rename = "defaultEnabled", default)] pub default_enabled: bool, #[serde(default)] pub hooks: PluginHooks, #[serde(default)] pub lifecycle: PluginLifecycle, + #[serde(default)] + pub tools: Vec, + #[serde(default)] + pub commands: Vec, +} + +#[derive(Debug, Clone, PartialEq, 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, + #[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, + #[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, + required_permission: String, + root: Option, +} + +impl PluginTool { + #[must_use] + pub fn new( + plugin_id: impl Into, + plugin_name: impl Into, + definition: PluginToolDefinition, + command: impl Into, + args: Vec, + required_permission: impl Into, + root: Option, + ) -> 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 { + 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, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub struct BuiltinPlugin { metadata: PluginMetadata, hooks: PluginHooks, lifecycle: PluginLifecycle, + tools: Vec, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub struct BundledPlugin { metadata: PluginMetadata, hooks: PluginHooks, lifecycle: PluginLifecycle, + tools: Vec, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub struct ExternalPlugin { metadata: PluginMetadata, hooks: PluginHooks, lifecycle: PluginLifecycle, + tools: Vec, } pub trait Plugin { fn metadata(&self) -> &PluginMetadata; fn hooks(&self) -> &PluginHooks; fn lifecycle(&self) -> &PluginLifecycle; + fn tools(&self) -> &[PluginTool]; fn validate(&self) -> Result<(), PluginError>; fn initialize(&self) -> Result<(), PluginError>; fn shutdown(&self) -> Result<(), PluginError>; } -#[derive(Debug, Clone, PartialEq, 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, } @@ -403,6 +579,27 @@ impl PluginRegistry { }) } + pub fn aggregated_tools(&self) -> Result, PluginError> { + let mut tools = Vec::new(); + let mut seen_names = BTreeMap::new(); + for plugin in self.plugins.iter().filter(|plugin| plugin.is_enabled()) { + plugin.validate()?; + for tool in plugin.tools() { + if let Some(existing_plugin) = + seen_names.insert(tool.definition().name.clone(), tool.plugin_id().to_string()) + { + return Err(PluginError::InvalidManifest(format!( + "plugin tool `{}` is defined by both `{existing_plugin}` and `{}`", + tool.definition().name, + tool.plugin_id() + ))); + } + tools.push(tool.clone()); + } + } + Ok(tools) + } + pub fn initialize(&self) -> Result<(), PluginError> { for plugin in self.plugins.iter().filter(|plugin| plugin.is_enabled()) { plugin.validate()?; @@ -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 { 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 { @@ -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 { }, hooks: PluginHooks::default(), lifecycle: PluginLifecycle::default(), + tools: Vec::new(), })] } @@ -815,7 +1018,7 @@ fn load_plugin_definition( source: String, marketplace: &str, ) -> Result { - 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 { - let manifest = load_manifest_from_root(root)?; - validate_manifest(&manifest)?; +pub fn load_plugin_from_directory(root: &Path) -> Result { + let manifest = load_manifest_from_directory(root)?; + validate_plugin_manifest(root, &manifest)?; + Ok(manifest) +} + +fn load_validated_package_manifest_from_root( + root: &Path, +) -> Result { + 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 { +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 { + 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 { 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 { Ok(serde_json::from_str(&contents)?) } +fn plugin_manifest_path(root: &Path) -> Result { + let direct_path = root.join(MANIFEST_FILE_NAME); + if direct_path.exists() { + return Ok(direct_path); + } + + let packaged_path = root.join(MANIFEST_RELATIVE_PATH); + if packaged_path.exists() { + return Ok(packaged_path); + } + + Err(PluginError::NotFound(format!( + "plugin manifest not found at {} or {}", + direct_path.display(), + packaged_path.display() + ))) +} + +fn 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 { + 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 { plugins_shutdown: bool, } +impl ConversationRuntime { + 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 ConversationRuntime where C: ApiClient, @@ -144,7 +159,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 { @@ -172,6 +187,11 @@ where feature_config: RuntimeFeatureConfig, plugin_registry: PluginRegistry, ) -> Result { + 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 { @@ -381,7 +393,7 @@ where impl Drop for ConversationRuntime { 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, 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 { + 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 "); - 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 "); - 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 "); - 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 "); - 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 "); - 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> { @@ -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(