Salta al contenuto principale

Costruire CLI in Go con Cobra: flag, sottocomandi e shell completion

·7 minuti
Indice dei contenuti
Cobra e lo standard de-facto per CLI Go serie. kubectl, la GitHub CLI e Docker lo usano tutti. Se stai costruendo uno strumento a riga di comando con piu sottocomandi, flag tipizzati e shell completion, ecco come farlo correttamente.

Il parsing grezzo di os.Args funziona per un singolo comando con un argomento posizionale. Nel momento in cui aggiungi un secondo sottocomando o un flag --dry-run, stai reinventando la ruota. Cobra gestisce il parsing degli argomenti, la validazione dei flag, la generazione dei testi di help e la shell completion out of the box.

Costruiremo un tool fileorg: un organizzatore che ordina i file per estensione e fornisce statistiche. L’esempio e semplice di proposito – i pattern si applicano a qualsiasi CLI.

Struttura del Progetto
#

Il pattern con la directory cmd/ mantiene main.go minimo e rende ogni sottocomando testabile indipendentemente.

fileorg/
  cmd/
    root.go       # root command, persistent flags
    organize.go   # sottocomando organize
    stats.go      # sottocomando stats
  internal/
    fs/
      scanner.go  # logica di scansione file
  main.go
main.go
package main

import "github.com/example/fileorg/cmd"

func main() {
    cmd.Execute()
}

main.go e esattamente tre righe. Tutta la logica vive in cmd/.

Root Command
#

cmd/root.go
package cmd

import (
    "fmt"
    "os"

    "github.com/spf13/cobra"
    "go.uber.org/zap"
)

var (
    verbose bool
    logger  *zap.SugaredLogger
)

var rootCmd = &cobra.Command{
    Use:   "fileorg",
    Short: "Organizza e analizza i file per estensione",
    Long: `fileorg organizza i file in sottodirectory per estensione
e fornisce statistiche sulla distribuzione dei file.`,
    Version: "1.0.0",
    PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
        // Setup comune che viene eseguito prima di ogni sottocomando
        cfg := zap.NewProductionConfig()
        if verbose {
            cfg.Level = zap.NewAtomicLevelAt(zap.DebugLevel)
        }
        log, err := cfg.Build()
        if err != nil {
            return fmt.Errorf("initializing logger: %w", err)
        }
        logger = log.Sugar()
        return nil
    },
}

func Execute() {
    cobra.CheckErr(rootCmd.Execute())
}

func init() {
    rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "abilita logging di debug")
}

PersistentPreRunE viene eseguito prima di ogni sottocomando. Usalo per l’inizializzazione comune: logger, file di configurazione, connessioni al database. Se restituisce un errore, Cobra lo stampa ed esce con codice non-zero automaticamente.

Il Sottocomando organize
#

cmd/organize.go
package cmd

import (
    "fmt"
    "io/fs"
    "os"
    "path/filepath"

    "github.com/spf13/cobra"
)

var (
    source string
    dryRun bool
)

var organizeCmd = &cobra.Command{
    Use:   "organize",
    Short: "Sposta i file in sottodirectory per estensione",
    Example: `  fileorg organize --source /tmp/downloads
  fileorg organize --source /tmp/downloads --dry-run`,
    RunE: func(cmd *cobra.Command, args []string) error {
        return runOrganize(source, dryRun)
    },
}

func init() {
    organizeCmd.Flags().StringVar(&source, "source", "", "directory da organizzare")
    organizeCmd.Flags().BoolVar(&dryRun, "dry-run", false, "mostra le azioni senza eseguirle")

    // i flag obbligatori falliscono subito con un messaggio chiaro
    cobra.CheckErr(organizeCmd.MarkFlagRequired("source"))

    rootCmd.AddCommand(organizeCmd)
}

