Skip to main content

Building Interactive CLI Applications in Go

Published: May 8, 2026 Updated: August 29, 2026 Larry Qu 7 min read

An interactive CLI asks questions, shows progress, and responds to keyboard input — instead of requiring every option upfront as a flag. This is the right pattern for setup wizards, administrative tools, deployment scripts, and any CLI where the user needs to make a series of decisions.

Go ships with the basic building blocks in the standard library, and two third-party libraries — golang.org/x/term for raw terminal control and github.com/charmbracelet/bubbletea for full TUI applications — cover everything else.

For flag-based CLIs see Go building CLI with Cobra and Go command-line parsing.

Reading Text Input

bufio.Scanner or bufio.Reader is the right way to read a line of input. fmt.Scanln stops at whitespace, which breaks inputs with spaces; bufio.Reader.ReadString reads the full line:

func prompt(label string) (string, error) {
    fmt.Print(label)
    reader := bufio.NewReader(os.Stdin)
    input, err := reader.ReadString('\n')
    if err != nil {
        return "", fmt.Errorf("reading input: %w", err)
    }
    return strings.TrimSpace(input), nil
}

name, err := prompt("Enter your name: ")
if err != nil {
    log.Fatal(err)
}
fmt.Printf("Hello, %s!\n", name)

For multiple prompts in sequence, share one bufio.Reader to avoid buffering issues — each bufio.NewReader call starts a new buffer, which can consume bytes from stdin that the previous reader buffered but didn’t use:

type Prompter struct {
    reader *bufio.Reader
}

func NewPrompter() *Prompter {
    return &Prompter{reader: bufio.NewReader(os.Stdin)}
}

func (p *Prompter) Ask(label string) (string, error) {
    fmt.Print(label)
    line, err := p.reader.ReadString('\n')
    return strings.TrimSpace(line), err
}

Secure Password Input

Never read passwords with echo enabled — the characters appear on screen. golang.org/x/term provides term.ReadPassword, which disables echo for the duration of the read:

go get golang.org/x/term
import "golang.org/x/term"

func readPassword(prompt string) (string, error) {
    fmt.Print(prompt)
    // ReadPassword takes the file descriptor, not an io.Reader
    password, err := term.ReadPassword(int(os.Stdin.Fd()))
    fmt.Println()  // ReadPassword doesn't print a newline after Enter
    if err != nil {
        return "", fmt.Errorf("reading password: %w", err)
    }
    return string(password), nil
}

password, err := readPassword("Password: ")
// The terminal showed nothing while the user typed

term.ReadPassword also works correctly when the terminal is in raw mode and handles Ctrl+C gracefully.

Yes/No Confirmation

Simple confirmations before destructive operations keep users from making accidental mistakes:

func confirm(p *Prompter, prompt string) (bool, error) {
    for {
        answer, err := p.Ask(prompt + " [y/N] ")
        if err != nil {
            return false, err
        }
        switch strings.ToLower(answer) {
        case "y", "yes":
            return true, nil
        case "n", "no", "":  // default to no
            return false, nil
        default:
            fmt.Println("Please enter y or n.")
        }
    }
}

ok, err := confirm(prompter, "Delete all records?")
if err != nil || !ok {
    fmt.Println("Aborted.")
    return
}
// proceed with deletion

Defaulting to “no” ("" maps to false) is the safer convention for destructive operations — an accidental Enter press doesn’t cause damage.

A numbered menu is the clearest way to present a bounded set of choices:

func selectOption(p *Prompter, title string, options []string) (int, string, error) {
    fmt.Println(title)
    for i, opt := range options {
        fmt.Printf("  %d) %s\n", i+1, opt)
    }

    for {
        raw, err := p.Ask(fmt.Sprintf("Choice [1-%d]: ", len(options)))
        if err != nil {
            return 0, "", err
        }
        n, err := strconv.Atoi(raw)
        if err != nil || n < 1 || n > len(options) {
            fmt.Printf("Please enter a number between 1 and %d.\n", len(options))
            continue
        }
        return n - 1, options[n-1], nil  // return zero-indexed
    }
}

idx, choice, err := selectOption(prompter, "Select environment:", []string{
    "development",
    "staging",
    "production",
})
if choice == "production" {
    ok, _ := confirm(prompter, "You selected production. Are you sure?")
    if !ok {
        return
    }
}

Input Validation

Validation should give immediate, specific feedback rather than letting the user reach an error later:

type Validator func(string) error

func required(s string) error {
    if strings.TrimSpace(s) == "" {
        return fmt.Errorf("this field is required")
    }
    return nil
}

func minLen(n int) Validator {
    return func(s string) error {
        if len(s) < n {
            return fmt.Errorf("must be at least %d characters", n)
        }
        return nil
    }
}

