Console applications are alive and kicking. Setting them up might be a bit hard. In this article I'll explore how to create a .NET console application that provides commands using the new System.CommandLine
package. This will provide arguments to command mapping out of the box. I'll be showing how to combine it with dependency injection for even more power ⚡.
- Goals
- NuGet Packages
- Project structure
- Fake weather service
- Commands
- Dependency injection
- Final thoughts
Goals
We want to create a CLI application with the following goals:
-
System.CommandLine — this is a fairly new project by .NET that helps to create better CLI applications. It offers the ability to add commands, arguments and options to your application. It comes with a
--help
feature and it will do the command line argument mapping for you. - Dependency Injection — why go anywhere without it? Dependency injection has made ASP.NET way more composable. I wrote an entire article on how to add it to console applications as well. We'll be reusing some of the code.
- Environment variable injection support — some of the configuration should be overridable using environment variables.
We're making a CLI, so what's a better way to describe it than showing what the --help
should look like?
Description:
Weather information using a fake weather service.
Usage:
MyCli [command] [options]
Options:
--version Show version information
-?, -h, --help Show help and usage information
Commands:
current Gets the current temperature.
forecast Get the forecast. Almost always wrong.
Note: if you want to use command line argument when executing a dotnet run
, you can use --
to feed the arguments to the application instead of the .NET CLI (so dotnet run -- --help
in this case).
NuGet Packages
If you say .NET, you say NuGet packages. We'll be using the following packages:
Install-Package System.CommandLine -Version 2.0.0-beta4.22272.1
Install-Package Microsoft.Extensions.Configuration -Version 7.0.0
Install-Package Microsoft.Extensions.Configuration.EnvironmentVariables -Version 7.0.0
Install-Package Microsoft.Extensions.DependencyInjection -Version 7.0.0
Install-Package Microsoft.Extensions.DependencyInjection.Abstractions -Version 7.0.0
Install-Package Microsoft.Extensions.Options -Version 7.0.1
Install-Package Microsoft.Extensions.Options.ConfigurationExtensions -Version 7.0.0
The System.CommandLine
package is still in beta preview. I expect it to be released soon, but things might still change. It is used by the .NET dotnet
CLI.
Project structure
I'm using the following project structure:
.
├── src/
│ └── MyCli/
│ ├── Commands/
│ │ ├── CurrentCommand.cs
│ │ └── ForcastCommand.cs
│ ├── Services/
│ │ ├── FakeWeatherService.cs
│ │ └── FakeWeatherServiceSettings.cs
│ └── Program.cs
└── MyCli.sln
Fake weather service
What is injection without a good service? Let's create a fake weather service that returns the temperature based on a randomizer:
namespace MyCli.Services;
public class FakeWeatherServiceSettings
{
public string DefaultCity { get; set; } = "Zwolle, NLD";
public int DefaultForecastDays { get; set; } = 5;
}
public class FakeWeatherService
{
public FakeWeatherService(IOptions<FakeWeatherServiceSettings> settings)
{
Settings = settings?.Value ?? throw new ArgumentNullException(nameof(settings));
}
public FakeWeatherServiceSettings Settings { get; }
public Task<string> GetTemperature(string? city = null)
{
if (city == null) city = Settings.DefaultCity;
var report = $"In {city} it is now {Random.Shared.Next(-20, 40)} degrees celcius.";
return Task.FromResult(report);
}
public Task<string[]> Forecast(int days, string? city = null)
{
if (city == null) city = Settings.DefaultCity;
var reports = new List<string>
{
$"Report for {city} for the next {days} days:"
};
for (var i = 0; i<days; i++)
{
var date = DateTime.Now.AddDays(i + 1).ToString("yyyy-MM-dd");
var report = $"- {date}: {Random.Shared.Next(-20, 40),3} degrees celcius.";
reports.Add(report);
}
return Task.FromResult(reports.ToArray());
}
}
Commands
Commands are implementations of the System.CommandLine.Command
class. To make them injectable, we create classes that are derived from the Command
class (see dependency injection section).
Current Temperature Command
To get our current temperature command, we'll need to do the following:
- Call the base constructor with the name and description of the command. This will be used by the
--help
feature. - Inject the
FakeWeatherService
, as it does the actual work. - Use the
FakeWeatherService.Settings
to get the default value for the--city
option. - Map it all together using a
SetHandler
. The option in automatically mapped to thecity
parameter of theExecute
method.
Now the implementation is very easy:
using MyCli.Services;
using System.CommandLine;
namespace MyCli.Commands;
class CurrentCommand : Command
{
private readonly FakeWeatherService _weather;
public CurrentCommand(FakeWeatherService weather) : base("current", "Gets the current temperature.")
{
_weather = weather ?? throw new ArgumentNullException(nameof(weather));
var cityOption = new Option<string>("--city", () => _weather.Settings.DefaultCity, "The city.");
AddOption(cityOption);
this.SetHandler(Execute, cityOption);
}
private async Task Execute(string city)
{
var report = await _weather.GetTemperature(city);
Console.WriteLine(report);
}
}
What I like about the setup is that we can add optional arguments with defaults. Here we get the default value from an object from our dependency injection. When we do a current --help
, we can a nice description and the actual injected value:
Description:
Gets the current temperature.
Usage:
MyCli current [options]
Options:
--city <city> The city. [default: Amsterdam, NLD]
-?, -h, --help Show help and usage information
Forecast Command
The same goes for the forecast command, but now we have 2 options: --city
and --days
.
using Microsoft.Extensions.Options;
using MyCli.Services;
using System.CommandLine;
namespace MyCli.Commands;
class ForecastCommand : Command
{
private readonly FakeWeatherService _weather;
public ForecastCommand(FakeWeatherService weather) : base("forecast", "Get the forecast. Almost always wrong.")
{
_weather = weather ?? throw new ArgumentNullException(nameof(weather));
var cityOption = new Option<string>("--city", ()=> _weather.Settings.DefaultCity, "The city.");
var daysOption = new Option<int>("--days", () => _weather.Settings.DefaultForecastDays, "Number of days.");
AddOption(cityOption);
AddOption(daysOption);
this.SetHandler(Execute, cityOption, daysOption);
}
private async Task Execute(string city, int days)
{
var report = await _weather.Forecast(days, city);
foreach (var item in report)
{
Console.WriteLine(item);
}
}
}
Dependency injection
Now, let's tie it all together using dependency injection. We need to do the following:
- Setup a
ServiceCollection
to store our dependencies. - Setup the configuration to use environment variables and read them into our
WeatherServiceSettings
object. - Add the commands
CurrentCommand
andForecastCommand
to the service collection. - Add the
WeatherService
to the service collection. - Create a
System.CommandLine.RootCommand
and tie it to the registeredCommand
implementation. - Invoke the root command with the given command line arguments.
This leads to the followingProgram.cs
code:
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using MyCli.Commands;
using MyCli.Services;
using System.CommandLine;
static void ConfigureServices(IServiceCollection services)
{
// build config
var configuration = new ConfigurationBuilder()
.AddEnvironmentVariables()
.Build();
// settings
services.Configure<FakeWeatherServiceSettings>(configuration.GetSection("Weather"));
// add commands:
services.AddTransient<Command, CurrentCommand>();
services.AddTransient<Command, ForecastCommand>();
// add services:
services.AddTransient<FakeWeatherService>();
}
// create service collection
var services = new ServiceCollection();
ConfigureServices(services);
// create service provider
using var serviceProvider = services.BuildServiceProvider();
// entry to run app
var commands = serviceProvider.GetServices<Command>();
var rootCommand = new RootCommand("Weather information using a fake weather service.");
commands.ToList().ForEach(command => rootCommand.AddCommand(command));
await rootCommand.InvokeAsync(args);
To make dependency injection work, we do a GetServices
to retrieve all the commands and add them to the root command.
Final thoughts
And that's all: now you have a CLI that supports commands and a --help
feature out of the box!
I've added the code to GitHub, so check it out: github.com/KeesCBakker/dotnet-cli-di-poc
Top comments (5)
Really good blog post. It gave me some ideas to use for some automation tasks. Thanks!
I think it is still preview isn'it ? What I do not like though is the options that disappear with multiple commands. So I end up specifying them in the description.
And stuff changes over time. Looks like they're using it for their own
dotnet
CLI: github.com/dotnet/sdk/blob/main/sr...Do you usually use several commands per project? How do you manage the option that are not global?
I think the missing options on multi command is the only missing part on my side.
Ah, if you want that, you might want to consider to inject only the RootCommand, and implement it like this: learn.microsoft.com/en-us/dotnet/s...