Ploch.CommandLine.Spectre
An opinionated framework for building .NET console applications, layering
Spectre.Console.Cli on top of
Microsoft.Extensions.Hosting so that a CLI gets dependency injection,
configuration, logging, and validation with a single builder chain.
Install
dotnet add package Ploch.CommandLine.Spectre
Stable releases are published to nuget.org, which is where
dotnet add package finds them. Prerelease builds of every push to main go to GitHub Packages
(https://nuget.pkg.github.com/mrploch/index.json), which needs a GitHub token with read:packages.
Packages
| Package | Purpose |
|---|---|
Ploch.CommandLine.Spectre |
Core framework — AppBuilder, command base classes, settings pipeline, output pipeline. |
Ploch.CommandLine.Spectre.Serilog |
Opt-in Serilog integration: UseSerilog on AppBuilder, backed by a SerilogConfigurationBundle for structured logging. The core package does not depend on it; without it ILogger<T> comes from the host's default providers. |
Ploch.CommandLine.Spectre.FluentValidation |
Declarative validation of command settings using FluentValidation validators. |
Ploch.CommandLine.UseCases |
Bridges commands to IResultUseCase for a Clean Architecture style, built on Ardalis.Result. |
Quick start
Define a settings class and a command:
using System.ComponentModel;
using Ploch.CommandLine.Spectre;
using Ploch.CommandLine.Spectre.Commands;
using Ploch.CommandLine.Spectre.Output;
using Spectre.Console.Cli;
public class GreetSettings : CommandSettings
{
[CommandArgument(0, "<name>")]
[Description("The name to greet.")]
public string Name { get; init; } = string.Empty;
[CommandOption("-l|--loud")]
[Description("Shout the greeting.")]
public bool Loud { get; init; }
}
public class GreetCommand(
CommandArgumentsRootProcessor settingsProcessor,
ICommandSettingsValidator<GreetSettings> validator,
IExceptionHandler exceptionHandler,
IOutput output)
: AppCommand<GreetSettings>(settingsProcessor, validator, exceptionHandler, output)
{
protected override ExitCode DoExecute(
CommandContext context, GreetSettings settings, CancellationToken cancellationToken)
{
var greeting = $"Hello, {settings.Name}!";
Output.WriteLine(settings.Loud ? greeting.ToUpperInvariant() : greeting);
return ExitCode.Success;
}
}
AppCommand<TSettings> takes the settings processor, the validator, the exception handler
and an IOutput, which it exposes as the Output property. Anything else the command
needs — a use case, your own services — is an ordinary constructor parameter resolved
from the container.
Wire it up in Program.cs:
using Ploch.CommandLine.Spectre;
using Spectre.Console.Cli;
// The builder owns the Ctrl+C handler and cancellation source it creates; dispose it after the run.
using var appBuilder = AppBuilder.Create(args)
.WithName("greeter")
.WithVersion(new Version(1, 0, 0))
.WithDescription("A greeting utility.");
var executor = appBuilder.ConfigureCommandApp(config =>
{
config.SetApplicationName("greeter");
config.AddCommand<GreetCommand>("greet")
.WithDescription("Greet someone by name.")
.WithExample("greet", "Alice", "--loud");
});
return executor.Run(args);
Services your commands take as constructor parameters are registered on the builder with
ConfigureServices(services => services.AddSingleton<IGreetingService, GreetingService>())
(using Microsoft.Extensions.DependencyInjection;), before ConfigureCommandApp.
$ greeter greet Alice --loud
HELLO, ALICE!
That is standard output. The start-up banner and an Executing command greet line go to standard
error, so the terminal shows them above the greeting while greeter greet Alice --loud | cat
prints just HELLO, ALICE!.
Core concepts
AppBuilder
AppBuilder.Create(args) starts the chain. It wraps a HostBuilder, so the
usual host-builder configuration entry points are available (the library never
starts the host; see below):
WithName,WithVersion,WithDescription— application metadata, also used to render the startup banner.ShowBanner(false)— drop the startup banner and theExecuting command …line written before each command. Both go to standard error, so neither reaches a pipe even when they are on. (The Ctrl+C notice and thePauseBeforeExitprompt still use standard output; PLO-819 tracks them.)ConfigureServices— register services into the container that resolves your commands.ConfigureAppConfiguration— add configuration sources on top of the host's own ones. Each source added here outranksappsettings.json, the environment variables and the command line, so do not re-addappsettings.jsonthrough it; useConfigureHost(host => host.UseContentRoot(...))when you only need the file to be found somewhere else.ConfigureHost— reach the underlyingIHostBuilderdirectly.Dispose— release theConsole.CancelKeyPresshandler and theCancellationTokenSourcethatCreateinstalled. A builder constructed directly with your own source owns neither and leaves both alone.AddServicesBundle<TBundle>— register aServicesBundlefromPloch.Common.DependencyInjection.
The token behind that source is handed to Spectre and reaches every command. The first Ctrl+C cancels it cooperatively; a second one terminates the process, so a command that ignores its token cannot leave the application unkillable from the keyboard.
ConfigureCommandApp terminates the chain and returns an ICommandAppExecutor,
which exposes Run and RunAsync.
The host is built once a run reaches command resolution — a parse failure or --version returns
before that — and the library never starts it. It exists for dependency injection, configuration
and logging; a CLI command is the unit of work, so the library neither starts nor stops an
IHostedService you register, and a host that was built is disposed when the run ends. Starting
the host around the command is a possible 1.x feature, tracked as
PLO-785.
Command base classes
| Base class | Use for |
|---|---|
AppCommand<TSettings> |
Synchronous commands. Implement DoExecute. |
AsyncAppCommand<TSettings> |
Asynchronous commands. Implement DoExecuteAsync. |
UseCaseAsyncCommand<...> |
Commands that delegate to an IResultUseCase. |
Spectre.Console.Cli calls each base class's Validate override first; Execute / ExecuteAsync
then runs the settings processor, invokes your implementation, and routes any
exception to the configured IExceptionHandler. Cancellation is handled separately from
failure: when the token the command received has been cancelled, an OperationCanceledException
returns ExitCode.Cancelled without reaching the exception handler. One raised while that token
is still live (an HttpClient timeout, a cancelled inner token) is a fault and goes to the
handler like any other exception.
Every base class also runs the settings-processing pipeline: AppCommand<TSettings>,
AsyncAppCommand<TSettings> and UseCaseAsyncCommand<...> all take a
CommandArgumentsRootProcessor and call it before your implementation. The
Executing command … line that announces the command is written to standard error by the
application before the command runs, not by the base class, which writes nothing of its own
to the output. The synchronous and asynchronous bases do the same framework work; only the
shape of your implementation differs.
Exit codes
| Member | Value | Meaning |
|---|---|---|
ExitCode.Success |
0 | Completed normally. |
ExitCode.Error |
1 | Unhandled failure. |
ExitCode.InvalidInput |
2 | The input was rejected — by the command (a value outside a supported set, a missing file, mutually exclusive flags) or by Spectre.Console.Cli while parsing, binding or validating it. |
ExitCode.Cancelled |
130 | Cancelled before completing — the conventional shell code for termination by SIGINT (128 + 2). |
A failed Validate exits with InvalidInput too. Spectre.Console.Cli runs validation before the
command and reports the errors itself; AppBuilder.ConfigureCommandApp installs a Spectre exception
handler that maps that failure, and every command line Spectre cannot parse or bind — an unknown command, a missing
required argument, a value that does not convert — to InvalidInput in place of Spectre's own -1
(255 in a POSIX shell). InvalidInput therefore means "the input was rejected" whether your code
or Spectre decided so — with one caveat: Spectre raises the same exception type for a few failures
that are not input errors, such as a settings property with no type converter, and those exit 2
too. The Getting Started guide walks through the exit
codes that come from elsewhere: the IExceptionHandler return value and Spectre's own 1 for a
branch or default command invoked without what it needs.
Token substitution
Mark a settings property with [SupportsTokens] and TokensArgumentsProcessor
expands {date} and {datetime} placeholders before the command runs:
[CommandOption("-o|--output")]
[SupportsTokens]
public string OutputPath { get; init; } = "./reports/{date}/report.txt";
Output pipeline
IOutput abstracts console writing so commands stay testable. Write and WriteLine render an
IRenderable directly, or dispatch to the most derived registered IMessageWriter for the message
type: the default writer writes a string verbatim, and a message no writer accepts is written as
plain text.
Registered IMessageFormatters apply where a writer consults them and in WriteBold /
WriteError. Markup is parsed by Markup, MarkupLine and the …Interpolated members, by a
FormattableString passed to Write or WriteLine (interpolated markup with its string holes
escaped; convert a non-string value to a string before interpolating it), and around the text of
WriteBold / WriteError; a plain string is not parsed by the
default writer (a custom writer registered for string decides for itself).
Learn more
- Getting Started — build a CLI from an empty directory, one feature at a time.
- Introduction — wiring up services, configuration, logging and validation.
- API reference — generated reference for every public type, by namespace.
- Sample application — a complete multi-level CLI showcase.