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/colorregelen dit automatisch. - Toon progress voor lange operaties: een spinner of progress bar met
schollz/progressbarvoorkomt 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.