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

  • type is set to cli
  • name is unique across all extensions
  • binary matches the binary name in Cargo.toml
  • description clearly explains the extension's purpose
  • commands lists all available subcommands
  • enabled is 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/, NOT extensions/ 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 clap with 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

  1. ExtensionLoader scans extensions/cli/*/manifest.yaml
  2. Validates manifest structure and required fields
  3. Checks enabled: true
  4. 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:

  1. Finds the extension by name
  2. Locates the binary in target/release/ or target/debug/
  3. Spawns the binary with arguments
  4. Sets environment variables (DATABASE_URL, SYSTEMPROMPT_PROFILE)
  5. 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 warnings passes
  • cargo fmt -- --check passes

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.