DEV Community

Cover image for Argument Parsing and Command Routing for .NET CLI Tools - Kiwify.Kiwi.CLI
Ajay Jain
Ajay Jain

Posted on

Argument Parsing and Command Routing for .NET CLI Tools - Kiwify.Kiwi.CLI

Part of the Kiwi Foundation

Kiwify.Kiwi.CLI is the argument-parsing and command-routing layer of the Kiwi Foundation - a trio of libraries (Presentation, Renderer, CLI) that together provide composable infrastructure for building .NET command-line and operational tooling applications.

CLI sits above Presentation: it uses IOutput and StyledConsole to format help text and validation errors. The dependency flows one way: CLI → Presentation. Renderer composes alongside CLI when applications need structured-data rendering, reporting, or multi-format output.


Source and installation

GitHub:

Package Source
Kiwify.Kiwi.CLI Kiwify.Kiwi.CLI
Kiwify.Kiwi.CLI.Declarative Kiwify.Kiwi.CLI.Declarative
Kiwify.Kiwi.CLI.Configuration Kiwify.Kiwi.CLI.Configuration
Kiwify.Kiwi.CLI.DependencyInjection Kiwify.Kiwi.CLI.DependencyInjection

NuGet:

dotnet add package Kiwify.Kiwi.CLI
dotnet add package Kiwify.Kiwi.CLI.Declarative           # attribute-based command definition
dotnet add package Kiwify.Kiwi.CLI.Configuration         # IConfiguration bridge
dotnet add package Kiwify.Kiwi.CLI.DependencyInjection   # DI command resolver
Enter fullscreen mode Exit fullscreen mode

Design philosophy

CLI is built around the principle that command objects should be ordinary C# classes - no required base types, no framework interfaces at the call site. The library binds parsed argument values to properties through Expression<Func<TCommand, object?>> selectors compiled at registration time, so member resolution occurs during registration rather than repeatedly during parsing.

Two configuration paths - fluent (programmatic) and declarative (attribute-based) - both produce the same Command<T> internally. The choice between them is a project convention decision, not an architectural one. Teams can mix styles across commands in the same application.

Parsing, validation, and command execution are intentionally separated into distinct stages. A command can be parsed and validated without invoking its handler, making it straightforward to inspect parsed state, run pre-execution checks, and test validation behaviour in isolation. This separation also supports dry-run scenarios and custom hosting arrangements where the application controls when - and whether - the handler is invoked.

All user-facing output - help text, validation errors, command results - flows through IOutput from Kiwi Presentation. CLI output participates in the same pipeline used by Presentation controls: consistent, testable, and redirectable without special handling. StringOutput captures all output in unit tests without mocking Console.

Validation is a first-class concern rather than middleware. Cross-property constraints - conditional requirements, all-or-none groups, exactly-one-of groups - are expressed as typed rule builders that operate on the command object directly, not on raw argument strings.

Command activation is separated from argument parsing and validation. Commands may be instantiated directly or resolved through dependency injection, allowing CLI applications to integrate naturally with broader application infrastructure while keeping command models framework-light. Kiwi CLI integrates with standard .NET dependency injection patterns and the Kiwi declarative registration extensions built on top of Microsoft.Extensions.DependencyInjection.


Intent

Argument parsing involves more than matching flags to properties. Options need multiple aliases, type conversion, and constraint checking - including cross-property constraints that cannot be expressed per-option. Different commands share an application host but need independent option sets. Help text must be generated, formatted, and wrapped consistently. Large codebases benefit from convention-based registration to reduce per-command ceremony. In operational tooling, parsed values often need to flow into IConfiguration to participate in the broader application configuration graph alongside JSON files and environment variables.

CLI addresses these concerns by separating argument parsing, command routing, and output into distinct, independently testable layers. Command objects remain framework-free; the library acts as a wiring layer that connects raw string[] arguments to typed, validated command state and then invokes a handler.


Architectural benefits

