MFormations
Modern Go Engineering

Chapitre 13

13 - CLI Tools avec Go

13 - CLI Tools avec Go

Cours 13 : CLI Tools avec Go

1. Introduction aux CLI Tools

Go est le langage de choix pour les outils CLI : compilation statique, cross-compilation, démarrage rapide, pas de dépendances runtime. Outils célèbres : Docker, Kubernetes, Hugo, Terraform.

1.1 Philosophie d'un bon CLI

// Principes UNIX : 
// - Faire une chose et la bien faire
// - Fonctionner avec d'autres outils (pipe)
// - Gérer le texte comme interface universelle

// Structure d'un bon CLI :
// 1. Sous-commandes (cobra)
// 2. Flags (cobra/pflag)
// 3. Configuration (viper)
// 4. Help et autocomplétion
// 5. Exit codes standards (0=success, non-zero=error)

2. Cobra

2.1 Commandes de base

package cmd

import (
    "fmt"
    "os"
    "github.com/spf13/cobra"
)

var rootCmd = &cobra.Command{
    Use:   "mycli",
    Short: "My CLI tool",
    Long:  `My CLI tool - a longer description`,
    Run: func(cmd *cobra.Command, args []string) {
        fmt.Println("Hello from mycli!")
    },
}

var versionCmd = &cobra.Command{
    Use:   "version",
    Short: "Print the version number",
    Run: func(cmd *cobra.Command, args []string) {
        fmt.Println("mycli v1.0.0")
    },
}

var echoCmd = &cobra.Command{
    Use:   "echo [text]",
    Short: "Echo text back",
    Args:  cobra.MinimumNArgs(1),
    Run: func(cmd *cobra.Command, args []string) {
        fmt.Println(strings.Join(args, " "))
    },
}

func init() {
    rootCmd.AddCommand(versionCmd)
    rootCmd.AddCommand(echoCmd)
}

func Execute() {
    if err := rootCmd.Execute(); err != nil {
        os.Exit(1)
    }
}

2.2 Flags

var (
    verbose bool
    name    string
    count   int
)

var greetCmd = &cobra.Command{
    Use:   "greet",
    Short: "Greet someone",
    Run: func(cmd *cobra.Command, args []string) {
        for i := 0; i < count; i++ {
            if verbose {
                fmt.Printf("Greeting %d: ", i+1)
            }
            fmt.Printf("Hello, %s!\n", name)
        }
    },
}

func init() {
    greetCmd.Flags().StringVarP(&name, "name", "n", "World", "Name to greet")
    greetCmd.Flags().IntVarP(&count, "count", "c", 1, "Number of greetings")
    greetCmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Verbose output")
    
    // Flags persistants (disponibles pour toutes les sous-commandes)
    rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output")
    
    // Flags locaux (disponibles seulement pour cette commande)
    greetCmd.Flags().String("format", "text", "Output format: text|json")
    
    // Flags requis
    greetCmd.MarkFlagRequired("name")
    
    rootCmd.AddCommand(greetCmd)
}

2.3 Args validation

// Validateurs intégrés
Args: cobra.ExactArgs(1)       // Exactement 1 argument
Args: cobra.MinimumNArgs(2)    // Au moins 2 arguments  
Args: cobra.MaximumNArgs(5)    // Au plus 5 arguments
Args: cobra.RangeArgs(1, 3)    // Entre 1 et 3 arguments
Args: cobra.OnlyValidArgs      // Seulement des arguments valides
Args: cobra.ArbitraryArgs      // Aucune validation

// Validateur personnalisé
var processCmd = &cobra.Command{
    Use: "process <input> [output]",
    Args: func(cmd *cobra.Command, args []string) error {
        if len(args) < 1 {
            return fmt.Errorf("requires at least 1 arg")
        }
        if len(args) > 2 {
            return fmt.Errorf("at most 2 args allowed")
        }
        if !strings.HasSuffix(args[0], ".txt") {
            return fmt.Errorf("input must be .txt file")
        }
        return nil
    },
    Run: func(cmd *cobra.Command, args []string) {
        // ...
    },
}

2.4 Help et documentation

