Writing A CLI Tool In C#

Patrick T Coakley 17 min read November 30, 2024 Updated: January 06, 2026 Software Development, Godot
[ #csharp #dotnet #cli #tools #godot #fgvm #rust #go #python #swift #zig ]
fgvm install 4 dev mono

Overview

At the time I first published this article, I had just recently released the first stable version of my Godot version manager, fgvm. It lets you manage multiple versions of your Godot installations locally right from the terminal without having to deal with downloading and unzipping the right files, and it supports all major versions, even going back to Godot 1.x for Windows. The main goals were to have multi-platform support, single static binaries, a user-friendly combination of terminal and command-line interfaces, and to have the ability to leverage queries to find what you're looking for. Over time I added more features like support for managing local installations in a project and using smart argument parsing when working with Godot to contextually launch in an attached or detached mode.

Like most people who have written CLI tools in the past, I have tended to prefer things like Go, Rust, or some kind of scripting language like Python, Powershell, Bash, etc. I wanted to give C# a go for a few reasons. First, I enjoy C# and have been using it for over a decade both professionally and personally. However, I don't actively use it in my profession anymore and might not ever again, so having an ongoing project is a good way to come back to it to try out new features and libraries. Second, I was also curious about some of the newer features of .NET, including NativeAOT, as well as some of the libraries being created to compete in the command-line tooling space. As mentioned, I have primarily used other languages to work on these kinds of projects, so it's been interesting to see how well C# handles the work. Finally, I enjoy C#'s support for dealing with string manipulation and parsing, as well as LINQ, and felt they would make some things easier in C# than something like Go. While there is nothing in fgvm that couldn't be done in any other language, I found it very easy to map some of my ideas to the code without needing a ton of external dependencies or boilerplate.

I created the first version of fgvm in a few weeks, mostly going through a few days of experimenting with different libraries, then another 2 weeks of writing a prototype that took a lot of shortcuts to get the desired result, and then another week and a half to refactor it so it had better testability and overall test coverage. After taking a few long breaks I dove back in just recently to get the last remaining features I wanted and did some big refactors over the course of a few busy days.

Libraries

What I Ended Up Using

A while back I had played around with Spectre.Console and knew immediately I wanted to use it in my tool, so I spent some time using it to see what I could do with it, and more specifically, how it would fit with what I wanted to do. Some cool features like live display weren't really going to be usable, but the different types of prompts and how easy they are to use were a great fit for what I was looking for. I also enjoy the markup support, which makes it easy to style the text without having to remember how to use ANSI escape sequences. It even has easy support for emojis, which is neat.

It was also pretty straightforward to implement a progress bar for downloads and have a status for each step in the install process, all things that made Spectre.Console pretty great to work with. The only major challenge I found was trying to use Spectre.Console.Cli, their command-line arguments parsing library. While it was really easy to use and felt like the best choice in the first initial batch I played with, I noticed that it had issues with NativeAOT trimming, and so I was forced to look for an alternative for that portion, but Spectre.Console is fantastic and has a great testing library that makes it easy to test your prompts and decouple code.

ConsoleAppFramework is a CLI framework focused on being high-performance and AOT-safe, including no reflection by using source generators. This makes it a perfect choice for static binaries on .NET, and it benchmarks very well in comparison to other similar libraries. I found ConsoleAppFramework to be a little obtuse to get started with because the documentation is a little bit confusing, and there aren't many good examples of how to use the command class builder interface.

I ended up finding a bug and filing an issue, but outside of these issues I found it to be very minimalistic and straightforward once you understand how to use it. Essentially, all you need to do is expose public methods, make your method parameters in line with this list, and then add the class to the builder, and the source generator will create all of the necessary code for you. There are some nuances with how it handles exceptions, logging, and other things like filters that were a little bit confusing, but I think I have everything how I want it for now, and the library seems like it's in a good place if you just want something that isn't too heavy to deal with command-like arguments. It does have some limitations that I've had to work around, but the structure of the code is pretty straightforward and makes it easy to go back to.

From ConsoleAppFramework's documentation I found ZLogger, also by the same developers. It too is a high-performance library that makes sense for something that is trying to minimize overhead, and it's built in using all of the same logging interfaces you'd expect in .NET. As a logger, there's not a ton of interesting stuff to say, but it's worth noting that there wasn't much configuration needed to get it working. It supports structured logging, which I am currently using, as well as rolling logs, in-memory logging, all kinds of custom formatters, including JSON and MessagePack, and a bunch of other things I probably won't use for fgvm, but are interesting for other projects in the future, especially if you are working on game services.

For testing, I just ended up with xUnit and a bit of FsCheck, though I need to add more property-based testing with the latter in the future. There really isn't much to say here, both are great libraries worth using on any .NET project.

How It's Been So Far

Since putting fgvm out and working on it in my free time for over a year, I have some thoughts on choices I made and what I might do differently.

One thing I think I would have done sooner was to establish an end-to-end test suite of blackbox tests that run against the binary without any reference to the internal implementation. I currently do this with xUnit running a container fixture using Testcontainers, but I plan to migrate these tests to an external test suite using Nushell, or possibly another shell, because I don't want to limit my tests to certain platform configurations, and in general there are going to be some edge cases when working in a cross-platform project. Nushell is nice because it works on all major platforms (including Windows), has a very clean syntax, and is designed for working with structured data. Having these tests has helped me identify regressions, including some that were on macOS but not on ARM or x64 Linux.

While I have enjoyed my library choices overall, I did have some frustrations with some of the limitations ConsoleAppFramework had while I was originally developing fgvm that make me want to consider alternatives. Up until recently it felt like development had stalled a little bit, and I had to use some hacky workarounds for features that are more standard in other libraries I've used in the past or for bugs that were inherent to the framework; thankfully most of these things have been fixed. I am still happy that I chose to use this library, and even now there aren't many alternatives that target NativeAOT as a goal.

ZLogger has been great, and I don't have any complaints about it. It works as you would expect and while I'm not even sure how much performance benefit I'm getting from it in a CLI tool, it's nice to know that it's not adding any unnecessary overhead. There are probably a lot of useful features I haven't explored yet, but for basic logging it's been perfect.

Spectre.Console has been fantastic overall, and I don't think I would change anything about it. Being able to test out of the box has been nice, and I think it has been relatively easy to use. There are a few quirks here and there, but nothing that would make me consider something else at this time. It does feel like there are some edge cases when working with NativeAOT, but nothing that has been a block so far.

For now I plan to stay the course with what I've been using, including beefing up the tests and adding more features, but I am already trying to think about what I want to use in the future for later versions.

Alternatives I Considered

There are a few options I could have considered for writing fgvm, and I wanted to take a moment to reflect on why I chose C# over some of the other popular choices for CLI tools.

Go

One of the things that I've enjoyed about using Go in the past was the robust CLI ecosystem, including things in the entire suite of Charm libraries and tools, Cobra, Kong, and many others. In addition, the flag package is really easy to use and can handle most situations for getting argument parsing going in a CLI project. This made it very easy to create internal tooling on teams where we needed something better than shell scripts but didn't want to introduce a lot of complexity that was also able to scale over time. However, at the end of the day I find writing business logic in Go tedious, especially when it comes to dealing with strings and collections, and while it generates much smaller binaries than C#, has very fast startup times, and supports more platforms overall than C#, I don't really find that it would be worth the tradeoff to lose a lot of the metaprogramming functionality, LINQ, etc. In addition, C#, like many other languages, is able to leverage vectorization and SIMD for things like cryptography, regex, and many other places, something Go still lacks. While not incredibly relevant to this particular project, it's something to consider if you're doing anything performance-sensitive.

I'm unsure where Go fits in the modern tooling world anymore: on the one hand, there are languages like Rust that simply outperform it in most areas while providing many high-level language features Go lacks, and on the other hand there is Deno, which provides a very productive experience for writing CLI tools for folks already familiar with TypeScript while also being very easy to distribute. There isn't one particular area where Go shines over other languages anymore, and while it's still a valid choice, even in areas where it was dominant in the past like cloud-native web applications, technologies like NativeAOT and GraalVM have really closed the performance and distribution gap, and Rust already has a growing ecosystem of its own. That being said, I still think projects like TinyGo and TamaGo are really interesting and could help bring new and interesting ways to use the language, such as embedded systems, but specifically for the use of tools like CLIs I think that there is a wide variety of choices that are just as good if not better at this point.

Rust

Rust not only has really great libraries like clap and ratatui, but as a language it has the ability to do expressive operations on strings and collections in the same way C# can, as well as actual pattern matching and macros to cut down on boilerplate, all the while outputting an even smaller binary than Go with better performance than most any other language. If I had not chosen C# for this project for the specific purposes of playing more with NativeAOT, I would have just used Rust as I have been using it on and off since about 1.0, but I also think it would have taken me longer to get something working in the way that I wanted because I tend to have to think more about my architecture with Rust than I do with C#, where it can be faster to throw together an MVP; even now I am still re-arranging things that might have created some friction had I started in Rust. Still, I think Rust is a great choice for creating tools and probably would recommend it for most CLI projects unless you have a specific reason to use something else, as I think the ecosystem has become very mature and the language itself is very well-suited for this kind of work.

Swift

Another language I absolutely enjoy working with is Swift, and there are certainly libraries like ArgumentParser that make it easy to build command-line interfaces. The core issue with Swift in the space I've found is that it simply has too much emphasis on Apple platforms. For example, I couldn't find an up-to-date INI library, so I wrote my own, Winnie. There aren't any libraries even remotely close to something like Spectre.Console that are also cross-platform; many libraries leverage the Darwin-specific APIs or are targeting UNIX for terminal manipulation, which makes it difficult to write cross-platform tools. I've spent some time trying to create an MVP of a terminal experience library in Swift that works cross-platform, but it was taking too much time and I'm not even sure how much use other folks would get out of it.

Also, while it technically works on multiple platforms, Swift on Windows is still not at parity with even Linux, let alone macOS, and since fgvm targets game developers, most of which will be on Windows, I felt that Swift wasn't a good fit for this particular project. I think Apple missed their window of opportunity for Swift to be a real competitor to other languages in this and any other cross-platform space, especially since Rust was open from the start and consistently focused on growth. While server-side Swift has seen some growth in recent years, there really haven't been any major CLI tools written in Swift that have gained traction.

Python

Python has pretty good built-in argument parsing, as well as the Rich and Textual libraries from the Textualize developers, and pytermgui, to name a few. The challenge with Python (and Bash, PowerShell, etc) is always distribution, and while I could have relied on just using PyPI, as well as Homebrew and Scoop (both of which I am currently using for fgvm), I wanted to be able to have the option of just unzipping a file and running it without having to deal with dependencies.

In the past, I've struggled with distributing Python tools due to system package manager differences and having to limit my Python version to be N-2 (that is, 2 versions behind the latest), which makes it frustrating to work with because you are often limited to older versions that also might impact library choices. uv tools simplifies the workflow to use external tools a lot, and in general I think uv and the other work being done by Astral is incredible for the Python ecosystem. Still, I also find Python has slower startup and runtime performance while also having a messy static typing story (I have worked on projects where I have to run 2 different type checkers to get proper coverage).

Zig

When I originally published the first draft of this article and was creating the original versions of the app, Zig wasn't something I had spent as much time with (though I had been following for a good bit from afar). I wanted to mention it as I do see it slowly growing in popularity, especially after Ghostty and Bun have emerged. Out of the box, the cross-platform support is very good, it produces very small and fast binaries, and it has a very small but growing ecosystem, including a Zig port of the aforementioned clap. In addition, it can directly work with C libraries without having to create bindings. Being that Zig lacks some of the higher-level language features of something like C# or Rust, I am not sure what my experience using Zig to create a CLI tool would be like, but between that and the immaturity of the language itself (it is years away from being stable), it's not something I would have considered for the first iteration of this project. That being said, I would like to explore ways to use Zig in the future.

Conclusion

I think it's safe to say developing and distributing tools in C# has gotten a lot better in recent years, and if the pace of performance improvements and NativeAOT features continue I can see C# being a strong competitor for organizations that are already invested in .NET for creating tools over its competitors. .NET 10 was a big release that introduced many new features and improvements, including ones targeting NativeAOT, and it has resulted in better performance and smaller binaries for free after updating fgvm. This trend seems to be continuing with each release and is one of the main benefits of sticking with .NET, as it seems like each release brings significant improvements without needing to change anything in your code.

For folks that aren't already using C#, I feel that it provides a very productive middle-ground between something like Rust and Go, where I still get to use all of the higher-level functionality from the .NET standard library at low cost of some overhead, including memory and binary size. To be clear, we are talking about the difference within 8-10 megabytes, so it's not worth picking something over for that reason alone. After making some architectural changes and upgrading dependencies, being able to get fgvm to have a startup time of roughly ~7ms for all non-networked IO commands puts it in the same class as other native compiled languages, and it's only getting better.