diff --git a/README.md b/README.md index 61033c4..f3fdfc3 100644 --- a/README.md +++ b/README.md @@ -216,8 +216,6 @@ Flags: - `--all` — with `--wait`, print the full results table instead of a summary - `-o, --output ` — with `--wait`, write the results to FILE (`.csv` or `.json`; format inferred from extension) -- `--stream` — emit one JSON event per line as the batch advances; implies - `--wait` and `--json` (see [NDJSON streaming](#ndjson-streaming)) - `--url ` — URL that will receive the batch results via HTTP POST - `--retries=true|false` — retry verifications when mail servers return certain responses, increasing accuracy (default: `true`) @@ -240,8 +238,6 @@ Flags: - `--all` — print the full results table inline instead of a summary - `-o, --output ` — write the results to FILE (`.csv` or `.json`; format inferred from extension) -- `--stream` — emit one JSON event per line as the batch advances; implies - `--wait` and `--json` (see [NDJSON streaming](#ndjson-streaming)) ### Account @@ -267,8 +263,7 @@ emailable account status --json Payloads pass through from the [Emailable API](https://emailable.com/docs/api/?code_language=cli) unchanged — the CLI doesn't re-shape or add fields. See the API docs for -the field reference. Error payloads and NDJSON stream events (below) are -CLI-specific. +the field reference. Error payloads are CLI-specific. ### Filtering with `--jq` @@ -283,36 +278,17 @@ emailable batch get 5cfc... --jq '.emails[] | select(.state == "deliverable") | ``` A string result is printed raw (unquoted, one per line), like `jq -r`, so it -drops straight into a script. Objects and arrays are printed as JSON. Combined -with `--stream`, the filter runs against each NDJSON event as it arrives (see -below). +drops straight into a script. Objects and arrays are printed as JSON. -### NDJSON streaming - -`batch verify --stream` and `batch get --stream` emit one JSON object per -line on stdout while polling, instead of one large object at the end. -Useful for AI agents and long-running scripts that want to react to progress -without waiting for completion. `--stream` automatically turns on `--wait` -and `--json`, so neither needs to be passed explicitly. - -```bash -emailable batch verify emails.csv --stream -``` - -``` -{"event":"submitted","id":"5cfc..."} -{"event":"progress","id":"5cfc...","processed":100,"total":1000} -{"event":"progress","id":"5cfc...","processed":500,"total":1000} -{"event":"complete","id":"5cfc...","status":"complete","reason_counts":{...},"emails":[...]} -``` - -Add `--jq` to filter each event as it streams. The filter sees the event -envelope (`event`, `id`, …), so guard on the event type; events the filter -doesn't match are skipped: +To stream batch results as [NDJSON](https://jsonlines.org/) — one result row +per line, ready to pipe into `while read`, `wc -l`, or another tool — filter +the completed batch's `emails` array with `.emails[]`. Pair it with `--wait` +so the payload is complete before filtering (a still-verifying batch has no +`emails` field, which would make `.emails[]` error): ```bash -emailable batch verify emails.csv --stream \ - --jq 'select(.event == "complete") | .emails[] | .email' +emailable batch get 5cfc... --wait --jq '.emails[]' # one row per line +emailable batch get 5cfc... --wait --jq '.emails[] | select(.state == "deliverable") | .email' ``` ### Errors diff --git a/cmd/batch.go b/cmd/batch.go index b44f38f..3203903 100644 --- a/cmd/batch.go +++ b/cmd/batch.go @@ -2,8 +2,6 @@ package cmd import ( "context" - "encoding/json" - "errors" "fmt" "io" "os" @@ -52,10 +50,8 @@ func newBatchCmd() *cobra.Command { partial, _ := cmd.Flags().GetBool("partial") outPath, _ := cmd.Flags().GetString("output") showAll, _ := cmd.Flags().GetBool("all") - stream, _ := cmd.Flags().GetBool("stream") - wait, jsonEff := applyStreamImplications(stream, wait, jsonOutput) - cctx, err := newCmdCtxFor(cmd, jsonEff) + cctx, err := newCmdCtxFor(cmd, jsonOutput) if err != nil { return err } @@ -68,14 +64,10 @@ func newBatchCmd() *cobra.Command { if partial { return fmt.Errorf("--wait and --partial can't be combined: --wait already polls until completion") } - sw := newStreamerIfEnabled(cmd, stream) - s, err := waitForCompletion(cmd.Context(), client, id, jsonEff, sw, cmd.ErrOrStderr()) + s, err := waitForCompletion(cmd.Context(), client, id, cctx.JSONMode || cctx.Quiet, cmd.ErrOrStderr()) if err != nil { return err } - if sw != nil { - return sw.emitComplete(id, s) - } return renderBatchOutcome(cmd, cctx, s, id, outPath, showAll) } @@ -90,7 +82,6 @@ func newBatchCmd() *cobra.Command { get.Flags().Bool("partial", false, "Include partial results while the batch is still verifying (batches ≤ 1,000 emails)") get.Flags().StringP("output", "o", "", "Write results to FILE (.csv or .json; format inferred from extension)") get.Flags().Bool("all", false, "Print the full results table inline instead of a summary") - get.Flags().Bool("stream", false, "Emit one JSON event per line while polling (implies --wait and --json)") verify := &cobra.Command{ Use: "verify EMAIL_OR_FILE [EMAIL_OR_FILE...]", @@ -104,19 +95,14 @@ func newBatchCmd() *cobra.Command { emailable batch verify emails.csv --wait # Verify two literal emails - emailable batch verify alice@example.com bob@example.com - - # Stream NDJSON progress events to stdout - emailable batch verify emails.csv --stream`, + emailable batch verify alice@example.com bob@example.com`, RunE: func(cmd *cobra.Command, args []string) error { field, _ := cmd.Flags().GetString("field") wait, _ := cmd.Flags().GetBool("wait") outPath, _ := cmd.Flags().GetString("output") showAll, _ := cmd.Flags().GetBool("all") - stream, _ := cmd.Flags().GetBool("stream") - wait, jsonEff := applyStreamImplications(stream, wait, jsonOutput) - cctx, err := newCmdCtxFor(cmd, jsonEff) + cctx, err := newCmdCtxFor(cmd, jsonOutput) if err != nil { return err } @@ -135,7 +121,7 @@ func newBatchCmd() *cobra.Command { return err } - f := newOutput(cmd.OutOrStdout(), jsonEff) + f := newOutput(cmd.OutOrStdout(), cctx.JSONMode) submit, err := client.SubmitBatch(cmd.Context(), emails, submitOpts) if err != nil { @@ -144,22 +130,13 @@ func newBatchCmd() *cobra.Command { if wait { // Print before polling so ctrl-c mid-wait still leaves the id visible. - if !jsonEff && !cctx.Quiet { + if !cctx.JSONMode && !cctx.Quiet { printBatchID(cmd.ErrOrStderr(), submit.ID) } - sw := newStreamerIfEnabled(cmd, stream) - if sw != nil { - if err := sw.emitSubmitted(submit.ID); err != nil { - return err - } - } - final, err := waitForCompletion(cmd.Context(), client, submit.ID, jsonEff || cctx.Quiet, sw, cmd.ErrOrStderr()) + final, err := waitForCompletion(cmd.Context(), client, submit.ID, cctx.JSONMode || cctx.Quiet, cmd.ErrOrStderr()) if err != nil { return err } - if sw != nil { - return sw.emitComplete(submit.ID, final) - } return renderBatchOutcome(cmd, cctx, final, submit.ID, outPath, showAll) } @@ -170,7 +147,6 @@ func newBatchCmd() *cobra.Command { verify.Flags().Bool("wait", false, "Poll until the batch completes") verify.Flags().StringP("output", "o", "", "Write results to FILE (.csv or .json; format inferred from extension)") verify.Flags().Bool("all", false, "Print the full results table inline instead of a summary") - verify.Flags().Bool("stream", false, "Emit one JSON event per line while polling (implies --wait and --json)") verify.Flags().String("url", "", "URL that will receive the batch results via HTTP POST") verify.Flags().Bool("retries", true, "Retry verifications when mail servers return certain responses, increasing accuracy") verify.Flags().StringSlice("response-fields", nil, "Fields to include in the response (default: all)") @@ -218,82 +194,6 @@ func printBatchID(w io.Writer, id string) { fmt.Fprintf(w, "%s %s\n", label, id) } -type batchStreamer struct { - f *output.JSON -} - -func newStreamerIfEnabled(cmd *cobra.Command, stream bool) *batchStreamer { - if !stream { - return nil - } - return &batchStreamer{f: &output.JSON{W: cmd.OutOrStdout(), Compact: true, Query: jqQuery}} -} - -func (s *batchStreamer) emit(payload map[string]any) error { - err := s.f.Print(payload) - // A --jq filter that errors on an event skips it, never aborting the stream. - var fe *output.FilterError - if errors.As(err, &fe) { - return nil - } - return err -} - -func (s *batchStreamer) emitSubmitted(id string) error { - return s.emit(map[string]any{"event": "submitted", "id": id}) -} - -func (s *batchStreamer) emitProgress(id string, processed, total int) error { - return s.emit(map[string]any{ - "event": "progress", - "id": id, - "processed": processed, - "total": total, - }) -} - -func (s *batchStreamer) emitComplete(id string, status *api.BatchStatus) error { - payload := map[string]any{ - "event": "complete", - "id": id, - } - - if raw := status.RawJSON(); len(raw) > 0 { - var fields map[string]json.RawMessage - if err := json.Unmarshal(raw, &fields); err == nil { - for k, v := range fields { - // Never let the API body shadow CLI-owned envelope keys. - if k == "event" || k == "id" { - continue - } - payload[k] = v - } - return s.emit(payload) - } - } - - if status.Status != "" { - payload["status"] = status.Status - } - if len(status.Reason) > 0 { - payload["reason_counts"] = status.Reason - } - if len(status.Emails) > 0 { - payload["emails"] = status.Emails - } - if status.DownloadFile != "" { - payload["download_file"] = status.DownloadFile - } - return s.emit(payload) -} - -func applyStreamImplications(stream, wait, jsonIn bool) (waitOut, jsonOut bool) { - if !stream { - return wait, jsonIn - } - return true, true -} - // Fast-then-slow polling: short interval for the first fastPollWindow, then back off. const ( fastPollInterval = 1 * time.Second @@ -302,11 +202,11 @@ const ( ) // waitForCompletion polls until completion. Progress goes to stderr so piped stdout stays clean. -func waitForCompletion(ctx context.Context, client *api.Client, id string, jsonMode bool, sw *batchStreamer, progressOut io.Writer) (*api.BatchStatus, error) { +func waitForCompletion(ctx context.Context, client *api.Client, id string, jsonMode bool, progressOut io.Writer) (*api.BatchStatus, error) { if progressOut == nil { progressOut = os.Stderr } - uiEnabled := !jsonMode && sw == nil + uiEnabled := !jsonMode var ( bar *ui.Bar @@ -339,10 +239,6 @@ func waitForCompletion(ctx context.Context, client *api.Client, id string, jsonM bar.Set(s.Processed, s.Total) } - if sw != nil && s.Total > 0 { - _ = sw.emitProgress(id, s.Processed, s.Total) - } - if s.IsComplete() { queueSpinner.Stop() diff --git a/cmd/batch_e2e_test.go b/cmd/batch_e2e_test.go index 34cbf78..d925876 100644 --- a/cmd/batch_e2e_test.go +++ b/cmd/batch_e2e_test.go @@ -2,7 +2,6 @@ package cmd import ( "bytes" - "encoding/json" "net/http" "os" "path/filepath" @@ -11,7 +10,6 @@ import ( "testing" "github.com/emailable/emailable-cli/internal/api" - "github.com/emailable/emailable-cli/internal/output" "github.com/spf13/cobra" ) @@ -322,171 +320,6 @@ func TestBatchGet_Wait(t *testing.T) { } } -// TestBatchVerify_StreamMode validates --stream emits NDJSON events to -// stdout. Implies --wait + --json. -func TestBatchVerify_StreamMode(t *testing.T) { - if testing.Short() { - t.Skip("polls multiple times, slow") - } - var calls int32 - env := newTestEnv(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodPost: - writeJSON(w, map[string]any{"id": "bch_stream"}) - case http.MethodGet: - n := atomic.AddInt32(&calls, 1) - if n < 2 { - writeJSON(w, map[string]any{ - "id": "bch_stream", "total": 2, "processed": 1, "status": "verifying", - }) - return - } - writeJSON(w, completedBatchPayload("bch_stream")) - } - })) - env.seedAPIKey(t, "sk_test_xxx") - - res := runRoot(t, "batch", "verify", "a@x.com", "b@y.com", "--stream") - if res.Err != nil { - t.Fatalf("execute: %v\nstderr: %s", res.Err, res.Stderr.String()) - } - lines := strings.Split(strings.TrimSpace(res.Stdout.String()), "\n") - if len(lines) < 3 { - t.Fatalf("expected at least 3 NDJSON lines (submitted, progress, complete), got: %v", lines) - } - // First line should be the `submitted` event. - first := decodeJSON(t, []byte(lines[0])) - if first["event"] != "submitted" { - t.Errorf("expected submitted event first, got %v", first) - } - // Last line should be `complete` with emails populated. - last := decodeJSON(t, []byte(lines[len(lines)-1])) - if last["event"] != "complete" { - t.Errorf("expected complete event last, got %v", last) - } -} - -// TestStream_CompleteEventPassesThroughVerbatim confirms the NDJSON `complete` -// event merges the raw batch body through unchanged: per-row nulls and the -// full total_counts survive, rather than being reconstructed from typed fields. -func TestStream_CompleteEventPassesThroughVerbatim(t *testing.T) { - if testing.Short() { - t.Skip("polls, slow") - } - env := newTestEnv(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - if r.Method == http.MethodPost { - _, _ = w.Write([]byte(`{"id":"bch_raw"}`)) - return - } - _, _ = w.Write([]byte(rawBatchPayload)) - })) - env.seedAPIKey(t, "sk_test_xxx") - - res := runRoot(t, "batch", "verify", "a@b.com", "--stream") - if res.Err != nil { - t.Fatalf("execute: %v\nstderr: %s", res.Err, res.Stderr.String()) - } - lines := strings.Split(strings.TrimSpace(res.Stdout.String()), "\n") - last := decodeJSON(t, []byte(lines[len(lines)-1])) - if last["event"] != "complete" { - t.Fatalf("expected complete event last, got %v", last) - } - tc, ok := last["total_counts"].(map[string]any) - if !ok { - t.Fatalf("expected total_counts object in complete event, got %v", last["total_counts"]) - } - if tc["duplicate"] != float64(5) { - t.Errorf("expected total_counts.duplicate=5 to pass through, got %v", tc["duplicate"]) - } - emails, ok := last["emails"].([]any) - if !ok || len(emails) != 1 { - t.Fatalf("expected 1 email in complete event, got %v", last["emails"]) - } - row := emails[0].(map[string]any) - if v, present := row["accept_all"]; !present || v != nil { - t.Errorf("expected per-row accept_all to stay null, got present=%v value=%v", present, v) - } -} - -// TestStream_CompleteEventStaysSingleLine guards the NDJSON "one object per -// line" contract when the API returns a pretty-printed body. Raw fields are -// embedded as json.RawMessage, but json.Marshal compacts marshaler output, so -// the emitted complete event must contain no embedded newlines regardless of -// how the server formatted its JSON. -func TestStream_CompleteEventStaysSingleLine(t *testing.T) { - if testing.Short() { - t.Skip("polls, slow") - } - // Deliberately indented body with real newlines inside the objects. - prettyBody := "{\n \"id\": \"bch_pretty\",\n \"emails\": [\n {\n \"email\": \"a@b.com\",\n \"state\": \"deliverable\"\n }\n ],\n \"total_counts\": {\n \"deliverable\": 1,\n \"processed\": 1,\n \"total\": 1\n }\n}" - env := newTestEnv(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - if r.Method == http.MethodPost { - _, _ = w.Write([]byte(`{"id":"bch_pretty"}`)) - return - } - _, _ = w.Write([]byte(prettyBody)) - })) - env.seedAPIKey(t, "sk_test_xxx") - - res := runRoot(t, "batch", "verify", "a@b.com", "--stream") - if res.Err != nil { - t.Fatalf("execute: %v\nstderr: %s", res.Err, res.Stderr.String()) - } - // Every non-empty stdout line must be independently parseable JSON; a - // leaked embedded newline would split one event across lines and fail here. - for _, line := range strings.Split(strings.TrimSpace(res.Stdout.String()), "\n") { - if line == "" { - continue - } - var obj map[string]any - if err := json.Unmarshal([]byte(line), &obj); err != nil { - t.Fatalf("NDJSON line is not a single valid JSON object: %v\nline: %q", err, line) - } - } -} - -// Unit-level tests for the helpers that were 0% covered: pure functions that -// don't need the httptest harness. - -func TestApplyStreamImplications(t *testing.T) { - // stream=false: both wait and json pass through unchanged. - gotWait, gotJSON := applyStreamImplications(false, true, false) - if !gotWait { - t.Errorf("stream=false, wait=true: gotWait=%v want true", gotWait) - } - if gotJSON { - t.Errorf("stream=false should not flip jsonOut, got %v", gotJSON) - } - - gotWait, gotJSON = applyStreamImplications(false, false, true) - if gotWait { - t.Errorf("stream=false, wait=false: gotWait=%v want false", gotWait) - } - if !gotJSON { - t.Errorf("stream=false should pass json through, got %v", gotJSON) - } - - // stream=true: forces both wait and json to true, regardless of inputs. - gotWait, gotJSON = applyStreamImplications(true, false, false) - if !gotWait { - t.Errorf("stream=true should force wait=true, gotWait=%v", gotWait) - } - if !gotJSON { - t.Errorf("stream=true should force jsonOut=true, gotJSON=%v", gotJSON) - } - - // And the package-level jsonOutput must NOT be mutated. - prev := jsonOutput - jsonOutput = false - t.Cleanup(func() { jsonOutput = prev }) - _, _ = applyStreamImplications(true, false, false) - if jsonOutput { - t.Errorf("applyStreamImplications must not mutate the global jsonOutput, got %v", jsonOutput) - } -} - func TestSubmitBatchOptionsFromFlags_NoneSet(t *testing.T) { verify := &cobra.Command{Use: "verify"} verify.Flags().String("url", "", "") @@ -533,68 +366,6 @@ func TestSubmitBatchOptionsFromFlags_AllSet(t *testing.T) { } } -// TestBatchStreamer_Events covers the emit* helpers end-to-end without going -// through the network path. -func TestBatchStreamer_Events(t *testing.T) { - var buf bytes.Buffer - s := &batchStreamer{f: &output.JSON{W: &buf, Compact: true}} - if err := s.emitSubmitted("bch_x"); err != nil { - t.Fatal(err) - } - if err := s.emitProgress("bch_x", 1, 10); err != nil { - t.Fatal(err) - } - if err := s.emitComplete("bch_x", &api.BatchStatus{ - Status: "complete", - Reason: map[string]int{"accepted_email": 1}, - Emails: []api.VerifyResult{{Email: "a@b.com", State: "deliverable"}}, - }); err != nil { - t.Fatal(err) - } - lines := strings.Split(strings.TrimSpace(buf.String()), "\n") - if len(lines) != 3 { - t.Fatalf("expected 3 lines, got %d: %v", len(lines), lines) - } - for i, want := range []string{"submitted", "progress", "complete"} { - var got map[string]any - if err := json.Unmarshal([]byte(lines[i]), &got); err != nil { - t.Fatalf("line %d not JSON: %v", i, err) - } - if got["event"] != want { - t.Errorf("line %d event: got %v, want %q", i, got["event"], want) - } - } -} - -// TestBatchStreamer_CompleteDownloadFile covers the large-batch branch of -// emitComplete (DownloadFile populated, Emails empty). -func TestBatchStreamer_CompleteDownloadFile(t *testing.T) { - var buf bytes.Buffer - s := &batchStreamer{f: &output.JSON{W: &buf, Compact: true}} - if err := s.emitComplete("bch_big", &api.BatchStatus{ - DownloadFile: "https://files.example/big.csv", - }); err != nil { - t.Fatal(err) - } - var got map[string]any - if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &got); err != nil { - t.Fatal(err) - } - if got["download_file"] != "https://files.example/big.csv" { - t.Errorf("expected download_file in payload, got %v", got) - } -} - -// TestNewStreamerIfEnabled returns nil when streaming is off. -func TestNewStreamerIfEnabled(t *testing.T) { - if s := newStreamerIfEnabled(&cobra.Command{}, false); s != nil { - t.Errorf("expected nil when stream=false, got %+v", s) - } - if s := newStreamerIfEnabled(&cobra.Command{}, true); s == nil { - t.Errorf("expected non-nil when stream=true") - } -} - func TestSavedMessage(t *testing.T) { cases := []struct { n int diff --git a/cmd/jq_test.go b/cmd/jq_test.go index 5dc4358..bc55bf3 100644 --- a/cmd/jq_test.go +++ b/cmd/jq_test.go @@ -88,37 +88,6 @@ func TestJQ_BadExpression(t *testing.T) { } } -func TestJQ_FiltersStreamEvents(t *testing.T) { - q := mustCompile(t, ".id") - var buf bytes.Buffer - s := &batchStreamer{f: &output.JSON{W: &buf, Compact: true, Query: q}} - - if err := s.emitSubmitted("bch_x"); err != nil { - t.Fatal(err) - } - if err := s.emitProgress("bch_x", 1, 10); err != nil { - t.Fatal(err) - } - // Both events carry an id, so each yields the raw id on its own line. - if got := buf.String(); got != "bch_x\nbch_x\n" { - t.Errorf("got %q, want %q", got, "bch_x\nbch_x\n") - } -} - -func TestJQ_StreamSkipsFilterErrors(t *testing.T) { - // .emails[] errors on a progress event (no emails); it must be skipped. - q := mustCompile(t, ".emails[]") - var buf bytes.Buffer - s := &batchStreamer{f: &output.JSON{W: &buf, Compact: true, Query: q}} - - if err := s.emitProgress("bch_x", 1, 10); err != nil { - t.Fatalf("progress event should be skipped, not error: %v", err) - } - if buf.Len() != 0 { - t.Errorf("expected no output for a skipped event, got %q", buf.String()) - } -} - func mustCompile(t *testing.T, expr string) *output.Query { t.Helper() q, err := output.CompileQuery(expr) diff --git a/skills/emailable/SKILL.md b/skills/emailable/SKILL.md index 2e26738..ff376e0 100644 --- a/skills/emailable/SKILL.md +++ b/skills/emailable/SKILL.md @@ -47,20 +47,13 @@ or a plain-text file with one address per line. Pass `-` to read newline-separated addresses from stdin. ```bash -emailable batch verify emails.csv --field email --stream +emailable batch verify emails.csv --field email --wait --json emailable batch verify a@example.com b@example.com --wait --json -cat emails.txt | emailable batch verify - --stream +cat emails.txt | emailable batch verify - --wait --json ``` -Prefer `--stream` over `--wait` when reacting to progress: it implies -both `--wait` and `--json` and emits one NDJSON event per line — -`submitted`, repeated `progress`, then `complete`: - -``` -{"event":"submitted","id":"5cfc..."} -{"event":"progress","id":"5cfc...","processed":500,"total":1000} -{"event":"complete","id":"5cfc...","status":"complete","emails":[...]} -``` +`--wait` polls until the batch finishes (a progress bar renders on +stderr in human mode), then prints the completed payload. Get the status or results of a batch later: @@ -70,6 +63,16 @@ emailable batch get --wait --json emailable batch get --output results.csv # or .json ``` +To consume results as NDJSON (one row per line), filter the `emails` +array with `--jq`. Pair with `--wait` so the payload is complete before +filtering — a still-verifying batch has no `emails` field, so `.emails[]` +would error and exit non-zero: + +```bash +emailable batch get --wait --jq '.emails[]' +emailable batch get --wait --jq '.emails[] | select(.state == "deliverable") | .email' +``` + ## Account credits ```bash @@ -114,7 +117,7 @@ around that — surface the final error instead. - Don't pass an API key on argv for everyday commands — it lands in shell history and `ps`. Use `EMAILABLE_API_KEY` or `emailable login`. -- Don't poll `batch get` in a tight loop — use `--wait` or - `--stream`, which back off correctly server-side. +- Don't poll `batch get` in a tight loop — use `--wait`, which + backs off correctly server-side. - Don't try to parse the human (non-`--json`) output — column widths, colors, and glyphs are for humans and aren't a stable interface.