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¶
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!")
})
}
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:
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"))
{
"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()
}
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:
- How-to guides for specific tasks.
- Formats & options reference for the complete surface.
- The Renderer façade for why it is shaped this way.