DEV Community

Cover image for CmdStarter 1.0: Super Easy CLI integration
Jonathan Boivin
Jonathan Boivin

Posted on AI-assisted

CmdStarter 1.0: Super Easy CLI integration

System.CommandLine 2.0 is stable at last, and it is a good parser. What it does not do is decide how your commands are organized in code. You still write the Command objects, the Option<T> objects, the handlers and the wiring between them. CmdStarter is a layer over System.CommandLine 2.0.14 that does that wiring from plain C# code: a class becomes a command, its public properties become options, the parameters of its handler become arguments. Version 1.0.0 is out on NuGet, for .NET 10.

I maintain CmdStarter. This article shows what the library does in 1.0, feature by feature, with the code of the samples that ship in the repository, and ends with what changed since the 0.9 betas.

Why a layer over System.CommandLine

System.CommandLine handles parsing, help, completions and the exit codes of parse errors. CmdStarter handles everything around it:

  • it finds the commands in your assemblies, at compile time through a generated catalog or at run time through reflection;
  • it builds the command tree from your namespaces, your attributes or a tree written in code;
  • it creates the System.CommandLine symbols from your properties and parameters, with their default values and their validators;
  • at execution, it creates a fresh instance of the command, binds the options onto it, runs your filters and your handler, and turns the result into an exit code.

The three stages of Starter.Start: find the command types, build the tree, create the System.CommandLine tree, then execute each command

The pipeline of a Starter. The three stages run once; every call then executes one command.

The result stays a System.CommandLine application: Starter.RootCommand is the real root command, Starter.FindCommand<T>() gives the node of any of your commands, and you can add your own validators or symbols to it.

Getting started in eight lines

Install the package in a net10.0 project:

dotnet add package cints.CmdStarter
Enter fullscreen mode Exit fullscreen mode

The entry point passes the arguments to a Starter and returns its exit code:

using CyberInternauts.CmdStarter;

public static class Program
{
    public static Task<int> Main(string[] args) => new Starter().Start(args);
}
Enter fullscreen mode Exit fullscreen mode

The command is a class that derives from StarterCommand. It needs a namespace: CmdStarter only discovers classes that have one.

using CyberInternauts.CmdStarter;

namespace HelloWorld.Commands;

public sealed class Hello : StarterCommand
{
    public string To { get; set; } = "World";

    public override Delegate HandlingMethod => (Action)(() => Console.Out.WriteLine($"Hello, {To}!"));
}
Enter fullscreen mode Exit fullscreen mode

The public read/write property To becomes the option --to, and its initial value World is the default that the help shows. HandlingMethod returns the handler. Since Hello is the only command of the application, it is rooted: its option and its handler go on the root command, and it runs without typing its name.

Terminal: the HelloWorld sample run with --help lists the --to option with its default World, and run with --to Bob prints Hello, Bob!

The help comes from System.CommandLine. The default value comes from the property initializer. The Description line is empty because the sample declares no [Description].

Three ways to declare a command

CmdStarter 1.0 has two command models, and the class model has two forms. All three can live in the same application.

A class that derives from StarterCommand

This is the form above. The class can also implement IStarterCommand directly, without the base class, if it provides HandlingMethod and a settable GlobalOptionsManager property. The handler is a Delegate: a lambda cast to a delegate type, or a method group. Its parameters become positional arguments, in order:

public sealed class Add : StarterCommand
{
    public override Delegate HandlingMethod => (Action<int, int>)((left, right) => Console.Out.WriteLine(left + right));
}
Enter fullscreen mode Exit fullscreen mode

With two classes Add and Subtract, the application has the commands add and subtract, named from the class names in kebab-case: add 2 3 prints 5.

A method marked [Command]

A public method marked [Command] is a command. A class marked [Command("service")] that declares such methods becomes a group, and its methods become its subcommands. Here is the service group of the DevOpsTool sample, which takes a service in its constructor:

[Command("service")]
public sealed class ServiceCommands(IDeploymentClient client)
{
    [Command]
    public async Task<int> DeployAsync(string name, DeployOptions options, [Option] string version = "latest")
    {
        // ...
    }

