mod hooks; use std::collections::{BTreeMap, BTreeSet}; use std::fmt::{Display, Formatter}; use std::fs; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::time::{SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; pub use hooks::{HookEvent, HookRunResult, HookRunner}; const EXTERNAL_MARKETPLACE: &str = "external"; const BUILTIN_MARKETPLACE: &str = "builtin"; const BUNDLED_MARKETPLACE: &str = "bundled"; 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 = ".claw-plugin/plugin.json"; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum PluginKind { Builtin, Bundled, External, } impl Display for PluginKind { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { Self::Builtin => write!(f, "builtin"), Self::Bundled => write!(f, "bundled"), Self::External => write!(f, "external"), } } } impl PluginKind { #[must_use] fn marketplace(self) -> &'static str { match self { Self::Builtin => BUILTIN_MARKETPLACE, Self::Bundled => BUNDLED_MARKETPLACE, Self::External => EXTERNAL_MARKETPLACE, } } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct PluginMetadata { pub id: String, pub name: String, pub version: String, pub description: String, pub kind: PluginKind, pub source: String, pub default_enabled: bool, pub root: Option, } #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct PluginHooks { #[serde(rename = "PreToolUse", default)] pub pre_tool_use: Vec, #[serde(rename = "PostToolUse", default)] pub post_tool_use: Vec, } impl PluginHooks { #[must_use] pub fn is_empty(&self) -> bool { self.pre_tool_use.is_empty() && self.post_tool_use.is_empty() } #[must_use] pub fn merged_with(&self, other: &Self) -> Self { let mut merged = self.clone(); merged .pre_tool_use .extend(other.pre_tool_use.iter().cloned()); merged .post_tool_use .extend(other.post_tool_use.iter().cloned()); merged } } #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct PluginLifecycle { #[serde(rename = "Init", default)] pub init: Vec, #[serde(rename = "Shutdown", default)] pub shutdown: Vec, } impl PluginLifecycle { #[must_use] pub fn is_empty(&self) -> bool { self.init.is_empty() && self.shutdown.is_empty() } } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct PluginManifest { pub name: String, pub version: String, pub description: String, 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, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum PluginPermission { Read, Write, Execute, } impl PluginPermission { #[must_use] pub fn as_str(self) -> &'static str { match self { Self::Read => "read", Self::Write => "write", Self::Execute => "execute", } } fn parse(value: &str) -> Option { match value { "read" => Some(Self::Read), "write" => Some(Self::Write), "execute" => Some(Self::Execute), _ => None, } } } impl AsRef for PluginPermission { fn as_ref(&self) -> &str { self.as_str() } } #[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, pub required_permission: PluginToolPermission, } #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum PluginToolPermission { ReadOnly, WorkspaceWrite, DangerFullAccess, } impl PluginToolPermission { #[must_use] pub fn as_str(self) -> &'static str { match self { Self::ReadOnly => "read-only", Self::WorkspaceWrite => "workspace-write", Self::DangerFullAccess => "danger-full-access", } } fn parse(value: &str) -> Option { match value { "read-only" => Some(Self::ReadOnly), "workspace-write" => Some(Self::WorkspaceWrite), "danger-full-access" => Some(Self::DangerFullAccess), _ => None, } } } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 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, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] struct RawPluginManifest { 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, Eq, Serialize, Deserialize)] struct RawPluginToolManifest { pub name: String, pub description: String, #[serde(rename = "inputSchema")] pub input_schema: Value, pub command: String, #[serde(default)] pub args: Vec, #[serde( rename = "requiredPermission", default = "default_tool_permission_label" )] pub required_permission: String, } #[derive(Debug, Clone, PartialEq)] pub struct PluginTool { plugin_id: String, plugin_name: String, definition: PluginToolDefinition, command: String, args: Vec, required_permission: PluginToolPermission, 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: PluginToolPermission, root: Option, ) -> Self { Self { plugin_id: plugin_id.into(), plugin_name: plugin_name.into(), definition, command: command.into(), args, required_permission, 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.as_str() } 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("CLAW_PLUGIN_ID", &self.plugin_id) .env("CLAW_PLUGIN_NAME", &self.plugin_name) .env("CLAW_TOOL_NAME", &self.definition.name) .env("CLAW_TOOL_INPUT", &input_json); if let Some(root) = &self.root { process .current_dir(root) .env("CLAW_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_label() -> String { "danger-full-access".to_string() } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum PluginInstallSource { LocalPath { path: PathBuf }, GitUrl { url: String }, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct InstalledPluginRecord { #[serde(default = "default_plugin_kind")] pub kind: PluginKind, pub id: String, pub name: String, pub version: String, pub description: String, pub install_path: PathBuf, pub source: PluginInstallSource, pub installed_at_unix_ms: u128, pub updated_at_unix_ms: u128, } #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct InstalledPluginRegistry { #[serde(default)] pub plugins: BTreeMap, } fn default_plugin_kind() -> PluginKind { PluginKind::External } #[derive(Debug, Clone, PartialEq)] pub struct BuiltinPlugin { metadata: PluginMetadata, hooks: PluginHooks, lifecycle: PluginLifecycle, tools: Vec, } #[derive(Debug, Clone, PartialEq)] pub struct BundledPlugin { metadata: PluginMetadata, hooks: PluginHooks, lifecycle: PluginLifecycle, tools: Vec, } #[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)] pub enum PluginDefinition { Builtin(BuiltinPlugin), Bundled(BundledPlugin), External(ExternalPlugin), } impl Plugin for BuiltinPlugin { fn metadata(&self) -> &PluginMetadata { &self.metadata } fn hooks(&self) -> &PluginHooks { &self.hooks } fn lifecycle(&self) -> &PluginLifecycle { &self.lifecycle } fn tools(&self) -> &[PluginTool] { &self.tools } fn validate(&self) -> Result<(), PluginError> { Ok(()) } fn initialize(&self) -> Result<(), PluginError> { Ok(()) } fn shutdown(&self) -> Result<(), PluginError> { Ok(()) } } impl Plugin for BundledPlugin { fn metadata(&self) -> &PluginMetadata { &self.metadata } fn hooks(&self) -> &PluginHooks { &self.hooks } fn lifecycle(&self) -> &PluginLifecycle { &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_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, ) } fn shutdown(&self) -> Result<(), PluginError> { run_lifecycle_commands( self.metadata(), self.lifecycle(), "shutdown", &self.lifecycle.shutdown, ) } } impl Plugin for ExternalPlugin { fn metadata(&self) -> &PluginMetadata { &self.metadata } fn hooks(&self) -> &PluginHooks { &self.hooks } fn lifecycle(&self) -> &PluginLifecycle { &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_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, ) } fn shutdown(&self) -> Result<(), PluginError> { run_lifecycle_commands( self.metadata(), self.lifecycle(), "shutdown", &self.lifecycle.shutdown, ) } } impl Plugin for PluginDefinition { fn metadata(&self) -> &PluginMetadata { match self { Self::Builtin(plugin) => plugin.metadata(), Self::Bundled(plugin) => plugin.metadata(), Self::External(plugin) => plugin.metadata(), } } fn hooks(&self) -> &PluginHooks { match self { Self::Builtin(plugin) => plugin.hooks(), Self::Bundled(plugin) => plugin.hooks(), Self::External(plugin) => plugin.hooks(), } } fn lifecycle(&self) -> &PluginLifecycle { match self { Self::Builtin(plugin) => plugin.lifecycle(), Self::Bundled(plugin) => plugin.lifecycle(), Self::External(plugin) => plugin.lifecycle(), } } 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(), Self::Bundled(plugin) => plugin.validate(), Self::External(plugin) => plugin.validate(), } } fn initialize(&self) -> Result<(), PluginError> { match self { Self::Builtin(plugin) => plugin.initialize(), Self::Bundled(plugin) => plugin.initialize(), Self::External(plugin) => plugin.initialize(), } } fn shutdown(&self) -> Result<(), PluginError> { match self { Self::Builtin(plugin) => plugin.shutdown(), Self::Bundled(plugin) => plugin.shutdown(), Self::External(plugin) => plugin.shutdown(), } } } #[derive(Debug, Clone, PartialEq)] pub struct RegisteredPlugin { definition: PluginDefinition, enabled: bool, } impl RegisteredPlugin { #[must_use] pub fn new(definition: PluginDefinition, enabled: bool) -> Self { Self { definition, enabled, } } #[must_use] pub fn metadata(&self) -> &PluginMetadata { self.definition.metadata() } #[must_use] pub fn hooks(&self) -> &PluginHooks { self.definition.hooks() } #[must_use] pub fn tools(&self) -> &[PluginTool] { self.definition.tools() } #[must_use] pub fn is_enabled(&self) -> bool { self.enabled } pub fn validate(&self) -> Result<(), PluginError> { self.definition.validate() } pub fn initialize(&self) -> Result<(), PluginError> { self.definition.initialize() } pub fn shutdown(&self) -> Result<(), PluginError> { self.definition.shutdown() } #[must_use] pub fn summary(&self) -> PluginSummary { PluginSummary { metadata: self.metadata().clone(), enabled: self.enabled, } } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct PluginSummary { pub metadata: PluginMetadata, pub enabled: bool, } #[derive(Debug, Clone, Default, PartialEq)] pub struct PluginRegistry { plugins: Vec, } impl PluginRegistry { #[must_use] pub fn new(mut plugins: Vec) -> Self { plugins.sort_by(|left, right| left.metadata().id.cmp(&right.metadata().id)); Self { plugins } } #[must_use] pub fn plugins(&self) -> &[RegisteredPlugin] { &self.plugins } #[must_use] pub fn get(&self, plugin_id: &str) -> Option<&RegisteredPlugin> { self.plugins .iter() .find(|plugin| plugin.metadata().id == plugin_id) } #[must_use] pub fn contains(&self, plugin_id: &str) -> bool { self.get(plugin_id).is_some() } #[must_use] pub fn summaries(&self) -> Vec { self.plugins.iter().map(RegisteredPlugin::summary).collect() } pub fn aggregated_hooks(&self) -> Result { self.plugins .iter() .filter(|plugin| plugin.is_enabled()) .try_fold(PluginHooks::default(), |acc, plugin| { plugin.validate()?; Ok(acc.merged_with(plugin.hooks())) }) } 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()?; plugin.initialize()?; } Ok(()) } pub fn shutdown(&self) -> Result<(), PluginError> { for plugin in self .plugins .iter() .rev() .filter(|plugin| plugin.is_enabled()) { plugin.shutdown()?; } Ok(()) } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct PluginManagerConfig { pub config_home: PathBuf, pub enabled_plugins: BTreeMap, pub external_dirs: Vec, pub install_root: Option, pub registry_path: Option, pub bundled_root: Option, } impl PluginManagerConfig { #[must_use] pub fn new(config_home: impl Into) -> Self { Self { config_home: config_home.into(), enabled_plugins: BTreeMap::new(), external_dirs: Vec::new(), install_root: None, registry_path: None, bundled_root: None, } } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct PluginManager { config: PluginManagerConfig, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct InstallOutcome { pub plugin_id: String, pub version: String, pub install_path: PathBuf, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct UpdateOutcome { pub plugin_id: String, pub old_version: String, pub new_version: String, pub install_path: PathBuf, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum PluginManifestValidationError { EmptyField { field: &'static str, }, EmptyEntryField { kind: &'static str, field: &'static str, name: Option, }, InvalidPermission { permission: String, }, DuplicatePermission { permission: String, }, DuplicateEntry { kind: &'static str, name: String, }, MissingPath { kind: &'static str, path: PathBuf, }, InvalidToolInputSchema { tool_name: String, }, InvalidToolRequiredPermission { tool_name: String, permission: String, }, } impl Display for PluginManifestValidationError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { Self::EmptyField { field } => { write!(f, "plugin manifest {field} cannot be empty") } Self::EmptyEntryField { kind, field, name } => match name { Some(name) if !name.is_empty() => { write!(f, "plugin {kind} `{name}` {field} cannot be empty") } _ => write!(f, "plugin {kind} {field} cannot be empty"), }, Self::InvalidPermission { permission } => { write!( f, "plugin manifest permission `{permission}` must be one of read, write, or execute" ) } Self::DuplicatePermission { permission } => { write!(f, "plugin manifest permission `{permission}` is duplicated") } Self::DuplicateEntry { kind, name } => { write!(f, "plugin {kind} `{name}` is duplicated") } Self::MissingPath { kind, path } => { write!(f, "{kind} path `{}` does not exist", path.display()) } Self::InvalidToolInputSchema { tool_name } => { write!( f, "plugin tool `{tool_name}` inputSchema must be a JSON object" ) } Self::InvalidToolRequiredPermission { tool_name, permission, } => write!( f, "plugin tool `{tool_name}` requiredPermission `{permission}` must be read-only, workspace-write, or danger-full-access" ), } } } #[derive(Debug)] pub enum PluginError { Io(std::io::Error), Json(serde_json::Error), ManifestValidation(Vec), InvalidManifest(String), NotFound(String), CommandFailed(String), } impl Display for PluginError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { Self::Io(error) => write!(f, "{error}"), Self::Json(error) => write!(f, "{error}"), Self::ManifestValidation(errors) => { for (index, error) in errors.iter().enumerate() { if index > 0 { write!(f, "; ")?; } write!(f, "{error}")?; } Ok(()) } Self::InvalidManifest(message) | Self::NotFound(message) | Self::CommandFailed(message) => write!(f, "{message}"), } } } impl std::error::Error for PluginError {} impl From for PluginError { fn from(value: std::io::Error) -> Self { Self::Io(value) } } impl From for PluginError { fn from(value: serde_json::Error) -> Self { Self::Json(value) } } impl PluginManager { #[must_use] pub fn new(config: PluginManagerConfig) -> Self { Self { config } } #[must_use] pub fn bundled_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("bundled") } #[must_use] pub fn install_root(&self) -> PathBuf { self.config .install_root .clone() .unwrap_or_else(|| self.config.config_home.join("plugins").join("installed")) } #[must_use] pub fn registry_path(&self) -> PathBuf { self.config.registry_path.clone().unwrap_or_else(|| { self.config .config_home .join("plugins") .join(REGISTRY_FILE_NAME) }) } #[must_use] pub fn settings_path(&self) -> PathBuf { self.config.config_home.join(SETTINGS_FILE_NAME) } pub fn plugin_registry(&self) -> Result { Ok(PluginRegistry::new( self.discover_plugins()? .into_iter() .map(|plugin| { let enabled = self.is_enabled(plugin.metadata()); RegisteredPlugin::new(plugin, enabled) }) .collect(), )) } pub fn list_plugins(&self) -> Result, PluginError> { Ok(self.plugin_registry()?.summaries()) } pub fn list_installed_plugins(&self) -> Result, PluginError> { Ok(self.installed_plugin_registry()?.summaries()) } pub fn discover_plugins(&self) -> Result, PluginError> { self.sync_bundled_plugins()?; let mut plugins = builtin_plugins(); plugins.extend(self.discover_installed_plugins()?); plugins.extend(self.discover_external_directory_plugins(&plugins)?); Ok(plugins) } pub fn aggregated_hooks(&self) -> Result { self.plugin_registry()?.aggregated_hooks() } pub fn aggregated_tools(&self) -> Result, PluginError> { self.plugin_registry()?.aggregated_tools() } pub fn validate_plugin_source(&self, source: &str) -> Result { let path = resolve_local_source(source)?; load_plugin_from_directory(&path) } pub fn install(&mut self, source: &str) -> Result { let install_source = parse_install_source(source)?; let temp_root = self.install_root().join(".tmp"); let staged_source = materialize_source(&install_source, &temp_root)?; let cleanup_source = matches!(install_source, PluginInstallSource::GitUrl { .. }); let manifest = load_plugin_from_directory(&staged_source)?; let plugin_id = plugin_id(&manifest.name, EXTERNAL_MARKETPLACE); let install_path = self.install_root().join(sanitize_plugin_id(&plugin_id)); if install_path.exists() { fs::remove_dir_all(&install_path)?; } copy_dir_all(&staged_source, &install_path)?; if cleanup_source { let _ = fs::remove_dir_all(&staged_source); } let now = unix_time_ms(); let record = InstalledPluginRecord { kind: PluginKind::External, id: plugin_id.clone(), name: manifest.name, version: manifest.version.clone(), description: manifest.description, install_path: install_path.clone(), source: install_source, installed_at_unix_ms: now, updated_at_unix_ms: now, }; let mut registry = self.load_registry()?; registry.plugins.insert(plugin_id.clone(), record); self.store_registry(®istry)?; self.write_enabled_state(&plugin_id, Some(true))?; self.config.enabled_plugins.insert(plugin_id.clone(), true); Ok(InstallOutcome { plugin_id, version: manifest.version, install_path, }) } pub fn enable(&mut self, plugin_id: &str) -> Result<(), PluginError> { self.ensure_known_plugin(plugin_id)?; self.write_enabled_state(plugin_id, Some(true))?; self.config .enabled_plugins .insert(plugin_id.to_string(), true); Ok(()) } pub fn disable(&mut self, plugin_id: &str) -> Result<(), PluginError> { self.ensure_known_plugin(plugin_id)?; self.write_enabled_state(plugin_id, Some(false))?; self.config .enabled_plugins .insert(plugin_id.to_string(), false); Ok(()) } pub fn uninstall(&mut self, plugin_id: &str) -> Result<(), PluginError> { let mut registry = self.load_registry()?; let record = registry.plugins.remove(plugin_id).ok_or_else(|| { PluginError::NotFound(format!("plugin `{plugin_id}` is not installed")) })?; if record.kind == PluginKind::Bundled { registry.plugins.insert(plugin_id.to_string(), record); return Err(PluginError::CommandFailed(format!( "plugin `{plugin_id}` is bundled and managed automatically; disable it instead" ))); } if record.install_path.exists() { fs::remove_dir_all(&record.install_path)?; } self.store_registry(®istry)?; self.write_enabled_state(plugin_id, None)?; self.config.enabled_plugins.remove(plugin_id); Ok(()) } pub fn update(&mut self, plugin_id: &str) -> Result { let mut registry = self.load_registry()?; let record = registry.plugins.get(plugin_id).cloned().ok_or_else(|| { PluginError::NotFound(format!("plugin `{plugin_id}` is not installed")) })?; let temp_root = self.install_root().join(".tmp"); let staged_source = materialize_source(&record.source, &temp_root)?; let cleanup_source = matches!(record.source, PluginInstallSource::GitUrl { .. }); let manifest = load_plugin_from_directory(&staged_source)?; if record.install_path.exists() { fs::remove_dir_all(&record.install_path)?; } copy_dir_all(&staged_source, &record.install_path)?; if cleanup_source { let _ = fs::remove_dir_all(&staged_source); } let updated_record = InstalledPluginRecord { version: manifest.version.clone(), description: manifest.description, updated_at_unix_ms: unix_time_ms(), ..record.clone() }; registry .plugins .insert(plugin_id.to_string(), updated_record); self.store_registry(®istry)?; Ok(UpdateOutcome { plugin_id: plugin_id.to_string(), old_version: record.version, new_version: manifest.version, install_path: record.install_path, }) } fn discover_installed_plugins(&self) -> Result, PluginError> { let mut registry = self.load_registry()?; let mut plugins = Vec::new(); let mut seen_ids = BTreeSet::::new(); let mut seen_paths = BTreeSet::::new(); let mut stale_registry_ids = Vec::new(); for install_path in discover_plugin_dirs(&self.install_root())? { let matched_record = registry .plugins .values() .find(|record| record.install_path == install_path); let kind = matched_record.map_or(PluginKind::External, |record| record.kind); let source = matched_record.map_or_else( || install_path.display().to_string(), |record| describe_install_source(&record.source), ); let plugin = load_plugin_definition(&install_path, kind, source, kind.marketplace())?; if seen_ids.insert(plugin.metadata().id.clone()) { seen_paths.insert(install_path); plugins.push(plugin); } } for record in registry.plugins.values() { if seen_paths.contains(&record.install_path) { continue; } if !record.install_path.exists() || plugin_manifest_path(&record.install_path).is_err() { stale_registry_ids.push(record.id.clone()); continue; } let plugin = load_plugin_definition( &record.install_path, record.kind, describe_install_source(&record.source), record.kind.marketplace(), )?; if seen_ids.insert(plugin.metadata().id.clone()) { seen_paths.insert(record.install_path.clone()); plugins.push(plugin); } } if !stale_registry_ids.is_empty() { for plugin_id in stale_registry_ids { registry.plugins.remove(&plugin_id); } self.store_registry(®istry)?; } Ok(plugins) } fn discover_external_directory_plugins( &self, existing_plugins: &[PluginDefinition], ) -> Result, PluginError> { let mut plugins = Vec::new(); for directory in &self.config.external_dirs { for root in discover_plugin_dirs(directory)? { let plugin = load_plugin_definition( &root, PluginKind::External, root.display().to_string(), EXTERNAL_MARKETPLACE, )?; if existing_plugins .iter() .chain(plugins.iter()) .all(|existing| existing.metadata().id != plugin.metadata().id) { plugins.push(plugin); } } } Ok(plugins) } fn installed_plugin_registry(&self) -> Result { self.sync_bundled_plugins()?; Ok(PluginRegistry::new( self.discover_installed_plugins()? .into_iter() .map(|plugin| { let enabled = self.is_enabled(plugin.metadata()); RegisteredPlugin::new(plugin, enabled) }) .collect(), )) } fn sync_bundled_plugins(&self) -> Result<(), PluginError> { let bundled_root = self .config .bundled_root .clone() .unwrap_or_else(Self::bundled_root); let bundled_plugins = discover_plugin_dirs(&bundled_root)?; let mut registry = self.load_registry()?; let mut changed = false; let install_root = self.install_root(); let mut active_bundled_ids = BTreeSet::new(); for source_root in bundled_plugins { let manifest = load_plugin_from_directory(&source_root)?; let plugin_id = plugin_id(&manifest.name, BUNDLED_MARKETPLACE); active_bundled_ids.insert(plugin_id.clone()); let install_path = install_root.join(sanitize_plugin_id(&plugin_id)); let now = unix_time_ms(); let existing_record = registry.plugins.get(&plugin_id); let installed_copy_is_valid = install_path.exists() && load_plugin_from_directory(&install_path).is_ok(); let needs_sync = existing_record.is_none_or(|record| { record.kind != PluginKind::Bundled || record.version != manifest.version || record.name != manifest.name || record.description != manifest.description || record.install_path != install_path || !record.install_path.exists() || !installed_copy_is_valid }); if !needs_sync { continue; } if install_path.exists() { fs::remove_dir_all(&install_path)?; } copy_dir_all(&source_root, &install_path)?; let installed_at_unix_ms = existing_record.map_or(now, |record| record.installed_at_unix_ms); registry.plugins.insert( plugin_id.clone(), InstalledPluginRecord { kind: PluginKind::Bundled, id: plugin_id, name: manifest.name, version: manifest.version, description: manifest.description, install_path, source: PluginInstallSource::LocalPath { path: source_root }, installed_at_unix_ms, updated_at_unix_ms: now, }, ); changed = true; } let stale_bundled_ids = registry .plugins .iter() .filter_map(|(plugin_id, record)| { (record.kind == PluginKind::Bundled && !active_bundled_ids.contains(plugin_id)) .then_some(plugin_id.clone()) }) .collect::>(); for plugin_id in stale_bundled_ids { if let Some(record) = registry.plugins.remove(&plugin_id) { if record.install_path.exists() { fs::remove_dir_all(&record.install_path)?; } changed = true; } } if changed { self.store_registry(®istry)?; } Ok(()) } fn is_enabled(&self, metadata: &PluginMetadata) -> bool { self.config .enabled_plugins .get(&metadata.id) .copied() .unwrap_or(match metadata.kind { PluginKind::External => false, PluginKind::Builtin | PluginKind::Bundled => metadata.default_enabled, }) } fn ensure_known_plugin(&self, plugin_id: &str) -> Result<(), PluginError> { if self.plugin_registry()?.contains(plugin_id) { Ok(()) } else { Err(PluginError::NotFound(format!( "plugin `{plugin_id}` is not installed or discoverable" ))) } } fn load_registry(&self) -> Result { let path = self.registry_path(); match fs::read_to_string(&path) { Ok(contents) if contents.trim().is_empty() => Ok(InstalledPluginRegistry::default()), Ok(contents) => Ok(serde_json::from_str(&contents)?), Err(error) if error.kind() == std::io::ErrorKind::NotFound => { Ok(InstalledPluginRegistry::default()) } Err(error) => Err(PluginError::Io(error)), } } fn store_registry(&self, registry: &InstalledPluginRegistry) -> Result<(), PluginError> { let path = self.registry_path(); if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } fs::write(path, serde_json::to_string_pretty(registry)?)?; Ok(()) } fn write_enabled_state( &self, plugin_id: &str, enabled: Option, ) -> Result<(), PluginError> { update_settings_json(&self.settings_path(), |root| { let enabled_plugins = ensure_object(root, "enabledPlugins"); match enabled { Some(value) => { enabled_plugins.insert(plugin_id.to_string(), Value::Bool(value)); } None => { enabled_plugins.remove(plugin_id); } } }) } } #[must_use] pub fn builtin_plugins() -> Vec { vec![PluginDefinition::Builtin(BuiltinPlugin { metadata: PluginMetadata { id: plugin_id("example-builtin", BUILTIN_MARKETPLACE), name: "example-builtin".to_string(), version: "0.1.0".to_string(), description: "Example built-in plugin scaffold for the Rust plugin system".to_string(), kind: PluginKind::Builtin, source: BUILTIN_MARKETPLACE.to_string(), default_enabled: false, root: None, }, hooks: PluginHooks::default(), lifecycle: PluginLifecycle::default(), tools: Vec::new(), })] } fn load_plugin_definition( root: &Path, kind: PluginKind, source: String, marketplace: &str, ) -> Result { let manifest = load_plugin_from_directory(root)?; let metadata = PluginMetadata { id: plugin_id(&manifest.name, marketplace), name: manifest.name, version: manifest.version, description: manifest.description, kind, source, default_enabled: manifest.default_enabled, root: Some(root.to_path_buf()), }; let hooks = resolve_hooks(root, &manifest.hooks); 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, }), }) } pub fn load_plugin_from_directory(root: &Path) -> Result { load_manifest_from_directory(root) } fn load_manifest_from_directory(root: &Path) -> Result { let manifest_path = plugin_manifest_path(root)?; load_manifest_from_path(root, &manifest_path) } fn load_manifest_from_path( root: &Path, manifest_path: &Path, ) -> Result { let contents = fs::read_to_string(manifest_path).map_err(|error| { PluginError::NotFound(format!( "plugin manifest not found at {}: {error}", manifest_path.display() )) })?; let raw_manifest: RawPluginManifest = serde_json::from_str(&contents)?; build_plugin_manifest(root, raw_manifest) } 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 build_plugin_manifest( root: &Path, raw: RawPluginManifest, ) -> Result { let mut errors = Vec::new(); validate_required_manifest_field("name", &raw.name, &mut errors); validate_required_manifest_field("version", &raw.version, &mut errors); validate_required_manifest_field("description", &raw.description, &mut errors); let permissions = build_manifest_permissions(&raw.permissions, &mut errors); validate_command_entries(root, raw.hooks.pre_tool_use.iter(), "hook", &mut errors); validate_command_entries(root, raw.hooks.post_tool_use.iter(), "hook", &mut errors); validate_command_entries( root, raw.lifecycle.init.iter(), "lifecycle command", &mut errors, ); validate_command_entries( root, raw.lifecycle.shutdown.iter(), "lifecycle command", &mut errors, ); let tools = build_manifest_tools(root, raw.tools, &mut errors); let commands = build_manifest_commands(root, raw.commands, &mut errors); if !errors.is_empty() { return Err(PluginError::ManifestValidation(errors)); } Ok(PluginManifest { name: raw.name, version: raw.version, description: raw.description, permissions, default_enabled: raw.default_enabled, hooks: raw.hooks, lifecycle: raw.lifecycle, tools, commands, }) } fn validate_required_manifest_field( field: &'static str, value: &str, errors: &mut Vec, ) { if value.trim().is_empty() { errors.push(PluginManifestValidationError::EmptyField { field }); } } fn build_manifest_permissions( permissions: &[String], errors: &mut Vec, ) -> Vec { let mut seen = BTreeSet::new(); let mut validated = Vec::new(); for permission in permissions { let permission = permission.trim(); if permission.is_empty() { errors.push(PluginManifestValidationError::EmptyEntryField { kind: "permission", field: "value", name: None, }); continue; } if !seen.insert(permission.to_string()) { errors.push(PluginManifestValidationError::DuplicatePermission { permission: permission.to_string(), }); continue; } match PluginPermission::parse(permission) { Some(permission) => validated.push(permission), None => errors.push(PluginManifestValidationError::InvalidPermission { permission: permission.to_string(), }), } } validated } fn build_manifest_tools( root: &Path, tools: Vec, errors: &mut Vec, ) -> Vec { let mut seen = BTreeSet::new(); let mut validated = Vec::new(); for tool in tools { let name = tool.name.trim().to_string(); if name.is_empty() { errors.push(PluginManifestValidationError::EmptyEntryField { kind: "tool", field: "name", name: None, }); continue; } if !seen.insert(name.clone()) { errors.push(PluginManifestValidationError::DuplicateEntry { kind: "tool", name }); continue; } if tool.description.trim().is_empty() { errors.push(PluginManifestValidationError::EmptyEntryField { kind: "tool", field: "description", name: Some(name.clone()), }); } if tool.command.trim().is_empty() { errors.push(PluginManifestValidationError::EmptyEntryField { kind: "tool", field: "command", name: Some(name.clone()), }); } else { validate_command_entry(root, &tool.command, "tool", errors); } if !tool.input_schema.is_object() { errors.push(PluginManifestValidationError::InvalidToolInputSchema { tool_name: name.clone(), }); } let Some(required_permission) = PluginToolPermission::parse(tool.required_permission.trim()) else { errors.push( PluginManifestValidationError::InvalidToolRequiredPermission { tool_name: name.clone(), permission: tool.required_permission.trim().to_string(), }, ); continue; }; validated.push(PluginToolManifest { name, description: tool.description, input_schema: tool.input_schema, command: tool.command, args: tool.args, required_permission, }); } validated } fn build_manifest_commands( root: &Path, commands: Vec, errors: &mut Vec, ) -> Vec { let mut seen = BTreeSet::new(); let mut validated = Vec::new(); for command in commands { let name = command.name.trim().to_string(); if name.is_empty() { errors.push(PluginManifestValidationError::EmptyEntryField { kind: "command", field: "name", name: None, }); continue; } if !seen.insert(name.clone()) { errors.push(PluginManifestValidationError::DuplicateEntry { kind: "command", name, }); continue; } if command.description.trim().is_empty() { errors.push(PluginManifestValidationError::EmptyEntryField { kind: "command", field: "description", name: Some(name.clone()), }); } if command.command.trim().is_empty() { errors.push(PluginManifestValidationError::EmptyEntryField { kind: "command", field: "command", name: Some(name.clone()), }); } else { validate_command_entry(root, &command.command, "command", errors); } validated.push(command); } validated } fn validate_command_entries<'a>( root: &Path, entries: impl Iterator, kind: &'static str, errors: &mut Vec, ) { for entry in entries { validate_command_entry(root, entry, kind, errors); } } fn validate_command_entry( root: &Path, entry: &str, kind: &'static str, errors: &mut Vec, ) { if entry.trim().is_empty() { errors.push(PluginManifestValidationError::EmptyEntryField { kind, field: "command", name: None, }); return; } if is_literal_command(entry) { return; } let path = if Path::new(entry).is_absolute() { PathBuf::from(entry) } else { root.join(entry) }; if !path.exists() { errors.push(PluginManifestValidationError::MissingPath { kind, path }); } } fn resolve_hooks(root: &Path, hooks: &PluginHooks) -> PluginHooks { PluginHooks { pre_tool_use: hooks .pre_tool_use .iter() .map(|entry| resolve_hook_entry(root, entry)) .collect(), post_tool_use: hooks .post_tool_use .iter() .map(|entry| resolve_hook_entry(root, entry)) .collect(), } } fn resolve_lifecycle(root: &Path, lifecycle: &PluginLifecycle) -> PluginLifecycle { PluginLifecycle { init: lifecycle .init .iter() .map(|entry| resolve_hook_entry(root, entry)) .collect(), shutdown: lifecycle .shutdown .iter() .map(|entry| resolve_hook_entry(root, entry)) .collect(), } } fn 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, Some(root.to_path_buf()), ) }) .collect() } fn validate_hook_paths(root: Option<&Path>, hooks: &PluginHooks) -> Result<(), PluginError> { let Some(root) = root else { return Ok(()); }; for entry in hooks.pre_tool_use.iter().chain(hooks.post_tool_use.iter()) { validate_command_path(root, entry, "hook")?; } Ok(()) } fn validate_lifecycle_paths( root: Option<&Path>, lifecycle: &PluginLifecycle, ) -> Result<(), PluginError> { let Some(root) = root else { return Ok(()); }; for entry in lifecycle.init.iter().chain(lifecycle.shutdown.iter()) { validate_command_path(root, entry, "lifecycle command")?; } Ok(()) } fn validate_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(()); } let path = if Path::new(entry).is_absolute() { PathBuf::from(entry) } else { root.join(entry) }; if !path.exists() { return Err(PluginError::InvalidManifest(format!( "{kind} path `{}` does not exist", path.display() ))); } Ok(()) } fn resolve_hook_entry(root: &Path, entry: &str) -> String { if is_literal_command(entry) { entry.to_string() } else { root.join(entry).display().to_string() } } fn is_literal_command(entry: &str) -> bool { !entry.starts_with("./") && !entry.starts_with("../") && !Path::new(entry).is_absolute() } fn run_lifecycle_commands( metadata: &PluginMetadata, lifecycle: &PluginLifecycle, phase: &str, commands: &[String], ) -> Result<(), PluginError> { if lifecycle.is_empty() || commands.is_empty() { return Ok(()); } for command in commands { let mut process = if Path::new(command).exists() { if cfg!(windows) { let mut process = Command::new("cmd"); process.arg("/C").arg(command); process } else { let mut process = Command::new("sh"); process.arg(command); process } } else if cfg!(windows) { let mut process = Command::new("cmd"); process.arg("/C").arg(command); process } else { 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(); return Err(PluginError::CommandFailed(format!( "plugin `{}` {} failed for `{}`: {}", metadata.id, phase, command, if stderr.is_empty() { format!("exit status {}", output.status) } else { stderr } ))); } } Ok(()) } fn resolve_local_source(source: &str) -> Result { let path = PathBuf::from(source); if path.exists() { Ok(path) } else { Err(PluginError::NotFound(format!( "plugin source `{source}` was not found" ))) } } fn parse_install_source(source: &str) -> Result { if source.starts_with("http://") || source.starts_with("https://") || source.starts_with("git@") || Path::new(source) .extension() .is_some_and(|extension| extension.eq_ignore_ascii_case("git")) { Ok(PluginInstallSource::GitUrl { url: source.to_string(), }) } else { Ok(PluginInstallSource::LocalPath { path: resolve_local_source(source)?, }) } } fn materialize_source( source: &PluginInstallSource, temp_root: &Path, ) -> Result { fs::create_dir_all(temp_root)?; match source { PluginInstallSource::LocalPath { path } => Ok(path.clone()), PluginInstallSource::GitUrl { url } => { let destination = temp_root.join(format!("plugin-{}", unix_time_ms())); let output = Command::new("git") .arg("clone") .arg("--depth") .arg("1") .arg(url) .arg(&destination) .output()?; if !output.status.success() { return Err(PluginError::CommandFailed(format!( "git clone failed for `{url}`: {}", String::from_utf8_lossy(&output.stderr).trim() ))); } Ok(destination) } } } fn discover_plugin_dirs(root: &Path) -> Result, PluginError> { match fs::read_dir(root) { Ok(entries) => { let mut paths = Vec::new(); for entry in entries { let path = entry?.path(); if path.is_dir() && plugin_manifest_path(&path).is_ok() { paths.push(path); } } paths.sort(); Ok(paths) } Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()), Err(error) => Err(PluginError::Io(error)), } } fn plugin_id(name: &str, marketplace: &str) -> String { format!("{name}@{marketplace}") } fn sanitize_plugin_id(plugin_id: &str) -> String { plugin_id .chars() .map(|ch| match ch { '/' | '\\' | '@' | ':' => '-', other => other, }) .collect() } fn describe_install_source(source: &PluginInstallSource) -> String { match source { PluginInstallSource::LocalPath { path } => path.display().to_string(), PluginInstallSource::GitUrl { url } => url.clone(), } } fn unix_time_ms() -> u128 { SystemTime::now() .duration_since(UNIX_EPOCH) .expect("time should be after epoch") .as_millis() } fn copy_dir_all(source: &Path, destination: &Path) -> Result<(), PluginError> { fs::create_dir_all(destination)?; for entry in fs::read_dir(source)? { let entry = entry?; let target = destination.join(entry.file_name()); if entry.file_type()?.is_dir() { copy_dir_all(&entry.path(), &target)?; } else { fs::copy(entry.path(), target)?; } } Ok(()) } fn update_settings_json( path: &Path, mut update: impl FnMut(&mut Map), ) -> Result<(), PluginError> { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } let mut root = match fs::read_to_string(path) { Ok(contents) if !contents.trim().is_empty() => serde_json::from_str::(&contents)?, Ok(_) => Value::Object(Map::new()), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Value::Object(Map::new()), Err(error) => return Err(PluginError::Io(error)), }; let object = root.as_object_mut().ok_or_else(|| { PluginError::InvalidManifest(format!( "settings file {} must contain a JSON object", path.display() )) })?; update(object); fs::write(path, serde_json::to_string_pretty(&root)?)?; Ok(()) } fn ensure_object<'a>(root: &'a mut Map, key: &str) -> &'a mut Map { if !root.get(key).is_some_and(Value::is_object) { root.insert(key.to_string(), Value::Object(Map::new())); } root.get_mut(key) .and_then(Value::as_object_mut) .expect("object should exist") } #[cfg(test)] mod tests { use super::*; fn temp_dir(label: &str) -> PathBuf { let nanos = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("time should be after epoch") .as_nanos(); std::env::temp_dir().join(format!("plugins-{label}-{nanos}")) } fn write_file(path: &Path, contents: &str) { if let Some(parent) = path.parent() { fs::create_dir_all(parent).expect("parent dir"); } fs::write(path, contents).expect("write file"); } fn write_loader_plugin(root: &Path) { write_file( root.join("hooks").join("pre.sh").as_path(), "#!/bin/sh\nprintf 'pre'\n", ); write_file( root.join("tools").join("echo-tool.sh").as_path(), "#!/bin/sh\ncat\n", ); write_file( root.join("commands").join("sync.sh").as_path(), "#!/bin/sh\nprintf 'sync'\n", ); write_file( root.join(MANIFEST_FILE_NAME).as_path(), r#"{ "name": "loader-demo", "version": "1.2.3", "description": "Manifest loader test plugin", "permissions": ["read", "write"], "hooks": { "PreToolUse": ["./hooks/pre.sh"] }, "tools": [ { "name": "echo_tool", "description": "Echoes JSON input", "inputSchema": { "type": "object" }, "command": "./tools/echo-tool.sh", "requiredPermission": "workspace-write" } ], "commands": [ { "name": "sync", "description": "Sync command", "command": "./commands/sync.sh" } ] }"#, ); } fn write_external_plugin(root: &Path, name: &str, version: &str) { write_file( root.join("hooks").join("pre.sh").as_path(), "#!/bin/sh\nprintf 'pre'\n", ); write_file( root.join("hooks").join("post.sh").as_path(), "#!/bin/sh\nprintf 'post'\n", ); write_file( root.join(MANIFEST_RELATIVE_PATH).as_path(), format!( "{{\n \"name\": \"{name}\",\n \"version\": \"{version}\",\n \"description\": \"test plugin\",\n \"hooks\": {{\n \"PreToolUse\": [\"./hooks/pre.sh\"],\n \"PostToolUse\": [\"./hooks/post.sh\"]\n }}\n}}" ) .as_str(), ); } fn write_broken_plugin(root: &Path, name: &str) { write_file( root.join(MANIFEST_RELATIVE_PATH).as_path(), format!( "{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"broken plugin\",\n \"hooks\": {{\n \"PreToolUse\": [\"./hooks/missing.sh\"]\n }}\n}}" ) .as_str(), ); } fn write_lifecycle_plugin(root: &Path, name: &str, version: &str) -> PathBuf { let log_path = root.join("lifecycle.log"); write_file( root.join("lifecycle").join("init.sh").as_path(), "#!/bin/sh\nprintf 'init\\n' >> lifecycle.log\n", ); write_file( root.join("lifecycle").join("shutdown.sh").as_path(), "#!/bin/sh\nprintf 'shutdown\\n' >> lifecycle.log\n", ); write_file( root.join(MANIFEST_RELATIVE_PATH).as_path(), format!( "{{\n \"name\": \"{name}\",\n \"version\": \"{version}\",\n \"description\": \"lifecycle plugin\",\n \"lifecycle\": {{\n \"Init\": [\"./lifecycle/init.sh\"],\n \"Shutdown\": [\"./lifecycle/shutdown.sh\"]\n }}\n}}" ) .as_str(), ); log_path } fn write_tool_plugin(root: &Path, name: &str, version: &str) { write_tool_plugin_with_name(root, name, version, "plugin_echo"); } fn write_tool_plugin_with_name(root: &Path, name: &str, version: &str, tool_name: &str) { let script_path = root.join("tools").join("echo-json.sh"); write_file( &script_path, "#!/bin/sh\nINPUT=$(cat)\nprintf '{\"plugin\":\"%s\",\"tool\":\"%s\",\"input\":%s}\\n' \"$CLAW_PLUGIN_ID\" \"$CLAW_TOOL_NAME\" \"$INPUT\"\n", ); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let mut permissions = fs::metadata(&script_path).expect("metadata").permissions(); permissions.set_mode(0o755); fs::set_permissions(&script_path, permissions).expect("chmod"); } write_file( root.join(MANIFEST_RELATIVE_PATH).as_path(), format!( "{{\n \"name\": \"{name}\",\n \"version\": \"{version}\",\n \"description\": \"tool plugin\",\n \"tools\": [\n {{\n \"name\": \"{tool_name}\",\n \"description\": \"Echo JSON input\",\n \"inputSchema\": {{\"type\": \"object\", \"properties\": {{\"message\": {{\"type\": \"string\"}}}}, \"required\": [\"message\"], \"additionalProperties\": false}},\n \"command\": \"./tools/echo-json.sh\",\n \"requiredPermission\": \"workspace-write\"\n }}\n ]\n}}" ) .as_str(), ); } fn write_bundled_plugin(root: &Path, name: &str, version: &str, default_enabled: bool) { write_file( root.join(MANIFEST_RELATIVE_PATH).as_path(), format!( "{{\n \"name\": \"{name}\",\n \"version\": \"{version}\",\n \"description\": \"bundled plugin\",\n \"defaultEnabled\": {}\n}}", if default_enabled { "true" } else { "false" } ) .as_str(), ); } fn load_enabled_plugins(path: &Path) -> BTreeMap { let contents = fs::read_to_string(path).expect("settings should exist"); let root: Value = serde_json::from_str(&contents).expect("settings json"); root.get("enabledPlugins") .and_then(Value::as_object) .map(|enabled_plugins| { enabled_plugins .iter() .map(|(plugin_id, value)| { ( plugin_id.clone(), value.as_bool().expect("plugin state should be a bool"), ) }) .collect() }) .unwrap_or_default() } #[test] fn load_plugin_from_directory_validates_required_fields() { let root = temp_dir("manifest-required"); write_file( root.join(MANIFEST_FILE_NAME).as_path(), r#"{"name":"","version":"1.0.0","description":"desc"}"#, ); let error = load_plugin_from_directory(&root).expect_err("empty name should fail"); assert!(error.to_string().contains("name cannot be empty")); let _ = fs::remove_dir_all(root); } #[test] fn load_plugin_from_directory_reads_root_manifest_and_validates_entries() { let root = temp_dir("manifest-root"); write_loader_plugin(&root); let manifest = load_plugin_from_directory(&root).expect("manifest should load"); assert_eq!(manifest.name, "loader-demo"); assert_eq!(manifest.version, "1.2.3"); assert_eq!( manifest .permissions .iter() .map(|permission| permission.as_str()) .collect::>(), vec!["read", "write"] ); assert_eq!(manifest.hooks.pre_tool_use, vec!["./hooks/pre.sh"]); assert_eq!(manifest.tools.len(), 1); assert_eq!(manifest.tools[0].name, "echo_tool"); assert_eq!( manifest.tools[0].required_permission, PluginToolPermission::WorkspaceWrite ); assert_eq!(manifest.commands.len(), 1); assert_eq!(manifest.commands[0].name, "sync"); let _ = fs::remove_dir_all(root); } #[test] fn load_plugin_from_directory_supports_packaged_manifest_path() { let root = temp_dir("manifest-packaged"); write_external_plugin(&root, "packaged-demo", "1.0.0"); let manifest = load_plugin_from_directory(&root).expect("packaged manifest should load"); assert_eq!(manifest.name, "packaged-demo"); assert!(manifest.tools.is_empty()); assert!(manifest.commands.is_empty()); let _ = fs::remove_dir_all(root); } #[test] fn load_plugin_from_directory_defaults_optional_fields() { let root = temp_dir("manifest-defaults"); write_file( root.join(MANIFEST_FILE_NAME).as_path(), r#"{ "name": "minimal", "version": "0.1.0", "description": "Minimal manifest" }"#, ); let manifest = load_plugin_from_directory(&root).expect("minimal manifest should load"); assert!(manifest.permissions.is_empty()); assert!(manifest.hooks.is_empty()); assert!(manifest.tools.is_empty()); assert!(manifest.commands.is_empty()); let _ = fs::remove_dir_all(root); } #[test] fn load_plugin_from_directory_rejects_duplicate_permissions_and_commands() { let root = temp_dir("manifest-duplicates"); write_file( root.join("commands").join("sync.sh").as_path(), "#!/bin/sh\nprintf 'sync'\n", ); write_file( root.join(MANIFEST_FILE_NAME).as_path(), r#"{ "name": "duplicate-manifest", "version": "1.0.0", "description": "Duplicate validation", "permissions": ["read", "read"], "commands": [ {"name": "sync", "description": "Sync one", "command": "./commands/sync.sh"}, {"name": "sync", "description": "Sync two", "command": "./commands/sync.sh"} ] }"#, ); let error = load_plugin_from_directory(&root).expect_err("duplicates should fail"); match error { PluginError::ManifestValidation(errors) => { assert!(errors.iter().any(|error| matches!( error, PluginManifestValidationError::DuplicatePermission { permission } if permission == "read" ))); assert!(errors.iter().any(|error| matches!( error, PluginManifestValidationError::DuplicateEntry { kind, name } if *kind == "command" && name == "sync" ))); } other => panic!("expected manifest validation errors, got {other}"), } let _ = fs::remove_dir_all(root); } #[test] fn load_plugin_from_directory_rejects_missing_tool_or_command_paths() { let root = temp_dir("manifest-paths"); write_file( root.join(MANIFEST_FILE_NAME).as_path(), r#"{ "name": "missing-paths", "version": "1.0.0", "description": "Missing path validation", "tools": [ { "name": "tool_one", "description": "Missing tool script", "inputSchema": {"type": "object"}, "command": "./tools/missing.sh" } ] }"#, ); let error = load_plugin_from_directory(&root).expect_err("missing paths should fail"); assert!(error.to_string().contains("does not exist")); let _ = fs::remove_dir_all(root); } #[test] fn load_plugin_from_directory_rejects_invalid_permissions() { let root = temp_dir("manifest-invalid-permissions"); write_file( root.join(MANIFEST_FILE_NAME).as_path(), r#"{ "name": "invalid-permissions", "version": "1.0.0", "description": "Invalid permission validation", "permissions": ["admin"] }"#, ); let error = load_plugin_from_directory(&root).expect_err("invalid permissions should fail"); match error { PluginError::ManifestValidation(errors) => { assert!(errors.iter().any(|error| matches!( error, PluginManifestValidationError::InvalidPermission { permission } if permission == "admin" ))); } other => panic!("expected manifest validation errors, got {other}"), } let _ = fs::remove_dir_all(root); } #[test] fn load_plugin_from_directory_rejects_invalid_tool_required_permission() { let root = temp_dir("manifest-invalid-tool-permission"); write_file( root.join("tools").join("echo.sh").as_path(), "#!/bin/sh\ncat\n", ); write_file( root.join(MANIFEST_FILE_NAME).as_path(), r#"{ "name": "invalid-tool-permission", "version": "1.0.0", "description": "Invalid tool permission validation", "tools": [ { "name": "echo_tool", "description": "Echo tool", "inputSchema": {"type": "object"}, "command": "./tools/echo.sh", "requiredPermission": "admin" } ] }"#, ); let error = load_plugin_from_directory(&root).expect_err("invalid tool permission should fail"); match error { PluginError::ManifestValidation(errors) => { assert!(errors.iter().any(|error| matches!( error, PluginManifestValidationError::InvalidToolRequiredPermission { tool_name, permission } if tool_name == "echo_tool" && permission == "admin" ))); } other => panic!("expected manifest validation errors, got {other}"), } let _ = fs::remove_dir_all(root); } #[test] fn load_plugin_from_directory_accumulates_multiple_validation_errors() { let root = temp_dir("manifest-multi-error"); write_file( root.join(MANIFEST_FILE_NAME).as_path(), r#"{ "name": "", "version": "1.0.0", "description": "", "permissions": ["admin"], "commands": [ {"name": "", "description": "", "command": "./commands/missing.sh"} ] }"#, ); let error = load_plugin_from_directory(&root).expect_err("multiple manifest errors should fail"); match error { PluginError::ManifestValidation(errors) => { assert!(errors.len() >= 4); assert!(errors.iter().any(|error| matches!( error, PluginManifestValidationError::EmptyField { field } if *field == "name" ))); assert!(errors.iter().any(|error| matches!( error, PluginManifestValidationError::EmptyField { field } if *field == "description" ))); assert!(errors.iter().any(|error| matches!( error, PluginManifestValidationError::InvalidPermission { permission } if permission == "admin" ))); } other => panic!("expected manifest validation errors, got {other}"), } let _ = fs::remove_dir_all(root); } #[test] fn discovers_builtin_and_bundled_plugins() { let manager = PluginManager::new(PluginManagerConfig::new(temp_dir("discover"))); let plugins = manager.list_plugins().expect("plugins should list"); assert!(plugins .iter() .any(|plugin| plugin.metadata.kind == PluginKind::Builtin)); assert!(plugins .iter() .any(|plugin| plugin.metadata.kind == PluginKind::Bundled)); } #[test] fn installs_enables_updates_and_uninstalls_external_plugins() { let config_home = temp_dir("home"); let source_root = temp_dir("source"); write_external_plugin(&source_root, "demo", "1.0.0"); let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home)); let install = manager .install(source_root.to_str().expect("utf8 path")) .expect("install should succeed"); assert_eq!(install.plugin_id, "demo@external"); assert!(manager .list_plugins() .expect("list plugins") .iter() .any(|plugin| plugin.metadata.id == "demo@external" && plugin.enabled)); let hooks = manager.aggregated_hooks().expect("hooks should aggregate"); assert_eq!(hooks.pre_tool_use.len(), 1); assert!(hooks.pre_tool_use[0].contains("pre.sh")); manager .disable("demo@external") .expect("disable should work"); assert!(manager .aggregated_hooks() .expect("hooks after disable") .is_empty()); manager.enable("demo@external").expect("enable should work"); write_external_plugin(&source_root, "demo", "2.0.0"); let update = manager.update("demo@external").expect("update should work"); assert_eq!(update.old_version, "1.0.0"); assert_eq!(update.new_version, "2.0.0"); manager .uninstall("demo@external") .expect("uninstall should work"); assert!(!manager .list_plugins() .expect("list plugins") .iter() .any(|plugin| plugin.metadata.id == "demo@external")); let _ = fs::remove_dir_all(config_home); let _ = fs::remove_dir_all(source_root); } #[test] fn auto_installs_bundled_plugins_into_the_registry() { let config_home = temp_dir("bundled-home"); let bundled_root = temp_dir("bundled-root"); write_bundled_plugin(&bundled_root.join("starter"), "starter", "0.1.0", false); let mut config = PluginManagerConfig::new(&config_home); config.bundled_root = Some(bundled_root.clone()); let manager = PluginManager::new(config); let installed = manager .list_installed_plugins() .expect("bundled plugins should auto-install"); assert!(installed.iter().any(|plugin| { plugin.metadata.id == "starter@bundled" && plugin.metadata.kind == PluginKind::Bundled && !plugin.enabled })); let registry = manager.load_registry().expect("registry should exist"); let record = registry .plugins .get("starter@bundled") .expect("bundled plugin should be recorded"); assert_eq!(record.kind, PluginKind::Bundled); assert!(record.install_path.exists()); let _ = fs::remove_dir_all(config_home); let _ = fs::remove_dir_all(bundled_root); } #[test] fn default_bundled_root_loads_repo_bundles_as_installed_plugins() { let config_home = temp_dir("default-bundled-home"); let manager = PluginManager::new(PluginManagerConfig::new(&config_home)); let installed = manager .list_installed_plugins() .expect("default bundled plugins should auto-install"); assert!(installed .iter() .any(|plugin| plugin.metadata.id == "example-bundled@bundled")); assert!(installed .iter() .any(|plugin| plugin.metadata.id == "sample-hooks@bundled")); let _ = fs::remove_dir_all(config_home); } #[test] fn bundled_sync_prunes_removed_bundled_registry_entries() { let config_home = temp_dir("bundled-prune-home"); let bundled_root = temp_dir("bundled-prune-root"); let stale_install_path = config_home .join("plugins") .join("installed") .join("stale-bundled-external"); write_bundled_plugin(&bundled_root.join("active"), "active", "0.1.0", false); write_file( stale_install_path.join(MANIFEST_RELATIVE_PATH).as_path(), r#"{ "name": "stale", "version": "0.1.0", "description": "stale bundled plugin" }"#, ); let mut config = PluginManagerConfig::new(&config_home); config.bundled_root = Some(bundled_root.clone()); config.install_root = Some(config_home.join("plugins").join("installed")); let manager = PluginManager::new(config); let mut registry = InstalledPluginRegistry::default(); registry.plugins.insert( "stale@bundled".to_string(), InstalledPluginRecord { kind: PluginKind::Bundled, id: "stale@bundled".to_string(), name: "stale".to_string(), version: "0.1.0".to_string(), description: "stale bundled plugin".to_string(), install_path: stale_install_path.clone(), source: PluginInstallSource::LocalPath { path: bundled_root.join("stale"), }, installed_at_unix_ms: 1, updated_at_unix_ms: 1, }, ); manager.store_registry(®istry).expect("store registry"); manager .write_enabled_state("stale@bundled", Some(true)) .expect("seed bundled enabled state"); let installed = manager .list_installed_plugins() .expect("bundled sync should succeed"); assert!(installed .iter() .any(|plugin| plugin.metadata.id == "active@bundled")); assert!(!installed .iter() .any(|plugin| plugin.metadata.id == "stale@bundled")); let registry = manager.load_registry().expect("load registry"); assert!(!registry.plugins.contains_key("stale@bundled")); assert!(!stale_install_path.exists()); let _ = fs::remove_dir_all(config_home); let _ = fs::remove_dir_all(bundled_root); } #[test] fn installed_plugin_discovery_keeps_registry_entries_outside_install_root() { let config_home = temp_dir("registry-fallback-home"); let bundled_root = temp_dir("registry-fallback-bundled"); let install_root = config_home.join("plugins").join("installed"); let external_install_path = temp_dir("registry-fallback-external"); write_file( external_install_path.join(MANIFEST_FILE_NAME).as_path(), r#"{ "name": "registry-fallback", "version": "1.0.0", "description": "Registry fallback plugin" }"#, ); let mut config = PluginManagerConfig::new(&config_home); config.bundled_root = Some(bundled_root.clone()); config.install_root = Some(install_root.clone()); let manager = PluginManager::new(config); let mut registry = InstalledPluginRegistry::default(); registry.plugins.insert( "registry-fallback@external".to_string(), InstalledPluginRecord { kind: PluginKind::External, id: "registry-fallback@external".to_string(), name: "registry-fallback".to_string(), version: "1.0.0".to_string(), description: "Registry fallback plugin".to_string(), install_path: external_install_path.clone(), source: PluginInstallSource::LocalPath { path: external_install_path.clone(), }, installed_at_unix_ms: 1, updated_at_unix_ms: 1, }, ); manager.store_registry(®istry).expect("store registry"); manager .write_enabled_state("stale-external@external", Some(true)) .expect("seed stale external enabled state"); let installed = manager .list_installed_plugins() .expect("registry fallback plugin should load"); assert!(installed .iter() .any(|plugin| plugin.metadata.id == "registry-fallback@external")); let _ = fs::remove_dir_all(config_home); let _ = fs::remove_dir_all(bundled_root); let _ = fs::remove_dir_all(external_install_path); } #[test] fn installed_plugin_discovery_prunes_stale_registry_entries() { let config_home = temp_dir("registry-prune-home"); let bundled_root = temp_dir("registry-prune-bundled"); let install_root = config_home.join("plugins").join("installed"); let missing_install_path = temp_dir("registry-prune-missing"); let mut config = PluginManagerConfig::new(&config_home); config.bundled_root = Some(bundled_root.clone()); config.install_root = Some(install_root); let manager = PluginManager::new(config); let mut registry = InstalledPluginRegistry::default(); registry.plugins.insert( "stale-external@external".to_string(), InstalledPluginRecord { kind: PluginKind::External, id: "stale-external@external".to_string(), name: "stale-external".to_string(), version: "1.0.0".to_string(), description: "stale external plugin".to_string(), install_path: missing_install_path.clone(), source: PluginInstallSource::LocalPath { path: missing_install_path.clone(), }, installed_at_unix_ms: 1, updated_at_unix_ms: 1, }, ); manager.store_registry(®istry).expect("store registry"); let installed = manager .list_installed_plugins() .expect("stale registry entries should be pruned"); assert!(!installed .iter() .any(|plugin| plugin.metadata.id == "stale-external@external")); let registry = manager.load_registry().expect("load registry"); assert!(!registry.plugins.contains_key("stale-external@external")); let _ = fs::remove_dir_all(config_home); let _ = fs::remove_dir_all(bundled_root); } #[test] fn persists_bundled_plugin_enable_state_across_reloads() { let config_home = temp_dir("bundled-state-home"); let bundled_root = temp_dir("bundled-state-root"); write_bundled_plugin(&bundled_root.join("starter"), "starter", "0.1.0", false); let mut config = PluginManagerConfig::new(&config_home); config.bundled_root = Some(bundled_root.clone()); let mut manager = PluginManager::new(config.clone()); manager .enable("starter@bundled") .expect("enable bundled plugin should succeed"); assert_eq!( load_enabled_plugins(&manager.settings_path()).get("starter@bundled"), Some(&true) ); let mut reloaded_config = PluginManagerConfig::new(&config_home); reloaded_config.bundled_root = Some(bundled_root.clone()); reloaded_config.enabled_plugins = load_enabled_plugins(&manager.settings_path()); let reloaded_manager = PluginManager::new(reloaded_config); let reloaded = reloaded_manager .list_installed_plugins() .expect("bundled plugins should still be listed"); assert!(reloaded .iter() .any(|plugin| { plugin.metadata.id == "starter@bundled" && plugin.enabled })); let _ = fs::remove_dir_all(config_home); let _ = fs::remove_dir_all(bundled_root); } #[test] fn persists_bundled_plugin_disable_state_across_reloads() { let config_home = temp_dir("bundled-disabled-home"); let bundled_root = temp_dir("bundled-disabled-root"); write_bundled_plugin(&bundled_root.join("starter"), "starter", "0.1.0", true); let mut config = PluginManagerConfig::new(&config_home); config.bundled_root = Some(bundled_root.clone()); let mut manager = PluginManager::new(config); manager .disable("starter@bundled") .expect("disable bundled plugin should succeed"); assert_eq!( load_enabled_plugins(&manager.settings_path()).get("starter@bundled"), Some(&false) ); let mut reloaded_config = PluginManagerConfig::new(&config_home); reloaded_config.bundled_root = Some(bundled_root.clone()); reloaded_config.enabled_plugins = load_enabled_plugins(&manager.settings_path()); let reloaded_manager = PluginManager::new(reloaded_config); let reloaded = reloaded_manager .list_installed_plugins() .expect("bundled plugins should still be listed"); assert!(reloaded .iter() .any(|plugin| { plugin.metadata.id == "starter@bundled" && !plugin.enabled })); let _ = fs::remove_dir_all(config_home); let _ = fs::remove_dir_all(bundled_root); } #[test] fn validates_plugin_source_before_install() { let config_home = temp_dir("validate-home"); let source_root = temp_dir("validate-source"); write_external_plugin(&source_root, "validator", "1.0.0"); let manager = PluginManager::new(PluginManagerConfig::new(&config_home)); let manifest = manager .validate_plugin_source(source_root.to_str().expect("utf8 path")) .expect("manifest should validate"); assert_eq!(manifest.name, "validator"); let _ = fs::remove_dir_all(config_home); let _ = fs::remove_dir_all(source_root); } #[test] fn plugin_registry_tracks_enabled_state_and_lookup() { let config_home = temp_dir("registry-home"); let source_root = temp_dir("registry-source"); write_external_plugin(&source_root, "registry-demo", "1.0.0"); let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home)); manager .install(source_root.to_str().expect("utf8 path")) .expect("install should succeed"); manager .disable("registry-demo@external") .expect("disable should succeed"); let registry = manager.plugin_registry().expect("registry should build"); let plugin = registry .get("registry-demo@external") .expect("installed plugin should be discoverable"); assert_eq!(plugin.metadata().name, "registry-demo"); assert!(!plugin.is_enabled()); assert!(registry.contains("registry-demo@external")); assert!(!registry.contains("missing@external")); let _ = fs::remove_dir_all(config_home); let _ = fs::remove_dir_all(source_root); } #[test] fn rejects_plugin_sources_with_missing_hook_paths() { let config_home = temp_dir("broken-home"); let source_root = temp_dir("broken-source"); write_broken_plugin(&source_root, "broken"); let manager = PluginManager::new(PluginManagerConfig::new(&config_home)); let error = manager .validate_plugin_source(source_root.to_str().expect("utf8 path")) .expect_err("missing hook file should fail validation"); assert!(error.to_string().contains("does not exist")); let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home)); let install_error = manager .install(source_root.to_str().expect("utf8 path")) .expect_err("install should reject invalid hook paths"); assert!(install_error.to_string().contains("does not exist")); let _ = fs::remove_dir_all(config_home); let _ = fs::remove_dir_all(source_root); } #[test] fn plugin_registry_runs_initialize_and_shutdown_for_enabled_plugins() { let config_home = temp_dir("lifecycle-home"); let source_root = temp_dir("lifecycle-source"); let _ = write_lifecycle_plugin(&source_root, "lifecycle-demo", "1.0.0"); let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home)); let install = manager .install(source_root.to_str().expect("utf8 path")) .expect("install should succeed"); let log_path = install.install_path.join("lifecycle.log"); let registry = manager.plugin_registry().expect("registry should build"); registry.initialize().expect("init should succeed"); registry.shutdown().expect("shutdown should succeed"); let log = fs::read_to_string(&log_path).expect("lifecycle log should exist"); assert_eq!(log, "init\nshutdown\n"); let _ = fs::remove_dir_all(config_home); let _ = fs::remove_dir_all(source_root); } #[test] fn aggregates_and_executes_plugin_tools() { let config_home = temp_dir("tool-home"); let source_root = temp_dir("tool-source"); write_tool_plugin(&source_root, "tool-demo", "1.0.0"); let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home)); manager .install(source_root.to_str().expect("utf8 path")) .expect("install should succeed"); let tools = manager.aggregated_tools().expect("tools should aggregate"); assert_eq!(tools.len(), 1); assert_eq!(tools[0].definition().name, "plugin_echo"); assert_eq!(tools[0].required_permission(), "workspace-write"); let output = tools[0] .execute(&serde_json::json!({ "message": "hello" })) .expect("plugin tool should execute"); let payload: Value = serde_json::from_str(&output).expect("valid json"); assert_eq!(payload["plugin"], "tool-demo@external"); assert_eq!(payload["tool"], "plugin_echo"); assert_eq!(payload["input"]["message"], "hello"); let _ = fs::remove_dir_all(config_home); let _ = fs::remove_dir_all(source_root); } #[test] fn list_installed_plugins_scans_install_root_without_registry_entries() { let config_home = temp_dir("installed-scan-home"); let bundled_root = temp_dir("installed-scan-bundled"); let install_root = config_home.join("plugins").join("installed"); let installed_plugin_root = install_root.join("scan-demo"); write_file( installed_plugin_root.join(MANIFEST_FILE_NAME).as_path(), r#"{ "name": "scan-demo", "version": "1.0.0", "description": "Scanned from install root" }"#, ); let mut config = PluginManagerConfig::new(&config_home); config.bundled_root = Some(bundled_root.clone()); config.install_root = Some(install_root); let manager = PluginManager::new(config); let installed = manager .list_installed_plugins() .expect("installed plugins should scan directories"); assert!(installed .iter() .any(|plugin| plugin.metadata.id == "scan-demo@external")); let _ = fs::remove_dir_all(config_home); let _ = fs::remove_dir_all(bundled_root); } #[test] fn list_installed_plugins_scans_packaged_manifests_in_install_root() { let config_home = temp_dir("installed-packaged-scan-home"); let bundled_root = temp_dir("installed-packaged-scan-bundled"); let install_root = config_home.join("plugins").join("installed"); let installed_plugin_root = install_root.join("scan-packaged"); write_file( installed_plugin_root.join(MANIFEST_RELATIVE_PATH).as_path(), r#"{ "name": "scan-packaged", "version": "1.0.0", "description": "Packaged manifest in install root" }"#, ); let mut config = PluginManagerConfig::new(&config_home); config.bundled_root = Some(bundled_root.clone()); config.install_root = Some(install_root); let manager = PluginManager::new(config); let installed = manager .list_installed_plugins() .expect("installed plugins should scan packaged manifests"); assert!(installed .iter() .any(|plugin| plugin.metadata.id == "scan-packaged@external")); let _ = fs::remove_dir_all(config_home); let _ = fs::remove_dir_all(bundled_root); } }