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. 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

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!"
}

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 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: