Skip to content

feat(golang): add golang cli design guide #4

Description

@grantcarthew

Golang CLI Design Guide

Purpose

This guide defines the recommended architecture, patterns, and conventions for building command-line tools in Go. It is derived from production CLIs and targets AI agents tasked with scaffolding or building new CLI projects.

Follow this guide when creating a new Go CLI. Deviate only when the domain demands it, and document why.

Project Layout

Follow the standard Go project layout. The structure below shows the recommended internal packages for CLI tools.

<project>/
├── cmd/<name>/
│   └── main.go
├── internal/
│   ├── cli/
│   ├── api/
│   ├── config/
│   ├── tui/
│   └── <domain>/
├── testdata/
├── scripts/
├── docs/
├── go.mod
├── go.sum
├── .golangci.yml
├── LICENSE
└── README.md

Package purposes:

  • cmd/<name>/main.go — Minimal entry point. Delegates everything to internal/cli
  • internal/cli/ — Cobra root command, subcommands, output helpers, exit codes
  • internal/api/ — HTTP client, authentication, pagination, error types
  • internal/config/ — Environment variable loading, validation, fallback chains
  • internal/tui/ — Terminal colour constants, formatting helpers, progress indicators
  • internal/width/ — Unicode display width calculation for table output (optional, add when table output is needed)
  • internal/output/ — Table, detail, and JSON formatting (optional, can live in tui/ for simpler CLIs)
  • internal/<domain>/ — Domain-specific packages as needed (e.g. converter, models, jira)

Not every CLI needs every package. A simple CLI might only need cli/ and config/. Add packages as complexity demands.

Note: testdata/ at the project root holds test fixtures (JSON responses, sample files). scripts/ holds manual and integration test scripts.

Entry Point

cmd/<name>/main.go is minimal. It calls cli.Execute(), formats any error, and exits with the appropriate code.

package main

import (
	"fmt"
	"os"

	"<module>/internal/cli"
	"<module>/internal/tui"
)

func main() {
	if err := cli.Execute(); err != nil {
		fmt.Fprintln(os.Stderr, tui.ColorError.Sprint(err))
		os.Exit(cli.ExitCodeFromError(err))
	}
}

Rules:

  • No application logic in main
  • No flag parsing in main
  • No configuration loading in main
  • The only responsibilities are: run, format error, exit

CLI Architecture

Root Command

The root command lives in internal/cli/root.go. It defines global flags, command groups, and the Execute() function.

package cli

import (
	"context"
	"os"
	"os/signal"
	"syscall"

	"github.com/spf13/cobra"
)

var Version = "dev"

var rootCmd = &cobra.Command{
	Use:           "<name>",
	Short:         "One-line description",
	Version:       Version,
	SilenceUsage:  true,
	SilenceErrors: true,
}

func init() {
	rootCmd.PersistentFlags().BoolVarP(&jsonOutput, "json", "j", false, "Output in JSON format")
	rootCmd.PersistentFlags().BoolVar(&verbose, "verbose", false, "Enable verbose output")
	rootCmd.PersistentFlags().BoolVar(&noColor, "no-color", false, "Disable colour output")
}

func Execute() error {
	ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer cancel()
	return rootCmd.ExecuteContext(ctx)
}

Set SilenceUsage: true and SilenceErrors: true on the root command. This prevents Cobra from printing usage on every error. Errors are handled by main.go.

One File Per Command

Each command lives in its own file. Name files after the command hierarchy: noun.go for parent commands, noun_verb.go for subcommands.

internal/cli/
├── root.go
├── exitcodes.go
├── help.go
├── completion.go
├── issue.go              # Parent: defines "issue" command
├── issue_list.go         # Subcommand: issue list
├── issue_create.go       # Subcommand: issue create
├── issue_view.go         # Subcommand: issue view
├── page.go               # Parent: defines "page" command
├── page_list.go
├── page_create.go
└── page_view.go

