go-cli-output · git:20260818.236909b · 2026-08-18 · sha256 73a1e6b5a7ff641b
go-cli-output git:20260818.236909bA
Immutable. This exact content is served forever at /api/v1/blob/73a1e6b5a7ff641b.
---
name: go-cli-output
description: The utils printer for Go CLI tools - the three output tiers behind --debug and --for-ai, the Print* API, table rendering, terminal colors, and error discipline. Use when writing anything a CLI prints, when building or changing utils/printer.go, utils/table.go, or utils/globals.go, or when a command reaches for fmt.Println. Triggers on PrintInfo, PrintSuccess, PrintError, PrintFatal, PrintWarn, PrintGeneric, PrintTable, GlobalDebugFlag, GlobalForAIFlag, lipgloss.ANSIColor, and --for-ai.
user-invocable: false
---
# Go CLI Output
**Every line a CLI Only tool prints goes through `utils`, which renders it three ways depending on who is reading.**
Web Only and Headless API Service projects have no `utils/` package and print with `log.Printf` instead.
## The Three Tiers
| Tier | Flag | Output | Input |
|---|---|---|---|
| Human (default) | none | styled ANSI via lipgloss | interactive bubbletea TUI |
| AI | `--for-ai` | plain text with `[OK]`, `[ERROR]`, `[WARN]`, `[INFO]` prefixes | piped stdin |
| Debug | `--debug` | structured zerolog with timestamps and full error detail | not applicable |
`--debug` and `--for-ai` are mutually exclusive, enforced by `MarkFlagsMutuallyExclusive`, because zerolog output interleaved with parseable plain text is neither.
`--for-ai` is the single gate for every AI-friendly behavior, so a caller enables the whole contract with one flag instead of discovering three. The tool carries no LLM SDK dependency: it is the thing an agent invokes, not a thing that invokes a model.
```
echo "my input" | toolname command --for-ai
```
Human mode is ephemeral, since transient lines get cleared. AI and debug modes are permanent, since everything printed has to survive being piped into a parser or a log.
## Globals
```go
package utils
// GlobalDebugFlag is set by the cobra root command when --debug is passed.
var GlobalDebugFlag bool
// GlobalForAIFlag is set by the cobra root command when --for-ai is passed.
var GlobalForAIFlag bool
```
## The Print Shape
Every printer branches the same way, debug first, then AI, then human. Writing them all to one shape means a new printer is a copy with a different glyph rather than a new decision.
```go
var (
infoStyle = lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(12)) // bright blue
successStyle = lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(10)) // bright green
errorStyle = lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(9)) // bright red
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(11)) // bright yellow
)
func PrintInfo(msg string) {
if GlobalDebugFlag {
log.Info().Msg(msg)
} else if GlobalForAIFlag {
fmt.Println("[INFO] " + msg)
} else {
fmt.Println(infoStyle.Render("→ " + msg))
}
}
func PrintError(msg string, err error) {
if GlobalDebugFlag {
if err != nil {
log.Error().Err(err).Msg(msg)
} else {
log.Error().Msg(msg)
}
} else if GlobalForAIFlag {
fmt.Println("[ERROR] " + msg)
} else {
fmt.Println(errorStyle.Render("✗ " + msg))
}
}
```
## The Print Surface
| Function | Human | AI | Debug |
|---|---|---|---|
| `PrintInfo(msg)` | `→ msg` blue | `[INFO] msg` | `log.Info()` |
| `PrintSuccess(msg)` | `✓ msg` green | `[OK] msg` | `log.Info()` |
| `PrintError(msg, err)` | `✗ msg` red | `[ERROR] msg` | `log.Error().Err(err)` |
| `PrintFatal(msg, err)` | `✗ msg` red, then `os.Exit(1)` | `[ERROR] msg`, then exit | `log.Error().Err(err)`, then exit |
| `PrintWarn(msg, err)` | `! msg` yellow | `[WARN] msg` | `log.Warn().Err(err)` |
| `PrintGeneric(msg)` | `msg` | `msg` | `msg` |
| `PrintRunning(msg)` | `↻ msg` blue | `[RUNNING] msg` | `log.Info()` |
| `PrintIndentedSuccess(msg)` | ` ✓ msg` green | `[OK] msg` | `log.Info()` |
| `PrintIndentedError(msg, err)` | ` ✗ msg` red | `[ERROR] msg` | `log.Error().Err(err)` |
| `PrintIndentedWarn(msg, err)` | ` ! msg` yellow | `[WARN] msg` | `log.Warn().Err(err)` |
| `PrintIndentedRunning(msg)` | ` ↻ msg` blue | `[RUNNING] msg` | `log.Info()` |
`PrintGeneric` prints raw text with no branch at all, because data the caller wants verbatim (a URL, a token, a rendered table) must not gain a prefix or a color that a consumer then has to strip.
`PrintFatal` exits with status 1 after printing, so a caller never has to remember the `os.Exit` that a fatal message implies.
## Error Discipline
The `msg` parameter is the human-readable label, and `err` is the Go error. Passing the actual `err` in the error parameter is what makes `--debug` useful, since zerolog's `.Err(err)` records it as a structured field that a baked-in string cannot become.
Human and AI modes show only `msg`. The error detail is exclusively for debug introspection, which keeps a stack of wrapped errors out of a user's face while leaving it one flag away.
```go
utils.PrintFatal("git not found in PATH", err)
utils.PrintIndentedError(toolName, result.Err)
```
Passing `nil` for `err` is correct only when there genuinely is no underlying error: a validation failure, a summary line, an informational warning.
## Subprocess Errors
A direct `exec.Command` that fails returns "exit status 1" and nothing else, so capture stderr into the error before printing it, or the debug tier records a message with no cause.
```go
cmd := exec.Command("sudo", "cp", src, dst)
var stderr strings.Builder
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if detail := strings.TrimSpace(stderr.String()); detail != "" {
err = fmt.Errorf("%s: %w", detail, err)
}
utils.PrintFatal("failed to copy binary", err)
}
```
A helper that already captures both streams into the returned error needs none of this.
## Terminal Colors
Colors are ANSI indices 0 through 15, never hex. An index is remapped by the user's terminal theme, so the same tool reads correctly under Dracula, Catppuccin, Solarized, or a scheme nobody has published; a hex value overrides that theme and fights it. Bright variants, 8 through 15, are preferred for foreground text.
```go
var (
ColorBlue = lipgloss.ANSIColor(12) // bright blue
ColorGreen = lipgloss.ANSIColor(10) // bright green
ColorRed = lipgloss.ANSIColor(9) // bright red
ColorYellow = lipgloss.ANSIColor(11) // bright yellow
ColorMagenta = lipgloss.ANSIColor(13) // bright magenta
ColorCyan = lipgloss.ANSIColor(14) // bright cyan
ColorFg = lipgloss.ANSIColor(15) // bright white, primary text
ColorMuted = lipgloss.ANSIColor(7) // white, secondary text
ColorChrome = lipgloss.ANSIColor(8) // bright black, borders and dim UI
)
```
## Tables
`PrintTable(headers, rows)` renders lipgloss box-drawing in human mode and a markdown table in AI mode, so the same call feeds a terminal and a parser without the caller branching.
```go
package utils
import (
"fmt"
"strings"
"charm.land/lipgloss/v2"
"charm.land/lipgloss/v2/table"
)
var (
headerStyle = lipgloss.NewStyle().Bold(true).Foreground(ColorFg).Padding(0, 1)
cellStyle = lipgloss.NewStyle().Foreground(ColorMuted).Padding(0, 1)
borderStyle = lipgloss.NewStyle().Foreground(ColorChrome)
)
func PrintTable(headers []string, rows [][]string) {
if GlobalForAIFlag {
printMarkdownTable(headers, rows)
return
}
t := table.New().
Border(lipgloss.NormalBorder()).
BorderStyle(borderStyle).
Headers(headers...).
Rows(rows...).
StyleFunc(func(row, col int) lipgloss.Style {
if row == table.HeaderRow {
return headerStyle
}
return cellStyle
})
PrintGeneric(t.Render())
}
func printMarkdownTable(headers []string, rows [][]string) {
if len(headers) == 0 {
return
}
seps := make([]string, len(headers))
for i := range seps {
seps[i] = "---"
}
fmt.Println("| " + strings.Join(escapeCells(headers), " | ") + " |")
fmt.Println("| " + strings.Join(seps, " | ") + " |")
for _, row := range rows {
fmt.Println("| " + strings.Join(escapeCells(row), " | ") + " |")
}
}
func escapeCells(cells []string) []string {
escaped := make([]string, len(cells))
for i, cell := range cells {
escaped[i] = strings.ReplaceAll(cell, "|", "\\|")
}
return escaped
}
```
Pipe characters in cell values are escaped, because one unescaped `|` shifts every column after it and silently corrupts the parse.