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.8Your 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
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.20Bubble 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 errThis 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 barComposing 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 APIThe 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.exeThe -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.fishCobra 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.
Botmonster Tech