How to bind arguments to handlers in System.CommandLine

Important

System.CommandLine is currently in PREVIEW, and this documentation is for version 2.0 beta 4. Some information relates to prerelease product that may be substantially modified before it's released. Microsoft makes no warranties, express or implied, with respect to the information provided here.

The process of parsing arguments and providing them to command handler code is called parameter binding. System.CommandLine has the ability to bind many argument types built in. For example, integers, enums, and file system objects such as FileInfo and DirectoryInfo can be bound. Several System.CommandLine types can also be bound.

Built-in argument validation

Arguments have expected types and arity. System.CommandLine rejects arguments that don't match these expectations.

For example, a parse error is displayed if the argument for an integer option isn't an integer.

myapp --delay not-an-int
Cannot parse argument 'not-an-int' as System.Int32.

An arity error is displayed if multiple arguments are passed to an option that has maximum arity of one:

myapp --delay-option 1 --delay-option 2
Option '--delay' expects a single argument but 2 were provided.

This behavior can be overridden by setting Option.AllowMultipleArgumentsPerToken to true. In that case you can repeat an option that has maximum arity of one, but only the last value on the line is accepted. In the following example, the value three would be passed to the app.

myapp --item one --item two --item three

Parameter binding up to 8 options and arguments

The following example shows how to bind options to command handler parameters, by calling SetHandler:

var delayOption = new Option<int>
    ("--delay", "An option whose argument is parsed as an int.");
var messageOption = new Option<string>
    ("--message", "An option whose argument is parsed as a string.");

var rootCommand = new RootCommand("Parameter binding example");
rootCommand.Add(delayOption);
rootCommand.Add(messageOption);

rootCommand.SetHandler(
    (delayOptionValue, messageOptionValue) =>
    {
        DisplayIntAndString(delayOptionValue, messageOptionValue);
    },
    delayOption, messageOption);

await rootCommand.InvokeAsync(args);
public static void DisplayIntAndString(int delayOptionValue, string messageOptionValue)
{
    Console.WriteLine($"--delay = {delayOptionValue}");
    Console.WriteLine($"--message = {messageOptionValue}");
}

The lambda parameters are variables that represent the values of options and arguments:

(delayOptionValue, messageOptionValue) =>
{
    DisplayIntAndString(delayOptionValue, messageOptionValue);
},

The variables that follow the lambda represent the option and argument objects that are the sources of the option and argument values:

delayOption, messageOption);

The options and arguments must be declared in the same order in the lambda and in the parameters that follow the lambda. If the order is not consistent, one of the following scenarios will result:

  • If the out-of-order options or arguments are of different types, a run-time exception is thrown. For example, an int might appear where a string should be in the list of sources.
  • If the out-of-order options or arguments are of the same type, the handler silently gets the wrong values in the parameters provided to it. For example, string option x might appear where string option y should be in the list of sources. In that case, the variable for the option y value gets the option x value.

There are overloads of SetHandler that support up to 8 parameters, with both synchronous and asynchronous signatures.

Parameter binding more than 8 options and arguments

To handle more than 8 options, or to construct a custom type from multiple options, you can use InvocationContext or a custom binder.

Use InvocationContext

A SetHandler overload provides access to the InvocationContext object, and you can use InvocationContext to get any number of option and argument values. For examples, see Set exit codes and Handle termination.

Use a custom binder

A custom binder lets you combine multiple option or argument values into a complex type and pass that into a single handler parameter. Suppose you have a Person type:

public class Person
{
    public string? FirstName { get; set; }
    public string? LastName { get; set; }
}

Create a class derived from BinderBase<T>, where T is the type to construct based on command line input:

public class PersonBinder : BinderBase<Person>
{
    private readonly Option<string> _firstNameOption;
    private readonly Option<string> _lastNameOption;

    public PersonBinder(Option<string> firstNameOption, Option<string> lastNameOption)
    {
        _firstNameOption = firstNameOption;
        _lastNameOption = lastNameOption;
    }

