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.

  • Languages
  • $rust-clap-cli
  • 243 lines
  • 2 files
  • ~2,050 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- rust-clap-cli

Installs to ~/.agents/skills/rust-clap-cli/ for every project on this machine.

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

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.

~/.agents/skills/rust-clap-cli/SKILL.md

--- name: rust-clap-cli description: Builds Rust command-line tools with clap 4 derive - Parser, Subcommand, Args and ValueEnum, env var fallbacks, validation, shell completions and man… license: MIT ---

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_t for typed defaults; env = "..." requires the env feature and shows in help. Never read secrets from flags (they end up in shell history and ps); prefer env or a file path.
  • Validate in the parser where possible (value_parser!(u16).range(..), ValueEnum, PathBuf). Cross-field rules go in conflicts_with, requires, or ArgGroup.
  • Destructive subcommands (delete, prune, reset) default to a confirmation prompt and offer --yes for scripts; refuse to prompt when stdin is not a TTY and --yes is 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))
}
CodeMeaning (document these in README and --help after_help)
0Success
1Runtime failure (I/O, network, remote error)
2Usage 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 json emits stable, documented fields (JSON Lines for streams). Treat field renames as breaking changes.

  • Colour and spinners only when io::stderr().is_terminal() and NO_COLOR is unset; the anstream/anstyle crates (used by clap itself) handle this.

  • Logging: tracing-subscriber writing to stderr, level from -v count, overridable by RUST_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> or bool with default_value_t = true cannot be turned off from the command line; model it as --no-foo or ArgAction::SetFalse.
  • global = true args must be defined on the top-level struct (or a flattened one).
  • A positional Vec<PathBuf> followed by another positional is ambiguous; use last = true or a flag.
  • Rust ignores SIGPIPE, so writes to a closed pipe return BrokenPipe errors; treat them as a clean exit, never unwrap() a write.
  • std::process::exit skips destructors (unflushed buffers, temp file cleanup); return an ExitCode from main instead.
  • 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.yamlgenerated from this page

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.

esc