Each file contains:

  1. Package-level flag variables for that command
  2. The var cmd = &cobra.Command{} definition
  3. An init() function that registers flags and adds the command to its parent
  4. A runCommandName(cmd *cobra.Command, args []string) error function
package cli

import "github.com/spf13/cobra"

var (
	issueListLimit  int
	issueListStatus string
)

var issueListCmd = &cobra.Command{
	Use:     "list <project>",
	Aliases: []string{"ls"},
	Short:   "List issues",
	Args:    cobra.ExactArgs(1),
	RunE:    runIssueList,
}

func init() {
	issueListCmd.Flags().IntVarP(&issueListLimit, "limit", "l", 25, "Maximum results")
	issueListCmd.Flags().StringVar(&issueListStatus, "status", "", "Filter by status")
	issueCmd.AddCommand(issueListCmd)
}

func runIssueList(cmd *cobra.Command, args []string) error {
	// Implementation
	return nil
}

Rules:

  • Always use RunE, never Run — commands return errors, never call os.Exit
  • Use cobra.ExactArgs(N) or cobra.ArbitraryArgs for argument validation
  • Prefix flag variables with the command name to avoid collisions (e.g. issueListLimit, not limit)
  • Register commands in init() — this keeps the command tree declarative

Command Groups

Organise commands into logical groups using Cobra's AddGroup for better help output.

func init() {
	rootCmd.AddGroup(
		&cobra.Group{ID: "commands", Title: "Commands:"},
		&cobra.Group{ID: "utilities", Title: "Utilities:"},
	)
}

Assign commands to groups via GroupID in the command definition.

Command Design

Hierarchy

Follow the pattern: tool noun verb --flags

tool issue list --status "Open"
tool issue create -s "Summary" -t Bug
tool page view 12345 --json

For simple tools with a single domain, the noun can be omitted: tool list, tool create.

Self-Describing Commands

Every command must have Short and Long descriptions. Include Example blocks for non-trivial commands.

var issueCreateCmd = &cobra.Command{
	Use:   "create",
	Short: "Create a new issue",
	Long:  "Create a new issue in the specified project with the given summary, type, and priority.",
	Example: `  ajira issue create -s "Fix login bug"
  ajira issue create -s "Add feature" -t Story -d "Description"
  echo "Description" | ajira issue create -s "From stdin" -f -`,
	RunE: runIssueCreate,
}

This is not optional decoration. AI agents rely on command help to understand how to use the tool. A command without descriptions is a command an agent cannot use effectively.

Aliases

Provide short aliases for frequently used commands: list / ls, delete / rm, view / show. Also alias plural and singular forms for noun commands so both feel natural: issue / issues, ticket / tickets, page / pages.

Input Methods

Support multiple input methods where commands accept content:

  • Positional arguments for short values
  • -f <file> flag for file input
  • -f - for explicit stdin
  • Piped stdin detection when no arguments provided
func readInput(cmd *cobra.Command, args []string, flagFile string) ([]byte, error) {
	if flagFile == "-" {
		return io.ReadAll(os.Stdin)
	}
	if flagFile != "" {
		return os.ReadFile(flagFile)
	}
	if len(args) > 0 {
		return []byte(strings.Join(args, " ")), nil
	}
	if !term.IsTerminal(int(os.Stdin.Fd())) {
		return io.ReadAll(os.Stdin)
	}
	return nil, fmt.Errorf("no input provided: use arguments, -f <file>, or pipe to stdin")
}

Global Flags

Standard Set

Flag Short Purpose
--json -j Machine-readable JSON output
--verbose HTTP request/response logging to stderr
--debug Finer-grained diagnostic output (when verbose is not enough)
--no-color Disable ANSI colour output
--quiet -q Suppress non-essential output

Not every CLI needs all of these. --json and --no-color are always recommended. Add --verbose, --debug, and --quiet as complexity warrants.

NO_COLOR Support

Respect the NO_COLOR environment variable in addition to the --no-color flag.

rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
	if noColor || os.Getenv("NO_COLOR") != "" {
		color.NoColor = true
	}
}

Accessor Functions