Benefit Description
Plain command objects Command types are ordinary C# classes. No framework base types or marker interfaces required. Properties are bound via Expression<Func<TCommand, object?>> - member resolution happens at registration, making the parse-time path straightforward.
Two configuration paths Fluent (programmatic) and declarative (attribute-based) both produce the same Command<T>. Switch between them per-command or per-project without any architectural difference.
First-class validation Four built-in rule builders (custom, conditional required, group-or-none, exactly-one-group) cover cross-property constraints without middleware. Option-level constraints are checked before cross-property rules, producing clear, ordered error messages.
Complex type support Collections, dictionaries, and nested objects from inline key=value pairs or loader syntax (json::, text::, env::) - without requiring custom converters for typical configuration-style data structures.
IConfiguration integration CLI arguments participate in the standard .NET IConfiguration layering model. Parsed values can be pushed into IConfigurationBuilder and overlay JSON, environment, and other configuration sources using the standard priority chain. Only explicitly set properties are contributed, preserving configuration semantics.
IOutput integration All output - help, errors, results - flows through IOutput from Kiwi Presentation. Swap in StringOutput for unit tests without any other changes.
Infrastructure-friendly composition Commands may be instantiated directly or resolved through standard .NET dependency injection, allowing CLI tools to integrate cleanly with logging, configuration, and application services without imposing framework coupling on command models.
Zero external dependencies Core package has no NuGet dependencies beyond the BCL and Microsoft.Extensions.Configuration.Abstractions. DI support is opt-in via a separate package.

Where it fits

CLI is designed for .NET applications where argument parsing, command routing, and output behaviour need to be independently testable and precisely controlled.

  • Single-command tools - parse a flat set of options and invoke a handler, with typed validation and help generation included.
  • Multi-command applications - route verb [options] invocations to independent command objects, each with its own option set and validation rules.
  • DevOps and automation tooling - CLI arguments layer naturally on top of environment-specific JSON config through IConfiguration integration, without custom merging logic.
  • Plugin architectures - declarative mode supports scanning external assemblies for [CliCommand]-decorated types at runtime.
  • Hosted and operational tooling - command handlers can participate in broader application composition, including logging, configuration, and dependency injection infrastructure, while the command model itself remains a plain C# class.
  • Testable CLI applications - StringOutput and direct Command<T>.Parse() / .Execute() make it straightforward to unit-test option parsing and validation without spinning up a full application host.

Comparison with alternatives

vs. System.CommandLine

System.CommandLine is the official Microsoft library. It is feature-rich and actively maintained, with a middleware pipeline and DragonFruit binder.

Kiwi CLI takes a different approach in several areas: expression-based property binding rather than Option<T> objects threaded through handler lambdas; cross-property validation as typed rule builders rather than middleware; native IOutput / IOutputWriter integration with the Kiwi Foundation; and no external NuGet dependencies in the core package.

vs. CommandLineParser (CommandLine)

CommandLineParser uses attribute decoration and a global Parser.Default.ParseArguments<T>() entry point. Kiwi CLI is similar in philosophy but adds a parallel fluent configuration path, first-class cross-property validation rule builders, ComplexPropertyParser for nested objects and loader syntax, IConfiguration integration, and IOutput / Presentation integration.

vs. CliFx

CliFx is a clean, attribute-first library with DI support and async-first handlers. Where it focuses on command composition and async execution, Kiwi CLI focuses on typed validation rules, two-mode configuration, and IConfiguration layering. Command handlers in Kiwi CLI may be synchronous or asynchronous depending on application requirements; the parsing and validation pipeline is independent of the execution model. CliFx has no equivalent of the cross-property validation rule builders.

vs. Cocona

Cocona maps methods directly to commands using naming conventions and DI. It is well-suited for microservice-style CLIs where each command is a method. Kiwi CLI is a stronger fit when explicit control over command objects, validation rules, and IConfiguration integration are requirements.


How it is used - detailed walkthrough

Fluent registration

Define a plain command object:

