console-cli について
console-cli は、.NET 10 のコンソールアプリやコマンドラインツールの設計・実装を案内するスキルです。組み合わせやすい入出力、安定した終了コード、キャンセル処理、用途に応じたアプリ構成と配布方法を扱います。
主な機能
- 01System.CommandLine 2.x のコマンド設計
- 02標準出力と標準エラーの分離
- 03安定した終了コード設計
- 04Ctrl+C によるキャンセル処理
- 05ツール・スクリプト・ホストの配布指針
活用シーン
- 01オプションやサブコマンドを持つ CLI の構築
- 02ファイルや標準入力を受け取るスクリプト作成
- 03NuGet による .NET ツールの配布
- 04CI やシェルパイプラインで使えるコマンドの作成
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ codewithmukesh/ dotnet-claude-kit --skill console-cliインストール後、Claude Code で/console-cliを入力して読み込まれたか確認します。
詳細
コンソールアプリとコマンドラインツール(.NET 10)
基本原則
- stdout はデータ、stderr はそれ以外 — 結果は stdout に出力するため、
mytool list | jqが動作します。ログ、進捗状況、プロンプト、エラーは stderr に出力します。これらを混在させると、ツールを利用するすべてのパイプやスクリプトが壊れます。 - 終了コードが API です —
0は成功、0 以外は失敗を表し、失敗の種類ごとに固定のコードを割り当てます。スクリプトや CI は終了コードで分岐し、エラーテキストの解析は決して行いません。 - 手作業で
argsを解析せず、System.CommandLine 2.x を使う — 2.0 で安定版となり、ヘルプ、--version、検証、タブ補完、Ctrl+C の処理をすぐに利用できます。手作業で解析すると、--opt=value、引用符、ヘルプ出力への対応が必ず漏れます。 - キャンセルは Ctrl+C からすべての await に伝播させる — System.CommandLine は SIGINT/SIGTERM を
CancellationTokenに変換します。これを最後まで渡さないと、Ctrl+C によって強制終了され、書き込み途中のファイルが破損することがあります。 - 必要な場合にのみ Generic Host を使う — DI、構成、ログ記録、
IHttpClientFactoryが必要なら、Host.CreateApplicationBuilderを使う理由になります。50 行程度のユーティリティには不要です。コマンドラインツールでは起動時間が重要です。
パターン
System.CommandLine 2.x によるコマンドツリー
System.CommandLine 2.0.x(安定版)で動作を確認済みです。ベータ版の API(SetHandler、InvocationContext、CommandLineBuilder)は廃止されています。
using System.CommandLine;
Option<FileInfo> inputOption = new("--input", "-i")
{
Description = "File to import",
Required = true
};
inputOption.AcceptExistingOnly();
Option<int> batchOption = new("--batch-size")
{
Description = "Rows per batch",
DefaultValueFactory = _ => 500
};
batchOption.Validators.Add(result =>
{
if (result.GetValue(batchOption) is < 1 or > 10_000)
result.AddError("--batch-size must be between 1 and 10000");
});
Option<bool> dryRunOption = new("--dry-run") { Description = "Validate without writing" };
Command importCommand = new("import", "Import orders from a CSV file")
{
inputOption, batchOption, dryRunOption
};
importCommand.SetAction((parseResult, ct) => ImportCommand.RunAsync(
parseResult.GetValue(inputOption)!,
parseResult.GetValue(batchOption),
parseResult.GetValue(dryRunOption),
ct));
RootCommand root = new("Order tooling") { importCommand };
return await root.Parse(args).InvokeAsync();
理由: オプションは型付きオブジェクトなので、GetValue はコンパイル時に型チェックされます。InvokeAsync は解析エラーを stderr に出力し、ヘルプを表示して、コードを実行せずに終了コード 1 を返します。Required、AcceptExistingOnly()、Validators を使うことで、入力を境界で検証できます。
契約としての終了コード
public static class ExitCodes
{
public const int Success = 0;
public const int Failure = 1; // also what System.CommandLine returns on parse errors
public const int ValidationFailed = 2;
public const int NotFound = 3;
public const int Cancelled = 130; // 128 + SIGINT, the shell convention
}
public static class ImportCommand
{
public static async Task<int> RunAsync(FileInfo input, int batchSize, bool dryRun, CancellationToken ct)
{
try
{
var rows = await CsvReader.ReadAsync(input, ct);
if (rows.Errors is [_, ..])
{
foreach (var error in rows.Errors)
await Console.Error.WriteLineAsync(error);
return ExitCodes.ValidationFailed;
}
if (!dryRun)
await OrderImporter.ImportAsync(rows.Items, batchSize, ct);
Console.WriteLine(rows.Items.Count); // the data result → stdout
return ExitCodes.Success;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
await Console.Error.WriteLineAsync("Cancelled.");
return ExitCodes.Cancelled;
}
}
}
理由: 名前付き定数によって契約を明示し、マジックナンバーがばらばらになるのを防げます。トークンによってキャンセルされた場合に限って OperationCanceledException を捕捉すれば、ユーザーによる Ctrl+C と実際のタイムアウトバグを区別できます。コードは --help のテキストまたは README に記載してください。
Ctrl+C と正常なシャットダウン
ParseResult.InvokeAsync は Ctrl+C、SIGINT、SIGTERM の際にアクションのトークンをキャンセルします。InvocationConfiguration.ProcessTerminationTimeout(既定値は 2 秒)は、プロセスが強制終了されるまでにアクションが完了するために使える時間です。
InvocationConfiguration config = new()
{
ProcessTerminationTimeout = TimeSpan.FromSeconds(10) // time to flush a partial batch
};
return await root.Parse(args).InvokeAsync(config);
理由: 既定の 2 秒はほとんどのツールに適しています。クリーンアップ(フラッシュ、ロックファイルの解放)に実際にそれ以上かかる場合のみ、時間を延ばしてください。独自の Console.CancelKeyPress ハンドラーを併せて登録しないでください。Ctrl+C の処理が二重に競合します。
コマンドラインツール内での Generic Host と DI
安定版の System.CommandLine.Hosting パッケージはありません(現在もアルファ版です)。そのため、自分で組み合わせます。まず解析し、サービスが必要なアクション内でホストを構築してください。
Option<bool> verboseOption = new("--verbose", "-v") { Recursive = true }; // added to root, inherited by subcommands
importCommand.SetAction(async (parseResult, ct) =>
{
using var host = AppHost.Build(parseResult.GetValue(verboseOption));
var handler = host.Services.GetRequiredService<ImportHandler>();
return await handler.RunAsync(parseResult.GetValue(inputOption)!, ct);
});
internal static class AppHost
{
public static IHost Build(bool verbose)
{
// No Args: System.CommandLine owns the command line, not configuration.
// ContentRoot = install dir, so a stray appsettings.json in the user's cwd is never loaded.
var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings
{
ContentRootPath = AppContext.BaseDirectory
});
builder.Logging.ClearProviders();
builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace); // logs → stderr
builder.Logging.SetMinimumLevel(verbose ? LogLevel.Debug : LogLevel.Warning);
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddHttpClient<OrdersApiClient>();
builder.Services.AddTransient<ImportHandler>();
return builder.Build();
}
}
internal sealed class ImportHandler(OrdersApiClient api, TimeProvider clock, ILogger<ImportHandler> logger)
{
public async Task<int> RunAsync(FileInfo input, CancellationToken ct) { /* ... */ return ExitCodes.Success; }
}
理由: ホストを構築しないため、--help や解析エラーは即座に表示されます。既定のコンソールロガーは stdout に書き込みます。LogToStandardErrorThreshold = LogLevel.Trace を設定すると、すべてのログ行が stderr に送られ、stdout をクリーンに保てます。実行完了後に終了するコマンドラインツールでは、host.RunAsync() を呼び出しません。必要なのはコンテナーだけです。
対話モードと非対話モード
public static class Terminal
{
public static bool IsInteractive =>
!Console.IsInputRedirected && !Console.IsOutputRedirected &&
Environment.GetEnvironmentVariable("CI") is null;
}
if (!force && Terminal.IsInteractive)
{
Console.Error.Write($"Delete {count} orders? [y/N] ");
if (Console.ReadLine()?.Trim() is not ("y" or "Y"))
return ExitCodes.Cancelled;
}
else if (!force)
{
await Console.Error.WriteLineAsync("Refusing to delete without --force in non-interactive mode.");
return ExitCodes.ValidationFailed;
}
理由: CI でプロンプトを表示すると、ジョブが永遠に停止します。リダイレクトを検出し、実行可能なメッセージを表示してすぐに失敗させてください。プロンプトには、それを回避するフラグ(--force、--yes)が必ず必要です。パイプ経由の stdout を汚さないよう、プロンプトは stderr に出力してください。
stdin の読み取り
Argument<FileInfo?> fileArgument = new("file") { Arity = ArgumentArity.ZeroOrOne };
TextReader reader = parseResult.GetValue(fileArgument) is { } file
? file.OpenText()
: Console.In; // `cat orders.csv | mytool import`
while (await reader.ReadLineAsync(ct) is { } line)
Process(line);
理由: ファイルまたは stdin(Unix の - 慣例)を受け付けることで、ツールを組み合わせて使えます。行ごとにストリームとして読み取り、サイズが不明な入力に対して ReadToEnd を使わないでください。
.NET ツールとしてパッケージ化する
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<PackAsTool>true</PackAsTool>
<ToolCommandName>orders</ToolCommandName>
<PackageId>Contoso.Orders.Cli</PackageId>
</PropertyGroup>
dotnet pack -c Release
dotnet tool install --global --add-source ./bin/Release Contoso.Orders.Cli
dotnet tool exec Contoso.Orders.Cli -- import -i orders.csv # .NET 10: run without installing
dnx Contoso.Orders.Cli import -i orders.csv # .NET 10 shorthand
理由: ツールパッケージはバージョン管理され、インストーラーなしで NuGet を通じてインストールおよび更新できます。.NET 10 では、Native AOT を含むプラットフォーム固有(RID)のビルドも、1 つのパッケージで配布できます。<RuntimeIdentifiers> に RID を列挙し、フレームワーク依存のフォールバックとして any を追加してください。native-aot スキルを参照してください。
スクリプト向けファイルベースアプリ(dotnet run app.cs)
#!/usr/bin/env -S dotnet --
#:package [email protected]
using System.CommandLine;
Argument<string> nameArgument = new("name");
RootCommand root = new("Greets someone") { nameArgument };
root.SetAction(parseResult => Console.WriteLine($"Hello, {parseResult.GetValue(nameArgument)}"));
return root.Parse(args).Invoke();
dotnet run greet.cs -- Mukesh # or: dotnet greet.cs Mukesh
dotnet publish greet.cs # Native AOT executable by default
dotnet project convert greet.cs # graduate to a .csproj when it grows
理由: 単一ファイルアプリを使うと、.csproj なしで、PowerShell/bash のつなぎ込みスクリプトを型安全な C# に置き換えられます。バージョンを指定しない #:package が使えるのは、中央パッケージ管理を利用している場合のみです。そのため、バージョンを固定するか、@* を使用してください。プロジェクトの Directory.Build.props の設定が適用されないよう、これらのファイルはプロジェクトフォルダーの外に置いてください。スクリプトにテストが必要になったとき、またはファイルが複数になったときに、プロジェクトへ移行してください。
アンチパターン
標準出力へのログ出力
// BAD — default console logger + status text on stdout corrupts piped output
logger.LogInformation("Exporting...");
Console.WriteLine("Done!");
Console.WriteLine(json);
// GOOD — only the data goes to stdout
builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace);
await Console.Error.WriteLineAsync("Exporting...");
Console.WriteLine(json);
想定される失敗に対して例外をスローする
// BAD — stack trace for a missing file, exit code is whatever the default handler picks
var text = File.ReadAllText(path);
// GOOD — validate at the boundary, return a documented exit code
if (!File.Exists(path))
{
await Console.Error.WriteLineAsync($"File not found: {path}");
return ExitCodes.NotFound;
}
同期処理と非同期処理の混在、およびトークンの無視
// BAD — blocks a thread, Ctrl+C cannot interrupt the download
root.SetAction(parseResult => Console.WriteLine(client.GetStringAsync(url).Result));
// GOOD — async action, token flows to every call
root.SetAction(async (parseResult, ct) =>
{
var body = await client.GetStringAsync(parseResult.GetValue(urlOption), ct);
Console.WriteLine(body);
return ExitCodes.Success;
});
return await root.Parse(args).InvokeAsync();
1つのアプリ内で、同期の Invoke() と非同期アクションを混在させないでください。全体を非同期に統一してください。
2.0 より前の System.CommandLine API を使用する
// BAD — beta API, removed in 2.0
rootCommand.SetHandler(async (FileInfo file) => { ... }, fileOption);
await new CommandLineBuilder(rootCommand).UseDefaults().Build().InvokeAsync(args);
// GOOD — 2.x API
rootCommand.SetAction((parseResult, ct) => RunAsync(parseResult.GetValue(fileOption)!, ct));
await rootCommand.Parse(args).InvokeAsync();
ツールでの DateTime.Now の使用
// BAD — untestable "older than 30 days" logic
var cutoff = DateTime.Now.AddDays(-30);
// GOOD — injectable clock (FakeTimeProvider in tests)
var cutoff = clock.GetUtcNow().AddDays(-30);
選択ガイド
| シナリオ | 推奨 |
|---|---|
| その場限りのスクリプト/自動化用のつなぎ込み | ファイルベースアプリ(dotnet run app.cs) |
| スクリプトが複数のファイルに拡大した、またはテストが必要 | dotnet project convert を実行してから、通常のコンソールプロジェクトを作成 |
| オプションまたはサブコマンドを持つツール | System.CommandLine 2.x |
| HTTP、構成、または DI が必要なツール | アクション内に Host.CreateApplicationBuilder() を構築し、ログを標準エラー出力に書き込む |
| サービスを必要としない単純なツール | ホストを使わず、通常の static ハンドラーを使用 |
| リッチなターミナル UI(テーブル、スピナー) | Spectre.Console を使用し、出力そのものである場合を除いて標準エラー出力に描画 |
| .NET 開発者向けに配布 | PackAsTool + NuGet(dotnet tool install / dnx) |
| .NET 以外のユーザー向けに配布 | RID ごとのネイティブ AOT 単一バイナリ(native-aot を参照) |
| 実行完了まで動作するのではなく、長時間稼働するデーモン | CLI ではなく、host.RunAsync() を使用する Worker Service |
| 破壊的なコマンド | 対話形式の確認 + CI 向けの --force フラグ |
| コマンドのテスト | root.Parse([...]).InvokeAsync(new() { Output = sw, Error = errSw }) を呼び出し、終了コードと出力を検証 |