dotnet-system-commandline

Using System.CommandLine 2.0. Commands, options, SetAction, custom parsing, middleware, testing.

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "dotnet-system-commandline" with this command: npx skills add wshaddix/dotnet-skills/wshaddix-dotnet-skills-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.

Out of scope: CLI application architecture patterns (layered command/handler/service design, configuration precedence, 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].

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

Custom Type Parsing

CustomParser Property

For types without built-in parsers, use the CustomParser property on Option<T> or Argument<T>.

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
};

Combining CustomParser with Validation

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;
    }
};

Validation

Option and Argument Validators

// 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
};

Built-In Validators

// 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");

Configuration

In 2.0.0 GA, CommandLineBuilder is removed. Configuration uses ParserConfiguration (for parsing) and InvocationConfiguration (for invocation).

Parser Configuration

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);

Invocation Configuration

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);

Tab Completion

Enabling Completion

Tab completion is built into RootCommand via the SuggestDirective (included by default).

Users register completions for their shell:

# 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

Custom Completions

// 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))
]);

Automatic --version and --help

Version

--version is automatically available on RootCommand via VersionOption. It reads from:

  1. AssemblyInformationalVersionAttribute (preferred -- includes SemVer metadata)
  2. AssemblyVersionAttribute (fallback)
<!-- Set in .csproj for automatic --version output -->
<PropertyGroup>
  <Version>1.2.3</Version>
  <!-- Or use source link / CI-generated version -->
  <InformationalVersion>1.2.3+abc123</InformationalVersion>
</PropertyGroup>

Help

Help is automatically provided via HelpOption on RootCommand. Descriptions from constructors and Description properties flow into help text.


Directives

Directives replace some beta-era CommandLineBuilder extensions. RootCommand exposes a Directives collection.

// 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]

Parse Error Handling

// 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();

Dependency Injection Pattern

The System.CommandLine.Hosting package is discontinued in 2.0.0 GA. For DI integration, use Microsoft.Extensions.Hosting directly and compose services before parsing.

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();

Testing

Testing with InvocationConfiguration (TextWriter Capture)

IConsole is removed in 2.0.0 GA. For testing, redirect output via 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());
}

Testing Parsed Values Without Invocation

[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);
}

Testing Custom Parsers

[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);
}

Testing with DI Services

[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);
}

Response Files

System.CommandLine supports response files (@filename) for passing large sets of arguments. Response file support is enabled by default; disable via ParserConfiguration.ResponseFileTokenReplacer = null.

# args.rsp
--source https://api.example.com
--output /tmp/results.json
--verbose

# Invoke with response file
mycli sync @args.rsp

Migration from Beta4 to 2.0.0 GA

Beta4 API2.0.0 GA Replacement
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 = trueoption.Required = true
option.IsHidden = trueoption.Hidden = true
InvocationContext contextParseResult parseResult (in SetAction)
context.GetCancellationToken()CancellationToken ct (second param in async SetAction)
context.ConsoleInvocationConfiguration.Output / .Error
IConsole / TestConsoleStringWriter via InvocationConfiguration
new CommandLineBuilder(root).UseDefaults().Build()root.Parse(args) (middleware built-in)
builder.AddMiddleware(...)Removed -- use ParseResult.Action inspection or wrap Invoke
CommandLineBuilderParserConfiguration + InvocationConfiguration
UseCommandHandler<T,T> (Hosting)Build host directly, resolve services in SetAction
Parser classCommandLineParser (static class)
FindResultFor(symbol)GetResult(symbol)
ErrorMessage = "..."result.AddError("...")
getDefaultValue: () => valDefaultValueFactory = _ => val
ParseArgument<T> delegateCustomParser property

Agent Gotchas

  1. Do not use beta4 API patterns. The 2.0.0 GA API is fundamentally different. There is no SetHandler -- use SetAction. There is no InvocationContext -- actions receive ParseResult directly. There is no CommandLineBuilder -- configuration uses ParserConfiguration/InvocationConfiguration.
  2. Do not reference discontinued packages. System.CommandLine.Hosting, System.CommandLine.NamingConventionBinder, and System.CommandLine.Rendering are discontinued. Use the single System.CommandLine package.
  3. Do not confuse Option<T> with Argument<T>. Options are named (--output file.txt), arguments are positional (mycli file.txt). Using the wrong type produces confusing parse errors.
  4. Do not use AddOption/AddCommand/AddAlias methods. These were replaced by mutable collection properties: Options.Add, Subcommands.Add, Aliases.Add. The old methods do not exist in 2.0.0.
  5. Do not use IConsole or TestConsole for testing. These interfaces were removed. Use InvocationConfiguration with StringWriter for Output/Error to capture test output.
  6. Do not ignore the CancellationToken in async actions. In 2.0.0 GA, CancellationToken is a mandatory second parameter for async SetAction delegates. The compiler warns (CA2016) when it is not propagated.
  7. Do not write Console.Out directly in command actions. Write to InvocationConfiguration.Output for testability. If no configuration is provided, output goes to Console.Out by default, but direct writes bypass test capture.
  8. Do not set default values via constructors. Use the DefaultValueFactory property instead. The old getDefaultValue constructor parameter does not exist in 2.0.0.

References


Attribution

Adapted from Aaronontheweb/dotnet-skills (MIT license).

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

dotnet-performance-patterns

No summary provided by upstream source.

Repository SourceNeeds Review
General

dotnet-solid-principles

No summary provided by upstream source.

Repository SourceNeeds Review
General

dotnet-winforms-basics

No summary provided by upstream source.

Repository SourceNeeds Review
General

dotnet-architecture-patterns

No summary provided by upstream source.

Repository SourceNeeds Review