Keyboard shortcuts

Press or to navigate between chapters

Press ? to show this help

Press Esc to hide this help

cli-template

A batteries-included Rust CLI template with structured errors, config management, CI/CD pipelines, and automated releases.

What You Get

  • Clap argument parsing with derive macros
  • snafu structured error handling
  • tokio async runtime
  • tracing observability
  • TOML config system with CLI get/set commands
  • reqwest HTTP client singletons
  • GitHub Actions CI/CD (lint, test, release)
  • cargo-dist cross-platform binary builds
  • npx distribution — users run your CLI without Rust installed
  • Agent-friendly output: JSON stdout, logs stderr

Quick Start

cargo generate rararulab/cli-template
cd my-awesome-cli
cargo run -- --help

See Getting Started for the full walkthrough.

Getting Started

Prerequisites

Install the Rust toolchain and cargo-generate:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
cargo install cargo-generate

Create a Project

cargo generate rararulab/cli-template

You’ll be prompted for three values:

PlaceholderFormatExampleUsed For
project-namekebab-casemy-awesome-cliBinary name, directory, npm pkg
crate_namesnake_case(auto-derived)Rust module name
github-orgmyorgRepo URLs, CI badges

crate_name is derived automatically from project-name — you rarely need to change it.

First Run

cd my-awesome-cli
cargo check
cargo test
cargo run -- --help
cargo run -- hello world

The hello command is a working example wired end-to-end. Use it as a reference when adding your own commands.

What to Customize

Once you’ve verified the template builds, update these files:

  • CLAUDE.md — add a description of your project for AI-assisted development
  • Cargo.toml — set description, repository, and homepage
  • src/cli/mod.rs — replace the example Hello command with your own
  • src/app_config.rs — replace ExampleConfig with your app’s config fields
  • README.md — rewrite for your project

Clean Up Example Code

Once your first real command is in place, remove the scaffolding:

  1. Delete the Hello variant from the Command enum in src/cli/mod.rs
  2. Delete its match arm in main.rs
  3. Replace ExampleConfig in src/app_config.rs with your own config struct

Don’t delete the example code until you have a real command working. It serves as a reference for the patterns used throughout the template.

Verify

cargo check && cargo test && cargo clippy

All three should pass cleanly before your first commit.

Next Steps

Project Structure

Module Map

src/
├── main.rs          # Entry: CLI parse → dispatch → JSON output
├── lib.rs           # Module re-exports
├── cli/mod.rs       # Clap: Cli struct, Command enum
├── error.rs         # AppError (snafu)
├── app_config.rs    # TOML config: load()/save()
├── paths.rs         # Data directory (~/.project-name/)
├── http.rs          # Shared reqwest clients
└── agent/           # AI agent backend integration
    ├── mod.rs
    ├── backend.rs
    ├── config.rs
    └── executor.rs

Data Flow

User input → Cli::parse() → match command → module logic → JSON stdout
                                                         → logs stderr

All commands follow the same pattern: parse arguments, execute logic, serialize results to JSON on stdout. Human-readable logs go to stderr via tracing.

Module Reference

main.rs

Entry point. Parses CLI args, dispatches to the matched command, and prints the result as JSON to stdout.

lib.rs

Re-exports all modules so they can be used from integration tests and other crates.

cli/mod.rs

Clap derive definitions. Contains the top-level Cli struct and the Command enum with all subcommands.

error.rs

Top-level AppError type built with snafu. Each error variant maps to a specific failure mode with structured context.

app_config.rs

TOML-based config with load() and save() functions. Uses OnceLock to cache the parsed config for the lifetime of the process.

paths.rs

Resolves the data directory (~/.project-name/) and config file paths. All filesystem locations are derived from here.

http.rs

Provides client() and download_client() singletons via OnceLock. Both return shared reqwest::Client instances with sensible defaults.

agent/

AI backend integration layer. backend.rs defines provider presets, config.rs handles agent-specific settings, and executor.rs spawns and manages agent processes.

