System.CommandLine is a library provided by Microsoft which eases out the functionality needed by CLI. It provides a way to parse command-line input and display help text. It will also ensure that inputs are parsed in accordance with POSIX or Windows conventions.

🧑‍💻 Let’s build a Greeting CLI

We’ll be building a very minimalistic greeting application which will get the name as a user input and write greeting text on the console. We’ll be using .NET 6 and VS Code to create Console Application.

dotnet new console -o Greeting.Cli --framework net6.0
cd .\Greeting.Cli\
code .

Now, we have a .NET 6 console project opened in VS Code. Project only contains Greeting.Cli.csproj and Program.cs files. We’ll add a Greeting method in Program.cs

static  void  Greeting(string  name) =>  Console.WriteLine($"Hello, {name}!");

We have a method which will take the name parameter and print a Hello {name}! on the console.

If we do dotnet run then we won’t see any output on the console as we have not called it from anywhere in code. Let’s add the sweetness of System.CommandLine to our CLI.

dotnet add package System.CommandLine --prerelease

This command will add the System.CommandLine package to the Greeting.Cli project, currently System.CommandLine is in beta so the --prerelease flag will be needed. Now we need to update our Program.cs file to include the name option and root command.

using  System.CommandLine;
static  void  Greeting(string  name) =>  Console.WriteLine($"Hello, {name}!");

var  nameOption  =  new  Option<string>(
	name: "--name",
	description: "The person name to greet."); 

var  rootCommand  =  new  RootCommand("Greeting CLI!");
rootCommand.AddOption(nameOption);
rootCommand.SetHandler((name) =>  Greeting(name), nameOption);
return  await  rootCommand.InvokeAsync(args);

If we again try to run our application with dotnet run we will see the Hello, ! in console output because we haven’t passed name parameter value from command-line, let’s pass --name value while running an app.

dotnet run -- --name Ashwini

-- before --name is just to let dotnet cli to pass anything after -- to console applications.

Here RootCommand represents the main action that the application performs, .AddOption will add --name option to rootCommand. In .SetHandler we can write our business logic. InvokeAsync will Parse and Invoke a command.

➡️ Add Short-form aliases and Required Option

Option supports multiple alias names, to add alias .AddAlias("-n") method will be used.

nameOption.AddAlias("-n");

Now, we can use -n instead of --name to provide name value.

PS D:\Greeting.Cli> dotnet run --name Ashwini
Hello, Ashwini!

PS D:\Greeting.Cli> dotnet run -n Ashwini
Hello, Ashwini!

and we will see expected output in console, previously we have seen if we do dotnet run then it will print Hello, ! this is because --name is an optional parameter, to make it required we can set nameOption.IsRequired = true;.

var  nameOption  =  new  Option<string>(
	name: "--name",
	description: "The person name to greet.");

nameOption.AddAlias("-n");
nameOption.IsRequired  =  true;
# Before setting --name as Required
PS D:\Greeting.Cli> dotnet run
Hello, !

# After setting --name as Required
PS D:\Greeting.Cli> dotnet run
Option '--name' is required.

Description:
  Greeting CLI!

Usage:
  Greeting.Cli [options]

Options:
  -n, --name <name> (REQUIRED)  The person's name to greet.
  --version                     Show version information
  -?, -h, --help                Show help and usage information

📦 Build and Package our CLI

Till this point we are running our application with dotnet run, but we can use the executable created while building our application.

PS D:\Greeting.Cli\> cd .\bin\Debug\net6.0\
PS D:\Greeting.Cli\bin\Debug\net6.0> .\Greeting.Cli.exe --help
Description:
  Greeting CLI!

Usage:
  Greeting.Cli [options]

Options:
  -n, -na, --name <name> (REQUIRED)  The person's name to greet.
  --version                          Show version information
  -?, -h, --help                     Show help and usage information

PS D:\Greeting.Cli\bin\Debug\net6.0> .\Greeting.Cli.exe -n Ashwini
Hello, Ashwini!

We can publish our application as Self Contained or Framework dependent binaries. Or can publish using .NET global tools.