// Help personnalisé
var rootCmd = &cobra.Command{
    Use: "mycli",
    Long: `mycli - CLI tool for doing things

This is a longer description that explains
what the tool does in more detail.

Usage:
  mycli [command] [flags]

Available Commands:
  greet       Greet someone
  version     Print version
  echo        Echo text

Flags:
  -h, --help      help for mycli
  -v, --verbose   verbose output

Use "mycli [command] --help" for more information.`,
}

// Template personnalisé
func init() {
    rootCmd.SetHelpTemplate(`Usage:{{if .Runnable}}
  {{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}
  {{.CommandPath}} [command]{{end}}

{{.Long | trimTrailingWhitespaces}}

{{if .HasAvailableSubCommands}}
Commands:{{range .Commands}}{{if .IsAvailableCommand}}
  {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}

{{if .HasAvailableLocalFlags}}
Flags:
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}

Use "{{.CommandPath}} [command] --help" for more information.
`)
}

3. Viper

3.1 Configuration

package config

import (
    "fmt"
    "github.com/spf13/viper"
)

type Config struct {
    Server   ServerConfig   `mapstructure:"server"`
    Database DatabaseConfig `mapstructure:"database"`
    Logging  LoggingConfig  `mapstructure:"logging"`
}

type ServerConfig struct {
    Host    string `mapstructure:"host"`
    Port    int    `mapstructure:"port"`
    TLS     bool   `mapstructure:"tls"`
}

type DatabaseConfig struct {
    Host     string `mapstructure:"host"`
    Port     int    `mapstructure:"port"`
    User     string `mapstructure:"user"`
    Password string `mapstructure:"password"`
    DBName   string `mapstructure:"dbname"`
}

type LoggingConfig struct {
    Level  string `mapstructure:"level"`
    Format string `mapstructure:"format"`
}

func LoadConfig() (*Config, error) {
    v := viper.New()

    // Default values
    v.SetDefault("server.host", "localhost")
    v.SetDefault("server.port", 8080)
    v.SetDefault("server.tls", false)
    v.SetDefault("logging.level", "info")
    v.SetDefault("logging.format", "json")

    // Configuration file
    v.SetConfigName("config")      // config.yaml, config.json, config.toml
    v.SetConfigType("yaml")
    v.AddConfigPath(".")
    v.AddConfigPath("$HOME/.mycli")
    v.AddConfigPath("/etc/mycli/")

    // Environment variables
    v.SetEnvPrefix("MYCLI")
    v.AutomaticEnv()

    // Binding env vars
    v.BindEnv("server.host", "MYCLI_SERVER_HOST")
    v.BindEnv("database.password", "MYCLI_DB_PASSWORD")

    // Read config
    if err := v.ReadInConfig(); err != nil {
        if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
            return nil, fmt.Errorf("read config: %w", err)
        }
        // Config file not found is ok (use defaults + env)
    }

    var cfg Config
    if err := v.Unmarshal(&cfg); err != nil {
        return nil, fmt.Errorf("unmarshal config: %w", err)
    }
    return &cfg, nil
}

3.2 config.yaml

server:
  host: "0.0.0.0"
  port: 8080
  tls: false

database:
  host: "localhost"
  port: 5432
  user: "admin"
  password: "${DB_PASSWORD}"  # Ou via env
  dbname: "myapp"

logging:
  level: "debug"
  format: "json"

4. Bubble Tea

4.1 Architecture (Model, Update, View)

Bubble Tea est un framework TUI basé sur The Elm Architecture :

package main

import (
    "fmt"
    "os"
    tea "github.com/charmbracelet/bubbletea"
)

// Model = état
type model struct {
    choices  []string
    cursor   int
    selected map[int]struct{}
}

// Init = commandes initiales
func (m model) Init() tea.Cmd {
    return nil
}

// Update = gère les messages/événements
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 " ":
            _, ok := m.selected[m.cursor]
            if ok {
                delete(m.selected, m.cursor)
            } else {
                m.selected[m.cursor] = struct{}{}
            }
        case "enter":
            return m, tea.Quit
        }
    }
    return m, nil
}

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

func main() {
    p := tea.NewProgram(model{
        choices:  []string{"Carrots", "Celery", "Kale"},
        selected: make(map[int]struct{}),
    })
    if _, err := p.Run(); err != nil {
        fmt.Fprintf(os.Stderr, "Error: %v", err)
        os.Exit(1)
    }
}