    protected override Person GetBoundValue(BindingContext bindingContext) =>
        new Person
        {
            FirstName = bindingContext.ParseResult.GetValueForOption(_firstNameOption),
            LastName = bindingContext.ParseResult.GetValueForOption(_lastNameOption)
        };
}

With the custom binder, you can get your custom type passed to your handler the same way you get values for options and arguments:

rootCommand.SetHandler((fileOptionValue, person) =>
    {
        DoRootCommand(fileOptionValue, person);
    },
    fileOption, new PersonBinder(firstNameOption, lastNameOption));

Here's the complete program that the preceding examples are taken from:

using System.CommandLine;
using System.CommandLine.Binding;

public class Program
{
    internal static async Task Main(string[] args)
    {
        var fileOption = new Option<FileInfo?>(
              name: "--file",
              description: "An option whose argument is parsed as a FileInfo",
              getDefaultValue: () => new FileInfo("scl.runtimeconfig.json"));

        var firstNameOption = new Option<string>(
              name: "--first-name",
              description: "Person.FirstName");

        var lastNameOption = new Option<string>(
              name: "--last-name",
              description: "Person.LastName");

        var rootCommand = new RootCommand();
        rootCommand.Add(fileOption);
        rootCommand.Add(firstNameOption);
        rootCommand.Add(lastNameOption);

        rootCommand.SetHandler((fileOptionValue, person) =>
            {
                DoRootCommand(fileOptionValue, person);
            },
            fileOption, new PersonBinder(firstNameOption, lastNameOption));

        await rootCommand.InvokeAsync(args);
    }

    public static void DoRootCommand(FileInfo? aFile, Person aPerson)
    {
        Console.WriteLine($"File = {aFile?.FullName}");
        Console.WriteLine($"Person = {aPerson?.FirstName} {aPerson?.LastName}");
    }

    public class Person
    {
        public string? FirstName { get; set; }
        public string? LastName { get; set; }
    }

    public class PersonBinder : BinderBase<Person>
    {
        private readonly Option<string> _firstNameOption;
        private readonly Option<string> _lastNameOption;

        public PersonBinder(Option<string> firstNameOption, Option<string> lastNameOption)
        {
            _firstNameOption = firstNameOption;
            _lastNameOption = lastNameOption;
        }

        protected override Person GetBoundValue(BindingContext bindingContext) =>
            new Person
            {
                FirstName = bindingContext.ParseResult.GetValueForOption(_firstNameOption),
                LastName = bindingContext.ParseResult.GetValueForOption(_lastNameOption)
            };
    }
}

Set exit codes

There are Task-returning Func overloads of SetHandler. If your handler is called from async code, you can return a Task<int> from a handler that uses one of these, and use the int value to set the process exit code, as in the following example:

static async Task<int> Main(string[] args)
{
    var delayOption = new Option<int>("--delay");
    var messageOption = new Option<string>("--message");

    var rootCommand = new RootCommand("Parameter binding example");
    rootCommand.Add(delayOption);
    rootCommand.Add(messageOption);

    rootCommand.SetHandler((delayOptionValue, messageOptionValue) =>
        {
            Console.WriteLine($"--delay = {delayOptionValue}");
            Console.WriteLine($"--message = {messageOptionValue}");
            return Task.FromResult(100);
        },
        delayOption, messageOption);

    return await rootCommand.InvokeAsync(args);
}

However, if the lambda itself needs to be async, you can't return a Task<int>. In that case, use InvocationContext.ExitCode. You can get the InvocationContext instance injected into your lambda by using a SetHandler overload that specifies the InvocationContext as the sole parameter. This SetHandler overload doesn't let you specify IValueDescriptor<T> objects, but you can get option and argument values from the ParseResult property of InvocationContext, as shown in the following example:

