Skip to content

Emit JSON for scripting

A CLI that is scriptable needs a stable, machine-readable envelope alongside its human output. output provides the Response envelope and Emit.

The envelope

type Response struct {
    Status  string `json:"status"`            // "success" | "error" | "warning"
    Command string `json:"command"`
    Data    any    `json:"data,omitempty"`
    Error   string `json:"error,omitempty"`
}

Emit — only when the format is JSON

Emit writes the envelope only when the renderer's format is json; in any other format it is a no-op. That lets you write both paths unconditionally:

r := output.New(output.WithWriter(os.Stdout), output.WithFormat(format))

result, err := doWork()
if err != nil {
    // In JSON mode this prints an error envelope; in text mode it is a no-op,
    // so return the error and let your normal text error-handling run.
    _ = r.EmitError("update", err)
    return err
}

if r.IsJSON() {
    return r.Emit(output.Response{
        Status:  output.StatusSuccess,
        Command: "update",
        Data:    result,
    })
}

// Human-readable path:
fmt.Fprintf(os.Stdout, "Updated to %s\n", result.Version)
return nil

Output for --output json:

{
  "status": "success",
  "command": "update",
  "data": { "version": "1.4.0" }
}

From a cobra command

If you build on cobra, the output/cobra subpackage reads the --output flag and the command's writer for you — see Wire it to a cobra CLI:

import ocobra "gitlab.com/phpboyscout/go/output/cobra"

return ocobra.NewRenderer(cmd).Emit(output.Response{
    Status: output.StatusSuccess, Command: "update", Data: result,
})

Emit once per invocation

Each Emit call writes a complete indented JSON document followed by a newline. Two calls produce two documents back to back — a stream of JSON values, not a single valid JSON value. jq reads such a stream by default, but a consumer that needs one value has to slurp it (jq -s), and any parser that expects exactly one document will reject the second. Put everything the caller needs inside one Data payload.

There is no YAML envelope

Emit writes only when the format is json. Under --output yaml — and under csv, tsv, markdown and text — it returns nil having written nothing. That is what makes the unconditional EmitError above safe, but it does mean a YAML consumer gets the raw payload from Write with no status field.

If a script needs the envelope's fields in YAML, model them yourself:

type envelope struct {
    Status  string `yaml:"status"`
    Command string `yaml:"command"`
    Data    any    `yaml:"data,omitempty"`
}

_ = r.Write(envelope{Status: output.StatusSuccess, Command: "update", Data: result}, nil)

Also worth knowing

  • Render is not silenced by JSON mode's siblings. It is a no-op in json only; in yaml, csv, tsv and markdown it writes ANSI escape sequences into the stream. See behaviour by format.
  • Send progress feedback to stderr. A spinner on the same writer as the envelope corrupts it — interactive vs plain rendering explains the two-renderer idiom.
  • --output jsno silently produces text. ParseFormat never errors; validate it yourself if a typo should be fatal (limitations).