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
- Cobra : standard de facto pour les CLI Go
- Viper : configuration multi-source (fichier, env, flags)
- Bubble Tea : TUIs puissantes avec Elm Architecture
- Lip Gloss : styles et couleurs pour terminaux
- Golden files : tester les sorties CLI
- GoReleaser : distribution multi-plateforme
- Complétion shell : améliorer l'UX