1use super::*;
2
3use roboticus_plugin_sdk::manifest::PluginManifest;
4use sha2::{Digest, Sha256};
5
6enum InstallSource {
9 Directory(std::path::PathBuf),
11 Archive(std::path::PathBuf),
13 Catalog(String),
15}
16
17fn detect_source(source: &str) -> InstallSource {
18 let path = std::path::Path::new(source);
19 let has_path_sep = source.contains('/') || source.contains('\\');
20 let is_zip = path.extension().and_then(|e| e.to_str()) == Some("zip");
21
22 if is_zip {
23 InstallSource::Archive(path.to_path_buf())
25 } else if has_path_sep || path.exists() {
26 InstallSource::Directory(path.to_path_buf())
28 } else {
29 InstallSource::Catalog(source.to_string())
31 }
32}
33
34pub async fn cmd_plugins_list(
37 base_url: &str,
38 json: bool,
39) -> Result<(), Box<dyn std::error::Error>> {
40 let resp = super::http_client()?
41 .get(format!("{base_url}/api/plugins"))
42 .send()
43 .await?;
44 let body: serde_json::Value = resp.json().await?;
45 if json {
46 println!("{}", serde_json::to_string_pretty(&body)?);
47 return Ok(());
48 }
49
50 let plugins = body
51 .get("plugins")
52 .and_then(|v| v.as_array())
53 .cloned()
54 .unwrap_or_default();
55
56 if plugins.is_empty() {
57 println!("\n No plugins installed.\n");
58 return Ok(());
59 }
60
61 println!(
62 "\n {:<20} {:<10} {:<10} {:<10}",
63 "Plugin", "Version", "Status", "Tools"
64 );
65 println!(" {}", "─".repeat(55));
66 for p in &plugins {
67 let name = p.get("name").and_then(|v| v.as_str()).unwrap_or("?");
68 let version = p.get("version").and_then(|v| v.as_str()).unwrap_or("?");
69 let status = p.get("status").and_then(|v| v.as_str()).unwrap_or("?");
70 let tools = p
71 .get("tools")
72 .and_then(|v| v.as_array())
73 .map(|a| a.len())
74 .unwrap_or(0);
75 println!(
76 " {:<20} {:<10} {:<10} {:<10}",
77 name, version, status, tools
78 );
79 }
80 println!();
81 Ok(())
82}
83
84pub async fn cmd_plugin_info(
87 base_url: &str,
88 name: &str,
89 json: bool,
90) -> Result<(), Box<dyn std::error::Error>> {
91 let (_dim, bold, _accent, green, yellow, red, _cyan, reset, _mono) = colors();
92 let (ok, _action, _warn, _detail, _err_icon) = icons();
93 let resp = super::http_client()?
94 .get(format!("{base_url}/api/plugins"))
95 .send()
96 .await?;
97 let body: serde_json::Value = resp.json().await.unwrap_or_else(|e| {
98 tracing::warn!("failed to parse plugin info response: {e}");
99 serde_json::Value::default()
100 });
101 if json {
102 println!("{}", serde_json::to_string_pretty(&body)?);
103 return Ok(());
104 }
105 let plugins: Vec<serde_json::Value> = body
106 .get("plugins")
107 .and_then(|v| v.as_array())
108 .cloned()
109 .unwrap_or_default();
110
111 let plugin = plugins
112 .iter()
113 .find(|p| p.get("name").and_then(|v| v.as_str()) == Some(name));
114
115 match plugin {
116 Some(p) => {
117 println!("\n {bold}Plugin: {name}{reset}\n");
118 if let Some(v) = p.get("version").and_then(|v| v.as_str()) {
119 println!(" Version: {v}");
120 }
121 if let Some(d) = p.get("description").and_then(|v| v.as_str()) {
122 println!(" Description: {d}");
123 }
124 let status = p
125 .get("status")
126 .and_then(|v| v.as_str())
127 .map(|s| s.to_ascii_lowercase())
128 .or_else(|| {
129 p.get("enabled").and_then(|v| v.as_bool()).map(|b| {
130 if b {
131 "active".to_string()
132 } else {
133 "disabled".to_string()
134 }
135 })
136 })
137 .unwrap_or_else(|| "unknown".to_string());
138 println!(
139 " Status: {}",
140 if status == "active" || status == "loaded" {
141 format!("{green}{status}{reset}")
142 } else if status == "disabled" || status == "error" {
143 format!("{red}{status}{reset}")
144 } else {
145 format!("{yellow}{status}{reset}")
146 }
147 );
148 if let Some(path) = p.get("manifest_path").and_then(|v| v.as_str()) {
149 println!(" Manifest: {path}");
150 }
151 if let Some(tools) = p.get("tools").and_then(|v| v.as_array()) {
152 println!(" Tools: {}", tools.len());
153 for tool in tools {
154 if let Some(tn) = tool.get("name").and_then(|v| v.as_str()) {
155 println!(" {ok} {tn}");
156 }
157 }
158 }
159 println!();
160 }
161 None => {
162 eprintln!(" Plugin not found: {name}");
163 return Err(format!("plugin not found: {name}").into());
164 }
165 }
166 Ok(())
167}
168
169fn copy_dir_recursive(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> {
172 if !dst.exists() {
173 std::fs::create_dir_all(dst)?;
174 }
175 for entry in std::fs::read_dir(src)? {
176 let entry = entry?;
177 let ty = entry.file_type()?;
178 if ty.is_symlink() {
179 continue;
180 }
181 let dest_path = dst.join(entry.file_name());
182 if ty.is_dir() {
183 copy_dir_recursive(&entry.path(), &dest_path)?;
184 } else if ty.is_file() {
185 std::fs::copy(entry.path(), &dest_path)?;
186 }
187 }
188 Ok(())
189}
190
191pub(crate) fn companion_skill_install_name(plugin_name: &str, skill_rel: &str) -> String {
192 let skill_filename = std::path::Path::new(skill_rel)
193 .file_name()
194 .unwrap_or_default()
195 .to_string_lossy();
196 let hash = Sha256::digest(skill_rel.as_bytes());
197 let short = hex::encode(&hash[..6]);
198 format!("{plugin_name}--{short}--{skill_filename}")
199}
200
201fn check_requirements(manifest: &PluginManifest) -> bool {
202 let (_dim, bold, _accent, green, yellow, red, cyan, reset, _mono) = colors();
203 let (ok, action, warn, _detail, err_icon) = icons();
204
205 if manifest.requirements.is_empty() {
206 return true;
207 }
208
209 println!(
210 "\n {action} Checking requirements for {bold}{}{reset}...\n",
211 manifest.name
212 );
213 let results = manifest.check_requirements();
214 let mut has_missing_required = false;
215
216 for (req, found) in &results {
217 if *found {
218 println!(
219 " {ok} {green}{}{reset} ({}) — found",
220 req.name, req.command
221 );
222 } else if req.optional {
223 println!(
224 " {warn} {yellow}{}{reset} ({}) — not found (optional)",
225 req.name, req.command
226 );
227 } else {
228 has_missing_required = true;
229 println!(
230 " {err_icon} {red}{}{reset} ({}) — not found",
231 req.name, req.command
232 );
233 if let Some(hint) = &req.install_hint {
234 println!(" Install: {cyan}{hint}{reset}");
235 }
236 }
237 }
238 println!();
239
240 if has_missing_required {
241 eprintln!(
242 " {err_icon} Cannot install {}: missing required dependencies.",
243 manifest.name
244 );
245 eprintln!(" Install the missing requirements above and try again.\n");
246 return false;
247 }
248 true
249}
250
251fn check_companion_skills_exist(manifest: &PluginManifest, source_dir: &std::path::Path) -> bool {
252 let (_dim, _bold, _accent, _green, _yellow, _red, _cyan, _reset, _mono) = colors();
253 let (_ok, _action, _warn, _detail, err_icon) = icons();
254
255 for skill_path in &manifest.companion_skills {
256 let full = source_dir.join(skill_path);
257 if !full.exists() {
258 eprintln!(" {err_icon} Companion skill not found in bundle: {skill_path}");
259 return false;
260 }
261 }
262 true
263}
264
265fn check_not_installed(plugin_name: &str) -> Result<std::path::PathBuf, ()> {
266 let roboticus_dir = roboticus_core::home_dir().join(".roboticus");
267 let plugins_dir = roboticus_dir.join("plugins");
268 let dest = plugins_dir.join(plugin_name);
269
270 if dest.exists() {
271 eprintln!(" Plugin already installed: {plugin_name}");
272 eprintln!(" Uninstall first with: roboticus plugins uninstall {plugin_name}");
273 return Err(());
274 }
275 Ok(dest)
276}
277
278fn deploy_companion_skills(
279 manifest: &PluginManifest,
280 source_dir: &std::path::Path,
281) -> Result<(), Box<dyn std::error::Error>> {
282 let (ok, _action, _warn, _detail, _err_icon) = icons();
283 if manifest.companion_skills.is_empty() {
284 return Ok(());
285 }
286
287 let roboticus_dir = roboticus_core::home_dir().join(".roboticus");
288 let skills_dir = roboticus_dir.join("skills");
289 std::fs::create_dir_all(&skills_dir)?;
290
291 let mut installed = Vec::new();
292 for skill_rel in &manifest.companion_skills {
293 let src_skill = source_dir.join(skill_rel);
294 let installed_name = companion_skill_install_name(&manifest.name, skill_rel);
295 let dest_skill = skills_dir.join(&installed_name);
296
297 if let Err(e) = std::fs::copy(&src_skill, &dest_skill) {
298 for path in installed.iter().rev() {
300 let _ = std::fs::remove_file(path);
301 }
302 return Err(Box::new(e));
303 }
304 installed.push(dest_skill);
305 println!(" {ok} Installed companion skill: {installed_name}");
306 }
307 Ok(())
308}
309
310fn print_plugin_summary(manifest: &PluginManifest, source_label: &str) {
311 let (_dim, bold, _accent, green, _yellow, _red, _cyan, reset, _mono) = colors();
312 let (ok, _action, _warn, _detail, _err_icon) = icons();
313
314 println!("\n {ok} Installed plugin: {bold}{}{reset}", manifest.name);
315 println!(" Version: {green}{}{reset}", manifest.version);
316 if !manifest.description.is_empty() {
317 println!(" {}", truncate_str(&manifest.description, 72));
318 }
319 println!(" Source: {source_label}");
320 println!(" Restart the server to activate.\n");
321}
322
323fn truncate_str(s: &str, max: usize) -> String {
324 if max == 0 {
325 return String::new();
326 }
327 if s.len() <= max {
328 return s.to_string();
329 }
330 let end = s
332 .char_indices()
333 .map(|(i, _)| i)
334 .take(max)
335 .last()
336 .unwrap_or(0);
337 format!("{}…", &s[..end])
338}
339
340fn prompt_yes_no(prompt: &str) -> bool {
341 use std::io::Write;
342 print!(" {prompt} [y/N] ");
343 std::io::stdout().flush().ok();
344 let mut input = String::new();
345 if std::io::stdin().read_line(&mut input).is_err() {
346 return false;
347 }
348 matches!(input.trim().to_ascii_lowercase().as_str(), "y" | "yes")
349}
350
351pub async fn cmd_plugin_install(source: &str) -> Result<(), Box<dyn std::error::Error>> {
354 match detect_source(source) {
355 InstallSource::Directory(path) => install_from_directory(&path),
356 InstallSource::Archive(path) => install_from_archive(&path),
357 InstallSource::Catalog(name) => install_from_catalog(&name).await,
358 }
359}
360
361fn install_from_directory(source_path: &std::path::Path) -> Result<(), Box<dyn std::error::Error>> {
364 let (_dim, _bold, _accent, _green, _yellow, _red, _cyan, _reset, _mono) = colors();
365 let (_ok, _action, warn, _detail, err_icon) = icons();
366
367 if !source_path.exists() {
368 return Err(format!("source not found: {}", source_path.display()).into());
369 }
370
371 let manifest_path = source_path.join("plugin.toml");
372 if !manifest_path.exists() {
373 return Err(format!("no plugin.toml found in {}", source_path.display()).into());
374 }
375
376 let manifest = PluginManifest::from_file(&manifest_path)
377 .map_err(|e| format!("Invalid plugin.toml: {e}"))?;
378
379 let report = manifest.vet(source_path);
381 for w in &report.warnings {
382 eprintln!(" {warn} {w}");
383 }
384 if !report.is_ok() {
385 for e in &report.errors {
386 eprintln!(" {err_icon} {e}");
387 }
388 eprintln!("\n {err_icon} Plugin failed vetting. Fix errors above before installing.\n");
389 return Err("plugin vetting failed".into());
390 }
391
392 if !check_requirements(&manifest) {
393 return Err("missing required plugin dependencies".into());
394 }
395 if !check_companion_skills_exist(&manifest, source_path) {
396 return Err("companion skill files missing from plugin bundle".into());
397 }
398 let dest = match check_not_installed(&manifest.name) {
399 Ok(d) => d,
400 Err(()) => return Err(format!("plugin '{}' already installed", manifest.name).into()),
401 };
402
403 std::fs::create_dir_all(&dest)?;
404 if let Err(e) = copy_dir_recursive(source_path, &dest) {
405 let _ = std::fs::remove_dir_all(&dest);
407 return Err(Box::new(e));
408 }
409
410 if let Err(e) = deploy_companion_skills(&manifest, source_path) {
411 let _ = std::fs::remove_dir_all(&dest);
413 return Err(e);
414 }
415 print_plugin_summary(&manifest, &format!("directory: {}", source_path.display()));
416 Ok(())
417}
418
419fn install_from_archive(archive_path: &std::path::Path) -> Result<(), Box<dyn std::error::Error>> {
422 use roboticus_plugin_sdk::archive;
423
424 let (_dim, bold, _accent, green, _yellow, _red, cyan, reset, _mono) = colors();
425 let (ok, action, _warn, _detail, err_icon) = icons();
426
427 if !archive_path.exists() {
428 return Err(format!("archive not found: {}", archive_path.display()).into());
429 }
430
431 println!("\n {action} Unpacking {}...", archive_path.display());
432
433 let staging_dir = roboticus_core::home_dir()
435 .join(".roboticus")
436 .join("staging");
437 std::fs::create_dir_all(&staging_dir)?;
438
439 let result = archive::unpack(archive_path, &staging_dir)
440 .map_err(|e| format!("Failed to unpack archive: {e}"))?;
441
442 println!(
443 " {ok} Unpacked {bold}{}{reset} v{green}{}{reset} ({} files)",
444 result.manifest.name, result.manifest.version, result.file_count
445 );
446 println!(" {ok} SHA-256: {cyan}{}{reset}", &result.sha256[..16]);
447
448 if !check_requirements(&result.manifest) {
450 let _ = std::fs::remove_dir_all(&result.dest_dir);
452 return Err("missing required plugin dependencies".into());
453 }
454
455 let dest = match check_not_installed(&result.manifest.name) {
457 Ok(d) => d,
458 Err(()) => {
459 let _ = std::fs::remove_dir_all(&result.dest_dir);
461 return Err(format!("plugin '{}' already installed", result.manifest.name).into());
462 }
463 };
464
465 if !prompt_yes_no(&format!(
467 "Install {} v{}?",
468 result.manifest.name, result.manifest.version
469 )) {
470 println!(" Cancelled.");
471 let _ = std::fs::remove_dir_all(&result.dest_dir);
472 return Ok(());
473 }
474
475 std::fs::create_dir_all(dest.parent().unwrap_or(&dest))?;
477 std::fs::rename(&result.dest_dir, &dest).or_else(|_| {
478 if let Err(e) = copy_dir_recursive(&result.dest_dir, &dest) {
480 let _ = std::fs::remove_dir_all(&dest);
481 return Err(e);
482 }
483 std::fs::remove_dir_all(&result.dest_dir)
484 })?;
485
486 if let Err(e) = deploy_companion_skills(&result.manifest, &dest) {
487 if let Err(clean_err) = std::fs::remove_dir_all(&dest) {
489 eprintln!(
490 " {err_icon} Companion skill deployment failed and rollback also failed: {clean_err}"
491 );
492 }
493 return Err(e);
494 }
495 print_plugin_summary(
496 &result.manifest,
497 &format!("archive: {}", archive_path.display()),
498 );
499 Ok(())
500}
501
502async fn install_from_catalog(name: &str) -> Result<(), Box<dyn std::error::Error>> {
505 use crate::cli::update;
506 use roboticus_plugin_sdk::archive;
507
508 let (_dim, bold, _accent, green, _yellow, red, cyan, reset, _mono) = colors();
509 let (ok, action, _warn, _detail, err_icon) = icons();
510
511 println!("\n {action} Searching catalog for {bold}{name}{reset}...");
512
513 let config_path = roboticus_core::config::resolve_config_path(None);
515 let config_str = config_path
516 .as_ref()
517 .map(|p| p.to_string_lossy().to_string())
518 .unwrap_or_default();
519 let registry_url = update::resolve_registry_url(None, &config_str);
520 let client = super::http_client()?;
521 let manifest = update::fetch_manifest(&client, ®istry_url).await?;
522
523 let catalog = manifest.packs.plugins.as_ref();
524 let catalog = match catalog {
525 Some(c) if !c.catalog.is_empty() => c,
526 _ => {
527 println!(
528 " The plugin catalog is empty. No plugins are available for installation yet.\n\
529 \n Plugins can be installed from a local archive instead:\n\
530 \n roboticus plugins install --path ./my-plugin.ic.zip\n"
531 );
532 return Ok(());
533 }
534 };
535
536 let entry = catalog
537 .find(name)
538 .ok_or_else(|| format!("Plugin '{name}' not found in catalog. Run `roboticus plugins search` to list available plugins."))?;
539
540 println!(
541 " {ok} Found: {bold}{}{reset} v{green}{}{reset}",
542 entry.name, entry.version
543 );
544 println!(" {}", truncate_str(&entry.description, 72));
545 println!(" Author: {}", entry.author);
546 println!(" Tier: {}", entry.tier);
547
548 if check_not_installed(&entry.name).is_err() {
550 return Err(format!("plugin '{}' already installed", entry.name).into());
551 }
552
553 if !prompt_yes_no(&format!(
555 "Download and install {} v{}?",
556 entry.name, entry.version
557 )) {
558 println!(" Cancelled.");
559 return Ok(());
560 }
561
562 let base_url = update::registry_base_url(®istry_url);
564 let archive_url = format!("{base_url}/{}", entry.path);
565
566 let client = super::http_client()?;
567 let resp = super::spin_while(
568 &format!("Downloading {}", entry.path),
569 client.get(&archive_url).send(),
570 )
571 .await?;
572
573 if !resp.status().is_success() {
574 return Err(format!("download failed: HTTP {}", resp.status()).into());
575 }
576
577 let bytes = super::spin_while("Receiving bytes", resp.bytes()).await?;
578 println!(" {ok} Downloaded {} bytes", bytes.len());
579
580 println!(" {action} Verifying SHA-256...");
582 archive::verify_bytes_checksum(&bytes, &entry.sha256)
583 .map_err(|e| format!("Checksum verification failed: {e}"))?;
584 println!(
585 " {ok} Checksum verified: {cyan}{}{reset}",
586 &entry.sha256[..16]
587 );
588
589 let staging_dir = roboticus_core::home_dir()
591 .join(".roboticus")
592 .join("staging");
593 std::fs::create_dir_all(&staging_dir)?;
594
595 let result = archive::unpack_bytes(&bytes, &staging_dir, entry.sha256.clone())
596 .map_err(|e| format!("Failed to unpack archive: {e}"))?;
597
598 if result.manifest.name != entry.name {
600 let _ = std::fs::remove_dir_all(&result.dest_dir);
601 return Err(format!(
602 "identity mismatch: catalog says '{}' but archive contains '{}'",
603 entry.name, result.manifest.name
604 )
605 .into());
606 }
607
608 if check_not_installed(&result.manifest.name).is_err() {
610 let _ = std::fs::remove_dir_all(&result.dest_dir);
611 return Err(format!("plugin '{}' already installed", result.manifest.name).into());
612 }
613
614 if !check_requirements(&result.manifest) {
616 let _ = std::fs::remove_dir_all(&result.dest_dir);
617 return Err("missing required plugin dependencies".into());
618 }
619
620 let dest = roboticus_core::home_dir()
622 .join(".roboticus")
623 .join("plugins")
624 .join(&result.manifest.name);
625 std::fs::create_dir_all(dest.parent().unwrap_or(&dest))?;
626 std::fs::rename(&result.dest_dir, &dest).or_else(|_| {
627 if let Err(e) = copy_dir_recursive(&result.dest_dir, &dest) {
628 let _ = std::fs::remove_dir_all(&dest);
629 return Err(e);
630 }
631 std::fs::remove_dir_all(&result.dest_dir)
632 })?;
633
634 if let Err(e) = deploy_companion_skills(&result.manifest, &dest) {
635 if let Err(clean_err) = std::fs::remove_dir_all(&dest) {
637 eprintln!(
638 " {err_icon} Companion skill deployment failed and rollback also failed: {clean_err}"
639 );
640 }
641 return Err(e);
642 }
643 print_plugin_summary(&result.manifest, &format!("catalog: {name}"));
644 Ok(())
645}
646
647pub fn cmd_plugin_uninstall(name: &str) -> Result<(), Box<dyn std::error::Error>> {
650 let (_dim, _bold, _accent, _green, _yellow, _red, _cyan, _reset, _mono) = colors();
651 let (ok, _action, warn, _detail, _err_icon) = icons();
652 let roboticus_dir = roboticus_core::home_dir().join(".roboticus");
653 let plugin_dir = roboticus_dir.join("plugins").join(name);
654
655 if !plugin_dir.exists() {
656 eprintln!(" Plugin not found: {name}");
657 return Err(format!("plugin not found: {name}").into());
658 }
659
660 let manifest_path = plugin_dir.join("plugin.toml");
662 if manifest_path.exists()
663 && let Ok(manifest) = PluginManifest::from_file(&manifest_path)
664 {
665 let skills_dir = roboticus_dir.join("skills");
666 for skill_rel in &manifest.companion_skills {
667 let installed_name = companion_skill_install_name(name, skill_rel);
668 let skill_path = skills_dir.join(&installed_name);
669 if skill_path.exists() {
670 if let Err(e) = std::fs::remove_file(&skill_path) {
671 eprintln!(" {warn} Could not remove companion skill {installed_name}: {e}",);
672 } else {
673 println!(" {ok} Removed companion skill: {installed_name}");
674 }
675 } else {
676 let legacy_name = std::path::Path::new(skill_rel)
678 .file_name()
679 .unwrap_or_default()
680 .to_string_lossy()
681 .to_string();
682 let old_prefixed_name = format!("{name}--{legacy_name}");
683 let legacy_path = skills_dir.join(&legacy_name);
684 let old_prefixed_path = skills_dir.join(&old_prefixed_name);
685 let source_path = plugin_dir.join(skill_rel);
686 let same_content = std::fs::read(&legacy_path)
687 .ok()
688 .zip(std::fs::read(&source_path).ok())
689 .map(|(a, b)| a == b)
690 .unwrap_or(false);
691 if same_content {
692 if let Err(e) = std::fs::remove_file(&legacy_path) {
693 eprintln!(
694 " {warn} Could not remove legacy companion skill {legacy_name}: {e}",
695 );
696 } else {
697 println!(" {ok} Removed legacy companion skill: {legacy_name}");
698 }
699 }
700 let old_prefixed_same_content = std::fs::read(&old_prefixed_path)
701 .ok()
702 .zip(std::fs::read(&source_path).ok())
703 .map(|(a, b)| a == b)
704 .unwrap_or(false);
705 if old_prefixed_same_content {
706 if let Err(e) = std::fs::remove_file(&old_prefixed_path) {
707 eprintln!(
708 " {warn} Could not remove legacy companion skill {old_prefixed_name}: {e}",
709 );
710 } else {
711 println!(" {ok} Removed legacy companion skill: {old_prefixed_name}");
712 }
713 }
714 }
715 }
716 }
717
718 let manifest_path = plugin_dir.join("plugin.toml");
720 if manifest_path.exists()
721 && let Ok(manifest) = PluginManifest::from_file(&manifest_path)
722 {
723 let skills_dir = roboticus_dir.join("skills");
724 for skill_rel in &manifest.companion_skills {
725 let installed_name = companion_skill_install_name(name, skill_rel);
726 let skill_path = skills_dir.join(&installed_name);
727 if skill_path.exists() {
728 if let Err(e) = std::fs::remove_file(&skill_path) {
729 eprintln!(" {warn} Could not remove companion skill {installed_name}: {e}",);
730 } else {
731 println!(" {ok} Removed companion skill: {installed_name}");
732 }
733 } else {
734 let legacy_name = std::path::Path::new(skill_rel)
736 .file_name()
737 .unwrap_or_default()
738 .to_string_lossy()
739 .to_string();
740 let old_prefixed_name = format!("{name}--{legacy_name}");
741 let legacy_path = skills_dir.join(&legacy_name);
742 let old_prefixed_path = skills_dir.join(&old_prefixed_name);
743 let source_path = plugin_dir.join(skill_rel);
744 let same_content = std::fs::read(&legacy_path)
745 .ok()
746 .zip(std::fs::read(&source_path).ok())
747 .map(|(a, b)| a == b)
748 .unwrap_or(false);
749 if same_content {
750 if let Err(e) = std::fs::remove_file(&legacy_path) {
751 eprintln!(
752 " {warn} Could not remove legacy companion skill {legacy_name}: {e}",
753 );
754 } else {
755 println!(" {ok} Removed legacy companion skill: {legacy_name}");
756 }
757 }
758 let old_prefixed_same_content = std::fs::read(&old_prefixed_path)
759 .ok()
760 .zip(std::fs::read(&source_path).ok())
761 .map(|(a, b)| a == b)
762 .unwrap_or(false);
763 if old_prefixed_same_content {
764 if let Err(e) = std::fs::remove_file(&old_prefixed_path) {
765 eprintln!(
766 " {warn} Could not remove legacy companion skill {old_prefixed_name}: {e}",
767 );
768 } else {
769 println!(" {ok} Removed legacy companion skill: {old_prefixed_name}");
770 }
771 }
772 }
773 }
774 }
775
776 std::fs::remove_dir_all(&plugin_dir)?;
777 println!(" {ok} Uninstalled plugin: {name}");
778 println!(" Restart the server to apply.\n");
779 Ok(())
780}
781
782pub async fn cmd_plugin_toggle(
785 base_url: &str,
786 name: &str,
787 enable: bool,
788) -> Result<(), Box<dyn std::error::Error>> {
789 let (ok, _action, _warn, _detail, _err_icon) = icons();
790 let action = if enable { "enable" } else { "disable" };
791 let client = super::http_client()?;
792 let resp = client
793 .put(format!("{base_url}/api/plugins/{name}/toggle"))
794 .json(&serde_json::json!({ "enabled": enable }))
795 .send()
796 .await?;
797
798 if resp.status().is_success() {
799 println!(" {ok} Plugin {name} {action}d");
800 } else {
801 eprintln!(" Failed to {action} plugin {name}: {}", resp.status());
802 return Err(format!("failed to {action} plugin {name}: HTTP {}", resp.status()).into());
803 }
804 Ok(())
805}
806
807pub async fn cmd_plugin_search(query: &str) -> Result<(), Box<dyn std::error::Error>> {
810 use crate::cli::update;
811
812 let (_dim, bold, _accent, green, yellow, _red, cyan, reset, _mono) = colors();
813 let (ok, action, _warn, _detail, _err_icon) = icons();
814
815 println!("\n {action} Searching plugin catalog...\n");
816
817 let config_path = roboticus_core::config::resolve_config_path(None);
818 let config_str = config_path
819 .as_ref()
820 .map(|p| p.to_string_lossy().to_string())
821 .unwrap_or_default();
822 let registry_url = update::resolve_registry_url(None, &config_str);
823 let client = super::http_client()?;
824 let manifest = update::fetch_manifest(&client, ®istry_url).await?;
825
826 let catalog = manifest.packs.plugins.as_ref();
827 let catalog = match catalog {
828 Some(c) if !c.catalog.is_empty() => c,
829 _ => {
830 println!(
831 " The plugin catalog is empty. No plugins are published yet.\n\
832 \n Plugins can be installed from a local archive:\n\
833 \n roboticus plugins install --path ./my-plugin.ic.zip\n"
834 );
835 return Ok(());
836 }
837 };
838
839 let results = catalog.search(query);
840
841 if results.is_empty() {
842 println!(" No plugins found matching \"{query}\".\n");
843 return Ok(());
844 }
845
846 println!(
847 " {:<20} {:<10} {:<12} {}",
848 "Name", "Version", "Tier", "Description"
849 );
850 println!(" {}", "─".repeat(70));
851 for entry in &results {
852 let tier_display = match entry.tier.as_str() {
853 "official" => format!("{green}official{reset}"),
854 "community" => format!("{yellow}community{reset}"),
855 _ => entry.tier.clone(),
856 };
857 println!(
858 " {:<20} {:<10} {:<12} {}",
859 entry.name,
860 entry.version,
861 tier_display,
862 truncate_str(&entry.description, 40)
863 );
864 }
865 println!(
866 "\n {ok} {} plugin(s) found. Install with: {cyan}roboticus plugins install <name>{reset}\n",
867 results.len()
868 );
869 Ok(())
870}
871
872pub fn cmd_plugin_pack(dir: &str, output: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
875 use roboticus_plugin_sdk::archive;
876
877 let (_dim, bold, _accent, green, _yellow, _red, cyan, reset, _mono) = colors();
878 let (ok, action, _warn, _detail, err_icon) = icons();
879
880 let source_path = std::path::Path::new(dir);
881 if !source_path.exists() {
882 return Err(format!("source directory not found: {dir}").into());
883 }
884
885 let manifest_path = source_path.join("plugin.toml");
886 if !manifest_path.exists() {
887 return Err(format!("no plugin.toml found in {dir}").into());
888 }
889
890 let manifest = PluginManifest::from_file(&manifest_path)
892 .map_err(|e| format!("Invalid plugin.toml: {e}"))?;
893
894 println!(
895 "\n {action} Vetting {bold}{}{reset} v{green}{}{reset}...\n",
896 manifest.name, manifest.version
897 );
898
899 let report = manifest.vet(source_path);
900 let has_problems = !report.errors.is_empty() || !report.warnings.is_empty();
901 if has_problems {
902 for err in &report.errors {
903 eprintln!(" {err_icon} {err}");
904 }
905 let (_ok2, _action2, warn2, _detail2, _err2) = icons();
906 for w in &report.warnings {
907 eprintln!(" {warn2} {w}");
908 }
909 if !report.errors.is_empty() {
910 eprintln!(
911 "\n {err_icon} Plugin failed vetting. Fix the errors above before packing.\n"
912 );
913 return Err("plugin vetting failed".into());
914 }
915 println!();
916 }
917
918 let output_dir = output
919 .map(std::path::PathBuf::from)
920 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
921
922 println!(" {action} Packing archive...");
923
924 let result = archive::pack(source_path, &output_dir)
925 .map_err(|e| format!("Failed to pack archive: {e}"))?;
926
927 println!(
928 " {ok} Created: {bold}{}{reset}",
929 result.archive_path.display()
930 );
931 println!(" SHA-256: {cyan}{}{reset}", result.sha256);
932 println!(" Files: {}", result.file_count);
933 println!(
934 " Size: {} bytes (uncompressed)\n",
935 result.uncompressed_bytes
936 );
937 Ok(())
938}
939
940#[cfg(test)]
941mod tests {
942 use super::companion_skill_install_name;
943
944 #[test]
945 fn companion_skill_install_name_distinguishes_paths_with_same_basename() {
946 let a = companion_skill_install_name("plugin-a", "skills/core/readme.md");
947 let b = companion_skill_install_name("plugin-a", "skills/extra/readme.md");
948 assert_ne!(a, b);
949 }
950}