func runOrganize(dir string, dryRun bool) error {
    entries, err := os.ReadDir(dir)
    if err != nil {
        return fmt.Errorf("reading directory %s: %w", dir, err)
    }

    moved := 0
    for _, entry := range entries {
        if entry.IsDir() {
            continue
        }

        ext := filepath.Ext(entry.Name())
        if ext == "" {
            ext = "no-extension"
        } else {
            ext = ext[1:] // rimuove il punto iniziale
        }

        destDir := filepath.Join(dir, ext)
        src := filepath.Join(dir, entry.Name())
        dst := filepath.Join(destDir, entry.Name())

        if dryRun {
            fmt.Printf("would move: %s -> %s\n", src, dst)
            continue
        }

        if err := os.MkdirAll(destDir, fs.ModePerm); err != nil {
            return fmt.Errorf("creating directory %s: %w", destDir, err)
        }
        if err := os.Rename(src, dst); err != nil {
            logger.Warnw("failed to move file", "src", src, "dst", dst, "error", err)
            continue
        }
        moved++
    }

    if !dryRun {
        fmt.Printf("spostati %d file\n", moved)
    }
    return nil
}
Suggerimento

Usa RunE invece di Run per tutti i sottocomandi. RunE ti permette di restituire errori in modo naturale; Cobra gestisce la stampa e il codice di uscita. Run ti obbliga a chiamare os.Exit manualmente, impedendo l’esecuzione dei defer.

Il Sottocomando stats
#

cmd/stats.go
package cmd

import (
    "fmt"
    "os"
    "path/filepath"
    "sort"
    "text/tabwriter"

    "github.com/spf13/cobra"
)

var statsCmd = &cobra.Command{
    Use:   "stats",
    Short: "Mostra la distribuzione delle estensioni in una directory",
    Args:  cobra.ExactArgs(1), // validazione argomenti posizionali
    RunE: func(cmd *cobra.Command, args []string) error {
        return runStats(args[0])
    },
}

func init() {
    rootCmd.AddCommand(statsCmd)
}

func runStats(dir string) error {
    counts := make(map[string]int)
    total := 0

    entries, err := os.ReadDir(dir)
    if err != nil {
        return fmt.Errorf("reading directory: %w", err)
    }

    for _, entry := range entries {
        if entry.IsDir() {
            continue
        }
        ext := filepath.Ext(entry.Name())
        if ext == "" {
            ext = "(nessuna)"
        }
        counts[ext]++
        total++
    }

    type row struct {
        ext   string
        count int
    }
    rows := make([]row, 0, len(counts))
    for ext, n := range counts {
        rows = append(rows, row{ext, n})
    }
    sort.Slice(rows, func(i, j int) bool {
        return rows[i].count > rows[j].count
    })

    w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
    fmt.Fprintln(w, "ESTENSIONE\tCONTEGGIO\tPERCENTUALE")
    fmt.Fprintln(w, "----------\t---------\t-----------")
    for _, r := range rows {
        pct := float64(r.count) / float64(total) * 100
        fmt.Fprintf(w, "%s\t%d\t%.1f%%\n", r.ext, r.count, pct)
    }
    w.Flush()

    fmt.Printf("\ntotale: %d file\n", total)
    return nil
}

Persistent Flag vs Local Flag
#

I persistent flag (aggiunti tramite PersistentFlags()) vengono ereditati da tutti i sottocomandi. I local flag (aggiunti tramite Flags()) si applicano solo al comando su cui sono definiti.

// Disponibile per tutti i sottocomandi
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "...")

// Solo per organize
organizeCmd.Flags().BoolVar(&dryRun, "dry-run", false, "...")

Usa MarkFlagRequired per imporre i flag obbligatori a livello Cobra invece che nella logica applicativa:

cobra.CheckErr(organizeCmd.MarkFlagRequired("source"))

Quando --source viene omesso, Cobra stampa required flag(s) "source" not set ed esce con codice 1 prima che RunE venga mai chiamato.

Shell Completion
#

Cobra genera script di completion per bash, zsh, fish e PowerShell automaticamente.

