From cab7ffb359d6cbf00db83976b31048bfd6b0d790 Mon Sep 17 00:00:00 2001 From: Antigravity Date: Tue, 23 Jun 2026 08:47:59 +0100 Subject: [PATCH] Show plugin descriptions and command aliases in plugin list. Persist Plugin::description() in the registry, render installed plugins and their command aliases via a new print::table helper, and restore the plugin commands subcommand helpers removed in a prior merge. Closes #120 Co-authored-by: Cursor --- src/commands/plugin.rs | 152 +++++++++++++++++++++++++++++----- src/lib.rs | 1 + src/main.rs | 4 +- src/plugins/mod.rs | 2 +- src/plugins/registry.rs | 124 ++++++++++++++++++++++++++- src/utils/horizon.rs | 10 +-- src/utils/print.rs | 67 +++++++++++++++ tests/plugin_lifecycle_e2e.rs | 4 +- 8 files changed, 331 insertions(+), 33 deletions(-) diff --git a/src/commands/plugin.rs b/src/commands/plugin.rs index 281d1107..b640df7d 100644 --- a/src/commands/plugin.rs +++ b/src/commands/plugin.rs @@ -5,6 +5,7 @@ use crate::plugins::{PluginLoadError, PluginManager}; use crate::utils::print as p; use anyhow::{Context, Result}; use clap::Subcommand; +use starforge::utils::config; use std::path::Path; use std::path::PathBuf; @@ -112,7 +113,7 @@ pub fn handle(cmd: PluginCommands) -> Result<()> { fn install(name: String, path: Option, source: Option, force: bool) -> Result<()> { let lib_path = registry::resolve_plugin_library_path(&name, path)?; let source_str = source.as_deref().unwrap_or(""); - let config = crate::utils::config::load().unwrap_or_default(); + let config = config::load().unwrap_or_default(); let trust = registry::classify_source_with_config(source_str, &config); // Warn the user about untrusted sources and require --force to proceed. @@ -134,21 +135,30 @@ 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 = { + // Load the plugin to discover the commands and description it registers. + let (discovered_commands, plugin_description) = { 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() + let commands: Vec = pm + .list_commands() .into_iter() .map(|c| RegisteredCommand { name: c.name, description: c.description, }) - .collect() + .collect(); + let description = pm + .list_plugins() + .into_iter() + .find(|(plugin_name, _, _)| *plugin_name == name) + .map(|(_, desc, _)| desc.to_string()) + .filter(|d| !d.is_empty()) + .unwrap_or_else(|| plugin_manifest.description.clone()); + (commands, description) }; registry::install_plugin( @@ -157,6 +167,7 @@ fn install(name: String, path: Option, source: Option, force: b source_str, &plugin_manifest.starforge_version, &plugin_manifest.version, + &plugin_description, discovered_commands.clone(), )?; @@ -193,20 +204,41 @@ fn list() -> Result<()> { p::kv("StarForge core version", CORE_VERSION); p::separator(); - for (i, pl) in reg.plugins.iter().enumerate() { - println!(" {:>2}. {}", i + 1, pl.name); - p::kv("Path", &pl.path); - p::kv("Trust", pl.trust.label()); - if !pl.source.is_empty() { - p::kv("Source", &pl.source); - } - if !pl.starforge_version.is_empty() { - p::kv("StarForge", &pl.starforge_version); - } - if i < reg.plugins.len() - 1 { - println!(); - } + + let entries = registry::plugin_list_entries(®); + + let plugin_rows: Vec> = entries + .iter() + .map(|entry| { + vec![ + entry.name.clone(), + entry.version.clone(), + entry.trust.label().to_string(), + entry.description.clone(), + ] + }) + .collect(); + p::table(&["Name", "Version", "Trust", "Description"], &plugin_rows); + + let command_rows: Vec> = entries + .iter() + .flat_map(|entry| { + entry.commands.iter().map(|cmd| { + vec![ + entry.name.clone(), + cmd.name.clone(), + cmd.description.clone(), + ] + }) + }) + .collect(); + + if !command_rows.is_empty() { + println!(); + p::info("Commands"); + p::table(&["Plugin", "Command", "Description"], &command_rows); } + p::separator(); Ok(()) } @@ -220,7 +252,7 @@ fn load() -> Result<()> { return Ok(()); } - let config = crate::utils::config::load().unwrap_or_default(); + let config = config::load().unwrap_or_default(); // Warn about any unknown-trust plugins before loading. for pl in reg.plugins.iter().filter(|p| { @@ -353,7 +385,7 @@ fn update(name: Option, yes: bool) -> Result<()> { return Ok(()); } - let config = crate::utils::config::load().unwrap_or_default(); + let config = config::load().unwrap_or_default(); let to_update: Vec<_> = match &name { Some(n) => { @@ -442,6 +474,7 @@ fn update(name: Option, yes: bool) -> Result<()> { &pl.source, &pl.starforge_version, &pl.plugin_version, + &pl.description, pl.commands.clone(), )?; p::success(&format!(" '{}' updated via cargo install", pl.name)); @@ -482,14 +515,15 @@ fn update(name: Option, yes: bool) -> Result<()> { 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()); + let (cmds, description) = discover_plugin_metadata(&pl.path) + .unwrap_or_else(|_| (pl.commands.clone(), pl.description.clone())); registry::install_plugin( &pl.name, std::path::Path::new(&pl.path), &pl.source, &pl.starforge_version, &pl.plugin_version, + &description, cmds, )?; p::success(&format!( @@ -555,7 +589,7 @@ fn verify(name: Option, deep: bool, runtime_check: bool) -> Result<()> { None => reg.plugins.iter().collect(), }; - let config = crate::utils::config::load().unwrap_or_default(); + let config = config::load().unwrap_or_default(); let mut all_ok = true; for pl in &to_check { @@ -882,3 +916,75 @@ fn print_audit_report(report: &AuditReport) { } println!(); } + +fn discover_plugin_metadata(path: &str) -> Result<(Vec, String)> { + let mut pm = PluginManager::new(); + unsafe { + pm.load_plugin(path) + .with_context(|| format!("Failed to load plugin from {}", path))?; + } + let commands = pm + .list_commands() + .into_iter() + .map(|c| RegisteredCommand { + name: c.name, + description: c.description, + }) + .collect(); + let description = pm + .list_plugins() + .into_iter() + .map(|(_, desc, _)| desc.to_string()) + .find(|d| !d.is_empty()) + .unwrap_or_default(); + Ok((commands, description)) +} + +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 entries: Vec<_> = match &name { + Some(n) => { + let found: Vec<_> = registry::plugin_list_entries(®) + .into_iter() + .filter(|entry| entry.name == *n) + .collect(); + if found.is_empty() { + anyhow::bail!( + "Plugin '{}' is not installed. Run `starforge plugin list`.", + n + ); + } + found + } + None => registry::plugin_list_entries(®), + }; + + let rows: Vec> = entries + .iter() + .flat_map(|entry| { + entry.commands.iter().map(|cmd| { + vec![ + entry.name.clone(), + cmd.name.clone(), + cmd.description.clone(), + ] + }) + }) + .collect(); + + if rows.is_empty() { + p::info("No commands registered. Re-install plugins to discover their commands."); + p::info(" starforge plugin install --path "); + return Ok(()); + } + + p::table(&["Plugin", "Command", "Description"], &rows); + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index 25a06164..952b5e11 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1 +1,2 @@ pub mod plugins; +pub mod utils; diff --git a/src/main.rs b/src/main.rs index 85dc3484..91c5d3fe 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,7 +9,7 @@ mod commands; pub use starforge::plugins; -mod utils; +pub use starforge::utils; use clap::{Parser, Subcommand}; use colored::*; @@ -214,7 +214,7 @@ fn handle_external_plugin(args: Vec) -> anyhow::Result<()> { let plugin_name = &args[0]; let plugin_args = &args[1..]; - let cfg = utils::config::load()?; + let cfg = starforge::utils::config::load()?; let reg = plugins::registry::load_registry().unwrap_or_default(); if reg.plugins.is_empty() { anyhow::bail!( diff --git a/src/plugins/mod.rs b/src/plugins/mod.rs index 31749700..761210ac 100644 --- a/src/plugins/mod.rs +++ b/src/plugins/mod.rs @@ -4,4 +4,4 @@ pub mod manifest; pub mod registry; pub use interface::{Plugin, PluginDeclaration, PluginRegistrar}; -pub use loader::PluginManager; +pub use loader::{PluginLoadError, PluginManager}; diff --git a/src/plugins/registry.rs b/src/plugins/registry.rs index a4e120d7..0ce78738 100644 --- a/src/plugins/registry.rs +++ b/src/plugins/registry.rs @@ -1,3 +1,4 @@ +use crate::plugins::manifest; use crate::utils::config::{self, Config}; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; @@ -216,6 +217,57 @@ pub struct InstalledPlugin { /// Commands registered by the plugin at install time. #[serde(default)] pub commands: Vec, + /// Plugin description from `Plugin::description()` at install time. + #[serde(default)] + pub description: String, +} + +/// Bundled metadata for `starforge plugin list` table output. +#[derive(Debug, Clone)] +pub struct PluginListEntry { + pub name: String, + pub version: String, + pub trust: TrustLevel, + pub description: String, + pub commands: Vec, +} + +/// Resolve a display description using registry data and on-disk manifest fallbacks. +pub fn resolve_plugin_description(plugin: &InstalledPlugin) -> String { + if !plugin.description.is_empty() { + return plugin.description.clone(); + } + + if let Ok(Some(mf)) = manifest::load_manifest_for_library(Path::new(&plugin.path)) { + if !mf.description.is_empty() { + return mf.description; + } + } + + plugin + .commands + .first() + .map(|c| c.description.clone()) + .filter(|d| !d.is_empty()) + .unwrap_or_else(|| "(none)".to_string()) +} + +/// Build table rows for every installed plugin. +pub fn plugin_list_entries(reg: &PluginRegistry) -> Vec { + reg.plugins + .iter() + .map(|p| PluginListEntry { + name: p.name.clone(), + version: if p.plugin_version.is_empty() { + "—".to_string() + } else { + p.plugin_version.clone() + }, + trust: p.trust.clone(), + description: resolve_plugin_description(p), + commands: p.commands.clone(), + }) + .collect() } fn registry_path() -> Result { @@ -288,12 +340,14 @@ 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()`). +/// `description` is the plugin summary from `Plugin::description()`. pub fn install_plugin( name: &str, library_path: &Path, source: &str, starforge_version: &str, plugin_version: &str, + description: &str, commands: Vec, ) -> Result<()> { if !library_path.exists() { @@ -314,6 +368,7 @@ pub fn install_plugin( plugin_version: plugin_version.to_string(), installed_at: Some(now), commands, + description: description.to_string(), }); reg.plugins.sort_by(|a, b| a.name.cmp(&b.name)); save_registry(®)?; @@ -572,7 +627,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", vec![]); + 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")); } @@ -609,6 +664,10 @@ mod tests { plugin.commands.is_empty(), "missing commands field should default to an empty list" ); + assert_eq!( + plugin.description, "", + "missing description field should default to empty string" + ); } // ── resolve_plugin_library_path ─────────────────────────────────────────── @@ -627,6 +686,69 @@ mod tests { assert!(result.is_err()); } + #[test] + fn resolve_plugin_description_prefers_registry_field() { + let plugin = InstalledPlugin { + name: "demo".into(), + path: "/tmp/demo.so".into(), + source: String::new(), + trust: TrustLevel::Local, + starforge_version: String::new(), + plugin_version: String::new(), + installed_at: None, + commands: vec![RegisteredCommand { + name: "demo".into(), + description: "from command".into(), + }], + description: "from plugin".into(), + }; + assert_eq!(resolve_plugin_description(&plugin), "from plugin"); + } + + #[test] + fn resolve_plugin_description_falls_back_to_first_command() { + let plugin = InstalledPlugin { + name: "demo".into(), + path: "/tmp/demo.so".into(), + source: String::new(), + trust: TrustLevel::Local, + starforge_version: String::new(), + plugin_version: String::new(), + installed_at: None, + commands: vec![RegisteredCommand { + name: "demo".into(), + description: "from command".into(), + }], + description: String::new(), + }; + assert_eq!(resolve_plugin_description(&plugin), "from command"); + } + + #[test] + fn plugin_list_entries_include_resolved_description() { + let reg = PluginRegistry { + plugins: vec![InstalledPlugin { + name: "trusted".into(), + path: "/tmp/trusted.so".into(), + source: String::new(), + trust: TrustLevel::Trusted, + starforge_version: "0.1.0".into(), + plugin_version: "1.0.0".into(), + installed_at: None, + commands: vec![RegisteredCommand { + name: "trusted".into(), + description: "Lifecycle integration test plugin".into(), + }], + description: "Lifecycle integration test plugin".into(), + }], + }; + + let entries = plugin_list_entries(®); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].description, "Lifecycle integration test plugin"); + assert_eq!(entries[0].commands[0].name, "trusted"); + } + // ── backward compatibility ──────────────────────────────────────────────── #[test] diff --git a/src/utils/horizon.rs b/src/utils/horizon.rs index 770c6291..61bce408 100644 --- a/src/utils/horizon.rs +++ b/src/utils/horizon.rs @@ -594,7 +594,7 @@ mod tests { #[test] fn fetch_account_returns_mocked_account() { - let server = Server::new(); + let mut server = Server::new(); let _guard = TestConfigGuard::new(&server.url(), None); let public_key = "GACCOUNT123"; @@ -620,7 +620,7 @@ mod tests { #[test] fn fetch_account_reports_parse_error_for_invalid_json() { - let server = Server::new(); + let mut server = Server::new(); let _guard = TestConfigGuard::new(&server.url(), None); let _mock = server @@ -636,7 +636,7 @@ mod tests { #[test] fn fund_account_reports_friendbot_error_path() { - let server = Server::new(); + let mut server = Server::new(); let _guard = TestConfigGuard::new(&server.url(), Some(server.url())); let _mock = server @@ -651,7 +651,7 @@ mod tests { #[test] fn build_transaction_query_url_includes_pagination_params() { - let server = Server::new(); + let mut server = Server::new(); let _guard = TestConfigGuard::new(&server.url(), None); let filter = TxFilter { @@ -672,7 +672,7 @@ mod tests { #[test] fn fetch_transactions_filtered_uses_cursor_and_filters_records() { - let server = Server::new(); + let mut server = Server::new(); let _guard = TestConfigGuard::new(&server.url(), None); let _mock = server diff --git a/src/utils/print.rs b/src/utils/print.rs index 9b34bd29..862f0ee5 100644 --- a/src/utils/print.rs +++ b/src/utils/print.rs @@ -81,3 +81,70 @@ pub fn verified_badge(verified: bool) -> colored::ColoredString { "".normal() } } + +/// Print an aligned table with dimmed headers and bright row values. +pub fn table(headers: &[&str], rows: &[Vec]) { + if headers.is_empty() { + return; + } + + let ncol = headers.len(); + let mut widths: Vec = headers.iter().map(|h| h.len()).collect(); + for row in rows { + for (i, cell) in row.iter().enumerate().take(ncol) { + widths[i] = widths[i].max(cell.len()); + } + } + + let header_line = headers + .iter() + .enumerate() + .map(|(i, h)| format!("{:>() + .join(" "); + println!(" {}", header_line.dimmed()); + + for row in rows { + let line = (0..ncol) + .map(|i| { + let val = row.get(i).map(String::as_str).unwrap_or(""); + format!("{:>() + .join(" "); + println!(" {}", line.bright_white()); + } +} + +#[cfg(test)] +mod tests { + fn column_widths(headers: &[&str], rows: &[Vec]) -> Vec { + let ncol = headers.len(); + let mut widths: Vec = headers.iter().map(|h| h.len()).collect(); + for row in rows { + for (i, cell) in row.iter().enumerate().take(ncol) { + widths[i] = widths[i].max(cell.len()); + } + } + widths + } + + #[test] + fn table_widths_use_header_and_cell_maxima() { + let widths = column_widths( + &["Name", "Description"], + &[vec![ + "trusted".into(), + "Lifecycle integration test plugin".into(), + ]], + ); + assert_eq!(widths[0], "trusted".len()); + assert_eq!(widths[1], "Lifecycle integration test plugin".len()); + } + + #[test] + fn table_widths_handle_empty_rows() { + let widths = column_widths(&["Name", "Version"], &[]); + assert_eq!(widths, vec![4, 7]); + } +} diff --git a/tests/plugin_lifecycle_e2e.rs b/tests/plugin_lifecycle_e2e.rs index 088cc243..5d79a7ce 100644 --- a/tests/plugin_lifecycle_e2e.rs +++ b/tests/plugin_lifecycle_e2e.rs @@ -192,7 +192,9 @@ fn plugin_lifecycle_install_list_verify_load_uninstall() { .output() .expect("run plugin list"); assert_success(&list, "plugin list"); - assert!(String::from_utf8_lossy(&list.stdout).contains("trusted")); + let list_stdout = String::from_utf8_lossy(&list.stdout); + assert!(list_stdout.contains("trusted")); + assert!(list_stdout.contains("Lifecycle integration test plugin")); let verify = starforge(home.path()) .args(["plugin", "verify", "trusted", "--deep"])