From 08c33668996d5065d776d064262ccfd789756b30 Mon Sep 17 00:00:00 2001 From: Brian Cooper Date: Wed, 25 Mar 2026 20:49:15 -0400 Subject: [PATCH 1/2] feat: add ecosystem plugin system Add plugin manifest types with TOML parsing (bin, api, launch), plugin discovery from ~/.config/omni/plugins/ with PATH fallback, plugin execution for bin and launch types, and CLI routing via external_subcommand. Includes integration tests and beacon plugin manifest example. --- .changeset/plugin-system.md | 5 + examples/plugins/beacon/plugin.toml | 35 +++++ src/cli/mod.rs | 20 +++ src/lib.rs | 1 + src/main.rs | 97 +++++++++++++ src/plugin/discovery.rs | 214 ++++++++++++++++++++++++++++ src/plugin/exec.rs | 115 +++++++++++++++ src/plugin/manifest.rs | 177 +++++++++++++++++++++++ src/plugin/mod.rs | 7 + tests/plugin_integration.rs | 79 ++++++++++ 10 files changed, 750 insertions(+) create mode 100644 .changeset/plugin-system.md create mode 100644 examples/plugins/beacon/plugin.toml create mode 100644 src/plugin/discovery.rs create mode 100644 src/plugin/exec.rs create mode 100644 src/plugin/manifest.rs create mode 100644 src/plugin/mod.rs create mode 100644 tests/plugin_integration.rs diff --git a/.changeset/plugin-system.md b/.changeset/plugin-system.md new file mode 100644 index 0000000..e72d9cb --- /dev/null +++ b/.changeset/plugin-system.md @@ -0,0 +1,5 @@ +--- +"@omnidotdev/cli": minor +--- + +Add ecosystem plugin system with three plugin types (bin, api, launch), plugin discovery from config directory and PATH, and CLI routing via external_subcommand diff --git a/examples/plugins/beacon/plugin.toml b/examples/plugins/beacon/plugin.toml new file mode 100644 index 0000000..0e6de7c --- /dev/null +++ b/examples/plugins/beacon/plugin.toml @@ -0,0 +1,35 @@ +name = "beacon" +version = "0.1.0" +description = "AI assistant runtime — voice, messaging, and browser automation" +type = "api" +endpoint = "${BEACON_GATEWAY_URL}" + +[commands.navigate] +description = "Navigate browser to a URL" +method = "POST" +path = "/browser/navigate" + +[commands.screenshot] +description = "Take a browser screenshot" +method = "POST" +path = "/browser/screenshot" + +[commands.click] +description = "Click an element" +method = "POST" +path = "/browser/click" + +[commands.type] +description = "Type text into an element" +method = "POST" +path = "/browser/type" + +[commands.execute] +description = "Execute JavaScript in the browser" +method = "POST" +path = "/browser/execute" + +[commands.status] +description = "Get browser session status" +method = "GET" +path = "/browser/status" diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 637bcf7..dae7c75 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -81,6 +81,26 @@ pub enum Commands { #[command(subcommand)] command: AuthCommands, }, + + /// List installed plugins. + Plugins, + + /// Install a plugin. + Install { + /// Plugin names to install. + #[arg(required = true)] + plugins: Vec, + }, + + /// Remove a plugin. + Uninstall { + /// Plugin name to remove. + plugin: String, + }, + + /// Delegate to an ecosystem plugin. + #[command(external_subcommand)] + External(Vec), } #[derive(Subcommand)] diff --git a/src/lib.rs b/src/lib.rs index 5f3f9dc..4ff71f4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24,6 +24,7 @@ pub mod build_info; pub mod cli; pub mod config; pub mod core; +pub mod plugin; pub mod tui; pub use config::Config; diff --git a/src/main.rs b/src/main.rs index 511623b..2204591 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ use omni_cli::{ Config, cli::{AuthCommands, Cli, Commands, ConfigCommands, SessionCommands, SynapseCommands}, core::session::SessionTarget, + plugin::{PluginDiscovery, PluginType, run_plugin_command}, }; #[tokio::main] @@ -139,6 +140,27 @@ async fn run(cli: Cli) -> anyhow::Result<()> { AuthCommands::Login => omni_cli::cli::auth::login().await?, AuthCommands::Logout => omni_cli::cli::auth::logout()?, }, + + Commands::Plugins => { + handle_plugins_command()?; + } + + Commands::Install { plugins } => { + for name in &plugins { + println!("Installing plugin '{name}' is not yet implemented"); + } + } + + Commands::Uninstall { plugin } => { + handle_uninstall_command(&plugin)?; + } + + Commands::External(args) => { + let code = handle_external_command(&args)?; + if code != 0 { + std::process::exit(code); + } + } } Ok(()) @@ -307,6 +329,81 @@ async fn handle_synapse_command(command: SynapseCommands) -> anyhow::Result<()> Ok(()) } +fn handle_plugins_command() -> anyhow::Result<()> { + let plugins_dir = PluginDiscovery::default_dir()?; + let discovery = PluginDiscovery::new(plugins_dir); + let plugins = discovery.discover_all()?; + + if plugins.is_empty() { + println!("No plugins installed. Use `omni install ` to install one."); + return Ok(()); + } + + println!( + "{:<16} {:<10} {:<8} {}", + "Name", "Version", "Type", "Description" + ); + println!("{}", "-".repeat(60)); + + for plugin in &plugins { + if let Some(manifest) = &plugin.manifest { + let type_str = match manifest.plugin_type { + PluginType::Bin => "bin", + PluginType::Api => "api", + PluginType::Launch => "launch", + }; + println!( + "{:<16} {:<10} {:<8} {}", + manifest.name, manifest.version, type_str, manifest.description + ); + } + } + + Ok(()) +} + +fn handle_uninstall_command(plugin: &str) -> anyhow::Result<()> { + let plugins_dir = PluginDiscovery::default_dir()?; + let plugin_dir = plugins_dir.join(plugin); + + if !plugin_dir.exists() { + anyhow::bail!("plugin '{plugin}' is not installed"); + } + + std::fs::remove_dir_all(&plugin_dir)?; + println!("Uninstalled plugin '{plugin}'"); + Ok(()) +} + +fn handle_external_command(args: &[String]) -> anyhow::Result { + let name = args + .first() + .ok_or_else(|| anyhow::anyhow!("no command specified"))?; + let remaining: Vec<&str> = args[1..].iter().map(String::as_str).collect(); + + let plugins_dir = PluginDiscovery::default_dir()?; + let discovery = PluginDiscovery::new(plugins_dir); + + let plugin = discovery.find(name)?.ok_or_else(|| { + anyhow::anyhow!("unknown command '{name}'. Run `omni install {name}` to install it") + })?; + + match &plugin.manifest { + Some(manifest) => run_plugin_command(manifest, &remaining), + None => { + // PATH-only fallback: run directly + let status = std::process::Command::new(name) + .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}"))?; + Ok(status.code().unwrap_or(1)) + } + } +} + /// Parse a duration string (e.g., "1h", "7d") to seconds fn parse_duration(s: &str) -> anyhow::Result { let s = s.trim(); diff --git a/src/plugin/discovery.rs b/src/plugin/discovery.rs new file mode 100644 index 0000000..2a47dbd --- /dev/null +++ b/src/plugin/discovery.rs @@ -0,0 +1,214 @@ +use std::path::PathBuf; + +use crate::plugin::PluginManifest; + +/// Where the plugin was discovered +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PluginSource { + /// Found in the plugins config directory + PluginDir, + /// Found on PATH via `which` + Path, +} + +/// A discovered plugin with optional manifest +#[derive(Debug, Clone)] +pub struct DiscoveredPlugin { + pub name: String, + pub manifest: Option, + pub source: PluginSource, +} + +/// Discovers plugins from the config directory and PATH +pub struct PluginDiscovery { + plugins_dir: PathBuf, +} + +impl PluginDiscovery { + #[must_use] + pub const fn new(plugins_dir: PathBuf) -> Self { + Self { plugins_dir } + } + + /// Return the default plugins directory + /// + /// # Errors + /// + /// Returns an error if the base directories cannot be determined + pub fn default_dir() -> anyhow::Result { + let base = directories::BaseDirs::new() + .ok_or_else(|| anyhow::anyhow!("cannot determine base directories"))?; + Ok(base.config_dir().join("omni").join("plugins")) + } + + /// Find a plugin by name + /// + /// Checks the plugin directory first, then falls back to PATH + /// + /// # Errors + /// + /// Returns an error if a manifest file exists but cannot be parsed + pub fn find(&self, name: &str) -> anyhow::Result> { + let manifest_path = self.plugins_dir.join(name).join("plugin.toml"); + + if manifest_path.exists() { + let manifest = PluginManifest::from_file(&manifest_path)?; + return Ok(Some(DiscoveredPlugin { + name: name.to_string(), + manifest: Some(manifest), + source: PluginSource::PluginDir, + })); + } + + // Fall back to PATH + if which::which(name).is_ok() { + return Ok(Some(DiscoveredPlugin { + name: name.to_string(), + manifest: None, + source: PluginSource::Path, + })); + } + + Ok(None) + } + + /// Discover all plugins in the plugins directory + /// + /// # Errors + /// + /// Returns an error if a manifest file exists but cannot be parsed + pub fn discover_all(&self) -> anyhow::Result> { + if !self.plugins_dir.exists() { + return Ok(Vec::new()); + } + + let mut plugins = Vec::new(); + let entries = std::fs::read_dir(&self.plugins_dir)?; + + for entry in entries { + let entry = entry?; + if !entry.file_type()?.is_dir() { + continue; + } + + let name = entry.file_name().to_string_lossy().to_string(); + let manifest_path = entry.path().join("plugin.toml"); + + if manifest_path.exists() { + let manifest = PluginManifest::from_file(&manifest_path)?; + plugins.push(DiscoveredPlugin { + name, + manifest: Some(manifest), + source: PluginSource::PluginDir, + }); + } + } + + plugins.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(plugins) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write_manifest(dir: &std::path::Path, name: &str, content: &str) { + let plugin_dir = dir.join(name); + std::fs::create_dir_all(&plugin_dir).unwrap(); + std::fs::write(plugin_dir.join("plugin.toml"), content).unwrap(); + } + + const EDEN_MANIFEST: &str = r#" +name = "eden" +version = "0.1.0" +description = "Developer onboarding preflight checks" +type = "bin" +bin = "eden" +"#; + + const RUNA_MANIFEST: &str = r#" +name = "runa" +version = "0.1.0" +description = "Streamlined project management" +type = "api" +endpoint = "https://runa.omni.dev/api" +"#; + + #[test] + fn discover_from_plugins_dir() { + let dir = tempfile::TempDir::new().unwrap(); + write_manifest(dir.path(), "eden", EDEN_MANIFEST); + write_manifest(dir.path(), "runa", RUNA_MANIFEST); + + let discovery = PluginDiscovery::new(dir.path().to_path_buf()); + let plugins = discovery.discover_all().unwrap(); + assert_eq!(plugins.len(), 2); + } + + #[test] + fn find_by_name_from_dir() { + let dir = tempfile::TempDir::new().unwrap(); + write_manifest(dir.path(), "eden", EDEN_MANIFEST); + + let discovery = PluginDiscovery::new(dir.path().to_path_buf()); + let plugin = discovery.find("eden").unwrap(); + assert!(plugin.is_some()); + let p = plugin.unwrap(); + assert_eq!(p.name, "eden"); + assert!(p.manifest.is_some()); + assert_eq!(p.source, PluginSource::PluginDir); + } + + #[test] + fn find_not_installed_returns_none() { + let dir = tempfile::TempDir::new().unwrap(); + let discovery = PluginDiscovery::new(dir.path().to_path_buf()); + let plugin = discovery.find("nonexistent").unwrap(); + assert!(plugin.is_none()); + } + + #[test] + fn find_falls_back_to_path() { + let dir = tempfile::TempDir::new().unwrap(); + let discovery = PluginDiscovery::new(dir.path().to_path_buf()); + 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()); + } + + #[test] + fn plugin_dir_takes_priority_over_path() { + let dir = tempfile::TempDir::new().unwrap(); + let manifest = r#" +name = "ls" +version = "0.1.0" +description = "Custom ls plugin" +type = "bin" +bin = "ls" +"#; + write_manifest(dir.path(), "ls", manifest); + + let discovery = PluginDiscovery::new(dir.path().to_path_buf()); + let plugin = discovery.find("ls").unwrap(); + assert!(plugin.is_some()); + assert_eq!(plugin.unwrap().source, PluginSource::PluginDir); + } + + #[test] + fn empty_plugins_dir_returns_empty() { + let dir = tempfile::TempDir::new().unwrap(); + let discovery = PluginDiscovery::new(dir.path().to_path_buf()); + let plugins = discovery.discover_all().unwrap(); + assert!(plugins.is_empty()); + } + + #[test] + fn nonexistent_dir_returns_empty() { + let discovery = PluginDiscovery::new(PathBuf::from("/nonexistent/plugins")); + let plugins = discovery.discover_all().unwrap(); + assert!(plugins.is_empty()); + } +} diff --git a/src/plugin/exec.rs b/src/plugin/exec.rs new file mode 100644 index 0000000..18b9ef7 --- /dev/null +++ b/src/plugin/exec.rs @@ -0,0 +1,115 @@ +use std::process::Command; + +use crate::plugin::{PluginManifest, PluginType}; + +/// Resolve the binary name and args for a plugin command +/// +/// # Errors +/// +/// Returns an error if the plugin type requires a binary but none is specified +pub fn resolve_bin_and_args<'a>( + manifest: &PluginManifest, + args: &'a [&'a str], +) -> anyhow::Result<(String, &'a [&'a str])> { + match manifest.plugin_type { + PluginType::Bin | PluginType::Launch => { + let bin = manifest + .bin + .as_ref() + .ok_or_else(|| { + anyhow::anyhow!( + "plugin '{}' has type {:?} but no bin field", + manifest.name, + manifest.plugin_type + ) + })? + .clone(); + Ok((bin, args)) + } + PluginType::Api => { + anyhow::bail!( + "plugin '{}' is an API adapter and cannot be executed as a binary", + manifest.name + ); + } + } +} + +/// Execute a plugin command by spawning the resolved binary +/// +/// Returns the process exit code +/// +/// # Errors +/// +/// Returns an error if the binary cannot be found or spawned +pub fn run_plugin_command(manifest: &PluginManifest, args: &[&str]) -> anyhow::Result { + let (bin, args) = resolve_bin_and_args(manifest, args)?; + + let status = Command::new(&bin) + .args(args) + .stdin(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .status() + .map_err(|e| anyhow::anyhow!("failed to execute '{bin}': {e}"))?; + + Ok(status.code().unwrap_or(1)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::plugin::{PluginManifest, PluginType}; + + fn make_manifest(plugin_type: PluginType, bin: Option<&str>) -> PluginManifest { + PluginManifest { + name: "test".to_string(), + version: "0.1.0".to_string(), + description: "test".to_string(), + plugin_type, + bin: bin.map(String::from), + endpoint: None, + commands: Default::default(), + packages: None, + } + } + + #[test] + fn resolve_bin_for_bin_plugin() { + let manifest = make_manifest(PluginType::Bin, Some("echo")); + let (bin, args) = resolve_bin_and_args(&manifest, &["hello"]).unwrap(); + assert_eq!(bin, "echo"); + assert_eq!(args, &["hello"]); + } + + #[test] + fn resolve_bin_for_launch_plugin() { + let manifest = make_manifest(PluginType::Launch, Some("echo")); + let empty: &[&str] = &[]; + let (bin, args) = resolve_bin_and_args(&manifest, empty).unwrap(); + assert_eq!(bin, "echo"); + assert!(args.is_empty()); + } + + #[test] + fn resolve_bin_missing_returns_error() { + let manifest = make_manifest(PluginType::Bin, None); + let empty: &[&str] = &[]; + let result = resolve_bin_and_args(&manifest, empty); + assert!(result.is_err()); + } + + #[test] + fn run_bin_plugin_succeeds() { + let manifest = make_manifest(PluginType::Bin, Some("echo")); + let code = run_plugin_command(&manifest, &["hello"]).unwrap(); + assert_eq!(code, 0); + } + + #[test] + fn run_missing_binary_returns_error() { + let manifest = make_manifest(PluginType::Bin, Some("nonexistent-binary-12345")); + let result = run_plugin_command(&manifest, &[]); + assert!(result.is_err()); + } +} diff --git a/src/plugin/manifest.rs b/src/plugin/manifest.rs new file mode 100644 index 0000000..9dfa0a2 --- /dev/null +++ b/src/plugin/manifest.rs @@ -0,0 +1,177 @@ +use std::collections::HashMap; +use std::path::Path; + +use serde::Deserialize; + +/// Plugin type determines how the CLI delegates to the plugin +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PluginType { + /// Delegates to an external binary + Bin, + /// Makes HTTP requests to an API endpoint + Api, + /// Launches a desktop application + Launch, +} + +/// A command exposed by a plugin +#[derive(Debug, Clone, Deserialize)] +pub struct CommandDef { + pub description: String, + /// HTTP method for API plugins + pub method: Option, + /// URL path for API plugins + pub path: Option, +} + +/// System package manager hints for installation +#[derive(Debug, Clone, Deserialize)] +pub struct PackageHints { + pub aur: Option, + pub homebrew: Option, +} + +/// Plugin manifest parsed from plugin.toml +#[derive(Debug, Clone, Deserialize)] +pub struct PluginManifest { + pub name: String, + pub version: String, + pub description: String, + #[serde(rename = "type")] + pub plugin_type: PluginType, + /// Binary name for bin/launch plugins + pub bin: Option, + /// API endpoint for api plugins + pub endpoint: Option, + /// Commands exposed by this plugin + #[serde(default)] + pub commands: HashMap, + /// System package manager hints + pub packages: Option, +} + +impl PluginManifest { + /// Read and parse a plugin manifest from a TOML file + /// + /// # Errors + /// + /// Returns an error if the file cannot be read or parsed + pub fn from_file(path: &Path) -> anyhow::Result { + let contents = std::fs::read_to_string(path)?; + let manifest: Self = toml::from_str(&contents)?; + Ok(manifest) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const BIN_MANIFEST: &str = r#" +name = "eden" +version = "0.1.0" +description = "Developer onboarding preflight checks" +type = "bin" +bin = "eden" + +[commands.check] +description = "Run preflight checks" + +[commands.init] +description = "Initialize a new project environment" +"#; + + const API_MANIFEST: &str = r#" +name = "runa" +version = "0.1.0" +description = "Streamlined project management" +type = "api" +endpoint = "https://runa.omni.dev/api" + +[commands.list] +description = "List projects" +method = "GET" +path = "/projects" + +[commands.create] +description = "Create a new task" +method = "POST" +path = "/tasks" +"#; + + const LAUNCH_MANIFEST: &str = r#" +name = "terminal" +version = "0.1.0" +description = "GPU-accelerated terminal emulator" +type = "launch" +bin = "omni-terminal" +"#; + + #[test] + fn parse_bin_manifest() { + let manifest: PluginManifest = toml::from_str(BIN_MANIFEST).unwrap(); + assert_eq!(manifest.name, "eden"); + assert_eq!(manifest.plugin_type, PluginType::Bin); + assert_eq!(manifest.bin.as_deref(), Some("eden")); + assert_eq!(manifest.commands.len(), 2); + assert!(manifest.commands.contains_key("check")); + } + + #[test] + fn parse_api_manifest() { + let manifest: PluginManifest = toml::from_str(API_MANIFEST).unwrap(); + assert_eq!(manifest.name, "runa"); + assert_eq!(manifest.plugin_type, PluginType::Api); + assert_eq!( + manifest.endpoint.as_deref(), + Some("https://runa.omni.dev/api") + ); + let list_cmd = &manifest.commands["list"]; + assert_eq!(list_cmd.method.as_deref(), Some("GET")); + assert_eq!(list_cmd.path.as_deref(), Some("/projects")); + } + + #[test] + fn parse_launch_manifest() { + let manifest: PluginManifest = toml::from_str(LAUNCH_MANIFEST).unwrap(); + assert_eq!(manifest.plugin_type, PluginType::Launch); + assert_eq!(manifest.bin.as_deref(), Some("omni-terminal")); + } + + #[test] + fn from_file_reads_toml() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("plugin.toml"); + std::fs::write(&path, BIN_MANIFEST).unwrap(); + let manifest = PluginManifest::from_file(&path).unwrap(); + assert_eq!(manifest.name, "eden"); + } + + #[test] + fn from_file_missing_returns_error() { + let result = PluginManifest::from_file(std::path::Path::new("/nonexistent/plugin.toml")); + assert!(result.is_err()); + } + + #[test] + fn invalid_type_returns_error() { + let bad = r#" +name = "bad" +version = "0.1.0" +description = "bad" +type = "invalid" +"#; + let result: Result = toml::from_str(bad); + assert!(result.is_err()); + } + + #[test] + fn missing_required_fields_returns_error() { + let bad = r#" +name = "bad" +"#; + let result: Result = toml::from_str(bad); + assert!(result.is_err()); + } +} diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs new file mode 100644 index 0000000..4e0bb97 --- /dev/null +++ b/src/plugin/mod.rs @@ -0,0 +1,7 @@ +pub mod discovery; +pub mod exec; +pub mod manifest; + +pub use discovery::{DiscoveredPlugin, PluginDiscovery, PluginSource}; +pub use exec::{resolve_bin_and_args, run_plugin_command}; +pub use manifest::{CommandDef, PackageHints, PluginManifest, PluginType}; diff --git a/tests/plugin_integration.rs b/tests/plugin_integration.rs new file mode 100644 index 0000000..65866fb --- /dev/null +++ b/tests/plugin_integration.rs @@ -0,0 +1,79 @@ +use tempfile::TempDir; + +use omni_cli::plugin::{PluginDiscovery, PluginType, run_plugin_command}; + +fn setup_plugin_dir(dir: &TempDir, name: &str, manifest: &str) { + let plugin_dir = dir.path().join(name); + std::fs::create_dir_all(&plugin_dir).unwrap(); + std::fs::write(plugin_dir.join("plugin.toml"), manifest).unwrap(); +} + +#[test] +fn discover_and_list_plugins() { + let dir = TempDir::new().unwrap(); + setup_plugin_dir( + &dir, + "eden", + r#" +name = "eden" +version = "0.1.0" +description = "Dev onboarding" +type = "bin" +bin = "echo" +"#, + ); + setup_plugin_dir( + &dir, + "runa", + r#" +name = "runa" +version = "0.1.0" +description = "Project management" +type = "api" +endpoint = "https://runa.omni.dev/api" +"#, + ); + + let discovery = PluginDiscovery::new(dir.path().to_path_buf()); + let plugins = discovery.discover_all().unwrap(); + + assert_eq!(plugins.len(), 2); + assert_eq!(plugins[0].name, "eden"); + assert_eq!(plugins[1].name, "runa"); +} + +#[test] +fn discover_find_and_execute_bin_plugin() { + let dir = TempDir::new().unwrap(); + setup_plugin_dir( + &dir, + "test-plugin", + r#" +name = "test-plugin" +version = "0.1.0" +description = "Test plugin" +type = "bin" +bin = "echo" +"#, + ); + + let discovery = PluginDiscovery::new(dir.path().to_path_buf()); + let plugin = discovery.find("test-plugin").unwrap().unwrap(); + let manifest = plugin.manifest.unwrap(); + + assert_eq!(manifest.plugin_type, PluginType::Bin); + + let code = run_plugin_command(&manifest, &["hello"]).unwrap(); + assert_eq!(code, 0); +} + +#[test] +fn path_fallback_when_no_manifest() { + let dir = TempDir::new().unwrap(); + let discovery = PluginDiscovery::new(dir.path().to_path_buf()); + + // "echo" should be found on PATH + let plugin = discovery.find("echo").unwrap(); + assert!(plugin.is_some()); + assert!(plugin.unwrap().manifest.is_none()); +} From 65db92a22482924718e104445e5cc3a5e18742e4 Mon Sep 17 00:00:00 2001 From: Brian Cooper Date: Thu, 26 Mar 2026 21:50:29 -0400 Subject: [PATCH 2/2] chore(deps): upgrade rand, toml, which and CI actions - rand 0.9 -> 0.10 (migrate to RngExt trait) - toml 0.9 -> 1 - which 7 -> 8 - actions/checkout v4 -> v6 - softprops/action-gh-release v1 -> v2 - patch crates-io agent-core to prevent resolution collision --- .github/workflows/release.yml | 8 ++-- .github/workflows/test.yml | 4 +- Cargo.lock | 81 ++++++++++++++++++++++------------- Cargo.toml | 6 +-- src/config/mod.rs | 2 +- src/core/agent/tools.rs | 2 +- src/core/session/mod.rs | 4 +- src/core/session/share.rs | 2 +- src/main.rs | 44 +++++++++---------- 9 files changed, 86 insertions(+), 67 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ec6a9f0..7f11a66 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: published: ${{ steps.changesets.outputs.published }} version_commit: ${{ steps.detect_version_commit.outputs.version_commit }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 - uses: oven-sh/setup-bun@v2 @@ -69,7 +69,7 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@stable with: @@ -104,7 +104,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Get version from Cargo.toml id: version @@ -134,7 +134,7 @@ jobs: echo "${EOF}" >> "$GITHUB_OUTPUT" - name: Create Release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 with: tag_name: v${{ steps.version.outputs.version }} name: v${{ steps.version.outputs.version }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dab5472..811b2a0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,7 +12,7 @@ jobs: timeout-minutes: 10 runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@stable with: components: clippy, rustfmt @@ -28,7 +28,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Check version sync run: | PKG_VERSION=$(jq -r '.version' package.json) diff --git a/Cargo.lock b/Cargo.lock index c98bb0a..07e65e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -430,6 +430,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.0", +] + [[package]] name = "chrono" version = "0.4.44" @@ -566,6 +577,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -875,12 +895,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "env_home" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" - [[package]] name = "equivalent" version = "1.0.2" @@ -1201,6 +1215,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.0", "wasip2", "wasip3", ] @@ -2251,7 +2266,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "pulldown-cmark 0.13.3", - "rand 0.9.2", + "rand 0.10.0", "ratatui", "regex", "reqwest 0.13.2", @@ -2268,7 +2283,7 @@ dependencies = [ "tokio", "tokio-stream", "tokio-test", - "toml 0.9.12+spec-1.1.0", + "toml 1.1.0+spec-1.1.0", "tower", "tower-http", "tracing", @@ -2656,6 +2671,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.0", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -2694,6 +2720,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" + [[package]] name = "ratatui" version = "0.30.0" @@ -3289,7 +3321,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -3341,9 +3373,9 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "similar" @@ -3793,17 +3825,17 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.12+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +checksum = "f8195ca05e4eb728f4ba94f3e3291661320af739c4e43779cbdfae82ab239fcc" dependencies = [ "indexmap", "serde_core", "serde_spanned 1.1.0", - "toml_datetime 0.7.5+spec-1.1.0", + "toml_datetime 1.1.0+spec-1.1.0", "toml_parser", "toml_writer", - "winnow 0.7.15", + "winnow 1.0.0", ] [[package]] @@ -3817,9 +3849,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.5+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" dependencies = [ "serde_core", ] @@ -4434,14 +4466,11 @@ dependencies = [ [[package]] name = "which" -version = "7.0.3" +version = "8.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" +checksum = "81995fafaaaf6ae47a7d0cc83c67caf92aeb7e5331650ae6ff856f7c0c60c459" dependencies = [ - "either", - "env_home", - "rustix", - "winsafe", + "libc", ] [[package]] @@ -4797,12 +4826,6 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" -[[package]] -name = "winsafe" -version = "0.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" - [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index 73617a2..930414c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,7 +62,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } # Config directories = "6" dirs = "6" -toml = "0.9" +toml = "1" # Async utilities futures = "0.3" @@ -102,7 +102,7 @@ regex = "1" # Permission system uuid = { version = "1", features = ["v4"] } -rand = "0.9.2" +rand = "0.10" rpassword = "7" hex = "0.4" chrono = { version = "0.4.43", features = ["serde"] } @@ -111,7 +111,7 @@ ulid = "1" indexmap = "2" # LSP integration -which = "7" +which = "8" [dev-dependencies] cargo-husky = { version = "1", default-features = false, features = ["precommit-hook", "run-cargo-fmt", "run-cargo-clippy", "run-cargo-test"] } diff --git a/src/config/mod.rs b/src/config/mod.rs index 33137f5..1cb315d 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -273,7 +273,7 @@ impl ApiConfig { /// Generate a new random API token. #[must_use] pub fn generate_token() -> String { - use rand::Rng; + use rand::RngExt; let mut rng = rand::rng(); let bytes: [u8; 32] = rng.random(); format!("omni_{}", hex::encode(bytes)) diff --git a/src/core/agent/tools.rs b/src/core/agent/tools.rs index 0f312c5..68f3f54 100644 --- a/src/core/agent/tools.rs +++ b/src/core/agent/tools.rs @@ -1896,7 +1896,7 @@ impl ToolRegistry { let status = input["status"].as_str().unwrap_or("pending"); let priority = input["priority"].as_str().map(String::from); - let id = format!("{:04}", rand::random::()); + let id = format!("{:04}", rand::RngExt::random::(&mut rand::rng())); let item = TodoItem { id: id.clone(), content: content.to_string(), diff --git a/src/core/session/mod.rs b/src/core/session/mod.rs index 3f54676..43686a5 100644 --- a/src/core/session/mod.rs +++ b/src/core/session/mod.rs @@ -80,12 +80,12 @@ pub fn new_slug() -> String { ]; let nouns = ["fox", "owl", "bear", "wolf", "hawk", "deer", "lynx", "crow"]; - use rand::prelude::IndexedRandom; + use rand::prelude::*; let mut rng = rand::rng(); let adj = adjectives.choose(&mut rng).unwrap_or(&"quick"); let noun = nouns.choose(&mut rng).unwrap_or(&"fox"); - let num: u16 = rand::Rng::random_range(&mut rng, 100..1000); + let num: u16 = rng.random_range(100..1000); format!("{adj}-{noun}-{num}") } diff --git a/src/core/session/share.rs b/src/core/session/share.rs index f24d088..729c310 100644 --- a/src/core/session/share.rs +++ b/src/core/session/share.rs @@ -47,7 +47,7 @@ impl ShareToken { /// Generate a short share token (8 chars, URL-safe) fn generate_token() -> String { - use rand::Rng; + use rand::RngExt; const CHARS: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789"; let mut rng = rand::rng(); (0..8) diff --git a/src/main.rs b/src/main.rs index 2204591..19bf6c5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -339,10 +339,7 @@ fn handle_plugins_command() -> anyhow::Result<()> { return Ok(()); } - println!( - "{:<16} {:<10} {:<8} {}", - "Name", "Version", "Type", "Description" - ); + println!("{:<16} {:<10} {:<8} Description", "Name", "Version", "Type",); println!("{}", "-".repeat(60)); for plugin in &plugins { @@ -362,16 +359,16 @@ fn handle_plugins_command() -> anyhow::Result<()> { Ok(()) } -fn handle_uninstall_command(plugin: &str) -> anyhow::Result<()> { - let plugins_dir = PluginDiscovery::default_dir()?; - let plugin_dir = plugins_dir.join(plugin); +fn handle_uninstall_command(name: &str) -> anyhow::Result<()> { + let base = PluginDiscovery::default_dir()?; + let target = base.join(name); - if !plugin_dir.exists() { - anyhow::bail!("plugin '{plugin}' is not installed"); + if !target.exists() { + anyhow::bail!("plugin '{name}' is not installed"); } - std::fs::remove_dir_all(&plugin_dir)?; - println!("Uninstalled plugin '{plugin}'"); + std::fs::remove_dir_all(&target)?; + println!("Uninstalled plugin '{name}'"); Ok(()) } @@ -388,19 +385,18 @@ fn handle_external_command(args: &[String]) -> anyhow::Result { anyhow::anyhow!("unknown command '{name}'. Run `omni install {name}` to install it") })?; - match &plugin.manifest { - Some(manifest) => run_plugin_command(manifest, &remaining), - None => { - // PATH-only fallback: run directly - let status = std::process::Command::new(name) - .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}"))?; - Ok(status.code().unwrap_or(1)) - } + if let Some(manifest) = &plugin.manifest { + run_plugin_command(manifest, &remaining) + } else { + // PATH-only fallback: run directly + let status = std::process::Command::new(name) + .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}"))?; + Ok(status.code().unwrap_or(1)) } }