Skip to content

Tutorial: build a CLI that speaks text and JSON

In this lesson you will build a small command-line program, greet, from an empty directory to a working tool that produces human-readable text, a machine-readable JSON envelope, and an aligned table — all from the same code, chosen at runtime by an --output flag. Along the way you will meet every part of output you need for day-to-day CLI work.

You need Go 1.25+ and about fifteen minutes. Type the code in as you go; each step builds on the last.

Step 1 — a new module

mkdir greet && cd greet
go mod init example.com/greet
go get gitlab.com/phpboyscout/go/output

Step 2 — a Renderer and your first output

Create main.go. We start with the simplest possible use: build a Renderer pointed at standard output and print a line.

package main

import (
    "fmt"
    "io"
    "os"

    "gitlab.com/phpboyscout/go/output"
)

func main() {
    r := output.New(output.WithWriter(os.Stdout))

    _ = r.Write(nil, func(w io.Writer) {
        fmt.Fprintln(w, "Hello!")
    })
}
go run .
# Hello!

Write takes the data and a function that renders its human-readable form. In the default text format it calls your function. Next we make the format a choice.

Step 3 — a data type and a format flag

Give the tool something structured to say, and let the caller pick the format.

type Greeting struct {
    Name    string `json:"name"    table:"Name,sortable"`
    Message string `json:"message" table:"Message"`
}

func main() {
    format, _ := output.ParseFormat(os.Getenv("FORMAT")) // we'll use a real flag in Step 6
    r := output.New(output.WithWriter(os.Stdout), output.WithFormat(format))

    g := Greeting{Name: "Ada", Message: "Hello, Ada!"}

    _ = r.Write(g, func(w io.Writer) {
        fmt.Fprintln(w, g.Message)
    })
}

Try both formats:

go run .                 # Hello, Ada!
FORMAT=json go run .     # {"name":"Ada","message":"Hello, Ada!"}  (indented)

The same Write call produced prose in text mode and marshalled JSON in JSON mode. That is the core idea: configure the format once, write once.

Step 4 — a table for many rows

Real tools list things. Swap the single greeting for a slice and render a table.

greetings := []Greeting{
    {Name: "Ada", Message: "Hello, Ada!"},
    {Name: "Alan", Message: "Hello, Alan!"},
}

if format == output.FormatText {
    _ = r.Table(greetings, output.WithSortBy("Name"))
} else {
    _ = r.Write(greetings, nil) // json/yaml/csv/tsv/markdown all handled here
}
go run .
# Name  Message
# Ada   Hello, Ada!
# Alan  Hello, Alan!

FORMAT=csv go run .
# Name,Message
# Ada,Hello, Ada!
# Alan,Hello, Alan!

Columns came from the table:"..." struct tags. Note that for csv/tsv/ markdown, Write tabulates for you — you only reach for Table directly when you want text-table options like sorting or column widths.

Step 5 — a JSON envelope for scripts

Scripts want a predictable envelope with a status, not just raw data. Use Emit.

if r.IsJSON() {
    _ = r.Emit(output.Response{
        Status:  output.StatusSuccess,
        Command: "greet",
        Data:    greetings,
    })
    return
}
_ = r.Table(greetings, output.WithSortBy("Name"))

FORMAT=json go run .
{
  "status": "success",
  "command": "greet",
  "data": [ { "name": "Ada", "message": "Hello, Ada!" }, ... ]
}

Emit writes only in JSON mode, so the IsJSON guard keeps the text path clean.

Step 6 — a real --output flag (cobra)

Environment variables are fine for a lesson; real CLIs use a flag. The output/cobra subpackage wires --output to a Renderer for you.

import (
    "github.com/spf13/cobra"
    ocobra "gitlab.com/phpboyscout/go/output/cobra"
)

func main() {
    root := &cobra.Command{
        Use: "greet",
        RunE: func(cmd *cobra.Command, args []string) error {
            r := ocobra.NewRenderer(cmd) // reads --output + cmd's stdout
            greetings := []Greeting{ {Name: "Ada", Message: "Hello, Ada!"} }

            if r.IsJSON() {
                return r.Emit(output.Response{Status: output.StatusSuccess, Command: "greet", Data: greetings})
            }
            return r.Table(greetings, output.WithSortBy("Name"))
        },
    }
    ocobra.RegisterOutputFlag(root) // --output text|json|yaml|csv|tsv|markdown
    _ = root.Execute()
}
go run . --output json
go run . --output markdown

Importing output/cobra is the only place cobra enters your build; the core stayed framework-free.

Step 7 — feedback while working

Finally, show the user that something is happening. Point a second renderer at os.Stderr (so it never pollutes piped stdout) and wrap slow work in a spinner:

rr := output.New(output.WithWriter(os.Stderr))
_ = rr.Spin(cmd.Context(), "Preparing greetings", func(ctx context.Context) error {
    time.Sleep(500 * time.Millisecond) // pretend work
    return nil
})

In a terminal you get an animated spinner that clears on completion; under CI (CI=true) it prints plain Preparing greetings... done. Ctrl-C cancels the work and returns context.Canceled — never a false success.

What you built

A single tool that renders text, JSON, YAML, CSV, TSV and Markdown; emits a scriptable envelope; draws a sorted table; and shows live progress — all chosen at runtime, all from one configured Renderer.

From here: