diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 5507dca..4d45e5e 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -825,6 +825,14 @@ dependencies = [ "time", ] +[[package]] +name = "plugins" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -1181,6 +1189,7 @@ dependencies = [ "commands", "compat-harness", "crossterm", + "plugins", "pulldown-cmark", "runtime", "rustyline", diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index b396bb0..dd1c89b 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -90,7 +90,7 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ SlashCommandSpec { name: "config", summary: "Inspect Claude config files or merged sections", - argument_hint: Some("[env|hooks|model]"), + argument_hint: Some("[env|hooks|model|plugins]"), resume_supported: true, }, SlashCommandSpec { @@ -129,6 +129,14 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ argument_hint: Some("[list|switch ]"), resume_supported: false, }, + SlashCommandSpec { + name: "plugins", + summary: "List or manage plugins", + argument_hint: Some( + "[list|install |enable |disable |uninstall |update ]", + ), + resume_supported: false, + }, ]; #[derive(Debug, Clone, PartialEq, Eq)] @@ -163,6 +171,10 @@ pub enum SlashCommand { action: Option, target: Option, }, + Plugins { + action: Option, + target: Option, + }, Unknown(String), } @@ -207,6 +219,10 @@ impl SlashCommand { action: parts.next().map(ToOwned::to_owned), target: parts.next().map(ToOwned::to_owned), }, + "plugins" => Self::Plugins { + action: parts.next().map(ToOwned::to_owned), + target: parts.next().map(ToOwned::to_owned), + }, other => Self::Unknown(other.to_string()), }) } @@ -291,6 +307,7 @@ pub fn handle_slash_command( | SlashCommand::Version | SlashCommand::Export { .. } | SlashCommand::Session { .. } + | SlashCommand::Plugins { .. } | SlashCommand::Unknown(_) => None, } } @@ -365,6 +382,13 @@ mod tests { target: Some("abc123".to_string()) }) ); + assert_eq!( + SlashCommand::parse("/plugins install demo"), + Some(SlashCommand::Plugins { + action: Some("install".to_string()), + target: Some("demo".to_string()) + }) + ); } #[test] @@ -379,14 +403,17 @@ mod tests { assert!(help.contains("/clear [--confirm]")); assert!(help.contains("/cost")); assert!(help.contains("/resume ")); - assert!(help.contains("/config [env|hooks|model]")); + assert!(help.contains("/config [env|hooks|model|plugins]")); assert!(help.contains("/memory")); assert!(help.contains("/init")); assert!(help.contains("/diff")); assert!(help.contains("/version")); assert!(help.contains("/export [file]")); assert!(help.contains("/session [list|switch ]")); - assert_eq!(slash_command_specs().len(), 15); + assert!(help.contains( + "/plugins [list|install |enable |disable |uninstall |update ]" + )); + assert_eq!(slash_command_specs().len(), 16); assert_eq!(resume_supported_slash_commands().len(), 11); } @@ -468,5 +495,8 @@ mod tests { assert!( handle_slash_command("/session list", &session, CompactionConfig::default()).is_none() ); + assert!( + handle_slash_command("/plugins list", &session, CompactionConfig::default()).is_none() + ); } } diff --git a/rust/crates/plugins/Cargo.toml b/rust/crates/plugins/Cargo.toml new file mode 100644 index 0000000..1771acc --- /dev/null +++ b/rust/crates/plugins/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "plugins" +version.workspace = true +edition.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[lints] +workspace = true diff --git a/rust/crates/plugins/bundled/example-bundled/.claude-plugin/plugin.json b/rust/crates/plugins/bundled/example-bundled/.claude-plugin/plugin.json new file mode 100644 index 0000000..81a4220 --- /dev/null +++ b/rust/crates/plugins/bundled/example-bundled/.claude-plugin/plugin.json @@ -0,0 +1,10 @@ +{ + "name": "example-bundled", + "version": "0.1.0", + "description": "Example bundled plugin scaffold for the Rust plugin system", + "defaultEnabled": false, + "hooks": { + "PreToolUse": ["./hooks/pre.sh"], + "PostToolUse": ["./hooks/post.sh"] + } +} diff --git a/rust/crates/plugins/bundled/example-bundled/hooks/post.sh b/rust/crates/plugins/bundled/example-bundled/hooks/post.sh new file mode 100755 index 0000000..c9eb66f --- /dev/null +++ b/rust/crates/plugins/bundled/example-bundled/hooks/post.sh @@ -0,0 +1,2 @@ +#!/bin/sh +printf '%s\n' 'example bundled post hook' diff --git a/rust/crates/plugins/bundled/example-bundled/hooks/pre.sh b/rust/crates/plugins/bundled/example-bundled/hooks/pre.sh new file mode 100755 index 0000000..af6b46b --- /dev/null +++ b/rust/crates/plugins/bundled/example-bundled/hooks/pre.sh @@ -0,0 +1,2 @@ +#!/bin/sh +printf '%s\n' 'example bundled pre hook' diff --git a/rust/crates/plugins/bundled/sample-hooks/.claude-plugin/plugin.json b/rust/crates/plugins/bundled/sample-hooks/.claude-plugin/plugin.json new file mode 100644 index 0000000..555f5df --- /dev/null +++ b/rust/crates/plugins/bundled/sample-hooks/.claude-plugin/plugin.json @@ -0,0 +1,10 @@ +{ + "name": "sample-hooks", + "version": "0.1.0", + "description": "Bundled sample plugin scaffold for hook integration tests.", + "defaultEnabled": false, + "hooks": { + "PreToolUse": ["./hooks/pre.sh"], + "PostToolUse": ["./hooks/post.sh"] + } +} diff --git a/rust/crates/plugins/bundled/sample-hooks/hooks/post.sh b/rust/crates/plugins/bundled/sample-hooks/hooks/post.sh new file mode 100755 index 0000000..c968e6d --- /dev/null +++ b/rust/crates/plugins/bundled/sample-hooks/hooks/post.sh @@ -0,0 +1,2 @@ +#!/bin/sh +printf 'sample bundled post hook' diff --git a/rust/crates/plugins/bundled/sample-hooks/hooks/pre.sh b/rust/crates/plugins/bundled/sample-hooks/hooks/pre.sh new file mode 100755 index 0000000..9560881 --- /dev/null +++ b/rust/crates/plugins/bundled/sample-hooks/hooks/pre.sh @@ -0,0 +1,2 @@ +#!/bin/sh +printf 'sample bundled pre hook' diff --git a/rust/crates/plugins/src/lib.rs b/rust/crates/plugins/src/lib.rs new file mode 100644 index 0000000..7505082 --- /dev/null +++ b/rust/crates/plugins/src/lib.rs @@ -0,0 +1,983 @@ +use std::collections::BTreeMap; +use std::fmt::{Display, Formatter}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +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_RELATIVE_PATH: &str = ".claude-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"), + } + } +} + +#[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, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginManifest { + pub name: String, + pub version: String, + pub description: String, + #[serde(rename = "defaultEnabled", default)] + pub default_enabled: bool, + #[serde(default)] + pub hooks: PluginHooks, +} + +#[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 { + 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, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BuiltinPlugin { + metadata: PluginMetadata, + hooks: PluginHooks, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BundledPlugin { + metadata: PluginMetadata, + hooks: PluginHooks, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExternalPlugin { + metadata: PluginMetadata, + hooks: PluginHooks, +} + +pub trait Plugin { + fn metadata(&self) -> &PluginMetadata; + fn hooks(&self) -> &PluginHooks; + fn validate(&self) -> Result<(), PluginError>; +} + +#[derive(Debug, Clone, PartialEq, Eq)] +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 validate(&self) -> Result<(), PluginError> { + Ok(()) + } +} + +impl Plugin for BundledPlugin { + fn metadata(&self) -> &PluginMetadata { + &self.metadata + } + + fn hooks(&self) -> &PluginHooks { + &self.hooks + } + + fn validate(&self) -> Result<(), PluginError> { + validate_hook_paths(self.metadata.root.as_deref(), &self.hooks) + } +} + +impl Plugin for ExternalPlugin { + fn metadata(&self) -> &PluginMetadata { + &self.metadata + } + + fn hooks(&self) -> &PluginHooks { + &self.hooks + } + + fn validate(&self) -> Result<(), PluginError> { + validate_hook_paths(self.metadata.root.as_deref(), &self.hooks) + } +} + +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 validate(&self) -> Result<(), PluginError> { + match self { + Self::Builtin(plugin) => plugin.validate(), + Self::Bundled(plugin) => plugin.validate(), + Self::External(plugin) => plugin.validate(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginSummary { + pub metadata: PluginMetadata, + pub enabled: bool, +} + +#[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)] +pub enum PluginError { + Io(std::io::Error), + Json(serde_json::Error), + 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::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 list_plugins(&self) -> Result, PluginError> { + let mut plugins = self + .discover_plugins()? + .into_iter() + .map(|plugin| PluginSummary { + enabled: self.is_enabled(plugin.metadata()), + metadata: plugin.metadata().clone(), + }) + .collect::>(); + plugins.sort_by(|left, right| left.metadata.id.cmp(&right.metadata.id)); + Ok(plugins) + } + + pub fn discover_plugins(&self) -> Result, PluginError> { + let mut plugins = builtin_plugins(); + plugins.extend(self.discover_bundled_plugins()?); + plugins.extend(self.discover_external_plugins()?); + Ok(plugins) + } + + pub fn aggregated_hooks(&self) -> Result { + self.discover_plugins()? + .into_iter() + .filter(|plugin| self.is_enabled(plugin.metadata())) + .try_fold(PluginHooks::default(), |acc, plugin| { + plugin.validate()?; + Ok(acc.merged_with(plugin.hooks())) + }) + } + + pub fn validate_plugin_source(&self, source: &str) -> Result { + let path = resolve_local_source(source)?; + load_manifest_from_root(&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_manifest_from_root(&staged_source)?; + validate_manifest(&manifest)?; + + 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 { + 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.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_manifest_from_root(&staged_source)?; + validate_manifest(&manifest)?; + + 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_bundled_plugins(&self) -> Result, PluginError> { + discover_plugin_dirs( + &self + .config + .bundled_root + .clone() + .unwrap_or_else(Self::bundled_root), + )? + .into_iter() + .map(|root| { + load_plugin_definition( + &root, + PluginKind::Bundled, + format!("{BUNDLED_MARKETPLACE}:{}", root.display()), + BUNDLED_MARKETPLACE, + ) + }) + .collect() + } + + fn discover_external_plugins(&self) -> Result, PluginError> { + let registry = self.load_registry()?; + let mut plugins = registry + .plugins + .values() + .map(|record| { + load_plugin_definition( + &record.install_path, + PluginKind::External, + describe_install_source(&record.source), + EXTERNAL_MARKETPLACE, + ) + }) + .collect::, _>>()?; + + 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 plugins + .iter() + .all(|existing| existing.metadata().id != plugin.metadata().id) + { + plugins.push(plugin); + } + } + } + + Ok(plugins) + } + + 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 + .list_plugins()? + .iter() + .any(|plugin| plugin.metadata.id == 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) => 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(), + })] +} + +fn load_plugin_definition( + root: &Path, + kind: PluginKind, + source: String, + marketplace: &str, +) -> Result { + let manifest = load_manifest_from_root(root)?; + validate_manifest(&manifest)?; + 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); + Ok(match kind { + PluginKind::Builtin => PluginDefinition::Builtin(BuiltinPlugin { metadata, hooks }), + PluginKind::Bundled => PluginDefinition::Bundled(BundledPlugin { metadata, hooks }), + PluginKind::External => PluginDefinition::External(ExternalPlugin { metadata, hooks }), + }) +} + +fn validate_manifest(manifest: &PluginManifest) -> 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(), + )); + } + Ok(()) +} + +fn load_manifest_from_root(root: &Path) -> Result { + let manifest_path = root.join(MANIFEST_RELATIVE_PATH); + let contents = fs::read_to_string(&manifest_path).map_err(|error| { + PluginError::NotFound(format!( + "plugin manifest not found at {}: {error}", + manifest_path.display() + )) + })?; + Ok(serde_json::from_str(&contents)?) +} + +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 validate_hook_paths(root: Option<&Path>, hooks: &PluginHooks) -> Result<(), PluginError> { + let Some(root) = root else { + return Ok(()); + }; + for entry in hooks.pre_tool_use.iter().chain(hooks.post_tool_use.iter()) { + if is_literal_command(entry) { + continue; + } + let path = if Path::new(entry).is_absolute() { + PathBuf::from(entry) + } else { + root.join(entry) + }; + if !path.exists() { + return Err(PluginError::InvalidManifest(format!( + "hook path `{}` does not exist", + path.display() + ))); + } + } + 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("../") +} + +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@") + || source.ends_with(".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.join(MANIFEST_RELATIVE_PATH).exists() { + paths.push(path); + } + } + 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 { + std::env::temp_dir().join(format!("plugins-{label}-{}", unix_time_ms())) + } + + fn write_external_plugin(root: &Path, name: &str, version: &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 'pre'\n", + ) + .expect("write pre hook"); + fs::write( + root.join("hooks").join("post.sh"), + "#!/bin/sh\nprintf 'post'\n", + ) + .expect("write post hook"); + fs::write( + root.join(MANIFEST_RELATIVE_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}}" + ), + ) + .expect("write manifest"); + } + + #[test] + fn validates_manifest_shape() { + let error = validate_manifest(&PluginManifest { + name: String::new(), + version: "1.0.0".to_string(), + description: "desc".to_string(), + default_enabled: false, + hooks: PluginHooks::default(), + }) + .expect_err("empty name should fail"); + assert!(error.to_string().contains("name cannot be empty")); + } + + #[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")); + + fs::remove_dir_all(config_home).expect("cleanup home"); + fs::remove_dir_all(source_root).expect("cleanup source"); + } + + #[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"); + fs::remove_dir_all(config_home).expect("cleanup home"); + fs::remove_dir_all(source_root).expect("cleanup source"); + } +} diff --git a/rust/crates/plugins/src/manager.rs b/rust/crates/plugins/src/manager.rs new file mode 100644 index 0000000..85a7cd0 --- /dev/null +++ b/rust/crates/plugins/src/manager.rs @@ -0,0 +1,642 @@ +use std::fmt::{Display, Formatter}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use runtime::{RuntimeConfig, RuntimeHookConfig}; +use serde::{Deserialize, Serialize}; + +use crate::manifest::{LoadedPlugin, Plugin, PluginHooks, PluginManifest}; +use crate::registry::PluginRegistry; +use crate::settings::{read_settings_file, write_plugin_state, write_settings_file}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PluginSourceKind { + Builtin, + Bundled, + External, +} + +impl PluginSourceKind { + fn suffix(self) -> &'static str { + match self { + Self::Builtin => "builtin", + Self::Bundled => "bundled", + Self::External => "external", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct InstalledPluginRecord { + pub id: String, + pub name: String, + pub version: String, + pub description: String, + pub source_kind: PluginSourceKind, + pub source_path: String, + pub install_path: String, + pub installed_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginListEntry { + pub plugin: LoadedPlugin, + pub enabled: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginOperationResult { + pub plugin_id: String, + pub message: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginError { + message: String, +} + +impl PluginError { + #[must_use] + pub fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} + +impl Display for PluginError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for PluginError {} + +impl From for PluginError { + fn from(value: String) -> Self { + Self::new(value) + } +} + +impl From for PluginError { + fn from(value: std::io::Error) -> Self { + Self::new(value.to_string()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginLoader { + registry_path: PathBuf, +} + +impl PluginLoader { + #[must_use] + pub fn new(config_home: impl Into) -> Self { + let config_home = config_home.into(); + Self { + registry_path: config_home.join("plugins").join("installed.json"), + } + } + + pub fn discover(&self) -> Result, PluginError> { + let mut plugins = builtin_plugins(); + plugins.extend(bundled_plugins()); + plugins.extend(self.load_external_plugins()?); + plugins.sort_by(|left, right| left.id.cmp(&right.id)); + Ok(plugins) + } + + fn load_external_plugins(&self) -> Result, PluginError> { + let registry = PluginRegistry::load(&self.registry_path)?; + registry + .plugins + .into_iter() + .map(|record| { + let install_path = PathBuf::from(&record.install_path); + let (manifest, root) = load_manifest_from_source(&install_path)?; + Ok(LoadedPlugin::new( + record.id, + PluginSourceKind::External, + manifest, + Some(root), + Some(PathBuf::from(record.source_path)), + )) + }) + .collect() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginManager { + cwd: PathBuf, + config_home: PathBuf, +} + +impl PluginManager { + #[must_use] + pub fn new(cwd: impl Into, config_home: impl Into) -> Self { + Self { + cwd: cwd.into(), + config_home: config_home.into(), + } + } + + #[must_use] + pub fn default_for(cwd: impl Into) -> Self { + let cwd = cwd.into(); + let config_home = std::env::var_os("CLAUDE_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".claude"))) + .unwrap_or_else(|| PathBuf::from(".claude")); + Self { cwd, config_home } + } + + #[must_use] + pub fn loader(&self) -> PluginLoader { + PluginLoader::new(&self.config_home) + } + + pub fn discover_plugins(&self) -> Result, PluginError> { + self.loader().discover() + } + + pub fn list_plugins( + &self, + runtime_config: &RuntimeConfig, + ) -> Result, PluginError> { + self.discover_plugins().map(|plugins| { + plugins + .into_iter() + .map(|plugin| { + let enabled = is_plugin_enabled(&plugin, runtime_config); + PluginListEntry { plugin, enabled } + }) + .collect() + }) + } + + pub fn active_hook_config( + &self, + runtime_config: &RuntimeConfig, + ) -> Result { + let mut hooks = PluginHooks::default(); + for plugin in self.list_plugins(runtime_config)? { + if plugin.enabled { + let resolved = plugin.plugin.resolved_hooks(); + hooks.pre_tool_use.extend(resolved.pre_tool_use); + hooks.post_tool_use.extend(resolved.post_tool_use); + } + } + Ok(RuntimeHookConfig::new(hooks.pre_tool_use, hooks.post_tool_use)) + } + + pub fn validate_plugin(&self, source: impl AsRef) -> Result { + let (manifest, _) = load_manifest_from_source(source.as_ref())?; + Ok(manifest) + } + + pub fn install_plugin( + &self, + source: impl AsRef, + ) -> Result { + let (manifest, root) = load_manifest_from_source(source.as_ref())?; + let plugin_id = external_plugin_id(&manifest.name); + let install_path = self.installs_root().join(sanitize_plugin_id(&plugin_id)); + let canonical_source = fs::canonicalize(root)?; + + copy_dir_recursive(&canonical_source, &install_path)?; + + let now = iso8601_now(); + let mut registry = self.load_registry()?; + let installed_at = registry + .find(&plugin_id) + .map(|record| record.installed_at.clone()) + .unwrap_or_else(|| now.clone()); + registry.upsert(InstalledPluginRecord { + id: plugin_id.clone(), + name: manifest.name.clone(), + version: manifest.version.clone(), + description: manifest.description.clone(), + source_kind: PluginSourceKind::External, + source_path: canonical_source.display().to_string(), + install_path: install_path.display().to_string(), + installed_at, + updated_at: now, + }); + self.save_registry(®istry)?; + self.write_enabled_state(&plugin_id, Some(true))?; + + Ok(PluginOperationResult { + plugin_id: plugin_id.clone(), + message: format!( + "Installed plugin {} from {}", + plugin_id, + canonical_source.display() + ), + }) + } + + pub fn enable_plugin(&self, plugin_ref: &str) -> Result { + let plugin = self.resolve_plugin(plugin_ref)?; + self.write_enabled_state(plugin.id(), Some(true))?; + Ok(PluginOperationResult { + plugin_id: plugin.id().to_string(), + message: format!("Enabled plugin {}", plugin.id()), + }) + } + + pub fn disable_plugin(&self, plugin_ref: &str) -> Result { + let plugin = self.resolve_plugin(plugin_ref)?; + self.write_enabled_state(plugin.id(), Some(false))?; + Ok(PluginOperationResult { + plugin_id: plugin.id().to_string(), + message: format!("Disabled plugin {}", plugin.id()), + }) + } + + pub fn uninstall_plugin( + &self, + plugin_ref: &str, + ) -> Result { + let plugin = self.resolve_plugin(plugin_ref)?; + if plugin.source_kind != PluginSourceKind::External { + return Err(PluginError::new(format!( + "plugin {} is {} and cannot be uninstalled", + plugin.id(), + plugin.source_kind.suffix() + ))); + } + + let mut registry = self.load_registry()?; + let Some(record) = registry.remove(plugin.id()) else { + return Err(PluginError::new(format!( + "plugin {} is not installed", + plugin.id() + ))); + }; + self.save_registry(®istry)?; + self.write_enabled_state(plugin.id(), None)?; + + let install_path = PathBuf::from(record.install_path); + if install_path.exists() { + fs::remove_dir_all(install_path)?; + } + + Ok(PluginOperationResult { + plugin_id: plugin.id().to_string(), + message: format!("Uninstalled plugin {}", plugin.id()), + }) + } + + pub fn update_plugin(&self, plugin_ref: &str) -> Result { + let plugin = self.resolve_plugin(plugin_ref)?; + match plugin.source_kind { + PluginSourceKind::Builtin | PluginSourceKind::Bundled => Ok(PluginOperationResult { + plugin_id: plugin.id().to_string(), + message: format!( + "Plugin {} is {} and already managed by the CLI", + plugin.id(), + plugin.source_kind.suffix() + ), + }), + PluginSourceKind::External => { + let registry = self.load_registry()?; + let record = registry.find(plugin.id()).ok_or_else(|| { + PluginError::new(format!("plugin {} is not installed", plugin.id())) + })?; + self.install_plugin(PathBuf::from(&record.source_path)).map(|_| PluginOperationResult { + plugin_id: plugin.id().to_string(), + message: format!("Updated plugin {}", plugin.id()), + }) + } + } + } + + fn resolve_plugin(&self, plugin_ref: &str) -> Result { + let plugins = self.discover_plugins()?; + if let Some(plugin) = plugins.iter().find(|plugin| plugin.id == plugin_ref) { + return Ok(plugin.clone()); + } + let mut matches = plugins + .into_iter() + .filter(|plugin| plugin.name() == plugin_ref) + .collect::>(); + match matches.len() { + 0 => Err(PluginError::new(format!("plugin {plugin_ref} was not found"))), + 1 => Ok(matches.remove(0)), + _ => Err(PluginError::new(format!( + "plugin name {plugin_ref} is ambiguous; use a full plugin id" + ))), + } + } + + fn settings_path(&self) -> PathBuf { + let _ = &self.cwd; + self.config_home.join("settings.json") + } + + fn installs_root(&self) -> PathBuf { + self.config_home.join("plugins").join("installs") + } + + fn registry_path(&self) -> PathBuf { + self.config_home.join("plugins").join("installed.json") + } + + fn load_registry(&self) -> Result { + PluginRegistry::load(&self.registry_path()).map_err(PluginError::from) + } + + fn save_registry(&self, registry: &PluginRegistry) -> Result<(), PluginError> { + registry.save(&self.registry_path()).map_err(PluginError::from) + } + + fn write_enabled_state( + &self, + plugin_id: &str, + enabled: Option, + ) -> Result<(), PluginError> { + let settings_path = self.settings_path(); + let mut settings = read_settings_file(&settings_path)?; + write_plugin_state(&mut settings, plugin_id, enabled); + write_settings_file(&settings_path, &settings)?; + Ok(()) + } +} + +fn builtin_plugins() -> Vec { + let manifest = PluginManifest { + name: "tool-guard".to_string(), + description: "Example built-in plugin with optional tool hook messages".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + default_enabled: false, + hooks: PluginHooks { + pre_tool_use: vec!["printf 'builtin tool-guard saw %s' \"$HOOK_TOOL_NAME\"".to_string()], + post_tool_use: Vec::new(), + }, + }; + vec![LoadedPlugin::new( + format!("{}@builtin", manifest.name), + PluginSourceKind::Builtin, + manifest, + None, + None, + )] +} + +fn bundled_plugins() -> Vec { + let manifest = PluginManifest { + name: "tool-audit".to_string(), + description: "Example bundled plugin with optional post-tool hooks".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + default_enabled: false, + hooks: PluginHooks { + pre_tool_use: Vec::new(), + post_tool_use: vec!["printf 'bundled tool-audit saw %s' \"$HOOK_TOOL_NAME\"".to_string()], + }, + }; + vec![LoadedPlugin::new( + format!("{}@bundled", manifest.name), + PluginSourceKind::Bundled, + manifest, + None, + None, + )] +} + +fn is_plugin_enabled(plugin: &LoadedPlugin, runtime_config: &RuntimeConfig) -> bool { + runtime_config.plugins().state_for(&plugin.id, plugin.manifest.default_enabled) +} + +fn external_plugin_id(name: &str) -> String { + format!("{}@external", name.trim()) +} + +fn sanitize_plugin_id(plugin_id: &str) -> String { + plugin_id + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || matches!(character, '-' | '_') { + character + } else { + '-' + } + }) + .collect() +} + +fn load_manifest_from_source(source: &Path) -> Result<(PluginManifest, PathBuf), PluginError> { + let (manifest_path, root) = resolve_manifest_path(source)?; + let contents = fs::read_to_string(&manifest_path).map_err(|error| { + PluginError::new(format!( + "failed to read plugin manifest {}: {error}", + manifest_path.display() + )) + })?; + let manifest: PluginManifest = serde_json::from_str(&contents).map_err(|error| { + PluginError::new(format!( + "failed to parse plugin manifest {}: {error}", + manifest_path.display() + )) + })?; + manifest.validate().map_err(PluginError::new)?; + Ok((manifest, root)) +} + +fn resolve_manifest_path(source: &Path) -> Result<(PathBuf, PathBuf), PluginError> { + if source.is_file() { + let file_name = source.file_name().and_then(|name| name.to_str()).unwrap_or_default(); + if file_name != "plugin.json" { + return Err(PluginError::new(format!( + "plugin manifest file must be named plugin.json: {}", + source.display() + ))); + } + let root = source + .parent() + .and_then(|parent| parent.parent().filter(|candidate| parent.file_name() == Some(std::ffi::OsStr::new(".claude-plugin")))) + .map_or_else( + || source.parent().unwrap_or_else(|| Path::new(".")).to_path_buf(), + Path::to_path_buf, + ); + return Ok((source.to_path_buf(), root)); + } + + let nested = source.join(".claude-plugin").join("plugin.json"); + if nested.exists() { + return Ok((nested, source.to_path_buf())); + } + + let direct = source.join("plugin.json"); + if direct.exists() { + return Ok((direct, source.to_path_buf())); + } + + Err(PluginError::new(format!( + "plugin manifest not found in {}", + source.display() + ))) +} + +fn copy_dir_recursive(source: &Path, destination: &Path) -> Result<(), PluginError> { + if destination.exists() { + fs::remove_dir_all(destination)?; + } + fs::create_dir_all(destination)?; + for entry in fs::read_dir(source)? { + let entry = entry?; + let path = entry.path(); + let target = destination.join(entry.file_name()); + if entry.file_type()?.is_dir() { + copy_dir_recursive(&path, &target)?; + } else { + fs::copy(&path, &target)?; + } + } + Ok(()) +} + +fn iso8601_now() -> String { + let seconds = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + format!("{seconds}") +} + +#[cfg(test)] +mod tests { + use super::{PluginLoader, PluginManager, PluginSourceKind}; + use runtime::ConfigLoader; + use std::fs; + use std::path::Path; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn temp_dir() -> std::path::PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time should be after epoch") + .as_nanos(); + std::env::temp_dir().join(format!("plugins-manager-{nanos}")) + } + + fn write_external_plugin(root: &Path, version: &str, hook_body: &str) { + fs::create_dir_all(root.join(".claude-plugin")).expect("plugin dir should exist"); + fs::write( + root.join(".claude-plugin").join("plugin.json"), + format!( + r#"{{ + "name": "sample-plugin", + "description": "sample external plugin", + "version": "{version}", + "hooks": {{ + "PreToolUse": ["printf 'pre from ${PLUGIN_DIR} {hook_body}'"] + }} +}}"# + ), + ) + .expect("plugin manifest should write"); + fs::write(root.join("README.md"), "sample").expect("payload should write"); + } + + #[test] + fn discovers_builtin_and_bundled_plugins() { + let root = temp_dir(); + let home = root.join("home").join(".claude"); + let loader = PluginLoader::new(&home); + let plugins = loader.discover().expect("plugins should load"); + assert!(plugins.iter().any(|plugin| plugin.source_kind == PluginSourceKind::Builtin)); + assert!(plugins.iter().any(|plugin| plugin.source_kind == PluginSourceKind::Bundled)); + fs::remove_dir_all(root).expect("cleanup"); + } + + #[test] + fn installs_and_lists_external_plugins() { + let root = temp_dir(); + let cwd = root.join("project"); + let home = root.join("home").join(".claude"); + let source = root.join("source-plugin"); + fs::create_dir_all(&cwd).expect("cwd should exist"); + write_external_plugin(&source, "1.0.0", "v1"); + + let manager = PluginManager::new(&cwd, &home); + let result = manager.install_plugin(&source).expect("install should succeed"); + assert_eq!(result.plugin_id, "sample-plugin@external"); + + let runtime_config = ConfigLoader::new(&cwd, &home) + .load() + .expect("config should load"); + let plugins = manager + .list_plugins(&runtime_config) + .expect("plugins should list"); + let external = plugins + .iter() + .find(|plugin| plugin.plugin.id == "sample-plugin@external") + .expect("external plugin should exist"); + assert!(external.enabled); + + let hook_config = manager + .active_hook_config(&runtime_config) + .expect("hook config should build"); + assert_eq!(hook_config.pre_tool_use().len(), 1); + assert!(hook_config.pre_tool_use()[0].contains("sample-plugin-external")); + + fs::remove_dir_all(root).expect("cleanup"); + } + + #[test] + fn disables_enables_updates_and_uninstalls_external_plugins() { + let root = temp_dir(); + let cwd = root.join("project"); + let home = root.join("home").join(".claude"); + let source = root.join("source-plugin"); + fs::create_dir_all(&cwd).expect("cwd should exist"); + write_external_plugin(&source, "1.0.0", "v1"); + + let manager = PluginManager::new(&cwd, &home); + manager.install_plugin(&source).expect("install should succeed"); + manager + .disable_plugin("sample-plugin") + .expect("disable should succeed"); + let runtime_config = ConfigLoader::new(&cwd, &home) + .load() + .expect("config should load"); + let plugins = manager + .list_plugins(&runtime_config) + .expect("plugins should list"); + assert!(!plugins + .iter() + .find(|plugin| plugin.plugin.id == "sample-plugin@external") + .expect("external plugin should exist") + .enabled); + + manager + .enable_plugin("sample-plugin@external") + .expect("enable should succeed"); + write_external_plugin(&source, "2.0.0", "v2"); + manager + .update_plugin("sample-plugin@external") + .expect("update should succeed"); + + let loader = PluginLoader::new(&home); + let plugins = loader.discover().expect("plugins should load"); + let external = plugins + .iter() + .find(|plugin| plugin.id == "sample-plugin@external") + .expect("external plugin should exist"); + assert_eq!(external.manifest.version, "2.0.0"); + + manager + .uninstall_plugin("sample-plugin@external") + .expect("uninstall should succeed"); + let plugins = loader.discover().expect("plugins should reload"); + assert!(!plugins + .iter() + .any(|plugin| plugin.id == "sample-plugin@external")); + + fs::remove_dir_all(root).expect("cleanup"); + } +} diff --git a/rust/crates/plugins/src/manifest.rs b/rust/crates/plugins/src/manifest.rs new file mode 100644 index 0000000..449b0be --- /dev/null +++ b/rust/crates/plugins/src/manifest.rs @@ -0,0 +1,175 @@ +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::PluginSourceKind; + +pub trait Plugin { + fn id(&self) -> &str; + fn manifest(&self) -> &PluginManifest; + fn source_kind(&self) -> PluginSourceKind; + fn root(&self) -> Option<&Path>; + + fn resolved_hooks(&self) -> PluginHooks { + self.manifest().hooks.resolve(self.root()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct PluginHooks { + #[serde(rename = "PreToolUse", alias = "preToolUse", default)] + pub pre_tool_use: Vec, + #[serde(rename = "PostToolUse", alias = "postToolUse", default)] + pub post_tool_use: Vec, +} + +impl PluginHooks { + #[must_use] + pub fn resolve(&self, root: Option<&Path>) -> Self { + let Some(root) = root else { + return self.clone(); + }; + let replacement = root.display().to_string(); + Self { + pre_tool_use: self + .pre_tool_use + .iter() + .map(|value| value.replace("${PLUGIN_DIR}", &replacement)) + .collect(), + post_tool_use: self + .post_tool_use + .iter() + .map(|value| value.replace("${PLUGIN_DIR}", &replacement)) + .collect(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginManifest { + pub name: String, + pub description: String, + #[serde(default = "default_version")] + pub version: String, + #[serde(default)] + pub default_enabled: bool, + #[serde(default)] + pub hooks: PluginHooks, +} + +impl PluginManifest { + pub fn validate(&self) -> Result<(), String> { + if self.name.trim().is_empty() { + return Err("plugin manifest name must not be empty".to_string()); + } + if self.description.trim().is_empty() { + return Err(format!( + "plugin manifest description must not be empty for {}", + self.name + )); + } + if self.version.trim().is_empty() { + return Err(format!( + "plugin manifest version must not be empty for {}", + self.name + )); + } + if self + .hooks + .pre_tool_use + .iter() + .chain(self.hooks.post_tool_use.iter()) + .any(|hook| hook.trim().is_empty()) + { + return Err(format!( + "plugin manifest hook entries must not be empty for {}", + self.name + )); + } + Ok(()) + } +} + +fn default_version() -> String { + "0.1.0".to_string() +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LoadedPlugin { + pub id: String, + pub source_kind: PluginSourceKind, + pub manifest: PluginManifest, + pub root: Option, + pub origin: Option, +} + +impl LoadedPlugin { + #[must_use] + pub fn new( + id: String, + source_kind: PluginSourceKind, + manifest: PluginManifest, + root: Option, + origin: Option, + ) -> Self { + Self { + id, + source_kind, + manifest, + root, + origin, + } + } + + #[must_use] + pub fn name(&self) -> &str { + &self.manifest.name + } +} + +impl Plugin for LoadedPlugin { + fn id(&self) -> &str { + &self.id + } + + fn manifest(&self) -> &PluginManifest { + &self.manifest + } + + fn source_kind(&self) -> PluginSourceKind { + self.source_kind + } + + fn root(&self) -> Option<&Path> { + self.root.as_deref() + } +} + +#[cfg(test)] +mod tests { + use super::{PluginHooks, PluginManifest}; + use std::path::Path; + + #[test] + fn validates_manifest_fields() { + let manifest = PluginManifest { + name: "demo".to_string(), + description: "demo plugin".to_string(), + version: "1.2.3".to_string(), + default_enabled: false, + hooks: PluginHooks::default(), + }; + assert!(manifest.validate().is_ok()); + } + + #[test] + fn resolves_plugin_dir_placeholders() { + let hooks = PluginHooks { + pre_tool_use: vec!["echo ${PLUGIN_DIR}/pre".to_string()], + post_tool_use: vec!["echo ${PLUGIN_DIR}/post".to_string()], + }; + let resolved = hooks.resolve(Some(Path::new("/tmp/plugin"))); + assert_eq!(resolved.pre_tool_use, vec!["echo /tmp/plugin/pre"]); + assert_eq!(resolved.post_tool_use, vec!["echo /tmp/plugin/post"]); + } +} diff --git a/rust/crates/plugins/src/registry.rs b/rust/crates/plugins/src/registry.rs new file mode 100644 index 0000000..a2f021d --- /dev/null +++ b/rust/crates/plugins/src/registry.rs @@ -0,0 +1,91 @@ +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +use crate::InstalledPluginRecord; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct PluginRegistry { + #[serde(default)] + pub plugins: Vec, +} + +impl PluginRegistry { + pub fn load(path: &Path) -> Result { + match std::fs::read_to_string(path) { + Ok(contents) => { + if contents.trim().is_empty() { + return Ok(Self::default()); + } + serde_json::from_str(&contents).map_err(|error| error.to_string()) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()), + Err(error) => Err(error.to_string()), + } + } + + pub fn save(&self, path: &Path) -> Result<(), String> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; + } + std::fs::write( + path, + serde_json::to_string_pretty(self).map_err(|error| error.to_string())?, + ) + .map_err(|error| error.to_string()) + } + + #[must_use] + pub fn find(&self, plugin_id: &str) -> Option<&InstalledPluginRecord> { + self.plugins.iter().find(|plugin| plugin.id == plugin_id) + } + + pub fn upsert(&mut self, record: InstalledPluginRecord) { + if let Some(existing) = self.plugins.iter_mut().find(|plugin| plugin.id == record.id) { + *existing = record; + } else { + self.plugins.push(record); + } + self.plugins.sort_by(|left, right| left.id.cmp(&right.id)); + } + + pub fn remove(&mut self, plugin_id: &str) -> Option { + let index = self.plugins.iter().position(|plugin| plugin.id == plugin_id)?; + Some(self.plugins.remove(index)) + } +} + +#[cfg(test)] +mod tests { + use super::PluginRegistry; + use crate::{InstalledPluginRecord, PluginSourceKind}; + + #[test] + fn upsert_replaces_existing_entries() { + let mut registry = PluginRegistry::default(); + registry.upsert(InstalledPluginRecord { + id: "demo@external".to_string(), + name: "demo".to_string(), + version: "1.0.0".to_string(), + description: "demo".to_string(), + source_kind: PluginSourceKind::External, + source_path: "/src".to_string(), + install_path: "/install".to_string(), + installed_at: "t1".to_string(), + updated_at: "t1".to_string(), + }); + registry.upsert(InstalledPluginRecord { + id: "demo@external".to_string(), + name: "demo".to_string(), + version: "1.0.1".to_string(), + description: "updated".to_string(), + source_kind: PluginSourceKind::External, + source_path: "/src".to_string(), + install_path: "/install".to_string(), + installed_at: "t1".to_string(), + updated_at: "t2".to_string(), + }); + assert_eq!(registry.plugins.len(), 1); + assert_eq!(registry.plugins[0].version, "1.0.1"); + } +} diff --git a/rust/crates/plugins/src/settings.rs b/rust/crates/plugins/src/settings.rs new file mode 100644 index 0000000..5ce0367 --- /dev/null +++ b/rust/crates/plugins/src/settings.rs @@ -0,0 +1,106 @@ +use std::path::Path; + +use runtime::RuntimePluginConfig; +use serde_json::{Map, Value}; + +pub fn read_settings_file(path: &Path) -> Result, String> { + match std::fs::read_to_string(path) { + Ok(contents) => { + if contents.trim().is_empty() { + return Ok(Map::new()); + } + serde_json::from_str::(&contents) + .map_err(|error| error.to_string())? + .as_object() + .cloned() + .ok_or_else(|| "settings file must contain a JSON object".to_string()) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Map::new()), + Err(error) => Err(error.to_string()), + } +} + +pub fn write_settings_file(path: &Path, root: &Map) -> Result<(), String> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; + } + std::fs::write( + path, + serde_json::to_string_pretty(root).map_err(|error| error.to_string())?, + ) + .map_err(|error| error.to_string()) +} + +pub fn read_enabled_plugin_map(root: &Map) -> Map { + root.get("enabledPlugins") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default() +} + +pub fn write_plugin_state( + root: &mut Map, + plugin_id: &str, + enabled: Option, +) { + let mut enabled_plugins = read_enabled_plugin_map(root); + match enabled { + Some(value) => { + enabled_plugins.insert(plugin_id.to_string(), Value::Bool(value)); + } + None => { + enabled_plugins.remove(plugin_id); + } + } + if enabled_plugins.is_empty() { + root.remove("enabledPlugins"); + } else { + root.insert("enabledPlugins".to_string(), Value::Object(enabled_plugins)); + } +} + +pub fn config_from_settings(root: &Map) -> RuntimePluginConfig { + let mut config = RuntimePluginConfig::default(); + if let Some(enabled_plugins) = root.get("enabledPlugins").and_then(Value::as_object) { + for (plugin_id, enabled) in enabled_plugins { + match enabled.as_bool() { + Some(value) => config.set_plugin_state(plugin_id.clone(), value), + None => {} + } + } + } + config +} + +#[cfg(test)] +mod tests { + use super::{config_from_settings, write_plugin_state}; + use serde_json::{json, Map, Value}; + + #[test] + fn writes_and_removes_enabled_plugin_state() { + let mut root = Map::new(); + write_plugin_state(&mut root, "demo@external", Some(true)); + assert_eq!( + root.get("enabledPlugins"), + Some(&json!({"demo@external": true})) + ); + write_plugin_state(&mut root, "demo@external", None); + assert_eq!(root.get("enabledPlugins"), None); + } + + #[test] + fn converts_settings_to_runtime_plugin_config() { + let mut root = Map::::new(); + root.insert( + "enabledPlugins".to_string(), + json!({"demo@external": true, "off@bundled": false}), + ); + let config = config_from_settings(&root); + assert_eq!( + config.enabled_plugins().get("demo@external"), + Some(&true) + ); + assert_eq!(config.enabled_plugins().get("off@bundled"), Some(&false)); + } +} diff --git a/rust/crates/runtime/src/config.rs b/rust/crates/runtime/src/config.rs index 368e7c5..dfc4d1a 100644 --- a/rust/crates/runtime/src/config.rs +++ b/rust/crates/runtime/src/config.rs @@ -35,9 +35,19 @@ pub struct RuntimeConfig { feature_config: RuntimeFeatureConfig, } +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct RuntimePluginConfig { + enabled_plugins: BTreeMap, + external_directories: Vec, + install_root: Option, + registry_path: Option, + bundled_root: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct RuntimeFeatureConfig { hooks: RuntimeHookConfig, + plugins: RuntimePluginConfig, mcp: McpConfigCollection, oauth: Option, model: Option, @@ -174,13 +184,15 @@ impl ConfigLoader { #[must_use] pub fn default_for(cwd: impl Into) -> Self { let cwd = cwd.into(); - let config_home = std::env::var_os("CLAUDE_CONFIG_HOME") - .map(PathBuf::from) - .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".claude"))) - .unwrap_or_else(|| PathBuf::from(".claude")); + let config_home = default_config_home(); Self { cwd, config_home } } + #[must_use] + pub fn config_home(&self) -> &Path { + &self.config_home + } + #[must_use] pub fn discover(&self) -> Vec { let user_legacy_path = self.config_home.parent().map_or_else( @@ -229,6 +241,7 @@ impl ConfigLoader { let feature_config = RuntimeFeatureConfig { hooks: parse_optional_hooks_config(&merged_value)?, + plugins: parse_optional_plugin_config(&merged_value)?, mcp: McpConfigCollection { servers: mcp_servers, }, @@ -291,6 +304,11 @@ impl RuntimeConfig { &self.feature_config.hooks } + #[must_use] + pub fn plugins(&self) -> &RuntimePluginConfig { + &self.feature_config.plugins + } + #[must_use] pub fn oauth(&self) -> Option<&OAuthConfig> { self.feature_config.oauth.as_ref() @@ -319,11 +337,22 @@ impl RuntimeFeatureConfig { self } + #[must_use] + pub fn with_plugins(mut self, plugins: RuntimePluginConfig) -> Self { + self.plugins = plugins; + self + } + #[must_use] pub fn hooks(&self) -> &RuntimeHookConfig { &self.hooks } + #[must_use] + pub fn plugins(&self) -> &RuntimePluginConfig { + &self.plugins + } + #[must_use] pub fn mcp(&self) -> &McpConfigCollection { &self.mcp @@ -350,6 +379,53 @@ impl RuntimeFeatureConfig { } } +impl RuntimePluginConfig { + #[must_use] + pub fn enabled_plugins(&self) -> &BTreeMap { + &self.enabled_plugins + } + + #[must_use] + pub fn external_directories(&self) -> &[String] { + &self.external_directories + } + + #[must_use] + pub fn install_root(&self) -> Option<&str> { + self.install_root.as_deref() + } + + #[must_use] + pub fn registry_path(&self) -> Option<&str> { + self.registry_path.as_deref() + } + + #[must_use] + pub fn bundled_root(&self) -> Option<&str> { + self.bundled_root.as_deref() + } + + pub fn set_plugin_state(&mut self, plugin_id: String, enabled: bool) { + self.enabled_plugins.insert(plugin_id, enabled); + } + + #[must_use] + pub fn state_for(&self, plugin_id: &str, default_enabled: bool) -> bool { + self.enabled_plugins + .get(plugin_id) + .copied() + .unwrap_or(default_enabled) + } +} + +#[must_use] +pub fn default_config_home() -> PathBuf { + std::env::var_os("CLAUDE_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".claude"))) + .unwrap_or_else(|| PathBuf::from(".claude")) +} + impl RuntimeHookConfig { #[must_use] pub fn new(pre_tool_use: Vec, post_tool_use: Vec) -> Self { @@ -368,6 +444,18 @@ impl RuntimeHookConfig { pub fn post_tool_use(&self) -> &[String] { &self.post_tool_use } + + #[must_use] + pub fn merged(&self, other: &Self) -> Self { + let mut merged = self.clone(); + merged.extend(other); + merged + } + + pub fn extend(&mut self, other: &Self) { + extend_unique(&mut self.pre_tool_use, other.pre_tool_use()); + extend_unique(&mut self.post_tool_use, other.post_tool_use()); + } } impl McpConfigCollection { @@ -484,6 +572,36 @@ fn parse_optional_hooks_config(root: &JsonValue) -> Result Result { + let Some(object) = root.as_object() else { + return Ok(RuntimePluginConfig::default()); + }; + + let mut config = RuntimePluginConfig::default(); + if let Some(enabled_plugins) = object.get("enabledPlugins") { + config.enabled_plugins = parse_bool_map(enabled_plugins, "merged settings.enabledPlugins")?; + } + + let Some(plugins_value) = object.get("plugins") else { + return Ok(config); + }; + let plugins = expect_object(plugins_value, "merged settings.plugins")?; + + if let Some(enabled_value) = plugins.get("enabled") { + config.enabled_plugins = parse_bool_map(enabled_value, "merged settings.plugins.enabled")?; + } + config.external_directories = + optional_string_array(plugins, "externalDirectories", "merged settings.plugins")? + .unwrap_or_default(); + config.install_root = + optional_string(plugins, "installRoot", "merged settings.plugins")?.map(str::to_string); + config.registry_path = + optional_string(plugins, "registryPath", "merged settings.plugins")?.map(str::to_string); + config.bundled_root = + optional_string(plugins, "bundledRoot", "merged settings.plugins")?.map(str::to_string); + Ok(config) +} + fn parse_optional_permission_mode( root: &JsonValue, ) -> Result, ConfigError> { @@ -716,6 +834,24 @@ fn optional_u16( } } +fn parse_bool_map(value: &JsonValue, context: &str) -> Result, ConfigError> { + let Some(map) = value.as_object() else { + return Err(ConfigError::Parse(format!( + "{context}: expected JSON object" + ))); + }; + map.iter() + .map(|(key, value)| { + value + .as_bool() + .map(|enabled| (key.clone(), enabled)) + .ok_or_else(|| { + ConfigError::Parse(format!("{context}: field {key} must be a boolean")) + }) + }) + .collect() +} + fn optional_string_array( object: &BTreeMap, key: &str, @@ -790,6 +926,18 @@ fn deep_merge_objects( } } +fn extend_unique(target: &mut Vec, values: &[String]) { + for value in values { + push_unique(target, value.clone()); + } +} + +fn push_unique(target: &mut Vec, value: String) { + if !target.iter().any(|existing| existing == &value) { + target.push(value); + } +} + #[cfg(test)] mod tests { use super::{ @@ -1033,6 +1181,96 @@ mod tests { fs::remove_dir_all(root).expect("cleanup temp dir"); } + #[test] + fn parses_plugin_config_from_enabled_plugins() { + let root = temp_dir(); + let cwd = root.join("project"); + let home = root.join("home").join(".claude"); + fs::create_dir_all(cwd.join(".claude")).expect("project config dir"); + fs::create_dir_all(&home).expect("home config dir"); + + fs::write( + home.join("settings.json"), + r#"{ + "enabledPlugins": { + "tool-guard@builtin": true, + "sample-plugin@external": false + } + }"#, + ) + .expect("write user settings"); + + let loaded = ConfigLoader::new(&cwd, &home) + .load() + .expect("config should load"); + + assert_eq!( + loaded.plugins().enabled_plugins().get("tool-guard@builtin"), + Some(&true) + ); + assert_eq!( + loaded + .plugins() + .enabled_plugins() + .get("sample-plugin@external"), + Some(&false) + ); + + fs::remove_dir_all(root).expect("cleanup temp dir"); + } + + #[test] + fn parses_plugin_config() { + let root = temp_dir(); + let cwd = root.join("project"); + let home = root.join("home").join(".claude"); + fs::create_dir_all(cwd.join(".claude")).expect("project config dir"); + fs::create_dir_all(&home).expect("home config dir"); + + fs::write( + home.join("settings.json"), + r#"{ + "enabledPlugins": { + "core-helpers@builtin": true + }, + "plugins": { + "externalDirectories": ["./external-plugins"], + "installRoot": "plugin-cache/installed", + "registryPath": "plugin-cache/installed.json", + "bundledRoot": "./bundled-plugins" + } + }"#, + ) + .expect("write plugin settings"); + + let loaded = ConfigLoader::new(&cwd, &home) + .load() + .expect("config should load"); + + assert_eq!( + loaded + .plugins() + .enabled_plugins() + .get("core-helpers@builtin"), + Some(&true) + ); + assert_eq!( + loaded.plugins().external_directories(), + &["./external-plugins".to_string()] + ); + assert_eq!( + loaded.plugins().install_root(), + Some("plugin-cache/installed") + ); + assert_eq!( + loaded.plugins().registry_path(), + Some("plugin-cache/installed.json") + ); + assert_eq!(loaded.plugins().bundled_root(), Some("./bundled-plugins")); + + fs::remove_dir_all(root).expect("cleanup temp dir"); + } + #[test] fn rejects_invalid_mcp_server_shapes() { let root = temp_dir(); diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index da745e5..afb3355 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -28,7 +28,7 @@ pub use config::{ McpConfigCollection, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig, McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig, ResolvedPermissionMode, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookConfig, - ScopedMcpServerConfig, CLAUDE_CODE_SETTINGS_SCHEMA_NAME, + RuntimePluginConfig, ScopedMcpServerConfig, CLAUDE_CODE_SETTINGS_SCHEMA_NAME, }; pub use conversation::{ ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, StaticToolExecutor, diff --git a/rust/crates/rusty-claude-cli/Cargo.toml b/rust/crates/rusty-claude-cli/Cargo.toml index 2ac6701..242ec0f 100644 --- a/rust/crates/rusty-claude-cli/Cargo.toml +++ b/rust/crates/rusty-claude-cli/Cargo.toml @@ -17,6 +17,7 @@ crossterm = "0.28" pulldown-cmark = "0.13" rustyline = "15" runtime = { path = "../runtime" } +plugins = { path = "../plugins" } serde_json = "1" syntect = "5" tokio = { version = "1", features = ["rt-multi-thread", "time"] } diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 5f8a7a6..ee7b1dd 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -22,6 +22,7 @@ use commands::{ }; use compat_harness::{extract_manifest, UpstreamPaths}; use init::initialize_repo; +use plugins::{PluginListEntry, PluginManager}; use render::{MarkdownStreamState, Spinner, TerminalRenderer}; use runtime::{ clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt, @@ -917,6 +918,7 @@ fn run_resume_command( | SlashCommand::Model { .. } | SlashCommand::Permissions { .. } | SlashCommand::Session { .. } + | SlashCommand::Plugins { .. } | SlashCommand::Unknown(_) => Err("unsupported resumed slash command".into()), } } @@ -1168,6 +1170,9 @@ impl LiveCli { SlashCommand::Session { action, target } => { self.handle_session_command(action.as_deref(), target.as_deref())? } + SlashCommand::Plugins { action, target } => { + self.handle_plugins_command(action.as_deref(), target.as_deref())? + } SlashCommand::Unknown(name) => { eprintln!("unknown slash command: /{name}"); false @@ -1430,6 +1435,87 @@ impl LiveCli { } } + fn handle_plugins_command( + &mut self, + action: Option<&str>, + target: Option<&str>, + ) -> Result> { + let cwd = env::current_dir()?; + let runtime_config = ConfigLoader::default_for(&cwd).load()?; + let manager = PluginManager::default_for(&cwd); + + match action { + None | Some("list") => { + let plugins = manager.list_plugins(&runtime_config)?; + println!("{}", render_plugins_report(&plugins)); + } + Some("install") => { + let Some(target) = target else { + println!("Usage: /plugins install "); + return Ok(false); + }; + let result = manager.install_plugin(PathBuf::from(target))?; + println!("Plugins\n Result {}", result.message); + self.reload_runtime_features()?; + } + Some("enable") => { + let Some(target) = target else { + println!("Usage: /plugins enable "); + return Ok(false); + }; + let result = manager.enable_plugin(target)?; + println!("Plugins\n Result {}", result.message); + self.reload_runtime_features()?; + } + Some("disable") => { + let Some(target) = target else { + println!("Usage: /plugins disable "); + return Ok(false); + }; + let result = manager.disable_plugin(target)?; + println!("Plugins\n Result {}", result.message); + self.reload_runtime_features()?; + } + Some("uninstall") => { + let Some(target) = target else { + println!("Usage: /plugins uninstall "); + return Ok(false); + }; + let result = manager.uninstall_plugin(target)?; + println!("Plugins\n Result {}", result.message); + self.reload_runtime_features()?; + } + Some("update") => { + let Some(target) = target else { + println!("Usage: /plugins update "); + return Ok(false); + }; + let result = manager.update_plugin(target)?; + println!("Plugins\n Result {}", result.message); + self.reload_runtime_features()?; + } + Some(other) => { + println!( + "Unknown /plugins action '{other}'. Use list, install, enable, disable, uninstall, or update." + ); + } + } + Ok(false) + } + + fn reload_runtime_features(&mut self) -> Result<(), Box> { + self.runtime = build_runtime( + self.runtime.session().clone(), + self.model.clone(), + self.system_prompt.clone(), + true, + true, + self.allowed_tools.clone(), + self.permission_mode, + )?; + self.persist_session() + } + fn compact(&mut self) -> Result<(), Box> { let result = self.runtime.compact(CompactionConfig::default()); let removed = result.removed_message_count; @@ -1568,6 +1654,33 @@ fn render_repl_help() -> String { ) } +fn render_plugins_report(plugins: &[PluginListEntry]) -> 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 = format!("{:?}", plugin.plugin.source_kind).to_lowercase(); + let location = plugin.plugin.root.as_ref().map_or_else( + || kind.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.plugin.id, + kind = kind, + version = plugin.plugin.manifest.version, + )); + } + lines.join("\n") +} + fn status_context( session_path: Option<&Path>, ) -> Result> { @@ -1691,9 +1804,12 @@ fn render_config_report(section: Option<&str>) -> Result runtime_config.get("env"), "hooks" => runtime_config.get("hooks"), "model" => runtime_config.get("model"), + "plugins" => runtime_config + .get("plugins") + .or_else(|| runtime_config.get("enabledPlugins")), other => { lines.push(format!( - " Unsupported config section '{other}'. Use env, hooks, or model." + " Unsupported config section '{other}'. Use env, hooks, model, or plugins." )); return Ok(lines.join( " @@ -1906,10 +2022,14 @@ fn build_system_prompt() -> Result, Box> { fn build_runtime_feature_config( ) -> Result> { let cwd = env::current_dir()?; - Ok(ConfigLoader::default_for(cwd) - .load()? + let loader = ConfigLoader::default_for(&cwd); + let runtime_config = loader.load()?; + let plugin_manager = PluginManager::default_for(&cwd); + let plugin_hooks = plugin_manager.active_hook_config(&runtime_config)?; + Ok(runtime_config .feature_config() - .clone()) + .clone() + .with_hooks(runtime_config.hooks().merged(&plugin_hooks))) } fn build_runtime( @@ -3072,13 +3192,16 @@ mod tests { assert!(help.contains("/clear [--confirm]")); assert!(help.contains("/cost")); assert!(help.contains("/resume ")); - assert!(help.contains("/config [env|hooks|model]")); + assert!(help.contains("/config [env|hooks|model|plugins]")); assert!(help.contains("/memory")); assert!(help.contains("/init")); assert!(help.contains("/diff")); assert!(help.contains("/version")); assert!(help.contains("/export [file]")); assert!(help.contains("/session [list|switch ]")); + assert!(help.contains( + "/plugins [list|install |enable |disable |uninstall |update ]" + )); assert!(help.contains("/exit")); } @@ -3229,6 +3352,9 @@ mod tests { fn config_report_supports_section_views() { let report = render_config_report(Some("env")).expect("config report should render"); assert!(report.contains("Merged section: env")); + let plugins_report = + render_config_report(Some("plugins")).expect("plugins config report should render"); + assert!(plugins_report.contains("Merged section: plugins")); } #[test]