1use 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#[derive(Debug, Clone)]
17pub struct Cli {
18 pub workspace_root: PathBuf,
20 pub user_install_dir: PathBuf,
22 pub trust: TrustStore,
24 pub marketplace: MarketplaceClient,
26 pub settings: PluginSettings,
28}
29
30#[derive(Debug, Clone)]
32pub struct ListedPlugin {
33 pub name: String,
35 pub version: String,
37 pub source: String,
39 pub enabled: bool,
41 pub summary: String,
43}
44
45impl Cli {
46 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 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 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 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 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 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 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 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 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 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 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}