Contents

How to Build a CLI Tool in Go with Cobra and Bubble Tea

You can build a professional, interactive command-line application in Go by combining Cobra for command structure with Bubble Tea for terminal UI. Cobra covers argument parsing, subcommands, flag handling, and auto-generated shell completions. Bubble Tea adds spinners, tables, text inputs, progress bars, and keyboard-driven navigation on top. The result is a single statically linked binary that works in scripts and CI pipelines when called non-interactively, and provides a full terminal interface when a human runs it directly.

This pairing has become the de facto standard for Go CLI tools that need more than basic flag parsing. Tools like gh (GitHub CLI), kubectl plugins, and plenty of infrastructure CLIs use Cobra under the hood. Bubble Tea and its companion libraries from Charm have pushed terminal applications well past what most developers expect from a command line.

Project Setup and Cobra Fundamentals

Start by initializing a Go module and pulling in Cobra:

go mod init github.com/yourname/mytool
go get github.com/spf13/cobra@v1.8

Your main.go is minimal. It just calls into the root command:

package main

import "github.com/yourname/mytool/cmd"

func main() {
    cmd.Execute()
}

The root command lives in cmd/root.go. Every Cobra application starts with a rootCmd - the top-level entry point. Subcommands attach to it:

package cmd

import (
    "fmt"
    "os"
    "github.com/spf13/cobra"
)

var cfgFile string

var rootCmd = &cobra.Command{
    Use:   "mytool",
    Short: "A deployment management CLI",
    Long:  "mytool manages deployments with both interactive and scriptable interfaces.",
}

func Execute() {
    if err := rootCmd.Execute(); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

func init() {
    rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c",
        "~/.mytool.yaml", "config file path")
}

Each cobra.Command struct has a few key fields. Use defines the command syntax. Short is the one-line description that shows up in help listings. Long is the detailed help text. Args handles validation - cobra.ExactArgs(1) rejects the wrong number of arguments with a clear error. RunE is the handler function that returns an error.

Subcommands are just more cobra.Command structs added to a parent:

var serveCmd = &cobra.Command{
    Use:   "serve",
    Short: "Start the web server",
    RunE: func(cmd *cobra.Command, args []string) error {
        port, _ := cmd.Flags().GetInt("port")
        fmt.Printf("Serving on :%d\n", port)
        return nil
    },
}

func init() {
    serveCmd.Flags().IntP("port", "p", 8080, "port to listen on")
    rootCmd.AddCommand(serveCmd)
}

You can nest subcommands to create hierarchies like mytool config set key value by calling configCmd.AddCommand(configSetCmd, configGetCmd). Cobra generates the help text, usage strings, and error messages for the entire tree automatically.

Flags and Configuration

Cobra distinguishes between persistent flags and local flags. A persistent flag defined on the root command is inherited by every subcommand. A local flag only applies to the command where it is defined.

For real applications, you almost always want Viper alongside Cobra. Viper reads configuration from flags, environment variables, and config files with automatic precedence (flags override env vars override config file override defaults):

import "github.com/spf13/viper"

func init() {
    viper.BindPFlag("port", serveCmd.Flags().Lookup("port"))
    viper.SetEnvPrefix("MYTOOL")
    viper.AutomaticEnv()
}

With this setup, --port 9090, MYTOOL_PORT=9090, and a port: 9090 line in ~/.mytool.yaml all work, and users do not need to remember which mechanism to use.

Cobra v1.8 also generates shell completions out of the box. Call rootCmd.GenBashCompletionFileV2("completions/mytool.bash", true) or expose it as a subcommand - Cobra can emit completions for Bash, Zsh, Fish, and PowerShell. Dynamic completion lets you suggest valid argument values (like deployment names fetched from an API) as users tab through options.

Adding Interactive TUI with Bubble Tea

Bubble Tea's Elm-architecture approach enables rich, interactive terminal UIs from a single Go binary

With the command structure in place, you can add interactivity. Install Bubble Tea along with Lip Gloss for styling and Bubbles for pre-built components:

go get github.com/charmbracelet/bubbletea@v1.3
go get github.com/charmbracelet/lipgloss@v1.1
go get github.com/charmbracelet/bubbles@v0.20

Bubble Tea uses the Elm architecture. You define three things: a Model (your state), an Update function (handles events and returns new state), and a View function (renders state to a string). The framework handles the event loop, terminal raw mode, and screen rendering.

type model struct {
    choices  []string
    cursor   int
    selected map[int]struct{}
}

