Interactive Go CLIs with Cobra Command Trees and Bubble Tea

You can build a polished, interactive command-line app in Go by pairing Cobra for command structure with Bubble Tea for the terminal UI. Cobra covers argument parsing, subcommands, flags, and auto-generated shell completions. Bubble Tea adds spinners, tables, text inputs, progress bars, and keyboard navigation on top. The result is one static binary. It runs in scripts and CI when called plainly, and shows a full terminal interface when a person runs it.

This pairing is now the default for Go CLI tools that need more than basic flag parsing. Tools like gh (the GitHub CLI), kubectl plugins, and many infrastructure CLIs use Cobra under the hood. Bubble Tea and its sister libraries from Charm have pushed terminal apps 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 app 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 full 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 build trees like mytool config set key value. Just call configCmd.AddCommand(configSetCmd, configGetCmd). Cobra then generates the help text, usage strings, and error messages for the whole tree on its own.

Flags and Configuration

Cobra splits flags into two kinds: persistent and local. A persistent flag set on the root command is inherited by every subcommand. A local flag only applies to the command where you define it.

For real apps, you almost always want Viper alongside Cobra. Viper reads config from flags, environment variables, and config files. It also sets the precedence for you: flags beat env vars, which beat the config file, which beats the 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. Users do not need to remember which one to reach for.

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 even lets you suggest live 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 ready-made 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 holds your state. An Update function handles events and returns the new state. A View function renders that state to a string. The framework runs the event loop, terminal raw mode, and screen rendering for you.

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 a spare terminal buffer, so your TUI does not clutter the user’s scrollback. When the program exits, the original terminal content comes back.

Non-Interactive Fallback

A good CLI works both interactively and non-interactively. Check 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, while mytool list gives a person a navigable table. For a similar pattern that uses fzf for keyboard-driven selection in shell scripts, see interactive CLI tools for Git repositories .

Building Common UI Components with Bubbles

The Bubbles library gives you ready-made components to embed in your model. Each one follows the same Init/Update/View pattern, so composing them is routine work.

The spinner is the one you will reach for most often. It gives visual feedback during slow 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 entry, such as API keys, search queries, or 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 jobs 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 apps need several 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 active, based on the state field. When a component signals it is done, say the user pressed Enter on the text input, advance the state and start the next component. This gives you multi-step wizards, confirmation dialogs, and progress screens, all within one Bubble Tea program.

Real-World Example: A Deployment CLI

Putting this together, here is how a deployment CLI might be laid out:

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 runs a multi-step wizard. Step 1 collects the deployment name via text input. Step 2 shows a list selector for the target environment, staging or production. Step 3 asks for the container image tag. Step 4 shows a summary of every choice and asks for confirmation. On confirm, a spinner runs while the API call happens. On success, the last screen shows the deployment ID and status.

The deploy rollback command is a good example of a safety-first confirmation pattern. It shows 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 makes the user type the deployment name to confirm, much like how GitHub handles repository deletion. This blocks accidental rollbacks from a stray Enter keypress.

Error Handling

Wrap API errors in styled views that tell the user what to do next:

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 three values: json, table, and interactive (the default). JSON mode skips the TUI and prints structured data. Table mode prints a plain ASCII table. Interactive mode launches Bubble Tea. With these three modes, the same binary serves 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 gives you higher-level helpers. They send message sequences and assert on rendered View output, which helps with integration-level TUI tests.

Cross-Compilation and Distribution

Go builds static binaries by default, which keeps distribution simple:

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, which shrinks the binary. A typical Cobra plus Bubble Tea app compiles to under 15MB.

For automated releases, GoReleaser does the heavy lifting. From a single .goreleaser.yaml file, it handles multi-platform builds, checksums, Homebrew tap formulas, Scoop manifests for Windows, and GitHub Release uploads. Run goreleaser release --clean and it produces everything.

Shell completions are basically free with Cobra. The framework writes 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 too. 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 runs.

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 runs just as well in a CI job as on someone’s laptop.