Making a CLI with .NET Core
There鈥檚 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鈥檚 own CLI, as so many other developer tools. Command line tools are awesome and .NET core has a way to create them, though it鈥檚 a bit obscure. So I鈥檓 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鈥檚 make a sample project to demonstrate what we鈥檙e doing.
> mkdir sample-cli
> cd sample-cli
> dotnet new console
The Project File
Step 1 is to update our csproj
file. Here鈥檚 the updated file:
<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 fromsample-cli
toSampleCLI
. This is simply naming convention preferences. - We added
PackAsTool
,ToolCommandName
,PackageOutputPath
, andVersion
.PackAsTool
allows us to generate a package that we can install usingdotnet tool
.ToolCommandName
is what we will use to invoke our CLI from a command linePackageOutputPath
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鈥檒l need a new file now, let鈥檚 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.
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鈥檚 a lot there. Let鈥檚 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鈥檚 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 thecsproj
. - The
HelpOption
attribute generates help text from theCommand
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 theCommandOptionType.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 useCommandOptionType.SingleValue
. There are other types, too, but this is a simple guide.
- Options can accept values. For that, you would want to make your property a string (probably) and change your template to something like
And finally the methods:
GetVersion
is used by theVersionOptionFromMember
attribute to read the version from thecsproj
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鈥檚 where the
CommandLineApplication
parameter comes from. - There is also an
async
version with the signatureasync Task OnExecuteAsync
if you need that.
- You can use DI on this method. That鈥檚 where the
The Program File
Alright, we have a command, let鈥檚 wire it up to fire as the entry point to our CLI. Here鈥檚 what the Program.cs
file looks like:
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鈥檚 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鈥檚 annoying. The CommandLineUtils library takes the pain and complexity out of parsing what comes into your CLI. It鈥檚 a wonderful tool and is very flexible.
Second, packaging and installing your CLI as a global tool makes calling it easy. You don鈥檛 need to be in the right folder and you don鈥檛 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鈥檝e made a couple of CLIs now using this method. It鈥檚 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.