Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/plugin-install.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@omnidotdev/cli": minor
---

Implement plugin install command with embedded registry, package manager detection (Homebrew, AUR), env var expansion in manifest endpoints, and scoped PATH fallback to `omni-` prefixed binaries
11 changes: 6 additions & 5 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use omni_cli::{
Config,
cli::{AuthCommands, Cli, Commands, ConfigCommands, SessionCommands, SynapseCommands},
core::session::SessionTarget,
plugin::{PluginDiscovery, PluginType, run_plugin_command},
plugin::{PluginDiscovery, PluginType, install_plugin, run_plugin_command},
};

#[tokio::main]
Expand Down Expand Up @@ -147,7 +147,7 @@ async fn run(cli: Cli) -> anyhow::Result<()> {

Commands::Install { plugins } => {
for name in &plugins {
println!("Installing plugin '{name}' is not yet implemented");
install_plugin(name).await?;
}
}

Expand Down Expand Up @@ -388,14 +388,15 @@ fn handle_external_command(args: &[String]) -> anyhow::Result<i32> {
if let Some(manifest) = &plugin.manifest {
run_plugin_command(manifest, &remaining)
} else {
// PATH-only fallback: run directly
let status = std::process::Command::new(name)
// PATH-only fallback: run omni-<name> binary
let bin = format!("omni-{name}");
let status = std::process::Command::new(&bin)
.args(&remaining)
.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit())
.status()
.map_err(|e| anyhow::anyhow!("failed to execute '{name}': {e}"))?;
.map_err(|e| anyhow::anyhow!("failed to execute '{bin}': {e}"))?;
Ok(status.code().unwrap_or(1))
}
}
Expand Down
13 changes: 6 additions & 7 deletions src/plugin/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,9 @@ impl PluginDiscovery {
}));
}