func validEmail(s string) error {
    if !strings.Contains(s, "@") || !strings.Contains(s, ".") {
        return fmt.Errorf("invalid email address")
    }
    return nil
}

// askWithValidation retries until all validators pass
func (p *Prompter) askWithValidation(label string, validators ...Validator) (string, error) {
    for {
        value, err := p.Ask(label)
        if err != nil {
            return "", err
        }
        var valid = true
        for _, v := range validators {
            if err := v(value); err != nil {
                fmt.Printf("  Error: %v\n", err)
                valid = false
                break
            }
        }
        if valid {
            return value, nil
        }
    }
}

email, err := prompter.askWithValidation("Email: ", required, validEmail)
password, err := prompter.askWithValidation("Password: ", required, minLen(8))

Progress Indicators

Long-running operations need feedback. A spinner shows “something is happening”; a progress bar shows completion percentage:

// Spinner that runs until a done channel is closed
func spinner(label string, done <-chan struct{}) {
    frames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
    i := 0
    for {
        select {
        case <-done:
            fmt.Printf("\r%s done\n", label)
            return
        default:
            fmt.Printf("\r%s %s ", frames[i%len(frames)], label)
            i++
            time.Sleep(80 * time.Millisecond)
        }
    }
}

// Usage
done := make(chan struct{})
go spinner("Deploying...", done)

err := deploy()

close(done)
if err != nil {
    fmt.Fprintln(os.Stderr, "deploy failed:", err)
    os.Exit(1)
}

For a progress bar, track completed/total and overwrite the line with \r:

func progressBar(current, total int, width int) string {
    pct := float64(current) / float64(total)
    filled := int(pct * float64(width))
    bar := strings.Repeat("█", filled) + strings.Repeat("░", width-filled)
    return fmt.Sprintf("[%s] %d/%d (%.0f%%)", bar, current, total, pct*100)
}

for i, file := range files {
    process(file)
    fmt.Printf("\r%s", progressBar(i+1, len(files), 40))
}
fmt.Println()

Full TUI: Bubble Tea

For richer interactions — scrollable lists, multi-select, forms with keyboard navigation — github.com/charmbracelet/bubbletea provides a full TUI framework based on The Elm Architecture (model → update → view):

go get github.com/charmbracelet/bubbletea
import tea "github.com/charmbracelet/bubbletea"

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

func initialModel() model {
    return model{
        choices:  []string{"API server", "Worker", "Scheduler", "All"},
        selected: make(map[int]struct{}),
    }
}

// Init runs any initial commands (none needed here)
func (m model) Init() tea.Cmd { return nil }

// Update handles keyboard events and returns the updated model
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 " ":
            if _, ok := m.selected[m.cursor]; ok {
                delete(m.selected, m.cursor)
            } else {
                m.selected[m.cursor] = struct{}{}
            }
        case "enter":
            return m, tea.Quit
        }
    }
    return m, nil
}

// View renders the current state as a string
func (m model) View() string {
    s := "Which components should be deployed?\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 += "\nSpace to toggle, Enter to confirm, q to quit.\n"
    return s
}

func main() {
    m := initialModel()
    p := tea.NewProgram(m)
    result, err := p.Run()
    if err != nil {
        log.Fatal(err)
    }
    final := result.(model)
    fmt.Println("Selected:")
    for i := range final.selected {
        fmt.Println(" -", final.choices[i])
    }
}

Bubble Tea handles terminal raw mode, resize events, and rendering automatically. The model is immutable — Update returns a new model rather than mutating the existing one, which makes the state machine easy to reason about.

The Charm ecosystem extends Bubble Tea: lipgloss for styling and layout, bubbles for pre-built components (text inputs, progress bars, spinners, tables), and glamour for Markdown rendering in the terminal.

When to Use Each Approach

Approach Best for
bufio.Reader prompts Simple sequential input, scripts
golang.org/x/term Password input, raw terminal control
Numbered menus Short lists of mutually exclusive choices
Progress bar / spinner Long operations with known or unknown duration
Bubble Tea Multi-field forms, selectable lists, navigation

Summary

  • Use a single shared bufio.Reader for sequential prompts — multiple bufio.NewReader calls cause buffering bugs
  • Use golang.org/x/term.ReadPassword for passwords — never roll your own echo-disabling
  • Loop with clear error messages until valid input is given — don’t fail with a cryptic error after one bad attempt
  • Default confirmations to “no” for destructive operations
  • Use Bubble Tea for anything requiring keyboard navigation, multi-select, or multi-screen flows
  • \r (carriage return without newline) overwrites the current line — the basis for spinners and progress bars

Resources

Comments

👍 Was this article helpful?