Skip to main content

caliban_plugins/
cli.rs

1//! `caliban plugin {install,list,enable,disable,remove,info,update}` impl.
2//!
3//! The CLI sub-binary delegates each subcommand into a free function that
4//! takes a configurable `Cli` context (so tests can drive it without spawning
5//! a subprocess). The binary wires these into `clap` in `caliban/src/main.rs`.
6
7use std::path::{Path, PathBuf};
8
9use crate::error::PluginError;
10use crate::manager::{PluginManager, PluginRoots, PluginSettings};
11use crate::marketplace::{MarketplaceClient, TrustDecision};
12use crate::trust::TrustStore;
13
14/// CLI execution context. Tests construct one directly; the binary
15/// constructs one from `clap` args.
16#[derive(Debug, Clone)]
17pub struct Cli {
18    /// Workspace root (used for project-scope discovery).
19    pub workspace_root: PathBuf,
20    /// User-scope install dir (where `install` and `remove` operate).
21    pub user_install_dir: PathBuf,
22    /// Trust store paths.
23    pub trust: TrustStore,
24    /// Marketplace client.
25    pub marketplace: MarketplaceClient,
26    /// Manager settings.
27    pub settings: PluginSettings,
28}
29
30/// Outcome row shown by `list`.
31#[derive(Debug, Clone)]
32pub struct ListedPlugin {
33    /// Plugin name.
34    pub name: String,
35    /// Plugin version.
36    pub version: String,
37    /// Source root.
38    pub source: String,
39    /// Enabled flag (from settings).
40    pub enabled: bool,
41    /// Counts string ("3 skills · 1 hook").
42    pub summary: String,
43}
44
45impl Cli {
46    /// `caliban plugin list` — return one row per installed plugin.
47    ///
48    /// # Errors
49    ///
50    /// Returns [`PluginError`] only on unrecoverable IO (e.g. unreadable
51    /// plugin root). Per-plugin parse errors are listed but don't fail
52    /// the call.
53    pub fn list(&self) -> Result<Vec<ListedPlugin>, PluginError> {
54        let roots = PluginRoots {
55            project: Some(self.workspace_root.join(".caliban").join("plugins")),
56            user: Some(self.user_install_dir.clone()),
57            managed: Some(crate::manager::default_managed_dir()),
58        };
59        // Use an unfiltered settings clone (clear enabled list) so list shows
60        // everything installed.
61        let mut s = self.settings.clone();
62        s.enabled = None;
63        let mgr = PluginManager::load(&roots, &s)?;
64        let mut out = Vec::new();
65        for p in mgr.loaded() {
66            let enabled = self
67                .settings
68                .enabled
69                .as_ref()
70                .is_none_or(|list| list.iter().any(|n| n == &p.manifest.name));
71            out.push(ListedPlugin {
72                name: p.manifest.name.clone(),
73                version: p.manifest.version.clone(),
74                source: p.source.as_str().to_string(),
75                enabled,
76                summary: summarize_components(p),
77            });
78        }
79        for f in mgr.failures() {
80            out.push(ListedPlugin {
81                name: f.dir_name.clone(),
82                version: "?".into(),
83                source: f.source.as_str().to_string(),
84                enabled: false,
85                summary: format!("invalid: {}", f.error),
86            });
87        }
88        Ok(out)
89    }
90
91    /// `caliban plugin info <name>` — return the manifest as JSON.
92    ///
93    /// # Errors
94    ///
95    /// [`PluginError::PluginNotFound`] when no plugin with that name is
96    /// installed.
97    pub fn info(&self, name: &str) -> Result<serde_json::Value, PluginError> {
98        let roots = PluginRoots {
99            project: Some(self.workspace_root.join(".caliban").join("plugins")),
100            user: Some(self.user_install_dir.clone()),
101            managed: Some(crate::manager::default_managed_dir()),
102        };
103        let mut s = self.settings.clone();
104        s.enabled = None;
105        let mgr = PluginManager::load(&roots, &s)?;
106        let p = mgr
107            .loaded()
108            .iter()
109            .find(|p| p.manifest.name == name)
110            .ok_or_else(|| PluginError::PluginNotFound {
111                name: name.to_string(),
112                url: "(installed)".into(),
113            })?;
114        let v = serde_json::to_value(&p.manifest).map_err(|source| PluginError::Parse {
115            path: p.root_dir.join("plugin.json"),
116            source,
117        })?;
118        Ok(v)
119    }
120
121    /// `caliban plugin remove <name>` — delete the user-scope install
122    /// directory and clear the trust record.
123    ///
124    /// # Errors
125    ///
126    /// Returns [`PluginError::Io`] on filesystem failure, or
127    /// [`PluginError::PluginNotFound`] if the plugin isn't installed.
128    pub fn remove(&mut self, name: &str) -> Result<(), PluginError> {
129        let dir = self.user_install_dir.join(name);
130        if !dir.exists() {
131            return Err(PluginError::PluginNotFound {
132                name: name.to_string(),
133                url: "(installed)".into(),
134            });
135        }
136        std::fs::remove_dir_all(&dir).map_err(|source| PluginError::Io {
137            path: dir.clone(),
138            source,
139        })?;
140        self.trust.forget(name);
141        self.trust.save()?;
142        Ok(())
143    }
144
145    /// `caliban plugin install <name>@<marketplace>` — full install flow.
146    ///
147    /// # Errors
148    ///
149    /// See [`MarketplaceClient::install`].
150    pub async fn install(
151        &mut self,
152        name: &str,
153        marketplace_url: &str,
154        desired_version: Option<&str>,
155        approve: bool,
156    ) -> Result<PathBuf, PluginError> {
157        let decision = if approve {
158            TrustDecision::Approve
159        } else {
160            TrustDecision::UseCache
161        };
162        std::fs::create_dir_all(&self.user_install_dir).map_err(|source| PluginError::Io {
163            path: self.user_install_dir.clone(),
164            source,
165        })?;
166        self.marketplace
167            .install(
168                name,
169                marketplace_url,
170                desired_version,
171                &self.user_install_dir,
172                &mut self.trust,
173                decision,
174            )
175            .await
176    }
177
178    /// `caliban plugin update <name>` — re-fetch the marketplace index and
179    /// reinstall if the remote version is newer than the local trust
180    /// record.
181    ///
182    /// # Errors
183    ///
184    /// See [`MarketplaceClient::install`].
185    pub async fn update(
186        &mut self,
187        name: &str,
188        approve: bool,
189    ) -> Result<Option<PathBuf>, PluginError> {
190        let rec = self
191            .trust
192            .get(name)
193            .cloned()
194            .ok_or_else(|| PluginError::PluginNotFound {
195                name: name.to_string(),
196                url: "(installed)".into(),
197            })?;
198        let index = self.marketplace.fetch_index(&rec.marketplace).await?;
199        let entry = index
200            .plugins
201            .iter()
202            .find(|e| e.name == name)
203            .ok_or_else(|| PluginError::PluginNotFound {
204                name: name.to_string(),
205                url: rec.marketplace.clone(),
206            })?;
207        let latest = entry.latest_version().ok_or_else(|| PluginError::Invalid {
208            path: PathBuf::from(&rec.marketplace),
209            message: format!("no version metadata for plugin '{name}'"),
210        })?;
211        if version_lte(&latest.version, &rec.version) {
212            tracing::info!(
213                target: caliban_common::tracing_targets::TARGET_PLUGINS,
214                name = name,
215                local = %rec.version,
216                remote = %latest.version,
217                "plugin update: local is up-to-date",
218            );
219            return Ok(None);
220        }
221        let path = self
222            .install(name, &rec.marketplace, Some(&latest.version), approve)
223            .await?;
224        Ok(Some(path))
225    }
226}
227
228fn version_lte(latest: &str, local: &str) -> bool {
229    match (
230        semver::Version::parse(latest),
231        semver::Version::parse(local),
232    ) {
233        (Ok(a), Ok(b)) => a <= b,
234        _ => latest == local,
235    }
236}
237
238fn summarize_components(p: &crate::loaded::LoadedPlugin) -> String {
239    let mut parts: Vec<String> = Vec::new();
240    let skills = if p.components.skills.is_empty() {
241        count_dir(&p.root_dir.join("skills"))
242    } else {
243        p.components.skills.len()
244    };
245    if skills > 0 {
246        parts.push(format!("{skills} skill{}", plural(skills)));
247    }
248    let hooks = if p.components.hooks.is_empty() {
249        usize::from(p.root_dir.join("hooks").join("hooks.json").exists())
250    } else {
251        p.components.hooks.len()
252    };
253    if hooks > 0 {
254        parts.push(format!("{hooks} hook{}", plural(hooks)));
255    }
256    let agents = if p.components.agents.is_empty() {
257        count_dir(&p.root_dir.join("agents"))
258    } else {
259        p.components.agents.len()
260    };
261    if agents > 0 {
262        parts.push(format!("{agents} agent{}", plural(agents)));
263    }
264    let styles = if p.components.output_styles.is_empty() {
265        count_dir(&p.root_dir.join("output-styles"))
266    } else {
267        p.components.output_styles.len()
268    };
269    if styles > 0 {
270        parts.push(format!("{styles} style{}", plural(styles)));
271    }
272    let mcps = if p.components.mcp_servers.is_empty() {
273        usize::from(p.root_dir.join("mcp").join(".mcp.json").exists())
274            + p.manifest.mcp_servers_inline.len()
275    } else {
276        p.components.mcp_servers.len()
277    };
278    if mcps > 0 {
279        parts.push(format!("{mcps} mcp"));
280    }
281    parts.join(" \u{00b7} ")
282}
283
284fn plural(n: usize) -> &'static str {
285    if n == 1 { "" } else { "s" }
286}
287
288fn count_dir(p: &Path) -> usize {
289    p.read_dir().map_or(0, |rd| rd.flatten().count())
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    fn make_plugin(root: &Path, name: &str) {
297        let dir = root.join(name);
298        std::fs::create_dir_all(&dir).unwrap();
299        std::fs::write(
300            dir.join("plugin.json"),
301            format!(r#"{{ "name": "{name}", "version": "0.1.0", "description": "x" }}"#),
302        )
303        .unwrap();
304    }
305
306    fn make_cli(tmp: &Path) -> Cli {
307        let user_dir = tmp.join("user");
308        let ws = tmp.join("ws");
309        std::fs::create_dir_all(&user_dir).unwrap();
310        std::fs::create_dir_all(&ws).unwrap();
311        Cli {
312            workspace_root: ws,
313            user_install_dir: user_dir,
314            trust: TrustStore::open(tmp.join("trust.json"), tmp.join("allow.json")).unwrap(),
315            marketplace: MarketplaceClient::default(),
316            settings: PluginSettings::default(),
317        }
318    }
319
320    #[test]
321    fn list_returns_installed_plugins() {
322        let tmp = tempfile::TempDir::new().unwrap();
323        let cli = make_cli(tmp.path());
324        make_plugin(&cli.user_install_dir, "demo");
325        let rows = cli.list().unwrap();
326        assert_eq!(rows.len(), 1);
327        assert_eq!(rows[0].name, "demo");
328        assert!(rows[0].enabled);
329    }
330
331    #[test]
332    fn info_returns_manifest_value() {
333        let tmp = tempfile::TempDir::new().unwrap();
334        let cli = make_cli(tmp.path());
335        make_plugin(&cli.user_install_dir, "demo");
336        let v = cli.info("demo").unwrap();
337        assert_eq!(v["name"], "demo");
338        assert_eq!(v["version"], "0.1.0");
339    }
340
341    #[test]
342    fn info_missing_plugin_errors() {
343        let tmp = tempfile::TempDir::new().unwrap();
344        let cli = make_cli(tmp.path());
345        let err = cli.info("does-not-exist").unwrap_err();
346        assert!(matches!(err, PluginError::PluginNotFound { .. }));
347    }
348
349    #[test]
350    fn remove_deletes_install_dir_and_clears_trust() {
351        let tmp = tempfile::TempDir::new().unwrap();
352        let mut cli = make_cli(tmp.path());
353        make_plugin(&cli.user_install_dir, "demo");
354        cli.trust.record(
355            "demo",
356            crate::trust::PluginTrustRecord {
357                version: "0.1.0".into(),
358                marketplace: "https://m/idx".into(),
359                manifest_sha256: "abc".into(),
360                installed_at: "now".into(),
361            },
362        );
363        cli.remove("demo").unwrap();
364        assert!(!cli.user_install_dir.join("demo").exists());
365        assert!(cli.trust.get("demo").is_none());
366    }
367
368    #[test]
369    fn list_includes_disabled_status() {
370        let tmp = tempfile::TempDir::new().unwrap();
371        let mut cli = make_cli(tmp.path());
372        make_plugin(&cli.user_install_dir, "demo");
373        make_plugin(&cli.user_install_dir, "off");
374        cli.settings.enabled = Some(vec!["demo".to_string()]);
375        let rows = cli.list().unwrap();
376        let demo = rows.iter().find(|r| r.name == "demo").unwrap();
377        let off = rows.iter().find(|r| r.name == "off").unwrap();
378        assert!(demo.enabled);
379        assert!(!off.enabled);
380    }
381
382    #[test]
383    fn list_empty_when_nothing_installed() {
384        let tmp = tempfile::TempDir::new().unwrap();
385        let cli = make_cli(tmp.path());
386        let rows = cli.list().unwrap();
387        assert!(rows.is_empty());
388    }
389
390    #[test]
391    fn list_reports_invalid_manifest_as_failure_row() {
392        let tmp = tempfile::TempDir::new().unwrap();
393        let cli = make_cli(tmp.path());
394        // A plugin dir with malformed JSON becomes a failure row, not a load.
395        let dir = cli.user_install_dir.join("broken");
396        std::fs::create_dir_all(&dir).unwrap();
397        std::fs::write(dir.join("plugin.json"), "{ not json").unwrap();
398        let rows = cli.list().unwrap();
399        assert_eq!(rows.len(), 1);
400        assert_eq!(rows[0].name, "broken");
401        assert_eq!(rows[0].version, "?");
402        assert!(!rows[0].enabled);
403        assert!(rows[0].summary.starts_with("invalid:"));
404    }
405
406    #[test]
407    fn list_discovers_project_scope_plugin() {
408        let tmp = tempfile::TempDir::new().unwrap();
409        let cli = make_cli(tmp.path());
410        let project_root = cli.workspace_root.join(".caliban").join("plugins");
411        std::fs::create_dir_all(&project_root).unwrap();
412        make_plugin(&project_root, "proj");
413        let rows = cli.list().unwrap();
414        let row = rows.iter().find(|r| r.name == "proj").unwrap();
415        assert_eq!(row.source, "project");
416    }
417
418    #[test]
419    fn list_summary_counts_skill_dirs() {
420        let tmp = tempfile::TempDir::new().unwrap();
421        let cli = make_cli(tmp.path());
422        make_plugin(&cli.user_install_dir, "demo");
423        // Auto-discovered skills/ subdirectory contributes to the summary.
424        let skills = cli.user_install_dir.join("demo").join("skills");
425        std::fs::create_dir_all(skills.join("alpha")).unwrap();
426        std::fs::create_dir_all(skills.join("beta")).unwrap();
427        let rows = cli.list().unwrap();
428        let row = rows.iter().find(|r| r.name == "demo").unwrap();
429        assert!(row.summary.contains("2 skills"), "summary={}", row.summary);
430    }
431
432    #[test]
433    fn info_serializes_full_manifest_fields() {
434        let tmp = tempfile::TempDir::new().unwrap();
435        let cli = make_cli(tmp.path());
436        let dir = cli.user_install_dir.join("rich");
437        std::fs::create_dir_all(&dir).unwrap();
438        std::fs::write(
439            dir.join("plugin.json"),
440            r#"{ "name": "rich", "version": "2.3.4", "description": "d", "author": "a", "license": "MIT" }"#,
441        )
442        .unwrap();
443        let v = cli.info("rich").unwrap();
444        assert_eq!(v["version"], "2.3.4");
445        assert_eq!(v["author"], "a");
446        assert_eq!(v["license"], "MIT");
447    }
448
449    #[test]
450    fn remove_missing_plugin_errors() {
451        let tmp = tempfile::TempDir::new().unwrap();
452        let mut cli = make_cli(tmp.path());
453        let err = cli.remove("ghost").unwrap_err();
454        assert!(matches!(err, PluginError::PluginNotFound { .. }));
455    }
456
457    #[test]
458    fn remove_without_trust_record_still_succeeds() {
459        let tmp = tempfile::TempDir::new().unwrap();
460        let mut cli = make_cli(tmp.path());
461        make_plugin(&cli.user_install_dir, "demo");
462        // No trust record recorded; forget() on a missing key is a no-op.
463        cli.remove("demo").unwrap();
464        assert!(!cli.user_install_dir.join("demo").exists());
465    }
466
467    #[tokio::test]
468    async fn update_unknown_plugin_errors_without_network() {
469        let tmp = tempfile::TempDir::new().unwrap();
470        let mut cli = make_cli(tmp.path());
471        // No trust record for "ghost" => fails before any network call.
472        let err = cli.update("ghost", false).await.unwrap_err();
473        assert!(matches!(err, PluginError::PluginNotFound { .. }));
474    }
475
476    #[test]
477    fn version_lte_semver_ordering() {
478        assert!(version_lte("1.0.0", "1.0.0"));
479        assert!(version_lte("1.0.0", "1.0.1"));
480        assert!(!version_lte("1.0.1", "1.0.0"));
481        // Non-semver falls back to string equality.
482        assert!(version_lte("nope", "nope"));
483        assert!(!version_lte("nope", "other"));
484    }
485
486    #[test]
487    fn plural_suffix() {
488        assert_eq!(plural(1), "");
489        assert_eq!(plural(0), "s");
490        assert_eq!(plural(2), "s");
491    }
492
493    #[test]
494    fn count_dir_counts_entries_and_handles_missing() {
495        let tmp = tempfile::TempDir::new().unwrap();
496        let dir = tmp.path().join("d");
497        std::fs::create_dir_all(&dir).unwrap();
498        std::fs::write(dir.join("a"), "x").unwrap();
499        std::fs::write(dir.join("b"), "y").unwrap();
500        assert_eq!(count_dir(&dir), 2);
501        assert_eq!(count_dir(&tmp.path().join("missing")), 0);
502    }
503}