Building cross-platform CLIs on .NET Core
Building CLIs on .NET has just changed thanks to a new experimental project that Microsoft is working on.
The revival of CLIs
Since the first release of Windows, graphical applications became the standard. Arcane command-line tools were pushed away to Linux, while the rest of the world enjoyed pointing and clicking. But recently things changed.
Graphical interfaces are great for abstracting away the complexities of the underlying software, but they share one big problem. They’re hard to make. It’s hard to make the UI user-friendly and any adjustment is costly and time-consuming.
More than ever, time to market matters. And so organizations, even Microsoft, choose to build command-line tools first, learn from their users, improve, and eventually release UI for the most common use-cases. And it turns out, that the professional audience doesn’t mind. They’re okay with using command-line tools if it means that they can get them quicker.
CLIs in .NET
Building console applications in .NET was never hard. All you had to do, was to spin up Visual Studio, create a new project and you were good to go. But you had a long way to go to build a true CLI in a console app.
You see, a CLI is more than just a console app. It often has multiple commands, each with its own arguments and options, some required, some optional. It validates user input and provides them with meaningful error messages. It exposes rich help with examples that illustrate how the CLI works and what’s possible.
Yes, you could do all that in a .NET console application, but you had to do all of it yourself. Before you could start implementing the functionality for your CLI, you had to build the plumbing. Parsing user input, validating it, rendering help. In the end, whatever you’ve built, would work but only on Windows. But this is no longer the case, thanks to DragonFruit.
Easily build cross-platform CLIs on .NET with DragonFruit
DragonFruit is a part of a new experimental set of features meant to simplify building cross-platform CLIs on .NET. It automatically takes care of the plumbing, allowing you to build a proper CLI in minutes. Literally. Take a look.
The basics
To illustrate the point, let’s build a simple CLI with two commands: one that shows a greeting for the specified name and the other that adds two values.
Let’s start with creating a new console app project:
dotnet new console
Next, add reference to the experimental command line packages:
dotnet add package System.CommandLine.Experimental -v 0.3.0-*
dotnet add package System.CommandLine.DragonFruit -v 0.3.0-*
In your code editor, open your newly created project and change its code to the following:
using System;
using System.CommandLine;
using System.CommandLine.Invocation;
namespace netcore_cli
{
class Program
{
static int Main(string[] args)
{
var cmd = new RootCommand();
cmd.AddCommand(greeting());
return cmd.InvokeAsync(args).Result;
}
private static Command greeting() {
var cmd = new Command("greeting", "Shows a greeting");
cmd.Handler = CommandHandler.Create(() => {
Console.WriteLine("Hello world");
});
return cmd;
}
}
}
We use
--
to specify that everything that follows should be passed into our CLI and not to thedotnet
CLI.
If you test it, you should see the following result:
$ dotnet run -- greeting
Hello world
To see the added value of DragonFruit and the experimental command-line features, execute:
dotnet run
Your CLI will show an error saying that you haven’t specified any command along with help listing all available options and commands. All that, while you haven’t programmed a line of code for it!
Accepting user input
In the previous step, we’ve just scratched the surface. Let’s extend our CLI, with the ability to accept user input.
Change the code in your application to the following:
using System;
using System.CommandLine;
using System.CommandLine.Invocation;
namespace netcore_cli
{
class Program
{
static int Main(string[] args)
{
var cmd = new RootCommand();
cmd.AddCommand(greeting());
return cmd.InvokeAsync(args).Result;
}
private static Command greeting() {
var cmd = new Command("greeting", "Greets the specified person");
cmd.AddOption(new Option(new[] { "--name", "-n" }, "Name of the person to greet") {
Argument = new Argument<string> {
Arity = ArgumentArity.ExactlyOne
}
});
cmd.Handler = CommandHandler.Create<string>((name) => {
Console.WriteLine($"Hello {name}");
});
return cmd;
}
}
}
Test it out, by executing:
$ dotnet run -- greeting --name Joe
Hello Joe
We’ve extended our command with an option that allows us to specify a name and can be used either in long format --name
or short -n
(line 18). Additionally, we said, that our option takes a value of type string (line 19), eg. --name Steve
and that it should have exactly one value (line 20). Notice, how DragonFruit automatically maps user input to the arguments of your command handler (line 23)!
Switches
Let’s extend the CLI further with an additional switch:
using System;
using System.CommandLine;
using System.CommandLine.Invocation;
namespace netcore_cli
{
class Program
{
static int Main(string[] args)
{
var cmd = new RootCommand();
cmd.AddCommand(greeting());
return cmd.InvokeAsync(args).Result;
}
private static Command greeting() {
var cmd = new Command("greeting", "Greets the specified person");
cmd.AddOption(new Option(new[] { "--name", "-n" }, "Name of the person to greet") {
Argument = new Argument<string> {
Arity = ArgumentArity.ExactlyOne
}
});
cmd.AddOption(new Option("--polite", "Show a polite greeting"));
cmd.Handler = CommandHandler.Create<string, bool>((name, polite) => {
Console.WriteLine($"{(polite ? "Good day" : "Hello")} {name}");
});
return cmd;
}
}
}
And let’s test it:
$ dotnet run -- greeting -n Joe --polite
Good day Joe
Required options
Let’s see what happens, when we run the CLI without specifying the person’s name:
$ dotnet run -- greeting
Hello
Unsurprisingly, we’re missing the name. But haven’t we specified that it should contain exactly one value with ArgumentArity.ExactlyOne
? Yes and no. What it says exactly, is that the option, when specified, should have exactly one value. But it doesn’t need to be specified. At the moment of writing this article, there is no built-in support for required options, but you can solve it easily, like this:
using System;
using System.CommandLine;
using System.CommandLine.Invocation;
namespace netcore_cli
{
class Program
{
static int Main(string[] args)
{
var cmd = new RootCommand();
cmd.AddCommand(greeting());
return cmd.InvokeAsync(args).Result;
}
private static Command greeting() {
var cmd = new Command("greeting", "Greets the specified person");
cmd.AddOption(new Option(new[] { "--name", "-n" }, "Name of the person to greet") {
Argument = new Argument<string> {
Arity = ArgumentArity.ExactlyOne
}
});
cmd.AddOption(new Option("--polite", "Show a polite greeting"));
cmd.Handler = CommandHandler.Create<string, bool>((name, polite) => {
if (String.IsNullOrEmpty(name)) {
Console.WriteLine("Required option name missing");
return 1;
}
Console.WriteLine($"{(polite ? "Good day" : "Hello")} {name}");
return 0;
});
return cmd;
}
}
}
We check if the option has a value in the command’s handler and show a meaningful error if it doesn’t. To properly support using the CLI in scripts, we indicate a failure with a non-zero return value.
Rich formatting
Before we finish, let’s take a look at how the experimental features that Microsoft is working help you focus on building the functionality instead of plumbing.
Often, CLIs are used to list multiple values, like a list of running processes, active users, etc. For each returned object, you might want to print several properties like ID, display name, etc. Here is how you can do this with the experimental command-line features.
Start, by adding a reference to the Rendering package:
dotnet add package System.CommandLine.Rendering -v 0.3.0-*
Then, let’s change the CLIs code to the following:
using System;
using System.CommandLine;
using System.CommandLine.Invocation;
using System.CommandLine.Rendering;
using System.CommandLine.Rendering.Views;
namespace netcore_cli {
class Program {
private static InvocationContext invocationContext;
private static ConsoleRenderer consoleRenderer;
static int Main(InvocationContext invocationContext, string[] args) {
Program.invocationContext = invocationContext;
consoleRenderer = new ConsoleRenderer(
invocationContext.Console,
mode: invocationContext.BindingContext.OutputMode(),
resetAfterRender: true);
var cmd = new RootCommand();
cmd.AddCommand(list());
return cmd.InvokeAsync(args).Result;
}
private static Command list() {
var cmd = new Command("list");
cmd.Handler = CommandHandler.Create(() => {
var users = new User[] {
new User {
ID = "1",
Name = "Joe Doe",
Email = "joe@doe.com"
},
new User {
ID = "2",
Name = "Jane Doe",
Email = "jane@doe.com"
}
};
var table = new TableView<User> {
Items = users
};
table.AddColumn(user => user.ID, "ID");
table.AddColumn(user => user.Name, "Name");
var screen = new ScreenView(consoleRenderer, invocationContext.Console) { Child = table };
screen.Render();
});
return cmd;
}
}
class User {
public string ID { get; set; }
public string Name { get; set; }
public string Email { get; set; }
}
}
Let’s test it:
$ dotnet run -- list
ID Name
1 Joe Doe
2 Jane Doe
Notice, how the CLI’s infrastructure automatically took care of formatting the data as a table, aligning the different columns for you!
All you had to do for this, was to add a reference to the Rendering package, instantiate the ConsoleRenderer
(lines 9-17, notice the extended Main
that accepts the InvocationContext
passed in by the CLI’s infrastructure), define the table’s structure (lines 40, 43, 44) with its data (line 41) and tell the CLI to print it out (lines 46, 47).
Summary
DragonFruit and its related packages are still in an experimental phase, but already they illustrate how easily you could be building cross-platform CLIs on .NET focusing on the functionality and not the plumbing. DragonFruit is not yet perfect. It misses some key capabilities like defining required options or validating arguments. Still, already it’s a huge improvement comparing to building CLIs from scratch. Give it a try, and I’m looking forward to hearing what you think of it.