static async Task<int> Main(string[] args)
{
    var delayOption = new Option<int>("--delay");
    var messageOption = new Option<string>("--message");

    var rootCommand = new RootCommand("Parameter binding example");
    rootCommand.Add(delayOption);
    rootCommand.Add(messageOption);

    rootCommand.SetHandler(async (context) =>
        {
            int delayOptionValue = context.ParseResult.GetValueForOption(delayOption);
            string? messageOptionValue = context.ParseResult.GetValueForOption(messageOption);
        
            Console.WriteLine($"--delay = {delayOptionValue}");
            await Task.Delay(delayOptionValue);
            Console.WriteLine($"--message = {messageOptionValue}");
            context.ExitCode = 100;
        });

    return await rootCommand.InvokeAsync(args);
}

If you don't have asynchronous work to do, you can use the Action overloads. In that case, set the exit code by using InvocationContext.ExitCode the same way you would with an async lambda.

The exit code defaults to 1. If you don't set it explicitly, its value is set to 0 when your handler exits normally. If an exception is thrown, it keeps the default value.

Supported types

The following examples show code that binds some commonly used types.

Enums

The values of enum types are bound by name, and the binding is case insensitive:

var colorOption = new Option<ConsoleColor>("--color");

var rootCommand = new RootCommand("Enum binding example");
rootCommand.Add(colorOption);

rootCommand.SetHandler((colorOptionValue) =>
    { Console.WriteLine(colorOptionValue); },
    colorOption);

await rootCommand.InvokeAsync(args);

Here's sample command-line input and resulting output from the preceding example:

myapp --color red
myapp --color RED
Red
Red

Arrays and lists

Many common types that implement IEnumerable are supported. For example:

var itemsOption = new Option<IEnumerable<string>>("--items")
    { AllowMultipleArgumentsPerToken = true };

var command = new RootCommand("IEnumerable binding example");
command.Add(itemsOption);

command.SetHandler((items) =>
    {
        Console.WriteLine(items.GetType());

        foreach (string item in items)
        {
            Console.WriteLine(item);
        }
    },
    itemsOption);

await command.InvokeAsync(args);

Here's sample command-line input and resulting output from the preceding example:

