Skip to main content
โšก Calmops

Building Interactive CLI Applications in Go

Building Interactive CLI Applications in Go

Introduction

Interactive CLI applications provide better user experience through prompts, menus, and real-time feedback. This guide covers building interactive CLI applications in Go using various approaches.

Interactive CLIs make applications more user-friendly and reduce the need for complex flag combinations.

Basic User Input

Simple Input Handling

package main

import (
	"bufio"
	"fmt"
	"os"
	"strings"
)

// ReadInput reads a line from user input
func ReadInput(prompt string) string {
	fmt.Print(prompt)
	reader := bufio.NewReader(os.Stdin)
	input, _ := reader.ReadString('\n')
	return strings.TrimSpace(input)
}

// ReadPassword reads password without echoing
func ReadPassword(prompt string) string {
	fmt.Print(prompt)
	// Note: This is simplified; use golang.org/x/term for production
	reader := bufio.NewReader(os.Stdin)
	password, _ := reader.ReadString('\n')
	return strings.TrimSpace(password)
}

// ReadConfirmation reads yes/no confirmation
func ReadConfirmation(prompt string) bool {
	response := ReadInput(prompt + " (y/n): ")
	return strings.ToLower(response) == "y" || strings.ToLower(response) == "yes"
}

// Example usage
func BasicInputExample() {
	name := ReadInput("Enter your name: ")
	fmt.Printf("Hello, %s!\n", name)

	if ReadConfirmation("Do you want to continue?") {
		fmt.Println("Continuing...")
	}
}

Good: Proper Interactive CLI Implementation

package main

import (
	"bufio"
	"fmt"
	"os"
	"strconv"
	"strings"
)

// InputValidator validates user input
type InputValidator func(string) error

// InteractivePrompt handles interactive user prompts
type InteractivePrompt struct {
	reader *bufio.Reader
}

// NewInteractivePrompt creates a new interactive prompt
func NewInteractivePrompt() *InteractivePrompt {
	return &InteractivePrompt{
		reader: bufio.NewReader(os.Stdin),
	}
}

// String prompts for a string value
func (ip *InteractivePrompt) String(prompt string, validators ...InputValidator) (string, error) {
	for {
		fmt.Print(prompt)
		input, err := ip.reader.ReadString('\n')
		if err != nil {
			return "", err
		}

		input = strings.TrimSpace(input)

		// Validate input
		for _, validator := range validators {
			if err := validator(input); err != nil {
				fmt.Printf("Error: %v\n", err)
				continue
			}
		}

		return input, nil
	}
}

// Integer prompts for an integer value
func (ip *InteractivePrompt) Integer(prompt string) (int, error) {
	for {
		fmt.Print(prompt)
		input, err := ip.reader.ReadString('\n')
		if err != nil {
			return 0, err
		}

		input = strings.TrimSpace(input)
		value, err := strconv.Atoi(input)
		if err != nil {
			fmt.Println("Error: Please enter a valid integer")
			continue
		}

		return value, nil
	}
}

// Choice prompts for a choice from options
func (ip *InteractivePrompt) Choice(prompt string, options []string) (string, error) {
	fmt.Println(prompt)
	for i, option := range options {
		fmt.Printf("%d. %s\n", i+1, option)
	}

	for {
		fmt.Print("Enter your choice (1-" + strconv.Itoa(len(options)) + "): ")
		input, err := ip.reader.ReadString('\n')
		if err != nil {
			return "", err
		}

		input = strings.TrimSpace(input)
		choice, err := strconv.Atoi(input)
		if err != nil || choice < 1 || choice > len(options) {
			fmt.Println("Error: Invalid choice")
			continue
		}

		return options[choice-1], nil
	}
}

// Confirmation prompts for yes/no confirmation
func (ip *InteractivePrompt) Confirmation(prompt string) (bool, error) {
	for {
		fmt.Print(prompt + " (y/n): ")
		input, err := ip.reader.ReadString('\n')
		if err != nil {
			return false, err
		}

		input = strings.ToLower(strings.TrimSpace(input))
		if input == "y" || input == "yes" {
			return true, nil
		} else if input == "n" || input == "no" {
			return false, nil
		}

		fmt.Println("Error: Please enter 'y' or 'n'")
	}
}

// Menu displays an interactive menu
type Menu struct {
	title   string
	options map[string]func() error
	prompt  *InteractivePrompt
}

// NewMenu creates a new menu
func NewMenu(title string) *Menu {
	return &Menu{
		title:   title,
		options: make(map[string]func() error),
		prompt:  NewInteractivePrompt(),
	}
}

// AddOption adds a menu option
func (m *Menu) AddOption(label string, handler func() error) {
	m.options[label] = handler
}

// Display displays the menu
func (m *Menu) Display() error {
	for {
		fmt.Println("\n" + strings.Repeat("=", 40))
		fmt.Println(m.title)
		fmt.Println(strings.Repeat("=", 40))

		options := make([]string, 0, len(m.options)+1)
		for label := range m.options {
			options = append(options, label)
		}
		options = append(options, "Exit")

		choice, err := m.prompt.Choice("Select an option:", options)
		if err != nil {
			return err
		}

		if choice == "Exit" {
			break
		}

		if handler, exists := m.options[choice]; exists {
			if err := handler(); err != nil {
				fmt.Printf("Error: %v\n", err)
			}
		}
	}

	return nil
}

