Building CLI Applications with System.CommandLine in .NET

Building CLI Applications with System.CommandLine in .NET

Building CLI Applications with System.CommandLine in .NET

Been in .NET for several years exists the System.CommandLine library, which allows you to quickly create CLI applications. Despite the fact that the library is still in beta, it is also actively used by Microsoft developers themselves, for example, in dotnet utility from the .NET SDK.

The advantages of this library are that it allows you to focus on the development of useful application functionality and not waste time creating a parser of commands, options and arguments, and also has ample opportunities for customization.

Implementing a Simple CLI Application

The library supports the following types of tokens:

Options and arguments are defined together via a generic type Option<T>:

var left = new Option<double>(
	aliases: new[] { "-l", "--left" },
	description: "Left operand")
{
	IsRequired = true,
	Arity = ArgumentArity.ExactlyOne
};
var right = new Option<double>(
	aliases: new[] { "-r", "--right" },
	description: "Right operand")
{
	IsRequired = true,
	Arity = ArgumentArity.ExactlyOne
};

The full source code can be found at our GitLab repository.

Subcommand and command differ in that subcommand defines a group of commands, while command defines the actual action performed by the command.

Let’s define the sum command first:

var sum = new Command("sum", "Sum of the two numbers");
sum.Add(left);
sum.Add(right);
sum.SetHandler((l, r) => Console.WriteLine(l + r), left, right);

The division into teams and groups of teams is very conditional, because. from the point of view of C#, both types are defined through the class Command. Also, nothing prevents us from defining an action for a group of commands:

var math = new Command("math", "Mathematical operations");
math.Add(sum);
math.SetHandler(() => Console.Write("Math command handler"));

In addition to primitive types, the library also supports arrays, lists, FileInfo, DirectoryInfo and others. For a complete list, see documentation. If there is a need to bind options to a custom type, then you can use the built-in binding mechanism through the type BinderBase<T>. Let’s define the subtraction operation using this method:

public record Subtract(double Left, double Right)
{
	public double Calc()
	{
    	var result = Left - Right;
    	File.WriteAllText("result.txt", $"{result}");
    	return result;
	}
}

public class SubtractBinder : BinderBase<Subtract>
{
	private readonly Option<double> _left;
	private readonly Option<double> _right;

	public SubtractBinder(Option<double> left, Option<double> right) =>
    	(_left, _right) = (left, right);

	protected override Subtract GetBoundValue(BindingContext bindingContext) =>
    	new Subtract(
        	bindingContext.ParseResult.GetValueForOption(_left),
        	bindingContext.ParseResult.GetValueForOption(_right));
}

Then the definition of the handler for the subtraction command will look like this:

subtract.SetHandler(
    (sub) => Console.WriteLine(sub.Calc()), 
        new SubtractBinder(left, right));

It remains to define the Root command through the class of the same name RootCommand :

var root = new RootCommand("CLI app example");
root.Add(math);
await root.InvokeAsync(args);

Now you can build the application and check the work:

dotnet run -– math // вывод: Math command handler
dotnet run -— math sum -l 4 -r 7 // вывод: 13
dotnet run -— math subtract -l 10 -r 3 // вывод: 7

Dependency Injection

The lifecycle of a CLI application looks like this:

  1. Some command is called.

  2. The process starts.

  3. The data is processed and the result is returned.

  4. The process ends.

Classic dependency containers are not recommended, because the dependencies of one command may be completely unnecessary for another command, and initializing all dependencies can increase the startup time of the CLI application. Instead, you can use the dependency injection mechanism for a specific handler. This again requires a class BinderBase<T>. Define a new class ResultWriterwhich will write the result of a mathematical operation to a file:

public class ResultWriter
{
	public void Write(double result) => File.WriteAllText("result.txt", $"{result}");
}

Now let’s create a class ResultWriterBinder. This class encapsulates an instance ResultWriter:

public class ResultWriterBinder : BinderBase<ResultWriter>
{
	private readonly ResultWriter _resultWriter = new ResultWriter();

	protected override ResultWriter GetBoundValue(BindingContext bindingContext) => _resultWriter;
}

Now let’s define the multiplication operation and embed an instance there ResultWriter:

var multiply = new Command("multiply", "Multiply one number by another");
multiply.Add(left);
multiply.Add(right);
multiply.SetHandler((left, right, resultWriter) =>
{
	var result = left * right;
	resultWriter.Write(result);
	Console.WriteLine(result);
}, left, right, new ResultWriterBinder());

This approach allows you to inject dependencies into command handlers independently of each other.

Output customization

Help is generated automatically from the descriptions that were used when initializing options and commands. For example, the command cli-app math sum -h will display the following:

Description:
  Sum of the two numbers

Usage:
  cli-app math sum [options]

Options:
  -l, --left <left> (REQUIRED)	  Left operand
  -r, --right <right> (REQUIRED)  Right operand
  -?, -h, --help              	  Show help and usage information

You can optionally replace any help topic, such as Description, or create a new one. Let’s add a new line with the text “This is a new section” to the top of the help:

using System.CommandLine.Builder;
using System.CommandLine.Help;
using System.CommandLine.Parsing;

// остальной код


var parser = new CommandLineBuilder(root)
	.UseDefaults()
	.UseHelp(ctx =>
    	ctx.HelpBuilder.CustomizeLayout(x =>
        	HelpBuilder.Default
            	.GetLayout()
            	.Prepend(d =>
                	Console.WriteLine("This is a new section"))))
	.Build();

Then the output will be:

This is a new section

Description:
  Sum of the two numbers

// остальной текст

If you need to improve the appearance of data output in general, then you can do this, for example, by writing a custom display method using the methods and properties of the Console class, such as SetCursorPosition, ForegroundColor, BackgroundColor, etc. Or use 3rd-party libraries:

1. ShellProgressBar. A simple library for displaying progress in the command window.

2. Specter.Console. A powerful library for creating beautiful console applications, which has many components that make it easy to create an interface. By the way, the cover for the article was made using this library.

3. ConsoleGUI. Allows you to create a full-fledged GUI based on the console. Apparently, the author was inspired by WPF, as it contains the components familiar to this framework: TextBox, CheckBox, DataGrid, DockPanel and others.

Conclusion

Library System.CommandLine is a useful tool for building CLI applications. It gives developers a flexible toolkit for working with commands and options, which allows them to reduce development time and create a convenient and functional user interface.

Similar Posts

Leave a Reply

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