Skip to content

Output

pkg/output is GTB's single source of truth for command output formatting. It provides three complementary capabilities:

  1. Writer β€” writes structured data as indented JSON or human-readable text from a single call site.
  2. Response envelope β€” a standard {status, command, data, error} JSON schema shared by all built-in commands, with Emit/IsJSONOutput/EmitError helpers to produce it.
  3. Markdown rendering β€” RenderMarkdown and Writer.Render apply glamour ANSI styling in text mode with automatic terminal width detection.

All three integrate with the --output flag defined on the root command.


Quick Start

Structured output with Writer

import (
    "fmt"
    "io"
    "os"
    "gitlab.com/phpboyscout/go-tool-base/pkg/output"
)

type Result struct {
    Name    string `json:"name"`
    Version string `json:"version"`
}

func runMyCommand(cmd *cobra.Command, args []string) error {
    format, _ := cmd.Flags().GetString("output")
    w := output.NewWriter(os.Stdout, output.Format(format))

    result := &Result{Name: "myapp", Version: "v1.2.3"}

    return w.Write(output.Response{
        Status:  output.StatusSuccess,
        Command: "mycommand",
        Data:    result,
    }, func(out io.Writer) {
        fmt.Fprintf(out, "Name:    %s\n", result.Name)
        fmt.Fprintf(out, "Version: %s\n", result.Version)
    })
}

mytool mycommand β†’ human-readable text. mytool mycommand --output json β†’ Response envelope:

{
  "status": "success",
  "command": "mycommand",
  "data": {
    "name": "myapp",
    "version": "v1.2.3"
  }
}

Markdown rendering

// Render AI output or release notes in the terminal
fmt.Print(output.RenderMarkdown(markdownContent))

// Or via Writer (no-op in JSON mode)
w.Render("## Changes\n\n- Added new flag\n- Fixed crash")

API Reference

Format

type Format string

const (
    FormatText Format = "text"  // Human-readable terminal output (default)
    FormatJSON Format = "json"  // Machine-readable JSON
    FormatYAML Format = "yaml"  // Machine-readable YAML
    FormatCSV      Format = "csv"      // Comma-separated values
    FormatMarkdown Format = "markdown" // Pipe-delimited markdown table
    FormatTSV      Format = "tsv"      // Tab-separated values
)

Response

The standard envelope for all command JSON output.

type Response struct {
    Status  string `json:"status"`
    Command string `json:"command"`
    Data    any    `json:"data,omitempty"`
    Error   string `json:"error,omitempty"`
}
Field Values Purpose
Status "success", "error", "warning" Quick outcome check
Command e.g. "version", "update" Which command produced the output
Data any JSON-serialisable value Command-specific payload
Error error message string Populated when Status is "error"

Status constants:

const (
    StatusSuccess = "success"
    StatusError   = "error"
    StatusWarning = "warning"
)

Emit

Writes a Response to cmd.OutOrStdout() when --output json is set. No-op for text mode.

func Emit(cmd *cobra.Command, resp Response) error
return output.Emit(cmd, output.Response{
    Status:  output.StatusSuccess,
    Command: "deploy",
    Data:    map[string]any{"environment": "production", "version": "v2.1.0"},
})

Returns an error only if JSON serialisation or writing fails β€” not for text mode.


IsJSONOutput

Returns true when the --output flag is set to "json".

func IsJSONOutput(cmd *cobra.Command) bool

Use this to skip text-only work (spinner animations, table headers, progress bars) when JSON output is requested:

if !output.IsJSONOutput(cmd) {
    spinner := startSpinner("Fetching…")
    defer spinner.Stop()
}

EmitError

Builds an error Response and emits it. No-op in text mode.

func EmitError(cmd *cobra.Command, commandName string, err error) error
if err := doWork(); err != nil {
    if emitErr := output.EmitError(cmd, "mycommand", err); emitErr != nil {
        return emitErr
    }
    // Log or handle for text mode
    return err
}

Writer

// NewWriter creates an output writer for the given io.Writer and format.
func NewWriter(w io.Writer, format Format) *Writer

// Write outputs data in the configured format.
// JSON mode: marshals data to indented JSON and writes it.
// Text mode: calls textFunc with the underlying writer.
func (o *Writer) Write(data any, textFunc func(io.Writer)) error

// Render writes glamour-styled markdown in text mode.
// In JSON mode it is a no-op β€” use Write for JSON output.
func (o *Writer) Render(markdown string) error

// IsJSON returns true when the writer is in JSON mode.
func (o *Writer) IsJSON() bool

Note: Pass the Response struct as the data argument to Write when you want the JSON envelope. Pass a plain struct if you have a specific reason to bypass the envelope (e.g. low-level data APIs).


RenderMarkdown

Renders markdown to styled ANSI terminal output via glamour. Detects terminal width automatically; falls back to 80 columns. If glamour fails for any reason, returns the original string unchanged β€” no error is surfaced.

func RenderMarkdown(content string) string
releaseNotes := "## v1.2.0\n\n- Added feature X\n- Fixed bug Y"
fmt.Print(output.RenderMarkdown(releaseNotes))

TableWriter

Renders structured data as an aligned text table, JSON, YAML, or CSV. Columns are derived from table struct tags or defined explicitly via WithColumns.

func NewTableWriter(w io.Writer, format Format, opts ...TableOption) *TableWriter
func (t *TableWriter) WriteRows(rows any) error

Struct tag convention:

type ServiceStatus struct {
    Name   string `json:"name"   table:"NAME,sortable"`
    Status string `json:"status" table:"STATUS"`
    Port   int    `json:"port"   table:"PORT,sortable"`
}

Tag format: table:"HEADER" or table:"HEADER,sortable". Use table:"-" to exclude a field.

