Skills

/agent-cli

エージェント向けCLIツールの設計・実装ガイド。Rust (clap) で構造化出力・dry-run・非対話モードを備えたCLIを作る。 Use when user says "CLI作りたい", "agent CLI", "エージェント向けCLI", "CLI設計", "agent-friendly CLI", "build CLI", "CLI tool", "新しいCLI"

About

エージェント向けCLIツールの設計・実装ガイド。Rust (clap) で構造化出力・dry-run・非対話モードを備えたCLIを作る。 Use when user says "CLI作りたい", "agent CLI", "エージェント向けCLI", "CLI設計", "agent-friendly CLI", "build CLI", "CLI tool", "新しいCLI".

Category:Coding
Scope:universal

Install

Agent:
curl -sf /api/skills/agent-cli/download | tar xz -C .claude/skills/

Download / Upload

ZIP でダウンロード

→ Claude.ai: Customize > Skills にアップロード

Source

Agent-Friendly CLI Builder

エージェント(Claude Code 等)が効率的に使えるCLIツールを設計・実装するスキル。

Ref: https://justin.poehnelt.com/posts/rewrite-your-cli-for-ai-agents/

設計原則

1. デュアルモード出力

人間とエージェントの両方を同一バイナリでサポートする。

  • --json フラグで JSON 出力を強制
  • stdout が TTY でない場合は自動的に JSON 出力(パイプ先がエージェント)
  • 人間向けは色付きテキスト、エージェント向けは構造化 JSON

2. 自己文書化

エージェントはドキュメントを検索できない。CLI 自体がドキュメントになること。

  • 全サブコマンドに --help を実装
  • clapabout / long_about で十分な説明を付与
  • 使用例をヘルプに含める

3. コンテキストウィンドウ規律

エージェントはトークンあたりコストがかかり、無関係な情報で推論能力が落ちる。

  • JSON 出力は最小限のフィールドに絞る
  • info メッセージは JSON モード時に抑制
  • 巨大レスポンスはページネーション or --limit で制御

4. 安全性

エージェントはミスをする前提で設計する。

  • --dry-run で実行内容を事前確認
  • --yes / -y で確認プロンプトをスキップ(エージェント/CI用)
  • 破壊的操作はデフォルトで確認を要求
  • 終了コードで成否を明確に伝える(0=成功, 1=失敗)

5. 構造化エラー

エラーも JSON で返し、エージェントがパース可能にする。

{"status": "error", "message": "Environment 'prod' not found. Available: dev, staging, production"}

実装テンプレート(Rust + clap)

1. プロジェクト初期化

cargo init <project-name>

2. Cargo.toml

[package]
name = "<project-name>"
edition = "2024"

[[bin]]
name = "<short-name>"    # 短いバイナリ名(例: mb, dx, kc)
path = "src/main.rs"

[dependencies]
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
anyhow = "1"
colored = "3"

外部API呼び出しが必要なら reqwest、設定ファイルなら toml/dirs を追加。

3. ソース構成

src/
├── main.rs          # CLI定義 (clap Parser) + エントリポイント
├── output.rs        # デュアルモード出力 (Human/JSON)
├── shell.rs         # 外部コマンド実行ヘルパー
├── config.rs        # 設定ファイル読み込み(任意)
└── commands/
    ├── mod.rs
    ├── foo.rs       # サブコマンドごとに1ファイル
    └── bar.rs

4. output.rs テンプレート

use colored::Colorize;
use serde::Serialize;
use std::io::IsTerminal;

#[derive(Debug, Clone, Copy, PartialEq)]
pub enum OutputFormat {
    Human,
    Json,
}

impl OutputFormat {
    pub fn detect(json_flag: bool) -> Self {
        if json_flag || !std::io::stdout().is_terminal() {
            Self::Json
        } else {
            Self::Human
        }
    }
}

pub struct Output {
    pub format: OutputFormat,
}

impl Output {
    pub fn new(format: OutputFormat) -> Self {
        Self { format }
    }

    pub fn is_json(&self) -> bool {
        self.format == OutputFormat::Json
    }

    pub fn success(&self, message: &str) {
        match self.format {
            OutputFormat::Human => eprintln!("{}", message.green()),
            OutputFormat::Json => println!("{}", serde_json::json!({"status": "ok", "message": message})),
        }
    }

    pub fn error(&self, message: &str) {
        match self.format {
            OutputFormat::Human => eprintln!("{}", message.red()),
            OutputFormat::Json => println!("{}", serde_json::json!({"status": "error", "message": message})),
        }
    }

    pub fn info(&self, message: &str) {
        match self.format {
            OutputFormat::Human => eprintln!("{}", message.yellow()),
            OutputFormat::Json => {} // suppress for context window discipline
        }
    }

    pub fn data<T: Serialize>(&self, data: &T) {
        match self.format {
            OutputFormat::Human => println!("{}", serde_json::to_string_pretty(data).unwrap()),
            OutputFormat::Json => println!("{}", serde_json::to_string(data).unwrap()),
        }
    }

    pub fn data_with_status<T: Serialize>(&self, data: &T) {
        match self.format {
            OutputFormat::Human => println!("{}", serde_json::to_string_pretty(data).unwrap()),
            OutputFormat::Json => {
                let val = serde_json::to_value(data).unwrap();
                let mut obj = serde_json::json!({"status": "ok"});
                if let serde_json::Value::Object(map) = val {
                    for (k, v) in map { obj[&k] = v; }
                } else {
                    obj["data"] = val;
                }
                println!("{}", serde_json::to_string(&obj).unwrap());
            }
        }
    }
}

5. main.rs テンプレート

use clap::{Parser, Subcommand};
use output::{Output, OutputFormat};

#[derive(Parser)]
#[command(name = "<short-name>", version, about)]
struct Cli {
    /// Force JSON output (auto-enabled when stdout is not a TTY)
    #[arg(long, global = true)]
    json: bool,

    /// Show what would be executed without running
    #[arg(long, global = true)]
    dry_run: bool,

    /// Skip confirmation prompts (for agent/CI use)
    #[arg(long, short = 'y', global = true)]
    yes: bool,

    #[command(subcommand)]
    command: Commands,
}

グローバルフラグ --json, --dry-run, --yes は必須パターン。

6. shell.rs テンプレート

use anyhow::{Context, Result};
use std::process::Stdio;

pub async fn run_capture(cmd: &str, dry_run: bool) -> Result<String> {
    if dry_run {
        return Ok(format!("[dry-run] {cmd}"));
    }
    let output = tokio::process::Command::new("sh")
        .arg("-c").arg(cmd)
        .stdout(Stdio::piped()).stderr(Stdio::piped())
        .output().await.context("Failed to execute command")?;
    Ok(String::from_utf8_lossy(&output.stdout).to_string())
}

pub async fn run_passthrough(cmd: &str) -> Result<()> {
    let status = tokio::process::Command::new("sh")
        .arg("-c").arg(cmd)
        .stdout(Stdio::inherit()).stderr(Stdio::inherit()).stdin(Stdio::inherit())
        .status().await.context("Failed to execute command")?;
    if !status.success() {
        anyhow::bail!("Command exited with status: {}", status);
    }
    Ok(())
}

7. サブコマンドのパターン

use clap::Subcommand;
use crate::output::Output;

#[derive(Subcommand)]
pub enum FooCmd {
    /// Description shown in --help
    Action {
        /// Required positional arg
        target: String,
        /// Optional flag
        #[arg(short, long)]
        verbose: bool,
    },
}

pub async fn run(cmd: &FooCmd, output: &Output, dry_run: bool) -> anyhow::Result<()> {
    match cmd {
        FooCmd::Action { target, verbose } => {
            // 実装
        }
    }
    Ok(())
}

スキルも一緒に作る

CLI を作ったら、対応するスキルも作成してエージェントが発見・使用できるようにする。

配置場所の判断:

  • プロジェクト固有 → <project>/.claude/skills/<name>/SKILL.md
  • グローバル(複数プロジェクトで使う) → ~/.claude/skills/<name>/SKILL.md

スキルに含めるもの:

  • トリガーワード(日英)
  • 全コマンドの一覧と使用例
  • エージェント向けフラグの使い方(--json --yes
  • よくあるワークフロー例

チェックリスト

新しい CLI を作る時に確認:

  • --json フラグ + TTY自動検出
  • --dry-run フラグ
  • --yes フラグ(確認スキップ)
  • 全サブコマンドに --help
  • エラーも構造化 JSON で出力
  • 破壊的操作にはデフォルトで確認プロンプト
  • 終了コードが適切(0=成功, 非0=失敗)
  • cargo install --path . でグローバルインストール可能
  • 対応するスキルファイルを作成
  • info / version コマンド