sen 0.7.0

Script to System CLI Engine - A type-safe, macro-powered CLI framework
Documentation

SEN: CLI Engine

Rust License

A type-safe, macro-powered CLI framework.

๐ŸŽฏ Philosophy

SEN transforms CLI development from ad-hoc scripts into systematic applications with:

  • Compile-time safety: Enum-based routing with exhaustiveness checking
  • Zero boilerplate: Derive macros generate all wiring code
  • Type-driven DI: Handler parameters injected based on type signature
  • Fixed workflows: Predictable behavior for humans and AI agents
  • Strict separation: Prevents the "1000-line main.rs" problem

๐Ÿš€ Quick Start

Installation

Add to your Cargo.toml:

[dependencies]
sen = { version = "0.1", features = ["clap"] }
clap = { version = "4", features = ["derive"] }
tokio = { version = "1", features = ["full"] }

Or use cargo add:

cargo add sen --features clap
cargo add clap --features derive
cargo add tokio --features full

Example (Router API with Clap - Recommended)

use sen::{Args, CliResult, Router, State};
use clap::Parser;

// 1. Define application state
#[derive(Clone)]
pub struct AppState {
    pub config: String,
}

// 2. Define arguments with Clap derive macro
//    These types are automatically parsed by SEN when clap feature is enabled
#[derive(Parser, Debug)]
struct BuildArgs {
    /// Build in release mode
    #[arg(long)]
    release: bool,

    /// Number of parallel jobs
    #[arg(short, long, default_value = "4")]
    jobs: usize,
}

// Add descriptions to handlers with #[sen::handler] macro (Router API)
// Or use #[sen(desc = "...")] for Enum API (see below)

#[derive(Parser, Debug)]
struct DeployArgs {
    /// Target environment (positional argument)
    environment: String,

    /// Docker image tag
    #[arg(long, default_value = "latest")]
    tag: String,
}

// 3. Implement handlers as async functions
//    Handlers can accept State, Args, or both in any order
//    Use #[sen::handler(desc = "...")] to add descriptions for help
mod handlers {
    use super::*;

    // Handler with State only (no arguments)
    // You can also use: #[sen::handler(desc = "Show application status")]
    pub async fn status(state: State<AppState>) -> CliResult<String> {
        let app = state.read().await;
        Ok(format!("Status: OK (config: {})", app.config))
    }

    // Handler with State + Args
    // Args(args): Args<BuildArgs> - Clap automatically parses CLI arguments here!
    #[sen::handler(desc = "Build the project")]
    pub async fn build(
        state: State<AppState>,
        Args(args): Args<BuildArgs>,  // ๐Ÿ‘ˆ Automatic parsing via Clap!
    ) -> CliResult<String> {
        let app = state.read().await;
        let mode = if args.release { "release" } else { "debug" };
        Ok(format!("Building in {} mode with {} jobs (config: {})",
                   mode, args.jobs, app.config))
    }

    // Order doesn't matter! Args can come before State
    #[sen::handler(desc = "Deploy to environment")]
    pub async fn deploy(
        Args(args): Args<DeployArgs>,  // ๐Ÿ‘ˆ Clap parses from CLI automatically!
        state: State<AppState>,
    ) -> CliResult<String> {
        let app = state.read().await;
        Ok(format!("Deploying to {} with tag {} (config: {})",
                   args.environment, args.tag, app.config))
    }
}

// 4. Wire it up with Router (< 30 lines of main.rs)
#[tokio::main]
async fn main() {
    // Create application state (shared across all handlers)
    let state = AppState {
        config: "production".to_string(),
    };

    // Build the router with command โ†’ handler mappings
    // Use #[sen::sen()] macro to set CLI metadata
    let router = build_router(state);

    // Execute the command from CLI arguments
    let response = router.execute().await;

    // Print output and exit with proper code
    if !response.output.is_empty() {
        println!("{}", response.output);
    }
    std::process::exit(response.exit_code);
}

// Set CLI metadata with #[sen::sen()] macro
#[sen::sen(
    name = "myapp",
    version = "1.0.0",
    about = "My awesome CLI application"
)]
fn build_router(state: AppState) -> Router<()> {
    Router::new()
        .route("status", handlers::status)   // myapp status
        .route("build", handlers::build)     // myapp build [--release] [--jobs N]
        .route("deploy", handlers::deploy)   // myapp deploy <env> [--tag TAG]
        .with_state(state)                   // Inject state into all handlers
}

