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:
- Model — lo stato della tua applicazione
- Update — una funzione che gestisce i messaggi e aggiorna il model
- View — una funzione che renderizza il model come stringa
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.
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:
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:
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:
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.exeConclusioni#
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.