Export flag state through functions, not variables.

var jsonOutput bool

func JSONOutput() bool { return jsonOutput }

Output Design

Modes

All commands produce text output by default and JSON output with --json. Text is for humans. JSON is for scripts and AI agents.

Output Helpers

Define these helpers in root.go or a shared file within internal/cli/:

func outputSuccess(msg string) {
	if JSONOutput() {
		outputJSON(map[string]any{"ok": true, "message": msg})
		return
	}
	fmt.Println(msg)
}

func outputError(msg string) error {
	if JSONOutput() {
		outputJSON(map[string]any{"ok": false, "error": msg})
	} else {
		fmt.Fprintln(os.Stderr, tui.ColorError.Sprint("Error: "+msg))
	}
	return &printedError{msg: msg}
}

func outputNotice(msg string) {
	if JSONOutput() {
		return
	}
	fmt.Fprintln(os.Stderr, tui.ColorWarning.Sprint(msg))
}

func outputHint(msg string) {
	if JSONOutput() {
		return
	}
	fmt.Fprintln(os.Stderr, tui.ColorDim.Sprint("Hint: "+msg))
}

func outputJSON(v any) {
	data, _ := json.MarshalIndent(v, "", "  ")
	fmt.Println(string(data))
}

JSON Output

When --json is set:

  • Success responses: {"ok": true, "data": ...} or raw API response
  • Error responses: {"ok": false, "error": "message"}
  • No colour codes in JSON output
  • Hints and notices are suppressed

Text Output

For list commands, use table formatting with Unicode-aware column widths. For detail commands, use key-value pairs with tabwriter alignment. See internal/output/ or internal/tui/ for implementations.

Pagination Indicators

When listing results, indicate if more are available:

Showing 25 results (more available)
Showing all 12 results

Agent Help System

Purpose

AI agents need to learn how to use the CLI quickly and cheaply. The help agents subcommand provides a token-efficient reference designed for agent consumption.

Implementation

Create a custom help command with an agents topic. Embed the help content as markdown files.

internal/cli/
├── help.go
└── agent-help/
    ├── overview.md
    └── workflow.md
//go:embed agent-help/overview.md
var agentOverview string

//go:embed agent-help/workflow.md
var agentWorkflow string

Register the help command:

var helpAgentsCmd = &cobra.Command{
	Use:   "agents [topic]",
	Short: "Help for AI agents",
	Long:  "Token-efficient reference for AI agents. Topics: overview, workflow, all",
	RunE:  runHelpAgents,
}

Content Guidelines

Agent help documents must be token-efficient. Every token costs money and consumes context window. The format is a condensed cheat sheet, not a flag reference manual. An agent should be able to read it once and immediately use the tool.

Structure:

  1. One-line tool description
  2. Environment and configuration notes (what must be set, what is optional)
  3. Global flags summary (one line)
  4. A single code block listing real, runnable example commands covering all operations
  5. Brief notes after the code block for non-obvious behaviours
  6. Other/less common commands listed by name with a pointer to --help
  7. Chaining examples showing JSON output piped to jq

Rules for agent help content:

  • No bold, italic, or decorative formatting
  • No emojis
  • No verbose explanations — state facts
  • Show commands by example, not by flag description
  • Show flag variations inline within examples (e.g. multiple list commands with different filters)
  • Group examples logically by operation (list, create, edit, delete)
  • Include the most common workflows as runnable one-liners

Example format:

# tool Agent Reference

Non-interactive CLI for Service X. Text output is token-efficient; use --json only when parsing.

