mcpField
B
building-cli-apps

building-cli-apps

github.com/mhagrelius開発35 回インストール
リポジトリ

building-cli-apps について

コマンドラインツールを開発する人向けの設計ガイドスキルです。パイプ入出力、コマンド構成、設定、対話、テスト、配布を扱い、Python、TypeScript、C#の例を示します。

主な機能

  1. 01CLI・TUI・GUIの選択指針
  2. 02標準ストリームと終了コードのパターン
  3. 03設定の優先順位とサブコマンド
  4. 04パイプ入力とシグナル処理
  5. 05テストと配布の例

活用シーン

  1. 01シェルパイプラインで連携するCLIの設計
  2. 02コマンド構成と設定の整理
  3. 03対話実行と自動実行への対応
  4. 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 を選ぶ場合: 技術に詳しくないユーザー、複雑な視覚化、ドラッグ&ドロップ

クイックリファレンス:言語別ライブラリ

言語引数の解析進捗表示/スピナー色プロンプト
Pythontyper(モダン)または clickrich.progressrichrich.prompt
TypeScriptcommander または yargsorachalkinquirer
C#System.CommandLineSpectre.ConsoleSpectre.ConsoleSpectre.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誤用引数が無効
130SIGINTCtrl+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. 設定の優先順位

優先順位(高い順):

  1. CLI 引数(--config value)
  2. 環境変数(APP_CONFIG)
  3. 設定ファイル(.apprc、config.json)
  4. デフォルト値
# 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

エラーメッセージ

わかりやすいエラーメッセージに含める内容:

  1. 何が起きたのか
  2. なぜ問題なのか
  3. どのように解決するのか
# 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)

配布

言語方法コマンド
PythonPyPIpip install myapp または pipx install myapp
Python単一ファイルpyinstaller --onefile main.py
TypeScriptnpmnpm install -g myapp
TypeScriptバイナリpkg . または bun build --compile
C#NuGet ツールdotnet tool install -g myapp
C#単一ファイルdotnet publish -c Release -p:PublishSingleFile=true

コメント