Add two lines in .csproj file to the end of the <PropertyGroup> node

<PackAsTool>true</PackAsTool>
<!-- <PackageOutputPath> is an optional element that determines where the NuGet package will be produced -->
<PackageOutputPath>./nupkgs</PackageOutputPath>

The .csproj now looks like the following -

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net6.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <PackAsTool>true</PackAsTool>
    <PackageOutputPath>./nupkgs</PackageOutputPath>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
  </ItemGroup>
</Project>

Now create a package and install it.

# Create Package
PS D:\Greeting.Cli> dotnet pack
Successfully created package 'D:\Greeting.Cli\nupkgs\Greeting.Cli.1.0.0.nupkg'.

# Install global tool from local directory
PS D:\Greeting.Cli> dotnet tool install --global --add-source ./nupkgs Greeting.Cli
You can invoke the tool using the following command: Greeting.Cli
Tool 'greeting.cli' (version '1.0.0') was successfully installed.

# Invoke program and pass options
PS D:\> Greeting.Cli --name Ash
Hello, Ash!

🤖 Add Sub-Commands

RootCommand is enough if your application is responsible for performing only one action, but if your application is performing more than one task then you need to have action specific commands. Take an example of dotnet CLI which have multiple sub-commands to deal with different actions -

PS D:\Greeting.Cli> dotnet --help
...
SDK commands:
  add               Add a package or reference to a .NET project.
  build             Build a .NET project.
  build-server      Interact with servers started by a build.
  clean             Clean build outputs of a .NET project.
  format            Apply style preferences to a project or solution.
...

As of now our Greeting.Cli only have one action to perform then having a RootCommand is enough. Let’s extend the functionality of our CLI by accepting a template from the user, for that we’ll add a template sub-command to your CLI.

...
// -- Template Sub-Command --
var  templateOption  =  new  Option<string>(
	name: "--template",
	description: "The template use for greeting.");
	
templateOption.AddAlias("-t");
templateOption.IsRequired  =  true;

var  templateCommand  =  new  Command("template", "Print greeting in provided format.");
templateCommand.AddOption(nameOption);
templateCommand.AddOption(templateOption);
templateCommand.SetHandler((template, name) =>
{
	var  parsedString  =  string.Format(template, name);
	Console.WriteLine(parsedString);
}, templateOption, nameOption);

// Add to RootCommand
rootCommand.AddCommand(templateCommand);
// -- Template Sub-Command end --
...

Here we initialized Command with command name and description and provided two options to it - name and template both as required. Also set the handler with parsing and print logic and at last we added it to the RootCommand instance.

If we see the help, it will show us the new template command

PS D:\Greeting.Cli> dotnet run -- --help
Description:
  Greeting CLI!

Usage:
  Greeting.Cli [command] [options]

Options:
  -n, --name <name> (REQUIRED)  The person name to greet.
  --version                     Show version information
  -?, -h, --help                Show help and usage information

Commands:
  template  Print greeting in provided format.

PS D:\Greeting.Cli> dotnet run -- template --help
Description:
  Print greeting in provided format.

Usage:
  Greeting.Cli template [options]

Options:
  -n, --name <name> (REQUIRED)          The person's name to greet.
  -t, --template <template> (REQUIRED)  The template use for greeting.
  -?, -h, --help                        Show help and usage information

Let’s use the template command and print the greeting in the provided format.

PS D:\Greeting.Cli> dotnet run -- template -t "Good Morning, {0}!" -n "Ashwini"
Good Morning, Ashwini!

Just like this we can create any number of sub-commands and nested sub-commands in our CLI.

💭 Closing Thoughts

  • With the System.CommandLine writing console application which needs command-line arguments, it becomes so easy that all heavy-lifting is abstracted by the package so we can only focus on our business needs.
  • DeltaX Vault CLI is built using System.CommandLine; Moving forward we shall write all of our utilities which require command-line arguments in similar fashion.
  • For Utilities deploy we must leverage the .NET global tool instead of copy-pasting binaries from one machine to another.

📖 References