Sviluppare applicazioni CLI in Go
Go è probabilmente il linguaggio che più di ogni altro ha riportato in auge gli strumenti da riga di comando. Docker, Kubernetes, Terraform, Hugo e la stessa toolchain di Go sono applicazioni CLI scritte in Go, e non è un caso: il compilatore produce un unico eseguibile statico, privo di dipendenze esterne, che si avvia in pochi millisecondi e può essere compilato per qualsiasi sistema operativo a partire da una sola macchina. In questo articolo costruiremo passo dopo passo uno strumento completo, tasks, un piccolo gestore di attività che salva i dati in un file JSON, usando solo la libreria standard.
L'obiettivo non è il gestore di attività in sé, ma tutto ciò che distingue un programma da riga di comando ben fatto da uno script improvvisato: sottocomandi, opzioni, codici di uscita coerenti, separazione tra output ed errori, rilevamento del terminale, scrittura atomica dei dati, test automatici e distribuzione.
Le regole del gioco di una buona CLI
Prima di scrivere codice conviene fissare le convenzioni che un utente si aspetta da qualunque strumento Unix. Sono le stesse che applicheremo in tutti gli esempi:
- i dati prodotti dal programma vanno su standard output, mentre messaggi di errore, avvisi e diagnostica vanno su standard error, in modo che l'utente possa redirigere l'output in un file o in una pipe senza sporcarlo;
- il codice di uscita comunica l'esito:
0per il successo,1per un errore di esecuzione,2per un errore di utilizzo (argomenti mancanti o non validi), secondo la convenzione adottata da molti strumenti GNU; - l'opzione
--helpstampa l'aiuto su standard output ed esce con codice0, perché in quel caso l'aiuto è esattamente ciò che l'utente ha chiesto; - i colori si usano solo quando l'output è un terminale interattivo e si disattivano se la variabile d'ambiente
NO_COLORè impostata; - quando l'output deve essere consumato da altri programmi si offre un formato strutturato, tipicamente JSON.
Lo strumento che realizzeremo esporrà quattro sottocomandi:
tasks add -priority high "Rinnovare il certificato TLS"
tasks list -all -format json
tasks done 3
tasks rm 3
Struttura del progetto
Organizziamo il codice secondo la disposizione tipica dei progetti Go: il pacchetto main resta minimale in cmd/tasks, mentre la logica vive in pacchetti interni che non possono essere importati da moduli esterni.
tasks/
├── go.mod
├── cmd/
│ └── tasks/
│ └── main.go
└── internal/
├── cli/
│ ├── cli.go
│ └── cli_test.go
└── store/
└── store.go
mkdir tasks && cd tasks
go mod init example.com/tasks
Tenere main minimale è una scelta precisa: la funzione main non restituisce valori e termina il processo con os.Exit, quindi non è testabile. Sposteremo tutto in una funzione Run che riceve gli argomenti e i flussi di output come parametri e restituisce un codice di uscita.
Il livello di persistenza
Il pacchetto store si occupa di leggere e scrivere le attività. Definiamo il modello dei dati, il percorso predefinito del file e le due operazioni fondamentali, Load e Save.
package store
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"time"
)
// ErrNotFound viene restituito quando un'attività con l'ID richiesto non esiste.
var ErrNotFound = errors.New("attività non trovata")
type Priority string
const (
Low Priority = "low"
Normal Priority = "normal"
High Priority = "high"
)
// ParsePriority converte una stringa in Priority validandone il valore.
func ParsePriority(value string) (Priority, error) {
switch Priority(value) {
case Low, Normal, High:
return Priority(value), nil
}
return "", fmt.Errorf("priorità non valida %q (valori ammessi: low, normal, high)", value)
}
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Priority Priority `json:"priority"`
Done bool `json:"done"`
CreatedAt time.Time `json:"created_at"`
}
type Store struct {
path string
}
func New(path string) *Store {
return &Store{path: path}
}
// DefaultPath restituisce il percorso del file dati: la variabile TASKS_FILE
// ha la precedenza, altrimenti si usa la directory di configurazione dell'utente.
func DefaultPath() (string, error) {
if path := os.Getenv("TASKS_FILE"); path != "" {
return path, nil
}
dir, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("impossibile determinare la directory di configurazione: %w", err)
}
return filepath.Join(dir, "tasks", "tasks.json"), nil
}
os.UserConfigDir restituisce $XDG_CONFIG_HOME (o ~/.config) su Linux, ~/Library/Application Support su macOS e %AppData% su Windows: in questo modo il programma rispetta le convenzioni di ogni piattaforma senza alcun codice specifico.
La lettura deve gestire con garbo il caso del primo avvio, in cui il file non esiste ancora: non si tratta di un errore, ma di una lista vuota.
// Load legge tutte le attività; un file assente equivale a una lista vuota.
func (s *Store) Load() ([]Task, error) {
data, err := os.ReadFile(s.path)
if errors.Is(err, os.ErrNotExist) {
return []Task{}, nil
}
if err != nil {
return nil, fmt.Errorf("lettura di %s: %w", s.path, err)
}
var tasks []Task
if err := json.Unmarshal(data, &tasks); err != nil {
return nil, fmt.Errorf("il file %s non contiene JSON valido: %w", s.path, err)
}
return tasks, nil
}
Scrittura atomica
Scrivere direttamente sul file dati con os.WriteFile sembra la soluzione più semplice, ma è pericolosa: se il processo viene interrotto a metà scrittura (un Ctrl+C, un disco pieno, un'interruzione di corrente) il file resta troncato e tutti i dati sono persi. La tecnica corretta consiste nello scrivere su un file temporaneo nella stessa directory e poi rinominarlo sopra l'originale: su POSIX la rinomina all'interno dello stesso filesystem è atomica, quindi chi legge vede o la versione vecchia o quella nuova, mai una via di mezzo.
// Save scrive le attività in modo atomico: file temporaneo, fsync e rinomina.
func (s *Store) Save(tasks []Task) error {
dir := filepath.Dir(s.path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("creazione di %s: %w", dir, err)
}
data, err := json.MarshalIndent(tasks, "", " ")
if err != nil {
return err
}
tmp, err := os.CreateTemp(dir, ".tasks-*.json")
if err != nil {
return err
}
// In caso di errore il file temporaneo viene rimosso; dopo la rinomina
// la Remove fallisce silenziosamente perché il file non esiste più.
defer os.Remove(tmp.Name())
if _, err := tmp.Write(append(data, '\n')); err != nil {
tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), s.path)
}
La chiamata a Sync forza il sistema operativo a scaricare i dati sul disco prima della rinomina: senza di essa, in caso di crash, potremmo ritrovarci con un file rinominato correttamente ma vuoto.
Completiamo il pacchetto con una funzione di supporto che calcola il prossimo identificativo e una che cerca un'attività per ID:
// NextID restituisce un identificativo maggiore di tutti quelli esistenti.
func NextID(tasks []Task) int {
highest := 0
for _, t := range tasks {
if t.ID > highest {
highest = t.ID
}
}
return highest + 1
}
// IndexOf restituisce la posizione dell'attività con l'ID indicato.
func IndexOf(tasks []Task, id int) (int, error) {
for i, t := range tasks {
if t.ID == id {
return i, nil
}
}
return -1, fmt.Errorf("%w: %d", ErrNotFound, id)
}
Il parsing degli argomenti con il pacchetto flag
La libreria standard offre il pacchetto flag, spesso sottovalutato. La sua funzione più nota, flag.Parse, lavora su un insieme globale di opzioni ed esce dal processo in caso di errore: comportamento comodo per un programma di dieci righe, ma inadatto a un'applicazione con sottocomandi e test. La soluzione è flag.NewFlagSet, che crea un insieme di opzioni indipendente per ciascun sottocomando. Con la modalità flag.ContinueOnError gli errori vengono restituiti invece di terminare il processo.
Definiamo innanzitutto i codici di uscita e un tipo di errore dedicato agli errori di utilizzo, così da poterli distinguere dagli errori di esecuzione al momento di scegliere il codice di uscita.
package cli
import (
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"strconv"
"strings"
"text/tabwriter"
"time"
"example.com/tasks/internal/store"
)
const (
exitOK = 0
exitFailure = 1
exitUsage = 2
)
// Version viene sovrascritta in fase di build tramite -ldflags.
var Version = "dev"
// usageError segnala un uso scorretto del programma (codice di uscita 2).
type usageError struct {
msg string
}
func (e usageError) Error() string { return e.msg }
func usagef(format string, args ...any) error {
return usageError{msg: fmt.Sprintf(format, args...)}
}
type app struct {
stdout io.Writer
stderr io.Writer
store *store.Store
color bool
}
const usageText = `Uso: tasks <comando> [opzioni] [argomenti]
Comandi:
add [-priority low|normal|high] <titolo> aggiunge un'attività
list [-all] [-format table|json] elenca le attività
done <id> segna un'attività come completata
rm <id> elimina un'attività
version mostra la versione
Usa "tasks <comando> -h" per l'aiuto di un singolo comando.
`
Ogni sottocomando crea il proprio FlagSet. Una funzione di supporto centralizza la configurazione comune: l'output dell'aiuto va su standard error, che è il comportamento atteso quando l'aiuto viene mostrato a causa di un errore.
func (a *app) newFlagSet(name, args string) *flag.FlagSet {
fs := flag.NewFlagSet(name, flag.ContinueOnError)
fs.SetOutput(a.stderr)
fs.Usage = func() {
fmt.Fprintf(fs.Output(), "Uso: tasks %s [opzioni] %s\n\nOpzioni:\n", name, args)
fs.PrintDefaults()
}
return fs
}
Il dispatcher e la funzione Run
Run è il cuore dell'applicazione: legge il primo argomento, sceglie il sottocomando e traduce l'errore restituito in un codice di uscita. Riceve gli argomenti e i flussi di output come parametri: è questo che la rende testabile.
// Run esegue il programma e restituisce il codice di uscita.
func Run(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
fmt.Fprint(stderr, usageText)
return exitUsage
}
path, err := store.DefaultPath()
if err != nil {
fmt.Fprintln(stderr, "tasks:", err)
return exitFailure
}
a := &app{
stdout: stdout,
stderr: stderr,
store: store.New(path),
color: colorEnabled(stdout),
}
command, rest := args[0], args[1:]
switch command {
case "add":
err = a.add(rest)
case "list", "ls":
err = a.list(rest)
case "done":
err = a.done(rest)
case "rm", "remove":
err = a.remove(rest)
case "version", "--version":
fmt.Fprintln(stdout, "tasks", Version)
case "help", "-h", "--help":
fmt.Fprint(stdout, usageText)
default:
err = usagef("comando sconosciuto %q", command)
}
return exitCode(err, stderr)
}
// exitCode traduce un errore nel codice di uscita appropriato.
func exitCode(err error, stderr io.Writer) int {
var ue usageError
switch {
case err == nil:
return exitOK
case errors.Is(err, flag.ErrHelp):
// L'utente ha chiesto -h: l'aiuto è già stato stampato.
return exitOK
case errors.As(err, &ue):
fmt.Fprintln(stderr, "tasks:", ue.msg)
fmt.Fprintln(stderr, `Esegui "tasks help" per l'elenco dei comandi.`)
return exitUsage
default:
fmt.Fprintln(stderr, "tasks:", err)
return exitFailure
}
}
Notate il prefisso tasks: nei messaggi di errore: è la convenzione Unix che permette all'utente di capire quale programma ha prodotto il messaggio quando più comandi sono collegati in una pipe.
Implementare i sottocomandi
add
Il comando add accetta un'opzione -priority e un titolo. Se l'utente non mette il titolo tra virgolette, gli argomenti posizionali vengono uniti con uno spazio: tasks add Comprare il latte funziona come ci si aspetta.
func (a *app) add(args []string) error {
fs := a.newFlagSet("add", "<titolo>")
priority := fs.String("priority", "normal", "priorità: low, normal o high")
if err := fs.Parse(args); err != nil {
return wrapParseError(err)
}
title := strings.TrimSpace(strings.Join(fs.Args(), " "))
if title == "" {
return usagef("add: il titolo è obbligatorio")
}
p, err := store.ParsePriority(*priority)
if err != nil {
return usageError{msg: "add: " + err.Error()}
}
tasks, err := a.store.Load()
if err != nil {
return err
}
task := store.Task{
ID: store.NextID(tasks),
Title: title,
Priority: p,
CreatedAt: time.Now().UTC(),
}
if err := a.store.Save(append(tasks, task)); err != nil {
return err
}
fmt.Fprintf(a.stdout, "Aggiunta l'attività %d: %s\n", task.ID, task.Title)
return nil
}
// wrapParseError trasforma gli errori del pacchetto flag in errori di utilizzo,
// lasciando passare flag.ErrHelp che va gestito come successo.
func wrapParseError(err error) error {
if errors.Is(err, flag.ErrHelp) {
return err
}
// Il pacchetto flag ha già stampato il messaggio e l'aiuto su stderr.
return usageError{msg: "argomenti non validi"}
}
Il pacchetto flag ha una particolarità che è bene conoscere: smette di analizzare le opzioni al primo argomento posizionale. Il comando tasks add Backup -priority high verrebbe interpretato come un titolo composto da tre parole. Le opzioni vanno quindi scritte prima degli argomenti, come in tasks add -priority high Backup. Si tratta della convenzione POSIX classica; se si desidera la libertà delle CLI in stile GNU, librerie come spf13/pflag o spf13/cobra riordinano automaticamente gli argomenti.
list
Il comando list è quello che più beneficia di una progettazione attenta dell'output. Per gli esseri umani produciamo una tabella allineata con text/tabwriter; per gli script un array JSON. Il formato JSON non deve mai contenere colori né testo decorativo.
func (a *app) list(args []string) error {
fs := a.newFlagSet("list", "")
all := fs.Bool("all", false, "include le attività completate")
format := fs.String("format", "table", "formato di output: table o json")
if err := fs.Parse(args); err != nil {
return wrapParseError(err)
}
if fs.NArg() > 0 {
return usagef("list: argomenti inattesi: %s", strings.Join(fs.Args(), " "))
}
tasks, err := a.store.Load()
if err != nil {
return err
}
visible := make([]store.Task, 0, len(tasks))
for _, t := range tasks {
if *all || !t.Done {
visible = append(visible, t)
}
}
switch *format {
case "json":
encoder := json.NewEncoder(a.stdout)
encoder.SetIndent("", " ")
return encoder.Encode(visible)
case "table":
return a.printTable(visible)
default:
return usagef("list: formato non supportato %q", *format)
}
}
func (a *app) printTable(tasks []store.Task) error {
if len(tasks) == 0 {
fmt.Fprintln(a.stdout, "Nessuna attività.")
return nil
}
w := tabwriter.NewWriter(a.stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "ID\tSTATO\tPRIORITÀ\tTITOLO")
for _, t := range tasks {
status := "aperta"
if t.Done {
status = "fatta"
}
fmt.Fprintf(w, "%d\t%s\t%s\t%s\n", t.ID, status, a.paint(t.Priority), t.Title)
}
return w.Flush()
}
done e rm
I due comandi rimanenti condividono la stessa necessità: leggere un ID numerico dagli argomenti. Una funzione di supporto evita la duplicazione e restituisce sempre errori di utilizzo, perché un ID non numerico è un errore dell'utente, non del programma.
func parseID(command string, args []string) (int, error) {
if len(args) != 1 {
return 0, usagef("%s: è richiesto esattamente un ID", command)
}
id, err := strconv.Atoi(args[0])
if err != nil || id <= 0 {
return 0, usagef("%s: ID non valido %q", command, args[0])
}
return id, nil
}
func (a *app) done(args []string) error {
id, err := parseID("done", args)
if err != nil {
return err
}
tasks, err := a.store.Load()
if err != nil {
return err
}
i, err := store.IndexOf(tasks, id)
if err != nil {
return err
}
tasks[i].Done = true
if err := a.store.Save(tasks); err != nil {
return err
}
fmt.Fprintf(a.stdout, "Attività %d completata.\n", id)
return nil
}
func (a *app) remove(args []string) error {
id, err := parseID("rm", args)
if err != nil {
return err
}
tasks, err := a.store.Load()
if err != nil {
return err
}
i, err := store.IndexOf(tasks, id)
if err != nil {
return err
}
tasks = append(tasks[:i], tasks[i+1:]...)
if err := a.store.Save(tasks); err != nil {
return err
}
fmt.Fprintf(a.stdout, "Attività %d eliminata.\n", id)
return nil
}
Un'attività inesistente produce store.ErrNotFound, che non è un errore di utilizzo: la sintassi era corretta, è lo stato dei dati a non permettere l'operazione. Il programma uscirà quindi con codice 1.
Colori e rilevamento del terminale
I codici di escape ANSI rendono l'output più leggibile, ma diventano spazzatura se l'output viene rediretto in un file o letto da grep. Per capire se stiamo scrivendo su un terminale controlliamo se il file descriptor corrisponde a un dispositivo a caratteri. Rispettiamo inoltre la convenzione NO_COLOR: se la variabile è presente e non vuota, i colori vanno disattivati.
// colorEnabled restituisce true solo se w è un terminale e NO_COLOR non è impostata.
func colorEnabled(w io.Writer) bool {
if os.Getenv("NO_COLOR") != "" {
return false
}
f, ok := w.(*os.File)
if !ok {
return false
}
info, err := f.Stat()
if err != nil {
return false
}
return info.Mode()&os.ModeCharDevice != 0
}
func (a *app) paint(p store.Priority) string {
if !a.color {
return string(p)
}
codes := map[store.Priority]string{
store.High: "\033[31m",
store.Normal: "\033[33m",
store.Low: "\033[90m",
}
return codes[p] + string(p) + "\033[0m"
}
Il controllo su os.ModeCharDevice è sufficiente per la maggior parte dei casi su Unix. Per una gestione completa, che includa ad esempio la console di Windows, il modulo golang.org/x/term mette a disposizione la funzione term.IsTerminal. C'è però un dettaglio sottile da considerare con tabwriter: i codici di escape contano come caratteri visibili nel calcolo delle colonne. Nel nostro caso il problema non si presenta perché la colonna colorata ha sempre la stessa lunghezza di codici aggiuntivi, ma in tabelle più complesse conviene applicare il colore dopo aver calcolato l'allineamento.
Il punto di ingresso e i segnali
Con tutta la logica in Run, il file main.go si riduce a una riga significativa:
package main
import (
"os"
"example.com/tasks/internal/cli"
)
func main() {
os.Exit(cli.Run(os.Args[1:], os.Stdout, os.Stderr))
}
Il nostro strumento esegue operazioni brevissime e la scrittura atomica lo protegge già dalle interruzioni. Un comando che esegue lavoro di lunga durata, come un'importazione o una sincronizzazione di rete, dovrebbe invece intercettare SIGINT e SIGTERM per terminare in modo ordinato. Il modo idiomatico in Go è signal.NotifyContext, che restituisce un contesto annullato all'arrivo del segnale:
func runLongOperation() int {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := importTasks(ctx); err != nil {
if errors.Is(err, context.Canceled) {
fmt.Fprintln(os.Stderr, "tasks: operazione interrotta")
// 130 = 128 + SIGINT, la convenzione delle shell per i processi interrotti
return 130
}
fmt.Fprintln(os.Stderr, "tasks:", err)
return 1
}
return 0
}
Test
Poiché Run riceve argomenti e writer come parametri, possiamo testare l'applicazione esattamente come la userebbe un utente, controllando output, errori e codice di uscita. t.TempDir crea una directory temporanea eliminata al termine del test, e t.Setenv imposta una variabile d'ambiente ripristinandola automaticamente.
package cli
import (
"bytes"
"encoding/json"
"path/filepath"
"strings"
"testing"
"example.com/tasks/internal/store"
)
// run esegue la CLI in un ambiente isolato e restituisce output e codice di uscita.
func run(t *testing.T, args ...string) (string, string, int) {
t.Helper()
var stdout, stderr bytes.Buffer
code := Run(args, &stdout, &stderr)
return stdout.String(), stderr.String(), code
}
func setup(t *testing.T) {
t.Helper()
t.Setenv("TASKS_FILE", filepath.Join(t.TempDir(), "tasks.json"))
t.Setenv("NO_COLOR", "1")
}
func TestAddAndListJSON(t *testing.T) {
setup(t)
if _, stderr, code := run(t, "add", "-priority", "high", "Scrivere i test"); code != exitOK {
t.Fatalf("add: codice %d, stderr: %s", code, stderr)
}
stdout, _, code := run(t, "list", "-format", "json")
if code != exitOK {
t.Fatalf("list: codice %d", code)
}
var tasks []store.Task
if err := json.Unmarshal([]byte(stdout), &tasks); err != nil {
t.Fatalf("output non JSON: %v", err)
}
if len(tasks) != 1 || tasks[0].Title != "Scrivere i test" || tasks[0].Priority != store.High {
t.Fatalf("attività inattese: %+v", tasks)
}
}
func TestExitCodes(t *testing.T) {
setup(t)
cases := []struct {
name string
args []string
want int
}{
{"nessun argomento", nil, exitUsage},
{"comando sconosciuto", []string{"fly"}, exitUsage},
{"titolo mancante", []string{"add"}, exitUsage},
{"priorità non valida", []string{"add", "-priority", "urgent", "x"}, exitUsage},
{"id non numerico", []string{"done", "abc"}, exitUsage},
{"id inesistente", []string{"done", "42"}, exitFailure},
{"aiuto", []string{"list", "-h"}, exitOK},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, stderr, code := run(t, tc.args...)
if code != tc.want {
t.Errorf("codice %d, atteso %d (stderr: %s)", code, tc.want, stderr)
}
})
}
}
func TestErrorsGoToStderr(t *testing.T) {
setup(t)
stdout, stderr, _ := run(t, "rm", "7")
if stdout != "" {
t.Errorf("stdout dovrebbe essere vuoto, contiene %q", stdout)
}
if !strings.HasPrefix(stderr, "tasks:") {
t.Errorf("stderr dovrebbe iniziare con il prefisso del programma: %q", stderr)
}
}
go test ./...
go vet ./...
L'ultimo test verifica una proprietà spesso trascurata: in caso di errore lo standard output deve restare vuoto. Uno script che esegue tasks list -format json | jq non deve mai ricevere un messaggio di errore al posto del JSON.
Compilazione e distribuzione
La compilazione produce un singolo binario. Con -ldflags possiamo ridurre le dimensioni eliminando le informazioni di debug e iniettare la versione nella variabile Version:
go build -trimpath \
-ldflags "-s -w -X example.com/tasks/internal/cli.Version=1.0.0" \
-o bin/tasks ./cmd/tasks
./bin/tasks version
La compilazione incrociata non richiede alcuna toolchain aggiuntiva, a patto di non usare cgo: basta impostare le variabili GOOS e GOARCH.
#!/usr/bin/env bash
set -euo pipefail
version="${1:?specificare la versione}"
targets=("linux/amd64" "linux/arm64" "darwin/arm64" "windows/amd64")
for target in "${targets[@]}"; do
os="${target%/*}"
arch="${target#*/}"
output="dist/tasks-${version}-${os}-${arch}"
[[ "$os" == "windows" ]] && output+=".exe"
CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" go build -trimpath \
-ldflags "-s -w -X example.com/tasks/internal/cli.Version=${version}" \
-o "$output" ./cmd/tasks
echo "compilato $output"
done
Per progetti pubblici lo strumento di riferimento è GoReleaser, che automatizza compilazione incrociata, archivi, checksum, pacchetti deb e rpm, formule Homebrew e pubblicazione delle release su GitHub o GitLab. Chi ha Go installato può inoltre installare lo strumento direttamente dal repository con go install example.com/tasks/cmd/tasks@latest.
Quando passare a Cobra
Il pacchetto flag è più che sufficiente per strumenti con pochi sottocomandi. Quando la CLI cresce, con gerarchie di comandi annidati, opzioni globali ereditate dai sottocomandi, completamento automatico per la shell e generazione delle pagine di manuale, la libreria spf13/cobra diventa la scelta naturale: è quella usata da kubectl, gh e hugo. La buona notizia è che l'architettura presentata qui si trasferisce senza modifiche: il pacchetto store resta identico, e ogni metodo di app diventa la funzione RunE di un cobra.Command.
Conclusione
Abbiamo costruito uno strumento da riga di comando completo usando solo la libreria standard di Go. Più del codice specifico, contano le scelte progettuali che lo rendono affidabile: una funzione Run testabile separata da main, errori di utilizzo distinti dagli errori di esecuzione e tradotti in codici di uscita coerenti, output strutturato per le macchine e leggibile per le persone, colori solo quando hanno senso e una scrittura dei dati che non può corrompere il file. Sono principi che ritroveremo identici negli altri linguaggi della serie, cambiando solo gli strumenti con cui vengono applicati.