Key Conventions

  • stdout = JSON only. Every command outputs machine-readable JSON. Never println! free-form text to stdout.
  • stderr = human-readable logs. Use tracing::info!, tracing::warn!, etc. for all human-facing output.
  • Config is cached via OnceLock. Call load() freely — the file is read once and reused.
  • HTTP clients are singletons. Use http::client() instead of constructing your own reqwest::Client.
  • Structs with 3+ fields use bon::Builder. Derive Builder for any struct that would otherwise need a verbose constructor.

Adding Commands

Three patterns, from simplest to most complex.

Pattern A: Simple Command (2 files)

Add a count command that counts lines in a file.

1. Add variant to Command enum

src/cli/mod.rs

#![allow(unused)]
fn main() {
/// Count lines in a file
Count {
    /// Path to the file
    path: std::path::PathBuf,
    /// Skip empty lines
    #[arg(long)]
    no_empty: bool,
},
}

2. Add dispatch

src/main.rs

#![allow(unused)]
fn main() {
Command::Count { path, no_empty } => {
    let content = std::fs::read_to_string(&path).context(IoSnafu)?;
    let count = if no_empty {
        content.lines().filter(|l| !l.trim().is_empty()).count()
    } else {
        content.lines().count()
    };
    eprintln!("{count} lines in {}", path.display());
    println!("{}", serde_json::json!({"ok": true, "action": "count", "lines": count}));
}
}

That’s it. Two files, done.


Pattern B: Config-Dependent Command

Everything from Pattern A, plus config integration.

1. Define config struct

src/app_config.rs

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DownloadConfig {
    /// Directory to save downloaded files
    #[serde(default = "default_output_dir")]
    pub output_dir: String,

    /// Maximum concurrent downloads
    #[serde(default = "default_max_concurrent")]
    pub max_concurrent: usize,
}

fn default_output_dir() -> String {
    "./downloads".to_string()
}

fn default_max_concurrent() -> usize {
    4
}

impl Default for DownloadConfig {
    fn default() -> Self {
        Self {
            output_dir: default_output_dir(),
            max_concurrent: default_max_concurrent(),
        }
    }
}
}

2. Add field to AppConfig

#![allow(unused)]
fn main() {
pub struct AppConfig {
    // ... existing fields ...
    #[serde(default)]
    pub download: DownloadConfig,
}
}

3. Wire config accessors

In set_config_field:

#![allow(unused)]
fn main() {
"download.output_dir" => {
    self.download.output_dir = value.to_string();
}
"download.max_concurrent" => {
    self.download.max_concurrent = value.parse().context(ParseIntSnafu)?;
}
}

In get_config_field:

#![allow(unused)]
fn main() {
"download.output_dir" => Some(self.download.output_dir.clone()),
"download.max_concurrent" => Some(self.download.max_concurrent.to_string()),
}

In config_as_map:

#![allow(unused)]
fn main() {
map.insert("download.output_dir".into(), self.download.output_dir.clone());
map.insert("download.max_concurrent".into(), self.download.max_concurrent.to_string());
}

4. Add command variant and dispatch

Same as Pattern A. Access config via config.download.output_dir.


Pattern C: HTTP Command

Everything from Pattern A, plus an HTTP call using the shared client.

Command variant

#![allow(unused)]
fn main() {
/// Fetch data from a URL
Fetch {
    /// Target URL
    url: String,
},
}

Dispatch

#![allow(unused)]
fn main() {
Command::Fetch { url } => {
    let resp = http::client()
        .get(&url)
        .send().await.context(HttpSnafu)?
        .json::<serde_json::Value>().await.context(HttpSnafu)?;
    println!("{}", serde_json::to_string_pretty(&resp).context(SerializeSnafu)?);
}
}

http::client() returns a pre-configured reqwest::Client with timeouts and default headers already set.


Extracting into a Module

When command logic outgrows the dispatch block, extract it.