4.2 Commandes et subscriptions

// Commandes (effets de bord)
type tickMsg struct{}
type fetchDataMsg struct {
    Data []byte
    Err  error
}

func tick() tea.Cmd {
    return tea.Tick(time.Second, func(t time.Time) tea.Msg {
        return tickMsg{}
    })
}

func fetchData() tea.Cmd {
    return func() tea.Msg {
        data, err := http.Get("https://api.example.com/data")
        if err != nil {
            return fetchDataMsg{Err: err}
        }
        defer data.Body.Close()
        body, _ := io.ReadAll(data.Body)
        return fetchDataMsg{Data: body}
    }
}

// Modèle avec effets
type model struct {
    loading bool
    data    []byte
    err     error
}

func (m model) Init() tea.Cmd {
    return tea.Batch(
        tick(),
        fetchData(),
    )
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tickMsg:
        return m, tick() // Continuer le tick
    case fetchDataMsg:
        m.loading = false
        if msg.Err != nil {
            m.err = msg.Err
        } else {
            m.data = msg.Data
        }
        return m, nil
    }
    return m, nil
}

4.3 Styles avec Lip Gloss

package styles

import (
    "github.com/charmbracelet/lipgloss"
)

var (
    // Colors
    PrimaryColor   = lipgloss.Color("#7C3AED") // Violet
    SecondaryColor = lipgloss.Color("#10B981") // Green
    ErrorColor     = lipgloss.Color("#EF4444") // Red
    WarningColor   = lipgloss.Color("#F59E0B") // Amber

    // Styles
    Title = lipgloss.NewStyle().
        Bold(true).
        Foreground(PrimaryColor).
        FontSize(18).
        Padding(0, 1)

    Subtitle = lipgloss.NewStyle().
        Foreground(lipgloss.Color("#6B7280")).
        Italic(true)

    Success = lipgloss.NewStyle().
        Foreground(SecondaryColor).
        Bold(true)

    Error = lipgloss.NewStyle().
        Foreground(ErrorColor).
        Bold(true)

    Warning = lipgloss.NewStyle().
        Foreground(WarningColor).
        Bold(true)

    Info = lipgloss.NewStyle().
        Foreground(lipgloss.Color("#3B82F6")) // Blue

    Code = lipgloss.NewStyle().
        Background(lipgloss.Color("#1F2937")).
        Foreground(lipgloss.Color("#F9FAFB")).
        Padding(0, 1)

    // Layout
    Box = lipgloss.NewStyle().
        Border(lipgloss.RoundedBorder()).
        Padding(1).
        Margin(1)

    List = lipgloss.NewStyle().
        PaddingLeft(2)

    Item = lipgloss.NewStyle().
        PaddingLeft(4)

    SelectedItem = lipgloss.NewStyle().
        PaddingLeft(2).
        Foreground(PrimaryColor).
        Bold(true)
)

func RenderTitle(text string) string {
    return Title.Render(text)
}

func RenderBox(content string) string {
    return Box.Render(content)
}

func RenderSuccess(text string) string {
    return Success.Render("✓ " + text)
}

func RenderError(text string) string {
    return Error.Render("✗ " + text)
}

func RenderProgress(current, total int) string {
    width := 50
    filled := int(float64(current) / float64(total) * float64(width))
    
    bar := ""
    for i := 0; i < width; i++ {
        if i < filled {
            bar += "█"
        } else {
            bar += "░"
        }
    }
    
    return lipgloss.NewStyle().
        Foreground(SecondaryColor).
        Render(fmt.Sprintf("%s %d/%d", bar, current, total))
}

5. Charm Ecosystem

5.1 Bubbles (composants TUI)

package main

import (
    "github.com/charmbracelet/bubbles/list"
    "github.com/charmbracelet/bubbles/spinner"
    "github.com/charmbracelet/bubbles/textinput"
    "github.com/charmbracelet/bubbles/table"
    "github.com/charmbracelet/bubbles/progress"
    "github.com/charmbracelet/bubbles/help"
    tea "github.com/charmbracelet/bubbletea"
)