public class DeployOptions
{
    public string Environment { get; set; } = string.Empty;
    public string Version     { get; set; } = string.Empty;
    public bool   DryRun      { get; set; }
    public int    Timeout     { get; set; } = 60;
}
Enter fullscreen mode Exit fullscreen mode

Register options and attach the handler:

var deployCommand = new Command<DeployOptions>("deploy", (opts, output) =>
{
    if (opts.DryRun)
        output.WriteInfo("[DRY RUN] No changes applied.");
    output.WriteResult($"Deployed {opts.Version} to {opts.Environment}.");
})
.AddOption(
    new GenericOption("--env", "-e")
        .SetHelpText("Target environment")
        .FieldRequired()
        .SetValidValues("dev", "staging", "prod"),
    c => c.Environment)
.AddOption(
    new GenericOption("--version", "-v")
        .SetHelpText("Version tag")
        .FieldRequired(),
    c => c.Version)
.AddOption(
    new BooleanOption("--dry-run")
        .SetHelpText("Print plan without applying"),
    c => c.DryRun)
.AddOption(
    new GenericOption("--timeout", "-t")
        .SetDefaultValue("60"),
    c => c.Timeout);
Enter fullscreen mode Exit fullscreen mode

Wire up the application:

var app = new CommandLineApplication("myapp", "1.0.0", "Deployment tool");
app.AddCommand(deployCommand);
app.Execute(args);
Enter fullscreen mode Exit fullscreen mode

Cross-property validation

Validation rules chain directly onto the command:

using Kiwify.Kiwi.CLI.Validation.Extensions;

deployCommand
    .CreateRequiredWhen<DeployOptions>()
        .AddCondition(opts => opts.Environment == "prod")
        .AddField(opts => opts.Version)
        .SetErrorMessage("--version is required in prod")
        .AttachRule()
    .CreateRequiredWholeFieldGroupOrNone<DeployOptions>()
        .AddField(opts => opts.CertPath)
        .AddField(opts => opts.CertPassword)
        .SetErrorMessage("Provide both --cert-path and --cert-password, or neither")
        .AttachRule()
    .CreateRequiredOnlyOneFieldGroupValidationRule<DeployOptions>()
        .AddGroup(g => g.AddField(opts => opts.SourceFile))
        .AddGroup(g => g.AddField(opts => opts.SourceUrl))
        .AttachRule();
Enter fullscreen mode Exit fullscreen mode

When validation fails, the error is printed via IOutput.WriteError and Execute returns without calling the handler.

Enum options

EnumOption<TEnum> derives valid values from the enum automatically:

public enum LogLevel { Trace, Debug, Info, Warning, Error }

command.AddOption(
    new EnumOption<LogLevel>("--log-level", "-l")
        .SetHelpText("Minimum log level")
        .SetDefaultValue("Info"),
    c => c.LogLevel);
Enter fullscreen mode Exit fullscreen mode

Parsing is case-insensitive. Invalid values produce a descriptive error listing the allowed names.

Complex types and loader syntax

public class ImportOptions
{
    public Dictionary<string, string> Mapping  { get; set; } = new();
    public string[]                   Tags     { get; set; } = Array.Empty<string>();
    public ConnectionConfig           Db       { get; set; } = new();
}
Enter fullscreen mode Exit fullscreen mode

Command line:

myapp import --mapping key1=val1,key2=val2 --tags alpha,beta,gamma --db json::./db.json
Enter fullscreen mode Exit fullscreen mode
  • --mapping populates a Dictionary<string, string> from inline key=value pairs.
  • --tags populates string[] from a comma-separated list.
  • --db json::./db.json deserializes ./db.json into ConnectionConfig.

Declarative mode

Decorate the command class:

[CliCommand("deploy", Description = "Deploy the application")]
[RequiredWhen("Environment", "prod",
    RequiredFields = new[] { "Version" },
    ErrorMessage   = "--version is required in prod")]
