Extract and redesign pkg/output into the standalone go/output module¶
- Authors
- Matt Cockayne, Claude Opus 4.8 (AI drafting assistant)
- Date
- 2026-07-21
- Status
- IMPLEMENTED (2026-07-21).
go/outputv0.1.0 published; GTB cut-over MR !278 merged (deletedpkg/output, migrated 14 call sites to the façade +ocobra). §9 open questions resolved with the maintainer: Q1 = single publicRendererfaçade (§5); Q2 = full-matrixWriterin v0.1.0; Q3 = minimal publicTheme; Q4 = keep the charm TUI stack in the core; Q5 =output/cobraoffersRegisterOutputFlag. Module coverage 95.9%; docs live atoutput.go.phpboyscout.uk. - Related
- module-extraction playbook (§5 procedure, §10/§11 findings),
workspace + changelog extraction (the most recent pure-repoint leaf batch),
extraction report (readiness checklist —
outputlisted "Ready now, with an upgrade")
1. Summary¶
pkg/output is a clean, framework-free leaf: it has zero go-tool-base
imports, uses cockroachdb/errors for errors, owns no GTB config schema and no
*FromProps adapter. By the playbook's §11.1 test ("does GTB own an adapter or
config schema on top of the package?" — no) it is a pure repoint.
Unlike the previous leaf batch (workspace, changelog — moved verbatim), output
is extracted with a deliberate redesign ("broad redesign" scope, chosen
2026-07-21). The extraction is the moment to fix accumulated design debt:
- Decouple from cobra and pflag. Today three functions (
IsJSONOutput,Emit,EmitError) take a*cobra.Commandpurely to read the--outputflag value and the output writer. The redesigned core takes plain(io.Writer, Format)— no flag-library dependency at all — and the tiny cobra binding moves to an opt-ingo/output/cobrasubpackage. - Remove hardcoded global state.
NewStatus,NewProgress,Spin,IsInteractive,RenderMarkdownreach directly foros.Stderr/os.Stdout/os.Stdinand theCIenv var. The redesign injects the writer and interactivity via functional options (defaults preserved), making every helper testable without global-state hacks. - Unify the progress-family renderers.
Status,Progress, and theSpinspinner are three independently-styled implementations (raw ANSI escapes inStatus, hand-built ASCII bars inProgress, a bubbles spinner inSpin). The redesign gives them a shared, themeable rendering seam and consistent Unicode display-width handling (ansi.StringWidth, asTableWriteralready uses). - Extend the
Formatmatrix beyond tables.TableWriteralready honoursJSON/YAML/CSV/TSV/Markdown/Text; the top-levelWriter.Writeonly handles JSON-vs-text. The redesign makesWriterhonour the full matrix.
The published module becomes a proper reusable CLI-output package usable by any tool (cobra, urfave/cli, or a raw HTTP handler), with the framework coupling isolated where it can be swapped or dropped.
2. Current state — inventory¶
pkg/output, 8 production files, ~1261 LOC (3030 incl. tests):
| File | LOC | Public surface | Notes |
|---|---|---|---|
output.go |
169 | Format, Response, Writer, NewWriter, Write, IsJSON, RenderMarkdown, Render, IsJSONOutput/Emit/EmitError |
glamour markdown render; the only cobra users |
table.go |
635 | Column, TableWriter, NewTableWriter, With* options, WriteRows |
full Format matrix; ansi.StringWidth already used |
spinner.go |
170 | Spin, SpinWithResult[T] |
bubbles/bubbletea spinner + plain fallback; Ctrl-C→context.Canceled |
status.go |
93 | Status, NewStatus, Update/Success/Warn/Fail/Done |
raw \r\033[K escapes; static iconSpin frame |
progress.go |
121 | Progress, NewProgress, Increment/IncrementBy/Done |
hand-built ASCII =/> bar |
interactive.go |
18 | IsInteractive |
CI=true OR non-TTY → non-interactive |
doc.go |
55 | — | package doc |
External deps: charm.land/{bubbles,bubbletea,glamour,lipgloss}/v2,
github.com/charmbracelet/x/{ansi,term}, github.com/cockroachdb/errors,
github.com/spf13/cobra, gopkg.in/yaml.v3.
GTB consumers (13 files, all in pkg/cmd/* + pkg/docs/):
- cobra-helper users (
Emit/IsJSONOutput):cmd/changelog,cmd/config/{get,path,unset},cmd/initialise,cmd/update. - core users (
NewWriter/RenderMarkdown/NewTableWriter/Spin/Progress/Status): the above pluscmd/config/list,cmd/docs/ask,cmd/doctor/{doctor,report},cmd/version,pkg/docs/ask.
The --output flag is a root persistent flag
(pkg/cmd/root/root.go:886 — PersistentFlags().String("output", "text", …)),
so every command already resolves format the same way.
3. Module shape & naming¶
Per the playbook §2:
| Concern | Value |
|---|---|
| Core module | gitlab.com/phpboyscout/go/output |
| Opt-in cobra binding | gitlab.com/phpboyscout/go/output/cobra (subpackage, same module) |
| GitLab project | phpboyscout/go/output |
| Docs microsite | output.go.phpboyscout.uk |
| Package name | package output (core), package cobra (subpackage) |
No branding in the fresh repo (per the workspace lesson): scaffold from the minimal shared toolkit header only — no logo/favicon/docs images.
3.1 Why a subpackage, not a companion module¶
output/cobra stays a subpackage of the same module, not a separate
output-cobra module, because it is a thin binding over the core's own types
(unlike the chat-<provider> / transport-openapi companions, which carry heavy
independent dependency graphs). cobra is lightweight and lands in the core
module's go.mod but is never compiled/linked into a consumer that does not
import the subpackage (Go package-level dead-code elimination). The
depfootprint_test.go guard asserts go list -deps . (the root package)
excludes cobra/pflag; the subpackage legitimately imports cobra and is excluded
from that assertion.
4. The Renderer façade (Q1 resolved — single public entry point)¶
The redesign's organising decision: replace the scattered constructors
(NewWriter, NewStatus, NewProgress, Spin, NewTableWriter) and the loose
cobra functions (Emit/EmitError/IsJSONOutput) with one configured
façade. The output destination, format, theme, and interactivity are
configured once on the Renderer; every method reads them, so no method
re-takes (w, format).
// Core (go/output) — no cobra, no pflag.
func New(opts ...Option) *Renderer
type Option func(*config)
func WithWriter(w io.Writer) Option // default os.Stderr
func WithFormat(f Format) Option // default FormatText
func WithInteractive(v bool) Option // default: auto-detect (TTY && !CI)
func WithTheme(t Theme) Option // default DefaultTheme (§5.3)
// Structured / envelope output — format-aware (full matrix, §5.4):
func (r *Renderer) Write(data any, textFunc func(io.Writer)) error
func (r *Renderer) Table(rows any, opts ...TableOption) error
func (r *Renderer) Render(markdown string) error // glamour
func (r *Renderer) Emit(resp Response) error // JSON envelope; no-op unless FormatJSON
func (r *Renderer) EmitError(command string, err error) error
func (r *Renderer) IsJSON() bool
// Progress family — share the Renderer's writer/theme/interactivity:
func (r *Renderer) Status() *Status
func (r *Renderer) Progress(total int, description string) *Progress
func (r *Renderer) Spin(ctx context.Context, msg string, fn func(context.Context) error) error
func SpinWithResult[T any](r *Renderer, ctx context.Context, msg string, fn func(context.Context) (T, error)) (T, error)
SpinWithResult stays a free generic function taking the *Renderer (Go methods
cannot add type parameters). IsInteractive() remains exported as the default
detector WithInteractive falls back to.
4.1 Cobra/pflag decoupling — the façade makes it trivial¶
The core façade takes no flag library. The opt-in go/output/cobra subpackage is
the only cobra user: it builds a core *Renderer from a command by reading
the two things the old helpers used — cmd.OutOrStdout() and the --output
flag.
package cobra // import ".../go/output/cobra", aliased `ocobra` at call sites
// NewRenderer builds a core Renderer: writer = cmd.OutOrStdout(),
// format = the --output flag (default FormatText), plus any extra opts.
func NewRenderer(cmd *cobra.Command, opts ...output.Option) *output.Renderer
// RegisterOutputFlag adds a consistent persistent --output flag (Q5 resolved):
// default "text", value set text,json,yaml,csv,tsv,markdown.
func RegisterOutputFlag(cmd *cobra.Command)
A GTB call site collapses from output.Emit(cmd, resp) to:
return ocobra.NewRenderer(cmd).Emit(output.Response{Status: output.StatusSuccess, Command: "update", Data: result})
Non-cobra consumers (HTTP handlers, urfave/cli, tests) call output.New(...)
directly. The depfootprint_test.go guard asserts the root package excludes
cobra/pflag; the subpackage is excluded from that assertion.
5. The redesign (broad scope)¶
5.1 Injectable writer + interactivity¶
Configured once on the Renderer (§4) via WithWriter / WithInteractive;
defaults preserve today's behaviour (os.Stderr + auto-detect). This kills
the os.Getenv("CI") / term.IsTerminal(os.Stdout.Fd()) reach-through inside the
render calls and makes every path deterministically testable — no env fiddling,
no global stdout swap.
5.2 Unified progress-family rendering + Unicode correctness¶
Status (raw \r\033[K), Progress (ASCII =/>), and Spin (bubbles) share
no rendering seam today. Behind the façade they share one internal renderer that:
- Uses
ansi-aware display width everywhere a message or bar cell is measured or truncated (matchingTableWriter), so multi-byte / wide-rune / emoji messages don't corrupt the line. - Routes line-clear/redraw through lipgloss rather than hand-written escapes.
- Gives
Status's "working" state a real animated spinner frame source (shared withSpin) instead of the staticiconSpin = "⠋".
5.3 Theming (Q3 resolved — minimal public Theme)¶
A public Theme value (icon set + lipgloss styles for success/warn/fail/spinner/
bar) with DefaultTheme, injectable via WithTheme. Replaces the package-level
spinnerStyle var and the hardcoded iconSuccess/Warn/Fail consts. Downstream
tools match their own palette from day one. Kept minimal (icons + a few styles) to
limit the pre-1.0 stability surface.
5.4 Writer honours the full Format matrix (Q2 resolved — in v0.1.0)¶
Renderer.Write honours JSON/YAML/CSV/TSV/Markdown/Text by delegating
structured formats to the same rendering core TableWriter already uses, so the
façade is format-complete and Table is a specialisation rather than the only
full-matrix path. This is the largest behavioural change; the cut-over verifies no
existing text output shifts unexpectedly.
6. Migration procedure (playbook §5 applied)¶
- Bootstrap
phpboyscout/go/outputto §3 (branding-free; cicd components at GTB's current pin v0.24.1, Go 1.26.5;depfootprint_test.goguarding the root cobra/pflag-free; nogoreleaser— library). - Move + redesign the code into the module (core +
cobra/subpackage). Carry all pure in-package tests; add tests for the new option seams and the Unicode-width paths test-first. Keep ≥90% coverage. - Docs (Diátaxis) + Pages: minimal microsite (getting-started + a how-to per
renderer family + an explanation of the format matrix);
zensical-pages; custom domain + LE cert (Pages visibility public, §11.9). - Shared transitive alignment (§10.6/§11.5): after
go mod tidy, diff every shared transitive against GTB and bump to match (expectx/sys,x/text,x/crypto,cockroachdb/errors, the charm.land set). Fold bumps into the bootstrap MR; expectosv-scannerto flag a transitive the lean graph exposes. - Cut
v0.1.0via the Release-MR flow (human-gated). - GTB cut-over (single MR,
feat): add the dependency; deletepkg/output; repoint + migrate the 13 callers to the façade — core users constructoutput.New(WithWriter(w), WithFormat(f), …)and call methods; the 6 cobra-helper sites useocobra.NewRenderer(cmd)(aliasedocobrato avoid the package-name clash withspf13/cobra) and drop their hand-rolledcmd.Flags().GetString("output"); where GTB registers--outputit may adoptocobra.RegisterOutputFlag. Runjust ci; add adocs/reference/migration/v0.x-output-extracted.mdnote (the API is redesigned, not just repointed — the migration note documents the old→new mapping for downstreams). Commit type isfeat(output):— deleting a publicpkg/is a breaking change and pre-1.0 ships as a minor;refactorwould neither bump nor changelog it (see [[feedback_cutover_commit_type]]). - GTB docs cross-reference the microsite (§4.1): stub the component page,
update the components index + README, sweep
docs/for stale references.
7. Testing, guards, coverage¶
- Pure in-package tests move with the code (they import only the package +
stdlib + testify). No cross-GTB-coupled tests exist in
pkg/output(verified: zero go-tool-base imports), so nothing stays behind — this is a clean move. - New seams (options, theme, Unicode width, full-matrix
Writer, thecobrasubpackage binding) are covered test-first to hold ≥90%. depfootprint_test.go(root pkg): forbidsgo-tool-base,spf13/viper,spf13/pflag,spf13/cobra, OTel, cloud SDKs. Thecobrasubpackage is excluded from the root assertion.- No mocks shipped unless the module's own tests need them (§11.4).
- GTB side: deleting
pkg/outputremoves it fromgo list; there is no GTB adapter package to re-cover (§11.7 — pure repoint), so no coverage backfill is needed GTB-side beyond the callers still compiling.
8. Downstream¶
afmpeg and any other GTB-based tools that import pkg/output repoint to
go/output (+ go/output/cobra where they emit JSON envelopes) as a normal
pre-1.0 import change.
9. Open questions — RESOLVED (2026-07-21)¶
All five resolved with the maintainer before implementation:
- Q1 — Unified renderer depth. → Single public
Rendererfaçade (§4). Replaces the scattered constructors and loose cobra functions with one configured entry point. Larger blast radius on GTB's 13 call sites (accepted) in exchange for a coherent, format-complete API and a trivial cobra decoupling. - Q2 — Full-matrix
Writer. → Include in v0.1.0 (§5.4). The redesign is the moment; the cut-over verifies no existing text output shifts. - Q3 — Theming public surface. → Ship a minimal public
Theme(§5.3): icons + a few lipgloss styles,DefaultTheme,WithTheme. - Q4 — charm TUI stack in core. → Keep in core for v0.1.0. The unified façade renderer leans on it anyway; splitting fights the unification. Revisit only if the graph proves heavy.
- Q5 —
--outputflag registration. →go/output/cobraoffersRegisterOutputFlag(cmd)(§4.1), defaulttext, value settext,json,yaml,csv,tsv,markdown.
10. Acceptance criteria¶
gitlab.com/phpboyscout/go/output+go/output/cobrapublished atv0.1.0; resolve from the proxy; build in a throwaway consumer (-buildvcs=false).depfootprint_test.gogreen: root package free of cobra/pflag/viper/OTel/ go-tool-base; module ≥90% coverage.- Docs live at
output.go.phpboyscout.uk(LE cert issued); landing-site row added; GTB component page stubbed and cross-referenced. - GTB cut-over MR (
feat(output):) deletespkg/output, repoints all 13 callers,just cigreen; migration note added. - The redesign items accepted in §9 are implemented and covered.