diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index d761645..be84455 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -297,7 +297,10 @@ impl SlashCommand { }, "plugins" => Self::Plugins { action: parts.next().map(ToOwned::to_owned), - target: parts.next().map(ToOwned::to_owned), + target: { + let remainder = parts.collect::>().join(" "); + (!remainder.is_empty()).then_some(remainder) + }, }, other => Self::Unknown(other.to_string()), }) diff --git a/rust/crates/plugins/src/lib.rs b/rust/crates/plugins/src/lib.rs index 319ebe9..a2631ff 100644 --- a/rust/crates/plugins/src/lib.rs +++ b/rust/crates/plugins/src/lib.rs @@ -732,7 +732,9 @@ fn parse_install_source(source: &str) -> Result &'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 deleted file mode 100644 index 449b0be..0000000 --- a/rust/crates/plugins/src/manifest.rs +++ /dev/null @@ -1,175 +0,0 @@ -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 deleted file mode 100644 index a2f021d..0000000 --- a/rust/crates/plugins/src/registry.rs +++ /dev/null @@ -1,91 +0,0 @@ -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 deleted file mode 100644 index 5ce0367..0000000 --- a/rust/crates/plugins/src/settings.rs +++ /dev/null @@ -1,106 +0,0 @@ -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/conversation.rs b/rust/crates/runtime/src/conversation.rs index f7a7741..1e9072d 100644 --- a/rust/crates/runtime/src/conversation.rs +++ b/rust/crates/runtime/src/conversation.rs @@ -133,6 +133,7 @@ where } #[must_use] + #[allow(clippy::needless_pass_by_value)] pub fn new_with_features( session: Session, api_client: C, @@ -761,7 +762,7 @@ mod tests { "post hook should preserve non-error result: {output:?}" ); assert!( - output.contains("4"), + output.contains('4'), "tool output missing value: {output:?}" ); assert!( diff --git a/rust/crates/runtime/src/hooks.rs b/rust/crates/runtime/src/hooks.rs index 36756a0..40da0d6 100644 --- a/rust/crates/runtime/src/hooks.rs +++ b/rust/crates/runtime/src/hooks.rs @@ -149,6 +149,7 @@ impl HookRunner { HookRunResult::allow(messages) } + #[allow(clippy::too_many_arguments, clippy::unused_self)] fn run_command( &self, command: &str, diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 313706f..c4d4396 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -4,6 +4,7 @@ mod render; use std::collections::BTreeSet; use std::env; +use std::fmt::Write as _; use std::fs; use std::io::{self, Read, Write}; use std::net::TcpListener; @@ -22,7 +23,7 @@ use commands::{ }; use compat_harness::{extract_manifest, UpstreamPaths}; use init::initialize_repo; -use plugins::{PluginListEntry, PluginManager}; +use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginSummary}; use render::{MarkdownStreamState, Spinner, TerminalRenderer}; use runtime::{ clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt, @@ -30,7 +31,7 @@ use runtime::{ AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock, ConversationMessage, ConversationRuntime, MessageRole, OAuthAuthorizationRequest, OAuthConfig, OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError, - Session, TokenUsage, ToolError, ToolExecutor, UsageTracker, + RuntimeHookConfig, Session, TokenUsage, ToolError, ToolExecutor, UsageTracker, }; use serde_json::json; use tools::{execute_tool, mvp_tool_specs, ToolSpec}; @@ -1490,21 +1491,30 @@ impl LiveCli { target: Option<&str>, ) -> Result> { let cwd = env::current_dir()?; - let runtime_config = ConfigLoader::default_for(&cwd).load()?; - let manager = PluginManager::default_for(&cwd); + let loader = ConfigLoader::default_for(&cwd); + let runtime_config = loader.load()?; + let mut manager = build_plugin_manager(&cwd, &loader, &runtime_config); match action { None | Some("list") => { - let plugins = manager.list_plugins(&runtime_config)?; + let plugins = manager.list_plugins()?; println!("{}", render_plugins_report(&plugins)); } Some("install") => { let Some(target) = target else { - println!("Usage: /plugins install "); + println!("Usage: /plugins install "); return Ok(false); }; - let result = manager.install_plugin(PathBuf::from(target))?; - println!("Plugins\n Result {}", result.message); + let result = manager.install(target)?; + println!( + "Plugins + Result installed {} + Version {} + Path {}", + result.plugin_id, + result.version, + result.install_path.display(), + ); self.reload_runtime_features()?; } Some("enable") => { @@ -1512,8 +1522,11 @@ impl LiveCli { println!("Usage: /plugins enable "); return Ok(false); }; - let result = manager.enable_plugin(target)?; - println!("Plugins\n Result {}", result.message); + manager.enable(target)?; + println!( + "Plugins + Result enabled {target}" + ); self.reload_runtime_features()?; } Some("disable") => { @@ -1521,8 +1534,11 @@ impl LiveCli { println!("Usage: /plugins disable "); return Ok(false); }; - let result = manager.disable_plugin(target)?; - println!("Plugins\n Result {}", result.message); + manager.disable(target)?; + println!( + "Plugins + Result disabled {target}" + ); self.reload_runtime_features()?; } Some("uninstall") => { @@ -1530,8 +1546,11 @@ impl LiveCli { println!("Usage: /plugins uninstall "); return Ok(false); }; - let result = manager.uninstall_plugin(target)?; - println!("Plugins\n Result {}", result.message); + manager.uninstall(target)?; + println!( + "Plugins + Result uninstalled {target}" + ); self.reload_runtime_features()?; } Some("update") => { @@ -1539,8 +1558,18 @@ impl LiveCli { println!("Usage: /plugins update "); return Ok(false); }; - let result = manager.update_plugin(target)?; - println!("Plugins\n Result {}", result.message); + let result = manager.update(target)?; + println!( + "Plugins + Result updated {} + Old version {} + New version {} + Path {}", + result.plugin_id, + result.old_version, + result.new_version, + result.install_path.display(), + ); self.reload_runtime_features()?; } Some(other) => { @@ -1858,19 +1887,22 @@ fn render_repl_help() -> String { ) } -fn render_plugins_report(plugins: &[PluginListEntry]) -> String { +fn render_plugins_report(plugins: &[PluginSummary]) -> String { let mut lines = vec!["Plugins".to_string()]; if plugins.is_empty() { lines.push(" No plugins discovered.".to_string()); return lines.join("\n"); } for plugin in plugins { - let kind = 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 kind = match plugin.metadata.kind { + PluginKind::Builtin => "builtin", + PluginKind::Bundled => "bundled", + PluginKind::External => "external", + }; + let location = plugin.metadata.root.as_ref().map_or_else( + || plugin.metadata.source.clone(), + |root| root.display().to_string(), + ); let enabled = if plugin.enabled { "enabled" } else { @@ -1878,9 +1910,9 @@ fn render_plugins_report(plugins: &[PluginListEntry]) -> String { }; lines.push(format!( " {id:<24} {kind:<8} {enabled:<8} v{version:<8} {location}", - id = plugin.plugin.id, + id = plugin.metadata.id, kind = kind, - version = plugin.plugin.manifest.version, + version = plugin.metadata.version, )); } lines.join("\n") @@ -2429,12 +2461,51 @@ fn build_runtime_feature_config( let cwd = env::current_dir()?; 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)?; + let plugin_manager = build_plugin_manager(&cwd, &loader, &runtime_config); + let plugin_hooks = plugin_manager.aggregated_hooks()?; Ok(runtime_config .feature_config() .clone() - .with_hooks(runtime_config.hooks().merged(&plugin_hooks))) + .with_hooks(runtime_config.hooks().merged(&RuntimeHookConfig::new( + plugin_hooks.pre_tool_use, + plugin_hooks.post_tool_use, + )))) +} + +fn build_plugin_manager( + cwd: &Path, + loader: &ConfigLoader, + runtime_config: &runtime::RuntimeConfig, +) -> PluginManager { + let plugin_settings = runtime_config.plugins(); + let mut plugin_config = PluginManagerConfig::new(loader.config_home().to_path_buf()); + plugin_config.enabled_plugins = plugin_settings.enabled_plugins().clone(); + plugin_config.external_dirs = plugin_settings + .external_directories() + .iter() + .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)) + .collect(); + plugin_config.install_root = plugin_settings + .install_root() + .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)); + plugin_config.registry_path = plugin_settings + .registry_path() + .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)); + plugin_config.bundled_root = plugin_settings + .bundled_root() + .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)); + PluginManager::new(plugin_config) +} + +fn resolve_plugin_path(cwd: &Path, config_home: &Path, value: &str) -> PathBuf { + let path = PathBuf::from(value); + if path.is_absolute() { + path + } else if value.starts_with('.') { + cwd.join(path) + } else { + config_home.join(path) + } } #[allow(clippy::needless_pass_by_value)] @@ -2890,13 +2961,13 @@ fn format_bash_result(icon: &str, parsed: &serde_json::Value) -> String { .get("backgroundTaskId") .and_then(|value| value.as_str()) { - lines[0].push_str(&format!(" backgrounded ({task_id})")); + write!(&mut lines[0], " backgrounded ({task_id})").expect("write to string"); } else if let Some(status) = parsed .get("returnCodeInterpretation") .and_then(|value| value.as_str()) .filter(|status| !status.is_empty()) { - lines[0].push_str(&format!(" {status}")); + write!(&mut lines[0], " {status}").expect("write to string"); } if let Some(stdout) = parsed.get("stdout").and_then(|value| value.as_str()) { @@ -2918,15 +2989,15 @@ fn format_read_result(icon: &str, parsed: &serde_json::Value) -> String { let path = extract_tool_path(file); let start_line = file .get("startLine") - .and_then(|value| value.as_u64()) + .and_then(serde_json::Value::as_u64) .unwrap_or(1); let num_lines = file .get("numLines") - .and_then(|value| value.as_u64()) + .and_then(serde_json::Value::as_u64) .unwrap_or(0); let total_lines = file .get("totalLines") - .and_then(|value| value.as_u64()) + .and_then(serde_json::Value::as_u64) .unwrap_or(num_lines); let content = file .get("content") @@ -2952,8 +3023,7 @@ fn format_write_result(icon: &str, parsed: &serde_json::Value) -> String { let line_count = parsed .get("content") .and_then(|value| value.as_str()) - .map(|content| content.lines().count()) - .unwrap_or(0); + .map_or(0, |content| content.lines().count()); format!( "{icon} \x1b[1;32m✏️ {} {path}\x1b[0m \x1b[2m({line_count} lines)\x1b[0m", if kind == "create" { "Wrote" } else { "Updated" }, @@ -2984,7 +3054,7 @@ fn format_edit_result(icon: &str, parsed: &serde_json::Value) -> String { let path = extract_tool_path(parsed); let suffix = if parsed .get("replaceAll") - .and_then(|value| value.as_bool()) + .and_then(serde_json::Value::as_bool) .unwrap_or(false) { " (replace all)" @@ -3012,7 +3082,7 @@ fn format_edit_result(icon: &str, parsed: &serde_json::Value) -> String { fn format_glob_result(icon: &str, parsed: &serde_json::Value) -> String { let num_files = parsed .get("numFiles") - .and_then(|value| value.as_u64()) + .and_then(serde_json::Value::as_u64) .unwrap_or(0); let filenames = parsed .get("filenames") @@ -3036,11 +3106,11 @@ fn format_glob_result(icon: &str, parsed: &serde_json::Value) -> String { fn format_grep_result(icon: &str, parsed: &serde_json::Value) -> String { let num_matches = parsed .get("numMatches") - .and_then(|value| value.as_u64()) + .and_then(serde_json::Value::as_u64) .unwrap_or(0); let num_files = parsed .get("numFiles") - .and_then(|value| value.as_u64()) + .and_then(serde_json::Value::as_u64) .unwrap_or(0); let content = parsed .get("content") diff --git a/rust/crates/rusty-claude-cli/src/render.rs b/rust/crates/rusty-claude-cli/src/render.rs index 465c5a4..01751fd 100644 --- a/rust/crates/rusty-claude-cli/src/render.rs +++ b/rust/crates/rusty-claude-cli/src/render.rs @@ -286,7 +286,7 @@ impl TerminalRenderer { ) { match event { Event::Start(Tag::Heading { level, .. }) => { - self.start_heading(state, level as u8, output) + self.start_heading(state, level as u8, output); } Event::End(TagEnd::Paragraph) => output.push_str("\n\n"), Event::Start(Tag::BlockQuote(..)) => self.start_quote(state, output), @@ -426,6 +426,7 @@ impl TerminalRenderer { } } + #[allow(clippy::unused_self)] fn start_heading(&self, state: &mut RenderState, level: u8, output: &mut String) { state.heading_level = Some(level); if !output.is_empty() {