--items one --items two --items three
System.Collections.Generic.List`1[System.String]
one
two
three

Because AllowMultipleArgumentsPerToken is set to true, the following input results in the same output:

--items one two three

File system types

Command-line applications that work with the file system can use the FileSystemInfo, FileInfo, and DirectoryInfo types. The following example shows the use of FileSystemInfo:

var fileOrDirectoryOption = new Option<FileSystemInfo>("--file-or-directory");

var command = new RootCommand();
command.Add(fileOrDirectoryOption);

command.SetHandler((fileSystemInfo) =>
    {
        switch (fileSystemInfo)
        {
            case FileInfo file                    :
                Console.WriteLine($"File name: {file.FullName}");
                break;
            case DirectoryInfo directory:
                Console.WriteLine($"Directory name: {directory.FullName}");
                break;
            default:
                Console.WriteLine("Not a valid file or directory name.");
                break;
        }
    },
    fileOrDirectoryOption);

await command.InvokeAsync(args);

With FileInfo and DirectoryInfo the pattern matching code is not required:

var fileOption = new Option<FileInfo>("--file");

var command = new RootCommand();
command.Add(fileOption);

command.SetHandler((file) =>
    {
        if (file is not null)
        {
            Console.WriteLine($"File name: {file?.FullName}");
        }
        else
        {
            Console.WriteLine("Not a valid file name.");
        }
    },
    fileOption);

await command.InvokeAsync(args);

Other supported types

Many types that have a constructor that takes a single string parameter can be bound in this way. For example, code that would work with FileInfo works with a Uri instead.

var endpointOption = new Option<Uri>("--endpoint");

var command = new RootCommand();
command.Add(endpointOption);

command.SetHandler((uri) =>
    {
        Console.WriteLine($"URL: {uri?.ToString()}");
    },
    endpointOption);

await command.InvokeAsync(args);

Besides the file system types and Uri, the following types are supported:

  • bool
  • byte
  • DateTime
  • DateTimeOffset
  • decimal
  • double
  • float
  • Guid
  • int
  • long
  • sbyte
  • short
  • uint
  • ulong
  • ushort

Use System.CommandLine objects

There's a SetHandler overload that gives you access to the InvocationContext object. That object can then be used to access other System.CommandLine objects. For example, you have access to the following objects:

InvocationContext

For examples, see Set exit codes and Handle termination.

CancellationToken

For information about how to use CancellationToken, see How to handle termination.

IConsole

IConsole makes testing as well as many extensibility scenarios easier than using System.Console. It's available in the InvocationContext.Console property.

ParseResult

The ParseResult object is available in the InvocationContext.ParseResult property. It's a singleton structure that represents the results of parsing the command line input. You can use it to check for the presence of options or arguments on the command line or to get the ParseResult.UnmatchedTokens property. This property contains a list of the tokens that were parsed but didn't match any configured command, option, or argument.

The list of unmatched tokens is useful in commands that behave like wrappers. A wrapper command takes a set of tokens and forwards them to another command or app. The sudo command in Linux is an example. It takes the name of a user to impersonate followed by a command to run. For example:

sudo -u admin apt update

This command line would run the apt update command as the user admin.

To implement a wrapper command like this one, set the command property TreatUnmatchedTokensAsErrors to false. Then the ParseResult.UnmatchedTokens property will contain all of the arguments that don't explicitly belong to the command. In the preceding example, ParseResult.UnmatchedTokens would contain the apt and update tokens. Your command handler could then forward the UnmatchedTokens to a new shell invocation, for example.

Custom validation and binding

To provide custom validation code, call AddValidator on your command, option, or argument, as shown in the following example:

var delayOption = new Option<int>("--delay");
delayOption.AddValidator(result =>
{
    if (result.GetValueForOption(delayOption) < 1)
    {
        result.ErrorMessage = "Must be greater than 0";
    }
});

If you want to parse as well as validate the input, use a ParseArgument<T> delegate, as shown in the following example:

var delayOption = new Option<int>(
      name: "--delay",
      description: "An option whose argument is parsed as an int.",
      isDefault: true,
      parseArgument: result =>
      {
          if (!result.Tokens.Any())
          {
              return 42;
          }

          if (int.TryParse(result.Tokens.Single().Value, out var delay))
          {
              if (delay < 1)
              {
                  result.ErrorMessage = "Must be greater than 0";
              }
              return delay;
          }
          else
          {
              result.ErrorMessage = "Not an int.";
              return 0; // Ignored.
          }
      });

The preceding code sets isDefault to true so that the parseArgument delegate will be called even if the user didn't enter a value for this option.

Here are some examples of what you can do with ParseArgument<T> that you can't do with AddValidator:

  • Parsing of custom types, such as the Person class in the following example:

    public class Person
    {
        public string? FirstName { get; set; }
        public string? LastName { get; set; }
    }
    
    var personOption = new Option<Person?>(
          name: "--person",
          description: "An option whose argument is parsed as a Person",
          parseArgument: result =>
          {
              if (result.Tokens.Count != 2)
              {
                  result.ErrorMessage = "--person requires two arguments";
                  return null;
              }
              return new Person
              {
                  FirstName = result.Tokens.First().Value,
                  LastName = result.Tokens.Last().Value
              };
          })
    {
        Arity = ArgumentArity.OneOrMore,
        AllowMultipleArgumentsPerToken = true
    };
    
  • Parsing of other kinds of input strings (for example, parse "1,2,3" into int[]).

  • Dynamic arity. For example, you have two arguments that are defined as string arrays, and you have to handle a sequence of strings in the command line input. The ArgumentResult.OnlyTake method enables you to dynamically divide up the input strings between the arguments.

See also

System.CommandLine overview