Skip to content

Add Scriptable JSON Output to a Command

The go/output module provides two things commands typically need: structured JSON output for CI/CD pipelines and scripts, and styled markdown rendering for terminal display. Both are controlled by the --output flag already defined on the root command.

Everything hangs off a single configured Renderer (output.New(...)). For cobra commands, the opt-in go/output/cobra subpackage reads the --output flag and the command's writer for you — import it aliased to avoid the clash with spf13/cobra:

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

The Standard JSON Envelope

All built-in GTB commands wrap their JSON output in a standard Response envelope:

{
  "status": "success",
  "command": "mycommand",
  "data": { ... }
}

Using this envelope means your command's JSON output follows the same schema as version, doctor, update, and init — consumers know where to look for the payload and can check status without parsing data.


Step 1: Define Your Data Struct

Tag every exported field for JSON serialisation:

type DeployResult struct {
    Environment string `json:"environment"`
    Version     string `json:"version"`
    Replicas    int    `json:"replicas"`
}

Step 2: Build a Renderer with the Response Envelope

The --output flag is already registered on the root command. ocobra.NewRenderer builds a Renderer from the command — its writer is cmd.OutOrStdout() and its format is the --output value:

func NewCmdDeploy(p *props.Props) *setup.Command {
    return setup.Wrap("deploy", &cobra.Command{
        Use:   "deploy",
        Short: "Deploy to an environment",
        RunE: func(cmd *cobra.Command, args []string) error {
            r := ocobra.NewRenderer(cmd)

            result := runDeploy(args[0])

            return r.Write(output.Response{
                Status:  output.StatusSuccess,
                Command: "deploy",
                Data:    result,
            }, func(out io.Writer) {
                fmt.Fprintf(out, "Deployed %s to %s (%d replicas)\n",
                    result.Version, result.Environment, result.Replicas)
            })
        },
    })
}

Text output (mytool deploy production):

Deployed v1.2.3 to production (3 replicas)

JSON output (mytool deploy production --output json):

{
  "status": "success",
  "command": "deploy",
  "data": {
    "environment": "production",
    "version": "v1.2.3",
    "replicas": 3
  }
}

Step 3: Add JSON Output to Existing Commands (Emit Pattern)

If your command already has text output via the logger or fmt.Print and you want to add a JSON path without changing the text path, use ocobra.Emit. It writes the envelope only when --output json is set, and is a no-op in text mode.

func runMigrate(cmd *cobra.Command, p *props.Props, env string) error {
    p.Logger.Info("Running migrations", "environment", env)

    count, err := runMigrations(env)
    if err != nil {
        return err
    }

    p.Logger.Info("migrations applied", "count", count)

    return ocobra.Emit(cmd, output.Response{
        Status:  output.StatusSuccess,
        Command: "migrate",
        Data:    map[string]any{"environment": env, "applied": count},
    })
}

Step 4: Handle Errors in JSON Mode

Use ocobra.EmitError to produce an error envelope in JSON mode. In text mode it is a no-op, so you can return the error as normal for text users.

result, err := deploy()
if err != nil {
    _ = ocobra.EmitError(cmd, "deploy", err)
    return err
}

JSON error output:

{
  "status": "error",
  "command": "deploy",
  "error": "connection refused: could not reach production cluster"
}

Step 5: Suppress Text-Only Work in JSON Mode

Use ocobra.IsJSONOutput to skip expensive or interactive text-only operations (spinners, colour tables, progress bars) when the caller wants JSON:

if !ocobra.IsJSONOutput(cmd) {
    err := output.New().Spin(cmd.Context(), "Deploying…", func(ctx context.Context) error {
        return deploy(ctx)
    })
    _ = err
}

Rendering Markdown in Terminal Output

Many commands receive markdown content — AI responses, release notes, changelogs — and need to display it styled in the terminal. Use output.RenderMarkdown:

notes, _ := fetchReleaseNotes(version)
fmt.Print(output.RenderMarkdown(notes))

RenderMarkdown detects the terminal width automatically, applies glamour's auto-style (light/dark theme aware), and falls back to the plain string if glamour fails.

Combining Markdown and JSON Output

Use Renderer.Render when a command produces markdown for terminals and structured data for JSON consumers. Render is a no-op in JSON mode, so both calls are unconditionally safe:

func runChangelog(cmd *cobra.Command, p *props.Props) error {
    r := ocobra.NewRenderer(cmd)

    notes, meta := fetchChangelog()

    // Writes glamour-styled output in text mode; no-op in JSON mode
    if err := r.Render(notes); err != nil {
        return err
    }

    // Writes envelope in JSON mode; no-op in text mode
    return r.Emit(output.Response{
        Status:  output.StatusSuccess,
        Command: "changelog",
        Data:    meta,
    })
}

Testing Both Formats

Because the Renderer takes its writer and format as injected values, tests drive it with a bytes.Buffer — no TTY, no globals, fully parallel-safe:

func TestDeploy_JSONOutput(t *testing.T) {
    var buf bytes.Buffer

    cmd := &cobra.Command{Use: "deploy"}
    ocobra.RegisterOutputFlag(cmd)
    _ = cmd.Flags().Set("output", "json")
    cmd.SetOut(&buf)
    cmd.SetContext(context.Background())

    err := runDeploy(cmd, testProps, "staging")
    require.NoError(t, err)

    var resp output.Response
    require.NoError(t, json.Unmarshal(buf.Bytes(), &resp))
    assert.Equal(t, output.StatusSuccess, resp.Status)
    assert.Equal(t, "deploy", resp.Command)

    // Access nested data
    data, _ := json.Marshal(resp.Data)
    var result DeployResult
    require.NoError(t, json.Unmarshal(data, &result))
    assert.Equal(t, "staging", result.Environment)
}

func TestDeploy_TextOutput(t *testing.T) {
    var buf bytes.Buffer

    cmd := &cobra.Command{Use: "deploy"}
    ocobra.RegisterOutputFlag(cmd)
    cmd.SetOut(&buf)
    cmd.SetContext(context.Background())

    err := runDeploy(cmd, testProps, "staging")
    require.NoError(t, err)

    // Text mode: no JSON envelope in output
    assert.Contains(t, buf.String(), "staging")
    assert.NotContains(t, buf.String(), `"status"`)
}

Pipe the JSON output through jq to confirm it parses cleanly:

mytool deploy staging --output json | jq '.data.environment'
# "staging"

Choosing the Right Pattern

Situation Pattern
New command, has both text and data output r.Write(output.Response{...}, textFunc)
Existing command with logger/fmt text output ocobra.Emit(cmd, output.Response{...})
Command displays markdown (AI output, release notes) output.RenderMarkdown(content) or r.Render(markdown)
Need to branch on format in logic (suppress spinners) ocobra.IsJSONOutput(cmd)
Error branch in JSON-capable command ocobra.EmitError(cmd, name, err)