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}