[RequiredWholeFieldGroupOrNone("CertPath", "CertPassword")]
[CustomValidation(nameof(ValidateTimeout),
    ErrorMessage = "--timeout must be between 10 and 300")]
public class DeployOptions
{
    [CliOption("--env", "-e", IsRequired = true)]
    public string Environment { get; set; } = string.Empty;

    [CliOption("--version", "-v")]
    public string Version { get; set; } = string.Empty;

    [CliOption("--timeout", "-t", DefaultValue = 60)]
    public int Timeout { get; set; }

    [CliOption("--cert-path")]
    public string CertPath { get; set; } = string.Empty;

    [CliOption("--cert-password")]
    public string CertPassword { get; set; } = string.Empty;

    public static bool ValidateTimeout(DeployOptions opts)
        => opts.Timeout is >= 10 and <= 300;

    [CliHandler]
    public void Handle(IOutput output)
    {
        output.WriteInfo($"Deploying {Version} to {Environment}...");
    }
}
Enter fullscreen mode Exit fullscreen mode

Load in the application:

app.LoadCommandsFromAssembly();   // scans calling assembly
app.Execute(args);
Enter fullscreen mode Exit fullscreen mode

Or load from a plugin assembly:

app.LoadCommandsFromAssembly("/plugins/myapp.commands.dll");
Enter fullscreen mode Exit fullscreen mode

Dependency injection

Command objects can receive services through constructor injection. ICommandResolver is the abstraction CLI uses when activating commands; ServiceProviderCommandResolver (available in the separate Kiwify.Kiwi.CLI.DependencyInjection package) implements it against any IServiceProvider:

// Command with constructor-injected services
public class DeployOptions
{
    private readonly IDeploymentService _deploy;
    private readonly ILogger<DeployOptions> _logger;

    public DeployOptions(IDeploymentService deploy, ILogger<DeployOptions> logger)
    {
        _deploy = deploy;
        _logger = logger;
    }

    public string Environment { get; set; } = string.Empty;
    public string Version     { get; set; } = string.Empty;

    [CliHandler]
    public void Handle(IOutput output)
    {
        _logger.LogInformation("Deploying {Version} to {Env}", Version, Environment);
        _deploy.Deploy(Version, Environment);
        output.WriteResult($"Deployed {Version} to {Environment}.");
    }
}
Enter fullscreen mode Exit fullscreen mode

Wire the resolver during application setup:

var services = new ServiceCollection()
    .AddSingleton<IDeploymentService, DeploymentService>()
    .AddLogging()
    .BuildServiceProvider();

var app = new CommandLineApplication("myapp", "1.0.0", "Deployment tool");
app.SetCommandResolver(new ServiceProviderCommandResolver(services));
app.LoadCommandsFromAssembly();
app.Execute(args);
Enter fullscreen mode Exit fullscreen mode

ServiceProviderCommandResolver is container-agnostic - any IServiceProvider-compatible container works. The parsing and validation pipeline operates independently of how the command instance is created; handler invocation is the only point at which the resolver is called.

Applications using the Kiwi declarative DI extensions (Kiwify.Kiwi.DI, built on top of Microsoft.Extensions.DependencyInjection) can register command types alongside other application services using attribute-driven patterns, including conditional registration based on configuration keys, environment names, or named conditions:

// Kiwi DI attribute registration - command registered as transient service,
// active only when the "Features:Deploy" configuration key is enabled
[Service(ServiceLifetime.Transient, ConfigKey = "Features:Deploy")]
public class DeployOptions
{
    public DeployOptions(IDeploymentService deploy, ILogger<DeployOptions> logger) { ... }
    // options and handler as above
}
Enter fullscreen mode Exit fullscreen mode

This lets command types participate in the same declarative service-scanning model as other application services - useful in operational tooling where configuration flags or environment conditions govern which commands are active.

IConfiguration integration

Parsed CLI arguments participate in the standard .NET configuration layering model alongside JSON files, environment variables, and other sources:

