Lesson 04 of 9 · Part 2 — Tools that talk to systems
Building command-line tools
Build command-line tools people trust: flags with the standard library, subcommands with cobra, configuration from files and environment, structured logs with slog, data on stdout and messages on stderr, clear exit codes and clean shutdown on Ctrl-C.
Flags with the standard library
package main
import (
"flag"
"fmt"
"os"
"time"
)
func main() {
cluster := flag.String("cluster", "", "cluster name (required)")
timeout := flag.Duration("timeout", 30*time.Second, "overall timeout")
verbose := flag.Bool("v", false, "verbose output")
flag.Parse()
if *cluster == "" {
fmt.Fprintln(os.Stderr, "error: --cluster is required")
flag.Usage()
os.Exit(2) // 2: usage error, by convention
}
fmt.Printf("checking %s (timeout %s, verbose %t)\n", *cluster, *timeout, *verbose)
}
flag is enough for single-purpose tools. Durations parse 30s, 5m, 1h30m for free.
A good command-line tool is like a good vending machine: clear buttons (flags), the snack comes out of one slot (stdout), messages appear on the little screen (stderr), and if you press cancel it gives your coins back instead of jamming.
Subcommands with cobra
For tools with several verbs (kaudit nodes, kaudit certs), cobra (used by kubectl, Helm and many others) gives subcommands, help text, persistent flags and shell completion:
package main
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
func main() {
var cluster string
root := &cobra.Command{Use: "kaudit", Short: "Audit Kubernetes clusters"}
root.PersistentFlags().StringVar(&cluster, "cluster", "", "cluster name")
nodes := &cobra.Command{
Use: "nodes",
Short: "Report node health",
RunE: func(cmd *cobra.Command, args []string) error {
if cluster == "" {
return fmt.Errorf("--cluster is required")
}
fmt.Println("nodes of", cluster)
return nil
},
}
root.AddCommand(nodes)
if err := root.Execute(); err != nil {
os.Exit(1)
}
}
Use RunE and return errors; let main decide the exit code.
Configuration: flags, environment, files
A predictable order: flag > environment variable > config file > default. Read environment with os.Getenv (or os.LookupEnv to tell empty from unset), files with os.ReadFile plus encoding/json or a YAML library. Libraries such as viper automate this, at the cost of a large dependency; for small tools, a few lines of your own code are clearer.
Logs with slog
package main
import (
"log/slog"
"os"
)
func main() {
log := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
log.Info("scan started", "cluster", "prod-eu", "nodes", 42)
log.Warn("node not ready", "node", "n7", "since", "5m")
}
Structured key-value logs are easy to search later; use the text handler for humans and the JSON handler for CI and log systems.
Exit codes and stdout/stderr
| Code | Meaning (convention) |
|---|---|
| 0 | Success |
| 1 | Failure (the check found problems, or an error occurred) |
| 2 | Usage error (bad flags) |
Write data to stdout and everything else to stderr. Offer -o json so other tools can consume the output. os.Exit skips deferred functions, so call it only in main.
Clean shutdown on Ctrl-C
package main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
select {
case <-time.After(10 * time.Second):
fmt.Println("work done")
case <-ctx.Done():
fmt.Fprintln(os.Stderr, "interrupted, cleaning up")
os.Exit(130)
}
}
Pass ctx to every HTTP call and loop (lessons 05 and 06) so cancellation reaches all of them.
Try it: kaudit, version 0
- Create a cobra tool
kauditwith subcommandsnodesandversion, and a persistent--clusterflag that falls back to$KAUDIT_CLUSTER. - Log with slog to stderr; print results to stdout; add
-o json. - Return exit code 1 when
nodesfinds a problem (fake one for now) and 2 for usage errors. - Make a long operation stop cleanly on Ctrl-C using
signal.NotifyContext. - Generate shell completion with
kaudit completion bashand try it.
Going deeper: CLIs others depend on
- Treat flags and output formats as an API: changing them breaks scripts; version and deprecate.
- Add
--dry-runto anything that changes state, and confirm destructive actions unless--yesis given. - Ship with goreleaser (multi-platform builds, checksums, release notes) once the tool is shared.
Recap
flagfor simple tools, cobra for subcommands and completion.- Config order: flag > env > file > default.
- slog for structured logs to stderr; data to stdout; offer JSON output.
- Clear exit codes; signal.NotifyContext for clean shutdown.
This site is a public version of my personal engineering knowledge hub. It intentionally excludes confidential company information and internal operational details.