Nullean.Argh.Core
0.2.0
dotnet add package Nullean.Argh.Core --version 0.2.0
NuGet\Install-Package Nullean.Argh.Core -Version 0.2.0
<PackageReference Include="Nullean.Argh.Core" Version="0.2.0" />
<PackageVersion Include="Nullean.Argh.Core" Version="0.2.0" />
<PackageReference Include="Nullean.Argh.Core" />
paket add Nullean.Argh.Core --version 0.2.0
#r "nuget: Nullean.Argh.Core, 0.2.0"
#:package Nullean.Argh.Core@0.2.0
#addin nuget:?package=Nullean.Argh.Core&version=0.2.0
#tool nuget:?package=Nullean.Argh.Core&version=0.2.0
Nullean.Argh
Build full-featured .NET CLIs without writing a parser.
Methods become commands, XML docs become help text, records become option sets. A Roslyn source generator emits parsing, routing, dispatch, and help into your assembly at build time — no reflection, no runtime overhead, trimming- and AOT-safe by default.
Write vanilla C# and get a fully functional CLI in return: rich --help output, shell tab-completions for bash, zsh, and fish, and a machine-readable JSON schema ready for agentic use cases — all without writing a single line of plumbing code for any of it.
Heavily Inspired by ConsoleAppFramework (Cysharp) — rewritten from scratch with a different feature set, but ConsoleAppFramework laid out the path for source-generated CLI's in .NET.