Usage:

myapp status                              # No arguments
myapp build --release --jobs 8            # With arguments
myapp deploy production --tag v1.2.3      # Positional + flags
myapp --help                              # Hierarchical help
myapp build --help                        # Clap auto-generates detailed help

Hierarchical --help output (automatically generated):

myapp 1.0.0
My awesome CLI application

Usage: myapp [OPTIONS] <COMMAND>

Other Commands:
  build   Build the project
  deploy  Deploy to environment
  status

Options:
  -h, --help            Print help
      --help --json     Show CLI schema (JSON format)
  -V, --version         Print version

Per-command --help (via Clap):

$ myapp build --help
Usage: cmd [OPTIONS]

Options:
      --release          Build in release mode
  -j, --jobs <JOBS>      Number of parallel jobs [default: 4]
  -h, --help             Print help

Example (Enum API with Clap - Type-safe alternative)

use sen::{Args, CliResult, State, SenRouter};
use clap::Parser;

// 1. Define application state
#[derive(Clone)]
pub struct AppState {
    pub config: String,
}

// 2. Define arguments with Clap derive macro
//    Same as Router API - just derive Parser on your argument types
#[derive(Parser, Debug)]
struct BuildArgs {
    /// Build in release mode
    #[arg(long)]
    release: bool,

    /// Number of parallel jobs
    #[arg(short, long, default_value = "4")]
    jobs: usize,
}

#[derive(Parser, Debug)]
struct DeployArgs {
    /// Target environment (positional argument)
    environment: String,

    /// Docker image tag
    #[arg(long, default_value = "latest")]
    tag: String,
}

// 3. Define commands with SenRouter derive macro
//    This generates the execute() method and routing logic at compile-time
#[derive(SenRouter)]
#[sen(state = AppState)]  // Tell macro what State type to use
enum Commands {
    #[sen(handler = handlers::status, desc = "Show application status")]
    Status,  // No arguments

    #[sen(handler = handlers::build, desc = "Build the project")]
    Build(BuildArgs),  // With Clap-parsed arguments

    #[sen(handler = handlers::deploy, desc = "Deploy to environment")]
    Deploy(DeployArgs),  // Compiler checks ALL variants have handlers!
}

// The macro also generates Commands::help() for displaying all commands
// Example: println!("{}", Commands::help());

// 4. Implement handlers as async functions
//    Same signature style as Router API
mod handlers {
    use super::*;

    // Handler with State only
    pub async fn status(state: State<AppState>) -> CliResult<String> {
        let app = state.read().await;
        Ok(format!("Status: OK (config: {})", app.config))
    }

    // Handler with State + Args
    pub async fn build(
        state: State<AppState>,
        Args(args): Args<BuildArgs>,  // ๐Ÿ‘ˆ Clap automatically parses here!
    ) -> CliResult<String> {
        let app = state.read().await;
        let mode = if args.release { "release" } else { "debug" };
        Ok(format!("Building in {} mode with {} jobs (config: {})",
                   mode, args.jobs, app.config))
    }

    // Order doesn't matter! Args can come before State
    pub async fn deploy(
        Args(args): Args<DeployArgs>,  // ๐Ÿ‘ˆ Automatic parsing via Clap!
        state: State<AppState>,
    ) -> CliResult<String> {
        let app = state.read().await;
        Ok(format!("Deploying to {} with tag {} (config: {})",
                   args.environment, args.tag, app.config))
    }
}

// 5. Wire it up (< 30 lines of main.rs)
#[tokio::main]
async fn main() {
    // Create application state (shared across all handlers)
    let state = State::new(AppState {
        config: "production".to_string(),
    });

    // Parse command from CLI arguments (your parsing logic)
    let cmd = Commands::parse();

    // Execute! The macro-generated execute() method handles routing
    let response = cmd.execute(state).await;

    // Print output and exit with proper code
    if !response.output.is_empty() {
        println!("{}", response.output);
    }
    std::process::exit(response.exit_code);
}

Key Features of Enum API:

  • Compile-time safety: #[derive(SenRouter)] macro generates the execute() method
  • Exhaustive matching: Compiler ensures all commands have handlers
  • Clap integration: Just add #[derive(Parser)] to argument types
  • Type-driven DI: Automatically injects State<T> and Args<T> based on handler signatures

