Create a colored CLI with System.CommandLine and Spectre 🎨

No matter how fancy desktop, web, and mobile applications get, we will always need command-line interfaces (CLI). We already blogged about how you can build a CLI here: Building a command-line tool with progress bar in .NET Core. Since writing that post we have introduced the elmah.io CLI where we use a different range of NuGet packages that I want to introduce you to in this post.

Packages that help users develop CLIs in C# have been available for years. I have used most of them like CommandlineUtils, GoCommando, and CommandLineParser. All great options for sure. When developing the dotnet.exe command, Microsoft decided to build a new command line parser, rather than using the previous CommandlineUtils. I'm not sure why, but the output is awesome. Say hello to System.CommandLine. System.CommandLine makes it easy to develop a CLI with multiple commands, accepting arguments, show help, etc.

For this post, I will develop a new tool using System.CommandLine named FART - File Association and Rename Tool. I will include two commands to showcase how to include multiple commands known from the dotnet tool. I also want to add some nice visual stuff using the awesome Spectre.Console package. More about the visuals later in this post.

Let's start by creating a new .NET (Core or 5) Console Application:

Next, install the System.CommandLine NuGet package:

As shown with the --prerelease parameter, the System.CommandLine package is still in prerelease. Being the engine behind the dotnet command, we are probably still pretty safe.

System.CommandLine works with the help of one or more commands. To start we want to add a root command. You can add behavior directly to a root command but in this case, we only want to include the root command to add nested commands as well as to show help:

public class Program
{
    public static async Task<int> Main(string[] args)
    {
        var rootCommand = new RootCommand();
        rootCommand.Name = "FART";
        rootCommand.Description = "File Association and Rename Tool";
        return await rootCommand.InvokeAsync(args);
    }
}

In the code, I simply add a new RootCommand, give it a name and description, and execute it using the args provided from the command line using the InvokeAsync method. That's it for our very first and simple command. Run it using --help to see the usage automatically generated by System.CommandLine:

As seen in the usage, we still don't accept any commands and/or arguments. Let's add the first command called rename:

var renameCommand = new Command("rename")
{
    new Option<string>("--input", description: "The input file name")
    {
        IsRequired = true
    },
    new Option<string>("--output", description: "The output file name")
    {
        IsRequired = true
    },
};
renameCommand.Description = "Rename a file";
renameCommand.Handler = CommandHandler.Create<string, string>((input, output) =>
{
    File.Move(input, output);
});

rootCommand.Add(renameCommand);

I start by creating the new command and handing it the rename command through the constructor. The command also accepts one or more options, specified using the Option class. Using generics, you specify the accepted input on each option. In this example, we accept two strings --input and --output. We also specify that both options are required.

Next, the command is given a description that is shown on the help screen as well as a command handler to execute. System.CommandLine only executes the code inside the lambda once the user has successfully provided the options set up in the previous step. For the handler code, I simply rename input to output using the File.Move method.

Finally, I add the new renameCommand as a nested command on rootCommand that we created in a previous step. Let's run the tool and see what happens:

Check out the Commands section at the bottom that now includes our new rename command. Running the new command is easy:

As we would expect, System.CommandLine complains about the missing required options --input and --output.

Adding a second command shouldn't be hard now:

var associateCommand = new Command("associate")
{
    new Option<string>("--extension", description: "The file extension to associate")
    {
        IsRequired = true
    },
    new Option<string>("--program", description: "The absolute path to a program")
    {
        IsRequired = true
    },
};
associateCommand.Description = "Associate a file extension to a program";
associateCommand.Handler = CommandHandler.Create<string, string>((extension, program) =>
{
    // TODO
});

rootCommand.Add(associateCommand);

This is the code to include the associate command. I have left the implementation out for now, since that isn't important for this post. Running the tool now shows two commands:

As you should have seen by now, adding commands, accepting options, parsing them, and including the code to run on each command is very easy.

Let's include some colors using the Spectre.Console NuGet package:

Spectre.Console opens up a world of nice features on CLIs. You can output colored text, generate tables, graphs, calendar widgets, and much more. For this post, I'll showcase two features but check out Spectre.Console for a lot of good stuff.

Ever seen those nice ASCII logos on top of CLIs like when executing tools like the Azure CLI? Spectre makes it easy to generate something similar to that using what's called Figlet text. Include the following code before calling the InvokeAsync method:

AnsiConsole.Render(
    new FigletText("FART")
    .Color(new Color(89, 48, 1)));

The code writes the text FART to the console and yes, I had to google the RGB color to get exactly right 😁

Nice right? Let's add a bit more implementation to the rename command and color a message depending on the outcome:

renameCommand.Handler = CommandHandler.Create<string, string>((input, output) =>
{
    try
    {
        File.Move(input, output);
        AnsiConsole.MarkupLine("[green]Success![/]");
    }
    catch
    {
        AnsiConsole.MarkupLine("[red]An error happened[/]");
    }
});

In case the Move method succeeded we output a green message using the AnsiConsole.MarkupLine method and a bit of magic markup within the string. In case of an exception, we output a red error message.

Doing control flow using exceptions like this is considered a bad practice but let's keep it simple for now. For more information, check out C# exception handling best practices.

Here's an example of the newly colored error message when executing the rename command with a non-existing file:

Here's the full source code:

public class Program
{
    public static async Task<int> Main(string[] args)
    {
        var rootCommand = new RootCommand();
        rootCommand.Name = "FART";
        rootCommand.Description = "File Association and Rename Tool";

        var renameCommand = new Command("rename")
        {
            new Option<string>("--input", description: "The input file name")
            {
                IsRequired = true
            },
            new Option<string>("--output", description: "The output file name")
            {
                IsRequired = true
            },
        };
        renameCommand.Description = "Rename a file";
        renameCommand.Handler = CommandHandler.Create<string, string>((input, output) =>
        {
            try
            {
                File.Move(input, output);
                AnsiConsole.MarkupLine("[green]Success![/]");
            }
            catch
            {
                AnsiConsole.MarkupLine("[red]An error happened[/]");
            }
        });

        rootCommand.Add(renameCommand);

        var associateCommand = new Command("associate")
        {
            new Option<string>("--extension", description: "The file extension to associate")
            {
                IsRequired = true
            },
            new Option<string>("--program", description: "The absolute path to a program")
            {
                IsRequired = true
            },
        };
        associateCommand.Description = "Associate a file extension to a program";
        associateCommand.Handler = CommandHandler.Create<string, string>((extension, program) =>
        {
            // TODO
        });

        rootCommand.Add(associateCommand);

        AnsiConsole.Render(
            new FigletText("FART")
            .Color(new Color(89, 48, 1)));

        return await rootCommand.InvokeAsync(args);
    }
}