CLI tools bouwen in Go: complete handleiding

Leer hoe je CLI tools bouwt in Go met flag, cobra en viper. Van argument parsing tot subcommands, config en distributie met praktische voorbeelden.

18 augustus 20267 min leestijdDoor We Develop Communication

Een goede CLI tool bouwen in Go is opvallend eenvoudig. De taal is vrijwel gemaakt voor dit type programma: snelle compilatie, één statische binary zonder runtime dependencies en cross-compilation naar Linux, macOS en Windows met één commando. Het is geen toeval dat tools als Docker, Kubernetes, Terraform en Hugo allemaal in Go geschreven zijn.

In deze gids loop je door het complete proces: van een simpele flag parser tot een volwaardige CLI met subcommands, configuratie en distributie. Je hebt basiskennis van Go nodig, als je nog moet starten, begin dan eerst met installatie en je eerste Go programma.

Waarom Go voor CLI tools?

Go combineert een paar eigenschappen die ideaal zijn voor command-line tools. Je compileert naar één binary die je zonder installatie kunt kopiëren naar een server of laptop. Er is geen virtual machine, geen interpreter en geen dependency hell. De binary start bovendien razendsnel, ideaal voor tools die vaak aangeroepen worden in scripts.

Cross-compilation is een killer feature. Vanaf je MacBook bouw je met GOOS=linux GOARCH=amd64 go build een Linux-binary. Voor Windows werkt het net zo. Dat maakt distributie voor meerdere platforms triviaal.

Daarnaast heeft Go een sterke standaardbibliotheek. Voor veel CLI's heb je helemaal geen externe libraries nodig. En als je wel extra functionaliteit wilt, is het ecosysteem volwassen.

Je eerste CLI met het flag pakket

Voor simpele tools is het ingebouwde flag pakket meer dan genoeg. Hier is een minimale CLI die een naam en leeftijd accepteert.

package main

import (
    "flag"
    "fmt"
    "os"
)

func main() {
    name := flag.String("name", "wereld", "naam om te groeten")
    age := flag.Int("age", 0, "leeftijd in jaren")
    verbose := flag.Bool("verbose", false, "uitgebreide output")

    flag.Parse()

    if *name == "" {
        fmt.Fprintln(os.Stderr, "naam is verplicht")
        os.Exit(1)
    }

    fmt.Printf("Hallo %s!\n", *name)
    if *verbose {
        fmt.Printf("Leeftijd: %d\n", *age)
    }
}

Je roept de tool aan als ./groet -name Sander -age 34 -verbose. Het flag pakket genereert automatisch een --help output en valideert types. Voor tools met vijf of minder opties en één commando is dit alles wat je nodig hebt.

Positional arguments

Naast flags kun je ook positionele argumenten opvangen via flag.Args(). Handig voor tools zoals mytool copy src.txt dst.txt waar de bestandsnamen geen flag nodig hebben.

flag.Parse()
args := flag.Args()
if len(args) < 2 {
    fmt.Fprintln(os.Stderr, "gebruik: mytool <src> <dst>")
    os.Exit(1)
}
src, dst := args[0], args[1]

Subcommands met cobra

Zodra je CLI meer dan één actie doet, denk aan git commit, git push, git pull, wordt flag te beperkt. Dan pak je cobra, de de-facto standaard voor serieuze Go CLI's. Kubernetes, Helm, GitHub CLI en Hugo gebruiken het allemaal.

Installeer cobra met:

go get -u github.com/spf13/cobra@latest
go install github.com/spf13/cobra-cli@latest

Een minimale cobra setup ziet er zo uit.

package main

import (
    "fmt"
    "os"

    "github.com/spf13/cobra"
)

var rootCmd = &cobra.Command{
    Use:   "taskctl",
    Short: "Een simpele taak-manager",
    Long:  "taskctl helpt je taken toevoegen, lijsten en voltooien vanaf de command line.",
}

var addCmd = &cobra.Command{
    Use:   "add [taak]",
    Short: "Voeg een nieuwe taak toe",
    Args:  cobra.ExactArgs(1),
    Run: func(cmd *cobra.Command, args []string) {
        fmt.Printf("Taak toegevoegd: %s\n", args[0])
    },
}

