/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".
Install
curl -sf /api/skills/agent-cli/download | tar xz -C .claude/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を実装 clapのabout/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 コマンド