A type-safe, macro-powered CLI framework.
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
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 fulluse 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 helpHierarchical --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
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 theexecute()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>andArgs<T>based on handler signatures
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
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
// 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<()>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
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.,
createinstead ofdb:create) - Groups are sorted alphabetically, with "Other Commands" last
- Use
#[sen::handler(desc = "...")]to add descriptions
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" })
}SEN provides automatic AI agent integration through built-in --agent-mode flag support.
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- Router detects
--agent-modeflag automatically - Strips the flag before passing args to handlers
- Sets
response.agent_mode = truefor your output logic - Zero boilerplate - no manual arg parsing needed
{
"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"
}
}For complex scenarios with global options, you can still manually implement agent mode (see examples/practical-cli).
- Automatic
--agent-modedetection: Framework handles flag parsing to_agent_json(): ConvertsResponseto structured JSON- Environment Sensors: Automatic collection of system metadata (requires
sensorsfeature) - Tier & Tags: Safety tier and command categorization metadata
- Structured Errors: Exit codes and error messages in machine-readable format
SEN has built-in Clap integration - the de-facto standard for Rust CLI argument parsing.
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 helpAll the documentation comments (///) in your struct become help text automatically!
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
clapor other parsers - ✅ You have complex validation or conflicting flag logic
- ✅ Building a production CLI (like
practical-cliexample)
The practical-cli example intentionally uses FromGlobalArgs instead of FromArgs:
- Global flags:
--verboseand--configapply to all commands clapintegration: Usesclap::Commandfor help generation- Flexibility: Manual parsing allows complex validation
- 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
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.
SEN provides a secure, cross-platform plugin system powered by WebAssembly.
- Write Once, Run Anywhere: Single
.wasmbinary works on all platforms - Language Agnostic: Write plugins in Rust, Zig, or any WASM-compatible language
- Secure by Default: Sandboxed execution with CPU/memory limits
- Hot Reload: Plugins reload automatically when files change
┌─────────────────────────────────────────┐
│ sen-plugin-api │
│ Shared protocol types (MessagePack) │
└─────────────────────────────────────────┘
↑ ↑
│ │
┌────────┴────────┐ ┌────────┴────────┐
│ sen-plugin-sdk │ │ sen-plugin-host │
│ Rust SDK for │ │ Wasmtime-based │
│ plugin authors │ │ plugin runtime │
└─────────────────┘ └─────────────────┘
use sen_plugin_sdk::prelude::*;
struct GreetPlugin;
impl Plugin for GreetPlugin {
fn manifest() -> PluginManifest {
PluginManifest::new(CommandSpec {
name: "greet".into(),
description: "Greet someone".into(),
version: "1.0.0".into(),
args: vec![ArgSpec::positional("name", "Name to greet")],
subcommands: vec![],
})
}
fn execute(args: Vec<String>) -> ExecuteResult {
let name = args.first().map(|s| s.as_str()).unwrap_or("World");
ExecuteResult::success(format!("Hello, {}!", name))
}
}
export_plugin!(GreetPlugin);Build with:
cargo build --release --target wasm32-unknown-unknownconst sdk = @import("sdk/plugin.zig");
pub const plugin = sdk.Plugin{
.name = "echo",
.about = "Echoes arguments back",
.version = "1.0.0",
.args = &.{
.{ .name = "message", .description = "Message to echo" },
},
};
pub fn execute(ctx: *sdk.Context) sdk.Result {
var args = ctx.args();
const message = args.next() orelse "No message";
return sdk.Result.success(message);
}
comptime { sdk.exportPlugin(@This()); }Build with:
zig build wasmuse sen::Router;
use sen_plugin_host::{PluginRegistry, RouterPluginExt};
#[tokio::main]
async fn main() {
let registry = PluginRegistry::new().unwrap();
registry.load_plugin("./plugins/greet.wasm").await.unwrap();
let router = Router::new()
.route("status", handlers::status)
.plugin(registry.get("greet").unwrap()) // Add plugin as route
.with_state(state);
router.execute().await;
}use sen_plugin_host::{PluginRegistry, HotReloadWatcher, WatcherConfig};
let registry = PluginRegistry::new()?;
let _watcher = HotReloadWatcher::new(
registry.clone(),
vec!["./plugins"],
WatcherConfig::default(),
).await?;
// Plugins automatically reload when .wasm files change| Protection | Implementation |
|---|---|
| CPU Limit | 10M fuel per execution |
| Stack Limit | 1MB WASM stack |
| Memory Isolation | Per-plugin linear memory |
| API Versioning | Rejects incompatible plugins |
| Capabilities | Fine-grained permission system |
Plugins declare required capabilities, and the host controls access:
use sen_plugin_host::permission::{PermissionPresets, PermissionConfig};
// Choose a preset based on your environment
let config = PermissionPresets::interactive("myapp")?; // Development
let config = PermissionPresets::ci("myapp", None)?; // CI/CD
let config = PermissionPresets::strict("myapp")?; // Production
// Or customize with builder
let config = PermissionConfigBuilder::new()
.app_name("myapp")
.strategy(DefaultPermissionStrategy)
.store(FilePermissionStore::default_for_app("myapp")?)
.prompt(TerminalPromptHandler::new())
.build()?;Trust Flags for CLI integration:
myapp --trust-plugin=hello run # Trust specific plugin
myapp --trust-command=db:migrate # Trust specific commandAvailable Strategies:
| Strategy | Behavior |
|---|---|
| Default | Prompts for ungranted permissions |
| Strict | Denies in non-interactive mode |
| Permissive | Allows non-network without prompt |
| CI | Never prompts, requires pre-granted |
| TrustAll | Bypasses all checks (dev only) |
Plugins run in a sandboxed WASM environment without direct network access. The Effect system allows plugins to request async operations from the host:
┌─────────────┐ execute() ┌──────────┐
│ Plugin │ ─────────────────→ │ Host │
│ │ ← Effect::HttpGet │ │
│ │ │ (async) │
│ │ resume(result) │ fetch │
│ │ ←─────────────────│ │
│ │ → Success(...) │ │
└─────────────┘ └──────────┘
Plugin side:
use sen_plugin_sdk::prelude::*;
impl Plugin for HttpPlugin {
fn execute(args: Vec<String>) -> ExecuteResult {
let url = args.first().unwrap();
// Request host to perform HTTP GET
ExecuteResult::http_get(1, url)
}
fn resume(_effect_id: u32, result: EffectResult) -> ExecuteResult {
match result {
EffectResult::Http(resp) if resp.is_success() => {
ExecuteResult::success(resp.body)
}
EffectResult::Http(resp) => {
ExecuteResult::user_error(format!("HTTP {}", resp.status))
}
EffectResult::Error(e) => ExecuteResult::user_error(e),
_ => ExecuteResult::system_error("Unexpected result"),
}
}
}Available Effects:
| Effect | Description |
|---|---|
HttpGet |
HTTP GET request |
HttpPost |
HTTP POST request |
Sleep |
Delay execution |
See the examples directory:
examples/hello-plugin/- Manual WASM implementation (Rust)examples/greet-plugin/- SDK-based plugin (Rust)examples/echo-plugin-zig/- Zig SDK exampleexamples/file-reader-plugin/- WASI filesystem access (Rust)examples/env-reader-plugin-zig/- WASI environment access (Zig)examples/http-plugin/- Effect system HTTP demo (Rust)
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.
Check out the examples/simple-cli directory for a working CLI with:
- Status command (no args)
- Build command (with
--releaseflag) - 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# Run all tests
cargo test
# Test specific crate
cargo test -p sen
cargo test -p sen-rs-macros- DESIGN.md - Complete design document
- Phase 1: Core framework (State, CliResult, IntoResponse)
- Phase 2: Macro system (#[derive(SenRouter)])
- Phase 3: WASM Plugin System
- Plugin loading with wasmtime
- Rust SDK (
sen-plugin-sdk) - Zig SDK
- Hot reload
- Router integration
- Capabilities & Permission system
- Audit logging
- WASI Preview 1 integration (fs, env, stdio)
- Effect system (host-side async I/O)
- Phase 4: Advanced features (ReloadableConfig, tracing)
- Phase 5: Developer experience (CLI generator, templates)
Contributions welcome! Please read DESIGN.md to understand the architecture first.
Licensed under either of:
- Apache License, Version 2.0 (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0)
- MIT license (LICENSE-MIT or http://opensource.org/licenses/MIT)
at your option.
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