关于 rust-cli-builder
用于规划和构建 Rust 命令行工具的技能,采用 clap。它指导需求梳理、项目结构、配置、错误处理、输出和测试。
主要功能
- 01用 clap 搭建 Rust CLI
- 02设计命令和参数
- 03规划 TOML、JSON 或 YAML 配置
- 04组织错误处理和终端输出
- 05添加单元及集成测试
适用场景
- 01从头创建 Rust CLI 项目
- 02为现有 CLI 添加子命令
- 03规划配置文件及命令行覆盖
- 04完善 CLI 测试和输出
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ davila7/ claude-code-templates --skill rust-cli-builder安装后在 Claude Code 中输入/rust-cli-builder确认已加载。
详细介绍
Rust CLI 工具构建器
适用场景
在以下情况下使用此技能:
- 使用 clap 从头搭建新的 Rust CLI 工具
- 为现有 CLI 应用添加子命令
- 实现配置文件加载(TOML/JSON/YAML)
- 使用 anyhow/thiserror 设置恰当的错误处理
- 添加彩色且格式化的终端输出
- 组织 CLI 项目,以便通过 cargo install 或 GitHub releases 分发
阶段 1:探索(计划模式)
进入计划模式。在编写任何代码之前,先探索现有项目:
如果要扩展现有项目
- 找到
Cargo.toml并检查当前依赖(clap 版本、serde、tokio 等) - 定位 CLI 入口(
src/main.rs或src/cli.rs) - 检查 clap 使用的是 derive 宏还是构建器模式
- 识别现有的子命令结构
- 查找现有错误类型、配置结构体和输出格式
- 检查是否有用于将库逻辑与 CLI 分离的
src/lib.rs
如果从头开始
- 检查工作区中是否已有 Rust 项目或工作区
Cargo.toml - 查找是否有包含自定义设置的
.cargo/config.toml - 检查
rust-toolchain.toml,了解目标 Rust 版本
阶段 2:访谈(AskUserQuestion)
使用 AskUserQuestion 澄清需求。分轮提问。
第 1 轮:工具用途和命令
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
第 2 轮:子命令(如果有多个命令)
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
第 3 轮:配置和输出
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
第 4 轮:异步和错误处理
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
阶段 3:规划(ExitPlanMode)
编写具体的实现计划,涵盖:
- 项目结构 —
Cargo.toml依赖、src/文件布局 - CLI 定义 — 所有命令、参数和标志的 clap derive 结构体
- 配置加载 — 配置文件格式及与 CLI 参数的合并策略
- 核心逻辑 — 各子命令的主函数,与 CLI 层分离
- 错误类型 — 错误枚举或 anyhow 用法,以及面向用户的错误消息
- 输出格式 — 彩色输出、JSON 模式、进度指示器
- 测试 — 核心逻辑的单元测试、CLI 行为的集成测试
通过 ExitPlanMode 提交计划,供用户批准。
阶段 4:执行
获得批准后,按以下顺序实现:
步骤 1:项目设置(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/)
步骤 2:使用 clap derive 定义 CLI
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,
}
步骤 3:错误处理
// 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),
}
步骤 4:配置文件加载
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")
}
}
步骤 5:彩色输出和格式化
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());
}
}
}
}
步骤 6:主入口
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<()> { ... }
步骤 7:子命令实现
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(())
}
步骤 8:测试
#[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("{"));
}
项目结构参考
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
最佳实践
将 CLI 与逻辑分离
将 clap 结构体和参数解析放在 cli.rs 中。将业务逻辑放在 commands/ 中。这样无需调用 CLI 即可测试核心逻辑。
状态信息使用 stderr,数据使用 stdout
面向用户的消息(进度、成功、错误)写入 stderr。机器可读的数据写入 stdout。这样用户可以顺畅地通过管道传递输出:toolname status --format json | jq '.items'。
遵循 NO_COLOR
检查 NO_COLOR 环境变量,若已设置则禁用颜色:
if std::env::var("NO_COLOR").is_ok() {
colored::control::set_override(false);
}
退出代码
使用有意义的退出代码:成功时为 0,一般错误时为 1,用法错误时为 2(clap 会自动处理)。
测试用开发依赖
[dev-dependencies]
assert_cmd = "2"
predicates = "3"
tempfile = "3"
完成前检查清单
-
clapderive 结构体包含文档注释(这些注释会成为 --help 文本) - 所有子命令都有简短和详细描述
- 配置文件有合理的默认值,缺失时不会报错
-
--format json会向 stdout 输出有效且可解析的 JSON - 错误会提供上下文(文件路径、问题原因和修复方法)
- 集成测试会端到端验证 CLI 行为
-
cargo clippy通过且无警告 - 已运行
cargo fmt
评论
支持 Markdown