abi_stablebased Plugin System for Rust CLI Applications
A CLI template repository to build extensible plugin based CLI applications in Rust. The template allows you to create and modify systems that are loaded dynamically at runtime, guaranteeing compatibility even if compiled with different Rust versions.
- Dynamic Loading: Loads plugins at runtime without needing to recompe the CLI.
- Type Safety: Leverages
abi_stabletypes for safe rust-to-rust FFI. - Simple Interface: Clean three-function contract for plugin development.
- Cross-Platform: Linux, macOS, Windows
- Self-Updating Binary: Binary checks for update on every execution.
- Rename package in
Cargo.toml:
[package]
name = "barebones" # <-- here
version = "0.1.0"
edition = "2024"
[[bin]]
name = "barebones-cli" # <-- here
path = "src/bin/main.rs"- Rename panic hooks on
src/bin/main.rs:
setup_panic!(
Metadata::new(env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"))
.authors("Barebones CLI Corp <support@barebones-cli.corp>")
.homepage("support.barebones-cli.corp")
.support("- Open a support request by email to support@barebones-cli.corp")
);-
APP_NAMEconstant insrc/lib.rs. -
You might need to configure your own Settings Default in
src/config/data.rs
impl Default for MyConfig {
fn default() -> Self {
#[expect(
clippy::unwrap_used,
reason = "We guarantee this version works in compile time"
)]
Self {
name: "Julia Naomi".to_string(), // Not required
is_machine: false, // Required if you want clean output for other machines
version: Version::parse("1.0.0").unwrap(), // Not required
plugins: vec![ // REQUIRED
"your_plugins_names" // <-- a list of your plugins
],
}
}
}Navigate to the plugins source directory:
cd plugins
cargo new --lib my-first-plugin
cd my-first-pluginUpdate Cargo.toml to build as a dynamic library:
[package]
name = "my-first-plugin"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
cli-dev.workspace = true
abi_stable.workspace = true
[lints]
workspace = trueCopy the contents of plugins/greeter/src/lib.rs to start:
Implement your plugin changes in plugins/my_first_plugin/src/lib.rs:
use abi_stable::{
export_root_module,
prefix_type::PrefixTypeTrait,
sabi_extern_fn,
std_types::{RString, RVec},
};
use cli_dev::{
logging::log_debug,
plugin::{CommandResult, PluginInfo, PluginMod, PluginModRef},
};
#[export_root_module]
pub fn instantiate_root_module() -> PluginModRef {
PluginMod {
get_info,
execute,
get_help,
}
.leak_into_prefix()
}
#[sabi_extern_fn]
pub fn get_info() -> PluginInfo {
PluginInfo {
name: "my-plugin".into(),
version: "0.1.0".into(),
description: "My awesome plugin".into(),
author: "Your Name".into(),
}
}
#[sabi_extern_fn]
pub fn execute(args: RVec<RString>) -> CommandResult {
// Your plugin logic here
CommandResult::ok("Plugin executed successfully!")
}
#[sabi_extern_fn]
pub fn get_help() -> RString {
"my-first-plugin - Does something awesome
USAGE:
your-cli-name my-first-plugin [args]".into()
}Build the plugin:
cargo build --release -p my-first-pluginCopy the compiled library to the cli config directory $HOME/.cli-name/:
# Linux
cp target/release/libmy_first_plugin.so $HOME/.cli-name/
# macOS
cp target/release/libmy_first_plugin.dylib $HOME/.cli-name/
# Windows
copy target\release\my_first_plugin.dll $HOME/.cli-name/./target/release/your-cli-app list
./target/release/your-cli-app my-first-plugin --your args
./target/release/your-cli-app help my-first-pluginEvery plugin must implement three functions:
Returns information about your plugin:
#[sabi_extern_fn]
pub fn get_info() -> PluginInfo {
PluginInfo {
name: "my-first-plugin".into(),
version: "1.0.0".into(),
description: "What your plugin does".into(),
author: "Your Name".into(),
}
}Executes your plugin's functionality:
#[sabi_extern_fn]
pub fn execute(args: RVec<RString>) -> CommandResult {
// Parse arguments
if args.is_empty() {
return CommandResult::err("No arguments provided", exitcode::USAGE);
}
// Do your plugin's work
let result = do_something(&args[0]);
// Return success or error
CommandResult::ok(format!("Result: {}", result))
}Returns help text for users:
#[sabi_extern_fn]
pub fn get_help() -> RString {
r#"my-first-plugin - Description
USAGE:
your-cli-app my-first-plugin [OPTIONS] [ARGS]
OPTIONS:
--flag Description of flag
EXAMPLES:
your-cli-app my-first-plugin --flag value
"#.into()
}Arguments are passed as RVec<RString>, which are abi_stable versions of Vec<String>:
#[sabi_extern_fn]
pub fn execute(args: RVec<RString>) -> CommandResult {
// Check argument count
if args.len() < 2 {
return CommandResult::err("Need at least 2 arguments", exitcode::USAGE);
}
// Parsing can also be done using `Clap::parse_from` iterator
// Access arguments
let first = args[0].as_str();
let second = args[1].as_str();
// Parse values
let number: i32 = match first.parse() {
Ok(n) => n,
Err(_) => return CommandResult::err("Invalid number", exitcode::DATAERR),
};
// Check for flags
if args.iter().any(|a| a.as_str() == "--verbose") {
// Verbose mode
}
CommandResult::ok("Success!")
}Use CommandResult::ok() for success and CommandResult::err() for failures:
// Success
return CommandResult::ok("Operation completed successfully");
// Error with exit code
return CommandResult::err("Something went wrong", exitcode::SOFTWARE);
// Format output
return CommandResult::ok(format!("Processed {} items", count));#[sabi_extern_fn]
pub fn execute(args: RVec<RString>) -> CommandResult {
let name = if args.is_empty() {
"World"
} else {
args[0].as_str()
};
CommandResult::ok(format!("Hello, {}!", name))
}#[sabi_extern_fn]
pub fn execute(args: RVec<RString>) -> CommandResult {
if args.len() != 3 {
return CommandResult::err("Usage: calc <NUM> <OP> <NUM>", exitcode::USAGE);
}
let Ok(a) = args[0].parse::<f64>() else {
return CommandResult::err(format!("first argument must be a number. Provided `{}`", args[0]), exitcode::DATAERR);
};
let op = args[1].as_str();
let Ok(b) = args[2].parse::<f64>() else {
return CommandResult::err(format!("third argument must be a number. Provided `{}`", args[0]), exitcode::DATAERR);
};
let result = match op {
"add" => a + b,
"sub" => a - b,
"mul" => a * b,
"div" if b != 0.0 => a / b,
"div" => return CommandResult::err("Division by zero", exitcode::DATAERR),
_ => return CommandResult::err("Unknown operation", exitcode::DATAERR),
};
CommandResult::ok(format!("{}", result))
}#[sabi_extern_fn]
pub fn execute(args: RVec<RString>) -> CommandResult {
if args.is_empty() {
return CommandResult::err("Please provide a filename", exitcode::USAGE);
}
let filename = args[0].as_str();
match std::fs::read_to_string(filename) {
Ok(content) => {
CommandResult::ok(content)
}
Err(e) => CommandResult::err(format!("Error reading file: {}", e), exitcode::IOERR),
}
}In src/main.rs, modify this line:
manager.load_plugins_from_dir("./plugins")?;instead of:
let settings = config_show()?;
if let Err(e) = manager.load_plugins_from_dir(&settings) // ...You can add validation logic in the load_plugin() method:
fn load_plugin(&mut self, path: &PathBuf) -> Result<(), Box<dyn std::error::Error>> {
let module = PluginModRef::load_from_file(path)?.into_inner();
let info = (module.get_info())();
// Validate version
if !info.version.starts_with("1.") {
return Err("Plugin version not supported".into());
}
// Check for duplicates
if self.plugins.iter().any(|p| p.name == info.name.as_str()) {
return Err(format!("Plugin '{}' already loaded", info.name).into());
}
// ... rest of loading logic
}-
Always copy the plugin interface: Each plugin needs an exact copy of the interface definition (
crates/cli-dev/plugin/mod.rs::PluginMod). -
Keep the interface stable: Once you release your CLI, avoid changing the interface to maintain plugin compatibility.
-
Error handling: Always return meaningful error messages to users.
-
Documentation: Write comprehensive help text for your plugins.
-
Testing: Test plugins independently before integrating them.
-
Distribution: You can distribute plugins as separate packages that users install to their plugins directory.
To disable auto-update, open $HOME/.barebones/config.toml and set should_auto_update = false. To run manually run the update command execute barebones-cli update. To auto accept downloads use the --accept flag barebones-cli --accept update
- Ensure the file extension is correct for your platform (.so/.dylib/.dll).
- Check that
abi_stableversions match between main app and plugin. - Verify the plugin exports
instantiate_root_module.
- Make sure
plugin interfaceis identical in both main app and plugin. - Check that
crate-type = ["cdylib"]is set in plugin's Cargo.toml. - Ensure all types used are
#[repr(C)]andStableAbi.
- Never use non-ABI-stable types across the FFI boundary.
- Always use
RString,RVec, etc. instead ofString,Vec. - Avoid unwrap() in plugin code; log errors and return them instead.