Rust CLI Tool Builder When to use Use this skill when you need to: Scaffold a new Rust CLI tool from scratch with clap Add subcommands to an existing CLI application Implement config file loading (TOML/JSON/YAML) Set up proper error handling with anyhow/thiserror Add colored and formatted terminal output Structure a CLI project for distribution via cargo install or GitHub releases Phase 1: Explore (Plan Mode) Enter plan mode. Before writing any code, explore the existing project: If extending an existing project Find Cargo.toml and check current dependencies (clap version, serde, tokio, etc.) Locate the CLI entry point ( src/main.rs or src/cli.rs ) Check if clap is using derive macros or builder pattern Identify existing subcommand structure Look for existing error types, config structs, and output formatting Check if there's a src/lib.rs separating library logic from CLI If starting from scratch Check the workspace for any existing Rust projects or workspace Cargo.toml Look for a .cargo/config.toml with custom settings Check for rust-toolchain.toml to know the target Rust edition Phase 2: Interview (AskUserQuestion) Use AskUserQuestion to clarify requirements. Ask in rounds. Round 1: Tool purpose and commands Question: "What kind of CLI tool are you building?" Header: "Tool type" Options: - "Single command (like ripgrep, curl)" — One main action with flags and arguments - "Multi-command (like git, cargo)" — Multiple subcommands under one binary - "Interactive REPL (like psql)" — Persistent session with a prompt loop - "Pipeline tool (like jq, sed)" — Reads stdin, transforms, writes stdout Question: "What will the tool operate on?" Header: "Input" Options: - "Files/directories" — Read, process, or generate files - "Network/API" — HTTP requests, TCP connections, API calls - "System resources" — Processes, hardware info, OS config - "Data streams (stdin/stdout)" — Pipe-friendly text/binary processing Round 2: Subcommands (if multi-command) Question: "Describe the subcommands you need (e.g., 'init', 'build', 'deploy')" Header: "Commands" Options: - "2-3 subcommands (I'll describe them)" — Small focused tool - "4-8 subcommands with groups" — Medium tool, may need command groups - "I have a rough list, help me design the API" — Collaborative command design Round 3: Configuration and output Question: "How should the tool be configured?" Header: "Config" Options: - "CLI flags only (Recommended)" — All config via command-line arguments - "Config file (TOML)" — Load defaults from ~/.config/toolname/config.toml - "Config file + CLI overrides" — Config file for defaults, flags override specific values - "Environment variables + flags" — Env vars for secrets, flags for everything else Question: "What output format does the tool need?" Header: "Output" Options: - "Human-readable (colored text)" — Pretty terminal output with colors and formatting - "Machine-readable (JSON)" — Structured output for piping to other tools - "Both (--format flag)" — Default human, --json or --format=json for machines - "Minimal (exit codes only)" — Success/failure via exit code, errors to stderr Round 4: Async and error handling Question: "Does the tool need async operations?" Header: "Async" Options: - "No — synchronous is fine (Recommended)" — File I/O, computation, simple operations - "Yes — tokio (network I/O)" — HTTP requests, concurrent connections, async file I/O - "Yes — tokio multi-threaded" — Heavy parallelism, multiple concurrent tasks Question: "How should errors be presented to users?" Header: "Errors" Options: - "Simple messages (anyhow) (Recommended)" — Human-readable error chains, good for most CLIs - "Typed errors (thiserror)" — Custom error enum with specific variants for each failure - "Both (thiserror for lib, anyhow for bin)" — Library code is typed, CLI wraps with anyhow Phase 3: Plan (ExitPlanMode) Write a concrete implementation plan covering: Project structure — Cargo.toml dependencies, src/ file layout CLI definition — clap derive structs for all commands, args, and flags Config loading — config file format and merge strategy with CLI args Core logic — main functions for each subcommand, separated from CLI layer Error types — error enum or anyhow usage, user-facing error messages Output formatting — colored output, JSON mode, progress indicators Tests — unit tests for core logic, integration tests for CLI behavior Present via ExitPlanMode for user approval. Phase 4: Execute After approval, implement following this order: Step 1: Project setup (Cargo.toml) [ package ] name = "toolname" version = "0.1.0" edition = "2021" description = "Short description of the tool" [ dependencies ] clap = { version = "4" , features = [ "derive" , "env" ] } serde = { version = "1" , features = [ "derive" ] } anyhow = "1"
Add based on interview:
thiserror = "2" # if typed errors
tokio = { version = "1", features = ["full"] } # if async
serde_json = "1" # if JSON output
toml = "0.8" # if TOML config
colored = "2" # if colored output
indicatif = "0.17" # if progress bars
dirs = "5" # if config file (~/.config/)
Step 2: CLI definition with clap derive use clap :: { Parser , Subcommand } ; /// Short one-line description of the tool
[derive(Parser, Debug)]
[command(name =
"toolname" , version, about, long_about = None)] pub struct Cli { /// Increase verbosity (-v, -vv, -vvv)
[arg(short, long, action = clap::ArgAction::Count, global = true)]
pub verbose : u8 , /// Output format
[arg(long, default_value =
"text" , global = true)] pub format : OutputFormat , /// Path to config file
[arg(long, global = true)]
pub config : Option < std :: path :: PathBuf
,
[command(subcommand)]
pub command : Commands , }
[derive(Subcommand, Debug)]
pub enum Commands { /// Initialize a new project Init { /// Project name name : String , /// Template to use
[arg(short, long, default_value =
"default" )] template : String , } , /// Build the project Build { /// Build in release mode
[arg(short, long)]
release : bool , /// Target directory
[arg(short, long)]
output : Option < std :: path :: PathBuf
, } , /// Show project status Status , }
[derive(clap::ValueEnum, Clone, Debug)]
pub enum OutputFormat { Text , Json , } Step 3: Error handling // With anyhow (simple approach): use anyhow :: { Context , Result } ; fn load_config ( path : & Path ) -> Result < Config
{ let content = std :: fs :: read_to_string ( path ) . with_context ( | | format! ( "Failed to read config file: {}" , path . display ( ) ) ) ? ; let config : Config = toml :: from_str ( & content ) . context ( "Invalid TOML in config file" ) ? ; Ok ( config ) } // With thiserror (typed approach): use thiserror :: Error ;
[derive(Error, Debug)]
pub enum AppError {
[error(
"Config file not found: {path}" )] ConfigNotFound { path : std :: path :: PathBuf } ,
[error(
"Invalid config: {0}" )] InvalidConfig (
[from]
toml :: de :: Error ) ,
[error(
"Network error: {0}" )] Network (
[from]
reqwest :: Error ) ,
[error(
"{0}" )] Custom ( String ) , } Step 4: Config file loading use serde :: Deserialize ; use std :: path :: { Path , PathBuf } ;
[derive(Deserialize, Debug, Default)]
pub struct Config { pub default_template : Option < String
, pub output_dir : Option < PathBuf
, // ... fields from interview } impl Config { pub fn load ( explicit_path : Option < & Path
) -> anyhow :: Result < Self
{ let path = match explicit_path { Some ( p ) => p . to_path_buf ( ) , None => Self :: default_path ( ) , } ; if ! path . exists ( ) { return Ok ( Config :: default ( ) ) ; } let content = std :: fs :: read_to_string ( & path ) ? ; let config : Config = toml :: from_str ( & content ) ? ; Ok ( config ) } fn default_path ( ) -> PathBuf { dirs :: config_dir ( ) . unwrap_or_else ( | | PathBuf :: from ( "." ) ) . join ( "toolname" ) . join ( "config.toml" ) } } Step 5: Colored output and formatting use colored :: Colorize ; pub struct Output { format : OutputFormat , verbose : u8 , } impl Output { pub fn new ( format : OutputFormat , verbose : u8 ) -> Self { Self { format , verbose } } pub fn success ( & self , msg : & str ) { match self . format { OutputFormat :: Text => eprintln! ( "{} {}" , "✓" . green ( ) . bold ( ) , msg ) , OutputFormat :: Json => { } // JSON output goes to stdout only } } pub fn error ( & self , msg : & str ) { match self . format { OutputFormat :: Text => eprintln! ( "{} {}" , "✗" . red ( ) . bold ( ) , msg ) , OutputFormat :: Json => { let err = serde_json :: json! ( { "error" : msg } ) ; println! ( "{}" , serde_json :: to_string ( & err ) . unwrap ( ) ) ; } } } pub fn info ( & self , msg : & str ) { if self . verbose = 1 { match self . format { OutputFormat :: Text => eprintln! ( "{} {}" , "ℹ" . blue ( ) , msg ) , OutputFormat :: Json => { } } } } pub fn data < T : serde :: Serialize
( & self , data : & T ) { match self . format { OutputFormat :: Text => { // Pretty print for humans — customize per subcommand println! ( "{:#?}" , data ) ; } OutputFormat :: Json => { println! ( "{}" , serde_json :: to_string_pretty ( data ) . unwrap ( ) ) ; } } } } Step 6: Main entry point use clap :: Parser ; fn main ( ) -> anyhow :: Result < ( )
{ let cli = Cli :: parse ( ) ; let config = Config :: load ( cli . config . as_deref ( ) ) ? ; let output = Output :: new ( cli . format . clone ( ) , cli . verbose ) ; match cli . command { Commands :: Init { name , template } => { cmd_init ( & name , & template , & config , & output ) ? ; } Commands :: Build { release , output_dir } => { let dir = output_dir . or ( config . output_dir . clone ( ) ) . unwrap_or_else ( | | PathBuf :: from ( "./dist" ) ) ; cmd_build ( release , & dir , & output ) ? ; } Commands :: Status => { cmd_status ( & config , & output ) ? ; } } Ok ( ( ) ) } // If async (tokio): // #[tokio::main] // async fn main() -> anyhow::Result<()> { ... } Step 7: Subcommand implementations fn cmd_init ( name : & str , template : & str , config : & Config , out : & Output ) -> anyhow :: Result < ( )
{ let template = if template == "default" { config . default_template . as_deref ( ) . unwrap_or ( "default" ) } else { template } ; out . info ( & format! ( "Using template: {}" , template ) ) ; let project_dir = Path :: new ( name ) ; if project_dir . exists ( ) { anyhow :: bail! ( "Directory '{}' already exists" , name ) ; } std :: fs :: create_dir_all ( project_dir ) ? ; // ... scaffold project files based on template out . success ( & format! ( "Created project '{}' with template '{}'" , name , template ) ) ; Ok ( ( ) ) } Step 8: Tests
[cfg(test)]
mod tests { use super :: * ;
[test]
fn test_config_default ( ) { let config = Config :: default ( ) ; assert! ( config . default_template . is_none ( ) ) ; }
[test]
fn test_config_parse_toml ( ) { let toml_str = r#" default_template = "react" output_dir = "./build" "# ; let config : Config = toml :: from_str ( toml_str ) . unwrap ( ) ; assert_eq! ( config . default_template . unwrap ( ) , "react" ) ; } } // Integration tests (tests/cli.rs): use assert_cmd :: Command ; use predicates :: prelude :: * ;
[test]
fn test_help_flag ( ) { Command :: cargo_bin ( "toolname" ) . unwrap ( ) . arg ( "--help" ) . assert ( ) . success ( ) . stdout ( predicate :: str :: contains ( "Usage:" ) ) ; }
[test]
fn test_version_flag ( ) { Command :: cargo_bin ( "toolname" ) . unwrap ( ) . arg ( "--version" ) . assert ( ) . success ( ) ; }
[test]
fn test_init_creates_directory ( ) { let dir = tempfile :: tempdir ( ) . unwrap ( ) ; let project_name = dir . path ( ) . join ( "test-project" ) ; Command :: cargo_bin ( "toolname" ) . unwrap ( ) . args ( [ "init" , project_name . to_str ( ) . unwrap ( ) ] ) . assert ( ) . success ( ) ; assert! ( project_name . exists ( ) ) ; }
[test]
fn test_init_existing_directory_fails ( ) { let dir = tempfile :: tempdir ( ) . unwrap ( ) ; Command :: cargo_bin ( "toolname" ) . unwrap ( ) . args ( [ "init" , dir . path ( ) . to_str ( ) . unwrap ( ) ] ) . assert ( ) . failure ( ) . stderr ( predicate :: str :: contains ( "already exists" ) ) ; }
[test]
fn test_json_output_format ( ) { Command :: cargo_bin ( "toolname" ) . unwrap ( ) . args ( [ "--format" , "json" , "status" ] ) . assert ( ) . success ( ) . stdout ( predicate :: str :: starts_with ( "{" ) ) ; } Project structure reference toolname/ ├── Cargo.toml ├── src/ │ ├── main.rs # Entry point, CLI parsing, command dispatch │ ├── cli.rs # Clap derive structs (Cli, Commands, Args) │ ├── config.rs # Config file loading and merging │ ├── output.rs # Output formatting (text/JSON/colored) │ ├── error.rs # Error types (if using thiserror) │ └── commands/ │ ├── mod.rs │ ├── init.rs # Init subcommand logic │ ├── build.rs # Build subcommand logic │ └── status.rs # Status subcommand logic └── tests/ └── cli.rs # Integration tests with assert_cmd Best practices Separate CLI from logic Keep clap structs and argument parsing in cli.rs . Put business logic in commands/ . This makes the core logic testable without invoking the CLI. Use stderr for status, stdout for data Human-readable messages (progress, success, errors) go to stderr . Machine-readable data goes to stdout . This lets users pipe output cleanly: toolname status --format json | jq '.items' . Respect NO_COLOR Check the NO_COLOR environment variable and disable colors when set: if std :: env :: var ( "NO_COLOR" ) . is_ok ( ) { colored :: control :: set_override ( false ) ; } Exit codes Use meaningful exit codes: 0 for success, 1 for general errors, 2 for usage errors (clap handles this automatically). Dev dependencies for testing [ dev-dependencies ] assert_cmd = "2" predicates = "3" tempfile = "3" Checklist before finishing clap derive structs have doc comments (they become --help text) All subcommands have short and long descriptions Config file has sensible defaults and doesn't error when missing --format json outputs valid, parseable JSON to stdout Errors show context (file paths, what went wrong, how to fix it) Integration tests verify CLI behavior end-to-end cargo clippy passes with no warnings cargo fmt has been run