ConsoleAppFramework: a CLI parser you can read in full
Zero Dependency, Zero Overhead, Zero Reflection, Zero Allocation, AOT Safe CLI Framework powered by C# Source Generator.
At a glance
- What is it?
- ConsoleAppFramework is a C# source generator that takes a lambda like (int foo, int bar) => ... and emits the argument-parsing code as a static method, so there is no reflection, no allocation and no runtime dependency at all, including itself. The README publishes the entire generated body, which is both the documentation and the argument for the performance claims, and the newest release is 5.7.13 from November 2025.
- Who is it for?
- Use ConsoleAppFramework if you are writing a .NET 8 or later command line tool and you want argument parsing, help text and validation without a NuGet dependency in your output assembly, and if you are willing to accept that the parsing is generated at compile time from the lambda's parameter names and types rather than configured at runtime.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 84 days ago.
- What is it written in?
- Mainly C#, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on September 28, 2026, and from our analysis. They are not legal advice.
Editorial analysis
The whole generated method is printed, which is the documentation
The most unusual thing in this README is that it includes the complete generated source code inside a details element, and that inclusion is the framework's central argument rather than an appendix.
The claim it is supporting is made first. The magical performance is achieved by statically generating everything and parsing inline. And the example that follows is two lines:
using ConsoleAppFramework;
// args: ./cmd --foo 10 --bar 20
ConsoleApp.Run(args, (int foo, int bar) => Console.WriteLine($"Sum: {foo + bar}"));That is the entire user-facing API for the simple case. There is no builder, no attribute, no configuration object. The parameter names and types on the lambda are the command line specification, and the generator reads them.
The mechanism is stated next, and it is the part that distinguishes this from most source generators. Unlike typical Source Generators that use attributes as keys for generation, ConsoleAppFramework analyzes the provided lambda expressions or method references and generates the actual code body of the Run method. So the generated artefact is not a registration table or a dispatch switch around your code; it is the parser itself, written out as C#.
The result is a partial class the generator fills in:
internal static partial class ConsoleApp
{
// Generate the Run method itself with arguments and body to match the lambda expression
public static void Run(string[] args, Action<int, int> command)
{
// code body
}
}And then the full body, roughly ninety lines, which is in the README for anyone who wants to see it. That is the decision worth copying regardless of whether you use this library. A framework whose performance claim rests on what the generated code looks like should show the generated code, because otherwise the claim is unfalsifiable. Here you can read the loop, the comparisons, the error paths and the delegate call, and you can form your own view of what a command invocation costs.
What you can see in that body, in order: a call to TryShowHelpOrVersion with the option count and what looks like a positional-argument count, so help and version are handled before anything else; a default value and a parsed flag per argument; a try block containing a for loop over args; a switch on the argument name; a post-loop check that every required argument was parsed; the delegate invocation with null-forgiveness operators on the locals; and a catch block. Everything else is a helper, and the only helper with a body is TryIncrementIndex, which is marked AggressiveInlining and does the bounds check for consuming the next argument.
There is no dictionary, no parser object, no options table, no string interning and no allocation in that method. The switch is compiled by the C# compiler into whatever the runtime does with a string switch, and the help text is a raw string literal inside a partial method. That is the entire framework cost, and it is genuinely zero rather than approximately zero.
Every option is parsed twice, and the slow path is a linear scan
The generated switch has a structure that is worth looking at closely, because it is a real design decision with a real cost.
Each option appears twice. The first is a case in the switch, matched exactly:
case "--foo":
{
if (!TryIncrementIndex(ref i, args.Length) || !int.TryParse(args[i], out arg0)) { ThrowArgumentParseFailed("foo", args[i]); }
arg0Parsed = true;
break;
}And the second is in the default branch, matched case-insensitively:
default:
if (string.Equals(name, "--foo", StringComparison.OrdinalIgnoreCase))
{
if (!TryIncrementIndex(ref i, args.Length) || !int.TryParse(args[i], out arg0)) { ThrowArgumentParseFailed("foo", args[i]); }
arg0Parsed = true;
break;
}So --FOO works as well as --foo, and it works through a completely separate copy of the parse logic. Anything that does not match exactly falls into the default branch and is compared against every option with an ordinal case-insensitive equality test until one matches or the name is reported as not found.
The reasoning is legible from the code. The exact case is what a switch does natively, and the C# compiler turns a switch over string constants into a length check followed by an efficient comparison, or into a hashed jump depending on the runtime. That is the fast path, and it is taken whenever the user types the option the way it is documented. The default branch is the compatibility path for people who type --FOO or --Foo, and it is written as a chain of string.Equals calls because a switch cannot be case-insensitive on constants.
The cost is proportional to the option count. A tool with three options has a three-comparison fallback. A tool with thirty has thirty. And the generated code is roughly twice the size it needs to be, because every parse block is duplicated.
For most command line tools this is irrelevant. Options are typed exactly as documented the overwhelming majority of the time, the fast path handles them, and the fallback never runs. It becomes worth thinking about if you have a large option set and users who type in mixed case, and the honest characterisation is that the framework optimises for the documented spelling and treats the rest as a compatibility layer rather than as a first-class path.
The rest of the loop is worth noting for a different reason. The bounds check for consuming an argument's value is factored into a helper taking the index by ref, so the increment happens in one place and the helper is marked AggressiveInlining. A generated method that could simply have written args[i+1] has instead written a two-line helper, presumably because the generated body is a template and the template wants the bounds check to be uniform across every option regardless of whether the value is a string, a list, an array or an optional parameter.
The NuGet package contributes nothing to your output assembly
The dependency claim in the description is four words long and the third one needs explaining.
Zero Dependency, Zero Overhead, Zero Reflection, Zero Allocation, AOT Safe.
Dependency is the unusual one. The README explains it like this: the ConsoleApp class, along with everything else, is generated entirely by the Source Generator, resulting in no dependencies, including ConsoleAppFramework itself. This characteristic should contribute to the small assembly size and ease of handling, including support for Native AOT.
That is a different packaging model from a normal library. ConsoleAppFramework is installed as a NuGet package and consumed as an analyzer, and its only job happens at compile time. Once your project has compiled, nothing from the package is referenced by your assembly. The generated `ConsoleApp` class is your code, in your assembly, with no call back into a framework runtime.
That is what makes the zero overhead claim structural rather than a matter of tuning. A library that parsed at runtime would be in your assembly and in your dependency graph forever, and trimming and NativeAOT would have to understand it. A source generator leaves nothing behind.
The Native AOT consequence is the important one. A command line tool that you want to ship as a single native binary, with no runtime installed on the target machine, has to survive trimming. Reflection-based parsers are the classic trimming casualty, because the reflection metadata the parser depends on is exactly what the trimmer removes. A generated switch on string literals contains no reflection, so the trimmer has nothing to remove, and the tool publishes. That is a real deployment advantage over the alternatives and it is the strongest argument for the design.
The four zeros are not all the same kind of claim, and it is worth being precise. Dependency is about what ends up in your assembly, and it is literally true. Reflection is about the parsing mechanism, and it is literally true. Overhead is about the work between process start and your command running, and it is true in the sense that the generated code is the minimum. Allocation is about the managed heap during parsing, and it is the one that depends on the C# compiler's choices for string switch lowering and on whether any of your parameters are reference types. A list option, for instance, will allocate. The claim is about the framework's own parsing, not about your program's allocations, and reading it that way is fair.
The prerequisites match. v5 requires .NET 8 and C# 13, and the features it leans on are all from that generation: IncrementalGenerator for the Roslyn pipeline, managed function pointers, params arrays and default values in lambda expressions, ISpanParsable<T> for parsing without intermediate strings, and PosixSignalRegistration for handling SIGINT and SIGTERM. The signal one is worth calling out because it is a feature rather than an optimisation: a CLI tool that can be interrupted should shut down cleanly, and a framework that registers the handlers for you is saving you from platform-specific code that differs between Linux, macOS and Windows.
Cold start is the reason for all of this, and the benchmark config admits it
The README does something that few performance-focused libraries do, which is explain why the optimisation is worth it, and the explanation is specific.
CLI applications typically involve single-shot execution from a cold start. As a result, common optimization techniques such as dynamic code generation, with IL Emit and ExpressionTree.Compile, and caching, with ArrayPool, do not work effectively. ConsoleAppFramework generates everything statically in advance, achieving performance equivalent to optimized hand-written code without reflection or boxing.
The argument is that a CLI process runs once and exits. The techniques that pay off in a long-running process are dominated by their own setup cost in a process that lives for fifty milliseconds. Compiling an expression tree at startup to avoid per-call allocation is a bad trade when there is exactly one call. Renting from a pool is a bad trade when the pool would be created and torn down around a single use. Any measurement of steady-state throughput is measuring something a CLI tool never does.
So the design is not that the framework is faster than a reflection-based parser. It is that the framework removes work that a CLI tool should not be doing anyway, and does the removal at compile time where it costs nothing at run time. That is a better argument than a benchmark number, and it is the one an evaluator should check first: is your process short-lived? If yes, the reasoning applies. If you are writing a long-running service that parses configuration once and then runs for a week, the whole premise is wrong and you should use whatever is clearest.
The benchmark methodology is disclosed too, which is the other thing worth crediting. The note above the example image says that .NET 10.0 with RunStrategy=ColdStart and WarmupCount=0 is the configuration for calculating the cold start benchmark, and that this is suitable for a CLI application.
That is a benchmark harness with an explicit knob for the thing being measured. WarmupCount=0 is the setting that stops the benchmark from reporting a JIT-warmed second run, which is the number most microbenchmarks publish and the number that means nothing for a program invoked from a shell. A framework that tells you which configuration to use for which kind of program is being straight with you about what its numbers describe.
The runtime version matters too. The note specifies .NET 10.0 rather than the .NET 8 baseline the library requires, which is a reasonable choice for a benchmark since you want the best available runtime, and it does mean the published figures are not from the minimum supported version. Whether the gap between .NET 8 and .NET 10 startup is large is something you would have to measure, and the README does not claim it is not.
The lineage is stated as well, and it is worth knowing because it tells you what problems the design was solving. The technique was influenced by Rust's macros, specifically the attribute-like and function-like macros, and ConsoleAppFramework's generation can be considered as Function-like macros. That is a precise and flattering comparison, because a Rust function-like procedural macro receives a token stream and emits a token stream, and gets to decide the shape of the code it produces. This is the same shape of power in a different host language.
One line for user error, a stack trace for everything else
The catch block at the end of the generated method is short, and it is the part of the framework that shapes what a user sees, so it is worth reading as a design decision rather than as boilerplate.
catch (Exception ex)
{
Environment.ExitCode = 1;
if (ex is ValidationException or ArgumentParseFailedException)
{
LogError(ex.Message);
}
else
{
LogError(ex.ToString());
}
}Three things happen. The exit code is set to 1, so the process signals failure to the shell without the command having to remember. Then the exception is sorted into two categories by type. And the two categories print very differently.
If the exception is a ValidationException or an ArgumentParseFailedException, only the message is printed. Those are the framework's own exceptions for the cases it can describe in a sentence: a required argument was not supplied, an argument name was not found, a value could not be parsed into the declared type. The user gets one line, and it is the useful line.
Anything else gets ex.ToString(), which is the exception type, the message, the stack trace and every inner exception. That is a lot of output for someone who mistyped a flag, and it is exactly right for someone who found a bug in your command. The framework cannot know which of its users is looking at the output, so it makes the split on the type of exception rather than on a verbosity setting, and the type is a good proxy: a framework-generated exception means the user made a mistake, and anything else means something went wrong that the framework does not have a description for.
Setting the exit code centrally rather than requiring each command to do it is the other decision. Because the catch is in generated code that wraps your delegate invocation, every command gets consistent failure signalling without writing it. That is a small correctness win across a whole tool, and it is the kind of thing that gets missed in a hand-rolled parser and then discovered by a script that treated a failed command as a success.
The help output is generated too, and it is worth looking at because the format tells you what the generator knows. From a two-argument lambda it produces:
Usage: [options...] [-h|--help] [--version]
Options:
--foo <int> [Required]
--bar <int> [Required]The type comes from the lambda parameter, the angle-bracket notation is conventional, and the Required marker is emitted because the int parameters have no default. So defaults, optionality, types and the help text all fall out of the same signature with no separate declaration. The help and version flags are added automatically, and the option count passed to TryShowHelpOrVersion at the top of the method is what the framework uses to know how many options it is responsible for.
The help method is declared as a static partial method, which means the framework supplies the implementation and your code could supply it instead. That is the extension seam: if the generated help text is not what you want, you write your own partial method rather than fighting the generator.
5.7.13 is the newest release, and the branch is seven months ahead
The release history is the one piece of this repository that an adopting team should look at before anything else, and it says something specific.
The three most recent releases are 5.7.11 on 2025-11-19, 5.7.12 on 2025-11-25, and 5.7.13 on 2025-11-26. The last push to the repository was on 2026-07-08.
So all three releases are from a four-day window in November 2025, and there have been no tags since. The default branch has roughly seven and a half months of commits past the newest NuGet package.
Two things follow, and they are different in kind. First, a NuGet consumer installing today gets 5.7.13 and not the current state of the code. If a fix or a feature you need landed after November 2025, it is in the repository and not on the feed. For a source generator, that is a slightly sharper edge than it would be for a runtime library, because generator behaviour changes what code you get, and a change in the generator is a change in your build rather than a change in a library you happen to call.
Second, seven months of commits without a release is not a red flag on its own. It is a normal pattern for a library owned by a small team, and the November window itself is a normal pattern: three patch releases in four days is someone fixing something, then fixing the fix. The v5.7.x scheme is also worth reading. Thirteen patch releases within one minor version is a lot of iteration on a stable API surface, which is a sign that the public API is settled and the work is in the generator's behaviour, the parsing edge cases and the platform support.
The version number and the language baseline are also worth pairing. v5 requires .NET 8 and C# 13, and the fifth major version implies that the API went through four breaking changes. The README describes v5 as a redesign built on incremental generators rather than the older generation model, so the v4-to-v5 boundary is a real migration and not a version bump.
The repository layout supports the same picture. There is a src/ and a tests/ directory, a sandbox/ directory, a global.json pinning the SDK, a Directory.Build.props for shared MSBuild properties, a ConsoleAppFramework.slnx, an exclusion.dic for the spell checker, an .editorconfig and a .dockerignore. The solution uses the .slnx format, which is the newer XML-based solution format rather than the classic .sln, so the project expects a recent SDK. And there is a .dockerignore with no Dockerfile at the root, which suggests the container work lives in the sandbox or the benchmark area rather than being part of the library build.
One small thing that will bite someone: the readme file is named ReadMe.md, not README.md. That is invisible on macOS and Windows and a distinct filename on Linux, so a script or a link that expects the conventional capitalisation will not find it.
Editorial conclusion
Use ConsoleAppFramework if you are writing a .NET 8 or later command line tool and you want argument parsing, help text and validation without a NuGet dependency in your output assembly, and if you are willing to accept that the parsing is generated at compile time from the lambda's parameter names and types rather than configured at runtime. Do not adopt it for a library that needs to parse arguments at runtime, or for a long-running process where reflection would have been amortised, because the whole design assumes a single-shot cold start. Do not treat the version you get as current: the newest release is 5.7.13 from 2025-11-26 and the last push was 2026-07-08, so a NuGet consumer is roughly seven months behind the default branch. Verify four things. Read the generated code in the README against the code you would have written by hand, because that comparison is the whole evaluation and the framework publishes it for exactly this purpose. Confirm your SDK is new enough, since v5 needs .NET 8 and C# 13 and the repository uses the slnx solution format and a global.json. Check whether the ordinal-ignore-case fallback matters for your option set, because every option is parsed twice and the fallback is a linear scan. And decide whether NativeAOT is a goal, because that is the case where a package that contributes nothing to the output assembly is worth considerably more than one that does. The deciding fact is that the framework's entire value is a code-generation technique whose output you can read in the README, so the decision to adopt it is a decision about whether you want that parser rather than about whether you trust the library.
Frequently asked questions
How do I create a command line app with ConsoleAppFramework?
Call ConsoleApp.Run with the args array and a lambda whose parameters are the arguments, for example ConsoleApp.Run(args, (int foo, int bar) => Console.WriteLine($"Sum: {foo + bar}")); for the invocation ./cmd --foo 10 --bar 20. The generator reads the lambda's parameter names and types and emits the parsing code, the help text and the validation from that signature alone.
Does ConsoleAppFramework add a runtime dependency?
No. The README states that the ConsoleApp class and everything else is generated entirely by the source generator, resulting in no dependencies including ConsoleAppFramework itself. The package is consumed as a build-time analyzer and nothing from it is referenced by your compiled assembly, which is what makes the Native AOT path and the small binary size work.
What are the runtime requirements for ConsoleAppFramework v5?
.NET 8 or later with C# 13. The generator uses IncrementalGenerator, managed function pointers, params arrays and default values in lambda expressions, ISpanParsable<T> for parsing, and PosixSignalRegistration for handling SIGINT and SIGTERM. The repository also uses the newer .slnx solution format and a global.json pinning the SDK.
Is ConsoleApp argument parsing case sensitive?
No, and the generated code shows how. Each option is emitted twice: once as an exact case in the switch, and once in the default branch as a string.Equals comparison with StringComparison.OrdinalIgnoreCase. So the documented spelling takes the fast switch path and any other casing falls into a linear scan over the option list.
What is the latest version of ConsoleAppFramework?
5.7.13, released 2025-11-26, after 5.7.12 on 2025-11-25 and 5.7.11 on 2025-11-19. The last push to the repository was 2026-07-08, so the default branch is roughly seven months ahead of the newest published package and a NuGet consumer gets the November 2025 state.
Is ConsoleAppFramework fast to start, and how is that measured?
The README says the cold start benchmark should be run on .NET 10.0 with RunStrategy=ColdStart and WarmupCount=0, and that this configuration is suitable for a CLI application. The reasoning given is that CLI tools are single-shot from a cold start, so techniques like IL Emit, ExpressionTree.Compile and ArrayPool do not pay for their own setup cost.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/cysharp-consoleappframework)