๐Ÿ“ Project Structure

SEN enforces clean file separation from day one:

my-cli/
โ”œโ”€โ”€ src/
โ”‚   โ”œโ”€โ”€ main.rs              # Entry point only (< 50 lines)
โ”‚   โ”œโ”€โ”€ handlers/            # One file per command
โ”‚   โ”‚   โ”œโ”€โ”€ mod.rs
โ”‚   โ”‚   โ”œโ”€โ”€ status.rs
โ”‚   โ”‚   โ”œโ”€โ”€ build.rs
โ”‚   โ”‚   โ””โ”€โ”€ test.rs
โ”‚   โ”œโ”€โ”€ workflows/           # Multi-task operations
โ”‚   โ”‚   โ””โ”€โ”€ preflight.rs     # fmt โ†’ lint โ†’ test
โ”‚   โ”œโ”€โ”€ tasks/               # Atomic operations
โ”‚   โ”‚   โ”œโ”€โ”€ fmt.rs
โ”‚   โ”‚   โ””โ”€โ”€ lint.rs
โ”‚   โ””โ”€โ”€ lib.rs               # Re-exports

Why?

  • Each command is independently testable
  • No println! debugging (handlers return structured data)
  • Impossible to create "1000-line main.rs"
  • AI agents can understand and modify specific commands easily

๐ŸŽจ Key Features

1. Flexible Routing - Choose Your Style

Router API (Axum-style) - Dynamic and flexible:

// Register handlers dynamically
let router = Router::new()
    .route("status", handlers::status)
    .route("build", handlers::build)
    .with_state(app_state);

// Easy to integrate with existing CLIs
let response = router.execute(&args).await;

Enum API - Compile-time safety:

#[derive(SenRouter)]
#[sen(state = AppState)]
enum Commands {
    #[sen(handler = handlers::status)]  // Typo? Compile error!
    Status,
}

Both approaches are supported - choose based on your needs:

  • Router API: Better for gradual migration, dynamic routes, existing CLIs
  • Enum API: Better for new projects, compile-time exhaustiveness checking

2. Axum-Style Handler Signatures

// Order doesn't matter!
pub async fn handler1(state: State<App>, args: Args) -> CliResult<String>
pub async fn handler2(args: Args, state: State<App>) -> CliResult<String>

// State optional
pub async fn handler3(args: Args) -> CliResult<()>

3. Smart Error Handling

pub enum CliError {
    User(UserError),      // Exit code 1: user can fix
    System(SystemError),  // Exit code 101: bug/system failure
}

Errors automatically format with helpful hints:

Error: Invalid argument '--foo'

The value 'bar' is not supported.

Hint: Use one of: baz, qux

4. Professional Help Generation

Automatic hierarchical grouping - Commands are organized by prefix:

$ myctl --help

Configuration Commands:
  edit      Edit configuration in editor
  init      Initialize configuration file
  show      Show current configuration

Database Commands:
  create    Create a new database
  delete    Delete a database
  list      List all databases

Server Commands:
  start     Start server instances
  stop      Stop server instances

Clap integration - Per-command help with full argument details:

$ myctl db create --help
Usage: cmd [OPTIONS] <NAME>

Arguments:
  <NAME>  Database name

Options:
      --size <SIZE>      Storage size [default: 10GB]
      --engine <ENGINE>  Database engine [default: postgres]
  -h, --help             Print help

JSON schema export for AI agents and IDEs:

$ myctl --help --json
{
  "commands": {
    "db:create": {
      "description": "Create a new database",
      "arguments": [...],
      "options": [...]
    }
  }
}

How grouping works:

  • Commands with : prefix are automatically grouped (e.g., db:create โ†’ "Database Commands")
  • Commands are displayed with just the suffix (e.g., create instead of db:create)
  • Groups are sorted alphabetically, with "Other Commands" last
  • Use #[sen::handler(desc = "...")] to add descriptions

5. No Println! in Handlers

Handlers return structured data, framework handles output:

// โŒ Bad: Can't test, can't redirect
pub async fn status() -> CliResult<()> {
    println!("Status: OK");
    Ok(())
}

// โœ… Good: Testable, flexible
pub async fn status() -> CliResult<StatusReport> {
    Ok(StatusReport { status: "OK" })
}

