Building Interactive CLI Tools in Go with Bubbletea#

If you’ve ever wanted to build a terminal application that feels more like a proper UI than a wall of text, the charmbracelet ecosystem is the way to go. I’ve been using it to build internal DevOps tools, and the developer experience is excellent. In this post, I’ll walk through building an interactive CLI tool using Bubbletea and Huh, the same libraries behind tools like gum and soft-serve.

Why Build TUI Tools?#

As a platform engineer, I spend a lot of time running repetitive operations across multiple AWS accounts: scanning for cost optimization opportunities, checking resource utilization, cleaning up unused infrastructure. A plain CLI with flags works, but when you have 100+ AWS profiles to choose from and a dozen scan categories, an interactive TUI makes the experience significantly better.

The alternative is a web dashboard, but that means deploying and maintaining another service. A Go binary with a TUI is a single file you can distribute to your team.

The Elm Architecture#

Bubbletea follows The Elm Architecture, which boils down to three concepts:

  1. Model - your application state
  2. Update - a function that handles messages and updates the model
  3. View - a function that renders the model as a string
package main

import (
    "fmt"
    tea "github.com/charmbracelet/bubbletea"
)

type model struct {
    profiles []string
    cursor   int
    selected string
}

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.profiles)-1 {
                m.cursor++
            }
        case "enter":
            m.selected = m.profiles[m.cursor]
            return m, tea.Quit
        }
    }
    return m, nil
}

func (m model) View() string {
    s := "Select an AWS profile:\n\n"
    for i, profile := range m.profiles {
        cursor := " "
        if m.cursor == i {
            cursor = ">"
        }
        s += fmt.Sprintf("%s %s\n", cursor, profile)
    }
    s += "\nPress q to quit.\n"
    return s
}

This gives you a scrollable list with keyboard navigation. But we can do much better with the higher-level libraries.

Using Huh for Forms#

Huh provides pre-built form components (selects, multi-selects, inputs, confirms) that handle all the rendering and interaction logic for you. This is what I use for building the interactive parts of DevOps tools.

package main

import (
    "fmt"
    "github.com/charmbracelet/huh"
)

func main() {
    var profile string
    var services []string
    var dryRun bool

    form := huh.NewForm(
        huh.NewGroup(
            huh.NewSelect[string]().
                Title("AWS Profile").
                Description("Select the AWS account to scan").
                Options(
                    huh.NewOption("production-eu", "prod-eu"),
                    huh.NewOption("staging-eu", "staging-eu"),
                    huh.NewOption("development-eu", "dev-eu"),
                ).
                Value(&profile),

            huh.NewMultiSelect[string]().
                Title("Services to Scan").
                Description("Choose which services to analyze").
                Options(
                    huh.NewOption("ECS Fargate", "ecs"),
                    huh.NewOption("Aurora Clusters", "aurora"),
                    huh.NewOption("S3 Buckets", "s3"),
                    huh.NewOption("EBS Volumes", "ebs"),
                    huh.NewOption("CloudWatch Logs", "cloudwatch"),
                    huh.NewOption("ElastiCache", "elasticache"),
                ).
                Value(&services),

            huh.NewConfirm().
                Title("Dry Run?").
                Description("Preview changes without applying them").
                Value(&dryRun),
        ),
    )

    err := form.Run()
    if err != nil {
        fmt.Println("Error:", err)
        return
    }

    fmt.Printf("Scanning %s for: %v (dry-run: %v)\n", profile, services, dryRun)
}

This gives you a polished, themed form with keyboard navigation, validation, and proper rendering out of the box.

Loading AWS Profiles Dynamically#

In practice, you want to load profiles from ~/.aws/credentials rather than hardcoding them:

package main

import (
    "bufio"
    "os"
    "path/filepath"
    "strings"

    "github.com/charmbracelet/huh"
)

func loadAWSProfiles() ([]huh.Option[string], error) {
    home, err := os.UserHomeDir()
    if err != nil {
        return nil, err
    }

    file, err := os.Open(filepath.Join(home, ".aws", "credentials"))
    if err != nil {
        return nil, err
    }
    defer file.Close()

    var options []huh.Option[string]
    scanner := bufio.NewScanner(file)
    for scanner.Scan() {
        line := strings.TrimSpace(scanner.Text())
        if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
            profile := line[1 : len(line)-1]
            options = append(options, huh.NewOption(profile, profile))
        }
    }

    return options, scanner.Err()
}

Running Scans with a Spinner#

Once the user selects their options, you want to show progress. Bubbletea’s spinner component works well here:

package main

import (
    "fmt"
    "time"

    "github.com/charmbracelet/bubbles/spinner"
    tea "github.com/charmbracelet/bubbletea"
    "github.com/charmbracelet/lipgloss"
)

type scanModel struct {
    spinner  spinner.Model
    scanning bool
    results  []ScanResult
    current  string
}

type ScanResult struct {
    Service        string
    ResourceCount  int
    EstimatedSaving float64
}