// List
type item struct{ title, desc string }
func (i item) Title() string       { return i.title }
func (i item) Description() string { return i.desc }
func (i item) FilterValue() string { return i.title }

func newList() list.Model {
    items := []list.Item{
        item{title: "Go", desc: "Programming language"},
        item{title: "Rust", desc: "Systems programming"},
        item{title: "Python", desc: "General purpose"},
    }
    return list.New(items, list.NewDefaultDelegate(), 0, 0)
}

// Spinner
func newSpinner() spinner.Model {
    return spinner.New(spinner.WithSpinner(spinner.Dot))
}

// Text input
func newTextInput() textinput.Model {
    ti := textinput.New()
    ti.Placeholder = "Enter name..."
    ti.Focus()
    ti.CharLimit = 100
    ti.Width = 50
    return ti
}

// Table
func newTable() table.Model {
    columns := []table.Column{
        {Title: "Name", Width: 20},
        {Title: "Age", Width: 10},
        {Title: "Role", Width: 20},
    }
    rows := []table.Row{
        {"Alice", "30", "Engineer"},
        {"Bob", "25", "Designer"},
    }
    return table.New(
        table.WithColumns(columns),
        table.WithRows(rows),
        table.WithFocused(true),
        table.WithHeight(7),
    )
}

// Progress bar
func newProgress() progress.Model {
    return progress.New(progress.WithDefaultGradient())
}

// Help
func newHelp() help.Model {
    return help.New()
}

5.2 Gum

# Gum : shell scripts stylés
gum style --foreground "#7C3AED" "Hello, World!"
gum confirm "Continue?" && echo "Yes" || echo "No"
gum input --placeholder "Enter name" > name.txt
gum choose "option 1" "option 2" "option 3"
gum spin --spinner dot --title "Loading..." -- sleep 3
gum format --type markdown < README.md
gum table -n -c "Name,Age,Role" -r "Alice,30,Engineer" -r "Bob,25,Designer"

6. File Watching (fsnotify)

package watcher

import (
    "log"
    "github.com/fsnotify/fsnotify"
)

type FileWatcher struct {
    watcher *fsnotify.Watcher
    events  chan Event
}

type Event struct {
    Path     string
    Op       fsnotify.Op
    Timestamp time.Time
}

func NewFileWatcher(paths ...string) (*FileWatcher, error) {
    w, err := fsnotify.NewWatcher()
    if err != nil {
        return nil, err
    }

    fw := &FileWatcher{
        watcher: w,
        events:  make(chan Event, 100),
    }

    for _, path := range paths {
        if err := w.Add(path); err != nil {
            return nil, err
        }
    }

    go fw.loop()
    return fw, nil
}

func (fw *FileWatcher) loop() {
    for {
        select {
        case event, ok := <-fw.watcher.Events:
            if !ok {
                return
            }
            fw.events <- Event{
                Path:     event.Name,
                Op:       event.Op,
                Timestamp: time.Now(),
            }
        case err, ok := <-fw.watcher.Errors:
            if !ok {
                return
            }
            log.Printf("watch error: %v", err)
        }
    }
}

func (fw *FileWatcher) Events() <-chan Event {
    return fw.events
}

func (fw *FileWatcher) Close() error {
    return fw.watcher.Close()
}

7. Shell Completion

package cmd

import (
    "os"
    "github.com/spf13/cobra"
)

var completionCmd = &cobra.Command{
    Use:   "completion [bash|zsh|fish|powershell]",
    Short: "Generate shell completion script",
    Args:  cobra.ExactArgs(1),
    Run: func(cmd *cobra.Command, args []string) {
        var err error
        switch args[0] {
        case "bash":
            err = cmd.Root().GenBashCompletion(os.Stdout)
        case "zsh":
            err = cmd.Root().GenZshCompletion(os.Stdout)
        case "fish":
            err = cmd.Root().GenFishCompletion(os.Stdout, true)
        case "powershell":
            err = cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout)
        default:
            err = fmt.Errorf("unsupported shell: %s", args[0])
        }
        if err != nil {
            fmt.Fprintf(os.Stderr, "Error: %v\n", err)
            os.Exit(1)
        }
    },
}