๐Ÿค– Agent Mode (Machine-Readable Output)

SEN provides automatic AI agent integration through built-in --agent-mode flag support.

Automatic Agent Mode (Recommended)

Simply call .with_agent_mode() and the framework handles everything:

use sen::Router;

#[tokio::main]
async fn main() {
    let router = Router::new()
        .route("build", handlers::build)
        .with_agent_mode()  // Enable automatic --agent-mode support
        .with_state(state);

    let response = router.execute().await;

    // Automatically outputs JSON if --agent-mode was passed
    if response.agent_mode {
        println!("{}", response.to_agent_json());
    } else {
        if !response.output.is_empty() {
            println!("{}", response.output);
        }
    }

    std::process::exit(response.exit_code);
}

User runs:

myapp build              # Normal text output
myapp --agent-mode build # JSON output

How It Works

  1. Router detects --agent-mode flag automatically
  2. Strips the flag before passing args to handlers
  3. Sets response.agent_mode = true for your output logic
  4. Zero boilerplate - no manual arg parsing needed

Example Output

{
  "result": "success",
  "exit_code": 0,
  "output": "Build completed successfully",
  "tier": "safe",
  "tags": ["build", "production"],
  "sensors": {
    "timestamp": "2024-01-15T10:30:00Z",
    "os": "macos",
    "cwd": "/Users/user/project"
  }
}

Advanced: Manual Agent Mode

For complex scenarios with global options, you can still manually implement agent mode (see examples/practical-cli).

Features

  • Automatic --agent-mode detection: Framework handles flag parsing
  • to_agent_json(): Converts Response to structured JSON
  • Environment Sensors: Automatic collection of system metadata (requires sensors feature)
  • Tier & Tags: Safety tier and command categorization metadata
  • Structured Errors: Exit codes and error messages in machine-readable format

๐Ÿ’ก Argument Parsing: Clap Integration (Recommended)

SEN has built-in Clap integration - the de-facto standard for Rust CLI argument parsing.

๐Ÿš€ Use Clap (Recommended for 99% of CLIs)

Simply derive clap::Parser on your argument types:

Step 1: Enable the clap feature:

[dependencies]
sen = { version = "0.1", features = ["clap"] }
clap = { version = "4", features = ["derive"] }

Step 2: Define arguments with #[derive(Parser)]:

use sen::{Args, CliResult};
use clap::Parser;

#[derive(Parser, Debug)]
struct BuildArgs {
    /// Build in release mode
    #[arg(long)]
    release: bool,

    /// Number of parallel jobs
    #[arg(short, long, default_value = "4")]
    jobs: usize,
}

async fn build(Args(args): Args<BuildArgs>) -> CliResult<String> {
    let mode = if args.release { "release" } else { "debug" };
    Ok(format!("Building in {} mode with {} jobs", mode, args.jobs))
}

Step 3: Register the handler - that's it!

let router = Router::new()
    .route("build", build)
    .with_state(state);

How it works: When the clap feature is enabled, SEN automatically implements FromArgs for any type implementing clap::Parser. Zero boilerplate required.

Benefits:

  • โœ… Automatic help generation (--help)
  • โœ… Type-safe with compile-time validation
  • โœ… Supports complex options (enums, lists, subcommands)
  • โœ… Battle-tested (used by cargo, ripgrep, etc.)
  • โœ… Recommended for all production CLIs