File structure

src/yourmodule/
  mod.rs       # //! doc + re-exports
  client.rs    # error enum + logic

src/yourmodule/mod.rs

#![allow(unused)]
fn main() {
//! Your module — does X, Y, Z.

mod client;

pub use client::{YourModuleClient, YourModuleError};
}

src/yourmodule/client.rs

#![allow(unused)]
fn main() {
use snafu::{ResultExt, Snafu};

#[derive(Debug, Snafu)]
#[snafu(visibility(pub))]
pub enum YourModuleError {
    #[snafu(display("request failed: {source}"))]
    Request { source: reqwest::Error },
}

pub type Result<T> = std::result::Result<T, YourModuleError>;

/// Client for interacting with the service.
pub struct YourModuleClient {
    http: reqwest::Client,
}

impl YourModuleClient {
    pub fn new(http: reqwest::Client) -> Self {
        Self { http }
    }

    pub async fn do_thing(&self) -> Result<()> {
        // ...
        Ok(())
    }
}
}

Register in src/lib.rs

#![allow(unused)]
fn main() {
pub mod yourmodule;
}

Add error variant to src/error.rs

#![allow(unused)]
fn main() {
#[snafu(display("yourmodule error: {source}"))]
YourModule { source: yourmodule::YourModuleError },
}

Checklist

Before opening a PR, verify:

  • Module has //! doc comment
  • Error enum named {Module}Error with #[derive(Debug, Snafu)]
  • All pub items have /// doc comments
  • Module registered in src/lib.rs
  • Structs with 3+ fields use #[derive(bon::Builder)]

Error Handling

Why snafu

snafu gives you contextual, structured errors without manual impl boilerplate. Each error variant carries the data needed to produce a useful message, and context selectors make propagation a one-liner.

Defining Errors

#![allow(unused)]
fn main() {
use snafu::{ResultExt, Snafu};

#[derive(Debug, Snafu)]
#[snafu(visibility(pub))]
pub enum FooError {
    #[snafu(display("failed to read {path}: {source}"))]
    ReadFile { path: String, source: std::io::Error },

    #[snafu(display("invalid format: {message}"))]
    InvalidFormat { message: String },
}

pub type Result<T> = std::result::Result<T, FooError>;
}

Key points:

  • #[snafu(visibility(pub))] makes context selectors (ReadFileSnafu, etc.) public so callers can use them.
  • Variants with a source field wrap an underlying error. Variants without source are leaf errors.
  • Always define a module-level Result<T> type alias.

Propagating Errors

Use .context() to convert a lower-level error into your domain error:

#![allow(unused)]
fn main() {
use std::fs;

pub fn read_config(path: &str) -> Result<String> {
    fs::read_to_string(path).context(ReadFileSnafu { path })
}
}

The ReadFileSnafu selector is auto-generated from the ReadFile variant. It captures path and wraps the io::Error as source.

For quick-and-dirty context when you don’t need structured fields:

#![allow(unused)]
fn main() {
use snafu::Whatever;

fn do_thing() -> Result<(), Whatever> {
    std::fs::remove_file("/tmp/x").whatever_context("failed to clean up temp file")?;
    Ok(())
}
}

Creating Errors Directly

When there’s no underlying error to wrap, use .fail():

#![allow(unused)]
fn main() {
pub fn validate(input: &str) -> Result<()> {
    if input.is_empty() {
        return InvalidFormatSnafu {
            message: "input must not be empty",
        }
        .fail();
    }
    Ok(())
}
}

Module Result Type

Every module with its own error enum should define:

#![allow(unused)]
fn main() {
pub type Result<T> = std::result::Result<T, MyModuleError>;
}

This keeps signatures clean:

#![allow(unused)]
fn main() {
// Good
pub fn parse(input: &str) -> Result<Config> { ... }

// Avoid
pub fn parse(input: &str) -> std::result::Result<Config, MyModuleError> { ... }
}

Rules

