Building a Multi-Command CLI Tool

Build a CLI application with multiple commands, subcommands, and shared settings

In this tutorial, we'll build a package manager CLI together. By the end, we'll have a tool with multiple commands organized into a hierarchy, sharing common options across all of them.

What We're Building

Here's how our CLI will work when we're done:

Multi Command Tutorial Screen Recording

Prerequisites

1

Adding Multiple Commands

Let's start by creating a new project and adding the Spectre.Console.Cli package:

bash
dotnet new console -n PackageManager
cd PackageManager
dotnet add package Spectre.Console.Cli

Now replace Program.cs with two commands - one to add packages and one to list them:

csharp
internal class AddCommand : Command<AddCommand.Settings>
{
    public class Settings : CommandSettings
    {
        [CommandArgument(0, "<name>")]
        [Description("The package name to add")]
        public string PackageName { get; init; } = string.Empty;
    }
  
    protected override int Execute(CommandContext context, Settings settings, CancellationToken cancellation)
    {
        System.Console.WriteLine($"Added package {settings.PackageName}");
        return 0;
    }
}
  
internal class ListCommand : Command<ListCommand.Settings>
{
    public class Settings : CommandSettings
    {
    }
  
    protected override int Execute(CommandContext context, Settings settings, CancellationToken cancellation)
    {
        System.Console.WriteLine("Packages:");
        System.Console.WriteLine("  (none yet)");
        return 0;
    }
}

Wire them up using CommandApp with Configure():

csharp
using Spectre.Console.Cli;
  
var app = new CommandApp();
app.Configure(config =>
{
    config.AddCommand<AddCommand>("add");
    config.AddCommand<ListCommand>("list");
});
return app.Run(args);

Run the commands:

bash
dotnet run -- add Newtonsoft.Json
# Added package Newtonsoft.Json
  
dotnet run -- list
# Packages:
#   (none yet)

Both commands work. Try running dotnet run -- --help to see the auto-generated help listing both commands. The CLI knows about add and list without any extra configuration.

2

Organizing Commands with Branches

Our add command works, but real CLIs often have subcommands. Let's refactor so users can run add package and add reference separately.

Update Program.cs to use AddBranch():

csharp
internal class AddPackageCommand : Command<AddPackageCommand.Settings>
{
    public class Settings : CommandSettings
    {
        [CommandArgument(0, "<name>")]
        [Description("The package name to add")]
        public string PackageName { get; init; } = string.Empty;
    }
  
    protected override int Execute(CommandContext context, Settings settings, CancellationToken cancellation)
    {
        System.Console.WriteLine($"Added package {settings.PackageName}");
        return 0;
    }
}
  
internal class AddReferenceCommand : Command<AddReferenceCommand.Settings>
{
    public class Settings : CommandSettings
    {
        [CommandArgument(0, "<path>")]
        [Description("The project reference path to add")]
        public string ReferencePath { get; init; } = string.Empty;
    }
  
    protected override int Execute(CommandContext context, Settings settings, CancellationToken cancellation)
    {
        System.Console.WriteLine($"Added reference to {settings.ReferencePath}");
        return 0;
    }
}
  
internal class ListCommand : Command<ListCommand.Settings>
{
    public class Settings : CommandSettings
    {
    }
  
    protected override int Execute(CommandContext context, Settings settings, CancellationToken cancellation)
    {
        System.Console.WriteLine("Packages:");
        System.Console.WriteLine("  (none yet)");
        System.Console.WriteLine("References:");
        System.Console.WriteLine("  (none yet)");
        return 0;
    }
}

Configure the branch structure:

csharp
using Spectre.Console.Cli;
  
var app = new CommandApp();
app.Configure(config =>
{
    config.AddBranch("add", add =>
    {
        add.AddCommand<AddPackageCommand>("package");
        add.AddCommand<AddReferenceCommand>("reference");
    });
    config.AddCommand<ListCommand>("list");
});
return app.Run(args);

Now the commands are organized hierarchically:

bash
dotnet run -- add package Newtonsoft.Json
# Added package Newtonsoft.Json
  
