go-cli-structure · git:20260826.ca0c5b8 · 2026-08-26 · sha256 2cea335f24ebe51c
go-cli-structure git:20260826.ca0c5b8A
Immutable. This exact content is served forever at /api/v1/blob/2cea335f24ebe51c.
---
name: go-cli-structure
description: The shape of a Go CLI's command tree - main.go, the root command for each project type, Execute and the exit path, a command file, the Run body, Run against RunE, and subcommand packages. Use when scaffolding a Go CLI, writing or changing cmd/root.go, adding a command file or a subcommand package, wiring setupLogs and --debug, or deciding whether a command returns an error. Triggers on main.go, cmd/root.go, cobra.Command, rootCmd, Execute, AddCommand, cobra.OnInitialize, setupLogs, CompletionOptions, SetHelpCommand, SilenceErrors, SilenceUsage, Run, RunE, cmd/ packages, and AppVersion ldflags injection. Not for flag conventions, positional arguments, or a flag reading stdin.
user-invocable: false
---
# Go CLI Structure
**How a Cobra command tree is put together: the entry point, the root, the commands hanging off it, and where a command's work goes.**
CLI Only projects and Web Only projects use different roots, and a CLI + Web hybrid uses the CLI Only root plus a `serve` command.
| Aspect | CLI Only | Web Only |
|---|---|---|
| Imports | zerolog, utils, subcommand packages | zerolog, cobra, subcommand packages |
| Global flags | `--debug` | `--debug` |
| Logging setup | `setupLogs()` via `cobra.OnInitialize`, terminal check through `utils` | `setupLogs()` via `cobra.OnInitialize`, terminal check inline |
| Output | `utils.Print*` | zerolog only |
Both set `Use`, `Short`, `Version` from the `AppVersion` ldflag, and `CompletionOptions.HiddenDefaultCmd: true`.
## Entry Point
```go
package main
import "github.com/[GITHUB_USER]/REPO_NAME/cmd"
func main() {
cmd.Execute()
}
```
`main.go` holds nothing else, so no logic ends up in the one function a test cannot call.
## Root Command, CLI Only
```go
package cmd
import (
"os"
"time"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"github.com/[GITHUB_USER]/REPO_NAME/utils"
featureCmd "github.com/[GITHUB_USER]/REPO_NAME/cmd/feature-cmd"
)
var AppVersion = "dev-build" // set at build time via ldflags
var debugFlag bool
var rootCmd = &cobra.Command{
Use: "appname",
Short: "Brief description of the application",
Version: AppVersion,
CompletionOptions: cobra.CompletionOptions{HiddenDefaultCmd: true},
}
func Execute() {
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
}
func setupLogs() {
zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
output := zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.DateTime, NoColor: !utils.StdoutIsTerminal}
log.Logger = zerolog.New(output).With().Timestamp().Logger()
zerolog.SetGlobalLevel(zerolog.InfoLevel)
if debugFlag {
zerolog.SetGlobalLevel(zerolog.DebugLevel)
utils.GlobalDebugFlag = true
}
}
func init() {
rootCmd.SetHelpCommand(&cobra.Command{Hidden: true})
rootCmd.PersistentFlags().BoolVar(&debugFlag, "debug", false, "Enable debug logging")
cobra.OnInitialize(setupLogs)
rootCmd.AddCommand(serveCmd)
rootCmd.AddCommand(featureCmd.FeatureCmd)
}
```
`setupLogs` runs through `cobra.OnInitialize` rather than at package init, because the flag values it reads are not parsed until Cobra has matched the command.
`Execute` sets the exit code and prints nothing. Cobra has already written the error to stderr by the time it returns one (`cobra@v1.10.2 command.go:1159-1161`), so a wrapper that prints it again shows the user the same line twice.
The help and completion commands are hidden so `appname --help` lists only the commands the tool actually offers.
## Root Command, Web Only
```go
package cmd
import (
"io"
"os"
"time"
"github.com/charmbracelet/x/term"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
)
var AppVersion = "dev-build"
var debugFlag bool
var rootCmd = &cobra.Command{
Use: "appname",
Short: "Brief description of the application",
Version: AppVersion,
CompletionOptions: cobra.CompletionOptions{HiddenDefaultCmd: true},
}
func Execute() {
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
}
func setupLogs() {
zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
var out io.Writer = os.Stdout
if term.IsTerminal(os.Stdout.Fd()) {
out = zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.DateTime}
}
log.Logger = zerolog.New(out).With().Timestamp().Logger()
zerolog.SetGlobalLevel(zerolog.InfoLevel)
if debugFlag {
zerolog.SetGlobalLevel(zerolog.DebugLevel)
}
}
func init() {
rootCmd.SetHelpCommand(&cobra.Command{Hidden: true})
rootCmd.PersistentFlags().BoolVar(&debugFlag, "debug", false, "Enable debug logging")
cobra.OnInitialize(setupLogs)
rootCmd.AddCommand(serveCmd)
}
```
Same logger and same `--debug` as CLI Only, without the `utils` import. What a server has no use for is the printers, since nobody is watching a container's stdout for a styled checkmark.
## Simple Command
A command without subcommands is defined directly in `cmd/`, with its own flag struct and `init()`. Grouping flags in a struct per command keeps two commands from colliding over a variable named `output` in the shared `cmd` package.
```go
// cmd/serve.go
package cmd
import (
"fmt"
"github.com/spf13/cobra"
"github.com/[GITHUB_USER]/REPO_NAME/internal/server"
u "github.com/[GITHUB_USER]/REPO_NAME/utils"
)
var serveFlags struct {
port int
host string
}
var serveCmd = &cobra.Command{
Use: "serve",
Short: "Start the web server",
Run: func(cmd *cobra.Command, args []string) {
srv := server.New(serveFlags.host, serveFlags.port)
if err := srv.Setup(); err != nil {
u.PrintFatal("Failed to set up server", err)
}
u.PrintInfo(fmt.Sprintf("Starting server on %s:%d", serveFlags.host, serveFlags.port))
if err := srv.Run(); err != nil {
u.PrintFatal("Server error", err)
}
},
}
func init() {
serveCmd.Flags().IntVarP(&serveFlags.port, "port", "p", 8080, "Port to listen on")
serveCmd.Flags().StringVarP(&serveFlags.host, "host", "H", "0.0.0.0", "Host to bind to")
}
```
The same command in a Web Only project swaps `u.PrintFatal(msg, err)` for `log.Fatal().Err(err).Msg(msg)` and `u.PrintInfo` for `log.Info()`, and drops the `utils` import.
## Run Function Shape
A `Run` body validates flags, builds a config struct, calls into `internal/`, and reports the result. Keeping the work in `internal/` leaves the command as the only layer that knows about flags and terminals, which is what lets the logic be tested without one.
```go
Run: func(cmd *cobra.Command, args []string) {
if flags.required == "" {
u.PrintFatal("--required flag is required", nil)
}
cfg := internal.Config{Field1: flags.field1, Field2: flags.field2}
result, err := internal.DoThing(cfg)
if err != nil {
u.PrintFatal("Failed to do thing", err)
}
u.PrintSuccess("Thing completed")
u.PrintGeneric(result)
}
```
`Run` with `u.PrintFatal` is the default. `RunE` is for a command holding something a deferred function has to release, such as a lock file, a temporary directory, or a half-written output file, since `PrintFatal` calls `os.Exit(1)` and skips defers.
Everywhere else `RunE` costs two things. `PrintFatal(msg, err)` carries a human label and the wrapped error separately, which is what lets the normal tier show the label alone while `--debug` shows the chain, and a returned `error` collapses both into one string. Cobra then prints `Error: <err>` followed by the entire usage block (`cobra@v1.10.2 command.go:1159-1167`), burying the message under a wall of flags.
A tree that uses `RunE` anywhere sets both silences on the root and reports the error itself, which arrives back at `PrintFatal`:
```go
func init() {
rootCmd.SilenceErrors = true
rootCmd.SilenceUsage = true
}
func Execute() {
if err := rootCmd.Execute(); err != nil {
u.PrintFatal("Command failed", err)
}
}
```
## Subcommand Package
A group of related subcommands gets a package under `cmd/`. The parent command is exported and has no `Run`, so invoking it bare prints help instead of doing something arbitrary; the children are unexported and each carry a `Run`.
```go
// cmd/feature-cmd/feature.go
package featureCmd
import (
"fmt"
"github.com/spf13/cobra"
"github.com/[GITHUB_USER]/REPO_NAME/internal/feature"
u "github.com/[GITHUB_USER]/REPO_NAME/utils"
)
var createFlags struct {
name string
config string
}
var FeatureCmd = &cobra.Command{
Use: "feature",
Short: "Feature management commands",
}
var createCmd = &cobra.Command{
Use: "create",
Short: "Create a new feature",
Run: func(cmd *cobra.Command, args []string) {
cfg := feature.CreateConfig{Name: createFlags.name, Config: createFlags.config}
if err := feature.Create(cfg); err != nil {
u.PrintFatal("Failed to create feature", err)
}
u.PrintSuccess(fmt.Sprintf("Created feature: %s", createFlags.name))
},
}
func init() {
FeatureCmd.AddCommand(createCmd)
createCmd.Flags().StringVarP(&createFlags.name, "name", "n", "", "Feature name (required)")
createCmd.MarkFlagRequired("name")
createCmd.Flags().StringVarP(&createFlags.config, "config", "c", "", "Config file path")
}
```