// Example usage
func InteractiveMenuExample() {
	menu := NewMenu("Main Menu")

	menu.AddOption("Create User", func() error {
		prompt := NewInteractivePrompt()
		name, _ := prompt.String("Enter name: ")
		email, _ := prompt.String("Enter email: ")
		fmt.Printf("Created user: %s (%s)\n", name, email)
		return nil
	})

	menu.AddOption("List Users", func() error {
		fmt.Println("Users: user1, user2, user3")
		return nil
	})

	menu.AddOption("Delete User", func() error {
		prompt := NewInteractivePrompt()
		name, _ := prompt.String("Enter user name: ")
		fmt.Printf("Deleted user: %s\n", name)
		return nil
	})

	menu.Display()
}

Bad: Improper Interactive CLI

package main

import (
	"fmt"
	"os"
)

// BAD: No input validation
func BadReadInput() string {
	var input string
	fmt.Scanln(&input)
	return input // No validation
}

// BAD: No error handling
func BadMenu() {
	fmt.Println("1. Option 1")
	fmt.Println("2. Option 2")
	var choice int
	fmt.Scanln(&choice)
	// No error handling
	// No validation
}

Problems:

  • No input validation
  • No error handling
  • No user feedback
  • Poor UX

Terminal UI Libraries

Using Bubble Tea

package main

import (
	"fmt"

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

// Model represents the application state
type Model struct {
	choices  []string
	cursor   int
	selected map[int]struct{}
}

// Init initializes the model
func (m Model) Init() tea.Cmd {
	return nil
}

// Update handles messages
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", " ":
			_, ok := m.selected[m.cursor]
			if ok {
				delete(m.selected, m.cursor)
			} else {
				m.selected[m.cursor] = struct{}{}
			}
		}
	}
	return m, nil
}

// View renders the UI
func (m Model) View() string {
	s := "What should we buy?\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
}

// BubbleTeaExample demonstrates Bubble Tea usage
func BubbleTeaExample() {
	m := Model{
		choices:  []string{"Buy carrots", "Buy celery", "Buy kohlrabi"},
		selected: make(map[int]struct{}),
	}

	p := tea.NewProgram(m)
	if _, err := p.Run(); err != nil {
		fmt.Println("Error:", err)
	}
}

Using Promptui

package main

import (
	"fmt"

	"github.com/manifoldco/promptui"
)

// PromptUIExample demonstrates promptui usage
func PromptUIExample() {
	// Text prompt
	prompt := promptui.Prompt{
		Label: "Enter your name",
	}
	name, _ := prompt.Run()
	fmt.Printf("Hello, %s!\n", name)

	// Select prompt
	items := []string{"Option 1", "Option 2", "Option 3"}
	selectPrompt := promptui.Select{
		Label: "Select an option",
		Items: items,
	}
	_, result, _ := selectPrompt.Run()
	fmt.Printf("You selected: %s\n", result)

	// Confirm prompt
	confirmPrompt := promptui.Prompt{
		Label:     "Continue",
		IsConfirm: true,
	}
	confirmPrompt.Run()
}

Input Validation

package main

import (
	"fmt"
	"regexp"
	"strconv"
	"strings"
)

// ValidateEmail validates email format
func ValidateEmail(email string) error {
	pattern := `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`
	matched, _ := regexp.MatchString(pattern, email)
	if !matched {
		return fmt.Errorf("invalid email format")
	}
	return nil
}

// ValidateNotEmpty validates non-empty input
func ValidateNotEmpty(input string) error {
	if strings.TrimSpace(input) == "" {
		return fmt.Errorf("input cannot be empty")
	}
	return nil
}

// ValidateInteger validates integer input
func ValidateInteger(input string) error {
	_, err := strconv.Atoi(input)
	return err
}

// ValidateRange validates integer range
func ValidateRange(min, max int) InputValidator {
	return func(input string) error {
		value, err := strconv.Atoi(input)
		if err != nil {
			return err
		}
		if value < min || value > max {
			return fmt.Errorf("value must be between %d and %d", min, max)
		}
		return nil
	}
}

Best Practices

1. Provide Clear Prompts

fmt.Print("Enter your name: ")

2. Validate Input

if err := ValidateEmail(email); err != nil {
	fmt.Printf("Error: %v\n", err)
}

3. Handle Errors

if err != nil {
	return err
}

4. Provide Feedback

fmt.Println("User created successfully!")

Common Pitfalls

1. No Input Validation

Always validate user input.

2. Poor Error Messages

Provide clear error messages.

3. No Confirmation

Ask for confirmation for destructive operations.

4. Unclear Prompts

Make prompts clear and concise.

Resources

Summary

Interactive CLIs improve user experience. Key takeaways:

  • Provide clear prompts
  • Validate all input
  • Handle errors gracefully
  • Provide feedback
  • Use libraries for advanced UI
  • Ask for confirmation for destructive operations
  • Test with various inputs

By building interactive CLIs, you create better user experiences.

Comments