var configBuilder = new ConfigurationBuilder()
    .AddJsonFile("appsettings.json")
    .AddKiwiCommandModel(deployCommand, sectionPrefix: "Deploy");

IConfiguration config = configBuilder.Build();

// CLI values overlay the JSON file
string env = config["Deploy:Environment"]!;
Enter fullscreen mode Exit fullscreen mode

Only properties that were explicitly set on the command line are contributed. Unset properties - even those with default values - are omitted, so the standard priority chain is preserved and existing configuration values are not accidentally overridden. In operational tooling where different environments have different JSON configuration files, this means a single invocation pattern can accommodate environment-specific configuration without conditional logic in the command layer.

Testing

Commands can be parsed and validated without invoking handlers, which makes it straightforward to unit-test argument parsing and validation rules in isolation:

var result = deployCommand.Parse(new[] { "--env", "staging", "--version", "v2.0.0" });

Assert.True(result.Success);
Assert.Equal("staging", result.Command!.Environment);
Enter fullscreen mode Exit fullscreen mode

Use StringOutput from Presentation to capture output. Pass it directly to a Command<T> for unit tests, or use app.SetOutput for integration tests that route through the full application:

// Unit test - exercise one command in isolation
var output = new StringOutput();
deployCommand.Execute(new[] { "--env", "prod" }, output);
Assert.Contains("--version is required in prod", output.GetErrors());

// Integration test - route through CommandLineApplication
var captured = new StringOutput();
app.SetOutput(captured);
app.Execute(new[] { "deploy", "--env", "prod" });
Assert.Contains("--version is required in prod", captured.GetErrors());
Enter fullscreen mode Exit fullscreen mode

Sample screens

Multi-command application help

$ myapp --help

myapp 1.0.0 - Deployment tool

Commands:
  deploy    Deploy the application
  rollback  Roll back to a previous version
  status    Show deployment status

Run 'myapp <command> --help' for command-specific help.
Enter fullscreen mode Exit fullscreen mode

Single-command help

$ myapp deploy --help

myapp 1.0.0

Usage: myapp deploy [options]

Options:
  --env,     -e    Target environment (required) [dev | staging | prod]
  --version, -v    Version tag (required)
  --dry-run        Print plan without applying
  --timeout, -t    Timeout in seconds [default: 60]
  --help,    -h    Show this help
Enter fullscreen mode Exit fullscreen mode

Validation errors

Missing required fields:

$ myapp deploy

Error: --env is required
Error: --version is required
Enter fullscreen mode Exit fullscreen mode

Cross-property constraint (--version required when environment is prod):

$ myapp deploy --env prod

Error: --version is required in prod
Enter fullscreen mode Exit fullscreen mode

Invalid enum value:

$ myapp deploy --env live --version v1.0

Error: --env: 'live' is not a valid value. Allowed: dev, staging, prod
Enter fullscreen mode Exit fullscreen mode

Successful execution

$ myapp deploy --env staging --version v2.1.0

Deployed v2.1.0 to staging.
Enter fullscreen mode Exit fullscreen mode

With --dry-run:

$ myapp deploy --env prod --version v2.1.0 --dry-run

[DRY RUN] No changes applied.
Deployed v2.1.0 to prod.
Enter fullscreen mode Exit fullscreen mode

Architectural notes

  • Prefix-agnostic parsing. ArgumentParser<T> matches switches by exact name after stripping leading dashes. --env, -e, and (if registered) env all resolve to the same option.
  • Expression-based binding. Property selectors use Expression<Func<TCommand, object?>> rather than strings. The expression is compiled and the member name extracted at registration time, not re-evaluated during each parse call.
  • Validation order. Option-level constraints (required, valid values, regex, custom) are checked before cross-property rules. This prevents confusing errors about conditional requirements when a primary option is missing.
  • Declarative loader isolation. CommandLoader.CreateCommandFrom<T>() instantiates the class, reflects on its attributes, and produces the same Command<T> as the fluent path. It does not cache anything; each call produces a fresh command instance.

See also

Top comments (0)