1use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5
6use crate::error::PluginError;
7use crate::expand;
8use crate::loaded::{LoadedPlugin, PluginSource};
9use crate::manifest::PluginManifest;
10
11#[derive(Debug, Clone)]
13pub struct PluginRoots {
14 pub project: Option<PathBuf>,
16 pub user: Option<PathBuf>,
18 pub managed: Option<PathBuf>,
20}
21
22impl PluginRoots {
23 #[must_use]
26 pub fn default_for(workspace_root: &Path) -> Self {
27 let project = Some(workspace_root.join(".caliban").join("plugins"));
28 let user = dirs::data_local_dir().map(|d| d.join("caliban").join("plugins"));
29 let managed = Some(default_managed_dir());
30 Self {
31 project,
32 user,
33 managed,
34 }
35 }
36
37 #[must_use]
39 pub fn ordered(&self) -> Vec<(PathBuf, PluginSource)> {
40 let mut out = Vec::with_capacity(3);
41 if let Some(p) = &self.project {
42 out.push((p.clone(), PluginSource::Project));
43 }
44 if let Some(p) = &self.user {
45 out.push((p.clone(), PluginSource::User));
46 }
47 if let Some(p) = &self.managed {
48 out.push((p.clone(), PluginSource::Managed));
49 }
50 out
51 }
52}
53
54#[must_use]
56pub fn default_managed_dir() -> PathBuf {
57 #[cfg(target_os = "macos")]
58 {
59 PathBuf::from("/Library/Application Support/Caliban/plugins")
60 }
61 #[cfg(target_os = "linux")]
62 {
63 PathBuf::from("/etc/caliban/plugins")
64 }
65 #[cfg(target_os = "windows")]
66 {
67 PathBuf::from(r"C:\ProgramData\Caliban\plugins")
68 }
69 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
70 {
71 PathBuf::from("/etc/caliban/plugins")
72 }
73}
74
75#[derive(Debug, Clone, Default)]
79pub struct PluginSettings {
80 pub enabled: Option<Vec<String>>,
83 pub strict_plugin_only_customization: bool,
86 pub caliban_version: Option<String>,
88}
89
90impl PluginSettings {
91 #[must_use]
93 pub fn from_env() -> Self {
94 let enabled = std::env::var("CALIBAN_ENABLED_PLUGINS").ok().map(|s| {
95 s.split(',')
96 .map(|t| t.trim().to_string())
97 .filter(|t| !t.is_empty())
98 .collect()
99 });
100 let strict = matches!(
101 std::env::var("CALIBAN_STRICT_PLUGIN_ONLY_CUSTOMIZATION")
102 .ok()
103 .as_deref(),
104 Some("1" | "true" | "TRUE" | "True" | "yes")
105 );
106 let caliban_version = option_env!("CARGO_PKG_VERSION").map(str::to_string);
107 Self {
108 enabled,
109 strict_plugin_only_customization: strict,
110 caliban_version,
111 }
112 }
113}
114
115#[derive(Debug, Default, Clone)]
117pub struct PluginManager {
118 plugins: Vec<LoadedPlugin>,
119 failures: Vec<PluginLoadFailure>,
121}
122
123#[derive(Debug, Clone)]
125pub struct PluginLoadFailure {
126 pub root_dir: PathBuf,
128 pub source: PluginSource,
130 pub dir_name: String,
132 pub error: String,
134}
135
136impl PluginManager {
137 pub fn load(roots: &PluginRoots, settings: &PluginSettings) -> Result<Self, PluginError> {
147 let mut by_name: BTreeMap<String, LoadedPlugin> = BTreeMap::new();
148 let mut failures: Vec<PluginLoadFailure> = Vec::new();
149
150 for (root, source) in roots.ordered() {
151 if !root.exists() {
152 continue;
153 }
154 let rd = match std::fs::read_dir(&root) {
155 Ok(rd) => rd,
156 Err(source_err) => {
157 return Err(PluginError::Io {
158 path: root.clone(),
159 source: source_err,
160 });
161 }
162 };
163 for entry in rd.flatten() {
164 let plug_dir = entry.path();
165 if !plug_dir.is_dir() {
166 continue;
167 }
168 let dir_name = plug_dir
169 .file_name()
170 .and_then(|s| s.to_str())
171 .unwrap_or_default()
172 .to_string();
173 let manifest_path = plug_dir.join("plugin.json");
174 if !manifest_path.exists() {
175 continue;
176 }
177 match Self::try_load_one(&plug_dir, &manifest_path, source, settings) {
178 Ok(Some(p)) => {
179 if let Some(existing) = by_name.get(&p.manifest.name) {
180 tracing::debug!(
181 target: caliban_common::tracing_targets::TARGET_PLUGINS,
182 name = %p.manifest.name,
183 shadowed_by = %existing.source.as_str(),
184 source = %p.source.as_str(),
185 "skipping shadowed plugin (already loaded from higher-priority root)",
186 );
187 } else {
188 by_name.insert(p.manifest.name.clone(), p);
189 }
190 }
191 Ok(None) => {
192 }
194 Err(e) => {
195 failures.push(PluginLoadFailure {
196 root_dir: plug_dir.clone(),
197 source,
198 dir_name,
199 error: e.to_string(),
200 });
201 }
202 }
203 }
204 }
205
206 Ok(Self {
207 plugins: by_name.into_values().collect(),
208 failures,
209 })
210 }
211
212 fn try_load_one(
213 plug_dir: &Path,
214 manifest_path: &Path,
215 source: PluginSource,
216 settings: &PluginSettings,
217 ) -> Result<Option<LoadedPlugin>, PluginError> {
218 let manifest = PluginManifest::from_path(manifest_path)?;
219 manifest.check_name_matches_dir(manifest_path)?;
220 if !manifest.platform_matches() {
222 tracing::info!(
223 target: caliban_common::tracing_targets::TARGET_PLUGINS,
224 name = %manifest.name,
225 "skipping plugin: platform mismatch",
226 );
227 return Ok(None);
228 }
229 if let (Some(min), Some(cur)) = (
231 manifest.caliban.min_version.as_deref(),
232 settings.caliban_version.as_deref(),
233 ) && let (Ok(min_v), Ok(cur_v)) = (
234 semver::Version::parse(&pad_version(min)),
235 semver::Version::parse(&pad_version(cur)),
236 ) && cur_v < min_v
237 {
238 tracing::info!(
239 target: caliban_common::tracing_targets::TARGET_PLUGINS,
240 name = %manifest.name,
241 min = %min,
242 current = %cur,
243 "skipping plugin: caliban version too old",
244 );
245 return Ok(None);
246 }
247
248 if settings.strict_plugin_only_customization && source != PluginSource::Managed {
250 return Err(PluginError::StrictPluginOnly {
251 name: manifest.name.clone(),
252 });
253 }
254
255 if source != PluginSource::Managed
257 && let Some(enabled) = settings.enabled.as_ref()
258 && !enabled.iter().any(|n| n == &manifest.name)
259 {
260 tracing::debug!(
261 target: caliban_common::tracing_targets::TARGET_PLUGINS,
262 name = %manifest.name,
263 "skipping plugin: not in CALIBAN_ENABLED_PLUGINS",
264 );
265 return Ok(None);
266 }
267
268 let components = manifest.resolved_components(plug_dir);
269 Ok(Some(LoadedPlugin {
270 namespace: manifest.name.clone(),
271 manifest,
272 root_dir: plug_dir.to_path_buf(),
273 source,
274 components,
275 }))
276 }
277
278 #[must_use]
280 pub fn loaded(&self) -> &[LoadedPlugin] {
281 &self.plugins
282 }
283
284 #[must_use]
286 pub fn failures(&self) -> &[PluginLoadFailure] {
287 &self.failures
288 }
289
290 #[must_use]
294 pub fn skill_roots(&self) -> Vec<PathBuf> {
295 let mut out = Vec::new();
296 for p in &self.plugins {
297 if p.components.skills.is_empty() {
298 out.push(p.root_dir.join("skills"));
299 } else {
300 out.extend(p.components.skills.iter().cloned());
301 }
302 }
303 out
304 }
305
306 #[must_use]
310 pub fn output_style_roots(&self) -> Vec<PathBuf> {
311 let mut out = Vec::new();
312 for p in &self.plugins {
313 if p.components.output_styles.is_empty() {
314 out.push(p.root_dir.join("output-styles"));
315 } else {
316 out.extend(p.components.output_styles.iter().cloned());
317 }
318 }
319 out
320 }
321
322 #[must_use]
324 pub fn agent_roots(&self) -> Vec<PathBuf> {
325 let mut out = Vec::new();
326 for p in &self.plugins {
327 if p.components.agents.is_empty() {
328 out.push(p.root_dir.join("agents"));
329 } else {
330 out.extend(p.components.agents.iter().cloned());
331 }
332 }
333 out
334 }
335
336 #[must_use]
341 pub fn hooks_configs(&self) -> Vec<(String, serde_json::Value)> {
342 let mut out = Vec::new();
343 for p in &self.plugins {
344 let candidates: Vec<PathBuf> = if p.components.hooks.is_empty() {
345 vec![p.root_dir.join("hooks").join("hooks.json")]
346 } else {
347 p.components.hooks.clone()
348 };
349 for path in candidates {
350 if !path.exists() {
351 continue;
352 }
353 match std::fs::read_to_string(&path) {
354 Ok(raw) => match serde_json::from_str::<serde_json::Value>(&raw) {
355 Ok(mut v) => {
356 expand::expand_json_in_place(&mut v, &p.root_dir);
357 out.push((p.namespace.clone(), v));
358 }
359 Err(e) => {
360 tracing::warn!(
361 target: caliban_common::tracing_targets::TARGET_PLUGINS,
362 path = %path.display(),
363 error = %e,
364 "skipping malformed plugin hooks.json",
365 );
366 }
367 },
368 Err(e) => {
369 tracing::warn!(
370 target: caliban_common::tracing_targets::TARGET_PLUGINS,
371 path = %path.display(),
372 error = %e,
373 "could not read plugin hooks.json",
374 );
375 }
376 }
377 }
378 }
379 out
380 }
381
382 #[must_use]
386 pub fn mcp_servers(&self) -> Vec<(String, serde_json::Value)> {
387 let mut out = Vec::new();
388 for p in &self.plugins {
389 let has_inline = !p.manifest.mcp_servers_inline.is_empty();
390 let has_external = !p.components.mcp_servers.is_empty()
391 || p.root_dir.join("mcp").join(".mcp.json").exists();
392 if has_inline && has_external {
393 tracing::warn!(
394 target: caliban_common::tracing_targets::TARGET_PLUGINS,
395 plugin = %p.namespace,
396 "both inline mcpServers and components.mcp_servers set; inline wins",
397 );
398 }
399 if has_inline {
400 for (srv_name, srv) in &p.manifest.mcp_servers_inline {
401 let key = format!("{}:{srv_name}", p.namespace);
402 let mut v = serde_json::to_value(srv).unwrap_or(serde_json::Value::Null);
403 expand::expand_json_in_place(&mut v, &p.root_dir);
404 out.push((key, v));
405 }
406 } else {
407 let candidates: Vec<PathBuf> = if p.components.mcp_servers.is_empty() {
408 let candidate = p.root_dir.join("mcp").join(".mcp.json");
409 if candidate.exists() {
410 vec![candidate]
411 } else {
412 Vec::new()
413 }
414 } else {
415 p.components.mcp_servers.clone()
416 };
417 for path in candidates {
418 if !path.exists() {
419 continue;
420 }
421 match std::fs::read_to_string(&path) {
422 Ok(raw) => match serde_json::from_str::<serde_json::Value>(&raw) {
423 Ok(v) => {
424 Self::flatten_mcp_json(&mut out, &p.namespace, &v, &p.root_dir);
425 }
426 Err(e) => tracing::warn!(
427 target: caliban_common::tracing_targets::TARGET_PLUGINS,
428 path = %path.display(),
429 error = %e,
430 "skipping malformed plugin .mcp.json",
431 ),
432 },
433 Err(e) => tracing::warn!(
434 target: caliban_common::tracing_targets::TARGET_PLUGINS,
435 path = %path.display(),
436 error = %e,
437 "could not read plugin .mcp.json",
438 ),
439 }
440 }
441 }
442 }
443 out
444 }
445
446 fn flatten_mcp_json(
449 out: &mut Vec<(String, serde_json::Value)>,
450 namespace: &str,
451 v: &serde_json::Value,
452 root: &Path,
453 ) {
454 let map = if let Some(inner) = v.get("mcpServers").and_then(|x| x.as_object()) {
456 inner.clone()
457 } else if let Some(obj) = v.as_object() {
458 obj.clone()
459 } else {
460 return;
461 };
462 for (srv_name, mut srv) in map {
463 expand::expand_json_in_place(&mut srv, root);
464 out.push((format!("{namespace}:{srv_name}"), srv));
465 }
466 }
467}
468
469fn pad_version(v: &str) -> String {
471 let parts: Vec<&str> = v.split('.').collect();
472 match parts.len() {
473 1 => format!("{}.0.0", parts[0]),
474 2 => format!("{}.{}.0", parts[0], parts[1]),
475 _ => v.to_string(),
476 }
477}
478
479#[cfg(test)]
480mod tests {
481 use super::*;
482 use std::fs;
483
484 fn make_plugin(root: &Path, name: &str, body: &str) {
485 let plug_dir = root.join(name);
486 fs::create_dir_all(&plug_dir).unwrap();
487 fs::write(plug_dir.join("plugin.json"), body).unwrap();
488 }
489
490 fn minimal(name: &str) -> String {
491 format!(r#"{{ "name": "{name}", "version": "0.1.0", "description": "x" }}"#)
492 }
493
494 #[test]
495 fn discovers_project_plugin() {
496 let tmp = tempfile::TempDir::new().unwrap();
497 let project_root = tmp.path().join(".caliban").join("plugins");
498 fs::create_dir_all(&project_root).unwrap();
499 make_plugin(&project_root, "demo", &minimal("demo"));
500 let roots = PluginRoots {
501 project: Some(project_root),
502 user: None,
503 managed: None,
504 };
505 let mgr = PluginManager::load(&roots, &PluginSettings::default()).unwrap();
506 assert_eq!(mgr.loaded().len(), 1);
507 assert_eq!(mgr.loaded()[0].source, PluginSource::Project);
508 assert_eq!(mgr.loaded()[0].namespace, "demo");
509 }
510
511 #[test]
512 fn project_shadows_user_with_same_name() {
513 let tmp = tempfile::TempDir::new().unwrap();
514 let project = tmp.path().join("project");
515 let user = tmp.path().join("user");
516 fs::create_dir_all(&project).unwrap();
517 fs::create_dir_all(&user).unwrap();
518 make_plugin(&project, "demo", &minimal("demo"));
519 make_plugin(&user, "demo", &minimal("demo"));
520 let roots = PluginRoots {
521 project: Some(project),
522 user: Some(user),
523 managed: None,
524 };
525 let mgr = PluginManager::load(&roots, &PluginSettings::default()).unwrap();
526 assert_eq!(mgr.loaded().len(), 1);
527 assert_eq!(mgr.loaded()[0].source, PluginSource::Project);
528 }
529
530 #[test]
531 fn managed_root_loads_too() {
532 let tmp = tempfile::TempDir::new().unwrap();
533 let managed = tmp.path().join("managed");
534 fs::create_dir_all(&managed).unwrap();
535 make_plugin(&managed, "policy", &minimal("policy"));
536 let roots = PluginRoots {
537 project: None,
538 user: None,
539 managed: Some(managed),
540 };
541 let mgr = PluginManager::load(&roots, &PluginSettings::default()).unwrap();
542 assert_eq!(mgr.loaded().len(), 1);
543 assert_eq!(mgr.loaded()[0].source, PluginSource::Managed);
544 }
545
546 #[test]
547 fn enabled_filter_excludes_user_plugin() {
548 let tmp = tempfile::TempDir::new().unwrap();
549 let user = tmp.path().join("user");
550 fs::create_dir_all(&user).unwrap();
551 make_plugin(&user, "demo", &minimal("demo"));
552 make_plugin(&user, "other", &minimal("other"));
553 let roots = PluginRoots {
554 project: None,
555 user: Some(user),
556 managed: None,
557 };
558 let settings = PluginSettings {
559 enabled: Some(vec!["demo".to_string()]),
560 ..Default::default()
561 };
562 let mgr = PluginManager::load(&roots, &settings).unwrap();
563 assert_eq!(mgr.loaded().len(), 1);
564 assert_eq!(mgr.loaded()[0].namespace, "demo");
565 }
566
567 #[test]
568 fn managed_ignores_enabled_filter() {
569 let tmp = tempfile::TempDir::new().unwrap();
570 let managed = tmp.path().join("managed");
571 fs::create_dir_all(&managed).unwrap();
572 make_plugin(&managed, "policy", &minimal("policy"));
573 let roots = PluginRoots {
574 project: None,
575 user: None,
576 managed: Some(managed),
577 };
578 let settings = PluginSettings {
579 enabled: Some(vec!["something-else".to_string()]),
580 ..Default::default()
581 };
582 let mgr = PluginManager::load(&roots, &settings).unwrap();
583 assert_eq!(mgr.loaded().len(), 1);
584 }
585
586 #[test]
587 fn strict_plugin_only_rejects_user_scope() {
588 let tmp = tempfile::TempDir::new().unwrap();
589 let user = tmp.path().join("user");
590 let managed = tmp.path().join("managed");
591 fs::create_dir_all(&user).unwrap();
592 fs::create_dir_all(&managed).unwrap();
593 make_plugin(&user, "demo", &minimal("demo"));
594 make_plugin(&managed, "policy", &minimal("policy"));
595 let roots = PluginRoots {
596 project: None,
597 user: Some(user),
598 managed: Some(managed),
599 };
600 let settings = PluginSettings {
601 strict_plugin_only_customization: true,
602 ..Default::default()
603 };
604 let mgr = PluginManager::load(&roots, &settings).unwrap();
605 assert_eq!(mgr.loaded().len(), 1);
607 assert_eq!(mgr.loaded()[0].namespace, "policy");
608 assert_eq!(mgr.failures().len(), 1);
609 assert!(mgr.failures()[0].error.contains("strict"));
610 }
611
612 #[test]
613 fn malformed_manifest_recorded_as_failure() {
614 let tmp = tempfile::TempDir::new().unwrap();
615 let user = tmp.path().join("user");
616 fs::create_dir_all(&user).unwrap();
617 make_plugin(&user, "demo", "{ not json");
618 let roots = PluginRoots {
619 project: None,
620 user: Some(user),
621 managed: None,
622 };
623 let mgr = PluginManager::load(&roots, &PluginSettings::default()).unwrap();
624 assert!(mgr.loaded().is_empty());
625 assert_eq!(mgr.failures().len(), 1);
626 assert_eq!(mgr.failures()[0].dir_name, "demo");
627 }
628
629 #[test]
630 fn skill_roots_returns_plugin_dirs() {
631 let tmp = tempfile::TempDir::new().unwrap();
632 let user = tmp.path().join("user");
633 fs::create_dir_all(&user).unwrap();
634 make_plugin(&user, "demo", &minimal("demo"));
635 let roots = PluginRoots {
636 project: None,
637 user: Some(user.clone()),
638 managed: None,
639 };
640 let mgr = PluginManager::load(&roots, &PluginSettings::default()).unwrap();
641 let sr = mgr.skill_roots();
642 assert_eq!(sr, vec![user.join("demo").join("skills")]);
643 }
644
645 #[test]
646 fn hooks_config_expands_caliban_plugin_root() {
647 let tmp = tempfile::TempDir::new().unwrap();
648 let user = tmp.path().join("user");
649 fs::create_dir_all(&user).unwrap();
650 let plug_dir = user.join("demo");
651 fs::create_dir_all(plug_dir.join("hooks")).unwrap();
652 fs::write(plug_dir.join("plugin.json"), minimal("demo")).unwrap();
653 fs::write(
654 plug_dir.join("hooks").join("hooks.json"),
655 r#"{ "PreToolUse": [{ "command": "${CALIBAN_PLUGIN_ROOT}/bin/run" }] }"#,
656 )
657 .unwrap();
658 let roots = PluginRoots {
659 project: None,
660 user: Some(user),
661 managed: None,
662 };
663 let mgr = PluginManager::load(&roots, &PluginSettings::default()).unwrap();
664 let hc = mgr.hooks_configs();
665 assert_eq!(hc.len(), 1);
666 let val = &hc[0].1;
667 let cmd = val["PreToolUse"][0]["command"].as_str().unwrap();
668 assert!(cmd.ends_with("/demo/bin/run"));
669 assert!(!cmd.contains("${"));
670 }
671
672 #[test]
673 fn hooks_config_honors_claude_plugin_root_alias() {
674 let tmp = tempfile::TempDir::new().unwrap();
675 let user = tmp.path().join("user");
676 let plug_dir = user.join("demo");
677 fs::create_dir_all(plug_dir.join("hooks")).unwrap();
678 fs::write(plug_dir.join("plugin.json"), minimal("demo")).unwrap();
679 fs::write(
680 plug_dir.join("hooks").join("hooks.json"),
681 r#"{ "PreToolUse": [{ "command": "${CLAUDE_PLUGIN_ROOT}/bin/run" }] }"#,
682 )
683 .unwrap();
684 let roots = PluginRoots {
685 project: None,
686 user: Some(user),
687 managed: None,
688 };
689 let mgr = PluginManager::load(&roots, &PluginSettings::default()).unwrap();
690 let hc = mgr.hooks_configs();
691 let cmd = hc[0].1["PreToolUse"][0]["command"].as_str().unwrap();
692 assert!(cmd.ends_with("/demo/bin/run"));
693 }
694
695 #[test]
696 fn mcp_inline_namespaces_servers() {
697 let tmp = tempfile::TempDir::new().unwrap();
698 let user = tmp.path().join("user");
699 let plug_dir = user.join("demo");
700 fs::create_dir_all(&plug_dir).unwrap();
701 let raw = r#"{
702 "name": "demo", "version": "0.1.0",
703 "mcpServers": {
704 "fix": { "command": "${CALIBAN_PLUGIN_ROOT}/bin/fix" }
705 }
706 }"#;
707 fs::write(plug_dir.join("plugin.json"), raw).unwrap();
708 let roots = PluginRoots {
709 project: None,
710 user: Some(user),
711 managed: None,
712 };
713 let mgr = PluginManager::load(&roots, &PluginSettings::default()).unwrap();
714 let servers = mgr.mcp_servers();
715 assert_eq!(servers.len(), 1);
716 assert_eq!(servers[0].0, "demo:fix");
717 let cmd = servers[0].1["command"].as_str().unwrap();
718 assert!(cmd.ends_with("/demo/bin/fix"));
719 }
720
721 #[test]
722 fn min_version_too_old_skips_plugin() {
723 let tmp = tempfile::TempDir::new().unwrap();
724 let user = tmp.path().join("user");
725 fs::create_dir_all(&user).unwrap();
726 let plug_dir = user.join("demo");
727 fs::create_dir_all(&plug_dir).unwrap();
728 fs::write(
729 plug_dir.join("plugin.json"),
730 r#"{ "name": "demo", "version": "0.1.0", "caliban": { "min_version": "99.0.0" } }"#,
731 )
732 .unwrap();
733 let roots = PluginRoots {
734 project: None,
735 user: Some(user),
736 managed: None,
737 };
738 let settings = PluginSettings {
739 caliban_version: Some("0.5.0".into()),
740 ..Default::default()
741 };
742 let mgr = PluginManager::load(&roots, &settings).unwrap();
743 assert!(mgr.loaded().is_empty());
744 }
745
746 #[test]
747 fn min_version_satisfied_loads_plugin() {
748 let tmp = tempfile::TempDir::new().unwrap();
749 let user = tmp.path().join("user");
750 let plug_dir = user.join("demo");
751 fs::create_dir_all(&plug_dir).unwrap();
752 fs::write(
754 plug_dir.join("plugin.json"),
755 r#"{ "name": "demo", "version": "0.1.0", "caliban": { "min_version": "0.5" } }"#,
756 )
757 .unwrap();
758 let roots = PluginRoots {
759 project: None,
760 user: Some(user),
761 managed: None,
762 };
763 let settings = PluginSettings {
764 caliban_version: Some("1.0".into()),
765 ..Default::default()
766 };
767 let mgr = PluginManager::load(&roots, &settings).unwrap();
768 assert_eq!(mgr.loaded().len(), 1);
769 }
770
771 #[test]
772 fn name_mismatch_recorded_as_failure() {
773 let tmp = tempfile::TempDir::new().unwrap();
774 let user = tmp.path().join("user");
775 let plug_dir = user.join("wrongdir");
776 fs::create_dir_all(&plug_dir).unwrap();
777 fs::write(plug_dir.join("plugin.json"), minimal("demo")).unwrap();
779 let roots = PluginRoots {
780 project: None,
781 user: Some(user),
782 managed: None,
783 };
784 let mgr = PluginManager::load(&roots, &PluginSettings::default()).unwrap();
785 assert!(mgr.loaded().is_empty());
786 assert_eq!(mgr.failures().len(), 1);
787 assert_eq!(mgr.failures()[0].dir_name, "wrongdir");
788 assert_eq!(mgr.failures()[0].source, PluginSource::User);
789 }
790
791 #[test]
792 fn dir_without_manifest_is_ignored() {
793 let tmp = tempfile::TempDir::new().unwrap();
794 let user = tmp.path().join("user");
795 fs::create_dir_all(user.join("not-a-plugin")).unwrap();
797 let roots = PluginRoots {
798 project: None,
799 user: Some(user),
800 managed: None,
801 };
802 let mgr = PluginManager::load(&roots, &PluginSettings::default()).unwrap();
803 assert!(mgr.loaded().is_empty());
804 assert!(mgr.failures().is_empty());
805 }
806
807 #[test]
808 fn nonexistent_root_is_skipped() {
809 let tmp = tempfile::TempDir::new().unwrap();
810 let roots = PluginRoots {
811 project: Some(tmp.path().join("does-not-exist")),
812 user: None,
813 managed: None,
814 };
815 let mgr = PluginManager::load(&roots, &PluginSettings::default()).unwrap();
816 assert!(mgr.loaded().is_empty());
817 }
818
819 #[test]
820 fn file_entry_in_root_is_ignored() {
821 let tmp = tempfile::TempDir::new().unwrap();
822 let user = tmp.path().join("user");
823 fs::create_dir_all(&user).unwrap();
824 fs::write(user.join("stray.txt"), "hi").unwrap();
826 make_plugin(&user, "demo", &minimal("demo"));
827 let roots = PluginRoots {
828 project: None,
829 user: Some(user),
830 managed: None,
831 };
832 let mgr = PluginManager::load(&roots, &PluginSettings::default()).unwrap();
833 assert_eq!(mgr.loaded().len(), 1);
834 }
835
836 #[test]
837 fn roots_ordered_priority() {
838 let roots = PluginRoots {
839 project: Some(PathBuf::from("/p")),
840 user: Some(PathBuf::from("/u")),
841 managed: Some(PathBuf::from("/m")),
842 };
843 let ordered = roots.ordered();
844 assert_eq!(ordered.len(), 3);
845 assert_eq!(ordered[0].1, PluginSource::Project);
846 assert_eq!(ordered[1].1, PluginSource::User);
847 assert_eq!(ordered[2].1, PluginSource::Managed);
848 }
849
850 #[test]
851 fn roots_ordered_skips_none() {
852 let roots = PluginRoots {
853 project: None,
854 user: Some(PathBuf::from("/u")),
855 managed: None,
856 };
857 let ordered = roots.ordered();
858 assert_eq!(ordered.len(), 1);
859 assert_eq!(ordered[0].1, PluginSource::User);
860 }
861
862 #[test]
863 fn default_for_populates_project_and_managed() {
864 let ws = PathBuf::from("/workspace");
865 let roots = PluginRoots::default_for(&ws);
866 assert_eq!(roots.project.unwrap(), ws.join(".caliban").join("plugins"));
867 assert!(roots.managed.is_some());
868 }
869
870 #[test]
871 fn default_managed_dir_is_nonempty() {
872 assert!(!default_managed_dir().as_os_str().is_empty());
873 }
874
875 #[test]
876 fn skill_roots_returns_explicit_subdirs_when_set() {
877 let tmp = tempfile::TempDir::new().unwrap();
878 let user = tmp.path().join("user");
879 let plug_dir = user.join("demo");
880 fs::create_dir_all(&plug_dir).unwrap();
881 fs::write(
882 plug_dir.join("plugin.json"),
883 r#"{ "name": "demo", "version": "0.1.0", "components": { "skills": ["skills/a", "skills/b"] } }"#,
884 )
885 .unwrap();
886 let roots = PluginRoots {
887 project: None,
888 user: Some(user),
889 managed: None,
890 };
891 let mgr = PluginManager::load(&roots, &PluginSettings::default()).unwrap();
892 let sr = mgr.skill_roots();
893 assert_eq!(sr.len(), 2);
894 assert!(sr[0].ends_with("skills/a"));
895 assert!(sr[1].ends_with("skills/b"));
896 }
897
898 #[test]
899 fn agent_and_output_style_roots_default_to_subdirs() {
900 let tmp = tempfile::TempDir::new().unwrap();
901 let user = tmp.path().join("user");
902 fs::create_dir_all(&user).unwrap();
903 make_plugin(&user, "demo", &minimal("demo"));
904 let roots = PluginRoots {
905 project: None,
906 user: Some(user.clone()),
907 managed: None,
908 };
909 let mgr = PluginManager::load(&roots, &PluginSettings::default()).unwrap();
910 assert_eq!(mgr.agent_roots(), vec![user.join("demo").join("agents")]);
911 assert_eq!(
912 mgr.output_style_roots(),
913 vec![user.join("demo").join("output-styles")]
914 );
915 }
916
917 #[test]
918 fn agent_and_style_roots_use_explicit_paths_when_set() {
919 let tmp = tempfile::TempDir::new().unwrap();
920 let user = tmp.path().join("user");
921 let plug_dir = user.join("demo");
922 fs::create_dir_all(&plug_dir).unwrap();
923 fs::write(
924 plug_dir.join("plugin.json"),
925 r#"{ "name": "demo", "version": "0.1.0", "components": { "agents": ["agents/x.md"], "output_styles": ["styles/y.md"] } }"#,
926 )
927 .unwrap();
928 let roots = PluginRoots {
929 project: None,
930 user: Some(user),
931 managed: None,
932 };
933 let mgr = PluginManager::load(&roots, &PluginSettings::default()).unwrap();
934 assert!(mgr.agent_roots()[0].ends_with("agents/x.md"));
935 assert!(mgr.output_style_roots()[0].ends_with("styles/y.md"));
936 }
937
938 #[test]
939 fn hooks_config_skips_missing_file() {
940 let tmp = tempfile::TempDir::new().unwrap();
941 let user = tmp.path().join("user");
942 fs::create_dir_all(&user).unwrap();
943 make_plugin(&user, "demo", &minimal("demo"));
945 let roots = PluginRoots {
946 project: None,
947 user: Some(user),
948 managed: None,
949 };
950 let mgr = PluginManager::load(&roots, &PluginSettings::default()).unwrap();
951 assert!(mgr.hooks_configs().is_empty());
952 }
953
954 #[test]
955 fn hooks_config_skips_malformed_json() {
956 let tmp = tempfile::TempDir::new().unwrap();
957 let user = tmp.path().join("user");
958 let plug_dir = user.join("demo");
959 fs::create_dir_all(plug_dir.join("hooks")).unwrap();
960 fs::write(plug_dir.join("plugin.json"), minimal("demo")).unwrap();
961 fs::write(plug_dir.join("hooks").join("hooks.json"), "{ not json").unwrap();
962 let roots = PluginRoots {
963 project: None,
964 user: Some(user),
965 managed: None,
966 };
967 let mgr = PluginManager::load(&roots, &PluginSettings::default()).unwrap();
968 assert!(mgr.hooks_configs().is_empty());
970 }
971
972 #[test]
973 fn mcp_servers_reads_external_mcp_json() {
974 let tmp = tempfile::TempDir::new().unwrap();
975 let user = tmp.path().join("user");
976 let plug_dir = user.join("demo");
977 fs::create_dir_all(plug_dir.join("mcp")).unwrap();
978 fs::write(plug_dir.join("plugin.json"), minimal("demo")).unwrap();
979 fs::write(
980 plug_dir.join("mcp").join(".mcp.json"),
981 r#"{ "mcpServers": { "srv": { "command": "${CALIBAN_PLUGIN_ROOT}/bin/x" } } }"#,
982 )
983 .unwrap();
984 let roots = PluginRoots {
985 project: None,
986 user: Some(user),
987 managed: None,
988 };
989 let mgr = PluginManager::load(&roots, &PluginSettings::default()).unwrap();
990 let servers = mgr.mcp_servers();
991 assert_eq!(servers.len(), 1);
992 assert_eq!(servers[0].0, "demo:srv");
993 assert!(
994 servers[0].1["command"]
995 .as_str()
996 .unwrap()
997 .ends_with("/bin/x")
998 );
999 }
1000
1001 #[test]
1002 fn mcp_servers_accepts_bare_object_shape() {
1003 let tmp = tempfile::TempDir::new().unwrap();
1004 let user = tmp.path().join("user");
1005 let plug_dir = user.join("demo");
1006 fs::create_dir_all(plug_dir.join("mcp")).unwrap();
1007 fs::write(plug_dir.join("plugin.json"), minimal("demo")).unwrap();
1008 fs::write(
1010 plug_dir.join("mcp").join(".mcp.json"),
1011 r#"{ "alpha": { "command": "/bin/a" }, "beta": { "command": "/bin/b" } }"#,
1012 )
1013 .unwrap();
1014 let roots = PluginRoots {
1015 project: None,
1016 user: Some(user),
1017 managed: None,
1018 };
1019 let mgr = PluginManager::load(&roots, &PluginSettings::default()).unwrap();
1020 let mut names: Vec<String> = mgr.mcp_servers().into_iter().map(|(k, _)| k).collect();
1021 names.sort();
1022 assert_eq!(names, vec!["demo:alpha".to_string(), "demo:beta".into()]);
1023 }
1024
1025 #[test]
1026 fn mcp_servers_skips_malformed_external_json() {
1027 let tmp = tempfile::TempDir::new().unwrap();
1028 let user = tmp.path().join("user");
1029 let plug_dir = user.join("demo");
1030 fs::create_dir_all(plug_dir.join("mcp")).unwrap();
1031 fs::write(plug_dir.join("plugin.json"), minimal("demo")).unwrap();
1032 fs::write(plug_dir.join("mcp").join(".mcp.json"), "{ broken").unwrap();
1033 let roots = PluginRoots {
1034 project: None,
1035 user: Some(user),
1036 managed: None,
1037 };
1038 let mgr = PluginManager::load(&roots, &PluginSettings::default()).unwrap();
1039 assert!(mgr.mcp_servers().is_empty());
1040 }
1041
1042 #[test]
1043 fn mcp_inline_wins_over_external_when_both_present() {
1044 let tmp = tempfile::TempDir::new().unwrap();
1045 let user = tmp.path().join("user");
1046 let plug_dir = user.join("demo");
1047 fs::create_dir_all(plug_dir.join("mcp")).unwrap();
1048 fs::write(
1049 plug_dir.join("plugin.json"),
1050 r#"{ "name": "demo", "version": "0.1.0", "mcpServers": { "inline": { "command": "/bin/i" } } }"#,
1051 )
1052 .unwrap();
1053 fs::write(
1054 plug_dir.join("mcp").join(".mcp.json"),
1055 r#"{ "external": { "command": "/bin/e" } }"#,
1056 )
1057 .unwrap();
1058 let roots = PluginRoots {
1059 project: None,
1060 user: Some(user),
1061 managed: None,
1062 };
1063 let mgr = PluginManager::load(&roots, &PluginSettings::default()).unwrap();
1064 let servers = mgr.mcp_servers();
1065 assert_eq!(servers.len(), 1);
1066 assert_eq!(servers[0].0, "demo:inline");
1067 }
1068
1069 #[test]
1070 fn pad_version_widens_partial_versions() {
1071 assert_eq!(pad_version("1"), "1.0.0");
1072 assert_eq!(pad_version("1.2"), "1.2.0");
1073 assert_eq!(pad_version("1.2.3"), "1.2.3");
1074 assert_eq!(pad_version("1.2.3.4"), "1.2.3.4");
1075 }
1076
1077 #[test]
1078 fn settings_from_env_returns_caliban_version() {
1079 let s = PluginSettings::from_env();
1083 assert!(s.caliban_version.is_some());
1084 }
1085}