dotnet-system-commandline
dotnet-system-commandline
System.CommandLine 2.0 stable API for building .NET CLI applications. Covers RootCommand, Command, Option<T>, Argument<T>, SetAction for handler binding, ParseResult-based value access, custom type parsing, validation, tab completion, and testing with TextWriter capture.
Version assumptions: .NET 8.0+ baseline. System.CommandLine 2.0.0+ (stable NuGet package, GA since November 2025). All examples target the 2.0.0 GA API surface.
Breaking change note: System.CommandLine 2.0.0 GA differs significantly from the pre-release beta4 API. Key changes: SetHandler replaced by SetAction, ICommandHandler removed in favor of SynchronousCommandLineAction/AsynchronousCommandLineAction, InvocationContext removed (ParseResult passed directly), CommandLineBuilder and AddMiddleware removed, IConsole removed in favor of TextWriter properties, and the System.CommandLine.Hosting/System.CommandLine.NamingConventionBinder packages discontinued. Do not use beta-era patterns.
Scope
- RootCommand, Command, Option, Argument hierarchy
- SetAction handler binding (sync and async)
- ParseResult-based value access
- Custom type parsing and validation
- Tab completion and directives
- Testing with InvocationConfiguration and TextWriter capture
- Migration from beta4 to 2.0.0 GA
- Dependency injection integration without System.CommandLine.Hosting
Out of scope
- CLI application architecture patterns (layered design, exit codes, stdin/stdout/stderr) -- see [skill:dotnet-cli-architecture]
- Native AOT compilation -- see [skill:dotnet-native-aot]
- CLI distribution strategy -- see [skill:dotnet-cli-distribution]
- General CI/CD patterns -- see [skill:dotnet-gha-patterns] and [skill:dotnet-ado-patterns]
- DI container mechanics -- see [skill:dotnet-csharp-dependency-injection]
- General coding standards -- see [skill:dotnet-csharp-coding-standards]
- CLI packaging for Homebrew, apt, winget -- see [skill:dotnet-cli-packaging]
Cross-references: [skill:dotnet-cli-architecture] for CLI design patterns, [skill:dotnet-native-aot] for AOT publishing CLI tools, [skill:dotnet-csharp-dependency-injection] for DI fundamentals, [skill:dotnet-csharp-configuration] for configuration integration, [skill:dotnet-csharp-coding-standards] for naming and style conventions.
Package Reference
<ItemGroup>
<PackageReference Include="System.CommandLine" Version="2.0.*" />
</ItemGroup>
System.CommandLine 2.0 targets .NET 8+ and .NET Standard 2.0. A single package provides all functionality -- the separate System.CommandLine.Hosting, System.CommandLine.NamingConventionBinder, and System.CommandLine.Rendering packages from the beta era are discontinued.
RootCommand and Command Hierarchy
Basic Command Structure
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);
Collection Initializer Syntax
// 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" }
}
};
Options and Arguments
Option<T> -- Named Parameters
// 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> -- Positional Parameters
// 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);
Constrained Values
var formatOption = new Option<string>("--format")
{
Description = "Output format"
};
formatOption.AcceptOnlyFromAmong("json", "csv", "table");
rootCommand.Options.Add(formatOption);
Aliases
// 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
// 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);
Setting Actions (Command Handlers)
In 2.0.0 GA, SetHandler is replaced by SetAction. Actions receive a ParseResult directly (no InvocationContext).
Synchronous Action
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
});
Asynchronous Action with 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;
});
Getting Values by Name
// 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}");
});
Parsing and Invoking
// 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 Without Invoking
// 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
For detailed examples (custom parsing, validation, configuration, tab completion, DI, testing, migration), see examples.md in this skill directory.
Agent Gotchas
- Do not use beta4 API patterns. The 2.0.0 GA API is fundamentally different. There is no
SetHandler-- useSetAction. There is noInvocationContext-- actions receiveParseResultdirectly. There is noCommandLineBuilder-- configuration usesParserConfiguration/InvocationConfiguration. - Do not reference discontinued packages.
System.CommandLine.Hosting,System.CommandLine.NamingConventionBinder, andSystem.CommandLine.Renderingare discontinued. Use the singleSystem.CommandLinepackage. - Do not confuse
Option<T>withArgument<T>. Options are named (--output file.txt), arguments are positional (mycli file.txt). Using the wrong type produces confusing parse errors. - Do not use
AddOption/AddCommand/AddAliasmethods. These were replaced by mutable collection properties:Options.Add,Subcommands.Add,Aliases.Add. The old methods do not exist in 2.0.0. - Do not use
IConsoleorTestConsolefor testing. These interfaces were removed. UseInvocationConfigurationwithStringWriterforOutput/Errorto capture test output. - Do not ignore the
CancellationTokenin async actions. In 2.0.0 GA,CancellationTokenis a mandatory second parameter for asyncSetActiondelegates. The compiler warns (CA2016) when it is not propagated. - Do not write
Console.Outdirectly in command actions. Write toInvocationConfiguration.Outputfor testability. If no configuration is provided, output goes toConsole.Outby default, but direct writes bypass test capture. - Do not set default values via constructors. Use the
DefaultValueFactoryproperty instead. The oldgetDefaultValueconstructor parameter does not exist in 2.0.0.
References
- System.CommandLine overview
- System.CommandLine migration guide (beta5+)
- How to parse and invoke
- How to customize parsing and validation
- System.CommandLine GitHub
Attribution
Adapted from Aaronontheweb/dotnet-skills (MIT license).