Sub-Commands in a .NET Core CLI
In my last post, I hinted as some more advanced usage of the CommandLineUtils library. In this post I want to show you how to set up sub-commands, which can make a big CLI much easier to manage and a lot easier to use.
I鈥檓 going to use my previous sample-cli
project from last time and expand it a bit. The previous version was basically echo
with an option to upper-case the text. In this one, we鈥檒l keep the same concept, but break the casing into sub-commands. Here鈥檚 what we鈥檙e going for:
> sample-cli camel "My Text" # -> myText
> sample-cli pascal "My Text" # -> MyText
> sample-cli snake "My Text" # -> my_text
The Project File
Nothing changes in the project file. We鈥檙e still using our settings from last time.
The Sub-Command Files
I recommend creating a folder called Commands
and putting your commands in there. Each will be it鈥檚 own class and will have a similar structure to our previous MainCommand.cs
. Here, in order, are SnakeCommand.cs
, PascalCommand.cs
, and CamelCommand.cs
.
using McMaster.Extensions.CommandLineUtils;
namespace SampleCLI.Commands
{
[Command(Name = "snake",
FullName = "Snake Case",
Description = "Converts a string to snake_case.")]
[HelpOption]
public class SnakeCommand
{
[Argument(0, "TEXT", "Text to convert")]
public string Text { get; set; }
public void OnExecute(IConsole console)
{
var output = Text.Replace(' ', '_').ToLower();
console.WriteLine(output);
}
}
}
using System;
using System.Linq;
using McMaster.Extensions.CommandLineUtils;
namespace SampleCLI.Commands
{
[Command(Name = "pascal",
FullName = "Pascal Case",
Description = "Converts a string to PascalCase.")]
[HelpOption]
public class PascalCommand
{
[Argument(0, "TEXT", "Text to convert")]
public string Text { get; set; }
public void OnExecute(IConsole console)
{
var parts = Text.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Select(x => x.Substring(0, 1).ToUpper() + x.Substring(1).ToLower());
console.WriteLine(string.Join("", parts));
}
}
}
using System;
using System.Linq;
using McMaster.Extensions.CommandLineUtils;
namespace SampleCLI.Commands
{
[Command(Name = "camel",
FullName = "Camel Case",
Description = "Converts a string to camelCase.")]
[HelpOption]
public class CamelCommand
{
[Argument(0, "TEXT", "Text to convert")]
public string Text { get; set; }
public void OnExecute(IConsole console)
{
var parts = Text.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Select(x => x.Substring(0, 1).ToUpper() + x.Substring(1).ToLower());
var output = string.Join("", parts);
output = char.ToLower(output[0]) + output.Substring(1);
console.WriteLine(output);
}
}
}
Note that each has a Command
attribute and an OnExecute
method. The IConsole
parameter is injected by the library and is a basic abstraction of the .NET Console
class. Also note that each has an argument of it鈥檚 own. They can have separate arguments and options.
You can also use DI with constructor arguments!
The Main Command
Now, our updated MainCommand.cs
, which I have also moved in to the Commands
folder (and updated the namespace):
using System.Reflection;
using McMaster.Extensions.CommandLineUtils;
namespace SampleCLI.Commands
{
[Command(Name = "sample-cli",
FullName = "Sample CLI",
Description = "A sample CLI tool.")]
[HelpOption]
[VersionOptionFromMember(MemberName = "GetVersion")]
[Subcommand(typeof(PascalCommand), typeof(CamelCommand), typeof(SnakeCommand))]
public class MainCommand
{
public void OnExecute(CommandLineApplication app)
{
app.ShowHelp();
}
private string GetVersion()
{
return typeof(MainCommand)
.Assembly?
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
.InformationalVersion;
}
}
}
Note that the OnExecute
method now does nothing but show the help text. That will only be called if we do not call into one of the sub-commands. Also note the Subcommand
attribute which takes any number of arguments. Also, the Program.cs
file will need a new using to the Commands
namespace, but is otherwise unchanged.
I wish there was a less-static way of defining sub-commands. I鈥檇 like to have a convention-based approach where I can either name all my sub-commands in a similar way or inherit from some class or interface and have the library do the rest.
More Advanced Usage
There is much more that you can do by combining sub-commands with C# inheritance. You can nest types to keep your sub-commands isolated from each other. You can set up a base class with common options and have your commands inherit from it. The possibilities are wide and it鈥檚 easy to get lost. Remember to keep it simple.
Using It
> sample-cli
Sample CLI 0.1.0
A sample CLI tool.
Usage: sample-cli [options] [command]
Options:
--version Show version information
-?|-h|--help Show help information
Commands:
camel Converts a string to camelCase.
pascal Converts a string to PascalCase.
snake Converts a string to snake_case.
Run 'sample-cli [command] --help' for more information about a command.
> sample-cli camel --help
Camel Case
Converts a string to camelCase.
Usage: sample-cli camel [options] <TEXT>
Arguments:
TEXT Text to convert
Options:
-?|-h|--help Show help information
> sample-cli camel "My Text"
myText
> sample-cli pascal "My Text"
MyText
> sample-cli snake "My Text"
my_text
Conclusion
Sub-commands are a great way to separate a large command into smaller, more manageable parts. I use this style for a tool that I made to rename various video files for use in Plex. I have separate sub-commands for movie and show files. The code for each shares some stuff, shared via service classes which are injected into each command, but can have their own separate logic. It鈥檚 a great way to make a flexible tool.