Skip to main content

room_cli/
plugin_cmd.rs

1//! Plugin management commands: install, list, remove, update.
2//!
3//! These are client-side operations that manage the `~/.room/plugins/`
4//! directory. The daemon loads plugins from this directory on startup
5//! using `libloading`.
6
7use std::path::{Path, PathBuf};
8
9use serde::{Deserialize, Serialize};
10
11// ── Builtin plugin metadata ─────────────────────────────────────────────────
12
13/// Statically compiled plugins shipped with the room binary.
14///
15/// These are always available regardless of what is installed in
16/// `~/.room/plugins/`. Displayed as `[builtin]` in `room plugin list`.
17pub const BUILTIN_PLUGINS: &[(&str, &str)] = &[
18    ("agent", "Agent spawn/stop/list/logs, /spawn personalities"),
19    ("queue", "FIFO message queue (push/pop/peek/list/clear)"),
20    ("stats", "Room statistics (message counts, uptime)"),
21    (
22        "taskboard",
23        "Task lifecycle management (post/claim/plan/approve/finish)",
24    ),
25];
26
27// ── Plugin metadata ─────────────────────────────────────────────────────────
28
29/// Metadata written alongside each installed plugin shared library.
30///
31/// Stored as `<name>.meta.json` in `~/.room/plugins/`.
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
33pub struct PluginMeta {
34    /// Short plugin name (e.g. `"agent"`, `"taskboard"`).
35    pub name: String,
36    /// Crate name on crates.io (e.g. `"room-plugin-agent"`).
37    pub crate_name: String,
38    /// Installed version (semver).
39    pub version: String,
40    /// Minimum compatible `room-protocol` version (semver).
41    pub min_protocol: String,
42    /// Shared library filename (e.g. `"libroom_plugin_agent.so"`).
43    pub lib_file: String,
44}
45
46// ── Name resolution ─────────────────────────────────────────────────────────
47
48/// Resolve a user-supplied plugin name to the crate name on crates.io.
49///
50/// If the name already starts with `room-plugin-`, it is returned as-is.
51/// Otherwise, `room-plugin-` is prepended.
52pub fn resolve_crate_name(name: &str) -> String {
53    if name.starts_with("room-plugin-") {
54        name.to_owned()
55    } else {
56        format!("room-plugin-{name}")
57    }
58}
59
60/// Derive the short plugin name from a crate name.
61///
62/// Strips the `room-plugin-` prefix if present.
63pub fn short_name(crate_name: &str) -> String {
64    crate_name
65        .strip_prefix("room-plugin-")
66        .unwrap_or(crate_name)
67        .to_owned()
68}
69
70/// Compute the expected shared library filename for a plugin crate.
71///
72/// Cargo produces `lib<crate_name_underscored>.so` on Linux and
73/// `lib<crate_name_underscored>.dylib` on macOS.
74pub fn lib_filename(crate_name: &str) -> String {
75    let stem = crate_name.replace('-', "_");
76    let ext = if cfg!(target_os = "macos") {
77        "dylib"
78    } else {
79        "so"
80    };
81    format!("lib{stem}.{ext}")
82}
83
84/// Path to the `.meta.json` file for a plugin.
85pub fn meta_path(plugins_dir: &Path, name: &str) -> PathBuf {
86    plugins_dir.join(format!("{name}.meta.json"))
87}
88
89// ── Scanning installed plugins ──────────────────────────────────────────────
90
91/// Scan the plugins directory and return metadata for all installed plugins.
92pub fn scan_installed(plugins_dir: &Path) -> Vec<PluginMeta> {
93    let entries = match std::fs::read_dir(plugins_dir) {
94        Ok(e) => e,
95        Err(_) => return vec![],
96    };
97    let mut metas = Vec::new();
98    for entry in entries.flatten() {
99        let path = entry.path();
100        if path.extension().and_then(|e| e.to_str()) == Some("json")
101            && path
102                .file_name()
103                .and_then(|n| n.to_str())
104                .is_some_and(|n| n.ends_with(".meta.json"))
105        {
106            if let Ok(data) = std::fs::read_to_string(&path) {
107                if let Ok(meta) = serde_json::from_str::<PluginMeta>(&data) {
108                    metas.push(meta);
109                }
110            }
111        }
112    }
113    metas.sort_by(|a, b| a.name.cmp(&b.name));
114    metas
115}
116
117// ── Commands ────────────────────────────────────────────────────────────────
118
119/// Install a plugin from crates.io.
120///
121/// 1. Resolves the crate name from the short name.
122/// 2. Creates a temporary directory and runs `cargo install` with
123///    `--target-dir` to build the cdylib.
124/// 3. Copies the shared library to `~/.room/plugins/`.
125/// 4. Writes `.meta.json` alongside it.
126pub fn cmd_install(plugins_dir: &Path, name: &str, version: Option<&str>) -> anyhow::Result<()> {
127    let crate_name = resolve_crate_name(name);
128    let short = short_name(&crate_name);
129
130    // Check if already installed.
131    let existing_meta = meta_path(plugins_dir, &short);
132    if existing_meta.exists() {
133        if let Ok(data) = std::fs::read_to_string(&existing_meta) {
134            if let Ok(meta) = serde_json::from_str::<PluginMeta>(&data) {
135                eprintln!(
136                    "plugin '{}' v{} is already installed — use `room plugin update {}` to upgrade",
137                    short, meta.version, short
138                );
139                return Ok(());
140            }
141        }
142    }
143
144    // Ensure plugins directory exists.
145    std::fs::create_dir_all(plugins_dir)?;
146
147    // Build in a temp directory.
148    let build_dir = tempfile::TempDir::new()?;
149    eprintln!("installing {crate_name}...");
150
151    let mut cmd = std::process::Command::new("cargo");
152    cmd.args(["install", "--root"])
153        .arg(build_dir.path())
154        .args(["--target-dir"])
155        .arg(build_dir.path().join("target"))
156        .arg(&crate_name);
157
158    if let Some(v) = version {
159        cmd.args(["--version", v]);
160    }
161
162    let output = cmd
163        .output()
164        .map_err(|e| anyhow::anyhow!("failed to run cargo install: {e} — is cargo on PATH?"))?;
165
166    if !output.status.success() {
167        let stderr = String::from_utf8_lossy(&output.stderr);
168        anyhow::bail!("cargo install failed:\n{stderr}");
169    }
170
171    // Find the built shared library.
172    let lib_name = lib_filename(&crate_name);
173    let built_lib = find_built_lib(build_dir.path(), &lib_name)?;
174
175    // Copy to plugins directory.
176    let dest_lib = plugins_dir.join(&lib_name);
177    std::fs::copy(&built_lib, &dest_lib).map_err(|e| {
178        anyhow::anyhow!(
179            "failed to copy {} to {}: {e}",
180            built_lib.display(),
181            dest_lib.display()
182        )
183    })?;
184
185    // Extract version from cargo output or default.
186    let installed_version = version.unwrap_or("latest").to_owned();
187
188    // Write metadata.
189    let meta = PluginMeta {
190        name: short.to_owned(),
191        crate_name: crate_name.clone(),
192        version: installed_version,
193        min_protocol: "0.0.0".to_owned(),
194        lib_file: lib_name,
195    };
196    let meta_file = meta_path(plugins_dir, &short);
197    std::fs::write(&meta_file, serde_json::to_string_pretty(&meta)?)?;
198
199    eprintln!("installed plugin '{}' to {}", short, dest_lib.display());
200    Ok(())
201}
202
203/// List all plugins — builtins first, then installed external plugins.
204pub fn cmd_list(plugins_dir: &Path) -> anyhow::Result<()> {
205    let externals = scan_installed(plugins_dir);
206
207    let header = format!(
208        "{:<16} {:<12} {:<10} {}",
209        "NAME", "VERSION", "SOURCE", "DESCRIPTION"
210    );
211    println!("{header}");
212
213    // Builtins — always shown
214    let version = env!("CARGO_PKG_VERSION");
215    for (name, description) in BUILTIN_PLUGINS {
216        println!(
217            "{:<16} {:<12} {:<10} {}",
218            name, version, "[builtin]", description
219        );
220    }
221
222    // External plugins from ~/.room/plugins/
223    for m in &externals {
224        println!(
225            "{:<16} {:<12} {:<10} {}",
226            m.name, m.version, "[external]", m.crate_name
227        );
228    }
229
230    Ok(())
231}
232
233/// Remove an installed plugin.
234pub fn cmd_remove(plugins_dir: &Path, name: &str) -> anyhow::Result<()> {
235    let short = short_name(&resolve_crate_name(name));
236    let meta_file = meta_path(plugins_dir, &short);
237
238    if !meta_file.exists() {
239        anyhow::bail!("plugin '{}' is not installed", short);
240    }
241
242    // Read meta to find the lib file.
243    let data = std::fs::read_to_string(&meta_file)?;
244    let meta: PluginMeta = serde_json::from_str(&data)?;
245
246    // Remove the shared library.
247    let lib_path = plugins_dir.join(&meta.lib_file);
248    if lib_path.exists() {
249        std::fs::remove_file(&lib_path)?;
250    }
251
252    // Remove the meta file.
253    std::fs::remove_file(&meta_file)?;
254
255    eprintln!("removed plugin '{}'", short);
256    Ok(())
257}
258
259/// Update an installed plugin to the latest (or specified) version.
260pub fn cmd_update(plugins_dir: &Path, name: &str, version: Option<&str>) -> anyhow::Result<()> {
261    let short = short_name(&resolve_crate_name(name));
262    let meta_file = meta_path(plugins_dir, &short);
263
264    if !meta_file.exists() {
265        anyhow::bail!(
266            "plugin '{}' is not installed — use `room plugin install {}` first",
267            short,
268            short
269        );
270    }
271
272    // Remove old installation, then reinstall.
273    cmd_remove(plugins_dir, name)?;
274    cmd_install(plugins_dir, name, version)?;
275    eprintln!("updated plugin '{}'", short);
276    Ok(())
277}
278
279// ── Helpers ─────────────────────────────────────────────────────────────────
280
281/// Search a build directory tree for a shared library matching the expected name.
282fn find_built_lib(build_dir: &Path, lib_name: &str) -> anyhow::Result<PathBuf> {
283    // cargo install --root puts the library in target/release/deps/ or similar.
284    // Walk the tree looking for the file.
285    for entry in walkdir(build_dir) {
286        if let Some(name) = entry.file_name().and_then(|n| n.to_str()) {
287            if name == lib_name {
288                return Ok(entry);
289            }
290        }
291    }
292    anyhow::bail!(
293        "built library '{}' not found in {}",
294        lib_name,
295        build_dir.display()
296    )
297}
298
299/// Simple recursive directory walker (avoids adding a `walkdir` dependency).
300fn walkdir(dir: &Path) -> Vec<PathBuf> {
301    let mut results = Vec::new();
302    if let Ok(entries) = std::fs::read_dir(dir) {
303        for entry in entries.flatten() {
304            let path = entry.path();
305            if path.is_dir() {
306                results.extend(walkdir(&path));
307            } else {
308                results.push(path);
309            }
310        }
311    }
312    results
313}
314
315// ── Tests ───────────────────────────────────────────────────────────────────
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    // ── resolve_crate_name ──────────────────────────────────────────────
322
323    #[test]
324    fn resolve_short_name_prepends_prefix() {
325        assert_eq!(resolve_crate_name("agent"), "room-plugin-agent");
326    }
327
328    #[test]
329    fn resolve_full_name_unchanged() {
330        assert_eq!(
331            resolve_crate_name("room-plugin-taskboard"),
332            "room-plugin-taskboard"
333        );
334    }
335
336    #[test]
337    fn resolve_hyphenated_name() {
338        assert_eq!(resolve_crate_name("my-custom"), "room-plugin-my-custom");
339    }
340
341    // ── short_name ──────────────────────────────────────────────────────
342
343    #[test]
344    fn short_name_strips_prefix() {
345        assert_eq!(short_name("room-plugin-agent"), "agent");
346    }
347
348    #[test]
349    fn short_name_no_prefix() {
350        assert_eq!(short_name("custom"), "custom");
351    }
352
353    // ── lib_filename ────────────────────────────────────────────────────
354
355    #[test]
356    fn lib_filename_replaces_hyphens() {
357        let name = lib_filename("room-plugin-agent");
358        assert!(name.starts_with("libroom_plugin_agent."));
359        // Extension is platform-dependent.
360        assert!(name.ends_with(".so") || name.ends_with(".dylib"));
361    }
362
363    // ── PluginMeta serialization ────────────────────────────────────────
364
365    #[test]
366    fn meta_roundtrip() {
367        let meta = PluginMeta {
368            name: "agent".to_owned(),
369            crate_name: "room-plugin-agent".to_owned(),
370            version: "3.4.0".to_owned(),
371            min_protocol: "3.0.0".to_owned(),
372            lib_file: "libroom_plugin_agent.so".to_owned(),
373        };
374        let json = serde_json::to_string(&meta).unwrap();
375        let parsed: PluginMeta = serde_json::from_str(&json).unwrap();
376        assert_eq!(parsed, meta);
377    }
378
379    #[test]
380    fn meta_pretty_print() {
381        let meta = PluginMeta {
382            name: "taskboard".to_owned(),
383            crate_name: "room-plugin-taskboard".to_owned(),
384            version: "1.0.0".to_owned(),
385            min_protocol: "0.0.0".to_owned(),
386            lib_file: "libroom_plugin_taskboard.so".to_owned(),
387        };
388        let json = serde_json::to_string_pretty(&meta).unwrap();
389        assert!(json.contains("\"name\": \"taskboard\""));
390        assert!(json.contains("\"version\": \"1.0.0\""));
391    }
392
393    // ── scan_installed ──────────────────────────────────────────────────
394
395    #[test]
396    fn scan_empty_dir() {
397        let dir = tempfile::TempDir::new().unwrap();
398        let result = scan_installed(dir.path());
399        assert!(result.is_empty());
400    }
401
402    #[test]
403    fn scan_nonexistent_dir() {
404        let result = scan_installed(Path::new("/nonexistent/plugins"));
405        assert!(result.is_empty());
406    }
407
408    #[test]
409    fn scan_finds_valid_meta_files() {
410        let dir = tempfile::TempDir::new().unwrap();
411        let meta = PluginMeta {
412            name: "test".to_owned(),
413            crate_name: "room-plugin-test".to_owned(),
414            version: "0.1.0".to_owned(),
415            min_protocol: "0.0.0".to_owned(),
416            lib_file: "libroom_plugin_test.so".to_owned(),
417        };
418        let meta_file = dir.path().join("test.meta.json");
419        std::fs::write(&meta_file, serde_json::to_string(&meta).unwrap()).unwrap();
420
421        let result = scan_installed(dir.path());
422        assert_eq!(result.len(), 1);
423        assert_eq!(result[0].name, "test");
424    }
425
426    #[test]
427    fn scan_skips_invalid_json() {
428        let dir = tempfile::TempDir::new().unwrap();
429        std::fs::write(dir.path().join("bad.meta.json"), "not json").unwrap();
430        let result = scan_installed(dir.path());
431        assert!(result.is_empty());
432    }
433
434    #[test]
435    fn scan_skips_non_meta_json() {
436        let dir = tempfile::TempDir::new().unwrap();
437        std::fs::write(dir.path().join("config.json"), "{}").unwrap();
438        let result = scan_installed(dir.path());
439        assert!(result.is_empty());
440    }
441
442    #[test]
443    fn scan_sorts_by_name() {
444        let dir = tempfile::TempDir::new().unwrap();
445        for name in &["zebra", "alpha", "mid"] {
446            let meta = PluginMeta {
447                name: name.to_string(),
448                crate_name: format!("room-plugin-{name}"),
449                version: "0.1.0".to_owned(),
450                min_protocol: "0.0.0".to_owned(),
451                lib_file: format!("libroom_plugin_{name}.so"),
452            };
453            std::fs::write(
454                dir.path().join(format!("{name}.meta.json")),
455                serde_json::to_string(&meta).unwrap(),
456            )
457            .unwrap();
458        }
459        let result = scan_installed(dir.path());
460        let names: Vec<&str> = result.iter().map(|m| m.name.as_str()).collect();
461        assert_eq!(names, vec!["alpha", "mid", "zebra"]);
462    }
463
464    // ── meta_path ───────────────────────────────────────────────────────
465
466    #[test]
467    fn meta_path_format() {
468        let p = meta_path(Path::new("/home/user/.room/plugins"), "agent");
469        assert_eq!(p, PathBuf::from("/home/user/.room/plugins/agent.meta.json"));
470    }
471
472    // ── cmd_remove ──────────────────────────────────────────────────────
473
474    #[test]
475    fn remove_nonexistent_plugin_fails() {
476        let dir = tempfile::TempDir::new().unwrap();
477        let result = cmd_remove(dir.path(), "nonexistent");
478        assert!(result.is_err());
479        assert!(result.unwrap_err().to_string().contains("not installed"));
480    }
481
482    #[test]
483    fn remove_deletes_lib_and_meta() {
484        let dir = tempfile::TempDir::new().unwrap();
485        let meta = PluginMeta {
486            name: "test".to_owned(),
487            crate_name: "room-plugin-test".to_owned(),
488            version: "0.1.0".to_owned(),
489            min_protocol: "0.0.0".to_owned(),
490            lib_file: "libroom_plugin_test.so".to_owned(),
491        };
492        std::fs::write(
493            dir.path().join("test.meta.json"),
494            serde_json::to_string(&meta).unwrap(),
495        )
496        .unwrap();
497        std::fs::write(dir.path().join("libroom_plugin_test.so"), b"fake").unwrap();
498
499        cmd_remove(dir.path(), "test").unwrap();
500        assert!(!dir.path().join("test.meta.json").exists());
501        assert!(!dir.path().join("libroom_plugin_test.so").exists());
502    }
503
504    // ── walkdir ─────────────────────────────────────────────────────────
505
506    #[test]
507    fn walkdir_finds_nested_files() {
508        let dir = tempfile::TempDir::new().unwrap();
509        let nested = dir.path().join("a").join("b");
510        std::fs::create_dir_all(&nested).unwrap();
511        std::fs::write(nested.join("target.so"), b"lib").unwrap();
512        std::fs::write(dir.path().join("top.txt"), b"top").unwrap();
513
514        let files = walkdir(dir.path());
515        assert!(files.iter().any(|p| p.ends_with("target.so")));
516        assert!(files.iter().any(|p| p.ends_with("top.txt")));
517    }
518
519    #[test]
520    fn walkdir_empty_dir() {
521        let dir = tempfile::TempDir::new().unwrap();
522        let files = walkdir(dir.path());
523        assert!(files.is_empty());
524    }
525
526    // ── find_built_lib ──────────────────────────────────────────────────
527
528    #[test]
529    fn find_built_lib_success() {
530        let dir = tempfile::TempDir::new().unwrap();
531        let release = dir.path().join("target").join("release");
532        std::fs::create_dir_all(&release).unwrap();
533        std::fs::write(release.join("libroom_plugin_test.so"), b"elf").unwrap();
534
535        let result = find_built_lib(dir.path(), "libroom_plugin_test.so");
536        assert!(result.is_ok());
537        assert!(result.unwrap().ends_with("libroom_plugin_test.so"));
538    }
539
540    #[test]
541    fn find_built_lib_not_found() {
542        let dir = tempfile::TempDir::new().unwrap();
543        let result = find_built_lib(dir.path(), "nonexistent.so");
544        assert!(result.is_err());
545        assert!(result.unwrap_err().to_string().contains("not found"));
546    }
547
548    // ── cmd_install duplicate check ─────────────────────────────────────
549
550    #[test]
551    fn install_skips_when_already_installed() {
552        let dir = tempfile::TempDir::new().unwrap();
553        let meta = PluginMeta {
554            name: "existing".to_owned(),
555            crate_name: "room-plugin-existing".to_owned(),
556            version: "1.0.0".to_owned(),
557            min_protocol: "0.0.0".to_owned(),
558            lib_file: "libroom_plugin_existing.so".to_owned(),
559        };
560        std::fs::write(
561            dir.path().join("existing.meta.json"),
562            serde_json::to_string(&meta).unwrap(),
563        )
564        .unwrap();
565
566        // Should succeed without error (prints "already installed").
567        let result = cmd_install(dir.path(), "existing", None);
568        assert!(result.is_ok());
569    }
570
571    // ── cmd_update when not installed ───────────────────────────────────
572
573    #[test]
574    fn update_nonexistent_plugin_fails() {
575        let dir = tempfile::TempDir::new().unwrap();
576        let result = cmd_update(dir.path(), "nonexistent", None);
577        assert!(result.is_err());
578        assert!(result.unwrap_err().to_string().contains("not installed"));
579    }
580
581    // ── builtin plugins ─────────────────────────────────────────────────
582
583    #[test]
584    fn builtin_plugins_has_four_entries() {
585        assert_eq!(BUILTIN_PLUGINS.len(), 4);
586    }
587
588    #[test]
589    fn builtin_plugins_includes_expected_names() {
590        let names: Vec<&str> = BUILTIN_PLUGINS.iter().map(|(n, _)| *n).collect();
591        assert!(names.contains(&"agent"));
592        assert!(names.contains(&"taskboard"));
593        assert!(names.contains(&"queue"));
594        assert!(names.contains(&"stats"));
595    }
596
597    #[test]
598    fn builtin_plugins_sorted_alphabetically() {
599        let names: Vec<&str> = BUILTIN_PLUGINS.iter().map(|(n, _)| *n).collect();
600        let mut sorted = names.clone();
601        sorted.sort();
602        assert_eq!(names, sorted);
603    }
604}