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:
The Standard JSON Envelope¶
All built-in GTB commands wrap their JSON output in a standard Response envelope:
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):
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.
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:
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:
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) |
Related Documentation¶
- Output component — the extracted module and its GTB wiring
- Module docs — full guides, and the API reference on pkg.go.dev
- Adding Custom Commands — command wiring patterns
- Switch to Structured JSON Logging for Containers — complement to JSON output for daemon/container deployments