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 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(®istry)?;
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 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(®istry)?;
144 self.set_enabled(plugin_id, None)?;
145 self.enabled_plugins.remove(plugin_id);
146 Ok(())
147 }
148
149 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 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 entries.push(Self::builtin_entry());
181
182 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}