From f52b8f68de7c861acb70cce48a4134d79b7aa12c Mon Sep 17 00:00:00 2001 From: Martin Obe Date: Mon, 1 Jun 2026 17:07:19 +0100 Subject: [PATCH] Closes #256: Add plugin command --- src/commands/completions.rs | 55 +++++++++++++++++- src/commands/plugin.rs | 109 ++++++++++++++++++++++++++++++++---- src/main.rs | 13 +++++ src/plugins/interface.rs | 18 ++++++ src/plugins/loader.rs | 8 +++ src/plugins/registry.rs | 32 ++++++++--- 6 files changed, 212 insertions(+), 23 deletions(-) diff --git a/src/commands/completions.rs b/src/commands/completions.rs index d7bd46ee..ca024f5c 100644 --- a/src/commands/completions.rs +++ b/src/commands/completions.rs @@ -1,7 +1,7 @@ use anyhow::Result; use clap::{CommandFactory, Parser, Subcommand}; use clap_complete::{generate, Shell}; -use std::io; +use std::io::{self, Write}; /// Shell to generate completions for #[derive(Subcommand)] @@ -15,16 +15,65 @@ pub enum CompletionShell { } pub fn handle(shell: CompletionShell) -> Result<()> { - let mut cmd = Cli::command(); let shell = match shell { CompletionShell::Bash => Shell::Bash, CompletionShell::Zsh => Shell::Zsh, CompletionShell::Fish => Shell::Fish, }; - generate(shell, &mut cmd, "starforge", &mut io::stdout()); + let mut buf = Vec::new(); + generate_completion(shell, &mut buf); + + // Append plugin command completions so they are visible in tab completion. + let plugin_cmds = crate::plugins::registry::load_all_registered_commands(); + if !plugin_cmds.is_empty() { + append_plugin_completions(shell, &plugin_cmds, &mut buf); + } + + io::stdout().write_all(&buf)?; Ok(()) } +fn append_plugin_completions( + shell: Shell, + cmds: &[crate::plugins::registry::RegisteredCommand], + buf: &mut Vec, +) { + use std::io::Write; + match shell { + Shell::Fish => { + for cmd in cmds { + let _ = writeln!( + buf, + "complete -c starforge -n '__fish_use_subcommand starforge' -f -a '{}' -d '{}'", + cmd.name, + cmd.description.replace('\'', "\\'") + ); + } + } + Shell::Bash => { + // Inject plugin names into the top-level subcommand list. + let names: Vec<&str> = cmds.iter().map(|c| c.name.as_str()).collect(); + let _ = writeln!( + buf, + "\n# Plugin commands\n_starforge_plugin_cmds='{}'\n", + names.join(" ") + ); + } + Shell::Zsh => { + let _ = writeln!(buf, "\n# Plugin commands"); + for cmd in cmds { + let _ = writeln!( + buf, + "# plugin: {} -- {}", + cmd.name, + cmd.description.replace('\'', "\\'") + ); + } + } + _ => {} + } +} + /// Generate completion script to a writer instead of stdout (used in tests). pub fn generate_completion(shell: Shell, writer: &mut impl io::Write) { let mut cmd = Cli::command(); diff --git a/src/commands/plugin.rs b/src/commands/plugin.rs index e015d848..a550d640 100644 --- a/src/commands/plugin.rs +++ b/src/commands/plugin.rs @@ -1,10 +1,9 @@ use crate::plugins::interface::CORE_VERSION; use crate::plugins::manifest; -use crate::plugins::registry::{self, TrustLevel, UninstallOptions}; +use crate::plugins::registry::{self, RegisteredCommand, TrustLevel, UninstallOptions}; use crate::plugins::PluginManager; use crate::utils::print as p; use anyhow::{Context, Result}; -use chrono; use clap::Subcommand; use std::path::PathBuf; @@ -63,6 +62,11 @@ pub enum PluginCommands { #[arg(long, default_value = "false")] yes: bool, }, + /// List commands registered by installed plugins + Commands { + /// Show commands for a specific plugin only + name: Option, + }, } pub fn handle(cmd: PluginCommands) -> Result<()> { @@ -78,6 +82,7 @@ pub fn handle(cmd: PluginCommands) -> Result<()> { PluginCommands::Uninstall { name, purge, yes } => uninstall(name, purge, yes), PluginCommands::Verify { name } => verify(name), PluginCommands::Update { name, yes } => update(name, yes), + PluginCommands::Commands { name } => commands(name), } } @@ -105,12 +110,29 @@ fn install(name: String, path: Option, source: Option, force: b let plugin_manifest = manifest::require_compatible_manifest(&lib_path, &name)?; + // Load the plugin to discover the commands it registers. + let discovered_commands: Vec = { + let mut pm = PluginManager::new(); + unsafe { + pm.load_plugin(&lib_path) + .with_context(|| format!("Failed to load plugin '{}' to discover commands", name))?; + } + pm.list_commands() + .into_iter() + .map(|c| RegisteredCommand { + name: c.name, + description: c.description, + }) + .collect() + }; + registry::install_plugin( &name, &lib_path, source_str, &plugin_manifest.starforge_version, &plugin_manifest.version, + discovered_commands.clone(), )?; p::header("Plugin Install"); @@ -123,6 +145,12 @@ fn install(name: String, path: Option, source: Option, force: b if !source_str.is_empty() { p::kv("Source", source_str); } + if !discovered_commands.is_empty() { + p::info("Registered commands:"); + for cmd in &discovered_commands { + p::info(&format!(" • {} — {}", cmd.name, cmd.description)); + } + } p::info("Load plugins with: starforge plugin load"); Ok(()) } @@ -316,9 +344,6 @@ fn update(name: Option, yes: bool) -> Result<()> { pl.name )); p::kv(" Path", &pl.path); - if let Some(ref ts) = pl.installed_at { - p::kv(" Installed at", ts); - } skipped += 1; println!(); continue; @@ -356,7 +381,10 @@ fn update(name: Option, yes: bool) -> Result<()> { match status { Ok(s) if s.success() => { - registry::install_plugin(&pl.name, std::path::Path::new(&pl.path), &pl.source)?; + // Re-discover commands from the updated library. + let cmds = discover_commands_from_library(&pl.path) + .unwrap_or_else(|_| pl.commands.clone()); + registry::install_plugin(&pl.name, std::path::Path::new(&pl.path), &pl.source, &pl.starforge_version, &pl.plugin_version, cmds)?; p::success(&format!(" '{}' updated via cargo install", pl.name)); updated += 1; } @@ -388,19 +416,19 @@ fn update(name: Option, yes: bool) -> Result<()> { }) .unwrap_or(0); - let installed_epoch = pl - .installed_at - .as_deref() - .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) - .map(|dt| dt.timestamp() as u64) - .unwrap_or(0); + let installed_epoch = 0u64; // no install timestamp stored; treat as always-stale if modified > installed_epoch { // Library on disk is newer — refresh the registry entry. + let cmds = discover_commands_from_library(&pl.path) + .unwrap_or_else(|_| pl.commands.clone()); registry::install_plugin( &pl.name, std::path::Path::new(&pl.path), &pl.source, + &pl.starforge_version, + &pl.plugin_version, + cmds, )?; p::success(&format!( " '{}' library on disk is newer — registry refreshed.", @@ -518,3 +546,60 @@ fn verify(name: Option) -> Result<()> { Ok(()) } + +fn discover_commands_from_library(path: &str) -> Result> { + let mut pm = PluginManager::new(); + unsafe { + pm.load_plugin(path) + .with_context(|| format!("Failed to load plugin from {}", path))?; + } + Ok(pm + .list_commands() + .into_iter() + .map(|c| RegisteredCommand { + name: c.name, + description: c.description, + }) + .collect()) +} + +fn commands(name: Option) -> Result<()> { + p::header("Plugin Commands"); + + let reg = registry::load_registry().unwrap_or_default(); + if reg.plugins.is_empty() { + p::info("No plugins installed. Use: starforge plugin install --path "); + return Ok(()); + } + + let plugins: Vec<_> = match &name { + Some(n) => { + let found: Vec<_> = reg.plugins.iter().filter(|p| &p.name == n).collect(); + if found.is_empty() { + anyhow::bail!("Plugin '{}' is not installed. Run `starforge plugin list`.", n); + } + found + } + None => reg.plugins.iter().collect(), + }; + + let mut any = false; + for pl in &plugins { + if pl.commands.is_empty() { + continue; + } + any = true; + p::kv_accent("Plugin", &pl.name); + for cmd in &pl.commands { + println!(" starforge {} — {}", cmd.name, cmd.description); + } + println!(); + } + + if !any { + p::info("No commands registered. Re-install plugins to discover their commands."); + p::info(" starforge plugin install --path "); + } + + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 490c5680..38af2380 100644 --- a/src/main.rs +++ b/src/main.rs @@ -206,6 +206,19 @@ fn handle_external_plugin(args: Vec) -> anyhow::Result<()> { ); } + // Check if the command matches any registered plugin command before loading .so files. + let all_commands = plugins::registry::load_all_registered_commands(); + let known = all_commands.iter().any(|c| c.name == *plugin_name); + if !known { + let available: Vec = all_commands.iter().map(|c| format!(" • {}", c.name)).collect(); + let hint = if available.is_empty() { + "No plugin commands registered. Re-install plugins to discover their commands.".to_string() + } else { + format!("Available plugin commands:\n{}", available.join("\n")) + }; + anyhow::bail!("Unknown command '{}'.\n\n{}", plugin_name, hint); + } + // Warn about unknown-trust plugins before loading. for pl in reg .plugins diff --git a/src/plugins/interface.rs b/src/plugins/interface.rs index d1cb4090..8be8e352 100644 --- a/src/plugins/interface.rs +++ b/src/plugins/interface.rs @@ -1,10 +1,28 @@ use std::any::Any; +/// A command (or subcommand) that a plugin exposes to the StarForge CLI. +#[derive(Debug, Clone)] +pub struct PluginCommand { + /// The command name users type, e.g. `"defi"` or `"defi swap"`. + pub name: String, + /// One-line description shown in help and completions. + pub description: String, +} + pub trait Plugin: Any + Send + Sync { fn name(&self) -> &'static str; fn version(&self) -> &'static str; fn description(&self) -> &'static str; + /// Commands this plugin registers. Defaults to a single top-level command + /// named after the plugin itself so existing plugins need no changes. + fn commands(&self) -> Vec { + vec![PluginCommand { + name: self.name().to_string(), + description: self.description().to_string(), + }] + } + fn on_load(&self) {} fn on_unload(&self) {} diff --git a/src/plugins/loader.rs b/src/plugins/loader.rs index 85015659..0cce500f 100644 --- a/src/plugins/loader.rs +++ b/src/plugins/loader.rs @@ -98,6 +98,14 @@ impl PluginManager { .collect() } + /// Returns all `PluginCommand`s advertised by every loaded plugin. + pub fn list_commands(&self) -> Vec { + self.plugins + .values() + .flat_map(|(p, _)| p.commands()) + .collect() + } + pub fn execute(&self, name: &str, args: &[String]) -> Result<(), String> { if let Some((plugin, _)) = self.plugins.get(name) { plugin.execute(args) diff --git a/src/plugins/registry.rs b/src/plugins/registry.rs index b9c2079f..bdecc70d 100644 --- a/src/plugins/registry.rs +++ b/src/plugins/registry.rs @@ -75,6 +75,16 @@ pub struct InstalledPlugin { /// Plugin version from manifest. #[serde(default)] pub plugin_version: String, + /// Commands registered by this plugin (name → description). + #[serde(default)] + pub commands: Vec, +} + +/// A command entry persisted in the registry so it is visible without loading the .so. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RegisteredCommand { + pub name: String, + pub description: String, } fn registry_path() -> Result { @@ -146,27 +156,22 @@ pub fn is_managed_plugin_path(path: &Path) -> bool { /// /// `source` is the URL or identifier where the plugin came from; pass an /// empty string when the user supplied `--path` directly. +/// `commands` is the list of commands the plugin advertises (from `Plugin::commands()`). pub fn install_plugin( name: &str, library_path: &Path, source: &str, starforge_version: &str, plugin_version: &str, + commands: Vec, ) -> Result<()> { if !library_path.exists() { anyhow::bail!("Plugin library not found: {}", library_path.display()); } let trust = classify_source(source); - let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); let mut reg = load_registry().unwrap_or_default(); - // Preserve existing version metadata when re-installing. - let existing_version = reg - .plugins - .iter() - .find(|p| p.name == name) - .and_then(|p| p.version.clone()); reg.plugins.retain(|p| p.name != name); reg.plugins.push(InstalledPlugin { name: name.to_string(), @@ -175,12 +180,23 @@ pub fn install_plugin( trust, starforge_version: starforge_version.to_string(), plugin_version: plugin_version.to_string(), + commands, }); reg.plugins.sort_by(|a, b| a.name.cmp(&b.name)); save_registry(®)?; Ok(()) } +/// Return all commands registered across all installed plugins (read from registry, no .so load). +pub fn load_all_registered_commands() -> Vec { + load_registry() + .unwrap_or_default() + .plugins + .into_iter() + .flat_map(|p| p.commands) + .collect() +} + /// Remove a plugin from the registry and optionally delete its library file. pub fn uninstall_plugin(name: &str, opts: &UninstallOptions) -> Result { let mut reg = load_registry().unwrap_or_default(); @@ -356,7 +372,7 @@ mod tests { fn install_missing_library_fails() { let tmp = TempDir::new().unwrap(); let missing = tmp.path().join("nonexistent.so"); - let result = install_plugin("test", &missing, "", "0.1.0", "1.0.0"); + let result = install_plugin("test", &missing, "", "0.1.0", "1.0.0", vec![]); assert!(result.is_err(), "installing a missing library must fail"); assert!(result.unwrap_err().to_string().contains("not found")); }