| title | Output & Rendering | ||
|---|---|---|---|
| description | Output formats, TTY detection, color and spinner gating, and rich long-op rendering in the kupe CLI | ||
| owner | platform-team | ||
| lastReviewed | 2026-04-20 | ||
| sidebar |
|
Output is the CLI's public interface. This doc specifies the formats supported, the rules that govern rich terminal affordances (colors, spinners, progress), and the stdout/stderr contract that makes the CLI safe to script.
List/get-style commands that expose -o / --output accept these values:
| Format | Purpose | TTY default | Notes |
|---|---|---|---|
table |
Human-readable columns | Yes | Default for list and get. |
wide |
table + extra columns |
No | Useful info that's noisy in the normal table. |
json |
encoding/json indent=2 |
No | Stable field order per resource. |
yaml |
yaml.v3 marshal |
No | Same schema as json. |
name |
Bare resource names, one per line | No | Pipe-friendly: kupe cluster list -o name | xargs …. |
go-template=... |
Go text/template |
No | e.g., -o go-template='{{.Status.Phase}}'. |
jsonpath=... |
Parsed for flag compatibility but currently returns a helpful not-implemented error directing users to go-template=... or -o json | jq. |
No | Deferred; see internal/printer/plain.go. |
The file-based variants kubectl supports (go-template-file=PATH,
jsonpath-file=PATH) are not yet wired to the -o flag. Pass the template
inline for now.
Toggle commands such as kupe version, kupe auth whoami, and
kupe apikey create accept only text (default) and json.
json, yaml, and name output schemas are part of the CLI's public
interface. Breaking field renames require a major version bump. New fields
can be added in minor versions (additive only).
table, wide, go-template, and error text are not guaranteed across
minor versions. Scripts should rely on json.
internal/printer/ is hand-written — the earlier plan called for importing
k8s.io/cli-runtime/pkg/genericclioptions.PrintFlags, but that library
expects runtime.Object types (DeepCopy, GVK tracking). Our resource types
are plain Go structs, so we shipped a thin in-house dispatcher instead:
printer.Parse/printer.MustParse— turns a-ostring into a*Format(MustParse wraps errors ascli.MisuseError).printer.RenderList[T]/printer.RenderOne[T]— generic helpers every command uses. One place to add a new-okind.- Per-resource
*Columns()functions live inprinter/{cluster,apikey, secret,member,tenant,invoice,plan}.go.
Each resource has a Columns() []Column function. A Column has a Name, an Extractor func(any) string, and a WideOnly bool.
Cluster (commands.md has the example output):
| Column | Source | Wide only? |
|---|---|---|
| NAME | .name |
|
| TYPE | .type |
|
| VERSION | .version |
|
| PHASE | .status.phase |
|
| HA | .status.haPhase → compact cell (off / pending / healthy (n/m) / degraded …) |
|
| CPU | .resources.cpu |
|
| MEM | .resources.memory |
|
| AGE | .createdAt → relative |
|
| ENDPOINT | .status.endpoint |
✓ |
| K8S-VERSION | .status.kubernetesVersion |
✓ |
| STORAGE | .resources.storage |
✓ |
APIKey:
| Column | Source | Wide only? |
|---|---|---|
| ID | .id (truncated to 8 chars + …) |
|
| NAME | .displayName |
|
| ROLE | .role |
|
| CREATED-BY | .createdBy |
|
| LAST-USED | .lastUsedAt → relative or never |
|
| AGE | .createdAt → relative |
|
| EXPIRES | .expiresAt → relative or never |
✓ |
| ID-FULL | .id (full UUID) |
✓ |
Secret:
| Column | Source | Wide only? |
|---|---|---|
| NAME | .name |
|
| PHASE | .status.phase |
|
| SYNCS | len(.sync) |
|
| AGE | .createdAt → relative |
|
| PATH | .secretPath |
✓ |
| CLUSTERS | deduped cluster names from .sync[].cluster, comma-joined |
✓ |
Member:
| Column | Source | Wide only? |
|---|---|---|
.email |
||
| ROLE | .role |
Invoice:
| Column | Source | Wide only? |
|---|---|---|
| NAME | .name |
|
| PHASE | .status.phase |
|
| ISSUED | .status.issuedAt |
|
| SUBTOTAL | .status.subtotal |
|
| CREDITS | .status.creditsApplied |
|
| TAX | .status.tax |
✓ |
| TOTAL (EXCL. VAT) | .status.total |
|
| CURRENCY | .status.currency |
|
| START | .billingPeriod.start |
✓ |
| END | .billingPeriod.end |
✓ |
Plan:
| Column | Source | Wide only? |
|---|---|---|
| NAME | .name |
|
| DISPLAY | .displayName or .name |
|
| FEE | .platformFee |
|
| MAX-CLUSTERS | .maxClusters |
|
| POOL | .resourcePool formatted as CPU/MEM/STORAGE |
|
| METRICS-SERIES | .observabilityPool.maxActiveSeries |
✓ |
| LOG-GB | .observabilityPool.logIngestGB |
✓ |
Column rendering uses text/tabwriter with 2-space padding. No unicode box characters in plain mode (breaks awk).
Colors are applied per-cell by the Extractor only when iostreams.ColorEnabled. Convention:
| Value | Color | Library |
|---|---|---|
Running |
green | lipgloss.Color("10") (ANSI bright green) |
Provisioning, Upgrading |
yellow | ANSI yellow |
Degraded, Terminating |
red | ANSI bright red |
Pending |
dim | ANSI faint |
The palette is defined in internal/ux/style.go. NO_COLOR or non-TTY strips colors entirely.
Implemented once in internal/cli/iostreams.go:
type IOStreams struct {
In, Out, ErrOut io.Writer // buffers in tests; os.Stdin/Stdout/Stderr in prod
stdinIsTTY bool
stdoutIsTTY bool
stderrIsTTY bool
ColorEnabled bool
SpinnersEnabled bool
PromptsEnabled bool
}
func System() *IOStreams {
// Detects once at startup using golang.org/x/term.IsTerminal(fd)
// Computes ColorEnabled / SpinnersEnabled / PromptsEnabled per the rules below.
}Rules (already referenced in design.md, reproduced here for completeness):
- ColorEnabled =
stdoutIsTTYANDNO_COLORunset AND--no-colorunset AND$TERM != "dumb". - SpinnersEnabled =
stderrIsTTYANDCIunset AND-qunset ANDKUPE_NO_PROGRESSunset. - PromptsEnabled =
stdinIsTTYANDstderrIsTTY. Destructive commands still bypass the prompt when their local--yesflag is set.
Detection happens once. The IOStreams is threaded through the factory into every command; no function ever re-detects or reads these env vars directly.
Every byte written to stdout is "data". Every byte on stderr is free-form. This contract is enforced by threading f.IOStreams.Out and f.IOStreams.ErrOut through commands and never using os.Stdout / os.Stderr directly.
| Goes to stdout | Goes to stderr |
|---|---|
Resource renders (table, json, yaml, …) |
Info/status messages (✓ Logged in as …) |
kubeconfig YAML (kupe cluster kubeconfig) |
Prompts (? Tenant:) |
ExecCredential JSON (kupe auth get-token) |
Spinners and progress |
| API key secret (one-time) | Warnings, errors, deprecations |
kupe completion <shell> |
-v debug traces |
A user doing kupe cluster list > /tmp/clusters.txt 2>/dev/null should get exactly the table (or JSON, etc.). Progress, spinners, and "✓ …" checkmarks go to /dev/null.
cluster create, cluster delete, cluster update, and explicit cluster wait use a polling loop (see architecture.md). Rendering of progress is split two ways by iostreams.SpinnersEnabled:
A standalone Bubbletea program runs during the wait. It owns one line of the terminal (stderr). The model is minimal:
type model struct {
spinner spinner.Model
startedAt time.Time
phase string
last time.Time
}
func (m model) View() string {
elapsed := humaniseElapsed(time.Since(m.startedAt))
return fmt.Sprintf("%s %s [%s] %s",
m.spinner.View(),
PhaseStyle(m.phase).Render(m.phase),
elapsed,
m.label,
)
}A tea.Cmd polls immediately and then with exponential backoff from 2s to 10s, returning a phase/done/error result. On a terminal phase (Running / gone / Degraded), the program quits. The wait returns and the normal command output resumes.
bubbles/spinner.Dot is the default frame style. There is currently no env
var to pick a different style — the spinner is a single constant. If that
turns out to matter (e.g., a terminal that renders dots poorly), we'll add
a KUPE_SPINNER knob at that point.
One newline-terminated status line per phase transition, on stderr, no ANSI:
[00:00] Pending
[00:04] Provisioning
[02:04] Running
[02:04] cluster prod ready
The timestamp is elapsed time from the command start (not wall-clock), so CI log scrollback stays useful even across replays. Lines are flushed immediately — no buffering.
If the phase hasn't changed between polls, no line is emitted. This keeps CI logs reasonable for a 15-minute cluster upgrade (~10 lines, not 450).
The command's context.Context is canceled by the Cobra signal handler. The waiter exits cleanly:
^C
Error: stopped waiting; cluster "prod" is still being created on Kupe Cloud
check status: kupe cluster get prod
resume wait: kupe cluster wait prod
abandon: kupe cluster delete prod
Exit code is 130 (standard Unix 128 + SIGINT). The cluster keeps provisioning — it's the operator's job, not the CLI's. This matches kubectl apply -f and gh pr merge --auto behavior.
Errors go through internal/cli/exit.go. For table output (default):
Error: cluster "prod" not found
run "kupe cluster list" to see available clusters
(request-id: 7a3b9e41-abcd-4567-8901-abcdef123456)
Body:
- Line 1:
Error: <message> - Indented hint lines: classification-specific guidance.
- Indented
(request-id: ...)when the error carries one.
Errors are always rendered as text today, even when -o json is set. Scripts should parse stdout only on success and use the process exit code for failure classification.
Commands that can provide a concrete next step attach one or more indented hint lines below the main message. API errors that are returned directly may only include the server message and request ID.
| Class | Hint |
|---|---|
Unauthorized |
run "kupe auth login" to re-authenticate |
Forbidden |
your API key has role "readonly"; ask an admin for write access |
NotFound |
run "kupe <resource> list" to see available resources |
Validation |
run "kupe <cmd> --help" for flag reference |
Conflict |
another caller modified the resource; retry (for 412) |
a resource with this name already exists (for 409) |
|
RateLimited |
you've been rate limited; retry in <Retry-After> |
Unavailable |
the resource is not yet ready; use "kupe cluster wait <name>" |
| Timeout | the operation did not complete before --wait-timeout; use "kupe cluster get <name>" to check status |
- Disables the animated spinner path by setting
SpinnersEnabled=false. - Does not change stdout.
-o jsonstill produces JSON;tablestill produces a table. - Does not bypass confirmation prompts; destructive commands still require
--yesin non-TTY usage.
Current long-running commands fall back to the plain stderr progress renderer when spinners are disabled.
- Enables debug logging to stderr.
- Logs: HTTP method, path, status, duration, and request ID for each round trip.
- Never logs tokens,
Authorizationheaders, or full request/response bodies.
-v -v reserved for future expansion (trace-level). For now -v only has one level.
Large outputs (cluster list across many clusters, get -o yaml of big objects) do not auto-page. Users pipe to less themselves:
kupe cluster list | lessReasons:
- Detecting a TTY + user preference is fragile (
$PAGERunset,$LESSweirdness, interactions withCI=true). - Scripts that happen to run on a TTY (interactive shell) should not silently invoke a pager.
A future --pager flag could opt in; not scoped for v1.
All user-facing strings are English in v1. No i18n package, no message catalogs. If we localize later, the stdout data format stays English (scripts depend on it); only stderr status/prompts are translated.