← back to Cli Printing Press
fix(cli): emit stderr truncation warning on single-page list responses (#1177)
6cda260642f1af9379c66d2a5e7e5fd4f6364245 · 2026-05-12 09:17:52 -0700 · Trevin Chow
* fix(cli): emit stderr truncation warning on single-page list responses
Closes #1137. When a paginated list command returns page 1 without --all
and the response advertises more pages (non-empty next cursor or
has_more=true), paginatedGet now writes a structured stderr event so
agents detect the truncation. Behavior is unchanged on the --all path,
on responses without pagination metadata, and on non-object responses.
* Update helpers.go.tmpl
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
* Update helpers.go.tmpl
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
* Update internal/generator/templates/helpers.go.tmpl
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
* fix(cli): remove stray brace and align goldens with runtime cursor guard
The previous Greptile-suggested fix turned a duplicate `return` into a
duplicate `}`, which prematurely closed `emitTruncationWarning` and made
every regenerated CLI fail to build. The goldens still carried the older
`nextCursorPath != ""` spec-config guard, hiding the divergence.
Drop the stray brace and refresh both goldens to the runtime guard
(`nextCursor != ""`) so template and goldens emit the same code and the
hybrid `{"has_more":true,"next_cursor":null}` case correctly suppresses
the `--all` hint.
---------
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Files touched
A internal/generator/paginated_truncate_test.goM internal/generator/templates/helpers.go.tmplM testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/helpers.goM testdata/golden/expected/generate-golden-api/dogfood.jsonM testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/helpers.go
Diff
commit 6cda260642f1af9379c66d2a5e7e5fd4f6364245
Author: Trevin Chow <trevin@trevinchow.com>
Date: Tue May 12 09:17:52 2026 -0700
fix(cli): emit stderr truncation warning on single-page list responses (#1177)
* fix(cli): emit stderr truncation warning on single-page list responses
Closes #1137. When a paginated list command returns page 1 without --all
and the response advertises more pages (non-empty next cursor or
has_more=true), paginatedGet now writes a structured stderr event so
agents detect the truncation. Behavior is unchanged on the --all path,
on responses without pagination metadata, and on non-object responses.
* Update helpers.go.tmpl
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
* Update helpers.go.tmpl
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
* Update internal/generator/templates/helpers.go.tmpl
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
* fix(cli): remove stray brace and align goldens with runtime cursor guard
The previous Greptile-suggested fix turned a duplicate `return` into a
duplicate `}`, which prematurely closed `emitTruncationWarning` and made
every regenerated CLI fail to build. The goldens still carried the older
`nextCursorPath != ""` spec-config guard, hiding the divergence.
Drop the stray brace and refresh both goldens to the runtime guard
(`nextCursor != ""`) so template and goldens emit the same code and the
hybrid `{"has_more":true,"next_cursor":null}` case correctly suppresses
the `--all` hint.
---------
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
---
internal/generator/paginated_truncate_test.go | 51 +++++++++++++++++++++
internal/generator/templates/helpers.go.tmpl | 52 +++++++++++++++++++++-
.../internal/cli/helpers.go | 52 +++++++++++++++++++++-
.../expected/generate-golden-api/dogfood.json | 2 +-
.../printing-press-golden/internal/cli/helpers.go | 52 +++++++++++++++++++++-
5 files changed, 205 insertions(+), 4 deletions(-)
diff --git a/internal/generator/paginated_truncate_test.go b/internal/generator/paginated_truncate_test.go
new file mode 100644
index 00000000..ff9cabda
--- /dev/null
+++ b/internal/generator/paginated_truncate_test.go
@@ -0,0 +1,51 @@
+package generator
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/mvanhorn/cli-printing-press/v4/internal/spec"
+ "github.com/stretchr/testify/require"
+)
+
+// TestPaginatedGetEmitsTruncationWarning verifies that generated CLIs include
+// the emitTruncationWarning helper and that paginatedGet calls it on the
+// single-page path. The warning is the signal agents rely on to detect
+// page-1 truncation when --all is not passed (issue #1137).
+func TestPaginatedGetEmitsTruncationWarning(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := minimalSpec("paginate-warn")
+ apiSpec.Resources = map[string]spec.Resource{
+ "orders": {
+ Description: "Manage orders",
+ Endpoints: map[string]spec.Endpoint{
+ "list": {
+ Method: "GET",
+ Path: "/orders",
+ Description: "List orders",
+ Pagination: &spec.Pagination{
+ Type: "cursor",
+ CursorParam: "after",
+ NextCursorPath: "next_cursor",
+ HasMoreField: "has_more",
+ },
+ Response: spec.ResponseDef{Type: "array", Item: "Order"},
+ },
+ },
+ },
+ }
+
+ outputDir := filepath.Join(t.TempDir(), "paginate-warn-pp-cli")
+ require.NoError(t, New(apiSpec, outputDir).Generate())
+
+ helpersSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "helpers.go"))
+ require.NoError(t, err)
+ require.Contains(t, string(helpersSrc), "func emitTruncationWarning(",
+ "generated helpers.go should define emitTruncationWarning")
+ require.Contains(t, string(helpersSrc), "emitTruncationWarning(data, nextCursorPath, hasMoreField)",
+ "paginatedGet should call emitTruncationWarning on the single-page path")
+
+ runGoCommand(t, outputDir, "build", "./internal/cli")
+}
diff --git a/internal/generator/templates/helpers.go.tmpl b/internal/generator/templates/helpers.go.tmpl
index cf8bec32..caa99397 100644
--- a/internal/generator/templates/helpers.go.tmpl
+++ b/internal/generator/templates/helpers.go.tmpl
@@ -600,7 +600,12 @@ func paginatedGet(c interface {
}
if !fetchAll {
- return c.GetWithHeaders(path, clean, headers)
+ data, err := c.GetWithHeaders(path, clean, headers)
+ if err != nil {
+ return nil, err
+ }
+ emitTruncationWarning(data, nextCursorPath, hasMoreField)
+ return data, nil
}
// Fetch all pages
@@ -669,6 +674,51 @@ func paginatedGet(c interface {
return json.RawMessage(result), nil
}
+// Silent page-1 truncation is the worst-possible mode for agents,
+// who otherwise compute totals against an incomplete set without
+// passing --all.
+func emitTruncationWarning(data json.RawMessage, nextCursorPath, hasMoreField string) {
+ if nextCursorPath == "" && hasMoreField == "" {
+ return
+ }
+ var obj map[string]json.RawMessage
+ if err := json.Unmarshal(data, &obj); err != nil {
+ return
+ }
+ var nextCursor string
+ if nextCursorPath != "" {
+ if tokenRaw, ok := rawAtPath(obj, nextCursorPath); ok {
+ _ = json.Unmarshal(tokenRaw, &nextCursor)
+ }
+ }
+ var hasMore bool
+ if hasMoreField != "" {
+ if moreRaw, ok := rawAtPath(obj, hasMoreField); ok {
+ _ = json.Unmarshal(moreRaw, &hasMore)
+ }
+ }
+ if nextCursor == "" && !hasMore {
+ return
+ }
+ // --all only advances when a next-cursor is configured. has_more-only
+ // endpoints have no cursor to set on the next page, so the --all loop
+ // re-fetches the same response forever. Don't advertise an escape
+ // hatch that doesn't work for this topology.
+ if nextCursor != "" {
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, "warning: results truncated; more pages available. Re-run with --all to fetch every page.\n")
+ } else {
+ fmt.Fprintf(os.Stderr, `{"event":"truncated","hint":"pass --all to fetch every page"}`+"\n")
+ }
+ return
+ }
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, "warning: results truncated; more pages available.\n")
+ } else {
+ fmt.Fprintf(os.Stderr, `{"event":"truncated"}`+"\n")
+ }
+}
+
func extractPaginatedItems(obj map[string]json.RawMessage) ([]json.RawMessage, bool) {
for _, field := range []string{"data", "items", "results", "messages", "members", "values"} {
if arr, ok := obj[field]; ok {
diff --git a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/helpers.go b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/helpers.go
index 6dbef5a3..f6db72c0 100644
--- a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/helpers.go
+++ b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/helpers.go
@@ -400,7 +400,12 @@ func paginatedGet(c interface {
}
if !fetchAll {
- return c.GetWithHeaders(path, clean, headers)
+ data, err := c.GetWithHeaders(path, clean, headers)
+ if err != nil {
+ return nil, err
+ }
+ emitTruncationWarning(data, nextCursorPath, hasMoreField)
+ return data, nil
}
// Fetch all pages
@@ -469,6 +474,51 @@ func paginatedGet(c interface {
return json.RawMessage(result), nil
}
+// Silent page-1 truncation is the worst-possible mode for agents,
+// who otherwise compute totals against an incomplete set without
+// passing --all.
+func emitTruncationWarning(data json.RawMessage, nextCursorPath, hasMoreField string) {
+ if nextCursorPath == "" && hasMoreField == "" {
+ return
+ }
+ var obj map[string]json.RawMessage
+ if err := json.Unmarshal(data, &obj); err != nil {
+ return
+ }
+ var nextCursor string
+ if nextCursorPath != "" {
+ if tokenRaw, ok := rawAtPath(obj, nextCursorPath); ok {
+ _ = json.Unmarshal(tokenRaw, &nextCursor)
+ }
+ }
+ var hasMore bool
+ if hasMoreField != "" {
+ if moreRaw, ok := rawAtPath(obj, hasMoreField); ok {
+ _ = json.Unmarshal(moreRaw, &hasMore)
+ }
+ }
+ if nextCursor == "" && !hasMore {
+ return
+ }
+ // --all only advances when a next-cursor is configured. has_more-only
+ // endpoints have no cursor to set on the next page, so the --all loop
+ // re-fetches the same response forever. Don't advertise an escape
+ // hatch that doesn't work for this topology.
+ if nextCursor != "" {
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, "warning: results truncated; more pages available. Re-run with --all to fetch every page.\n")
+ } else {
+ fmt.Fprintf(os.Stderr, `{"event":"truncated","hint":"pass --all to fetch every page"}`+"\n")
+ }
+ return
+ }
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, "warning: results truncated; more pages available.\n")
+ } else {
+ fmt.Fprintf(os.Stderr, `{"event":"truncated"}`+"\n")
+ }
+}
+
func extractPaginatedItems(obj map[string]json.RawMessage) ([]json.RawMessage, bool) {
for _, field := range []string{"data", "items", "results", "messages", "members", "values"} {
if arr, ok := obj[field]; ok {
diff --git a/testdata/golden/expected/generate-golden-api/dogfood.json b/testdata/golden/expected/generate-golden-api/dogfood.json
index a2d47881..56d2563e 100644
--- a/testdata/golden/expected/generate-golden-api/dogfood.json
+++ b/testdata/golden/expected/generate-golden-api/dogfood.json
@@ -16,7 +16,7 @@
},
"dead_functions": {
"dead": 0,
- "total": 57
+ "total": 58
},
"dir": "<ARTIFACT_DIR>/generate-golden-api/printing-press-golden",
"example_check": {
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/helpers.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/helpers.go
index aaa19131..e9f50563 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/helpers.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/helpers.go
@@ -403,7 +403,12 @@ func paginatedGet(c interface {
}
if !fetchAll {
- return c.GetWithHeaders(path, clean, headers)
+ data, err := c.GetWithHeaders(path, clean, headers)
+ if err != nil {
+ return nil, err
+ }
+ emitTruncationWarning(data, nextCursorPath, hasMoreField)
+ return data, nil
}
// Fetch all pages
@@ -472,6 +477,51 @@ func paginatedGet(c interface {
return json.RawMessage(result), nil
}
+// Silent page-1 truncation is the worst-possible mode for agents,
+// who otherwise compute totals against an incomplete set without
+// passing --all.
+func emitTruncationWarning(data json.RawMessage, nextCursorPath, hasMoreField string) {
+ if nextCursorPath == "" && hasMoreField == "" {
+ return
+ }
+ var obj map[string]json.RawMessage
+ if err := json.Unmarshal(data, &obj); err != nil {
+ return
+ }
+ var nextCursor string
+ if nextCursorPath != "" {
+ if tokenRaw, ok := rawAtPath(obj, nextCursorPath); ok {
+ _ = json.Unmarshal(tokenRaw, &nextCursor)
+ }
+ }
+ var hasMore bool
+ if hasMoreField != "" {
+ if moreRaw, ok := rawAtPath(obj, hasMoreField); ok {
+ _ = json.Unmarshal(moreRaw, &hasMore)
+ }
+ }
+ if nextCursor == "" && !hasMore {
+ return
+ }
+ // --all only advances when a next-cursor is configured. has_more-only
+ // endpoints have no cursor to set on the next page, so the --all loop
+ // re-fetches the same response forever. Don't advertise an escape
+ // hatch that doesn't work for this topology.
+ if nextCursor != "" {
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, "warning: results truncated; more pages available. Re-run with --all to fetch every page.\n")
+ } else {
+ fmt.Fprintf(os.Stderr, `{"event":"truncated","hint":"pass --all to fetch every page"}`+"\n")
+ }
+ return
+ }
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, "warning: results truncated; more pages available.\n")
+ } else {
+ fmt.Fprintf(os.Stderr, `{"event":"truncated"}`+"\n")
+ }
+}
+
func extractPaginatedItems(obj map[string]json.RawMessage) ([]json.RawMessage, bool) {
for _, field := range []string{"data", "items", "results", "messages", "members", "values"} {
if arr, ok := obj[field]; ok {
← 4aba68e6 docs(cli): cross-reference #1199 in mcp-handler positional-p
·
back to Cli Printing Press
·
fix(cli): map Cobra/pflag pre-RunE usage errors to exit code dabf63f8 →