Options:

Option Description
WithColumns(cols ...Column) Explicit column definitions (overrides struct tags)
WithSortBy(field string) Sort rows by column header (must be sortable)
WithSortDescending() Reverse sort order
WithNoHeader() Suppress header row in text output
WithNoTruncation() Disable terminal-width truncation
WithMaxWidth(width int) Override automatic terminal width detection

Column struct:

type Column struct {
    Header    string         // Display name in header row
    Field     string         // Struct field name or map key
    Width     int            // Fixed width (0 = auto-size)
    Sortable  bool           // Allow sorting by this column
    Formatter func(any) string // Custom cell formatter
}

Format constants:

Format Output
FormatText (default) Aligned, padded text table
FormatJSON Indented JSON array
FormatYAML YAML list
FormatCSV CSV with header row
FormatMarkdown Pipe-delimited markdown table with separator row
FormatTSV Tab-separated values for shell pipelines (awk, cut, sort)

Example:

services := []ServiceStatus{
    {Name: "api", Status: "running", Port: 8080},
    {Name: "worker", Status: "stopped", Port: 0},
}

format := output.Format(cmd.Flag("output").Value.String())
tw := output.NewTableWriter(cmd.OutOrStdout(), format,
    output.WithSortBy("NAME"),
)

if err := tw.WriteRows(services); err != nil {
    return err
}

// Text output:
// NAME     STATUS    PORT
// api      running   8080
// worker   stopped   0

// JSON output: [{"name":"api","status":"running","port":8080}, ...]
// YAML output:
// - name: api
//   status: running
//   port: 8080
// ...
// CSV output: NAME,STATUS,PORT\napi,running,8080\n...
// Markdown output:
// | NAME   | STATUS  | PORT |
// | ------ | ------- | ---- |
// | api    | running | 8080 |
// | worker | stopped | 0    |

Map slices are supported with explicit columns:

rows := []map[string]any{
    {"name": "alpha", "count": 10},
    {"name": "beta", "count": 20},
}

tw := output.NewTableWriter(os.Stdout, output.FormatText,
    output.WithColumns(
        output.Column{Header: "NAME", Field: "name"},
        output.Column{Header: "COUNT", Field: "count", Sortable: true},
    ),
)
tw.WriteRows(rows)

Usage Patterns

Pattern 1 β€” Writer with Response envelope (built-in command style)

Use this for commands that already use output.NewWriter. Pass Response as the data, so text mode renders your formatted output and JSON mode gets the envelope.

func runVersion(cmd *cobra.Command, p *props.Props) error {
    format, _ := cmd.Flags().GetString("output")
    w := output.NewWriter(os.Stdout, output.Format(format))

    info := getVersionInfo(p)

    return w.Write(output.Response{
        Status:  output.StatusSuccess,
        Command: "version",
        Data:    info,
    }, func(out io.Writer) {
        fmt.Fprintf(out, "Version: %s\n", info.Version)
    })
}

Pattern 2 β€” Emit for commands that don't use Writer

Use Emit when your command produces text output via the logger or fmt.Print and you just need to add a JSON path. The call is placed after all text work is done; it writes nothing in text mode.

func runDeploy(cmd *cobra.Command, p *props.Props) error {
    p.Logger.Info("Deploying…")

    result, err := deploy()
    if err != nil {
        return err
    }

    p.Logger.Info("Deployed", "version", result.Version)

    // JSON output only β€” text users see the logger output above
    return output.Emit(cmd, output.Response{
        Status:  output.StatusSuccess,
        Command: "deploy",
        Data:    result,
    })
}

Pattern 3 β€” Markdown in text mode, JSON data in JSON mode

Use Writer.Render and Writer.Write together when a command produces rich markdown text output but structured data for JSON consumers.

func runChangelog(cmd *cobra.Command, p *props.Props) error {
    format, _ := cmd.Flags().GetString("output")
    w := output.NewWriter(os.Stdout, output.Format(format))

    notes, meta := fetchChangelog()

    // Render markdown when in text mode
    if err := w.Render(notes); err != nil {
        return err
    }

    // Emit structured JSON when in JSON mode
    return output.Emit(cmd, output.Response{
        Status:  output.StatusSuccess,
        Command: "changelog",
        Data:    meta,
    })
}

Writer.Render is a no-op in JSON mode, so both calls are safe to make unconditionally.


Testing

Use bytes.Buffer as the writer and set it on the command to capture output:

func TestMyCommand_JSONOutput(t *testing.T) {
    var buf bytes.Buffer

    cmd := &cobra.Command{Use: "mycommand"}
    cmd.Flags().String("output", "text", "output format")
    _ = cmd.Flags().Set("output", "json")
    cmd.SetOut(&buf)

    err := runMyCommand(cmd, testProps)
    require.NoError(t, err)

    var resp output.Response
    require.NoError(t, json.Unmarshal(buf.Bytes(), &resp))
    assert.Equal(t, output.StatusSuccess, resp.Status)
    assert.Equal(t, "mycommand", resp.Command)
}

func TestMyCommand_TextOutput(t *testing.T) {
    var buf bytes.Buffer

    cmd := &cobra.Command{Use: "mycommand"}
    cmd.Flags().String("output", "text", "output format")
    cmd.SetOut(&buf)

    err := runMyCommand(cmd, testProps)
    require.NoError(t, err)

    // Verify text output β€” no JSON envelope
    assert.Contains(t, buf.String(), "myapp")
    assert.NotContains(t, buf.String(), `"status"`)
}

For RenderMarkdown, simply assert the output is non-empty β€” ANSI escape codes vary by terminal:

result := output.RenderMarkdown("# Heading\n\n**bold** text")
assert.NotEmpty(t, result)