mcpField
D
dotnet-cli-architecture

dotnet-cli-architecture

github.com/wshaddix開発8672 回インストール35 週間前に更新
リポジトリ

dotnet-cli-architecture について

System.CommandLine 2.0 とジェネリックホストで CLI ツールを作る開発者向けに、.NET コマンドラインアプリの構成を解説するスキルです。責務の分離とシェルで扱いやすい動作に重点を置きます。

主な機能

  1. 01コマンド・ハンドラー・サービスの分離
  2. 02設定の優先順位
  3. 03構造化ログと詳細度
  4. 04終了コードの規約
  5. 05標準ストリームの使い分け
  6. 06プロセス内 CLI テスト

活用シーン

  1. 01再利用可能な CLI 業務ロジックの設計
  2. 02パイプ出力への診断情報混入の防止
  3. 03エラー種別ごとの終了コード設定
  4. 04子プロセスを起動せずにコマンドをテスト

インストール

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

$npx skills add https://github.com/wshaddix/dotnet-skills --skill dotnet-cli-architecture

インストール後、Claude Code で/dotnet-cli-architectureを入力して読み込まれたか確認します。

詳細

dotnet-cli-architecture

.NET 向けの階層型 CLI アプリケーションアーキテクチャ:clig.dev の原則に沿ったコマンド/ハンドラー/サービスの分離、設定の優先順位(appsettings → 環境変数 → CLI 引数)、CLI コンテキストでの構造化ログ、終了コードの規約、stdin/stdout/stderr のパターン、および出力をキャプチャするプロセス内呼び出しによる CLI アプリケーションのテスト。

バージョンの前提: .NET 8.0 以降を基準とします。ここで紹介するパターンは、System.CommandLine 2.0 と汎用ホストを使用して構築された CLI ツールに適用できます。

対象外: System.CommandLine API の詳細(RootCommand、Option、ミドルウェア、ホスティング設定)については、[skill:dotnet-system-commandline] を参照してください。ネイティブ AOT コンパイルと発行パイプラインについては、[skill:dotnet-native-aot] を参照してください。CLI の配布、パッケージング、リリースの自動化については、[skill:dotnet-cli-distribution] と [skill:dotnet-cli-packaging] を参照してください。一般的な CI/CD パターンについては、[skill:dotnet-gha-patterns] と [skill:dotnet-ado-patterns] を参照してください。DI コンテナーの内部構造については、[skill:dotnet-csharp-dependency-injection] を参照してください。一般的なテスト戦略については、[skill:dotnet-testing-strategy] を参照してください。

相互参照:[skill:dotnet-system-commandline] は System.CommandLine 2.0 API、[skill:dotnet-native-aot] は CLI ツールの AOT 発行、[skill:dotnet-csharp-dependency-injection] は DI パターン、[skill:dotnet-csharp-configuration] は設定の統合、[skill:dotnet-testing-strategy] は一般的なテストパターンを参照してください。


.NET CLI ツールの clig.dev 原則

コマンドラインインターフェイスのガイドラインでは、適切に動作する CLI ツールのための、言語に依存しない原則が示されています。これらは .NET のパターンにそのまま適用できます。

基本原則
原則実装
デフォルトでは人間にとって読みやすい出力データには Console.Out、診断情報には Console.Error を使用します
--json による機械可読な出力出力形式を切り替える --json グローバルオプションを追加します
ステータス/診断情報には stderrログ、進捗バー、プロンプトは stderr に出力します
stdout にはデータのみパイプ出力(mycli list | jq .)にログなどのノイズを含めてはいけません
失敗時はゼロ以外の終了コード用途に応じた終了コードを返します(下記の規約を参照)
早期に、明確に失敗する処理を開始する前に入力を検証します
NO_COLOR を尊重するEnvironment.GetEnvironmentVariable("NO_COLOR") を確認します
--verbose と --quiet をサポートする出力の詳細度を制御するグローバルオプションを用意します
.NET における stdout と stderr
// Data output -- goes to stdout (can be piped)
Console.Out.WriteLine(JsonSerializer.Serialize(result, jsonContext.Options));

// Status/diagnostic output -- goes to stderr (user sees it, pipe ignores it)
Console.Error.WriteLine("Processing 42 files...");

// With ILogger (when using hosting)
// ILogger writes to stderr via console provider by default
logger.LogInformation("Connected to {Endpoint}", endpoint);

階層型のコマンド → ハンドラー → サービスアーキテクチャ

CLI に関する処理を次の 3 つのレイヤーに分離します。