Example --help output (auto-generated from your #[arg] attributes):

$ myapp build --help
Usage: myapp build [OPTIONS]

Options:
      --release          Build in release mode
  -j, --jobs <JOBS>      Number of parallel jobs [default: 4]
  -h, --help             Print help

All the documentation comments (///) in your struct become help text automatically!

Global Options (For CLI-wide Flags)

For applications with global flags that apply to all commands:

use sen::FromGlobalArgs;

#[derive(Clone)]
pub struct GlobalOpts {
    pub verbose: bool,
    pub config_path: String,
}

impl FromGlobalArgs for GlobalOpts {
    fn from_global_args(args: &[String]) -> Result<(Self, Vec<String>), CliError> {
        let mut verbose = false;
        let mut config_path = "~/.config/myapp".to_string();
        let mut remaining_args = Vec::new();

        for arg in args {
            match arg.as_str() {
                "--verbose" | "-v" => verbose = true,
                "--config" => { /* handle next arg */ }
                _ => remaining_args.push(arg.clone()),
            }
        }

        Ok((GlobalOpts { verbose, config_path }, remaining_args))
    }
}

Use Global Options when:

  • โœ… You need flags that apply to all commands (--verbose, --config)
  • โœ… You want integration with clap or other parsers
  • โœ… You have complex validation or conflicting flag logic
  • โœ… Building a production CLI (like practical-cli example)

Why practical-cli Uses Global Options

The practical-cli example intentionally uses FromGlobalArgs instead of FromArgs:

  1. Global flags: --verbose and --config apply to all commands
  2. clap integration: Uses clap::Command for help generation
  3. Flexibility: Manual parsing allows complex validation
  4. Real-world pattern: Mirrors production CLI tools like kubectl, docker, etc.

Key Insight: For complex CLIs with global flags, use FromGlobalArgs to parse them once, then use Clap's #[derive(Parser)] for per-command arguments.

See examples/practical-cli for a complete production-ready example showing:

  • Global flags with FromGlobalArgs
  • Per-command arguments with Clap's #[derive(Parser)]
  • Nested routers for organizing commands by resource

Advanced: Manual FromArgs Implementation (Rarely Needed)

If you need custom parsing logic and cannot use Clap, you can manually implement FromArgs:

use sen::{Args, FromArgs, CliError, CliResult};

#[derive(Debug)]
struct CustomArgs {
    flag: bool,
}

impl FromArgs for CustomArgs {
    fn from_args(args: &[String]) -> Result<Self, CliError> {
        // Your custom parsing logic
        Ok(CustomArgs {
            flag: args.contains(&"--flag".to_string()),
        })
    }
}

async fn handler(Args(args): Args<CustomArgs>) -> CliResult<String> {
    Ok(format!("Flag: {}", args.flag))
}

Only use manual FromArgs when:

  • โŒ Clap doesn't support your use case (very rare)
  • โŒ You need parsing logic that's impossible to express in Clap
  • โŒ You're integrating with a non-Clap parser

For 99% of use cases, use Clap's #[derive(Parser)] instead.

๐Ÿ—๏ธ Architecture

SEN follows a three-layer design:

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Router Layer (Compile-time)            โ”‚
โ”‚  - Enum-based command tree              โ”‚
โ”‚  - Handler binding via proc macros      โ”‚
โ”‚  - Type-safe routing                    โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                   โ”‚
                   โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Handler Layer (Runtime)                โ”‚
โ”‚  - Dependency injection (State, Args)   โ”‚
โ”‚  - Business logic execution             โ”‚
โ”‚  - Result<T, E> return type             โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                   โ”‚
                   โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Response Layer (Exit)                  โ”‚
โ”‚  - Exit code mapping (0, 1, 101)        โ”‚
โ”‚  - Structured output (JSON/Human)       โ”‚
โ”‚  - Logging & telemetry                  โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

See DESIGN.md for full architecture details.

๐Ÿ“š Examples

Check out the examples/simple-cli directory for a working CLI with:

  • Status command (no args)
  • Build command (with --release flag)
  • Test command (with optional filter)
  • Proper error handling

Run it:

cd examples/simple-cli
cargo build
./target/debug/admin status
./target/debug/admin build --release
./target/debug/admin test my_test

๐Ÿงช Testing

# Run all tests
cargo test

# Test specific crate
cargo test -p sen
cargo test -p sen-rs-macros

๐Ÿ“– Documentation

๐Ÿ›ฃ๏ธ Roadmap

  • Phase 1: Core framework (State, CliResult, IntoResponse)
  • Phase 2: Macro system (#[derive(SenRouter)])
  • Phase 3: Advanced features (ReloadableConfig, tracing)
  • Phase 4: Developer experience (CLI generator, templates)

๐Ÿค Contributing

Contributions welcome! Please read DESIGN.md to understand the architecture first.

๐Ÿ“œ License

Licensed under either of:

at your option.

๐Ÿ™ Inspiration

SEN is inspired by:

  • Axum - Type-safe handler functions
  • Clap - CLI argument parsing
  • The philosophy of Anti-Fragility and Fixed Workflows

SEN (็ทš/ๅ…ˆ): The Line to Success, Leading the Future of CLI Development