Writing A CLI Tool In C#
Patrick T Coakley 10 min read November 30, 2024 Software Development, Godot[ #csharp #dotnet #cli #tools #godot ]
Overview
I just recently released the first public version of my Godot version manager, gdvm. 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 combination of terminal and command-like interfaces, and to have the ability to pass in partial queries to best-guess the result based on context. I wanted to make something friendly, easy-to-use, and portable.
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 almost a decade on and off, but I don't actively use it in my profession anymore; having an on-going project is a good way to come back to it when new things come out or I want to try new ideas.
Another reason is that I have been 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 space. Finally, I enjoy C#'s support for dealing with string manipulating and parsing, as well as LINQ, and felt they would make some things easier in C# than something like Go.
I created gdvm 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. I figured I'd write a blog post talking a little about my experience, specifically with using C# since it's a less popular option for this sort of thing.
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 having 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.
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 every well in comparison to other similar libraries. I found ConsoleAppFramework to be a little obtuse to get started with because the documentation is 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 that will hopefully resolve it, 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.
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 gdvm, but are interesting for other projects in the future.
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. One really nice feature with Spectre.Console was the ease of testing, and while I only have a few areas to test I appreciate that there is Spectre.Console.Testing. There isn't much documentation for it, but using the examples in the repo you can kind of figure it out as you go, and I need to write more tests in the future.
Honorable Mentions
- As I mentioned, I started out with Spectre.Console.Cli and would have been happy to keep using it if I didn't care about NativeAOT.
- Terminal.Gui is a mature library that has a lot of neat features, but I didn't find it to be that useful for the kind of app I was making. If I was doing a more terminal-driven user-experience I think it would actually be a better fit than Spectre.Console.
Comparisons To Other Languages
One of the things that I enjoyed about using Go was the robust CLI ecosystem, including things 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. However, at the end of the day I found writing business logic in Go tedious, especially when it came to dealing with strings and collections, and while it generates much smaller binaries 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# is able to leverage vectorization and SIMD for things like cryptography, regex, and many other places, something Go still lacks.
Rust, on the other hand, has both really good libraries like clap and ratatui and the ability to do robust operations on strings and collections while still outputting an even smaller binary than Go. If I had not chosen C# for this project I likely would have tried to use Rust with the aforementioned libraries, but I also think it would have taken me longer to get something working in the way that I wanted. Still, it would be the next logical choice should I want to move to something else.
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 gdvm), 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 package manager differences and having to limit my Python version to be N-2 the latest, which makes it frustrating to work with. In addition, Python can have pretty poor startup performance, which I think should be important when developing a command-line application (and why I specifically wanted to use AOT for .NET in gdvm).
Conclusion
I think it's safe to say developing and distributing tools in C# has gotten a lot better in the last 5 years, and if the amount of performance improvements and NativeAOT continue I can see C# being a strong competitor for organizations that are already invested in .NET for creating internal tooling over its competitors. 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 used all of the higher-level functionality from the .NET standard library but at the 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.
Performance-wise, there shouldn't really be much of a difference between the three for typical CLI tooling, but obviously if you're doing something long-running that is CPU-bound you'll just have to benchmark and find out. I'm pretty confident modern .NET can compete just fine in this front, though. The stereotypes and perceptions of the old .NET world need to be broken, and many people still think of C# as "Microsoft's Java," but it's clear that the .NET team has invested a lot of time and energy to make it a viable platform on all major OSes and to compete in spaces it was previously not able to.