┌─────────────────────────────────────┐
│  Commands (System.CommandLine)      │  Parse args, wire options
│  ─ RootCommand, Command, Option<T>  │
├─────────────────────────────────────┤
│  Handlers (orchestration)           │  Coordinate services, format output
│  ─ ICommandHandler implementations  │
├─────────────────────────────────────┤
│  Services (business logic)          │  Pure logic, no CLI concerns
│  ─ Interfaces + implementations     │
└─────────────────────────────────────┘
3 つのレイヤーを設ける理由
  • コマンドは CLI の構文(オプション、引数、サブコマンド)を把握しますが、ビジネスロジックは扱いません
  • ハンドラーは CLI の入力をサービス呼び出しにつなぎ、結果を出力用に整形します
  • サービスにはドメインロジックを記述します。CLI の外部(テスト、ライブラリ、API)でも再利用できます
構成例
src/
  MyCli/
    MyCli.csproj
    Program.cs                    # RootCommand + CommandLineBuilder
    Commands/
      SyncCommandDefinition.cs    # Command, options, arguments
    Handlers/
      SyncHandler.cs              # ICommandHandler, orchestrates services
    Services/
      ISyncService.cs             # Business logic interface
      SyncService.cs              # Implementation (no CLI awareness)
    Output/
      ConsoleFormatter.cs         # Table/JSON output formatting
コマンド定義レイヤー
// Commands/SyncCommandDefinition.cs
public static class SyncCommandDefinition
{
    public static readonly Option<Uri> SourceOption = new(
        "--source", "Source endpoint URL") { IsRequired = true };

    public static readonly Option<bool> DryRunOption = new(
        "--dry-run", "Preview changes without applying");

    public static Command Create()
    {
        var command = new Command("sync", "Synchronize data from source");
        command.AddOption(SourceOption);
        command.AddOption(DryRunOption);
        return command;
    }
}
ハンドラーレイヤー
// Handlers/SyncHandler.cs
public class SyncHandler : ICommandHandler
{
    private readonly ISyncService _syncService;
    private readonly ILogger<SyncHandler> _logger;

    public SyncHandler(ISyncService syncService, ILogger<SyncHandler> logger)
    {
        _syncService = syncService;
        _logger = logger;
    }

    // Bound by naming convention from options
    public Uri Source { get; set; } = null!;
    public bool DryRun { get; set; }

    public int Invoke(InvocationContext context) =>
        InvokeAsync(context).GetAwaiter().GetResult();

    public async Task<int> InvokeAsync(InvocationContext context)
    {
        var ct = context.GetCancellationToken();

        _logger.LogInformation("Syncing from {Source}", Source);

        var result = await _syncService.SyncAsync(Source, DryRun, ct);

        if (result.HasErrors)
        {
            context.Console.Error.Write($"Sync failed: {result.ErrorMessage}\n");
            return ExitCodes.SyncFailed;
        }

        context.Console.Out.Write($"Synced {result.ItemCount} items.\n");
        return ExitCodes.Success;
    }
}
サービスレイヤー
// Services/ISyncService.cs -- no CLI dependency
public interface ISyncService
{
    Task<SyncResult> SyncAsync(Uri source, bool dryRun, CancellationToken ct);
}

// Services/SyncService.cs
public class SyncService : ISyncService
{
    private readonly HttpClient _httpClient;

    public SyncService(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }

    public async Task<SyncResult> SyncAsync(
        Uri source, bool dryRun, CancellationToken ct)
    {
        // Pure business logic -- testable without CLI infrastructure
        var data = await _httpClient.GetFromJsonAsync<SyncData>(source, ct);
        // ...
        return new SyncResult(ItemCount: data.Items.Length);
    }
}

設定の優先順位

CLI ツールでは、次の設定の優先順位(低い順から高い順)を使用します。

  1. コンパイル済みのデフォルト値 -- ハードコードされたフォールバック値
  2. appsettings.json -- ツールに同梱される設定
  3. appsettings.{Environment}.json -- 環境固有のオーバーライド
  4. 環境変数 -- シェルまたは CI によって設定される値
  5. CLI 引数 -- ユーザーが明示的に指定する入力(最優先)
汎用ホストによる実装
var builder = new CommandLineBuilder(rootCommand)
    .UseHost(_ => Host.CreateDefaultBuilder(args), host =>
    {
        host.ConfigureAppConfiguration((ctx, config) =>
        {
            // Layers 2-3 handled by CreateDefaultBuilder:
            //   appsettings.json, appsettings.{env}.json, env vars

            // Layer 4: User-specific config file
            var configPath = Path.Combine(
                Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
                ".mycli", "config.json");
            if (File.Exists(configPath))
            {
                config.AddJsonFile(configPath, optional: true);
            }
        });

        // Layer 5: CLI args override everything
        // System.CommandLine options take precedence via handler binding
    })
    .UseDefaults()
    .Build();
ユーザーレベルの設定

