Skip to main content

harmont_cli/builtin/
plugin.rs

1//! Implementation of `hm plugin {list, info, install, remove}`.
2//!
3//! `list` and `info` read the real [`PluginRegistry`]; `install` and
4//! `remove` operate on the on-disk install dir via
5//! [`crate::plugin::install`] and [`crate::plugin::paths`].
6
7use anyhow::{Context, Result};
8
9use crate::cli::PluginCommand;
10use crate::plugin::{PluginRegistry, RegistryConfig, paths};
11
12/// Dispatch a parsed `hm plugin <subcommand>` to its handler.
13///
14/// # Errors
15///
16/// Surfaces registry-load failures from [`PluginRegistry::load`] for
17/// `list`/`info`, "no such plugin" for `info <unknown>`, network/IO
18/// failures and SHA-256 mismatches for `install`, and "no plugin file"
19/// for `remove`.
20pub async fn run(cmd: PluginCommand) -> Result<()> {
21    match cmd {
22        PluginCommand::List => list().await,
23        PluginCommand::Info { name } => info(&name).await,
24        PluginCommand::Install { source, pin } => install_cmd(&source, pin.as_deref()).await,
25        PluginCommand::Remove { name } => remove(&name).await,
26    }
27}
28
29// `println!` is the user-facing output for `hm plugin list`; this is
30// the intended sink, not a debug-print left behind.
31#[allow(clippy::print_stdout)]
32// The dispatcher signature in `commands::dispatch` is `async`, but the
33// body is currently synchronous — the registry load is CPU-bound.
34#[allow(clippy::unused_async)]
35async fn list() -> Result<()> {
36    let reg = PluginRegistry::load(RegistryConfig {
37        auto_discover: true,
38        ..Default::default()
39    })?;
40    if reg.manifests().count() == 0 {
41        println!("No plugins installed.");
42        println!();
43        println!("Plugins live in:");
44        if let Some(p) = paths::user_plugins_dir() {
45            println!("  {}", p.display());
46        }
47        if let Some(p) = paths::project_plugins_dir() {
48            println!("  {}", p.display());
49        }
50        println!();
51        println!("Install one with `hm plugin install <path-or-url>`.");
52        return Ok(());
53    }
54    println!("{:<28} {:>10}  capabilities", "name", "version");
55    for m in reg.manifests() {
56        let caps: Vec<String> = m.capabilities.iter().map(capability_summary).collect();
57        println!("{:<28} {:>10}  {}", m.name, m.version, caps.join(", "));
58    }
59    Ok(())
60}
61
62// `println!` is the user-facing output for `hm plugin info`; intended.
63#[allow(clippy::print_stdout)]
64#[allow(clippy::unused_async)]
65async fn info(name: &str) -> Result<()> {
66    let reg = PluginRegistry::load(RegistryConfig {
67        auto_discover: true,
68        ..Default::default()
69    })?;
70    let m = reg
71        .manifests()
72        .find(|m| m.name == name)
73        .with_context(|| format!("no plugin named '{name}' is installed"))?;
74    let json = serde_json::to_string_pretty(m)?;
75    println!("{json}");
76    Ok(())
77}
78
79// `println!` is the user-facing success line for `hm plugin install`.
80#[allow(clippy::print_stdout)]
81async fn install_cmd(source: &str, pin: Option<&str>) -> Result<()> {
82    let path = crate::plugin::install::install(source, pin).await?;
83    println!("Installed plugin to {}", path.display());
84    Ok(())
85}
86
87// `println!` is the user-facing success line for `hm plugin remove`.
88#[allow(clippy::print_stdout)]
89#[allow(clippy::unused_async)]
90async fn remove(name: &str) -> Result<()> {
91    let dir = crate::plugin::paths::install_dir().context("no install dir")?;
92    let target = dir.join(format!("{name}.wasm"));
93    if !target.is_file() {
94        anyhow::bail!("no plugin file at {}", target.display());
95    }
96    std::fs::remove_file(&target).context("remove plugin")?;
97    println!("Removed {}", target.display());
98    Ok(())
99}
100
101fn capability_summary(cap: &hm_plugin_protocol::Capability) -> String {
102    use hm_plugin_protocol::Capability::{
103        LifecycleHook, OutputFormatter, StepExecutor, Subcommand,
104    };
105    match cap {
106        Subcommand(s) => format!("subcmd:{}", s.verb),
107        StepExecutor(s) => {
108            if s.default {
109                format!("runner:{}(*)", s.runner)
110            } else {
111                format!("runner:{}", s.runner)
112            }
113        }
114        LifecycleHook(_) => "hook".into(),
115        OutputFormatter(s) => format!("format:{}", s.name),
116    }
117}