[object Object]

← 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

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 →