Warning: Never use thiserror or manual impl Error for new code. Never use .unwrap() outside of tests — use .expect("why this is safe") if you must assert.

Configuration

Location

Configuration lives at ~/.{project-name}/config.toml. It is auto-created with defaults on first use.

Structure

AppConfig is the top-level struct. Each section is a nested struct tagged with #[serde(default)] so missing fields fall back to defaults.

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
    #[serde(default)]
    pub general: GeneralConfig,
    // add new sections here
}
}

Adding a Section

Full example adding a DownloadConfig section.

1. Define the struct with a Default impl

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DownloadConfig {
    pub output_dir: String,
    pub max_retries: u32,
}

impl Default for DownloadConfig {
    fn default() -> Self {
        Self {
            output_dir: "./downloads".into(),
            max_retries: 3,
        }
    }
}
}

2. Add to AppConfig

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
    #[serde(default)]
    pub general: GeneralConfig,
    #[serde(default)]
    pub download: DownloadConfig,
}
}

3. Wire the CLI helpers

Update these three functions so config set / config get work for your new fields:

  • set_config_field — match on "download.output_dir", "download.max_retries", etc. and mutate the config.
  • get_config_field — match on the same keys and return the current value as a string.
  • config_as_map — insert each field into the map so config list can display them.

Reading

#![allow(unused)]
fn main() {
let cfg = app_config::load(); // returns &'static AppConfig (OnceLock-cached)
println!("{}", cfg.download.output_dir);
}

The config is loaded once and cached for the lifetime of the process.

Writing

#![allow(unused)]
fn main() {
let mut cfg = app_config::load().clone();
cfg.download.output_dir = "./out".into();
app_config::save(&cfg)?;
}

CLI Commands

# Set a value
myapp config set download.output_dir ./out

# Get a value
myapp config get download.output_dir

CI/CD & Release

Included Workflows

WorkflowFileTriggerPurpose
CIci.ymlPRscargo check, test, clippy
Lintlint.ymlPRscargo fmt check
Buildbuild-binaries.ymlReleaseCross-platform binary builds via cargo-dist
npm Publishpublish-npm.ymlReleasePublish platform packages to npm
Pagespages.ymlPush to mainDeploy web/ to GitHub Pages

Conventional Commits

All commits should follow the format:

type(scope): description (#N)

Accepted types: feat, fix, refactor, docs, test, chore, ci, perf.

Examples:

feat(download): add retry logic (#42)
fix(config): handle missing file gracefully (#51)
chore(deps): bump serde to 1.0.200

Release Flow

  1. Merge your PR into main.
  2. release-plz automatically opens a release PR that bumps the version and updates the changelog.
  3. Merge the release PR.
  4. cargo-dist builds binaries for all platforms and creates a GitHub release.
  5. publish-npm.yml publishes the npm wrapper packages.

No manual version bumping or tagging required.

justfile

Run the full pre-commit suite locally:

just pre-commit

This runs, in order: fmt --check, clippy, doc, and test.

npx Distribution

How It Works

An npm wrapper package detects the user’s platform and architecture, downloads the matching pre-built Rust binary, and runs it. No Rust toolchain required on the user’s machine.

Directory Structure

npm/
├── package.json          # root wrapper with install/bin scripts
├── darwin-arm64/         # macOS Apple Silicon
├── darwin-x64/           # macOS Intel
├── linux-arm64/          # Linux ARM
└── linux-x64/            # Linux x86_64

Each platform directory contains its own package.json and is published as a separate npm package marked as an optional dependency.

User Experience

npx @your-org/your-project --help

That’s it. npm resolves the correct platform package automatically.

Publishing

Automated by the publish-npm.yml workflow. On each GitHub release, it updates the version in every platform package.json and publishes all packages to npm.

Customizing

Update npm/package.json and each platform package.json with:

  • Your npm scope and package name (e.g., @your-org/your-project)
  • The binary name that matches your Cargo build output