mcpField
R
rust-cli-builder

rust-cli-builder

github.com/davila7開発33.0k96 回インストール1 日前に更新
リポジトリ

rust-cli-builder について

clapを使ったRustコマンドラインツールの計画と構築を支援するスキルです。要件整理から構成、設定、エラー処理、出力、テストまでを案内します。

主な機能

  1. 01clapによるRust CLIの雛形作成
  2. 02コマンドと引数の設計
  3. 03TOML、JSON、YAML設定の計画
  4. 04エラー処理とターミナル出力の構成
  5. 05単体テストと統合テストの追加

活用シーン

  1. 01Rust CLIプロジェクトの新規作成
  2. 02既存CLIへのサブコマンド追加
  3. 03設定ファイルとCLI上書きの計画
  4. 04CLIのテストと出力の改善

インストール

コマンドをコピーしてターミナルで実行します。

$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 を使って適切なエラー処理を設定する
  • 色付きで整形されたターミナル出力を追加する
  • cargo install または GitHub リリースで配布できるように CLI プロジェクトを構成する

フェーズ 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 エディションを把握するために rust-toolchain.toml を確認する

フェーズ 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)

次の内容を含む、具体的な実装計画を作成します。

  1. プロジェクト構成 — Cargo.toml の依存関係、src/ のファイルレイアウト
  2. CLI 定義 — すべてのコマンド、引数、フラグのための clap derive 構造体
  3. 設定の読み込み — 設定ファイルの形式と、CLI 引数とのマージ戦略
  4. コアロジック — CLI 層から分離された、各サブコマンドのメイン関数
  5. エラー型 — エラー enum または anyhow の使用方法、ユーザー向けのエラーメッセージ
  6. 出力フォーマット — 色付き出力、JSON モード、進捗インジケーター
  7. テスト — コアロジックのユニットテスト、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"

完了前のチェックリスト

  • clap の derive 構造体にドキュメントコメントがある(これが --help のテキストになる)
  • すべてのサブコマンドに短い説明と長い説明がある
  • 設定ファイルには適切なデフォルト値があり、ファイルが存在しなくてもエラーにならない
  • --format json は、有効で解析可能な JSON を stdout に出力する
  • エラーには文脈(ファイルパス、何が問題だったか、修正方法)が表示される
  • 統合テストで CLI の動作をエンドツーエンドで検証している
  • cargo clippy が警告なしで通る
  • cargo fmt が実行されている

コメント