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
55 changes: 52 additions & 3 deletions src/commands/completions.rs
Original file line number Diff line number Diff line change
@@ -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)]
Expand All @@ -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<u8>,
) {
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();
Expand Down
109 changes: 97 additions & 12 deletions src/commands/plugin.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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<String>,
},
}

pub fn handle(cmd: PluginCommands) -> Result<()> {
Expand All @@ -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),
}
}

Expand Down Expand Up @@ -105,12 +110,29 @@ fn install(name: String, path: Option<PathBuf>, source: Option<String>, 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<RegisteredCommand> = {
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");
Expand All @@ -123,6 +145,12 @@ fn install(name: String, path: Option<PathBuf>, source: Option<String>, 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(())
}
Expand Down Expand Up @@ -316,9 +344,6 @@ fn update(name: Option<String>, 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;
Expand Down Expand Up @@ -356,7 +381,10 @@ fn update(name: Option<String>, 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;
}
Expand Down Expand Up @@ -388,19 +416,19 @@ fn update(name: Option<String>, 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.",
Expand Down Expand Up @@ -518,3 +546,60 @@ fn verify(name: Option<String>) -> Result<()> {

Ok(())
}

fn discover_commands_from_library(path: &str) -> Result<Vec<RegisteredCommand>> {
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<String>) -> 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 <name> --path <lib>");
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 <name> --path <lib>");
}

Ok(())
}
13 changes: 13 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,19 @@ fn handle_external_plugin(args: Vec<String>) -> 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<String> = 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
Expand Down
18 changes: 18 additions & 0 deletions src/plugins/interface.rs
Original file line number Diff line number Diff line change
@@ -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<PluginCommand> {
vec![PluginCommand {
name: self.name().to_string(),
description: self.description().to_string(),
}]
}

fn on_load(&self) {}
fn on_unload(&self) {}

Expand Down
8 changes: 8 additions & 0 deletions src/plugins/loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,14 @@ impl PluginManager {
.collect()
}

/// Returns all `PluginCommand`s advertised by every loaded plugin.
pub fn list_commands(&self) -> Vec<crate::plugins::interface::PluginCommand> {
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)
Expand Down
Loading