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 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 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); } 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}!")); } 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.
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)); } 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) { // ... } } 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.
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; } } 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.
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); 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.
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; } } 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}")); } 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.
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); 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))); } 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); 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; } } 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) 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(); 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.
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); 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.
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); 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) })); } Source:https://dev.to/djon/cmdstarter-10-super-easy-cli-integration-3bd7Related posts
Why AI Coding Assistants (Cursor & Claude Code) Leak Secrets — And How to Stop It

Cómo mencionar a Claude Code en un canal de osFoundry como a un compañero de equipo
FastH3 OmniRef in ComfyUI: 8-Step Distillation, VSA Sparse Acceleration and Multi-Reference Image/Video/Audio Generation

Come passare lavoro da osFoundry a una chat di Claude collegata








