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    println!(
208        "{:<16} {:<12} {:<10} {}",
209        "NAME", "VERSION", "SOURCE", "DESCRIPTION"
210    );
211
212    // Builtins — always shown
213    let version = env!("CARGO_PKG_VERSION");
214    for (name, description) in BUILTIN_PLUGINS {
215        println!(
216            "{:<16} {:<12} {:<10} {}",
217            name, version, "[builtin]", description
218        );
219    }
220
221    // External plugins from ~/.room/plugins/
222    for m in &externals {
223        println!(
224            "{:<16} {:<12} {:<10} {}",
225            m.name, m.version, "[external]", m.crate_name
226        );
227    }
228
229    Ok(())
230}
231
232/// Remove an installed plugin.
233pub fn cmd_remove(plugins_dir: &Path, name: &str) -> anyhow::Result<()> {
234    let short = short_name(&resolve_crate_name(name));
235    let meta_file = meta_path(plugins_dir, &short);
236
237    if !meta_file.exists() {
238        anyhow::bail!("plugin '{}' is not installed", short);
239    }
240
241    // Read meta to find the lib file.
242    let data = std::fs::read_to_string(&meta_file)?;
243    let meta: PluginMeta = serde_json::from_str(&data)?;
244
245    // Remove the shared library.
246    let lib_path = plugins_dir.join(&meta.lib_file);
247    if lib_path.exists() {
248        std::fs::remove_file(&lib_path)?;
249    }
250
251    // Remove the meta file.
252    std::fs::remove_file(&meta_file)?;
253
254    eprintln!("removed plugin '{}'", short);
255    Ok(())
256}
257
258/// Update an installed plugin to the latest (or specified) version.
259pub fn cmd_update(plugins_dir: &Path, name: &str, version: Option<&str>) -> anyhow::Result<()> {
260    let short = short_name(&resolve_crate_name(name));
261    let meta_file = meta_path(plugins_dir, &short);
262
263    if !meta_file.exists() {
264        anyhow::bail!(
265            "plugin '{}' is not installed — use `room plugin install {}` first",
266            short,
267            short
268        );
269    }
270
271    // Remove old installation, then reinstall.
272    cmd_remove(plugins_dir, name)?;
273    cmd_install(plugins_dir, name, version)?;
274    eprintln!("updated plugin '{}'", short);
275    Ok(())
276}
277
278// ── Helpers ─────────────────────────────────────────────────────────────────
279
280/// Search a build directory tree for a shared library matching the expected name.
281fn find_built_lib(build_dir: &Path, lib_name: &str) -> anyhow::Result<PathBuf> {
282    // cargo install --root puts the library in target/release/deps/ or similar.
283    // Walk the tree looking for the file.
284    for entry in walkdir(build_dir) {
285        if let Some(name) = entry.file_name().and_then(|n| n.to_str()) {
286            if name == lib_name {
287                return Ok(entry);
288            }
289        }
290    }
291    anyhow::bail!(
292        "built library '{}' not found in {}",
293        lib_name,
294        build_dir.display()
295    )
296}
297
298/// Simple recursive directory walker (avoids adding a `walkdir` dependency).
299fn walkdir(dir: &Path) -> Vec<PathBuf> {
300    let mut results = Vec::new();
301    if let Ok(entries) = std::fs::read_dir(dir) {
302        for entry in entries.flatten() {
303            let path = entry.path();
304            if path.is_dir() {
305                results.extend(walkdir(&path));
306            } else {
307                results.push(path);
308            }
309        }
310    }
311    results
312}
313
314// ── Tests ───────────────────────────────────────────────────────────────────
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    // ── resolve_crate_name ──────────────────────────────────────────────
321
322    #[test]
323    fn resolve_short_name_prepends_prefix() {
324        assert_eq!(resolve_crate_name("agent"), "room-plugin-agent");
325    }
326
327    #[test]
328    fn resolve_full_name_unchanged() {
329        assert_eq!(
330            resolve_crate_name("room-plugin-taskboard"),
331            "room-plugin-taskboard"
332        );
333    }
334
335    #[test]
336    fn resolve_hyphenated_name() {
337        assert_eq!(resolve_crate_name("my-custom"), "room-plugin-my-custom");
338    }
339
340    // ── short_name ──────────────────────────────────────────────────────
341
342    #[test]
343    fn short_name_strips_prefix() {
344        assert_eq!(short_name("room-plugin-agent"), "agent");
345    }
346
347    #[test]
348    fn short_name_no_prefix() {
349        assert_eq!(short_name("custom"), "custom");
350    }
351
352    // ── lib_filename ────────────────────────────────────────────────────
353
354    #[test]
355    fn lib_filename_replaces_hyphens() {
356        let name = lib_filename("room-plugin-agent");
357        assert!(name.starts_with("libroom_plugin_agent."));
358        // Extension is platform-dependent.
359        assert!(name.ends_with(".so") || name.ends_with(".dylib"));
360    }
361
362    // ── PluginMeta serialization ────────────────────────────────────────
363
364    #[test]
365    fn meta_roundtrip() {
366        let meta = PluginMeta {
367            name: "agent".to_owned(),
368            crate_name: "room-plugin-agent".to_owned(),
369            version: "3.4.0".to_owned(),
370            min_protocol: "3.0.0".to_owned(),
371            lib_file: "libroom_plugin_agent.so".to_owned(),
372        };
373        let json = serde_json::to_string(&meta).unwrap();
374        let parsed: PluginMeta = serde_json::from_str(&json).unwrap();
375        assert_eq!(parsed, meta);
376    }
377
378    #[test]
379    fn meta_pretty_print() {
380        let meta = PluginMeta {
381            name: "taskboard".to_owned(),
382            crate_name: "room-plugin-taskboard".to_owned(),
383            version: "1.0.0".to_owned(),
384            min_protocol: "0.0.0".to_owned(),
385            lib_file: "libroom_plugin_taskboard.so".to_owned(),
386        };
387        let json = serde_json::to_string_pretty(&meta).unwrap();
388        assert!(json.contains("\"name\": \"taskboard\""));
389        assert!(json.contains("\"version\": \"1.0.0\""));
390    }
391
392    // ── scan_installed ──────────────────────────────────────────────────
393
394    #[test]
395    fn scan_empty_dir() {
396        let dir = tempfile::TempDir::new().unwrap();
397        let result = scan_installed(dir.path());
398        assert!(result.is_empty());
399    }
400
401    #[test]
402    fn scan_nonexistent_dir() {
403        let result = scan_installed(Path::new("/nonexistent/plugins"));
404        assert!(result.is_empty());
405    }
406
407    #[test]
408    fn scan_finds_valid_meta_files() {
409        let dir = tempfile::TempDir::new().unwrap();
410        let meta = PluginMeta {
411            name: "test".to_owned(),
412            crate_name: "room-plugin-test".to_owned(),
413            version: "0.1.0".to_owned(),
414            min_protocol: "0.0.0".to_owned(),
415            lib_file: "libroom_plugin_test.so".to_owned(),
416        };
417        let meta_file = dir.path().join("test.meta.json");
418        std::fs::write(&meta_file, serde_json::to_string(&meta).unwrap()).unwrap();
419
420        let result = scan_installed(dir.path());
421        assert_eq!(result.len(), 1);
422        assert_eq!(result[0].name, "test");
423    }
424
425    #[test]
426    fn scan_skips_invalid_json() {
427        let dir = tempfile::TempDir::new().unwrap();
428        std::fs::write(dir.path().join("bad.meta.json"), "not json").unwrap();
429        let result = scan_installed(dir.path());
430        assert!(result.is_empty());
431    }
432
433    #[test]
434    fn scan_skips_non_meta_json() {
435        let dir = tempfile::TempDir::new().unwrap();
436        std::fs::write(dir.path().join("config.json"), "{}").unwrap();
437        let result = scan_installed(dir.path());
438        assert!(result.is_empty());
439    }
440
441    #[test]
442    fn scan_sorts_by_name() {
443        let dir = tempfile::TempDir::new().unwrap();
444        for name in &["zebra", "alpha", "mid"] {
445            let meta = PluginMeta {
446                name: name.to_string(),
447                crate_name: format!("room-plugin-{name}"),
448                version: "0.1.0".to_owned(),
449                min_protocol: "0.0.0".to_owned(),
450                lib_file: format!("libroom_plugin_{name}.so"),
451            };
452            std::fs::write(
453                dir.path().join(format!("{name}.meta.json")),
454                serde_json::to_string(&meta).unwrap(),
455            )
456            .unwrap();
457        }
458        let result = scan_installed(dir.path());
459        let names: Vec<&str> = result.iter().map(|m| m.name.as_str()).collect();
460        assert_eq!(names, vec!["alpha", "mid", "zebra"]);
461    }
462
463    // ── meta_path ───────────────────────────────────────────────────────
464
465    #[test]
466    fn meta_path_format() {
467        let p = meta_path(Path::new("/home/user/.room/plugins"), "agent");
468        assert_eq!(p, PathBuf::from("/home/user/.room/plugins/agent.meta.json"));
469    }
470
471    // ── cmd_remove ──────────────────────────────────────────────────────
472
473    #[test]
474    fn remove_nonexistent_plugin_fails() {
475        let dir = tempfile::TempDir::new().unwrap();
476        let result = cmd_remove(dir.path(), "nonexistent");
477        assert!(result.is_err());
478        assert!(result.unwrap_err().to_string().contains("not installed"));
479    }
480
481    #[test]
482    fn remove_deletes_lib_and_meta() {
483        let dir = tempfile::TempDir::new().unwrap();
484        let meta = PluginMeta {
485            name: "test".to_owned(),
486            crate_name: "room-plugin-test".to_owned(),
487            version: "0.1.0".to_owned(),
488            min_protocol: "0.0.0".to_owned(),
489            lib_file: "libroom_plugin_test.so".to_owned(),
490        };
491        std::fs::write(
492            dir.path().join("test.meta.json"),
493            serde_json::to_string(&meta).unwrap(),
494        )
495        .unwrap();
496        std::fs::write(dir.path().join("libroom_plugin_test.so"), b"fake").unwrap();
497
498        cmd_remove(dir.path(), "test").unwrap();
499        assert!(!dir.path().join("test.meta.json").exists());
500        assert!(!dir.path().join("libroom_plugin_test.so").exists());
501    }
502
503    // ── walkdir ─────────────────────────────────────────────────────────
504
505    #[test]
506    fn walkdir_finds_nested_files() {
507        let dir = tempfile::TempDir::new().unwrap();
508        let nested = dir.path().join("a").join("b");
509        std::fs::create_dir_all(&nested).unwrap();
510        std::fs::write(nested.join("target.so"), b"lib").unwrap();
511        std::fs::write(dir.path().join("top.txt"), b"top").unwrap();
512
513        let files = walkdir(dir.path());
514        assert!(files.iter().any(|p| p.ends_with("target.so")));
515        assert!(files.iter().any(|p| p.ends_with("top.txt")));
516    }
517
518    #[test]
519    fn walkdir_empty_dir() {
520        let dir = tempfile::TempDir::new().unwrap();
521        let files = walkdir(dir.path());
522        assert!(files.is_empty());
523    }
524
525    // ── find_built_lib ──────────────────────────────────────────────────
526
527    #[test]
528    fn find_built_lib_success() {
529        let dir = tempfile::TempDir::new().unwrap();
530        let release = dir.path().join("target").join("release");
531        std::fs::create_dir_all(&release).unwrap();
532        std::fs::write(release.join("libroom_plugin_test.so"), b"elf").unwrap();
533
534        let result = find_built_lib(dir.path(), "libroom_plugin_test.so");
535        assert!(result.is_ok());
536        assert!(result.unwrap().ends_with("libroom_plugin_test.so"));
537    }
538
539    #[test]
540    fn find_built_lib_not_found() {
541        let dir = tempfile::TempDir::new().unwrap();
542        let result = find_built_lib(dir.path(), "nonexistent.so");
543        assert!(result.is_err());
544        assert!(result.unwrap_err().to_string().contains("not found"));
545    }
546
547    // ── cmd_install duplicate check ─────────────────────────────────────
548
549    #[test]
550    fn install_skips_when_already_installed() {
551        let dir = tempfile::TempDir::new().unwrap();
552        let meta = PluginMeta {
553            name: "existing".to_owned(),
554            crate_name: "room-plugin-existing".to_owned(),
555            version: "1.0.0".to_owned(),
556            min_protocol: "0.0.0".to_owned(),
557            lib_file: "libroom_plugin_existing.so".to_owned(),
558        };
559        std::fs::write(
560            dir.path().join("existing.meta.json"),
561            serde_json::to_string(&meta).unwrap(),
562        )
563        .unwrap();
564
565        // Should succeed without error (prints "already installed").
566        let result = cmd_install(dir.path(), "existing", None);
567        assert!(result.is_ok());
568    }
569
570    // ── cmd_update when not installed ───────────────────────────────────
571
572    #[test]
573    fn update_nonexistent_plugin_fails() {
574        let dir = tempfile::TempDir::new().unwrap();
575        let result = cmd_update(dir.path(), "nonexistent", None);
576        assert!(result.is_err());
577        assert!(result.unwrap_err().to_string().contains("not installed"));
578    }
579
580    // ── builtin plugins ─────────────────────────────────────────────────
581
582    #[test]
583    fn builtin_plugins_has_four_entries() {
584        assert_eq!(BUILTIN_PLUGINS.len(), 4);
585    }
586
587    #[test]
588    fn builtin_plugins_includes_expected_names() {
589        let names: Vec<&str> = BUILTIN_PLUGINS.iter().map(|(n, _)| *n).collect();
590        assert!(names.contains(&"agent"));
591        assert!(names.contains(&"taskboard"));
592        assert!(names.contains(&"queue"));
593        assert!(names.contains(&"stats"));
594    }
595
596    #[test]
597    fn builtin_plugins_sorted_alphabetically() {
598        let names: Vec<&str> = BUILTIN_PLUGINS.iter().map(|(n, _)| *n).collect();
599        let mut sorted = names.clone();
600        sorted.sort();
601        assert_eq!(names, sorted);
602    }
603}