dotnet-system-commandline について
安定版 System.CommandLine 2.0 API でコマンドラインアプリを構築する .NET 開発者向けのスキルガイドです。コマンド構成、解析、検証、補完、テストに加え、旧ベータ API からの変更点を扱います。
主な機能
- 01コマンドとサブコマンドの階層構築
- 02オプション、引数、独自パーサーの設定
- 03SetAction によるハンドラーの紐付け
- 04検証ルールとタブ補完の追加
- 05TextWriter による出力の捕捉とテスト
活用シーン
- 01beta4 のコードを 2.0 GA に移行
- 02CLI 引数の型解析と検証
- 03CLI のシェル補完を設定
- 04コマンド解析結果と出力のテスト
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ wshaddix/ dotnet-skills --skill dotnet-system-commandlineインストール後、Claude Code で/dotnet-system-commandlineを入力して読み込まれたか確認します。
詳細
dotnet-system-commandline
.NET CLI アプリケーションを構築するための、安定版 System.CommandLine 2.0 API です。RootCommand、Command、Option<T>、Argument<T>、ハンドラーのバインドに使う SetAction、ParseResult による値の取得、カスタム型の解析、検証、タブ補完、TextWriter のキャプチャによるテストを扱います。
バージョンの前提条件: .NET 8.0 以降を基準とします。System.CommandLine 2.0.0 以降(安定版 NuGet パッケージ。2025 年 11 月から GA)。すべての例は、2.0.0 GA の API サーフェスを対象としています。
破壊的変更に関する注意: System.CommandLine 2.0.0 GA は、プレリリース版の beta4 API と大きく異なります。主な変更点:SetHandler は SetAction に置き換えられ、ICommandHandler は SynchronousCommandLineAction/AsynchronousCommandLineAction に置き換えられ、InvocationContext は削除されて ParseResult が直接渡されるようになり、CommandLineBuilder と AddMiddleware は削除され、IConsole は TextWriter プロパティに置き換えられました。また、System.CommandLine.Hosting/System.CommandLine.NamingConventionBinder パッケージは廃止されました。beta 時代のパターンは使用しないでください。
対象外: CLI アプリケーションのアーキテクチャパターン(レイヤー化されたコマンド/ハンドラー/サービス設計、構成の優先順位、終了コード、stdin/stdout/stderr)については、[skill:dotnet-cli-architecture] を参照してください。Native AOT コンパイルについては、[skill:dotnet-native-aot] を参照してください。CLI の配布戦略については、[skill:dotnet-cli-distribution] を参照してください。一般的な CI/CD パターンについては、[skill:dotnet-gha-patterns] と [skill:dotnet-ado-patterns] を参照してください。DI コンテナーの仕組みについては、[skill:dotnet-csharp-dependency-injection] を参照してください。一般的なコーディング規約については、[skill:dotnet-csharp-coding-standards] を参照してください。
相互参照:CLI の設計パターンについては [skill:dotnet-cli-architecture]、CLI ツールの AOT 発行については [skill:dotnet-native-aot]、DI の基礎については [skill:dotnet-csharp-dependency-injection]、構成との統合については [skill:dotnet-csharp-configuration]、命名およびスタイルの規約については [skill:dotnet-csharp-coding-standards] を参照してください。
パッケージの参照
<ItemGroup>
<PackageReference Include="System.CommandLine" Version="2.0.*" />
</ItemGroup>
System.CommandLine 2.0 は .NET 8 以降および .NET Standard 2.0 を対象としています。単一のパッケージですべての機能を提供します。beta 時代に別途提供されていた System.CommandLine.Hosting、System.CommandLine.NamingConventionBinder、System.CommandLine.Rendering パッケージは廃止されました。
RootCommand と Command の階層
基本的なコマンド構造
using System.CommandLine;
// Root command -- the entry point
var rootCommand = new RootCommand("My CLI tool description");
// Add a subcommand via mutable collection
var listCommand = new Command("list", "List all items");
rootCommand.Subcommands.Add(listCommand);
// Nested subcommands: mycli migrate up
var migrateCommand = new Command("migrate", "Database migrations");
var upCommand = new Command("up", "Apply pending migrations");
var downCommand = new Command("down", "Revert last migration");
migrateCommand.Subcommands.Add(upCommand);
migrateCommand.Subcommands.Add(downCommand);
rootCommand.Subcommands.Add(migrateCommand);
コレクション初期化子の構文
// Fluent collection initializer (commands, options, arguments)
RootCommand rootCommand = new("My CLI tool")
{
new Option<string>("--output", "-o") { Description = "Output file path" },
new Argument<FileInfo>("file") { Description = "Input file" },
new Command("list", "List all items")
{
new Option<int>("--limit") { Description = "Max items to return" }
}
};
オプションと引数
Option<T> -- 名前付きパラメーター
// Option<T> -- named parameter (--output, -o)
// name is the first parameter; additional params are aliases
var outputOption = new Option<FileInfo>("--output", "-o")
{
Description = "Output file path",
Required = true // was IsRequired in beta4
};
// Option with default value via DefaultValueFactory
var verbosityOption = new Option<int>("--verbosity")
{
Description = "Verbosity level (0-3)",
DefaultValueFactory = _ => 1
};
Argument<T> -- 位置指定パラメーター
// Argument<T> -- positional parameter
// name is mandatory in 2.0 (used for help text)
var fileArgument = new Argument<FileInfo>("file")
{
Description = "Input file to process"
};
rootCommand.Arguments.Add(fileArgument);
制約付きの値
var formatOption = new Option<string>("--format")
{
Description = "Output format"
};
formatOption.AcceptOnlyFromAmong("json", "csv", "table");
rootCommand.Options.Add(formatOption);
エイリアス
// Aliases are separate from the name in 2.0
// First constructor param is the name; rest are aliases
var verboseOption = new Option<bool>("--verbose", "-v")
{
Description = "Enable verbose output"
};
// Or add aliases after construction
verboseOption.Aliases.Add("-V");
グローバルオプション
// Global options are inherited by all subcommands
var debugOption = new Option<bool>("--debug")
{
Description = "Enable debug mode",
Recursive = true // makes it global (inherited by subcommands)
};
rootCommand.Options.Add(debugOption);
アクションの設定(コマンドハンドラー)
2.0.0 GA では、SetHandler は SetAction に置き換えられています。アクションには ParseResult が直接渡されます(InvocationContext は使いません)。
同期アクション
var outputOption = new Option<FileInfo>("--output", "-o")
{
Description = "Output file path",
Required = true
};
var verbosityOption = new Option<int>("--verbosity")
{
DefaultValueFactory = _ => 1
};
rootCommand.Options.Add(outputOption);
rootCommand.Options.Add(verbosityOption);
rootCommand.SetAction(parseResult =>
{
var output = parseResult.GetValue(outputOption)!;
var verbosity = parseResult.GetValue(verbosityOption);
Console.WriteLine($"Output: {output.FullName}, Verbosity: {verbosity}");
return 0; // exit code
});
CancellationToken を使用する非同期アクション
// Async actions receive ParseResult AND CancellationToken
rootCommand.SetAction(async (ParseResult parseResult, CancellationToken ct) =>
{
var output = parseResult.GetValue(outputOption)!;
var verbosity = parseResult.GetValue(verbosityOption);
await ProcessAsync(output, verbosity, ct);
return 0;
});
名前による値の取得
// Values can also be retrieved by symbol name (requires type parameter)
rootCommand.SetAction(parseResult =>
{
int delay = parseResult.GetValue<int>("--delay");
string? message = parseResult.GetValue<string>("--message");
Console.WriteLine($"Delay: {delay}, Message: {message}");
});
解析と呼び出し
// Program.cs entry point -- parse then invoke
static int Main(string[] args)
{
var rootCommand = BuildCommand();
ParseResult parseResult = rootCommand.Parse(args);
return parseResult.Invoke();
}
// Async entry point
static async Task<int> Main(string[] args)
{
var rootCommand = BuildCommand();
ParseResult parseResult = rootCommand.Parse(args);
return await parseResult.InvokeAsync();
}
呼び出しを行わずに解析
// Parse-only mode: inspect results without running actions
ParseResult parseResult = rootCommand.Parse(args);
if (parseResult.Errors.Count > 0)
{
foreach (var error in parseResult.Errors)
{
Console.Error.WriteLine(error.Message);
}
return 1;
}
FileInfo? file = parseResult.GetValue(fileOption);
// Process directly without SetAction
カスタム型の解析
CustomParser プロパティ
組み込みパーサーがない型には、Option<T> または Argument<T> の CustomParser プロパティを使用します。
public record ConnectionInfo(string Host, int Port);
var connectionOption = new Option<ConnectionInfo?>("--connection")
{
Description = "Connection as host:port",
CustomParser = result =>
{
var raw = result.Tokens.SingleOrDefault()?.Value;
if (raw is null)
{
result.AddError("--connection requires a value");
return null;
}
var parts = raw.Split(':');
if (parts.Length != 2 || !int.TryParse(parts[1], out var port))
{
result.AddError("Expected format: host:port");
return null;
}
return new ConnectionInfo(parts[0], port);
}
};
DefaultValueFactory
var portOption = new Option<int>("--port")
{
Description = "Server port",
DefaultValueFactory = _ => 8080 // type-safe default
};
CustomParser と検証の組み合わせ
var uriOption = new Option<Uri?>("--uri")
{
Description = "Target URI",
CustomParser = result =>
{
var raw = result.Tokens.SingleOrDefault()?.Value;
if (raw is null) return null;
if (!Uri.TryCreate(raw, UriKind.Absolute, out var uri))
{
result.AddError("Invalid URI format");
return null;
}
if (uri.Scheme != "https")
{
result.AddError("Only HTTPS URIs are accepted");
return null;
}
return uri;
}
};
検証
オプションと引数のバリデーター
// Validators use Validators.Add (not AddValidator in 2.0)
var portOption = new Option<int>("--port") { Description = "Port number" };
portOption.Validators.Add(result =>
{
var value = result.GetValue(portOption);
if (value < 1 || value > 65535)
{
result.AddError("Port must be between 1 and 65535");
}
});
// Arity constraints
var tagsOption = new Option<string[]>("--tag")
{
Arity = new ArgumentArity(1, 5), // 1 to 5 tags
AllowMultipleArgumentsPerToken = true
};
組み込みバリデーター
// Accept only existing files/directories
var inputOption = new Option<FileInfo>("--input");
inputOption.AcceptExistingOnly();
// Accept only legal file names
var nameArg = new Argument<string>("name");
nameArg.AcceptLegalFileNamesOnly();
// Accept only from a set of values (moved from FromAmong)
var envOption = new Option<string>("--env");
envOption.AcceptOnlyFromAmong("dev", "staging", "prod");
構成
2.0.0 GA では、CommandLineBuilder は削除されています。構成には、解析用の ParserConfiguration と呼び出し用の InvocationConfiguration を使用します。
パーサーの構成
using System.CommandLine;
var config = new ParserConfiguration
{
EnablePosixBundling = true, // -abc == -a -b -c (default: true)
};
// Response files enabled by default; disable with:
// config.ResponseFileTokenReplacer = null;
ParseResult parseResult = rootCommand.Parse(args, config);
呼び出しの構成
var invocationConfig = new InvocationConfiguration
{
// Redirect output for testing or customization
Output = Console.Out,
Error = Console.Error,
// Process termination handling (default: 2 seconds)
ProcessTerminationTimeout = TimeSpan.FromSeconds(5),
// Disable default exception handler for custom try/catch
EnableDefaultExceptionHandler = false
};
int exitCode = parseResult.Invoke(invocationConfig);
タブ補完
補完を有効にする
タブ補完は RootCommand に組み込まれており、SuggestDirective(デフォルトで含まれています)を使用します。
ユーザーはシェルに補完を登録します。
# Bash -- add to ~/.bashrc
source <(mycli [suggest:bash])
# Zsh -- add to ~/.zshrc
source <(mycli [suggest:zsh])
# PowerShell -- add to $PROFILE
mycli [suggest:powershell] | Out-String | Invoke-Expression
# Fish
mycli [suggest:fish] | source
カスタム補完
// Static completions
var envOption = new Option<string>("--environment");
envOption.CompletionSources.Add("development", "staging", "production");
// Dynamic completions
var branchOption = new Option<string>("--branch");
branchOption.CompletionSources.Add(ctx =>
[
new CompletionItem("main"),
new CompletionItem("develop"),
// Dynamically fetch branches
.. GetGitBranches().Select(b => new CompletionItem(b))
]);
--version と --help の自動表示
バージョン
--version は VersionOption を介して RootCommand で自動的に利用できます。次の属性から読み取ります。
AssemblyInformationalVersionAttribute(優先。SemVer メタデータを含みます)AssemblyVersionAttribute(フォールバック)
<PropertyGroup>
<Version>1.2.3</Version>
<InformationalVersion>1.2.3+abc123</InformationalVersion>
</PropertyGroup>
ヘルプ
ヘルプは RootCommand の HelpOption を介して自動的に提供されます。コンストラクターと Description プロパティの説明は、ヘルプテキストに反映されます。
ディレクティブ
ディレクティブは、ベータ版の CommandLineBuilder 拡張機能の一部に代わるものです。RootCommand は Directives コレクションを公開します。
// Built-in directives (included by default on RootCommand):
// [suggest] -- tab completion suggestions
// Other available directives:
rootCommand.Directives.Add(new DiagramDirective()); // [diagram] -- shows parse tree
rootCommand.Directives.Add(new EnvironmentVariablesDirective()); // [env:VAR=value]
解析エラーの処理
// Customize parse error behavior
ParseResult result = rootCommand.Parse(args);
if (result.Action is ParseErrorAction parseError)
{
parseError.ShowTypoCorrections = true;
parseError.ShowHelp = false;
}
int exitCode = result.Invoke();
依存性注入パターン
System.CommandLine.Hosting パッケージは 2.0.0 GA で廃止されました。DI を統合するには、Microsoft.Extensions.Hosting を直接使用し、解析の前にサービスを構成してください。
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using System.CommandLine;
var host = Host.CreateDefaultBuilder(args)
.ConfigureServices(services =>
{
services.AddSingleton<ISyncService, SyncService>();
services.AddSingleton<IFileSystem, PhysicalFileSystem>();
})
.Build();
var serviceProvider = host.Services;
var sourceOption = new Option<string>("--source") { Description = "Source endpoint" };
var syncCommand = new Command("sync", "Synchronize data");
syncCommand.Options.Add(sourceOption);
syncCommand.SetAction(async (ParseResult parseResult, CancellationToken ct) =>
{
var syncService = serviceProvider.GetRequiredService<ISyncService>();
var source = parseResult.GetValue(sourceOption);
await syncService.SyncAsync(source!, ct);
return 0;
});
var rootCommand = new RootCommand("My CLI tool");
rootCommand.Subcommands.Add(syncCommand);
return await rootCommand.Parse(args).InvokeAsync();
テスト
InvocationConfiguration(TextWriter キャプチャ)を使用したテスト
IConsole は 2.0.0 GA で削除されました。テストでは、InvocationConfiguration を介して出力をリダイレクトしてください。
[Fact]
public void ListCommand_WritesItems_ToOutput()
{
// Arrange
var outputWriter = new StringWriter();
var errorWriter = new StringWriter();
var config = new InvocationConfiguration
{
Output = outputWriter,
Error = errorWriter
};
var rootCommand = BuildRootCommand();
// Act
ParseResult parseResult = rootCommand.Parse("list --format json");
int exitCode = parseResult.Invoke(config);
// Assert
Assert.Equal(0, exitCode);
Assert.Contains("json", outputWriter.ToString());
Assert.Empty(errorWriter.ToString());
}
呼び出しを行わずに解析済みの値をテストする
[Fact]
public void ParseResult_ExtractsOptionValues()
{
var portOption = new Option<int>("--port") { DefaultValueFactory = _ => 8080 };
var rootCommand = new RootCommand { portOption };
ParseResult result = rootCommand.Parse("--port 3000");
Assert.Equal(3000, result.GetValue(portOption));
Assert.Empty(result.Errors);
}
[Fact]
public void ParseResult_ReportsErrors_ForInvalidInput()
{
var portOption = new Option<int>("--port");
var rootCommand = new RootCommand { portOption };
ParseResult result = rootCommand.Parse("--port not-a-number");
Assert.NotEmpty(result.Errors);
}
カスタムパーサーのテスト
[Fact]
public void CustomParser_ParsesConnectionInfo()
{
var connOption = new Option<ConnectionInfo?>("--connection")
{
CustomParser = result =>
{
var parts = result.Tokens.Single().Value.Split(':');
return new ConnectionInfo(parts[0], int.Parse(parts[1]));
}
};
var rootCommand = new RootCommand { connOption };
ParseResult result = rootCommand.Parse("--connection localhost:5432");
var conn = result.GetValue(connOption);
Assert.Equal("localhost", conn!.Host);
Assert.Equal(5432, conn.Port);
}
DI サービスを使用したテスト
[Fact]
public async Task SyncCommand_CallsService()
{
var mockService = new Mock<ISyncService>();
var services = new ServiceCollection()
.AddSingleton(mockService.Object)
.BuildServiceProvider();
var sourceOption = new Option<string>("--source");
var syncCommand = new Command("sync") { sourceOption };
syncCommand.SetAction(async (ParseResult pr, CancellationToken ct) =>
{
var svc = services.GetRequiredService<ISyncService>();
await svc.SyncAsync(pr.GetValue(sourceOption)!, ct);
return 0;
});
var root = new RootCommand { syncCommand };
int exitCode = await root.Parse("sync --source https://api.example.com")
.InvokeAsync();
Assert.Equal(0, exitCode);
mockService.Verify(s => s.SyncAsync("https://api.example.com",
It.IsAny<CancellationToken>()), Times.Once);
}
レスポンスファイル
System.CommandLine は、大量の引数を渡すためのレスポンスファイル(@filename)をサポートしています。レスポンスファイルのサポートはデフォルトで有効です。無効にするには ParserConfiguration.ResponseFileTokenReplacer = null を設定してください。
# args.rsp
--source https://api.example.com
--output /tmp/results.json
--verbose
# Invoke with response file
mycli sync @args.rsp
Beta4 から 2.0.0 GA への移行
| Beta4 API | 2.0.0 GA での置き換え |
|---|---|
command.SetHandler(...) | command.SetAction(...) |
command.AddOption(opt) | command.Options.Add(opt) |
command.AddCommand(sub) | command.Subcommands.Add(sub) |
command.AddArgument(arg) | command.Arguments.Add(arg) |
option.AddAlias("-x") | option.Aliases.Add("-x") |
option.AddValidator(...) | option.Validators.Add(...) |
option.IsRequired = true | option.Required = true |
option.IsHidden = true | option.Hidden = true |
InvocationContext context | ParseResult parseResult(SetAction 内) |
context.GetCancellationToken() | CancellationToken ct(非同期 SetAction の 2 番目のパラメーター) |
context.Console | InvocationConfiguration.Output / .Error |
IConsole / TestConsole | InvocationConfiguration を介した StringWriter |
new CommandLineBuilder(root).UseDefaults().Build() | root.Parse(args)(ミドルウェアは組み込み) |
builder.AddMiddleware(...) | 削除済み — ParseResult.Action の検査、または Invoke のラップを使用 |
CommandLineBuilder | ParserConfiguration + InvocationConfiguration |
UseCommandHandler<T,T>(Hosting) | ホストを直接構築し、SetAction 内でサービスを解決 |
Parser クラス | CommandLineParser(静的クラス) |
FindResultFor(symbol) | GetResult(symbol) |
ErrorMessage = "..." | result.AddError("...") |
getDefaultValue: () => val | DefaultValueFactory = _ => val |
ParseArgument<T> デリゲート | CustomParser プロパティ |
エージェントが陥りやすい落とし穴
- beta4 の API パターンは使用しないでください。 2.0.0 GA の API は根本的に異なります。
SetHandlerはありません。SetActionを使用してください。InvocationContextはありません。アクションにはParseResultが直接渡されます。CommandLineBuilderはありません。構成にはParserConfiguration/InvocationConfigurationを使用します。 - 廃止されたパッケージを参照しないでください。
System.CommandLine.Hosting、System.CommandLine.NamingConventionBinder、System.CommandLine.Renderingは廃止されています。単一のSystem.CommandLineパッケージを使用してください。 Option<T>とArgument<T>を混同しないでください。 オプションには名前があります(--output file.txt)。引数は位置指定です(mycli file.txt)。誤った型を使うと、分かりにくい解析エラーが発生します。AddOption/AddCommand/AddAliasメソッドは使用しないでください。 これらは変更可能なコレクションプロパティに置き換えられました。Options.Add、Subcommands.Add、Aliases.Addを使用してください。旧メソッドは 2.0.0 には存在しません。- テストに
IConsoleやTestConsoleを使用しないでください。 これらのインターフェイスは削除されています。テスト出力を取得するには、Output/ErrorにStringWriterを指定したInvocationConfigurationを使用してください。 - 非同期アクションでは
CancellationTokenを無視しないでください。 2.0.0 GA では、非同期のSetActionデリゲートの 2 番目のパラメーターとしてCancellationTokenが必須です。引き継がない場合、コンパイラーが警告(CA2016)を出します。 - コマンドアクションで
Console.Outに直接書き込まないでください。 テスト可能にするため、InvocationConfiguration.Outputに書き込んでください。構成が指定されていない場合、出力先はデフォルトでConsole.Outになりますが、直接書き込むとテスト出力の取得を回避してしまいます。 - コンストラクターでデフォルト値を設定しないでください。 代わりに
DefaultValueFactoryプロパティを使用してください。旧コンストラクターパラメーターのgetDefaultValueは 2.0.0 には存在しません。
参考資料
- System.CommandLine の概要
- System.CommandLine 移行ガイド(beta5 以降)
- 解析と呼び出しの方法
- 解析と検証のカスタマイズ方法
- System.CommandLine GitHub
クレジット
Aaronontheweb/dotnet-skills(MIT ライセンス)を元に作成しています。