packages/omo-codex/plugin/skills/programming/references/go/cobra-stack.md
The canonical Go CLI skeleton. cobra is the de facto framework — Kubernetes, Docker CLI, Helm, GitHub CLI, gh, Hugo all use it. Use it.
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.
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.gopackage 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.gopackage 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.gopackage 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/.
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.
// 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.
cobra + viper is overkill for env binding. Use caarlos0/env/v11:
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.
// 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:
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.
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:
mytool completion zsh > "${fpath[1]}/_mytool"
huh from charmFor prompts/forms (Are you sure?, "Pick an environment", multi-field forms):
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.
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:
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()
Honor --output json for any CLI that scripts will parse:
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.
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.Execute:
var ErrCancelled = errors.New("cancelled by user")
// ... return ErrCancelled
// in main:
if errors.Is(err, cmd.ErrCancelled) { os.Exit(130) } // 128 + SIGINT
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.