Getting Started with Ploch.CommandLine.Spectre
A hands-on walkthrough: you build a small CLI from an empty directory, one feature at a time, and run it after every step. Every console listing below is real output captured from the finished application rather than an illustration, with machine-specific values — paths and host names — replaced by neutral placeholders.
For the conceptual overview — what each package is for, how the pieces fit together — see the documentation site home page and the Introduction article. This guide is deliberately practical and does not repeat them.
The finished application lives in samples/SampleApp. If you
would rather read the code than type it, start there.
Contents
- Prerequisites
- Create the project
- Bootstrap the application with AppBuilder
- Your first command: settings and AppCommand
- Configuration that survives the working directory
- Asynchronous commands and dependency injection
- Composing a multi-level CLI
- Validating settings with FluentValidation
- Token expansion in settings
- Use cases and Ardalis.Result
- Exit codes
- Cancellation
- Logging with Serilog
- Testing commands
- Development conveniences
1. Prerequisites
- .NET SDK 10.0 or later (
dotnet --version). - A NuGet feed carrying the
Ploch.*packages. They are published to the MrPloch GitHub Packages feed; see the repository README for the feed URL and authentication.
2. Create the project
dotnet new console -n MyTool
cd MyTool
dotnet add package Ploch.CommandLine.Spectre
dotnet add package Spectre.Console.Cli
Add the optional packages as you reach the steps that need them:
| Package | Adds |
|---|---|
Ploch.CommandLine.Spectre |
AppBuilder, the command base classes, IOutput, exit codes, token processing |
Ploch.CommandLine.Spectre.FluentValidation |
FluentValidation-backed settings validation |
Ploch.CommandLine.Spectre.Serilog |
Serilog wiring with console and rolling-file sinks |
Ploch.CommandLine.UseCases |
IResultUseCase<,> and UseCaseAsyncCommand<,,,> |
3. Bootstrap the application with AppBuilder
AppBuilder builds a Microsoft.Extensions.Hosting host, wires its service provider into
Spectre.Console.Cli, and returns an executor you run with the process arguments.
using Ploch.CommandLine.Spectre;
// The builder owns the Ctrl+C handler and the cancellation source it creates, so dispose it once
// the run has returned. The token stays live for the whole run, so an earlier scope exit would
// cancel the application it is meant to be shutting down.
using var appBuilder = AppBuilder.Create(args)
.WithName("My Tool")
.WithVersion(new Version(1, 0, 0))
.WithDescription("Does something useful.");
var executor = appBuilder.ConfigureCommandApp(config =>
{
config.SetApplicationName("mytool");
});
return executor.Run(args);
Three things happen here that you do not have to write yourself:
- Application banner. The name is rendered as FIGlet text, followed by the version and description, before any command runs.
- Hosting.
Host.CreateDefaultBuildersupplies configuration, logging and the service provider. Anything you register withConfigureServicesis injectable into commands. - Ctrl+C.
AppBuilder.Createinstalls aConsole.CancelKeyPresshandler over aCancellationTokenSource, and that source's token is the one your commands receive (see Cancellation). The first interrupt cancels it cooperatively instead of killing the process, so a command that honours its token can stop and tidy up; a second interrupt takes the default path and terminates, so a command that ignores its token never leaves the application unkillable from the keyboard. The builder owns both the source and the handler: disposing it unsubscribes the handler and releases the source, and the handler also detaches itself once an interrupt has been handled.Console.CancelKeyPressis process-wide, so a builder that is neither disposed nor interrupted keeps its handler installed for the life of the process.
ConfigureServices has two overloads — one taking just IServiceCollection, one taking the
HostBuilderContext as well, which is how you reach IConfiguration during registration:
.ConfigureServices((context, services) =>
{
services.AddSingleton<IUserService, UserService>();
services.Configure<MyOptions>(context.Configuration.GetSection("MyOptions"));
})
Both overloads are additive, and so are ConfigureHost and ConfigureAppConfiguration: call them
as many times as you like and every delegate runs, in the order you added them. That is the same
behaviour as the IHostBuilder methods underneath, so registration can be split across helper
methods without one call quietly replacing another.
4. Your first command: settings and AppCommand
A command is a pair: a settings class describing the command line, and a command class doing the work.
Settings derive from Spectre's CommandSettings and use its attributes. [CommandArgument] is
positional (<REQUIRED> in angle brackets, [OPTIONAL] in square ones); [CommandOption] is a
named flag; [Description] feeds the generated help; [DefaultValue] supplies the default and is
also shown in help.
using System.ComponentModel;
using Spectre.Console.Cli;
public class InfoCommandSettings : CommandSettings
{
[CommandOption("-d|--diagnostics")]
[Description("Display extended runtime and host diagnostics.")]
[DefaultValue(false)]
public bool ShowDiagnostics { get; set; }
}
For synchronous work, derive from AppCommand<TSettings> and implement DoExecute:
using Ploch.CommandLine.Spectre.Commands;
using Ploch.CommandLine.Spectre.Output;
using Spectre.Console.Cli;
public class InfoCommand(ICommandSettingsValidator<InfoCommandSettings> validator,
IExceptionHandler exceptionHandler,
IOutput output) : AppCommand<InfoCommandSettings>(validator, exceptionHandler)
{
protected override ExitCode DoExecute(CommandContext? context, InfoCommandSettings settings, CancellationToken cancellationToken)
{
output.MarkupLineInterpolated($"[bold cyan]Hello from My Tool[/]");
return ExitCode.Success;
}
}
Note what the base class gives you and what you therefore never write in DoExecute:
- Validation runs first, through the injected
ICommandSettingsValidator<TSettings>. - Exceptions never escape. Anything thrown goes to the injected
IExceptionHandler, whose return value becomes the exit code. - Cancellation is separated from failure: an
OperationCanceledExceptionis not treated as a fault, it returnsExitCode.Cancelled. ExitCode, notint. The base class casts for you.
AppCommand<TSettings> has no Output property — use the IOutput you injected. Its asynchronous
sibling, introduced in step 6, does expose Output.
Register the command and run it:
config.AddCommand<InfoCommand>("info")
.WithDescription("Display system, application, and host runtime information.")
.WithExample("info")
.WithExample("info", "-d");
$ mytool info
=== Application & System Information ===
╭──────────────────────┬────────────────────────────────────────────╮
│ Property │ Value │
├──────────────────────┼────────────────────────────────────────────┤
│ Application Name │ Ploch.CommandLine.Spectre Sample App │
│ Framework │ .NET 10.0.11 │
│ OS Description │ Microsoft Windows 10.0.26200 │
│ Process Architecture │ X64 │
│ Current Directory │ C:\projects\sample-app │
│ Machine Name │ EXAMPLE-HOST │
│ Environment Setting │ Development │
╰──────────────────────┴────────────────────────────────────────────╯
Command completed successfully.
The WithExample calls are not decoration — they are what fills the EXAMPLES: block of the
generated help.
5. Configuration that survives the working directory
Add an appsettings.json and copy it to the output directory:
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
There is a trap here that only shows up once you install the tool. The host resolves relative
configuration paths against the current working directory, and a CLI is run from wherever the
user happens to be — so appsettings.json silently fails to load and every setting reads back as
null. Anchor it to the deployment directory instead:
.ConfigureAppConfiguration(configuration => configuration.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true))
optional: false is deliberate: a missing configuration file should fail loudly at start-up rather
than produce a tool that behaves differently depending on the directory it was launched from.
Inject IConfiguration into a command like any other service.
Do not enumerate the configuration root for display. The host adds an environment-variable
provider, so configuration.GetChildren() yields every environment variable of the process — API
keys and access tokens included. If a command renders configuration, give it an allow-list of the
sections your application owns:
private static readonly string[] ApplicationSections = ["SampleAppSettings", "Logging", "Serilog"];
6. Asynchronous commands and dependency injection
AsyncAppCommand<TSettings> is the asynchronous base class. It takes two more dependencies than
AppCommand<TSettings> — a CommandArgumentsRootProcessor (which pre-processes settings, e.g.
token expansion) and an IOutput, exposed to you as the Output property.
public class UserAddCommand(CommandArgumentsRootProcessor settingsProcessor,
ICommandSettingsValidator<UserAddCommandSettings> validator,
IExceptionHandler exceptionHandler,
IOutput output,
IUserService userService)
: AsyncAppCommand<UserAddCommandSettings>(settingsProcessor, validator, exceptionHandler, output)
{
protected override async Task<ExitCode> DoExecuteAsync(CommandContext context,
UserAddCommandSettings settings,
CancellationToken cancellationToken)
{
Output.MarkupLineInterpolated($"[cyan]Creating new user account for[/] [bold yellow]{settings.Name}[/]...");
var user = await userService.CreateUserAsync(settings.Name, settings.Email, settings.Role, cancellationToken);
return ExitCode.Success;
}
}
Use the inherited Output property rather than capturing the constructor parameter — capturing a
parameter that is also passed to the base constructor stores it twice and the compiler warns about
it (CS9107).
IUserService is resolved from the container you configured in step 3; command constructors are
plain constructor injection.
$ mytool user add "Alice Smith" -e alice@example.com -r Administrator
Executing command UserAddCommandSettings
Processing arguments...
Creating new user account for Alice Smith...
╭─User Created Successfully────────╮
│ User ID: 4 │
│ Name: Alice Smith │
│ Email: alice@example.com │
│ Role: Administrator │
│ Active: Yes │
│ Created: 2026-08-22 12:37:59 UTC │
╰──────────────────────────────────╯
The Executing command … / Processing arguments… preamble comes from AsyncAppCommand, not from
the command body.
Render through IOutput, and escape what the user typed
Two habits worth forming early, both visible throughout the sample.
Render renderables through IOutput, not the static AnsiConsole. IOutput.Write(IRenderable)
takes a Table, Panel or Tree just as AnsiConsole.Write does, but it goes through the console
the host configured and can be mocked in a test:
Output.Write(table); // not AnsiConsole.Write(table)
Escape values that came from outside your source file. Table cells, panel content, tree nodes
and Markup are all parsed as Spectre markup, so a user name, path or configuration value
containing [ throws during rendering or injects formatting:
table.AddRow(user.Id.ToString(), Markup.Escape(user.Name), Markup.Escape(user.Email));
MarkupLineInterpolated escapes its interpolation holes automatically — that is the difference
between it and building a Markup from an interpolated string, which does not.
IOutput.Write dispatches on the type of the message. A FormattableString, a string and an
IRenderable are rendered directly; anything else is offered to the registered IMessageWriters,
and the writer whose message type matches receives the message itself along with the
IMessageFormatterProcessor, so the writer decides how the value is formatted. Writing an
Exception therefore renders the full exception through ExceptionMessageWriter, and writing a
collection produces one line per item. Register your own with
services.AddMessageWriter<TMessage, TWriter>() — and format the message inside Write rather than
expecting to be handed formatted text.
7. Composing a multi-level CLI
Sub-commands are grouped into branches. A branch is a verb with no behaviour of its own that owns a set of commands, and branches can be nested to any depth.
var executor = appBuilder.ConfigureCommandApp(config =>
{
config.SetApplicationName("sample");
// Root-level command.
config.AddCommand<InfoCommand>("info")
.WithDescription("Display system, application, and host runtime information.");
// A branch with three sub-commands.
config.AddBranch("user", user =>
{
user.SetDescription("Manage user accounts and profile data.");
user.AddCommand<UserAddCommand>("add")
.WithDescription("Create a new user account with validation.")
.WithExample("user", "add", "Alice Smith", "-e", "alice@example.com", "-r", "Administrator");
user.AddCommand<UserListCommand>("list")
.WithDescription("List registered user accounts in a rich table.");
user.AddCommand<UserDeleteCommand>("delete")
.WithDescription("Delete a user account by ID.");
});
});
Help is generated for every level. The root:
$ sample --help
USAGE:
sample [OPTIONS] <COMMAND>
EXAMPLES:
sample info
sample info -d
sample user add Alice Smith -e alice@example.com -r Administrator
sample user list
sample user list -a -f compact
OPTIONS:
-h, --help Prints help information
COMMANDS:
info Display system, application, and host runtime information
user Manage user accounts and profile data
config Inspect and manage application configuration settings
file File processing and report generation utilities
project Project operations powered by Clean Architecture use cases and
Ardalis.Result
And the branch:
$ sample user --help
DESCRIPTION:
Manage user accounts and profile data
USAGE:
sample user [OPTIONS] <COMMAND>
EXAMPLES:
sample user add Alice Smith -e alice@example.com -r Administrator
sample user list
sample user list -a -f compact
sample user delete 1 --force
OPTIONS:
-h, --help Prints help information
COMMANDS:
add <NAME> Create a new user account with validation
list List registered user accounts in a rich table
delete <ID> Delete a user account by ID
Nesting further is the same call again — user.AddBranch("keys", keys => …) gives you
sample user keys rotate.
Options shared by a group of commands
Declare an option once in a base settings class and inherit it, rather than repeating the property on every command in a branch:
public class GlobalSettings : CommandSettings
{
[CommandOption("-v|--verbose")]
[Description("Enable verbose console output.")]
[DefaultValue(false)]
public bool Verbose { get; set; }
}
public class UserListCommandSettings : GlobalSettings
{
[CommandOption("-a|--active-only")]
[Description("Only display active user accounts.")]
[DefaultValue(false)]
public bool ActiveOnly { get; set; }
}
Inherited options appear in the generated help of every derived command, defaults included:
$ sample user list --help
OPTIONS:
DEFAULT
-h, --help Prints help information
-v, --verbose Enable verbose console output
-a, --active-only Only display active user accounts
-f, --format <FORMAT> table The output format: 'table' or 'compact'
An option that appears in help must do something in every command that inherits it — a flag the command silently ignores is worse than no flag.
8. Validating settings with FluentValidation
Add the package, then register validation once during service configuration. Assembly scanning
picks up every AbstractValidator<TSettings> in the assemblies you list:
using Ploch.CommandLine.Spectre.FluentValidation;
services.AddCommandLineSettingsFluentValidation(builder => builder.AddAssembly(typeof(Program).Assembly));
This registers FluentCommandSettingsValidator<T> as the ICommandSettingsValidator<T> your
commands receive, so no command code changes.
public class UserAddCommandSettingsValidator : AbstractValidator<UserAddCommandSettings>
{
public UserAddCommandSettingsValidator()
{
RuleFor(x => x.Name)
.NotEmpty().WithMessage("User name is required.")
.MinimumLength(2).WithMessage("User name must be at least 2 characters long.");
RuleFor(x => x.Email)
.NotEmpty().WithMessage("User email is required.")
.EmailAddress().WithMessage("A valid email address must be provided.");
}
}
Validation runs before DoExecuteAsync, so an invalid invocation never reaches your code:
$ sample user add "A" -e "invalid-email"
Error: User name must be at least 2 characters long.
A valid email address must be provided.
Mind the exit code. A failed Validate is reported by Spectre.Console.Cli itself, which
short-circuits with its own exit code of -1 (255 as the shell sees it) — not with
ExitCode.InvalidInput. ExitCode.InvalidInput is what your command returns when it rejects input
itself; see Exit codes.
9. Token expansion in settings
Mark a string setting with [SupportsTokens] and the CommandArgumentsRootProcessor rewrites its
value before DoExecuteAsync runs. {date} and {datetime} are resolved out of the box.
[CommandOption("-o|--output-path <PATH>")]
[Description("Output destination path. Supports tokens like '{date}' and '{datetime}'.")]
[SupportsTokens]
[DefaultValue("./processed-{date}/output.dat")]
public string OutputPath { get; set; } = "./processed-{date}/output.dat";
The command body sees the expanded value, never the template:
$ sample file process dataset.csv -o "./out-{date}/result.dat"
Executing command FileProcessCommandSettings
Processing arguments...
Processing File: dataset.csv
Resolved Output Path (with tokens replaced): ./out-2026-08-22/result.dat
Backup enabled: True
Processing file content...
File processed successfully!
Saved result to: ./out-2026-08-22/result.dat
Token expansion only happens for commands whose base class runs the settings processor — the
asynchronous ones. AppCommand<TSettings> does not take a processor.
10. Use cases and Ardalis.Result
When the real work belongs in an application layer rather than in the CLI, put it in an
IResultUseCase<TRequest, TResponse> and let UseCaseAsyncCommand<,,,> do the plumbing.
public class CreateProjectUseCase(IProjectRepository projectRepository)
: IResultUseCase<CreateProjectRequest, CreateProjectResponse>
{
public async Task<Result<CreateProjectResponse>> ExecuteAsync(CreateProjectRequest request,
CancellationToken cancellationToken = default)
{
var existing = await projectRepository.GetByNameAsync(request.Name, cancellationToken);
if (existing != null)
{
return Result<CreateProjectResponse>.Conflict($"A project with name '{request.Name}' already exists.");
}
var project = new ProjectItem(request.Name, request.Description, request.Template, DateTime.UtcNow);
await projectRepository.AddAsync(project, cancellationToken);
return Result<CreateProjectResponse>.Success(new(project.Name, project.Description, project.Template, project.CreatedAt));
}
}
The command shrinks to a single mapping method — everything else, including rendering the result, is inherited:
public class ProjectCreateCommand(IOutput output,
CreateProjectUseCase useCase,
CommandArgumentsRootProcessor settingsProcessor,
ICommandSettingsValidator<ProjectCreateCommandSettings> validator,
IExceptionHandler exceptionHandler)
: UseCaseAsyncCommand<ProjectCreateCommandSettings, CreateProjectUseCase, CreateProjectRequest, CreateProjectResponse>(
output, useCase, settingsProcessor, validator, exceptionHandler)
{
protected override CreateProjectRequest CreateRequest(ProjectCreateCommandSettings commandSettings) =>
new(commandSettings.Name, commandSettings.Description, commandSettings.Template);
}
Success and failure are rendered by the base class, which returns ExitCode.Success and
ExitCode.Error respectively. Override ProcessSuccessResponse or ProcessFailureResponse to
change either.
$ sample project create MicroserviceDemo -d "Cloud native backend" -t WebAPI
Starting use case CreateProjectUseCase
Settings:
Name: MicroserviceDemo
Description: Cloud native backend
Template: WebAPI
Use case completed successfully.
$ sample project create SpectreDemo
Starting use case CreateProjectUseCase
Settings:
Name: SpectreDemo
Description: Sample project
Template: Console
Use case failed: A project with name 'SpectreDemo' already exists.
[exit code 1]
11. Exit codes
ExitCode is the contract between your commands and whatever script calls them.
| Member | Value | Meaning |
|---|---|---|
Success |
0 | The command completed. |
Error |
1 | The command ran and failed. |
InvalidInput |
2 | The command rejected the input it was given. |
Cancelled |
130 | The command stopped because cancellation was requested (128 + SIGINT). |
Two exit codes do not come from this enumeration:
-1—Spectre.Console.Clicould not bind or validate the command line (unknown command, missing required argument, failedValidate). It is produced before your command runs.- Whatever
IExceptionHandlerreturns — an unhandled exception inside a command is routed to the handler, and the handler's return value is the exit code.
Return InvalidInput from a command when the input parses fine but is not acceptable — a value
outside a supported set, a file that does not exist, mutually exclusive flags:
if (!SupportedFormats.Contains(settings.Format, StringComparer.OrdinalIgnoreCase))
{
Output.MarkupLineInterpolated($"[red]Unsupported format '{settings.Format}'. Supported formats: {string.Join(", ", SupportedFormats)}.[/]");
return ExitCode.InvalidInput;
}
$ sample user list -f xml
Unsupported format 'xml'. Supported formats: table, compact.
$ echo $LASTEXITCODE
2
A destructive command needs the same discipline in reverse: confirm before acting, and distinguish "you did not tell me it was safe to proceed" from "you told me to stop".
if (!AnsiConsole.Profile.Capabilities.Interactive)
{
Output.MarkupLineInterpolated($"[yellow]Refusing to delete user {userId} without confirmation. Re-run with --force.[/]");
return ExitCode.InvalidInput; // nobody can answer a prompt in a pipeline
}
return AnsiConsole.Confirm($"Delete user {userId}?", defaultValue: false) ? null : ExitCode.Cancelled;
12. Cancellation
Every DoExecute / DoExecuteAsync receives a CancellationToken. Forward it — into service
calls, Task.Delay, HTTP requests, database queries. A command that accepts the token and ignores
it cannot be interrupted.
protected override async Task<ExitCode> DoExecuteAsync(CommandContext context,
FileProcessCommandSettings settings,
CancellationToken cancellationToken)
{
await AnsiConsole.Status()
.Spinner(Spinner.Known.Dots)
.StartAsync("Processing file content...",
async _ => await Task.Delay(TimeSpan.FromMilliseconds(300), cancellationToken));
return ExitCode.Success;
}
The base class treats cancellation as an outcome rather than a fault: an OperationCanceledException
is not passed to IExceptionHandler, it becomes ExitCode.Cancelled (130).
Where the token comes from
AppBuilder.Create owns the CancellationTokenSource, and ConfigureCommandApp hands its token to
Spectre, which passes it to every command. The source is also registered in the container, so a
command that needs to request shutdown itself can resolve a CancellationTokenSource and cancel it.
Interrupting the application from the keyboard follows a two-step contract:
| Interrupt | Effect |
|---|---|
| First Ctrl+C | Cancels the token; the command is expected to stop cooperatively. The application prints Shutting down... press Ctrl+C again to force an exit. |
| Second Ctrl+C | Takes the default path and terminates the process, so a command that ignores its token cannot hang the application |
A cancellation callback that throws is reported rather than allowed to escape — an unhandled
exception on the Console.CancelKeyPress thread would terminate the process, which is the opposite
of the shutdown being requested.
EnvironmentSettings.PauseBeforeExit is skipped after cancellation: prompting for Enter on the way
out of a shutdown the user just asked for would turn it into a hang.
13. Logging with Serilog
Ploch.CommandLine.Spectre.Serilog configures Serilog as the logging provider, with a console sink
and two rolling files — everything, and errors and warnings only:
using Ploch.CommandLine.Spectre.Serilog;
.ConfigureServices((context, services) =>
{
services.AddSerilog(context.Configuration,
logName: "sample",
logPath: Path.Combine(AppContext.BaseDirectory, "logs"));
})
Levels come from the Serilog section of appsettings.json:
{
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": { "Microsoft": "Warning", "System": "Warning" }
}
}
}
Inject ILogger<TCommand> into a command and use it for the operator's record, keeping IOutput
for what the user reads:
logger.LogWarning("[UserDeleteCommand] Delete requested for unknown user {UserId}", settings.Id);
Output.MarkupLineInterpolated($"[red]User with ID {settings.Id} was not found.[/]");
$ cat logs/sample.log
2026-08-22 14:36:16.690 +02:00 [WRN] [UserDeleteCommand] Delete requested for unknown user 99
$ cat logs/sample-errors.log
[14:36:16 WRN] [Ploch.CommandLine.Spectre.SampleApp.Commands.Users.UserDeleteCommand] [UserDeleteCommand] Delete requested for unknown user 99
14. Testing commands
Commands are ordinary classes with constructor dependencies, so they are tested without a host.
Construct the command with test doubles, build a CommandContext, and call the public
Execute / ExecuteAsync — that path exercises validation, exception handling and cancellation as
well as your DoExecute body.
public class UserListCommandTests
{
private readonly Mock<ICommandSettingsValidator<UserListCommandSettings>> _validatorMock = new();
private readonly Mock<IExceptionHandler> _exceptionHandlerMock = new();
private readonly Mock<IOutput> _outputMock = new();
private readonly Mock<IUserService> _userServiceMock = new();
private readonly CommandArgumentsRootProcessor _processor = new([]);
[Fact]
public async Task ExecuteAsync_should_return_invalid_input_when_the_format_is_not_supported()
{
var settings = new UserListCommandSettings { Format = "xml" };
var context = new CommandContext([], Mock.Of<IRemainingArguments>(), "list", null);
var command = new UserListCommand(_processor,
_validatorMock.Object,
_exceptionHandlerMock.Object,
_outputMock.Object,
_userServiceMock.Object);
var result = await command.ExecuteAsync(context, settings, CancellationToken.None);
result.Should().Be((int)ExitCode.InvalidInput);
}
}
Two things worth testing that are easy to overlook:
- Token expansion. Give the processor a
TokensArgumentsProcessor(new CommandArgumentsRootProcessor([new TokensArgumentsProcessor()])) and assert the setting was rewritten. - Cancellation. Pass an already-cancelled token and assert
ExitCode.Cancelled, plus that the exception handler was never called — that proves cancellation is not being reported as a failure.
15. Development conveniences
Set DEV_RUNTIME_CONSOLE_EXIT_PAUSE=true and the application waits for Enter before exiting, so a
console window launched from an IDE does not close before you can read it. It is read from the
environment, so it never affects a build server or an end user who has not set it.
Environment variables prefixed DEV_RUNTIME are collected into EnvironmentSettings.Current
alongside the debugger state.
Running the sample
# From the repository root, against the library sources in this repository:
dotnet run --project samples/SampleApp/src/SampleApp -p:UsePlochProjectReferences=true -- --help
dotnet test samples/SampleApp/Ploch.CommandLine.Spectre.SampleApp.slnx -p:UsePlochProjectReferences=true
See samples/SampleApp/README.md for the full command tour and
for the difference between the standalone (NuGet) and in-repository (project reference) build
modes.
If you copy that switching trick into a sample of your own, put the conditional import in
Directory.Build.targets rather than Directory.Build.props: props is evaluated before the project
body, so <PackageReference Remove="..." /> would have nothing to remove and the project would end
up with both a package reference and a project reference to every library.