多くの CLI ツールは、ユーザーレベルの設定(例:~/.mycli/config.json、~/.config/mycli/config.yaml)をサポートしています。プラットフォームの慣例に従ってください。

プラットフォーム場所
Linux/macOS~/.config/mycli/ または ~/.mycli/
Windows%APPDATA%\mycli\
XDG 準拠$XDG_CONFIG_HOME/mycli/

CLI コンテキストでの構造化ログ

CLI 向けのログ設定

CLI ツールでは、Web アプリとは異なるログ設定が必要です。ログは stderr に出力し、詳細度はフラグで制御します。

host.ConfigureLogging((ctx, logging) =>
{
    logging.ClearProviders();
    logging.AddConsole(options =>
    {
        // Write to stderr, not stdout
        options.LogToStandardErrorThreshold = LogLevel.Trace;
    });
});
詳細度のマッピング

--verbose/--quiet フラグをログレベルにマッピングします。

public static class VerbosityMapping
{
    public static LogLevel ToLogLevel(bool verbose, bool quiet) => (verbose, quiet) switch
    {
        (true, _) => LogLevel.Debug,
        (_, true) => LogLevel.Warning,
        _ => LogLevel.Information  // default
    };
}

// In host configuration
host.ConfigureLogging((ctx, logging) =>
{
    var level = VerbosityMapping.ToLogLevel(verbose, quiet);
    logging.SetMinimumLevel(level);
});

終了コードの規則

標準の終了コード
public static class ExitCodes
{
    public const int Success = 0;
    public const int GeneralError = 1;
    public const int InvalidUsage = 2;    // Bad arguments or options
    public const int IoError = 3;         // File not found, permission denied
    public const int NetworkError = 4;    // Connection failed, timeout
    public const int AuthError = 5;       // Authentication/authorization failure

    // Tool-specific codes start at 10+
    public const int SyncFailed = 10;
    public const int ValidationFailed = 11;
}
ガイドライン
  • 0 = 成功(常に)
  • 1 = 一般的なエラー/未指定のエラー
  • 2 = 使用方法が無効(不正な引数)。System.CommandLine は、解析エラーが発生すると自動的にこのコードを返します。
  • 3-9 = 一般的なカテゴリ用に予約
  • 10+ = ツール固有のエラーコード
  • 終了コードに 125 を超える値は使用しないでください(シェルによって予約されています。126 = 実行不可、127 = 見つからない、128+N = シグナル N による終了)。
終了コードの伝播
public async Task<int> InvokeAsync(InvocationContext context)
{
    try
    {
        await _service.ProcessAsync(context.GetCancellationToken());
        return ExitCodes.Success;
    }
    catch (HttpRequestException ex)
    {
        _logger.LogError(ex, "Network error");
        context.Console.Error.Write($"Error: {ex.Message}\n");
        return ExitCodes.NetworkError;
    }
    catch (UnauthorizedAccessException ex)
    {
        context.Console.Error.Write($"Permission denied: {ex.Message}\n");
        return ExitCodes.IoError;
    }
}

Stdin/Stdout/Stderr のパターン

Stdin からの読み取り

ファイル引数の代替として、パイプ入力をサポートします。

public async Task<int> InvokeAsync(InvocationContext context)
{
    string input;

    if (InputFile is not null)
    {
        input = await File.ReadAllTextAsync(InputFile.FullName);
    }
    else if (Console.IsInputRedirected)
    {
        // Read from stdin: echo '{"data":1}' | mycli process
        input = await Console.In.ReadToEndAsync();
    }
    else
    {
        context.Console.Error.Write("Error: Provide input via --file or stdin.\n");
        return ExitCodes.InvalidUsage;
    }

    var result = _processor.Process(input);
    context.Console.Out.Write(JsonSerializer.Serialize(result));
    return ExitCodes.Success;
}
機械可読な出力
// Global --json option for machine-readable output
var jsonOption = new Option<bool>("--json", "Output as JSON");
rootCommand.AddGlobalOption(jsonOption);

// In handler
if (useJson)
{
    Console.Out.WriteLine(JsonSerializer.Serialize(result, jsonContext.Options));
}
else
{
    // Human-friendly table format
    ConsoleFormatter.WriteTable(result, context.Console);
}
Stderr への進捗状況の出力
// Progress reporting goes to stderr (does not pollute piped stdout)
await foreach (var item in _service.StreamAsync(ct))
{
    Console.Error.Write($"\rProcessing {item.Index}/{total}...");
    Console.Out.WriteLine(item.ToJson());
}
Console.Error.WriteLine();  // Clear progress line

CLI アプリケーションのテスト

CommandLineBuilder によるプロセス内での呼び出し

子プロセスを起動せずに、CLI のパイプライン全体をテストします。

public class CliTestHarness
{
    private readonly RootCommand _rootCommand;
    private readonly Action<IServiceCollection>? _configureServices;

