CLI

Build your own cli because you can

CLI

I have always been impressed by the Azure CLI and how it has played a significant role in my DevOps journey over the years. That got me wondering, how can I build my own CLI in a similar vein to Azure? Follow along in this blog post where I’ll be sharing my step-by-step process for creating a CLI using C# in .NET. Don’t miss out on this exciting adventure!

What is CLI

A command line interface (CLI) is a text-based user interface used to run programs, manage computer files, and interact with the machine. Back in the day, everything was done on a computer using a CLI, there was no GUI (Graphical User Interface) and the user should learn how to use a CLI to perform different actions on the computer. Nowadays, CLI has become wildly used specifically for a developer or a technical person. Some of the great CLI out there are dotnet, git, GitHub, azure cli, and others.

Project Overview

The main goal of this blog post is to create a CLI using C# with .NET 7. As .NET 7 is cross-platform, at the end of this project, you will have a fully functional, cross-platform command line interface that can be deployed on any machine. We will be sharing the step-by-step process for creating this CLI and all codes will be available on the following GitHub repository.

We’ll be building a small todo CLI that will allow us to create and manage todo items. Let’s get started and see how we can put this powerful tool to use in our daily tasks and workflow.

Project Setup

Let’s start by creating our console app project using the dotnet cli (using cli to create our cli 🔥)

dotnet new console -o Hal9000Cli

Cliché Alert
I know it might be a bit of a cliché, but I couldn’t resist using Hal9000 as the name for my CLI. So, that’s just what it is

After running the following command, we need to add the CommandLineUtils library from the Nuget package manager, the library is an open-source project created by Nate McMaster that will simplify parsing arguments provided on the command line, validating user inputs, and generating a help text.

dotnet add package McMaster.Extensions.CommandLineUtils

Let’s Start Coding

Open the new project in your preferred IDE and let’s start coding

There are 2 ways to implement the CommandLineUtils library, using the attributes or using the builder pattern, I will be using the attributes method in this post, you can check more about it in the library’s documentation

Create a new class called Hal9000Cmd.cs with the following content

[Command(Name = "hal9000", OptionsComparison = StringComparison.InvariantCultureIgnoreCase)]
[VersionOptionFromMember("--version", MemberName = nameof(GetVersion))]
public class Hal9000Cmd
{
    protected Task<int> OnExecute(CommandLineApplication app)
    {
        app.ShowHelp();
        return Task.FromResult(0);
    }
    
    private static string? GetVersion()
        => typeof(Hal9000Cmd).Assembly?.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
}

Adding the Command attribute at the class level will register the command name to be used, so when you type hal9000 it will redirect you to that class. This class will be the main class, we will add sub-commands moving forward in the post.

OnExecute is important here, this is the method that will be called when you type any command in the terminal, this is used by the CommandLineUtils caller from the Program.cs file later.

the --version option is now applicable and it will return the assembly version, it is defaulted to 1.0.0, let us change it by adding the version tag in the project csproj file, mine is set to 0.0.1. The following is the csproj content so far

<Project Sdk="Microsoft.NET.Sdk">

	<PropertyGroup>
		<OutputType>Exe</OutputType>
		<TargetFramework>net7.0</TargetFramework>
		<ImplicitUsings>enable</ImplicitUsings>
		<Nullable>enable</Nullable>
		<Authors>Mohamad Dbouk</Authors>
		<Product>Hal9000</Product>
		<AssemblyName>hal9000</AssemblyName>
		<Version>0.0.1</Version>
	</PropertyGroup>

	<ItemGroup>
		<PackageReference Include="McMaster.Extensions.CommandLineUtils" Version="4.0.1" />
	</ItemGroup>

</Project>

Add the following into Program.cs file

CommandLineApplication.Execute<Hal9000Cmd>(args);

CommandLineApplication will execute the method Execute in the Hal9000Cmd class

Now let us test the application, run the following dotnet command to build and run the application

dotnet run