// Dynamic completion
var showCmd = &cobra.Command{
    Use: "show [resource]",
    ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
        resources := []string{"users", "orders", "products", "events"}
        var completions []string
        for _, r := range resources {
            if strings.HasPrefix(r, toComplete) {
                completions = append(completions, r)
            }
        }
        return completions, cobra.ShellCompDirectiveNoFileComp
    },
    Run: func(cmd *cobra.Command, args []string) {
        // ...
    },
}

8. Testing CLI

8.1 Golden Files

package cmd_test

import (
    "testing"
    "github.com/sebdah/goldie/v2"
)

func TestGreetCommand(t *testing.T) {
    // Setup
    cmd := cmd.NewGreetCommand()
    buf := new(bytes.Buffer)
    cmd.SetOut(buf)
    cmd.SetArgs([]string{"--name", "World", "--count", "2"})

    // Execute
    err := cmd.Execute()
    assert.NoError(t, err)

    // Golden file test
    g := goldie.New(t)
    g.Assert(t, "greet_output", buf.Bytes())
}

// Mettre à jour les golden files :
// go test ./... -update

8.2 testscript

package main

import (
    "testing"
    "github.com/rogpeppe/go-internal/testscript"
)

func TestMain(m *testing.M) {
    os.Exit(testscript.RunMain(m, map[string]func() int{
        "mycli": Main,
    }))
}

func TestCLI(t *testing.T) {
    testscript.Run(t, testscript.Params{
        Dir: "testdata/scripts",
    })
}

// testdata/scripts/greet.txt
// Test greet command
// go run mycli greet --name World
// stdout 'Hello, World!'
// 
// Test help
// go run mycli --help
// stdout 'Usage:'
// stdout 'greet'
//
// Test error on missing flag
// ! go run mycli greet
// stderr 'required flag'

// testdata/scripts/echo.txt
// go run mycli echo hello
// stdout 'hello'
//
// go run mycli echo hello world
// stdout 'hello world'

9. Distribution

9.1 GoReleaser

# .goreleaser.yaml
project_name: mycli
version: 2

before:
  hooks:
    - go mod tidy
    - go generate ./...

builds:
  - env:
      - CGO_ENABLED=0
    goos:
      - linux
      - darwin
      - windows
    goarch:
      - amd64
      - arm64
    flags:
      - -trimpath
    ldflags:
      - -s -w
      - -X main.Version={{.Version}}
      - -X main.Commit={{.Commit}}
      - -X main.Date={{.Date}}

archives:
  - format: tar.gz
    name_template: >-
      {{ .ProjectName }}_
      {{- title .Os }}_
      {{- if eq .Arch "amd64" }}x86_64
      {{- else if eq .Arch "386" }}i386
      {{- else }}{{ .Arch }}{{ end }}
      {{- if .Arm }}v{{ .Arm }}{{ end }}

brews:
  - name: mycli
    homepage: "https://github.com/user/mycli"
    description: "My awesome CLI tool"
    repository:
      owner: user
      name: homebrew-tap
    install: |
      bin.install "mycli"

nfpms:
  - maintainer: "User <user@example.com>"
    description: "My awesome CLI tool"
    formats:
      - deb
      - rpm

release:
  github:
    owner: user
    name: mycli

milestones:
  - close: true

changelog:
  sort: asc
  filters:
    exclude:
      - '^docs:'
      - '^test:'
      - '^ci:'

9.2 CI/CD

# .github/workflows/release.yml
name: Release
on:
  push:
    tags:
      - 'v*'

jobs:
  goreleaser:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-go@v5
        with:
          go-version: '1.22'
      - name: Run GoReleaser
        uses: goreleaser/goreleaser-action@v5
        with:
          distribution: goreleaser
          version: latest
          args: release --clean
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}

10. Diagrammes

Diagramme en cours de génération...
Diagramme en cours de génération...

Points Clés

  1. Cobra : standard de facto pour les CLI Go
  2. Viper : configuration multi-source (fichier, env, flags)
  3. Bubble Tea : TUIs puissantes avec Elm Architecture
  4. Lip Gloss : styles et couleurs pour terminaux
  5. Golden files : tester les sorties CLI
  6. GoReleaser : distribution multi-plateforme
  7. Complétion shell : améliorer l'UX