Skip to main content

pleiades_agent_plugins/
manager.rs

1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use serde::{Deserialize, Serialize};
6
7use crate::manifest::PluginError;
8use crate::plugin::{Plugin, PluginDefinition, PluginKind};
9use crate::registry::{PluginEntry, PluginRegistry};
10
11const REGISTRY_FILE: &str = "installed.json";
12const SETTINGS_FILE: &str = "settings.json";
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15pub struct InstalledPluginRecord {
16    pub kind: PluginKind,
17    pub id: String,
18    pub name: String,
19    pub version: String,
20    pub description: String,
21    pub install_path: PathBuf,
22    pub installed_at_unix_ms: u128,
23    pub updated_at_unix_ms: u128,
24}
25
26#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
27pub struct InstalledPluginRegistry {
28    #[serde(default)]
29    pub plugins: BTreeMap<String, InstalledPluginRecord>,
30}
31
32#[derive(Debug, Clone)]
33pub struct InstallOutcome {
34    pub plugin_id: String,
35    pub version: String,
36    pub install_path: PathBuf,
37}
38
39#[derive(Debug, Clone)]
40pub struct UpdateOutcome {
41    pub plugin_id: String,
42    pub old_version: String,
43    pub new_version: String,
44    pub install_path: PathBuf,
45}
46
47pub struct PluginManager {
48    config_home: PathBuf,
49    enabled_plugins: BTreeMap<String, bool>,
50}
51
52impl PluginManager {
53    pub fn new(config_home: impl Into<PathBuf>) -> Self {
54        let config_home = config_home.into();
55        let enabled_plugins = Self::load_enabled_state(&config_home);
56        Self {
57            config_home,
58            enabled_plugins,
59        }
60    }
61
62    fn install_root(&self) -> PathBuf {
63        self.config_home.join("plugins").join("installed")
64    }
65
66    fn registry_path(&self) -> PathBuf {
67        self.config_home.join("plugins").join(REGISTRY_FILE)
68    }
69
70    fn settings_path(&self) -> PathBuf {
71        self.config_home.join(SETTINGS_FILE)
72    }
73
74    pub fn plugin_registry(&self) -> Result<PluginRegistry, PluginError> {
75        self.discover_plugins()
76    }
77
78    pub fn list_plugins(&self) -> Result<Vec<crate::registry::PluginSummary>, PluginError> {
79        Ok(self.plugin_registry()?.summaries())
80    }
81
82    /// Install a plugin from a local directory path.
83    pub fn install(&mut self, source: &str) -> Result<InstallOutcome, PluginError> {
84        let source_path = PathBuf::from(source);
85        if !source_path.is_dir() {
86            return Err(PluginError::NotFound(format!(
87                "plugin source `{source}` is not a valid directory"
88            )));
89        }
90
91        let manifest = crate::manifest::PluginManifest::load_from_directory(&source_path)?;
92        let plugin_id = format!("{}-external", manifest.name);
93        let install_path = self.install_root().join(&plugin_id);
94
95        if install_path.exists() {
96            std::fs::remove_dir_all(&install_path)?;
97        }
98        std::fs::create_dir_all(&install_path)?;
99        copy_dir_recursive(&source_path, &install_path)?;
100
101        let now = unix_ms();
102        let record = InstalledPluginRecord {
103            kind: PluginKind::External,
104            id: plugin_id.clone(),
105            name: manifest.name,
106            version: manifest.version.clone(),
107            description: manifest.description,
108            install_path: install_path.clone(),
109            installed_at_unix_ms: now,
110            updated_at_unix_ms: now,
111        };
112
113        let mut registry = self.load_registry()?;
114        registry.plugins.insert(plugin_id.clone(), record);
115        self.store_registry(&registry)?;
116        self.set_enabled(&plugin_id, Some(true))?;
117        self.enabled_plugins.insert(plugin_id.clone(), true);
118
119        Ok(InstallOutcome {
120            plugin_id,
121            version: manifest.version,
122            install_path,
123        })
124    }
125
126    /// Uninstall a plugin.
127    pub fn uninstall(&mut self, plugin_id: &str) -> Result<(), PluginError> {
128        let mut registry = self.load_registry()?;
129        let record = registry.plugins.remove(plugin_id).ok_or_else(|| {
130            PluginError::NotFound(format!("plugin `{plugin_id}` is not installed"))
131        })?;
132
133        if record.kind == PluginKind::Bundled {
134            registry.plugins.insert(plugin_id.to_string(), record);
135            return Err(PluginError::CommandFailed(format!(
136                "plugin `{plugin_id}` is bundled; disable it instead"
137            )));
138        }
139
140        if record.install_path.exists() {
141            std::fs::remove_dir_all(&record.install_path)?;
142        }
143        self.store_registry(&registry)?;
144        self.set_enabled(plugin_id, None)?;
145        self.enabled_plugins.remove(plugin_id);
146        Ok(())
147    }
148
149    /// Enable a plugin.
150    pub fn enable(&mut self, plugin_id: &str) -> Result<(), PluginError> {
151        self.ensure_known(plugin_id)?;
152        self.set_enabled(plugin_id, Some(true))?;
153        self.enabled_plugins.insert(plugin_id.to_string(), true);
154        Ok(())
155    }
156
157    /// Disable a plugin.
158    pub fn disable(&mut self, plugin_id: &str) -> Result<(), PluginError> {
159        self.ensure_known(plugin_id)?;
160        self.set_enabled(plugin_id, Some(false))?;
161        self.enabled_plugins.insert(plugin_id.to_string(), false);
162        Ok(())
163    }
164
165    fn ensure_known(&self, plugin_id: &str) -> Result<(), PluginError> {
166        if self.plugin_registry()?.contains(plugin_id) {
167            Ok(())
168        } else {
169            Err(PluginError::NotFound(format!(
170                "plugin `{plugin_id}` is not installed"
171            )))
172        }
173    }
174
175    fn discover_plugins(&self) -> Result<PluginRegistry, PluginError> {
176        let registry = self.load_registry()?;
177        let mut entries = Vec::new();
178
179        // Discover builtin plugins
180        entries.push(Self::builtin_entry());
181
182        // Discover installed plugins
183        for record in registry.plugins.values() {
184            if !record.install_path.exists() {
185                continue;
186            }
187            match PluginDefinition::load_from_directory(
188                &record.install_path,
189                record.kind,
190                record.install_path.display().to_string(),
191                record.kind.marketplace(),
192            ) {
193                Ok(def) => {
194                    let enabled = self.is_enabled(def.metadata());
195                    entries.push(PluginEntry {
196                        definition: def,
197                        enabled,
198                    });
199                }
200                Err(e) => {
201                    tracing::warn!(
202                        "Failed to load plugin `{}`: {e}",
203                        record.install_path.display()
204                    );
205                }
206            }
207        }
208
209        Ok(PluginRegistry::new(entries))
210    }
211
212    fn builtin_entry() -> PluginEntry {
213        let metadata = crate::plugin::PluginMetadata {
214            id: "pleiades-agent-core-builtin".to_string(),
215            name: "pleiades-agent-core".to_string(),
216            version: env!("CARGO_PKG_VERSION").to_string(),
217            description: "Core Pleiades built-in capabilities".to_string(),
218            kind: PluginKind::Builtin,
219            source: "builtin".to_string(),
220            default_enabled: true,
221            root: None,
222        };
223        PluginEntry {
224            definition: PluginDefinition::Builtin(crate::plugin::BuiltinPlugin {
225                metadata,
226                hooks: crate::manifest::PluginHooks::default(),
227                lifecycle: crate::manifest::PluginLifecycle::default(),
228                tools: Vec::new(),
229            }),
230            enabled: true,
231        }
232    }
233
234    fn is_enabled(&self, metadata: &crate::plugin::PluginMetadata) -> bool {
235        self.enabled_plugins
236            .get(&metadata.id)
237            .copied()
238            .unwrap_or(match metadata.kind {
239                PluginKind::External => false,
240                PluginKind::Builtin | PluginKind::Bundled => metadata.default_enabled,
241            })
242    }
243
244    fn load_registry(&self) -> Result<InstalledPluginRegistry, PluginError> {
245        let path = self.registry_path();
246        match std::fs::read_to_string(&path) {
247            Ok(content) if content.trim().is_empty() => Ok(InstalledPluginRegistry::default()),
248            Ok(content) => Ok(serde_json::from_str(&content)?),
249            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
250                Ok(InstalledPluginRegistry::default())
251            }
252            Err(e) => Err(PluginError::Io(e)),
253        }
254    }
255
256    fn store_registry(&self, registry: &InstalledPluginRegistry) -> Result<(), PluginError> {
257        let path = self.registry_path();
258        if let Some(parent) = path.parent() {
259            std::fs::create_dir_all(parent)?;
260        }
261        std::fs::write(&path, serde_json::to_string_pretty(registry)?)?;
262        Ok(())
263    }
264
265    fn set_enabled(&self, plugin_id: &str, enabled: Option<bool>) -> Result<(), PluginError> {
266        let path = self.settings_path();
267        let mut settings: serde_json::Value = std::fs::read_to_string(&path)
268            .ok()
269            .and_then(|c| serde_json::from_str(&c).ok())
270            .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new()));
271
272        let enabled_plugins = settings
273            .as_object_mut()
274            .unwrap()
275            .entry("enabledPlugins")
276            .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()))
277            .as_object_mut()
278            .unwrap();
279
280        match enabled {
281            Some(val) => {
282                enabled_plugins.insert(plugin_id.to_string(), serde_json::json!(val));
283            }
284            None => {
285                enabled_plugins.remove(plugin_id);
286            }
287        }
288
289        if let Some(parent) = path.parent() {
290            std::fs::create_dir_all(parent)?;
291        }
292        std::fs::write(&path, serde_json::to_string_pretty(&settings)?)?;
293        Ok(())
294    }
295
296    fn load_enabled_state(config_home: &Path) -> BTreeMap<String, bool> {
297        let path = config_home.join(SETTINGS_FILE);
298        let content = match std::fs::read_to_string(&path) {
299            Ok(c) => c,
300            Err(_) => return BTreeMap::new(),
301        };
302        let settings: serde_json::Value = match serde_json::from_str(&content) {
303            Ok(v) => v,
304            Err(_) => return BTreeMap::new(),
305        };
306        let mut map = BTreeMap::new();
307        if let Some(enabled) = settings.get("enabledPlugins").and_then(|v| v.as_object()) {
308            for (id, val) in enabled {
309                if let Some(state) = val.as_bool() {
310                    map.insert(id.clone(), state);
311                }
312            }
313        }
314        map
315    }
316}
317
318fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), std::io::Error> {
319    for entry in walkdir::WalkDir::new(src).min_depth(1) {
320        let entry = entry?;
321        let relative = entry.path().strip_prefix(src).unwrap();
322        let target = dst.join(relative);
323        if entry.file_type().is_dir() {
324            std::fs::create_dir_all(&target)?;
325        } else {
326            std::fs::copy(entry.path(), &target)?;
327        }
328    }
329    Ok(())
330}
331
332fn unix_ms() -> u128 {
333    SystemTime::now()
334        .duration_since(UNIX_EPOCH)
335        .unwrap_or_default()
336        .as_millis()
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342    use tempfile::TempDir;
343
344    fn write_test_plugin(dir: &Path, name: &str, enabled: Option<bool>) {
345        let plugin_dir = dir.join(name);
346        std::fs::create_dir_all(plugin_dir.join(".pleiades-plugin")).unwrap();
347        std::fs::create_dir_all(plugin_dir.join("hooks")).unwrap();
348        std::fs::write(
349            plugin_dir.join("hooks").join("pre.sh"),
350            "#!/bin/sh\nprintf 'ok'\n",
351        )
352        .unwrap();
353        #[cfg(unix)]
354        {
355            use std::os::unix::fs::PermissionsExt;
356            std::fs::set_permissions(
357                plugin_dir.join("hooks").join("pre.sh"),
358                std::fs::Permissions::from_mode(0o755),
359            )
360            .ok();
361        }
362        std::fs::write(
363            plugin_dir.join(".pleiades-plugin").join("plugin.json"),
364            serde_json::to_string_pretty(&serde_json::json!({
365                "name": name,
366                "version": "1.0.0",
367                "description": format!("test plugin {}", name),
368                "defaultEnabled": enabled.unwrap_or(false),
369                "hooks": {
370                    "PreToolUse": ["./hooks/pre.sh"]
371                }
372            }))
373            .unwrap(),
374        )
375        .unwrap();
376    }
377
378    #[test]
379    fn install_and_list_plugin() {
380        let tmp = TempDir::new().unwrap();
381        let config_home = tmp.path().join("config");
382        write_test_plugin(tmp.path(), "my-plugin", Some(true));
383
384        let mut manager = PluginManager::new(&config_home);
385        let outcome = manager
386            .install(tmp.path().join("my-plugin").to_str().unwrap())
387            .expect("install should succeed");
388
389        assert_eq!(outcome.plugin_id, "my-plugin-external");
390
391        let plugins = manager.list_plugins().expect("list should succeed");
392        let plugin = plugins.iter().find(|p| p.id == outcome.plugin_id);
393        assert!(plugin.is_some());
394        let p = plugin.unwrap();
395        assert_eq!(p.name, "my-plugin");
396        assert!(p.enabled);
397    }
398
399    #[test]
400    fn enable_disable_plugin() {
401        let tmp = TempDir::new().unwrap();
402        let config_home = tmp.path().join("config");
403        write_test_plugin(tmp.path(), "test-plugin", Some(false));
404
405        let mut manager = PluginManager::new(&config_home);
406        manager
407            .install(tmp.path().join("test-plugin").to_str().unwrap())
408            .expect("install should succeed");
409
410        let plugin_id = "test-plugin-external";
411
412        manager.disable(plugin_id).expect("disable should succeed");
413        let plugins = manager.list_plugins().expect("list should succeed");
414        let p = plugins.iter().find(|p| p.id == plugin_id).unwrap();
415        assert!(!p.enabled);
416
417        manager.enable(plugin_id).expect("enable should succeed");
418        let plugins = manager.list_plugins().expect("list should succeed");
419        let p = plugins.iter().find(|p| p.id == plugin_id).unwrap();
420        assert!(p.enabled);
421    }
422
423    #[test]
424    fn uninstall_removes_plugin() {
425        let tmp = TempDir::new().unwrap();
426        let config_home = tmp.path().join("config");
427        write_test_plugin(tmp.path(), "remove-me", None);
428
429        let mut manager = PluginManager::new(&config_home);
430        manager
431            .install(tmp.path().join("remove-me").to_str().unwrap())
432            .expect("install should succeed");
433
434        let plugin_id = "remove-me-external";
435        assert!(manager.plugin_registry().unwrap().contains(plugin_id));
436
437        manager
438            .uninstall(plugin_id)
439            .expect("uninstall should succeed");
440        assert!(!manager.plugin_registry().unwrap().contains(plugin_id));
441    }
442
443    #[test]
444    fn plugin_registry_aggregates_hooks() {
445        let tmp = TempDir::new().unwrap();
446        let config_home = tmp.path().join("config");
447        write_test_plugin(tmp.path(), "alpha", Some(true));
448        write_test_plugin(tmp.path(), "beta", Some(true));
449
450        let mut manager = PluginManager::new(&config_home);
451        manager
452            .install(tmp.path().join("alpha").to_str().unwrap())
453            .expect("install alpha");
454        manager
455            .install(tmp.path().join("beta").to_str().unwrap())
456            .expect("install beta");
457
458        let registry = manager.plugin_registry().expect("registry");
459        let hooks = registry.aggregated_hooks();
460        assert_eq!(hooks.pre_tool_use.len(), 2);
461    }
462}