    public CliTestHarness(Action<IServiceCollection>? configureServices = null)
    {
        _rootCommand = Program.BuildRootCommand();
        _configureServices = configureServices;
    }

    public async Task<(int ExitCode, string Stdout, string Stderr)> InvokeAsync(
        string commandLine)
    {
        var console = new TestConsole();

        var builder = new CommandLineBuilder(_rootCommand)
            .UseHost(_ => Host.CreateDefaultBuilder(), host =>
            {
                if (_configureServices is not null)
                {
                    host.ConfigureServices(_configureServices);
                }
            })
            .UseDefaults()
            .Build();

        var exitCode = await builder.InvokeAsync(commandLine, console);

        return (exitCode, console.Out.ToString()!, console.Error.ToString()!);
    }
}
サービスモックを使用したテスト
[Fact]
public async Task Sync_WithValidSource_ReturnsZero()
{
    var fakeSyncService = new FakeSyncService(
        new SyncResult(ItemCount: 5));

    var harness = new CliTestHarness(services =>
    {
        services.AddSingleton<ISyncService>(fakeSyncService);
    });

    var (exitCode, stdout, stderr) = await harness.InvokeAsync(
        "sync --source https://api.example.com");

    Assert.Equal(0, exitCode);
    Assert.Contains("Synced 5 items", stdout);
}

[Fact]
public async Task Sync_WithMissingSource_ReturnsNonZero()
{
    var harness = new CliTestHarness();

    var (exitCode, _, stderr) = await harness.InvokeAsync("sync");

    Assert.NotEqual(0, exitCode);
    Assert.Contains("--source", stderr);  // Parse error mentions missing option
}
終了コードのアサーション
[Theory]
[InlineData("sync --source https://valid.example.com", 0)]
[InlineData("sync", 2)]  // Missing required option
[InlineData("invalid-command", 1)]
public async Task ExitCode_MatchesExpected(string args, int expectedExitCode)
{
    var harness = new CliTestHarness();
    var (exitCode, _, _) = await harness.InvokeAsync(args);
    Assert.Equal(expectedExitCode, exitCode);
}
出力形式のテスト
[Fact]
public async Task List_WithJsonFlag_OutputsValidJson()
{
    var harness = new CliTestHarness(services =>
    {
        services.AddSingleton<IItemRepository>(
            new FakeItemRepository([new Item(1, "Widget")]));
    });

    var (exitCode, stdout, _) = await harness.InvokeAsync("list --json");

    Assert.Equal(0, exitCode);
    var items = JsonSerializer.Deserialize<Item[]>(stdout);
    Assert.NotNull(items);
    Assert.Single(items);
}

[Fact]
public async Task List_StderrContainsLogs_StdoutContainsDataOnly()
{
    var harness = new CliTestHarness();
    var (_, stdout, stderr) = await harness.InvokeAsync("list --json --verbose");

    // Stdout must be valid JSON (no log noise)
    // xUnit: just call it -- if it throws, the test fails
    var doc = JsonDocument.Parse(stdout);
    Assert.NotNull(doc);

    // Stderr contains diagnostic output
    Assert.Contains("Connected to", stderr);
}

エージェントが注意すべき点

  1. 診断情報を stdout に出力しないでください。 ログ、進捗状況、エラーは stderr に出力します。stdout は、パイプで渡せるデータ出力専用です。ログを stdout に混在させる CLI ツールは、シェルのパイプラインを壊します。
  2. すべてのエラーに終了コード 1 をハードコードしないでください。 エラーのカテゴリ(I/O、ネットワーク、認証、検証など)ごとに異なる終了コードを使用します。呼び出し元やスクリプトは、何が問題だったのかを判断するために終了コードを利用します。
  3. コマンドハンドラーにビジネスロジックを記述しないでください。 ハンドラーは、注入されたサービスの呼び出しを調整し、出力を整形する役割に留めます。ハンドラー内のビジネスロジックは再利用できず、個別にユニットテストすることもできません。
  4. プロセスを起動する方法だけで CLI ツールをテストしないでください。 高速で信頼性の高いテストには、CommandLineBuilder と TestConsole を使ったプロセス内での呼び出しを使用します。プロセスレベルのテストは、公開済みバイナリのスモークテストに限ってください。
  5. stdin を受け付ける際に Console.IsInputRedirected の確認を省略しないでください。 確認しないと、パイプ入力なしでツールを起動したときに、入力待ちでハングする可能性があります。
  6. 125 を超える終了コードを使用しないでください。 Unix シェルでは、コード 126~255 に特別な意味があります(126 = 実行不可、127 = 見つからない、128+N = シグナル N による終了)。ツール固有のコードは 1~125 の範囲にしてください。

参考資料

コメント