Salta al contenuto principale

Costruire Tool CLI Interattivi in Go con Bubbletea

·4 minuti
Indice dei contenuti
Bubbletea porta l’architettura Elm nel terminale, rendendo possibile costruire tool CLI ricchi e interattivi in Go con una gestione dello stato pulita. Questo articolo copre i fondamentali con un esempio pratico.

Se hai mai voluto costruire un’applicazione terminale che sembri più un’interfaccia vera che un muro di testo, l’ecosistema charmbracelet è la strada giusta. Lo uso per costruire tool DevOps interni, e l’esperienza da sviluppatore è eccellente. In questo articolo mostro come costruire un tool CLI interattivo usando Bubbletea e Huh.

Perché Costruire Tool TUI?
#

Come platform engineer, passo molto tempo a eseguire operazioni ripetitive su più account AWS: scansionare opportunità di ottimizzazione dei costi, controllare l’utilizzo delle risorse, fare pulizia dell’infrastruttura inutilizzata. Una CLI con flag funziona, ma quando hai 100+ profili AWS da scegliere e una dozzina di categorie di scansione, una TUI interattiva migliora significativamente l’esperienza.

L’alternativa è una dashboard web, ma questo significa distribuire e mantenere un altro servizio. Un binario Go con TUI è un singolo file che puoi distribuire al tuo team.

L’Architettura Elm
#

Bubbletea segue The Elm Architecture, che si riduce a tre concetti:

  1. Model — lo stato della tua applicazione
  2. Update — una funzione che gestisce i messaggi e aggiorna il model
  3. View — una funzione che renderizza il model come stringa
main.go
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 := "Seleziona un profilo AWS:\n\n"
    for i, profile := range m.profiles {
        cursor := " "
        if m.cursor == i {
            cursor = ">"
        }
        s += fmt.Sprintf("%s %s\n", cursor, profile)
    }
    s += "\nPremi q per uscire.\n"
    return s
}

Usare Huh per i Form
#

Huh fornisce componenti form pre-costruiti (select, multi-select, input, conferme) che gestiscono tutta la logica di rendering e interazione. È quello che uso per le parti interattive dei tool DevOps.

model.go
var profile string
var services []string
var dryRun bool

form := huh.NewForm(
    huh.NewGroup(
        huh.NewSelect[string]().
            Title("Profilo AWS").
            Description("Seleziona l'account AWS da scansionare").
            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("Servizi da Scansionare").
            Options(
                huh.NewOption("ECS Fargate", "ecs"),
                huh.NewOption("Cluster Aurora", "aurora"),
                huh.NewOption("Bucket S3", "s3"),
                huh.NewOption("Volumi EBS", "ebs"),
                huh.NewOption("CloudWatch Logs", "cloudwatch"),
            ).
            Value(&services),

        huh.NewConfirm().
            Title("Dry Run?").
            Description("Anteprima delle modifiche senza applicarle").
            Value(&dryRun),
    ),
)

Scansioni con Uno Spinner
#

Una volta che l’utente ha selezionato le opzioni, vuoi mostrare i progressi. Il componente spinner di Bubbletea funziona bene qui:

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

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

    s := "Risultati della Scansione:\n\n"
    totalSaving := 0.0
    for _, r := range m.results {
        s += fmt.Sprintf("  %s: %d risorse, risparmio stimato: EUR %.2f/mese\n",
            r.Service, r.ResourceCount, r.EstimatedSaving)
        totalSaving += r.EstimatedSaving
    }
    s += fmt.Sprintf("\n  Risparmio totale stimato: EUR %.2f/mese (EUR %.2f/anno)\n",
        totalSaving, totalSaving*12)
    return s
}

Rendering di Tabelle con Lipgloss
#

Per l’output finale, Lipgloss ti permette di stilizzare l’output del terminale con un’API simile a CSS:

view.go
func renderResults(results []ScanResult) string {
    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("Servizio", "Risorse", "Risparmio Stimato/mese").
        Rows(rows...)

    return t.String()
}

Distribuzione del Binario
#

Uno dei migliori aspetti di Go è l’output a singolo binario. Build per le piattaforme del tuo team:

terminal
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

Conclusioni
#

Dopo aver costruito diversi tool interni con questo stack, ecco le mie raccomandazioni:

  • Usa Huh per i form, Bubbletea per le interazioni custom. Non costruire un componente select personalizzato quando Huh ne ha già uno.
  • Mantieni la TUI sottile. La parte interattiva dovrebbe raccogliere solo l’input dell’utente. La logica di business effettiva (chiamate API AWS, scansioni, ecc.) dovrebbe essere in package separati.
  • Aggiungi un flag --json. Altri tool e script vorranno consumare il tuo output.

L’ecosistema charmbracelet rende facile costruire tool che il tuo team vorrà davvero usare, invece dell’ennesimo script con 30 flag.

Articoli correlati