- SERVICE_API_TOKEN env required
- SERVICE_BASE_URL env optional (defaults to https://example.com)

Global flags: -j/--json, --verbose, --no-color. Only use --json when piping to jq.

## Core Commands

\```
tool item list
tool item list -l 10 --status "Active" -t Widget
tool item list --order-by created --reverse
tool item view ITEM-123
tool item create -s "New item" -t Widget
tool item create -s "From file" -f description.md
echo "Content" | tool item create -s "From stdin" -f -
tool item edit ITEM-123 -s "Updated summary"
tool item delete ITEM-123 --yes
tool item assign ITEM-123 me
tool item move ITEM-123 "Done" -m "Completed"
\```

Note: view shows 5 comments by default. IDs shown as [date] [id] Author.

## Other Commands

See --help: info, user search, config list

## Chaining (JSON)

\```
KEY=$(tool item create -s "New" --json | jq -r .key)
tool item assign $KEY me
\```

Design Implication

The agent help system is not an afterthought. It should influence command design:

  • Commands should be guessable from name alone
  • Flag names should be self-explanatory
  • Consistent flag naming across commands (-l always means limit, -j always means JSON)
  • Avoid flags that require reading documentation to understand

Exit Codes

Standard Codes

For CLIs that interact with APIs or have complex error scenarios:

const (
	ExitSuccess   = 0 // Normal completion
	ExitUserError = 1 // Invalid input, missing required values
	ExitAPIError  = 2 // API error (4xx/5xx except auth)
	ExitNetError  = 3 // Network or DNS error
	ExitAuthError = 4 // Authentication error (401/403)
	ExitPartial   = 5 // Partial failure in batch operations
)

For simple CLIs without API interaction, use 0 for success and 1 for failure.

ExitError Type

type ExitError struct {
	Code int
	Err  error
}

func (e *ExitError) Error() string { return e.Err.Error() }
func (e *ExitError) Unwrap() error { return e.Err }

Exit Code Mapping

func ExitCodeFromError(err error) int {
	var exitErr *ExitError
	if errors.As(err, &exitErr) {
		return exitErr.Code
	}

	var apiErr *api.APIError
	if errors.As(err, &apiErr) {
		switch apiErr.StatusCode {
		case 401, 403:
			return ExitAuthError
		default:
			return ExitAPIError
		}
	}

	var netErr net.Error
	if errors.As(err, &netErr) {
		return ExitNetError
	}

	return ExitUserError
}

Error Handling

Wrapping

Always wrap errors with context using %w:

if err := client.GetPage(ctx, pageID); err != nil {
	return fmt.Errorf("getting page %s: %w", pageID, err)
}

Preventing Double Output

The printedError type wraps errors that have already been output to stderr. main.go checks for this before printing.

type printedError struct {
	msg string
	err error
}

func (e *printedError) Error() string { return e.msg }
func (e *printedError) Unwrap() error { return e.err }

func IsPrintedError(err error) bool {
	var pe *printedError
	return errors.As(err, &pe)
}

Update main.go to respect this:

func main() {
	if err := cli.Execute(); err != nil {
		if !cli.IsPrintedError(err) {
			fmt.Fprintln(os.Stderr, tui.ColorError.Sprint(err))
		}
		os.Exit(cli.ExitCodeFromError(err))
	}
}

Actionable Messages

Error messages should tell the user what to do:

// Good
return fmt.Errorf("space key required: use --space flag or set CONFLUENCE_SPACE_KEY")

// Bad
return fmt.Errorf("missing space key")

Validation Order

Validate inputs before making API calls. Check required flags, parse arguments, and verify preconditions early.

Configuration

Environment Variables

Use environment variables for configuration. No config files unless the tool needs persistent local state.

type Config struct {
	BaseURL  string
	Email    string
	APIToken string
}

func Load() (Config, error) {
	var errs []error
	cfg := Config{}

	cfg.BaseURL = firstNonEmpty(os.Getenv("TOOL_BASE_URL"), os.Getenv("ATLASSIAN_BASE_URL"))
	if cfg.BaseURL == "" {
		errs = append(errs, fmt.Errorf("TOOL_BASE_URL or ATLASSIAN_BASE_URL must be set"))
	}

	cfg.APIToken = firstNonEmpty(os.Getenv("TOOL_API_TOKEN"), os.Getenv("ATLASSIAN_API_TOKEN"))
	if cfg.APIToken == "" {
		errs = append(errs, fmt.Errorf("TOOL_API_TOKEN or ATLASSIAN_API_TOKEN must be set"))
	}

	if len(errs) > 0 {
		return Config{}, errors.Join(errs...)
	}
	return cfg, nil
}

Fallback Chains

Support tool-specific and shared environment variable names. Check tool-specific first:

  1. TOOL_API_TOKEN (specific to this tool)
  2. SHARED_API_TOKEN (shared across related tools)
  3. Error if neither set

CUE Configuration

For tools that need persistent local state (default models, cached data, complex settings), use CUE:

  • Config path: ~/.config/<name>/ or $XDG_CONFIG_HOME/<name>/
  • CUE provides built-in type validation and constraints
  • See the cuelang.org/go package

Validation

Validate configuration at load time. Aggregate all errors before returning so the user can fix everything in one pass.

var errs []error
if cfg.BaseURL == "" {
	errs = append(errs, fmt.Errorf("BASE_URL must be set"))
}
if cfg.APIToken == "" {
	errs = append(errs, fmt.Errorf("API_TOKEN must be set"))
}
return errors.Join(errs...)

API Client

Structure

type Client struct {
	baseURL    string
	httpClient *http.Client
	auth       string
	verbose    io.Writer
}

func NewClient(cfg *config.Config) *Client {
	return &Client{
		baseURL: cfg.BaseURL,
		httpClient: &http.Client{
			Timeout: 30 * time.Second,
		},
		auth: cfg.APIToken,
	}
}

Standard Methods

Implement Get, Post, Put, Delete methods that handle:

  • Authentication headers (Basic or Bearer)
  • Content-Type setting
  • Response body reading and closing (defer resp.Body.Close())
  • Status code validation (2xx range)
  • Error parsing into APIError type

APIError Type

type APIError struct {
	StatusCode int
	Status     string
	Messages   []string
	Method     string
	Path       string
}

func (e *APIError) Error() string {
	if len(e.Messages) > 0 {
		return fmt.Sprintf("%s %s: %s (%d)", e.Method, e.Path, strings.Join(e.Messages, "; "), e.StatusCode)
	}
	return fmt.Sprintf("%s %s: %s", e.Method, e.Path, e.Status)
}

Pagination

Implement pagination as a separate function that wraps API calls:

  • Offset-based: uses start and limit query parameters
  • Cursor-based: uses a cursor token from the previous response
  • Set a maximum page count to prevent runaway pagination
  • Trim results to the exact limit requested

Verbose Logging

Support verbose HTTP logging via an io.Writer that can be set to os.Stderr:

func (c *Client) SetVerboseOutput(w io.Writer) {
	c.verbose = w
}

Log request method, path, status code, and duration when verbose is enabled.

Signal Handling

Set up graceful shutdown in Execute():

func Execute() error {
	ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer cancel()
	return rootCmd.ExecuteContext(ctx)
}

Pass context through to API calls and long-running operations. Check ctx.Done() in loops and pagination.

TUI Package

internal/tui/ holds terminal formatting utilities. It is not a full terminal user interface framework, but it can import TUI libraries as needed.

Colour Constants

package tui

import "github.com/fatih/color"

var (
	ColorError   = color.New(color.FgRed)
	ColorWarning = color.New(color.FgYellow)
	ColorSuccess = color.New(color.FgGreen)
	ColorHeader  = color.New(color.FgGreen)
	ColorDim     = color.New(color.Faint)
)

Formatting Helpers

func Annotate(format string, a ...any) string {
	text := fmt.Sprintf(format, a...)
	return ColorDim.Sprintf("(%s)", text)
}

func Bracket(format string, a ...any) string {
	text := fmt.Sprintf(format, a...)
	return ColorDim.Sprintf("[%s]", text)
}

Progress Indicator

A simple carriage-return progress indicator that only outputs to terminals:

type Progress struct {
	writer    io.Writer
	isTerminal bool
}

func NewProgress(w io.Writer) *Progress {
	fd, ok := w.(interface{ Fd() uintptr })
	isTerm := ok && term.IsTerminal(int(fd.Fd()))
	return &Progress{writer: w, isTerminal: isTerm}
}

func (p *Progress) Update(msg string) {
	if !p.isTerminal {
		return
	}
	fmt.Fprintf(p.writer, "\r%s", msg)
}

func (p *Progress) Done() {
	if !p.isTerminal {
		return
	}
	fmt.Fprint(p.writer, "\r\033[K")
}

Testing

Design for Testability

Define interfaces at consumption points. This allows tests to substitute implementations without mocking entire API surfaces.

// In the package that consumes the API client
type PageGetter interface {
	GetPage(ctx context.Context, pageID string) (*api.Page, error)
}

The real api.Client satisfies this interface. Tests can provide a minimal implementation.

HTTP Testing

Use httptest.NewServer for testing HTTP interactions. This tests real HTTP behaviour without mocking.

func TestGetPage(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		fmt.Fprint(w, `{"id": "123", "title": "Test"}`)
	}))
	defer server.Close()

	client := api.NewClient(&config.Config{BaseURL: server.URL})
	page, err := client.GetPage(context.Background(), "123")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if page.Title != "Test" {
		t.Errorf("got title %q, want %q", page.Title, "Test")
	}
}

Table-Driven Tests

Use table-driven tests for functions with multiple input/output scenarios:

func TestValidateSpaceKey(t *testing.T) {
	tests := []struct {
		name    string
		input   string
		wantErr bool
	}{
		{"valid", "MYSPACE", false},
		{"with numbers", "SPACE123", false},
		{"empty", "", true},
		{"special chars", "MY SPACE!", true},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			err := validateSpaceKey(tt.input)
			if (err != nil) != tt.wantErr {
				t.Errorf("validateSpaceKey(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
			}
		})
	}
}

Test Utilities

  • t.TempDir() for temporary file and directory tests
  • t.Setenv() for environment variable tests (auto-cleanup)
  • testdata/ directory for JSON fixtures and sample files
  • Flag reset helpers when testing cobra commands (flags are package-level mutable state)

Recommended Dependencies

These packages have been evaluated against alternatives and are recommended for Go CLI projects.

Core

Package Purpose
github.com/spf13/cobra CLI framework: commands, flags, help, completion
github.com/fatih/color Terminal colour output with NO_COLOR support
golang.org/x/term Terminal detection, TTY checks

Markdown and Content

Package Purpose
github.com/yuin/goldmark Markdown parsing and rendering, GFM support
github.com/charmbracelet/glamour Terminal markdown rendering with themes
github.com/JohannesKaufmann/html-to-markdown/v2 HTML to Markdown conversion

Configuration

Package Purpose
cuelang.org/go CUE language for typed configuration and validation

Authentication

Package Purpose
golang.org/x/oauth2 OAuth2 with Google Application Default Credentials

Utilities

Package Purpose
github.com/google/uuid UUID generation
github.com/chzyer/readline REPL and interactive line input
github.com/coder/websocket WebSocket client
golang.org/x/mod Module and version handling

Testing

Package Purpose
go.uber.org/goleak Goroutine leak detection in tests

Dependency Philosophy

Keep dependencies minimal and purposeful. Every dependency is a maintenance burden. Before adding a package:

  • Check if the standard library can do it
  • Evaluate alternatives for size, maintenance, and API quality
  • Prefer packages with no or few transitive dependencies

Build and Distribution

Building

go build -o <name> ./cmd/<name>/

Version Injection

Set the version at build time:

go build -ldflags "-X <module>/internal/cli.Version=v1.0.0" -o <name> ./cmd/<name>/

Linting

Include a .golangci.yml at the project root. A minimal starting configuration:

linters-settings:
  errcheck:
    exclude-functions:
      - fmt.Fprint
      - fmt.Fprintf
      - fmt.Fprintln

Run before commits: golangci-lint run

Distribution

Publish via Homebrew tap for easy installation:

brew tap <user>/tap
brew install <name>

Also support go install:

go install <module>@latest

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions