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:
| Placeholder | Format | Example | Used For |
|---|---|---|---|
project-name | kebab-case | my-awesome-cli | Binary name, directory, npm pkg |
crate_name | snake_case | (auto-derived) | Rust module name |
github-org | — | myorg | Repo URLs, CI badges |
crate_nameis derived automatically fromproject-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 developmentCargo.toml— setdescription,repository, andhomepagesrc/cli/mod.rs— replace the exampleHellocommand with your ownsrc/app_config.rs— replaceExampleConfigwith your app’s config fieldsREADME.md— rewrite for your project
Clean Up Example Code
Once your first real command is in place, remove the scaffolding:
- Delete the
Hellovariant from theCommandenum insrc/cli/mod.rs - Delete its match arm in
main.rs - Replace
ExampleConfiginsrc/app_config.rswith 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 — understand the module layout and conventions
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. Callload()freely — the file is read once and reused. - HTTP clients are singletons. Use
http::client()instead of constructing your ownreqwest::Client. - Structs with 3+ fields use
bon::Builder. DeriveBuilderfor 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}Errorwith#[derive(Debug, Snafu)] - All
pubitems 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
sourcefield wrap an underlying error. Variants withoutsourceare 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
thiserroror manualimpl Errorfor 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 soconfig listcan 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
| Workflow | File | Trigger | Purpose |
|---|---|---|---|
| CI | ci.yml | PRs | cargo check, test, clippy |
| Lint | lint.yml | PRs | cargo fmt check |
| Build | build-binaries.yml | Release | Cross-platform binary builds via cargo-dist |
| npm Publish | publish-npm.yml | Release | Publish platform packages to npm |
| Pages | pages.yml | Push to main | Deploy 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
- Merge your PR into
main. - release-plz automatically opens a release PR that bumps the version and updates the changelog.
- Merge the release PR.
- cargo-dist builds binaries for all platforms and creates a GitHub release.
- 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