moscardino.net

Making a CLI with .NET Core

There’s a thing I always want to do, but the set up is kind of tricky (and not very clear). I want to build a CLI using .NET Core.

.NET has it’s own CLI, as so many other developer tools. Command line tools are awesome and .NET core has a way to create them, though it’s a bit obscure. So I’m going to try to create a simple guide to building CLIs using .NET Core.

.NET Core 3.1 is the latest version at the time of this writing, though I imagine this guide will continue working for a while.

Getting Started

Let’s make a sample project to demonstrate what we’re doing.

> mkdir sample-cli
> cd sample-cli
> dotnet new console

The Project File

Step 1 is to update our csproj file. Here’s the updated file:

sample-cli.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp3.1</TargetFramework>
<RootNamespace>SampleCLI</RootNamespace>
<PackAsTool>true</PackAsTool>
<ToolCommandName>sample-cli</ToolCommandName>
<PackageOutputPath>./dist</PackageOutputPath>
<Version>0.1.0</Version>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="McMaster.Extensions.Hosting.CommandLine" Version="2.4.4" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="3.1.0" />
</ItemGroup>
</Project>

So what changed?

  • The RootNamespace changed from sample-cli to SampleCLI. This is simply naming convention preferences.
  • We added PackAsTool, ToolCommandName, PackageOutputPath, and Version.
    • PackAsTool allows us to generate a package that we can install using dotnet tool.
    • ToolCommandName is what we will use to invoke our CLI from a command line
    • PackageOutputPath is where the output package will be written when we run the package command.
    • Version is helpful and is used later.
  • We also added some packages. I like the CommandLineUtils library, and we will use Microsoft.Extensions.Hosting to set up the generic host and DI.
    • CommandLineUtils will do the hard work of parsing arguments and options from the command to our code.

The Main Command

We’ll need a new file now, let’s call it MainCommand.cs. It will be a simple command to output whatever text is passed in, with an option to convert the text to upper case.

MainCommand.cs
using System;
using System.Reflection;
using McMaster.Extensions.CommandLineUtils;

namespace SampleCLI
{
[Command(Name = "sample-cli",
FullName = "Sample CLI",
Description = "A sample CLI tool.")]
[VersionOptionFromMember(MemberName = "GetVersion")]
[HelpOption]
public class MainCommand
{
[Argument(0, "Text", "Some text to ouput")]
public string Text { get; set; }

[Option("-u|--upper", "Convert the text to capitals.", CommandOptionType.NoValue)]
public bool UpperCase { get; set; }

public void OnExecute(CommandLineApplication app)
{
if (string.IsNullOrWhiteSpace(Text))
{
app.ShowHelp();
return;
}

if (UpperCase)
Console.WriteLine(Text.ToUpper());
else
Console.WriteLine(Text);
}

private string GetVersion()
{
return typeof(MainCommand)
.Assembly?
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
.InformationalVersion;
}
}
}

There’s a lot there. Let’s break it down. First the attributes:

  • The Command attribute is how we know this is a command that can be invoked, either directly or as a subcommand on another command.
    • The Name is important, but the Full Name and Description are only used for the help text.
    • I may do another post on subcommands and more advanced usage of this library. It’s pretty deep and lots of fun.
  • The VersionOptionFromMember attribute allows us to call a method to get the version. The method pulls from the Version element of the csproj.
  • The HelpOption attribute generates help text from the Command and any options or arguments you define. It also adds options (-h, , -?, and --help) to show the help text.

Now the properties:

  • Text is our main argument. Arguments are indexed, hence the 0 being passed to the attribute. We also give it a name and a description.
  • UpperCase is our option. Options can take several forms. This is a Boolean option, so we use the CommandOptionType.NoValue type which tells the library that the option is either provided or not. We also define that flag that (-u or --upper in this case), and a description.
    • Options can accept values. For that, you would want to make your property a string (probably) and change your template to something like -i|--input <TEXT> and use CommandOptionType.SingleValue. There are other types, too, but this is a simple guide.

And finally the methods:

  • GetVersion is used by the VersionOptionFromMember attribute to read the version from the csproj file.
  • OnExecute is what is called when our command is executed. This is where we do the work, or call whatever will do the work for us.
    • You can use DI on this method. That’s where the CommandLineApplication parameter comes from.
    • There is also an async version with the signature async Task OnExecuteAsync if you need that.

The Program File

Alright, we have a command, let’s wire it up to fire as the entry point to our CLI. Here’s what the Program.cs file looks like:

Program.cs
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;

namespace SampleCLI
{
public class Program
{
public static async Task Main(string[] args)
{
await Host
.CreateDefaultBuilder()
.ConfigureServices((context, services) =>
{
// Add DI services here!
})
.RunCommandLineApplicationAsync<MainCommand>(args);
}
}
}

This class uses the Generic Host added in .NET Core 3. It also allows us to set up DI services, and then sets our MainCommand as the entry point for the command line application.

Why the Generic Host? Dependency injection! ASP.NET Core has built in DI, and Generic Host takes that goodness and removes the ASP.NET part so it can be used outside the context of a web application.

From there, the CommandLineUtils library handles parsing the args array and mapping it to our command and it’s arguments and options.

Installing it

Installing is done using the .NET CLI. There are 3 commands needed:

First, build the project:

> dotnet build

Then pack the project. This will output a file to /dist which we will then use to install it. It can also be pushed to nuget.org to distribute it widely.

> dotnet pack

Finally, install the package as a global tool:

> dotnet tool install -g --add-source ./dist sample-cli

Uninstalling it

While developing, you will need to uninstall the package before re-installing it.

> dotnet tool uninstall -g sample-cli

Using it

Invoke it using the ToolCommandName we set in the project file. It can be invoked from any directory.

> sample-cli
Sample CLI 0.1.0

A sample CLI tool.

Usage: sample-cli [options] <Text>

Arguments:
Text Some text to ouput

Options:
--version Show version information
-?|-h|--help Show help information
-u|--upper Convert the text to capitals.
> sample-cli "My text" -u
MY TEXT

Conclusions

Why do this? Well, there are two main advantages I see.

First, you can create a command line application without the CommandLineUtils library. You can do all the parsing of options and arguments manually. But that’s annoying. The CommandLineUtils library takes the pain and complexity out of parsing what comes into your CLI. It’s a wonderful tool and is very flexible.

Second, packaging and installing your CLI as a global tool makes calling it easy. You don’t need to be in the right folder and you don’t need to use dotnet run. Your tool is available everywhere and is easy to invoke. I love using it for file processing things since I cad cd to a folder and run a tool on the files within.

I’ve made a couple of CLIs now using this method. It’s easy, flexible, and very powerful. Plus you get all the benefits of using .NET Core like types and cross-platform goodness. Try it out and see for yourself.