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.