func (m model) Init() tea.Cmd {
    return nil
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        switch msg.String() {
        case "ctrl+c", "q":
            return m, tea.Quit
        case "up", "k":
            if m.cursor > 0 {
                m.cursor--
            }
        case "down", "j":
            if m.cursor < len(m.choices)-1 {
                m.cursor++
            }
        case "enter", " ":
            if _, ok := m.selected[m.cursor]; ok {
                delete(m.selected, m.cursor)
            } else {
                m.selected[m.cursor] = struct{}{}
            }
        }
    }
    return m, nil
}

func (m model) View() string {
    s := "Select deployments:\n\n"
    for i, choice := range m.choices {
        cursor := " "
        if m.cursor == i {
            cursor = ">"
        }
        checked := " "
        if _, ok := m.selected[i]; ok {
            checked = "x"
        }
        s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, choice)
    }
    s += "\nPress q to quit.\n"
    return s
}

Integrating Bubble Tea with Cobra Commands

In your Cobra command’s RunE function, create a Bubble Tea model and run the program:

var listCmd = &cobra.Command{
    Use:   "list",
    Short: "List deployments interactively",
    RunE: func(cmd *cobra.Command, args []string) error {
        m := initialModel()
        p := tea.NewProgram(m, tea.WithAltScreen())
        finalModel, err := p.Run()
        if err != nil {
            return fmt.Errorf("TUI error: %w", err)
        }
        // Use finalModel to access the user's selections
        result := finalModel.(model)
        fmt.Printf("Selected %d items\n", len(result.selected))
        return nil
    },
}

tea.WithAltScreen() switches to an alternate terminal buffer so your TUI does not pollute the user’s scrollback history. When the program exits, the original terminal content reappears.

Non-Interactive Fallback

A good CLI works in both interactive and non-interactive contexts. Detect whether stdin is a terminal and skip the TUI when it is not:

import "golang.org/x/term"

func isInteractive() bool {
    return term.IsTerminal(int(os.Stdin.Fd()))
}

In your command handler, branch on this:

if !isInteractive() || outputFormat == "json" {
    // Print machine-readable output
    return json.NewEncoder(os.Stdout).Encode(deployments)
}
// Launch the interactive TUI
p := tea.NewProgram(newListModel(deployments), tea.WithAltScreen())
_, err := p.Run()
return err

This way, mytool list | jq '.[] | .name' works in a pipeline, and mytool list gives a human user a navigable table.

Building Common UI Components with Bubbles

The Bubbles library provides ready-made components that you embed in your model. Each component follows the same Init/Update/View pattern, so composing them is mechanical.

The spinner is the one you will reach for most often. It gives visual feedback during long-running operations:

import "github.com/charmbracelet/bubbles/spinner"

type deployModel struct {
    spinner  spinner.Model
    deploying bool
    done     bool
}

func newDeployModel() deployModel {
    s := spinner.New()
    s.Spinner = spinner.MiniDot
    s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B"))
    return deployModel{spinner: s, deploying: true}
}

Forward the spinner’s messages through your Update function so the animation runs:

func (m deployModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    if m.deploying {
        var cmd tea.Cmd
        m.spinner, cmd = m.spinner.Update(msg)
        return m, cmd
    }
    return m, nil
}

Text input handles single-line data collection (API keys, search queries, deployment names):

import "github.com/charmbracelet/bubbles/textinput"

ti := textinput.New()
ti.Placeholder = "Enter deployment name..."
ti.CharLimit = 64
ti.Width = 40
ti.Focus()

The table component displays structured data with keyboard navigation:

import "github.com/charmbracelet/bubbles/table"

columns := []table.Column{
    {Title: "Name", Width: 20},
    {Title: "Status", Width: 10},
    {Title: "Version", Width: 10},
    {Title: "Updated", Width: 20},
}
rows := []table.Row{
    {"api-prod", "running", "v2.4.1", "2026-03-28 14:30"},
    {"api-staging", "running", "v2.5.0", "2026-03-28 15:00"},
}
t := table.New(
    table.WithColumns(columns),
    table.WithRows(rows),
    table.WithFocused(true),
)

And the progress bar works well for batch operations and file transfers:

import "github.com/charmbracelet/bubbles/progress"

p := progress.New(progress.WithDefaultGradient())
// In View():
p.ViewAs(0.75) // Renders a 75% filled bar

Composing Multiple Components

Real applications need multiple components on screen or in sequence. The standard pattern is a state machine embedded in your model:

type state int

const (
    stateInput state = iota
    stateConfirm
    stateRunning
    stateDone
)

type wizardModel struct {
    state    state
    nameInput textinput.Model
    envList   list.Model
    spinner   spinner.Model
    result    string
}

In Update(), route messages to whichever component is currently active based on the state field. When a component signals completion (like the user pressing Enter on the text input), advance the state and initialize the next component. This gives you multi-step wizards, confirmation dialogs, and progress screens all within a single Bubble Tea program.

Real-World Example: A Deployment CLI

