From 0f6c1401dc094ce2be1c0bd7e48757cd4eab7f4c Mon Sep 17 00:00:00 2001 From: Bharat Date: Thu, 29 Oct 2026 17:14:31 +0545 Subject: [PATCH 1/3] PLAT-1314/feat: browse paginated responses interactively --- README.md | 15 ++ cmd/twoctl/cli/interactive_pagination.go | 92 ++++++++ cmd/twoctl/cli/interactive_pagination_test.go | 199 ++++++++++++++++++ cmd/twoctl/cli/operations.go | 8 +- cmd/twoctl/cli/paginate.go | 21 +- 5 files changed, 332 insertions(+), 3 deletions(-) create mode 100644 cmd/twoctl/cli/interactive_pagination.go create mode 100644 cmd/twoctl/cli/interactive_pagination_test.go diff --git a/README.md b/README.md index c30e81b..809a0ed 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,20 @@ Top-level non-resource commands: - `-o table|json|yaml` (default `auto`: table on TTY, JSON when piped). - Errors are emitted as JSON on stderr with shape `{error, message, status, response, request_id}`. Stdout stays clean for piping. +### Pagination + +Operations with a `page_cursor` parameter support `--all` to collect every +page and `--no-paginate` to explicitly fetch one page. Without either flag, +table output on an interactive terminal renders one complete page, then asks +`next page? [Y/n]` on stderr when a next cursor is present. Enter or `y` +loads the next page; `n`, other answers, or end-of-input stops. + +Prompts require terminal stdin, stdout and stderr. JSON/YAML output, pipes, +redirected streams and `--file -` never prompt, preserving single-page +machine-readable responses and request-body stdin. `--all` still works in +those modes and emits one combined result. `--all` and `--no-paginate` +cannot be combined. Dry runs do not fetch or prompt. + ### Exit codes | Code | Meaning | @@ -303,3 +317,4 @@ removed. ## Licence MIT - see [LICENSE](LICENSE). + diff --git a/cmd/twoctl/cli/interactive_pagination.go b/cmd/twoctl/cli/interactive_pagination.go new file mode 100644 index 0000000..1909cfe --- /dev/null +++ b/cmd/twoctl/cli/interactive_pagination.go @@ -0,0 +1,92 @@ +package cli + +import ( + "bufio" + "context" + "fmt" + "io" + "net/http" + "strings" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/spf13/cobra" + "golang.org/x/term" + + "github.com/two-inc/twoctl-cli/internal/output" +) + +// Kept injectable so CI can exercise terminal policy without a real terminal. +var paginationTerminal = func(stream any) bool { + fd, ok := stream.(interface{ Fd() uintptr }) + return ok && term.IsTerminal(int(fd.Fd())) +} + +func shouldPromptPagination(c *cobra.Command, b *opBindings) bool { + if flagBool(c, "no-paginate", "twoctl-no-paginate") || flagBool(c, "all", "twoctl-all") { + return false + } + // Never consume request-body stdin as an answer, even if it is a TTY. + if b.bodyFile != nil && *b.bodyFile == "-" { + return false + } + format, err := resolveOutputFormat(c) + return err == nil && format == output.FormatTable && + paginationTerminal(c.InOrStdin()) && + paginationTerminal(c.OutOrStdout()) && + paginationTerminal(c.ErrOrStderr()) +} + +func runInteractivePages(c *cobra.Command, client *http.Client, baseURL, method, path string, op *openapi3.Operation, b *opBindings, body []byte, cursor *string) error { + input := bufio.NewReader(c.InOrStdin()) + _, err := fetchPages(c, client, baseURL, method, path, op, b, body, cursor, func(raw []byte, next string) (bool, error) { + // Preserve the complete page (including metadata and numeric precision) + // using the existing table renderer's JSON fallback for envelopes. + if err := output.Render(c.OutOrStdout(), output.FormatTable, raw); err != nil { + return false, err + } + if next == "" { + return false, nil + } + return askNextPage(c.Context(), input, c.ErrOrStderr()) + }) + return err +} + +func askNextPage(ctx context.Context, input *bufio.Reader, stderr io.Writer) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + if _, err := fmt.Fprint(stderr, "next page? [Y/n] "); err != nil { + return false, err + } + type reply struct { + answer string + err error + } + replies := make(chan reply, 1) + go func() { + answer, err := input.ReadString('\n') + replies <- reply{answer, err} + }() + var answer string + var err error + select { + case <-ctx.Done(): + return false, ctx.Err() + case r := <-replies: + answer, err = r.answer, r.err + } + if err != nil { + // EOF is a decline, not the empty-line default: do not drain pages + // when a terminal closes or the command has no more input. + if err == io.EOF { + return false, nil + } + return false, err + } + if err := ctx.Err(); err != nil { + return false, err + } + answer = strings.ToLower(strings.TrimSpace(answer)) + return answer == "" || answer == "y" || answer == "yes", nil +} diff --git a/cmd/twoctl/cli/interactive_pagination_test.go b/cmd/twoctl/cli/interactive_pagination_test.go new file mode 100644 index 0000000..3b78623 --- /dev/null +++ b/cmd/twoctl/cli/interactive_pagination_test.go @@ -0,0 +1,199 @@ +package cli + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/spf13/cobra" +) + +func interactiveTestCommand(t *testing.T, input string) (*cobra.Command, *bytes.Buffer, *bytes.Buffer) { + t.Helper() + resetGlobals(t) + old := paginationTerminal + paginationTerminal = func(any) bool { return true } + t.Cleanup(func() { paginationTerminal = old }) + flagOutput = "table" + op := &openapi3.Operation{Parameters: openapi3.Parameters{ + {Value: &openapi3.Parameter{Name: cursorParamName, In: "query"}}, + }} + cmd := buildOperationCommand("list", "/items", "GET", op) + var out, stderr bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&stderr) + cmd.SetIn(strings.NewReader(input)) + cmd.SetContext(context.Background()) + return cmd, &out, &stderr +} + +func TestInteractivePaginationAnswers(t *testing.T) { + for _, tc := range []struct { + name, input string + pages int + }{ + {"default yes then no", "\nn\n", 2}, + {"yes uppercase", "YES\n", 2}, + {"no", "n\n", 1}, + {"other", "maybe\n", 1}, + {"eof", "", 1}, + {"unfinished yes", "y", 1}, + } { + t.Run(tc.name, func(t *testing.T) { + cmd, out, stderr := interactiveTestCommand(t, tc.input) + pages := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + pages++ + if pages == 2 && r.URL.Query().Get(cursorParamName) != "second" { + t.Errorf("second page cursor: %s", r.URL.RawQuery) + } + next := "second" + if pages == 2 { + next = "" + } + fmt.Fprintf(w, `{"items":[{"id":9007199254740993,"page":%d}],"next_page_cursor":%q}`, pages, next) + })) + defer srv.Close() + flagURL, flagAPIKey = srv.URL, "secret_test_x" + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + if pages != tc.pages { + t.Fatalf("pages = %d, want %d", pages, tc.pages) + } + if !strings.Contains(out.String(), "9007199254740993") { + t.Fatal("numeric precision lost") + } + if strings.Contains(out.String(), "next page?") { + t.Fatal("prompt leaked to stdout") + } + if stderr.String() != "next page? [Y/n] " { + t.Fatalf("stderr: %q", stderr.String()) + } + }) + } +} + +func TestInteractivePaginationPolicy(t *testing.T) { + for _, tc := range []struct { + name, format string + args []string + terminal bool + pages int + }{ + {"json", "json", nil, true, 1}, + {"yaml", "yaml", nil, true, 1}, + {"pipe", "table", nil, false, 1}, + {"single page", "table", []string{"--no-paginate"}, true, 1}, + {"all json", "json", []string{"--all"}, true, 2}, + {"all table", "table", []string{"--all"}, true, 2}, + } { + t.Run(tc.name, func(t *testing.T) { + cmd, _, stderr := interactiveTestCommand(t, "y\n") + flagOutput = tc.format + paginationTerminal = func(any) bool { return tc.terminal } + pages := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + pages++ + next := "second" + if pages == 2 { + next = "" + } + fmt.Fprintf(w, `{"items":[{"id":%d}],"next_page_cursor":%q}`, pages, next) + })) + defer srv.Close() + flagURL, flagAPIKey = srv.URL, "secret_test_x" + cmd.SetArgs(tc.args) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + if pages != tc.pages || stderr.Len() != 0 { + t.Fatalf("pages=%d stderr=%q", pages, stderr.String()) + } + }) + } +} + +func TestInteractivePaginationStreamAndBodyPolicy(t *testing.T) { + cmd, _, _ := interactiveTestCommand(t, "") + b := &opBindings{} + for _, stream := range []any{cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr()} { + paginationTerminal = func(candidate any) bool { return candidate != stream } + if shouldPromptPagination(cmd, b) { + t.Fatal("redirected stream allowed prompting") + } + } + paginationTerminal = func(any) bool { return true } + stdin := "-" + b.bodyFile = &stdin + if shouldPromptPagination(cmd, b) { + t.Fatal("request-body stdin allowed prompting") + } +} + +func TestPaginationFlagsConflictAndDryRun(t *testing.T) { + cmd, _, stderr := interactiveTestCommand(t, "y\n") + cmd.SetArgs([]string{"--all", "--no-paginate"}) + var ae *apiError + if err := cmd.Execute(); !errors.As(err, &ae) || ae.exit != ExitUsage { + t.Fatalf("want usage error, got %v", err) + } + cmd, out, stderr := interactiveTestCommand(t, "y\n") + cmd.SetArgs([]string{"--dry-run"}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + if stderr.Len() != 0 || !strings.Contains(out.String(), "has_body") { + t.Fatalf("dry run out=%q stderr=%q", out.String(), stderr.String()) + } +} + +func TestInteractivePaginationLoopAndCursorRestore(t *testing.T) { + cmd, _, _ := interactiveTestCommand(t, "\n\n\n") + pages := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + pages++ + fmt.Fprint(w, `{"items":[{"id":1}],"next_page_cursor":"same"}`) + })) + defer srv.Close() + flagURL, flagAPIKey = srv.URL, "secret_test_x" + cmd.SetArgs([]string{"--page-cursor", "original"}) + if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "loop detected") { + t.Fatalf("want loop error, got %v", err) + } + cursor, _ := cmd.Flags().GetString("page-cursor") + if pages != 2 || cursor != "original" { + t.Fatalf("pages=%d cursor=%q", pages, cursor) + } +} + +func TestNextPageCancellation(t *testing.T) { + in, writer := io.Pipe() + defer in.Close() + defer writer.Close() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { + _, err := askNextPage(ctx, bufio.NewReader(in), io.Discard) + done <- err + }() + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("prompt ignored cancellation") + } +} diff --git a/cmd/twoctl/cli/operations.go b/cmd/twoctl/cli/operations.go index 2386000..5e7ee25 100644 --- a/cmd/twoctl/cli/operations.go +++ b/cmd/twoctl/cli/operations.go @@ -215,6 +215,8 @@ func buildOperationCommand(name, path, method string, op *openapi3.Operation) *c if hasPaginationParam(op) { registerBoolUnlessTaken(cmd, "all", "twoctl-all", "follow next_page_cursor and emit every page concatenated") + registerBoolUnlessTaken(cmd, "no-paginate", "twoctl-no-paginate", + "fetch only one page without prompting") } cmd.RunE = func(c *cobra.Command, args []string) error { @@ -227,7 +229,10 @@ func buildOperationCommand(name, path, method string, op *openapi3.Operation) *c if describe { return printDescribe(c, method, path, op) } - if all && !dryRun { + if all && flagBool(c, "no-paginate", "twoctl-no-paginate") { + return &apiError{code: "conflicting_pagination_flags", message: "--all and --no-paginate cannot be combined", exit: ExitUsage} + } + if !dryRun && (all || (hasPaginationParam(op) && shouldPromptPagination(c, bindings))) { return runPaginated(c, method, path, op, bindings) } return runOperation(c, method, path, op, bindings, dryRun) @@ -794,3 +799,4 @@ func buildOpLong(method, path string, op *openapi3.Operation) string { } return b.String() } + diff --git a/cmd/twoctl/cli/paginate.go b/cmd/twoctl/cli/paginate.go index 098291e..5acd935 100644 --- a/cmd/twoctl/cli/paginate.go +++ b/cmd/twoctl/cli/paginate.go @@ -77,6 +77,10 @@ func runPaginated(c *cobra.Command, method, path string, op *openapi3.Operation, } } + if shouldPromptPagination(c, b) && !flagBool(c, "all", "twoctl-all") { + return runInteractivePages(c, client, resolved.BaseURL, method, path, op, b, bodyBytes, cursorPtr) + } + combined, err = fetchAllPages(c, client, resolved.BaseURL, method, path, op, b, bodyBytes, cursorPtr) if err != nil { return err @@ -105,8 +109,16 @@ func runPaginated(c *cobra.Command, method, path string, op *openapi3.Operation, // and max-page guards. Extracted from runPaginated so the cyclomatic // complexity of the caller stays under the lint threshold. func fetchAllPages(c *cobra.Command, client *http.Client, baseURL, method, path string, op *openapi3.Operation, b *opBindings, bodyBytes []byte, cursorPtr *string) ([]any, error) { + return fetchPages(c, client, baseURL, method, path, op, b, bodyBytes, cursorPtr, nil) +} + +// fetchPages shares request construction and safety guards between --all and +// interactive browsing. A visitor streams each page; nil collects all items. +func fetchPages(c *cobra.Command, client *http.Client, baseURL, method, path string, op *openapi3.Operation, b *opBindings, bodyBytes []byte, cursorPtr *string, visit func([]byte, string) (bool, error)) ([]any, error) { + originalCursor := *cursorPtr + defer func() { *cursorPtr = originalCursor }() var combined []any - seen := map[string]struct{}{} + seen := map[string]struct{}{originalCursor: {}} for page := 0; page < maxPaginatedPages; page++ { req, err := buildPageRequest(c, baseURL, method, path, op, b, bodyBytes) if err != nil { @@ -117,7 +129,11 @@ func fetchAllPages(c *cobra.Command, client *http.Client, baseURL, method, path return nil, err } items, next := splitPage(raw) - combined = append(combined, items...) + if visit == nil { + combined = append(combined, items...) + } else if more, err := visit(raw, next); err != nil || !more { + return nil, err + } if next == "" { return combined, nil } @@ -195,3 +211,4 @@ func splitPage(raw []byte) ([]any, string) { } return items, next } + From abc7168af540ba09b3605da57e6f776b9ef92c7e Mon Sep 17 00:00:00 2001 From: Bharat Date: Thu, 29 Oct 2026 17:17:15 +0545 Subject: [PATCH 2/3] PLAT-1314/fix: format pagination changes and verify blocked prompt cancellation --- README.md | 1 - cmd/twoctl/cli/interactive_pagination_test.go | 21 ++++++++++++++++--- cmd/twoctl/cli/operations.go | 1 - cmd/twoctl/cli/paginate.go | 1 - 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 809a0ed..73c97e6 100644 --- a/README.md +++ b/README.md @@ -317,4 +317,3 @@ removed. ## Licence MIT - see [LICENSE](LICENSE). - diff --git a/cmd/twoctl/cli/interactive_pagination_test.go b/cmd/twoctl/cli/interactive_pagination_test.go index 3b78623..890d510 100644 --- a/cmd/twoctl/cli/interactive_pagination_test.go +++ b/cmd/twoctl/cli/interactive_pagination_test.go @@ -177,9 +177,8 @@ func TestInteractivePaginationLoopAndCursorRestore(t *testing.T) { } func TestNextPageCancellation(t *testing.T) { - in, writer := io.Pipe() - defer in.Close() - defer writer.Close() + in := &paginationBlockedReader{entered: make(chan struct{}), release: make(chan struct{})} + defer close(in.release) ctx, cancel := context.WithCancel(context.Background()) defer cancel() done := make(chan error, 1) @@ -187,6 +186,11 @@ func TestNextPageCancellation(t *testing.T) { _, err := askNextPage(ctx, bufio.NewReader(in), io.Discard) done <- err }() + select { + case <-in.entered: + case <-time.After(time.Second): + t.Fatal("prompt never entered input read") + } cancel() select { case err := <-done: @@ -197,3 +201,14 @@ func TestNextPageCancellation(t *testing.T) { t.Fatal("prompt ignored cancellation") } } + +type paginationBlockedReader struct { + entered chan struct{} + release chan struct{} +} + +func (r *paginationBlockedReader) Read([]byte) (int, error) { + close(r.entered) + <-r.release + return 0, io.EOF +} diff --git a/cmd/twoctl/cli/operations.go b/cmd/twoctl/cli/operations.go index 5e7ee25..2bb6c64 100644 --- a/cmd/twoctl/cli/operations.go +++ b/cmd/twoctl/cli/operations.go @@ -799,4 +799,3 @@ func buildOpLong(method, path string, op *openapi3.Operation) string { } return b.String() } - diff --git a/cmd/twoctl/cli/paginate.go b/cmd/twoctl/cli/paginate.go index 5acd935..1cb5391 100644 --- a/cmd/twoctl/cli/paginate.go +++ b/cmd/twoctl/cli/paginate.go @@ -211,4 +211,3 @@ func splitPage(raw []byte) ([]any, string) { } return items, next } - From 54f381b2579c931f6beffe8558054e75a0477159 Mon Sep 17 00:00:00 2001 From: Bharat Date: Thu, 29 Oct 2026 17:20:24 +0545 Subject: [PATCH 3/3] PLAT-1314/fix: follow cursor metadata from actual API response envelopes --- cmd/twoctl/cli/interactive_pagination_test.go | 44 +++++++++++++++++-- cmd/twoctl/cli/paginate.go | 27 ++++++++---- 2 files changed, 60 insertions(+), 11 deletions(-) diff --git a/cmd/twoctl/cli/interactive_pagination_test.go b/cmd/twoctl/cli/interactive_pagination_test.go index 890d510..9f783c9 100644 --- a/cmd/twoctl/cli/interactive_pagination_test.go +++ b/cmd/twoctl/cli/interactive_pagination_test.go @@ -60,7 +60,7 @@ func TestInteractivePaginationAnswers(t *testing.T) { if pages == 2 { next = "" } - fmt.Fprintf(w, `{"items":[{"id":9007199254740993,"page":%d}],"next_page_cursor":%q}`, pages, next) + fmt.Fprintf(w, `{"items":[{"id":9007199254740993,"page":%d}],"cursor_page_metadata":{"next_page_cursor":%q}}`, pages, next) })) defer srv.Close() flagURL, flagAPIKey = srv.URL, "secret_test_x" @@ -108,7 +108,7 @@ func TestInteractivePaginationPolicy(t *testing.T) { if pages == 2 { next = "" } - fmt.Fprintf(w, `{"items":[{"id":%d}],"next_page_cursor":%q}`, pages, next) + fmt.Fprintf(w, `{"items":[{"id":%d}],"cursor_page_metadata":{"next_page_cursor":%q}}`, pages, next) })) defer srv.Close() flagURL, flagAPIKey = srv.URL, "secret_test_x" @@ -162,7 +162,7 @@ func TestInteractivePaginationLoopAndCursorRestore(t *testing.T) { pages := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { pages++ - fmt.Fprint(w, `{"items":[{"id":1}],"next_page_cursor":"same"}`) + fmt.Fprint(w, `{"items":[{"id":1}],"cursor_page_metadata":{"next_page_cursor":"same"}}`) })) defer srv.Close() flagURL, flagAPIKey = srv.URL, "secret_test_x" @@ -212,3 +212,41 @@ func (r *paginationBlockedReader) Read([]byte) (int, error) { <-r.release return 0, io.EOF } + +func TestBillingStatementSchemaPagination(t *testing.T) { + for _, all := range []bool{false, true} { + t.Run(fmt.Sprintf("all=%v", all), func(t *testing.T) { + interactiveTestCommand(t, "") + t.Setenv("TWOCTL_SKIP_UPGRADE_CHECK", "1") + Root().SetIn(strings.NewReader("\n")) + t.Cleanup(func() { Root().SetIn(nil) }) + pages := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + pages++ + next := "second" + if pages == 2 { + next = "" + if r.URL.Query().Get(cursorParamName) != "second" { + t.Errorf("cursor: %s", r.URL.RawQuery) + } + } + fmt.Fprintf(w, `{"statements":[{"statement_id":%d}],"cursor_page_metadata":{"next_page_cursor":%q,"limit":1}}`, pages, next) + })) + defer srv.Close() + args := []string{"--url", srv.URL, "--api-key", "secret_test_x", "-o", "table", "billing-statement", "get"} + if all { + args = append(args, "--all") + } + out, stderr, err := runRoot(t, args...) + if err != nil { + t.Fatal(err) + } + if pages != 2 || !strings.Contains(out, "2") { + t.Fatalf("pages=%d stdout=%q", pages, out) + } + if strings.Contains(stderr, "next page?") == all { + t.Fatalf("all=%v stderr=%q", all, stderr) + } + }) + } +} diff --git a/cmd/twoctl/cli/paginate.go b/cmd/twoctl/cli/paginate.go index 1cb5391..d73402c 100644 --- a/cmd/twoctl/cli/paginate.go +++ b/cmd/twoctl/cli/paginate.go @@ -1,7 +1,7 @@ package cli // Cursor-style pagination. Operations that expose a `page_cursor` query -// parameter and return a body with `next_page_cursor` get an extra `--all` +// parameter and return `cursor_page_metadata.next_page_cursor` get an extra `--all` // flag that follows the cursor until exhaustion, concatenating each page's // `items` array into a single JSON output. // @@ -142,11 +142,11 @@ func fetchPages(c *cobra.Command, client *http.Client, baseURL, method, path str } seen[next] = struct{}{} if len(items) == 0 { - return nil, fmt.Errorf("server returned empty items with a non-empty next_page_cursor; aborting --all") + return nil, fmt.Errorf("server returned empty items with a non-empty next_page_cursor; aborting pagination") } *cursorPtr = next } - return nil, fmt.Errorf("--all hit %d-page safety cap; narrow the query or paginate manually", maxPaginatedPages) + return nil, fmt.Errorf("pagination hit %d-page safety cap; narrow the query or paginate manually", maxPaginatedPages) } func buildPageRequest(c *cobra.Command, baseURL, method, path string, op *openapi3.Operation, b *opBindings, bodyBytes []byte) (*http.Request, error) { @@ -195,19 +195,30 @@ func fetchPage(client *http.Client, req *http.Request) ([]byte, error) { // either field is treated as absent. func splitPage(raw []byte) ([]any, string) { var page map[string]any - if err := json.Unmarshal(raw, &page); err != nil { + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + if err := decoder.Decode(&page); err != nil { return nil, "" } var items []any - switch v := page[itemsResponseName].(type) { + values := page[itemsResponseName] + if values == nil { + // BillingStatementsResponse uses statements; other cursor responses + // in the vendored specs use items. + values = page["statements"] + } + switch v := values.(type) { case []any: items = v case nil: // null or missing - leave items empty. } - var next string - if c, ok := page[cursorResponseName].(string); ok { - next = c + // The API specs nest cursor metadata. Keep the legacy root-level + // cursor shape supported for compatibility with existing callers. + cursor := page[cursorResponseName] + if metadata, ok := page["cursor_page_metadata"].(map[string]any); ok { + cursor = metadata[cursorResponseName] } + next, _ := cursor.(string) return items, next }