<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>CLI on</title><link>/tags/cli/</link><description>Recent content in CLI on</description><generator>Hugo</generator><language>en</language><lastBuildDate>Thu, 05 Jun 2025 00:00:00 +0000</lastBuildDate><atom:link href="/tags/cli/index.xml" rel="self" type="application/rss+xml"/><item><title>Lesson 8: Packaging and Distributing — GoReleaser, Homebrew, and getting your tool to users</title><link>/post/go/go-cli-distribution/</link><pubDate>Thu, 05 Jun 2025 00:00:00 +0000</pubDate><guid>/post/go/go-cli-distribution/</guid><description>&lt;p&gt;Building a great CLI tool is half the job. The other half is getting it to users without making them compile it from source, navigate a GitHub releases page manually, or run a curl-pipe-to-bash script from an unverified URL. Distribution is where many Go projects stop short: the binary exists, the README says &lt;code&gt;go install&lt;/code&gt;, and that is considered &amp;ldquo;distributed.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;go install&lt;/code&gt; is fine for Go developers. It is not acceptable for operators, system administrators, and end users who reasonably expect &lt;code&gt;brew install&lt;/code&gt; or &lt;code&gt;apt install&lt;/code&gt;. GoReleaser closes this gap by automating the full release pipeline — cross-compiled binaries, checksums, GitHub releases, Homebrew formulas, Debian packages, Docker images — from a single configuration file and one &lt;code&gt;git push --tags&lt;/code&gt;.&lt;/p&gt;</description></item><item><title>Lesson 7: Build Flags and ldflags — Inject version info at compile time</title><link>/post/go/go-cli-build-flags/</link><pubDate>Sat, 05 Apr 2025 00:00:00 +0000</pubDate><guid>/post/go/go-cli-build-flags/</guid><description>&lt;p&gt;A CLI tool that cannot tell you what version it is running is a frustrating tool to operate. When something breaks, the first question is &amp;ldquo;which version?&amp;rdquo; Without a proper answer, debugging becomes archaeology. The good news is that Go&amp;rsquo;s build system provides two mechanisms for injecting metadata at compile time — &lt;code&gt;ldflags&lt;/code&gt; for injecting variable values from the shell, and build constraints for including or excluding code based on the build context — and both are straightforward once you understand their syntax.&lt;/p&gt;</description></item><item><title>Lesson 6: Embedding Assets — embed.FS puts files inside your binary</title><link>/post/go/go-cli-embed/</link><pubDate>Wed, 05 Feb 2025 00:00:00 +0000</pubDate><guid>/post/go/go-cli-embed/</guid><description>&lt;p&gt;Before Go 1.16, embedding static assets in a Go binary required either a code generation tool that converted files to byte arrays, a third-party library like &lt;code&gt;packr&lt;/code&gt; or &lt;code&gt;statik&lt;/code&gt;, or shipping the files alongside the binary and reading them from disk at runtime. Each approach had real costs: generated code bloated repositories, third-party tools had to be installed separately, and shipping separate files broke the &amp;ldquo;single binary&amp;rdquo; distribution story.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;//go:embed&lt;/code&gt; directive in Go 1.16 solved this cleanly. Files, directories, and whole asset trees can be embedded directly into the binary with a single comment and a variable declaration. Templates, SQL migrations, web assets, default configs — everything your binary needs can travel with it.&lt;/p&gt;</description></item><item><title>Lesson 5: Cross-Compilation — Build for Linux from your Mac in one command</title><link>/post/go/go-cli-cross-compile/</link><pubDate>Tue, 10 Dec 2024 00:00:00 +0000</pubDate><guid>/post/go/go-cli-cross-compile/</guid><description>&lt;p&gt;One of Go&amp;rsquo;s most practical superpowers is that cross-compilation is a first-class feature, not an afterthought. Two environment variables — &lt;code&gt;GOOS&lt;/code&gt; and &lt;code&gt;GOARCH&lt;/code&gt; — are all you need to produce a Linux binary from macOS, a Windows executable from Linux, or an ARM binary from an x86 machine. No Docker containers required, no cross-compilation toolchain setup, no linker flags hunting. Just &lt;code&gt;GOOS=linux GOARCH=amd64 go build&lt;/code&gt; and you have a production-ready binary for the target platform.&lt;/p&gt;</description></item><item><title>Lesson 4: Signal Handling — Catch SIGTERM or lose your work</title><link>/post/go/go-cli-signals/</link><pubDate>Wed, 30 Oct 2024 00:00:00 +0000</pubDate><guid>/post/go/go-cli-signals/</guid><description>&lt;p&gt;Most CLI tools work perfectly on the happy path and fail silently on the unhappy one. The user presses Ctrl-C, the OS sends SIGINT, and the process dies immediately — leaving a temp file half-written, a database connection open, or a progress bar frozen mid-operation. The problem is invisible because most of the time nobody looks at what gets left behind. Until a deployment script depends on that temp file being complete, or a database hits its connection limit, or a batch job loses six hours of progress because the container was terminated between checkpoints.&lt;/p&gt;</description></item><item><title>Lesson 10: Testing CLI Applications — End-to-end CLI tests</title><link>/post/rust/rust-cli-testing/</link><pubDate>Sun, 22 Sep 2024 12:10:00 +0000</pubDate><guid>/post/rust/rust-cli-testing/</guid><description>&lt;p&gt;I shipped a CLI tool with 100% unit test coverage on the core logic. Users immediately found three bugs. The argument parser accepted &lt;code&gt;--port&lt;/code&gt; but the value wasn&amp;rsquo;t being passed to the server. The &lt;code&gt;--json&lt;/code&gt; flag produced output that wasn&amp;rsquo;t valid JSON because of a stray debug print. And &lt;code&gt;--help&lt;/code&gt; showed the wrong default for &lt;code&gt;--timeout&lt;/code&gt;. None of these bugs lived in the &amp;ldquo;core logic.&amp;rdquo; They lived in the glue between clap, the output formatter, and the actual binary. Unit tests didn&amp;rsquo;t catch them because unit tests don&amp;rsquo;t run the actual binary.&lt;/p&gt;</description></item><item><title>Lesson 9: Building TUIs with ratatui — Terminal user interfaces</title><link>/post/rust/rust-cli-tui/</link><pubDate>Thu, 19 Sep 2024 07:55:00 +0000</pubDate><guid>/post/rust/rust-cli-tui/</guid><description>&lt;p&gt;I was monitoring a deployment through five separate terminal windows — one for logs, one for metrics, one for the deployment status, one for the database, and one running htop. Alt-tabbing between them like a madman. Then a colleague showed me their custom TUI dashboard that combined all five views into a single terminal screen, with tabs and live-updating graphs. It was written in Rust with ratatui. I rebuilt it that weekend.&lt;/p&gt;</description></item><item><title>Lesson 8: Distribution — Static binaries, cargo-dist, Homebrew</title><link>/post/rust/rust-cli-distribution/</link><pubDate>Mon, 16 Sep 2024 19:40:00 +0000</pubDate><guid>/post/rust/rust-cli-distribution/</guid><description>&lt;p&gt;I built a CLI tool that three teams at work used daily. It lived in a shared directory on an NFS mount. Every time I pushed an update, I&amp;rsquo;d post in Slack: &amp;ldquo;new version in /shared/tools, please copy it to your PATH.&amp;rdquo; Half the team was running a version from three months ago because they forgot. The other half had four copies scattered across their home directories. Distribution matters. If installing your tool is harder than &lt;code&gt;brew install myapp&lt;/code&gt;, most people won&amp;rsquo;t bother.&lt;/p&gt;</description></item><item><title>Lesson 7: Cross-Compilation for Linux, Mac, Windows — Build once, run anywhere</title><link>/post/rust/rust-cli-cross-compile/</link><pubDate>Sat, 14 Sep 2024 10:25:00 +0000</pubDate><guid>/post/rust/rust-cli-cross-compile/</guid><description>&lt;p&gt;First time I tried to cross-compile a Rust binary from my Mac to Linux, I ran &lt;code&gt;cargo build --target x86_64-unknown-linux-gnu&lt;/code&gt; and got hit with a wall of linker errors. Missing &lt;code&gt;cc&lt;/code&gt;, wrong &lt;code&gt;libc&lt;/code&gt;, something about &lt;code&gt;crt1.o&lt;/code&gt;. It felt like the &amp;ldquo;build once, run anywhere&amp;rdquo; promise was a lie. It wasn&amp;rsquo;t — I just didn&amp;rsquo;t understand how Rust&amp;rsquo;s compilation model interacts with system libraries. Once that clicked, cross-compilation became routine.&lt;/p&gt;
&lt;h2 id="how-rust-compilation-works"&gt;How Rust Compilation Works&lt;/h2&gt;
&lt;p&gt;Rust compiles to LLVM IR, then LLVM generates machine code for the target architecture. That part works across platforms — LLVM knows how to emit x86_64, aarch64, arm, riscv, wasm, and more. The problem is the &lt;em&gt;linker&lt;/em&gt;.&lt;/p&gt;</description></item><item><title>Lesson 3: File I/O Patterns — Read, write, stream without loading everything into memory</title><link>/post/go/go-cli-file-io/</link><pubDate>Thu, 12 Sep 2024 00:00:00 +0000</pubDate><guid>/post/go/go-cli-file-io/</guid><description>&lt;p&gt;CLI tools spend most of their life doing file I/O. Reading config files, processing log dumps, writing output, transforming data from stdin to stdout — it all comes down to bytes moving through your program. The difference between a CLI tool that handles 100MB files gracefully and one that runs out of memory on large inputs is almost always whether you read everything into memory or stream it.&lt;/p&gt;
&lt;p&gt;Go&amp;rsquo;s standard library gives you everything you need to stream data efficiently. The key is knowing which functions to reach for and which ones to avoid when the input is large or unknown in size.&lt;/p&gt;</description></item><item><title>Lesson 6: Subcommands and Complex CLI Structures — git-style interfaces</title><link>/post/rust/rust-cli-subcommands/</link><pubDate>Wed, 11 Sep 2024 13:50:00 +0000</pubDate><guid>/post/rust/rust-cli-subcommands/</guid><description>&lt;p&gt;Every tool starts as &lt;code&gt;mytool --flag input.txt&lt;/code&gt;. Then someone asks for a second mode. Then a third. Before you know it, you have &lt;code&gt;mytool --mode=convert --input foo --output bar&lt;/code&gt; and &lt;code&gt;mytool --mode=validate --strict --input foo&lt;/code&gt; and users are scrolling through &lt;code&gt;--help&lt;/code&gt; trying to find the three flags that matter for their use case. The answer is subcommands. &lt;code&gt;git commit&lt;/code&gt;, &lt;code&gt;docker build&lt;/code&gt;, &lt;code&gt;cargo test&lt;/code&gt; — separate commands with separate flags, unified under one binary.&lt;/p&gt;</description></item><item><title>Lesson 5: Signal Handling and Graceful Shutdown — Clean exits</title><link>/post/rust/rust-cli-signals/</link><pubDate>Mon, 09 Sep 2024 09:17:00 +0000</pubDate><guid>/post/rust/rust-cli-signals/</guid><description>&lt;p&gt;I had a CLI tool that converted video files. Big ones — 10, 20 gigabytes each. The conversion created a temp file, wrote the converted output there, then renamed it to the final destination. Hit Ctrl+C at the wrong moment and you&amp;rsquo;d get a half-written 15GB temp file sitting on disk. Users ran out of disk space without knowing why. All because I never handled signals properly.&lt;/p&gt;
&lt;h2 id="what-happens-when-you-press-ctrlc"&gt;What Happens When You Press Ctrl+C&lt;/h2&gt;
&lt;p&gt;When you press Ctrl+C in a terminal, the kernel sends &lt;code&gt;SIGINT&lt;/code&gt; (signal interrupt) to the foreground process group. By default, this kills your program immediately. No destructors run. No &lt;code&gt;Drop&lt;/code&gt; implementations execute. Temporary files stay on disk. Database connections aren&amp;rsquo;t closed. Partial writes aren&amp;rsquo;t rolled back.&lt;/p&gt;</description></item><item><title>Lesson 4: Colored Output and Progress Bars — UX for the terminal</title><link>/post/rust/rust-cli-colored-output/</link><pubDate>Sat, 07 Sep 2024 16:33:00 +0000</pubDate><guid>/post/rust/rust-cli-colored-output/</guid><description>&lt;p&gt;I used to think terminal output was either plain text or ANSI escape code soup. Then I looked at how tools like &lt;code&gt;cargo&lt;/code&gt;, &lt;code&gt;ripgrep&lt;/code&gt;, and &lt;code&gt;bat&lt;/code&gt; handle their output — color used purposefully to draw the eye, progress bars that give you actual information, spinners that tell you something is happening. Good terminal UX isn&amp;rsquo;t about making things pretty. It&amp;rsquo;s about making information scannable.&lt;/p&gt;
&lt;h2 id="raw-ansi-escape-codes"&gt;Raw ANSI Escape Codes&lt;/h2&gt;
&lt;p&gt;Before using any crate, you should understand what&amp;rsquo;s actually happening. Terminal colors are just special byte sequences embedded in the output stream:&lt;/p&gt;</description></item><item><title>Lesson 3: Configuration Files and Environment Variables — Config that scales</title><link>/post/rust/rust-cli-config/</link><pubDate>Thu, 05 Sep 2024 11:08:00 +0000</pubDate><guid>/post/rust/rust-cli-config/</guid><description>&lt;p&gt;I shipped a CLI tool with 23 flags once. Twenty-three. The &lt;code&gt;--help&lt;/code&gt; output scrolled past two terminal screens. Users hated it, nobody could remember the flags, and every deployment script was a wall of backslash-continued command lines. That&amp;rsquo;s when I learned: if your tool has more than about eight flags, you need a configuration file.&lt;/p&gt;
&lt;h2 id="the-configuration-hierarchy"&gt;The Configuration Hierarchy&lt;/h2&gt;
&lt;p&gt;Every serious CLI tool follows the same precedence order:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Command-line flags (highest priority)&lt;/li&gt;
&lt;li&gt;Environment variables&lt;/li&gt;
&lt;li&gt;Project-local config file (&lt;code&gt;.myapp.toml&lt;/code&gt; in the current directory)&lt;/li&gt;
&lt;li&gt;User config file (&lt;code&gt;~/.config/myapp/config.toml&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;System config file (&lt;code&gt;/etc/myapp/config.toml&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Compiled-in defaults (lowest priority)&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Each layer overrides the one below it. This lets users set defaults in their home directory, override them per-project, and override those on a per-invocation basis with flags. kubectl, git, docker — they all work this way.&lt;/p&gt;</description></item><item><title>Lesson 2: stdin, stdout, stderr — I/O patterns</title><link>/post/rust/rust-cli-io/</link><pubDate>Tue, 03 Sep 2024 08:45:00 +0000</pubDate><guid>/post/rust/rust-cli-io/</guid><description>&lt;p&gt;A coworker once asked me to review a Rust CLI they&amp;rsquo;d written. It read a CSV file, transformed some columns, and wrote the result. Worked perfectly — until someone piped in a 2GB file. The tool ate 4GB of RAM, hung for thirty seconds, then crashed. They were reading the entire file into a &lt;code&gt;String&lt;/code&gt; before processing a single line. Classic.&lt;/p&gt;
&lt;h2 id="why-io-is-harder-than-it-looks"&gt;Why I/O Is Harder Than It Looks&lt;/h2&gt;
&lt;p&gt;Unix tools work because of a simple contract: read from stdin, write to stdout, errors to stderr. &lt;code&gt;cat file | grep pattern | sort | uniq -c&lt;/code&gt;. Each program does one thing. They compose through pipes. It&amp;rsquo;s beautiful when it works.&lt;/p&gt;</description></item><item><title>Lesson 1: clap — Argument parsing done right</title><link>/post/rust/rust-cli-clap/</link><pubDate>Sun, 01 Sep 2024 14:22:00 +0000</pubDate><guid>/post/rust/rust-cli-clap/</guid><description>&lt;p&gt;I&amp;rsquo;ve written argument parsers by hand in four different languages. Every single time, I ended up with a tangled mess of string matching, edge cases around flags that take optional values, and help text that drifted out of sync with reality within a week. Then I found clap.&lt;/p&gt;
&lt;h2 id="the-problem-with-rolling-your-own"&gt;The Problem With Rolling Your Own&lt;/h2&gt;
&lt;p&gt;Parsing command-line arguments &lt;em&gt;seems&lt;/em&gt; simple. You grab &lt;code&gt;std::env::args()&lt;/code&gt;, maybe split on &lt;code&gt;=&lt;/code&gt;, handle some &lt;code&gt;--flag&lt;/code&gt; and &lt;code&gt;-f&lt;/code&gt; cases. Works great until someone passes &lt;code&gt;--output=&lt;/code&gt; with no value. Or uses &lt;code&gt;-vvv&lt;/code&gt; for triple verbosity. Or expects &lt;code&gt;--help&lt;/code&gt; to just work. Or wants &lt;code&gt;--color=always|never|auto&lt;/code&gt;. Suddenly your 40-line parser is 400 lines of spaghetti.&lt;/p&gt;</description></item><item><title>Lesson 2: Config and Env Handling — Viper, envconfig, or just os.Getenv?</title><link>/post/go/go-cli-config/</link><pubDate>Mon, 05 Aug 2024 00:00:00 +0000</pubDate><guid>/post/go/go-cli-config/</guid><description>&lt;p&gt;Configuration is one of those problems that looks trivial until it is not. A single environment variable is three lines of code. A configuration file with overridable environment variables, sensible defaults, validation, and reload-on-signal is a project in itself. Knowing when to use each approach — raw &lt;code&gt;os.Getenv&lt;/code&gt;, struct-based env decoding, or a full configuration library like Viper — is more about understanding the tradeoffs than about which library is &amp;ldquo;best.&amp;rdquo;&lt;/p&gt;</description></item><item><title>Lesson 1: Building CLIs with Cobra — From main.go to production CLI</title><link>/post/go/go-cli-cobra/</link><pubDate>Sat, 22 Jun 2024 00:00:00 +0000</pubDate><guid>/post/go/go-cli-cobra/</guid><description>&lt;p&gt;Every Go project that grows into something useful eventually needs a command-line interface. Maybe it starts as a quick &lt;code&gt;main.go&lt;/code&gt; with &lt;code&gt;os.Args[1]&lt;/code&gt; checks and a switch statement. That works until you need subcommands, flags, help text, shell completion, and version information — and suddenly you are maintaining a hand-rolled argument parser that nobody wants to touch. Cobra is the standard library-grade solution to this problem, and learning to structure a Cobra application properly saves you from rewriting it twice.&lt;/p&gt;</description></item></channel></rss>