Featured image of post Which CLI library should you start with?

Which CLI library should you start with?

You’ve decided to build a command-line tool. Good. And before you’ve written a single line of the thing it’s actually meant to do, you hit a decision that feels far more permanent than it should: which library parses the arguments. You go looking for the answer, and instead of an answer… you get a scrap. The Go crowd will tell you flag, or Cobra, or urfave/cli, usually with a bit of heat. The Rust crowd will tell you clap, and then argue about how you ought to hold it.

I’ve shipped CLIs in both languages, and wired both into frameworks other people build on (go-tool-base and rust-tool-base). So here’s the map I wish I’d had. Not “the best one”, because there isn’t one, but where to start, and the single thing that tells you when you’ve outgrown it.

Go: a ladder you climb

Go hands you a perfectly good argument parser in the standard library, flag, and for a great many tools that is the entire answer. A couple of options, no subcommands, done:

name := flag.String("name", "world", "who to greet")
flag.Parse()
fmt.Printf("hello, %s\n", *name)

No dependency, no ceremony, and it’s already on your machine. If your tool is one verb and a handful of flags, stop reading and use this. Reaching for anything bigger is just wiring you’ll maintain for nobody’s benefit.

The moment flag runs out of road is a specific one, and it pays to know exactly where it sits… nested subcommands. The instant you want mytool build and mytool deploy to be separate commands, each with its own flags and its own --help, flag has nothing for you and you’re hand-rolling a tangle of switch statements. That’s the signal to climb.

At the top of the ladder is Cobra. It’s the one behind kubectl, docker, hugo, gh and helm, north of 170,000 projects all told, so if you’ve ever used a Go CLI you’ve used it. Subcommands, generated help, shell completions, man pages: all the furniture a “grown-up” tool eventually wants. The cost is ceremony. A cobra.Command per verb, usually Viper alongside it for config, and a fair pile of boilerplate before the tool does anything at all.

Between the two sits urfave/cli, the option people forget to mention in the fight. It does subcommands like Cobra, but with a lighter, more declarative feel, and if Cobra’s structure looks like more than you need, it’s the sensible middle rung.

So Go is a genuine ladder: start on flag, and climb to a framework the day subcommands arrive. Or don’t wire Cobra and Viper together yourself at all. That assembly is precisely the job go-tool-base wraps up for you, which is a whole series of its own. A funnel, I’ll grant you, but a fair one.

Rust: a ladder you descend

Rust is a different shape entirely, and that difference is the interesting part of this whole question.

There’s no argument parser in Rust’s standard library, and there’s no real fight over which crate to use either, because one of them won and won decisively. clap has getting on for a billion downloads and is a dependency of some sixty-odd thousand other crates. It’s the default in the truest sense of the word: it’s what people reach for without stopping to think, and it scales from a single flag to a deep tree of subcommands without you ever swapping libraries. One struct, subcommands and all:

#[derive(Parser)]
struct Cli {
    #[command(subcommand)]
    command: Command,
}

#[derive(Subcommand)]
enum Command {
    Build { target: String },
    Deploy { #[arg(long)] dry_run: bool },
}

That’s the derive style, macros hung on your structs, and it’s the ergonomic default (there’s a runtime builder API too, for when you’re assembling commands dynamically). It’s what we use in rust-tool-base, and the reasoning was nothing exotic. We already knew CLI conventions well, we knew rtb needed the configuration depth only a heavy-hitter gives you, and clap’s sheer popularity made it the boring, sensible bet. When a crate is that dominant, choosing it is the low-risk move, and rtb goes a step further and generates the clap wiring so you never write that struct by hand at all.

Which is why the Rust ladder runs the other way. In Go you start small and climb up toward a framework. In Rust the framework is the ground floor, and the only real question is whether some specific pressure justifies stepping down to something smaller:

  • pico-args or lexopt when binary size and compile time genuinely matter and you can live without generated help. Zero or near-zero dependencies, and you write the usage text yourself. Grand for a small, sharp tool.
  • argh when you want the derive ergonomics but lighter than clap, and you don’t mind that it follows Fuchsia’s conventions rather than the Unix ones your users will expect.
  • bpaf when your parsing is genuinely unusual and you’d sooner compose it from combinators, while keeping the niceties like completions.

Every one of those is a step down from clap for a reason you can actually name. Without such a reason, clap is the answer, and that isn’t laziness. It’s the ecosystem having already done the choosing on your behalf.

(One wrinkle worth a footnote: clap’s “global” flags don’t behave the way you’d assume once you start nesting commands, which cost me an afternoon and a post of its own.)

Same rule, opposite ends

Strip the two languages back and the principle underneath is identical: use the smallest thing that fits your constraints, and let a real need, not a hunch, be the thing that moves you off it. What’s genuinely interesting is that the two ecosystems put the default at opposite ends of that rule.

Go’s default sits at the bottom, because the standard library gives you flag for nothing and moving up to Cobra is a switch you make on purpose. Rust’s default sits at the top, because the standard library gives you nothing at all and one crate grew so dominant that starting anywhere else is the choice you’d have to justify. Go makes you pick and grow into it. Rust makes you descend under pressure.

So the real answer to “which should I start with” isn’t a library. It’s a direction. In Go, start at the bottom and climb the day the subcommands turn up. In Rust, start at the top and only ever climb down, and only when something forces your hand. Either way, you’ll know the exact moment you need to move, which is the only thing the question was ever really asking.

Cover: the Go gopher by Renée French (CC BY 3.0) and Ferris (CC0), reimagined in cut paper.

Built with Hugo
Theme Stack designed by Jimmy