Applies to: All CLI extension crates in extensions/cli/
Core Principle
CLI extensions are standalone binaries discovered via manifest.yaml files. They run as separate processes launched by the main CLI via systemprompt plugins run <name>.
Unlike compiled extensions (which implement the Extension trait), CLI extensions:
- Are standalone executables
- Don't require recompilation of the core CLI
- Can be written in any language (though Rust is recommended)
- Receive context via environment variables
Required Structure
extensions/cli/{name}/
├── Cargo.toml
├── manifest.yaml # REQUIRED: Extension declaration
└── src/
└── main.rs # CLI entry point
manifest.yaml
Every CLI extension MUST have a manifest.yaml file:
extension:
type: cli # REQUIRED: Must be "cli"
name: my-extension # REQUIRED: Extension name (used in `plugins run`)
binary: systemprompt-my-extension # REQUIRED: Binary name in target/
description: "What this extension does"
enabled: true # Set to false to disable
commands: # Document available commands
- name: do-something
description: "Does something useful"
- name: another-command
description: "Another useful command"
Checklist
-
typeis set tocli -
nameis unique across all extensions -
binarymatches the binary name inCargo.toml -
descriptionclearly explains the extension's purpose -
commandslists all available subcommands -
enabledis set appropriately
Cargo.toml
[package]
name = "systemprompt-my-extension"
version = "1.0.0"
edition = "2021"
[[bin]]
name = "systemprompt-my-extension" # Must match manifest.yaml binary
path = "src/main.rs"
[dependencies]
# CLI framework
clap = { version = "4.4", features = ["derive"] }
# Async runtime
tokio = { version = "1.47", features = ["full"] }
# Error handling
anyhow = "1.0"
thiserror = "2.0"
# Serialization (for config)
serde = { version = "1.0", features = ["derive"] }
serde_yaml = "0.9"
# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Add domain-specific dependencies as needed
Checklist
- Package name follows
systemprompt-{name}pattern - Binary target defined with
[[bin]]section - Binary name matches
manifest.yaml - Located in
extensions/cli/, NOTextensions/root
Main Entry Point
use clap::{Parser, Subcommand};
use tracing_subscriber::EnvFilter;
#[derive(Parser)]
#[command(name = "systemprompt-my-extension")]
#[command(about = "My CLI extension description")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Does something useful
DoSomething {
/// Required argument
input: String,
/// Optional flag
#[arg(long, short)]
verbose: bool,
},
/// Another command
AnotherCommand {
#[arg(long)]
option: Option<String>,
},
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Initialize logging
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
.init();
let cli = Cli::parse();
match cli.command {
Commands::DoSomething { input, verbose } => {
do_something(&input, verbose).await?;
}
Commands::AnotherCommand { option } => {
another_command(option).await?;
}
}
Ok(())
}
async fn do_something(input: &str, verbose: bool) -> anyhow::Result<()> {
// Implementation
println!("Processing: {}", input);
Ok(())
}
async fn another_command(option: Option<String>) -> anyhow::Result<()> {
// Implementation
Ok(())
}
Checklist
- Uses
clapwith derive macros for CLI parsing - Command name matches binary name
- Each subcommand has doc comment (shows in
--help) - Arguments have helpful descriptions
- Initializes tracing for logging
- Returns
anyhow::Result<()>for error handling
Environment Variables
The main CLI passes these environment variables when running extensions:
| Variable | Description |
|---|---|
SYSTEMPROMPT_PROFILE |
Path to the active profile directory |
DATABASE_URL |
Database connection string |
Accessing Environment Variables
fn get_profile_path() -> anyhow::Result<std::path::PathBuf> {
std::env::var("SYSTEMPROMPT_PROFILE")
.map(std::path::PathBuf::from)
.map_err(|_| anyhow::anyhow!("SYSTEMPROMPT_PROFILE not set"))
}
fn get_database_url() -> anyhow::Result<String> {
std::env::var("DATABASE_URL")
.map_err(|_| anyhow::anyhow!("DATABASE_URL not set"))
}
Configuration
CLI extensions can load configuration from the profile directory:
use serde::Deserialize;
use std::path::Path;
#[derive(Debug, Deserialize)]
struct MyConfig {
api_key: String,
enabled: bool,
}
fn load_config(profile_path: &Path) -> anyhow::Result<MyConfig> {
let config_path = profile_path.join("extensions/my-extension.yaml");
let content = std::fs::read_to_string(&config_path)?;
let config: MyConfig = serde_yaml::from_str(&content)?;
Ok(config)
}
Config File Location
Place extension config at: services/config/extensions/{name}.yaml
Error Handling
Use anyhow for application errors and thiserror for domain errors:
use thiserror::Error;
#[derive(Error, Debug)]
pub enum MyExtensionError {
#[error("Configuration error: {0}")]
Config(String),
#[error("API error: {0}")]
Api(String),
#[error("Not found: {0}")]
NotFound(String),
}
// Convert to anyhow for main()
impl From<MyExtensionError> for anyhow::Error {
fn from(err: MyExtensionError) -> Self {
anyhow::anyhow!(err)
}
}
Output Formatting
For consistent output with the main CLI:
use colored::Colorize;
fn print_success(message: &str) {
println!("{} {}", "✓".green(), message);
}
fn print_error(message: &str) {
eprintln!("{} {}", "✗".red(), message);
}
fn print_info(message: &str) {
println!("{} {}", "ℹ".blue(), message);
}
Or use the console crate for more advanced formatting.
Discovery and Execution
How Extensions Are Discovered
ExtensionLoaderscansextensions/cli/*/manifest.yaml- Validates manifest structure and required fields
- Checks
enabled: true - Registers extension name → binary mapping
How Extensions Are Run
# List available CLI extensions
systemprompt plugins list --type cli
# Run an extension
systemprompt plugins run my-extension do-something "input"
# With flags
systemprompt plugins run my-extension do-something "input" --verbose
The main CLI:
- Finds the extension by name
- Locates the binary in
target/release/ortarget/debug/ - Spawns the binary with arguments
- Sets environment variables (
DATABASE_URL,SYSTEMPROMPT_PROFILE) - Streams stdout/stderr to terminal
Workspace Registration
Add to workspace Cargo.toml:
[workspace]
members = [
"extensions/cli/my-extension",
# ... other members
]
Testing
Build
cargo build -p systemprompt-my-extension
Lint
cargo clippy -p systemprompt-my-extension -- -D warnings
Format
cargo fmt -p systemprompt-my-extension -- --check
Run Directly
# During development, run directly
cargo run -p systemprompt-my-extension -- do-something "test"
# Or via plugins run (requires build first)
systemprompt plugins run my-extension do-something "test"
Code Quality
- File length <= 300 lines
- Function length <= 75 lines
- No
unwrap()/panic!()in production paths - Proper error messages with context
-
cargo clippy -- -D warningspasses -
cargo fmt -- --checkpasses
Quick Reference
| Task | Command |
|---|---|
| Build | cargo build -p systemprompt-{name} |
| Run directly | cargo run -p systemprompt-{name} -- <args> |
| Run via CLI | systemprompt plugins run {name} <args> |
| List extensions | systemprompt plugins list --type cli |
| Lint | cargo clippy -p systemprompt-{name} -- -D warnings |
| Format | cargo fmt -p systemprompt-{name} -- --check |
Reference Implementation
See extensions/cli/discord/ for a complete example of a CLI extension that sends messages via Discord.