Rust CLI with clap skill for Codex
Scriptable Rust CLIs that never panic on a pipe. Codex designs the clap derive model first, validates values in the parser, and treats output as an interface: results on stdout, diagnostics on stderr, stable JSON Lines behind a flag, and exit codes documented in one place. It handles BrokenPipe so piping into head exits cleanly and covers every command with tests that run the compiled binary.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- rust-clap-cli Installs to ~/.agents/skills/rust-clap-cli/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install rust-clap-cli The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project rust-clap-cli Installs to .agents/skills/rust-clap-cli/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/rust-clap-cli.zipunzip -o rust-clap-cli.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) rust-clap-cli PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $rust-clap-cli, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Builds Rust command-line tools with clap 4 derive - Parser, Subcommand, Args and ValueEnum, env var fallbacks, validation, shell completions and man pages - plus the parts clap does not do - stdout versus stderr discipline, --json output, NO_COLOR, broken pipe handling, documented exit codes and assert_cmd integration tests. Use when creating a new Rust CLI, adding subcommands or flags, making a tool scriptable, fixing panics on piped output, or writing tests that run the binary.
Use it when
- You are starting a new command-line tool in Rust
- Adding subcommands, flags, env var fallbacks or shell completions with clap
- Scripts need machine-readable output and reliable exit codes
- The binary needs end-to-end tests that run it with real arguments
Use something else when
- The CLI is written in Python with Typer or Click: use python-cli-tools
- You need language-neutral advice on help text wording: use cli-help-text
How to check the result
Cli::command().debug_assert() passes, assert_cmd tests cover success, usage errors (exit 2) and JSON shape, and piping output into head -1 exits cleanly.
The mistake it prevents
Using println! for results, which panics with a broken pipe error as soon as the output is piped into head or a closed reader.
Done well means: --help reads like documentation, results go to stdout and diagnostics
to stderr, scripts can rely on --json output and exit codes, piping into head never
panics, and every flag is covered by a test that runs the real binary.
Setup
cargo add clap --features derive,env,wrap_help # needs network
cargo add anyhow
cargo add --dev assert_cmd predicates tempfile
Check AGENTS.md and existing crates first: if the workspace already uses argh,
bpaf or lexopt, extend that instead of mixing parsers. clap 4.x is current; the derive
API is the default choice.
Argument model
use clap::{Args, Parser, Subcommand, ValueEnum};
use std::path::PathBuf;
/// Sync files between a local folder and a bucket.
#[derive(Debug, Parser)]
#[command(version, about, long_about = None, propagate_version = true)]
pub struct Cli {
#[command(flatten)]
pub global: GlobalOpts,
#[command(subcommand)]
pub command: Command,
}
#[derive(Debug, Args)]
pub struct GlobalOpts {
/// Increase log detail (-v, -vv, -vvv)
#[arg(short, long, action = clap::ArgAction::Count, global = true)]
pub verbose: u8,
/// Suppress progress output
#[arg(short, long, global = true, conflicts_with = "verbose")]
pub quiet: bool,
/// Output format
#[arg(long, value_enum, default_value_t = Format::Text, global = true)]
pub format: Format,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum Format { Text, Json }
#[derive(Debug, Subcommand)]
pub enum Command {
/// Upload changed files
Push {
/// Local directory to read
#[arg(value_name = "DIR")]
src: PathBuf,
/// Bucket URL, e.g. s3://team-assets
#[arg(long, env = "SYNC_BUCKET")]
bucket: String,
/// Parallel uploads
#[arg(short = 'j', long, default_value_t = 4,
value_parser = clap::value_parser!(u16).range(1..=64))]
jobs: u16,
/// Show what would change without uploading
#[arg(long)]
dry_run: bool,
},
/// List remote objects
Ls { prefix: Option<String> },
/// Print shell completions
Completions { shell: clap_complete::Shell },
}
Rules:
- Doc comments become help text; the first line is the short help, keep it under ~60 characters with no trailing period.
default_value_tfor typed defaults;env = "..."requires theenvfeature and shows in help. Never read secrets from flags (they end up in shell history andps); prefer env or a file path.- Validate in the parser where possible (
value_parser!(u16).range(..),ValueEnum,PathBuf). Cross-field rules go inconflicts_with,requires, orArgGroup. - Destructive subcommands (
delete,prune,reset) default to a confirmation prompt and offer--yesfor scripts; refuse to prompt when stdin is not a TTY and--yesis absent.
main and exit codes
use std::io::{self, IsTerminal, Write};
use std::process::ExitCode;
fn main() -> ExitCode {
let cli = Cli::parse(); // prints help/usage and exits 2 on bad args
init_logging(cli.global.verbose, cli.global.quiet);
match run(cli) {
Ok(()) => ExitCode::SUCCESS,
Err(err) if is_broken_pipe(&err) => ExitCode::SUCCESS,
Err(err) => {
eprintln!("error: {err:#}"); // full anyhow chain on one line
exit_code_for(&err)
}
}
}
fn is_broken_pipe(err: &anyhow::Error) -> bool {
err.chain().any(|e| e.downcast_ref::<io::Error>()
.is_some_and(|io| io.kind() == io::ErrorKind::BrokenPipe))
}
| Code | Meaning (document these in README and --help after_help) |
|---|---|
| 0 | Success |
| 1 | Runtime failure (I/O, network, remote error) |
| 2 | Usage error (clap uses this automatically) |
| 3+ | Tool-specific, e.g. 3 = “differences found” for check-style commands |
Map typed errors to codes in one function (exit_code_for) using err.downcast_ref.
Output discipline
-
Results to stdout; progress, logs, warnings and prompts to stderr.
-
Write through a locked, buffered handle and propagate errors instead of
println!, which panics on a closed pipe (mytool ls | head -1):let stdout = io::stdout(); let mut out = io::BufWriter::new(stdout.lock()); for obj in objects { match cli.global.format { Format::Text => writeln!(out, "{}\t{}", obj.size, obj.key)?, Format::Json => { serde_json::to_writer(&mut out, &obj)?; writeln!(out)?; } } } out.flush()?; -
--format jsonemits stable, documented fields (JSON Lines for streams). Treat field renames as breaking changes. -
Colour and spinners only when
io::stderr().is_terminal()andNO_COLORis unset; theanstream/anstylecrates (used by clap itself) handle this. -
Logging:
tracing-subscriberwriting to stderr, level from-vcount, overridable byRUST_LOG.
Completions and man pages
Command::Completions { shell } => {
let mut cmd = <Cli as clap::CommandFactory>::command();
clap_complete::generate(shell, &mut cmd, "synctl", &mut io::stdout());
}
For man pages, generate at build or release time with clap_mangen::Man::new(cmd).render(&mut file)
from a small xtask or build.rs; do not hand-write them.
Tests
// tests/cli.rs
use assert_cmd::Command;
use predicates::prelude::*;
#[test]
fn verify_cli_definition() {
use clap::CommandFactory;
synctl::Cli::command().debug_assert(); // catches conflicting/duplicate args
}
#[test]
fn rejects_zero_jobs() {
Command::cargo_bin("synctl").unwrap()
.args(["push", "./data", "--bucket", "s3://x", "-j", "0"])
.assert()
.code(2)
.stderr(predicate::str::contains("0 is not in 1..=64"));
}
#[test]
fn json_output_is_parseable() {
let out = Command::cargo_bin("synctl").unwrap()
.args(["ls", "--format", "json"]).env("SYNC_BUCKET", "file://tests/fixtures")
.output().unwrap();
assert!(out.status.success());
for line in String::from_utf8(out.stdout).unwrap().lines() {
serde_json::from_str::<serde_json::Value>(line).unwrap();
}
}
Put the Cli struct in lib.rs (or a cli module re-exported from it) so tests can call
Cli::command(). For many help-text and output cases, snapshot tests with trycmd keep
expectations in .toml/.md files.
Workflow
- [ ] 1. Read AGENTS.md and existing commands; list subcommands, flags, env vars, outputs
- [ ] 2. Define the Cli structs; run --help for every subcommand and read it as a user
- [ ] 3. Implement run() returning anyhow::Result, output via a buffered stdout writer
- [ ] 4. Map errors to exit codes; handle BrokenPipe
- [ ] 5. Add debug_assert test plus assert_cmd tests for success, usage error, JSON shape
- [ ] 6. Check piping: cargo run -- ls | head -1 exits cleanly
Gotchas
Option<bool>orboolwithdefault_value_t = truecannot be turned off from the command line; model it as--no-fooorArgAction::SetFalse.global = trueargs must be defined on the top-level struct (or a flattened one).- A positional
Vec<PathBuf>followed by another positional is ambiguous; uselast = trueor a flag. - Rust ignores SIGPIPE, so writes to a closed pipe return
BrokenPipeerrors; treat them as a clean exit, neverunwrap()a write. std::process::exitskips destructors (unflushed buffers, temp file cleanup); return anExitCodefrommaininstead.- Interactive prompts must go to stderr and must not run when stdin is not a terminal.
Verification
cargo test
cargo run -- --help && cargo run -- push --help
cargo run -- ls --format json | head -1; echo "exit=$?"
cargo run -- push ./nope --bucket x -j 0; echo "exit=$?" # expect 2
Report template
**Commands:** push, ls, completions (+ global -v/-q/--format)
**Exit codes:** 0 ok, 1 runtime, 2 usage, 3 diff found (documented in README)
**Output:** text default, JSON Lines with --format json (fields: key, size, etag)
**Tests:** 9 assert_cmd tests + Cli::command().debug_assert(); pipe to head exits 0 What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Rust CLI with clap"
short_description: "Scriptable Rust CLIs that never panic on a pipe"
default_prompt: "$rust-clap-cli Scaffold a Rust CLI with push, ls and completions subcommands using clap" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.