var listCmd = &cobra.Command{
    Use:   "list",
    Short: "Toon alle taken",
    Run: func(cmd *cobra.Command, args []string) {
        fmt.Println("1. Boodschappen doen")
        fmt.Println("2. Blog schrijven")
    },
}

func main() {
    rootCmd.AddCommand(addCmd, listCmd)
    if err := rootCmd.Execute(); err != nil {
        os.Exit(1)
    }
}

Je roept dit aan als taskctl add "Boodschappen doen" of taskctl list. Cobra genereert automatisch --help voor elk subcommand en ondersteunt shell completion voor bash, zsh, fish en PowerShell.

Nested subcommands en flags per commando

Cobra schaalt naar diepe structuren zoals kubectl get pods --namespace default. Elk commando kan eigen flags hebben, en PersistentFlags() deelt flags met child commands.

var namespace string

func init() {
    rootCmd.PersistentFlags().StringVar(&namespace, "namespace", "default", "namespace om te gebruiken")
}

Configuratie met viper

Voor grotere tools wil je configuratie uit meerdere bronnen lezen: een config file, environment variables en flags. Viper combineert dit met slimme precedence: flags winnen van env vars, die winnen van config files, die winnen van defaults.

import "github.com/spf13/viper"

func initConfig() {
    viper.SetConfigName("config")
    viper.SetConfigType("yaml")
    viper.AddConfigPath("$HOME/.taskctl")
    viper.AddConfigPath(".")

    viper.SetEnvPrefix("TASKCTL")
    viper.AutomaticEnv()

    viper.SetDefault("api.timeout", 30)

    if err := viper.ReadInConfig(); err != nil {
        if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
            log.Fatal(err)
        }
    }
}

timeout := viper.GetInt("api.timeout")

Nu kan de gebruiker api.timeout instellen via ~/.taskctl/config.yaml, TASKCTL_API_TIMEOUT=60 of een --timeout flag. Dat is de ervaring die gebruikers verwachten van tools als kubectl of terraform.

Gebruikerservaring en output

Een goede CLI voelt prettig. Een paar vuistregels die vaak het verschil maken.

  • Gebruik exit codes correct: 0 bij succes, niet-nul bij fouten. Scripts vertrouwen hierop.
  • Schrijf fouten naar stderr: fmt.Fprintln(os.Stderr, ...) zodat > redirects alleen normale output opvangen.
  • Detecteer TTY voor kleuren: alleen kleuren gebruiken als de output naar een terminal gaat, niet naar een pipe. Libraries zoals fatih/color regelen dit automatisch.
  • Toon progress voor lange operaties: een spinner of progress bar met schollz/progressbar voorkomt dat gebruikers denken dat de tool hangt.
  • Respecteer NO_COLOR: de NO_COLOR standaard schakelt kleuren uit wanneer de env var gezet is.

Voor tabellen en gekleurde output zijn olekukonko/tablewriter en charmbracelet/lipgloss populaire keuzes. De laatste is onderdeel van Charm, een ecosysteem voor mooie terminal UI's in Go.

Error handling in CLI tools

CLI tools hebben een iets andere error-stijl dan servers. Een gebruiker ziet de fout direct, dus schrijf hem begrijpelijk. Combineer dit met patterns uit functions en error handling.

if err := doSomething(); err != nil {
    fmt.Fprintf(os.Stderr, "fout bij verwerken: %v\n", err)
    os.Exit(1)
}

Voor cobra kun je de RunE variant gebruiken die een error teruggeeft, waarna cobra zelf de stderr-output en exit code regelt. Zet SilenceUsage: true op je commando om te voorkomen dat de help-tekst bij elke fout getoond wordt.

Testen van je CLI

Testen van een CLI volgt grotendeels dezelfde patronen als andere Go code, zie testing in Go voor de basis. Het belangrijkste principe: scheid je logica van main(). Zet de echte functionaliteit in functies die je direct kunt aanroepen en test niet via subprocessen.

Cobra ondersteunt dit met cmd.SetOut() en cmd.SetArgs(), zodat je een commando kunt uitvoeren met een bytes.Buffer als output.

func TestListCommand(t *testing.T) {
    buf := new(bytes.Buffer)
    rootCmd.SetOut(buf)
    rootCmd.SetArgs([]string{"list"})

    if err := rootCmd.Execute(); err != nil {
        t.Fatal(err)
    }

    if !strings.Contains(buf.String(), "Boodschappen") {
        t.Errorf("verwachtte boodschappen in output, kreeg: %s", buf.String())
    }
}