Table of contents
- Features
- Packages
- Quick start
- Registration model
- Namespaces
- Parameters and binding
- Object binding
- Fuzzy matching
- Help and XML documentation
- Middleware
- Dependency injection
- Hosting
- Routing API
- Shell completions
- Schema JSON
- License and links
Features
- XML docs are your help text
- Summaries, param descriptions, remarks, and
<example>blocks appear in--helpautomatically - No separate attribute layer, no string duplication
- Summaries, param descriptions, remarks, and
- Everything is generated C#
- Typed dispatch tree, option parsers, and help printers emitted directly into your assembly
- Read it, step through it in a debugger, ship it trimmed or AOT-compiled
MapGroup-style namespaces- Nested command groups with their own help pages and scoped option types
- Immediately familiar if you've used ASP.NET minimal APIs
- DTO binding with
[AsParameters]- Records and classes expand into flags without a custom bind loop
- Optional prefix (
[AsParameters("app")]) namespaces all long names
- Shell completions built-in
- Generated lookup tables for subcommands, namespaces, and flags — no extra package
- One install command per shell (bash, zsh, fish)
- Agent-ready schema
myapp __schemaemits a full JSON description of commands, options, summaries, and examples- Feed it to an LLM, a docs generator, or diff it in CI to catch breaking changes
- Fuzzy matching
- Typos produce actionable errors with the correct qualified path and a
--helpsuggestion - No silent no-match
- Typos produce actionable errors with the correct qualified path and a
- Zero-dep or ME. native*
Nullean.Argh— noMicrosoft.Extensions.*dependencyNullean.Argh.Hosting— same registration surface, plugs intoIHostand DI
Packages
Which package do I need?
Nullean.Arghdependency free version.Nullean.Argh.Hostingisolated implementation of.Corethat fully integrates withMicrosoft.Extensions.*ecosystem.
Everything else is pulled in transitively, you do not reference .Core or .Interfaces manually for normal apps.
The two packages are isolated implementations and both only depend on .Core.
Nullean.Argh.CoreShared runtime pulled in by both user-facing packages. ContainsArghApp, runtime, help, and the embedded source generator. Not referenced directly in normal apps.Nullean.Argh.InterfacesReference directly only when building a shared library (e.g. reusable middleware or parsers) that other Argh-based apps will consume. Contains attributes,IArghBuilder, and middleware/parser contracts. Zero external dependencies.
Nullean.Argh.Generator is not a separate NuGet package — it ships embedded inside Nullean.Argh.Core under analyzers/dotnet/cs.
Console app
<ItemGroup>
<PackageReference Include="Nullean.Argh" />
</ItemGroup>
Hosted app
<ItemGroup>
<PackageReference Include="Nullean.Argh.Hosting" />
</ItemGroup>
Shared middleware / parser library
<ItemGroup>
<PackageReference Include="Nullean.Argh.Interfaces" />
</ItemGroup>
Quick start
Console app (Nullean.Argh)
using Nullean.Argh;
var app = new ArghApp();
app.Map("hello", MyHandlers.SayHello);
return await app.RunAsync(args);
RunAsync dispatches into generated code in your assembly.
Hosted app (Nullean.Argh.Hosting)
Use when the app is already built on Microsoft.Extensions.Hosting and you want commands and middleware registered in DI with lifetimes, CancellationToken linked to the host, etc.
using Microsoft.Extensions.Hosting;
using Nullean.Argh.Hosting;
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddArgh(args, b =>
{
b.Map("hello", MyHandlers.SayHello);
// b.Map<MyCommandHandlers>(); b.UseGlobalOptions<MyGlobals>(); …
});
await builder.Build().RunAsync();
See AddArgh for exit behavior and hosted-service ordering.
Registration model
Three forms, same registration surface — all are fully supported. With class and method-group registration, XML doc comments on your handler methods flow directly into --help output. Lambdas skip that path.
// 1. Method group — direct typed dispatch.
app.Map("deploy", DeployHandlers.Run);
// 2. Lambda — convenient for simple one-liners.
app.Map("greet", (string name) => Console.WriteLine($"Hello, {name}!"));
// 3. Class — registers every public method on T as a command.
app.Map<StorageHandlers>();
| API | Purpose |
|---|---|
Map(name, handler) |
Bind a command name to a delegate. |
Map<T>() |
Register every public method on T as a command (typically a static class of handlers). |
MapRoot(handler) |
Default handler when no subcommand is given (at app root, or inside a MapNamespace callback for that namespace). |
Flat apps route app <command> …; hierarchical apps route app <namespace> … <command> …. The generator emits the switch/dispatch tree accordingly.
Namespaces
Group related commands under a shared path, scoped options, and their own help page — the same mental model as ASP.NET's MapGroup.
The idiomatic pattern is to put commands as methods on the class. Nested sub-groups are registered explicitly — there is no auto-discovery of nested types.
/// <summary>Commands under <c>storage</c>.</summary>
internal sealed class StorageCommands
{
/// <summary>List objects in the bucket.</summary>
public void List() => Console.WriteLine("storage:list");
}
/// <summary>Commands under <c>storage blob</c>.</summary>
internal sealed class BlobCommands
{
/// <summary>Upload a file.</summary>
/// <param name="path">-p,--path, Local file path.</param>
public void Upload(string path) => Console.WriteLine($"storage:blob:upload:{path}");
/// <summary>Download a file.</summary>
/// <param name="key">-k,--key, Object key.</param>
public void Download(string key) => Console.WriteLine($"storage:blob:download:{key}");
}
app.AddNamespace<StorageCommands>("storage", ns =>
{
ns.AddNamespace<BlobCommands>("blob");
});
// Resulting paths:
// storage list
// storage blob upload --path ./file.txt
// storage blob download --key backups/db.sql
The generator produces separate help printers for the namespace overview and each leaf command. Add CommandNamespaceOptions<T>() inside the callback to attach scoped options:
app.AddNamespace<StorageCommands>("storage", ns =>
{
ns.CommandNamespaceOptions<StorageOptions>();
ns.AddNamespace<BlobCommands>("blob");
});
Parameters and binding
Method parameters become CLI flags automatically. No attribute boilerplate for the common case.
Arguments (positional)
Mark a parameter with [Argument] to make it positional. Indices must start at 0 and be consecutive.
public static Task<int> Deploy([Argument] string environment) { … }
// myapp deploy production
Flags (named options)
Parameters without [Argument] become --kebab-case long flags. A bool flag defaults to false; pass --flag to set it.
public static Task<int> Build(string outputDir, bool release = false) { … }
// myapp build --output-dir ./bin --release
Supported types
| Category | Types |
|---|---|
| Primitives | string, int, long, double, float, decimal, bool, bool? |
| System | enum, FileInfo, DirectoryInfo, Uri |
| Collections | List<T>, T[] — repeated flag or [CollectionSyntax(Separator=",")] for a single comma-separated value |
Collections accept the flag multiple times, or a single comma-separated value via [CollectionSyntax]:
public static Task<int> Deploy(string[] targets, [CollectionSyntax(Separator = ",")] string[] tags) { … }
// Repeated: myapp deploy --targets web --targets api
// Separator: myapp deploy --targets web,api --tags blue,green
Nullable bool — --flag / --no-flag pairs
A bool? flag generates both --flag (sets true) and --no-flag (sets false). Omitting either leaves the value null, letting you distinguish "not specified" from an explicit false. Help output shows --flag / --no-flag for nullable bools.
public static Task<int> Deploy(string env, bool? dryRun = null) { … }
// myapp deploy staging → dryRun is null
// myapp deploy staging --dry-run → dryRun is true
// myapp deploy staging --no-dry-run → dryRun is false
DTO binding — [AsParameters]
A record or class parameter annotated with [AsParameters] expands its members into individual flags or positionals. Works with records (constructor parameters) and classes (public settable properties). Add a string argument to prefix all long names.
// Record — constructor parameters become flags
public record DeployOptions(string Environment, bool DryRun = false);
public static Task<int> Deploy([AsParameters] DeployOptions opts) { … }
// myapp deploy --environment staging --dry-run
// Class — public settable properties become flags
public class BuildOptions
{
public string OutputDir { get; set; } = "";
public bool Release { get; set; }
}
public static Task<int> Build([AsParameters] BuildOptions opts) { … }
// myapp build --output-dir ./bin --release
// Prefix — all long names get a common prefix
public record AppOptions(string Name, string Version = "");
public static Task<int> Configure([AsParameters("app")] AppOptions opts) { … }
// myapp configure --app-name foo --app-version 2
Custom parsing — IArgumentParser<T>
For types with no built-in support, implement IArgumentParser<T> and annotate the parameter:
public class SemVerParser : IArgumentParser<SemVer>
{
public static bool TryParse(string value, out SemVer result) =>
SemVer.TryParse(value, out result);
}
public static Task<int> Release([ArgumentParser(typeof(SemVerParser))] SemVer version) { … }
// myapp release 1.2.3
IArgumentParser<T> is in Nullean.Argh.Interfaces.
Object binding
Share state across commands without repeating parameters on every method signature.
Global options
public record GlobalOptions(bool Verbose = false);
app.UseGlobalOptions<GlobalOptions>();
app.Map("build", (GlobalOptions g) => { if (g.Verbose) … });
// myapp build --verbose
Globals are parsed before routing and available to every command.
Namespace options
Scoped to a namespace and its children. The options type must inherit the parent's options type — GlobalOptions at the root, or the enclosing namespace's options further down. The generator reports an error (AGH0004) if the chain is broken.
public record StorageOptions(string ConnectionString = "") : GlobalOptions;
app.MapNamespace<StorageHandlers>("storage", ns =>
{
ns.UseNamespaceOptions<StorageOptions>();
ns.Map("list", (StorageOptions o) => { … });
});
// myapp storage list --connection-string "…" --verbose
Parsing order in generated code: globals → namespace options along the path → command flags and positionals.
Combining with [AsParameters]
A command can extend a global or namespace options type and annotate it with [AsParameters] to inherit those flags alongside its own:
public record DeployOptions(string Environment, bool DryRun = false) : StorageOptions;
ns.Map("deploy", ([AsParameters] DeployOptions opts) => { … });
// myapp storage deploy --connection-string "…" --environment staging --dry-run
Note: commands under a namespace are required to declare the namespace options type as a parameter (enforced by analyzer AGH0021). Annotate the method with
[NoOptionsInjection]to opt out.
Fuzzy matching
Typos produce actionable errors with the correct qualified path and a --help suggestion:
$ myapp stoarge list
Error: unknown command or namespace 'stoarge'. Did you mean 'storage'?
Run 'myapp storage --help' for usage.
Run 'myapp --help' for usage.
Inside a namespace, the suggestion includes the full path (storage blob upload, not just upload).
Help and XML documentation
Write XML doc once; the generator reads it at build time and bakes the text into --help output. Your .xml doc file is not read at runtime. Enable GenerateDocumentationFile in your project file — it is not on by default.
Commands
Document handler methods normally:
/// <summary>Deploy the application to the target environment.</summary>
/// <remarks>
/// Runs pre-flight checks before deploying. Pass <c>--dry-run</c> to
/// validate without making changes. See also <see cref="Rollback"/>.
/// </remarks>
/// <param name="environment">Target environment (staging, production).</param>
/// <param name="dryRun">Validate only — make no changes.</param>
public static Task<int> Deploy(string environment, bool dryRun = false) { … }
The generated myapp deploy --help output:
Usage: myapp deploy <environment> [options]
Deploy the application to the target environment.
Global options:
--help, -h Show help.
Arguments:
<environment> Target environment (staging, production).
Options:
--dry-run Validate only — make no changes.
Notes:
Runs pre-flight checks before deploying. Pass --dry-run to validate
without making changes. See also: myapp rollback <args>
Namespaces
Put the <summary> (and optionally <remarks>) on the class T passed to MapNamespace<T>. The generator uses it as the namespace description in myapp storage --help and in the root command listing:
/// <summary>Manage blob and file storage resources.</summary>
/// <remarks>
/// Requires a storage connection string via <c>--connection-string</c>
/// or the <c>STORAGE_CONN</c> environment variable.
/// </remarks>
internal sealed class StorageCommands { … }
app.MapNamespace<StorageCommands>("storage", ns => { … });
Root app
The root myapp --help shows a description when a root command is registered via MapRoot. The XML doc on that handler becomes the app-level overview:
/// <summary>Manage and deploy your application's cloud resources.</summary>
/// <remarks>
/// Run <c>myapp <command> --help</c> for details on any command.
/// </remarks>
public static Task<int> Root() { … }
app.MapRoot(Root);
In remarks, <paramref> to a flag becomes --name; <see cref> to another handler becomes that command's usage synopsis. See examples/XmlDocShowcase for the full tag inventory.
Middleware
Cross-cutting logic — auth checks, logging, timing — lives in middleware and stays out of handler methods.
public class TimingMiddleware : ICommandMiddleware
{
public async Task InvokeAsync(CommandContext ctx, Func<Task> next)
{
var sw = Stopwatch.StartNew();
await next();
Console.Error.WriteLine($"{ctx.CommandPath}: {sw.ElapsedMilliseconds}ms");
}
}
// Global — runs for every command
app.UseMiddleware<TimingMiddleware>();
// Per-handler — attribute on the method
[MiddlewareAttribute<TimingMiddleware>]
public static Task<int> Deploy(string environment) { … }
ICommandMiddleware receives CommandContext with CommandPath, Args, ExitCode, and CancellationToken. Middleware does not run for --help, --version, __completion, __complete, or __schema. The pipeline is wired in generated code — not a runtime delegate chain. Each middleware call is emitted as a direct invocation in the generated dispatch method; there is no runtime list to build or iterate.
Dependency injection
When using Nullean.Argh.Hosting, DI integration is fully transparent — register your handler and middleware types in the service collection and the generated code resolves them automatically. No manual ServiceProvider wiring needed.
For advanced use or when not using Nullean.Argh.Hosting: ArghServices.ServiceProvider is typed as System.IServiceProvider and set when running under a host. For Map<T>() instance methods and UseMiddleware<T>() / [MiddlewareAttribute<T>], generated code resolves via GetService(typeof(T)) when a provider is present; otherwise it falls back to new T().
// Handler with an injected service
public class DeployCommands(IDeployService deployer)
{
public async Task<int> Run(string environment)
{
await deployer.DeployAsync(environment);
return 0;
}
}
// Registration — service must be in the DI container
builder.Services.AddScoped<IDeployService, DeployService>();
builder.Services.AddArgh(args, b => b.Map<DeployCommands>());
For native AOT / trimming, register handler and middleware types explicitly in DI so required constructors are preserved.
Hosting
Nullean.Argh.Hosting plugs the same command registration model into IHost and Microsoft.Extensions.DependencyInjection — no custom bootstrapping or glue code needed.
services.AddArgh(args, b => { … }) (AddArgh) mirrors the same Map / Map<T> / UseGlobalOptions / UseNamespaceOptions / UseMiddleware / MapNamespace surface as ArghApp, and additionally lets you control DI lifetimes:
using Microsoft.Extensions.DependencyInjection;
builder.Services.AddArgh(args, b =>
{
b.MapScoped<DeployCommands>(); // resolved per command invocation
b.UseMiddleware<AuditMiddleware>(ServiceLifetime.Singleton); // single instance for the process
b.Map("ping", PingHandlers.Run); // static method — no DI lifetime needed
b.UseGlobalOptions<GlobalOptions>();
});
IArghHostingBuilder API |
Purpose |
|---|---|
Map<T>() |
Register T as transient and add all its public methods as commands. |
MapTransient<T>() / MapScoped<T>() / MapSingleton<T>() |
Same, with an explicit DI lifetime. |
UseGlobalOptions<T>() |
Register T as the global options type and add it to DI. |
UseMiddleware<TMiddleware>() |
Register middleware as transient. |
UseMiddleware<TMiddleware>(lifetime) |
Register middleware with an explicit DI lifetime. |
AddArgh registers a hosted service that runs ArghRuntime.RunAsync(args) and then calls Environment.Exit with the exit code — the host does not continue after the CLI completes.
CancellationToken parameters on command handlers are linked to console cancellation and IHostApplicationLifetime.ApplicationStopping.
Register AddArgh before other IHostedService registrations if you want the CLI (including --help) to run first and exit without starting later background work. Services registered before AddArgh still get StartAsync on every invocation.
Routing API
ArghParser.Route(args) returns a RouteMatch (CommandPath, RemainingArgs) without invoking handlers — useful for tests and tooling.
Shell completions
Tab completion for subcommands, namespaces, and flags is included out of the box: the source generator emits lookup tables at compile time (same model as routing and --help), and a small __complete handler answers the shell with one candidate per line. --completions is not reserved — use __completion / __complete only for Argh's integration.
| Command | Purpose |
|---|---|
myapp __completion bash\|zsh\|fish |
Print an install snippet from CompletionScriptTemplates (substitutes your executable name). |
myapp __complete <shell> -- <words...> |
Return completion candidates; words are argv after the program name (full line context for nested commands). |
Bash — eval "$(myapp __completion bash)" (add to ~/.bashrc to persist).
Zsh — source <(myapp __completion zsh) (add to ~/.zshrc to persist).
Fish (3.4+ for commandline -opc):
mkdir -p ~/.config/fish/completions
myapp __completion fish > ~/.config/fish/completions/myapp.fish
Details: CompletionProtocol.
Schema JSON
myapp __schema writes a JSON document to stdout describing your entire CLI — commands, namespaces, global and namespace options, summaries, remarks, usage, and examples. The output is generated at build time from the same source the generator uses for routing and help, so it is always in sync with your code.
myapp __schema > cli-schema.json
Use cases:
- LLM / agent tooling — feed the schema to a language model to give it accurate, structured knowledge of your CLI's commands and options.
- Generated documentation — pipe into a docs generator or templating step to keep reference docs in sync without manual maintenance.
- CI validation — diff
cli-schema.jsonacross commits to catch unintentional breaking changes to the CLI surface.
The shape is defined by ArghCliSchemaDocument. Output is indented camelCase JSON. Reserved meta-commands (__complete, __completion, __schema) appear under reservedMetaCommands.
Native AOT in CI: The GitHub Actions workflow runs an aot-validate job that publishes examples/ArghAotSmoketest with Native AOT on Linux, macOS, and Windows and invokes __schema on the native binary. The repo uses the SDK unified artifacts layout (output under .artifacts/, gitignored).
License and links
- License: MIT
- Repository: github.com/nullean/argh
- Releases: GitHub releases
This README is the NuGet package readme for Nullean.Argh, Nullean.Argh.Core, Nullean.Argh.Interfaces, and Nullean.Argh.Hosting.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net5.0 was computed. net5.0-windows was computed. net6.0 was computed. net6.0-android was computed. net6.0-ios was computed. net6.0-maccatalyst was computed. net6.0-macos was computed. net6.0-tvos was computed. net6.0-windows was computed. net7.0 was computed. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. net8.0 was computed. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
| .NET Core | netcoreapp2.0 was computed. netcoreapp2.1 was computed. netcoreapp2.2 was computed. netcoreapp3.0 was computed. netcoreapp3.1 was computed. |
| .NET Standard | netstandard2.0 is compatible. netstandard2.1 was computed. |
| .NET Framework | net461 was computed. net462 was computed. net463 was computed. net47 was computed. net471 was computed. net472 was computed. net48 was computed. net481 was computed. |
| MonoAndroid | monoandroid was computed. |
| MonoMac | monomac was computed. |
| MonoTouch | monotouch was computed. |
| Tizen | tizen40 was computed. tizen60 was computed. |
| Xamarin.iOS | xamarinios was computed. |
| Xamarin.Mac | xamarinmac was computed. |
| Xamarin.TVOS | xamarintvos was computed. |
| Xamarin.WatchOS | xamarinwatchos was computed. |
-
.NETStandard 2.0
- Nullean.Argh.Interfaces (>= 0.2.0)
- System.Memory (>= 4.5.5)
- System.Text.Json (>= 8.0.5)
- System.Threading.Tasks.Extensions (>= 4.6.3)
NuGet packages (2)
Showing the top 2 NuGet packages that depend on Nullean.Argh.Core:
| Package | Downloads |
|---|---|
|
Nullean.Argh.Hosting
Hosting integration for Nullean.Argh CLI applications. |
|
|
Nullean.Argh
Metapackage: references Nullean.Argh.Core (runtime + analyzer) and Nullean.Argh.Interfaces. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.2.0 | 30 | 4/17/2026 |