From d18d972e88c1a03890fcbe3ba2f0dbffb06f5e48 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 02:05:13 +0000 Subject: [PATCH 01/52] 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 2742613..940785a 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/beeper-desktop-api-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/beeper-desktop-api-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 c7c519cf80adffc7cebbd3ec220227baf48672d7 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 02:06:05 +0000 Subject: [PATCH 02/52] chore(tests): bump steady to v0.19.7 --- scripts/mock | 6 +++--- scripts/test | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/mock b/scripts/mock index 0f82c95..3732f8e 100755 --- a/scripts/mock +++ b/scripts/mock @@ -22,9 +22,9 @@ echo "==> Starting mock server with URL ${URL}" # Run steady mock on the given spec if [ "$1" == "--daemon" ]; then # Pre-install the package so the download doesn't eat into the startup timeout - npm exec --package=@stdy/cli@0.19.6 -- steady --version + npm exec --package=@stdy/cli@0.19.7 -- steady --version - npm exec --package=@stdy/cli@0.19.6 -- steady --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets "$URL" &> .stdy.log & + npm exec --package=@stdy/cli@0.19.7 -- steady --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets "$URL" &> .stdy.log & # Wait for server to come online via health endpoint (max 30s) echo -n "Waiting for server" @@ -48,5 +48,5 @@ if [ "$1" == "--daemon" ]; then echo else - npm exec --package=@stdy/cli@0.19.6 -- steady --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets "$URL" + npm exec --package=@stdy/cli@0.19.7 -- steady --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets "$URL" fi diff --git a/scripts/test b/scripts/test index 9f448ce..0a858e6 100755 --- a/scripts/test +++ b/scripts/test @@ -46,7 +46,7 @@ elif ! steady_is_running ; then echo -e "To run the server, pass in the path or url of your OpenAPI" echo -e "spec to the steady command:" echo - echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.19.6 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets${NC}" + echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.19.7 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets${NC}" echo exit 1 From 03e1c530c7c6e0988d6f9f95a4f115dc178d7463 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 02:25:05 +0000 Subject: [PATCH 03/52] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index 2b39be6..60bb453 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 23 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-5a8ac7b545c48dc892e5c680303e305254921554dabee848e40a808659dbcf1e.yml openapi_spec_hash: 0103975601aac1445d3a4ef418c5d17a -config_hash: ca148af6be59ec54295b2c5f852a38d1 +config_hash: e342a96262eaf44c54e8bbb93cc8d7a7 From 8ccee5cbcc06978fef3c96a220d18eaae98bd49a Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 02:25:23 +0000 Subject: [PATCH 04/52] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index 60bb453..16d5bba 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 23 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-5a8ac7b545c48dc892e5c680303e305254921554dabee848e40a808659dbcf1e.yml openapi_spec_hash: 0103975601aac1445d3a4ef418c5d17a -config_hash: e342a96262eaf44c54e8bbb93cc8d7a7 +config_hash: f99f904573839260bdb6d428bad17613 From 42310f51c4a4a7de7562f5d79b87bdf6cc33fb8f Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 02:25:34 +0000 Subject: [PATCH 05/52] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index 16d5bba..2c47924 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 23 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-5a8ac7b545c48dc892e5c680303e305254921554dabee848e40a808659dbcf1e.yml openapi_spec_hash: 0103975601aac1445d3a4ef418c5d17a -config_hash: f99f904573839260bdb6d428bad17613 +config_hash: 7d85c0b454fc78a59db6474c5c4d73c6 From c332ed822bc6e94cb521620032bdcf941d4b7593 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 02:07:21 +0000 Subject: [PATCH 06/52] 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 8b749cf09b595e66687d4030cee0791aff7e14c4 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 02:08:12 +0000 Subject: [PATCH 07/52] 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 4a39cd0..f74e11e 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 180bf353b36de3cfee9e9497dca4558622654e18 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 02:09:39 +0000 Subject: [PATCH 08/52] chore(internal): update multipart form array serialization --- scripts/mock | 4 ++-- scripts/test | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/mock b/scripts/mock index 3732f8e..58e4628 100755 --- a/scripts/mock +++ b/scripts/mock @@ -24,7 +24,7 @@ if [ "$1" == "--daemon" ]; then # Pre-install the package so the download doesn't eat into the startup timeout npm exec --package=@stdy/cli@0.19.7 -- steady --version - npm exec --package=@stdy/cli@0.19.7 -- steady --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets "$URL" &> .stdy.log & + npm exec --package=@stdy/cli@0.19.7 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" &> .stdy.log & # Wait for server to come online via health endpoint (max 30s) echo -n "Waiting for server" @@ -48,5 +48,5 @@ if [ "$1" == "--daemon" ]; then echo else - npm exec --package=@stdy/cli@0.19.7 -- steady --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets "$URL" + npm exec --package=@stdy/cli@0.19.7 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" fi diff --git a/scripts/test b/scripts/test index 0a858e6..79ceebe 100755 --- a/scripts/test +++ b/scripts/test @@ -46,7 +46,7 @@ elif ! steady_is_running ; then echo -e "To run the server, pass in the path or url of your OpenAPI" echo -e "spec to the steady command:" echo - echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.19.7 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets${NC}" + echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.19.7 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets${NC}" echo exit 1 From 16566c5d80a77de14f33e1895ec72cfa89da99c7 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 28 Mar 2026 02:21:33 +0000 Subject: [PATCH 09/52] 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 9b24177..cb8183c 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 533501d2960fc565d5891d5f610a7feef458d0b2 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 1 Apr 2026 02:18:01 +0000 Subject: [PATCH 10/52] chore(tests): bump steady to v0.20.1 --- scripts/mock | 6 +++--- scripts/test | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/mock b/scripts/mock index 58e4628..5ea72a2 100755 --- a/scripts/mock +++ b/scripts/mock @@ -22,9 +22,9 @@ echo "==> Starting mock server with URL ${URL}" # Run steady mock on the given spec if [ "$1" == "--daemon" ]; then # Pre-install the package so the download doesn't eat into the startup timeout - npm exec --package=@stdy/cli@0.19.7 -- steady --version + npm exec --package=@stdy/cli@0.20.1 -- steady --version - npm exec --package=@stdy/cli@0.19.7 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" &> .stdy.log & + npm exec --package=@stdy/cli@0.20.1 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" &> .stdy.log & # Wait for server to come online via health endpoint (max 30s) echo -n "Waiting for server" @@ -48,5 +48,5 @@ if [ "$1" == "--daemon" ]; then echo else - npm exec --package=@stdy/cli@0.19.7 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" + npm exec --package=@stdy/cli@0.20.1 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" fi diff --git a/scripts/test b/scripts/test index 79ceebe..17f4857 100755 --- a/scripts/test +++ b/scripts/test @@ -46,7 +46,7 @@ elif ! steady_is_running ; then echo -e "To run the server, pass in the path or url of your OpenAPI" echo -e "spec to the steady command:" echo - echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.19.7 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets${NC}" + echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.20.1 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets${NC}" echo exit 1 From 3a7b085b2130d21ee0a2b434dbaa3efada1e1bb3 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 1 Apr 2026 02:22:27 +0000 Subject: [PATCH 11/52] chore(tests): bump steady to v0.20.2 --- scripts/mock | 6 +++--- scripts/test | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/mock b/scripts/mock index 5ea72a2..7c58865 100755 --- a/scripts/mock +++ b/scripts/mock @@ -22,9 +22,9 @@ echo "==> Starting mock server with URL ${URL}" # Run steady mock on the given spec if [ "$1" == "--daemon" ]; then # Pre-install the package so the download doesn't eat into the startup timeout - npm exec --package=@stdy/cli@0.20.1 -- steady --version + npm exec --package=@stdy/cli@0.20.2 -- steady --version - npm exec --package=@stdy/cli@0.20.1 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" &> .stdy.log & + npm exec --package=@stdy/cli@0.20.2 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" &> .stdy.log & # Wait for server to come online via health endpoint (max 30s) echo -n "Waiting for server" @@ -48,5 +48,5 @@ if [ "$1" == "--daemon" ]; then echo else - npm exec --package=@stdy/cli@0.20.1 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" + npm exec --package=@stdy/cli@0.20.2 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" fi diff --git a/scripts/test b/scripts/test index 17f4857..7ba4b3f 100755 --- a/scripts/test +++ b/scripts/test @@ -46,7 +46,7 @@ elif ! steady_is_running ; then echo -e "To run the server, pass in the path or url of your OpenAPI" echo -e "spec to the steady command:" echo - echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.20.1 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets${NC}" + echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.20.2 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets${NC}" echo exit 1 From 6df00248f562bbbd98e7cbccf8ddd82b35a40ae3 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 02:09:40 +0000 Subject: [PATCH 12/52] 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 4af189dc0e5add5c654204e0e90c91e199de479a Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 02:09:55 +0000 Subject: [PATCH 13/52] 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 cb8183c..82655ad 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 ed64986c234a83e6c875a1202a7c217df9efa206 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 02:10:33 +0000 Subject: [PATCH 14/52] feat: binary-only parameters become CLI flags that take filenames only --- internal/requestflag/requestflag.go | 10 +++ pkg/cmd/asset.go | 9 +-- pkg/cmd/asset_test.go | 10 ++- pkg/cmd/flagoptions.go | 96 +++++++++++++++++++++++++++++ 4 files changed, 118 insertions(+), 7 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/asset.go b/pkg/cmd/asset.go index d7fc779..65023e3 100644 --- a/pkg/cmd/asset.go +++ b/pkg/cmd/asset.go @@ -53,10 +53,11 @@ var assetsUpload = cli.Command{ Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ - Name: "file", - Usage: "The file to upload (max 500 MB).", - Required: true, - BodyPath: "file", + Name: "file", + Usage: "The file to upload (max 500 MB).", + Required: true, + BodyPath: "file", + FileInput: true, }, &requestflag.Flag[string]{ Name: "file-name", diff --git a/pkg/cmd/asset_test.go b/pkg/cmd/asset_test.go index 2ce574c..696660a 100644 --- a/pkg/cmd/asset_test.go +++ b/pkg/cmd/asset_test.go @@ -3,6 +3,7 @@ package cmd import ( + "strings" "testing" "github.com/beeper/desktop-api-cli/internal/mocktest" @@ -46,18 +47,21 @@ func TestAssetsUpload(t *testing.T) { t, "--access-token", "string", "assets", "upload", - "--file", "Example data", + "--file", mocktest.TestFile(t, "Example data"), "--file-name", "fileName", "--mime-type", "mimeType", ) }) t.Run("piping data", func(t *testing.T) { + testFile := mocktest.TestFile(t, "Example data") // Test piping YAML data over stdin - pipeData := []byte("" + + pipeDataStr := "" + "file: Example data\n" + "fileName: fileName\n" + - "mimeType: mimeType\n") + "mimeType: mimeType\n" + pipeDataStr = strings.ReplaceAll(pipeDataStr, "Example data", testFile) + pipeData := []byte(pipeDataStr) mocktest.TestRunMockTestWithPipeAndFlags( t, pipeData, "--access-token", "string", diff --git a/pkg/cmd/flagoptions.go b/pkg/cmd/flagoptions.go index f74e11e..24ed79a 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 8d976d441c86ddfaa67c8fad50a156f9374e0d16 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 02:11:17 +0000 Subject: [PATCH 15/52] feat: better error message if scheme forgotten in CLI `*_BASE_URL`/`--base-url` --- cmd/beeper-desktop-cli/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/beeper-desktop-cli/main.go b/cmd/beeper-desktop-cli/main.go index e27225a..8ac76ad 100644 --- a/cmd/beeper-desktop-cli/main.go +++ b/cmd/beeper-desktop-cli/main.go @@ -23,6 +23,13 @@ func main() { prepareForAutocomplete(app) } + if baseURL, ok := os.LookupEnv("BEEPER_DESKTOP_BASE_URL"); ok { + if err := cmd.ValidateBaseURL(baseURL, "BEEPER_DESKTOP_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 3351e7f..4c91ac7 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 82655ad..06531f3 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("BeeperDesktop/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 eacfea0f881332d854999d73092a4786d0b3ae33 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 02:11:44 +0000 Subject: [PATCH 16/52] 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 24ed79a..198b295 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 c0de07ef35b6d776781b0347a102c3855445f0cb Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 02:11:59 +0000 Subject: [PATCH 17/52] 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 59678e17b692460f3fdc03ca82ba6270d13ed460 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 02:12:15 +0000 Subject: [PATCH 18/52] 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 9e6fe8f0d1cba9d5115fba0b82f24a5938b3e7bc Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 4 Apr 2026 02:08:31 +0000 Subject: [PATCH 19/52] 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 ++++----------- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/pkg/cmd/cmdutil.go b/pkg/cmd/cmdutil.go index 06531f3..fad3c3c 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()) }) } From 6960f674e30ae4b66c8c727accf4b140df4aa257 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 02:18:10 +0000 Subject: [PATCH 20/52] 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 940785a..82ce250 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/beeper-desktop-api-cli' run: | - ./scripts/link 'github.com/stainless-sdks/beeper-desktop-api-go@${{ github.ref_name }}' || true + ./scripts/link 'github.com/stainless-sdks/beeper-desktop-api-go@${{ github.ref_name }}' || go mod edit -dropreplace='github.com/stainless-sdks/beeper-desktop-api-go - name: Bootstrap run: ./scripts/bootstrap @@ -60,7 +60,7 @@ jobs: - name: Link staging branch if: github.repository == 'stainless-sdks/beeper-desktop-api-cli' run: | - ./scripts/link 'github.com/stainless-sdks/beeper-desktop-api-go@${{ github.ref_name }}' || true + ./scripts/link 'github.com/stainless-sdks/beeper-desktop-api-go@${{ github.ref_name }}' || go mod edit -dropreplace='github.com/stainless-sdks/beeper-desktop-api-go - name: Bootstrap run: ./scripts/bootstrap @@ -107,7 +107,7 @@ jobs: - name: Link staging branch if: github.repository == 'stainless-sdks/beeper-desktop-api-cli' run: | - ./scripts/link 'github.com/stainless-sdks/beeper-desktop-api-go@${{ github.ref_name }}' || true + ./scripts/link 'github.com/stainless-sdks/beeper-desktop-api-go@${{ github.ref_name }}' || go mod edit -dropreplace='github.com/stainless-sdks/beeper-desktop-api-go - name: Bootstrap run: ./scripts/bootstrap From 81a96d9880aee422f73677cdbe67134af37f6814 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 02:19:52 +0000 Subject: [PATCH 21/52] 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 82ce250..a726d4d 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/beeper-desktop-api-cli' run: | - ./scripts/link 'github.com/stainless-sdks/beeper-desktop-api-go@${{ github.ref_name }}' || go mod edit -dropreplace='github.com/stainless-sdks/beeper-desktop-api-go + ./scripts/link 'github.com/stainless-sdks/beeper-desktop-api-go@${{ github.ref_name }}' || go mod edit -dropreplace='github.com/stainless-sdks/beeper-desktop-api-go' - name: Bootstrap run: ./scripts/bootstrap @@ -60,7 +60,7 @@ jobs: - name: Link staging branch if: github.repository == 'stainless-sdks/beeper-desktop-api-cli' run: | - ./scripts/link 'github.com/stainless-sdks/beeper-desktop-api-go@${{ github.ref_name }}' || go mod edit -dropreplace='github.com/stainless-sdks/beeper-desktop-api-go + ./scripts/link 'github.com/stainless-sdks/beeper-desktop-api-go@${{ github.ref_name }}' || go mod edit -dropreplace='github.com/stainless-sdks/beeper-desktop-api-go' - name: Bootstrap run: ./scripts/bootstrap @@ -107,7 +107,7 @@ jobs: - name: Link staging branch if: github.repository == 'stainless-sdks/beeper-desktop-api-cli' run: | - ./scripts/link 'github.com/stainless-sdks/beeper-desktop-api-go@${{ github.ref_name }}' || go mod edit -dropreplace='github.com/stainless-sdks/beeper-desktop-api-go + ./scripts/link 'github.com/stainless-sdks/beeper-desktop-api-go@${{ github.ref_name }}' || go mod edit -dropreplace='github.com/stainless-sdks/beeper-desktop-api-go' - name: Bootstrap run: ./scripts/bootstrap From c37cb3e7e49185c7e7e398fb70a254ea8daf2456 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 02:35:23 +0000 Subject: [PATCH 22/52] 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 fad3c3c..3435a90 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 79f9f751f5fd415d9a34893e47b8409aa82dcb7c Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 02:39:30 +0000 Subject: [PATCH 23/52] 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..024fbe6 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/beeper/desktop-api-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 5480c0e637e57e45ef1780847a2ec1638ef8550d Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 02:40:54 +0000 Subject: [PATCH 24/52] 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 a726d4d..940785a 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/beeper-desktop-api-cli' run: | - ./scripts/link 'github.com/stainless-sdks/beeper-desktop-api-go@${{ github.ref_name }}' || go mod edit -dropreplace='github.com/stainless-sdks/beeper-desktop-api-go' + ./scripts/link 'github.com/stainless-sdks/beeper-desktop-api-go@${{ github.ref_name }}' || true - name: Bootstrap run: ./scripts/bootstrap @@ -60,7 +60,7 @@ jobs: - name: Link staging branch if: github.repository == 'stainless-sdks/beeper-desktop-api-cli' run: | - ./scripts/link 'github.com/stainless-sdks/beeper-desktop-api-go@${{ github.ref_name }}' || go mod edit -dropreplace='github.com/stainless-sdks/beeper-desktop-api-go' + ./scripts/link 'github.com/stainless-sdks/beeper-desktop-api-go@${{ github.ref_name }}' || true - name: Bootstrap run: ./scripts/bootstrap @@ -107,7 +107,7 @@ jobs: - name: Link staging branch if: github.repository == 'stainless-sdks/beeper-desktop-api-cli' run: | - ./scripts/link 'github.com/stainless-sdks/beeper-desktop-api-go@${{ github.ref_name }}' || go mod edit -dropreplace='github.com/stainless-sdks/beeper-desktop-api-go' + ./scripts/link 'github.com/stainless-sdks/beeper-desktop-api-go@${{ github.ref_name }}' || true - name: Bootstrap run: ./scripts/bootstrap diff --git a/scripts/link b/scripts/link index 2da9715..332584c 100755 --- a/scripts/link +++ b/scripts/link @@ -9,5 +9,9 @@ export GOPRIVATE="${GOPRIVATE:+$GOPRIVATE,}github.com/beeper/desktop-api-go,gith REPLACEMENT="${1:-"../beeperdesktop-go"}" echo "==> Replacing Go SDK with $REPLACEMENT" -go mod edit -replace github.com/beeper/desktop-api-go="$REPLACEMENT" -go mod tidy -e +if [[ -d "$REPLACEMENT" ]] || go list -m "$REPLACEMENT" >/dev/null; then + go mod edit -replace github.com/beeper/desktop-api-go="$REPLACEMENT" + go mod tidy -e +else + echo "Skipping Go SDK replacement (branch may not exist on Go SDK)" +fi From ccd116df91e25275b2f63f53bedf9939aae8a64a Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sun, 12 Apr 2026 18:31:54 +0000 Subject: [PATCH 25/52] feat(api): add network, bridge fields to accounts --- .stats.yml | 6 ++-- README.md | 3 +- pkg/cmd/chat.go | 74 +++----------------------------------------- pkg/cmd/chat_test.go | 46 +++------------------------ 4 files changed, 13 insertions(+), 116 deletions(-) diff --git a/.stats.yml b/.stats.yml index 2c47924..229f6b5 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 23 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-5a8ac7b545c48dc892e5c680303e305254921554dabee848e40a808659dbcf1e.yml -openapi_spec_hash: 0103975601aac1445d3a4ef418c5d17a -config_hash: 7d85c0b454fc78a59db6474c5c4d73c6 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-611aa7641fbca8cf31d626bf86f9efd3c2b92778e897ebbb25c6ea44185ed1ed.yml +openapi_spec_hash: d6c0a1776048dab04f6c5625c9893c9c +config_hash: 39ed0717b5f415499aaace2468346e1a diff --git a/README.md b/README.md index 09e97fb..d1e4df9 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,6 @@ The official CLI for the [Beeper Desktop REST API](https://developers.beeper.com/desktop-api/). -It is generated with [Stainless](https://www.stainless.com/). - ## Installation @@ -55,6 +53,7 @@ beeper-desktop-cli [resource] [flags...] ```sh beeper-desktop-cli chats search \ + --access-token 'My Access Token' \ --include-muted \ --limit 3 \ --type single diff --git a/pkg/cmd/chat.go b/pkg/cmd/chat.go index 41a766b..afa01f8 100644 --- a/pkg/cmd/chat.go +++ b/pkg/cmd/chat.go @@ -15,85 +15,19 @@ import ( "github.com/urfave/cli/v3" ) -var chatsCreate = requestflag.WithInnerFlags(cli.Command{ +var chatsCreate = cli.Command{ Name: "create", Usage: "Create a single/group chat (mode='create') or start a direct chat from merged\nuser data (mode='start').", Suggest: true, Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "account-id", - Usage: "Account to create or start the chat on.", - Required: true, - BodyPath: "accountID", - }, - &requestflag.Flag[bool]{ - Name: "allow-invite", - Usage: "Whether invite-based DM creation is allowed when required by the platform. Used for mode='start'.", - Default: true, - BodyPath: "allowInvite", - }, - &requestflag.Flag[string]{ - Name: "message-text", - Usage: "Optional first message content if the platform requires it to create the chat.", - BodyPath: "messageText", - }, - &requestflag.Flag[string]{ - Name: "mode", - Usage: "Operation mode. Defaults to 'create' when omitted.", - BodyPath: "mode", - }, - &requestflag.Flag[[]string]{ - Name: "participant-id", - Usage: "Required when mode='create'. User IDs to include in the new chat.", - BodyPath: "participantIDs", - }, - &requestflag.Flag[string]{ - Name: "title", - Usage: "Optional title for group chats when mode='create'; ignored for single chats on most platforms.", - BodyPath: "title", - }, - &requestflag.Flag[string]{ - Name: "type", - Usage: "Required when mode='create'. 'single' requires exactly one participantID; 'group' supports multiple participants and optional title.", - BodyPath: "type", - }, &requestflag.Flag[map[string]any]{ - Name: "user", - Usage: "Required when mode='start'. Merged user-like contact payload used to resolve the best identifier.", - BodyPath: "user", + Name: "params", + BodyRoot: true, }, }, Action: handleChatsCreate, HideHelpCommand: true, -}, map[string][]requestflag.HasOuterFlag{ - "user": { - &requestflag.InnerFlag[string]{ - Name: "user.id", - Usage: "Known user ID when available.", - InnerField: "id", - }, - &requestflag.InnerFlag[string]{ - Name: "user.email", - Usage: "Email candidate.", - InnerField: "email", - }, - &requestflag.InnerFlag[string]{ - Name: "user.full-name", - Usage: "Display name hint used for ranking only.", - InnerField: "fullName", - }, - &requestflag.InnerFlag[string]{ - Name: "user.phone-number", - Usage: "Phone number candidate (E.164 preferred).", - InnerField: "phoneNumber", - }, - &requestflag.InnerFlag[string]{ - Name: "user.username", - Usage: "Username/handle candidate.", - InnerField: "username", - }, - }, -}) +} var chatsRetrieve = cli.Command{ Name: "retrieve", diff --git a/pkg/cmd/chat_test.go b/pkg/cmd/chat_test.go index ff041f7..ea462e1 100644 --- a/pkg/cmd/chat_test.go +++ b/pkg/cmd/chat_test.go @@ -6,7 +6,6 @@ import ( "testing" "github.com/beeper/desktop-api-cli/internal/mocktest" - "github.com/beeper/desktop-api-cli/internal/requestflag" ) func TestChatsCreate(t *testing.T) { @@ -15,38 +14,7 @@ func TestChatsCreate(t *testing.T) { t, "--access-token", "string", "chats", "create", - "--account-id", "accountID", - "--allow-invite=true", - "--message-text", "messageText", - "--mode", "create", - "--participant-id", "string", - "--title", "title", - "--type", "single", - "--user", "{id: id, email: email, fullName: fullName, phoneNumber: phoneNumber, username: username}", - ) - }) - - t.Run("inner flags", func(t *testing.T) { - // Check that inner flags have been set up correctly - requestflag.CheckInnerFlags(chatsCreate) - - // Alternative argument passing style using inner flags - mocktest.TestRunMockTestWithFlags( - t, - "--access-token", "string", - "chats", "create", - "--account-id", "accountID", - "--allow-invite=true", - "--message-text", "messageText", - "--mode", "create", - "--participant-id", "string", - "--title", "title", - "--type", "single", - "--user.id", "id", - "--user.email", "email", - "--user.full-name", "fullName", - "--user.phone-number", "phoneNumber", - "--user.username", "username", + "--params", "{accountID: accountID, mode: start, user: {id: id, email: email, fullName: fullName, phoneNumber: phoneNumber, username: username}, allowInvite: true, messageText: messageText}", ) }) @@ -54,19 +22,15 @@ func TestChatsCreate(t *testing.T) { // Test piping YAML data over stdin pipeData := []byte("" + "accountID: accountID\n" + - "allowInvite: true\n" + - "messageText: messageText\n" + - "mode: create\n" + - "participantIDs:\n" + - " - string\n" + - "title: title\n" + - "type: single\n" + + "mode: start\n" + "user:\n" + " id: id\n" + " email: email\n" + " fullName: fullName\n" + " phoneNumber: phoneNumber\n" + - " username: username\n") + " username: username\n" + + "allowInvite: true\n" + + "messageText: messageText\n") mocktest.TestRunMockTestWithPipeAndFlags( t, pipeData, "--access-token", "string", From b57a69441818f20bf3d1bbf949a30b65c55aa728 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 02:16:37 +0000 Subject: [PATCH 26/52] chore: add documentation for ./scripts/link --- README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/README.md b/README.md index d1e4df9..de0b813 100644 --- a/README.md +++ b/README.md @@ -118,3 +118,23 @@ base64-encoding). Note that absolute paths will begin with `@file://` or ```bash beeper-desktop-cli --arg @data://file.txt ``` + +## Linking different Go SDK versions + +You can link the CLI against a different version of the Beeper Desktop 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/beeperdesktopapi-go +``` + +If you run the link script without any arguments, it will default to `../beeperdesktopapi-go`. From de64b1c7b2620be74c563e4da9ae4ff6a486555e Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 02:15:22 +0000 Subject: [PATCH 27/52] chore(cli): fall back to JSON when using default "explore" with non-TTY --- cmd/beeper-desktop-cli/main.go | 2 +- pkg/cmd/account.go | 3 +- pkg/cmd/accountcontact.go | 8 ++-- pkg/cmd/asset.go | 9 +++-- pkg/cmd/beeperdesktopapi.go | 6 ++- pkg/cmd/chat.go | 16 +++++--- pkg/cmd/chatmessagereaction.go | 6 ++- pkg/cmd/cmdutil.go | 29 +++++++++++--- pkg/cmd/cmdutil_test.go | 70 +++++++++++++++++++++++++++++++++- pkg/cmd/info.go | 3 +- pkg/cmd/message.go | 16 +++++--- 11 files changed, 136 insertions(+), 32 deletions(-) diff --git a/cmd/beeper-desktop-cli/main.go b/cmd/beeper-desktop-cli/main.go index 8ac76ad..a8ca8c2 100644 --- a/cmd/beeper-desktop-cli/main.go +++ b/cmd/beeper-desktop-cli/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/account.go b/pkg/cmd/account.go index 6ee1774..e779c12 100644 --- a/pkg/cmd/account.go +++ b/pkg/cmd/account.go @@ -51,6 +51,7 @@ func handleAccountsList(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, "accounts list", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "accounts list", obj, format, explicitFormat, transform) } diff --git a/pkg/cmd/accountcontact.go b/pkg/cmd/accountcontact.go index 76cc862..6ab3c04 100644 --- a/pkg/cmd/accountcontact.go +++ b/pkg/cmd/accountcontact.go @@ -101,6 +101,7 @@ func handleAccountsContactsList(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 @@ -115,7 +116,7 @@ func handleAccountsContactsList(ctx context.Context, cmd *cli.Command) error { return err } obj := gjson.ParseBytes(res) - return ShowJSON(os.Stdout, "accounts:contacts list", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "accounts:contacts list", obj, format, explicitFormat, transform) } else { iter := client.Accounts.Contacts.ListAutoPaging( ctx, @@ -127,7 +128,7 @@ func handleAccountsContactsList(ctx context.Context, cmd *cli.Command) error { if cmd.IsSet("max-items") { maxItems = cmd.Value("max-items").(int64) } - return ShowJSONIterator(os.Stdout, "accounts:contacts list", iter, format, transform, maxItems) + return ShowJSONIterator(os.Stdout, os.Stderr, "accounts:contacts list", iter, format, explicitFormat, transform, maxItems) } } @@ -169,6 +170,7 @@ func handleAccountsContactsSearch(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, "accounts:contacts search", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "accounts:contacts search", obj, format, explicitFormat, transform) } diff --git a/pkg/cmd/asset.go b/pkg/cmd/asset.go index 65023e3..160f317 100644 --- a/pkg/cmd/asset.go +++ b/pkg/cmd/asset.go @@ -130,8 +130,9 @@ func handleAssetsDownload(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, "assets download", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "assets download", obj, format, explicitFormat, transform) } func handleAssetsServe(ctx context.Context, cmd *cli.Command) error { @@ -188,8 +189,9 @@ func handleAssetsUpload(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, "assets upload", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "assets upload", obj, format, explicitFormat, transform) } func handleAssetsUploadBase64(ctx context.Context, cmd *cli.Command) error { @@ -222,6 +224,7 @@ func handleAssetsUploadBase64(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, "assets upload-base64", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "assets upload-base64", obj, format, explicitFormat, transform) } diff --git a/pkg/cmd/beeperdesktopapi.go b/pkg/cmd/beeperdesktopapi.go index bbef6b7..e55d9f2 100644 --- a/pkg/cmd/beeperdesktopapi.go +++ b/pkg/cmd/beeperdesktopapi.go @@ -91,8 +91,9 @@ func handleFocus(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, "focus", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "focus", obj, format, explicitFormat, transform) } func handleSearch(ctx context.Context, cmd *cli.Command) error { @@ -125,6 +126,7 @@ func handleSearch(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, "search", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "search", obj, format, explicitFormat, transform) } diff --git a/pkg/cmd/chat.go b/pkg/cmd/chat.go index afa01f8..730ecfd 100644 --- a/pkg/cmd/chat.go +++ b/pkg/cmd/chat.go @@ -208,8 +208,9 @@ func handleChatsCreate(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, "chats create", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "chats create", obj, format, explicitFormat, transform) } func handleChatsRetrieve(ctx context.Context, cmd *cli.Command) error { @@ -250,8 +251,9 @@ func handleChatsRetrieve(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, "chats retrieve", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "chats retrieve", obj, format, explicitFormat, transform) } func handleChatsList(ctx context.Context, cmd *cli.Command) error { @@ -276,6 +278,7 @@ func handleChatsList(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 @@ -285,14 +288,14 @@ func handleChatsList(ctx context.Context, cmd *cli.Command) error { return err } obj := gjson.ParseBytes(res) - return ShowJSON(os.Stdout, "chats list", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "chats list", obj, format, explicitFormat, transform) } else { iter := client.Chats.ListAutoPaging(ctx, params, options...) maxItems := int64(-1) if cmd.IsSet("max-items") { maxItems = cmd.Value("max-items").(int64) } - return ShowJSONIterator(os.Stdout, "chats list", iter, format, transform, maxItems) + return ShowJSONIterator(os.Stdout, os.Stderr, "chats list", iter, format, explicitFormat, transform, maxItems) } } @@ -350,6 +353,7 @@ func handleChatsSearch(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 @@ -359,13 +363,13 @@ func handleChatsSearch(ctx context.Context, cmd *cli.Command) error { return err } obj := gjson.ParseBytes(res) - return ShowJSON(os.Stdout, "chats search", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "chats search", obj, format, explicitFormat, transform) } else { iter := client.Chats.SearchAutoPaging(ctx, params, options...) maxItems := int64(-1) if cmd.IsSet("max-items") { maxItems = cmd.Value("max-items").(int64) } - return ShowJSONIterator(os.Stdout, "chats search", iter, format, transform, maxItems) + return ShowJSONIterator(os.Stdout, os.Stderr, "chats search", iter, format, explicitFormat, transform, maxItems) } } diff --git a/pkg/cmd/chatmessagereaction.go b/pkg/cmd/chatmessagereaction.go index f72f189..ee0615f 100644 --- a/pkg/cmd/chatmessagereaction.go +++ b/pkg/cmd/chatmessagereaction.go @@ -110,8 +110,9 @@ func handleChatsMessagesReactionsDelete(ctx context.Context, cmd *cli.Command) e obj := gjson.ParseBytes(res) format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "chats:messages:reactions delete", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "chats:messages:reactions delete", obj, format, explicitFormat, transform) } func handleChatsMessagesReactionsAdd(ctx context.Context, cmd *cli.Command) error { @@ -154,6 +155,7 @@ func handleChatsMessagesReactionsAdd(ctx context.Context, cmd *cli.Command) erro obj := gjson.ParseBytes(res) format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "chats:messages:reactions add", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "chats:messages:reactions add", obj, format, explicitFormat, transform) } diff --git a/pkg/cmd/cmdutil.go b/pkg/cmd/cmdutil.go index 3435a90..5163bc1 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 024fbe6..aaf0b1b 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/info.go b/pkg/cmd/info.go index 2b46d49..2272e2b 100644 --- a/pkg/cmd/info.go +++ b/pkg/cmd/info.go @@ -51,6 +51,7 @@ func handleInfoRetrieve(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, "info retrieve", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "info retrieve", obj, format, explicitFormat, transform) } diff --git a/pkg/cmd/message.go b/pkg/cmd/message.go index b65cef7..0e0a097 100644 --- a/pkg/cmd/message.go +++ b/pkg/cmd/message.go @@ -254,8 +254,9 @@ func handleMessagesUpdate(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, "messages update", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "messages update", obj, format, explicitFormat, transform) } func handleMessagesList(ctx context.Context, cmd *cli.Command) error { @@ -283,6 +284,7 @@ func handleMessagesList(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 @@ -297,7 +299,7 @@ func handleMessagesList(ctx context.Context, cmd *cli.Command) error { return err } obj := gjson.ParseBytes(res) - return ShowJSON(os.Stdout, "messages list", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "messages list", obj, format, explicitFormat, transform) } else { iter := client.Messages.ListAutoPaging( ctx, @@ -309,7 +311,7 @@ func handleMessagesList(ctx context.Context, cmd *cli.Command) error { if cmd.IsSet("max-items") { maxItems = cmd.Value("max-items").(int64) } - return ShowJSONIterator(os.Stdout, "messages list", iter, format, transform, maxItems) + return ShowJSONIterator(os.Stdout, os.Stderr, "messages list", iter, format, explicitFormat, transform, maxItems) } } @@ -335,6 +337,7 @@ func handleMessagesSearch(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 @@ -344,14 +347,14 @@ func handleMessagesSearch(ctx context.Context, cmd *cli.Command) error { return err } obj := gjson.ParseBytes(res) - return ShowJSON(os.Stdout, "messages search", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "messages search", obj, format, explicitFormat, transform) } else { iter := client.Messages.SearchAutoPaging(ctx, params, options...) maxItems := int64(-1) if cmd.IsSet("max-items") { maxItems = cmd.Value("max-items").(int64) } - return ShowJSONIterator(os.Stdout, "messages search", iter, format, transform, maxItems) + return ShowJSONIterator(os.Stdout, os.Stderr, "messages search", iter, format, explicitFormat, transform, maxItems) } } @@ -393,6 +396,7 @@ func handleMessagesSend(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, "messages send", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "messages send", obj, format, explicitFormat, transform) } From 301f6f2894cae408e91bb5834d50d6c16b78c28f Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 02:16:39 +0000 Subject: [PATCH 28/52] 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 198b295..4cc1965 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 d4e5630718dc3170dac5f9efe0acfd0be2f1a390 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 02:11:59 +0000 Subject: [PATCH 29/52] chore(cli): switch long lists of positional args over to param structs --- cmd/beeper-desktop-cli/main.go | 7 ++- pkg/cmd/account.go | 8 +++- pkg/cmd/accountcontact.go | 22 +++++++-- pkg/cmd/asset.go | 22 +++++++-- pkg/cmd/beeperdesktopapi.go | 15 ++++-- pkg/cmd/chat.go | 43 ++++++++++++++--- pkg/cmd/chatmessagereaction.go | 15 ++++-- pkg/cmd/cmdutil.go | 84 ++++++++++++++++++++++------------ pkg/cmd/cmdutil_test.go | 31 +++++++++++-- pkg/cmd/info.go | 8 +++- pkg/cmd/message.go | 43 ++++++++++++++--- 11 files changed, 231 insertions(+), 67 deletions(-) diff --git a/cmd/beeper-desktop-cli/main.go b/cmd/beeper-desktop-cli/main.go index a8ca8c2..c98a926 100644 --- a/cmd/beeper-desktop-cli/main.go +++ b/cmd/beeper-desktop-cli/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/account.go b/pkg/cmd/account.go index e779c12..c2809b0 100644 --- a/pkg/cmd/account.go +++ b/pkg/cmd/account.go @@ -5,7 +5,6 @@ package cmd import ( "context" "fmt" - "os" "github.com/beeper/desktop-api-cli/internal/apiquery" "github.com/beeper/desktop-api-go" @@ -53,5 +52,10 @@ func handleAccountsList(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, "accounts list", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "accounts list", + Transform: transform, + }) } diff --git a/pkg/cmd/accountcontact.go b/pkg/cmd/accountcontact.go index 6ab3c04..c8ec6e3 100644 --- a/pkg/cmd/accountcontact.go +++ b/pkg/cmd/accountcontact.go @@ -5,7 +5,6 @@ package cmd import ( "context" "fmt" - "os" "github.com/beeper/desktop-api-cli/internal/apiquery" "github.com/beeper/desktop-api-cli/internal/requestflag" @@ -116,7 +115,12 @@ func handleAccountsContactsList(ctx context.Context, cmd *cli.Command) error { return err } obj := gjson.ParseBytes(res) - return ShowJSON(os.Stdout, os.Stderr, "accounts:contacts list", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "accounts:contacts list", + Transform: transform, + }) } else { iter := client.Accounts.Contacts.ListAutoPaging( ctx, @@ -128,7 +132,12 @@ func handleAccountsContactsList(ctx context.Context, cmd *cli.Command) error { if cmd.IsSet("max-items") { maxItems = cmd.Value("max-items").(int64) } - return ShowJSONIterator(os.Stdout, os.Stderr, "accounts:contacts list", iter, format, explicitFormat, transform, maxItems) + return ShowJSONIterator(iter, maxItems, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "accounts:contacts list", + Transform: transform, + }) } } @@ -172,5 +181,10 @@ func handleAccountsContactsSearch(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, "accounts:contacts search", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "accounts:contacts search", + Transform: transform, + }) } diff --git a/pkg/cmd/asset.go b/pkg/cmd/asset.go index 160f317..227a235 100644 --- a/pkg/cmd/asset.go +++ b/pkg/cmd/asset.go @@ -5,7 +5,6 @@ package cmd import ( "context" "fmt" - "os" "github.com/beeper/desktop-api-cli/internal/apiquery" "github.com/beeper/desktop-api-cli/internal/requestflag" @@ -132,7 +131,12 @@ func handleAssetsDownload(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, "assets download", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "assets download", + Transform: transform, + }) } func handleAssetsServe(ctx context.Context, cmd *cli.Command) error { @@ -191,7 +195,12 @@ func handleAssetsUpload(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, "assets upload", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "assets upload", + Transform: transform, + }) } func handleAssetsUploadBase64(ctx context.Context, cmd *cli.Command) error { @@ -226,5 +235,10 @@ func handleAssetsUploadBase64(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, "assets upload-base64", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "assets upload-base64", + Transform: transform, + }) } diff --git a/pkg/cmd/beeperdesktopapi.go b/pkg/cmd/beeperdesktopapi.go index e55d9f2..a948c5a 100644 --- a/pkg/cmd/beeperdesktopapi.go +++ b/pkg/cmd/beeperdesktopapi.go @@ -5,7 +5,6 @@ package cmd import ( "context" "fmt" - "os" "github.com/beeper/desktop-api-cli/internal/apiquery" "github.com/beeper/desktop-api-cli/internal/requestflag" @@ -93,7 +92,12 @@ func handleFocus(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, "focus", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "focus", + Transform: transform, + }) } func handleSearch(ctx context.Context, cmd *cli.Command) error { @@ -128,5 +132,10 @@ func handleSearch(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, "search", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "search", + Transform: transform, + }) } diff --git a/pkg/cmd/chat.go b/pkg/cmd/chat.go index 730ecfd..60cb728 100644 --- a/pkg/cmd/chat.go +++ b/pkg/cmd/chat.go @@ -5,7 +5,6 @@ package cmd import ( "context" "fmt" - "os" "github.com/beeper/desktop-api-cli/internal/apiquery" "github.com/beeper/desktop-api-cli/internal/requestflag" @@ -210,7 +209,12 @@ func handleChatsCreate(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, "chats create", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "chats create", + Transform: transform, + }) } func handleChatsRetrieve(ctx context.Context, cmd *cli.Command) error { @@ -253,7 +257,12 @@ func handleChatsRetrieve(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, "chats retrieve", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "chats retrieve", + Transform: transform, + }) } func handleChatsList(ctx context.Context, cmd *cli.Command) error { @@ -288,14 +297,24 @@ func handleChatsList(ctx context.Context, cmd *cli.Command) error { return err } obj := gjson.ParseBytes(res) - return ShowJSON(os.Stdout, os.Stderr, "chats list", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "chats list", + Transform: transform, + }) } else { iter := client.Chats.ListAutoPaging(ctx, params, options...) maxItems := int64(-1) if cmd.IsSet("max-items") { maxItems = cmd.Value("max-items").(int64) } - return ShowJSONIterator(os.Stdout, os.Stderr, "chats list", iter, format, explicitFormat, transform, maxItems) + return ShowJSONIterator(iter, maxItems, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "chats list", + Transform: transform, + }) } } @@ -363,13 +382,23 @@ func handleChatsSearch(ctx context.Context, cmd *cli.Command) error { return err } obj := gjson.ParseBytes(res) - return ShowJSON(os.Stdout, os.Stderr, "chats search", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "chats search", + Transform: transform, + }) } else { iter := client.Chats.SearchAutoPaging(ctx, params, options...) maxItems := int64(-1) if cmd.IsSet("max-items") { maxItems = cmd.Value("max-items").(int64) } - return ShowJSONIterator(os.Stdout, os.Stderr, "chats search", iter, format, explicitFormat, transform, maxItems) + return ShowJSONIterator(iter, maxItems, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "chats search", + Transform: transform, + }) } } diff --git a/pkg/cmd/chatmessagereaction.go b/pkg/cmd/chatmessagereaction.go index ee0615f..da64dc4 100644 --- a/pkg/cmd/chatmessagereaction.go +++ b/pkg/cmd/chatmessagereaction.go @@ -5,7 +5,6 @@ package cmd import ( "context" "fmt" - "os" "github.com/beeper/desktop-api-cli/internal/apiquery" "github.com/beeper/desktop-api-cli/internal/requestflag" @@ -112,7 +111,12 @@ func handleChatsMessagesReactionsDelete(ctx context.Context, cmd *cli.Command) e format := cmd.Root().String("format") explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, os.Stderr, "chats:messages:reactions delete", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "chats:messages:reactions delete", + Transform: transform, + }) } func handleChatsMessagesReactionsAdd(ctx context.Context, cmd *cli.Command) error { @@ -157,5 +161,10 @@ func handleChatsMessagesReactionsAdd(ctx context.Context, cmd *cli.Command) erro format := cmd.Root().String("format") explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, os.Stderr, "chats:messages:reactions add", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "chats:messages:reactions add", + Transform: transform, + }) } diff --git a/pkg/cmd/cmdutil.go b/pkg/cmd/cmdutil.go index 5163bc1..8054c00 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 aaf0b1b..b318a0e 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/info.go b/pkg/cmd/info.go index 2272e2b..bade3e1 100644 --- a/pkg/cmd/info.go +++ b/pkg/cmd/info.go @@ -5,7 +5,6 @@ package cmd import ( "context" "fmt" - "os" "github.com/beeper/desktop-api-cli/internal/apiquery" "github.com/beeper/desktop-api-go" @@ -53,5 +52,10 @@ func handleInfoRetrieve(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, "info retrieve", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "info retrieve", + Transform: transform, + }) } diff --git a/pkg/cmd/message.go b/pkg/cmd/message.go index 0e0a097..79d7827 100644 --- a/pkg/cmd/message.go +++ b/pkg/cmd/message.go @@ -5,7 +5,6 @@ package cmd import ( "context" "fmt" - "os" "github.com/beeper/desktop-api-cli/internal/apiquery" "github.com/beeper/desktop-api-cli/internal/requestflag" @@ -256,7 +255,12 @@ func handleMessagesUpdate(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, "messages update", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "messages update", + Transform: transform, + }) } func handleMessagesList(ctx context.Context, cmd *cli.Command) error { @@ -299,7 +303,12 @@ func handleMessagesList(ctx context.Context, cmd *cli.Command) error { return err } obj := gjson.ParseBytes(res) - return ShowJSON(os.Stdout, os.Stderr, "messages list", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "messages list", + Transform: transform, + }) } else { iter := client.Messages.ListAutoPaging( ctx, @@ -311,7 +320,12 @@ func handleMessagesList(ctx context.Context, cmd *cli.Command) error { if cmd.IsSet("max-items") { maxItems = cmd.Value("max-items").(int64) } - return ShowJSONIterator(os.Stdout, os.Stderr, "messages list", iter, format, explicitFormat, transform, maxItems) + return ShowJSONIterator(iter, maxItems, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "messages list", + Transform: transform, + }) } } @@ -347,14 +361,24 @@ func handleMessagesSearch(ctx context.Context, cmd *cli.Command) error { return err } obj := gjson.ParseBytes(res) - return ShowJSON(os.Stdout, os.Stderr, "messages search", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "messages search", + Transform: transform, + }) } else { iter := client.Messages.SearchAutoPaging(ctx, params, options...) maxItems := int64(-1) if cmd.IsSet("max-items") { maxItems = cmd.Value("max-items").(int64) } - return ShowJSONIterator(os.Stdout, os.Stderr, "messages search", iter, format, explicitFormat, transform, maxItems) + return ShowJSONIterator(iter, maxItems, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "messages search", + Transform: transform, + }) } } @@ -398,5 +422,10 @@ func handleMessagesSend(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, "messages send", obj, format, explicitFormat, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + Title: "messages send", + Transform: transform, + }) } From 5a018e83f5f9347b262b10176639fcc24ec5f370 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 02:29:22 +0000 Subject: [PATCH 30/52] 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 4e556fb..b207697 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 b1ab49bb30c6d4132fb9827cff19f73d83e7af5c Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 02:30:44 +0000 Subject: [PATCH 31/52] 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 4cc1965..aa566b3 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 963cb2275ff0b6900c09dbbd0cc45c37e745d561 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 02:32:45 +0000 Subject: [PATCH 32/52] feat(cli): add `--raw-output`/`-r` option to print raw (non-JSON) strings --- pkg/cmd/account.go | 1 + pkg/cmd/accountcontact.go | 3 +++ pkg/cmd/asset.go | 3 +++ pkg/cmd/beeperdesktopapi.go | 2 ++ pkg/cmd/chat.go | 6 ++++++ pkg/cmd/chatmessagereaction.go | 2 ++ pkg/cmd/cmd.go | 5 +++++ pkg/cmd/cmdutil.go | 14 +++++++++---- pkg/cmd/cmdutil_test.go | 37 ++++++++++++++++++++++++++++++---- pkg/cmd/info.go | 1 + pkg/cmd/message.go | 6 ++++++ 11 files changed, 72 insertions(+), 8 deletions(-) diff --git a/pkg/cmd/account.go b/pkg/cmd/account.go index c2809b0..c47af67 100644 --- a/pkg/cmd/account.go +++ b/pkg/cmd/account.go @@ -55,6 +55,7 @@ func handleAccountsList(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "accounts list", Transform: transform, }) diff --git a/pkg/cmd/accountcontact.go b/pkg/cmd/accountcontact.go index c8ec6e3..82bc951 100644 --- a/pkg/cmd/accountcontact.go +++ b/pkg/cmd/accountcontact.go @@ -118,6 +118,7 @@ func handleAccountsContactsList(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "accounts:contacts list", Transform: transform, }) @@ -135,6 +136,7 @@ func handleAccountsContactsList(ctx context.Context, cmd *cli.Command) error { return ShowJSONIterator(iter, maxItems, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "accounts:contacts list", Transform: transform, }) @@ -184,6 +186,7 @@ func handleAccountsContactsSearch(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "accounts:contacts search", Transform: transform, }) diff --git a/pkg/cmd/asset.go b/pkg/cmd/asset.go index 227a235..2e56489 100644 --- a/pkg/cmd/asset.go +++ b/pkg/cmd/asset.go @@ -134,6 +134,7 @@ func handleAssetsDownload(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "assets download", Transform: transform, }) @@ -198,6 +199,7 @@ func handleAssetsUpload(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "assets upload", Transform: transform, }) @@ -238,6 +240,7 @@ func handleAssetsUploadBase64(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "assets upload-base64", Transform: transform, }) diff --git a/pkg/cmd/beeperdesktopapi.go b/pkg/cmd/beeperdesktopapi.go index a948c5a..0d2da45 100644 --- a/pkg/cmd/beeperdesktopapi.go +++ b/pkg/cmd/beeperdesktopapi.go @@ -95,6 +95,7 @@ func handleFocus(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "focus", Transform: transform, }) @@ -135,6 +136,7 @@ func handleSearch(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "search", Transform: transform, }) diff --git a/pkg/cmd/chat.go b/pkg/cmd/chat.go index 60cb728..cbb512a 100644 --- a/pkg/cmd/chat.go +++ b/pkg/cmd/chat.go @@ -212,6 +212,7 @@ func handleChatsCreate(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "chats create", Transform: transform, }) @@ -260,6 +261,7 @@ func handleChatsRetrieve(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "chats retrieve", Transform: transform, }) @@ -300,6 +302,7 @@ func handleChatsList(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "chats list", Transform: transform, }) @@ -312,6 +315,7 @@ func handleChatsList(ctx context.Context, cmd *cli.Command) error { return ShowJSONIterator(iter, maxItems, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "chats list", Transform: transform, }) @@ -385,6 +389,7 @@ func handleChatsSearch(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "chats search", Transform: transform, }) @@ -397,6 +402,7 @@ func handleChatsSearch(ctx context.Context, cmd *cli.Command) error { return ShowJSONIterator(iter, maxItems, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "chats search", Transform: transform, }) diff --git a/pkg/cmd/chatmessagereaction.go b/pkg/cmd/chatmessagereaction.go index da64dc4..ed245df 100644 --- a/pkg/cmd/chatmessagereaction.go +++ b/pkg/cmd/chatmessagereaction.go @@ -114,6 +114,7 @@ func handleChatsMessagesReactionsDelete(ctx context.Context, cmd *cli.Command) e return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "chats:messages:reactions delete", Transform: transform, }) @@ -164,6 +165,7 @@ func handleChatsMessagesReactionsAdd(ctx context.Context, cmd *cli.Command) erro return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "chats:messages:reactions add", Transform: transform, }) diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index 4c91ac7..66f0c29 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: "access-token", Usage: "Bearer access token obtained via OAuth2 PKCE flow or created in-app. Required for all API operations.", diff --git a/pkg/cmd/cmdutil.go b/pkg/cmd/cmdutil.go index 8054c00..6dd1aae 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 b318a0e..f8e10ef 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/info.go b/pkg/cmd/info.go index bade3e1..bb9301e 100644 --- a/pkg/cmd/info.go +++ b/pkg/cmd/info.go @@ -55,6 +55,7 @@ func handleInfoRetrieve(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "info retrieve", Transform: transform, }) diff --git a/pkg/cmd/message.go b/pkg/cmd/message.go index 79d7827..873cbfc 100644 --- a/pkg/cmd/message.go +++ b/pkg/cmd/message.go @@ -258,6 +258,7 @@ func handleMessagesUpdate(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "messages update", Transform: transform, }) @@ -306,6 +307,7 @@ func handleMessagesList(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "messages list", Transform: transform, }) @@ -323,6 +325,7 @@ func handleMessagesList(ctx context.Context, cmd *cli.Command) error { return ShowJSONIterator(iter, maxItems, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "messages list", Transform: transform, }) @@ -364,6 +367,7 @@ func handleMessagesSearch(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "messages search", Transform: transform, }) @@ -376,6 +380,7 @@ func handleMessagesSearch(ctx context.Context, cmd *cli.Command) error { return ShowJSONIterator(iter, maxItems, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "messages search", Transform: transform, }) @@ -425,6 +430,7 @@ func handleMessagesSend(ctx context.Context, cmd *cli.Command) error { return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, Format: format, + RawOutput: cmd.Root().Bool("raw-output"), Title: "messages send", Transform: transform, }) From 384a7f434bd6d40b626b6e635f005853f66ff917 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 02:31:35 +0000 Subject: [PATCH 33/52] 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 6dd1aae..c98055a 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 f8e10ef..2c500d7 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 216464542c88aca1c22ebf0e9af3ac0eb6854a38 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 02:33:09 +0000 Subject: [PATCH 34/52] chore(tests): bump steady to v0.22.1 --- scripts/mock | 6 +++--- scripts/test | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/mock b/scripts/mock index 7c58865..9c7c439 100755 --- a/scripts/mock +++ b/scripts/mock @@ -22,9 +22,9 @@ echo "==> Starting mock server with URL ${URL}" # Run steady mock on the given spec if [ "$1" == "--daemon" ]; then # Pre-install the package so the download doesn't eat into the startup timeout - npm exec --package=@stdy/cli@0.20.2 -- steady --version + npm exec --package=@stdy/cli@0.22.1 -- steady --version - npm exec --package=@stdy/cli@0.20.2 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" &> .stdy.log & + npm exec --package=@stdy/cli@0.22.1 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" &> .stdy.log & # Wait for server to come online via health endpoint (max 30s) echo -n "Waiting for server" @@ -48,5 +48,5 @@ if [ "$1" == "--daemon" ]; then echo else - npm exec --package=@stdy/cli@0.20.2 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" + npm exec --package=@stdy/cli@0.22.1 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" fi diff --git a/scripts/test b/scripts/test index 7ba4b3f..e2baeca 100755 --- a/scripts/test +++ b/scripts/test @@ -46,7 +46,7 @@ elif ! steady_is_running ; then echo -e "To run the server, pass in the path or url of your OpenAPI" echo -e "spec to the steady command:" echo - echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.20.2 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets${NC}" + echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.22.1 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets${NC}" echo exit 1 From 888af53697b79c93159f9fce5e60cca4ea1e5a04 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 02:11:53 +0000 Subject: [PATCH 35/52] 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 9cc3bc3470b42af42366690da4a7264542c4fd78 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 19:50:53 +0000 Subject: [PATCH 36/52] Update Desktop API Stainless config and OpenAPI spec --- .stats.yml | 4 +- cmd/beeper-desktop-cli/main.go | 4 +- pkg/cmd/account.go | 5 +- pkg/cmd/accountcontact.go | 10 +++- pkg/cmd/asset.go | 16 +++++- pkg/cmd/asset_test.go | 1 + pkg/cmd/beeperdesktopapi.go | 5 +- pkg/cmd/chat.go | 93 ++++++++++++++++++++++++++++++---- pkg/cmd/chat_test.go | 44 ++++++++++++++-- pkg/cmd/chatmessagereaction.go | 2 +- pkg/cmd/cmd.go | 4 +- pkg/cmd/info.go | 5 +- pkg/cmd/message.go | 12 +++-- 13 files changed, 176 insertions(+), 29 deletions(-) diff --git a/.stats.yml b/.stats.yml index 229f6b5..e925f68 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 23 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-611aa7641fbca8cf31d626bf86f9efd3c2b92778e897ebbb25c6ea44185ed1ed.yml -openapi_spec_hash: d6c0a1776048dab04f6c5625c9893c9c -config_hash: 39ed0717b5f415499aaace2468346e1a +openapi_spec_hash: 4840f003552e8b48eb8e689b59a819ef +config_hash: 05ebdec072113f63395372504da98192 diff --git a/cmd/beeper-desktop-cli/main.go b/cmd/beeper-desktop-cli/main.go index c98a926..8640e9d 100644 --- a/cmd/beeper-desktop-cli/main.go +++ b/cmd/beeper-desktop-cli/main.go @@ -23,8 +23,8 @@ func main() { prepareForAutocomplete(app) } - if baseURL, ok := os.LookupEnv("BEEPER_DESKTOP_BASE_URL"); ok { - if err := cmd.ValidateBaseURL(baseURL, "BEEPER_DESKTOP_BASE_URL"); err != nil { + if baseURL, ok := os.LookupEnv("BEEPER_BASE_URL"); ok { + if err := cmd.ValidateBaseURL(baseURL, "BEEPER_BASE_URL"); err != nil { fmt.Fprintf(os.Stderr, "%s\n", err.Error()) os.Exit(1) } diff --git a/pkg/cmd/account.go b/pkg/cmd/account.go index c47af67..9cf40bb 100644 --- a/pkg/cmd/account.go +++ b/pkg/cmd/account.go @@ -49,8 +49,11 @@ func handleAccountsList(ctx context.Context, cmd *cli.Command) error { } obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") + format := "json" explicitFormat := cmd.Root().IsSet("format") + if explicitFormat { + format = cmd.Root().String("format") + } transform := cmd.Root().String("transform") return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, diff --git a/pkg/cmd/accountcontact.go b/pkg/cmd/accountcontact.go index 82bc951..cd491b0 100644 --- a/pkg/cmd/accountcontact.go +++ b/pkg/cmd/accountcontact.go @@ -99,8 +99,11 @@ func handleAccountsContactsList(ctx context.Context, cmd *cli.Command) error { return err } - format := cmd.Root().String("format") + format := "json" explicitFormat := cmd.Root().IsSet("format") + if explicitFormat { + format = cmd.Root().String("format") + } transform := cmd.Root().String("transform") if format == "raw" { var res []byte @@ -180,8 +183,11 @@ func handleAccountsContactsSearch(ctx context.Context, cmd *cli.Command) error { } obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") + format := "json" explicitFormat := cmd.Root().IsSet("format") + if explicitFormat { + format = cmd.Root().String("format") + } transform := cmd.Root().String("transform") return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, diff --git a/pkg/cmd/asset.go b/pkg/cmd/asset.go index 2e56489..6e62006 100644 --- a/pkg/cmd/asset.go +++ b/pkg/cmd/asset.go @@ -5,6 +5,7 @@ package cmd import ( "context" "fmt" + "os" "github.com/beeper/desktop-api-cli/internal/apiquery" "github.com/beeper/desktop-api-cli/internal/requestflag" @@ -41,6 +42,11 @@ var assetsServe = cli.Command{ Required: true, QueryPath: "url", }, + &requestflag.Flag[string]{ + Name: "output", + Aliases: []string{"o"}, + Usage: "The file where the response contents will be stored. Use the value '-' to force output to stdout.", + }, }, Action: handleAssetsServe, HideHelpCommand: true, @@ -161,7 +167,15 @@ func handleAssetsServe(ctx context.Context, cmd *cli.Command) error { return err } - return client.Assets.Serve(ctx, params, options...) + response, err := client.Assets.Serve(ctx, params, options...) + if err != nil { + return err + } + message, err := writeBinaryResponse(response, os.Stdout, cmd.String("output")) + if message != "" { + fmt.Println(message) + } + return err } func handleAssetsUpload(ctx context.Context, cmd *cli.Command) error { diff --git a/pkg/cmd/asset_test.go b/pkg/cmd/asset_test.go index 696660a..4375d2e 100644 --- a/pkg/cmd/asset_test.go +++ b/pkg/cmd/asset_test.go @@ -37,6 +37,7 @@ func TestAssetsServe(t *testing.T) { "--access-token", "string", "assets", "serve", "--url", "x", + "--output", "/dev/null", ) }) } diff --git a/pkg/cmd/beeperdesktopapi.go b/pkg/cmd/beeperdesktopapi.go index 0d2da45..5bc483e 100644 --- a/pkg/cmd/beeperdesktopapi.go +++ b/pkg/cmd/beeperdesktopapi.go @@ -130,8 +130,11 @@ func handleSearch(ctx context.Context, cmd *cli.Command) error { } obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") + format := "json" explicitFormat := cmd.Root().IsSet("format") + if explicitFormat { + format = cmd.Root().String("format") + } transform := cmd.Root().String("transform") return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, diff --git a/pkg/cmd/chat.go b/pkg/cmd/chat.go index cbb512a..166458e 100644 --- a/pkg/cmd/chat.go +++ b/pkg/cmd/chat.go @@ -14,19 +14,85 @@ import ( "github.com/urfave/cli/v3" ) -var chatsCreate = cli.Command{ +var chatsCreate = requestflag.WithInnerFlags(cli.Command{ Name: "create", - Usage: "Create a single/group chat (mode='create') or start a direct chat from merged\nuser data (mode='start').", + Usage: "Create a direct or group chat with mode=\"create\", or use mode=\"start\" to resolve\na contact and open a direct chat.", Suggest: true, Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "account-id", + Usage: "Account to create or start the chat on.", + Required: true, + BodyPath: "accountID", + }, + &requestflag.Flag[bool]{ + Name: "allow-invite", + Usage: "Only used for mode='start'. Whether invite-based DM creation is allowed when required by the platform.", + Default: true, + BodyPath: "allowInvite", + }, + &requestflag.Flag[string]{ + Name: "message-text", + Usage: "Optional first message content if the platform requires it to create the chat.", + BodyPath: "messageText", + }, + &requestflag.Flag[string]{ + Name: "mode", + Usage: "Operation mode. Use 'start' to resolve a user/contact and start a direct chat. Omit or set 'create' to create a chat directly.", + BodyPath: "mode", + }, + &requestflag.Flag[[]string]{ + Name: "participant-id", + Usage: "Required for create mode. Provide exactly one user ID for 'single' chats and one or more for 'group' chats.", + BodyPath: "participantIDs", + }, + &requestflag.Flag[string]{ + Name: "title", + Usage: "Optional title for group chats; ignored for single chats on most networks.", + BodyPath: "title", + }, + &requestflag.Flag[string]{ + Name: "type", + Usage: "Required for create mode. 'single' creates a direct message chat; 'group' creates a group chat.", + BodyPath: "type", + }, &requestflag.Flag[map[string]any]{ - Name: "params", - BodyRoot: true, + Name: "user", + Usage: "Required for mode='start'. Merged user-like contact payload used to resolve the best identifier.", + BodyPath: "user", }, }, Action: handleChatsCreate, HideHelpCommand: true, -} +}, map[string][]requestflag.HasOuterFlag{ + "user": { + &requestflag.InnerFlag[string]{ + Name: "user.id", + Usage: "Known user ID when available.", + InnerField: "id", + }, + &requestflag.InnerFlag[string]{ + Name: "user.email", + Usage: "Email candidate.", + InnerField: "email", + }, + &requestflag.InnerFlag[string]{ + Name: "user.full-name", + Usage: "Display name hint used for ranking only.", + InnerField: "fullName", + }, + &requestflag.InnerFlag[string]{ + Name: "user.phone-number", + Usage: "Phone number candidate (E.164 preferred).", + InnerField: "phoneNumber", + }, + &requestflag.InnerFlag[string]{ + Name: "user.username", + Usage: "Username/handle candidate.", + InnerField: "username", + }, + }, +}) var chatsRetrieve = cli.Command{ Name: "retrieve", @@ -101,7 +167,7 @@ var chatsArchive = cli.Command{ var chatsSearch = cli.Command{ Name: "search", - Usage: "Search chats by title/network or participants using Beeper Desktop's renderer\nalgorithm.", + Usage: "Search chats by title, network, or participant names.", Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[[]string]{ @@ -255,8 +321,11 @@ func handleChatsRetrieve(ctx context.Context, cmd *cli.Command) error { } obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") + format := "json" explicitFormat := cmd.Root().IsSet("format") + if explicitFormat { + format = cmd.Root().String("format") + } transform := cmd.Root().String("transform") return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, @@ -288,8 +357,11 @@ func handleChatsList(ctx context.Context, cmd *cli.Command) error { return err } - format := cmd.Root().String("format") + format := "json" explicitFormat := cmd.Root().IsSet("format") + if explicitFormat { + format = cmd.Root().String("format") + } transform := cmd.Root().String("transform") if format == "raw" { var res []byte @@ -375,8 +447,11 @@ func handleChatsSearch(ctx context.Context, cmd *cli.Command) error { return err } - format := cmd.Root().String("format") + format := "json" explicitFormat := cmd.Root().IsSet("format") + if explicitFormat { + format = cmd.Root().String("format") + } transform := cmd.Root().String("transform") if format == "raw" { var res []byte diff --git a/pkg/cmd/chat_test.go b/pkg/cmd/chat_test.go index ea462e1..d7966cd 100644 --- a/pkg/cmd/chat_test.go +++ b/pkg/cmd/chat_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/beeper/desktop-api-cli/internal/mocktest" + "github.com/beeper/desktop-api-cli/internal/requestflag" ) func TestChatsCreate(t *testing.T) { @@ -14,7 +15,38 @@ func TestChatsCreate(t *testing.T) { t, "--access-token", "string", "chats", "create", - "--params", "{accountID: accountID, mode: start, user: {id: id, email: email, fullName: fullName, phoneNumber: phoneNumber, username: username}, allowInvite: true, messageText: messageText}", + "--account-id", "accountID", + "--allow-invite=true", + "--message-text", "messageText", + "--mode", "start", + "--participant-id", "string", + "--title", "title", + "--type", "single", + "--user", "{id: id, email: email, fullName: fullName, phoneNumber: phoneNumber, username: username}", + ) + }) + + t.Run("inner flags", func(t *testing.T) { + // Check that inner flags have been set up correctly + requestflag.CheckInnerFlags(chatsCreate) + + // Alternative argument passing style using inner flags + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "chats", "create", + "--account-id", "accountID", + "--allow-invite=true", + "--message-text", "messageText", + "--mode", "start", + "--participant-id", "string", + "--title", "title", + "--type", "single", + "--user.id", "id", + "--user.email", "email", + "--user.full-name", "fullName", + "--user.phone-number", "phoneNumber", + "--user.username", "username", ) }) @@ -22,15 +54,19 @@ func TestChatsCreate(t *testing.T) { // Test piping YAML data over stdin pipeData := []byte("" + "accountID: accountID\n" + + "allowInvite: true\n" + + "messageText: messageText\n" + "mode: start\n" + + "participantIDs:\n" + + " - string\n" + + "title: title\n" + + "type: single\n" + "user:\n" + " id: id\n" + " email: email\n" + " fullName: fullName\n" + " phoneNumber: phoneNumber\n" + - " username: username\n" + - "allowInvite: true\n" + - "messageText: messageText\n") + " username: username\n") mocktest.TestRunMockTestWithPipeAndFlags( t, pipeData, "--access-token", "string", diff --git a/pkg/cmd/chatmessagereaction.go b/pkg/cmd/chatmessagereaction.go index ed245df..35cacf3 100644 --- a/pkg/cmd/chatmessagereaction.go +++ b/pkg/cmd/chatmessagereaction.go @@ -16,7 +16,7 @@ import ( var chatsMessagesReactionsDelete = cli.Command{ Name: "delete", - Usage: "Remove the authenticated user's reaction from an existing message.", + Usage: "Remove the reaction added by the authenticated user from an existing message.", Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index 66f0c29..81a042c 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -46,7 +46,7 @@ func init() { &cli.StringFlag{ Name: "format", Usage: "The format for displaying response data (one of: " + strings.Join(OutputFormats, ", ") + ")", - Value: "pretty", + Value: "json", Validator: func(format string) error { if !slices.Contains(OutputFormats, strings.ToLower(format)) { return fmt.Errorf("format must be one of: %s", strings.Join(OutputFormats, ", ")) @@ -57,7 +57,7 @@ func init() { &cli.StringFlag{ Name: "format-error", Usage: "The format for displaying error data (one of: " + strings.Join(OutputFormats, ", ") + ")", - Value: "pretty", + Value: "json", Validator: func(format string) error { if !slices.Contains(OutputFormats, strings.ToLower(format)) { return fmt.Errorf("format must be one of: %s", strings.Join(OutputFormats, ", ")) diff --git a/pkg/cmd/info.go b/pkg/cmd/info.go index bb9301e..57fdd22 100644 --- a/pkg/cmd/info.go +++ b/pkg/cmd/info.go @@ -49,8 +49,11 @@ func handleInfoRetrieve(ctx context.Context, cmd *cli.Command) error { } obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") + format := "json" explicitFormat := cmd.Root().IsSet("format") + if explicitFormat { + format = cmd.Root().String("format") + } transform := cmd.Root().String("transform") return ShowJSON(obj, ShowJSONOpts{ ExplicitFormat: explicitFormat, diff --git a/pkg/cmd/message.go b/pkg/cmd/message.go index 873cbfc..c1a22eb 100644 --- a/pkg/cmd/message.go +++ b/pkg/cmd/message.go @@ -70,7 +70,7 @@ var messagesList = cli.Command{ var messagesSearch = cli.Command{ Name: "search", - Usage: "Search messages across chats using Beeper's message index", + Usage: "Search messages across chats.", Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[[]string]{ @@ -288,8 +288,11 @@ func handleMessagesList(ctx context.Context, cmd *cli.Command) error { return err } - format := cmd.Root().String("format") + format := "json" explicitFormat := cmd.Root().IsSet("format") + if explicitFormat { + format = cmd.Root().String("format") + } transform := cmd.Root().String("transform") if format == "raw" { var res []byte @@ -353,8 +356,11 @@ func handleMessagesSearch(ctx context.Context, cmd *cli.Command) error { return err } - format := cmd.Root().String("format") + format := "json" explicitFormat := cmd.Root().IsSet("format") + if explicitFormat { + format = cmd.Root().String("format") + } transform := cmd.Root().String("transform") if format == "raw" { var res []byte From 24ceca2b5cbf662fd9fe190036d6f29d6bf7a8d7 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 19:53:32 +0000 Subject: [PATCH 37/52] Preserve asset serve SDK compatibility --- .stats.yml | 2 +- pkg/cmd/asset.go | 16 +--------------- pkg/cmd/asset_test.go | 1 - 3 files changed, 2 insertions(+), 17 deletions(-) diff --git a/.stats.yml b/.stats.yml index e925f68..1d3cc36 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 23 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-611aa7641fbca8cf31d626bf86f9efd3c2b92778e897ebbb25c6ea44185ed1ed.yml -openapi_spec_hash: 4840f003552e8b48eb8e689b59a819ef +openapi_spec_hash: 8dff13848934c5b20f3804236e8286d3 config_hash: 05ebdec072113f63395372504da98192 diff --git a/pkg/cmd/asset.go b/pkg/cmd/asset.go index 6e62006..2e56489 100644 --- a/pkg/cmd/asset.go +++ b/pkg/cmd/asset.go @@ -5,7 +5,6 @@ package cmd import ( "context" "fmt" - "os" "github.com/beeper/desktop-api-cli/internal/apiquery" "github.com/beeper/desktop-api-cli/internal/requestflag" @@ -42,11 +41,6 @@ var assetsServe = cli.Command{ Required: true, QueryPath: "url", }, - &requestflag.Flag[string]{ - Name: "output", - Aliases: []string{"o"}, - Usage: "The file where the response contents will be stored. Use the value '-' to force output to stdout.", - }, }, Action: handleAssetsServe, HideHelpCommand: true, @@ -167,15 +161,7 @@ func handleAssetsServe(ctx context.Context, cmd *cli.Command) error { return err } - response, err := client.Assets.Serve(ctx, params, options...) - if err != nil { - return err - } - message, err := writeBinaryResponse(response, os.Stdout, cmd.String("output")) - if message != "" { - fmt.Println(message) - } - return err + return client.Assets.Serve(ctx, params, options...) } func handleAssetsUpload(ctx context.Context, cmd *cli.Command) error { diff --git a/pkg/cmd/asset_test.go b/pkg/cmd/asset_test.go index 4375d2e..696660a 100644 --- a/pkg/cmd/asset_test.go +++ b/pkg/cmd/asset_test.go @@ -37,7 +37,6 @@ func TestAssetsServe(t *testing.T) { "--access-token", "string", "assets", "serve", "--url", "x", - "--output", "/dev/null", ) }) } From ce78a27ad6a8ef45ff2bbeea7b56d965f03e4c04 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 19:54:37 +0000 Subject: [PATCH 38/52] Document asset serve stream response --- .stats.yml | 2 +- pkg/cmd/asset.go | 16 +++++++++++++++- pkg/cmd/asset_test.go | 1 + 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index 1d3cc36..e925f68 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 23 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-611aa7641fbca8cf31d626bf86f9efd3c2b92778e897ebbb25c6ea44185ed1ed.yml -openapi_spec_hash: 8dff13848934c5b20f3804236e8286d3 +openapi_spec_hash: 4840f003552e8b48eb8e689b59a819ef config_hash: 05ebdec072113f63395372504da98192 diff --git a/pkg/cmd/asset.go b/pkg/cmd/asset.go index 2e56489..6e62006 100644 --- a/pkg/cmd/asset.go +++ b/pkg/cmd/asset.go @@ -5,6 +5,7 @@ package cmd import ( "context" "fmt" + "os" "github.com/beeper/desktop-api-cli/internal/apiquery" "github.com/beeper/desktop-api-cli/internal/requestflag" @@ -41,6 +42,11 @@ var assetsServe = cli.Command{ Required: true, QueryPath: "url", }, + &requestflag.Flag[string]{ + Name: "output", + Aliases: []string{"o"}, + Usage: "The file where the response contents will be stored. Use the value '-' to force output to stdout.", + }, }, Action: handleAssetsServe, HideHelpCommand: true, @@ -161,7 +167,15 @@ func handleAssetsServe(ctx context.Context, cmd *cli.Command) error { return err } - return client.Assets.Serve(ctx, params, options...) + response, err := client.Assets.Serve(ctx, params, options...) + if err != nil { + return err + } + message, err := writeBinaryResponse(response, os.Stdout, cmd.String("output")) + if message != "" { + fmt.Println(message) + } + return err } func handleAssetsUpload(ctx context.Context, cmd *cli.Command) error { diff --git a/pkg/cmd/asset_test.go b/pkg/cmd/asset_test.go index 696660a..4375d2e 100644 --- a/pkg/cmd/asset_test.go +++ b/pkg/cmd/asset_test.go @@ -37,6 +37,7 @@ func TestAssetsServe(t *testing.T) { "--access-token", "string", "assets", "serve", "--url", "x", + "--output", "/dev/null", ) }) } From 2b1da99d8aa0d518a3a1e90126467b4eabff4a03 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 07:49:49 +0000 Subject: [PATCH 39/52] 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 55190af84fadce3f17bf08a89471632232412c49 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 07:53:01 +0000 Subject: [PATCH 40/52] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index e925f68..a2edbe5 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 23 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-611aa7641fbca8cf31d626bf86f9efd3c2b92778e897ebbb25c6ea44185ed1ed.yml +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper/beeper-desktop-api-fdefa92da44ff91a34438591d022c182a83d9daf9a28b452556e325c6ef7b3f0.yml openapi_spec_hash: 4840f003552e8b48eb8e689b59a819ef config_hash: 05ebdec072113f63395372504da98192 From 865b85180334a84053ebbc19c718f17c29c9d9d7 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 07:54:21 +0000 Subject: [PATCH 41/52] 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/chat.go | 10 +- pkg/cmd/message.go | 8 +- 5 files changed, 383 insertions(+), 13 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/chat.go b/pkg/cmd/chat.go index 166458e..a85812f 100644 --- a/pkg/cmd/chat.go +++ b/pkg/cmd/chat.go @@ -104,10 +104,10 @@ var chatsRetrieve = cli.Command{ Usage: "Unique identifier of the chat.", Required: true, }, - &requestflag.Flag[any]{ + &requestflag.Flag[*int64]{ Name: "max-participant-count", Usage: "Maximum number of participants to return. Use -1 for all; otherwise 0–500. Defaults to all (-1).", - Default: -1, + Default: requestflag.Ptr[int64](-1), QueryPath: "maxParticipantCount", }, }, @@ -190,10 +190,10 @@ var chatsSearch = cli.Command{ Usage: `Filter by inbox type: "primary" (non-archived, non-low-priority), "low-priority", or "archive". If not specified, shows all chats.`, QueryPath: "inbox", }, - &requestflag.Flag[any]{ + &requestflag.Flag[*bool]{ Name: "include-muted", Usage: "Include chats marked as Muted by the user, which are usually less important. Default: true. Set to false if the user wants a more refined search.", - Default: true, + Default: requestflag.Ptr[bool](true), QueryPath: "includeMuted", }, &requestflag.Flag[any]{ @@ -229,7 +229,7 @@ var chatsSearch = cli.Command{ Default: "any", QueryPath: "type", }, - &requestflag.Flag[any]{ + &requestflag.Flag[*bool]{ Name: "unread-only", Usage: "Set to true to only retrieve chats that have unread messages", QueryPath: "unreadOnly", diff --git a/pkg/cmd/message.go b/pkg/cmd/message.go index c1a22eb..815bb10 100644 --- a/pkg/cmd/message.go +++ b/pkg/cmd/message.go @@ -108,16 +108,16 @@ var messagesSearch = cli.Command{ Usage: "Pagination direction used with 'cursor': 'before' fetches older results, 'after' fetches newer results. Defaults to 'before' when only 'cursor' is provided.", QueryPath: "direction", }, - &requestflag.Flag[any]{ + &requestflag.Flag[*bool]{ Name: "exclude-low-priority", Usage: "Exclude messages marked Low Priority by the user. Default: true. Set to false to include all.", - Default: true, + Default: requestflag.Ptr[bool](true), QueryPath: "excludeLowPriority", }, - &requestflag.Flag[any]{ + &requestflag.Flag[*bool]{ Name: "include-muted", Usage: "Include messages in chats marked as Muted by the user, which are usually less important. Default: true. Set to false if the user wants a more refined search.", - Default: true, + Default: requestflag.Ptr[bool](true), QueryPath: "includeMuted", }, &requestflag.Flag[int64]{ From 5fe5a8d8aa02222e08a8407728d8063d08d9fdb9 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 03:37:58 +0000 Subject: [PATCH 42/52] feat: support passing path and query params over stdin --- internal/requestflag/requestflag.go | 115 +++++++- internal/requestflag/requestflag_test.go | 345 +++++++++++++++++++++++ pkg/cmd/accountcontact.go | 22 +- pkg/cmd/asset.go | 16 +- pkg/cmd/beeperdesktopapi.go | 8 +- pkg/cmd/chat.go | 34 +-- pkg/cmd/chatmessagereaction.go | 40 +-- pkg/cmd/chatreminder.go | 18 +- pkg/cmd/flagoptions.go | 48 +++- pkg/cmd/message.go | 46 +-- 10 files changed, 595 insertions(+), 97 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/accountcontact.go b/pkg/cmd/accountcontact.go index cd491b0..e626581 100644 --- a/pkg/cmd/accountcontact.go +++ b/pkg/cmd/accountcontact.go @@ -20,9 +20,10 @@ var accountsContactsList = cli.Command{ Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ - Name: "account-id", - Usage: "Account ID this resource belongs to.", - Required: true, + Name: "account-id", + Usage: "Account ID this resource belongs to.", + Required: true, + PathParam: "accountID", }, &requestflag.Flag[string]{ Name: "cursor", @@ -60,9 +61,10 @@ var accountsContactsSearch = cli.Command{ Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ - Name: "account-id", - Usage: "Account ID this resource belongs to.", - Required: true, + Name: "account-id", + Usage: "Account ID this resource belongs to.", + Required: true, + PathParam: "accountID", }, &requestflag.Flag[string]{ Name: "query", @@ -86,8 +88,6 @@ func handleAccountsContactsList(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := beeperdesktopapi.AccountContactListParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -99,6 +99,8 @@ func handleAccountsContactsList(ctx context.Context, cmd *cli.Command) error { return err } + params := beeperdesktopapi.AccountContactListParams{} + format := "json" explicitFormat := cmd.Root().IsSet("format") if explicitFormat { @@ -157,8 +159,6 @@ func handleAccountsContactsSearch(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := beeperdesktopapi.AccountContactSearchParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -170,6 +170,8 @@ func handleAccountsContactsSearch(ctx context.Context, cmd *cli.Command) error { return err } + params := beeperdesktopapi.AccountContactSearchParams{} + var res []byte options = append(options, option.WithResponseBodyInto(&res)) _, err = client.Accounts.Contacts.Search( diff --git a/pkg/cmd/asset.go b/pkg/cmd/asset.go index 6e62006..1b5126c 100644 --- a/pkg/cmd/asset.go +++ b/pkg/cmd/asset.go @@ -113,8 +113,6 @@ func handleAssetsDownload(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := beeperdesktopapi.AssetDownloadParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -126,6 +124,8 @@ func handleAssetsDownload(ctx context.Context, cmd *cli.Command) error { return err } + params := beeperdesktopapi.AssetDownloadParams{} + var res []byte options = append(options, option.WithResponseBodyInto(&res)) _, err = client.Assets.Download(ctx, params, options...) @@ -154,8 +154,6 @@ func handleAssetsServe(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := beeperdesktopapi.AssetServeParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -167,6 +165,8 @@ func handleAssetsServe(ctx context.Context, cmd *cli.Command) error { return err } + params := beeperdesktopapi.AssetServeParams{} + response, err := client.Assets.Serve(ctx, params, options...) if err != nil { return err @@ -186,8 +186,6 @@ func handleAssetsUpload(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := beeperdesktopapi.AssetUploadParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -199,6 +197,8 @@ func handleAssetsUpload(ctx context.Context, cmd *cli.Command) error { return err } + params := beeperdesktopapi.AssetUploadParams{} + var res []byte options = append(options, option.WithResponseBodyInto(&res)) _, err = client.Assets.Upload(ctx, params, options...) @@ -227,8 +227,6 @@ func handleAssetsUploadBase64(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := beeperdesktopapi.AssetUploadBase64Params{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -240,6 +238,8 @@ func handleAssetsUploadBase64(ctx context.Context, cmd *cli.Command) error { return err } + params := beeperdesktopapi.AssetUploadBase64Params{} + var res []byte options = append(options, option.WithResponseBodyInto(&res)) _, err = client.Assets.UploadBase64(ctx, params, options...) diff --git a/pkg/cmd/beeperdesktopapi.go b/pkg/cmd/beeperdesktopapi.go index 5bc483e..ee5f5ca 100644 --- a/pkg/cmd/beeperdesktopapi.go +++ b/pkg/cmd/beeperdesktopapi.go @@ -68,8 +68,6 @@ func handleFocus(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := beeperdesktopapi.FocusParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -81,6 +79,8 @@ func handleFocus(ctx context.Context, cmd *cli.Command) error { return err } + params := beeperdesktopapi.FocusParams{} + var res []byte options = append(options, option.WithResponseBodyInto(&res)) _, err = client.Focus(ctx, params, options...) @@ -109,8 +109,6 @@ func handleSearch(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := beeperdesktopapi.SearchParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -122,6 +120,8 @@ func handleSearch(ctx context.Context, cmd *cli.Command) error { return err } + params := beeperdesktopapi.SearchParams{} + var res []byte options = append(options, option.WithResponseBodyInto(&res)) _, err = client.Search(ctx, params, options...) diff --git a/pkg/cmd/chat.go b/pkg/cmd/chat.go index a85812f..e88c656 100644 --- a/pkg/cmd/chat.go +++ b/pkg/cmd/chat.go @@ -100,9 +100,10 @@ var chatsRetrieve = cli.Command{ Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ - Name: "chat-id", - Usage: "Unique identifier of the chat.", - Required: true, + Name: "chat-id", + Usage: "Unique identifier of the chat.", + Required: true, + PathParam: "chatID", }, &requestflag.Flag[*int64]{ Name: "max-participant-count", @@ -150,9 +151,10 @@ var chatsArchive = cli.Command{ Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ - Name: "chat-id", - Usage: "Unique identifier of the chat.", - Required: true, + Name: "chat-id", + Usage: "Unique identifier of the chat.", + Required: true, + PathParam: "chatID", }, &requestflag.Flag[bool]{ Name: "archived", @@ -251,8 +253,6 @@ func handleChatsCreate(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := beeperdesktopapi.ChatNewParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -264,6 +264,8 @@ func handleChatsCreate(ctx context.Context, cmd *cli.Command) error { return err } + params := beeperdesktopapi.ChatNewParams{} + var res []byte options = append(options, option.WithResponseBodyInto(&res)) _, err = client.Chats.New(ctx, params, options...) @@ -295,8 +297,6 @@ func handleChatsRetrieve(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := beeperdesktopapi.ChatGetParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -308,6 +308,8 @@ func handleChatsRetrieve(ctx context.Context, cmd *cli.Command) error { return err } + params := beeperdesktopapi.ChatGetParams{} + var res []byte options = append(options, option.WithResponseBodyInto(&res)) _, err = client.Chats.Get( @@ -344,8 +346,6 @@ func handleChatsList(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := beeperdesktopapi.ChatListParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -357,6 +357,8 @@ func handleChatsList(ctx context.Context, cmd *cli.Command) error { return err } + params := beeperdesktopapi.ChatListParams{} + format := "json" explicitFormat := cmd.Root().IsSet("format") if explicitFormat { @@ -405,8 +407,6 @@ func handleChatsArchive(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := beeperdesktopapi.ChatArchiveParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -418,6 +418,8 @@ func handleChatsArchive(ctx context.Context, cmd *cli.Command) error { return err } + params := beeperdesktopapi.ChatArchiveParams{} + return client.Chats.Archive( ctx, cmd.Value("chat-id").(string), @@ -434,8 +436,6 @@ func handleChatsSearch(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := beeperdesktopapi.ChatSearchParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -447,6 +447,8 @@ func handleChatsSearch(ctx context.Context, cmd *cli.Command) error { return err } + params := beeperdesktopapi.ChatSearchParams{} + format := "json" explicitFormat := cmd.Root().IsSet("format") if explicitFormat { diff --git a/pkg/cmd/chatmessagereaction.go b/pkg/cmd/chatmessagereaction.go index 35cacf3..f96f915 100644 --- a/pkg/cmd/chatmessagereaction.go +++ b/pkg/cmd/chatmessagereaction.go @@ -20,13 +20,15 @@ var chatsMessagesReactionsDelete = cli.Command{ Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ - Name: "chat-id", - Usage: "Unique identifier of the chat.", - Required: true, + Name: "chat-id", + Usage: "Unique identifier of the chat.", + Required: true, + PathParam: "chatID", }, &requestflag.Flag[string]{ - Name: "message-id", - Required: true, + Name: "message-id", + Required: true, + PathParam: "messageID", }, &requestflag.Flag[string]{ Name: "reaction-key", @@ -45,13 +47,15 @@ var chatsMessagesReactionsAdd = cli.Command{ Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ - Name: "chat-id", - Usage: "Unique identifier of the chat.", - Required: true, + Name: "chat-id", + Usage: "Unique identifier of the chat.", + Required: true, + PathParam: "chatID", }, &requestflag.Flag[string]{ - Name: "message-id", - Required: true, + Name: "message-id", + Required: true, + PathParam: "messageID", }, &requestflag.Flag[string]{ Name: "reaction-key", @@ -80,10 +84,6 @@ func handleChatsMessagesReactionsDelete(ctx context.Context, cmd *cli.Command) e return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := beeperdesktopapi.ChatMessageReactionDeleteParams{ - ChatID: cmd.Value("chat-id").(string), - } - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -95,6 +95,10 @@ func handleChatsMessagesReactionsDelete(ctx context.Context, cmd *cli.Command) e return err } + params := beeperdesktopapi.ChatMessageReactionDeleteParams{ + ChatID: cmd.Value("chat-id").(string), + } + var res []byte options = append(options, option.WithResponseBodyInto(&res)) _, err = client.Chats.Messages.Reactions.Delete( @@ -131,10 +135,6 @@ func handleChatsMessagesReactionsAdd(ctx context.Context, cmd *cli.Command) erro return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := beeperdesktopapi.ChatMessageReactionAddParams{ - ChatID: cmd.Value("chat-id").(string), - } - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -146,6 +146,10 @@ func handleChatsMessagesReactionsAdd(ctx context.Context, cmd *cli.Command) erro return err } + params := beeperdesktopapi.ChatMessageReactionAddParams{ + ChatID: cmd.Value("chat-id").(string), + } + var res []byte options = append(options, option.WithResponseBodyInto(&res)) _, err = client.Chats.Messages.Reactions.Add( diff --git a/pkg/cmd/chatreminder.go b/pkg/cmd/chatreminder.go index 5f288e1..acccdaf 100644 --- a/pkg/cmd/chatreminder.go +++ b/pkg/cmd/chatreminder.go @@ -18,9 +18,10 @@ var chatsRemindersCreate = requestflag.WithInnerFlags(cli.Command{ Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ - Name: "chat-id", - Usage: "Unique identifier of the chat.", - Required: true, + Name: "chat-id", + Usage: "Unique identifier of the chat.", + Required: true, + PathParam: "chatID", }, &requestflag.Flag[map[string]any]{ Name: "reminder", @@ -52,9 +53,10 @@ var chatsRemindersDelete = cli.Command{ Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ - Name: "chat-id", - Usage: "Unique identifier of the chat.", - Required: true, + Name: "chat-id", + Usage: "Unique identifier of the chat.", + Required: true, + PathParam: "chatID", }, }, Action: handleChatsRemindersDelete, @@ -72,8 +74,6 @@ func handleChatsRemindersCreate(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := beeperdesktopapi.ChatReminderNewParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -85,6 +85,8 @@ func handleChatsRemindersCreate(ctx context.Context, cmd *cli.Command) error { return err } + params := beeperdesktopapi.ChatReminderNewParams{} + return client.Chats.Reminders.New( ctx, cmd.Value("chat-id").(string), diff --git a/pkg/cmd/flagoptions.go b/pkg/cmd/flagoptions.go index aa566b3..9452fd0 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/message.go b/pkg/cmd/message.go index 815bb10..ff1413f 100644 --- a/pkg/cmd/message.go +++ b/pkg/cmd/message.go @@ -20,13 +20,15 @@ var messagesUpdate = cli.Command{ Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ - Name: "chat-id", - Usage: "Unique identifier of the chat.", - Required: true, + Name: "chat-id", + Usage: "Unique identifier of the chat.", + Required: true, + PathParam: "chatID", }, &requestflag.Flag[string]{ - Name: "message-id", - Required: true, + Name: "message-id", + Required: true, + PathParam: "messageID", }, &requestflag.Flag[string]{ Name: "text", @@ -45,9 +47,10 @@ var messagesList = cli.Command{ Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ - Name: "chat-id", - Usage: "Unique identifier of the chat.", - Required: true, + Name: "chat-id", + Usage: "Unique identifier of the chat.", + Required: true, + PathParam: "chatID", }, &requestflag.Flag[string]{ Name: "cursor", @@ -156,9 +159,10 @@ var messagesSend = requestflag.WithInnerFlags(cli.Command{ Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ - Name: "chat-id", - Usage: "Unique identifier of the chat.", - Required: true, + Name: "chat-id", + Usage: "Unique identifier of the chat.", + Required: true, + PathParam: "chatID", }, &requestflag.Flag[map[string]any]{ Name: "attachment", @@ -224,10 +228,6 @@ func handleMessagesUpdate(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := beeperdesktopapi.MessageUpdateParams{ - ChatID: cmd.Value("chat-id").(string), - } - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -239,6 +239,10 @@ func handleMessagesUpdate(ctx context.Context, cmd *cli.Command) error { return err } + params := beeperdesktopapi.MessageUpdateParams{ + ChatID: cmd.Value("chat-id").(string), + } + var res []byte options = append(options, option.WithResponseBodyInto(&res)) _, err = client.Messages.Update( @@ -275,8 +279,6 @@ func handleMessagesList(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := beeperdesktopapi.MessageListParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -288,6 +290,8 @@ func handleMessagesList(ctx context.Context, cmd *cli.Command) error { return err } + params := beeperdesktopapi.MessageListParams{} + format := "json" explicitFormat := cmd.Root().IsSet("format") if explicitFormat { @@ -343,8 +347,6 @@ func handleMessagesSearch(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := beeperdesktopapi.MessageSearchParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -356,6 +358,8 @@ func handleMessagesSearch(ctx context.Context, cmd *cli.Command) error { return err } + params := beeperdesktopapi.MessageSearchParams{} + format := "json" explicitFormat := cmd.Root().IsSet("format") if explicitFormat { @@ -404,8 +408,6 @@ func handleMessagesSend(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := beeperdesktopapi.MessageSendParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -417,6 +419,8 @@ func handleMessagesSend(ctx context.Context, cmd *cli.Command) error { return err } + params := beeperdesktopapi.MessageSendParams{} + var res []byte options = append(options, option.WithResponseBodyInto(&res)) _, err = client.Messages.Send( From 36b4a2f8acddfd6f1da0ee61f18438bdd26c4d8c Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 03:38:56 +0000 Subject: [PATCH 43/52] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index a2edbe5..ec75571 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 23 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper/beeper-desktop-api-fdefa92da44ff91a34438591d022c182a83d9daf9a28b452556e325c6ef7b3f0.yml +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper/beeper-desktop-api-356444646dafe352d3ef7c2e01aedf030197a5519b41cf2c3fd8be2571456b43.yml openapi_spec_hash: 4840f003552e8b48eb8e689b59a819ef config_hash: 05ebdec072113f63395372504da98192 From bf4b43c832c3b56adda72018ffd91a7dc0660966 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 18:07:10 +0000 Subject: [PATCH 44/52] feat(api): api update --- .stats.yml | 8 +- pkg/cmd/chat.go | 172 +++++++++++++++++++++++++++++-------------- pkg/cmd/chat_test.go | 93 +++++++++++++---------- pkg/cmd/cmd.go | 1 + 4 files changed, 177 insertions(+), 97 deletions(-) diff --git a/.stats.yml b/.stats.yml index ec75571..75ad795 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 23 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper/beeper-desktop-api-356444646dafe352d3ef7c2e01aedf030197a5519b41cf2c3fd8be2571456b43.yml -openapi_spec_hash: 4840f003552e8b48eb8e689b59a819ef -config_hash: 05ebdec072113f63395372504da98192 +configured_endpoints: 24 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper/beeper-desktop-api-8b89ffbfeb39b4186328fefd81f7dab1d28c786012201feb8035c7f920c4fbae.yml +openapi_spec_hash: de40e013fcc83fa44d5c51ddecb543c0 +config_hash: 08b781db5f1857ed601d1b2f4b6b45d9 diff --git a/pkg/cmd/chat.go b/pkg/cmd/chat.go index e88c656..99523ed 100644 --- a/pkg/cmd/chat.go +++ b/pkg/cmd/chat.go @@ -14,9 +14,9 @@ import ( "github.com/urfave/cli/v3" ) -var chatsCreate = requestflag.WithInnerFlags(cli.Command{ +var chatsCreate = cli.Command{ Name: "create", - Usage: "Create a direct or group chat with mode=\"create\", or use mode=\"start\" to resolve\na contact and open a direct chat.", + Usage: "Create a direct or group chat from participant IDs.", Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ @@ -25,74 +25,32 @@ var chatsCreate = requestflag.WithInnerFlags(cli.Command{ Required: true, BodyPath: "accountID", }, - &requestflag.Flag[bool]{ - Name: "allow-invite", - Usage: "Only used for mode='start'. Whether invite-based DM creation is allowed when required by the platform.", - Default: true, - BodyPath: "allowInvite", + &requestflag.Flag[[]string]{ + Name: "participant-id", + Usage: "User IDs to include in the new chat.", + Required: true, + BodyPath: "participantIDs", + }, + &requestflag.Flag[string]{ + Name: "type", + Usage: "'single' requires exactly one participantID; 'group' supports multiple participants and optional title.", + Required: true, + BodyPath: "type", }, &requestflag.Flag[string]{ Name: "message-text", Usage: "Optional first message content if the platform requires it to create the chat.", BodyPath: "messageText", }, - &requestflag.Flag[string]{ - Name: "mode", - Usage: "Operation mode. Use 'start' to resolve a user/contact and start a direct chat. Omit or set 'create' to create a chat directly.", - BodyPath: "mode", - }, - &requestflag.Flag[[]string]{ - Name: "participant-id", - Usage: "Required for create mode. Provide exactly one user ID for 'single' chats and one or more for 'group' chats.", - BodyPath: "participantIDs", - }, &requestflag.Flag[string]{ Name: "title", Usage: "Optional title for group chats; ignored for single chats on most networks.", BodyPath: "title", }, - &requestflag.Flag[string]{ - Name: "type", - Usage: "Required for create mode. 'single' creates a direct message chat; 'group' creates a group chat.", - BodyPath: "type", - }, - &requestflag.Flag[map[string]any]{ - Name: "user", - Usage: "Required for mode='start'. Merged user-like contact payload used to resolve the best identifier.", - BodyPath: "user", - }, }, Action: handleChatsCreate, HideHelpCommand: true, -}, map[string][]requestflag.HasOuterFlag{ - "user": { - &requestflag.InnerFlag[string]{ - Name: "user.id", - Usage: "Known user ID when available.", - InnerField: "id", - }, - &requestflag.InnerFlag[string]{ - Name: "user.email", - Usage: "Email candidate.", - InnerField: "email", - }, - &requestflag.InnerFlag[string]{ - Name: "user.full-name", - Usage: "Display name hint used for ranking only.", - InnerField: "fullName", - }, - &requestflag.InnerFlag[string]{ - Name: "user.phone-number", - Usage: "Phone number candidate (E.164 preferred).", - InnerField: "phoneNumber", - }, - &requestflag.InnerFlag[string]{ - Name: "user.username", - Usage: "Username/handle candidate.", - InnerField: "username", - }, - }, -}) +} var chatsRetrieve = cli.Command{ Name: "retrieve", @@ -245,6 +203,67 @@ var chatsSearch = cli.Command{ HideHelpCommand: true, } +var chatsStart = requestflag.WithInnerFlags(cli.Command{ + Name: "start", + Usage: "Resolve a user/contact and open a direct chat. Reuses an existing direct chat\nwhen one is found. Available in Beeper Desktop v4.2.799+.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "account-id", + Usage: "Account to create or start the chat on.", + Required: true, + BodyPath: "accountID", + }, + &requestflag.Flag[map[string]any]{ + Name: "user", + Usage: "Merged user-like contact payload used to resolve the best identifier.", + Required: true, + BodyPath: "user", + }, + &requestflag.Flag[bool]{ + Name: "allow-invite", + Usage: "Whether invite-based DM creation is allowed when required by the platform.", + Default: true, + BodyPath: "allowInvite", + }, + &requestflag.Flag[string]{ + Name: "message-text", + Usage: "Optional first message content if the platform requires it to create the chat.", + BodyPath: "messageText", + }, + }, + Action: handleChatsStart, + HideHelpCommand: true, +}, map[string][]requestflag.HasOuterFlag{ + "user": { + &requestflag.InnerFlag[string]{ + Name: "user.id", + Usage: "Known user ID when available.", + InnerField: "id", + }, + &requestflag.InnerFlag[string]{ + Name: "user.email", + Usage: "Email candidate.", + InnerField: "email", + }, + &requestflag.InnerFlag[string]{ + Name: "user.full-name", + Usage: "Display name hint used for ranking only.", + InnerField: "fullName", + }, + &requestflag.InnerFlag[string]{ + Name: "user.phone-number", + Usage: "Phone number candidate (E.164 preferred).", + InnerField: "phoneNumber", + }, + &requestflag.InnerFlag[string]{ + Name: "user.username", + Usage: "Username/handle candidate.", + InnerField: "username", + }, + }, +}) + func handleChatsCreate(ctx context.Context, cmd *cli.Command) error { client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) unusedArgs := cmd.Args().Slice() @@ -485,3 +504,44 @@ func handleChatsSearch(ctx context.Context, cmd *cli.Command) error { }) } } + +func handleChatsStart(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.ChatStartParams{} + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Chats.Start(ctx, params, options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "chats start", + Transform: transform, + }) +} diff --git a/pkg/cmd/chat_test.go b/pkg/cmd/chat_test.go index d7966cd..4e79c01 100644 --- a/pkg/cmd/chat_test.go +++ b/pkg/cmd/chat_test.go @@ -16,37 +16,10 @@ func TestChatsCreate(t *testing.T) { "--access-token", "string", "chats", "create", "--account-id", "accountID", - "--allow-invite=true", - "--message-text", "messageText", - "--mode", "start", "--participant-id", "string", - "--title", "title", "--type", "single", - "--user", "{id: id, email: email, fullName: fullName, phoneNumber: phoneNumber, username: username}", - ) - }) - - t.Run("inner flags", func(t *testing.T) { - // Check that inner flags have been set up correctly - requestflag.CheckInnerFlags(chatsCreate) - - // Alternative argument passing style using inner flags - mocktest.TestRunMockTestWithFlags( - t, - "--access-token", "string", - "chats", "create", - "--account-id", "accountID", - "--allow-invite=true", "--message-text", "messageText", - "--mode", "start", - "--participant-id", "string", "--title", "title", - "--type", "single", - "--user.id", "id", - "--user.email", "email", - "--user.full-name", "fullName", - "--user.phone-number", "phoneNumber", - "--user.username", "username", ) }) @@ -54,19 +27,11 @@ func TestChatsCreate(t *testing.T) { // Test piping YAML data over stdin pipeData := []byte("" + "accountID: accountID\n" + - "allowInvite: true\n" + - "messageText: messageText\n" + - "mode: start\n" + "participantIDs:\n" + " - string\n" + - "title: title\n" + "type: single\n" + - "user:\n" + - " id: id\n" + - " email: email\n" + - " fullName: fullName\n" + - " phoneNumber: phoneNumber\n" + - " username: username\n") + "messageText: messageText\n" + + "title: title\n") mocktest.TestRunMockTestWithPipeAndFlags( t, pipeData, "--access-token", "string", @@ -148,3 +113,57 @@ func TestChatsSearch(t *testing.T) { ) }) } + +func TestChatsStart(t *testing.T) { + t.Skip("Stainless mock tests currently load the project-published OpenAPI spec URL, which may not include newly-added local-only endpoints during build checks.") + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "chats", "start", + "--account-id", "accountID", + "--user", "{id: id, email: email, fullName: fullName, phoneNumber: phoneNumber, username: username}", + "--allow-invite=true", + "--message-text", "messageText", + ) + }) + + t.Run("inner flags", func(t *testing.T) { + // Check that inner flags have been set up correctly + requestflag.CheckInnerFlags(chatsStart) + + // Alternative argument passing style using inner flags + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "chats", "start", + "--account-id", "accountID", + "--user.id", "id", + "--user.email", "email", + "--user.full-name", "fullName", + "--user.phone-number", "phoneNumber", + "--user.username", "username", + "--allow-invite=true", + "--message-text", "messageText", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("" + + "accountID: accountID\n" + + "user:\n" + + " id: id\n" + + " email: email\n" + + " fullName: fullName\n" + + " phoneNumber: phoneNumber\n" + + " username: username\n" + + "allowInvite: true\n" + + "messageText: messageText\n") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, + "--access-token", "string", + "chats", "start", + ) + }) +} diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index 81a042c..1298235 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -114,6 +114,7 @@ func init() { &chatsList, &chatsArchive, &chatsSearch, + &chatsStart, }, }, { From 3920764851961421776d64cf694dee1f999afa3e Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 18:51:31 +0000 Subject: [PATCH 45/52] feat(api): api update --- .stats.yml | 4 ++-- pkg/cmd/asset.go | 17 +++++++++-------- pkg/cmd/asset_test.go | 18 +++++++----------- 3 files changed, 18 insertions(+), 21 deletions(-) diff --git a/.stats.yml b/.stats.yml index 75ad795..8180b73 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 24 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper/beeper-desktop-api-8b89ffbfeb39b4186328fefd81f7dab1d28c786012201feb8035c7f920c4fbae.yml -openapi_spec_hash: de40e013fcc83fa44d5c51ddecb543c0 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper/beeper-desktop-api-7a6e57b0b4f45713b9a6e87421b1e6d867e83151b2536cde07165c6b7e1ffc87.yml +openapi_spec_hash: 68a4fd30bd7eb32aea004dc2af15e9ac config_hash: 08b781db5f1857ed601d1b2f4b6b45d9 diff --git a/pkg/cmd/asset.go b/pkg/cmd/asset.go index 1b5126c..9ccd4b6 100644 --- a/pkg/cmd/asset.go +++ b/pkg/cmd/asset.go @@ -58,20 +58,21 @@ var assetsUpload = cli.Command{ Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ - Name: "file", - Usage: "The file to upload (max 500 MB).", - Required: true, - BodyPath: "file", - FileInput: true, + Name: "content", + Usage: "Base64-encoded file content (max ~500MB decoded)", + Required: true, + BodyPath: "content", }, &requestflag.Flag[string]{ Name: "file-name", - Usage: "Original filename. Defaults to the uploaded file name if omitted", + Usage: "Original filename. Required for the JSON form of /v1/assets/upload.", + Required: true, BodyPath: "fileName", }, &requestflag.Flag[string]{ Name: "mime-type", - Usage: "MIME type. Auto-detected from magic bytes if omitted", + Usage: "MIME type. Required for the JSON form of /v1/assets/upload.", + Required: true, BodyPath: "mimeType", }, }, @@ -190,7 +191,7 @@ func handleAssetsUpload(ctx context.Context, cmd *cli.Command) error { cmd, apiquery.NestedQueryFormatBrackets, apiquery.ArrayQueryFormatRepeat, - MultipartFormEncoded, + ApplicationJSON, false, ) if err != nil { diff --git a/pkg/cmd/asset_test.go b/pkg/cmd/asset_test.go index 4375d2e..869a671 100644 --- a/pkg/cmd/asset_test.go +++ b/pkg/cmd/asset_test.go @@ -3,7 +3,6 @@ package cmd import ( - "strings" "testing" "github.com/beeper/desktop-api-cli/internal/mocktest" @@ -48,21 +47,18 @@ func TestAssetsUpload(t *testing.T) { t, "--access-token", "string", "assets", "upload", - "--file", mocktest.TestFile(t, "Example data"), - "--file-name", "fileName", - "--mime-type", "mimeType", + "--content", "x", + "--file-name", "x", + "--mime-type", "x", ) }) t.Run("piping data", func(t *testing.T) { - testFile := mocktest.TestFile(t, "Example data") // Test piping YAML data over stdin - pipeDataStr := "" + - "file: Example data\n" + - "fileName: fileName\n" + - "mimeType: mimeType\n" - pipeDataStr = strings.ReplaceAll(pipeDataStr, "Example data", testFile) - pipeData := []byte(pipeDataStr) + pipeData := []byte("" + + "content: x\n" + + "fileName: x\n" + + "mimeType: x\n") mocktest.TestRunMockTestWithPipeAndFlags( t, pipeData, "--access-token", "string", From 3c797176b7ced4499d6bf3ff71f7d3a94967e026 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 6 May 2026 21:17:49 +0000 Subject: [PATCH 46/52] feat(api): api update --- .stats.yml | 8 +- README.md | 3 + pkg/cmd/account.go | 2 +- pkg/cmd/asset.go | 27 ++- pkg/cmd/asset_test.go | 18 +- pkg/cmd/beeperdesktopapi.go | 6 +- pkg/cmd/chat.go | 340 +++++++++++++++++++++++++++- pkg/cmd/chat_test.go | 141 +++++++++++- pkg/cmd/chatmessagereaction.go | 21 +- pkg/cmd/chatmessagereaction_test.go | 6 +- pkg/cmd/chatreminder.go | 12 +- pkg/cmd/chatreminder_test.go | 6 +- pkg/cmd/cmd.go | 6 + pkg/cmd/info.go | 2 +- pkg/cmd/message.go | 149 +++++++++++- pkg/cmd/message_test.go | 38 +++- 16 files changed, 713 insertions(+), 72 deletions(-) diff --git a/.stats.yml b/.stats.yml index 8180b73..2dd3fee 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 24 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper/beeper-desktop-api-7a6e57b0b4f45713b9a6e87421b1e6d867e83151b2536cde07165c6b7e1ffc87.yml -openapi_spec_hash: 68a4fd30bd7eb32aea004dc2af15e9ac -config_hash: 08b781db5f1857ed601d1b2f4b6b45d9 +configured_endpoints: 30 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper/beeper-desktop-api-c08c14bb754b4cb0e02b21fabb680469368286be339dec0aaa8c69d04a1f021a.yml +openapi_spec_hash: a10246aaf7cdc33b682fc245bd5f893b +config_hash: 72f9d43b9b51a5da912e9f3730e53ae2 diff --git a/README.md b/README.md index de0b813..6bca084 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,9 @@ beeper-desktop-cli [resource] [flags...] ```sh beeper-desktop-cli chats search \ --access-token 'My Access Token' \ + --account-id matrix \ + --account-id discordgo \ + --account-id local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc \ --include-muted \ --limit 3 \ --type single diff --git a/pkg/cmd/account.go b/pkg/cmd/account.go index 9cf40bb..382b5fc 100644 --- a/pkg/cmd/account.go +++ b/pkg/cmd/account.go @@ -15,7 +15,7 @@ import ( var accountsList = cli.Command{ Name: "list", - Usage: "Lists chat accounts across networks (WhatsApp, Telegram, Twitter/X, etc.)\nactively connected to this Beeper Desktop instance", + Usage: "List Chat Accounts connected to this Beeper Desktop instance, including bridge\nmetadata and network identity.", Suggest: true, Flags: []cli.Flag{}, Action: handleAccountsList, diff --git a/pkg/cmd/asset.go b/pkg/cmd/asset.go index 9ccd4b6..959b2fb 100644 --- a/pkg/cmd/asset.go +++ b/pkg/cmd/asset.go @@ -17,12 +17,12 @@ import ( var assetsDownload = cli.Command{ Name: "download", - Usage: "Download a Matrix asset using its mxc:// or localmxc:// URL to the device\nrunning Beeper Desktop and return the local file URL.", + Usage: "Download a Matrix file using its mxc:// or localmxc:// URL to the device running\nBeeper Desktop and return the local file URL.", Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ Name: "url", - Usage: "Matrix content URL (mxc:// or localmxc://) for the asset to download.", + Usage: "Matrix content URL (mxc:// or localmxc://) for the file to download.", Required: true, BodyPath: "url", }, @@ -38,7 +38,7 @@ var assetsServe = cli.Command{ Flags: []cli.Flag{ &requestflag.Flag[string]{ Name: "url", - Usage: "Asset URL to serve. Accepts mxc://, localmxc://, or file:// URLs.", + Usage: "File URL to serve. Accepts mxc://, localmxc://, or file:// URLs.", Required: true, QueryPath: "url", }, @@ -54,25 +54,24 @@ var assetsServe = cli.Command{ var assetsUpload = cli.Command{ Name: "upload", - Usage: "Upload a file to a temporary location using multipart/form-data. Returns an\nuploadID that can be referenced when sending messages with attachments.", + Usage: "Upload a file to a temporary location using multipart/form-data. Returns an\nuploadID that can be referenced when sending a message or materializing a draft\nattachment.", Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ - Name: "content", - Usage: "Base64-encoded file content (max ~500MB decoded)", - Required: true, - BodyPath: "content", + Name: "file", + Usage: "The file to upload (max 500 MB).", + Required: true, + BodyPath: "file", + FileInput: true, }, &requestflag.Flag[string]{ Name: "file-name", - Usage: "Original filename. Required for the JSON form of /v1/assets/upload.", - Required: true, + Usage: "Original filename. Defaults to the uploaded file name if omitted", BodyPath: "fileName", }, &requestflag.Flag[string]{ Name: "mime-type", - Usage: "MIME type. Required for the JSON form of /v1/assets/upload.", - Required: true, + Usage: "MIME type. Auto-detected from magic bytes if omitted", BodyPath: "mimeType", }, }, @@ -82,7 +81,7 @@ var assetsUpload = cli.Command{ var assetsUploadBase64 = cli.Command{ Name: "upload-base64", - Usage: "Upload a file using a JSON body with base64-encoded content. Returns an uploadID\nthat can be referenced when sending messages with attachments. Alternative to\nthe multipart upload endpoint.", + Usage: "Upload a file using a JSON body with base64-encoded content. Returns an uploadID\nthat can be referenced when sending a message or materializing a draft\nattachment. Alternative to the multipart upload endpoint.", Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ @@ -191,7 +190,7 @@ func handleAssetsUpload(ctx context.Context, cmd *cli.Command) error { cmd, apiquery.NestedQueryFormatBrackets, apiquery.ArrayQueryFormatRepeat, - ApplicationJSON, + MultipartFormEncoded, false, ) if err != nil { diff --git a/pkg/cmd/asset_test.go b/pkg/cmd/asset_test.go index 869a671..4375d2e 100644 --- a/pkg/cmd/asset_test.go +++ b/pkg/cmd/asset_test.go @@ -3,6 +3,7 @@ package cmd import ( + "strings" "testing" "github.com/beeper/desktop-api-cli/internal/mocktest" @@ -47,18 +48,21 @@ func TestAssetsUpload(t *testing.T) { t, "--access-token", "string", "assets", "upload", - "--content", "x", - "--file-name", "x", - "--mime-type", "x", + "--file", mocktest.TestFile(t, "Example data"), + "--file-name", "fileName", + "--mime-type", "mimeType", ) }) t.Run("piping data", func(t *testing.T) { + testFile := mocktest.TestFile(t, "Example data") // Test piping YAML data over stdin - pipeData := []byte("" + - "content: x\n" + - "fileName: x\n" + - "mimeType: x\n") + pipeDataStr := "" + + "file: Example data\n" + + "fileName: fileName\n" + + "mimeType: mimeType\n" + pipeDataStr = strings.ReplaceAll(pipeDataStr, "Example data", testFile) + pipeData := []byte(pipeDataStr) mocktest.TestRunMockTestWithPipeAndFlags( t, pipeData, "--access-token", "string", diff --git a/pkg/cmd/beeperdesktopapi.go b/pkg/cmd/beeperdesktopapi.go index ee5f5ca..c575710 100644 --- a/pkg/cmd/beeperdesktopapi.go +++ b/pkg/cmd/beeperdesktopapi.go @@ -16,7 +16,7 @@ import ( var focus = cli.Command{ Name: "focus", - Usage: "Focus Beeper Desktop and optionally navigate to a specific chat, message, or\npre-fill draft text and attachment.", + Usage: "Focus Beeper Desktop and optionally navigate to a specific chat, message, or\npre-fill plain text and an image path.", Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ @@ -26,12 +26,12 @@ var focus = cli.Command{ }, &requestflag.Flag[string]{ Name: "draft-attachment-path", - Usage: "Optional draft attachment path to populate in the message input field.", + Usage: "Optional image path to populate in the message input field.", BodyPath: "draftAttachmentPath", }, &requestflag.Flag[string]{ Name: "draft-text", - Usage: "Optional draft text to populate in the message input field.", + Usage: "Optional plain text to populate in the message input field.", BodyPath: "draftText", }, &requestflag.Flag[string]{ diff --git a/pkg/cmd/chat.go b/pkg/cmd/chat.go index 99523ed..7b73175 100644 --- a/pkg/cmd/chat.go +++ b/pkg/cmd/chat.go @@ -16,7 +16,7 @@ import ( var chatsCreate = cli.Command{ Name: "create", - Usage: "Create a direct or group chat from participant IDs.", + Usage: "Create a direct or group chat from participant IDs. Returns the created chat.", Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ @@ -59,14 +59,14 @@ var chatsRetrieve = cli.Command{ Flags: []cli.Flag{ &requestflag.Flag[string]{ Name: "chat-id", - Usage: "Unique identifier of the chat.", + Usage: "Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available.", Required: true, PathParam: "chatID", }, &requestflag.Flag[*int64]{ Name: "max-participant-count", - Usage: "Maximum number of participants to return. Use -1 for all; otherwise 0–500. Defaults to all (-1).", - Default: requestflag.Ptr[int64](-1), + Usage: "Maximum number of participants to return. Use -1 for all; otherwise 0-500. Defaults to 100. List and search endpoints return up to 20 participants per chat.", + Default: requestflag.Ptr[int64](100), QueryPath: "maxParticipantCount", }, }, @@ -74,6 +74,80 @@ var chatsRetrieve = cli.Command{ HideHelpCommand: true, } +var chatsUpdate = requestflag.WithInnerFlags(cli.Command{ + Name: "update", + Usage: "Update supported chat fields. Non-empty draft objects are accepted only when the\ncurrent draft is empty. Send draft=null to clear the draft before setting new\ndraft text or attachments.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "chat-id", + Usage: "Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available.", + Required: true, + PathParam: "chatID", + }, + &requestflag.Flag[*string]{ + Name: "description", + Usage: "Group chat description/topic. Support depends on the chat account and chat permissions.", + BodyPath: "description", + }, + &requestflag.Flag[map[string]any]{ + Name: "draft", + Usage: "Draft object to set or clear. Non-empty drafts are only accepted when the current draft is empty. Send draft=null to clear text and attachments together before setting a new draft.", + BodyPath: "draft", + }, + &requestflag.Flag[*string]{ + Name: "img-url", + Usage: "Local filesystem path to a group chat avatar image. Support depends on the chat account and chat permissions.", + BodyPath: "imgURL", + }, + &requestflag.Flag[bool]{ + Name: "is-archived", + Usage: "Archive or unarchive the chat.", + BodyPath: "isArchived", + }, + &requestflag.Flag[bool]{ + Name: "is-low-priority", + Usage: "Mark or unmark the chat as low priority when supported by the account.", + BodyPath: "isLowPriority", + }, + &requestflag.Flag[bool]{ + Name: "is-muted", + Usage: "Mute or unmute the chat.", + BodyPath: "isMuted", + }, + &requestflag.Flag[bool]{ + Name: "is-pinned", + Usage: "Pin or unpin the chat when supported by the account.", + BodyPath: "isPinned", + }, + &requestflag.Flag[*int64]{ + Name: "message-expiry-seconds", + Usage: "Disappearing-message timer in seconds, or null to clear when supported.", + BodyPath: "messageExpirySeconds", + }, + &requestflag.Flag[*string]{ + Name: "title", + Usage: "Custom chat title. Support depends on the chat account and chat permissions.", + BodyPath: "title", + }, + }, + Action: handleChatsUpdate, + HideHelpCommand: true, +}, map[string][]requestflag.HasOuterFlag{ + "draft": { + &requestflag.InnerFlag[string]{ + Name: "draft.text", + Usage: "Draft text. Plain text and Markdown are converted to Matrix HTML with the same rules used by send and edit.", + InnerField: "text", + }, + &requestflag.InnerFlag[map[string]any]{ + Name: "draft.attachments", + Usage: "Draft attachments keyed by attachment ID. Each attachment must reference an uploadID returned by the upload file endpoint.", + InnerField: "attachments", + }, + }, +}) + var chatsList = cli.Command{ Name: "list", Usage: "List all chats sorted by last activity (most recent first). Combines all\naccounts into a single paginated list.", @@ -110,7 +184,7 @@ var chatsArchive = cli.Command{ Flags: []cli.Flag{ &requestflag.Flag[string]{ Name: "chat-id", - Usage: "Unique identifier of the chat.", + Usage: "Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available.", Required: true, PathParam: "chatID", }, @@ -125,6 +199,64 @@ var chatsArchive = cli.Command{ HideHelpCommand: true, } +var chatsMarkRead = cli.Command{ + Name: "mark-read", + Usage: "Mark a chat as read, optionally through a specific message ID.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "chat-id", + Usage: "Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available.", + Required: true, + PathParam: "chatID", + }, + &requestflag.Flag[string]{ + Name: "message-id", + Usage: "Optional message ID to mark read through.", + BodyPath: "messageID", + }, + }, + Action: handleChatsMarkRead, + HideHelpCommand: true, +} + +var chatsMarkUnread = cli.Command{ + Name: "mark-unread", + Usage: "Mark a chat as unread, optionally from a specific message ID.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "chat-id", + Usage: "Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available.", + Required: true, + PathParam: "chatID", + }, + &requestflag.Flag[string]{ + Name: "message-id", + Usage: "Optional message ID to mark unread from.", + BodyPath: "messageID", + }, + }, + Action: handleChatsMarkUnread, + HideHelpCommand: true, +} + +var chatsNotifyAnyway = cli.Command{ + Name: "notify-anyway", + Usage: "Force a delivery notification when supported by the underlying network.\nCurrently intended for iMessage on macOS; unsupported networks return an error.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "chat-id", + Usage: "Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available.", + Required: true, + PathParam: "chatID", + }, + }, + Action: handleChatsNotifyAnyway, + HideHelpCommand: true, +} + var chatsSearch = cli.Command{ Name: "search", Usage: "Search chats by title, network, or participant names.", @@ -205,7 +337,7 @@ var chatsSearch = cli.Command{ var chatsStart = requestflag.WithInnerFlags(cli.Command{ Name: "start", - Usage: "Resolve a user/contact and open a direct chat. Reuses an existing direct chat\nwhen one is found. Available in Beeper Desktop v4.2.799+.", + Usage: "Resolve a user/contact and open a direct chat. Reuses and returns an existing\ndirect chat when one is found. Available in Beeper Desktop v4.2.808+.", Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ @@ -357,6 +489,55 @@ func handleChatsRetrieve(ctx context.Context, cmd *cli.Command) error { }) } +func handleChatsUpdate(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("chat-id") && len(unusedArgs) > 0 { + cmd.Set("chat-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.ChatUpdateParams{} + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Chats.Update( + ctx, + cmd.Value("chat-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "chats update", + Transform: transform, + }) +} + func handleChatsList(ctx context.Context, cmd *cli.Command) error { client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) unusedArgs := cmd.Args().Slice() @@ -447,6 +628,153 @@ func handleChatsArchive(ctx context.Context, cmd *cli.Command) error { ) } +func handleChatsMarkRead(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("chat-id") && len(unusedArgs) > 0 { + cmd.Set("chat-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.ChatMarkReadParams{} + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Chats.MarkRead( + ctx, + cmd.Value("chat-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "chats mark-read", + Transform: transform, + }) +} + +func handleChatsMarkUnread(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("chat-id") && len(unusedArgs) > 0 { + cmd.Set("chat-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.ChatMarkUnreadParams{} + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Chats.MarkUnread( + ctx, + cmd.Value("chat-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "chats mark-unread", + Transform: transform, + }) +} + +func handleChatsNotifyAnyway(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("chat-id") && len(unusedArgs) > 0 { + cmd.Set("chat-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + EmptyBody, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.ChatNotifyAnywayParams{} + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Chats.NotifyAnyway( + ctx, + cmd.Value("chat-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "chats notify-anyway", + Transform: transform, + }) +} + func handleChatsSearch(ctx context.Context, cmd *cli.Command) error { client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) unusedArgs := cmd.Args().Slice() diff --git a/pkg/cmd/chat_test.go b/pkg/cmd/chat_test.go index 4e79c01..01c710b 100644 --- a/pkg/cmd/chat_test.go +++ b/pkg/cmd/chat_test.go @@ -47,7 +47,82 @@ func TestChatsRetrieve(t *testing.T) { "--access-token", "string", "chats", "retrieve", "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", - "--max-participant-count", "50", + "--max-participant-count", "100", + ) + }) +} + +func TestChatsUpdate(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "chats", "update", + "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", + "--description", "description", + "--draft", "{text: text, attachments: {foo: {uploadID: uploadID, id: id, duration: 0, fileName: fileName, mimeType: mimeType, size: {height: 0, width: 0}, type: image}}}", + "--img-url", "imgURL", + "--is-archived=true", + "--is-low-priority=true", + "--is-muted=true", + "--is-pinned=true", + "--message-expiry-seconds", "0", + "--title", "title", + ) + }) + + t.Run("inner flags", func(t *testing.T) { + // Check that inner flags have been set up correctly + requestflag.CheckInnerFlags(chatsUpdate) + + // Alternative argument passing style using inner flags + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "chats", "update", + "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", + "--description", "description", + "--draft.text", "text", + "--draft.attachments", "{foo: {uploadID: uploadID, id: id, duration: 0, fileName: fileName, mimeType: mimeType, size: {height: 0, width: 0}, type: image}}", + "--img-url", "imgURL", + "--is-archived=true", + "--is-low-priority=true", + "--is-muted=true", + "--is-pinned=true", + "--message-expiry-seconds", "0", + "--title", "title", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("" + + "description: description\n" + + "draft:\n" + + " text: text\n" + + " attachments:\n" + + " foo:\n" + + " uploadID: uploadID\n" + + " id: id\n" + + " duration: 0\n" + + " fileName: fileName\n" + + " mimeType: mimeType\n" + + " size:\n" + + " height: 0\n" + + " width: 0\n" + + " type: image\n" + + "imgURL: imgURL\n" + + "isArchived: true\n" + + "isLowPriority: true\n" + + "isMuted: true\n" + + "isPinned: true\n" + + "messageExpirySeconds: 0\n" + + "title: title\n") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, + "--access-token", "string", + "chats", "update", + "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", ) }) } @@ -59,8 +134,9 @@ func TestChatsList(t *testing.T) { "--access-token", "string", "chats", "list", "--max-items", "10", + "--account-id", "matrix", + "--account-id", "discordgo", "--account-id", "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", - "--account-id", "local-instagram_ba_eRfQMmnSNy_p7Ih7HL7RduRpKFU", "--cursor", "1725489123456|c29tZUltc2dQYWdl", "--direction", "before", ) @@ -90,6 +166,63 @@ func TestChatsArchive(t *testing.T) { }) } +func TestChatsMarkRead(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "chats", "mark-read", + "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", + "--message-id", "1343993", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("messageID: '1343993'") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, + "--access-token", "string", + "chats", "mark-read", + "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + }) +} + +func TestChatsMarkUnread(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "chats", "mark-unread", + "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", + "--message-id", "1343993", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("messageID: '1343993'") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, + "--access-token", "string", + "chats", "mark-unread", + "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + }) +} + +func TestChatsNotifyAnyway(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "chats", "notify-anyway", + "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + }) +} + func TestChatsSearch(t *testing.T) { t.Run("regular flags", func(t *testing.T) { mocktest.TestRunMockTestWithFlags( @@ -97,8 +230,9 @@ func TestChatsSearch(t *testing.T) { "--access-token", "string", "chats", "search", "--max-items", "10", + "--account-id", "matrix", + "--account-id", "discordgo", "--account-id", "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", - "--account-id", "local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI", "--cursor", "1725489123456|c29tZUltc2dQYWdl", "--direction", "before", "--inbox", "primary", @@ -115,7 +249,6 @@ func TestChatsSearch(t *testing.T) { } func TestChatsStart(t *testing.T) { - t.Skip("Stainless mock tests currently load the project-published OpenAPI spec URL, which may not include newly-added local-only endpoints during build checks.") t.Run("regular flags", func(t *testing.T) { mocktest.TestRunMockTestWithFlags( t, diff --git a/pkg/cmd/chatmessagereaction.go b/pkg/cmd/chatmessagereaction.go index f96f915..681f963 100644 --- a/pkg/cmd/chatmessagereaction.go +++ b/pkg/cmd/chatmessagereaction.go @@ -21,20 +21,21 @@ var chatsMessagesReactionsDelete = cli.Command{ Flags: []cli.Flag{ &requestflag.Flag[string]{ Name: "chat-id", - Usage: "Unique identifier of the chat.", + Usage: "Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available.", Required: true, PathParam: "chatID", }, &requestflag.Flag[string]{ Name: "message-id", + Usage: "Message ID.", Required: true, PathParam: "messageID", }, &requestflag.Flag[string]{ Name: "reaction-key", - Usage: "Reaction key to remove", + Usage: "Reaction key to remove (emoji, shortcode, or custom emoji key)", Required: true, - QueryPath: "reactionKey", + PathParam: "reactionKey", }, }, Action: handleChatsMessagesReactionsDelete, @@ -48,12 +49,13 @@ var chatsMessagesReactionsAdd = cli.Command{ Flags: []cli.Flag{ &requestflag.Flag[string]{ Name: "chat-id", - Usage: "Unique identifier of the chat.", + Usage: "Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available.", Required: true, PathParam: "chatID", }, &requestflag.Flag[string]{ Name: "message-id", + Usage: "Message ID.", Required: true, PathParam: "messageID", }, @@ -65,7 +67,7 @@ var chatsMessagesReactionsAdd = cli.Command{ }, &requestflag.Flag[string]{ Name: "transaction-id", - Usage: "Optional transaction ID for deduplication and local echo tracking", + Usage: "Optional transaction ID for deduplication and send tracking", BodyPath: "transactionID", }, }, @@ -76,8 +78,8 @@ var chatsMessagesReactionsAdd = cli.Command{ func handleChatsMessagesReactionsDelete(ctx context.Context, cmd *cli.Command) error { client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("message-id") && len(unusedArgs) > 0 { - cmd.Set("message-id", unusedArgs[0]) + if !cmd.IsSet("reaction-key") && len(unusedArgs) > 0 { + cmd.Set("reaction-key", unusedArgs[0]) unusedArgs = unusedArgs[1:] } if len(unusedArgs) > 0 { @@ -96,14 +98,15 @@ func handleChatsMessagesReactionsDelete(ctx context.Context, cmd *cli.Command) e } params := beeperdesktopapi.ChatMessageReactionDeleteParams{ - ChatID: cmd.Value("chat-id").(string), + ChatID: cmd.Value("chat-id").(string), + MessageID: cmd.Value("message-id").(string), } var res []byte options = append(options, option.WithResponseBodyInto(&res)) _, err = client.Chats.Messages.Reactions.Delete( ctx, - cmd.Value("message-id").(string), + cmd.Value("reaction-key").(string), params, options..., ) diff --git a/pkg/cmd/chatmessagereaction_test.go b/pkg/cmd/chatmessagereaction_test.go index 74168cd..16a0157 100644 --- a/pkg/cmd/chatmessagereaction_test.go +++ b/pkg/cmd/chatmessagereaction_test.go @@ -15,7 +15,7 @@ func TestChatsMessagesReactionsDelete(t *testing.T) { "--access-token", "string", "chats:messages:reactions", "delete", "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", - "--message-id", "messageID", + "--message-id", "1343993", "--reaction-key", "x", ) }) @@ -28,7 +28,7 @@ func TestChatsMessagesReactionsAdd(t *testing.T) { "--access-token", "string", "chats:messages:reactions", "add", "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", - "--message-id", "messageID", + "--message-id", "1343993", "--reaction-key", "x", "--transaction-id", "transactionID", ) @@ -44,7 +44,7 @@ func TestChatsMessagesReactionsAdd(t *testing.T) { "--access-token", "string", "chats:messages:reactions", "add", "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", - "--message-id", "messageID", + "--message-id", "1343993", ) }) } diff --git a/pkg/cmd/chatreminder.go b/pkg/cmd/chatreminder.go index acccdaf..8afb724 100644 --- a/pkg/cmd/chatreminder.go +++ b/pkg/cmd/chatreminder.go @@ -19,7 +19,7 @@ var chatsRemindersCreate = requestflag.WithInnerFlags(cli.Command{ Flags: []cli.Flag{ &requestflag.Flag[string]{ Name: "chat-id", - Usage: "Unique identifier of the chat.", + Usage: "Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available.", Required: true, PathParam: "chatID", }, @@ -34,10 +34,10 @@ var chatsRemindersCreate = requestflag.WithInnerFlags(cli.Command{ HideHelpCommand: true, }, map[string][]requestflag.HasOuterFlag{ "reminder": { - &requestflag.InnerFlag[float64]{ - Name: "reminder.remind-at-ms", - Usage: "Unix timestamp in milliseconds when reminder should trigger", - InnerField: "remindAtMs", + &requestflag.InnerFlag[any]{ + Name: "reminder.remind-at", + Usage: "Timestamp when the reminder should trigger.", + InnerField: "remindAt", }, &requestflag.InnerFlag[bool]{ Name: "reminder.dismiss-on-incoming-message", @@ -54,7 +54,7 @@ var chatsRemindersDelete = cli.Command{ Flags: []cli.Flag{ &requestflag.Flag[string]{ Name: "chat-id", - Usage: "Unique identifier of the chat.", + Usage: "Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available.", Required: true, PathParam: "chatID", }, diff --git a/pkg/cmd/chatreminder_test.go b/pkg/cmd/chatreminder_test.go index 84f08fb..9ef55ab 100644 --- a/pkg/cmd/chatreminder_test.go +++ b/pkg/cmd/chatreminder_test.go @@ -16,7 +16,7 @@ func TestChatsRemindersCreate(t *testing.T) { "--access-token", "string", "chats:reminders", "create", "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", - "--reminder", "{remindAtMs: 0, dismissOnIncomingMessage: true}", + "--reminder", "{remindAt: '2025-08-31T23:30:12.520Z', dismissOnIncomingMessage: true}", ) }) @@ -30,7 +30,7 @@ func TestChatsRemindersCreate(t *testing.T) { "--access-token", "string", "chats:reminders", "create", "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", - "--reminder.remind-at-ms", "0", + "--reminder.remind-at", "2025-08-31T23:30:12.520Z", "--reminder.dismiss-on-incoming-message=true", ) }) @@ -39,7 +39,7 @@ func TestChatsRemindersCreate(t *testing.T) { // Test piping YAML data over stdin pipeData := []byte("" + "reminder:\n" + - " remindAtMs: 0\n" + + " remindAt: '2025-08-31T23:30:12.520Z'\n" + " dismissOnIncomingMessage: true\n") mocktest.TestRunMockTestWithPipeAndFlags( t, pipeData, diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index 1298235..1304eaa 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -111,8 +111,12 @@ func init() { Commands: []*cli.Command{ &chatsCreate, &chatsRetrieve, + &chatsUpdate, &chatsList, &chatsArchive, + &chatsMarkRead, + &chatsMarkUnread, + &chatsNotifyAnyway, &chatsSearch, &chatsStart, }, @@ -140,8 +144,10 @@ func init() { Category: "API RESOURCE", Suggest: true, Commands: []*cli.Command{ + &messagesRetrieve, &messagesUpdate, &messagesList, + &messagesDelete, &messagesSearch, &messagesSend, }, diff --git a/pkg/cmd/info.go b/pkg/cmd/info.go index 57fdd22..afa3591 100644 --- a/pkg/cmd/info.go +++ b/pkg/cmd/info.go @@ -15,7 +15,7 @@ import ( var infoRetrieve = cli.Command{ Name: "retrieve", - Usage: "Returns app, platform, server, and endpoint discovery metadata for this Beeper\nDesktop instance.", + Usage: "Returns app, platform, server, endpoint discovery, OAuth, and WebSocket metadata\nfor this Beeper Desktop instance.", Suggest: true, Flags: []cli.Flag{}, Action: handleInfoRetrieve, diff --git a/pkg/cmd/message.go b/pkg/cmd/message.go index ff1413f..57d143c 100644 --- a/pkg/cmd/message.go +++ b/pkg/cmd/message.go @@ -14,6 +14,28 @@ import ( "github.com/urfave/cli/v3" ) +var messagesRetrieve = cli.Command{ + Name: "retrieve", + Usage: "Retrieve a message by final message ID, pendingMessageID, or Matrix event ID.\nChat ID may be a Beeper chat ID or local chat ID.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "chat-id", + Usage: "Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available.", + Required: true, + PathParam: "chatID", + }, + &requestflag.Flag[string]{ + Name: "message-id", + Usage: "Message ID.", + Required: true, + PathParam: "messageID", + }, + }, + Action: handleMessagesRetrieve, + HideHelpCommand: true, +} + var messagesUpdate = cli.Command{ Name: "update", Usage: "Edit the text content of an existing message. Messages with attachments cannot\nbe edited.", @@ -21,12 +43,13 @@ var messagesUpdate = cli.Command{ Flags: []cli.Flag{ &requestflag.Flag[string]{ Name: "chat-id", - Usage: "Unique identifier of the chat.", + Usage: "Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available.", Required: true, PathParam: "chatID", }, &requestflag.Flag[string]{ Name: "message-id", + Usage: "Message ID.", Required: true, PathParam: "messageID", }, @@ -48,7 +71,7 @@ var messagesList = cli.Command{ Flags: []cli.Flag{ &requestflag.Flag[string]{ Name: "chat-id", - Usage: "Unique identifier of the chat.", + Usage: "Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available.", Required: true, PathParam: "chatID", }, @@ -71,6 +94,34 @@ var messagesList = cli.Command{ HideHelpCommand: true, } +var messagesDelete = cli.Command{ + Name: "delete", + Usage: "Delete a message by final message ID. Pending message IDs are not accepted\nbecause messages cannot be deleted while sending.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "chat-id", + Usage: "Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available.", + Required: true, + PathParam: "chatID", + }, + &requestflag.Flag[string]{ + Name: "message-id", + Usage: "Message ID.", + Required: true, + PathParam: "messageID", + }, + &requestflag.Flag[*bool]{ + Name: "for-everyone", + Usage: "True to request deletion for everyone when the network supports it; false to delete only for the authenticated user when supported.", + Default: requestflag.Ptr[bool](true), + QueryPath: "forEveryone", + }, + }, + Action: handleMessagesDelete, + HideHelpCommand: true, +} + var messagesSearch = cli.Command{ Name: "search", Usage: "Search messages across chats.", @@ -160,7 +211,7 @@ var messagesSend = requestflag.WithInnerFlags(cli.Command{ Flags: []cli.Flag{ &requestflag.Flag[string]{ Name: "chat-id", - Usage: "Unique identifier of the chat.", + Usage: "Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available.", Required: true, PathParam: "chatID", }, @@ -176,7 +227,7 @@ var messagesSend = requestflag.WithInnerFlags(cli.Command{ }, &requestflag.Flag[string]{ Name: "text", - Usage: "Text content of the message you want to send. You may use markdown.", + Usage: "Draft text. Plain text and Markdown are converted to Matrix HTML with the same rules used by send and edit.", BodyPath: "text", }, }, @@ -211,12 +262,66 @@ var messagesSend = requestflag.WithInnerFlags(cli.Command{ }, &requestflag.InnerFlag[string]{ Name: "attachment.type", - Usage: "Special attachment type (gif, voiceNote, sticker). If omitted, auto-detected from mimeType", + Usage: "Attachment type hint (image, video, audio, file, gif, voice-note, sticker). If omitted, auto-detected from mimeType", InnerField: "type", }, }, }) +func handleMessagesRetrieve(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("message-id") && len(unusedArgs) > 0 { + cmd.Set("message-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + EmptyBody, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.MessageGetParams{ + ChatID: cmd.Value("chat-id").(string), + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Messages.Get( + ctx, + cmd.Value("message-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := "json" + explicitFormat := cmd.Root().IsSet("format") + if explicitFormat { + format = cmd.Root().String("format") + } + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "messages retrieve", + Transform: transform, + }) +} + func handleMessagesUpdate(ctx context.Context, cmd *cli.Command) error { client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) unusedArgs := cmd.Args().Slice() @@ -339,6 +444,40 @@ func handleMessagesList(ctx context.Context, cmd *cli.Command) error { } } +func handleMessagesDelete(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("message-id") && len(unusedArgs) > 0 { + cmd.Set("message-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + EmptyBody, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.MessageDeleteParams{ + ChatID: cmd.Value("chat-id").(string), + } + + return client.Messages.Delete( + ctx, + cmd.Value("message-id").(string), + params, + options..., + ) +} + func handleMessagesSearch(ctx context.Context, cmd *cli.Command) error { client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) unusedArgs := cmd.Args().Slice() diff --git a/pkg/cmd/message_test.go b/pkg/cmd/message_test.go index 11fe9a2..913046b 100644 --- a/pkg/cmd/message_test.go +++ b/pkg/cmd/message_test.go @@ -9,6 +9,18 @@ import ( "github.com/beeper/desktop-api-cli/internal/requestflag" ) +func TestMessagesRetrieve(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "messages", "retrieve", + "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", + "--message-id", "1343993", + ) + }) +} + func TestMessagesUpdate(t *testing.T) { t.Run("regular flags", func(t *testing.T) { mocktest.TestRunMockTestWithFlags( @@ -16,7 +28,7 @@ func TestMessagesUpdate(t *testing.T) { "--access-token", "string", "messages", "update", "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", - "--message-id", "messageID", + "--message-id", "1343993", "--text", "x", ) }) @@ -29,7 +41,7 @@ func TestMessagesUpdate(t *testing.T) { "--access-token", "string", "messages", "update", "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", - "--message-id", "messageID", + "--message-id", "1343993", ) }) } @@ -48,6 +60,19 @@ func TestMessagesList(t *testing.T) { }) } +func TestMessagesDelete(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "messages", "delete", + "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", + "--message-id", "1343993", + "--for-everyone=true", + ) + }) +} + func TestMessagesSearch(t *testing.T) { t.Run("regular flags", func(t *testing.T) { mocktest.TestRunMockTestWithFlags( @@ -55,8 +80,9 @@ func TestMessagesSearch(t *testing.T) { "--access-token", "string", "messages", "search", "--max-items", "10", + "--account-id", "matrix", + "--account-id", "discordgo", "--account-id", "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", - "--account-id", "local-instagram_ba_eRfQMmnSNy_p7Ih7HL7RduRpKFU", "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", "--chat-id", "1231073", "--chat-type", "group", @@ -81,7 +107,7 @@ func TestMessagesSend(t *testing.T) { "--access-token", "string", "messages", "send", "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", - "--attachment", "{uploadID: uploadID, duration: 0, fileName: fileName, mimeType: mimeType, size: {height: 0, width: 0}, type: gif}", + "--attachment", "{uploadID: uploadID, duration: 0, fileName: fileName, mimeType: mimeType, size: {height: 0, width: 0}, type: image}", "--reply-to-message-id", "replyToMessageID", "--text", "text", ) @@ -102,7 +128,7 @@ func TestMessagesSend(t *testing.T) { "--attachment.file-name", "fileName", "--attachment.mime-type", "mimeType", "--attachment.size", "{height: 0, width: 0}", - "--attachment.type", "gif", + "--attachment.type", "image", "--reply-to-message-id", "replyToMessageID", "--text", "text", ) @@ -119,7 +145,7 @@ func TestMessagesSend(t *testing.T) { " size:\n" + " height: 0\n" + " width: 0\n" + - " type: gif\n" + + " type: image\n" + "replyToMessageID: replyToMessageID\n" + "text: text\n") mocktest.TestRunMockTestWithPipeAndFlags( From ec112ec91ee336d835177ab87406e72ad585966e Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 7 May 2026 03:18:24 +0000 Subject: [PATCH 47/52] chore(internal): codegen related update --- .github/workflows/ci.yml | 8 ++++---- cmd/beeper-desktop-cli/main.go | 2 +- go.mod | 2 +- go.sum | 4 ++-- pkg/cmd/account.go | 4 ++-- pkg/cmd/accountcontact.go | 4 ++-- pkg/cmd/asset.go | 4 ++-- pkg/cmd/beeperdesktopapi.go | 4 ++-- pkg/cmd/chat.go | 4 ++-- pkg/cmd/chatmessagereaction.go | 4 ++-- pkg/cmd/chatreminder.go | 2 +- pkg/cmd/cmdutil.go | 2 +- pkg/cmd/flagoptions.go | 2 +- pkg/cmd/info.go | 4 ++-- pkg/cmd/message.go | 4 ++-- scripts/build | 2 +- scripts/link | 4 ++-- scripts/lint | 2 +- scripts/run | 2 +- scripts/test | 2 +- scripts/unlink | 2 +- 21 files changed, 34 insertions(+), 34 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 940785a..a9d361d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ on: - 'stl-preview-base/**' env: - GOPRIVATE: github.com/beeper/desktop-api-go,github.com/stainless-sdks/beeper-desktop-api-go + GOPRIVATE: github.com/beeper/desktop-api-go/v5,github.com/stainless-sdks/beeper-desktop-api-go/v5 jobs: lint: @@ -34,7 +34,7 @@ jobs: - name: Link staging branch if: github.repository == 'stainless-sdks/beeper-desktop-api-cli' run: | - ./scripts/link 'github.com/stainless-sdks/beeper-desktop-api-go@${{ github.ref_name }}' || true + ./scripts/link 'github.com/stainless-sdks/beeper-desktop-api-go/v5@${{ github.ref_name }}' || true - name: Bootstrap run: ./scripts/bootstrap @@ -60,7 +60,7 @@ jobs: - name: Link staging branch if: github.repository == 'stainless-sdks/beeper-desktop-api-cli' run: | - ./scripts/link 'github.com/stainless-sdks/beeper-desktop-api-go@${{ github.ref_name }}' || true + ./scripts/link 'github.com/stainless-sdks/beeper-desktop-api-go/v5@${{ github.ref_name }}' || true - name: Bootstrap run: ./scripts/bootstrap @@ -107,7 +107,7 @@ jobs: - name: Link staging branch if: github.repository == 'stainless-sdks/beeper-desktop-api-cli' run: | - ./scripts/link 'github.com/stainless-sdks/beeper-desktop-api-go@${{ github.ref_name }}' || true + ./scripts/link 'github.com/stainless-sdks/beeper-desktop-api-go/v5@${{ github.ref_name }}' || true - name: Bootstrap run: ./scripts/bootstrap diff --git a/cmd/beeper-desktop-cli/main.go b/cmd/beeper-desktop-cli/main.go index 8640e9d..189bd39 100644 --- a/cmd/beeper-desktop-cli/main.go +++ b/cmd/beeper-desktop-cli/main.go @@ -11,7 +11,7 @@ import ( "slices" "github.com/beeper/desktop-api-cli/pkg/cmd" - "github.com/beeper/desktop-api-go" + "github.com/beeper/desktop-api-go/v5" "github.com/tidwall/gjson" "github.com/urfave/cli/v3" ) diff --git a/go.mod b/go.mod index 4d42343..f23b9d7 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/beeper/desktop-api-cli go 1.25 require ( - github.com/beeper/desktop-api-go v0.5.0 + github.com/beeper/desktop-api-go/v5 v5.0.0 github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbletea v1.3.6 github.com/charmbracelet/lipgloss v1.1.0 diff --git a/go.sum b/go.sum index c6fed8f..57a38af 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= -github.com/beeper/desktop-api-go v0.5.0 h1:0Myrz8eop5dC3/QseUrbYVIyWkHPGLyU47/lffw/kT4= -github.com/beeper/desktop-api-go v0.5.0/go.mod h1:y9Mk83OdQWo6ldLTcPyaUPrwjkmvy/3QkhHqZLhU/mA= +github.com/beeper/desktop-api-go/v5 v5.0.0 h1:kKL/HNGnGS9K3fjM3+f7TTmiA5KeWO3ZusG42AXRAeo= +github.com/beeper/desktop-api-go/v5 v5.0.0/go.mod h1:WUEtzxs0a2AhNYPlLy2Ip3SEXV9w9h1o3M4zz/qXfi0= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= diff --git a/pkg/cmd/account.go b/pkg/cmd/account.go index 382b5fc..284208c 100644 --- a/pkg/cmd/account.go +++ b/pkg/cmd/account.go @@ -7,8 +7,8 @@ import ( "fmt" "github.com/beeper/desktop-api-cli/internal/apiquery" - "github.com/beeper/desktop-api-go" - "github.com/beeper/desktop-api-go/option" + "github.com/beeper/desktop-api-go/v5" + "github.com/beeper/desktop-api-go/v5/option" "github.com/tidwall/gjson" "github.com/urfave/cli/v3" ) diff --git a/pkg/cmd/accountcontact.go b/pkg/cmd/accountcontact.go index e626581..40e36b6 100644 --- a/pkg/cmd/accountcontact.go +++ b/pkg/cmd/accountcontact.go @@ -8,8 +8,8 @@ import ( "github.com/beeper/desktop-api-cli/internal/apiquery" "github.com/beeper/desktop-api-cli/internal/requestflag" - "github.com/beeper/desktop-api-go" - "github.com/beeper/desktop-api-go/option" + "github.com/beeper/desktop-api-go/v5" + "github.com/beeper/desktop-api-go/v5/option" "github.com/tidwall/gjson" "github.com/urfave/cli/v3" ) diff --git a/pkg/cmd/asset.go b/pkg/cmd/asset.go index 959b2fb..2222a2c 100644 --- a/pkg/cmd/asset.go +++ b/pkg/cmd/asset.go @@ -9,8 +9,8 @@ import ( "github.com/beeper/desktop-api-cli/internal/apiquery" "github.com/beeper/desktop-api-cli/internal/requestflag" - "github.com/beeper/desktop-api-go" - "github.com/beeper/desktop-api-go/option" + "github.com/beeper/desktop-api-go/v5" + "github.com/beeper/desktop-api-go/v5/option" "github.com/tidwall/gjson" "github.com/urfave/cli/v3" ) diff --git a/pkg/cmd/beeperdesktopapi.go b/pkg/cmd/beeperdesktopapi.go index c575710..88d2583 100644 --- a/pkg/cmd/beeperdesktopapi.go +++ b/pkg/cmd/beeperdesktopapi.go @@ -8,8 +8,8 @@ import ( "github.com/beeper/desktop-api-cli/internal/apiquery" "github.com/beeper/desktop-api-cli/internal/requestflag" - "github.com/beeper/desktop-api-go" - "github.com/beeper/desktop-api-go/option" + "github.com/beeper/desktop-api-go/v5" + "github.com/beeper/desktop-api-go/v5/option" "github.com/tidwall/gjson" "github.com/urfave/cli/v3" ) diff --git a/pkg/cmd/chat.go b/pkg/cmd/chat.go index 7b73175..4b22dc0 100644 --- a/pkg/cmd/chat.go +++ b/pkg/cmd/chat.go @@ -8,8 +8,8 @@ import ( "github.com/beeper/desktop-api-cli/internal/apiquery" "github.com/beeper/desktop-api-cli/internal/requestflag" - "github.com/beeper/desktop-api-go" - "github.com/beeper/desktop-api-go/option" + "github.com/beeper/desktop-api-go/v5" + "github.com/beeper/desktop-api-go/v5/option" "github.com/tidwall/gjson" "github.com/urfave/cli/v3" ) diff --git a/pkg/cmd/chatmessagereaction.go b/pkg/cmd/chatmessagereaction.go index 681f963..e1976b9 100644 --- a/pkg/cmd/chatmessagereaction.go +++ b/pkg/cmd/chatmessagereaction.go @@ -8,8 +8,8 @@ import ( "github.com/beeper/desktop-api-cli/internal/apiquery" "github.com/beeper/desktop-api-cli/internal/requestflag" - "github.com/beeper/desktop-api-go" - "github.com/beeper/desktop-api-go/option" + "github.com/beeper/desktop-api-go/v5" + "github.com/beeper/desktop-api-go/v5/option" "github.com/tidwall/gjson" "github.com/urfave/cli/v3" ) diff --git a/pkg/cmd/chatreminder.go b/pkg/cmd/chatreminder.go index 8afb724..535ee8a 100644 --- a/pkg/cmd/chatreminder.go +++ b/pkg/cmd/chatreminder.go @@ -8,7 +8,7 @@ import ( "github.com/beeper/desktop-api-cli/internal/apiquery" "github.com/beeper/desktop-api-cli/internal/requestflag" - "github.com/beeper/desktop-api-go" + "github.com/beeper/desktop-api-go/v5" "github.com/urfave/cli/v3" ) diff --git a/pkg/cmd/cmdutil.go b/pkg/cmd/cmdutil.go index c98055a..f36172c 100644 --- a/pkg/cmd/cmdutil.go +++ b/pkg/cmd/cmdutil.go @@ -17,7 +17,7 @@ import ( "syscall" "github.com/beeper/desktop-api-cli/internal/jsonview" - "github.com/beeper/desktop-api-go/option" + "github.com/beeper/desktop-api-go/v5/option" "github.com/charmbracelet/x/term" "github.com/itchyny/json2yaml" diff --git a/pkg/cmd/flagoptions.go b/pkg/cmd/flagoptions.go index 9452fd0..2d9c0a5 100644 --- a/pkg/cmd/flagoptions.go +++ b/pkg/cmd/flagoptions.go @@ -20,7 +20,7 @@ import ( "github.com/beeper/desktop-api-cli/internal/apiquery" "github.com/beeper/desktop-api-cli/internal/debugmiddleware" "github.com/beeper/desktop-api-cli/internal/requestflag" - "github.com/beeper/desktop-api-go/option" + "github.com/beeper/desktop-api-go/v5/option" "github.com/goccy/go-yaml" "github.com/urfave/cli/v3" diff --git a/pkg/cmd/info.go b/pkg/cmd/info.go index afa3591..fa4ea4d 100644 --- a/pkg/cmd/info.go +++ b/pkg/cmd/info.go @@ -7,8 +7,8 @@ import ( "fmt" "github.com/beeper/desktop-api-cli/internal/apiquery" - "github.com/beeper/desktop-api-go" - "github.com/beeper/desktop-api-go/option" + "github.com/beeper/desktop-api-go/v5" + "github.com/beeper/desktop-api-go/v5/option" "github.com/tidwall/gjson" "github.com/urfave/cli/v3" ) diff --git a/pkg/cmd/message.go b/pkg/cmd/message.go index 57d143c..3b9b74e 100644 --- a/pkg/cmd/message.go +++ b/pkg/cmd/message.go @@ -8,8 +8,8 @@ import ( "github.com/beeper/desktop-api-cli/internal/apiquery" "github.com/beeper/desktop-api-cli/internal/requestflag" - "github.com/beeper/desktop-api-go" - "github.com/beeper/desktop-api-go/option" + "github.com/beeper/desktop-api-go/v5" + "github.com/beeper/desktop-api-go/v5/option" "github.com/tidwall/gjson" "github.com/urfave/cli/v3" ) diff --git a/scripts/build b/scripts/build index 7eb0308..d267577 100755 --- a/scripts/build +++ b/scripts/build @@ -5,7 +5,7 @@ set -euo pipefail cd "$(dirname "$0")/.." # Mark the necessary Go modules as private to avoid Go's proxy -export GOPRIVATE="${GOPRIVATE:+$GOPRIVATE,}github.com/beeper/desktop-api-go,github.com/stainless-sdks/beeper-desktop-api-go" +export GOPRIVATE="${GOPRIVATE:+$GOPRIVATE,}github.com/beeper/desktop-api-go/v5,github.com/stainless-sdks/beeper-desktop-api-go/v5" echo "==> Building beeper-desktop-cli" go build ./cmd/beeper-desktop-cli diff --git a/scripts/link b/scripts/link index 332584c..60c6042 100755 --- a/scripts/link +++ b/scripts/link @@ -5,12 +5,12 @@ set -euo pipefail cd "$(dirname "$0")/.." # Mark the necessary Go modules as private to avoid Go's proxy -export GOPRIVATE="${GOPRIVATE:+$GOPRIVATE,}github.com/beeper/desktop-api-go,github.com/stainless-sdks/beeper-desktop-api-go" +export GOPRIVATE="${GOPRIVATE:+$GOPRIVATE,}github.com/beeper/desktop-api-go/v5,github.com/stainless-sdks/beeper-desktop-api-go/v5" REPLACEMENT="${1:-"../beeperdesktop-go"}" echo "==> Replacing Go SDK with $REPLACEMENT" if [[ -d "$REPLACEMENT" ]] || go list -m "$REPLACEMENT" >/dev/null; then - go mod edit -replace github.com/beeper/desktop-api-go="$REPLACEMENT" + go mod edit -replace github.com/beeper/desktop-api-go/v5="$REPLACEMENT" go mod tidy -e else echo "Skipping Go SDK replacement (branch may not exist on Go SDK)" diff --git a/scripts/lint b/scripts/lint index bfcb7b6..8a18b1d 100755 --- a/scripts/lint +++ b/scripts/lint @@ -5,7 +5,7 @@ set -euo pipefail cd "$(dirname "$0")/.." # Mark the necessary Go modules as private to avoid Go's proxy -export GOPRIVATE="${GOPRIVATE:+$GOPRIVATE,}github.com/beeper/desktop-api-go,github.com/stainless-sdks/beeper-desktop-api-go" +export GOPRIVATE="${GOPRIVATE:+$GOPRIVATE,}github.com/beeper/desktop-api-go/v5,github.com/stainless-sdks/beeper-desktop-api-go/v5" echo "==> Running Go build" go build ./... diff --git a/scripts/run b/scripts/run index 5cfec5f..cf54869 100755 --- a/scripts/run +++ b/scripts/run @@ -5,6 +5,6 @@ set -euo pipefail cd "$(dirname "$0")/.." # Mark the necessary Go modules as private to avoid Go's proxy -export GOPRIVATE="${GOPRIVATE:+$GOPRIVATE,}github.com/beeper/desktop-api-go,github.com/stainless-sdks/beeper-desktop-api-go" +export GOPRIVATE="${GOPRIVATE:+$GOPRIVATE,}github.com/beeper/desktop-api-go/v5,github.com/stainless-sdks/beeper-desktop-api-go/v5" go run ./cmd/beeper-desktop-cli "$@" diff --git a/scripts/test b/scripts/test index e2baeca..95e0bde 100755 --- a/scripts/test +++ b/scripts/test @@ -5,7 +5,7 @@ set -euo pipefail cd "$(dirname "$0")/.." # Mark the necessary Go modules as private to avoid Go's proxy -export GOPRIVATE="${GOPRIVATE:+$GOPRIVATE,}github.com/beeper/desktop-api-go,github.com/stainless-sdks/beeper-desktop-api-go" +export GOPRIVATE="${GOPRIVATE:+$GOPRIVATE,}github.com/beeper/desktop-api-go/v5,github.com/stainless-sdks/beeper-desktop-api-go/v5" RED='\033[0;31m' GREEN='\033[0;32m' diff --git a/scripts/unlink b/scripts/unlink index dfb62ae..9ba3798 100755 --- a/scripts/unlink +++ b/scripts/unlink @@ -5,4 +5,4 @@ set -e cd "$(dirname "$0")/.." echo "==> Unlinking with local directory" -go mod edit -dropreplace github.com/beeper/desktop-api-go +go mod edit -dropreplace github.com/beeper/desktop-api-go/v5 From 3b786e24f0f0b206fc0817fa1fc80fa3b084346a Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 8 May 2026 03:42:53 +0000 Subject: [PATCH 48/52] chore: redact api-key headers in debug logs --- internal/debugmiddleware/debug_middleware.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/debugmiddleware/debug_middleware.go b/internal/debugmiddleware/debug_middleware.go index f07b93b..647f1de 100644 --- a/internal/debugmiddleware/debug_middleware.go +++ b/internal/debugmiddleware/debug_middleware.go @@ -21,7 +21,12 @@ const redactedPlaceholder = "" // Headers known to contain sensitive information like an API key. Note that this exclude `Authorization`, // which is handled specially in `redactRequest` below. -var sensitiveHeaders = []string{} +var sensitiveHeaders = []string{ + "api-key", + "x-api-key", + "cookie", + "set-cookie", +} // RequestLogger is a middleware that logs HTTP requests and responses. type RequestLogger struct { From 7d88c2d5073569f59934a3b1d261e4c404531e0f Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 02:40:49 +0000 Subject: [PATCH 49/52] ci: pin GitHub Actions to commit SHAs Pin all GitHub Actions referenced in generated workflows (both first-party `actions/*` and third-party) to immutable commit SHAs. Updating pinned actions is now a deliberate codegen-side bump rather than implicit on every workflow run. --- .github/actions/setup-go/action.yml | 4 ++-- .github/workflows/ci.yml | 10 +++++----- .github/workflows/publish-release.yml | 6 +++--- .github/workflows/release-doctor.yml | 2 +- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/actions/setup-go/action.yml b/.github/actions/setup-go/action.yml index 480f40f..4210aa6 100644 --- a/.github/actions/setup-go/action.yml +++ b/.github/actions/setup-go/action.yml @@ -7,7 +7,7 @@ inputs: runs: using: composite steps: - - uses: stainless-api/retrieve-github-access-token@v1 + - uses: stainless-api/retrieve-github-access-token@1f03f929b746c5b03dcdafa2bebbb18ca5672e1a # v1.0.0 if: github.repository == 'stainless-sdks/beeper-desktop-api-cli' id: get_token with: @@ -20,7 +20,7 @@ runs: run: git config --global url."https://x-access-token:${{ steps.get_token.outputs.github_access_token }}@github.com/stainless-sdks/beeper-desktop-api-go".insteadOf "https://github.com/stainless-sdks/beeper-desktop-api-go" - name: Setup go - uses: actions/setup-go@v5 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: go-version-file: ./go.mod diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a9d361d..54838a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: 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 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/setup-go with: @@ -51,7 +51,7 @@ jobs: runs-on: ${{ github.repository == 'stainless-sdks/beeper-desktop-api-cli' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} 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 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/setup-go with: @@ -66,7 +66,7 @@ jobs: run: ./scripts/bootstrap - name: Run goreleaser - uses: goreleaser/goreleaser-action@v6.1.0 + uses: goreleaser/goreleaser-action@9ed2f89a662bf1735a48bc8557fd212fa902bebf # v6.1.0 with: version: latest args: release --snapshot --clean --skip=publish @@ -78,7 +78,7 @@ jobs: github.repository == 'stainless-sdks/beeper-desktop-api-cli' && !startsWith(github.ref, 'refs/heads/stl/') id: github-oidc - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: core.setOutput('github_token', await core.getIDToken()); @@ -98,7 +98,7 @@ jobs: runs-on: ${{ github.repository == 'stainless-sdks/beeper-desktop-api-cli' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} if: github.event_name == 'push' || github.event.pull_request.head.repo.fork steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/setup-go with: diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index b207697..058282a 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -16,15 +16,15 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: go-version-file: "go.mod" - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v6.1.0 + uses: goreleaser/goreleaser-action@9ed2f89a662bf1735a48bc8557fd212fa902bebf # v6.1.0 with: version: latest args: release --clean diff --git a/.github/workflows/release-doctor.yml b/.github/workflows/release-doctor.yml index 8f8d88b..bd60cc6 100644 --- a/.github/workflows/release-doctor.yml +++ b/.github/workflows/release-doctor.yml @@ -12,7 +12,7 @@ jobs: if: github.repository == 'beeper/desktop-api-cli' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'release-please') || github.head_ref == 'next') steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Check release environment run: | From 387f5ad6a8e720dc43dfef4a63d52c7cb171003a Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 16 May 2026 19:57:02 +0000 Subject: [PATCH 50/52] Update Desktop API SDKs --- .stats.yml | 8 +- pkg/cmd/app.go | 65 +++ pkg/cmd/app_test.go | 19 + pkg/cmd/appe2eerecoverycode.go | 120 +++++ pkg/cmd/appe2eerecoverycode_test.go | 40 ++ pkg/cmd/appe2eerecoverycodereset.go | 128 +++++ pkg/cmd/appe2eerecoverycodereset_test.go | 51 ++ pkg/cmd/appe2eeverification.go | 204 ++++++++ pkg/cmd/appe2eeverification_test.go | 67 +++ pkg/cmd/appe2eeverificationqr.go | 130 +++++ pkg/cmd/appe2eeverificationqr_test.go | 41 ++ pkg/cmd/appe2eeverificationsa.go | 131 +++++ pkg/cmd/appe2eeverificationsa_test.go | 31 ++ pkg/cmd/applogin.go | 264 ++++++++++ pkg/cmd/applogin_test.go | 95 ++++ pkg/cmd/bridge.go | 65 +++ pkg/cmd/bridge_test.go | 19 + pkg/cmd/cmd.go | 175 +++++++ pkg/cmd/matrixbridgeauth.go | 584 +++++++++++++++++++++++ pkg/cmd/matrixbridgeauth_test.go | 134 ++++++ pkg/cmd/matrixbridgecapability.go | 75 +++ pkg/cmd/matrixbridgecapability_test.go | 20 + pkg/cmd/matrixbridgecontact.go | 87 ++++ pkg/cmd/matrixbridgecontact_test.go | 21 + pkg/cmd/matrixbridgeroom.go | 240 ++++++++++ pkg/cmd/matrixbridgeroom_test.go | 98 ++++ pkg/cmd/matrixbridgeuser.go | 168 +++++++ pkg/cmd/matrixbridgeuser_test.go | 47 ++ pkg/cmd/matrixroom.go | 337 +++++++++++++ pkg/cmd/matrixroom_test.go | 168 +++++++ pkg/cmd/matrixroomaccountdata.go | 177 +++++++ pkg/cmd/matrixroomaccountdata_test.go | 49 ++ pkg/cmd/matrixroomevent.go | 89 ++++ pkg/cmd/matrixroomevent_test.go | 21 + pkg/cmd/matrixroomstate.go | 160 +++++++ pkg/cmd/matrixroomstate_test.go | 34 ++ pkg/cmd/matrixuser.go | 75 +++ pkg/cmd/matrixuser_test.go | 20 + pkg/cmd/matrixuseraccountdata.go | 165 +++++++ pkg/cmd/matrixuseraccountdata_test.go | 46 ++ 40 files changed, 4464 insertions(+), 4 deletions(-) create mode 100644 pkg/cmd/app.go create mode 100644 pkg/cmd/app_test.go create mode 100644 pkg/cmd/appe2eerecoverycode.go create mode 100644 pkg/cmd/appe2eerecoverycode_test.go create mode 100644 pkg/cmd/appe2eerecoverycodereset.go create mode 100644 pkg/cmd/appe2eerecoverycodereset_test.go create mode 100644 pkg/cmd/appe2eeverification.go create mode 100644 pkg/cmd/appe2eeverification_test.go create mode 100644 pkg/cmd/appe2eeverificationqr.go create mode 100644 pkg/cmd/appe2eeverificationqr_test.go create mode 100644 pkg/cmd/appe2eeverificationsa.go create mode 100644 pkg/cmd/appe2eeverificationsa_test.go create mode 100644 pkg/cmd/applogin.go create mode 100644 pkg/cmd/applogin_test.go create mode 100644 pkg/cmd/bridge.go create mode 100644 pkg/cmd/bridge_test.go create mode 100644 pkg/cmd/matrixbridgeauth.go create mode 100644 pkg/cmd/matrixbridgeauth_test.go create mode 100644 pkg/cmd/matrixbridgecapability.go create mode 100644 pkg/cmd/matrixbridgecapability_test.go create mode 100644 pkg/cmd/matrixbridgecontact.go create mode 100644 pkg/cmd/matrixbridgecontact_test.go create mode 100644 pkg/cmd/matrixbridgeroom.go create mode 100644 pkg/cmd/matrixbridgeroom_test.go create mode 100644 pkg/cmd/matrixbridgeuser.go create mode 100644 pkg/cmd/matrixbridgeuser_test.go create mode 100644 pkg/cmd/matrixroom.go create mode 100644 pkg/cmd/matrixroom_test.go create mode 100644 pkg/cmd/matrixroomaccountdata.go create mode 100644 pkg/cmd/matrixroomaccountdata_test.go create mode 100644 pkg/cmd/matrixroomevent.go create mode 100644 pkg/cmd/matrixroomevent_test.go create mode 100644 pkg/cmd/matrixroomstate.go create mode 100644 pkg/cmd/matrixroomstate_test.go create mode 100644 pkg/cmd/matrixuser.go create mode 100644 pkg/cmd/matrixuser_test.go create mode 100644 pkg/cmd/matrixuseraccountdata.go create mode 100644 pkg/cmd/matrixuseraccountdata_test.go diff --git a/.stats.yml b/.stats.yml index 2dd3fee..b63d20d 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 30 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper/beeper-desktop-api-c08c14bb754b4cb0e02b21fabb680469368286be339dec0aaa8c69d04a1f021a.yml -openapi_spec_hash: a10246aaf7cdc33b682fc245bd5f893b -config_hash: 72f9d43b9b51a5da912e9f3730e53ae2 +configured_endpoints: 72 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper/beeper-desktop-api-de1370e6a3183044fa135a886d2ee8f779d5e86228cdbd503d553b4c13cc7cbe.yml +openapi_spec_hash: 30b435d7585d8b6951610e7147369779 +config_hash: 683b13ea6fb6aa9d6b1b8814cca24f1c diff --git a/pkg/cmd/app.go b/pkg/cmd/app.go new file mode 100644 index 0000000..aa20f30 --- /dev/null +++ b/pkg/cmd/app.go @@ -0,0 +1,65 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "context" + "fmt" + + "github.com/beeper/desktop-api-cli/internal/apiquery" + "github.com/beeper/desktop-api-go/v5" + "github.com/beeper/desktop-api-go/v5/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var appStatus = cli.Command{ + Name: "status", + Usage: "Return the current Beeper Desktop sign-in and encrypted messaging setup state.\nThis endpoint is public before sign-in so apps can discover that login is\nneeded; after sign-in, pass a read token.", + Suggest: true, + Flags: []cli.Flag{}, + Action: handleAppStatus, + HideHelpCommand: true, +} + +func handleAppStatus(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + EmptyBody, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.App.Status(ctx, options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := "json" + explicitFormat := cmd.Root().IsSet("format") + if explicitFormat { + format = cmd.Root().String("format") + } + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "app status", + Transform: transform, + }) +} diff --git a/pkg/cmd/app_test.go b/pkg/cmd/app_test.go new file mode 100644 index 0000000..214d308 --- /dev/null +++ b/pkg/cmd/app_test.go @@ -0,0 +1,19 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "testing" + + "github.com/beeper/desktop-api-cli/internal/mocktest" +) + +func TestAppStatus(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "app", "status", + ) + }) +} diff --git a/pkg/cmd/appe2eerecoverycode.go b/pkg/cmd/appe2eerecoverycode.go new file mode 100644 index 0000000..6279a23 --- /dev/null +++ b/pkg/cmd/appe2eerecoverycode.go @@ -0,0 +1,120 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "context" + "fmt" + + "github.com/beeper/desktop-api-cli/internal/apiquery" + "github.com/beeper/desktop-api-cli/internal/requestflag" + "github.com/beeper/desktop-api-go/v5" + "github.com/beeper/desktop-api-go/v5/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var appE2eeRecoveryCodeMarkBackedUp = cli.Command{ + Name: "mark-backed-up", + Usage: "Record that the user saved their recovery key.", + Suggest: true, + Flags: []cli.Flag{}, + Action: handleAppE2eeRecoveryCodeMarkBackedUp, + HideHelpCommand: true, +} + +var appE2eeRecoveryCodeVerify = cli.Command{ + Name: "verify", + Usage: "Unlock encrypted messages with the user recovery key.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "recovery-code", + Usage: "Recovery key saved by the user.", + Required: true, + BodyPath: "recoveryCode", + }, + }, + Action: handleAppE2eeRecoveryCodeVerify, + HideHelpCommand: true, +} + +func handleAppE2eeRecoveryCodeMarkBackedUp(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + EmptyBody, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.App.E2ee.RecoveryCode.MarkBackedUp(ctx, options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "app:e2ee:recovery-code mark-backed-up", + Transform: transform, + }) +} + +func handleAppE2eeRecoveryCodeVerify(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.AppE2eeRecoveryCodeVerifyParams{} + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.App.E2ee.RecoveryCode.Verify(ctx, params, options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "app:e2ee:recovery-code verify", + Transform: transform, + }) +} diff --git a/pkg/cmd/appe2eerecoverycode_test.go b/pkg/cmd/appe2eerecoverycode_test.go new file mode 100644 index 0000000..c460860 --- /dev/null +++ b/pkg/cmd/appe2eerecoverycode_test.go @@ -0,0 +1,40 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "testing" + + "github.com/beeper/desktop-api-cli/internal/mocktest" +) + +func TestAppE2eeRecoveryCodeMarkBackedUp(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "app:e2ee:recovery-code", "mark-backed-up", + ) + }) +} + +func TestAppE2eeRecoveryCodeVerify(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "app:e2ee:recovery-code", "verify", + "--recovery-code", "x", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("recoveryCode: x") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, + "--access-token", "string", + "app:e2ee:recovery-code", "verify", + ) + }) +} diff --git a/pkg/cmd/appe2eerecoverycodereset.go b/pkg/cmd/appe2eerecoverycodereset.go new file mode 100644 index 0000000..1226ae8 --- /dev/null +++ b/pkg/cmd/appe2eerecoverycodereset.go @@ -0,0 +1,128 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "context" + "fmt" + + "github.com/beeper/desktop-api-cli/internal/apiquery" + "github.com/beeper/desktop-api-cli/internal/requestflag" + "github.com/beeper/desktop-api-go/v5" + "github.com/beeper/desktop-api-go/v5/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var appE2eeRecoveryCodeResetCreate = cli.Command{ + Name: "create", + Usage: "Create a new recovery key when the user cannot use the existing one.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "recovery-code", + Usage: "Existing recovery key, if the user has it.", + BodyPath: "recoveryCode", + }, + }, + Action: handleAppE2eeRecoveryCodeResetCreate, + HideHelpCommand: true, +} + +var appE2eeRecoveryCodeResetConfirm = cli.Command{ + Name: "confirm", + Usage: "Confirm that the new recovery key should be used for this account.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "recovery-code", + Usage: "New recovery key returned by the reset step.", + Required: true, + BodyPath: "recoveryCode", + }, + }, + Action: handleAppE2eeRecoveryCodeResetConfirm, + HideHelpCommand: true, +} + +func handleAppE2eeRecoveryCodeResetCreate(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.AppE2eeRecoveryCodeResetNewParams{} + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.App.E2ee.RecoveryCode.Reset.New(ctx, params, options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "app:e2ee:recovery-code:reset create", + Transform: transform, + }) +} + +func handleAppE2eeRecoveryCodeResetConfirm(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.AppE2eeRecoveryCodeResetConfirmParams{} + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.App.E2ee.RecoveryCode.Reset.Confirm(ctx, params, options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "app:e2ee:recovery-code:reset confirm", + Transform: transform, + }) +} diff --git a/pkg/cmd/appe2eerecoverycodereset_test.go b/pkg/cmd/appe2eerecoverycodereset_test.go new file mode 100644 index 0000000..75f8543 --- /dev/null +++ b/pkg/cmd/appe2eerecoverycodereset_test.go @@ -0,0 +1,51 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "testing" + + "github.com/beeper/desktop-api-cli/internal/mocktest" +) + +func TestAppE2eeRecoveryCodeResetCreate(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "app:e2ee:recovery-code:reset", "create", + "--recovery-code", "recoveryCode", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("recoveryCode: recoveryCode") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, + "--access-token", "string", + "app:e2ee:recovery-code:reset", "create", + ) + }) +} + +func TestAppE2eeRecoveryCodeResetConfirm(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "app:e2ee:recovery-code:reset", "confirm", + "--recovery-code", "x", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("recoveryCode: x") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, + "--access-token", "string", + "app:e2ee:recovery-code:reset", "confirm", + ) + }) +} diff --git a/pkg/cmd/appe2eeverification.go b/pkg/cmd/appe2eeverification.go new file mode 100644 index 0000000..4caecba --- /dev/null +++ b/pkg/cmd/appe2eeverification.go @@ -0,0 +1,204 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "context" + "fmt" + + "github.com/beeper/desktop-api-cli/internal/apiquery" + "github.com/beeper/desktop-api-cli/internal/requestflag" + "github.com/beeper/desktop-api-go/v5" + "github.com/beeper/desktop-api-go/v5/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var appE2eeVerificationCreate = cli.Command{ + Name: "create", + Usage: "Start verifying this device from another signed-in device.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "user-id", + Usage: "User ID to verify. Defaults to the signed-in user.", + BodyPath: "userID", + }, + }, + Action: handleAppE2eeVerificationCreate, + HideHelpCommand: true, +} + +var appE2eeVerificationAccept = cli.Command{ + Name: "accept", + Usage: "Accept an incoming device verification request.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "verification-id", + Usage: "Verification ID.", + Required: true, + PathParam: "verificationID", + }, + }, + Action: handleAppE2eeVerificationAccept, + HideHelpCommand: true, +} + +var appE2eeVerificationCancel = cli.Command{ + Name: "cancel", + Usage: "Cancel an active device verification request.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "verification-id", + Usage: "Verification ID.", + Required: true, + PathParam: "verificationID", + }, + &requestflag.Flag[string]{ + Name: "code", + Usage: "Optional cancellation code.", + BodyPath: "code", + }, + &requestflag.Flag[string]{ + Name: "reason", + Usage: "Optional user-facing cancellation reason.", + BodyPath: "reason", + }, + }, + Action: handleAppE2eeVerificationCancel, + HideHelpCommand: true, +} + +func handleAppE2eeVerificationCreate(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.AppE2eeVerificationNewParams{} + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.App.E2ee.Verification.New(ctx, params, options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "app:e2ee:verification create", + Transform: transform, + }) +} + +func handleAppE2eeVerificationAccept(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("verification-id") && len(unusedArgs) > 0 { + cmd.Set("verification-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + EmptyBody, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.App.E2ee.Verification.Accept(ctx, cmd.Value("verification-id").(string), options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "app:e2ee:verification accept", + Transform: transform, + }) +} + +func handleAppE2eeVerificationCancel(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("verification-id") && len(unusedArgs) > 0 { + cmd.Set("verification-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.AppE2eeVerificationCancelParams{} + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.App.E2ee.Verification.Cancel( + ctx, + cmd.Value("verification-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "app:e2ee:verification cancel", + Transform: transform, + }) +} diff --git a/pkg/cmd/appe2eeverification_test.go b/pkg/cmd/appe2eeverification_test.go new file mode 100644 index 0000000..57130ce --- /dev/null +++ b/pkg/cmd/appe2eeverification_test.go @@ -0,0 +1,67 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "testing" + + "github.com/beeper/desktop-api-cli/internal/mocktest" +) + +func TestAppE2eeVerificationCreate(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "app:e2ee:verification", "create", + "--user-id", "userID", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("userID: userID") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, + "--access-token", "string", + "app:e2ee:verification", "create", + ) + }) +} + +func TestAppE2eeVerificationAccept(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "app:e2ee:verification", "accept", + "--verification-id", "x", + ) + }) +} + +func TestAppE2eeVerificationCancel(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "app:e2ee:verification", "cancel", + "--verification-id", "x", + "--code", "code", + "--reason", "reason", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("" + + "code: code\n" + + "reason: reason\n") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, + "--access-token", "string", + "app:e2ee:verification", "cancel", + "--verification-id", "x", + ) + }) +} diff --git a/pkg/cmd/appe2eeverificationqr.go b/pkg/cmd/appe2eeverificationqr.go new file mode 100644 index 0000000..331aa1b --- /dev/null +++ b/pkg/cmd/appe2eeverificationqr.go @@ -0,0 +1,130 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "context" + "fmt" + + "github.com/beeper/desktop-api-cli/internal/apiquery" + "github.com/beeper/desktop-api-cli/internal/requestflag" + "github.com/beeper/desktop-api-go/v5" + "github.com/beeper/desktop-api-go/v5/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var appE2eeVerificationQrConfirmScanned = cli.Command{ + Name: "confirm-scanned", + Usage: "Confirm that another device scanned this device QR code.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "verification-id", + Usage: "Verification ID.", + Required: true, + PathParam: "verificationID", + }, + }, + Action: handleAppE2eeVerificationQrConfirmScanned, + HideHelpCommand: true, +} + +var appE2eeVerificationQrScan = cli.Command{ + Name: "scan", + Usage: "Submit the QR code scanned from another signed-in device.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "data", + Usage: "QR code payload scanned from the other device.", + Required: true, + BodyPath: "data", + }, + }, + Action: handleAppE2eeVerificationQrScan, + HideHelpCommand: true, +} + +func handleAppE2eeVerificationQrConfirmScanned(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("verification-id") && len(unusedArgs) > 0 { + cmd.Set("verification-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + EmptyBody, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.App.E2ee.Verification.Qr.ConfirmScanned(ctx, cmd.Value("verification-id").(string), options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "app:e2ee:verification:qr confirm-scanned", + Transform: transform, + }) +} + +func handleAppE2eeVerificationQrScan(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.AppE2eeVerificationQrScanParams{} + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.App.E2ee.Verification.Qr.Scan(ctx, params, options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "app:e2ee:verification:qr scan", + Transform: transform, + }) +} diff --git a/pkg/cmd/appe2eeverificationqr_test.go b/pkg/cmd/appe2eeverificationqr_test.go new file mode 100644 index 0000000..635420d --- /dev/null +++ b/pkg/cmd/appe2eeverificationqr_test.go @@ -0,0 +1,41 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "testing" + + "github.com/beeper/desktop-api-cli/internal/mocktest" +) + +func TestAppE2eeVerificationQrConfirmScanned(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "app:e2ee:verification:qr", "confirm-scanned", + "--verification-id", "x", + ) + }) +} + +func TestAppE2eeVerificationQrScan(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "app:e2ee:verification:qr", "scan", + "--data", "x", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("data: x") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, + "--access-token", "string", + "app:e2ee:verification:qr", "scan", + ) + }) +} diff --git a/pkg/cmd/appe2eeverificationsa.go b/pkg/cmd/appe2eeverificationsa.go new file mode 100644 index 0000000..afbda96 --- /dev/null +++ b/pkg/cmd/appe2eeverificationsa.go @@ -0,0 +1,131 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "context" + "fmt" + + "github.com/beeper/desktop-api-cli/internal/apiquery" + "github.com/beeper/desktop-api-cli/internal/requestflag" + "github.com/beeper/desktop-api-go/v5" + "github.com/beeper/desktop-api-go/v5/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var appE2eeVerificationSasConfirm = cli.Command{ + Name: "confirm", + Usage: "Confirm that the emoji or number sequence matches on both devices.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "verification-id", + Usage: "Verification ID.", + Required: true, + PathParam: "verificationID", + }, + }, + Action: handleAppE2eeVerificationSasConfirm, + HideHelpCommand: true, +} + +var appE2eeVerificationSasStart = cli.Command{ + Name: "start", + Usage: "Start emoji comparison for device verification.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "verification-id", + Usage: "Verification ID.", + Required: true, + PathParam: "verificationID", + }, + }, + Action: handleAppE2eeVerificationSasStart, + HideHelpCommand: true, +} + +func handleAppE2eeVerificationSasConfirm(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("verification-id") && len(unusedArgs) > 0 { + cmd.Set("verification-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + EmptyBody, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.App.E2ee.Verification.Sas.Confirm(ctx, cmd.Value("verification-id").(string), options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "app:e2ee:verification:sas confirm", + Transform: transform, + }) +} + +func handleAppE2eeVerificationSasStart(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("verification-id") && len(unusedArgs) > 0 { + cmd.Set("verification-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + EmptyBody, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.App.E2ee.Verification.Sas.Start(ctx, cmd.Value("verification-id").(string), options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "app:e2ee:verification:sas start", + Transform: transform, + }) +} diff --git a/pkg/cmd/appe2eeverificationsa_test.go b/pkg/cmd/appe2eeverificationsa_test.go new file mode 100644 index 0000000..526a653 --- /dev/null +++ b/pkg/cmd/appe2eeverificationsa_test.go @@ -0,0 +1,31 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "testing" + + "github.com/beeper/desktop-api-cli/internal/mocktest" +) + +func TestAppE2eeVerificationSasConfirm(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "app:e2ee:verification:sas", "confirm", + "--verification-id", "x", + ) + }) +} + +func TestAppE2eeVerificationSasStart(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "app:e2ee:verification:sas", "start", + "--verification-id", "x", + ) + }) +} diff --git a/pkg/cmd/applogin.go b/pkg/cmd/applogin.go new file mode 100644 index 0000000..2c83b7f --- /dev/null +++ b/pkg/cmd/applogin.go @@ -0,0 +1,264 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "context" + "fmt" + + "github.com/beeper/desktop-api-cli/internal/apiquery" + "github.com/beeper/desktop-api-cli/internal/requestflag" + "github.com/beeper/desktop-api-go/v5" + "github.com/beeper/desktop-api-go/v5/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var appLoginEmail = cli.Command{ + Name: "email", + Usage: "Send a sign-in code to the user email address.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "email", + Usage: "Email address to send the sign-in code to.", + Required: true, + BodyPath: "email", + }, + &requestflag.Flag[string]{ + Name: "request", + Usage: "Login request ID returned by the start step.", + Required: true, + BodyPath: "request", + }, + }, + Action: handleAppLoginEmail, + HideHelpCommand: true, +} + +var appLoginRegister = cli.Command{ + Name: "register", + Usage: "Create a Beeper account after the user chooses a username and accepts the Terms\nof Use.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[bool]{ + Name: "accept-terms", + Usage: "Confirms that the user accepted the Terms of Use and acknowledged the Privacy Policy.", + Required: true, + BodyPath: "acceptTerms", + }, + &requestflag.Flag[string]{ + Name: "lead-token", + Usage: "Registration token returned by Beeper.", + Required: true, + BodyPath: "leadToken", + }, + &requestflag.Flag[string]{ + Name: "request", + Usage: "Login request ID returned by the start step.", + Required: true, + BodyPath: "request", + }, + &requestflag.Flag[string]{ + Name: "username", + Usage: "Username selected by the user.", + Required: true, + BodyPath: "username", + }, + }, + Action: handleAppLoginRegister, + HideHelpCommand: true, +} + +var appLoginResponse = cli.Command{ + Name: "response", + Usage: "Finish sign-in with the code sent to the user email address. If the user needs a\nnew account, the response includes account creation copy and username\nsuggestions.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "request", + Usage: "Login request ID returned by the start step.", + Required: true, + BodyPath: "request", + }, + &requestflag.Flag[string]{ + Name: "response", + Usage: "Sign-in code from the user email.", + Required: true, + BodyPath: "response", + }, + }, + Action: handleAppLoginResponse, + HideHelpCommand: true, +} + +var appLoginStart = cli.Command{ + Name: "start", + Usage: "Start a first-party Beeper Desktop sign-in session.", + Suggest: true, + Flags: []cli.Flag{}, + Action: handleAppLoginStart, + HideHelpCommand: true, +} + +func handleAppLoginEmail(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.AppLoginEmailParams{} + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.App.Login.Email(ctx, params, options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "app:login email", + Transform: transform, + }) +} + +func handleAppLoginRegister(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.AppLoginRegisterParams{} + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.App.Login.Register(ctx, params, options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "app:login register", + Transform: transform, + }) +} + +func handleAppLoginResponse(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.AppLoginResponseParams{} + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.App.Login.Response(ctx, params, options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "app:login response", + Transform: transform, + }) +} + +func handleAppLoginStart(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + EmptyBody, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.App.Login.Start(ctx, options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "app:login start", + Transform: transform, + }) +} diff --git a/pkg/cmd/applogin_test.go b/pkg/cmd/applogin_test.go new file mode 100644 index 0000000..b4f866a --- /dev/null +++ b/pkg/cmd/applogin_test.go @@ -0,0 +1,95 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "testing" + + "github.com/beeper/desktop-api-cli/internal/mocktest" +) + +func TestAppLoginEmail(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "app:login", "email", + "--email", "dev@stainless.com", + "--request", "request", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("" + + "email: dev@stainless.com\n" + + "request: request\n") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, + "--access-token", "string", + "app:login", "email", + ) + }) +} + +func TestAppLoginRegister(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "app:login", "register", + "--accept-terms=true", + "--lead-token", "leadToken", + "--request", "request", + "--username", "x", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("" + + "acceptTerms: true\n" + + "leadToken: leadToken\n" + + "request: request\n" + + "username: x\n") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, + "--access-token", "string", + "app:login", "register", + ) + }) +} + +func TestAppLoginResponse(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "app:login", "response", + "--request", "request", + "--response", "response", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("" + + "request: request\n" + + "response: response\n") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, + "--access-token", "string", + "app:login", "response", + ) + }) +} + +func TestAppLoginStart(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "app:login", "start", + ) + }) +} diff --git a/pkg/cmd/bridge.go b/pkg/cmd/bridge.go new file mode 100644 index 0000000..a33054e --- /dev/null +++ b/pkg/cmd/bridge.go @@ -0,0 +1,65 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "context" + "fmt" + + "github.com/beeper/desktop-api-cli/internal/apiquery" + "github.com/beeper/desktop-api-go/v5" + "github.com/beeper/desktop-api-go/v5/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var bridgesList = cli.Command{ + Name: "list", + Usage: "List bridge-backed account types that can be shown in add-account flows, grouped\nwith connected accounts that use the same Account schema as GET /v1/accounts.", + Suggest: true, + Flags: []cli.Flag{}, + Action: handleBridgesList, + HideHelpCommand: true, +} + +func handleBridgesList(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + EmptyBody, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Bridges.List(ctx, options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := "json" + explicitFormat := cmd.Root().IsSet("format") + if explicitFormat { + format = cmd.Root().String("format") + } + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "bridges list", + Transform: transform, + }) +} diff --git a/pkg/cmd/bridge_test.go b/pkg/cmd/bridge_test.go new file mode 100644 index 0000000..25bdeb8 --- /dev/null +++ b/pkg/cmd/bridge_test.go @@ -0,0 +1,19 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "testing" + + "github.com/beeper/desktop-api-cli/internal/mocktest" +) + +func TestBridgesList(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "bridges", "list", + ) + }) +} diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index 1304eaa..089dd61 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -87,6 +87,71 @@ func init() { Commands: []*cli.Command{ &focus, &search, + { + Name: "app", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &appStatus, + }, + }, + { + Name: "app:login", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &appLoginEmail, + &appLoginRegister, + &appLoginResponse, + &appLoginStart, + }, + }, + { + Name: "app:e2ee:recovery-code", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &appE2eeRecoveryCodeMarkBackedUp, + &appE2eeRecoveryCodeVerify, + }, + }, + { + Name: "app:e2ee:recovery-code:reset", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &appE2eeRecoveryCodeResetCreate, + &appE2eeRecoveryCodeResetConfirm, + }, + }, + { + Name: "app:e2ee:verification", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &appE2eeVerificationCreate, + &appE2eeVerificationAccept, + &appE2eeVerificationCancel, + }, + }, + { + Name: "app:e2ee:verification:qr", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &appE2eeVerificationQrConfirmScanned, + &appE2eeVerificationQrScan, + }, + }, + { + Name: "app:e2ee:verification:sas", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &appE2eeVerificationSasConfirm, + &appE2eeVerificationSasStart, + }, + }, { Name: "accounts", Category: "API RESOURCE", @@ -104,6 +169,116 @@ func init() { &accountsContactsSearch, }, }, + { + Name: "bridges", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &bridgesList, + }, + }, + { + Name: "matrix:users", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &matrixUsersRetrieveProfile, + }, + }, + { + Name: "matrix:users:account-data", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &matrixUsersAccountDataRetrieve, + &matrixUsersAccountDataUpdate, + }, + }, + { + Name: "matrix:rooms", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &matrixRoomsCreate, + &matrixRoomsJoin, + &matrixRoomsLeave, + }, + }, + { + Name: "matrix:rooms:account-data", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &matrixRoomsAccountDataRetrieve, + &matrixRoomsAccountDataUpdate, + }, + }, + { + Name: "matrix:rooms:state", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &matrixRoomsStateRetrieve, + &matrixRoomsStateList, + }, + }, + { + Name: "matrix:rooms:events", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &matrixRoomsEventsRetrieve, + }, + }, + { + Name: "matrix:bridges:auth", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &matrixBridgesAuthListFlows, + &matrixBridgesAuthListLogins, + &matrixBridgesAuthLogout, + &matrixBridgesAuthStartLogin, + &matrixBridgesAuthSubmitCookies, + &matrixBridgesAuthSubmitUserInput, + &matrixBridgesAuthWaitForStep, + &matrixBridgesAuthWhoami, + }, + }, + { + Name: "matrix:bridges:contacts", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &matrixBridgesContactsList, + }, + }, + { + Name: "matrix:bridges:users", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &matrixBridgesUsersResolve, + &matrixBridgesUsersSearch, + }, + }, + { + Name: "matrix:bridges:rooms", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &matrixBridgesRoomsCreateDm, + &matrixBridgesRoomsCreateGroup, + }, + }, + { + Name: "matrix:bridges:capabilities", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &matrixBridgesCapabilitiesRetrieve, + }, + }, { Name: "chats", Category: "API RESOURCE", diff --git a/pkg/cmd/matrixbridgeauth.go b/pkg/cmd/matrixbridgeauth.go new file mode 100644 index 0000000..ea8deb5 --- /dev/null +++ b/pkg/cmd/matrixbridgeauth.go @@ -0,0 +1,584 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "context" + "fmt" + + "github.com/beeper/desktop-api-cli/internal/apiquery" + "github.com/beeper/desktop-api-cli/internal/requestflag" + "github.com/beeper/desktop-api-go/v5" + "github.com/beeper/desktop-api-go/v5/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var matrixBridgesAuthListFlows = cli.Command{ + Name: "list-flows", + Usage: "Get the available login flows.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "bridge-id", + Required: true, + PathParam: "bridgeID", + }, + }, + Action: handleMatrixBridgesAuthListFlows, + HideHelpCommand: true, +} + +var matrixBridgesAuthListLogins = cli.Command{ + Name: "list-logins", + Usage: "Get the login IDs of the current user.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "bridge-id", + Required: true, + PathParam: "bridgeID", + }, + }, + Action: handleMatrixBridgesAuthListLogins, + HideHelpCommand: true, +} + +var matrixBridgesAuthLogout = cli.Command{ + Name: "logout", + Usage: "Log out of an existing login.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "bridge-id", + Required: true, + PathParam: "bridgeID", + }, + &requestflag.Flag[string]{ + Name: "login-id", + Usage: "The unique ID of a login. Defined by the network connector.", + Required: true, + PathParam: "loginID", + }, + }, + Action: handleMatrixBridgesAuthLogout, + HideHelpCommand: true, +} + +var matrixBridgesAuthStartLogin = cli.Command{ + Name: "start-login", + Usage: "This endpoint starts a new login process, which is used to log into the bridge.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "bridge-id", + Required: true, + PathParam: "bridgeID", + }, + &requestflag.Flag[string]{ + Name: "flow-id", + Required: true, + PathParam: "flowID", + }, + &requestflag.Flag[string]{ + Name: "login-id", + Usage: "An existing login ID to re-login as. If this is specified and the user logs into a different account, the provided ID will be logged out.", + QueryPath: "login_id", + }, + }, + Action: handleMatrixBridgesAuthStartLogin, + HideHelpCommand: true, +} + +var matrixBridgesAuthSubmitCookies = cli.Command{ + Name: "submit-cookies", + Usage: "Submit extracted cookies in a login process.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "bridge-id", + Required: true, + PathParam: "bridgeID", + }, + &requestflag.Flag[string]{ + Name: "login-process-id", + Required: true, + PathParam: "loginProcessID", + }, + &requestflag.Flag[string]{ + Name: "step-id", + Required: true, + PathParam: "stepID", + }, + &requestflag.Flag[map[string]any]{ + Name: "body", + Required: true, + BodyRoot: true, + }, + }, + Action: handleMatrixBridgesAuthSubmitCookies, + HideHelpCommand: true, +} + +var matrixBridgesAuthSubmitUserInput = cli.Command{ + Name: "submit-user-input", + Usage: "Submit user input in a login process.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "bridge-id", + Required: true, + PathParam: "bridgeID", + }, + &requestflag.Flag[string]{ + Name: "login-process-id", + Required: true, + PathParam: "loginProcessID", + }, + &requestflag.Flag[string]{ + Name: "step-id", + Required: true, + PathParam: "stepID", + }, + &requestflag.Flag[map[string]any]{ + Name: "body", + Required: true, + BodyRoot: true, + }, + }, + Action: handleMatrixBridgesAuthSubmitUserInput, + HideHelpCommand: true, +} + +var matrixBridgesAuthWaitForStep = cli.Command{ + Name: "wait-for-step", + Usage: "Wait for the next step after displaying data to the user.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "bridge-id", + Required: true, + PathParam: "bridgeID", + }, + &requestflag.Flag[string]{ + Name: "login-process-id", + Required: true, + PathParam: "loginProcessID", + }, + &requestflag.Flag[string]{ + Name: "step-id", + Required: true, + PathParam: "stepID", + }, + }, + Action: handleMatrixBridgesAuthWaitForStep, + HideHelpCommand: true, +} + +var matrixBridgesAuthWhoami = cli.Command{ + Name: "whoami", + Usage: "Get all info that is useful for presenting this bridge in a manager interface.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "bridge-id", + Required: true, + PathParam: "bridgeID", + }, + }, + Action: handleMatrixBridgesAuthWhoami, + HideHelpCommand: true, +} + +func handleMatrixBridgesAuthListFlows(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("bridge-id") && len(unusedArgs) > 0 { + cmd.Set("bridge-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + EmptyBody, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Matrix.Bridges.Auth.ListFlows(ctx, cmd.Value("bridge-id").(string), options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := "json" + explicitFormat := cmd.Root().IsSet("format") + if explicitFormat { + format = cmd.Root().String("format") + } + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "matrix:bridges:auth list-flows", + Transform: transform, + }) +} + +func handleMatrixBridgesAuthListLogins(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("bridge-id") && len(unusedArgs) > 0 { + cmd.Set("bridge-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + EmptyBody, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Matrix.Bridges.Auth.ListLogins(ctx, cmd.Value("bridge-id").(string), options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := "json" + explicitFormat := cmd.Root().IsSet("format") + if explicitFormat { + format = cmd.Root().String("format") + } + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "matrix:bridges:auth list-logins", + Transform: transform, + }) +} + +func handleMatrixBridgesAuthLogout(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("login-id") && len(unusedArgs) > 0 { + cmd.Set("login-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + EmptyBody, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.MatrixBridgeAuthLogoutParams{ + BridgeID: cmd.Value("bridge-id").(string), + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Matrix.Bridges.Auth.Logout( + ctx, + cmd.Value("login-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "matrix:bridges:auth logout", + Transform: transform, + }) +} + +func handleMatrixBridgesAuthStartLogin(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("flow-id") && len(unusedArgs) > 0 { + cmd.Set("flow-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + EmptyBody, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.MatrixBridgeAuthStartLoginParams{ + BridgeID: cmd.Value("bridge-id").(string), + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Matrix.Bridges.Auth.StartLogin( + ctx, + cmd.Value("flow-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "matrix:bridges:auth start-login", + Transform: transform, + }) +} + +func handleMatrixBridgesAuthSubmitCookies(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("step-id") && len(unusedArgs) > 0 { + cmd.Set("step-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.MatrixBridgeAuthSubmitCookiesParams{ + BridgeID: cmd.Value("bridge-id").(string), + LoginProcessID: cmd.Value("login-process-id").(string), + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Matrix.Bridges.Auth.SubmitCookies( + ctx, + cmd.Value("step-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "matrix:bridges:auth submit-cookies", + Transform: transform, + }) +} + +func handleMatrixBridgesAuthSubmitUserInput(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("step-id") && len(unusedArgs) > 0 { + cmd.Set("step-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.MatrixBridgeAuthSubmitUserInputParams{ + BridgeID: cmd.Value("bridge-id").(string), + LoginProcessID: cmd.Value("login-process-id").(string), + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Matrix.Bridges.Auth.SubmitUserInput( + ctx, + cmd.Value("step-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "matrix:bridges:auth submit-user-input", + Transform: transform, + }) +} + +func handleMatrixBridgesAuthWaitForStep(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("step-id") && len(unusedArgs) > 0 { + cmd.Set("step-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + EmptyBody, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.MatrixBridgeAuthWaitForStepParams{ + BridgeID: cmd.Value("bridge-id").(string), + LoginProcessID: cmd.Value("login-process-id").(string), + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Matrix.Bridges.Auth.WaitForStep( + ctx, + cmd.Value("step-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "matrix:bridges:auth wait-for-step", + Transform: transform, + }) +} + +func handleMatrixBridgesAuthWhoami(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("bridge-id") && len(unusedArgs) > 0 { + cmd.Set("bridge-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + EmptyBody, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Matrix.Bridges.Auth.Whoami(ctx, cmd.Value("bridge-id").(string), options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := "json" + explicitFormat := cmd.Root().IsSet("format") + if explicitFormat { + format = cmd.Root().String("format") + } + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "matrix:bridges:auth whoami", + Transform: transform, + }) +} diff --git a/pkg/cmd/matrixbridgeauth_test.go b/pkg/cmd/matrixbridgeauth_test.go new file mode 100644 index 0000000..1776eda --- /dev/null +++ b/pkg/cmd/matrixbridgeauth_test.go @@ -0,0 +1,134 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "testing" + + "github.com/beeper/desktop-api-cli/internal/mocktest" +) + +func TestMatrixBridgesAuthListFlows(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:bridges:auth", "list-flows", + "--bridge-id", "bridgeID", + ) + }) +} + +func TestMatrixBridgesAuthListLogins(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:bridges:auth", "list-logins", + "--bridge-id", "bridgeID", + ) + }) +} + +func TestMatrixBridgesAuthLogout(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:bridges:auth", "logout", + "--bridge-id", "bridgeID", + "--login-id", "bcc68892-b180-414f-9516-b4aadf7d0496", + ) + }) +} + +func TestMatrixBridgesAuthStartLogin(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:bridges:auth", "start-login", + "--bridge-id", "bridgeID", + "--flow-id", "qr", + "--login-id", "bcc68892-b180-414f-9516-b4aadf7d0496", + ) + }) +} + +func TestMatrixBridgesAuthSubmitCookies(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:bridges:auth", "submit-cookies", + "--bridge-id", "bridgeID", + "--login-process-id", "loginProcessID", + "--step-id", "stepID", + "--body", "{foo: string}", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("foo: string") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, + "--access-token", "string", + "matrix:bridges:auth", "submit-cookies", + "--bridge-id", "bridgeID", + "--login-process-id", "loginProcessID", + "--step-id", "stepID", + ) + }) +} + +func TestMatrixBridgesAuthSubmitUserInput(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:bridges:auth", "submit-user-input", + "--bridge-id", "bridgeID", + "--login-process-id", "loginProcessID", + "--step-id", "stepID", + "--body", "{foo: string}", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("foo: string") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, + "--access-token", "string", + "matrix:bridges:auth", "submit-user-input", + "--bridge-id", "bridgeID", + "--login-process-id", "loginProcessID", + "--step-id", "stepID", + ) + }) +} + +func TestMatrixBridgesAuthWaitForStep(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:bridges:auth", "wait-for-step", + "--bridge-id", "bridgeID", + "--login-process-id", "loginProcessID", + "--step-id", "stepID", + ) + }) +} + +func TestMatrixBridgesAuthWhoami(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:bridges:auth", "whoami", + "--bridge-id", "bridgeID", + ) + }) +} diff --git a/pkg/cmd/matrixbridgecapability.go b/pkg/cmd/matrixbridgecapability.go new file mode 100644 index 0000000..0accdfe --- /dev/null +++ b/pkg/cmd/matrixbridgecapability.go @@ -0,0 +1,75 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "context" + "fmt" + + "github.com/beeper/desktop-api-cli/internal/apiquery" + "github.com/beeper/desktop-api-cli/internal/requestflag" + "github.com/beeper/desktop-api-go/v5" + "github.com/beeper/desktop-api-go/v5/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var matrixBridgesCapabilitiesRetrieve = cli.Command{ + Name: "retrieve", + Usage: "Get bridge capabilities", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "bridge-id", + Required: true, + PathParam: "bridgeID", + }, + }, + Action: handleMatrixBridgesCapabilitiesRetrieve, + HideHelpCommand: true, +} + +func handleMatrixBridgesCapabilitiesRetrieve(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("bridge-id") && len(unusedArgs) > 0 { + cmd.Set("bridge-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + EmptyBody, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Matrix.Bridges.Capabilities.Get(ctx, cmd.Value("bridge-id").(string), options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := "json" + explicitFormat := cmd.Root().IsSet("format") + if explicitFormat { + format = cmd.Root().String("format") + } + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "matrix:bridges:capabilities retrieve", + Transform: transform, + }) +} diff --git a/pkg/cmd/matrixbridgecapability_test.go b/pkg/cmd/matrixbridgecapability_test.go new file mode 100644 index 0000000..d90cd36 --- /dev/null +++ b/pkg/cmd/matrixbridgecapability_test.go @@ -0,0 +1,20 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "testing" + + "github.com/beeper/desktop-api-cli/internal/mocktest" +) + +func TestMatrixBridgesCapabilitiesRetrieve(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:bridges:capabilities", "retrieve", + "--bridge-id", "bridgeID", + ) + }) +} diff --git a/pkg/cmd/matrixbridgecontact.go b/pkg/cmd/matrixbridgecontact.go new file mode 100644 index 0000000..55c3eb7 --- /dev/null +++ b/pkg/cmd/matrixbridgecontact.go @@ -0,0 +1,87 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "context" + "fmt" + + "github.com/beeper/desktop-api-cli/internal/apiquery" + "github.com/beeper/desktop-api-cli/internal/requestflag" + "github.com/beeper/desktop-api-go/v5" + "github.com/beeper/desktop-api-go/v5/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var matrixBridgesContactsList = cli.Command{ + Name: "list", + Usage: "Get a list of contacts.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "bridge-id", + Required: true, + PathParam: "bridgeID", + }, + &requestflag.Flag[string]{ + Name: "login-id", + Usage: "An optional explicit login ID to do the action through.", + QueryPath: "login_id", + }, + }, + Action: handleMatrixBridgesContactsList, + HideHelpCommand: true, +} + +func handleMatrixBridgesContactsList(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("bridge-id") && len(unusedArgs) > 0 { + cmd.Set("bridge-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + EmptyBody, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.MatrixBridgeContactListParams{} + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Matrix.Bridges.Contacts.List( + ctx, + cmd.Value("bridge-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := "json" + explicitFormat := cmd.Root().IsSet("format") + if explicitFormat { + format = cmd.Root().String("format") + } + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "matrix:bridges:contacts list", + Transform: transform, + }) +} diff --git a/pkg/cmd/matrixbridgecontact_test.go b/pkg/cmd/matrixbridgecontact_test.go new file mode 100644 index 0000000..df14d10 --- /dev/null +++ b/pkg/cmd/matrixbridgecontact_test.go @@ -0,0 +1,21 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "testing" + + "github.com/beeper/desktop-api-cli/internal/mocktest" +) + +func TestMatrixBridgesContactsList(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:bridges:contacts", "list", + "--bridge-id", "bridgeID", + "--login-id", "bcc68892-b180-414f-9516-b4aadf7d0496", + ) + }) +} diff --git a/pkg/cmd/matrixbridgeroom.go b/pkg/cmd/matrixbridgeroom.go new file mode 100644 index 0000000..20d611d --- /dev/null +++ b/pkg/cmd/matrixbridgeroom.go @@ -0,0 +1,240 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "context" + "fmt" + + "github.com/beeper/desktop-api-cli/internal/apiquery" + "github.com/beeper/desktop-api-cli/internal/requestflag" + "github.com/beeper/desktop-api-go/v5" + "github.com/beeper/desktop-api-go/v5/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var matrixBridgesRoomsCreateDm = cli.Command{ + Name: "create-dm", + Usage: "Create a direct chat with a user on the remote network.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "bridge-id", + Required: true, + PathParam: "bridgeID", + }, + &requestflag.Flag[string]{ + Name: "identifier", + Required: true, + PathParam: "identifier", + }, + &requestflag.Flag[string]{ + Name: "login-id", + Usage: "An optional explicit login ID to do the action through.", + QueryPath: "login_id", + }, + }, + Action: handleMatrixBridgesRoomsCreateDm, + HideHelpCommand: true, +} + +var matrixBridgesRoomsCreateGroup = requestflag.WithInnerFlags(cli.Command{ + Name: "create-group", + Usage: "Create a group chat on the remote network.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "bridge-id", + Required: true, + PathParam: "bridgeID", + }, + &requestflag.Flag[string]{ + Name: "group-type", + Required: true, + PathParam: "groupType", + }, + &requestflag.Flag[string]{ + Name: "login-id", + Usage: "An optional explicit login ID to do the action through.", + QueryPath: "login_id", + }, + &requestflag.Flag[map[string]any]{ + Name: "avatar", + Usage: "The `m.room.avatar` event content for the room.", + BodyPath: "avatar", + }, + &requestflag.Flag[map[string]any]{ + Name: "disappear", + Usage: "The `com.beeper.disappearing_timer` event content for the room.", + BodyPath: "disappear", + }, + &requestflag.Flag[map[string]any]{ + Name: "name", + Usage: "The `m.room.name` event content for the room.", + BodyPath: "name", + }, + &requestflag.Flag[any]{ + Name: "parent", + BodyPath: "parent", + }, + &requestflag.Flag[[]string]{ + Name: "participant", + Usage: "The users to add to the group initially.", + BodyPath: "participants", + }, + &requestflag.Flag[string]{ + Name: "room-id", + Usage: "An existing Matrix room ID to bridge to.\nThe other parameters must be already in sync with the room state when using this parameter.\n", + BodyPath: "room_id", + }, + &requestflag.Flag[map[string]any]{ + Name: "topic", + Usage: "The `m.room.topic` event content for the room.", + BodyPath: "topic", + }, + &requestflag.Flag[string]{ + Name: "type", + Usage: "The type of group to create.", + BodyPath: "type", + }, + &requestflag.Flag[string]{ + Name: "username", + Usage: "The public username for the created group.", + BodyPath: "username", + }, + }, + Action: handleMatrixBridgesRoomsCreateGroup, + HideHelpCommand: true, +}, map[string][]requestflag.HasOuterFlag{ + "avatar": { + &requestflag.InnerFlag[string]{ + Name: "avatar.url", + InnerField: "url", + }, + }, + "disappear": { + &requestflag.InnerFlag[float64]{ + Name: "disappear.timer", + InnerField: "timer", + }, + &requestflag.InnerFlag[string]{ + Name: "disappear.type", + InnerField: "type", + }, + }, + "name": { + &requestflag.InnerFlag[string]{ + Name: "name.name", + InnerField: "name", + }, + }, + "topic": { + &requestflag.InnerFlag[string]{ + Name: "topic.topic", + InnerField: "topic", + }, + }, +}) + +func handleMatrixBridgesRoomsCreateDm(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("identifier") && len(unusedArgs) > 0 { + cmd.Set("identifier", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + EmptyBody, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.MatrixBridgeRoomNewDmParams{ + BridgeID: cmd.Value("bridge-id").(string), + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Matrix.Bridges.Rooms.NewDm( + ctx, + cmd.Value("identifier").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "matrix:bridges:rooms create-dm", + Transform: transform, + }) +} + +func handleMatrixBridgesRoomsCreateGroup(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("group-type") && len(unusedArgs) > 0 { + cmd.Set("group-type", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.MatrixBridgeRoomNewGroupParams{ + BridgeID: cmd.Value("bridge-id").(string), + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Matrix.Bridges.Rooms.NewGroup( + ctx, + cmd.Value("group-type").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "matrix:bridges:rooms create-group", + Transform: transform, + }) +} diff --git a/pkg/cmd/matrixbridgeroom_test.go b/pkg/cmd/matrixbridgeroom_test.go new file mode 100644 index 0000000..4e4c59e --- /dev/null +++ b/pkg/cmd/matrixbridgeroom_test.go @@ -0,0 +1,98 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "testing" + + "github.com/beeper/desktop-api-cli/internal/mocktest" + "github.com/beeper/desktop-api-cli/internal/requestflag" +) + +func TestMatrixBridgesRoomsCreateDm(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:bridges:rooms", "create-dm", + "--bridge-id", "bridgeID", + "--identifier", "identifier", + "--login-id", "bcc68892-b180-414f-9516-b4aadf7d0496", + ) + }) +} + +func TestMatrixBridgesRoomsCreateGroup(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:bridges:rooms", "create-group", + "--bridge-id", "bridgeID", + "--group-type", "groupType", + "--login-id", "bcc68892-b180-414f-9516-b4aadf7d0496", + "--avatar", "{url: url}", + "--disappear", "{timer: 0, type: type}", + "--name", "{name: name}", + "--parent", "{}", + "--participant", "string", + "--room-id", "room_id", + "--topic", "{topic: topic}", + "--type", "channel", + "--username", "username", + ) + }) + + t.Run("inner flags", func(t *testing.T) { + // Check that inner flags have been set up correctly + requestflag.CheckInnerFlags(matrixBridgesRoomsCreateGroup) + + // Alternative argument passing style using inner flags + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:bridges:rooms", "create-group", + "--bridge-id", "bridgeID", + "--group-type", "groupType", + "--login-id", "bcc68892-b180-414f-9516-b4aadf7d0496", + "--avatar.url", "url", + "--disappear.timer", "0", + "--disappear.type", "type", + "--name.name", "name", + "--parent", "{}", + "--participant", "string", + "--room-id", "room_id", + "--topic.topic", "topic", + "--type", "channel", + "--username", "username", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("" + + "avatar:\n" + + " url: url\n" + + "disappear:\n" + + " timer: 0\n" + + " type: type\n" + + "name:\n" + + " name: name\n" + + "parent: {}\n" + + "participants:\n" + + " - string\n" + + "room_id: room_id\n" + + "topic:\n" + + " topic: topic\n" + + "type: channel\n" + + "username: username\n") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, + "--access-token", "string", + "matrix:bridges:rooms", "create-group", + "--bridge-id", "bridgeID", + "--group-type", "groupType", + "--login-id", "bcc68892-b180-414f-9516-b4aadf7d0496", + ) + }) +} diff --git a/pkg/cmd/matrixbridgeuser.go b/pkg/cmd/matrixbridgeuser.go new file mode 100644 index 0000000..4596e5c --- /dev/null +++ b/pkg/cmd/matrixbridgeuser.go @@ -0,0 +1,168 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "context" + "fmt" + + "github.com/beeper/desktop-api-cli/internal/apiquery" + "github.com/beeper/desktop-api-cli/internal/requestflag" + "github.com/beeper/desktop-api-go/v5" + "github.com/beeper/desktop-api-go/v5/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var matrixBridgesUsersResolve = cli.Command{ + Name: "resolve", + Usage: "Resolve an identifier to a user on the remote network.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "bridge-id", + Required: true, + PathParam: "bridgeID", + }, + &requestflag.Flag[string]{ + Name: "identifier", + Required: true, + PathParam: "identifier", + }, + &requestflag.Flag[string]{ + Name: "login-id", + Usage: "An optional explicit login ID to do the action through.", + QueryPath: "login_id", + }, + }, + Action: handleMatrixBridgesUsersResolve, + HideHelpCommand: true, +} + +var matrixBridgesUsersSearch = cli.Command{ + Name: "search", + Usage: "Search for users on the remote network", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "bridge-id", + Required: true, + PathParam: "bridgeID", + }, + &requestflag.Flag[string]{ + Name: "login-id", + Usage: "An optional explicit login ID to do the action through.", + QueryPath: "login_id", + }, + &requestflag.Flag[string]{ + Name: "query", + Usage: "The search query to send to the remote network", + BodyPath: "query", + }, + }, + Action: handleMatrixBridgesUsersSearch, + HideHelpCommand: true, +} + +func handleMatrixBridgesUsersResolve(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("identifier") && len(unusedArgs) > 0 { + cmd.Set("identifier", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + EmptyBody, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.MatrixBridgeUserResolveParams{ + BridgeID: cmd.Value("bridge-id").(string), + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Matrix.Bridges.Users.Resolve( + ctx, + cmd.Value("identifier").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := "json" + explicitFormat := cmd.Root().IsSet("format") + if explicitFormat { + format = cmd.Root().String("format") + } + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "matrix:bridges:users resolve", + Transform: transform, + }) +} + +func handleMatrixBridgesUsersSearch(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("bridge-id") && len(unusedArgs) > 0 { + cmd.Set("bridge-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.MatrixBridgeUserSearchParams{} + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Matrix.Bridges.Users.Search( + ctx, + cmd.Value("bridge-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "matrix:bridges:users search", + Transform: transform, + }) +} diff --git a/pkg/cmd/matrixbridgeuser_test.go b/pkg/cmd/matrixbridgeuser_test.go new file mode 100644 index 0000000..c3f11aa --- /dev/null +++ b/pkg/cmd/matrixbridgeuser_test.go @@ -0,0 +1,47 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "testing" + + "github.com/beeper/desktop-api-cli/internal/mocktest" +) + +func TestMatrixBridgesUsersResolve(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:bridges:users", "resolve", + "--bridge-id", "bridgeID", + "--identifier", "identifier", + "--login-id", "bcc68892-b180-414f-9516-b4aadf7d0496", + ) + }) +} + +func TestMatrixBridgesUsersSearch(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:bridges:users", "search", + "--bridge-id", "bridgeID", + "--login-id", "bcc68892-b180-414f-9516-b4aadf7d0496", + "--query", "query", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("query: query") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, + "--access-token", "string", + "matrix:bridges:users", "search", + "--bridge-id", "bridgeID", + "--login-id", "bcc68892-b180-414f-9516-b4aadf7d0496", + ) + }) +} diff --git a/pkg/cmd/matrixroom.go b/pkg/cmd/matrixroom.go new file mode 100644 index 0000000..f870eaa --- /dev/null +++ b/pkg/cmd/matrixroom.go @@ -0,0 +1,337 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "context" + "fmt" + + "github.com/beeper/desktop-api-cli/internal/apiquery" + "github.com/beeper/desktop-api-cli/internal/requestflag" + "github.com/beeper/desktop-api-go/v5" + "github.com/beeper/desktop-api-go/v5/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var matrixRoomsCreate = requestflag.WithInnerFlags(cli.Command{ + Name: "create", + Usage: "Create a new room with various configuration options.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[any]{ + Name: "creation-content", + Usage: "Extra keys, such as `m.federate`, to be added to the content\nof the [`m.room.create`](https://spec.matrix.org/v1.18/client-server-api/#mroomcreate) event.\n\nThe server will overwrite the following\nkeys: `creator`, `room_version`. Future versions of the specification\nmay allow the server to overwrite other keys.\n\nWhen using the `trusted_private_chat` preset, the server SHOULD combine\n`additional_creators` specified here and the `invite` array into the\neventual `m.room.create` event's `additional_creators`, deduplicating\nbetween the two parameters.", + BodyPath: "creation_content", + }, + &requestflag.Flag[[]map[string]any]{ + Name: "initial-state", + Usage: "A list of state events to set in the new room. This allows\nthe user to override the default state events set in the new\nroom. The expected format of the state events are an object\nwith type, state_key and content keys set.\n\nTakes precedence over events set by `preset`, but gets\noverridden by `name` and `topic` keys.", + BodyPath: "initial_state", + }, + &requestflag.Flag[[]string]{ + Name: "invite", + Usage: "A list of user IDs to invite to the room. This will tell the\nserver to invite everyone in the list to the newly created room.", + BodyPath: "invite", + }, + &requestflag.Flag[[]map[string]any]{ + Name: "invite-3pid", + Usage: "A list of objects representing third-party IDs to invite into\nthe room.", + BodyPath: "invite_3pid", + }, + &requestflag.Flag[bool]{ + Name: "is-direct", + Usage: "This flag makes the server set the `is_direct` flag on the\n`m.room.member` events sent to the users in `invite` and\n`invite_3pid`. See [Direct Messaging](https://spec.matrix.org/v1.18/client-server-api/#direct-messaging) for more information.", + BodyPath: "is_direct", + }, + &requestflag.Flag[string]{ + Name: "name", + Usage: "If this is included, an [`m.room.name`](https://spec.matrix.org/v1.18/client-server-api/#mroomname) event\nwill be sent into the room to indicate the name for the room.\nThis overwrites any [`m.room.name`](https://spec.matrix.org/v1.18/client-server-api/#mroomname)\nevent in `initial_state`.", + BodyPath: "name", + }, + &requestflag.Flag[any]{ + Name: "power-level-content-override", + Usage: "The power level content to override in the default power level\nevent. This object is applied on top of the generated\n[`m.room.power_levels`](https://spec.matrix.org/v1.18/client-server-api/#mroompower_levels)\nevent content prior to it being sent to the room. Defaults to\noverriding nothing.", + BodyPath: "power_level_content_override", + }, + &requestflag.Flag[string]{ + Name: "preset", + Usage: "Convenience parameter for setting various default state events\nbased on a preset.\n\nIf unspecified, the server should use the `visibility` to determine\nwhich preset to use. A visibility of `public` equates to a preset of\n`public_chat` and `private` visibility equates to a preset of\n`private_chat`.", + BodyPath: "preset", + }, + &requestflag.Flag[string]{ + Name: "room-alias-name", + Usage: "The desired room alias **local part**. If this is included, a\nroom alias will be created and mapped to the newly created\nroom. The alias will belong on the *same* homeserver which\ncreated the room. For example, if this was set to \"foo\" and\nsent to the homeserver \"example.com\" the complete room alias\nwould be `#foo:example.com`.\n\nThe complete room alias will become the canonical alias for\nthe room and an `m.room.canonical_alias` event will be sent\ninto the room.", + BodyPath: "room_alias_name", + }, + &requestflag.Flag[string]{ + Name: "room-version", + Usage: "The room version to set for the room. If not provided, the homeserver is\nto use its configured default. If provided, the homeserver will return a\n400 error with the errcode `M_UNSUPPORTED_ROOM_VERSION` if it does not\nsupport the room version.", + BodyPath: "room_version", + }, + &requestflag.Flag[string]{ + Name: "topic", + Usage: "If this is included, an [`m.room.topic`](https://spec.matrix.org/v1.18/client-server-api/#mroomtopic)\nevent with a `text/plain` mimetype will be sent into the room\nto indicate the topic for the room. This overwrites any\n[`m.room.topic`](https://spec.matrix.org/v1.18/client-server-api/#mroomtopic) event in `initial_state`.", + BodyPath: "topic", + }, + &requestflag.Flag[string]{ + Name: "visibility", + Usage: "The room's visibility in the server's\n[published room directory](https://spec.matrix.org/v1.18/client-server-api#published-room-directory).\nDefaults to `private`.", + BodyPath: "visibility", + }, + }, + Action: handleMatrixRoomsCreate, + HideHelpCommand: true, +}, map[string][]requestflag.HasOuterFlag{ + "initial-state": { + &requestflag.InnerFlag[any]{ + Name: "initial-state.content", + Usage: "The content of the event.", + InnerField: "content", + }, + &requestflag.InnerFlag[string]{ + Name: "initial-state.type", + Usage: "The type of event to send.", + InnerField: "type", + }, + &requestflag.InnerFlag[string]{ + Name: "initial-state.state-key", + Usage: "The state_key of the state event. Defaults to an empty string.", + InnerField: "state_key", + }, + }, + "invite-3pid": { + &requestflag.InnerFlag[string]{ + Name: "invite-3pid.address", + Usage: "The invitee's third-party identifier.", + InnerField: "address", + }, + &requestflag.InnerFlag[string]{ + Name: "invite-3pid.id-access-token", + Usage: "An access token previously registered with the identity server. Servers\ncan treat this as optional to distinguish between r0.5-compatible clients\nand this specification version.", + InnerField: "id_access_token", + }, + &requestflag.InnerFlag[string]{ + Name: "invite-3pid.id-server", + Usage: "The hostname+port of the identity server which should be used for third-party identifier lookups.", + InnerField: "id_server", + }, + &requestflag.InnerFlag[string]{ + Name: "invite-3pid.medium", + Usage: "The kind of address being passed in the address field, for example `email`\n(see [the list of recognised values](https://spec.matrix.org/v1.18/appendices/#3pid-types)).", + InnerField: "medium", + }, + }, +}) + +var matrixRoomsJoin = requestflag.WithInnerFlags(cli.Command{ + Name: "join", + Usage: "_Note that this API takes either a room ID or alias, unlike_\n`/rooms/{roomId}/join`.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "room-id-or-alias", + Required: true, + PathParam: "roomIdOrAlias", + }, + &requestflag.Flag[[]string]{ + Name: "via", + Usage: "The servers to attempt to join the room through. One of the servers\nmust be participating in the room.", + QueryPath: "via", + }, + &requestflag.Flag[string]{ + Name: "reason", + Usage: "Optional reason to be included as the `reason` on the subsequent\nmembership event.", + BodyPath: "reason", + }, + &requestflag.Flag[map[string]any]{ + Name: "third-party-signed", + Usage: "A signature of an `m.third_party_invite` token to prove that this user\nowns a third-party identity which has been invited to the room.", + BodyPath: "third_party_signed", + }, + }, + Action: handleMatrixRoomsJoin, + HideHelpCommand: true, +}, map[string][]requestflag.HasOuterFlag{ + "third-party-signed": { + &requestflag.InnerFlag[string]{ + Name: "third-party-signed.token", + Usage: "The state key of the m.third_party_invite event.", + InnerField: "token", + }, + &requestflag.InnerFlag[string]{ + Name: "third-party-signed.mxid", + Usage: "The Matrix ID of the invitee.", + InnerField: "mxid", + }, + &requestflag.InnerFlag[string]{ + Name: "third-party-signed.sender", + Usage: "The Matrix ID of the user who issued the invite.", + InnerField: "sender", + }, + &requestflag.InnerFlag[map[string]any]{ + Name: "third-party-signed.signatures", + Usage: "A signatures object containing a signature of the entire signed object.", + InnerField: "signatures", + }, + }, +}) + +var matrixRoomsLeave = cli.Command{ + Name: "leave", + Usage: "This API stops a user participating in a particular room.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "room-id", + Required: true, + PathParam: "roomId", + }, + &requestflag.Flag[string]{ + Name: "reason", + Usage: "Optional reason to be included as the `reason` on the subsequent\nmembership event.", + BodyPath: "reason", + }, + }, + Action: handleMatrixRoomsLeave, + HideHelpCommand: true, +} + +func handleMatrixRoomsCreate(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.MatrixRoomNewParams{} + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Matrix.Rooms.New(ctx, params, options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "matrix:rooms create", + Transform: transform, + }) +} + +func handleMatrixRoomsJoin(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("room-id-or-alias") && len(unusedArgs) > 0 { + cmd.Set("room-id-or-alias", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.MatrixRoomJoinParams{} + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Matrix.Rooms.Join( + ctx, + cmd.Value("room-id-or-alias").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "matrix:rooms join", + Transform: transform, + }) +} + +func handleMatrixRoomsLeave(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("room-id") && len(unusedArgs) > 0 { + cmd.Set("room-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.MatrixRoomLeaveParams{} + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Matrix.Rooms.Leave( + ctx, + cmd.Value("room-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "matrix:rooms leave", + Transform: transform, + }) +} diff --git a/pkg/cmd/matrixroom_test.go b/pkg/cmd/matrixroom_test.go new file mode 100644 index 0000000..241ec53 --- /dev/null +++ b/pkg/cmd/matrixroom_test.go @@ -0,0 +1,168 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "testing" + + "github.com/beeper/desktop-api-cli/internal/mocktest" + "github.com/beeper/desktop-api-cli/internal/requestflag" +) + +func TestMatrixRoomsCreate(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:rooms", "create", + "--creation-content", "{m.federate: false}", + "--initial-state", "{content: {}, type: type, state_key: state_key}", + "--invite", "string", + "--invite-3pid", "{address: cheeky@monkey.com, id_access_token: abc123_OpaqueString, id_server: matrix.org, medium: email}", + "--is-direct=true", + "--name", "The Grand Duke Pub", + "--power-level-content-override", "{}", + "--preset", "public_chat", + "--room-alias-name", "thepub", + "--room-version", "1", + "--topic", "All about happy hour", + "--visibility", "public", + ) + }) + + t.Run("inner flags", func(t *testing.T) { + // Check that inner flags have been set up correctly + requestflag.CheckInnerFlags(matrixRoomsCreate) + + // Alternative argument passing style using inner flags + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:rooms", "create", + "--creation-content", "{m.federate: false}", + "--initial-state.content", "{}", + "--initial-state.type", "type", + "--initial-state.state-key", "state_key", + "--invite", "string", + "--invite-3pid.address", "cheeky@monkey.com", + "--invite-3pid.id-access-token", "abc123_OpaqueString", + "--invite-3pid.id-server", "matrix.org", + "--invite-3pid.medium", "email", + "--is-direct=true", + "--name", "The Grand Duke Pub", + "--power-level-content-override", "{}", + "--preset", "public_chat", + "--room-alias-name", "thepub", + "--room-version", "1", + "--topic", "All about happy hour", + "--visibility", "public", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("" + + "creation_content:\n" + + " m.federate: false\n" + + "initial_state:\n" + + " - content: {}\n" + + " type: type\n" + + " state_key: state_key\n" + + "invite:\n" + + " - string\n" + + "invite_3pid:\n" + + " - address: cheeky@monkey.com\n" + + " id_access_token: abc123_OpaqueString\n" + + " id_server: matrix.org\n" + + " medium: email\n" + + "is_direct: true\n" + + "name: The Grand Duke Pub\n" + + "power_level_content_override: {}\n" + + "preset: public_chat\n" + + "room_alias_name: thepub\n" + + "room_version: '1'\n" + + "topic: All about happy hour\n" + + "visibility: public\n") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, + "--access-token", "string", + "matrix:rooms", "create", + ) + }) +} + +func TestMatrixRoomsJoin(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:rooms", "join", + "--room-id-or-alias", "!monkeys:matrix.org", + "--via", "string", + "--reason", "Looking for support", + "--third-party-signed", "{token: random8nonce, mxid: bob, sender: alice, signatures: {example.org: {ed25519:0: some9signature}}}", + ) + }) + + t.Run("inner flags", func(t *testing.T) { + // Check that inner flags have been set up correctly + requestflag.CheckInnerFlags(matrixRoomsJoin) + + // Alternative argument passing style using inner flags + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:rooms", "join", + "--room-id-or-alias", "!monkeys:matrix.org", + "--via", "string", + "--reason", "Looking for support", + "--third-party-signed.token", "random8nonce", + "--third-party-signed.mxid", "bob", + "--third-party-signed.sender", "alice", + "--third-party-signed.signatures", "{example.org: {ed25519:0: some9signature}}", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("" + + "reason: Looking for support\n" + + "third_party_signed:\n" + + " token: random8nonce\n" + + " mxid: bob\n" + + " sender: alice\n" + + " signatures:\n" + + " example.org:\n" + + " ed25519:0: some9signature\n") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, + "--access-token", "string", + "matrix:rooms", "join", + "--room-id-or-alias", "!monkeys:matrix.org", + "--via", "string", + ) + }) +} + +func TestMatrixRoomsLeave(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:rooms", "leave", + "--room-id", "!nkl290a:matrix.org", + "--reason", "Saying farewell - thanks for the support!", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("reason: Saying farewell - thanks for the support!") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, + "--access-token", "string", + "matrix:rooms", "leave", + "--room-id", "!nkl290a:matrix.org", + ) + }) +} diff --git a/pkg/cmd/matrixroomaccountdata.go b/pkg/cmd/matrixroomaccountdata.go new file mode 100644 index 0000000..0d78680 --- /dev/null +++ b/pkg/cmd/matrixroomaccountdata.go @@ -0,0 +1,177 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "context" + "fmt" + + "github.com/beeper/desktop-api-cli/internal/apiquery" + "github.com/beeper/desktop-api-cli/internal/requestflag" + "github.com/beeper/desktop-api-go/v5" + "github.com/beeper/desktop-api-go/v5/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var matrixRoomsAccountDataRetrieve = cli.Command{ + Name: "retrieve", + Usage: "Get some account data for the client on a given room. This config is only\nvisible to the user that set the account data.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "user-id", + Required: true, + PathParam: "userId", + }, + &requestflag.Flag[string]{ + Name: "room-id", + Required: true, + PathParam: "roomId", + }, + &requestflag.Flag[string]{ + Name: "type", + Required: true, + PathParam: "type", + }, + }, + Action: handleMatrixRoomsAccountDataRetrieve, + HideHelpCommand: true, +} + +var matrixRoomsAccountDataUpdate = cli.Command{ + Name: "update", + Usage: "Set some account data for the client on a given room. This config is only\nvisible to the user that set the account data. The config will be delivered to\nclients in the per-room entries via\n[/sync](https://spec.matrix.org/v1.18/client-server-api/#get_matrixclientv3sync).", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "user-id", + Required: true, + PathParam: "userId", + }, + &requestflag.Flag[string]{ + Name: "room-id", + Required: true, + PathParam: "roomId", + }, + &requestflag.Flag[string]{ + Name: "type", + Required: true, + PathParam: "type", + }, + &requestflag.Flag[any]{ + Name: "body", + Required: true, + BodyRoot: true, + }, + }, + Action: handleMatrixRoomsAccountDataUpdate, + HideHelpCommand: true, +} + +func handleMatrixRoomsAccountDataRetrieve(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("type") && len(unusedArgs) > 0 { + cmd.Set("type", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + EmptyBody, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.MatrixRoomAccountDataGetParams{ + UserID: cmd.Value("user-id").(string), + RoomID: cmd.Value("room-id").(string), + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Matrix.Rooms.AccountData.Get( + ctx, + cmd.Value("type").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := "json" + explicitFormat := cmd.Root().IsSet("format") + if explicitFormat { + format = cmd.Root().String("format") + } + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "matrix:rooms:account-data retrieve", + Transform: transform, + }) +} + +func handleMatrixRoomsAccountDataUpdate(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("type") && len(unusedArgs) > 0 { + cmd.Set("type", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.MatrixRoomAccountDataUpdateParams{ + UserID: cmd.Value("user-id").(string), + RoomID: cmd.Value("room-id").(string), + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Matrix.Rooms.AccountData.Update( + ctx, + cmd.Value("type").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "matrix:rooms:account-data update", + Transform: transform, + }) +} diff --git a/pkg/cmd/matrixroomaccountdata_test.go b/pkg/cmd/matrixroomaccountdata_test.go new file mode 100644 index 0000000..08d8c65 --- /dev/null +++ b/pkg/cmd/matrixroomaccountdata_test.go @@ -0,0 +1,49 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "testing" + + "github.com/beeper/desktop-api-cli/internal/mocktest" +) + +func TestMatrixRoomsAccountDataRetrieve(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:rooms:account-data", "retrieve", + "--user-id", "@alice:example.com", + "--room-id", "!726s6s6q:example.com", + "--type", "org.example.custom.room.config", + ) + }) +} + +func TestMatrixRoomsAccountDataUpdate(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:rooms:account-data", "update", + "--user-id", "@alice:example.com", + "--room-id", "!726s6s6q:example.com", + "--type", "org.example.custom.room.config", + "--body", "{custom_account_data_key: custom_account_data_value}", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("custom_account_data_key: custom_account_data_value") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, + "--access-token", "string", + "matrix:rooms:account-data", "update", + "--user-id", "@alice:example.com", + "--room-id", "!726s6s6q:example.com", + "--type", "org.example.custom.room.config", + ) + }) +} diff --git a/pkg/cmd/matrixroomevent.go b/pkg/cmd/matrixroomevent.go new file mode 100644 index 0000000..072ae10 --- /dev/null +++ b/pkg/cmd/matrixroomevent.go @@ -0,0 +1,89 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "context" + "fmt" + + "github.com/beeper/desktop-api-cli/internal/apiquery" + "github.com/beeper/desktop-api-cli/internal/requestflag" + "github.com/beeper/desktop-api-go/v5" + "github.com/beeper/desktop-api-go/v5/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var matrixRoomsEventsRetrieve = cli.Command{ + Name: "retrieve", + Usage: "Get a single event based on `roomId/eventId`. You must have permission to\nretrieve this event e.g. by being a member in the room for this event.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "room-id", + Required: true, + PathParam: "roomId", + }, + &requestflag.Flag[string]{ + Name: "event-id", + Required: true, + PathParam: "eventId", + }, + }, + Action: handleMatrixRoomsEventsRetrieve, + HideHelpCommand: true, +} + +func handleMatrixRoomsEventsRetrieve(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("event-id") && len(unusedArgs) > 0 { + cmd.Set("event-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + EmptyBody, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.MatrixRoomEventGetParams{ + RoomID: cmd.Value("room-id").(string), + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Matrix.Rooms.Events.Get( + ctx, + cmd.Value("event-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := "json" + explicitFormat := cmd.Root().IsSet("format") + if explicitFormat { + format = cmd.Root().String("format") + } + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "matrix:rooms:events retrieve", + Transform: transform, + }) +} diff --git a/pkg/cmd/matrixroomevent_test.go b/pkg/cmd/matrixroomevent_test.go new file mode 100644 index 0000000..31d4483 --- /dev/null +++ b/pkg/cmd/matrixroomevent_test.go @@ -0,0 +1,21 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "testing" + + "github.com/beeper/desktop-api-cli/internal/mocktest" +) + +func TestMatrixRoomsEventsRetrieve(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:rooms:events", "retrieve", + "--room-id", "!636q39766251:matrix.org", + "--event-id", "$asfDuShaf7Gafaw:matrix.org", + ) + }) +} diff --git a/pkg/cmd/matrixroomstate.go b/pkg/cmd/matrixroomstate.go new file mode 100644 index 0000000..f09b100 --- /dev/null +++ b/pkg/cmd/matrixroomstate.go @@ -0,0 +1,160 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "context" + "fmt" + + "github.com/beeper/desktop-api-cli/internal/apiquery" + "github.com/beeper/desktop-api-cli/internal/requestflag" + "github.com/beeper/desktop-api-go/v5" + "github.com/beeper/desktop-api-go/v5/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var matrixRoomsStateRetrieve = cli.Command{ + Name: "retrieve", + Usage: "Looks up the contents of a state event in a room. If the user is joined to the\nroom then the state is taken from the current state of the room. If the user has\nleft the room then the state is taken from the state of the room when they left.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "room-id", + Required: true, + PathParam: "roomId", + }, + &requestflag.Flag[string]{ + Name: "event-type", + Required: true, + PathParam: "eventType", + }, + &requestflag.Flag[string]{ + Name: "state-key", + Required: true, + PathParam: "stateKey", + }, + &requestflag.Flag[string]{ + Name: "format", + Usage: "The format to use for the returned data. `content` (the default) will\nreturn only the content of the state event. `event` will return the entire\nevent in the usual format suitable for clients, including fields like event\nID, sender and timestamp.", + QueryPath: "format", + }, + }, + Action: handleMatrixRoomsStateRetrieve, + HideHelpCommand: true, +} + +var matrixRoomsStateList = cli.Command{ + Name: "list", + Usage: "Get the state events for the current state of a room.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "room-id", + Required: true, + PathParam: "roomId", + }, + }, + Action: handleMatrixRoomsStateList, + HideHelpCommand: true, +} + +func handleMatrixRoomsStateRetrieve(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("state-key") && len(unusedArgs) > 0 { + cmd.Set("state-key", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + EmptyBody, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.MatrixRoomStateGetParams{ + RoomID: cmd.Value("room-id").(string), + EventType: cmd.Value("event-type").(string), + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Matrix.Rooms.State.Get( + ctx, + cmd.Value("state-key").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := "json" + explicitFormat := cmd.Root().IsSet("format") + if explicitFormat { + format = cmd.Root().String("format") + } + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "matrix:rooms:state retrieve", + Transform: transform, + }) +} + +func handleMatrixRoomsStateList(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("room-id") && len(unusedArgs) > 0 { + cmd.Set("room-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + EmptyBody, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Matrix.Rooms.State.List(ctx, cmd.Value("room-id").(string), options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := "json" + explicitFormat := cmd.Root().IsSet("format") + if explicitFormat { + format = cmd.Root().String("format") + } + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "matrix:rooms:state list", + Transform: transform, + }) +} diff --git a/pkg/cmd/matrixroomstate_test.go b/pkg/cmd/matrixroomstate_test.go new file mode 100644 index 0000000..8ff3c71 --- /dev/null +++ b/pkg/cmd/matrixroomstate_test.go @@ -0,0 +1,34 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "testing" + + "github.com/beeper/desktop-api-cli/internal/mocktest" +) + +func TestMatrixRoomsStateRetrieve(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:rooms:state", "retrieve", + "--room-id", "!636q39766251:example.com", + "--event-type", "m.room.name", + "--state-key", "state_key", + "--format", "content", + ) + }) +} + +func TestMatrixRoomsStateList(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:rooms:state", "list", + "--room-id", "!636q39766251:example.com", + ) + }) +} diff --git a/pkg/cmd/matrixuser.go b/pkg/cmd/matrixuser.go new file mode 100644 index 0000000..50d1fb0 --- /dev/null +++ b/pkg/cmd/matrixuser.go @@ -0,0 +1,75 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "context" + "fmt" + + "github.com/beeper/desktop-api-cli/internal/apiquery" + "github.com/beeper/desktop-api-cli/internal/requestflag" + "github.com/beeper/desktop-api-go/v5" + "github.com/beeper/desktop-api-go/v5/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var matrixUsersRetrieveProfile = cli.Command{ + Name: "retrieve-profile", + Usage: "Get the complete profile for a user.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "user-id", + Required: true, + PathParam: "userId", + }, + }, + Action: handleMatrixUsersRetrieveProfile, + HideHelpCommand: true, +} + +func handleMatrixUsersRetrieveProfile(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("user-id") && len(unusedArgs) > 0 { + cmd.Set("user-id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + EmptyBody, + false, + ) + if err != nil { + return err + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Matrix.Users.GetProfile(ctx, cmd.Value("user-id").(string), options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := "json" + explicitFormat := cmd.Root().IsSet("format") + if explicitFormat { + format = cmd.Root().String("format") + } + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "matrix:users retrieve-profile", + Transform: transform, + }) +} diff --git a/pkg/cmd/matrixuser_test.go b/pkg/cmd/matrixuser_test.go new file mode 100644 index 0000000..6c48b75 --- /dev/null +++ b/pkg/cmd/matrixuser_test.go @@ -0,0 +1,20 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "testing" + + "github.com/beeper/desktop-api-cli/internal/mocktest" +) + +func TestMatrixUsersRetrieveProfile(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:users", "retrieve-profile", + "--user-id", "@alice:example.com", + ) + }) +} diff --git a/pkg/cmd/matrixuseraccountdata.go b/pkg/cmd/matrixuseraccountdata.go new file mode 100644 index 0000000..dd0347b --- /dev/null +++ b/pkg/cmd/matrixuseraccountdata.go @@ -0,0 +1,165 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "context" + "fmt" + + "github.com/beeper/desktop-api-cli/internal/apiquery" + "github.com/beeper/desktop-api-cli/internal/requestflag" + "github.com/beeper/desktop-api-go/v5" + "github.com/beeper/desktop-api-go/v5/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var matrixUsersAccountDataRetrieve = cli.Command{ + Name: "retrieve", + Usage: "Get some account data for the client. This config is only visible to the user\nthat set the account data.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "user-id", + Required: true, + PathParam: "userId", + }, + &requestflag.Flag[string]{ + Name: "type", + Required: true, + PathParam: "type", + }, + }, + Action: handleMatrixUsersAccountDataRetrieve, + HideHelpCommand: true, +} + +var matrixUsersAccountDataUpdate = cli.Command{ + Name: "update", + Usage: "Set some account data for the client. This config is only visible to the user\nthat set the account data. The config will be available to clients through the\ntop-level `account_data` field in the homeserver response to\n[/sync](https://spec.matrix.org/v1.18/client-server-api/#get_matrixclientv3sync).", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "user-id", + Required: true, + PathParam: "userId", + }, + &requestflag.Flag[string]{ + Name: "type", + Required: true, + PathParam: "type", + }, + &requestflag.Flag[any]{ + Name: "body", + Required: true, + BodyRoot: true, + }, + }, + Action: handleMatrixUsersAccountDataUpdate, + HideHelpCommand: true, +} + +func handleMatrixUsersAccountDataRetrieve(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("type") && len(unusedArgs) > 0 { + cmd.Set("type", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + EmptyBody, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.MatrixUserAccountDataGetParams{ + UserID: cmd.Value("user-id").(string), + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Matrix.Users.AccountData.Get( + ctx, + cmd.Value("type").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := "json" + explicitFormat := cmd.Root().IsSet("format") + if explicitFormat { + format = cmd.Root().String("format") + } + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "matrix:users:account-data retrieve", + Transform: transform, + }) +} + +func handleMatrixUsersAccountDataUpdate(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("type") && len(unusedArgs) > 0 { + cmd.Set("type", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.MatrixUserAccountDataUpdateParams{ + UserID: cmd.Value("user-id").(string), + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Matrix.Users.AccountData.Update( + ctx, + cmd.Value("type").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "matrix:users:account-data update", + Transform: transform, + }) +} diff --git a/pkg/cmd/matrixuseraccountdata_test.go b/pkg/cmd/matrixuseraccountdata_test.go new file mode 100644 index 0000000..af50936 --- /dev/null +++ b/pkg/cmd/matrixuseraccountdata_test.go @@ -0,0 +1,46 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "testing" + + "github.com/beeper/desktop-api-cli/internal/mocktest" +) + +func TestMatrixUsersAccountDataRetrieve(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:users:account-data", "retrieve", + "--user-id", "@alice:example.com", + "--type", "org.example.custom.config", + ) + }) +} + +func TestMatrixUsersAccountDataUpdate(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:users:account-data", "update", + "--user-id", "@alice:example.com", + "--type", "org.example.custom.config", + "--body", "{custom_account_data_key: custom_config_value}", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("custom_account_data_key: custom_config_value") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, + "--access-token", "string", + "matrix:users:account-data", "update", + "--user-id", "@alice:example.com", + "--type", "org.example.custom.config", + ) + }) +} From b23ec6ef972e12e2b27e95bfeec2741566a64182 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 16 May 2026 20:21:44 +0000 Subject: [PATCH 51/52] Update Desktop API Stainless config --- .stats.yml | 4 ++-- README.md | 8 ++++---- pkg/cmd/cmd.go | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.stats.yml b/.stats.yml index b63d20d..62239e7 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 72 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper/beeper-desktop-api-de1370e6a3183044fa135a886d2ee8f779d5e86228cdbd503d553b4c13cc7cbe.yml +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper/beeper-desktop-api-8ba2755730c4180ec88f92a300948445d7917898abfc912ca3fa6adc766a7520.yml openapi_spec_hash: 30b435d7585d8b6951610e7147369779 -config_hash: 683b13ea6fb6aa9d6b1b8814cca24f1c +config_hash: a53888715ed00d433e5a5dafab9f7b9f diff --git a/README.md b/README.md index 6bca084..be90c67 100644 --- a/README.md +++ b/README.md @@ -66,13 +66,13 @@ For details about specific commands, use the `--help` flag. ### Environment variables -| Environment variable | Description | Required | -| --------------------- | ----------------------------------------------------------------------------------------------------- | -------- | -| `BEEPER_ACCESS_TOKEN` | Bearer access token obtained via OAuth2 PKCE flow or created in-app. Required for all API operations. | yes | +| Environment variable | Description | Required | Default value | +| --------------------- | --------------------------------------------------------------------------------------------------------------- | -------- | ------------- | +| `BEEPER_ACCESS_TOKEN` | Bearer access token obtained via OAuth2 PKCE flow or created in-app. Required for authenticated API operations. | no | `null` | ### Global flags -- `--access-token` - Bearer access token obtained via OAuth2 PKCE flow or created in-app. Required for all API operations. (can also be set with `BEEPER_ACCESS_TOKEN` env var) +- `--access-token` - Bearer access token obtained via OAuth2 PKCE flow or created in-app. Required for authenticated API operations. (can also be set with `BEEPER_ACCESS_TOKEN` env var) - `--help` - Show command line usage - `--debug` - Enable debug logging (includes HTTP request/response details) - `--version`, `-v` - Show the CLI version diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index 089dd61..e45969c 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -80,7 +80,7 @@ func init() { }, &requestflag.Flag[string]{ Name: "access-token", - Usage: "Bearer access token obtained via OAuth2 PKCE flow or created in-app. Required for all API operations.", + Usage: "Bearer access token obtained via OAuth2 PKCE flow or created in-app. Required for authenticated API operations.", Sources: cli.EnvVars("BEEPER_ACCESS_TOKEN"), }, }, From 873fe562bf0d1a9eb37f08827cfa7971a8358185 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 16 May 2026 20:22:20 +0000 Subject: [PATCH 52/52] release: 5.0.0 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 55 +++++++++++++++++++++++++++++++++++ pkg/cmd/version.go | 2 +- 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 6b7b74c..8e76abb 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.3.0" + ".": "5.0.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index a8fa2ac..7447e70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,60 @@ # Changelog +## 5.0.0 (2026-05-16) + +Full Changelog: [v0.3.0...v5.0.0](https://github.com/beeper/desktop-api-cli/compare/v0.3.0...v5.0.0) + +### Features + +* allow `-` as value representing stdin to binary-only file parameters in CLIs ([eacfea0](https://github.com/beeper/desktop-api-cli/commit/eacfea0f881332d854999d73092a4786d0b3ae33)) +* **api:** add network, bridge fields to accounts ([ccd116d](https://github.com/beeper/desktop-api-cli/commit/ccd116df91e25275b2f63f53bedf9939aae8a64a)) +* **api:** api update ([3c79717](https://github.com/beeper/desktop-api-cli/commit/3c797176b7ced4499d6bf3ff71f7d3a94967e026)) +* **api:** api update ([3920764](https://github.com/beeper/desktop-api-cli/commit/3920764851961421776d64cf694dee1f999afa3e)) +* **api:** api update ([bf4b43c](https://github.com/beeper/desktop-api-cli/commit/bf4b43c832c3b56adda72018ffd91a7dc0660966)) +* better error message if scheme forgotten in CLI `*_BASE_URL`/`--base-url` ([8d976d4](https://github.com/beeper/desktop-api-cli/commit/8d976d441c86ddfaa67c8fad50a156f9374e0d16)) +* binary-only parameters become CLI flags that take filenames only ([ed64986](https://github.com/beeper/desktop-api-cli/commit/ed64986c234a83e6c875a1202a7c217df9efa206)) +* **cli:** add `--raw-output`/`-r` option to print raw (non-JSON) strings ([963cb22](https://github.com/beeper/desktop-api-cli/commit/963cb2275ff0b6900c09dbbd0cc45c37e745d561)) +* **cli:** alias parameters in data with `x-stainless-cli-data-alias` ([301f6f2](https://github.com/beeper/desktop-api-cli/commit/301f6f2894cae408e91bb5834d50d6c16b78c28f)) +* **cli:** send filename and content type when reading input from files ([b1ab49b](https://github.com/beeper/desktop-api-cli/commit/b1ab49bb30c6d4132fb9827cff19f73d83e7af5c)) +* set CLI flag constant values automatically where `x-stainless-const` is set ([c332ed8](https://github.com/beeper/desktop-api-cli/commit/c332ed822bc6e94cb521620032bdcf941d4b7593)) +* support passing path and query params over stdin ([5fe5a8d](https://github.com/beeper/desktop-api-cli/commit/5fe5a8d8aa02222e08a8407728d8063d08d9fdb9)) + + +### Bug Fixes + +* **cli:** correctly load zsh autocompletion ([2b1da99](https://github.com/beeper/desktop-api-cli/commit/2b1da99d8aa0d518a3a1e90126467b4eabff4a03)) +* fall back to main branch if linking fails in CI ([6960f67](https://github.com/beeper/desktop-api-cli/commit/6960f674e30ae4b66c8c727accf4b140df4aa257)) +* fix for failing to drop invalid module replace in link script ([5480c0e](https://github.com/beeper/desktop-api-cli/commit/5480c0e637e57e45ef1780847a2ec1638ef8550d)) +* fix for off-by-one error in pagination logic ([16566c5](https://github.com/beeper/desktop-api-cli/commit/16566c5d80a77de14f33e1895ec72cfa89da99c7)) +* fix quoting typo ([81a96d9](https://github.com/beeper/desktop-api-cli/commit/81a96d9880aee422f73677cdbe67134af37f6814)) +* flags for nullable body scalar fields are strictly typed ([865b851](https://github.com/beeper/desktop-api-cli/commit/865b85180334a84053ebbc19c718f17c29c9d9d7)) +* handle empty data set using `--format explore` ([6df0024](https://github.com/beeper/desktop-api-cli/commit/6df00248f562bbbd98e7cbccf8ddd82b35a40ae3)) +* use `RawJSON` when iterating items with `--format explore` in the CLI ([4af189d](https://github.com/beeper/desktop-api-cli/commit/4af189dc0e5add5c654204e0e90c91e199de479a)) + + +### Chores + +* add documentation for ./scripts/link ([b57a694](https://github.com/beeper/desktop-api-cli/commit/b57a69441818f20bf3d1bbf949a30b65c55aa728)) +* **ci:** skip lint on metadata-only changes ([d18d972](https://github.com/beeper/desktop-api-cli/commit/d18d972e88c1a03890fcbe3ba2f0dbffb06f5e48)) +* **ci:** support manually triggering release workflow ([5a018e8](https://github.com/beeper/desktop-api-cli/commit/5a018e83f5f9347b262b10176639fcc24ec5f370)) +* **cli:** additional test cases for `ShowJSONIterator` ([79f9f75](https://github.com/beeper/desktop-api-cli/commit/79f9f751f5fd415d9a34893e47b8409aa82dcb7c)) +* **cli:** fall back to JSON when using default "explore" with non-TTY ([de64b1c](https://github.com/beeper/desktop-api-cli/commit/de64b1c7b2620be74c563e4da9ae4ff6a486555e)) +* **cli:** let `--format raw` be used in conjunction with `--transform` ([c37cb3e](https://github.com/beeper/desktop-api-cli/commit/c37cb3e7e49185c7e7e398fb70a254ea8daf2456)) +* **cli:** switch long lists of positional args over to param structs ([d4e5630](https://github.com/beeper/desktop-api-cli/commit/d4e5630718dc3170dac5f9efe0acfd0be2f1a390)) +* **cli:** use `ShowJSONOpts` as argument to `formatJSON` instead of many positionals ([384a7f4](https://github.com/beeper/desktop-api-cli/commit/384a7f434bd6d40b626b6e635f005853f66ff917)) +* **internal:** codegen related update ([ec112ec](https://github.com/beeper/desktop-api-cli/commit/ec112ec91ee336d835177ab87406e72ad585966e)) +* **internal:** more robust bootstrap script ([888af53](https://github.com/beeper/desktop-api-cli/commit/888af53697b79c93159f9fce5e60cca4ea1e5a04)) +* **internal:** update multipart form array serialization ([180bf35](https://github.com/beeper/desktop-api-cli/commit/180bf353b36de3cfee9e9497dca4558622654e18)) +* mark all CLI-related tests in Go with `t.Parallel()` ([59678e1](https://github.com/beeper/desktop-api-cli/commit/59678e17b692460f3fdc03ca82ba6270d13ed460)) +* modify CLI tests to inject stdout so mutating `os.Stdout` isn't necessary ([9e6fe8f](https://github.com/beeper/desktop-api-cli/commit/9e6fe8f0d1cba9d5115fba0b82f24a5938b3e7bc)) +* omit full usage information when missing required CLI parameters ([8b749cf](https://github.com/beeper/desktop-api-cli/commit/8b749cf09b595e66687d4030cee0791aff7e14c4)) +* redact api-key headers in debug logs ([3b786e2](https://github.com/beeper/desktop-api-cli/commit/3b786e24f0f0b206fc0817fa1fc80fa3b084346a)) +* switch some CLI Go tests from `os.Chdir` to `t.Chdir` ([c0de07e](https://github.com/beeper/desktop-api-cli/commit/c0de07ef35b6d776781b0347a102c3855445f0cb)) +* **tests:** bump steady to v0.19.7 ([c7c519c](https://github.com/beeper/desktop-api-cli/commit/c7c519cf80adffc7cebbd3ec220227baf48672d7)) +* **tests:** bump steady to v0.20.1 ([533501d](https://github.com/beeper/desktop-api-cli/commit/533501d2960fc565d5891d5f610a7feef458d0b2)) +* **tests:** bump steady to v0.20.2 ([3a7b085](https://github.com/beeper/desktop-api-cli/commit/3a7b085b2130d21ee0a2b434dbaa3efada1e1bb3)) +* **tests:** bump steady to v0.22.1 ([2164645](https://github.com/beeper/desktop-api-cli/commit/216464542c88aca1c22ebf0e9af3ac0eb6854a38)) + ## 0.3.0 (2026-03-24) Full Changelog: [v0.2.0...v0.3.0](https://github.com/beeper/desktop-api-cli/compare/v0.2.0...v0.3.0) diff --git a/pkg/cmd/version.go b/pkg/cmd/version.go index 5266c84..26ffb4f 100644 --- a/pkg/cmd/version.go +++ b/pkg/cmd/version.go @@ -2,4 +2,4 @@ package cmd -const Version = "0.3.0" // x-release-please-version +const Version = "5.0.0" // x-release-please-version