Distributie en releases

Een Go CLI distribueren kan op meerdere manieren. De simpelste: go install github.com/user/tool@latest voor Go-gebruikers. Voor bredere distributie bouw je binaries voor meerdere platforms.

Cross-compilation doe je met environment variables:

GOOS=linux GOARCH=amd64 go build -o taskctl-linux
GOOS=darwin GOARCH=arm64 go build -o taskctl-macos-arm64
GOOS=windows GOARCH=amd64 go build -o taskctl.exe

Voor serieuze releases gebruik je goreleaser. Het bouwt automatisch binaries voor alle platforms, maakt checksums, publiceert naar GitHub Releases en kan zelfs Homebrew formulas en Docker images genereren. Je configureert alles in één .goreleaser.yaml en triggert het via een git tag.

Vergeet niet om je binary te strippen voor kleinere bestanden:

go build -ldflags="-s -w" -o taskctl

De -s -w flags verwijderen debug-informatie en besparen vaak 30% van de binary-grootte. Gebruik upx voor nog verdere compressie als grootte kritiek is.

Versioning via ldflags

Je kunt het versienummer tijdens de build injecteren:

go build -ldflags="-X main.version=1.2.3" -o taskctl

In je code:

var version = "dev"

var versionCmd = &cobra.Command{
    Use: "version",
    Run: func(cmd *cobra.Command, args []string) {
        fmt.Println(version)
    },
}

Goreleaser vult dit automatisch in op basis van je git tag.

Project structuur voor CLI tools

Voor grotere CLI's volg je de conventies uit project structuur in Go. Een typische layout:

taskctl/
├── cmd/
│   └── taskctl/
│       └── main.go        # minimale entrypoint
├── internal/
│   ├── cli/               # cobra commando's
│   │   ├── root.go
│   │   ├── add.go
│   │   └── list.go
│   ├── store/             # data layer
│   └── task/              # business logic
├── go.mod
└── .goreleaser.yaml

Houd main.go zo dun mogelijk, idealiter alleen een cli.Execute() aanroep. Dat maakt testen makkelijker en de structuur helder.

Veelgestelde vragen

Wat is een CLI tool in Go?

Een CLI tool is een command-line programma dat je vanuit de terminal aanroept. Go is populair voor CLI's vanwege snelle compilatie, statische binaries en uitstekende cross-compilation. Veel bekende tools zoals Docker, Kubernetes en Terraform zijn in Go geschreven.

Moet ik flag of cobra gebruiken?

Gebruik het ingebouwde flag pakket voor simpele tools met een paar opties. Kies cobra zodra je subcommands, uitgebreide help-teksten of shell completion nodig hebt. Cobra is de standaard voor grotere projecten zoals kubectl en hugo.

Hoe distribueer ik mijn Go CLI tool?

Compileer met go build voor één platform, of gebruik goreleaser om automatisch binaries voor Linux, macOS en Windows te bouwen. Je kunt releases publiceren via GitHub Releases, Homebrew of een eigen package manager.

Hoe lees ik configuratie uit een bestand?

Gebruik viper om config uit YAML, JSON of TOML bestanden te lezen, gecombineerd met environment variables en flags. Viper regelt de precedence automatisch: flags overrulen env vars, die weer config files overrulen.

Hoe test ik een CLI tool?

Scheid je commando-logica van main() zodat je functies direct kunt aanroepen in tests. Cobra heeft ingebouwde ondersteuning om commando's uit te voeren met een buffer als output, zodat je de uitvoer kunt asserten zonder subprocessen.

Conclusie

Go is uitzonderlijk geschikt voor CLI tools. Start klein met het flag pakket, stap over op cobra zodra je subcommands nodig hebt, en voeg viper toe voor configuratie. Investeer in een prettige gebruikerservaring, duidelijke fouten, correcte exit codes en goede help-teksten, en gebruik goreleaser voor pijnloze distributie. De tools die je vandaag bouwt, kunnen morgen door duizenden ontwikkelaars gebruikt worden zonder ze ooit te installeren via een package manager.

Veelgestelde vragen

Klaar om digitaal te groeien?

Wij helpen Nederlandse bedrijven met webtechnologie en SEO-strategieën die écht werken. Neem vrijblijvend contact op.