System.CommandLine as a Starting Point

Writing console apps from scratch can involve a lot of messing around with boilerplate code just to get basic user input and take advantage of basic features like logging and configuration that come more or less for free in an AspNetCore web app. Adding in features normally found in command line apps like parsing input parameters commands and options or displaying help text to guide users adds on even more work before getting to the app’s core functionality. The System.CommandLine library can help with smoothing out a lot of these basic functions and with a little more work can help create a well-structured app that will be easy to navigate and maintain.

After creating a new Console app I want to add references to two NuGet packages, both of which are in preview state as of this writing but are perfectly usable: System.CommandLine and System.CommandLine.Hosting. Adding on the Hosting package makes it a lot easier to build something that feels more like a web app.

The initial setup is just adding a few lines of plumbing code in the app’s Main method. The RootCommand will be the starting point for expanding to add functionality to the app.

var rootCommand = new RootCommand("My Helper App");

var cmdlineBuilder = new CommandLineBuilder(rootCommand);

var parser = cmdlineBuilder
    .UseHost(_ => Host.CreateDefaultBuilder(args))
    .UseDefaults().Build();

return await parser.InvokeAsync(args);

At this point the app doesn’t do anything yet but we can see the Host start up and run to completion.

Hosted app output

We’re also already getting an assist from the main System.CommandLine library to add default help text behind the -h, -?, or --help options.

Default help text

Commands and Handlers

The app is up and running but I need to start adding some real code now to make it do something useful. To add functionality I need to execute some code in response to Commands that the app can invoke. So far I have a single RootCommand, but haven’t put any code behind it to execute.

For now instead of running code from root itself, I’m going to start adding explicit commands to do different functions. To do this I need to create an implementation of the Command class that describes the command and an associated implementation of the ICommandHandler interface which will actually perform the action. Here I’m going to use a nested class to make it easy to keep them together in the definition and also clearly associated when they’re referenced elsewhere. The handler interface includes both async and non-async methods which will be used depending on how the Parser is initially invoked, but most of the time these should probably just be set up to run the same code, either by calling each other or another method common to both.

public class TimeCommand : Command
{
    public TimeCommand()
        : base("time", "Outputs the current time")
    {
    }

    public class CommandHandler : ICommandHandler
    {
        public int Invoke(InvocationContext context)
        {
            Console.WriteLine($"{DateTime.UtcNow:T}");

            return 0;
        }

        public Task<int> InvokeAsync(InvocationContext context)
        {
            return Task.FromResult(Invoke(context));
        }
    }
}

This command then needs to be registered at startup in order to be usable. First it is added as a child of the RootCommand:

rootCommand.AddCommand(new TimeCommand());

And then the handler needs to be associated to the command type, in this case using a shortcut provided by an extension on the IHostBuilder.

var parser = cmdlineBuilder
    .UseHost(_ => Host.CreateDefaultBuilder(args),
        builder =>
        {
            builder.UseCommandHandler<TimeCommand, TimeCommand.CommandHandler>();
        })
    .UseDefaults().Build();

Now I can run the app using .\helper time to execute my new command, and it has also been added into the help for me without any extra work!

Injecting Services

My current handler implementation isn’t doing much but I can start making it more interesting by starting to use some dependency injection to get some services. This is where the architecture I’ve chosen really starts to become an advantage, because my CommandHandler class is already being created by the Host’s DI, so all I need to do is add a constructor parameter. Because of the default setup of the host I also even have some services set up for free already, like ILogger and IConfiguration.

private readonly ILogger<CommandHandler> _logger;

public CommandHandler(ILogger<CommandHandler> logger)
{
    _logger = logger;
}

To add more services that aren’t built in I need to go back to my startup code and make it look a little more like AspNetCore. I can do this inline, or add a separate named method:

var parser = cmdlineBuilder
    .UseHost(_ => Host.CreateDefaultBuilder(args),
        builder =>
        {
            builder
                .ConfigureServices(ConfigureServices)
                .UseCommandHandler<TimeCommand, TimeCommand.CommandHandler>();
        })
    .UseDefaults().Build();

And then add a service:

private static void ConfigureServices(IServiceCollection services)
{
    services.AddTransient<ISystemClock, SystemClock>();
}

And now those services can be used in the handler:

private readonly ISystemClock _systemClock;
private readonly ILogger<CommandHandler> _logger;

public CommandHandler(ISystemClock systemClock, ILogger<CommandHandler> logger)
{
    _systemClock = systemClock;
    _logger = logger;
}

public int Invoke(InvocationContext context)
{
    DateTimeOffset currentGmt = _systemClock.UtcNow;
    _logger.LogInformation("Called at {Time}", currentGmt);

    Console.WriteLine($"{currentGmt:T}");

    return 0;
}

At this point I might also want to change the logging settings to not show the default output from the Host. Again I can take advantage of the default setup and by just adding a new appsettings.json file (set as Content:Copy if newer) with appropriate config values everything gets automatically wired up.

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.Hosting": "Warning"
    }
  }
}

And now the output shows just the output from the handler code.

Time command output

Command Options

So far my basic command is doing a lot of interesting things internally but isn’t very interactive. To enable the command to be more flexible I can start adding options on top of the base command and then use values entered by the user in my handler code. I want to add an option to switch time zones so first I’ll define that in my TimeCommand class.

public TimeCommand()
    : base("time", "Outputs the current time")
{
    AddOptions(this);
}

public static void AddOptions(Command command)
{
    var timezoneOption = new Option<double?>(
        aliases: new[] { "--timezone", "-z" },
        description: "The positive or negative GMT offset");

    command.AddOption(timezoneOption);
}

Notice here that I defined this as a double type which will give me automatic type enforcement as the user input is parsed. I also created two aliases so that either --timezone or -z can be used for this option. Everything that’s entered here will also now be reflected in the help for this command, with no other code changes needed.

Time command help

I haven’t done anything with this input value yet so I need to go back into the CommandHandler. Everything that is parsed from the user input is available on the InvocationContext parameter provided to the Invoke methods and that could be used to get option values using context.ParseResult.GetValueForOption<double?>(OptionObject), but that option object isn’t available to used as a parameter here with the code I set up above that only declares it as a local variable. It wouldn’t be a big deal to change that code but this whole step can be skipped by taking advantage of some binding magic that is built into the command system.

public double? TimeZone { get; set; }

Adding this property onto the handler class automatically binds it to the value of the option with a matching name, --timezone in this case, and here again gets automatic type enforcement. I can now just use this property in the code:

Console.WriteLine($"{currentGmt.ToOffset(TimeSpan.FromHours(TimeZone ?? 0)):T}");

And see my new output for my GMT-8:00 timezone:

Time command with timezone option

I can keep adding more commands and options as needed following the same pattern. By having everything nicely structured I don’t need to worry about all the parsing and branching logic that normally dominates the code of a console application. As someone who is used to writing .NET web apps this is also a much more comfortable way to work, with the command handler code where the real work is happening looking more like a controller endpoint or component event handler.

Code Versions

Example code is using .NET 6.0 with these library versions:

  • System.CommandLine 2.0.0-beta4.22272.1
  • System.CommandLine.Hosting 0.4.0-alpha.22272.1