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