From aad810e7b632dd112c095cf2d428c23ee4a96a99 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 18:09:47 +0000 Subject: [PATCH 01/37] chore(internal): codegen related update --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 3e53cf4..ca86287 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/charmbracelet/bubbletea v1.3.6 github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/x/term v0.2.1 - github.com/deeptable-com/deeptable-go v0.0.2-beta + github.com/deeptable-com/deeptable-go v0.1.0-beta.1 github.com/goccy/go-yaml v1.18.0 github.com/itchyny/json2yaml v0.1.4 github.com/muesli/reflow v0.3.0 diff --git a/go.sum b/go.sum index 8d7b796..46fc55e 100644 --- a/go.sum +++ b/go.sum @@ -22,8 +22,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHH github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/deeptable-com/deeptable-go v0.0.2-beta h1:6DLb/If0Ylrro/OMaZhuC5Em10HxoPDE73RE8npZDFo= -github.com/deeptable-com/deeptable-go v0.0.2-beta/go.mod h1:fIo2owpf3vsy9vMv3XyFe4ahUcPAjKtutJEobZ8R/jI= +github.com/deeptable-com/deeptable-go v0.1.0-beta.1 h1:MP6rhTpCBYQTGX3uxCUWNIpTh2ufYX9Vq71X7XljvP8= +github.com/deeptable-com/deeptable-go v0.1.0-beta.1/go.mod h1:fIo2owpf3vsy9vMv3XyFe4ahUcPAjKtutJEobZ8R/jI= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= From 428cb27be73809d9bb17a02134a302a08a0f9520 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 24 Mar 2026 05:17:59 +0000 Subject: [PATCH 02/37] chore(internal): update gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index e1e83aa..64ff022 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .prism.log +.stdy.log dist/ /deeptable *.exe From 0063fc108a77d22495846cc6106592c22761792c Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 24 Mar 2026 05:23:22 +0000 Subject: [PATCH 03/37] fix: cli no longer hangs when stdin is attached to a pipe with empty input --- pkg/cmd/cmdutil.go | 30 ++++++++++++++++++++++++++++-- pkg/cmd/cmdutil_unix.go | 11 +++++++++++ pkg/cmd/cmdutil_windows.go | 26 +++++++++++++++++++++++++- 3 files changed, 64 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/cmdutil.go b/pkg/cmd/cmdutil.go index 954389e..0700288 100644 --- a/pkg/cmd/cmdutil.go +++ b/pkg/cmd/cmdutil.go @@ -70,9 +70,35 @@ var debugMiddlewareOption = option.WithMiddleware( }, ) +// isInputPiped tries to check for input being piped into the CLI which tells us that we should try to read +// from stdin. This can be a bit tricky in some cases like when an stdin is connected to a pipe but nothing is +// being piped in (this may happen in some environments like Cursor's integration terminal or CI), which is +// why this function is a little more elaborate than it'd be otherwise. func isInputPiped() bool { - stat, _ := os.Stdin.Stat() - return (stat.Mode() & os.ModeCharDevice) == 0 + stat, err := os.Stdin.Stat() + if err != nil { + return false + } + + mode := stat.Mode() + + // Regular file (redirect like < file.txt) — only if non-empty. + // + // Notably, on Unix the case like `< /dev/null` is handled below because `/dev/null` is not a regular + // file. On Windows, NUL appears as a regular file with size 0, so it's also handled correctly. + if mode.IsRegular() && stat.Size() > 0 { + return true + } + + // For pipes/sockets (e.g. `echo foo | stainlesscli`), use an OS-specific check to determine whether + // data is actually available. Some environments like Cursor's integrated terminal connect stdin as a + // pipe even when nothing is being piped. + if mode&(os.ModeNamedPipe|os.ModeSocket) != 0 { + // Defined in either cmdutil_unix.go or cmdutil_windows.go. + return isPipedDataAvailableOSSpecific() + } + + return false } func isTerminal(w io.Writer) bool { diff --git a/pkg/cmd/cmdutil_unix.go b/pkg/cmd/cmdutil_unix.go index f4a0e5c..edefcd7 100644 --- a/pkg/cmd/cmdutil_unix.go +++ b/pkg/cmd/cmdutil_unix.go @@ -12,6 +12,17 @@ import ( "golang.org/x/sys/unix" ) +func isPipedDataAvailableOSSpecific() bool { + // Try to determine if there's non-empty data being piped into the command by polling for data for a short + // amount of time. This is necessary because some environments (e.g. Cursor's integrated terminal) connect + // stdin as a pipe even when nothing is being piped, which would cause the command to block indefinitely + // waiting for input that will never come. The 10 ms timeout is arbitrary -- designed to be long enough to + // allow data to be detected, but short enough that it shouldn't cause a noticeable delay in command runs. + fds := []unix.PollFd{{Fd: int32(os.Stdin.Fd()), Events: unix.POLLIN}} + n, _ := unix.Poll(fds, 10 /* ms */) + return n > 0 +} + func streamOutputOSSpecific(label string, generateOutput func(w *os.File) error) error { // Try to use socket pair for better buffer control pagerInput, pid, err := openSocketPairPager(label) diff --git a/pkg/cmd/cmdutil_windows.go b/pkg/cmd/cmdutil_windows.go index 608adb7..49b025e 100644 --- a/pkg/cmd/cmdutil_windows.go +++ b/pkg/cmd/cmdutil_windows.go @@ -2,7 +2,31 @@ package cmd -import "os" +import ( + "os" + "syscall" + "unsafe" +) + +var ( + kernel32 = syscall.NewLazyDLL("kernel32.dll") + procPeekNamedPipe = kernel32.NewProc("PeekNamedPipe") +) + +func isPipedDataAvailableOSSpecific() bool { + // On Windows, unix.Poll is not available. Use PeekNamedPipe to check if data is available + // on the pipe without consuming it. + var available uint32 + r, _, _ := procPeekNamedPipe.Call( + os.Stdin.Fd(), + 0, + 0, + 0, + uintptr(unsafe.Pointer(&available)), + 0, + ) + return r != 0 && available > 0 +} func streamOutputOSSpecific(label string, generateOutput func(w *os.File) error) error { // We have a trick with sockets that we use when possible on Unix-like systems. Those APIs aren't From 0be1028ca0f24e44cd5fe74b046ad514b47d08a4 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 03:32:30 +0000 Subject: [PATCH 04/37] chore(ci): skip lint on metadata-only changes Note that we still want to run tests, as these depend on the metadata. --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0110e1a..ec5e177 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: timeout-minutes: 10 name: lint runs-on: ${{ github.repository == 'stainless-sdks/deeptable-cli' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} - if: github.event_name == 'push' || github.event.pull_request.head.repo.fork + if: (github.event_name == 'push' || github.event.pull_request.head.repo.fork) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata') steps: - uses: actions/checkout@v6 @@ -49,7 +49,7 @@ jobs: contents: read id-token: write runs-on: ${{ github.repository == 'stainless-sdks/deeptable-cli' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} - if: github.event_name == 'push' || github.event.pull_request.head.repo.fork + if: (github.event_name == 'push' || github.event.pull_request.head.repo.fork) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata') steps: - uses: actions/checkout@v6 From d5a50f395b1a844f07824fb1f2e4bdfcd22e354c Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 06:50:56 +0000 Subject: [PATCH 05/37] feat: set CLI flag constant values automatically where `x-stainless-const` is set --- internal/requestflag/requestflag.go | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/internal/requestflag/requestflag.go b/internal/requestflag/requestflag.go index 21a8a69..32c13f5 100644 --- a/internal/requestflag/requestflag.go +++ b/internal/requestflag/requestflag.go @@ -37,6 +37,12 @@ type Flag[ BodyPath string // location in the request body to put this flag's value BodyRoot bool // if true, then use this value as the entire request body + // Const, when true, marks this flag as a constant. The flag's Default value is used as the fixed value + // and always included in the request (IsSet returns true). The user can still see and override the flag, + // but isn't required to provide it. This is used for single-value enums and `x-stainless-const` + // parameters. + Const bool + // unexported fields for internal use count int // number of times the flag has been set hasBeenSet bool // whether the flag has been set from env or file @@ -229,7 +235,7 @@ func (f *Flag[T]) String() string { } func (f *Flag[T]) IsSet() bool { - return f.hasBeenSet + return f.hasBeenSet || f.Const } func (f *Flag[T]) Names() []string { @@ -255,6 +261,10 @@ func (f *Flag[T]) SetCategory(c string) { var _ cli.RequiredFlag = (*Flag[any])(nil) // Type assertion to ensure interface compliance func (f *Flag[T]) IsRequired() bool { + // Const flags are always auto-set, so never required from the user. + if f.Const { + return false + } // Intentionally don't use `f.Required`, because request flags may be passed // over stdin as well as by flag. if f.BodyPath != "" || f.BodyRoot { @@ -268,6 +278,10 @@ type RequiredFlagOrStdin interface { } func (f *Flag[T]) IsRequiredAsFlagOrStdin() bool { + // Const flags are always auto-set, so never required from the user. + if f.Const { + return false + } return f.Required } From 33fb84c343af94a2a673652f9da1655f9ea20d2e Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 06:51:23 +0000 Subject: [PATCH 06/37] chore: omit full usage information when missing required CLI parameters --- pkg/cmd/flagoptions.go | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/pkg/cmd/flagoptions.go b/pkg/cmd/flagoptions.go index 0112b79..4252a8f 100644 --- a/pkg/cmd/flagoptions.go +++ b/pkg/cmd/flagoptions.go @@ -246,17 +246,15 @@ func flagOptions( } if missingFlags := requestflag.GetMissingRequiredFlags(cmd, requestContents.Body); len(missingFlags) > 0 { - var buf bytes.Buffer - cli.HelpPrinter(&buf, cli.SubcommandHelpTemplate, cmd) - usage := buf.String() if len(missingFlags) == 1 { - return nil, fmt.Errorf("%sRequired flag %q not set", usage, missingFlags[0].Names()[0]) + return nil, fmt.Errorf("Required flag %q not set\nRun '%s --help' for usage information", missingFlags[0].Names()[0], cmd.FullName()) + } else { names := []string{} for _, flag := range missingFlags { names = append(names, flag.Names()[0]) } - return nil, fmt.Errorf("%sRequired flags %q not set", usage, strings.Join(names, ", ")) + return nil, fmt.Errorf("Required flags %q not set\nRun '%s --help' for usage information", strings.Join(names, ", "), cmd.FullName()) } } From 6241318a939febb87359001b98fe890de2797099 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 28 Mar 2026 07:42:43 +0000 Subject: [PATCH 07/37] chore(internal): codegen related update --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index ca86287..941a0c4 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/charmbracelet/bubbletea v1.3.6 github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/x/term v0.2.1 - github.com/deeptable-com/deeptable-go v0.1.0-beta.1 + github.com/deeptable-com/deeptable-go v0.1.0-beta.2 github.com/goccy/go-yaml v1.18.0 github.com/itchyny/json2yaml v0.1.4 github.com/muesli/reflow v0.3.0 diff --git a/go.sum b/go.sum index 46fc55e..be24130 100644 --- a/go.sum +++ b/go.sum @@ -22,8 +22,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHH github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/deeptable-com/deeptable-go v0.1.0-beta.1 h1:MP6rhTpCBYQTGX3uxCUWNIpTh2ufYX9Vq71X7XljvP8= -github.com/deeptable-com/deeptable-go v0.1.0-beta.1/go.mod h1:fIo2owpf3vsy9vMv3XyFe4ahUcPAjKtutJEobZ8R/jI= +github.com/deeptable-com/deeptable-go v0.1.0-beta.2 h1:5NL7fBmBW/47JP1nq18I69o1wl7WjKTXpISWlcHZUFg= +github.com/deeptable-com/deeptable-go v0.1.0-beta.2/go.mod h1:fIo2owpf3vsy9vMv3XyFe4ahUcPAjKtutJEobZ8R/jI= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= From d7142f9c3552886983d8590e9b47df4b774ffb49 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 28 Mar 2026 07:47:47 +0000 Subject: [PATCH 08/37] fix: fix for off-by-one error in pagination logic --- pkg/cmd/cmdutil.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/pkg/cmd/cmdutil.go b/pkg/cmd/cmdutil.go index 0700288..e7b251c 100644 --- a/pkg/cmd/cmdutil.go +++ b/pkg/cmd/cmdutil.go @@ -378,6 +378,7 @@ type HasRawJSON interface { // For an iterator over different value types, display its values to the user in // different formats. +// -1 is used to signal no limit of items to display func ShowJSONIterator[T any](stdout *os.File, title string, iter jsonview.Iterator[T], format string, transform string, itemsToDisplay int64) error { if format == "explore" { return jsonview.ExploreJSONStream(title, iter) @@ -393,10 +394,8 @@ func ShowJSONIterator[T any](stdout *os.File, title string, iter jsonview.Iterat usePager := false output := []byte{} numberOfNewlines := 0 - for iter.Next() { - if itemsToDisplay == 0 { - break - } + // -1 is used to signal no limit of items to display + for itemsToDisplay != 0 && iter.Next() { item := iter.Current() var obj gjson.Result if hasRaw, ok := any(item).(HasRawJSON); ok { From 76e8aa73ea6dbaceb088b835888f26688dc6f360 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 05:35:21 +0000 Subject: [PATCH 09/37] fix: handle empty data set using `--format explore` --- internal/jsonview/explorer.go | 4 ++++ internal/jsonview/explorer_test.go | 37 ++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 internal/jsonview/explorer_test.go diff --git a/internal/jsonview/explorer.go b/internal/jsonview/explorer.go index 055541e..ea900bc 100644 --- a/internal/jsonview/explorer.go +++ b/internal/jsonview/explorer.go @@ -406,6 +406,10 @@ func (v *JSONViewer) navigateForward() (tea.Model, tea.Cmd) { return v, nil } + if len(tableView.rowData) < 1 { + return v, nil + } + cursor := tableView.table.Cursor() selected := tableView.rowData[cursor] if !v.canNavigateInto(selected) { diff --git a/internal/jsonview/explorer_test.go b/internal/jsonview/explorer_test.go new file mode 100644 index 0000000..3f0e751 --- /dev/null +++ b/internal/jsonview/explorer_test.go @@ -0,0 +1,37 @@ +package jsonview + +import ( + "testing" + + "github.com/charmbracelet/bubbles/help" + "github.com/tidwall/gjson" +) + +func TestNavigateForward_EmptyRowData(t *testing.T) { + // An empty JSON array produces a TableView with no rows. + emptyArray := gjson.Parse("[]") + view, err := newTableView("", emptyArray, false) + if err != nil { + t.Fatalf("newTableView: %v", err) + } + + viewer := &JSONViewer{ + stack: []JSONView{view}, + root: "test", + help: help.New(), + } + + // Should return without panicking despite the empty data set. + model, cmd := viewer.navigateForward() + if model != viewer { + t.Error("expected same viewer model returned") + } + if cmd != nil { + t.Error("expected nil cmd") + } + + // Stack should remain unchanged (no new view pushed). + if len(viewer.stack) != 1 { + t.Errorf("expected stack length 1, got %d", len(viewer.stack)) + } +} From 0806fe8ed9e8355d4f06f028de673e541d091105 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 05:35:35 +0000 Subject: [PATCH 10/37] fix: use `RawJSON` when iterating items with `--format explore` in the CLI --- internal/jsonview/explorer.go | 34 ++++++++++++++++++++-- internal/jsonview/explorer_test.go | 45 ++++++++++++++++++++++-------- pkg/cmd/cmdutil.go | 6 ++-- 3 files changed, 68 insertions(+), 17 deletions(-) diff --git a/internal/jsonview/explorer.go b/internal/jsonview/explorer.go index ea900bc..836bb2c 100644 --- a/internal/jsonview/explorer.go +++ b/internal/jsonview/explorer.go @@ -1,6 +1,7 @@ package jsonview import ( + "bytes" "encoding/json" "errors" "fmt" @@ -309,6 +310,10 @@ func ExploreJSON(title string, json gjson.Result) error { return err } +type hasRawJSON interface { + RawJSON() string +} + // ExploreJSONStream explores JSON data loaded incrementally via an iterator func ExploreJSONStream[T any](title string, it Iterator[T]) error { anyIt := genericToAnyIterator(it) @@ -327,12 +332,12 @@ func ExploreJSONStream[T any](title string, it Iterator[T]) error { return err } - // Convert items to JSON array - jsonBytes, err := json.Marshal(items) + arrayJSONBytes, err := marshalItemsToJSONArray(items) if err != nil { return err } - arrayJSON := gjson.ParseBytes(jsonBytes) + + arrayJSON := gjson.ParseBytes(arrayJSONBytes) view, err := newTableView("", arrayJSON, false) if err != nil { return err @@ -352,6 +357,29 @@ func ExploreJSONStream[T any](title string, it Iterator[T]) error { return err } +func marshalItemsToJSONArray(items []any) ([]byte, error) { + var buf bytes.Buffer + buf.WriteByte('[') + + for i, item := range items { + if i > 0 { + buf.WriteByte(',') + } + if hasRaw, ok := item.(hasRawJSON); ok { + buf.WriteString(hasRaw.RawJSON()) + } else { + jsonData, err := json.Marshal(item) + if err != nil { + return nil, err + } + buf.Write(jsonData) + } + } + + buf.WriteByte(']') + return buf.Bytes(), nil +} + func (v *JSONViewer) current() JSONView { return v.stack[len(v.stack)-1] } func (v *JSONViewer) Init() tea.Cmd { return nil } diff --git a/internal/jsonview/explorer_test.go b/internal/jsonview/explorer_test.go index 3f0e751..c559254 100644 --- a/internal/jsonview/explorer_test.go +++ b/internal/jsonview/explorer_test.go @@ -5,15 +5,15 @@ import ( "github.com/charmbracelet/bubbles/help" "github.com/tidwall/gjson" + + "github.com/stretchr/testify/require" ) func TestNavigateForward_EmptyRowData(t *testing.T) { // An empty JSON array produces a TableView with no rows. emptyArray := gjson.Parse("[]") view, err := newTableView("", emptyArray, false) - if err != nil { - t.Fatalf("newTableView: %v", err) - } + require.NoError(t, err) viewer := &JSONViewer{ stack: []JSONView{view}, @@ -23,15 +23,38 @@ func TestNavigateForward_EmptyRowData(t *testing.T) { // Should return without panicking despite the empty data set. model, cmd := viewer.navigateForward() - if model != viewer { - t.Error("expected same viewer model returned") - } - if cmd != nil { - t.Error("expected nil cmd") - } + require.Equal(t, model, viewer, "expected same viewer model returned") + require.Nil(t, cmd) // Stack should remain unchanged (no new view pushed). - if len(viewer.stack) != 1 { - t.Errorf("expected stack length 1, got %d", len(viewer.stack)) + require.Equal(t, 1, len(viewer.stack), "expected stack length 1, got %d", len(viewer.stack)) +} + +// rawJSONItem implements HasRawJSON, returning pre-built JSON. +type rawJSONItem struct { + raw string +} + +func (r rawJSONItem) RawJSON() string { return r.raw } + +func TestMarshalItemsToJSONArray_WithHasRawJSON(t *testing.T) { + items := []any{ + rawJSONItem{raw: `{"id":1,"name":"alice"}`}, + rawJSONItem{raw: `{"id":2,"name":"bob"}`}, } + + got, err := marshalItemsToJSONArray(items) + require.NoError(t, err) + require.JSONEq(t, `[{"id":1,"name":"alice"},{"id":2,"name":"bob"}]`, string(got)) +} + +func TestMarshalItemsToJSONArray_WithoutHasRawJSON(t *testing.T) { + items := []any{ + map[string]any{"id": 1, "name": "alice"}, + map[string]any{"id": 2, "name": "bob"}, + } + + got, err := marshalItemsToJSONArray(items) + require.NoError(t, err) + require.JSONEq(t, `[{"id":1,"name":"alice"},{"id":2,"name":"bob"}]`, string(got)) } diff --git a/pkg/cmd/cmdutil.go b/pkg/cmd/cmdutil.go index e7b251c..b890266 100644 --- a/pkg/cmd/cmdutil.go +++ b/pkg/cmd/cmdutil.go @@ -372,7 +372,7 @@ func countTerminalLines(data []byte, terminalWidth int) int { return bytes.Count([]byte(wrap.String(string(data), terminalWidth)), []byte("\n")) } -type HasRawJSON interface { +type hasRawJSON interface { RawJSON() string } @@ -398,7 +398,7 @@ func ShowJSONIterator[T any](stdout *os.File, title string, iter jsonview.Iterat for itemsToDisplay != 0 && iter.Next() { item := iter.Current() var obj gjson.Result - if hasRaw, ok := any(item).(HasRawJSON); ok { + if hasRaw, ok := any(item).(hasRawJSON); ok { obj = gjson.Parse(hasRaw.RawJSON()) } else { jsonData, err := json.Marshal(item) @@ -445,7 +445,7 @@ func ShowJSONIterator[T any](stdout *os.File, title string, iter jsonview.Iterat } item := iter.Current() var obj gjson.Result - if hasRaw, ok := any(item).(HasRawJSON); ok { + if hasRaw, ok := any(item).(hasRawJSON); ok { obj = gjson.Parse(hasRaw.RawJSON()) } else { jsonData, err := json.Marshal(item) From 482d722a794806e5c60ab124dee0459170397bc9 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 05:48:25 +0000 Subject: [PATCH 11/37] feat: binary-only parameters become CLI flags that take filenames only --- internal/requestflag/requestflag.go | 10 +++ pkg/cmd/file.go | 9 +-- pkg/cmd/file_test.go | 8 ++- pkg/cmd/flagoptions.go | 96 +++++++++++++++++++++++++++++ 4 files changed, 117 insertions(+), 6 deletions(-) diff --git a/internal/requestflag/requestflag.go b/internal/requestflag/requestflag.go index 32c13f5..bdef64f 100644 --- a/internal/requestflag/requestflag.go +++ b/internal/requestflag/requestflag.go @@ -43,6 +43,11 @@ type Flag[ // parameters. Const bool + // FileInput, when true, indicates that the flag value is always treated as a file path. The file is read + // automatically without requiring the "@" prefix. This is used for parameters with `type: string, format: + // binary` in the OpenAPI spec. + FileInput bool + // unexported fields for internal use count int // number of times the flag has been set hasBeenSet bool // whether the flag has been set from env or file @@ -59,6 +64,7 @@ type InRequest interface { GetHeaderPath() string GetBodyPath() string IsBodyRoot() bool + IsFileInput() bool } func (f Flag[T]) GetQueryPath() string { @@ -77,6 +83,10 @@ func (f Flag[T]) IsBodyRoot() bool { return f.BodyRoot } +func (f Flag[T]) IsFileInput() bool { + return f.FileInput +} + // The values that will be sent in different parts of a request. type RequestContents struct { Queries map[string]any diff --git a/pkg/cmd/file.go b/pkg/cmd/file.go index 412bde8..e5b37a6 100644 --- a/pkg/cmd/file.go +++ b/pkg/cmd/file.go @@ -96,10 +96,11 @@ var filesUpload = cli.Command{ Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ - Name: "file", - Usage: "The spreadsheet file to upload", - Required: true, - BodyPath: "file", + Name: "file", + Usage: "The spreadsheet file to upload", + Required: true, + BodyPath: "file", + FileInput: true, }, }, Action: handleFilesUpload, diff --git a/pkg/cmd/file_test.go b/pkg/cmd/file_test.go index 6869b45..bd02230 100644 --- a/pkg/cmd/file_test.go +++ b/pkg/cmd/file_test.go @@ -3,6 +3,7 @@ package cmd import ( + "strings" "testing" "github.com/deeptable-com/deeptable-cli/internal/mocktest" @@ -66,13 +67,16 @@ func TestFilesUpload(t *testing.T) { t, "--api-key", "string", "files", "upload", - "--file", "Example data", + "--file", mocktest.TestFile(t, "Example data"), ) }) t.Run("piping data", func(t *testing.T) { + testFile := mocktest.TestFile(t, "Example data") // Test piping YAML data over stdin - pipeData := []byte("file: Example data") + pipeDataStr := "file: Example data" + pipeDataStr = strings.ReplaceAll(pipeDataStr, "Example data", testFile) + pipeData := []byte(pipeDataStr) mocktest.TestRunMockTestWithPipeAndFlags( t, pipeData, "--api-key", "string", diff --git a/pkg/cmd/flagoptions.go b/pkg/cmd/flagoptions.go index 4252a8f..1d3a010 100644 --- a/pkg/cmd/flagoptions.go +++ b/pkg/cmd/flagoptions.go @@ -98,6 +98,21 @@ func embedFilesValue(v reflect.Value, embedStyle FileEmbedStyle) (reflect.Value, return result, nil case reflect.String: + // FilePathValue is always treated as a file path without needing the "@" prefix. + // These only appear on binary upload parameters (multipart/octet-stream), which + // always use EmbedIOReader. + if v.Type() == reflect.TypeOf(FilePathValue("")) { + s := v.String() + if s == "" { + return v, nil + } + content, err := os.ReadFile(s) + if err != nil { + return v, err + } + return reflect.ValueOf(string(content)), nil + } + s := v.String() if literal, ok := strings.CutPrefix(s, "\\@"); ok { // Allow for escaped @ signs if you don't want them to be treated as files @@ -258,6 +273,12 @@ func flagOptions( } } + // For flags marked as FileInput (type: string, format: binary), the value is always + // a file path. Wrap with FilePathValue so embedFiles reads the file automatically + // without requiring the user to type the "@" prefix. This handles both values set + // via explicit CLI flags and values that arrived via piped YAML/JSON data. + wrapFileInputValues(cmd, &requestContents) + // Embed files passed as "@file.jpg" in the request body, headers, and query: embedStyle := EmbedText if bodyType == ApplicationOctetStream || bodyType == MultipartFormEncoded { @@ -371,3 +392,78 @@ func flagOptions( return options, nil } + +// FilePathValue is a string wrapper that marks a value as a file path whose contents should be read +// and embedded in the request. Unlike a regular string, embedFilesValue always treats a FilePathValue +// as a file path without needing the "@" prefix. +type FilePathValue string + +// wrapFileInputValues replaces string values for FileInput flags (type: string, format: binary) with +// FilePathValue sentinel values. embedFilesValue recognizes FilePathValue and reads the file contents +// directly, so the user doesn't need to type the "@" prefix. This handles both values set via explicit +// CLI flags and values that arrived via piped YAML/JSON data. +func wrapFileInputValues(cmd *cli.Command, contents *requestflag.RequestContents) { + bodyMap, _ := contents.Body.(map[string]any) + + for _, flag := range cmd.Flags { + inReq, ok := flag.(requestflag.InRequest) + if !ok || !inReq.IsFileInput() || inReq.IsBodyRoot() { + continue + } + + // Wrap values set via explicit CLI flags. + if flag.IsSet() { + if wrapped, changed := wrapFileInputValue(flag.Get()); changed { + if bodyPath := inReq.GetBodyPath(); bodyPath != "" { + if bodyMap != nil { + bodyMap[bodyPath] = wrapped + } + } else if queryPath := inReq.GetQueryPath(); queryPath != "" { + contents.Queries[queryPath] = wrapped + } else if headerPath := inReq.GetHeaderPath(); headerPath != "" { + contents.Headers[headerPath] = wrapped + } + } + } + + // Wrap values that arrived via piped YAML/JSON data in the body map. + if bodyPath := inReq.GetBodyPath(); bodyPath != "" && bodyMap != nil { + if value, exists := bodyMap[bodyPath]; exists { + if wrapped, changed := wrapFileInputValue(value); changed { + bodyMap[bodyPath] = wrapped + } + } + } + } +} + +func wrapFileInputValue(value any) (any, bool) { + switch v := value.(type) { + case string: + if v == "" { + return value, false + } + return FilePathValue(v), true + + case []string: + result := make([]any, len(v)) + for i, s := range v { + result[i] = FilePathValue(s) + } + return result, true + + case []any: + result := make([]any, len(v)) + for i, elem := range v { + if s, ok := elem.(string); ok { + result[i] = FilePathValue(s) + } else { + result[i] = elem + } + } + return result, true + + default: + return value, false + } +} From 3ee457aa17ed94997cc134b99c77e28a85900a82 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 05:49:13 +0000 Subject: [PATCH 12/37] feat: better error message if scheme forgotten in CLI `*_BASE_URL`/`--base-url` --- cmd/deeptable/main.go | 7 +++++++ pkg/cmd/cmd.go | 3 +++ pkg/cmd/cmdutil.go | 9 +++++++++ pkg/cmd/cmdutil_test.go | 39 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 58 insertions(+) diff --git a/cmd/deeptable/main.go b/cmd/deeptable/main.go index c090747..bbf45de 100644 --- a/cmd/deeptable/main.go +++ b/cmd/deeptable/main.go @@ -23,6 +23,13 @@ func main() { prepareForAutocomplete(app) } + if baseURL, ok := os.LookupEnv("DEEPTABLE_BASE_URL"); ok { + if err := cmd.ValidateBaseURL(baseURL, "DEEPTABLE_BASE_URL"); err != nil { + fmt.Fprintf(os.Stderr, "%s\n", err.Error()) + os.Exit(1) + } + } + if err := app.Run(context.Background(), os.Args); err != nil { exitCode := 1 diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index abf30c3..8dee259 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -39,6 +39,9 @@ func init() { Name: "base-url", DefaultText: "url", Usage: "Override the base URL for API requests", + Validator: func(baseURL string) error { + return ValidateBaseURL(baseURL, "--base-url") + }, }, &cli.StringFlag{ Name: "format", diff --git a/pkg/cmd/cmdutil.go b/pkg/cmd/cmdutil.go index b890266..6e75441 100644 --- a/pkg/cmd/cmdutil.go +++ b/pkg/cmd/cmdutil.go @@ -29,6 +29,15 @@ import ( var OutputFormats = []string{"auto", "explore", "json", "jsonl", "pretty", "raw", "yaml"} +// ValidateBaseURL checks that a base URL is correctly prefixed with a protocol scheme and produces a better +// error message than the person would see otherwise if it doesn't. +func ValidateBaseURL(value, source string) error { + if value != "" && !strings.HasPrefix(value, "http://") && !strings.HasPrefix(value, "https://") { + return fmt.Errorf("%s %q is missing a scheme (expected http:// or https://)", source, value) + } + return nil +} + func getDefaultRequestOptions(cmd *cli.Command) []option.RequestOption { opts := []option.RequestOption{ option.WithHeader("User-Agent", fmt.Sprintf("DeepTable/CLI %s", Version)), diff --git a/pkg/cmd/cmdutil_test.go b/pkg/cmd/cmdutil_test.go index 0a46fd1..8487408 100644 --- a/pkg/cmd/cmdutil_test.go +++ b/pkg/cmd/cmdutil_test.go @@ -125,3 +125,42 @@ func TestCreateDownloadFile(t *testing.T) { assert.Equal(t, "passwd", filepath.Base(file.Name())) }) } + +func TestValidateBaseURL(t *testing.T) { + t.Parallel() + + t.Run("ValidHTTPS", func(t *testing.T) { + t.Parallel() + + require.NoError(t, ValidateBaseURL("https://api.example.com", "--base-url")) + }) + + t.Run("ValidHTTP", func(t *testing.T) { + t.Parallel() + + require.NoError(t, ValidateBaseURL("http://localhost:8080", "--base-url")) + }) + + t.Run("Empty", func(t *testing.T) { + t.Parallel() + + require.NoError(t, ValidateBaseURL("", "MY_BASE_URL")) + }) + + t.Run("MissingScheme", func(t *testing.T) { + t.Parallel() + + err := ValidateBaseURL("localhost:8080", "MY_BASE_URL") + require.Error(t, err) + assert.Contains(t, err.Error(), "MY_BASE_URL") + assert.Contains(t, err.Error(), "missing a scheme") + }) + + t.Run("HostOnly", func(t *testing.T) { + t.Parallel() + + err := ValidateBaseURL("api.example.com", "--base-url") + require.Error(t, err) + assert.Contains(t, err.Error(), "--base-url") + }) +} From e30bb3a93f299cc74a5fd022fd6d7bc4ad16bebf Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 05:49:43 +0000 Subject: [PATCH 13/37] feat: allow `-` as value representing stdin to binary-only file parameters in CLIs --- pkg/cmd/flagoptions.go | 103 +++++++++++++++++++++++++++++++++--- pkg/cmd/flagoptions_test.go | 91 ++++++++++++++++++++++++++++++- 2 files changed, 184 insertions(+), 10 deletions(-) diff --git a/pkg/cmd/flagoptions.go b/pkg/cmd/flagoptions.go index 1d3a010..48a170a 100644 --- a/pkg/cmd/flagoptions.go +++ b/pkg/cmd/flagoptions.go @@ -40,12 +40,48 @@ const ( EmbedIOReader ) -func embedFiles(obj any, embedStyle FileEmbedStyle) (any, error) { +// onceStdinReader wraps an io.Reader that can only be consumed once, used to ensure stdin is read by at most +// one parameter (or only for a body root parameter or only for YAML parameter input). If reason is set, stdin +// is unavailable and read() returns an error explaining why. +type onceStdinReader struct { + stdinReader io.Reader + failureReason string +} + +func (o *onceStdinReader) read() (io.Reader, error) { + if o.failureReason != "" { + return nil, fmt.Errorf("cannot read from stdin: %s", o.failureReason) + } + if o.stdinReader == nil { + return nil, fmt.Errorf("stdin has already been read by another parameter; it can only be read once") + } + r := o.stdinReader + o.stdinReader = nil + return r, nil +} + +func (o *onceStdinReader) readAll() ([]byte, error) { + r, err := o.read() + if err != nil { + return nil, err + } + return io.ReadAll(r) +} + +func isStdinPath(s string) bool { + switch s { + case "-", "/dev/fd/0", "/dev/stdin": + return true + } + return false +} + +func embedFiles(obj any, embedStyle FileEmbedStyle, stdin *onceStdinReader) (any, error) { if obj == nil { return obj, nil } v := reflect.ValueOf(obj) - result, err := embedFilesValue(v, embedStyle) + result, err := embedFilesValue(v, embedStyle, stdin) if err != nil { return nil, err } @@ -53,7 +89,7 @@ func embedFiles(obj any, embedStyle FileEmbedStyle) (any, error) { } // Replace "@file.txt" with the file's contents inside a value -func embedFilesValue(v reflect.Value, embedStyle FileEmbedStyle) (reflect.Value, error) { +func embedFilesValue(v reflect.Value, embedStyle FileEmbedStyle, stdin *onceStdinReader) (reflect.Value, error) { // Unwrap interface values to get the concrete type if v.Kind() == reflect.Interface { if v.IsNil() { @@ -74,7 +110,7 @@ func embedFilesValue(v reflect.Value, embedStyle FileEmbedStyle) (reflect.Value, for iter.Next() { key := iter.Key() val := iter.Value() - newVal, err := embedFilesValue(val, embedStyle) + newVal, err := embedFilesValue(val, embedStyle, stdin) if err != nil { return reflect.Value{}, err } @@ -89,7 +125,7 @@ func embedFilesValue(v reflect.Value, embedStyle FileEmbedStyle) (reflect.Value, // Use `[]any` to allow for types to change when embedding files result := reflect.MakeSlice(reflect.TypeOf([]any{}), v.Len(), v.Len()) for i := 0; i < v.Len(); i++ { - newVal, err := embedFilesValue(v.Index(i), embedStyle) + newVal, err := embedFilesValue(v.Index(i), embedStyle, stdin) if err != nil { return reflect.Value{}, err } @@ -106,6 +142,13 @@ func embedFilesValue(v reflect.Value, embedStyle FileEmbedStyle) (reflect.Value, if s == "" { return v, nil } + if isStdinPath(s) { + content, err := stdin.readAll() + if err != nil { + return v, err + } + return reflect.ValueOf(string(content)), nil + } content, err := os.ReadFile(s) if err != nil { return v, err @@ -123,6 +166,13 @@ func embedFilesValue(v reflect.Value, embedStyle FileEmbedStyle) (reflect.Value, if filename, ok := strings.CutPrefix(s, "@data://"); ok { // The "@data://" prefix is for files you explicitly want to upload // as base64-encoded (even if the file itself is plain text) + if isStdinPath(filename) { + content, err := stdin.readAll() + if err != nil { + return v, err + } + return reflect.ValueOf(base64.StdEncoding.EncodeToString(content)), nil + } content, err := os.ReadFile(filename) if err != nil { return v, err @@ -132,12 +182,29 @@ func embedFilesValue(v reflect.Value, embedStyle FileEmbedStyle) (reflect.Value, // The "@file://" prefix is for files that you explicitly want to // upload as a string literal with backslash escapes (not base64 // encoded) + if isStdinPath(filename) { + content, err := stdin.readAll() + if err != nil { + return v, err + } + return reflect.ValueOf(string(content)), nil + } content, err := os.ReadFile(filename) if err != nil { return v, err } return reflect.ValueOf(string(content)), nil } else if filename, ok := strings.CutPrefix(s, "@"); ok { + if isStdinPath(filename) { + content, err := stdin.readAll() + if err != nil { + return v, err + } + if isUTF8TextFile(content) { + return reflect.ValueOf(string(content)), nil + } + return reflect.ValueOf(base64.StdEncoding.EncodeToString(content)), nil + } content, err := os.ReadFile(filename) if err != nil { // If the string is "@username", it's probably supposed to be a @@ -175,6 +242,14 @@ func embedFilesValue(v reflect.Value, embedStyle FileEmbedStyle) (reflect.Value, expectsFile = strings.Contains(filename, ".") || strings.Contains(filename, "/") } + if isStdinPath(filename) { + r, err := stdin.read() + if err != nil { + return v, err + } + return reflect.ValueOf(io.NopCloser(r)), nil + } + file, err := os.Open(filename) if err != nil { if !expectsFile { @@ -234,6 +309,7 @@ func flagOptions( requestContents := requestflag.ExtractRequestContents(cmd) + stdinConsumedByPipe := false if (bodyType == MultipartFormEncoded || bodyType == ApplicationJSON) && !ignoreStdin && isInputPiped() { pipeData, err := io.ReadAll(os.Stdin) if err != nil { @@ -241,6 +317,7 @@ func flagOptions( } if len(pipeData) > 0 { + stdinConsumedByPipe = true var bodyData any if err := yaml.Unmarshal(pipeData, &bodyData); err != nil { return nil, fmt.Errorf("Failed to parse piped data as YAML/JSON:\n%w", err) @@ -279,24 +356,34 @@ func flagOptions( // via explicit CLI flags and values that arrived via piped YAML/JSON data. wrapFileInputValues(cmd, &requestContents) + // Determine stdin availability for FileInput params that use "-". + var stdinReader onceStdinReader + if ignoreStdin { + stdinReader = onceStdinReader{failureReason: "stdin is already being used for the request body"} + } else if stdinConsumedByPipe { + stdinReader = onceStdinReader{failureReason: "stdin was already consumed by piped YAML/JSON input"} + } else { + stdinReader = onceStdinReader{stdinReader: os.Stdin} + } + // Embed files passed as "@file.jpg" in the request body, headers, and query: embedStyle := EmbedText if bodyType == ApplicationOctetStream || bodyType == MultipartFormEncoded { embedStyle = EmbedIOReader } - if embedded, err := embedFiles(requestContents.Body, embedStyle); err != nil { + if embedded, err := embedFiles(requestContents.Body, embedStyle, &stdinReader); err != nil { return nil, err } else { requestContents.Body = embedded } - if headersWithFiles, err := embedFiles(requestContents.Headers, EmbedText); err != nil { + if headersWithFiles, err := embedFiles(requestContents.Headers, EmbedText, &stdinReader); err != nil { return nil, err } else { requestContents.Headers = headersWithFiles.(map[string]any) } - if queriesWithFiles, err := embedFiles(requestContents.Queries, EmbedText); err != nil { + if queriesWithFiles, err := embedFiles(requestContents.Queries, EmbedText, &stdinReader); err != nil { return nil, err } else { requestContents.Queries = queriesWithFiles.(map[string]any) diff --git a/pkg/cmd/flagoptions_test.go b/pkg/cmd/flagoptions_test.go index e5dad4b..9a7fe3b 100644 --- a/pkg/cmd/flagoptions_test.go +++ b/pkg/cmd/flagoptions_test.go @@ -2,8 +2,10 @@ package cmd import ( "encoding/base64" + "io" "os" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -11,6 +13,7 @@ import ( ) func TestIsUTF8TextFile(t *testing.T) { + tests := []struct { content []byte expected bool @@ -32,6 +35,7 @@ func TestIsUTF8TextFile(t *testing.T) { } func TestEmbedFiles(t *testing.T) { + // Create temporary directory for test files tmpDir := t.TempDir() @@ -216,7 +220,8 @@ func TestEmbedFiles(t *testing.T) { for _, tt := range tests { t.Run(tt.name+" text", func(t *testing.T) { - got, err := embedFiles(tt.input, EmbedText) + + got, err := embedFiles(tt.input, EmbedText, nil) if tt.wantErr { assert.Error(t, err) } else { @@ -226,7 +231,8 @@ func TestEmbedFiles(t *testing.T) { }) t.Run(tt.name+" io.Reader", func(t *testing.T) { - _, err := embedFiles(tt.input, EmbedIOReader) + + _, err := embedFiles(tt.input, EmbedIOReader, nil) if tt.wantErr { assert.Error(t, err) } else { @@ -236,9 +242,90 @@ func TestEmbedFiles(t *testing.T) { } } +func TestEmbedFilesStdin(t *testing.T) { + + t.Run("FilePathValueDash", func(t *testing.T) { + + stdin := &onceStdinReader{stdinReader: strings.NewReader("stdin content")} + + withEmbedded, err := embedFiles(map[string]any{"file": FilePathValue("-")}, EmbedText, stdin) + require.NoError(t, err) + require.Equal(t, map[string]any{"file": "stdin content"}, withEmbedded) + }) + + t.Run("FilePathValueDevStdin", func(t *testing.T) { + + stdin := &onceStdinReader{stdinReader: strings.NewReader("stdin content")} + + withEmbedded, err := embedFiles(map[string]any{"file": FilePathValue("/dev/stdin")}, EmbedText, stdin) + require.NoError(t, err) + require.Equal(t, map[string]any{"file": "stdin content"}, withEmbedded) + }) + + t.Run("MultipleFilePathValueDashesError", func(t *testing.T) { + + stdin := &onceStdinReader{stdinReader: strings.NewReader("stdin content")} + + _, err := embedFiles(map[string]any{ + "file1": FilePathValue("-"), + "file2": FilePathValue("-"), + }, EmbedText, stdin) + require.Error(t, err) + require.Contains(t, err.Error(), "already been read") + }) + + t.Run("FilePathValueDashUnavailableStdin", func(t *testing.T) { + + stdin := &onceStdinReader{failureReason: "stdin is already being used for the request body"} + + _, err := embedFiles(map[string]any{"file": FilePathValue("-")}, EmbedText, stdin) + require.Error(t, err) + require.Contains(t, err.Error(), "cannot read from stdin") + require.Contains(t, err.Error(), "request body") + }) + + t.Run("AtDashEmbedText", func(t *testing.T) { + + stdin := &onceStdinReader{stdinReader: strings.NewReader("piped content")} + + withEmbedded, err := embedFiles(map[string]any{"data": "@-"}, EmbedText, stdin) + require.NoError(t, err) + require.Equal(t, map[string]any{"data": "piped content"}, withEmbedded) + }) + + t.Run("AtDashEmbedIOReader", func(t *testing.T) { + + stdin := &onceStdinReader{stdinReader: strings.NewReader("piped content")} + + withEmbedded, err := embedFiles(map[string]any{"data": "@-"}, EmbedIOReader, stdin) + require.NoError(t, err) + + withEmbeddedMap := withEmbedded.(map[string]any) + r := withEmbeddedMap["data"].(io.ReadCloser) + + content, err := io.ReadAll(r) + require.NoError(t, err) + require.Equal(t, "piped content", string(content)) + }) + + t.Run("FilePathValueRealFile", func(t *testing.T) { + + tmpDir := t.TempDir() + writeTestFile(t, tmpDir, "test.txt", "file content") + + stdin := &onceStdinReader{stdinReader: strings.NewReader("unused stdin")} + + withEmbedded, err := embedFiles(map[string]any{"file": FilePathValue(filepath.Join(tmpDir, "test.txt"))}, EmbedText, stdin) + require.NoError(t, err) + require.Equal(t, map[string]any{"file": "file content"}, withEmbedded) + }) +} + func writeTestFile(t *testing.T, dir, filename, content string) { t.Helper() + path := filepath.Join(dir, filename) + err := os.WriteFile(path, []byte(content), 0644) require.NoError(t, err, "failed to write test file %s", path) } From 1c11dc73b06973991ce1c129a2010e54bf4f599e Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 05:49:57 +0000 Subject: [PATCH 14/37] chore: switch some CLI Go tests from `os.Chdir` to `t.Chdir` --- pkg/cmd/cmdutil_test.go | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/pkg/cmd/cmdutil_test.go b/pkg/cmd/cmdutil_test.go index 8487408..550c995 100644 --- a/pkg/cmd/cmdutil_test.go +++ b/pkg/cmd/cmdutil_test.go @@ -67,10 +67,7 @@ func TestWriteBinaryResponse(t *testing.T) { func TestCreateDownloadFile(t *testing.T) { t.Run("creates file with filename from header", func(t *testing.T) { - tmpDir := t.TempDir() - oldWd, _ := os.Getwd() - os.Chdir(tmpDir) - defer os.Chdir(oldWd) + t.Chdir(t.TempDir()) resp := &http.Response{ Header: http.Header{ @@ -96,10 +93,7 @@ func TestCreateDownloadFile(t *testing.T) { }) t.Run("creates temp file when no header", func(t *testing.T) { - tmpDir := t.TempDir() - oldWd, _ := os.Getwd() - os.Chdir(tmpDir) - defer os.Chdir(oldWd) + t.Chdir(t.TempDir()) resp := &http.Response{Header: http.Header{}} file, err := createDownloadFile(resp, []byte("test content")) @@ -109,10 +103,7 @@ func TestCreateDownloadFile(t *testing.T) { }) t.Run("prevents directory traversal", func(t *testing.T) { - tmpDir := t.TempDir() - oldWd, _ := os.Getwd() - os.Chdir(tmpDir) - defer os.Chdir(oldWd) + t.Chdir(t.TempDir()) resp := &http.Response{ Header: http.Header{ From f8956929608c4ee645de258a11fc982cdf1f35e6 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 05:50:13 +0000 Subject: [PATCH 15/37] chore: mark all CLI-related tests in Go with `t.Parallel()` --- internal/apiform/form_test.go | 4 ++ internal/apiquery/query_test.go | 4 ++ internal/autocomplete/autocomplete_test.go | 40 +++++++++++++++ internal/jsonview/explorer_test.go | 6 +++ internal/requestflag/innerflag_test.go | 28 +++++++++++ internal/requestflag/requestflag_test.go | 58 ++++++++++++++++++++++ pkg/cmd/flagoptions_test.go | 12 +++++ 7 files changed, 152 insertions(+) diff --git a/internal/apiform/form_test.go b/internal/apiform/form_test.go index 2cf5bdd..f68cfd1 100644 --- a/internal/apiform/form_test.go +++ b/internal/apiform/form_test.go @@ -85,8 +85,12 @@ var tests = map[string]struct { } func TestEncode(t *testing.T) { + t.Parallel() + for name, test := range tests { t.Run(name, func(t *testing.T) { + t.Parallel() + buf := bytes.NewBuffer(nil) writer := multipart.NewWriter(buf) writer.SetBoundary("xxx") diff --git a/internal/apiquery/query_test.go b/internal/apiquery/query_test.go index 8bee784..3791ec9 100644 --- a/internal/apiquery/query_test.go +++ b/internal/apiquery/query_test.go @@ -6,6 +6,8 @@ import ( ) func TestEncode(t *testing.T) { + t.Parallel() + tests := map[string]struct { val any settings QuerySettings @@ -114,6 +116,8 @@ func TestEncode(t *testing.T) { for name, test := range tests { t.Run(name, func(t *testing.T) { + t.Parallel() + query := map[string]any{"query": test.val} values, err := MarshalWithSettings(query, test.settings) if err != nil { diff --git a/internal/autocomplete/autocomplete_test.go b/internal/autocomplete/autocomplete_test.go index 3e8aa33..2338924 100644 --- a/internal/autocomplete/autocomplete_test.go +++ b/internal/autocomplete/autocomplete_test.go @@ -8,6 +8,8 @@ import ( ) func TestGetCompletions_EmptyArgs(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ {Name: "generate", Usage: "Generate SDK"}, @@ -26,6 +28,8 @@ func TestGetCompletions_EmptyArgs(t *testing.T) { } func TestGetCompletions_SubcommandPrefix(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ {Name: "generate", Usage: "Generate SDK"}, @@ -43,6 +47,8 @@ func TestGetCompletions_SubcommandPrefix(t *testing.T) { } func TestGetCompletions_HiddenCommand(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ {Name: "visible", Usage: "Visible command"}, @@ -57,6 +63,8 @@ func TestGetCompletions_HiddenCommand(t *testing.T) { } func TestGetCompletions_NestedSubcommand(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ { @@ -79,6 +87,8 @@ func TestGetCompletions_NestedSubcommand(t *testing.T) { } func TestGetCompletions_FlagCompletion(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ { @@ -102,6 +112,8 @@ func TestGetCompletions_FlagCompletion(t *testing.T) { } func TestGetCompletions_ShortFlagCompletion(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ { @@ -123,6 +135,8 @@ func TestGetCompletions_ShortFlagCompletion(t *testing.T) { } func TestGetCompletions_FileFlagBehavior(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ { @@ -142,6 +156,8 @@ func TestGetCompletions_FileFlagBehavior(t *testing.T) { } func TestGetCompletions_NonBoolFlagValue(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ { @@ -161,6 +177,8 @@ func TestGetCompletions_NonBoolFlagValue(t *testing.T) { } func TestGetCompletions_BoolFlagDoesNotBlockCompletion(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ { @@ -185,6 +203,8 @@ func TestGetCompletions_BoolFlagDoesNotBlockCompletion(t *testing.T) { } func TestGetCompletions_ColonCommands_NoColonTyped(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ {Name: "config:get", Usage: "Get config value"}, @@ -202,6 +222,8 @@ func TestGetCompletions_ColonCommands_NoColonTyped(t *testing.T) { } func TestGetCompletions_ColonCommands_ColonTyped_Bash(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ {Name: "config:get", Usage: "Get config value"}, @@ -221,6 +243,8 @@ func TestGetCompletions_ColonCommands_ColonTyped_Bash(t *testing.T) { } func TestGetCompletions_ColonCommands_ColonTyped_Zsh(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ {Name: "config:get", Usage: "Get config value"}, @@ -240,6 +264,8 @@ func TestGetCompletions_ColonCommands_ColonTyped_Zsh(t *testing.T) { } func TestGetCompletions_BashStyleColonCompletion(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ {Name: "config:get", Usage: "Get config value"}, @@ -257,6 +283,8 @@ func TestGetCompletions_BashStyleColonCompletion(t *testing.T) { } func TestGetCompletions_BashStyleColonCompletion_NoMatch(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ {Name: "config:get", Usage: "Get config value"}, @@ -271,6 +299,8 @@ func TestGetCompletions_BashStyleColonCompletion_NoMatch(t *testing.T) { } func TestGetCompletions_ZshStyleColonCompletion(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ {Name: "config:get", Usage: "Get config value"}, @@ -287,6 +317,8 @@ func TestGetCompletions_ZshStyleColonCompletion(t *testing.T) { } func TestGetCompletions_MixedColonAndRegularCommands(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ {Name: "generate", Usage: "Generate SDK"}, @@ -305,6 +337,8 @@ func TestGetCompletions_MixedColonAndRegularCommands(t *testing.T) { } func TestGetCompletions_FlagWithBoolFlagSkipsValue(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ { @@ -329,6 +363,8 @@ func TestGetCompletions_FlagWithBoolFlagSkipsValue(t *testing.T) { } func TestGetCompletions_MultipleFlagsBeforeSubcommand(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ { @@ -353,6 +389,8 @@ func TestGetCompletions_MultipleFlagsBeforeSubcommand(t *testing.T) { } func TestGetCompletions_CommandAliases(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ {Name: "generate", Aliases: []string{"gen", "g"}, Usage: "Generate SDK"}, @@ -372,6 +410,8 @@ func TestGetCompletions_CommandAliases(t *testing.T) { } func TestGetCompletions_AllFlagsWhenNoPrefix(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ { diff --git a/internal/jsonview/explorer_test.go b/internal/jsonview/explorer_test.go index c559254..67ee730 100644 --- a/internal/jsonview/explorer_test.go +++ b/internal/jsonview/explorer_test.go @@ -10,6 +10,8 @@ import ( ) func TestNavigateForward_EmptyRowData(t *testing.T) { + t.Parallel() + // An empty JSON array produces a TableView with no rows. emptyArray := gjson.Parse("[]") view, err := newTableView("", emptyArray, false) @@ -38,6 +40,8 @@ type rawJSONItem struct { func (r rawJSONItem) RawJSON() string { return r.raw } func TestMarshalItemsToJSONArray_WithHasRawJSON(t *testing.T) { + t.Parallel() + items := []any{ rawJSONItem{raw: `{"id":1,"name":"alice"}`}, rawJSONItem{raw: `{"id":2,"name":"bob"}`}, @@ -49,6 +53,8 @@ func TestMarshalItemsToJSONArray_WithHasRawJSON(t *testing.T) { } func TestMarshalItemsToJSONArray_WithoutHasRawJSON(t *testing.T) { + t.Parallel() + items := []any{ map[string]any{"id": 1, "name": "alice"}, map[string]any{"id": 2, "name": "bob"}, diff --git a/internal/requestflag/innerflag_test.go b/internal/requestflag/innerflag_test.go index 3f204c9..133e8b4 100644 --- a/internal/requestflag/innerflag_test.go +++ b/internal/requestflag/innerflag_test.go @@ -8,6 +8,8 @@ import ( ) func TestInnerFlagSet(t *testing.T) { + t.Parallel() + tests := []struct { name string flagType string @@ -27,6 +29,8 @@ func TestInnerFlagSet(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + outerFlag := &Flag[map[string]any]{ Name: "test-flag", } @@ -81,6 +85,8 @@ func TestInnerFlagSet(t *testing.T) { } func TestInnerFlagValidator(t *testing.T) { + t.Parallel() + outerFlag := &Flag[map[string]any]{Name: "test-flag"} innerFlag := &InnerFlag[int64]{ @@ -105,6 +111,8 @@ func TestInnerFlagValidator(t *testing.T) { } func TestWithInnerFlags(t *testing.T) { + t.Parallel() + outerFlag := &Flag[map[string]any]{Name: "outer"} innerFlag := &InnerFlag[string]{ Name: "outer.baz", @@ -126,6 +134,8 @@ func TestWithInnerFlags(t *testing.T) { } func TestInnerFlagTypeNames(t *testing.T) { + t.Parallel() + tests := []struct { name string flag cli.DocGenerationFlag @@ -143,6 +153,8 @@ func TestInnerFlagTypeNames(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + typeName := tt.flag.TypeName() assert.Equal(t, tt.expected, typeName, "Expected type name %q, got %q", tt.expected, typeName) }) @@ -150,8 +162,12 @@ func TestInnerFlagTypeNames(t *testing.T) { } func TestInnerYamlHandling(t *testing.T) { + t.Parallel() + // Test with map value t.Run("Parse YAML to map", func(t *testing.T) { + t.Parallel() + outerFlag := &Flag[map[string]any]{Name: "outer"} innerFlag := &InnerFlag[map[string]any]{ Name: "outer.baz", @@ -176,6 +192,8 @@ func TestInnerYamlHandling(t *testing.T) { // Test with invalid YAML t.Run("Parse invalid YAML", func(t *testing.T) { + t.Parallel() + outerFlag := &Flag[map[string]any]{Name: "outer"} innerFlag := &InnerFlag[map[string]any]{ Name: "outer.baz", @@ -190,6 +208,8 @@ func TestInnerYamlHandling(t *testing.T) { // Test setting inner flags on a map multiple times t.Run("Set inner flags on map multiple times", func(t *testing.T) { + t.Parallel() + outerFlag := &Flag[map[string]any]{Name: "outer"} // Set first inner flag @@ -219,6 +239,8 @@ func TestInnerYamlHandling(t *testing.T) { // Test setting YAML and then an inner flag t.Run("Set YAML and then inner flag", func(t *testing.T) { + t.Parallel() + outerFlag := &Flag[map[string]any]{Name: "outer"} // First set the outer flag with YAML @@ -246,7 +268,11 @@ func TestInnerYamlHandling(t *testing.T) { } func TestInnerFlagWithSliceType(t *testing.T) { + t.Parallel() + t.Run("Setting inner flags on slice of maps", func(t *testing.T) { + t.Parallel() + outerFlag := &Flag[[]map[string]any]{Name: "outer"} // Set first inner flag (should create first item) @@ -284,6 +310,8 @@ func TestInnerFlagWithSliceType(t *testing.T) { }) t.Run("Appending to existing slice", func(t *testing.T) { + t.Parallel() + // Initialize with existing items outerFlag := &Flag[[]map[string]any]{Name: "outer"} err := outerFlag.Set(outerFlag.Name, `{name: initial}`) diff --git a/internal/requestflag/requestflag_test.go b/internal/requestflag/requestflag_test.go index 9751904..0e86e07 100644 --- a/internal/requestflag/requestflag_test.go +++ b/internal/requestflag/requestflag_test.go @@ -11,6 +11,8 @@ import ( ) func TestDateValueParse(t *testing.T) { + t.Parallel() + tests := []struct { name string input string @@ -56,6 +58,8 @@ func TestDateValueParse(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var d DateValue err := d.Parse(tt.input) @@ -70,6 +74,8 @@ func TestDateValueParse(t *testing.T) { } func TestDateTimeValueParse(t *testing.T) { + t.Parallel() + tests := []struct { name string input string @@ -119,6 +125,8 @@ func TestDateTimeValueParse(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var d DateTimeValue err := d.Parse(tt.input) @@ -136,6 +144,8 @@ func TestDateTimeValueParse(t *testing.T) { } func TestTimeValueParse(t *testing.T) { + t.Parallel() + tests := []struct { name string input string @@ -181,6 +191,8 @@ func TestTimeValueParse(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var tv TimeValue err := tv.Parse(tt.input) @@ -195,7 +207,11 @@ func TestTimeValueParse(t *testing.T) { } func TestRequestParams(t *testing.T) { + t.Parallel() + t.Run("map body type", func(t *testing.T) { + t.Parallel() + // Create a mock command with flags cmd := &cli.Command{ Name: "test", @@ -283,6 +299,8 @@ func TestRequestParams(t *testing.T) { }) t.Run("non-map body type", func(t *testing.T) { + t.Parallel() + // Create a mock command with flags cmd := &cli.Command{ Name: "test", @@ -304,6 +322,8 @@ func TestRequestParams(t *testing.T) { } func TestFlagSet(t *testing.T) { + t.Parallel() + strFlag := &Flag[string]{ Name: "string-flag", Default: "default-string", @@ -327,38 +347,52 @@ func TestFlagSet(t *testing.T) { // Test initialization and setting t.Run("PreParse initialization", func(t *testing.T) { + t.Parallel() + assert.NoError(t, strFlag.PreParse()) assert.True(t, strFlag.applied) assert.Equal(t, "default-string", strFlag.Get()) }) t.Run("Set string flag", func(t *testing.T) { + t.Parallel() + assert.NoError(t, strFlag.Set("string-flag", "new-value")) assert.Equal(t, "new-value", strFlag.Get()) assert.True(t, strFlag.IsSet()) }) t.Run("Set int flag with valid value", func(t *testing.T) { + t.Parallel() + assert.NoError(t, superstitiousIntFlag.Set("int-flag", "100")) assert.Equal(t, int64(100), superstitiousIntFlag.Get()) assert.True(t, superstitiousIntFlag.IsSet()) }) t.Run("Set int flag with invalid value", func(t *testing.T) { + t.Parallel() + assert.Error(t, superstitiousIntFlag.Set("int-flag", "not-an-int")) }) t.Run("Set int flag with validator failing", func(t *testing.T) { + t.Parallel() + assert.Error(t, superstitiousIntFlag.Set("int-flag", "13")) }) t.Run("Set bool flag", func(t *testing.T) { + t.Parallel() + assert.NoError(t, boolFlag.Set("bool-flag", "true")) assert.Equal(t, true, boolFlag.Get()) assert.True(t, boolFlag.IsSet()) }) t.Run("Set slice flag with multiple values", func(t *testing.T) { + t.Parallel() + sliceFlag := &Flag[[]int64]{ Name: "slice-flag", Default: []int64{}, @@ -381,6 +415,8 @@ func TestFlagSet(t *testing.T) { }) t.Run("Set slice flag with a nonempty default", func(t *testing.T) { + t.Parallel() + sliceFlag := &Flag[[]int64]{ Name: "slice-flag", Default: []int64{99, 100}, @@ -400,6 +436,8 @@ func TestFlagSet(t *testing.T) { } func TestParseTimeWithFormats(t *testing.T) { + t.Parallel() + tests := []struct { name string input string @@ -439,6 +477,8 @@ func TestParseTimeWithFormats(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := parseTimeWithFormats(tt.input, tt.formats) if tt.wantErr { @@ -452,8 +492,12 @@ func TestParseTimeWithFormats(t *testing.T) { } func TestYamlHandling(t *testing.T) { + t.Parallel() + // Test with any value t.Run("Parse YAML to any", func(t *testing.T) { + t.Parallel() + cv := &cliValue[any]{} err := cv.Set("name: test\nvalue: 42\n") assert.NoError(t, err) @@ -478,6 +522,8 @@ func TestYamlHandling(t *testing.T) { // Test with array t.Run("Parse YAML array", func(t *testing.T) { + t.Parallel() + cv := &cliValue[any]{} err := cv.Set("- item1\n- item2\n- item3\n") assert.NoError(t, err) @@ -495,6 +541,8 @@ func TestYamlHandling(t *testing.T) { }) t.Run("Parse @file.txt as YAML", func(t *testing.T) { + t.Parallel() + flag := &Flag[any]{ Name: "file-flag", Default: nil, @@ -507,6 +555,8 @@ func TestYamlHandling(t *testing.T) { }) t.Run("Parse @file.txt list as YAML", func(t *testing.T) { + t.Parallel() + flag := &Flag[[]any]{ Name: "file-flag", Default: nil, @@ -520,6 +570,8 @@ func TestYamlHandling(t *testing.T) { }) t.Run("Parse identifiers as YAML", func(t *testing.T) { + t.Parallel() + tests := []string{ "hello", "e4e355fa-b03b-4c57-a73d-25c9733eec79", @@ -555,6 +607,8 @@ func TestYamlHandling(t *testing.T) { // Test with invalid YAML t.Run("Parse invalid YAML", func(t *testing.T) { + t.Parallel() + invalidYaml := `[not closed` cv := &cliValue[any]{} err := cv.Set(invalidYaml) @@ -563,6 +617,8 @@ func TestYamlHandling(t *testing.T) { } func TestFlagTypeNames(t *testing.T) { + t.Parallel() + tests := []struct { name string flag cli.DocGenerationFlag @@ -583,6 +639,8 @@ func TestFlagTypeNames(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + typeName := tt.flag.TypeName() assert.Equal(t, tt.expected, typeName, "Expected type name %q, got %q", tt.expected, typeName) }) diff --git a/pkg/cmd/flagoptions_test.go b/pkg/cmd/flagoptions_test.go index 9a7fe3b..039b9ff 100644 --- a/pkg/cmd/flagoptions_test.go +++ b/pkg/cmd/flagoptions_test.go @@ -13,6 +13,7 @@ import ( ) func TestIsUTF8TextFile(t *testing.T) { + t.Parallel() tests := []struct { content []byte @@ -35,6 +36,7 @@ func TestIsUTF8TextFile(t *testing.T) { } func TestEmbedFiles(t *testing.T) { + t.Parallel() // Create temporary directory for test files tmpDir := t.TempDir() @@ -220,6 +222,7 @@ func TestEmbedFiles(t *testing.T) { for _, tt := range tests { t.Run(tt.name+" text", func(t *testing.T) { + t.Parallel() got, err := embedFiles(tt.input, EmbedText, nil) if tt.wantErr { @@ -231,6 +234,7 @@ func TestEmbedFiles(t *testing.T) { }) t.Run(tt.name+" io.Reader", func(t *testing.T) { + t.Parallel() _, err := embedFiles(tt.input, EmbedIOReader, nil) if tt.wantErr { @@ -243,8 +247,10 @@ func TestEmbedFiles(t *testing.T) { } func TestEmbedFilesStdin(t *testing.T) { + t.Parallel() t.Run("FilePathValueDash", func(t *testing.T) { + t.Parallel() stdin := &onceStdinReader{stdinReader: strings.NewReader("stdin content")} @@ -254,6 +260,7 @@ func TestEmbedFilesStdin(t *testing.T) { }) t.Run("FilePathValueDevStdin", func(t *testing.T) { + t.Parallel() stdin := &onceStdinReader{stdinReader: strings.NewReader("stdin content")} @@ -263,6 +270,7 @@ func TestEmbedFilesStdin(t *testing.T) { }) t.Run("MultipleFilePathValueDashesError", func(t *testing.T) { + t.Parallel() stdin := &onceStdinReader{stdinReader: strings.NewReader("stdin content")} @@ -275,6 +283,7 @@ func TestEmbedFilesStdin(t *testing.T) { }) t.Run("FilePathValueDashUnavailableStdin", func(t *testing.T) { + t.Parallel() stdin := &onceStdinReader{failureReason: "stdin is already being used for the request body"} @@ -285,6 +294,7 @@ func TestEmbedFilesStdin(t *testing.T) { }) t.Run("AtDashEmbedText", func(t *testing.T) { + t.Parallel() stdin := &onceStdinReader{stdinReader: strings.NewReader("piped content")} @@ -294,6 +304,7 @@ func TestEmbedFilesStdin(t *testing.T) { }) t.Run("AtDashEmbedIOReader", func(t *testing.T) { + t.Parallel() stdin := &onceStdinReader{stdinReader: strings.NewReader("piped content")} @@ -309,6 +320,7 @@ func TestEmbedFilesStdin(t *testing.T) { }) t.Run("FilePathValueRealFile", func(t *testing.T) { + t.Parallel() tmpDir := t.TempDir() writeTestFile(t, tmpDir, "test.txt", "file content") From 1fae171f023fc2529341f3b2ee4704dceefeeff1 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 4 Apr 2026 05:14:46 +0000 Subject: [PATCH 16/37] chore: modify CLI tests to inject stdout so mutating `os.Stdout` isn't necessary --- pkg/cmd/cmdutil.go | 9 ++++++--- pkg/cmd/cmdutil_test.go | 15 ++++----------- pkg/cmd/file.go | 2 +- pkg/cmd/structuredsheet.go | 2 +- pkg/cmd/structuredsheettable.go | 2 +- 5 files changed, 13 insertions(+), 17 deletions(-) diff --git a/pkg/cmd/cmdutil.go b/pkg/cmd/cmdutil.go index 6e75441..768a1bd 100644 --- a/pkg/cmd/cmdutil.go +++ b/pkg/cmd/cmdutil.go @@ -193,7 +193,10 @@ func streamToStdout(generateOutput func(w *os.File) error) error { return err } -func writeBinaryResponse(response *http.Response, outfile string) (string, error) { +// writeBinaryResponse writes a binary response to stdout or a file. +// +// Takes in a stdout reference so we can test this function without overriding os.Stdout in tests. +func writeBinaryResponse(response *http.Response, stdout io.Writer, outfile string) (string, error) { defer response.Body.Close() body, err := io.ReadAll(response.Body) if err != nil { @@ -201,13 +204,13 @@ func writeBinaryResponse(response *http.Response, outfile string) (string, error } switch outfile { case "-", "/dev/stdout": - _, err := os.Stdout.Write(body) + _, err := stdout.Write(body) return "", err case "": // If output file is unspecified, then print to stdout for plain text or // if stdout is not a terminal: if !isTerminal(os.Stdout) || isUTF8TextFile(body) { - _, err := os.Stdout.Write(body) + _, err := stdout.Write(body) return "", err } diff --git a/pkg/cmd/cmdutil_test.go b/pkg/cmd/cmdutil_test.go index 550c995..8eca397 100644 --- a/pkg/cmd/cmdutil_test.go +++ b/pkg/cmd/cmdutil_test.go @@ -32,7 +32,7 @@ func TestWriteBinaryResponse(t *testing.T) { Body: io.NopCloser(bytes.NewReader(body)), } - msg, err := writeBinaryResponse(resp, outfile) + msg, err := writeBinaryResponse(resp, os.Stdout, outfile) require.NoError(t, err) assert.Contains(t, msg, outfile) @@ -43,24 +43,17 @@ func TestWriteBinaryResponse(t *testing.T) { }) t.Run("write to stdout", func(t *testing.T) { - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w + t.Parallel() + var buf bytes.Buffer body := []byte("stdout content") resp := &http.Response{ Body: io.NopCloser(bytes.NewReader(body)), } - msg, err := writeBinaryResponse(resp, "-") - - w.Close() - os.Stdout = oldStdout + msg, err := writeBinaryResponse(resp, &buf, "-") require.NoError(t, err) assert.Empty(t, msg) - - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) assert.Equal(t, body, buf.Bytes()) }) } diff --git a/pkg/cmd/file.go b/pkg/cmd/file.go index e5b37a6..f527218 100644 --- a/pkg/cmd/file.go +++ b/pkg/cmd/file.go @@ -245,7 +245,7 @@ func handleFilesDownload(ctx context.Context, cmd *cli.Command) error { if err != nil { return err } - message, err := writeBinaryResponse(response, cmd.String("output")) + message, err := writeBinaryResponse(response, os.Stdout, cmd.String("output")) if message != "" { fmt.Println(message) } diff --git a/pkg/cmd/structuredsheet.go b/pkg/cmd/structuredsheet.go index e0f1cd1..c908f8e 100644 --- a/pkg/cmd/structuredsheet.go +++ b/pkg/cmd/structuredsheet.go @@ -346,7 +346,7 @@ func handleStructuredSheetsDownload(ctx context.Context, cmd *cli.Command) error if err != nil { return err } - message, err := writeBinaryResponse(response, cmd.String("output")) + message, err := writeBinaryResponse(response, os.Stdout, cmd.String("output")) if message != "" { fmt.Println(message) } diff --git a/pkg/cmd/structuredsheettable.go b/pkg/cmd/structuredsheettable.go index a0a2508..db95efa 100644 --- a/pkg/cmd/structuredsheettable.go +++ b/pkg/cmd/structuredsheettable.go @@ -230,7 +230,7 @@ func handleStructuredSheetsTablesDownload(ctx context.Context, cmd *cli.Command) if err != nil { return err } - message, err := writeBinaryResponse(response, cmd.String("output")) + message, err := writeBinaryResponse(response, os.Stdout, cmd.String("output")) if message != "" { fmt.Println(message) } From ec4eb09e2f76d4a75fdfec1f2b407d9ad8afb67a Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 08:22:58 +0000 Subject: [PATCH 17/37] fix: fall back to main branch if linking fails in CI --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec5e177..c559810 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ jobs: - name: Link staging branch if: github.repository == 'stainless-sdks/deeptable-cli' run: | - ./scripts/link 'github.com/stainless-sdks/deeptable-go@${{ github.ref_name }}' || true + ./scripts/link 'github.com/stainless-sdks/deeptable-go@${{ github.ref_name }}' || go mod edit -dropreplace='github.com/stainless-sdks/deeptable-go - name: Bootstrap run: ./scripts/bootstrap @@ -60,7 +60,7 @@ jobs: - name: Link staging branch if: github.repository == 'stainless-sdks/deeptable-cli' run: | - ./scripts/link 'github.com/stainless-sdks/deeptable-go@${{ github.ref_name }}' || true + ./scripts/link 'github.com/stainless-sdks/deeptable-go@${{ github.ref_name }}' || go mod edit -dropreplace='github.com/stainless-sdks/deeptable-go - name: Bootstrap run: ./scripts/bootstrap @@ -107,7 +107,7 @@ jobs: - name: Link staging branch if: github.repository == 'stainless-sdks/deeptable-cli' run: | - ./scripts/link 'github.com/stainless-sdks/deeptable-go@${{ github.ref_name }}' || true + ./scripts/link 'github.com/stainless-sdks/deeptable-go@${{ github.ref_name }}' || go mod edit -dropreplace='github.com/stainless-sdks/deeptable-go - name: Bootstrap run: ./scripts/bootstrap From 006840d8fc41deeb53de45b2ab1c18fd877480aa Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 08:23:43 +0000 Subject: [PATCH 18/37] fix: fix quoting typo --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c559810..2958689 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ jobs: - name: Link staging branch if: github.repository == 'stainless-sdks/deeptable-cli' run: | - ./scripts/link 'github.com/stainless-sdks/deeptable-go@${{ github.ref_name }}' || go mod edit -dropreplace='github.com/stainless-sdks/deeptable-go + ./scripts/link 'github.com/stainless-sdks/deeptable-go@${{ github.ref_name }}' || go mod edit -dropreplace='github.com/stainless-sdks/deeptable-go' - name: Bootstrap run: ./scripts/bootstrap @@ -60,7 +60,7 @@ jobs: - name: Link staging branch if: github.repository == 'stainless-sdks/deeptable-cli' run: | - ./scripts/link 'github.com/stainless-sdks/deeptable-go@${{ github.ref_name }}' || go mod edit -dropreplace='github.com/stainless-sdks/deeptable-go + ./scripts/link 'github.com/stainless-sdks/deeptable-go@${{ github.ref_name }}' || go mod edit -dropreplace='github.com/stainless-sdks/deeptable-go' - name: Bootstrap run: ./scripts/bootstrap @@ -107,7 +107,7 @@ jobs: - name: Link staging branch if: github.repository == 'stainless-sdks/deeptable-cli' run: | - ./scripts/link 'github.com/stainless-sdks/deeptable-go@${{ github.ref_name }}' || go mod edit -dropreplace='github.com/stainless-sdks/deeptable-go + ./scripts/link 'github.com/stainless-sdks/deeptable-go@${{ github.ref_name }}' || go mod edit -dropreplace='github.com/stainless-sdks/deeptable-go' - name: Bootstrap run: ./scripts/bootstrap From 636523c180805486a19a0687901d9b4c27212e19 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 05:20:18 +0000 Subject: [PATCH 19/37] chore(internal): codegen related update --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 941a0c4..9d09716 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/charmbracelet/bubbletea v1.3.6 github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/x/term v0.2.1 - github.com/deeptable-com/deeptable-go v0.1.0-beta.2 + github.com/deeptable-com/deeptable-go v0.1.0-beta.3 github.com/goccy/go-yaml v1.18.0 github.com/itchyny/json2yaml v0.1.4 github.com/muesli/reflow v0.3.0 diff --git a/go.sum b/go.sum index be24130..79ea8a9 100644 --- a/go.sum +++ b/go.sum @@ -22,8 +22,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHH github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/deeptable-com/deeptable-go v0.1.0-beta.2 h1:5NL7fBmBW/47JP1nq18I69o1wl7WjKTXpISWlcHZUFg= -github.com/deeptable-com/deeptable-go v0.1.0-beta.2/go.mod h1:fIo2owpf3vsy9vMv3XyFe4ahUcPAjKtutJEobZ8R/jI= +github.com/deeptable-com/deeptable-go v0.1.0-beta.3 h1:q0jKrpq5LnDJU4GOsi3IayJy4V1T8PVbSQChaCQWgys= +github.com/deeptable-com/deeptable-go v0.1.0-beta.3/go.mod h1:fIo2owpf3vsy9vMv3XyFe4ahUcPAjKtutJEobZ8R/jI= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= From eca1743bead6e107e3c839d96245005d1401a0c7 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 11:23:04 +0000 Subject: [PATCH 20/37] chore(cli): let `--format raw` be used in conjunction with `--transform` --- pkg/cmd/cmdutil.go | 4 ++-- pkg/cmd/cmdutil_test.go | 42 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/cmdutil.go b/pkg/cmd/cmdutil.go index 768a1bd..a73e0ba 100644 --- a/pkg/cmd/cmdutil.go +++ b/pkg/cmd/cmdutil.go @@ -312,7 +312,7 @@ func shouldUseColors(w io.Writer) bool { } func formatJSON(expectedOutput *os.File, title string, res gjson.Result, format string, transform string) ([]byte, error) { - if format != "raw" && transform != "" { + if transform != "" { transformed := res.Get(transform) if transformed.Exists() { res = transformed @@ -356,7 +356,7 @@ func formatJSON(expectedOutput *os.File, title string, res gjson.Result, format // Display JSON to the user in various different formats func ShowJSON(out *os.File, title string, res gjson.Result, format string, transform string) error { - if format != "raw" && transform != "" { + if transform != "" { transformed := res.Get(transform) if transformed.Exists() { res = transformed diff --git a/pkg/cmd/cmdutil_test.go b/pkg/cmd/cmdutil_test.go index 8eca397..5178057 100644 --- a/pkg/cmd/cmdutil_test.go +++ b/pkg/cmd/cmdutil_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" ) func TestStreamOutput(t *testing.T) { @@ -148,3 +149,44 @@ func TestValidateBaseURL(t *testing.T) { assert.Contains(t, err.Error(), "--base-url") }) } + +func TestFormatJSON(t *testing.T) { + t.Parallel() + + t.Run("RawWithTransform", func(t *testing.T) { + t.Parallel() + + res := gjson.Parse(`{"id":"abc123","name":"test"}`) + formatted, err := formatJSON(os.Stdout, "test", res, "raw", "id") + require.NoError(t, err) + require.Equal(t, `"abc123"`+"\n", string(formatted)) + }) + + t.Run("RawWithoutTransform", func(t *testing.T) { + t.Parallel() + + res := gjson.Parse(`{"id":"abc123","name":"test"}`) + formatted, err := formatJSON(os.Stdout, "test", res, "raw", "") + require.NoError(t, err) + require.Equal(t, `{"id":"abc123","name":"test"}`+"\n", string(formatted)) + }) + + t.Run("RawWithNestedTransform", func(t *testing.T) { + t.Parallel() + + res := gjson.Parse(`{"data":{"items":[1,2,3]}}`) + formatted, err := formatJSON(os.Stdout, "test", res, "raw", "data.items") + require.NoError(t, err) + require.Equal(t, "[1,2,3]\n", string(formatted)) + }) + + t.Run("RawWithNonexistentTransform", func(t *testing.T) { + t.Parallel() + + res := gjson.Parse(`{"id":"abc123"}`) + formatted, err := formatJSON(os.Stdout, "test", res, "raw", "missing") + require.NoError(t, err) + // Transform path doesn't exist, so original result is returned + require.Equal(t, `{"id":"abc123"}`+"\n", string(formatted)) + }) +} From f8824621a6ffc592f70e7cefe0c0ded4ce892888 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:28:07 +0000 Subject: [PATCH 21/37] chore(cli): additional test cases for `ShowJSONIterator` --- pkg/cmd/cmdutil_test.go | 78 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/pkg/cmd/cmdutil_test.go b/pkg/cmd/cmdutil_test.go index 5178057..84e2f43 100644 --- a/pkg/cmd/cmdutil_test.go +++ b/pkg/cmd/cmdutil_test.go @@ -11,6 +11,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" + + "github.com/deeptable-com/deeptable-cli/internal/jsonview" ) func TestStreamOutput(t *testing.T) { @@ -190,3 +192,79 @@ func TestFormatJSON(t *testing.T) { require.Equal(t, `{"id":"abc123"}`+"\n", string(formatted)) }) } + +func TestShowJSONIterator(t *testing.T) { + t.Parallel() + + t.Run("RawMultipleItems", 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, "raw", "", -1) + assert.Equal(t, `{"id":"abc","name":"first"}`+"\n"+`{"id":"def","name":"second"}`+"\n", captured) + }) + + t.Run("RawWithTransform", 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, "raw", "id", -1) + assert.Equal(t, `"abc"`+"\n"+`"def"`+"\n", captured) + }) + + t.Run("LimitItems", func(t *testing.T) { + t.Parallel() + + iter := &sliceIterator[map[string]any]{items: []map[string]any{ + {"id": "abc"}, + {"id": "def"}, + {"id": "ghi"}, + }} + captured := captureShowJSONIterator(t, iter, "raw", "", 2) + assert.Equal(t, `{"id":"abc"}`+"\n"+`{"id":"def"}`+"\n", captured) + }) +} + +// sliceIterator is a simple iterator over a slice for testing. +type sliceIterator[T any] struct { + index int + items []T +} + +func (it *sliceIterator[T]) Next() bool { + it.index++ + return it.index <= len(it.items) +} + +func (it *sliceIterator[T]) Current() T { + return it.items[it.index-1] +} + +func (it *sliceIterator[T]) Err() error { + return nil +} + +var _ jsonview.Iterator[any] = (*sliceIterator[any])(nil) + +// captureShowJSONIterator runs ShowJSONIterator and captures the output written to a file. +func captureShowJSONIterator[T any](t *testing.T, iter jsonview.Iterator[T], format, transform string, itemsToDisplay int64) string { + t.Helper() + + r, w, err := os.Pipe() + require.NoError(t, err) + defer r.Close() + + err = ShowJSONIterator(w, "test", iter, format, transform, itemsToDisplay) + w.Close() + require.NoError(t, err) + + var buf bytes.Buffer + _, _ = buf.ReadFrom(r) + return buf.String() +} From b3ea45a6f1c7bc4111bf68507f061b642d6bb789 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:29:12 +0000 Subject: [PATCH 22/37] fix: fix for failing to drop invalid module replace in link script --- .github/workflows/ci.yml | 6 +++--- scripts/link | 8 ++++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2958689..ec5e177 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ jobs: - name: Link staging branch if: github.repository == 'stainless-sdks/deeptable-cli' run: | - ./scripts/link 'github.com/stainless-sdks/deeptable-go@${{ github.ref_name }}' || go mod edit -dropreplace='github.com/stainless-sdks/deeptable-go' + ./scripts/link 'github.com/stainless-sdks/deeptable-go@${{ github.ref_name }}' || true - name: Bootstrap run: ./scripts/bootstrap @@ -60,7 +60,7 @@ jobs: - name: Link staging branch if: github.repository == 'stainless-sdks/deeptable-cli' run: | - ./scripts/link 'github.com/stainless-sdks/deeptable-go@${{ github.ref_name }}' || go mod edit -dropreplace='github.com/stainless-sdks/deeptable-go' + ./scripts/link 'github.com/stainless-sdks/deeptable-go@${{ github.ref_name }}' || true - name: Bootstrap run: ./scripts/bootstrap @@ -107,7 +107,7 @@ jobs: - name: Link staging branch if: github.repository == 'stainless-sdks/deeptable-cli' run: | - ./scripts/link 'github.com/stainless-sdks/deeptable-go@${{ github.ref_name }}' || go mod edit -dropreplace='github.com/stainless-sdks/deeptable-go' + ./scripts/link 'github.com/stainless-sdks/deeptable-go@${{ github.ref_name }}' || true - name: Bootstrap run: ./scripts/bootstrap diff --git a/scripts/link b/scripts/link index bc835cb..1914e38 100755 --- a/scripts/link +++ b/scripts/link @@ -9,5 +9,9 @@ export GOPRIVATE="${GOPRIVATE:+$GOPRIVATE,}github.com/deeptable-com/deeptable-go REPLACEMENT="${1:-"../deeptable-go"}" echo "==> Replacing Go SDK with $REPLACEMENT" -go mod edit -replace github.com/deeptable-com/deeptable-go="$REPLACEMENT" -go mod tidy -e +if [[ -d "$REPLACEMENT" ]] || go list -m "$REPLACEMENT" >/dev/null; then + go mod edit -replace github.com/deeptable-com/deeptable-go="$REPLACEMENT" + go mod tidy -e +else + echo "Skipping Go SDK replacement (branch may not exist on Go SDK)" +fi From a8008f2a7b3a064ad21d86ab8800d6696c8d455e Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 05:32:21 +0000 Subject: [PATCH 23/37] chore: add documentation for ./scripts/link --- README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/README.md b/README.md index 003ffc4..1359636 100644 --- a/README.md +++ b/README.md @@ -112,3 +112,23 @@ base64-encoding). Note that absolute paths will begin with `@file://` or ```bash deeptable --arg @data://file.txt ``` + +## Linking different Go SDK versions + +You can link the CLI against a different version of the DeepTable Go SDK +for development purposes using the `./scripts/link` script. + +To link to a specific version from a repository (version can be a branch, +git tag, or commit hash): + +```bash +./scripts/link github.com/org/repo@version +``` + +To link to a local copy of the SDK: + +```bash +./scripts/link ../path/to/deeptable-go +``` + +If you run the link script without any arguments, it will default to `../deeptable-go`. From 22db507c72c8578edaea3d1295d08b22229430bd Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 05:13:30 +0000 Subject: [PATCH 24/37] chore(cli): fall back to JSON when using default "explore" with non-TTY --- cmd/deeptable/main.go | 2 +- pkg/cmd/cmdutil.go | 29 +++++++++++--- pkg/cmd/cmdutil_test.go | 70 ++++++++++++++++++++++++++++++++- pkg/cmd/file.go | 14 ++++--- pkg/cmd/structuredsheet.go | 17 +++++--- pkg/cmd/structuredsheettable.go | 8 ++-- 6 files changed, 118 insertions(+), 22 deletions(-) diff --git a/cmd/deeptable/main.go b/cmd/deeptable/main.go index bbf45de..bde85c1 100644 --- a/cmd/deeptable/main.go +++ b/cmd/deeptable/main.go @@ -43,7 +43,7 @@ func main() { fmt.Fprintf(os.Stderr, "%s %q: %d %s\n", apierr.Request.Method, apierr.Request.URL, apierr.Response.StatusCode, http.StatusText(apierr.Response.StatusCode)) format := app.String("format-error") json := gjson.Parse(apierr.RawJSON()) - show_err := cmd.ShowJSON(os.Stdout, "Error", json, format, app.String("transform-error")) + show_err := cmd.ShowJSON(os.Stdout, os.Stderr, "Error", json, format, app.IsSet("format-error"), app.String("transform-error")) if show_err != nil { // Just print the original error: fmt.Fprintf(os.Stderr, "%s\n", err.Error()) diff --git a/pkg/cmd/cmdutil.go b/pkg/cmd/cmdutil.go index a73e0ba..22d875d 100644 --- a/pkg/cmd/cmdutil.go +++ b/pkg/cmd/cmdutil.go @@ -354,8 +354,13 @@ func formatJSON(expectedOutput *os.File, title string, res gjson.Result, format } } -// Display JSON to the user in various different formats -func ShowJSON(out *os.File, title string, res gjson.Result, format string, transform string) error { +const warningExploreNotSupported = "Warning: Output format 'explore' not supported for non-terminal output; falling back to 'json'\n" + +// Display JSON to the user in various different formats. The explicitFormat parameter indicates +// whether the format was explicitly set by the user (via --format), which controls whether we +// silently fall back to json when explore is requested on non-terminal output. Warnings are +// written to stderr. +func ShowJSON(out *os.File, stderr io.Writer, title string, res gjson.Result, format string, explicitFormat bool, transform string) error { if transform != "" { transformed := res.Get(transform) if transformed.Exists() { @@ -365,8 +370,14 @@ func ShowJSON(out *os.File, title string, res gjson.Result, format string, trans switch strings.ToLower(format) { case "auto": - return ShowJSON(out, title, res, "json", "") + return ShowJSON(out, stderr, title, res, "json", explicitFormat, "") case "explore": + if !isTerminal(out) { + if explicitFormat { + fmt.Fprint(stderr, warningExploreNotSupported) + } + return ShowJSON(out, stderr, title, res, "json", explicitFormat, transform) + } return jsonview.ExploreJSON(title, res) default: bytes, err := formatJSON(out, title, res, format, transform) @@ -391,9 +402,15 @@ type hasRawJSON interface { // For an iterator over different value types, display its values to the user in // different formats. // -1 is used to signal no limit of items to display -func ShowJSONIterator[T any](stdout *os.File, title string, iter jsonview.Iterator[T], format string, transform string, itemsToDisplay int64) error { +func ShowJSONIterator[T any](stdout *os.File, stderr io.Writer, title string, iter jsonview.Iterator[T], format string, explicitFormat bool, transform string, itemsToDisplay int64) error { if format == "explore" { - return jsonview.ExploreJSONStream(title, iter) + if isTerminal(stdout) { + return jsonview.ExploreJSONStream(title, iter) + } + if explicitFormat { + fmt.Fprint(stderr, warningExploreNotSupported) + } + format = "json" } terminalWidth, terminalHeight, err := term.GetSize(os.Stdout.Fd()) @@ -466,7 +483,7 @@ func ShowJSONIterator[T any](stdout *os.File, title string, iter jsonview.Iterat } obj = gjson.ParseBytes(jsonData) } - if err := ShowJSON(pager, title, obj, format, transform); err != nil { + if err := ShowJSON(pager, stderr, title, obj, format, explicitFormat, transform); err != nil { return err } itemsToDisplay -= 1 diff --git a/pkg/cmd/cmdutil_test.go b/pkg/cmd/cmdutil_test.go index 84e2f43..98d2147 100644 --- a/pkg/cmd/cmdutil_test.go +++ b/pkg/cmd/cmdutil_test.go @@ -231,6 +231,73 @@ func TestShowJSONIterator(t *testing.T) { }) } +func TestExploreFallback(t *testing.T) { + t.Parallel() + + t.Run("ShowJSONFallsBackToJsonOnNonTTY", func(t *testing.T) { + t.Parallel() + + // os.Pipe() produces a *os.File that isn't a terminal, so explore should fall back. + r, w, err := os.Pipe() + require.NoError(t, err) + defer r.Close() + + var stderr bytes.Buffer + res := gjson.Parse(`{"id":"abc"}`) + err = ShowJSON(w, &stderr, "test", res, "explore", false, "") + w.Close() + require.NoError(t, err) + + var buf bytes.Buffer + _, _ = buf.ReadFrom(r) + assert.Contains(t, buf.String(), `"id"`) + assert.Contains(t, buf.String(), `"abc"`) + }) + + t.Run("ShowJSONIteratorFallsBackToJsonOnNonTTY", func(t *testing.T) { + t.Parallel() + + iter := &sliceIterator[map[string]any]{items: []map[string]any{ + {"id": "abc"}, + }} + captured := captureShowJSONIterator(t, iter, "explore", "", -1) + assert.Contains(t, captured, `"id"`) + assert.Contains(t, captured, `"abc"`) + }) + + t.Run("ShowJSONWarnsWhenExplicitFormatOnNonTTY", func(t *testing.T) { + t.Parallel() + + r, w, err := os.Pipe() + require.NoError(t, err) + defer r.Close() + + var stderr bytes.Buffer + res := gjson.Parse(`{"id":"abc"}`) + err = ShowJSON(w, &stderr, "test", res, "explore", true, "") + w.Close() + require.NoError(t, err) + + assert.Equal(t, warningExploreNotSupported, stderr.String()) + }) + + t.Run("ShowJSONSilentWhenDefaultFormatOnNonTTY", func(t *testing.T) { + t.Parallel() + + r, w, err := os.Pipe() + require.NoError(t, err) + defer r.Close() + + var stderr bytes.Buffer + res := gjson.Parse(`{"id":"abc"}`) + err = ShowJSON(w, &stderr, "test", res, "explore", false, "") + w.Close() + require.NoError(t, err) + + assert.Empty(t, stderr.String(), "no warning expected when format was not explicit") + }) +} + // sliceIterator is a simple iterator over a slice for testing. type sliceIterator[T any] struct { index int @@ -260,7 +327,8 @@ func captureShowJSONIterator[T any](t *testing.T, iter jsonview.Iterator[T], for require.NoError(t, err) defer r.Close() - err = ShowJSONIterator(w, "test", iter, format, transform, itemsToDisplay) + var stderr bytes.Buffer + err = ShowJSONIterator(w, &stderr, "test", iter, format, false, transform, itemsToDisplay) w.Close() require.NoError(t, err) diff --git a/pkg/cmd/file.go b/pkg/cmd/file.go index f527218..82f9e1b 100644 --- a/pkg/cmd/file.go +++ b/pkg/cmd/file.go @@ -138,8 +138,9 @@ func handleFilesRetrieve(ctx context.Context, cmd *cli.Command) error { obj := gjson.ParseBytes(res) format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "files retrieve", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "files retrieve", obj, format, explicitFormat, transform) } func handleFilesList(ctx context.Context, cmd *cli.Command) error { @@ -164,6 +165,7 @@ func handleFilesList(ctx context.Context, cmd *cli.Command) error { } format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") if format == "raw" { var res []byte @@ -173,14 +175,14 @@ func handleFilesList(ctx context.Context, cmd *cli.Command) error { return err } obj := gjson.ParseBytes(res) - return ShowJSON(os.Stdout, "files list", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "files list", obj, format, explicitFormat, transform) } else { iter := client.Files.ListAutoPaging(ctx, params, options...) maxItems := int64(-1) if cmd.IsSet("max-items") { maxItems = cmd.Value("max-items").(int64) } - return ShowJSONIterator(os.Stdout, "files list", iter, format, transform, maxItems) + return ShowJSONIterator(os.Stdout, os.Stderr, "files list", iter, format, explicitFormat, transform, maxItems) } } @@ -215,8 +217,9 @@ func handleFilesDelete(ctx context.Context, cmd *cli.Command) error { obj := gjson.ParseBytes(res) format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "files delete", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "files delete", obj, format, explicitFormat, transform) } func handleFilesDownload(ctx context.Context, cmd *cli.Command) error { @@ -282,6 +285,7 @@ func handleFilesUpload(ctx context.Context, cmd *cli.Command) error { obj := gjson.ParseBytes(res) format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "files upload", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "files upload", obj, format, explicitFormat, transform) } diff --git a/pkg/cmd/structuredsheet.go b/pkg/cmd/structuredsheet.go index c908f8e..ca71981 100644 --- a/pkg/cmd/structuredsheet.go +++ b/pkg/cmd/structuredsheet.go @@ -162,8 +162,9 @@ func handleStructuredSheetsCreate(ctx context.Context, cmd *cli.Command) error { obj := gjson.ParseBytes(res) format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "structured-sheets create", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "structured-sheets create", obj, format, explicitFormat, transform) } func handleStructuredSheetsRetrieve(ctx context.Context, cmd *cli.Command) error { @@ -197,8 +198,9 @@ func handleStructuredSheetsRetrieve(ctx context.Context, cmd *cli.Command) error obj := gjson.ParseBytes(res) format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "structured-sheets retrieve", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "structured-sheets retrieve", obj, format, explicitFormat, transform) } func handleStructuredSheetsList(ctx context.Context, cmd *cli.Command) error { @@ -223,6 +225,7 @@ func handleStructuredSheetsList(ctx context.Context, cmd *cli.Command) error { } format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") if format == "raw" { var res []byte @@ -232,14 +235,14 @@ func handleStructuredSheetsList(ctx context.Context, cmd *cli.Command) error { return err } obj := gjson.ParseBytes(res) - return ShowJSON(os.Stdout, "structured-sheets list", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "structured-sheets list", obj, format, explicitFormat, transform) } else { iter := client.StructuredSheets.ListAutoPaging(ctx, params, options...) maxItems := int64(-1) if cmd.IsSet("max-items") { maxItems = cmd.Value("max-items").(int64) } - return ShowJSONIterator(os.Stdout, "structured-sheets list", iter, format, transform, maxItems) + return ShowJSONIterator(os.Stdout, os.Stderr, "structured-sheets list", iter, format, explicitFormat, transform, maxItems) } } @@ -274,8 +277,9 @@ func handleStructuredSheetsDelete(ctx context.Context, cmd *cli.Command) error { obj := gjson.ParseBytes(res) format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "structured-sheets delete", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "structured-sheets delete", obj, format, explicitFormat, transform) } func handleStructuredSheetsCancel(ctx context.Context, cmd *cli.Command) error { @@ -309,8 +313,9 @@ func handleStructuredSheetsCancel(ctx context.Context, cmd *cli.Command) error { obj := gjson.ParseBytes(res) format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "structured-sheets cancel", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "structured-sheets cancel", obj, format, explicitFormat, transform) } func handleStructuredSheetsDownload(ctx context.Context, cmd *cli.Command) error { diff --git a/pkg/cmd/structuredsheettable.go b/pkg/cmd/structuredsheettable.go index db95efa..7dab957 100644 --- a/pkg/cmd/structuredsheettable.go +++ b/pkg/cmd/structuredsheettable.go @@ -136,8 +136,9 @@ func handleStructuredSheetsTablesRetrieve(ctx context.Context, cmd *cli.Command) obj := gjson.ParseBytes(res) format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "structured-sheets:tables retrieve", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "structured-sheets:tables retrieve", obj, format, explicitFormat, transform) } func handleStructuredSheetsTablesList(ctx context.Context, cmd *cli.Command) error { @@ -165,6 +166,7 @@ func handleStructuredSheetsTablesList(ctx context.Context, cmd *cli.Command) err } format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") if format == "raw" { var res []byte @@ -179,7 +181,7 @@ func handleStructuredSheetsTablesList(ctx context.Context, cmd *cli.Command) err return err } obj := gjson.ParseBytes(res) - return ShowJSON(os.Stdout, "structured-sheets:tables list", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "structured-sheets:tables list", obj, format, explicitFormat, transform) } else { iter := client.StructuredSheets.Tables.ListAutoPaging( ctx, @@ -191,7 +193,7 @@ func handleStructuredSheetsTablesList(ctx context.Context, cmd *cli.Command) err if cmd.IsSet("max-items") { maxItems = cmd.Value("max-items").(int64) } - return ShowJSONIterator(os.Stdout, "structured-sheets:tables list", iter, format, transform, maxItems) + return ShowJSONIterator(os.Stdout, os.Stderr, "structured-sheets:tables list", iter, format, explicitFormat, transform, maxItems) } } From 9a763d3b2a19e9e1fb60cab9da8a9562a0760177 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 05:14:43 +0000 Subject: [PATCH 25/37] feat(cli): alias parameters in data with `x-stainless-cli-data-alias` --- internal/requestflag/innerflag.go | 19 ++++++++++-- internal/requestflag/requestflag.go | 9 ++++++ pkg/cmd/flagoptions.go | 47 +++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/internal/requestflag/innerflag.go b/internal/requestflag/innerflag.go index 102624f..eeeb8bc 100644 --- a/internal/requestflag/innerflag.go +++ b/internal/requestflag/innerflag.go @@ -22,14 +22,29 @@ type InnerFlag[ Aliases []string // aliases that are allowed for this flag Validator func(T) error // custom function to validate this flag value - OuterFlag cli.Flag // The flag on which this inner flag will set values - InnerField string // The inner field which this flag will set + OuterFlag cli.Flag // The flag on which this inner flag will set values + InnerField string // The inner field which this flag will set + DataAliases []string // alternate names recognized in YAML values passed as the outer flag +} + +// GetDataAliases returns the aliases recognized when parsing inner field keys from piped or flag YAML. +func (f *InnerFlag[T]) GetDataAliases() []string { + return f.DataAliases +} + +// GetInnerField returns the API field name that this inner flag sets on its outer flag's value. +// For example, the flag --parent.foo targeting a parameter whose OpenAPI property name is "foo" +// would return "foo". This is distinct from the flag's CLI name and from any DataAliases entries. +func (f *InnerFlag[T]) GetInnerField() string { + return f.InnerField } type HasOuterFlag interface { cli.Flag SetOuterFlag(cli.Flag) GetOuterFlag() cli.Flag + GetInnerField() string + GetDataAliases() []string } func (f *InnerFlag[T]) SetOuterFlag(flag cli.Flag) { diff --git a/internal/requestflag/requestflag.go b/internal/requestflag/requestflag.go index bdef64f..bfaf064 100644 --- a/internal/requestflag/requestflag.go +++ b/internal/requestflag/requestflag.go @@ -48,6 +48,10 @@ type Flag[ // binary` in the OpenAPI spec. FileInput bool + // DataAliases is a list of alternate names for this parameter recognized when parsing piped YAML/JSON + // input. Values keyed by any alias are translated to the canonical API name before being sent. + DataAliases []string + // unexported fields for internal use count int // number of times the flag has been set hasBeenSet bool // whether the flag has been set from env or file @@ -65,6 +69,7 @@ type InRequest interface { GetBodyPath() string IsBodyRoot() bool IsFileInput() bool + GetDataAliases() []string } func (f Flag[T]) GetQueryPath() string { @@ -87,6 +92,10 @@ func (f Flag[T]) IsFileInput() bool { return f.FileInput } +func (f Flag[T]) GetDataAliases() []string { + return f.DataAliases +} + // The values that will be sent in different parts of a request. type RequestContents struct { Queries map[string]any diff --git a/pkg/cmd/flagoptions.go b/pkg/cmd/flagoptions.go index 48a170a..cbe168b 100644 --- a/pkg/cmd/flagoptions.go +++ b/pkg/cmd/flagoptions.go @@ -309,6 +309,12 @@ func flagOptions( requestContents := requestflag.ExtractRequestContents(cmd) + // Translate inner-field aliases in YAML values that came from flags (e.g. + // `--parent '{"alias": val}'` resolving to the canonical inner field). + if bodyMap, ok := requestContents.Body.(map[string]any); ok { + applyDataAliases(cmd, bodyMap) + } + stdinConsumedByPipe := false if (bodyType == MultipartFormEncoded || bodyType == ApplicationJSON) && !ignoreStdin && isInputPiped() { pipeData, err := io.ReadAll(os.Stdin) @@ -323,6 +329,7 @@ func flagOptions( return nil, fmt.Errorf("Failed to parse piped data as YAML/JSON:\n%w", err) } if bodyMap, ok := bodyData.(map[string]any); ok { + applyDataAliases(cmd, bodyMap) if flagMap, ok := requestContents.Body.(map[string]any); ok { maps.Copy(bodyMap, flagMap) requestContents.Body = bodyMap @@ -485,6 +492,46 @@ func flagOptions( // as a file path without needing the "@" prefix. type FilePathValue string +// applyDataAliases rewrites keys in a body map based on flag `DataAliases` metadata. For top-level flags, +// `{alias: value}` becomes `{canonical: value}`. For inner flags (those registered under an outer flag +// via WithInnerFlags), the alias translation is also applied to the nested map under the outer flag's +// body path, so values like `--parent '{"alias": val}'` resolve to the canonical inner field name. +func applyDataAliases(cmd *cli.Command, bodyMap map[string]any) { + for _, flag := range cmd.Flags { + // Inner flags: rewrite aliases inside the nested map under the outer flag's body path. + if inner, ok := flag.(requestflag.HasOuterFlag); ok { + outer, outerOk := inner.GetOuterFlag().(requestflag.InRequest) + if !outerOk { + continue + } + if nested, ok := bodyMap[outer.GetBodyPath()].(map[string]any); ok && inner.GetInnerField() != "" { + rewriteAliases(nested, inner.GetInnerField(), inner.GetDataAliases()) + } + continue + } + // Top-level flags: rewrite aliases in the body map. + if inReq, ok := flag.(requestflag.InRequest); ok && inReq.GetBodyPath() != "" { + rewriteAliases(bodyMap, inReq.GetBodyPath(), inReq.GetDataAliases()) + } + } +} + +// rewriteAliases replaces each alias key in m with the canonical key, preserving the value. The +// "canonical" key is the name the API itself expects (the OpenAPI property/field name) — e.g. for +// a top-level flag, the parameter's BodyPath; for an inner flag, the inner field name. Aliases are +// the user-facing alternate names declared via x-stainless-cli-data-alias. +func rewriteAliases(m map[string]any, canonical string, aliases []string) { + for _, alias := range aliases { + if alias == "" || alias == canonical { + continue + } + if val, exists := m[alias]; exists { + m[canonical] = val + delete(m, alias) + } + } +} + // wrapFileInputValues replaces string values for FileInput flags (type: string, format: binary) with // FilePathValue sentinel values. embedFilesValue recognizes FilePathValue and reads the file contents // directly, so the user doesn't need to type the "@" prefix. This handles both values set via explicit From 5d7b97aad32a194a00a4f43c95197bbd018413b2 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 04:21:45 +0000 Subject: [PATCH 26/37] chore(cli): switch long lists of positional args over to param structs --- cmd/deeptable/main.go | 7 ++- pkg/cmd/cmdutil.go | 84 +++++++++++++++++++++------------ pkg/cmd/cmdutil_test.go | 31 ++++++++++-- pkg/cmd/file.go | 35 ++++++++++++-- pkg/cmd/structuredsheet.go | 42 ++++++++++++++--- pkg/cmd/structuredsheettable.go | 21 +++++++-- 6 files changed, 171 insertions(+), 49 deletions(-) diff --git a/cmd/deeptable/main.go b/cmd/deeptable/main.go index bde85c1..3c11dc1 100644 --- a/cmd/deeptable/main.go +++ b/cmd/deeptable/main.go @@ -43,7 +43,12 @@ func main() { fmt.Fprintf(os.Stderr, "%s %q: %d %s\n", apierr.Request.Method, apierr.Request.URL, apierr.Response.StatusCode, http.StatusText(apierr.Response.StatusCode)) format := app.String("format-error") json := gjson.Parse(apierr.RawJSON()) - show_err := cmd.ShowJSON(os.Stdout, os.Stderr, "Error", json, format, app.IsSet("format-error"), app.String("transform-error")) + show_err := cmd.ShowJSON(json, cmd.ShowJSONOpts{ + ExplicitFormat: app.IsSet("format-error"), + Format: format, + Title: "Error", + Transform: app.String("transform-error"), + }) if show_err != nil { // Just print the original error: fmt.Fprintf(os.Stderr, "%s\n", err.Error()) diff --git a/pkg/cmd/cmdutil.go b/pkg/cmd/cmdutil.go index 22d875d..c38d3d5 100644 --- a/pkg/cmd/cmdutil.go +++ b/pkg/cmd/cmdutil.go @@ -356,36 +356,58 @@ func formatJSON(expectedOutput *os.File, title string, res gjson.Result, format const warningExploreNotSupported = "Warning: Output format 'explore' not supported for non-terminal output; falling back to 'json'\n" -// Display JSON to the user in various different formats. The explicitFormat parameter indicates -// whether the format was explicitly set by the user (via --format), which controls whether we -// silently fall back to json when explore is requested on non-terminal output. Warnings are -// written to stderr. -func ShowJSON(out *os.File, stderr io.Writer, title string, res gjson.Result, format string, explicitFormat bool, transform string) error { - if transform != "" { - transformed := res.Get(transform) +// 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) + 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 +} + +func (o *ShowJSONOpts) setDefaults() { + if o.Stderr == nil { + o.Stderr = os.Stderr + } + if o.Stdout == nil { + o.Stdout = os.Stdout + } +} + +// ShowJSON displays a single JSON result to the user. +func ShowJSON(res gjson.Result, opts ShowJSONOpts) error { + opts.setDefaults() + + if opts.Transform != "" { + transformed := res.Get(opts.Transform) if transformed.Exists() { res = transformed } } - switch strings.ToLower(format) { + switch strings.ToLower(opts.Format) { case "auto": - return ShowJSON(out, stderr, title, res, "json", explicitFormat, "") + jsonOpts := opts + jsonOpts.Format = "json" + return ShowJSON(res, jsonOpts) case "explore": - if !isTerminal(out) { - if explicitFormat { - fmt.Fprint(stderr, warningExploreNotSupported) + if !isTerminal(opts.Stdout) { + if opts.ExplicitFormat { + fmt.Fprint(opts.Stderr, warningExploreNotSupported) } - return ShowJSON(out, stderr, title, res, "json", explicitFormat, transform) + jsonOpts := opts + jsonOpts.Format = "json" + return ShowJSON(res, jsonOpts) } - return jsonview.ExploreJSON(title, res) + return jsonview.ExploreJSON(opts.Title, res) default: - bytes, err := formatJSON(out, title, res, format, transform) + bytes, err := formatJSON(opts.Stdout, opts.Title, res, opts.Format, opts.Transform) if err != nil { return err } - _, err = out.Write(bytes) + _, err = opts.Stdout.Write(bytes) return err } } @@ -399,16 +421,17 @@ type hasRawJSON interface { RawJSON() string } -// For an iterator over different value types, display its values to the user in -// different formats. -// -1 is used to signal no limit of items to display -func ShowJSONIterator[T any](stdout *os.File, stderr io.Writer, title string, iter jsonview.Iterator[T], format string, explicitFormat bool, transform string, itemsToDisplay int64) error { +// ShowJSONIterator displays an iterator of values to the user. Use itemsToDisplay = -1 for no limit. +func ShowJSONIterator[T any](iter jsonview.Iterator[T], itemsToDisplay int64, opts ShowJSONOpts) error { + opts.setDefaults() + + format := opts.Format if format == "explore" { - if isTerminal(stdout) { - return jsonview.ExploreJSONStream(title, iter) + if isTerminal(opts.Stdout) { + return jsonview.ExploreJSONStream(opts.Title, iter) } - if explicitFormat { - fmt.Fprint(stderr, warningExploreNotSupported) + if opts.ExplicitFormat { + fmt.Fprint(opts.Stderr, warningExploreNotSupported) } format = "json" } @@ -436,7 +459,7 @@ func ShowJSONIterator[T any](stdout *os.File, stderr io.Writer, title string, it } obj = gjson.ParseBytes(jsonData) } - json, err := formatJSON(stdout, title, obj, format, transform) + json, err := formatJSON(opts.Stdout, opts.Title, obj, format, opts.Transform) if err != nil { return err } @@ -453,7 +476,7 @@ func ShowJSONIterator[T any](stdout *os.File, stderr io.Writer, title string, it } if !usePager { - _, err := stdout.Write(output) + _, err := opts.Stdout.Write(output) if err != nil { return err } @@ -461,13 +484,16 @@ func ShowJSONIterator[T any](stdout *os.File, stderr io.Writer, title string, it return iter.Err() } - return streamOutput(title, func(pager *os.File) error { - // Write the output we used during the initial terminal size computation + return streamOutput(opts.Title, func(pager *os.File) error { _, err := pager.Write(output) if err != nil { return err } + pagerOpts := opts + pagerOpts.Format = format + pagerOpts.Stdout = pager + for iter.Next() { if itemsToDisplay == 0 { break @@ -483,7 +509,7 @@ func ShowJSONIterator[T any](stdout *os.File, stderr io.Writer, title string, it } obj = gjson.ParseBytes(jsonData) } - if err := ShowJSON(pager, stderr, title, obj, format, explicitFormat, transform); err != nil { + if err := ShowJSON(obj, pagerOpts); err != nil { return err } itemsToDisplay -= 1 diff --git a/pkg/cmd/cmdutil_test.go b/pkg/cmd/cmdutil_test.go index 98d2147..179b17c 100644 --- a/pkg/cmd/cmdutil_test.go +++ b/pkg/cmd/cmdutil_test.go @@ -244,7 +244,12 @@ func TestExploreFallback(t *testing.T) { var stderr bytes.Buffer res := gjson.Parse(`{"id":"abc"}`) - err = ShowJSON(w, &stderr, "test", res, "explore", false, "") + err = ShowJSON(res, ShowJSONOpts{ + Format: "explore", + Stderr: &stderr, + Stdout: w, + Title: "test", + }) w.Close() require.NoError(t, err) @@ -274,7 +279,13 @@ func TestExploreFallback(t *testing.T) { var stderr bytes.Buffer res := gjson.Parse(`{"id":"abc"}`) - err = ShowJSON(w, &stderr, "test", res, "explore", true, "") + err = ShowJSON(res, ShowJSONOpts{ + ExplicitFormat: true, + Format: "explore", + Stderr: &stderr, + Stdout: w, + Title: "test", + }) w.Close() require.NoError(t, err) @@ -290,7 +301,12 @@ func TestExploreFallback(t *testing.T) { var stderr bytes.Buffer res := gjson.Parse(`{"id":"abc"}`) - err = ShowJSON(w, &stderr, "test", res, "explore", false, "") + err = ShowJSON(res, ShowJSONOpts{ + Format: "explore", + Stderr: &stderr, + Stdout: w, + Title: "test", + }) w.Close() require.NoError(t, err) @@ -327,8 +343,13 @@ func captureShowJSONIterator[T any](t *testing.T, iter jsonview.Iterator[T], for require.NoError(t, err) defer r.Close() - var stderr bytes.Buffer - err = ShowJSONIterator(w, &stderr, "test", iter, format, false, transform, itemsToDisplay) + err = ShowJSONIterator(iter, itemsToDisplay, ShowJSONOpts{ + Format: format, + Stderr: io.Discard, + Stdout: w, + Title: "test", + Transform: transform, + }) w.Close() require.NoError(t, err) diff --git a/pkg/cmd/file.go b/pkg/cmd/file.go index 82f9e1b..6e4c813 100644 --- a/pkg/cmd/file.go +++ b/pkg/cmd/file.go @@ -140,7 +140,12 @@ func handleFilesRetrieve(ctx context.Context, cmd *cli.Command) error { format := cmd.Root().String("format") explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, os.Stderr, "files retrieve", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "files retrieve", + Transform: transform, + }) } func handleFilesList(ctx context.Context, cmd *cli.Command) error { @@ -175,14 +180,24 @@ func handleFilesList(ctx context.Context, cmd *cli.Command) error { return err } obj := gjson.ParseBytes(res) - return ShowJSON(os.Stdout, os.Stderr, "files list", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "files list", + Transform: transform, + }) } else { iter := client.Files.ListAutoPaging(ctx, params, options...) maxItems := int64(-1) if cmd.IsSet("max-items") { maxItems = cmd.Value("max-items").(int64) } - return ShowJSONIterator(os.Stdout, os.Stderr, "files list", iter, format, explicitFormat, transform, maxItems) + return ShowJSONIterator(iter, maxItems, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "files list", + Transform: transform, + }) } } @@ -219,7 +234,12 @@ func handleFilesDelete(ctx context.Context, cmd *cli.Command) error { format := cmd.Root().String("format") explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, os.Stderr, "files delete", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "files delete", + Transform: transform, + }) } func handleFilesDownload(ctx context.Context, cmd *cli.Command) error { @@ -287,5 +307,10 @@ func handleFilesUpload(ctx context.Context, cmd *cli.Command) error { format := cmd.Root().String("format") explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, os.Stderr, "files upload", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "files upload", + Transform: transform, + }) } diff --git a/pkg/cmd/structuredsheet.go b/pkg/cmd/structuredsheet.go index ca71981..4016538 100644 --- a/pkg/cmd/structuredsheet.go +++ b/pkg/cmd/structuredsheet.go @@ -164,7 +164,12 @@ func handleStructuredSheetsCreate(ctx context.Context, cmd *cli.Command) error { format := cmd.Root().String("format") explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, os.Stderr, "structured-sheets create", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "structured-sheets create", + Transform: transform, + }) } func handleStructuredSheetsRetrieve(ctx context.Context, cmd *cli.Command) error { @@ -200,7 +205,12 @@ func handleStructuredSheetsRetrieve(ctx context.Context, cmd *cli.Command) error format := cmd.Root().String("format") explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, os.Stderr, "structured-sheets retrieve", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "structured-sheets retrieve", + Transform: transform, + }) } func handleStructuredSheetsList(ctx context.Context, cmd *cli.Command) error { @@ -235,14 +245,24 @@ func handleStructuredSheetsList(ctx context.Context, cmd *cli.Command) error { return err } obj := gjson.ParseBytes(res) - return ShowJSON(os.Stdout, os.Stderr, "structured-sheets list", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "structured-sheets list", + Transform: transform, + }) } else { iter := client.StructuredSheets.ListAutoPaging(ctx, params, options...) maxItems := int64(-1) if cmd.IsSet("max-items") { maxItems = cmd.Value("max-items").(int64) } - return ShowJSONIterator(os.Stdout, os.Stderr, "structured-sheets list", iter, format, explicitFormat, transform, maxItems) + return ShowJSONIterator(iter, maxItems, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "structured-sheets list", + Transform: transform, + }) } } @@ -279,7 +299,12 @@ func handleStructuredSheetsDelete(ctx context.Context, cmd *cli.Command) error { format := cmd.Root().String("format") explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, os.Stderr, "structured-sheets delete", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "structured-sheets delete", + Transform: transform, + }) } func handleStructuredSheetsCancel(ctx context.Context, cmd *cli.Command) error { @@ -315,7 +340,12 @@ func handleStructuredSheetsCancel(ctx context.Context, cmd *cli.Command) error { format := cmd.Root().String("format") explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, os.Stderr, "structured-sheets cancel", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "structured-sheets cancel", + Transform: transform, + }) } func handleStructuredSheetsDownload(ctx context.Context, cmd *cli.Command) error { diff --git a/pkg/cmd/structuredsheettable.go b/pkg/cmd/structuredsheettable.go index 7dab957..49ad4c5 100644 --- a/pkg/cmd/structuredsheettable.go +++ b/pkg/cmd/structuredsheettable.go @@ -138,7 +138,12 @@ func handleStructuredSheetsTablesRetrieve(ctx context.Context, cmd *cli.Command) format := cmd.Root().String("format") explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, os.Stderr, "structured-sheets:tables retrieve", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "structured-sheets:tables retrieve", + Transform: transform, + }) } func handleStructuredSheetsTablesList(ctx context.Context, cmd *cli.Command) error { @@ -181,7 +186,12 @@ func handleStructuredSheetsTablesList(ctx context.Context, cmd *cli.Command) err return err } obj := gjson.ParseBytes(res) - return ShowJSON(os.Stdout, os.Stderr, "structured-sheets:tables list", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "structured-sheets:tables list", + Transform: transform, + }) } else { iter := client.StructuredSheets.Tables.ListAutoPaging( ctx, @@ -193,7 +203,12 @@ func handleStructuredSheetsTablesList(ctx context.Context, cmd *cli.Command) err if cmd.IsSet("max-items") { maxItems = cmd.Value("max-items").(int64) } - return ShowJSONIterator(os.Stdout, os.Stderr, "structured-sheets:tables list", iter, format, explicitFormat, transform, maxItems) + return ShowJSONIterator(iter, maxItems, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "structured-sheets:tables list", + Transform: transform, + }) } } From 92efacd686131b1245b8f25467daaba4c650d602 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 08:07:17 +0000 Subject: [PATCH 27/37] chore(ci): support manually triggering release workflow --- .github/workflows/publish-release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 263859c..0858063 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -10,6 +10,7 @@ on: push: tags: - "v*" + workflow_dispatch: {} jobs: goreleaser: runs-on: ubuntu-latest From 4116cf01842502beea08e26368f9d99a5049bb06 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 08:08:26 +0000 Subject: [PATCH 28/37] feat(cli): send filename and content type when reading input from files --- pkg/cmd/flagoptions.go | 65 +++++++++++++++++++++++++++++++++++-- pkg/cmd/flagoptions_test.go | 59 ++++++++++++++++++++++++++++++--- 2 files changed, 117 insertions(+), 7 deletions(-) diff --git a/pkg/cmd/flagoptions.go b/pkg/cmd/flagoptions.go index cbe168b..1934f91 100644 --- a/pkg/cmd/flagoptions.go +++ b/pkg/cmd/flagoptions.go @@ -7,9 +7,11 @@ import ( "fmt" "io" "maps" + "mime" "mime/multipart" "net/http" "os" + "path/filepath" "reflect" "strings" "unicode/utf8" @@ -36,7 +38,14 @@ const ( type FileEmbedStyle int const ( + // EmbedText reads referenced files fully into memory and substitutes the file's contents back into the + // value as a string. Binary files are base64-encoded. Used for JSON request bodies and for headers and + // query parameters, where the file contents need to be serialized inline. EmbedText FileEmbedStyle = iota + + // EmbedIOReader replaces file references with an io.Reader that streams the file's contents. Used for + // `multipart/form-data` and `application/octet-stream` request bodies, where files are uploaded as binary + // parts rather than embedded into a text value. EmbedIOReader ) @@ -142,6 +151,20 @@ func embedFilesValue(v reflect.Value, embedStyle FileEmbedStyle, stdin *onceStdi if s == "" { return v, nil } + if embedStyle == EmbedIOReader { + if isStdinPath(s) { + r, err := stdin.read() + if err != nil { + return v, err + } + return reflect.ValueOf(io.NopCloser(r)), nil + } + upload, err := openFileUpload(s) + if err != nil { + return v, err + } + return reflect.ValueOf(upload), nil + } if isStdinPath(s) { content, err := stdin.readAll() if err != nil { @@ -250,7 +273,7 @@ func embedFilesValue(v reflect.Value, embedStyle FileEmbedStyle, stdin *onceStdi return reflect.ValueOf(io.NopCloser(r)), nil } - file, err := os.Open(filename) + upload, err := openFileUpload(filename) if err != nil { if !expectsFile { // For strings that start with "@" and don't look like a filename, return the string @@ -258,7 +281,7 @@ func embedFilesValue(v reflect.Value, embedStyle FileEmbedStyle, stdin *onceStdi } return v, err } - return reflect.ValueOf(file), nil + return reflect.ValueOf(upload), nil } } return v, nil @@ -492,6 +515,44 @@ func flagOptions( // as a file path without needing the "@" prefix. type FilePathValue string +// fileUpload wraps an io.Reader with filename and content-type metadata for +// use as a multipart form part. The apiform encoder detects the Filename and +// ContentType methods and uses them to populate the Content-Disposition +// filename and the Content-Type header on the part. +type fileUpload struct { + io.Reader // apiform checks for reader and reads its contents during encode + filename string + contentType string +} + +func (f fileUpload) Filename() string { return f.filename } +func (f fileUpload) ContentType() string { return f.contentType } +func (f fileUpload) Close() error { + if c, ok := f.Reader.(io.Closer); ok { + return c.Close() + } + return nil +} + +// openFileUpload opens the file at path and returns a fileUpload whose filename +// is the path's basename and whose content type is derived from the file +// extension (falling back to application/octet-stream when unknown). +func openFileUpload(path string) (fileUpload, error) { + file, err := os.Open(path) + if err != nil { + return fileUpload{}, err + } + contentType := mime.TypeByExtension(filepath.Ext(path)) + if contentType == "" { + contentType = "application/octet-stream" + } + return fileUpload{ + Reader: file, + filename: filepath.Base(path), + contentType: contentType, + }, nil +} + // applyDataAliases rewrites keys in a body map based on flag `DataAliases` metadata. For top-level flags, // `{alias: value}` becomes `{canonical: value}`. For inner flags (those registered under an outer flag // via WithInnerFlags), the alias translation is also applied to the nested map under the outer flag's diff --git a/pkg/cmd/flagoptions_test.go b/pkg/cmd/flagoptions_test.go index 039b9ff..00734ca 100644 --- a/pkg/cmd/flagoptions_test.go +++ b/pkg/cmd/flagoptions_test.go @@ -8,7 +8,6 @@ import ( "strings" "testing" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -31,7 +30,7 @@ func TestIsUTF8TextFile(t *testing.T) { } for _, tt := range tests { - assert.Equal(t, tt.expected, isUTF8TextFile(tt.content)) + require.Equal(t, tt.expected, isUTF8TextFile(tt.content)) } } @@ -226,10 +225,10 @@ func TestEmbedFiles(t *testing.T) { got, err := embedFiles(tt.input, EmbedText, nil) if tt.wantErr { - assert.Error(t, err) + require.Error(t, err) } else { require.NoError(t, err) - assert.Equal(t, tt.want, got) + require.Equal(t, tt.want, got) } }) @@ -238,7 +237,7 @@ func TestEmbedFiles(t *testing.T) { _, err := embedFiles(tt.input, EmbedIOReader, nil) if tt.wantErr { - assert.Error(t, err) + require.Error(t, err) } else { require.NoError(t, err) } @@ -333,6 +332,56 @@ func TestEmbedFilesStdin(t *testing.T) { }) } +// TestEmbedFilesUploadMetadata verifies that EmbedIOReader mode wraps file readers with filename and +// content-type metadata so the multipart encoder populates `Content-Disposition` and `Content-Type` headers. +func TestEmbedFilesUploadMetadata(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + writeTestFile(t, tmpDir, "hello.txt", "hi") + writeTestFile(t, tmpDir, "page.html", "") + writeTestFile(t, tmpDir, "blob.bin", "\x00\x01") + + cases := []struct { + basename string + wantContentType string + }{ + {"hello.txt", "text/plain; charset=utf-8"}, + {"page.html", "text/html; charset=utf-8"}, + {"blob.bin", "application/octet-stream"}, + } + + for _, tc := range cases { + t.Run("AtPrefix_"+tc.basename, func(t *testing.T) { + t.Parallel() + + path := filepath.Join(tmpDir, tc.basename) + withEmbedded, err := embedFiles(map[string]any{"file": "@" + path}, EmbedIOReader, nil) + require.NoError(t, err) + + upload, ok := withEmbedded.(map[string]any)["file"].(fileUpload) + require.True(t, ok, "expected fileUpload, got %T", withEmbedded.(map[string]any)["file"]) + require.Equal(t, tc.basename, upload.Filename()) + require.Equal(t, upload.ContentType(), tc.wantContentType) + require.NoError(t, upload.Close()) + }) + + t.Run("FilePathValue_"+tc.basename, func(t *testing.T) { + t.Parallel() + + path := filepath.Join(tmpDir, tc.basename) + withEmbedded, err := embedFiles(map[string]any{"file": FilePathValue(path)}, EmbedIOReader, nil) + require.NoError(t, err) + + upload, ok := withEmbedded.(map[string]any)["file"].(fileUpload) + require.True(t, ok, "expected fileUpload, got %T", withEmbedded.(map[string]any)["file"]) + require.Equal(t, tc.basename, upload.Filename()) + require.Equal(t, upload.ContentType(), tc.wantContentType) + require.NoError(t, upload.Close()) + }) + } +} + func writeTestFile(t *testing.T, dir, filename, content string) { t.Helper() From 177c28f629b61bb326788545144976e2e54e46a3 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 08:09:57 +0000 Subject: [PATCH 29/37] feat(cli): add `--raw-output`/`-r` option to print raw (non-JSON) strings --- pkg/cmd/cmd.go | 5 +++++ pkg/cmd/cmdutil.go | 14 +++++++++---- pkg/cmd/cmdutil_test.go | 37 +++++++++++++++++++++++++++++---- pkg/cmd/file.go | 5 +++++ pkg/cmd/structuredsheet.go | 6 ++++++ pkg/cmd/structuredsheettable.go | 3 +++ 6 files changed, 62 insertions(+), 8 deletions(-) diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index 8dee259..819b1fd 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -73,6 +73,11 @@ func init() { Name: "transform-error", Usage: "The GJSON transformation for errors.", }, + &cli.BoolFlag{ + Name: "raw-output", + Aliases: []string{"r"}, + Usage: "If the result is a string, print it without JSON quotes. This can be useful for making output transforms talk to non-JSON-based systems.", + }, &requestflag.Flag[string]{ Name: "api-key", Sources: cli.EnvVars("DEEPTABLE_API_KEY"), diff --git a/pkg/cmd/cmdutil.go b/pkg/cmd/cmdutil.go index c38d3d5..b839d08 100644 --- a/pkg/cmd/cmdutil.go +++ b/pkg/cmd/cmdutil.go @@ -311,16 +311,21 @@ func shouldUseColors(w io.Writer) bool { return isTerminal(w) } -func formatJSON(expectedOutput *os.File, title string, res gjson.Result, format string, transform string) ([]byte, error) { +func formatJSON(expectedOutput *os.File, title string, res gjson.Result, format string, transform string, rawOutput bool) ([]byte, error) { if transform != "" { transformed := res.Get(transform) if transformed.Exists() { res = transformed } } + // Modeled after `jq -r` (`--raw-output`): if the result is a string, print it without JSON quotes so that + // it's easier to pipe into other programs. + if rawOutput && res.Type == gjson.String { + return []byte(res.Str + "\n"), nil + } switch strings.ToLower(format) { case "auto": - return formatJSON(expectedOutput, title, res, "json", "") + return formatJSON(expectedOutput, title, res, "json", "", rawOutput) case "pretty": return []byte(jsonview.RenderJSON(title, res) + "\n"), nil case "json": @@ -360,6 +365,7 @@ const warningExploreNotSupported = "Warning: Output format 'explore' not support type ShowJSONOpts struct { ExplicitFormat bool // true if the user explicitly passed --format Format string // output format (auto, 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 @@ -402,7 +408,7 @@ func ShowJSON(res gjson.Result, opts ShowJSONOpts) error { } return jsonview.ExploreJSON(opts.Title, res) default: - bytes, err := formatJSON(opts.Stdout, opts.Title, res, opts.Format, opts.Transform) + bytes, err := formatJSON(opts.Stdout, opts.Title, res, opts.Format, opts.Transform, opts.RawOutput) if err != nil { return err } @@ -459,7 +465,7 @@ func ShowJSONIterator[T any](iter jsonview.Iterator[T], itemsToDisplay int64, op } obj = gjson.ParseBytes(jsonData) } - json, err := formatJSON(opts.Stdout, opts.Title, obj, format, opts.Transform) + json, err := formatJSON(opts.Stdout, opts.Title, obj, format, opts.Transform, opts.RawOutput) if err != nil { return err } diff --git a/pkg/cmd/cmdutil_test.go b/pkg/cmd/cmdutil_test.go index 179b17c..782039a 100644 --- a/pkg/cmd/cmdutil_test.go +++ b/pkg/cmd/cmdutil_test.go @@ -159,7 +159,7 @@ func TestFormatJSON(t *testing.T) { t.Parallel() res := gjson.Parse(`{"id":"abc123","name":"test"}`) - formatted, err := formatJSON(os.Stdout, "test", res, "raw", "id") + formatted, err := formatJSON(os.Stdout, "test", res, "raw", "id", false) require.NoError(t, err) require.Equal(t, `"abc123"`+"\n", string(formatted)) }) @@ -168,7 +168,7 @@ func TestFormatJSON(t *testing.T) { t.Parallel() res := gjson.Parse(`{"id":"abc123","name":"test"}`) - formatted, err := formatJSON(os.Stdout, "test", res, "raw", "") + formatted, err := formatJSON(os.Stdout, "test", res, "raw", "", false) require.NoError(t, err) require.Equal(t, `{"id":"abc123","name":"test"}`+"\n", string(formatted)) }) @@ -177,7 +177,7 @@ func TestFormatJSON(t *testing.T) { t.Parallel() res := gjson.Parse(`{"data":{"items":[1,2,3]}}`) - formatted, err := formatJSON(os.Stdout, "test", res, "raw", "data.items") + formatted, err := formatJSON(os.Stdout, "test", res, "raw", "data.items", false) require.NoError(t, err) require.Equal(t, "[1,2,3]\n", string(formatted)) }) @@ -186,11 +186,40 @@ func TestFormatJSON(t *testing.T) { t.Parallel() res := gjson.Parse(`{"id":"abc123"}`) - formatted, err := formatJSON(os.Stdout, "test", res, "raw", "missing") + formatted, err := formatJSON(os.Stdout, "test", res, "raw", "missing", false) require.NoError(t, err) // Transform path doesn't exist, so original result is returned require.Equal(t, `{"id":"abc123"}`+"\n", string(formatted)) }) + + t.Run("RawOutputString", func(t *testing.T) { + t.Parallel() + + res := gjson.Parse(`{"id":"abc123","name":"test"}`) + formatted, err := formatJSON(os.Stdout, "test", res, "json", "id", true) + require.NoError(t, err) + require.Equal(t, "abc123\n", string(formatted)) + }) + + t.Run("RawOutputNonString", func(t *testing.T) { + t.Parallel() + + // --raw-output has no effect on non-string values + res := gjson.Parse(`{"count":42}`) + formatted, err := formatJSON(os.Stdout, "test", res, "raw", "count", true) + require.NoError(t, err) + require.Equal(t, "42\n", string(formatted)) + }) + + t.Run("RawOutputObject", func(t *testing.T) { + t.Parallel() + + // --raw-output has no effect on objects + res := gjson.Parse(`{"nested":{"a":1}}`) + formatted, err := formatJSON(os.Stdout, "test", res, "raw", "nested", true) + require.NoError(t, err) + require.Equal(t, `{"a":1}`+"\n", string(formatted)) + }) } func TestShowJSONIterator(t *testing.T) { diff --git a/pkg/cmd/file.go b/pkg/cmd/file.go index 6e4c813..8017714 100644 --- a/pkg/cmd/file.go +++ b/pkg/cmd/file.go @@ -143,6 +143,7 @@ func handleFilesRetrieve(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "files retrieve", Transform: transform, }) @@ -183,6 +184,7 @@ func handleFilesList(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "files list", Transform: transform, }) @@ -195,6 +197,7 @@ func handleFilesList(ctx context.Context, cmd *cli.Command) error { return ShowJSONIterator(iter, maxItems, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "files list", Transform: transform, }) @@ -237,6 +240,7 @@ func handleFilesDelete(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "files delete", Transform: transform, }) @@ -310,6 +314,7 @@ func handleFilesUpload(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "files upload", Transform: transform, }) diff --git a/pkg/cmd/structuredsheet.go b/pkg/cmd/structuredsheet.go index 4016538..992853e 100644 --- a/pkg/cmd/structuredsheet.go +++ b/pkg/cmd/structuredsheet.go @@ -167,6 +167,7 @@ func handleStructuredSheetsCreate(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "structured-sheets create", Transform: transform, }) @@ -208,6 +209,7 @@ func handleStructuredSheetsRetrieve(ctx context.Context, cmd *cli.Command) error return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "structured-sheets retrieve", Transform: transform, }) @@ -248,6 +250,7 @@ func handleStructuredSheetsList(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "structured-sheets list", Transform: transform, }) @@ -260,6 +263,7 @@ func handleStructuredSheetsList(ctx context.Context, cmd *cli.Command) error { return ShowJSONIterator(iter, maxItems, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "structured-sheets list", Transform: transform, }) @@ -302,6 +306,7 @@ func handleStructuredSheetsDelete(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "structured-sheets delete", Transform: transform, }) @@ -343,6 +348,7 @@ func handleStructuredSheetsCancel(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "structured-sheets cancel", Transform: transform, }) diff --git a/pkg/cmd/structuredsheettable.go b/pkg/cmd/structuredsheettable.go index 49ad4c5..a492e04 100644 --- a/pkg/cmd/structuredsheettable.go +++ b/pkg/cmd/structuredsheettable.go @@ -141,6 +141,7 @@ func handleStructuredSheetsTablesRetrieve(ctx context.Context, cmd *cli.Command) return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "structured-sheets:tables retrieve", Transform: transform, }) @@ -189,6 +190,7 @@ func handleStructuredSheetsTablesList(ctx context.Context, cmd *cli.Command) err return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "structured-sheets:tables list", Transform: transform, }) @@ -206,6 +208,7 @@ func handleStructuredSheetsTablesList(ctx context.Context, cmd *cli.Command) err return ShowJSONIterator(iter, maxItems, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "structured-sheets:tables list", Transform: transform, }) From 8eb757d4dc41a21f87cdeda972597ada022a3580 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 08:28:06 +0000 Subject: [PATCH 30/37] chore(cli): use `ShowJSONOpts` as argument to `formatJSON` instead of many positionals --- pkg/cmd/cmdutil.go | 54 ++++++++++++++++++++--------------------- pkg/cmd/cmdutil_test.go | 14 +++++------ 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/pkg/cmd/cmdutil.go b/pkg/cmd/cmdutil.go index b839d08..e1b9ea9 100644 --- a/pkg/cmd/cmdutil.go +++ b/pkg/cmd/cmdutil.go @@ -311,26 +311,29 @@ func shouldUseColors(w io.Writer) bool { return isTerminal(w) } -func formatJSON(expectedOutput *os.File, title string, res gjson.Result, format string, transform string, rawOutput bool) ([]byte, error) { - if transform != "" { - transformed := res.Get(transform) +func formatJSON(res gjson.Result, opts ShowJSONOpts) ([]byte, error) { + if opts.Transform != "" { + transformed := res.Get(opts.Transform) if transformed.Exists() { res = transformed } } // Modeled after `jq -r` (`--raw-output`): if the result is a string, print it without JSON quotes so that // it's easier to pipe into other programs. - if rawOutput && res.Type == gjson.String { + if opts.RawOutput && res.Type == gjson.String { return []byte(res.Str + "\n"), nil } - switch strings.ToLower(format) { + switch strings.ToLower(opts.Format) { case "auto": - return formatJSON(expectedOutput, title, res, "json", "", rawOutput) + autoOpts := opts + autoOpts.Format = "json" + autoOpts.Transform = "" + return formatJSON(res, autoOpts) case "pretty": - return []byte(jsonview.RenderJSON(title, res) + "\n"), nil + return []byte(jsonview.RenderJSON(opts.Title, res) + "\n"), nil case "json": prettyJSON := pretty.Pretty([]byte(res.Raw)) - if shouldUseColors(expectedOutput) { + if shouldUseColors(opts.Stdout) { return pretty.Color(prettyJSON, pretty.TerminalStyle), nil } else { return prettyJSON, nil @@ -338,7 +341,7 @@ func formatJSON(expectedOutput *os.File, title string, res gjson.Result, format case "jsonl": // @ugly is gjson syntax for "no whitespace", so it fits on one line oneLineJSON := res.Get("@ugly").Raw - if shouldUseColors(expectedOutput) { + if shouldUseColors(opts.Stdout) { bytes := append(pretty.Color([]byte(oneLineJSON), pretty.TerminalStyle), '\n') return bytes, nil } else { @@ -352,10 +355,10 @@ func formatJSON(expectedOutput *os.File, title string, res gjson.Result, format if err := json2yaml.Convert(&yaml, input); err != nil { return nil, err } - _, err := expectedOutput.Write([]byte(yaml.String())) + _, err := opts.Stdout.Write([]byte(yaml.String())) return nil, err default: - return nil, fmt.Errorf("Invalid format: %s, valid formats are: %s", format, strings.Join(OutputFormats, ", ")) + return nil, fmt.Errorf("Invalid format: %s, valid formats are: %s", opts.Format, strings.Join(OutputFormats, ", ")) } } @@ -385,18 +388,11 @@ func (o *ShowJSONOpts) setDefaults() { func ShowJSON(res gjson.Result, opts ShowJSONOpts) error { opts.setDefaults() - if opts.Transform != "" { - transformed := res.Get(opts.Transform) - if transformed.Exists() { - res = transformed - } - } - switch strings.ToLower(opts.Format) { case "auto": - jsonOpts := opts - jsonOpts.Format = "json" - return ShowJSON(res, jsonOpts) + autoOpts := opts + autoOpts.Format = "json" + return ShowJSON(res, autoOpts) case "explore": if !isTerminal(opts.Stdout) { if opts.ExplicitFormat { @@ -406,9 +402,15 @@ func ShowJSON(res gjson.Result, opts ShowJSONOpts) error { jsonOpts.Format = "json" return ShowJSON(res, jsonOpts) } + if opts.Transform != "" { + transformed := res.Get(opts.Transform) + if transformed.Exists() { + res = transformed + } + } return jsonview.ExploreJSON(opts.Title, res) default: - bytes, err := formatJSON(opts.Stdout, opts.Title, res, opts.Format, opts.Transform, opts.RawOutput) + bytes, err := formatJSON(res, opts) if err != nil { return err } @@ -431,15 +433,14 @@ type hasRawJSON interface { func ShowJSONIterator[T any](iter jsonview.Iterator[T], itemsToDisplay int64, opts ShowJSONOpts) error { opts.setDefaults() - format := opts.Format - if format == "explore" { + if opts.Format == "explore" { if isTerminal(opts.Stdout) { return jsonview.ExploreJSONStream(opts.Title, iter) } if opts.ExplicitFormat { fmt.Fprint(opts.Stderr, warningExploreNotSupported) } - format = "json" + opts.Format = "json" } terminalWidth, terminalHeight, err := term.GetSize(os.Stdout.Fd()) @@ -465,7 +466,7 @@ func ShowJSONIterator[T any](iter jsonview.Iterator[T], itemsToDisplay int64, op } obj = gjson.ParseBytes(jsonData) } - json, err := formatJSON(opts.Stdout, opts.Title, obj, format, opts.Transform, opts.RawOutput) + json, err := formatJSON(obj, opts) if err != nil { return err } @@ -497,7 +498,6 @@ func ShowJSONIterator[T any](iter jsonview.Iterator[T], itemsToDisplay int64, op } pagerOpts := opts - pagerOpts.Format = format pagerOpts.Stdout = pager for iter.Next() { diff --git a/pkg/cmd/cmdutil_test.go b/pkg/cmd/cmdutil_test.go index 782039a..1046d6b 100644 --- a/pkg/cmd/cmdutil_test.go +++ b/pkg/cmd/cmdutil_test.go @@ -159,7 +159,7 @@ func TestFormatJSON(t *testing.T) { t.Parallel() res := gjson.Parse(`{"id":"abc123","name":"test"}`) - formatted, err := formatJSON(os.Stdout, "test", res, "raw", "id", false) + formatted, err := formatJSON(res, ShowJSONOpts{Format: "raw", Stdout: os.Stdout, Transform: "id"}) require.NoError(t, err) require.Equal(t, `"abc123"`+"\n", string(formatted)) }) @@ -168,7 +168,7 @@ func TestFormatJSON(t *testing.T) { t.Parallel() res := gjson.Parse(`{"id":"abc123","name":"test"}`) - formatted, err := formatJSON(os.Stdout, "test", res, "raw", "", false) + formatted, err := formatJSON(res, ShowJSONOpts{Format: "raw", Stdout: os.Stdout}) require.NoError(t, err) require.Equal(t, `{"id":"abc123","name":"test"}`+"\n", string(formatted)) }) @@ -177,7 +177,7 @@ func TestFormatJSON(t *testing.T) { t.Parallel() res := gjson.Parse(`{"data":{"items":[1,2,3]}}`) - formatted, err := formatJSON(os.Stdout, "test", res, "raw", "data.items", false) + formatted, err := formatJSON(res, ShowJSONOpts{Format: "raw", Stdout: os.Stdout, Transform: "data.items"}) require.NoError(t, err) require.Equal(t, "[1,2,3]\n", string(formatted)) }) @@ -186,7 +186,7 @@ func TestFormatJSON(t *testing.T) { t.Parallel() res := gjson.Parse(`{"id":"abc123"}`) - formatted, err := formatJSON(os.Stdout, "test", res, "raw", "missing", false) + formatted, err := formatJSON(res, ShowJSONOpts{Format: "raw", Stdout: os.Stdout, Transform: "missing"}) require.NoError(t, err) // Transform path doesn't exist, so original result is returned require.Equal(t, `{"id":"abc123"}`+"\n", string(formatted)) @@ -196,7 +196,7 @@ func TestFormatJSON(t *testing.T) { t.Parallel() res := gjson.Parse(`{"id":"abc123","name":"test"}`) - formatted, err := formatJSON(os.Stdout, "test", res, "json", "id", true) + formatted, err := formatJSON(res, ShowJSONOpts{Format: "json", Stdout: os.Stdout, Transform: "id", RawOutput: true}) require.NoError(t, err) require.Equal(t, "abc123\n", string(formatted)) }) @@ -206,7 +206,7 @@ func TestFormatJSON(t *testing.T) { // --raw-output has no effect on non-string values res := gjson.Parse(`{"count":42}`) - formatted, err := formatJSON(os.Stdout, "test", res, "raw", "count", true) + formatted, err := formatJSON(res, ShowJSONOpts{Format: "raw", Stdout: os.Stdout, Transform: "count", RawOutput: true}) require.NoError(t, err) require.Equal(t, "42\n", string(formatted)) }) @@ -216,7 +216,7 @@ func TestFormatJSON(t *testing.T) { // --raw-output has no effect on objects res := gjson.Parse(`{"nested":{"a":1}}`) - formatted, err := formatJSON(os.Stdout, "test", res, "raw", "nested", true) + formatted, err := formatJSON(res, ShowJSONOpts{Format: "raw", Stdout: os.Stdout, Transform: "nested", RawOutput: true}) require.NoError(t, err) require.Equal(t, `{"a":1}`+"\n", string(formatted)) }) From 3e2edb59bd63cdb77f3128feaf7c1a68e1b0c77c Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 04:11:33 +0000 Subject: [PATCH 31/37] chore(internal): more robust bootstrap script --- scripts/bootstrap | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/bootstrap b/scripts/bootstrap index 9ebb7d3..bbc786d 100755 --- a/scripts/bootstrap +++ b/scripts/bootstrap @@ -4,7 +4,7 @@ set -e cd "$(dirname "$0")/.." -if [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ] && [ "$SKIP_BREW" != "1" ] && [ -t 0 ]; then +if [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ] && [ "${SKIP_BREW:-}" != "1" ] && [ -t 0 ]; then brew bundle check >/dev/null 2>&1 || { echo -n "==> Install Homebrew dependencies? (y/N): " read -r response From ab0b024de44d535fb1402bdfd82f048d943ff65d Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 02:52:54 +0000 Subject: [PATCH 32/37] chore(internal): codegen related update --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 9d09716..0fc3149 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/charmbracelet/bubbletea v1.3.6 github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/x/term v0.2.1 - github.com/deeptable-com/deeptable-go v0.1.0-beta.3 + github.com/deeptable-com/deeptable-go v0.1.0-beta.4 github.com/goccy/go-yaml v1.18.0 github.com/itchyny/json2yaml v0.1.4 github.com/muesli/reflow v0.3.0 diff --git a/go.sum b/go.sum index 79ea8a9..e9f49b8 100644 --- a/go.sum +++ b/go.sum @@ -22,8 +22,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHH github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/deeptable-com/deeptable-go v0.1.0-beta.3 h1:q0jKrpq5LnDJU4GOsi3IayJy4V1T8PVbSQChaCQWgys= -github.com/deeptable-com/deeptable-go v0.1.0-beta.3/go.mod h1:fIo2owpf3vsy9vMv3XyFe4ahUcPAjKtutJEobZ8R/jI= +github.com/deeptable-com/deeptable-go v0.1.0-beta.4 h1:iZ2W2ROHRAfsGEbJmcQ1EJdtu35d2jsrMjxDKIvmos8= +github.com/deeptable-com/deeptable-go v0.1.0-beta.4/go.mod h1:fIo2owpf3vsy9vMv3XyFe4ahUcPAjKtutJEobZ8R/jI= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= From 9e91ddf6fb36e634f56a23a9a01102e548073eb4 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 04:13:38 +0000 Subject: [PATCH 33/37] fix(cli): correctly load zsh autocompletion --- .../autocomplete/shellscripts/zsh_autocomplete.zsh | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/internal/autocomplete/shellscripts/zsh_autocomplete.zsh b/internal/autocomplete/shellscripts/zsh_autocomplete.zsh index 4d4bdcd..d937171 100644 --- a/internal/autocomplete/shellscripts/zsh_autocomplete.zsh +++ b/internal/autocomplete/shellscripts/zsh_autocomplete.zsh @@ -1,5 +1,4 @@ -#!/bin/zsh -compdef ____APPNAME___zsh_autocomplete __APPNAME__ +#compdef __APPNAME__ ____APPNAME___zsh_autocomplete() { @@ -44,3 +43,14 @@ ____APPNAME___zsh_autocomplete() { ;; esac } + +# When installed in fpath (e.g., via Homebrew's zsh_completion stanza), this file +# is autoloaded as the function ___APPNAME__ and its body becomes that function's +# body. Detect that case via funcstack and dispatch to the completion function. +# When sourced (e.g., `source <(__APPNAME__ @completion zsh)`), register the +# function with compdef instead. +if [[ "${funcstack[1]}" = "___APPNAME__" ]]; then + ____APPNAME___zsh_autocomplete "$@" +else + compdef ____APPNAME___zsh_autocomplete __APPNAME__ +fi From 092af8eefabf67b9a33f7c9379bdf846307e33cc Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 04:16:42 +0000 Subject: [PATCH 34/37] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index 6eb4c83..ab1ff83 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 14 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/deeptable%2Fdeeptable-acfb7545d19701827292fc4663c6852d423dd84e65639e5b7ed766dc2d01aff2.yml +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/deeptable/deeptable-acfb7545d19701827292fc4663c6852d423dd84e65639e5b7ed766dc2d01aff2.yml openapi_spec_hash: 9f090892f01aa7afe4f22910c41b911b config_hash: 56ee7b575c6a6e90046a0f807e5f3234 From a98eb255536c876836413ba333773b7b54929fb9 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 04:18:16 +0000 Subject: [PATCH 35/37] fix: flags for nullable body scalar fields are strictly typed --- internal/requestflag/innerflag.go | 16 +- internal/requestflag/requestflag.go | 128 ++++++++++++- internal/requestflag/requestflag_test.go | 234 +++++++++++++++++++++++ pkg/cmd/file.go | 2 +- pkg/cmd/structuredsheet.go | 2 +- pkg/cmd/structuredsheettable.go | 2 +- 6 files changed, 377 insertions(+), 7 deletions(-) diff --git a/internal/requestflag/innerflag.go b/internal/requestflag/innerflag.go index eeeb8bc..528915f 100644 --- a/internal/requestflag/innerflag.go +++ b/internal/requestflag/innerflag.go @@ -14,7 +14,8 @@ import ( type InnerFlag[ T []any | []map[string]any | []DateTimeValue | []DateValue | []TimeValue | []string | []float64 | []int64 | []bool | any | map[string]any | DateTimeValue | DateValue | TimeValue | - string | float64 | int64 | bool, + string | float64 | int64 | bool | + *string | *float64 | *int64 | *bool | *DateTimeValue | *DateValue | *TimeValue, ] struct { Name string // name of the flag DefaultText string // default text of the flag for usage purposes @@ -25,6 +26,12 @@ type InnerFlag[ OuterFlag cli.Flag // The flag on which this inner flag will set values InnerField string // The inner field which this flag will set DataAliases []string // alternate names recognized in YAML values passed as the outer flag + + // OuterIsArrayOfObjects tells an untyped outer flag (Flag[any], used for nullable + // complex schemas) to seed its underlying value as []map[string]any rather than + // map[string]any before SetInnerField runs. The hint is ignored for typed outer + // flags whose zero value already carries a dispatchable reflect.Kind. + OuterIsArrayOfObjects bool } // GetDataAliases returns the aliases recognized when parsing inner field keys from piped or flag YAML. @@ -76,6 +83,10 @@ func (f *InnerFlag[T]) Set(name string, rawVal string) error { } } + if seeder, ok := f.OuterFlag.(InnerFieldSeeder); ok { + seeder.SeedInnerCollection(f.OuterIsArrayOfObjects) + } + if settableInnerField, ok := f.OuterFlag.(SettableInnerField); ok { settableInnerField.SetInnerField(f.InnerField, parsedValue) } else { @@ -136,6 +147,9 @@ func (f *InnerFlag[T]) TypeName() string { if ty == nil { return "" } + if ty.Kind() == reflect.Pointer { + ty = ty.Elem() + } // Get base type name with special handling for built-in types getTypeName := func(t reflect.Type) string { diff --git a/internal/requestflag/requestflag.go b/internal/requestflag/requestflag.go index bfaf064..54c2509 100644 --- a/internal/requestflag/requestflag.go +++ b/internal/requestflag/requestflag.go @@ -15,10 +15,15 @@ import ( // Flag [T] is a generic flag base which can be used to implement the most // common interfaces used by urfave/cli. Additionally, it allows specifying // where in an HTTP request the flag values should be placed (e.g. query, body, etc.). +// +// Pointer-to-primitive type parameters (e.g. *string) are used for flags whose underlying +// schema is nullable. They give flags a tri-state: unset (excluded from the request), +// set to the literal "null" (nil pointer → JSON null), or set to a value (*v → JSON value). type Flag[ T []any | []map[string]any | []DateTimeValue | []DateValue | []TimeValue | []string | []float64 | []int64 | []bool | any | map[string]any | DateTimeValue | DateValue | TimeValue | - string | float64 | int64 | bool, + string | float64 | int64 | bool | + *string | *float64 | *int64 | *bool | *DateTimeValue | *DateValue | *TimeValue, ] struct { Name string // name of the flag Category string // category of the flag, if any @@ -341,6 +346,11 @@ func (f *Flag[T]) TypeName() string { if ty == nil { return "" } + // Deref pointer-typed flags so --help surfaces the pointee kind (e.g. "string"), not + // Go's pointer syntax. + if ty.Kind() == reflect.Pointer { + ty = ty.Elem() + } // Get base type name with special handling for built-in types getTypeName := func(t reflect.Type) string { @@ -396,6 +406,8 @@ func (f *Flag[T]) IsMultiValueFlag() bool { } func (f *Flag[T]) IsBoolFlag() bool { + // Flag[*bool] is deliberately not treated as a bool flag — the pointer form needs an + // explicit value (`--foo true`, `--foo null`) to disambiguate the tri-state. _, isBool := any(f.Default).(bool) return isBool } @@ -419,7 +431,8 @@ func (f Flag[T]) IsLocal() bool { type cliValue[ T []any | []map[string]any | []DateTimeValue | []DateValue | []TimeValue | []string | []float64 | []int64 | []bool | any | map[string]any | DateTimeValue | DateValue | TimeValue | string | - float64 | int64 | bool, + float64 | int64 | bool | + *string | *float64 | *int64 | *bool | *DateTimeValue | *DateValue | *TimeValue, ] struct { value T } @@ -429,12 +442,27 @@ type cliValue[ func parseCLIArg[ T []any | []map[string]any | []DateTimeValue | []DateValue | []TimeValue | []string | []float64 | []int64 | []bool | any | map[string]any | DateTimeValue | DateValue | TimeValue | string | - float64 | int64 | bool, + float64 | int64 | bool | + *string | *float64 | *int64 | *bool | *DateTimeValue | *DateValue | *TimeValue, ](value string) (T, error) { var parsedValue any var err error var empty T + + if value == "null" { + switch any(empty).(type) { + // Pointer-to-primitive: explicit nil gives the tri-state its "null" state + // (unset / null / value). Without this, numeric flags would fail to parse + // "null" and string flags would accept the literal word as a raw value. + case *string, *int64, *float64, *bool, *DateValue, *DateTimeValue, *TimeValue: + return empty, nil + // Maps marshal nil as JSON null natively; short-circuit avoids a YAML round-trip. + case map[string]any: + return empty, nil + } + } + switch any(empty).(type) { case string: parsedValue = value @@ -465,6 +493,48 @@ func parseCLIArg[ parsedValue = t } + // Pointer-to-primitive flags reach here only when `value != "null"`; we parse the + // pointee type and return its address so JSON marshaling emits the underlying value. + case *string: + v := value + parsedValue = &v + case *int64: + var v int64 + v, err = strconv.ParseInt(value, 0, 64) + if err == nil { + parsedValue = &v + } + case *float64: + var v float64 + v, err = strconv.ParseFloat(value, 64) + if err == nil { + parsedValue = &v + } + case *bool: + var v bool + v, err = strconv.ParseBool(value) + if err == nil { + parsedValue = &v + } + case *DateTimeValue: + var dt DateTimeValue + err = (&dt).Parse(value) + if err == nil { + parsedValue = &dt + } + case *DateValue: + var d DateValue + err = (&d).Parse(value) + if err == nil { + parsedValue = &d + } + case *TimeValue: + var t TimeValue + err = (&t).Parse(value) + if err == nil { + parsedValue = &t + } + default: if strings.HasPrefix(value, "@") { // File literals like @file.txt should work here @@ -501,6 +571,13 @@ func parseCLIArg[ } +// Ptr returns a pointer to its argument. It is used to initialize `Default` on pointer-typed +// Flag values, since Go does not allow taking the address of a composite literal's element +// or of an untyped constant. +func Ptr[T any](v T) *T { + return &v +} + // Assuming this string failed to parse as valid YAML, this function will // return true for strings that can reasonably be interpreted as a string literal, // like identifiers (`foo_bar`), UUIDs (`945b2f0c-8e89-487a-b02c-f851c69ea459`), @@ -594,6 +671,15 @@ func (c *cliValue[T]) String() string { // For basic types, use standard string representation return fmt.Sprintf("%v", v) + case *string, *int64, *float64, *bool, *DateTimeValue, *DateValue, *TimeValue: + // Pointer-to-primitive: nil renders as "null" (the CLI literal that produces it); + // non-nil derefs to the pointee's standard representation. + rv := reflect.ValueOf(v) + if rv.IsNil() { + return "null" + } + return fmt.Sprintf("%v", rv.Elem().Interface()) + default: // For complex types, convert to YAML yamlBytes, err := yaml.MarshalWithOptions(c.value, yaml.Flow(true)) @@ -705,6 +791,15 @@ type SettableInnerField interface { SetInnerField(string, any) } +// InnerFieldSeeder lets an InnerFlag prepare its outer flag's underlying value +// before dispatching SetInnerField. This is only meaningful for Flag[any] — +// the codegen output for nullable complex schemas — whose untyped-nil zero +// value would otherwise have no reflect.Kind for the inner-field switch to +// dispatch on. +type InnerFieldSeeder interface { + SeedInnerCollection(isArrayOfObjects bool) +} + func (f *Flag[T]) SetInnerField(field string, val any) { if f.value == nil { f.value = &cliValue[T]{} @@ -718,6 +813,33 @@ func (f *Flag[T]) SetInnerField(field string, val any) { } } +// SeedInnerCollection initializes a Flag[any]'s underlying value as an empty +// map[string]any or []map[string]any so subsequent SetInnerField calls have a +// dispatchable reflect.Kind. For typed Flag[T] this is a no-op: the type +// assertion fails and the existing reflect.Kind on the typed-nil zero value +// already routes correctly. +func (f *Flag[T]) SeedInnerCollection(isArrayOfObjects bool) { + if f.value == nil { + f.value = &cliValue[T]{} + } + cv, ok := f.value.(*cliValue[T]) + if !ok { + return + } + if reflect.ValueOf(cv.value).Kind() != reflect.Invalid { + return + } + if isArrayOfObjects { + if seed, ok := any([]map[string]any{}).(T); ok { + cv.value = seed + } + return + } + if seed, ok := any(map[string]any{}).(T); ok { + cv.value = seed + } +} + func (c *cliValue[T]) SetInnerField(field string, val any) { flagVal := c.value flagValReflect := reflect.ValueOf(flagVal) diff --git a/internal/requestflag/requestflag_test.go b/internal/requestflag/requestflag_test.go index 0e86e07..06ffb72 100644 --- a/internal/requestflag/requestflag_test.go +++ b/internal/requestflag/requestflag_test.go @@ -1,6 +1,7 @@ package requestflag import ( + "encoding/json" "fmt" "testing" "time" @@ -616,6 +617,178 @@ func TestYamlHandling(t *testing.T) { }) } +// TestNullLiteralHandling pins how each Flag[T] type handles the literal value "null" +// when passed via the CLI. Pointer-typed flags serialize nil as JSON null, which is how +// nullable body fields (`anyOf: [T, null]` / `{nullable: true}`) let users clear a field +// via `--foo null`. Non-pointer primitive flags treat "null" as a raw value — these are +// non-nullable schemas where explicit null has no API semantics anyway. +func TestNullLiteralHandling(t *testing.T) { + t.Parallel() + + assertJSONBody := func(t *testing.T, value any, expected string) { + t.Helper() + body, err := json.Marshal(map[string]any{"foo": value}) + assert.NoError(t, err) + assert.JSONEq(t, expected, string(body)) + } + + t.Run("Flag[any] null sends JSON null", func(t *testing.T) { + t.Parallel() + cv := &cliValue[any]{} + assert.NoError(t, cv.Set("null")) + assertJSONBody(t, cv.Get(), `{"foo":null}`) + }) + + t.Run("Flag[string] null is the raw string \"null\"", func(t *testing.T) { + t.Parallel() + cv := &cliValue[string]{} + assert.NoError(t, cv.Set("null")) + assertJSONBody(t, cv.Get(), `{"foo":"null"}`) + }) + + t.Run("Flag[int64] null errors", func(t *testing.T) { + t.Parallel() + cv := &cliValue[int64]{} + assert.Error(t, cv.Set("null")) + }) + + t.Run("Flag[*string] null sends JSON null", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*string]{} + assert.NoError(t, cv.Set("null")) + assertJSONBody(t, cv.Get(), `{"foo":null}`) + }) + + t.Run("Flag[*string] value sends the string", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*string]{} + assert.NoError(t, cv.Set("1.1")) + assertJSONBody(t, cv.Get(), `{"foo":"1.1"}`) + }) + + t.Run("Flag[*int64] null sends JSON null", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*int64]{} + assert.NoError(t, cv.Set("null")) + assertJSONBody(t, cv.Get(), `{"foo":null}`) + }) + + t.Run("Flag[*int64] value sends the integer", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*int64]{} + assert.NoError(t, cv.Set("42")) + assertJSONBody(t, cv.Get(), `{"foo":42}`) + }) + + t.Run("Flag[*int64] invalid value errors", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*int64]{} + assert.Error(t, cv.Set("not-an-int")) + }) + + t.Run("Flag[*bool] null sends JSON null", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*bool]{} + assert.NoError(t, cv.Set("null")) + assertJSONBody(t, cv.Get(), `{"foo":null}`) + }) + + t.Run("Flag[*bool] value sends the boolean", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*bool]{} + assert.NoError(t, cv.Set("true")) + assertJSONBody(t, cv.Get(), `{"foo":true}`) + }) + + t.Run("Flag[*float64] null sends JSON null", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*float64]{} + assert.NoError(t, cv.Set("null")) + assertJSONBody(t, cv.Get(), `{"foo":null}`) + }) + + t.Run("Flag[*float64] value sends the float", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*float64]{} + assert.NoError(t, cv.Set("1.5")) + assertJSONBody(t, cv.Get(), `{"foo":1.5}`) + }) + + t.Run("Flag[*float64] invalid value errors", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*float64]{} + assert.Error(t, cv.Set("not-a-float")) + }) + + t.Run("Flag[*DateValue] null sends JSON null", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*DateValue]{} + assert.NoError(t, cv.Set("null")) + assertJSONBody(t, cv.Get(), `{"foo":null}`) + }) + + t.Run("Flag[*DateValue] value sends the date", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*DateValue]{} + assert.NoError(t, cv.Set("2023-05-15")) + assertJSONBody(t, cv.Get(), `{"foo":"2023-05-15"}`) + }) + + t.Run("Flag[*DateValue] invalid value errors", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*DateValue]{} + assert.Error(t, cv.Set("not-a-date")) + }) + + t.Run("Flag[*DateTimeValue] null sends JSON null", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*DateTimeValue]{} + assert.NoError(t, cv.Set("null")) + assertJSONBody(t, cv.Get(), `{"foo":null}`) + }) + + t.Run("Flag[*DateTimeValue] value sends the datetime", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*DateTimeValue]{} + assert.NoError(t, cv.Set("2023-05-15T14:30:45Z")) + assertJSONBody(t, cv.Get(), `{"foo":"2023-05-15T14:30:45Z"}`) + }) + + t.Run("Flag[*DateTimeValue] invalid value errors", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*DateTimeValue]{} + assert.Error(t, cv.Set("not-a-datetime")) + }) + + t.Run("Flag[*TimeValue] null sends JSON null", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*TimeValue]{} + assert.NoError(t, cv.Set("null")) + assertJSONBody(t, cv.Get(), `{"foo":null}`) + }) + + t.Run("Flag[*TimeValue] value sends the time", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*TimeValue]{} + assert.NoError(t, cv.Set("14:30:45")) + assertJSONBody(t, cv.Get(), `{"foo":"14:30:45"}`) + }) + + t.Run("Flag[*TimeValue] invalid value errors", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*TimeValue]{} + assert.Error(t, cv.Set("not-a-time")) + }) + + // Nullable maps don't need pointer wrapping — a nil map already marshals as JSON null. + t.Run("Flag[map[string]any] null sends JSON null", func(t *testing.T) { + t.Parallel() + cv := &cliValue[map[string]any]{} + assert.NoError(t, cv.Set("null")) + assertJSONBody(t, cv.Get(), `{"foo":null}`) + }) +} + func TestFlagTypeNames(t *testing.T) { t.Parallel() @@ -646,3 +819,64 @@ func TestFlagTypeNames(t *testing.T) { }) } } + +// TestInnerFlagDispatchOnUntypedFlag pins inner-flag behavior for `Flag[any]`, +// which is the codegen output for nullable complex schemas (`anyOf: [T, null]` +// or `{nullable: true}`). The untyped-nil zero value carries no reflect.Kind, +// so SetInnerField has nowhere to dispatch the assignment — without explicit +// help the inner-field value silently drops. +func TestInnerFlagDispatchOnUntypedFlag(t *testing.T) { + t.Parallel() + + t.Run("nullable array of objects appends element from inner flag", func(t *testing.T) { + t.Parallel() + outer := &Flag[any]{Name: "mcp-server"} + assert.NoError(t, outer.PreParse()) + + nameFlag := &InnerFlag[string]{ + Name: "mcp-server.name", InnerField: "name", + OuterFlag: outer, OuterIsArrayOfObjects: true, + } + assert.NoError(t, nameFlag.Set("mcp-server.name", "first")) + + body, err := json.Marshal(map[string]any{"foo": outer.Get()}) + assert.NoError(t, err) + assert.JSONEq(t, `{"foo":[{"name":"first"}]}`, string(body)) + }) + + t.Run("nullable object sets field from inner flag", func(t *testing.T) { + t.Parallel() + outer := &Flag[any]{Name: "metadata"} + assert.NoError(t, outer.PreParse()) + + keyFlag := &InnerFlag[string]{ + Name: "metadata.key", InnerField: "key", OuterFlag: outer, + } + assert.NoError(t, keyFlag.Set("metadata.key", "value")) + + body, err := json.Marshal(map[string]any{"foo": outer.Get()}) + assert.NoError(t, err) + assert.JSONEq(t, `{"foo":{"key":"value"}}`, string(body)) + }) + + t.Run("multiple inner flags merge into the trailing element", func(t *testing.T) { + t.Parallel() + outer := &Flag[any]{Name: "mcp-server"} + assert.NoError(t, outer.PreParse()) + + nameFlag := &InnerFlag[string]{ + Name: "mcp-server.name", InnerField: "name", + OuterFlag: outer, OuterIsArrayOfObjects: true, + } + urlFlag := &InnerFlag[string]{ + Name: "mcp-server.url", InnerField: "url", + OuterFlag: outer, OuterIsArrayOfObjects: true, + } + assert.NoError(t, nameFlag.Set("mcp-server.name", "first")) + assert.NoError(t, urlFlag.Set("mcp-server.url", "https://example.com")) + + body, err := json.Marshal(map[string]any{"foo": outer.Get()}) + assert.NoError(t, err) + assert.JSONEq(t, `{"foo":[{"name":"first","url":"https://example.com"}]}`, string(body)) + }) +} diff --git a/pkg/cmd/file.go b/pkg/cmd/file.go index 8017714..f1f7369 100644 --- a/pkg/cmd/file.go +++ b/pkg/cmd/file.go @@ -35,7 +35,7 @@ var filesList = cli.Command{ Usage: "List all files uploaded by the current user.", Suggest: true, Flags: []cli.Flag{ - &requestflag.Flag[any]{ + &requestflag.Flag[*string]{ Name: "after", Usage: "A cursor for pagination. Use the `last_id` from a previous response to fetch the next page.", QueryPath: "after", diff --git a/pkg/cmd/structuredsheet.go b/pkg/cmd/structuredsheet.go index 992853e..dfdbbc6 100644 --- a/pkg/cmd/structuredsheet.go +++ b/pkg/cmd/structuredsheet.go @@ -56,7 +56,7 @@ var structuredSheetsList = cli.Command{ Usage: "List all structured sheets conversions for the authenticated user. Results are\npaginated using cursor-based pagination.", Suggest: true, Flags: []cli.Flag{ - &requestflag.Flag[any]{ + &requestflag.Flag[*string]{ Name: "after", Usage: "A cursor for pagination. Use the `last_id` from a previous response to fetch the next page of results.", QueryPath: "after", diff --git a/pkg/cmd/structuredsheettable.go b/pkg/cmd/structuredsheettable.go index a492e04..fedefc6 100644 --- a/pkg/cmd/structuredsheettable.go +++ b/pkg/cmd/structuredsheettable.go @@ -45,7 +45,7 @@ var structuredSheetsTablesList = cli.Command{ Usage: "The unique identifier of the structured sheet conversion.", Required: true, }, - &requestflag.Flag[any]{ + &requestflag.Flag[*string]{ Name: "after", Usage: "A cursor for pagination. Use the `last_id` from a previous response to fetch the next page of results.", QueryPath: "after", From 8e27218906f07ebd77ee8ba790eec504bbcfbe7f Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 04:20:55 +0000 Subject: [PATCH 36/37] feat: support passing path and query params over stdin --- internal/requestflag/requestflag.go | 115 +++++++- internal/requestflag/requestflag_test.go | 345 +++++++++++++++++++++++ pkg/cmd/file.go | 29 +- pkg/cmd/flagoptions.go | 48 +++- pkg/cmd/structuredsheet.go | 40 +-- pkg/cmd/structuredsheettable.go | 55 ++-- 6 files changed, 564 insertions(+), 68 deletions(-) diff --git a/internal/requestflag/requestflag.go b/internal/requestflag/requestflag.go index 54c2509..77c4f1f 100644 --- a/internal/requestflag/requestflag.go +++ b/internal/requestflag/requestflag.go @@ -1,6 +1,7 @@ package requestflag import ( + "encoding/json" "fmt" "reflect" "strconv" @@ -12,6 +13,26 @@ import ( "github.com/urfave/cli/v3" ) +// formatForFlagSet converts a Go value parsed from YAML/JSON stdin data into a string +// that flag.Set (and thus parseCLIArg) can parse correctly for each flag type. +// Strings are returned as-is (parseCLIArg[string] assigns the raw value directly, so +// JSON-quoting must be avoided). Scalars use %v. Complex types (maps, slices) are +// JSON-encoded, which the yaml.Unmarshal default branch in parseCLIArg can parse. +func formatForFlagSet(val any) (string, error) { + switch v := val.(type) { + case string: + return v, nil + case bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64: + return fmt.Sprintf("%v", val), nil + default: + b, err := json.Marshal(val) + if err != nil { + return "", fmt.Errorf("cannot format value %T for flag.Set: %w", val, err) + } + return string(b), nil + } +} + // Flag [T] is a generic flag base which can be used to implement the most // common interfaces used by urfave/cli. Additionally, it allows specifying // where in an HTTP request the flag values should be placed (e.g. query, body, etc.). @@ -41,6 +62,7 @@ type Flag[ HeaderPath string // location in the request header to put this flag's value BodyPath string // location in the request body to put this flag's value BodyRoot bool // if true, then use this value as the entire request body + PathParam string // name of the URL path parameter this flag's value maps to // Const, when true, marks this flag as a constant. The flag's Default value is used as the fixed value // and always included in the request (IsSet returns true). The user can still see and override the flag, @@ -72,6 +94,7 @@ type InRequest interface { GetQueryPath() string GetHeaderPath() string GetBodyPath() string + GetPathParam() string IsBodyRoot() bool IsFileInput() bool GetDataAliases() []string @@ -89,6 +112,10 @@ func (f Flag[T]) GetBodyPath() string { return f.BodyPath } +func (f Flag[T]) GetPathParam() string { + return f.PathParam +} + func (f Flag[T]) IsBodyRoot() bool { return f.BodyRoot } @@ -108,7 +135,91 @@ type RequestContents struct { Body any } -// Extract query parameters, headers, and body values from command flags. +// ApplyStdinDataToFlags sets flag values from a parsed stdin data map for flags that have not already been +// set via the command line. This allows piped YAML/JSON data to satisfy path, query, and header parameters. +// Body parameters are excluded: they are already handled by the maps.Copy merge in flagOptions. +// For each unset flag, if the parsed data map contains a key matching the flag's QueryPath, HeaderPath, or +// PathParam (or any of its DataAliases), the flag is set to that value via flag.Set. +// +// Inner flags (those with an outer flag) are also handled: if the outer flag's body path key exists in the +// data map and contains a nested map with a key matching the inner flag's field (or aliases), the inner +// flag is set from that nested value. +func ApplyStdinDataToFlags(cmd *cli.Command, data map[string]any) error { + for _, flag := range cmd.Flags { + if flag.IsSet() { + continue + } + + // Handle inner flags: look for their value nested under the outer flag's body path. + if inner, ok := flag.(HasOuterFlag); ok { + outer, outerOk := inner.GetOuterFlag().(InRequest) + if !outerOk || outer.GetBodyPath() == "" { + continue + } + nested, ok := data[outer.GetBodyPath()].(map[string]any) + if !ok { + continue + } + innerField := inner.GetInnerField() + val, found := nested[innerField] + if !found { + for _, alias := range inner.GetDataAliases() { + if alias != "" && alias != innerField { + if v, ok := nested[alias]; ok { + val, found = v, true + break + } + } + } + } + if !found { + continue + } + setVal, err := formatForFlagSet(val) + if err != nil { + return fmt.Errorf("cannot format piped value for flag %q: %w", flag.Names()[0], err) + } + if err := flag.Set(flag.Names()[0], setVal); err != nil { + return fmt.Errorf("cannot set flag %q from piped data: %w", flag.Names()[0], err) + } + continue + } + + inReq, ok := flag.(InRequest) + if !ok { + continue + } + + // Try each request location in turn, checking the canonical path key and all aliases. + // Body params are excluded: they are already handled by the maps.Copy merge in flagOptions. + for _, path := range []string{inReq.GetQueryPath(), inReq.GetHeaderPath(), inReq.GetPathParam()} { + if path == "" { + continue + } + var val any + var found bool + for _, key := range append([]string{path}, inReq.GetDataAliases()...) { + if v, ok := data[key]; ok { + val, found = v, true + break + } + } + if !found { + continue + } + setVal, err := formatForFlagSet(val) + if err != nil { + return fmt.Errorf("cannot format piped value for flag %q: %w", flag.Names()[0], err) + } + if err := flag.Set(flag.Names()[0], setVal); err != nil { + return fmt.Errorf("cannot set flag %q from piped data: %w", flag.Names()[0], err) + } + break + } + } + return nil +} + func ExtractRequestContents(cmd *cli.Command) RequestContents { bodyMap := make(map[string]any) res := RequestContents{ @@ -291,7 +402,7 @@ func (f *Flag[T]) IsRequired() bool { } // Intentionally don't use `f.Required`, because request flags may be passed // over stdin as well as by flag. - if f.BodyPath != "" || f.BodyRoot { + if f.BodyPath != "" || f.BodyRoot || f.PathParam != "" || f.QueryPath != "" || f.HeaderPath != "" { return false } return f.Required diff --git a/internal/requestflag/requestflag_test.go b/internal/requestflag/requestflag_test.go index 06ffb72..779bd57 100644 --- a/internal/requestflag/requestflag_test.go +++ b/internal/requestflag/requestflag_test.go @@ -880,3 +880,348 @@ func TestInnerFlagDispatchOnUntypedFlag(t *testing.T) { assert.JSONEq(t, `{"foo":[{"name":"first","url":"https://example.com"}]}`, string(body)) }) } + +func TestApplyStdinDataToFlags(t *testing.T) { + t.Parallel() + + t.Run("sets query path flag from piped data", func(t *testing.T) { + t.Parallel() + + flag := &Flag[string]{ + Name: "account-id", + QueryPath: "account_id", + } + assert.NoError(t, flag.PreParse()) + + data := map[string]any{"account_id": "acct_123"} + cmd := &cli.Command{Flags: []cli.Flag{flag}} + assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) + + assert.True(t, flag.IsSet()) + assert.Equal(t, "acct_123", flag.Get()) + }) + + t.Run("sets header path flag from piped data", func(t *testing.T) { + t.Parallel() + + flag := &Flag[string]{ + Name: "idempotency-key", + HeaderPath: "Idempotency-Key", + } + assert.NoError(t, flag.PreParse()) + + data := map[string]any{"Idempotency-Key": "key-xyz"} + cmd := &cli.Command{Flags: []cli.Flag{flag}} + assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) + + assert.True(t, flag.IsSet()) + assert.Equal(t, "key-xyz", flag.Get()) + }) + + t.Run("does not set body path flag from piped data", func(t *testing.T) { + t.Parallel() + + // Body params are handled by the maps.Copy merge in flagOptions, not by ApplyStdinDataToFlags. + flag := &Flag[string]{ + Name: "message", + BodyPath: "message", + } + assert.NoError(t, flag.PreParse()) + + data := map[string]any{"message": "hello world"} + cmd := &cli.Command{Flags: []cli.Flag{flag}} + assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) + + assert.False(t, flag.IsSet()) + }) + + t.Run("does not override flag already set via CLI", func(t *testing.T) { + t.Parallel() + + flag := &Flag[string]{ + Name: "account-id", + QueryPath: "account_id", + } + assert.NoError(t, flag.PreParse()) + assert.NoError(t, flag.Set("account-id", "explicit_value")) + + data := map[string]any{"account_id": "piped_value"} + cmd := &cli.Command{Flags: []cli.Flag{flag}} + assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) + + // The explicitly-set value should win. + assert.Equal(t, "explicit_value", flag.Get()) + }) + + t.Run("sets integer query flag from piped data", func(t *testing.T) { + t.Parallel() + + flag := &Flag[int64]{ + Name: "page-size", + QueryPath: "page_size", + } + assert.NoError(t, flag.PreParse()) + + data := map[string]any{"page_size": int64(50)} + cmd := &cli.Command{Flags: []cli.Flag{flag}} + assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) + + assert.True(t, flag.IsSet()) + assert.Equal(t, int64(50), flag.Get()) + }) + + t.Run("sets boolean query flag from piped data", func(t *testing.T) { + t.Parallel() + + flag := &Flag[bool]{ + Name: "include-deleted", + QueryPath: "include_deleted", + } + assert.NoError(t, flag.PreParse()) + + data := map[string]any{"include_deleted": true} + cmd := &cli.Command{Flags: []cli.Flag{flag}} + assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) + + assert.True(t, flag.IsSet()) + assert.Equal(t, true, flag.Get()) + }) + + t.Run("resolves query path flag via data alias", func(t *testing.T) { + t.Parallel() + + flag := &Flag[string]{ + Name: "account-id", + QueryPath: "account_id", + DataAliases: []string{"accountId", "account"}, + } + assert.NoError(t, flag.PreParse()) + + // Use one of the aliases as the key in piped data. + data := map[string]any{"accountId": "acct_alias"} + cmd := &cli.Command{Flags: []cli.Flag{flag}} + assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) + + assert.True(t, flag.IsSet()) + assert.Equal(t, "acct_alias", flag.Get()) + }) + + t.Run("does not set body path flag via data alias", func(t *testing.T) { + t.Parallel() + + // Body params are handled by the maps.Copy merge in flagOptions, not by ApplyStdinDataToFlags. + flag := &Flag[string]{ + Name: "user-name", + BodyPath: "user_name", + DataAliases: []string{"userName", "username"}, + } + assert.NoError(t, flag.PreParse()) + + data := map[string]any{"userName": "alice"} + cmd := &cli.Command{Flags: []cli.Flag{flag}} + assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) + + assert.False(t, flag.IsSet()) + }) + + t.Run("ignores flags with no matching key in piped data", func(t *testing.T) { + t.Parallel() + + flag := &Flag[string]{ + Name: "account-id", + QueryPath: "account_id", + } + assert.NoError(t, flag.PreParse()) + + data := map[string]any{"other_key": "value"} + cmd := &cli.Command{Flags: []cli.Flag{flag}} + assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) + + assert.False(t, flag.IsSet()) + }) + + t.Run("ignores flags with no path set", func(t *testing.T) { + t.Parallel() + + flag := &Flag[string]{ + Name: "some-flag", + // No QueryPath, HeaderPath, or BodyPath + } + assert.NoError(t, flag.PreParse()) + + data := map[string]any{"some-flag": "value"} + cmd := &cli.Command{Flags: []cli.Flag{flag}} + assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) + + assert.False(t, flag.IsSet()) + }) + + t.Run("handles multiple flags from piped data", func(t *testing.T) { + t.Parallel() + + accountFlag := &Flag[string]{ + Name: "account-id", + QueryPath: "account_id", + } + limitFlag := &Flag[int64]{ + Name: "limit", + QueryPath: "limit", + } + assert.NoError(t, accountFlag.PreParse()) + assert.NoError(t, limitFlag.PreParse()) + + data := map[string]any{ + "account_id": "acct_abc", + "limit": int64(25), + } + cmd := &cli.Command{Flags: []cli.Flag{accountFlag, limitFlag}} + assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) + + assert.True(t, accountFlag.IsSet()) + assert.Equal(t, "acct_abc", accountFlag.Get()) + assert.True(t, limitFlag.IsSet()) + assert.Equal(t, int64(25), limitFlag.Get()) + }) + + t.Run("sets inner flag from nested piped data under outer body path", func(t *testing.T) { + t.Parallel() + + outer := &Flag[map[string]any]{ + Name: "address", + BodyPath: "address", + } + assert.NoError(t, outer.PreParse()) + + cityInner := &InnerFlag[string]{ + Name: "address.city", + InnerField: "city", + OuterFlag: outer, + } + + data := map[string]any{ + "address": map[string]any{"city": "San Francisco"}, + } + cmd := &cli.Command{Flags: []cli.Flag{outer, cityInner}} + assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) + + // InnerFlag.IsSet() is always false by design; verify the value was written + // into the outer flag's underlying map instead. + outerVal, ok := outer.Get().(map[string]any) + assert.True(t, ok, "expected outer flag value to be map[string]any, got %T", outer.Get()) + assert.Equal(t, "San Francisco", outerVal["city"]) + }) + + t.Run("sets inner flag via data alias in nested piped data", func(t *testing.T) { + t.Parallel() + + outer := &Flag[map[string]any]{ + Name: "address", + BodyPath: "address", + } + assert.NoError(t, outer.PreParse()) + + cityInner := &InnerFlag[string]{ + Name: "address.city", + InnerField: "city", + DataAliases: []string{"cityName"}, + OuterFlag: outer, + } + + // Use the alias in piped data. + data := map[string]any{ + "address": map[string]any{"cityName": "Portland"}, + } + cmd := &cli.Command{Flags: []cli.Flag{outer, cityInner}} + assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) + + // InnerFlag.IsSet() is always false by design; verify the value was written + // into the outer flag's underlying map instead. + outerVal, ok := outer.Get().(map[string]any) + assert.True(t, ok, "expected outer flag value to be map[string]any, got %T", outer.Get()) + assert.Equal(t, "Portland", outerVal["city"]) + }) + + t.Run("does not set inner flag when outer flag has no body path", func(t *testing.T) { + t.Parallel() + + outer := &Flag[map[string]any]{ + Name: "options", + // No BodyPath set + } + assert.NoError(t, outer.PreParse()) + + inner := &InnerFlag[string]{ + Name: "options.key", + InnerField: "key", + OuterFlag: outer, + } + + data := map[string]any{ + "options": map[string]any{"key": "value"}, + } + cmd := &cli.Command{Flags: []cli.Flag{outer, inner}} + assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) + + assert.False(t, inner.IsSet()) + }) + + t.Run("does not set inner flag when piped data has no nested map for outer path", func(t *testing.T) { + t.Parallel() + + outer := &Flag[map[string]any]{ + Name: "address", + BodyPath: "address", + } + assert.NoError(t, outer.PreParse()) + + inner := &InnerFlag[string]{ + Name: "address.city", + InnerField: "city", + OuterFlag: outer, + } + + // The outer body path key is missing from the piped data. + data := map[string]any{"other": "value"} + cmd := &cli.Command{Flags: []cli.Flag{outer, inner}} + assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) + + assert.False(t, inner.IsSet()) + }) + + t.Run("canonical path key takes precedence over alias when both are present", func(t *testing.T) { + t.Parallel() + + flag := &Flag[string]{ + Name: "account-id", + QueryPath: "account_id", + DataAliases: []string{"accountId"}, + } + assert.NoError(t, flag.PreParse()) + + // Both canonical and alias present — canonical should win because it's checked first. + data := map[string]any{ + "account_id": "canonical_value", + "accountId": "alias_value", + } + cmd := &cli.Command{Flags: []cli.Flag{flag}} + assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) + + assert.True(t, flag.IsSet()) + assert.Equal(t, "canonical_value", flag.Get()) + }) + + t.Run("empty data map does not set any flags", func(t *testing.T) { + t.Parallel() + + flag := &Flag[string]{ + Name: "account-id", + QueryPath: "account_id", + } + assert.NoError(t, flag.PreParse()) + + cmd := &cli.Command{Flags: []cli.Flag{flag}} + assert.NoError(t, ApplyStdinDataToFlags(cmd, map[string]any{})) + + assert.False(t, flag.IsSet()) + }) +} diff --git a/pkg/cmd/file.go b/pkg/cmd/file.go index f1f7369..3d5ecf6 100644 --- a/pkg/cmd/file.go +++ b/pkg/cmd/file.go @@ -21,9 +21,10 @@ var filesRetrieve = cli.Command{ Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ - Name: "file-id", - Usage: "The unique identifier of the file.", - Required: true, + Name: "file-id", + Usage: "The unique identifier of the file.", + Required: true, + PathParam: "file_id", }, }, Action: handleFilesRetrieve, @@ -61,9 +62,10 @@ var filesDelete = cli.Command{ Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ - Name: "file-id", - Usage: "The unique identifier of the file.", - Required: true, + Name: "file-id", + Usage: "The unique identifier of the file.", + Required: true, + PathParam: "file_id", }, }, Action: handleFilesDelete, @@ -76,9 +78,10 @@ var filesDownload = cli.Command{ Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ - Name: "file-id", - Usage: "The unique identifier of the file.", - Required: true, + Name: "file-id", + Usage: "The unique identifier of the file.", + Required: true, + PathParam: "file_id", }, &requestflag.Flag[string]{ Name: "output", @@ -157,8 +160,6 @@ func handleFilesList(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := deeptable.FileListParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -170,6 +171,8 @@ func handleFilesList(ctx context.Context, cmd *cli.Command) error { return err } + params := deeptable.FileListParams{} + format := cmd.Root().String("format") explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") @@ -287,8 +290,6 @@ func handleFilesUpload(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := deeptable.FileUploadParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -300,6 +301,8 @@ func handleFilesUpload(ctx context.Context, cmd *cli.Command) error { return err } + params := deeptable.FileUploadParams{} + var res []byte options = append(options, option.WithResponseBodyInto(&res)) _, err = client.Files.Upload(ctx, params, options...) diff --git a/pkg/cmd/flagoptions.go b/pkg/cmd/flagoptions.go index 1934f91..e524a01 100644 --- a/pkg/cmd/flagoptions.go +++ b/pkg/cmd/flagoptions.go @@ -339,7 +339,7 @@ func flagOptions( } stdinConsumedByPipe := false - if (bodyType == MultipartFormEncoded || bodyType == ApplicationJSON) && !ignoreStdin && isInputPiped() { + if bodyType != ApplicationOctetStream && !ignoreStdin && isInputPiped() { pipeData, err := io.ReadAll(os.Stdin) if err != nil { return nil, err @@ -353,16 +353,45 @@ func flagOptions( } if bodyMap, ok := bodyData.(map[string]any); ok { applyDataAliases(cmd, bodyMap) - if flagMap, ok := requestContents.Body.(map[string]any); ok { - maps.Copy(bodyMap, flagMap) - requestContents.Body = bodyMap + // Apply any matching keys from the piped data to path, query, and header flags + // that have not already been set via the command line. + if err := requestflag.ApplyStdinDataToFlags(cmd, bodyMap); err != nil { + return nil, err + } + // Re-extract request contents now that flags may have been updated. + requestContents = requestflag.ExtractRequestContents(cmd) + // Remove keys that were consumed as query, header, or path params so they + // don't also leak into the request body via the maps.Copy merge below. + // We delete both the canonical key and any aliases since the user may have + // piped data using an alias name rather than the canonical API name. + for _, flag := range cmd.Flags { + inReq, ok := flag.(requestflag.InRequest) + if !ok || !flag.IsSet() { + continue + } + if inReq.GetQueryPath() != "" || inReq.GetHeaderPath() != "" || inReq.GetPathParam() != "" { + delete(bodyMap, inReq.GetQueryPath()) + delete(bodyMap, inReq.GetHeaderPath()) + delete(bodyMap, inReq.GetPathParam()) + for _, alias := range inReq.GetDataAliases() { + delete(bodyMap, alias) + } + } + } + if bodyType != EmptyBody { + if flagMap, ok := requestContents.Body.(map[string]any); ok { + maps.Copy(bodyMap, flagMap) + requestContents.Body = bodyMap + } else { + bodyData = requestContents.Body + } + } + } else if bodyType != EmptyBody { + if flagMap, ok := requestContents.Body.(map[string]any); ok && len(flagMap) > 0 { + return nil, fmt.Errorf("Cannot merge flags with a body that is not a map: %v", bodyData) } else { - bodyData = requestContents.Body + requestContents.Body = bodyData } - } else if flagMap, ok := requestContents.Body.(map[string]any); ok && len(flagMap) > 0 { - return nil, fmt.Errorf("Cannot merge flags with a body that is not a map: %v", bodyData) - } else { - requestContents.Body = bodyData } } } @@ -370,7 +399,6 @@ func flagOptions( if missingFlags := requestflag.GetMissingRequiredFlags(cmd, requestContents.Body); len(missingFlags) > 0 { if len(missingFlags) == 1 { return nil, fmt.Errorf("Required flag %q not set\nRun '%s --help' for usage information", missingFlags[0].Names()[0], cmd.FullName()) - } else { names := []string{} for _, flag := range missingFlags { diff --git a/pkg/cmd/structuredsheet.go b/pkg/cmd/structuredsheet.go index dfdbbc6..1ca7546 100644 --- a/pkg/cmd/structuredsheet.go +++ b/pkg/cmd/structuredsheet.go @@ -42,9 +42,10 @@ var structuredSheetsRetrieve = cli.Command{ Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ - Name: "structured-sheet-id", - Usage: "The unique identifier of the structured sheet conversion.", - Required: true, + Name: "structured-sheet-id", + Usage: "The unique identifier of the structured sheet conversion.", + Required: true, + PathParam: "structured_sheet_id", }, }, Action: handleStructuredSheetsRetrieve, @@ -82,9 +83,10 @@ var structuredSheetsDelete = cli.Command{ Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ - Name: "structured-sheet-id", - Usage: "The unique identifier of the structured sheet conversion.", - Required: true, + Name: "structured-sheet-id", + Usage: "The unique identifier of the structured sheet conversion.", + Required: true, + PathParam: "structured_sheet_id", }, }, Action: handleStructuredSheetsDelete, @@ -97,9 +99,10 @@ var structuredSheetsCancel = cli.Command{ Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ - Name: "structured-sheet-id", - Usage: "The unique identifier of the structured sheet conversion.", - Required: true, + Name: "structured-sheet-id", + Usage: "The unique identifier of the structured sheet conversion.", + Required: true, + PathParam: "structured_sheet_id", }, }, Action: handleStructuredSheetsCancel, @@ -112,9 +115,10 @@ var structuredSheetsDownload = cli.Command{ Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ - Name: "structured-sheet-id", - Usage: "The unique identifier of the structured sheet conversion.", - Required: true, + Name: "structured-sheet-id", + Usage: "The unique identifier of the structured sheet conversion.", + Required: true, + PathParam: "structured_sheet_id", }, &requestflag.Flag[string]{ Name: "format", @@ -140,8 +144,6 @@ func handleStructuredSheetsCreate(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := deeptable.StructuredSheetNewParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -153,6 +155,8 @@ func handleStructuredSheetsCreate(ctx context.Context, cmd *cli.Command) error { return err } + params := deeptable.StructuredSheetNewParams{} + var res []byte options = append(options, option.WithResponseBodyInto(&res)) _, err = client.StructuredSheets.New(ctx, params, options...) @@ -223,8 +227,6 @@ func handleStructuredSheetsList(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := deeptable.StructuredSheetListParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -236,6 +238,8 @@ func handleStructuredSheetsList(ctx context.Context, cmd *cli.Command) error { return err } + params := deeptable.StructuredSheetListParams{} + format := cmd.Root().String("format") explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") @@ -365,8 +369,6 @@ func handleStructuredSheetsDownload(ctx context.Context, cmd *cli.Command) error return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := deeptable.StructuredSheetDownloadParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -378,6 +380,8 @@ func handleStructuredSheetsDownload(ctx context.Context, cmd *cli.Command) error return err } + params := deeptable.StructuredSheetDownloadParams{} + response, err := client.StructuredSheets.Download( ctx, cmd.Value("structured-sheet-id").(string), diff --git a/pkg/cmd/structuredsheettable.go b/pkg/cmd/structuredsheettable.go index fedefc6..424bb55 100644 --- a/pkg/cmd/structuredsheettable.go +++ b/pkg/cmd/structuredsheettable.go @@ -21,14 +21,16 @@ var structuredSheetsTablesRetrieve = cli.Command{ Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ - Name: "structured-sheet-id", - Usage: "The unique identifier of the structured sheet conversion.", - Required: true, + Name: "structured-sheet-id", + Usage: "The unique identifier of the structured sheet conversion.", + Required: true, + PathParam: "structured_sheet_id", }, &requestflag.Flag[string]{ - Name: "table-id", - Usage: "The unique identifier of the table.", - Required: true, + Name: "table-id", + Usage: "The unique identifier of the table.", + Required: true, + PathParam: "table_id", }, }, Action: handleStructuredSheetsTablesRetrieve, @@ -41,9 +43,10 @@ var structuredSheetsTablesList = cli.Command{ Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ - Name: "structured-sheet-id", - Usage: "The unique identifier of the structured sheet conversion.", - Required: true, + Name: "structured-sheet-id", + Usage: "The unique identifier of the structured sheet conversion.", + Required: true, + PathParam: "structured_sheet_id", }, &requestflag.Flag[*string]{ Name: "after", @@ -71,14 +74,16 @@ var structuredSheetsTablesDownload = cli.Command{ Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ - Name: "structured-sheet-id", - Usage: "The unique identifier of the structured sheet conversion.", - Required: true, + Name: "structured-sheet-id", + Usage: "The unique identifier of the structured sheet conversion.", + Required: true, + PathParam: "structured_sheet_id", }, &requestflag.Flag[string]{ - Name: "table-id", - Usage: "The unique identifier of the table.", - Required: true, + Name: "table-id", + Usage: "The unique identifier of the table.", + Required: true, + PathParam: "table_id", }, &requestflag.Flag[string]{ Name: "format", @@ -107,10 +112,6 @@ func handleStructuredSheetsTablesRetrieve(ctx context.Context, cmd *cli.Command) return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := deeptable.StructuredSheetTableGetParams{ - StructuredSheetID: cmd.Value("structured-sheet-id").(string), - } - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -122,6 +123,10 @@ func handleStructuredSheetsTablesRetrieve(ctx context.Context, cmd *cli.Command) return err } + params := deeptable.StructuredSheetTableGetParams{ + StructuredSheetID: cmd.Value("structured-sheet-id").(string), + } + var res []byte options = append(options, option.WithResponseBodyInto(&res)) _, err = client.StructuredSheets.Tables.Get( @@ -158,8 +163,6 @@ func handleStructuredSheetsTablesList(ctx context.Context, cmd *cli.Command) err return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := deeptable.StructuredSheetTableListParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -171,6 +174,8 @@ func handleStructuredSheetsTablesList(ctx context.Context, cmd *cli.Command) err return err } + params := deeptable.StructuredSheetTableListParams{} + format := cmd.Root().String("format") explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") @@ -226,10 +231,6 @@ func handleStructuredSheetsTablesDownload(ctx context.Context, cmd *cli.Command) return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := deeptable.StructuredSheetTableDownloadParams{ - StructuredSheetID: cmd.Value("structured-sheet-id").(string), - } - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -241,6 +242,10 @@ func handleStructuredSheetsTablesDownload(ctx context.Context, cmd *cli.Command) return err } + params := deeptable.StructuredSheetTableDownloadParams{ + StructuredSheetID: cmd.Value("structured-sheet-id").(string), + } + response, err := client.StructuredSheets.Tables.Download( ctx, cmd.Value("table-id").(string), From 4bd53efc89a95f7fae317e04dbde49be01010fe4 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 04:21:27 +0000 Subject: [PATCH 37/37] release: 0.1.0-beta.3 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 50 +++++++++++++++++++++++++++++++++++ pkg/cmd/version.go | 2 +- 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index aff3ead..bf7fe4f 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.1.0-beta.2" + ".": "0.1.0-beta.3" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 870813a..8149c9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,55 @@ # Changelog +## 0.1.0-beta.3 (2026-04-30) + +Full Changelog: [v0.1.0-beta.2...v0.1.0-beta.3](https://github.com/deeptable-com/deeptable-cli/compare/v0.1.0-beta.2...v0.1.0-beta.3) + +### Features + +* allow `-` as value representing stdin to binary-only file parameters in CLIs ([e30bb3a](https://github.com/deeptable-com/deeptable-cli/commit/e30bb3a93f299cc74a5fd022fd6d7bc4ad16bebf)) +* better error message if scheme forgotten in CLI `*_BASE_URL`/`--base-url` ([3ee457a](https://github.com/deeptable-com/deeptable-cli/commit/3ee457aa17ed94997cc134b99c77e28a85900a82)) +* binary-only parameters become CLI flags that take filenames only ([482d722](https://github.com/deeptable-com/deeptable-cli/commit/482d722a794806e5c60ab124dee0459170397bc9)) +* **cli:** add `--raw-output`/`-r` option to print raw (non-JSON) strings ([177c28f](https://github.com/deeptable-com/deeptable-cli/commit/177c28f629b61bb326788545144976e2e54e46a3)) +* **cli:** alias parameters in data with `x-stainless-cli-data-alias` ([9a763d3](https://github.com/deeptable-com/deeptable-cli/commit/9a763d3b2a19e9e1fb60cab9da8a9562a0760177)) +* **cli:** send filename and content type when reading input from files ([4116cf0](https://github.com/deeptable-com/deeptable-cli/commit/4116cf01842502beea08e26368f9d99a5049bb06)) +* set CLI flag constant values automatically where `x-stainless-const` is set ([d5a50f3](https://github.com/deeptable-com/deeptable-cli/commit/d5a50f395b1a844f07824fb1f2e4bdfcd22e354c)) +* support passing path and query params over stdin ([8e27218](https://github.com/deeptable-com/deeptable-cli/commit/8e27218906f07ebd77ee8ba790eec504bbcfbe7f)) + + +### Bug Fixes + +* cli no longer hangs when stdin is attached to a pipe with empty input ([0063fc1](https://github.com/deeptable-com/deeptable-cli/commit/0063fc108a77d22495846cc6106592c22761792c)) +* **cli:** correctly load zsh autocompletion ([9e91ddf](https://github.com/deeptable-com/deeptable-cli/commit/9e91ddf6fb36e634f56a23a9a01102e548073eb4)) +* fall back to main branch if linking fails in CI ([ec4eb09](https://github.com/deeptable-com/deeptable-cli/commit/ec4eb09e2f76d4a75fdfec1f2b407d9ad8afb67a)) +* fix for failing to drop invalid module replace in link script ([b3ea45a](https://github.com/deeptable-com/deeptable-cli/commit/b3ea45a6f1c7bc4111bf68507f061b642d6bb789)) +* fix for off-by-one error in pagination logic ([d7142f9](https://github.com/deeptable-com/deeptable-cli/commit/d7142f9c3552886983d8590e9b47df4b774ffb49)) +* fix quoting typo ([006840d](https://github.com/deeptable-com/deeptable-cli/commit/006840d8fc41deeb53de45b2ab1c18fd877480aa)) +* flags for nullable body scalar fields are strictly typed ([a98eb25](https://github.com/deeptable-com/deeptable-cli/commit/a98eb255536c876836413ba333773b7b54929fb9)) +* handle empty data set using `--format explore` ([76e8aa7](https://github.com/deeptable-com/deeptable-cli/commit/76e8aa73ea6dbaceb088b835888f26688dc6f360)) +* use `RawJSON` when iterating items with `--format explore` in the CLI ([0806fe8](https://github.com/deeptable-com/deeptable-cli/commit/0806fe8ed9e8355d4f06f028de673e541d091105)) + + +### Chores + +* add documentation for ./scripts/link ([a8008f2](https://github.com/deeptable-com/deeptable-cli/commit/a8008f2a7b3a064ad21d86ab8800d6696c8d455e)) +* **ci:** skip lint on metadata-only changes ([0be1028](https://github.com/deeptable-com/deeptable-cli/commit/0be1028ca0f24e44cd5fe74b046ad514b47d08a4)) +* **ci:** support manually triggering release workflow ([92efacd](https://github.com/deeptable-com/deeptable-cli/commit/92efacd686131b1245b8f25467daaba4c650d602)) +* **cli:** additional test cases for `ShowJSONIterator` ([f882462](https://github.com/deeptable-com/deeptable-cli/commit/f8824621a6ffc592f70e7cefe0c0ded4ce892888)) +* **cli:** fall back to JSON when using default "explore" with non-TTY ([22db507](https://github.com/deeptable-com/deeptable-cli/commit/22db507c72c8578edaea3d1295d08b22229430bd)) +* **cli:** let `--format raw` be used in conjunction with `--transform` ([eca1743](https://github.com/deeptable-com/deeptable-cli/commit/eca1743bead6e107e3c839d96245005d1401a0c7)) +* **cli:** switch long lists of positional args over to param structs ([5d7b97a](https://github.com/deeptable-com/deeptable-cli/commit/5d7b97aad32a194a00a4f43c95197bbd018413b2)) +* **cli:** use `ShowJSONOpts` as argument to `formatJSON` instead of many positionals ([8eb757d](https://github.com/deeptable-com/deeptable-cli/commit/8eb757d4dc41a21f87cdeda972597ada022a3580)) +* **internal:** codegen related update ([ab0b024](https://github.com/deeptable-com/deeptable-cli/commit/ab0b024de44d535fb1402bdfd82f048d943ff65d)) +* **internal:** codegen related update ([636523c](https://github.com/deeptable-com/deeptable-cli/commit/636523c180805486a19a0687901d9b4c27212e19)) +* **internal:** codegen related update ([6241318](https://github.com/deeptable-com/deeptable-cli/commit/6241318a939febb87359001b98fe890de2797099)) +* **internal:** codegen related update ([aad810e](https://github.com/deeptable-com/deeptable-cli/commit/aad810e7b632dd112c095cf2d428c23ee4a96a99)) +* **internal:** more robust bootstrap script ([3e2edb5](https://github.com/deeptable-com/deeptable-cli/commit/3e2edb59bd63cdb77f3128feaf7c1a68e1b0c77c)) +* **internal:** update gitignore ([428cb27](https://github.com/deeptable-com/deeptable-cli/commit/428cb27be73809d9bb17a02134a302a08a0f9520)) +* mark all CLI-related tests in Go with `t.Parallel()` ([f895692](https://github.com/deeptable-com/deeptable-cli/commit/f8956929608c4ee645de258a11fc982cdf1f35e6)) +* modify CLI tests to inject stdout so mutating `os.Stdout` isn't necessary ([1fae171](https://github.com/deeptable-com/deeptable-cli/commit/1fae171f023fc2529341f3b2ee4704dceefeeff1)) +* omit full usage information when missing required CLI parameters ([33fb84c](https://github.com/deeptable-com/deeptable-cli/commit/33fb84c343af94a2a673652f9da1655f9ea20d2e)) +* switch some CLI Go tests from `os.Chdir` to `t.Chdir` ([1c11dc7](https://github.com/deeptable-com/deeptable-cli/commit/1c11dc73b06973991ce1c129a2010e54bf4f599e)) + ## 0.1.0-beta.2 (2026-03-19) Full Changelog: [v0.1.0-beta.1...v0.1.0-beta.2](https://github.com/deeptable-com/deeptable-cli/compare/v0.1.0-beta.1...v0.1.0-beta.2) diff --git a/pkg/cmd/version.go b/pkg/cmd/version.go index 7cb64c1..bc12c38 100644 --- a/pkg/cmd/version.go +++ b/pkg/cmd/version.go @@ -2,4 +2,4 @@ package cmd -const Version = "0.1.0-beta.2" // x-release-please-version +const Version = "0.1.0-beta.3" // x-release-please-version