This will show the app help page, which contains the version and the main commands (currently we have only the version and the default show help command)

? dotnet run
0.0.1

Usage: hal9000 [options]

Options:
  --version     Show version information.
  -?|-h|--help  Show help information.

Now type the following dotnet run --version this will return the version specified in the csproj 0.0.1

A nerdy CLI intro

So let’s add a cool CLI intro for our HAL9000, something nerdy using ASCII codes

First Install the following NuGet package FIGlet.Net using the following command

dotnet add package FIGlet.Net --version 1.1.2

And then add the following line of code into the Execute method in Hal9000Cmd.cs class

protected Task<int> OnExecute(CommandLineApplication app)
{
    var displayTitle = new WenceyWang.FIGlet.AsciiArt("HAL9000");

    Console.ForegroundColor = ConsoleColor.Yellow;
    Console.WriteLine(displayTitle.ToString());
    Console.ResetColor();
    Console.WriteLine();

    app.ShowHelp();
    return Task.FromResult(0);
}

Now when you run the application you will get something like this:

? dotnet run

 _   _     _     _       ___    ___    ___    ___  
| | | |   / \   | |     / _ \  / _ \  / _ \  / _ \ 
| |_| |  / _ \  | |    | (_) || | | || | | || | | |
|  _  | / ___ \ | |___  \__, || |_| || |_| || |_| |
|_| |_|/_/   \_\|_____|   /_/  \___/  \___/  \___/

0.0.1

Usage: hal9000 [options]

Options:
  --version     Show version information.
  -?|-h|--help  Show help information.

The Todo Service

I’m not going to go advance here, we will have a JSON file, located in the local machine, and using the cli, we will be able to create, update, and query a list of task items in the todo list.

Now let us create 2 classes, TodoItem that contains the todo item model, and TodoService that will be used to perform the different action on our Todo

TodoItem.cs

public class TodoItem
{
    public int Id { get; set; }
    public string? Title { get; set; }
    public bool Completed { get; set; }
    public DateTime? CompletedTime { get; set; }
    public DateTime? UpdatedTime { get; set; }
    public DateTime CreatedTime { get; set; }

    public override string ToString()
    {
        var options = new JsonSerializerOptions { WriteIndented = true };
        return JsonSerializer.Serialize(this, options);
    }
}

TodoService.cs

public class TodoService
{
    public TodoItem AddItem(string title)
    {
        ...
    }

    public TodoItem? UpdateItem(int id, string title)
    {
        ...
    }

    public TodoItem? MarkAsDone(int id)
    {
        ...
    }

    public List<TodoItem> GetItems()
    {
        ...
    }

    public TodoItem? GetItem(int id)
    {
        ...
    }
}

Check the full code in the GitHub repo here

Adding Sub Commands

To add a sub command, we simply need to create a new class, the same way we did the Hal9000Cmd, adding the attributes to specify the command name. We need to have a TodoCmd class, that will also contain 3 sub commands CreateTodoItemCmd, UpdateTodoItemCmd, and QueryTodoItemCmd

In the terminal, the end goal is to achieve the following

hal9000 todo create --title "Create a todo item"
// this will create a new todo item

hal9000 todo update --id 1 --done
// this will mark the item as done

hal9000 todo list --query "[*].{Id:Id, Title: Title}"
// this will return the list of items in addition to 

So to do so, we will create the main class TodoCmd and we will create 3 sub-command classes for create, update, and query todo items

Todo Command

Create new file TodoCmd.cs and add the following boilerplate code to get it up and running

[Command(Name = "todo", Description = "Manage Todo Items (Create, Update, and List)")]
public class TodoCmd
{
    protected Task<int> OnExecute(CommandLineApplication app)
    {
        app.ShowHelp();
        return Task.FromResult(0);
    }
}

And add the following SubCommand attribute on top of the Hal9000Cmd class