dotnet run -- add reference ../MyLib/MyLib.csproj
# Added reference to ../MyLib/MyLib.csproj
  
dotnet run -- add --help
# Shows 'package' and 'reference' as subcommands

The add branch groups related commands together. Users can run add --help to discover what's available.

3

Complete CLI with Shared Settings

Most CLIs have options that apply everywhere - things like --verbose or --quiet. Let's add a shared --verbose flag to all our commands.

Create a base settings class that other settings inherit from:

csharp
internal class GlobalSettings : CommandSettings
{
    [CommandOption("-v|--verbose")]
    [Description("Enable verbose output")]
    [DefaultValue(false)]
    public bool Verbose { get; init; }
}
  
internal class AddPackageCommand : Command<AddPackageCommand.Settings>
{
    public class Settings : GlobalSettings
    {
        [CommandArgument(0, "<name>")]
        [Description("The package name to add")]
        public string PackageName { get; init; } = string.Empty;
  
        [CommandOption("--version")]
        [Description("The package version (default: latest)")]
        public string? Version { get; init; }
    }
  
    protected override int Execute(CommandContext context, Settings settings, CancellationToken cancellation)
    {
        var version = settings.Version ?? "latest";
  
        if (settings.Verbose)
        {
            System.Console.WriteLine($"Searching for package {settings.PackageName}...");
            System.Console.WriteLine($"Resolving version {version}...");
            System.Console.WriteLine($"Installing to ./packages...");
        }
  
        System.Console.WriteLine($"Added package {settings.PackageName} v{version}");
        return 0;
    }
}
  
internal class AddReferenceCommand : Command<AddReferenceCommand.Settings>
{
    public class Settings : GlobalSettings
    {
        [CommandArgument(0, "<path>")]
        [Description("The project reference path to add")]
        public string ReferencePath { get; init; } = string.Empty;
    }
  
    protected override int Execute(CommandContext context, Settings settings, CancellationToken cancellation)
    {
        if (settings.Verbose)
        {
            System.Console.WriteLine($"Validating project at {settings.ReferencePath}...");
            System.Console.WriteLine($"Adding reference to project file...");
        }
  
        System.Console.WriteLine($"Added reference to {settings.ReferencePath}");
        return 0;
    }
}
  
internal class ListCommand : Command<ListCommand.Settings>
{
    public class Settings : GlobalSettings
    {
    }
  
    protected override int Execute(CommandContext context, Settings settings, CancellationToken cancellation)
    {
        if (settings.Verbose)
        {
            System.Console.WriteLine("Reading project file...");
        }
  
        System.Console.WriteLine("Packages:");
        System.Console.WriteLine("  Newtonsoft.Json (13.0.1)");
        System.Console.WriteLine("References:");
        System.Console.WriteLine("  ../MyLib/MyLib.csproj");
        return 0;
    }
}

The configuration stays the same - each command's settings inherit from GlobalSettings:

csharp
using Spectre.Console.Cli;
  
var app = new CommandApp();
app.Configure(config =>
{
    config.AddBranch("add", add =>
    {
        add.AddCommand<AddPackageCommand>("package");
        add.AddCommand<AddReferenceCommand>("reference");
    });
    config.AddCommand<ListCommand>("list");
});
return app.Run(args);

The --verbose flag now works across all commands:

bash
dotnet run -- add package Serilog --version 3.0.0 --verbose
# Searching for package Serilog...
# Resolving version 3.0.0...
# Installing to ./packages...
# Added package Serilog v3.0.0
  
dotnet run -- list --verbose
# Reading project file...
# Packages:
#   Newtonsoft.Json (13.0.1)
# References:
#   ../MyLib/MyLib.csproj

Settings inheritance keeps your code DRY. Define common options once, use them everywhere.

Congratulations!

You've built a multi-command CLI with:

  • Multiple commands registered via AddCommand<T>()
  • Hierarchical organization using AddBranch()
  • Shared settings through inheritance
  • Auto-generated help at every level

These same patterns scale to CLIs with dozens of commands and deep nesting.

Next Steps

Looking to enhance your CLI output? Check out these Spectre.Console tutorials: