building-cli-apps について
コマンドラインツールを開発する人向けの設計ガイドスキルです。パイプ入出力、コマンド構成、設定、対話、テスト、配布を扱い、Python、TypeScript、C#の例を示します。
主な機能
- 01CLI・TUI・GUIの選択指針
- 02標準ストリームと終了コードのパターン
- 03設定の優先順位とサブコマンド
- 04パイプ入力とシグナル処理
- 05テストと配布の例
活用シーン
- 01シェルパイプラインで連携するCLIの設計
- 02コマンド構成と設定の整理
- 03対話実行と自動実行への対応
- 04コマンドラインツールのテストとパッケージ化
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ mhagrelius/ dotfiles --skill building-cli-appsインストール後、Claude Code で/building-cli-appsを入力して読み込まれたか確認します。
詳細
CLI アプリケーションの構築
概要
CLI アプリはパイプライン内のフィルターです。入力を読み取り、変換し、出力を書き込みます。Unix の哲学が当てはまります。1 つのことをうまく行い、ほかのツールと組み合わせます。
CLI、TUI、GUI の使い分け
digraph decision {
rankdir=TB;
"User interaction needed?" [shape=diamond];
"Complex state/navigation?" [shape=diamond];
"Scriptable/automatable?" [shape=diamond];
"CLI" [shape=box, style=filled, fillcolor=lightblue];
"TUI" [shape=box, style=filled, fillcolor=lightgreen];
"GUI" [shape=box, style=filled, fillcolor=lightyellow];
"User interaction needed?" -> "Complex state/navigation?" [label="yes"];
"User interaction needed?" -> "Scriptable/automatable?" [label="no"];
"Scriptable/automatable?" -> "CLI" [label="yes"];
"Scriptable/automatable?" -> "GUI" [label="no"];
"Complex state/navigation?" -> "TUI" [label="yes"];
"Complex state/navigation?" -> "CLI" [label="no"];
}
CLI を選ぶ場合: 単一の操作、パイプでの利用、スクリプト化、CI/CD、シンプルなプロンプト TUI を選ぶ場合: ダッシュボード、複数ビューのナビゲーション、リアルタイム監視 GUI を選ぶ場合: 技術に詳しくないユーザー、複雑な視覚化、ドラッグ&ドロップ
クイックリファレンス:言語別ライブラリ
| 言語 | 引数の解析 | 進捗表示/スピナー | 色 | プロンプト |
|---|---|---|---|---|
| Python | typer(モダン)または click | rich.progress | rich | rich.prompt |
| TypeScript | commander または yargs | ora | chalk | inquirer |
| C# | System.CommandLine | Spectre.Console | Spectre.Console | Spectre.Console |
基本パターン
1. ストリーム:stdout と stderr
stdout → Data/results (pipeable)
stderr → Progress, logs, errors (human feedback)
Python:
import sys
from rich.console import Console
console = Console(stderr=True) # Progress/logs to stderr
output = Console() # Results to stdout
console.print("[dim]Processing...[/]") # → stderr
output.print_json(data=result) # → stdout (pipeable)
TypeScript:
// Results to stdout
console.log(JSON.stringify(result));
// Progress to stderr
process.stderr.write('Processing...\n');
C#:
Console.WriteLine(result); // stdout
Console.Error.WriteLine("Working..."); // stderr
2. 終了コード
| コード | 意味 | 使用する状況 |
|---|---|---|
| 0 | 成功 | 操作が完了した |
| 1 | 一般的なエラー | ユーザー/入力エラー |
| 2 | 誤用 | 引数が無効 |
| 130 | SIGINT | Ctrl+C による中断 |
# Python
import sys
sys.exit(0) # Success
sys.exit(1) # Error
// TypeScript
process.exit(0);
process.exitCode = 1; // Preferred - allows cleanup
// C#
Environment.Exit(0);
return 1; // From Main
3. 設定の優先順位
優先順位(高い順):
- CLI 引数(
--config value) - 環境変数(
APP_CONFIG) - 設定ファイル(
.apprc、config.json) - デフォルト値
# Python with typer
import typer
import os
def main(
config: str = typer.Option(
os.environ.get("APP_CONFIG", "default"),
"--config", "-c"
)
):
pass
4. サブコマンドの構成
mycli/
├── src/
│ ├── main.py # Entry point, registers commands
│ ├── commands/
│ │ ├── __init__.py
│ │ ├── process.py # mycli process <file>
│ │ └── config.py # mycli config show|set
│ └── lib/ # Shared logic
└── tests/
└── commands/
└── test_process.py
typer を使った Python:
# main.py
import typer
from commands import process, config
app = typer.Typer()
app.add_typer(process.app, name="process")
app.add_typer(config.app, name="config")
if __name__ == "__main__":
app()
commander を使った TypeScript:
// index.ts
import { Command } from 'commander';
import { processCommand } from './commands/process';
import { configCommand } from './commands/config';
const program = new Command();
program.addCommand(processCommand);
program.addCommand(configCommand);
program.parse();
System.CommandLine を使った C#:
var rootCommand = new RootCommand("My CLI");
rootCommand.AddCommand(ProcessCommand.Create());
rootCommand.AddCommand(ConfigCommand.Create());
await rootCommand.InvokeAsync(args);
5. 対話モードと非対話モード
import sys
import typer
from rich.prompt import Confirm
def main(
force: bool = typer.Option(False, "--force", "-f"),
file: str = typer.Argument(...)
):
# Check if running interactively
is_interactive = sys.stdin.isatty()
if not force and is_interactive:
if not Confirm.ask(f"Delete {file}?"):
raise typer.Abort()
elif not force and not is_interactive:
# Non-interactive without --force: fail safe
typer.echo("Use --force in non-interactive mode", err=True)
raise typer.Exit(1)
# Proceed with operation
delete_file(file)
6. stdin からの読み取り(パイプ入力)
ファイル引数とパイプ入力(- の規約)の両方をサポートします。
import sys
import typer
@app.command()
def process(
file: str = typer.Argument(..., help="Input file (or - for stdin)")
):
if file == "-":
content = sys.stdin.read()
else:
content = Path(file).read_text()
# Process content...
import { createInterface } from 'readline';
async function readInput(file: string): Promise<string> {
if (file === '-') {
const lines: string[] = [];
const rl = createInterface({ input: process.stdin });
for await (const line of rl) lines.push(line);
return lines.join('\n');
}
return fs.readFileSync(file, 'utf-8');
}
使用例: cat data.txt | mycli process - または echo "test" | mycli process -
7. シグナル処理
import signal
import sys
def handle_sigint(signum, frame):
print("\nInterrupted, cleaning up...", file=sys.stderr)
cleanup()
sys.exit(130)
signal.signal(signal.SIGINT, handle_sigint)
process.on('SIGINT', () => {
console.error('\nInterrupted, cleaning up...');
cleanup();
process.exit(130);
});
Console.CancelKeyPress += (sender, e) => {
e.Cancel = true; // Prevent immediate termination
Console.Error.WriteLine("\nInterrupted, cleaning up...");
Cleanup();
Environment.Exit(130);
};
アンチパターン
| アンチパターン | 問題 | 対策 |
|---|---|---|
| 進捗を stdout に出力する | パイプ処理が壊れる | stderr を使う |
| 失敗を通知しない | ユーザーが何に失敗したのかわからない | エラーを表示し、0 以外で終了する |
--help がない | 使いにくい | typer/commander を使う(自動生成される) |
| パスをハードコードする | 移植性がない | 環境変数または設定を使う |
| 終了コードがない | スクリプトで成功を確認できない | 適切に 0/1 で終了する |
| パイプ入力時に確認を求める | 自動化が停止する | isatty() を確認し、--force を使う |
| すべての例外を捕捉する | バグが隠れる | 特定の例外だけを捕捉し、ほかの例外はそのままクラッシュさせる |
CLI アプリのテスト
pytest を使った Python:
from typer.testing import CliRunner
from myapp.main import app
runner = CliRunner()
def test_process_success():
result = runner.invoke(app, ["process", "test.txt"])
assert result.exit_code == 0
assert "processed" in result.stdout
def test_process_missing_file():
result = runner.invoke(app, ["process", "nonexistent.txt"])
assert result.exit_code == 1
assert "not found" in result.stderr
def test_piped_input(tmp_path):
input_file = tmp_path / "input.txt"
input_file.write_text("test data")
result = runner.invoke(app, ["process", "-"], input="test data")
assert result.exit_code == 0
Jest を使った TypeScript:
import { execSync } from 'child_process';
test('process command succeeds', () => {
const result = execSync('npx ts-node src/index.ts process test.txt');
expect(result.toString()).toContain('processed');
});
test('process command fails on missing file', () => {
expect(() => {
execSync('npx ts-node src/index.ts process nonexistent.txt');
}).toThrow();
});
ヘルプテキストのベストプラクティス
import typer
app = typer.Typer(
help="Process loan notices with AI classification.",
no_args_is_help=True, # Show help if no args
)
@app.command()
def process(
file: str = typer.Argument(..., help="Path to notice file (or - for stdin)"),
output: str = typer.Option(None, "--output", "-o", help="Output file (default: stdout)"),
format: str = typer.Option("json", "--format", "-f", help="Output format: json, csv, table"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show processing details"),
):
"""
Process a loan notice through the classification pipeline.
Examples:
mycli process notice.pdf
mycli process notice.pdf --format table
cat notice.txt | mycli process - --output result.json
"""
pass
エラーメッセージ
わかりやすいエラーメッセージに含める内容:
- 何が起きたのか
- なぜ問題なのか
- どのように解決するのか
# Bad
print("Error: invalid input")
sys.exit(1)
# Good
print(f"Error: File '{path}' is not a valid PDF.", file=sys.stderr)
print(f"Expected: PDF file with loan notice content", file=sys.stderr)
print(f"Try: mycli process --help for supported formats", file=sys.stderr)
sys.exit(1)
配布
| 言語 | 方法 | コマンド |
|---|---|---|
| Python | PyPI | pip install myapp または pipx install myapp |
| Python | 単一ファイル | pyinstaller --onefile main.py |
| TypeScript | npm | npm install -g myapp |
| TypeScript | バイナリ | pkg . または bun build --compile |
| C# | NuGet ツール | dotnet tool install -g myapp |
| C# | 単一ファイル | dotnet publish -c Release -p:PublishSingleFile=true |
コメント
Markdown 対応