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:
Prerequisites
- Completed the Quick Start tutorial
- .NET 6.0 or later
- 1
Adding Multiple Commands
Let's start by creating a new project and adding the Spectre.Console.Cli package:
dotnet new console -n PackageManager cd PackageManager dotnet add package Spectre.Console.CliNow replace
Program.cswith two commands - one to add packages and one to list them: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
CommandAppwithConfigure():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:
dotnet run -- add Newtonsoft.Json # Added package Newtonsoft.Json dotnet run -- list # Packages: # (none yet)Both commands work. Try running
dotnet run -- --helpto see the auto-generated help listing both commands. The CLI knows aboutaddandlistwithout any extra configuration. - 2
Organizing Commands with Branches
Our
addcommand works, but real CLIs often have subcommands. Let's refactor so users can runadd packageandadd referenceseparately.Update
Program.csto useAddBranch():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:
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:
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 subcommandsThe
addbranch groups related commands together. Users can runadd --helpto discover what's available. - 3
Complete CLI with Shared Settings
Most CLIs have options that apply everywhere - things like
--verboseor--quiet. Let's add a shared--verboseflag to all our commands.Create a base settings class that other settings inherit from:
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: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
--verboseflag now works across all commands: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.csprojSettings 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
- Configuring CommandApp and Commands - Customize application behavior, add aliases, and set examples
- Working with Multiple Command Hierarchies - Build even deeper command structures
- Async Commands and Cancellation - Handle long-running operations with proper cancellation support
Related Console Tutorials
Looking to enhance your CLI output? Check out these Spectre.Console tutorials:
- Building a Rich Console App - Add tables, colors, and formatting to your command output
- Showing Status and Spinners - Display progress while packages install