← back to Cli Printing Press
fix(cli): emit StringVar for cursor/page/timestamp pagination flags (#435)
1556ff1b78f0d6dd7e60ce0e07562769dd2fe80d · 2026-04-30 09:31:48 -0700 · Matt Van Horn
Generated CLIs declared pagination params (cursor, min_time, page, etc) as
Float64Var. Go's default float formatter renders values at or above 10^6 in
scientific notation (1.740168616e+09), which upstream APIs reject as invalid
integer cursors. The bug stayed hidden during page 1 and during local testing
with small literal cursors, then broke on every paginating call where the
server returned a real Unix-second or Unix-millisecond cursor.
Add isCursorParam classifier mirroring isIDParam, route it through
cobraFlagFuncForParam, goTypeForParam, defaultValForParam, and the
zeroValForParam template helper. StringVar handles opaque tokens, numeric
strings, and integer literals uniformly, matching the existing
profile-videos --max-cursor string precedent.
Also fix the dry-run printer in client.go.tmpl to emit ? for the first query
param and & for subsequent ones, with sorted keys for deterministic output.
The actual HTTP request was always assembled correctly; only the preview was
misleading.
Goldens updated. Note: the helpers.go, promoted_public.go, dogfood.json, and
scorecard.json deltas in this update are pre-existing drift from
8ceea6a (printJSONFiltered + resolveRead comment) that wasn't captured in
the prior golden refresh; the client.go delta is the new behavior from this
change.
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Files touched
A internal/generator/cursor_param_test.goM internal/generator/generator.goM internal/generator/templates/client.go.tmplM testdata/golden/expected/generate-golden-api/dogfood.jsonM testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/helpers.goM testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/promoted_public.goM testdata/golden/expected/generate-golden-api/printing-press-golden/internal/client/client.goM testdata/golden/expected/generate-golden-api/scorecard.json
Diff
commit 1556ff1b78f0d6dd7e60ce0e07562769dd2fe80d
Author: Matt Van Horn <mvanhorn@users.noreply.github.com>
Date: Thu Apr 30 09:31:48 2026 -0700
fix(cli): emit StringVar for cursor/page/timestamp pagination flags (#435)
Generated CLIs declared pagination params (cursor, min_time, page, etc) as
Float64Var. Go's default float formatter renders values at or above 10^6 in
scientific notation (1.740168616e+09), which upstream APIs reject as invalid
integer cursors. The bug stayed hidden during page 1 and during local testing
with small literal cursors, then broke on every paginating call where the
server returned a real Unix-second or Unix-millisecond cursor.
Add isCursorParam classifier mirroring isIDParam, route it through
cobraFlagFuncForParam, goTypeForParam, defaultValForParam, and the
zeroValForParam template helper. StringVar handles opaque tokens, numeric
strings, and integer literals uniformly, matching the existing
profile-videos --max-cursor string precedent.
Also fix the dry-run printer in client.go.tmpl to emit ? for the first query
param and & for subsequent ones, with sorted keys for deterministic output.
The actual HTTP request was always assembled correctly; only the preview was
misleading.
Goldens updated. Note: the helpers.go, promoted_public.go, dogfood.json, and
scorecard.json deltas in this update are pre-existing drift from
8ceea6a (printJSONFiltered + resolveRead comment) that wasn't captured in
the prior golden refresh; the client.go delta is the new behavior from this
change.
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
---
internal/generator/cursor_param_test.go | 134 +++++++++++++++++++++
internal/generator/generator.go | 42 ++++++-
internal/generator/templates/client.go.tmpl | 26 +++-
.../expected/generate-golden-api/dogfood.json | 8 +-
.../printing-press-golden/internal/cli/helpers.go | 12 ++
.../internal/cli/promoted_public.go | 3 +
.../internal/client/client.go | 19 ++-
.../expected/generate-golden-api/scorecard.json | 6 +-
8 files changed, 235 insertions(+), 15 deletions(-)
diff --git a/internal/generator/cursor_param_test.go b/internal/generator/cursor_param_test.go
new file mode 100644
index 00000000..14023490
--- /dev/null
+++ b/internal/generator/cursor_param_test.go
@@ -0,0 +1,134 @@
+package generator
+
+import (
+ "testing"
+
+ "github.com/mvanhorn/cli-printing-press/v3/internal/spec"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestIsCursorParam(t *testing.T) {
+ t.Parallel()
+ tests := []struct {
+ name string
+ in string
+ want bool
+ }{
+ {"cursor exact", "cursor", true},
+ {"cursor uppercase", "CURSOR", true},
+ {"min_cursor", "min_cursor", true},
+ {"max_cursor", "max_cursor", true},
+ {"next_cursor", "next_cursor", true},
+ {"page", "page", true},
+ {"page_token", "page_token", true},
+ {"next_page_token", "next_page_token", true},
+ {"min_time", "min_time", true},
+ {"max_time", "max_time", true},
+ {"offset", "offset", true},
+
+ {"page_size is not a cursor", "page_size", false},
+ {"limit is not a cursor", "limit", false},
+ {"per_page is not a cursor", "per_page", false},
+ {"id is not a cursor", "id", false},
+ {"user_id is not a cursor", "user_id", false},
+ {"threshold is not a cursor", "threshold", false},
+ {"empty string", "", false},
+ {"unrelated word", "handle", false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ assert.Equal(t, tt.want, isCursorParam(tt.in))
+ })
+ }
+}
+
+func TestCobraFlagFuncForParamCursorOverride(t *testing.T) {
+ t.Parallel()
+ tests := []struct {
+ name string
+ paramName string
+ paramType string
+ want string
+ }{
+ {"cursor declared as number maps to StringVar", "cursor", "float", "StringVar"},
+ {"cursor declared as integer maps to StringVar", "cursor", "int", "StringVar"},
+ {"min_time as float maps to StringVar", "min_time", "float", "StringVar"},
+ {"page as int maps to StringVar", "page", "int", "StringVar"},
+ {"max_cursor as float maps to StringVar", "max_cursor", "float", "StringVar"},
+
+ {"page_size keeps IntVar (not a cursor)", "page_size", "int", "IntVar"},
+ {"threshold keeps Float64Var (not a cursor)", "threshold", "float", "Float64Var"},
+ {"limit keeps IntVar (not a cursor)", "limit", "int", "IntVar"},
+
+ {"user_id keeps StringVar via existing isIDParam path", "user_id", "int", "StringVar"},
+ {"plain string flag unaffected", "handle", "string", "StringVar"},
+ {"plain bool flag unaffected", "trim", "bool", "BoolVar"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ assert.Equal(t, tt.want, cobraFlagFuncForParam(tt.paramName, tt.paramType))
+ })
+ }
+}
+
+func TestGoTypeForParamCursorOverride(t *testing.T) {
+ t.Parallel()
+ tests := []struct {
+ name string
+ paramName string
+ paramType string
+ want string
+ }{
+ {"cursor float becomes string", "cursor", "float", "string"},
+ {"min_time int becomes string", "min_time", "int", "string"},
+ {"page int becomes string", "page", "int", "string"},
+
+ {"page_size int stays int", "page_size", "int", "int"},
+ {"threshold float stays float", "threshold", "float", "float64"},
+ {"user_id int becomes string via isIDParam", "user_id", "int", "string"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ assert.Equal(t, tt.want, goTypeForParam(tt.paramName, tt.paramType))
+ })
+ }
+}
+
+func TestDefaultValForParamCursorOverride(t *testing.T) {
+ t.Parallel()
+ tests := []struct {
+ name string
+ p spec.Param
+ want string
+ }{
+ {
+ name: "cursor float with no default → empty string",
+ p: spec.Param{Name: "cursor", Type: "float"},
+ want: `""`,
+ },
+ {
+ name: "min_time int with no default → empty string",
+ p: spec.Param{Name: "min_time", Type: "int"},
+ want: `""`,
+ },
+ {
+ name: "page int with default 1 → quoted string",
+ p: spec.Param{Name: "page", Type: "int", Default: 1},
+ want: `"1"`,
+ },
+ {
+ name: "user_id int with default still routes through isIDParam",
+ p: spec.Param{Name: "user_id", Type: "int"},
+ want: `""`,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ assert.Equal(t, tt.want, defaultValForParam(tt.p))
+ })
+ }
+}
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 243fbb82..943ada34 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -198,6 +198,9 @@ func New(s *spec.APISpec, outputDir string) *Generator {
if isIDParam(name) && t == "int" {
return `""`
}
+ if isCursorParam(name) && (t == "int" || t == "float") {
+ return `""`
+ }
return zeroVal(t)
},
"positionalArgs": positionalArgs,
@@ -2206,6 +2209,24 @@ func isIDParam(name string) bool {
lower == "steamid" || lower == "steamids"
}
+// isCursorParam returns true if the parameter name suggests it's a pagination
+// cursor, page, offset, or timestamp that should be typed as string regardless
+// of the spec's declared numeric type. Spec'd as `number` or `integer`, these
+// values cross into scientific-notation territory (~10^6 and above) when Go's
+// default float formatter renders them, which upstream APIs reject as invalid
+// integer cursors. String typing handles opaque tokens, numeric strings, and
+// integer literals uniformly.
+func isCursorParam(name string) bool {
+ lower := strings.ToLower(name)
+ switch lower {
+ case "cursor", "min_cursor", "max_cursor", "next_cursor",
+ "page", "page_token", "next_page_token",
+ "min_time", "max_time", "offset":
+ return true
+ }
+ return false
+}
+
func goType(t string) string {
switch t {
case "string":
@@ -2330,25 +2351,34 @@ func cobraFlagFunc(t string) string {
}
// goTypeForParam returns the Go type for a parameter, overriding int→string
-// for ID-like parameters to avoid overflow and zero-value confusion.
+// for ID-like parameters to avoid overflow and zero-value confusion, and
+// numeric→string for pagination cursors so they survive scientific-notation
+// rendering of large Unix timestamps and millisecond cursors.
func goTypeForParam(name, t string) string {
if isIDParam(name) && t == "int" {
return "string"
}
+ if isCursorParam(name) && (t == "int" || t == "float") {
+ return "string"
+ }
return goType(t)
}
// cobraFlagFuncForParam returns the cobra flag function, overriding IntVar→StringVar
-// for ID-like parameters.
+// for ID-like parameters and Float64Var/IntVar→StringVar for pagination cursors.
func cobraFlagFuncForParam(name, t string) string {
if isIDParam(name) && t == "int" {
return "StringVar"
}
+ if isCursorParam(name) && (t == "int" || t == "float") {
+ return "StringVar"
+ }
return cobraFlagFunc(t)
}
// defaultValForParam returns the default value for a flag parameter,
-// overriding int→string for ID-like parameters.
+// overriding int→string for ID-like parameters and numeric→string for
+// pagination cursors so the StringVar default matches the StringVar field type.
func defaultValForParam(p spec.Param) string {
if isIDParam(p.Name) && p.Type == "int" {
if p.Default != nil {
@@ -2356,6 +2386,12 @@ func defaultValForParam(p spec.Param) string {
}
return `""`
}
+ if isCursorParam(p.Name) && (p.Type == "int" || p.Type == "float") {
+ if p.Default != nil {
+ return fmt.Sprintf("%q", fmt.Sprintf("%v", p.Default))
+ }
+ return `""`
+ }
return defaultVal(p)
}
diff --git a/internal/generator/templates/client.go.tmpl b/internal/generator/templates/client.go.tmpl
index fba1d80b..ff65bcea 100644
--- a/internal/generator/templates/client.go.tmpl
+++ b/internal/generator/templates/client.go.tmpl
@@ -15,6 +15,7 @@ import (
"net/url"
"os"
"path/filepath"
+ "sort"
"strings"
"time"
@@ -527,20 +528,37 @@ func (c *Client) dryRun(method, targetURL, path string, params map[string]string
fmt.Fprintf(os.Stderr, " Envelope:\n %s\n", string(envelopeJSON))
{{- else}}
fmt.Fprintf(os.Stderr, "%s %s\n", method, targetURL)
+ queryPrinted := false
if params != nil {
- for k, v := range params {
- if v != "" {
- fmt.Fprintf(os.Stderr, " ?%s=%s\n", k, v)
+ keys := make([]string, 0, len(params))
+ for k := range params {
+ if params[k] != "" {
+ keys = append(keys, k)
}
}
+ sort.Strings(keys)
+ for _, k := range keys {
+ sep := "?"
+ if queryPrinted {
+ sep = "&"
+ }
+ fmt.Fprintf(os.Stderr, " %s%s=%s\n", sep, k, params[k])
+ queryPrinted = true
+ }
}
{{- if and .Auth .Auth.In (eq .Auth.In "query")}}
// Auth material travels as a query parameter on the live request — surface
// it in the preview so users can verify wiring.
if authHeader != "" {
- fmt.Fprintf(os.Stderr, " ?%s=%s\n", "{{if .Auth.Header}}{{.Auth.Header}}{{else}}api_key{{end}}", maskToken(authHeader))
+ sep := "?"
+ if queryPrinted {
+ sep = "&"
+ }
+ fmt.Fprintf(os.Stderr, " %s%s=%s\n", sep, "{{if .Auth.Header}}{{.Auth.Header}}{{else}}api_key{{end}}", maskToken(authHeader))
+ queryPrinted = true
}
{{- end}}
+ _ = queryPrinted
if body != nil {
var pretty json.RawMessage
if json.Unmarshal(body, &pretty) == nil {
diff --git a/testdata/golden/expected/generate-golden-api/dogfood.json b/testdata/golden/expected/generate-golden-api/dogfood.json
index 58d50943..dfe95b77 100644
--- a/testdata/golden/expected/generate-golden-api/dogfood.json
+++ b/testdata/golden/expected/generate-golden-api/dogfood.json
@@ -15,8 +15,11 @@
"total": 17
},
"dead_functions": {
- "dead": 0,
- "total": 48
+ "dead": 1,
+ "items": [
+ "printJSONFiltered"
+ ],
+ "total": 49
},
"dir": "<ARTIFACT_DIR>/generate-golden-api/printing-press-golden",
"example_check": {
@@ -27,6 +30,7 @@
"with_examples": 0
},
"issues": [
+ "1 dead helper functions found",
"example check skipped: could not build CLI binary: exit status 1"
],
"mcp_surface_parity": {
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 3db13fff..119e1003 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
@@ -334,6 +334,18 @@ func paginatedGet(c interface {
return json.RawMessage(result), nil
}
+// printJSONFiltered marshals a Go-typed value through the same output
+// pipeline endpoint-mirror commands use. Hand-written novel commands that
+// build a typed slice/struct call this so --select, --compact, --csv, and
+// --quiet all behave the same way as on generator-emitted commands.
+func printJSONFiltered(w io.Writer, v any, flags *rootFlags) error {
+ raw, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+ return printOutputWithFlags(w, json.RawMessage(raw), flags)
+}
+
// filterFields keeps only the specified fields (comma-separated) from JSON objects/arrays.
// Supports dotted paths like "events.shortName" to descend into nested structures.
// Arrays are traversed element-wise: "events.shortName" keeps shortName on each event.
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/promoted_public.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/promoted_public.go
index bc0382a1..7b5e2ae3 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/promoted_public.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/promoted_public.go
@@ -27,6 +27,9 @@ func newPublicPromotedCmd(flags *rootFlags) *cobra.Command {
path := "/public/status"
params := map[string]string{}
+ // resolveRead is GET-only internally, so HasStore + non-GET should
+ // be marked Hidden in the printed CLI and reached via the typed
+ // `<resource> <endpoint>` form instead.
data, prov, err := resolveRead(c, flags, "public", false, path, params, nil)
if err != nil {
return classifyAPIError(err)
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/client/client.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/client/client.go
index 460f5171..1524e263 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/client/client.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/client/client.go
@@ -15,6 +15,7 @@ import (
"net/url"
"os"
"path/filepath"
+ "sort"
"strings"
"time"
"printing-press-golden-pp-cli/internal/cliutil"
@@ -274,13 +275,25 @@ func (c *Client) do(method, path string, params map[string]string, body any, hea
// call — the caller is responsible for passing cached auth material only.
func (c *Client) dryRun(method, targetURL, path string, params map[string]string, body []byte, headerOverrides map[string]string, authHeader string) (json.RawMessage, int, error) {
fmt.Fprintf(os.Stderr, "%s %s\n", method, targetURL)
+ queryPrinted := false
if params != nil {
- for k, v := range params {
- if v != "" {
- fmt.Fprintf(os.Stderr, " ?%s=%s\n", k, v)
+ keys := make([]string, 0, len(params))
+ for k := range params {
+ if params[k] != "" {
+ keys = append(keys, k)
}
}
+ sort.Strings(keys)
+ for _, k := range keys {
+ sep := "?"
+ if queryPrinted {
+ sep = "&"
+ }
+ fmt.Fprintf(os.Stderr, " %s%s=%s\n", sep, k, params[k])
+ queryPrinted = true
+ }
}
+ _ = queryPrinted
if body != nil {
var pretty json.RawMessage
if json.Unmarshal(body, &pretty) == nil {
diff --git a/testdata/golden/expected/generate-golden-api/scorecard.json b/testdata/golden/expected/generate-golden-api/scorecard.json
index 888d6c04..c4b566e2 100644
--- a/testdata/golden/expected/generate-golden-api/scorecard.json
+++ b/testdata/golden/expected/generate-golden-api/scorecard.json
@@ -14,7 +14,7 @@
"breadth": 5,
"cache_freshness": 5,
"data_pipeline_integrity": 7,
- "dead_code": 5,
+ "dead_code": 4,
"doctor": 10,
"error_handling": 10,
"insight": 8,
@@ -28,11 +28,11 @@
"mcp_tool_design": 0,
"output_modes": 10,
"path_validity": 0,
- "percentage": 82,
+ "percentage": 81,
"readme": 8,
"sync_correctness": 10,
"terminal_ux": 8,
- "total": 82,
+ "total": 81,
"type_fidelity": 3,
"vision": 7,
"workflows": 10
← b6c70893 fix(ci): disable golangci-lint remote schema verify (#432)
·
back to Cli Printing Press
·
feat(cli,skills): five remaining retro WUs from postman-expl d24f60ed →