Putting this together, here is how a deployment management CLI might be structured:

mytool/
  main.go
  cmd/
    root.go
    list.go
    create.go
    rollback.go
    logs.go
  ui/
    list.go        // Table-based deployment list
    wizard.go      // Multi-step create wizard
    confirm.go     // Typed confirmation dialog
    styles.go      // Shared Lip Gloss styles
  api/
    client.go      // HTTP client for deployment API

The deploy create command launches a multi-step wizard: Step 1 collects the deployment name via text input. Step 2 presents a list selector for the target environment (staging or production). Step 3 asks for the container image tag. Step 4 shows a summary of all selections and asks for confirmation. On confirm, a spinner runs while the API call happens. On success, the final screen shows the deployment ID and status.

The deploy rollback command is a good example of a safety-critical confirmation pattern. It displays a styled warning box:

warningStyle := lipgloss.NewStyle().
    Border(lipgloss.RoundedBorder()).
    BorderForeground(lipgloss.Color("#FF0000")).
    Padding(1, 2)

warning := warningStyle.Render(
    "You are about to rollback production-api to v2.3.1.\n" +
    "This will affect 3 running instances.")

Then it requires the user to type the deployment name to confirm, similar to how GitHub handles repository deletion. This prevents accidental rollbacks from a stray Enter keypress.

Error Handling

Wrap API errors in styled views that give the user actionable information:

errorStyle := lipgloss.NewStyle().
    Bold(true).
    Foreground(lipgloss.Color("#FF6B6B"))

func renderError(status int, message string) string {
    return errorStyle.Render("Error: ") +
        fmt.Sprintf("HTTP %d - %s\n", status, message) +
        "Run `mytool list` to verify the deployment exists."
}

Support an --output flag with values json, table, and interactive (default). JSON mode skips the TUI entirely and prints structured data. Table mode prints a plain ASCII table. Interactive mode launches Bubble Tea. This three-mode approach means the same binary works for human operators, shell scripts, and CI/CD pipelines.

Testing, Building, and Distributing

Testing Cobra Commands

Test Cobra commands by setting arguments programmatically and capturing output:

func TestListCommand(t *testing.T) {
    var buf bytes.Buffer
    rootCmd.SetOut(&buf)
    rootCmd.SetArgs([]string{"list", "--output", "json"})
    err := rootCmd.Execute()
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    var deployments []Deployment
    json.Unmarshal(buf.Bytes(), &deployments)
    if len(deployments) == 0 {
        t.Error("expected at least one deployment")
    }
}

Testing Bubble Tea Models

Test Bubble Tea models by calling Update directly with synthetic messages:

func TestWizardNavigation(t *testing.T) {
    m := newWizardModel()
    // Simulate typing a name
    m, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("my-deploy")})
    // Simulate pressing Enter to advance
    m, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter})
    wizard := m.(wizardModel)
    if wizard.state != stateConfirm {
        t.Errorf("expected stateConfirm, got %v", wizard.state)
    }
}

The teatest package from Charm provides higher-level helpers for sending message sequences and asserting on rendered View output, which is useful for integration-level TUI tests.

Cross-Compilation and Distribution

Go produces statically linked binaries by default, which makes distribution straightforward:

GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.version=1.2.3" \
    -o dist/mytool-linux-amd64

GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w -X main.version=1.2.3" \
    -o dist/mytool-darwin-arm64

GOOS=windows GOARCH=amd64 go build -ldflags="-s -w -X main.version=1.2.3" \
    -o dist/mytool-windows-amd64.exe

The -s -w flags strip debug symbols and DWARF info, reducing binary size. A typical Cobra + Bubble Tea application compiles to under 15MB.

For automated releases, GoReleaser handles multi-platform builds, checksum generation, Homebrew tap formulas, Scoop manifests for Windows, and GitHub Release uploads from a single .goreleaser.yaml config file. Run goreleaser release --clean and it produces everything.

Shell completions are essentially free with Cobra. The framework generates completion scripts for Bash, Zsh, Fish, and PowerShell. Expose them as a subcommand:

# Bash
mytool completion bash > /etc/bash_completion.d/mytool

# Zsh
mytool completion zsh > "${fpath[1]}/_mytool"

# Fish
mytool completion fish > ~/.config/fish/completions/mytool.fish

Cobra v1.8 supports dynamic completions, meaning your tool can suggest valid values at completion time by querying an API or reading a config file. This makes the CLI feel native to whichever shell the user prefers.

That is the full picture. Cobra owns the command tree, flags, help text, and shell completions. Bubble Tea owns whatever interactive bits your users need. Bubbles saves you from writing your own spinner or table widget. The whole thing compiles to one binary with no runtime dependencies, and it works equally well in a CI job and on someone’s laptop.