cmd/completion.go
var completionCmd = &cobra.Command{
    Use:   "completion [bash|zsh|fish|powershell]",
    Short: "Genera lo script di shell completion",
    ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
    Args:  cobra.ExactValidArgs(1),
    RunE: func(cmd *cobra.Command, args []string) error {
        switch args[0] {
        case "bash":
            return rootCmd.GenBashCompletion(os.Stdout)
        case "zsh":
            return rootCmd.GenZshCompletion(os.Stdout)
        case "fish":
            return rootCmd.GenFishCompletion(os.Stdout, true)
        case "powershell":
            return rootCmd.GenPowerShellCompletionWithDesc(os.Stdout)
        }
        return nil
    },
}

func init() {
    rootCmd.AddCommand(completionCmd)
}

Installazione per zsh:

fileorg completion zsh > "${fpath[1]}/_fileorg"

Testare i Comandi CLI
#

La chiave per CLI testabili e scrivere l’output su un io.Writer configurabile invece di usare sempre os.Stdout. Nei test imposta l’output del comando su un bytes.Buffer.

cmd/stats_test.go
package cmd

import (
    "bytes"
    "os"
    "path/filepath"
    "testing"
)

func TestStatsCommand(t *testing.T) {
    dir := t.TempDir()
    for _, name := range []string{"a.go", "b.go", "c.py", "d.txt"} {
        f, _ := os.Create(filepath.Join(dir, name))
        f.Close()
    }

    buf := new(bytes.Buffer)
    statsCmd.SetOut(buf)
    statsCmd.SetErr(buf)

    statsCmd.SetArgs([]string{dir})
    err := statsCmd.Execute()
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }

    out := buf.String()
    if !bytes.Contains([]byte(out), []byte(".go")) {
        t.Errorf("expected .go in output, got:\n%s", out)
    }
}

Build e Distribuzione
#

# Binario singolo per la piattaforma corrente
go build -ldflags="-s -w" -o fileorg ./...

# Cross-compile per Linux amd64
GOOS=linux GOARCH=amd64 go build -o fileorg-linux-amd64 ./...

Per release multi-piattaforma automatizzate, GoReleaser gestisce cross-compilation, checksum e creazione release GitHub da un singolo .goreleaser.yaml:

.goreleaser.yaml
builds:
  - env: [CGO_ENABLED=0]
    goos: [linux, darwin, windows]
    goarch: [amd64, arm64]

archives:
  - format: tar.gz
    format_overrides:
      - goos: windows
        format: zip

checksum:
  name_template: "checksums.txt"
Errori comuni e come evitarli

Stato globale nei comandi Dichiarare le variabili dei flag come variabili a livello di package funziona per tool piccoli ma causa contaminazione tra test quando piu test girano nello stesso processo. Preferisci passare i flag come argomenti di funzione o racchiudere i comandi in una struct con un costruttore NewRootCmd() *cobra.Command.

Non usare PersistentPreRunE Ripetere l’inizializzazione del logger o il caricamento della configurazione nel RunE di ogni sottocomando e fragile. Metti il setup condiviso in PersistentPreRunE sul root command. Se un sottocomando sovrascrive PreRunE, deve chiamare esplicitamente parent.PersistentPreRunE, altrimenti il setup del parent viene saltato.

Ignorare i codici di uscita cmd.Execute() restituisce un errore. Se lo chiami senza cobra.CheckErr o la tua gestione del codice di uscita, il binario esce con 0 anche quando un sottocomando fallisce. I chiamanti negli script di shell trattano silenziosamente i fallimenti come successi.

Nessuna shell completion Gli utenti che installano il tuo tool tramite Homebrew o un package manager si aspettano che il tab completion funzioni. Ci vogliono circa 20 righe per aggiungerlo (mostrato sopra). Senza di esso il tuo tool sembra grezzo rispetto a kubectl o git.

Usare ioutil.ReadDir (deprecato) ioutil.ReadDir e stato deprecato in Go 1.16. Usa invece os.ReadDir – restituisce []os.DirEntry che e piu efficiente perche evita una chiamata Stat per ogni file.


Se vuoi approfondire questi argomenti, offro sessioni di coaching 1:1 per ingegneri che lavorano su integrazione AI, architettura cloud e platform engineering. Prenota una sessione (50 EUR / 60 min) o scrivimi a manuel.fedele+website@gmail.com.

Articoli correlati