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
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
IConfigurationintegration, 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 -
StringOutputand directCommand<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;
}
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);
Wire up the application:
var app = new CommandLineApplication("myapp", "1.0.0", "Deployment tool");
app.AddCommand(deployCommand);
app.Execute(args);
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();
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);
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();
}
Command line:
myapp import --mapping key1=val1,key2=val2 --tags alpha,beta,gamma --db json::./db.json
-
--mappingpopulates aDictionary<string, string>from inlinekey=valuepairs. -
--tagspopulatesstring[]from a comma-separated list. -
--db json::./db.jsondeserializes./db.jsonintoConnectionConfig.
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}...");
}
}
Load in the application:
app.LoadCommandsFromAssembly(); // scans calling assembly
app.Execute(args);
Or load from a plugin assembly:
app.LoadCommandsFromAssembly("/plugins/myapp.commands.dll");
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}.");
}
}
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);
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
}
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"]!;
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);
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());
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.
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
Validation errors
Missing required fields:
$ myapp deploy
Error: --env is required
Error: --version is required
Cross-property constraint (--version required when environment is prod):
$ myapp deploy --env prod
Error: --version is required in prod
Invalid enum value:
$ myapp deploy --env live --version v1.0
Error: --env: 'live' is not a valid value. Allowed: dev, staging, prod
Successful execution
$ myapp deploy --env staging --version v2.1.0
Deployed v2.1.0 to staging.
With --dry-run:
$ myapp deploy --env prod --version v2.1.0 --dry-run
[DRY RUN] No changes applied.
Deployed v2.1.0 to prod.
Architectural notes
-
Prefix-agnostic parsing.
ArgumentParser<T>matches switches by exact name after stripping leading dashes.--env,-e, and (if registered)envall 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 sameCommand<T>as the fluent path. It does not cache anything; each call produces a fresh command instance.
Top comments (0)