Преглед изворни кода

feat: plugin subsystem — loader, hooks, tools, bundled, CLI

Yeachan-Heo пре 2 месеци
родитељ
комит
5d14ff1d5f

+ 2 - 0
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",

+ 1 - 0
rust/crates/commands/Cargo.toml

@@ -9,4 +9,5 @@ publish.workspace = true
 workspace = true
 
 [dependencies]
+plugins = { path = "../plugins" }
 runtime = { path = "../runtime" }

+ 248 - 2
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);
+    }
 }

+ 435 - 32
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");

+ 172 - 17
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;

+ 98 - 1
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(),
+            &registry,
+        )
+        .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('\'', "\"")

+ 9 - 126
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(