TL;DR
For subcommands, flags, and shell completions, the shape of kubectl or gh, reach for Cobra. For a single command with a handful of flags and the least ceremony possible, urfave/cli v3 gets you there with half the boilerplate. Bubble Tea plays a different game: it draws interactive terminal UIs (menus, spinners, forms), and you usually bolt it onto a Cobra command rather than pick between them. I built the same deploy tool three times to figure out where the lines actually fall, and the verdict depends more on which problem you have than which library is objectively best.
Why I rebuilt the same CLI three times
Two weeks ago I got tired of guessing. I maintain a small internal deploy tool at work, the kind of thing that reads a config, asks which environment you mean, and shells out to a few commands. (I walked through building a single-purpose CLI from scratch in Python in an earlier uv tutorial; this is the Go counterpart.) It started as a 40-line flag package script and had grown warts. So I rewrote it three times in one afternoon: once with Cobra, once with urfave/cli v3, once as a Bubble Tea TUI. Same behavior, three implementations, so I could compare boilerplate, binary size, and how each one felt under my fingers instead of trusting a table someone else made.
That exercise settled a few things I had been repeating without checking. urfave/cli v3 is a genuinely different library from the v2 most tutorials still show. Bubble Tea v2 quietly broke its own API in ways that will bite you if you copy an old snippet. And Cobra, the one everyone calls heavyweight, wrote itself faster than I expected because cobra-cli scaffolds the whole tree. This post is what I learned, with the code I actually ran.
If you want the wider Go tooling context, I keep a running Modern Go in 2026 guide that this piece slots into.
The split: parsers versus painters
The single most common mistake I see in “best Go CLI library” threads is treating these three as interchangeable, when two of them parse arguments and one of them paints screens.
Cobra and urfave/cli both answer the same question: given mytool deploy --env prod service-a, how do I route to the right code with the right values? They give you commands, flags, arguments, help text, and completions.
Bubble Tea answers a different question: how do I draw a live, keyboard-driven interface in the terminal (a selectable list, a progress bar, a text form) that redraws as the user types? It owns the screen rather than the argument vector. When people say “Cobra vs Bubble Tea,” what they usually want is “Cobra for the command tree, Bubble Tea for the interactive bits,” and the two compose cleanly.
Go’s single static binary and fast startup are a big part of why so many CLIs get written in it, the same traits that keep it in the fight with Rust for systems work. Star counts here are reported from GitHub at the time of writing and move around; treat them as rough popularity signals, not precision.
Feature comparison
| Capability | Cobra | urfave/cli v3 | Bubble Tea |
|---|---|---|---|
| Subcommand trees | Yes, deep nesting | Yes, flatter feel | N/A |
| Flag parsing | pflag (POSIX) | Built-in | N/A |
| Auto help / usage | Yes | Yes | Manual |
| Shell completions | bash/zsh/fish/pwsh | bash/zsh/fish | N/A |
| Config integration | Viper (same author) | Manual / altsrc | N/A |
| Interactive UI | No | No | Yes (core purpose) |
| Boilerplate for hello-world | Medium | Low | Higher |
| Scaffolding tool | cobra-cli | None | None |
| Latest line at writing | v1.10.x | v3.10.x | v2.x |
That last row is easy to skim past. Both urfave/cli and Bubble Tea shipped major versions that changed their APIs, and a lot of the tutorials Google surfaces are still on the old ones.
Cobra: the one that scales
Cobra is what the big tools use. kubectl, gh, hugo, and a long list of infrastructure binaries route their commands through Cobra. The reason is nested subcommands: git remote add origin ... is three levels deep, and Cobra models that tree natively with per-command flags, help, and completions generated for free.
Here is the greeting command from my deploy tool, cut down to what runs:
package main
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
func main() {
var verbose bool
root := &cobra.Command{
Use: "greet [name]",
Short: "Greet someone by name",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
if verbose {
fmt.Fprintln(os.Stderr, "verbose mode on")
}
fmt.Printf("Hello, %s!\n", args[0])
},
}
root.Flags().BoolVarP(&verbose, "verbose", "v", false, "enable verbose output")
if err := root.Execute(); err != nil {
os.Exit(1)
}
}
Run it and the plumbing is already there:
$ go run . greet Ada --verbose
verbose mode on
Hello, Ada!
$ go run . --help
Usage:
greet [name] [flags]
Flags:
-h, --help help for greet
-v, --verbose enable verbose output
The --help output, the flag validation, the arg count check (cobra.ExactArgs(1)): none of that is code I wrote. For a single command it feels like a lot of struct scaffolding. The payoff shows up at the third or fourth subcommand, when you add deploy, rollback, and status as children of the root and each one carries its own flags without you rewiring anything.
The other half of the Cobra story is Viper, from the same author. Viper layers config files, enviroment variables, and flags into one precedence chain, so --env on the command line overrides DEPLOY_ENV overrides config.yaml. If your tool has real configuration, that pairing is the reason to stay on Cobra. I lean on it whenever a tool needs to read a config file, which is most of them.
Where Cobra frustrates me: the empty-Run-function ritual for parent commands, and the fact that you scaffold a lot before the first useful line runs. The cobra-cli init command papers over that by writing the whole tree for you, and honestly it’s the fastest way to start a serious multi-command tool in Go today.
urfave/cli v3: less ceremony, same job
urfave/cli is the library I reach for when the tool is one command with a few flags and I don’t want to think about a command tree. v3 is the current line, and it’s worth being explicit that it broke from v2: the action signature now takes a context.Context and a *cli.Command, and you run with cmd.Run(ctx, os.Args). Copy a v2 snippet into a v3 project and it won’t compile, and the error messages aren’t obvious. That single gotcha cost me ten minutes.
The same greeting, in v3:
package main
import (
"context"
"fmt"
"os"
"github.com/urfave/cli/v3"
)
func main() {
cmd := &cli.Command{
Name: "greet",
Usage: "greet someone by name",
Flags: []cli.Flag{
&cli.BoolFlag{Name: "verbose", Aliases: []string{"v"}},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
name := cmd.Args().First()
if name == "" {
return fmt.Errorf("name is required")
}
if cmd.Bool("verbose") {
fmt.Fprintln(os.Stderr, "verbose mode on")
}
fmt.Printf("Hello, %s!\n", name)
return nil
},
}
if err := cmd.Run(context.Background(), os.Args); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
Two things I like here over Cobra. The action returns an error, so failure handling is a normal Go return instead of an os.Exit buried in a closure. And there’s no Args: cobra.ExactArgs(1) vocabulary to learn. You pull arguments off cmd.Args() and validate them yourself, which for a small tool is one line and reads plainly.
The tradeoff is that everything Cobra gives you automatically becomes your job at scale. Nested commands exist in urfave/cli via a Commands slice, but the ergonomics get thinner the deeper you nest, and there’s no cobra-cli equivalent to scaffold them. My rule of thumb after this exercise: one-to-three commands, urfave/cli; a real tree with shared config, Cobra.
For a worked example of a single-purpose Go tool built end to end, I walked through one in the Go AI agent tutorial, and the argument-handling shape there is exactly the urfave/cli sweet spot.
Bubble Tea: when a flag isn’t enough
Some tools shouldn’t answer a question with a flag. “Which environment?” is nicer as an arrow-key menu than as --env prod, especially for a tool humans run interactively. That kind of prompt is what Bubble Tea is for. It implements the Elm architecture: a Model holding state, an Update that folds messages into new state, and a View that renders state to a string. It redraws on every keystroke.
Here is the environment picker from my deploy tool. This targets the classic Bubble Tea API (see the version note right after; v2 changes some of this):
package main
import (
"fmt"
"os"
tea "github.com/charmbracelet/bubbletea"
)
type model struct {
choices []string
cursor int
selected string
}
func (m model) Init() tea.Cmd { return nil }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
case "down", "j":
if m.cursor < len(m.choices)-1 {
m.cursor++
}
case "enter":
m.selected = m.choices[m.cursor]
return m, tea.Quit
case "q", "ctrl+c":
return m, tea.Quit
}
}
return m, nil
}
func (m model) View() string {
s := "Pick an environment:\n\n"
for i, choice := range m.choices {
cursor := " "
if m.cursor == i {
cursor = ">"
}
s += fmt.Sprintf("%s %s\n", cursor, choice)
}
return s + "\n(↑/↓ move · enter select · q quit)\n"
}
func main() {
m := model{choices: []string{"dev", "staging", "production"}}
final, err := tea.NewProgram(m).Run()
if err != nil {
fmt.Println("error:", err)
os.Exit(1)
}
if fm, ok := final.(model); ok && fm.selected != "" {
fmt.Printf("Deploying to %s\n", fm.selected)
}
}
Running it gives you a live menu:
Pick an environment:
dev
> staging
production
(↑/↓ move · enter select · q quit)
Bubble Tea rarely travels alone. Lip Gloss styles the strings (colors, borders, padding), Bubbles ships pre-built components (text inputs, spinners, tables, paginators), and Huh wraps the whole thing into ready-made forms if you just want a questionnaire. That family is Charm, and it’s why so many recent Go TUIs share a look.
What changed in Bubble Tea v2
If you’re starting fresh in 2026, know that Bubble Tea v2 is a breaking release, and copying the snippet above verbatim into a v2 project won’t fully work. The changes that bite:
- Key messages now split into
tea.KeyPressMsgandtea.KeyReleaseMsg. You can still match both with thetea.KeyMsginterface, butkey.Type/key.Runesbecamekey.Code/key.Text, and modifiers moved intokey.Mod. - The big one:
View()no longer returns a plainstring. It returns atea.Viewstruct that declares content, cursor, alt-screen mode, colors, and more, which is the change most likely to break an oldView() stringmethod. - Imports moved to the vanity path
charm.land/bubbletea/v2.
The mental model (model, update, view, messages) is identical. The signatures are what shifted. I kept the example on the v1 API because it’s the version the vast majority of existing tutorials and libraries still target, and it runs today. When you upgrade, the migration is mechanical, just tedious.
Combining Cobra and Bubble Tea
This is the setup I shipped, and it’s the answer to most “which one” arguments: use both. Cobra owns the command tree and flags; a Bubble Tea program launches inside a command’s Run when the tool needs interaction. Falling back to a flag when one is supplied keeps it scriptable.
var deployCmd = &cobra.Command{
Use: "deploy",
Short: "Deploy a service to an environment",
RunE: func(cmd *cobra.Command, args []string) error {
env, _ := cmd.Flags().GetString("env")
if env == "" {
// No flag given: ask interactively with Bubble Tea.
m := model{choices: []string{"dev", "staging", "production"}}
final, err := tea.NewProgram(m).Run()
if err != nil {
return err
}
env = final.(model).selected
}
fmt.Printf("Deploying to %s\n", env)
return nil
},
}
deploy --env prod runs non-interactively in CI; a bare deploy opens the menu for a human. You get Cobra’s completions and help for free, and Bubble Tea only for the part that benefits from it. That split is why I stopped thinking of these as competitors.
Decision matrix
| Your situation | Reach for |
|---|---|
| Multi-command tool, shared config, completions | Cobra (+ Viper) |
| One or two commands, minimal deps, quick | urfave/cli v3 |
| Interactive menu, form, dashboard, or spinner | Bubble Tea (+ Lip Gloss / Bubbles) |
| Scriptable in CI and friendly for humans | Cobra + Bubble Tea together |
| A throwaway script, one flag | stdlib flag (skip all three) |
The flag row isn’t a joke. The standard library parses flags fine, and for a 50-line utility that nobody else runs, pulling in a framework is overhead you’ll feel in build time and binary size for no benefit. I reach for a framework when the tool grows a second command or a real user other than me.
Getting started
# Cobra (+ scaffolding tool)
go get github.com/spf13/cobra@latest
go install github.com/spf13/cobra-cli@latest
cobra-cli init mytool
# urfave/cli v3
go get github.com/urfave/cli/v3@latest
# Bubble Tea + Charm libraries
go get github.com/charmbracelet/bubbletea@latest
go get github.com/charmbracelet/lipgloss@latest
go get github.com/charmbracelet/bubbles@latest
For anything Cobra-based, run cobra-cli init first and let it write the tree. It’s the single biggest time-saver in this whole space. Then add commands with cobra-cli add deploy. If you’re on Bubble Tea, start from an official example in their repo rather than a blog snippet, because the v2 API churn means half the snippets online are subtly stale.
FAQ
Is Cobra or urfave/cli better?
Neither wins in the abstract. Cobra is better for tools with deep subcommand trees and configuration (it powers kubectl and gh for a reason). urfave/cli v3 is better for small tools where you want less boilerplate and a normal error-returning action. Pick by the shape of your command tree rather than by popularity.
Is Bubble Tea a replacement for Cobra?
No. Bubble Tea draws interactive terminal interfaces; Cobra parses commands and flags. They solve different problems and compose well: Cobra for the command structure, Bubble Tea launched inside a command for the interactive parts.
What CLI library does the GitHub CLI use?
gh is built on Cobra, as are kubectl, hugo, and many other large Go tools. The draw is native nested subcommands, generated help, and shell completions across bash, zsh, fish, and PowerShell.
Do I need Cobra to use Bubble Tea?
No. Bubble Tea runs standalone as tea.NewProgram(model).Run(). You only pair it with Cobra when you also need a command tree and flags around the interactive UI. For a single interactive screen, Bubble Tea alone is enough.
What is the best Go CLI framework in 2026?
For most non-trivial tools, Cobra, because of subcommands, Viper config, and completions. For small single-purpose tools, urfave/cli v3. For interactive UIs, Bubble Tea. There’s no one winner. The right answer depends on whether you’re parsing commands, keeping it minimal, or drawing a screen.
Sources
- Cobra GitHub repository and releases: the framework behind
kubectlandgh, current v1.10.x line - urfave/cli GitHub repository: v3 releases and migration notes from v2
- Bubble Tea GitHub repository and releases: Elm-architecture TUI framework, v2 breaking changes
- Bubble Tea v2.0.0 release notes: the key-message split,
tea.Viewreturn type, and import-path change - Charm: Lip Gloss, Bubbles, and Huh, the styling and component layer around Bubble Tea
Bottom line
The useful question is what your tool actually does, not which library wins a benchmark. If it parses commands, the choice is Cobra versus urfave/cli, and it comes down to how deep your subcommand tree goes and whether you want Viper config: Cobra for depth, urfave/cli for lightness. If it draws a screen, reach for Bubble Tea, which sits on top of whichever parser you chose rather than competing with it. My deploy tool ended up Cobra plus Bubble Tea, and rebuilding it three times was the fastest way I’ve found to stop guessing and just pick.