[Command(Name = "hal9000", OptionsComparison = StringComparison.InvariantCultureIgnoreCase)]
[VersionOptionFromMember("--version", MemberName = nameof(GetVersion))]
[Subcommand(typeof(TodoCmd))]
public class Hal9000Cmd
{
  ...

Subcommand accept params of Types, so anytime we add new sub commands, we need to add it as a parameter (we will do that using the Away command)

Now when you run the application you will see that there is a new section in the help output showing the Commands as shown in the following output

 _   _     _     _       ___    ___    ___    ___  
| | | |   / \   | |     / _ \  / _ \  / _ \  / _ \ 
| |_| |  / _ \  | |    | (_) || | | || | | || | | |
|  _  | / ___ \ | |___  \__, || |_| || |_| || |_| |
|_| |_|/_/   \_\|_____|   /_/  \___/  \___/  \___/

0.0.1

Usage: hal9000 [command] [options]

Options:
  --version     Show version information.
  -?|-h|--help  Show help information.

Commands:
  todo          Manage Todo Items (Create, Update, and List)

Run 'hal9000 [command] -?|-h|--help' for more information about a command.

Create, Update, and List Commands

Now, create 3 classes, CreateTodoItemCmd, UpdateTodoItemCmd, and QueryTodoItemCmd

For Create, we need the title as an argument, so we will create a property and we set the Option attribute as follow

[Option(CommandOptionType.SingleValue, ShortName = "t", LongName = "title", Description = "The title of the todo item", ShowInHelpText = true, ValueName = "The title of the todo item")]
[Required]
public string? Title { get; set; }

And now from the OnExecute method, we can call the TodoService to create a new item as showing the final CreateTodoItemCmd class

[Command(Name = "create", Description = "Create a Todo Item")]
[HelpOption]
public class CreateTodoItemCmd
{
    private readonly IConsole _console;
    private readonly TodoService _todoService;

    public CreateTodoItemCmd(IConsole console, TodoService todoService)
    {
        _console = console;
        _todoService = todoService;
    }

    [Option(CommandOptionType.SingleValue, ShortName = "t", LongName = "title", Description = "The title of the todo item", ShowInHelpText = true, ValueName = "The title of the todo item")]
    [Required]
    public string? Title { get; set; }