type scanDoneMsg struct {
    results []ScanResult
}

func newScanModel(services []string) scanModel {
    s := spinner.New()
    s.Spinner = spinner.Dot
    s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))

    return scanModel{
        spinner:  s,
        scanning: true,
        current:  services[0],
    }
}

func (m scanModel) Init() tea.Cmd {
    return tea.Batch(m.spinner.Tick, runScans())
}

func (m scanModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case scanDoneMsg:
        m.scanning = false
        m.results = msg.results
        return m, tea.Quit
    case spinner.TickMsg:
        var cmd tea.Cmd
        m.spinner, cmd = m.spinner.Update(msg)
        return m, cmd
    }
    return m, nil
}

func (m scanModel) View() string {
    if m.scanning {
        return fmt.Sprintf("%s Scanning %s...\n", m.spinner.View(), m.current)
    }

    s := "Scan Results:\n\n"
    totalSaving := 0.0
    for _, r := range m.results {
        s += fmt.Sprintf("  %s: %d resources, estimated saving: EUR %.2f/month\n",
            r.Service, r.ResourceCount, r.EstimatedSaving)
        totalSaving += r.EstimatedSaving
    }
    s += fmt.Sprintf("\n  Total estimated saving: EUR %.2f/month (EUR %.2f/year)\n",
        totalSaving, totalSaving*12)
    return s
}

func runScans() tea.Cmd {
    return func() tea.Msg {
        // Your actual AWS scanning logic goes here
        time.Sleep(2 * time.Second) // Simulated scan
        return scanDoneMsg{
            results: []ScanResult{
                {Service: "ECS Fargate", ResourceCount: 12, EstimatedSaving: 340.50},
                {Service: "EBS Volumes", ResourceCount: 8, EstimatedSaving: 120.00},
                {Service: "CloudWatch Logs", ResourceCount: 45, EstimatedSaving: 85.30},
            },
        }
    }
}

Parallel Scanning with Goroutines#

For a real tool, you want to scan multiple services in parallel:

func scanServices(profile string, services []string) tea.Cmd {
    return func() tea.Msg {
        results := make(chan ScanResult, len(services))
        var wg sync.WaitGroup

        cfg, err := config.LoadDefaultConfig(context.Background(),
            config.WithSharedConfigProfile(profile),
        )
        if err != nil {
            return scanErrorMsg{err: err}
        }

        for _, svc := range services {
            wg.Add(1)
            go func(service string) {
                defer wg.Done()
                result := scanService(cfg, service)
                results <- result
            }(svc)
        }

        go func() {
            wg.Wait()
            close(results)
        }()

        var allResults []ScanResult
        for r := range results {
            allResults = append(allResults, r)
        }

        return scanDoneMsg{results: allResults}
    }
}

Rendering Tables with Lipgloss#

For the final output, Lipgloss lets you style terminal output with a CSS-like API:

package main

import (
    "fmt"
    "github.com/charmbracelet/lipgloss"
    "github.com/charmbracelet/lipgloss/table"
)

func renderResults(results []ScanResult) string {
    headerStyle := lipgloss.NewStyle().
        Bold(true).
        Foreground(lipgloss.Color("205"))

    rows := [][]string{}
    totalSaving := 0.0
    for _, r := range results {
        rows = append(rows, []string{
            r.Service,
            fmt.Sprintf("%d", r.ResourceCount),
            fmt.Sprintf("EUR %.2f", r.EstimatedSaving),
        })
        totalSaving += r.EstimatedSaving
    }

    t := table.New().
        Border(lipgloss.NormalBorder()).
        Headers("Service", "Resources", "Est. Saving/month").
        Rows(rows...)

    summary := headerStyle.Render(
        fmt.Sprintf("\nTotal: EUR %.2f/month (EUR %.2f/year)",
            totalSaving, totalSaving*12))

    return t.String() + "\n" + summary
}

Distributing the Binary#

One of the best things about Go is the single-binary output. Build for your team’s platforms:

GOOS=linux GOARCH=amd64 go build -o tool-linux-amd64
GOOS=darwin GOARCH=arm64 go build -o tool-darwin-arm64
GOOS=windows GOARCH=amd64 go build -o tool-windows-amd64.exe

Or use GoReleaser to automate this with your CI pipeline.

Takeaways#

After building several internal tools with this stack, here are my recommendations:

  • Use Huh for forms, Bubbletea for custom interactions. Don’t build a custom select component when Huh already has one.
  • Keep the TUI thin. The interactive part should only collect user input. The actual business logic (AWS API calls, scanning, etc.) should be in separate packages that can also be called non-interactively via flags.
  • Add a --json flag. Other tools and scripts will want to consume your output.
  • Test with tea.NewProgram options. Bubbletea supports testing by passing in a custom reader/writer.

The charmbracelet ecosystem makes it easy to build tools that your team will actually enjoy using, instead of yet another script with 30 flags.

References#