System.CommandLine — parse & dispatch a .NET CLI (current API)
System.CommandLine (namespace System.CommandLine) parses arguments into a command tree and runs
an action. The 2.0 GA redesign removed the old invocation/binding stack, so most remembered
snippets do not compile. Pin the current shapes below.
Use the System.CommandLine skills, not the web. Do NOT
web_search/web_fetchfor System.CommandLine usage — the web is dominated by the pre-GA beta API (SetHandler,AddOption,AddCommand,BinderBase<T>,IConsole,getDefaultValue:ctor args) that no longer exists. These skills are the current, authoritative API. This skill covers the core pattern; beta→GA migration, options/arguments in depth, actions/invocation, subcommands/help, and 3.x additions are covered separately.
If the task asks for a dedicated action class, do not substitute an inline SetAction delegate:
use the actions/invocation skill for SynchronousCommandLineAction,
AsynchronousCommandLineAction, and command.Action. If the task coordinates defaults,
completion, allowed values, or explicit presence across options, use the options/arguments skill;
those rules must run before the action.
Composite commands: finish the input contract before assigning the action
A dedicated action does not replace parser configuration. For each command, finish its option contract first:
using System.CommandLine;
using System.CommandLine.Invocation;
string[] knownTypes = ["openai", "azure", "ollama"];
var type = new Option<string[]>("--type")
{
Required = true,
Arity = ArgumentArity.OneOrMore,
HelpName = string.Join("|", knownTypes),
AllowMultipleArgumentsPerToken = true,
};
type.CompletionSources.Add(knownTypes); // 2.0.10 takes the strings directly
type.Validators.Add(result =>
{
foreach (string value in result.GetValueOrDefault<string[]>() ?? [])
if (!knownTypes.Any(k => k.Equals(value, StringComparison.OrdinalIgnoreCase)))
result.AddError($"Unknown type '{value}'.");
});
var authType = new Option<string>("--auth-type") { DefaultValueFactory = _ => "device" };
var authId = new Option<string?>("--auth-id");
var command = new Command("add") { type, authType, authId };
command.Validators.Add(result =>
{
bool hasType = result.GetResult(authType)?.Implicit == false;
bool hasId = result.GetResult(authId)?.Implicit == false;
if (hasType != hasId) result.AddError("Supply both authentication settings or neither.");
});
command.Action = new AddAction(type, authType, authId);
internal sealed class AddAction(
Option<string[]> type,
Option<string> authType,
Option<string?> authId) : AsynchronousCommandLineAction
{
public override Task<int> InvokeAsync(
ParseResult parseResult,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
string[] types = parseResult.GetValue(type) ?? [];
string selectedAuthType = parseResult.GetValue(authType)!;
string? id = parseResult.GetValue(authId);
string auth = id is null ? "anonymous" : $"{selectedAuthType}:{id}";
Console.WriteLine($"type={string.Join(",", types)};auth={auth}");
return Task.FromResult(0);
}
}
On stable 2.0.x, AcceptOnlyFromAmong is case-sensitive and its comparer overload is not available.
Use a case-insensitive validator as above; never normalize by rewriting raw args. A rule spanning
options belongs on command.Validators, not inside the action, so invalid input prevents invocation.
Give sibling commands separate option and action instances when their required/default/arity
contracts differ. If the requirement says asynchronous action, inherit
AsynchronousCommandLineAction even when the initial body has no naturally asynchronous operation.
For 2.0.10 completion, copy option.CompletionSources.Add(knownValues) exactly. The collection takes
the strings directly; do not invent a CompletionSource.ForValues(...) wrapper and then remove
completion when that obsolete shape fails. Likewise, do not remove an option's parser default to make
an all-or-none check easier: keep the contract and use GetResult(...).Implicit for explicit presence.
The core pattern (current API)
using System.CommandLine;
// 1. Declare options/arguments. KEEP the instances — you read values back by identity.
var nameOption = new Option<string>("--name") // "--name" is the name; extra strings are ALIASES
{
Description = "Who to greet", // description is a PROPERTY, not a ctor arg
Required = true,
};
nameOption.Aliases.Add("-n");
var countOption = new Option<int>("--count") { DefaultValueFactory = _ => 1 };
// 2. Build the command tree.
var root = new RootCommand("Greeter sample");
root.Options.Add(nameOption);
root.Options.Add(countOption);
// 3. Wire behavior with SetAction; read parsed values from the ParseResult by instance.
root.SetAction(parseResult =>
{
string name = parseResult.GetValue(nameOption)!;
int count = parseResult.GetValue(countOption);
for (int i = 0; i < count; i++) Console.WriteLine($"Hello, {name}!");
return 0; // exit code
});
// 4. Parse then invoke.
return await root.Parse(args).InvokeAsync();
Gotchas (compile-clean but wrong, or removed-API)
new Option<T>("--name", "description")is WRONG. The 2nd positional arg is an alias, so the description becomes a bogus alias and the help text is lost. Usenew Option<T>("--name") { Description = "..." }; pass real aliases as extra strings (new Option<T>("--name", "-n")) or via.Aliases.Add(...). Same forArgument<T>.- Read values by identity.
parseResult.GetValue(theOptionInstance)— keep the exact instance you added. There is no delegate-parameter binding anymore. SetHandleris gone. UseSetAction(parseResult => ...)(sync) orSetAction(async (parseResult, ct) => ...)(async).AddOption/AddArgument/AddCommandare gone. Use the.Options/.Arguments/.Subcommandscollections:root.Options.Add(o),cmd.Subcommands.Add(sub).Required, notIsRequired. Default values areDefaultValueFactory = _ => v, notgetDefaultValue:/SetDefaultValue(...).IConsole/BinderBase<T>/HelpBuilderare gone. UseConsoledirectly; customize help via aHelpAction(help customization is covered separately).