dotnet-cli-architecture について
System.CommandLine 2.0 とジェネリックホストで CLI ツールを作る開発者向けに、.NET コマンドラインアプリの構成を解説するスキルです。責務の分離とシェルで扱いやすい動作に重点を置きます。
主な機能
- 01コマンド・ハンドラー・サービスの分離
- 02設定の優先順位
- 03構造化ログと詳細度
- 04終了コードの規約
- 05標準ストリームの使い分け
- 06プロセス内 CLI テスト
活用シーン
- 01再利用可能な CLI 業務ロジックの設計
- 02パイプ出力への診断情報混入の防止
- 03エラー種別ごとの終了コード設定
- 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 ツールでは、次の設定の優先順位(低い順から高い順)を使用します。
- コンパイル済みのデフォルト値 -- ハードコードされたフォールバック値
- appsettings.json -- ツールに同梱される設定
- appsettings.{Environment}.json -- 環境固有のオーバーライド
- 環境変数 -- シェルまたは CI によって設定される値
- 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);
}
エージェントが注意すべき点
- 診断情報を stdout に出力しないでください。 ログ、進捗状況、エラーは stderr に出力します。stdout は、パイプで渡せるデータ出力専用です。ログを stdout に混在させる CLI ツールは、シェルのパイプラインを壊します。
- すべてのエラーに終了コード 1 をハードコードしないでください。 エラーのカテゴリ(I/O、ネットワーク、認証、検証など)ごとに異なる終了コードを使用します。呼び出し元やスクリプトは、何が問題だったのかを判断するために終了コードを利用します。
- コマンドハンドラーにビジネスロジックを記述しないでください。 ハンドラーは、注入されたサービスの呼び出しを調整し、出力を整形する役割に留めます。ハンドラー内のビジネスロジックは再利用できず、個別にユニットテストすることもできません。
- プロセスを起動する方法だけで CLI ツールをテストしないでください。 高速で信頼性の高いテストには、
CommandLineBuilderとTestConsoleを使ったプロセス内での呼び出しを使用します。プロセスレベルのテストは、公開済みバイナリのスモークテストに限ってください。 - stdin を受け付ける際に
Console.IsInputRedirectedの確認を省略しないでください。 確認しないと、パイプ入力なしでツールを起動したときに、入力待ちでハングする可能性があります。 - 125 を超える終了コードを使用しないでください。 Unix シェルでは、コード 126~255 に特別な意味があります(126 = 実行不可、127 = 見つからない、128+N = シグナル N による終了)。ツール固有のコードは 1~125 の範囲にしてください。