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. It is all the same
code, and an --output flag picks the format at runtime. Along the way you will
use Write, Table, Emit, the cobra flag helper and Spin.
You need the Go version on the go line of the module's go.mod (an older
toolchain refuses to build it) 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 Preparing greetings... when the work starts and
Preparing greetings... done when it finishes. In a terminal, Ctrl-C while the
spinner is showing cancels the work and returns context.Canceled, so you never
see 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. The format is chosen at runtime and
everything comes from one configured Renderer.
From here:
- How-to guides for specific tasks.
- Formats & options reference for the complete surface.
- Behaviour by format for what each method does in each format, and errors and failure modes for what goes wrong.
- What this does not do for the boundaries.
- The Renderer façade for why it is shaped this way.