From c0b9323a398643836d01e021014a4e7e7ced6d3c Mon Sep 17 00:00:00 2001 From: Matthew Bauer Date: Mon, 20 Jul 2026 15:41:36 -0500 Subject: [PATCH 1/4] Add CSV format to mercury-cli output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a CSV format to mercury-cli. Some tools support CSV but not JSON. You can clean it up with jq, but it’s kind of nice for mercury-cli to natively support CSV. --- README.md | 4 ++-- pkg/cmd/cmdutil.go | 14 ++++++++++++-- pkg/cmd/overrides.go | 4 ++-- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 0f5c02d..3e4da36 100644 --- a/README.md +++ b/README.md @@ -185,8 +185,8 @@ which credential is active. - `--debug` - Enable debug logging (includes HTTP request/response details) - `--version`, `-v` - Show the CLI version - `--base-url` - Use a custom API backend URL -- `--format` - Change the output format (`auto`, `explore`, `json`, `jsonl`, `pretty`, `raw`, `yaml`) -- `--format-error` - Change the output format for errors (`auto`, `explore`, `json`, `jsonl`, `pretty`, `raw`, `yaml`) +- `--format` - Change the output format (`auto`, `csv`, `explore`, `json`, `jsonl`, `pretty`, `raw`, `yaml`) +- `--format-error` - Change the output format for errors (`auto`, `csv`, `explore`, `json`, `jsonl`, `pretty`, `raw`, `yaml`) - `--transform` - Transform the data output using [GJSON syntax](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) - `--transform-error` - Transform the error output using [GJSON syntax](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) diff --git a/pkg/cmd/cmdutil.go b/pkg/cmd/cmdutil.go index a93ad9d..1eb2979 100644 --- a/pkg/cmd/cmdutil.go +++ b/pkg/cmd/cmdutil.go @@ -30,7 +30,7 @@ import ( "github.com/urfave/cli/v3" ) -var OutputFormats = []string{"auto", "explore", "json", "jsonl", "pretty", "raw", "yaml"} +var OutputFormats = []string{"auto", "csv", "explore", "json", "jsonl", "pretty", "raw", "yaml"} var Environments = []string{"production", "sandbox"} @@ -378,6 +378,8 @@ func formatJSON(res gjson.Result, opts ShowJSONOpts) ([]byte, error) { } case "raw": return []byte(res.Raw + "\n"), nil + case "csv": + return formatCSV(res, opts.csv) case "yaml": // Prefix every document with "---" so concatenated outputs (e.g. list // commands streaming one item at a time) form a valid multi-document @@ -403,12 +405,17 @@ const warningExploreNotSupported = "Warning: Output format 'explore' not support // ShowJSONOpts configures how JSON output is displayed. type ShowJSONOpts struct { ExplicitFormat bool // true if the user explicitly passed --format - Format string // output format (auto, explore, json, jsonl, pretty, raw, yaml) + Format string // output format (auto, csv, explore, json, jsonl, pretty, raw, yaml) RawOutput bool // like jq -r: print strings without JSON quotes Stderr io.Writer // stderr for warnings; injectable for testing; defaults to os.Stderr Stdout *os.File // stdout (or pager); injectable for testing; defaults to os.Stdout Title string // display title Transform string // GJSON path to extract before displaying + + // csv holds the CSV column layout shared across per-item formatJSON calls; + // the pointer survives the by-value copies of ShowJSONOpts so streaming + // output writes one header row for the whole stream. + csv *csvState } func (o *ShowJSONOpts) setDefaults() { @@ -418,6 +425,9 @@ func (o *ShowJSONOpts) setDefaults() { if o.Stdout == nil { o.Stdout = os.Stdout } + if o.csv == nil { + o.csv = &csvState{} + } } // ShowJSON displays a single JSON result to the user. diff --git a/pkg/cmd/overrides.go b/pkg/cmd/overrides.go index e885a71..e011895 100644 --- a/pkg/cmd/overrides.go +++ b/pkg/cmd/overrides.go @@ -97,11 +97,11 @@ func init() { } case "format": if sf, ok := f.(*cli.StringFlag); ok { - sf.Usage = "Output format (auto|json|jsonl|pretty|raw|yaml|explore)" + sf.Usage = "Output format (auto|json|jsonl|pretty|raw|yaml|csv|explore)" } case "format-error": if sf, ok := f.(*cli.StringFlag); ok { - sf.Usage = "Error format (auto|json|jsonl|pretty|raw|yaml|explore)" + sf.Usage = "Error format (auto|json|jsonl|pretty|raw|yaml|csv|explore)" } case "transform": if sf, ok := f.(*cli.StringFlag); ok { From 55d977cbc37f5cf26b574cdeb889e81af2d4c8d9 Mon Sep 17 00:00:00 2001 From: Matthew Bauer Date: Mon, 20 Jul 2026 15:51:33 -0500 Subject: [PATCH 2/4] Add missing csvformat.go file --- pkg/cmd/csvformat.go | 105 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 pkg/cmd/csvformat.go diff --git a/pkg/cmd/csvformat.go b/pkg/cmd/csvformat.go new file mode 100644 index 0000000..91bed12 --- /dev/null +++ b/pkg/cmd/csvformat.go @@ -0,0 +1,105 @@ +package cmd + +// csvformat.go — Human-authored CSV output format (`--format csv`). +// +// This file is NOT generated by Stainless and will survive codegen runs. +// The format is wired into formatJSON in cmdutil.go. + +import ( + "bytes" + "encoding/csv" + + "github.com/tidwall/gjson" +) + +// csvState carries the column layout across formatJSON calls so that streaming +// display paths (ShowJSONIterator emits one item at a time) write the header +// row exactly once and keep every subsequent record aligned to it. The columns +// are derived from the first item; later items missing a column produce an +// empty cell, and fields not present in the first item are dropped. +type csvState struct { + columns []string +} + +// formatCSV renders a JSON object (one record) or array of objects (one +// record each) as CSV. Nested objects are flattened into dot-separated column +// names; arrays and other non-object values are emitted as their raw JSON. +func formatCSV(res gjson.Result, state *csvState) ([]byte, error) { + rows := []gjson.Result{res} + if res.IsArray() { + rows = res.Array() + } + + if state == nil { + state = &csvState{} + } + + var buf bytes.Buffer + w := csv.NewWriter(&buf) + for _, row := range rows { + columns, values := flattenForCSV(row) + if state.columns == nil { + state.columns = columns + if err := w.Write(state.columns); err != nil { + return nil, err + } + } + record := make([]string, len(state.columns)) + for i, column := range state.columns { + record[i] = values[column] + } + if err := w.Write(record); err != nil { + return nil, err + } + } + w.Flush() + return buf.Bytes(), w.Error() +} + +// flattenForCSV walks a single record and returns its column names in +// document order alongside the rendered value for each column. Nested objects +// contribute dot-separated columns (e.g. "details.address.city"); a +// non-object record becomes a single "value" column. +func flattenForCSV(row gjson.Result) ([]string, map[string]string) { + columns := []string{} + values := map[string]string{} + + var walk func(prefix string, obj gjson.Result) + walk = func(prefix string, obj gjson.Result) { + obj.ForEach(func(key, value gjson.Result) bool { + name := key.String() + if prefix != "" { + name = prefix + "." + name + } + if value.IsObject() { + walk(name, value) + } else { + columns = append(columns, name) + values[name] = csvValue(value) + } + return true + }) + } + + if row.IsObject() { + walk("", row) + } else { + columns = append(columns, "value") + values["value"] = csvValue(row) + } + return columns, values +} + +// csvValue renders a single JSON value as a CSV cell: strings unquoted, null +// as empty, and everything else (numbers, booleans, arrays) as raw JSON. +// Quoting and escaping are left to encoding/csv. +func csvValue(v gjson.Result) string { + switch v.Type { + case gjson.Null: + return "" + case gjson.String: + return v.Str + default: + return v.Raw + } +} From e6420e51ee2ab065ef4d4a766b3b113cbd1b2b09 Mon Sep 17 00:00:00 2001 From: Matthew Bauer Date: Mon, 20 Jul 2026 15:54:40 -0500 Subject: [PATCH 3/4] Drop unhelpful comment --- pkg/cmd/csvformat.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/pkg/cmd/csvformat.go b/pkg/cmd/csvformat.go index 91bed12..ad5a3db 100644 --- a/pkg/cmd/csvformat.go +++ b/pkg/cmd/csvformat.go @@ -1,10 +1,5 @@ package cmd -// csvformat.go — Human-authored CSV output format (`--format csv`). -// -// This file is NOT generated by Stainless and will survive codegen runs. -// The format is wired into formatJSON in cmdutil.go. - import ( "bytes" "encoding/csv" From 6c505d3f1677fce175bad7905c77b8206f8a5346 Mon Sep 17 00:00:00 2001 From: Matthew Bauer Date: Mon, 20 Jul 2026 15:55:24 -0500 Subject: [PATCH 4/4] Add test --- pkg/cmd/csvformat_test.go | 88 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 pkg/cmd/csvformat_test.go diff --git a/pkg/cmd/csvformat_test.go b/pkg/cmd/csvformat_test.go new file mode 100644 index 0000000..9054a87 --- /dev/null +++ b/pkg/cmd/csvformat_test.go @@ -0,0 +1,88 @@ +package cmd + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestFormatCSV(t *testing.T) { + t.Parallel() + + t.Run("SingleObject", func(t *testing.T) { + t.Parallel() + + res := gjson.Parse(`{"id":"abc123","amount":-12.5,"note":null}`) + formatted, err := formatJSON(res, ShowJSONOpts{Format: "csv", Stdout: os.Stdout}) + require.NoError(t, err) + require.Equal(t, "id,amount,note\nabc123,-12.5,\n", string(formatted)) + }) + + t.Run("ArrayOfObjects", func(t *testing.T) { + t.Parallel() + + res := gjson.Parse(`[{"id":"abc","name":"first"},{"id":"def","name":"second"}]`) + formatted, err := formatJSON(res, ShowJSONOpts{Format: "csv", Stdout: os.Stdout}) + require.NoError(t, err) + require.Equal(t, "id,name\nabc,first\ndef,second\n", string(formatted)) + }) + + t.Run("NestedObjectsFlattenToDottedColumns", func(t *testing.T) { + t.Parallel() + + res := gjson.Parse(`{"id":"abc","details":{"address":{"city":"Portland"},"kind":"wire"}}`) + formatted, err := formatJSON(res, ShowJSONOpts{Format: "csv", Stdout: os.Stdout}) + require.NoError(t, err) + require.Equal(t, "id,details.address.city,details.kind\nabc,Portland,wire\n", string(formatted)) + }) + + t.Run("ArraysEmittedAsRawJSON", func(t *testing.T) { + t.Parallel() + + res := gjson.Parse(`{"id":"abc","attachments":[{"url":"x"}]}`) + formatted, err := formatJSON(res, ShowJSONOpts{Format: "csv", Stdout: os.Stdout}) + require.NoError(t, err) + require.Equal(t, "id,attachments\nabc,\"[{\"\"url\"\":\"\"x\"\"}]\"\n", string(formatted)) + }) + + t.Run("QuotingAndEscaping", func(t *testing.T) { + t.Parallel() + + res := gjson.Parse(`{"note":"has, comma and \"quotes\"","bank":"plain"}`) + formatted, err := formatJSON(res, ShowJSONOpts{Format: "csv", Stdout: os.Stdout}) + require.NoError(t, err) + require.Equal(t, "note,bank\n\"has, comma and \"\"quotes\"\"\",plain\n", string(formatted)) + }) + + t.Run("MissingFieldsAlignToFirstRowColumns", func(t *testing.T) { + t.Parallel() + + res := gjson.Parse(`[{"id":"abc","note":"n1"},{"id":"def"},{"id":"ghi","note":"n3","extra":"dropped"}]`) + formatted, err := formatJSON(res, ShowJSONOpts{Format: "csv", Stdout: os.Stdout}) + require.NoError(t, err) + require.Equal(t, "id,note\nabc,n1\ndef,\nghi,n3\n", string(formatted)) + }) + + t.Run("ScalarBecomesValueColumn", func(t *testing.T) { + t.Parallel() + + res := gjson.Parse(`{"id":"abc123"}`) + formatted, err := formatJSON(res, ShowJSONOpts{Format: "csv", Stdout: os.Stdout, Transform: "id"}) + require.NoError(t, err) + require.Equal(t, "value\nabc123\n", string(formatted)) + }) + + t.Run("IteratorWritesSingleHeader", func(t *testing.T) { + t.Parallel() + + iter := &sliceIterator[map[string]any]{items: []map[string]any{ + {"id": "abc", "name": "first"}, + {"id": "def", "name": "second"}, + }} + captured := captureShowJSONIterator(t, iter, "csv", "", -1) + assert.Equal(t, "id,name\nabc,first\ndef,second\n", captured) + }) +}