    protected Task<int> OnExecute(CommandLineApplication app)
    {
        var item = _todoService.AddItem(Title!);

        _console.WriteLine($"Todo item \"{item.Title}\" with Id {item.Id} created succesfully!");

        return Task.FromResult(0);
    }
}

Now after doing so, we can create an item using the following command:

? hal9000 todo create --title "Eat a large size pizza"                    
Todo item "Eat a large size pizza" with Id 10 created succesfully!

JMESPath Query String

This is one of my favorite implementations, Azure CLI has it, so I replicated what they did here. Basically what the get list command does is return the list of all items using the properties of the TodoItem model. But, sometimes we don’t need all of the properties of the model, maybe we need only the Id and Title, and maybe we need to perform some kind of filter (where MarkAsDone is false) or (where CreatedTime > yesterday). So to perform that we can use the JMESPath implementation from the following github repo jdevillard/JmesPath.Net: A fully compliant implementation of JMESPATH for .NetCore (github.com) by jdevillard (github.com)

Calling the JMESPath is very simple, add the following implementation

private string JMESPathTransform(string input)
{
    if (!string.IsNullOrEmpty(Query))
    {
        var jmes = new JmesPath();
        return jmes.Transform(input, Query);
    }

    return string.Empty;
}

And then call the function from the QueryTodoItemCmd:

[Option(CommandOptionType.SingleValue, ShortName = "", LongName = "query", Description = " JMESPath query string. See http://jmespath.org/ for more information and examples.", ShowInHelpText = true, ValueName = "")]
public string? Query { get; set; }

protected Task<int> OnExecute(CommandLineApplication app)
{
    var items = _todoService.GetItems();

    var options = new JsonSerializerOptions { WriteIndented = true };
    string jsonString = JsonSerializer.Serialize(items, options);

    if (!string.IsNullOrEmpty(Query))
    {
        jsonString = JMESPathTransform(jsonString);
        jsonString = JsonSerializer.Serialize(JsonSerializer.Deserialize<object>(jsonString), options);
    }

    _console.WriteLine(jsonString);

    return Task.FromResult(0);
}

And now, from your terminal you can perform something like this:

? Hal9000 todo list --query "[*].{Id: Id, Title: Title, Date: CreatedTime}"
[
  {
    "Id": 1,
    "Title": "Create a Blog",
    "Date": "2022-09-26T12:31:05.86027\u002B03:00"
  },
  {
    "Id": 2,
    "Title": "Finish the blog",
    "Date": "2022-09-26T12:31:07.932452\u002B03:00"
  },
  {
    "Id": 3,
    "Title": "Eat Pizza",
    "Date": "2022-09-26T12:31:08.722755\u002B03:00"
  }
]

Or even something like this 🍕

? hal9000 todo list --query "[?contains(Title, 'Pizza')].{Id: Id, Title: Title}"
[
  {
    "Id": 5,
    "Title": "Order a large Pizza"
  },
  {
    "Id": 6,
    "Title": "Order a small Pizza"
  },
  {
    "Id": 8,
    "Title": "Pizza Party!"
  },
  {
    "Id": 10,
    "Title": "Eat a large size Pizza"
  }
]

This is very helpful if you decided to perform some scripting on top of your CLI (getting data with different filters and then calling an API to store them in the database, ..)

Adding PostBuild commands

To test the application directly from the terminal, you need to add the following code to the project csproj file

<Target Name="PostBuild" AfterTargets="PostBuildEvent" Condition=" '$(OS)' == 'Windows_NT' ">
	<Exec Command="xcopy "$(SolutionDir)\bin\Debug\net7.0" "C:\Hal9000\"  /Y /I /E" />
</Target>
<Target Name="PostBuild" AfterTargets="PostBuildEvent" Condition=" '$(OS)' != 'Windows_NT' ">
	<Exec Command="cp $(SolutionDir)/bin/Debug/net7.0/* /Users/mohamaddbouk/Hal9000" />
</Target>

Here I added 2 PosBuild commands, one when the OS type is windows, and the other if not (macOS, Linux, ..). The PostBuild will be executed when you do a full build of the project, then automatically the bin directory will be copied into another directory. Once you build the project, you can open the terminal and run the Hal9000 application.

Environment variable of the output directory
Make sure to create the directory and add it to the Environment Path variable. Once added, restart your terminal and you will be able to call Hal9000

Summary

Wow, that was a long blob post, I hope that you learned something new today, we talked about CLI, how to create them, and how to create multiple sub-commands with different attributes, we also added the JMESPath functionality and we added Post Build Events in Visual Studio. We also styled our CLI using FIGlet Asci Art and the most important part, we had fun doing all of that.

Thank you for reading and catch you in the next one, Ciao 👋

Recent Posts

HybridCache in .NET 9 is Awesome!
.NET 9 is now live, and it comes with a new set of features. Some are great, and some are just icing on the cake. …
Adding Custom Formatting to Your Classes with IFormattable
DateTime has a great feature that I often replicate in my classes: the ability for users to format the ToString output however they want. Using …
Azure-Sync: Sync your Azure App Settings to local
Azure-Sync is a handy shell script tool designed to help .NET developers working with Azure App Services. Inspired by the functionality provided by the Azure …
Implement Builders easily with Source Generator in .NET
I created a YouTube video on Source Generator in which I showcased one possible implementation. However, I feel that I didn’t fully highlight its capabilities. …

3 thoughts on “Build your own cli because you can

  1. Pingback: Say Hello to Reliable Minimal APIs with Integration Tests – Mohamad Dbouk

Leave a Reply

Your email address will not be published. Required fields are marked *

This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.