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:
- Package-level flag variables for that command
- The
var cmd = &cobra.Command{} definition
- An
init() function that registers flags and adds the command to its parent
- 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:
- One-line tool description
- Environment and configuration notes (what must be set, what is optional)
- Global flags summary (one line)
- A single code block listing real, runnable example commands covering all operations
- Brief notes after the code block for non-obvious behaviours
- Other/less common commands listed by name with a pointer to --help
- 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:
TOOL_API_TOKEN (specific to this tool)
SHARED_API_TOKEN (shared across related tools)
- 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
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.
Package purposes:
cmd/<name>/main.go— Minimal entry point. Delegates everything tointernal/cliinternal/cli/— Cobra root command, subcommands, output helpers, exit codesinternal/api/— HTTP client, authentication, pagination, error typesinternal/config/— Environment variable loading, validation, fallback chainsinternal/tui/— Terminal colour constants, formatting helpers, progress indicatorsinternal/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/andconfig/. 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.gois minimal. It callscli.Execute(), formats any error, and exits with the appropriate code.Rules:
CLI Architecture
Root Command
The root command lives in
internal/cli/root.go. It defines global flags, command groups, and theExecute()function.Set
SilenceUsage: trueandSilenceErrors: trueon the root command. This prevents Cobra from printing usage on every error. Errors are handled bymain.go.One File Per Command
Each command lives in its own file. Name files after the command hierarchy:
noun.gofor parent commands,noun_verb.gofor subcommands.Each file contains:
var cmd = &cobra.Command{}definitioninit()function that registers flags and adds the command to its parentrunCommandName(cmd *cobra.Command, args []string) errorfunctionRules:
RunE, neverRun— commands return errors, never callos.Exitcobra.ExactArgs(N)orcobra.ArbitraryArgsfor argument validationissueListLimit, notlimit)init()— this keeps the command tree declarativeCommand Groups
Organise commands into logical groups using Cobra's
AddGroupfor better help output.Assign commands to groups via
GroupIDin the command definition.Command Design
Hierarchy
Follow the pattern:
tool noun verb --flagsFor simple tools with a single domain, the noun can be omitted:
tool list,tool create.Self-Describing Commands
Every command must have
ShortandLongdescriptions. IncludeExampleblocks for non-trivial commands.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:
-f <file>flag for file input-f -for explicit stdinGlobal Flags
Standard Set
--json-j--verbose--debug--no-color--quiet-qNot every CLI needs all of these.
--jsonand--no-colorare always recommended. Add--verbose,--debug, and--quietas complexity warrants.NO_COLOR Support
Respect the
NO_COLORenvironment variable in addition to the--no-colorflag.Accessor Functions
Export flag state through functions, not variables.
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.goor a shared file withininternal/cli/:JSON Output
When
--jsonis set:{"ok": true, "data": ...}or raw API response{"ok": false, "error": "message"}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/orinternal/tui/for implementations.Pagination Indicators
When listing results, indicate if more are available:
Agent Help System
Purpose
AI agents need to learn how to use the CLI quickly and cheaply. The
help agentssubcommand provides a token-efficient reference designed for agent consumption.Implementation
Create a custom help command with an
agentstopic. Embed the help content as markdown files.Register the help command:
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:
Rules for agent help content:
Example format:
Design Implication
The agent help system is not an afterthought. It should influence command design:
-lalways means limit,-jalways means JSON)Exit Codes
Standard Codes
For CLIs that interact with APIs or have complex error scenarios:
For simple CLIs without API interaction, use 0 for success and 1 for failure.
ExitError Type
Exit Code Mapping
Error Handling
Wrapping
Always wrap errors with context using
%w:Preventing Double Output
The
printedErrortype wraps errors that have already been output to stderr.main.gochecks for this before printing.Update
main.goto respect this:Actionable Messages
Error messages should tell the user what to do:
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.
Fallback Chains
Support tool-specific and shared environment variable names. Check tool-specific first:
TOOL_API_TOKEN(specific to this tool)SHARED_API_TOKEN(shared across related tools)CUE Configuration
For tools that need persistent local state (default models, cached data, complex settings), use CUE:
~/.config/<name>/or$XDG_CONFIG_HOME/<name>/cuelang.org/gopackageValidation
Validate configuration at load time. Aggregate all errors before returning so the user can fix everything in one pass.
API Client
Structure
Standard Methods
Implement
Get,Post,Put,Deletemethods that handle:defer resp.Body.Close())APIErrortypeAPIError Type
Pagination
Implement pagination as a separate function that wraps API calls:
startandlimitquery parametersVerbose Logging
Support verbose HTTP logging via an
io.Writerthat can be set toos.Stderr:Log request method, path, status code, and duration when verbose is enabled.
Signal Handling
Set up graceful shutdown in
Execute():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
Formatting Helpers
Progress Indicator
A simple carriage-return progress indicator that only outputs to terminals:
Testing
Design for Testability
Define interfaces at consumption points. This allows tests to substitute implementations without mocking entire API surfaces.
The real
api.Clientsatisfies this interface. Tests can provide a minimal implementation.HTTP Testing
Use
httptest.NewServerfor testing HTTP interactions. This tests real HTTP behaviour without mocking.Table-Driven Tests
Use table-driven tests for functions with multiple input/output scenarios:
Test Utilities
t.TempDir()for temporary file and directory testst.Setenv()for environment variable tests (auto-cleanup)testdata/directory for JSON fixtures and sample filesRecommended Dependencies
These packages have been evaluated against alternatives and are recommended for Go CLI projects.
Core
github.com/spf13/cobragithub.com/fatih/colorgolang.org/x/termMarkdown and Content
github.com/yuin/goldmarkgithub.com/charmbracelet/glamourgithub.com/JohannesKaufmann/html-to-markdown/v2Configuration
cuelang.org/goAuthentication
golang.org/x/oauth2Utilities
github.com/google/uuidgithub.com/chzyer/readlinegithub.com/coder/websocketgolang.org/x/modTesting
go.uber.org/goleakDependency Philosophy
Keep dependencies minimal and purposeful. Every dependency is a maintenance burden. Before adding a package:
Build and Distribution
Building
Version Injection
Set the version at build time:
Linting
Include a
.golangci.ymlat the project root. A minimal starting configuration:Run before commits:
golangci-lint runDistribution
Publish via Homebrew tap for easy installation:
Also support
go install: