Skip to main content

caliban_plugins/
manager.rs

1//! Plugin discovery + filter + namespacing.
2
3use 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/// Discovery roots, in priority order (project beats user beats managed).
12#[derive(Debug, Clone)]
13pub struct PluginRoots {
14    /// `<workspace>/.caliban/plugins/`
15    pub project: Option<PathBuf>,
16    /// `$XDG_DATA_HOME/caliban/plugins/`
17    pub user: Option<PathBuf>,
18    /// `/etc/caliban/plugins/` (Linux), `/Library/Application Support/Caliban/plugins/` (macOS).
19    pub managed: Option<PathBuf>,
20}
21
22impl PluginRoots {
23    /// Default roots derived from the workspace + XDG dirs + OS-specific
24    /// managed location.
25    #[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    /// Iterate over `(root, source)` pairs in priority order.
38    #[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/// Default OS-managed plugin root.
55#[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/// Operator-visible settings read by the manager. The settings.json keys
76/// (ADR 0026) land later; for now these are read from env vars in the
77/// binary and passed in.
78#[derive(Debug, Clone, Default)]
79pub struct PluginSettings {
80    /// When `Some`, only listed plugins are loaded. When `None`, all
81    /// discovered plugins are loaded. Managed plugins ignore this filter.
82    pub enabled: Option<Vec<String>>,
83    /// When true, non-managed plugins (project + user) are rejected.
84    /// Matches Claude Code's `strictPluginOnlyCustomization`.
85    pub strict_plugin_only_customization: bool,
86    /// Running caliban version (used for `caliban.min_version` checks).
87    pub caliban_version: Option<String>,
88}
89
90impl PluginSettings {
91    /// Build a settings struct from environment variables.
92    #[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/// Discovery + filter result.
116#[derive(Debug, Default, Clone)]
117pub struct PluginManager {
118    plugins: Vec<LoadedPlugin>,
119    /// Per-plugin parse / validation errors, surfaced in `/plugins`.
120    failures: Vec<PluginLoadFailure>,
121}
122
123/// A plugin that *was* discovered on disk but failed manifest validation.
124#[derive(Debug, Clone)]
125pub struct PluginLoadFailure {
126    /// Absolute path of the plugin dir.
127    pub root_dir: PathBuf,
128    /// Source root.
129    pub source: PluginSource,
130    /// Best-effort directory name (used as a stand-in for `name`).
131    pub dir_name: String,
132    /// Human-readable error.
133    pub error: String,
134}
135
136impl PluginManager {
137    /// Load every plugin discoverable under `roots`, applying `settings`
138    /// filters. The returned manager is safe to share read-only.
139    ///
140    /// # Errors
141    ///
142    /// Returns [`PluginError`] only for failures that can't be attributed
143    /// to a specific plugin (e.g. an unreadable parent dir). Per-plugin
144    /// errors are recorded in [`Self::failures`] and surfaced in the
145    /// `/plugins` overlay.
146    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                        // Filtered out (disabled, platform mismatch, etc.)
193                    }
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        // Platform gating.
221        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        // Min-version gating.
230        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        // Strict-plugin-only-customization: reject non-managed plugins.
249        if settings.strict_plugin_only_customization && source != PluginSource::Managed {
250            return Err(PluginError::StrictPluginOnly {
251                name: manifest.name.clone(),
252            });
253        }
254
255        // Enable list filter (managed plugins ignore it).
256        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    /// Loaded plugins, ordered alphabetically by name.
279    #[must_use]
280    pub fn loaded(&self) -> &[LoadedPlugin] {
281        &self.plugins
282    }
283
284    /// Per-plugin failures (for `/plugins` overlay).
285    #[must_use]
286    pub fn failures(&self) -> &[PluginLoadFailure] {
287        &self.failures
288    }
289
290    /// Return the union of skill discovery roots. When the manifest's
291    /// `components.skills` is set, the returned paths are the explicit
292    /// subdirectories. When unset, falls back to `<plugin>/skills/`.
293    #[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    /// Same as [`skill_roots`] for output styles. Returned paths are
307    /// *directories* containing `.md` files; if the manifest enumerated
308    /// individual files, those file paths are returned as-is.
309    #[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    /// Same as [`skill_roots`] for agents.
323    #[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    /// Merged hooks config across all loaded plugins. Each plugin's
337    /// hooks file is read, `${CALIBAN_PLUGIN_ROOT}` expanded, and the
338    /// resulting `serde_json::Value` returned in load order. The downstream
339    /// hooks loader is responsible for merging into its TOML world.
340    #[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    /// Merged MCP server configs across plugins. Inline `mcpServers` block
383    /// wins over `components.mcp_servers` when both are present (with a
384    /// warning). Each server name is namespaced `<plugin>:<server>`.
385    #[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    /// Flatten `{"mcpServers": {"a": {...}, "b": {...}}}` (Claude Code shape)
447    /// or `{"a": {...}}` (bare) into namespaced entries.
448    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        // Accept either `{"mcpServers": {...}}` or a bare object of servers.
455        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
469/// Pad a "0.5" → "0.5.0" so semver parses it.
470fn 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        // Only the managed plugin loads; user-scoped becomes a failure record.
606        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        // Partial "0.5" min_version is padded to "0.5.0"; current 1.0 >= 0.5.
753        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        // Manifest name "demo" does not match dir "wrongdir".
778        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        // Directory with no plugin.json is skipped silently.
796        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        // A plain file (not a dir) at the root level is skipped.
825        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        // Plugin with no hooks/hooks.json => no hooks config entries.
944        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        // Malformed hooks file is warned-and-skipped, not a hard error.
969        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        // Bare object (no "mcpServers" wrapper) is also accepted.
1009        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        // from_env reads only compile-time CARGO_PKG_VERSION for the version
1080        // field; enabled/strict come from process env which we don't mutate
1081        // here (hermetic). Just assert the version is populated.
1082        let s = PluginSettings::from_env();
1083        assert!(s.caliban_version.is_some());
1084    }
1085}