    [Command]
    public int Rollback(string name, DeployOptions options)
    {
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

The method name gives the command name, with a trailing Async removed: DeployAsync is service deploy. Its parameters are classified when the tree is built, in this order: a CancellationToken, a CommandConsole, a global options container (DeployOptions here), a [FromServices] parameter, an [Option] parameter (--version, with its default latest), a type that the service provider knows, and anything else is a positional argument (name). A static method is a command too, and needs no instance.

Terminal: the DevOpsTool sample run with service --help lists the deploy and rollback subcommands, each with a name argument, and the recursive options --environment and --dry-run

The help of the service group: its two methods are its subcommands, and the global options are accepted at every level.

A [Command] name can be a path: [Command("admin users")] on a class, or [Command("report daily")] on a method, creates the intermediate groups when they do not exist.

A plain class marked [Command]

New in 1.0: a class with no base class and no interface is a command too, when it carries [Command] and declares no [Command] method. Its public read/write properties are its options, and its handler is its only public instance method:

using CyberInternauts.CmdStarter.Attributes;

namespace PlainCommand.Commands;

[Command]
public sealed class Greet
{
    public string Greeting { get; set; } = "Hello";

    public int Run(string name)
    {
        Console.Out.WriteLine($"{Greeting}, {name}!");
        return 0;
    }
}
Enter fullscreen mode Exit fullscreen mode

greet Bob --greeting Hi prints Hi, Bob!. When the class has several public methods, [Command(Handler = nameof(Show))] names the handler. The parameters of the handler follow the rules of the method model, so a plain command can take services, a cancellation token or a global options container as parameters. It keeps your domain classes free of any dependency on the library, apart from the attribute.

Terminal: the PlainCommand sample run with greet Bob --greeting Hi prints Hi, Bob!, and run with --help lists the commands greet with a name argument and report with a topic argument

The PlainCommand sample: two plain classes, one with a Handler.

What the handler returns

The return value of the handler, whichever model, is the exit code of the process:

The handler returns Exit code
void, Task, ValueTask 0
int that value
Task<int>, ValueTask<int> the awaited value

Only leaf commands run a handler. A command that has subcommands is the path to them.

Options, arguments and defaults

In the class model, every public instance property with a getter and a setter is an option, named -- followed by the kebab-case of the property name (MyInt becomes --my-int). Any setter counts, even a private or an init one. Three attributes change the selection: [NotOption] removes a property, [Option] switches the class to explicit mode where only the marked properties are options, and [AllOptionsExcluded] removes every property that the class declares.

The initial value of a property becomes the default value of its option, and the help shows it. The value is skipped when it is null, an empty string or collection, the default of a value type such as 0, or when the option is [Required]. A bool property initialized to true keeps true when the option is omitted, which 0.9 got wrong.

The parameters of a class handler are positional arguments. A parameter with a default value is optional. A CancellationToken parameter receives the token of the execution and creates no argument, and a CommandConsole parameter receives the console of the execution.

Attributes on properties and parameters add [Description], [Hidden], [Alias] and [AutoComplete] (values from an enum, a provider or a factory), and the DataAnnotations attributes described below.

Nesting commands

A command ends up under another one in four ways, and they combine.

By namespace. A class command that is alone in its namespace becomes the parent of the commands in the deeper namespaces. In the NestedCommands sample, Remote is the only command in Commands, and Add lives in Commands.RemoteCommands, so the command is remote add origin <url>.

With attributes. [Parent<T>] on a class makes it a subcommand of T, wherever T is declared. [Children<T>] or [Children("Some.Namespace")] on a class adopts the commands of a namespace. Starter.ClassesBuildingMode chooses between attributes, namespaces, or both (the default).

With paths in the method model, as shown above: each segment of [Command("admin users")] that does not exist yet becomes an implicit group, and an explicit group declared later adopts it.

In code. Starter.UseTree replaces discovery and placement of the class commands by a tree written in code, with groups, names and aliases:

return await new Starter()
    .UseTree(tree => tree
        .Add<Users>(users => users
            .Add<ListUsers>(list => list.WithAliases("ls")))
        .Add<Groups>(groups => groups
            .Add<ListGroups>())
        .Group("admin", admin => admin
            .Add<Reset>()))
    .Start(args);
Enter fullscreen mode Exit fullscreen mode

Two List classes in different namespaces, both at the root, are a name conflict, which CmdStarter reports when it builds the tree. Starter.GroupsByNamespace solves that case: the namespace segments after the common base namespace become groups, so Users.List and Groups.List become users list and groups list.

Terminal: the DevOpsTool sample run with --help lists the global options --environment with its default dev and --dry-run, then the commands status and service; run with service deploy api --version 1.2.3 it prints Deploying api 1.2.3 to dev

The DevOpsTool sample: a class command, a method group, global options and services. The root help shows the first level only; service --help shows the group.

Global options

A global options container is a class that implements the marker interface IGlobalOptionsContainer. Its public read/write properties become recursive options of the root, accepted by every command:

public sealed class DeployOptions : IGlobalOptionsContainer
{
    public string Environment { get; set; } = "dev";

    public bool DryRun { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

A class command reads it with GetGlobalOptions<DeployOptions>(). A method command, or the handler of a plain command, receives it as a parameter, with no attribute. The container holds the values of the current execution, with the initial values for the options that are not on the command line.

Validation with DataAnnotations

Each ValidationAttribute on an option property, a handler parameter, a [Command] method parameter or a global option becomes a System.CommandLine validator. [Range], [RegularExpression], [AllowedValues] and [Required] work out of the box, and ErrorMessage replaces the default message:

using System.ComponentModel.DataAnnotations;

public sealed class Serve : StarterCommand
{
    [Range(1, 65535, ErrorMessage = "Port must be between 1 and 65535.")]
    public int Port { get; set; } = 8080;
    public override Delegate HandlingMethod => (Action)(() => Console.Out.WriteLine($"Serving on port {Port}"));
}
Enter fullscreen mode Exit fullscreen mode

A failed check is a parse error: System.CommandLine reports it, the exit code is non-zero and the handler does not run. An option you leave out is not validated, even when its default is outside the range. [Required] on an option also makes it required, and a handler parameter without a default value is a required argument without any attribute.

Terminal: serve --port 8080 prints Serving on port 8080 and the exit code is 0; serve --port 70000 prints Port must be between 1 and 65535. followed by the help of serve, and the exit code is 1

The Validation sample. The message is the one of the attribute, on the error output; System.CommandLine then prints the help of the command, as for any parse error.

Dependency injection, one scope per execution

CmdStarter creates a command instance for every execution, and releases it afterwards, even when the handler throws. The instance comes from the first of these that applies: a static GetInstance<T>() declared by the command type, Starter.ServiceProvider, the factory of Starter.SetFactory, or the parameterless constructor.

With Microsoft.Extensions.DependencyInjection, CmdStarterBuilder builds the application. Each execution gets its own AsyncServiceScope, so scoped services are shared inside one execution and renewed for the next:

var builder = new CmdStarterBuilder().AddCommandsFromAssembly(typeof(Program).Assembly);
builder.Services.AddSingleton<IGreetingService, GreetingService>();
await using var app = builder.Build();
return await app.RunAsync(args);
Enter fullscreen mode Exit fullscreen mode

A class command takes its services in its constructor. A [Command] method takes them as parameters, without any attribute when the type is registered: the builder validates scopes and registrations when it builds the provider.

public sealed class Greet : StarterCommand
{
    private readonly IGreetingService service;

    public Greet(IGreetingService service) => this.service = service;

    public string To { get; set; } = "World";

    public override Delegate HandlingMethod => (Action)(() => Console.Out.WriteLine(service.Greet(To)));
}
Enter fullscreen mode Exit fullscreen mode

Without the builder, IServiceCollection.AddCmdStarter(options => ...) registers a configured Starter in an existing service collection, with no extra package. For another container, Starter.SetFactory(type => container.GetInstance(type)) plugs it in; the SimpleInjector demo of the repository does exactly that.

Filters around every execution

A filter is a class that implements ICommandFilter. Its single method receives a CommandContext and a next delegate, and returns the exit code that the caller sees. The chain runs after the instance is created and bound, so a filter sees the bound values. The first registered filter is the outermost:

public static Task<int> Main(string[] args) => new Starter()
    .AddFilter<LoggingFilter>()
    .AddFilter<ConfirmationFilter>()
    .Start(args);
Enter fullscreen mode Exit fullscreen mode
public sealed class LoggingFilter : ICommandFilter
{
    public async Task<int> InvokeAsync(CommandContext context, Func<Task<int>> next)
    {
        var exitCode = await next();
        context.Console.Error.WriteLine($"[filter] {context.CommandPath} -> {exitCode}");
        return exitCode;
    }
}
Enter fullscreen mode Exit fullscreen mode

A filter that does not call next refuses the command. The confirmation filter of the sample reads an attribute on context.Member and the --yes global option in context.ParseResult, and returns 3 when the confirmation is missing. The context also exposes the command, its instance, the services of the execution scope, the console and the cancellation token. With the builder, AddFilter<T>() registers the filter as a transient, so its constructor can take scoped services.

Command states

An interactive application has commands that only make sense in some states: login before, whoami after. CmdStarter defines no state. Your application exposes its own values through an ICommandStateProvider, and marks commands with [AvailableInState(...)]:

[Command]
[AvailableInState(SessionState.LoggedOut)]
public static void Login(string user, Session session)
// ...
[Command]
[AvailableInState(SessionState.LoggedIn)]
public static void Whoami(Session session)
Enter fullscreen mode Exit fullscreen mode

Before each Start, CmdStarter reads the current state. An unavailable command, and its whole subtree, is hidden from the help and from the completions, and typing it anyway returns 1 with Command '<path>' is not available in the current state. The tree is not rebuilt. The provider comes from Starter.StateProvider or from the service provider, as a singleton. Hiding a command is an interface decision, not a security measure: the handler still checks the rights.

A REPL with history, completion, sequences and pipes

ReplStarter is a Starter that reads lines in a loop from an IReplInputProvider. ConsoleReplProvider reads the console with a line editor: history with Up and Down, Tab completion of commands, options and [AutoComplete] values, Escape to clear the line, Ctrl+C to abandon the line. With the builder, UseRepl and RunReplAsync give the REPL one service scope per command:

var builder = new CmdStarterBuilder()
    .AddCommandsFromAssembly(typeof(Program).Assembly)
    .UseRepl(new ConsoleReplProvider());
builder.Services.AddSingleton<Session>();
builder.Services.AddScoped<CommandScope>();

await using var app = builder.Build();
await app.RunReplAsync();
Enter fullscreen mode Exit fullscreen mode

The built-in words exit and quit end the loop, and help [command] shows the help. Every line is a sequence: ; always runs the next command, && runs it after a success, || after a failure, with shell semantics and no precedence. A single | is a text pipe: the output of one command is captured in memory and becomes the input of the next, read through its CommandConsole.In. A line with no operator runs exactly as before.

Terminal: five lines piped into the Repl sample: help prints the root help, login ann prints Logged in as ann (scope 1), whoami prints ann (scope 2), login bob && whoami prints Logged in as bob (scope 3) then bob (scope 4), and exit ends the session

The Repl sample, fed through its standard input, so the typed lines are not echoed. The session is a singleton; the scope number is renewed for each command, and the two commands of the && line get two scopes.

Ctrl+C during a command cancels that command only, which returns 130, and the REPL reads the next line. Sequences also work outside the REPL: Starter.StartSequence(line) runs one, and Starter.SequenceOption = "-c" lets your application accept app -c "login ann; whoami".

Remote REPL and daemon mode

ReplPipeServer serves a REPL over a named pipe on the local machine, one session at a time, with a fresh ReplStarter per session so that a login lives and dies with its session. ReplPipeClient is the other end: ExecuteAsync sends one line and copies back the output, the errors and the exit code, and RunInteractiveAsync loops over it. The protocol is one line per message, UTF-8, and the pipe is opened with PipeOptions.CurrentUserOnly.

Daemon mode builds on it. The same executable, when it is already running, executes the command lines of the next invocations, so they skip the startup of a new process and share its state:

var local = CreateLocalStarter(CreateServices());
return await ReplPipeClient.TryForwardAsync(pipeName, args, TimeSpan.FromMilliseconds(500), CommandConsole.Standard, local.SequenceOption) ?? await local.Start(args);
Enter fullscreen mode Exit fullscreen mode

TryForwardAsync joins the arguments into one REPL line, quoting what needs it, and returns the exit code of the server, or null when no server answered within the timeout, so the application runs the arguments itself. The session of a daemon empties ExitCommands and HelpCommands, so app exit behaves as Start(args) would.

Terminal: the Daemon sample's demo mode prints forwarded: 1, forwarded: 2 and local: 1

The Daemon sample: two invocations forwarded to the running copy, then the daemon stops and the third runs locally.

Generic Host

The separate package cints.CmdStarter.Hosting runs the application on the .NET Generic Host, with its configuration, options, logging and hosted services. AddCmdStarter registers it on an IHostApplicationBuilder, and RunCmdStarterAsync starts the host, runs the command or the REPL, and stops the host:

var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings { ContentRootPath = AppContext.BaseDirectory });
builder.Services.Configure<GreetingOptions>(builder.Configuration.GetSection("Greeting"));
builder.AddCmdStarter(options => options.AddCommandsFromAssembly(typeof(Program).Assembly));
using var host = builder.Build();
return await host.RunCmdStarterAsync(args);
Enter fullscreen mode Exit fullscreen mode

A command then takes IOptions<GreetingOptions> or ILogger<T> like any other service. The console lifetime of the host is replaced by a silent one, so no Application started message mixes with your output, and Ctrl+C keeps its CmdStarter meaning.

Testing in memory

Keep the composition in one method that returns the CmdStarterBuilder, use it in Main and in the tests, and replace a service with a fake before Build():

[Test]
public async Task TransfersThroughTheAccountsService()
{
    var builder = TransferApp.CreateBuilder();
    var fake = new FakeAccounts(true);
    builder.Services.Replace(ServiceDescriptor.Singleton(typeof(IAccounts), fake));
    await using var app = builder.Build();
    var result = await app.RunAsync(["transfer", "alice", "bob", "--amount", "10"]);
    Assert.That(result, Is.Zero);
    Assert.That(fake.Calls, Is.EqualTo(new[] { ("alice", "bob", 10) }));
}
Enter fullscreen mode Exit fullscreen mode

To capture the output, give the handler a CommandConsole parameter and pass a CommandConsole built on StringWriters to Starter.Start(args, console). Help, parse errors and the library's own messages follow that console too, so a test never touches System.Console.

Errors and exit codes

The exit codes are predictable, which matters for scripts:

Situation Exit code
Handler returns void, Task or ValueTask 0
Handler returns an int that value
Parse error: unknown command, missing argument, bad value, failed validation non-zero, from System.CommandLine
Exception during activation, binding, the handler or the release 1, with the message on one line on the error output
Command not available in the current state 1
Cancelled command 130
Sequence or pipe the code of the last command that ran

Starter.Start never throws for a parse error. An unhandled exception writes only its message by default; Starter.ShowExceptionDetails = true hands it to System.CommandLine, which prints the whole exception. Configuration errors are different: they derive from CmdStarterException and are thrown while the tree is built, so a parent loop, a second [Parent], a missing state provider or two commands with the same name fail at startup, not when a user types the branch.

Compile-time help: the analyzer and the command catalog

The package ships a Roslyn analyzer with 17 rules, CMDS0001 to CMDS0017. Most of them repeat at compile time the checks that CmdStarter makes when it builds the tree: a parent cycle, several [Parent] attributes, a blank command name, a non-public or generic [Command] method, a ref parameter, a class that mixes the two models, a plain command without a usable handler, an invalid Handler. Two are advice: a command option that hides a global option, and an empty global options container. They are warnings, so a project with TreatWarningsAsErrors cannot build a broken command.

The same assembly holds a source generator. In every project that references the package, it writes a command catalog: a class that lists the command classes, the method command classes and the global options containers of the assembly, plus one line per value type that the options and arguments use. Starter reads the catalog instead of scanning the assembly by reflection, and the value types get typed symbol factories that create the System.CommandLine symbols without building generic types at run time. On CoreCLR nothing observable changes, and the measures show no measurable difference at startup. The catalog is what makes the next section possible.

Trimming and Native AOT

Version 1.0 supports PublishTrimmed and PublishAot for an application that has the command catalog, which the package generates by default. IsTrimmable and IsAotCompatible are set on both packages, and the library builds with zero IL warnings. The criterion was functional parity, measured on win-x64 with four applications and 26 commands, trimmed and as native executables, with the package built from the sources and again with the package that nuget.org serves: the same exit codes and the same outputs as on CoreCLR, including collection options, services injected by the container or by ActivatorUtilities, filters registered by type, commands created by their own GetInstance, and trees written with UseTree.

What is lost in a Native AOT executable is listed, not hidden: an assembly without a catalog is not scanned, so its commands are not found; and a value type that the catalog cannot name (a private enum, for example) loses its option, which the generator reports at compile time with CMDS0016. Trimming and AOT were not tried on other platforms.

Upgrading from the 0.9 betas

Only 0.9 betas were ever published, so this release is 1.0.0, and it accepts breaking changes. The migration guide lists them all; here are the ones that touch every application:

  • Namespaces. com.cyberinternauts.csharp.CmdStarter.Lib becomes CyberInternauts.CmdStarter. The assembly stays CmdStarter.Lib and the package cints.CmdStarter.
  • Constants are PascalCase: Starter.EXCLUSION_SYMBOL becomes Starter.ExclusionSymbol, StarterCommand.OPTION_PREFIX becomes StarterCommand.OptionPrefix.
  • StarterCommand is no longer a System.CommandLine Command. It is an adapter. Starter.FindCommand<T>() gives the node, Starter.FindCommandInstance<T>() gives the description instance. UnderlyingCommand and GenericStarterCommand<T> are gone, and a class that only implements IStarterCommand needs no wrapper any more.
  • One instance per execution. The object used to build the tree is never executed. Subscribe to CommandHandled to see the instance that ran.
  • IServiceManager is removed. Use Starter.ServiceProvider, CmdStarterBuilder or Starter.SetFactory, which is now an instance method, not a process-wide setting.
  • .NET 10 and System.CommandLine 2.0.14. Option.Name includes the -- prefix, and the properties are Hidden and Required. Help is localized by System.CommandLine.
  • Validation attributes apply. Annotations that 0.9 ignored now reject values.
  • Exceptions print one line and return 1, unless ShowExceptionDetails is on.
  • REPL input providers return null at the end of the input, and ;, &&, || and | are operators in a REPL line.

What 1.0 adds without breaking anything: the method model, plain commands, the fluent tree, namespace groups, filters, command states, the per-execution console, the REPL with line editing, sequences and pipes, the remote REPL, the daemon mode, the Generic Host package, the analyzer, the command catalog and trimming and Native AOT.

How it compares

The repository has a comparison page with Spectre.Console.Cli, CliFx and Cocona, with every fact sourced from their packages and READMEs. In short: the three others target .NET Standard 2.0, Spectre.Console.Cli comes with rich console rendering, and CliFx has almost no dependency. CmdStarter is different on the REPL with sequences and pipes, the remote REPL and the daemon mode, the three ways to declare a command, the compile-time analyzers, the command states and filters, and the measured trimming and Native AOT support, all on top of System.CommandLine rather than a parser of its own.

Links

I would welcome feedback on the API, on the documentation, and on whether the limits of trimming and Native AOT are clear. Issues and ideas go to the Jira project or to GitHub.

Top comments (0)