// Fall back to PATH
if which::which(name).is_ok() {
// Fall back to PATH with `omni-` prefix convention (like git/cargo)
let prefixed = format!("omni-{name}");
if which::which(&prefixed).is_ok() {
return Ok(Some(DiscoveredPlugin {
name: name.to_string(),
manifest: None,
Expand Down Expand Up @@ -169,14 +170,12 @@ endpoint = "https://runa.omni.dev/api"
}

#[test]
fn find_falls_back_to_path() {
fn find_does_not_fall_back_to_unprefixed_path() {
let dir = tempfile::TempDir::new().unwrap();
let discovery = PluginDiscovery::new(dir.path().to_path_buf());
// "ls" exists on PATH but "omni-ls" does not
let plugin = discovery.find("ls").unwrap();
assert!(plugin.is_some());
let p = plugin.unwrap();
assert_eq!(p.source, PluginSource::Path);
assert!(p.manifest.is_none());
assert!(plugin.is_none());
}

#[test]
Expand Down
221 changes: 221 additions & 0 deletions src/plugin/install.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
use std::path::Path;

use crate::plugin::{PluginDiscovery, PluginManifest, registry};

/// Embedded plugin registry (offline fallback)
const REGISTRY: &[(&str, &str)] = &[(
"beacon",
include_str!("../../examples/plugins/beacon/plugin.toml"),
)];

/// Detect the available system package manager
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PackageManager {
Homebrew,
Aur,
}

impl PackageManager {
fn detect() -> Option<Self> {
if which::which("brew").is_ok() {
return Some(Self::Homebrew);
}

// Prefer AUR helpers over raw pacman
if which::which("yay").is_ok() || which::which("paru").is_ok() {
return Some(Self::Aur);
}

None
}

fn install_cmd(self, package: &str) -> Vec<String> {
match self {
Self::Homebrew => vec!["brew".into(), "install".into(), package.into()],
Self::Aur => {
let helper = if which::which("paru").is_ok() {
"paru"
} else {
"yay"
};
vec![
helper.into(),
"-S".into(),
"--noconfirm".into(),
package.into(),
]
}
}
}

fn package_field(self, manifest: &PluginManifest) -> Option<&str> {
let packages = manifest.packages.as_ref()?;
match self {
Self::Homebrew => packages.homebrew.as_deref(),
Self::Aur => packages.aur.as_deref(),
}
}
}

/// Resolve a plugin manifest by trying the remote registry first, then the
/// embedded fallback. Returns the manifest and TOML string to write to disk.
async fn resolve_manifest(name: &str) -> anyhow::Result<(PluginManifest, String)> {
let base_url = registry::registry_url();

match registry::fetch_plugin(&base_url, name).await {
Ok(manifest) => {
let toml_str = toml::to_string_pretty(&manifest)
.map_err(|e| anyhow::anyhow!("failed to serialize manifest: {e}"))?;
Ok((manifest, toml_str))
}
Err(e) => {
tracing::debug!("remote registry unavailable, using embedded fallback: {e}");

let toml_str = REGISTRY
.iter()
.find(|(n, _)| *n == name)
.map(|(_, toml)| *toml)
.ok_or_else(|| anyhow::anyhow!("unknown plugin '{name}'"))?;

let manifest: PluginManifest = toml::from_str(toml_str)
.map_err(|e| anyhow::anyhow!("invalid manifest for '{name}': {e}"))?;

Ok((manifest, toml_str.to_string()))
}
}
}

/// Install a plugin by name
///
/// Fetches the manifest from the remote plugin registry, falling back to
/// the embedded registry if unavailable.
///
/// # Errors
///
/// Returns an error if the plugin is unknown, already installed, or installation fails
pub async fn install_plugin(name: &str) -> anyhow::Result<()> {
let plugins_dir = PluginDiscovery::default_dir()?;
let target = plugins_dir.join(name);

if target.exists() {
anyhow::bail!("plugin '{name}' is already installed");
}

let (manifest, toml_str) = resolve_manifest(name).await?;

// Install binary via package manager if applicable
if manifest.bin.is_some() {
install_binary(name, &manifest)?;
}

// Write manifest to plugins dir
write_manifest(&target, &toml_str)?;

println!("Installed plugin '{name}'");
Ok(())
}

/// Install the binary for a plugin via system package manager
fn install_binary(name: &str, manifest: &PluginManifest) -> anyhow::Result<()> {
// Check if binary is already available
if let Some(ref bin) = manifest.bin {
if which::which(bin).is_ok() {
println!("'{bin}' is already on PATH, skipping binary install");
return Ok(());
}
}

let pm = PackageManager::detect().ok_or_else(|| {
anyhow::anyhow!("no supported package manager found (brew, yay, or paru required)")
})?;

let package = pm.package_field(manifest).ok_or_else(|| {
anyhow::anyhow!("plugin '{name}' has no package hint for the detected package manager")
})?;

println!("Installing '{package}' via {pm:?}...");

let cmd = pm.install_cmd(package);
let status = std::process::Command::new(&cmd[0])
.args(&cmd[1..])
.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit())
.status()
.map_err(|e| anyhow::anyhow!("failed to run package manager: {e}"))?;

if !status.success() {
anyhow::bail!("package manager exited with status {status}");
}

Ok(())
}

/// Write a plugin manifest to the plugins directory
fn write_manifest(target: &Path, manifest_toml: &str) -> anyhow::Result<()> {
std::fs::create_dir_all(target)?;
std::fs::write(target.join("plugin.toml"), manifest_toml)?;
Ok(())
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn registry_contains_known_plugins() {
for (name, toml_str) in REGISTRY {
let manifest: PluginManifest = toml::from_str(toml_str)
.unwrap_or_else(|e| panic!("invalid manifest for '{name}': {e}"));
assert_eq!(manifest.name, *name);
}
}

#[test]
fn detect_package_manager_returns_some_on_ci_or_dev() {
// Just verify it doesn't panic
let _ = PackageManager::detect();
}

#[test]
fn homebrew_install_cmd() {
let cmd = PackageManager::Homebrew.install_cmd("omnidotdev/tap/foo");
assert_eq!(cmd, vec!["brew", "install", "omnidotdev/tap/foo"]);
}

#[test]
fn install_already_installed_errors() {
let dir = tempfile::TempDir::new().unwrap();
let target = dir.path().join("beacon");
std::fs::create_dir_all(&target).unwrap();
std::fs::write(target.join("plugin.toml"), "").unwrap();

// Verify the manifest writes correctly
let result = write_manifest(&dir.path().join("new-plugin"), "name = \"test\"");
assert!(result.is_ok());
assert!(dir.path().join("new-plugin/plugin.toml").exists());
}

#[test]
fn write_manifest_creates_dir_and_file() {
let dir = tempfile::TempDir::new().unwrap();
let target = dir.path().join("test-plugin");
write_manifest(&target, "name = \"test\"").unwrap();
assert!(target.join("plugin.toml").exists());
assert_eq!(
std::fs::read_to_string(target.join("plugin.toml")).unwrap(),
"name = \"test\""
);
}

#[test]
fn manifest_round_trips_through_toml() {
for (_, toml_str) in REGISTRY {
let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
let serialized = toml::to_string_pretty(&manifest).unwrap();
let reparsed: PluginManifest = toml::from_str(&serialized).unwrap();
assert_eq!(manifest.name, reparsed.name);
assert_eq!(manifest.plugin_type, reparsed.plugin_type);
}
}
}
50 changes: 44 additions & 6 deletions src/plugin/manifest.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
use std::collections::HashMap;
use std::path::Path;

use serde::Deserialize;
use regex::Regex;
use serde::{Deserialize, Serialize};

/// Plugin type determines how the CLI delegates to the plugin
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PluginType {
/// Delegates to an external binary
Expand All @@ -16,7 +17,7 @@ pub enum PluginType {
}

/// A command exposed by a plugin
#[derive(Debug, Clone, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommandDef {
pub description: String,
/// HTTP method for API plugins
Expand All @@ -26,14 +27,14 @@ pub struct CommandDef {
}

/// System package manager hints for installation
#[derive(Debug, Clone, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PackageHints {
pub aur: Option<String>,
pub homebrew: Option<String>,
}

/// Plugin manifest parsed from plugin.toml
#[derive(Debug, Clone, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginManifest {
pub name: String,
pub version: String,
Expand All @@ -54,16 +55,32 @@ pub struct PluginManifest {
impl PluginManifest {
/// Read and parse a plugin manifest from a TOML file
///
/// Expands `${VAR}` references in the `endpoint` field
///
/// # Errors
///
/// Returns an error if the file cannot be read or parsed
pub fn from_file(path: &Path) -> anyhow::Result<Self> {
let contents = std::fs::read_to_string(path)?;
let manifest: Self = toml::from_str(&contents)?;
let mut manifest: Self = toml::from_str(&contents)?;

if let Some(ref ep) = manifest.endpoint {
manifest.endpoint = Some(expand_env_vars(ep));
}

Ok(manifest)
}
}

/// Expand `${VAR}` references using environment variables
fn expand_env_vars(input: &str) -> String {
let re = Regex::new(r"\$\{([^}]+)\}").expect("valid regex");
re.replace_all(input, |caps: &regex::Captures| {
std::env::var(&caps[1]).unwrap_or_default()
})
.into_owned()
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -166,6 +183,27 @@ type = "invalid"
assert!(result.is_err());
}

#[test]
fn expand_env_vars_substitutes_known_vars() {
// HOME is always set in test environments
let result = super::expand_env_vars("${HOME}/plugins");
assert!(!result.contains("${HOME}"));
assert!(result.ends_with("/plugins"));
assert!(result.len() > "/plugins".len());
}

#[test]
fn expand_env_vars_missing_var_becomes_empty() {
let result = super::expand_env_vars("${NONEXISTENT_VAR_12345}/api");
assert_eq!(result, "/api");
}

#[test]
fn expand_env_vars_no_vars_unchanged() {
let result = super::expand_env_vars("https://example.com/api");
assert_eq!(result, "https://example.com/api");
}

#[test]
fn missing_required_fields_returns_error() {
let bad = r#"
Expand Down
Loading
Loading