Skip to main content

roboticus_cli/cli/admin/
plugins.rs

1use super::*;
2
3use roboticus_plugin_sdk::manifest::PluginManifest;
4use sha2::{Digest, Sha256};
5
6// ── Install source detection ────────────────────────────────────
7
8enum InstallSource {
9    /// Local directory containing plugin.toml (dev mode)
10    Directory(std::path::PathBuf),
11    /// Local .ic.zip archive
12    Archive(std::path::PathBuf),
13    /// Catalog plugin name (fetched from registry)
14    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        // Anything ending in .zip is treated as an archive path
24        InstallSource::Archive(path.to_path_buf())
25    } else if has_path_sep || path.exists() {
26        // Contains path separators or exists on disk → filesystem directory
27        InstallSource::Directory(path.to_path_buf())
28    } else {
29        // Bare name like "claude-code" → catalog lookup
30        InstallSource::Catalog(source.to_string())
31    }
32}
33
34// ── Plugin listing ────────────────────────────────────────────
35
36pub 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
84// ── Plugin info ─────────────────────────────────────────────
85
86pub 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
169// ── Shared helpers ──────────────────────────────────────────
170
171fn 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            // best-effort: rollback cleanup on install failure
299            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    // Walk char boundaries to find the last safe index within budget
331    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
351// ── Install: unified entry point ────────────────────────────
352
353pub 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
361// ── Install from local directory (dev mode) ─────────────────
362
363fn 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    // Vet the plugin before installing (same gate as pack and server startup)
380    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        // best-effort: rollback cleanup on install failure
406        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        // best-effort: rollback cleanup on install failure
412        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
419// ── Install from .ic.zip archive ────────────────────────────
420
421fn 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    // Unpack to staging area
434    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    // Requirements check
449    if !check_requirements(&result.manifest) {
450        // best-effort: staging cleanup on early exit
451        let _ = std::fs::remove_dir_all(&result.dest_dir);
452        return Err("missing required plugin dependencies".into());
453    }
454
455    // Check not already installed
456    let dest = match check_not_installed(&result.manifest.name) {
457        Ok(d) => d,
458        Err(()) => {
459            // best-effort: staging cleanup on early exit
460            let _ = std::fs::remove_dir_all(&result.dest_dir);
461            return Err(format!("plugin '{}' already installed", result.manifest.name).into());
462        }
463    };
464
465    // Prompt user
466    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    // Move from staging to plugins dir
476    std::fs::create_dir_all(dest.parent().unwrap_or(&dest))?;
477    std::fs::rename(&result.dest_dir, &dest).or_else(|_| {
478        // rename fails across filesystems; fall back to copy + remove
479        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        // Roll back partial install on post-move failure.
488        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
502// ── Install from remote catalog ─────────────────────────────
503
504async 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    // Fetch registry manifest
514    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, &registry_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    // Check not already installed
549    if check_not_installed(&entry.name).is_err() {
550        return Err(format!("plugin '{}' already installed", entry.name).into());
551    }
552
553    // Prompt before download
554    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    // Download archive
563    let base_url = update::registry_base_url(&registry_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    // Verify checksum against catalog
581    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    // Unpack to staging
590    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    // Identity check: manifest name must match catalog entry name
599    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    // Re-check "already installed" against manifest name (authoritative identity)
609    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    // Requirements check
615    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    // Move from staging to plugins dir
621    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        // Roll back partial install on post-move failure.
636        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
647// ── Uninstall ───────────────────────────────────────────────
648
649pub 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    // Remove companion skills if the manifest declares them
661    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                // Backward compat: legacy flat naming — only remove if content matches
677                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    // Remove companion skills if the manifest declares them
719    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                // Backward compat: legacy flat naming — only remove if content matches
735                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
782// ── Toggle enable/disable ───────────────────────────────────
783
784pub 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
807// ── Search remote catalog ───────────────────────────────────
808
809pub 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, &registry_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
872// ── Pack a plugin directory into .ic.zip ────────────────────
873
874pub 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    // Vet the plugin before packing
891    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}