469 lines
12 KiB
Markdown
469 lines
12 KiB
Markdown
# CLI Stack — cobra + slog + caarlos0/env + signal handling
|
|
|
|
The canonical Go CLI skeleton. `cobra` is the de facto framework — Kubernetes, Docker CLI, Helm, GitHub CLI, gh, Hugo all use it. Use it.
|
|
|
|
---
|
|
|
|
## Toolchain
|
|
|
|
```bash
|
|
go install github.com/spf13/cobra-cli@latest
|
|
cobra-cli init mytool
|
|
cobra-cli add server
|
|
cobra-cli add migrate
|
|
```
|
|
|
|
`cobra-cli` scaffolds the `cmd/` package. Edit the result; do not regenerate.
|
|
|
|
---
|
|
|
|
## Layout
|
|
|
|
```
|
|
mytool/
|
|
├── go.mod
|
|
├── main.go # ≤ 30 LOC, calls cmd.Execute
|
|
├── cmd/
|
|
│ ├── root.go # rootCmd, persistent flags, slog setup
|
|
│ ├── server.go # `mytool server` subcommand
|
|
│ ├── migrate.go # `mytool migrate` subcommand
|
|
│ └── version.go # `mytool version` — auto-injected version
|
|
├── internal/
|
|
│ ├── config/
|
|
│ └── server/
|
|
└── Taskfile.yml
|
|
```
|
|
|
|
---
|
|
|
|
## `main.go`
|
|
|
|
```go
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
|
|
"github.com/your-org/mytool/cmd"
|
|
)
|
|
|
|
func main() {
|
|
ctx, stop := signal.NotifyContext(context.Background(),
|
|
syscall.SIGINT, syscall.SIGTERM)
|
|
defer stop()
|
|
|
|
if err := cmd.Execute(ctx); err != nil {
|
|
slog.Error("fatal", slog.Any("err", err))
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
```
|
|
|
|
`signal.NotifyContext` (Go 1.16+) gives every subcommand a ctx that cancels on Ctrl-C. Subcommands plumb the ctx into their workers.
|
|
|
|
---
|
|
|
|
## `cmd/root.go`
|
|
|
|
```go
|
|
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"os"
|
|
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
var (
|
|
verbose bool
|
|
logFormat string
|
|
configPath string
|
|
)
|
|
|
|
var rootCmd = &cobra.Command{
|
|
Use: "mytool",
|
|
Short: "Short description of mytool",
|
|
Long: `Long description, prose; cobra wraps it for --help.`,
|
|
PersistentPreRunE: func(c *cobra.Command, args []string) error {
|
|
return setupLogger()
|
|
},
|
|
SilenceUsage: true, // don't print --help on every error
|
|
SilenceErrors: true, // we log them ourselves in Execute
|
|
}
|
|
|
|
func init() {
|
|
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false,
|
|
"enable debug logging")
|
|
rootCmd.PersistentFlags().StringVar(&logFormat, "log-format", "text",
|
|
"log format: text or json")
|
|
rootCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "",
|
|
"path to config file (optional)")
|
|
}
|
|
|
|
func Execute(ctx context.Context) error {
|
|
return rootCmd.ExecuteContext(ctx)
|
|
}
|
|
|
|
func setupLogger() error {
|
|
level := slog.LevelInfo
|
|
if verbose { level = slog.LevelDebug }
|
|
opts := &slog.HandlerOptions{Level: level}
|
|
|
|
var h slog.Handler
|
|
switch logFormat {
|
|
case "json":
|
|
h = slog.NewJSONHandler(os.Stderr, opts)
|
|
case "text":
|
|
h = slog.NewTextHandler(os.Stderr, opts)
|
|
default:
|
|
return fmt.Errorf("invalid log-format %q", logFormat)
|
|
}
|
|
slog.SetDefault(slog.New(h))
|
|
return nil
|
|
}
|
|
```
|
|
|
|
Notes:
|
|
|
|
- `RunE` / `PersistentPreRunE` (the `E` variants) return errors. Use these; never use `Run` (no error return, encourages `log.Fatal`).
|
|
- `SilenceUsage: true` + `SilenceErrors: true` together: cobra stops printing the full `--help` on every command failure (the default behavior is rude in production scripts).
|
|
- `ExecuteContext` (cobra 1.8+) plumbs the ctx into every subcommand's `cmd.Context()`.
|
|
|
|
---
|
|
|
|
## `cmd/server.go`
|
|
|
|
```go
|
|
package cmd
|
|
|
|
import (
|
|
"log/slog"
|
|
|
|
"github.com/spf13/cobra"
|
|
"github.com/your-org/mytool/internal/server"
|
|
)
|
|
|
|
var (
|
|
serverAddr string
|
|
)
|
|
|
|
var serverCmd = &cobra.Command{
|
|
Use: "server",
|
|
Short: "Run the HTTP server",
|
|
RunE: func(c *cobra.Command, args []string) error {
|
|
ctx := c.Context()
|
|
slog.InfoContext(ctx, "starting", slog.String("addr", serverAddr))
|
|
return server.Run(ctx, serverAddr)
|
|
},
|
|
}
|
|
|
|
func init() {
|
|
serverCmd.Flags().StringVar(&serverAddr, "addr", ":8080",
|
|
"listen address")
|
|
rootCmd.AddCommand(serverCmd)
|
|
}
|
|
```
|
|
|
|
The subcommand is a thin shim — flags + log line + delegate to `internal/server`. Anything bigger violates the 250-LOC ceiling and belongs in `internal/`.
|
|
|
|
---
|
|
|
|
## Subcommands with arguments
|
|
|
|
```go
|
|
var migrateUpCmd = &cobra.Command{
|
|
Use: "up [N]",
|
|
Short: "Apply N migrations (default: all)",
|
|
Args: cobra.MaximumNArgs(1),
|
|
RunE: func(c *cobra.Command, args []string) error {
|
|
n := -1 // all
|
|
if len(args) == 1 {
|
|
var err error
|
|
n, err = strconv.Atoi(args[0])
|
|
if err != nil {
|
|
return fmt.Errorf("invalid N: %w", err)
|
|
}
|
|
}
|
|
return migrate.Up(c.Context(), n)
|
|
},
|
|
}
|
|
```
|
|
|
|
Use cobra's argument validators (`cobra.ExactArgs`, `cobra.MaximumNArgs`, `cobra.OnlyValidArgs`). They produce clean help text.
|
|
|
|
---
|
|
|
|
## Flag types — typed, not strings
|
|
|
|
```go
|
|
// GOOD
|
|
serverCmd.Flags().DurationVar(&timeout, "timeout", 30*time.Second, "request timeout")
|
|
serverCmd.Flags().IntVar(&port, "port", 8080, "port")
|
|
serverCmd.Flags().StringSliceVar(&hosts, "host", nil, "allowed hosts (repeatable)")
|
|
|
|
// BAD — manual parsing
|
|
serverCmd.Flags().StringVar(&timeoutStr, "timeout", "30s", "")
|
|
// ...then later: time.ParseDuration(timeoutStr)
|
|
```
|
|
|
|
`pflag` (cobra's flag lib) has typed variants for every common type. Use them; the parsing and error messages are free.
|
|
|
|
---
|
|
|
|
## Bind flags to env vars
|
|
|
|
cobra + viper is overkill for env binding. Use `caarlos0/env/v11`:
|
|
|
|
```go
|
|
type ServerOpts struct {
|
|
Addr string `env:"ADDR" envDefault:":8080"`
|
|
Timeout time.Duration `env:"TIMEOUT" envDefault:"30s"`
|
|
}
|
|
|
|
var opts ServerOpts
|
|
|
|
var serverCmd = &cobra.Command{
|
|
Use: "server",
|
|
PersistentPreRunE: func(c *cobra.Command, args []string) error {
|
|
// 1. Parse env first.
|
|
if err := env.Parse(&opts); err != nil { return err }
|
|
// 2. Flags override env if explicitly set.
|
|
if c.Flags().Changed("addr") {
|
|
opts.Addr, _ = c.Flags().GetString("addr")
|
|
}
|
|
return nil
|
|
},
|
|
RunE: func(c *cobra.Command, args []string) error {
|
|
return server.Run(c.Context(), opts)
|
|
},
|
|
}
|
|
|
|
func init() {
|
|
serverCmd.Flags().String("addr", "", "listen address (env: ADDR)")
|
|
serverCmd.Flags().Duration("timeout", 0, "request timeout (env: TIMEOUT)")
|
|
rootCmd.AddCommand(serverCmd)
|
|
}
|
|
```
|
|
|
|
Precedence: **flag (if set) > env > default**. Document the env var in the flag usage string.
|
|
|
|
---
|
|
|
|
## Version subcommand — build-injected
|
|
|
|
```go
|
|
// cmd/version.go
|
|
package cmd
|
|
|
|
import (
|
|
"fmt"
|
|
"runtime/debug"
|
|
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
// Set by -ldflags at build time, falls back to debug.BuildInfo.
|
|
var (
|
|
version = ""
|
|
commit = ""
|
|
date = ""
|
|
)
|
|
|
|
var versionCmd = &cobra.Command{
|
|
Use: "version",
|
|
Short: "Print version",
|
|
Run: func(c *cobra.Command, args []string) {
|
|
v, c2, d := resolveVersion()
|
|
fmt.Printf("mytool %s (commit %s, built %s)\n", v, c2, d)
|
|
},
|
|
}
|
|
|
|
func resolveVersion() (string, string, string) {
|
|
if version != "" { return version, commit, date }
|
|
info, ok := debug.ReadBuildInfo()
|
|
if !ok { return "dev", "unknown", "unknown" }
|
|
|
|
var vcs, hash, time string
|
|
for _, s := range info.Settings {
|
|
switch s.Key {
|
|
case "vcs.revision": hash = s.Value
|
|
case "vcs.time": time = s.Value
|
|
case "vcs": vcs = s.Value
|
|
}
|
|
}
|
|
return info.Main.Version, hash, time + " (" + vcs + ")"
|
|
}
|
|
|
|
func init() { rootCmd.AddCommand(versionCmd) }
|
|
```
|
|
|
|
Build with version injection:
|
|
|
|
```bash
|
|
go build \
|
|
-ldflags="-X 'github.com/your-org/mytool/cmd.version=v1.2.3' -X 'github.com/your-org/mytool/cmd.commit=$(git rev-parse --short HEAD)' -X 'github.com/your-org/mytool/cmd.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)'" \
|
|
-o bin/mytool ./
|
|
```
|
|
|
|
The `debug.BuildInfo` fallback means a `go install`'d binary also has version info — no manual `-ldflags` needed.
|
|
|
|
---
|
|
|
|
## Shell completions
|
|
|
|
```go
|
|
var completionCmd = &cobra.Command{
|
|
Use: "completion [bash|zsh|fish|powershell]",
|
|
Short: "Generate shell completion",
|
|
Args: cobra.ExactValidArgs(1),
|
|
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
|
|
DisableFlagsInUseLine: true,
|
|
RunE: func(c *cobra.Command, args []string) error {
|
|
switch args[0] {
|
|
case "bash": return rootCmd.GenBashCompletionV2(os.Stdout, true)
|
|
case "zsh": return rootCmd.GenZshCompletion(os.Stdout)
|
|
case "fish": return rootCmd.GenFishCompletion(os.Stdout, true)
|
|
case "powershell": return rootCmd.GenPowerShellCompletion(os.Stdout)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
|
|
func init() { rootCmd.AddCommand(completionCmd) }
|
|
```
|
|
|
|
User:
|
|
|
|
```bash
|
|
mytool completion zsh > "${fpath[1]}/_mytool"
|
|
```
|
|
|
|
---
|
|
|
|
## Interactive prompts — `huh` from charm
|
|
|
|
For prompts/forms (`Are you sure?`, "Pick an environment", multi-field forms):
|
|
|
|
```go
|
|
import "github.com/charmbracelet/huh"
|
|
|
|
var confirm bool
|
|
err := huh.NewConfirm().
|
|
Title("Apply migrations to PRODUCTION?").
|
|
Affirmative("Yes, do it").
|
|
Negative("Abort").
|
|
Value(&confirm).
|
|
Run()
|
|
```
|
|
|
|
`huh` replaces `survey` (which is no longer maintained). It composes with `lipgloss` for styling.
|
|
|
|
---
|
|
|
|
## Progress / spinners
|
|
|
|
```go
|
|
import "github.com/charmbracelet/huh/spinner"
|
|
|
|
err := spinner.New().Title("Fetching...").Action(func() {
|
|
// long-running work
|
|
}).Run()
|
|
```
|
|
|
|
For determinate progress (downloads, batch processing), use `vbauerster/mpb/v8`:
|
|
|
|
```go
|
|
import "github.com/vbauerster/mpb/v8"
|
|
|
|
p := mpb.New(mpb.WithWidth(60))
|
|
bar := p.AddBar(int64(total), /* decorators */)
|
|
for i := 0; i < total; i++ {
|
|
work()
|
|
bar.Increment()
|
|
}
|
|
p.Wait()
|
|
```
|
|
|
|
---
|
|
|
|
## Output — JSON vs text
|
|
|
|
Honor `--output json` for any CLI that scripts will parse:
|
|
|
|
```go
|
|
var outputFmt string
|
|
|
|
rootCmd.PersistentFlags().StringVar(&outputFmt, "output", "text",
|
|
"output format: text or json")
|
|
|
|
func render(v any) error {
|
|
switch outputFmt {
|
|
case "json":
|
|
enc := json.NewEncoder(os.Stdout)
|
|
enc.SetIndent("", " ")
|
|
return enc.Encode(v)
|
|
case "text":
|
|
return renderText(v)
|
|
default:
|
|
return fmt.Errorf("invalid --output %q", outputFmt)
|
|
}
|
|
}
|
|
```
|
|
|
|
The `text` format uses `lipgloss` tables or `aquasecurity/table` for nicely-aligned columns. The `json` format is for `jq`-style piping.
|
|
|
|
---
|
|
|
|
## Error semantics
|
|
|
|
- Return errors from `RunE`. Cobra catches them and the `Execute` wrapper logs + exits non-zero.
|
|
- `os.Exit(1)` should appear **only in `main.go`**. Anywhere else means a subcommand cannot be tested.
|
|
- For graceful early termination ("user cancelled"), return a sentinel and check it in `Execute`:
|
|
```go
|
|
var ErrCancelled = errors.New("cancelled by user")
|
|
// ... return ErrCancelled
|
|
// in main:
|
|
if errors.Is(err, cmd.ErrCancelled) { os.Exit(130) } // 128 + SIGINT
|
|
```
|
|
|
|
---
|
|
|
|
## Testing CLI commands
|
|
|
|
```go
|
|
func TestServerCmd_runs_with_default_addr(t *testing.T) {
|
|
// Given
|
|
buf := &bytes.Buffer{}
|
|
rootCmd.SetOut(buf)
|
|
rootCmd.SetErr(buf)
|
|
rootCmd.SetArgs([]string{"server", "--addr", ":0"})
|
|
|
|
// When
|
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
|
defer cancel()
|
|
err := rootCmd.ExecuteContext(ctx)
|
|
|
|
// Then
|
|
require.NoError(t, err)
|
|
require.Contains(t, buf.String(), "starting")
|
|
}
|
|
```
|
|
|
|
`SetArgs` + `ExecuteContext` is the canonical pattern. Bind a ctx with a short deadline for tests that would otherwise block.
|
|
|
|
---
|
|
|
|
## Sources
|
|
|
|
- cobra docs: https://github.com/spf13/cobra/blob/main/site/content/user_guide.md
|
|
- pflag: https://github.com/spf13/pflag
|
|
- huh: https://github.com/charmbracelet/huh
|
|
- caarlos0/env: https://github.com/caarlos0/env
|
|
- signal.NotifyContext: https://pkg.go.dev/os/signal#NotifyContext
|