← back to Cli Printing Press
feat(cli): emit fetchFull<X> companion for GET-embedded paged sub-resources (#1301)
900b335ce15efab58b8007a8cdb80de2de80ff4e · 2026-05-13 14:31:32 -0700 · Trevin Chow
* feat(cli): emit fetchFull<X> companion for GET-embedded paged sub-resources
Detect properties in a GET endpoint's success response whose nested shape
is a paged envelope (items+next), and emit a per-endpoint
fetchFull<Resource><Endpoint><Property> helper that calls the dedicated
child endpoint and paginates until exhausted. Backed by a single shared
runtime helper (fetchEmbeddedPagedSubresource) gated on a HelperFlag so
unused CLIs don't carry it.
The standard GET handler keeps returning the parent's truncated embed for
back-compat; the helper is opt-in for hand-written novel commands that
need the complete child collection. This raises the floor for every CLI
generated from specs that embed paged sub-resources (Spotify playlists,
Slack conversations, Atlassian issues, Notion pages, GitHub pulls, etc.)
without changing existing generated output.
Closes #1277
* fix(cli): distinguish URL-bearing next fields from opaque cursors
Greptile review caught a real defect in the runtime helper: the
detector classified every string-typed next-page field uniformly
(next, next_url, next_cursor, next_page_token, cursor), and the
helper assigned the field's value to nextURL on every iteration.
For URL-bearing fields (next, next_url) the value is a fully-qualified
URL the runtime can GET directly; for opaque cursors (next_cursor,
next_page_token, cursor) the value is a token that must be forwarded
as a specific query parameter on the next request. The embedded
envelope never declares which parameter that is, so the runtime would
GET against the cursor string itself and resolve it to a nonsense
path under the client BaseURL.
Splits nextFieldStringNames into nextFieldURLNames (followable) and
nextFieldCursorNames (unfollowable), adds an EmbeddedPagedSubresource.
NextIsURL flag, and updates the runtime helper to single-fetch + emit
a truncation event for non-URL signals — identical to the has_more
branch. Generated wrappers pass both NextIsURL and NextIsBoolean so
the helper picks the right path per envelope shape.
Also addresses two adjacent review findings:
- Replaces the residual "Spotify" vendor name in spec.go with a
generic placeholder (AGENTS.md "no hardcoded API names in
reusable artifacts").
- Adds a generate-embedded-paged-api golden case exercising all three
envelope flavors (URL, cursor, has_more) so the new generation
path is locked in for future refactors.
Refs #1277
* test(cli): refresh generate-embedded-paged-api goldens against current main
Three fixture files needed updating after rebase onto main:
- customers_get.go
- pages_get.go
- playlists_get.go
The diff is purely cosmetic: main now wraps the provenance-print block
in `if wantsHumanTable(...)` (from #1280), so the actual generator
output gains that guard. #1301's fixture predates the change.
Behavior of this PR's fetchFull<X> companion emission is unchanged.
Files touched
A internal/generator/embedded_paged_test.goM internal/generator/generator.goM internal/generator/templates/command_endpoint.go.tmplM internal/generator/templates/command_promoted.go.tmplM internal/generator/templates/helpers.go.tmplA internal/openapi/embedded_paged_test.goM internal/openapi/parser.goM internal/spec/spec.goA testdata/golden/cases/generate-embedded-paged-api/artifacts.txtA testdata/golden/cases/generate-embedded-paged-api/command.txtA testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/customers_get.goA testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/helpers.goA testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/pages_get.goA testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/playlists_get.goA testdata/golden/expected/generate-embedded-paged-api/exit.txtA testdata/golden/expected/generate-embedded-paged-api/stderr.txtA testdata/golden/expected/generate-embedded-paged-api/stdout.txtA testdata/golden/fixtures/embedded-paged-api.yaml
Diff
commit 900b335ce15efab58b8007a8cdb80de2de80ff4e
Author: Trevin Chow <trevin@trevinchow.com>
Date: Wed May 13 14:31:32 2026 -0700
feat(cli): emit fetchFull<X> companion for GET-embedded paged sub-resources (#1301)
* feat(cli): emit fetchFull<X> companion for GET-embedded paged sub-resources
Detect properties in a GET endpoint's success response whose nested shape
is a paged envelope (items+next), and emit a per-endpoint
fetchFull<Resource><Endpoint><Property> helper that calls the dedicated
child endpoint and paginates until exhausted. Backed by a single shared
runtime helper (fetchEmbeddedPagedSubresource) gated on a HelperFlag so
unused CLIs don't carry it.
The standard GET handler keeps returning the parent's truncated embed for
back-compat; the helper is opt-in for hand-written novel commands that
need the complete child collection. This raises the floor for every CLI
generated from specs that embed paged sub-resources (Spotify playlists,
Slack conversations, Atlassian issues, Notion pages, GitHub pulls, etc.)
without changing existing generated output.
Closes #1277
* fix(cli): distinguish URL-bearing next fields from opaque cursors
Greptile review caught a real defect in the runtime helper: the
detector classified every string-typed next-page field uniformly
(next, next_url, next_cursor, next_page_token, cursor), and the
helper assigned the field's value to nextURL on every iteration.
For URL-bearing fields (next, next_url) the value is a fully-qualified
URL the runtime can GET directly; for opaque cursors (next_cursor,
next_page_token, cursor) the value is a token that must be forwarded
as a specific query parameter on the next request. The embedded
envelope never declares which parameter that is, so the runtime would
GET against the cursor string itself and resolve it to a nonsense
path under the client BaseURL.
Splits nextFieldStringNames into nextFieldURLNames (followable) and
nextFieldCursorNames (unfollowable), adds an EmbeddedPagedSubresource.
NextIsURL flag, and updates the runtime helper to single-fetch + emit
a truncation event for non-URL signals — identical to the has_more
branch. Generated wrappers pass both NextIsURL and NextIsBoolean so
the helper picks the right path per envelope shape.
Also addresses two adjacent review findings:
- Replaces the residual "Spotify" vendor name in spec.go with a
generic placeholder (AGENTS.md "no hardcoded API names in
reusable artifacts").
- Adds a generate-embedded-paged-api golden case exercising all three
envelope flavors (URL, cursor, has_more) so the new generation
path is locked in for future refactors.
Refs #1277
* test(cli): refresh generate-embedded-paged-api goldens against current main
Three fixture files needed updating after rebase onto main:
- customers_get.go
- pages_get.go
- playlists_get.go
The diff is purely cosmetic: main now wraps the provenance-print block
in `if wantsHumanTable(...)` (from #1280), so the actual generator
output gains that guard. #1301's fixture predates the change.
Behavior of this PR's fetchFull<X> companion emission is unchanged.
---
internal/generator/embedded_paged_test.go | 197 +++
internal/generator/generator.go | 7 +
.../generator/templates/command_endpoint.go.tmpl | 25 +
.../generator/templates/command_promoted.go.tmpl | 25 +
internal/generator/templates/helpers.go.tmpl | 136 ++
internal/openapi/embedded_paged_test.go | 437 ++++++
internal/openapi/parser.go | 180 +++
internal/spec/spec.go | 47 +-
.../generate-embedded-paged-api/artifacts.txt | 4 +
.../cases/generate-embedded-paged-api/command.txt | 2 +
.../internal/cli/customers_get.go | 100 ++
.../embedded-paged-api/internal/cli/helpers.go | 1659 ++++++++++++++++++++
.../embedded-paged-api/internal/cli/pages_get.go | 100 ++
.../internal/cli/playlists_get.go | 100 ++
.../expected/generate-embedded-paged-api/exit.txt | 1 +
.../generate-embedded-paged-api/stderr.txt | 2 +
.../generate-embedded-paged-api/stdout.txt | 0
testdata/golden/fixtures/embedded-paged-api.yaml | 137 ++
18 files changed, 3148 insertions(+), 11 deletions(-)
diff --git a/internal/generator/embedded_paged_test.go b/internal/generator/embedded_paged_test.go
new file mode 100644
index 00000000..c7a26e89
--- /dev/null
+++ b/internal/generator/embedded_paged_test.go
@@ -0,0 +1,197 @@
+package generator
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/mvanhorn/cli-printing-press/v4/internal/spec"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestEmbeddedPagedHelperEmitted_NonPromoted ensures the generator emits
+// fetchFull<Endpoint><Property> companion helpers for the non-promoted
+// command-emission path. The multi-endpoint resource here keeps the GET
+// from being promoted, so command_endpoint.go.tmpl is the active path.
+func TestEmbeddedPagedHelperEmitted_NonPromoted(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := minimalSpec("embedded-paged-nonpromoted")
+ apiSpec.Resources = map[string]spec.Resource{
+ "playlists": {
+ Description: "Manage playlists",
+ Endpoints: map[string]spec.Endpoint{
+ "list": {Method: "GET", Path: "/playlists", Description: "List playlists"},
+ "get": {
+ Method: "GET",
+ Path: "/playlists/{id}",
+ Description: "Get one playlist",
+ Params: []spec.Param{
+ {Name: "id", Type: "string", Required: true, Positional: true, PathParam: true},
+ },
+ EmbeddedPagedSubresources: []spec.EmbeddedPagedSubresource{
+ {
+ Property: "tracks",
+ ChildPath: "/playlists/{id}/tracks",
+ ItemsField: "items",
+ NextField: "next",
+ },
+ },
+ },
+ },
+ },
+ }
+
+ outDir := filepath.Join(t.TempDir(), "embedded-paged-nonpromoted-pp-cli")
+ require.NoError(t, New(apiSpec, outDir).Generate())
+
+ getSrc, err := os.ReadFile(filepath.Join(outDir, "internal", "cli", "playlists_get.go"))
+ require.NoError(t, err)
+ body := string(getSrc)
+ assert.Contains(t, body, "func fetchFullPlaylistsGetTracks(",
+ "non-promoted GET should emit a fetchFull<Resource><Endpoint><Property> helper")
+ assert.Contains(t, body, "childPath := \"/playlists/{id}/tracks\"",
+ "helper should hard-code the conventional child path so callers pass only path-param values")
+ assert.Contains(t, body, "fetchEmbeddedPagedSubresource(c, childPath,",
+ "per-endpoint helper should delegate to the shared runtime helper rather than open-coding the page loop")
+
+ helpersSrc, err := os.ReadFile(filepath.Join(outDir, "internal", "cli", "helpers.go"))
+ require.NoError(t, err)
+ assert.Contains(t, string(helpersSrc), "func fetchEmbeddedPagedSubresource(",
+ "helpers.go must emit the shared paginator when at least one endpoint has detected sub-resources")
+ assert.Contains(t, string(helpersSrc), "embeddedPagedSubresourcePageCap",
+ "shared helper must carry the page-count cap so callers can't spin against a misbehaving server")
+
+ runGoCommand(t, outDir, "build", "./internal/cli")
+}
+
+// TestEmbeddedPagedHelperEmitted_Promoted covers the single-endpoint
+// resource case where the generator promotes the GET to a top-level
+// command (command_promoted.go.tmpl is the active path). The helper
+// must appear there too — otherwise the bug only fixes itself for
+// CLIs whose specs happen to declare a sibling list endpoint.
+func TestEmbeddedPagedHelperEmitted_Promoted(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := minimalSpec("embedded-paged-promoted")
+ apiSpec.Resources = map[string]spec.Resource{
+ "playlists": {
+ Description: "Manage playlists",
+ Endpoints: map[string]spec.Endpoint{
+ "get": {
+ Method: "GET",
+ Path: "/playlists/{id}",
+ Description: "Get one playlist",
+ Params: []spec.Param{
+ {Name: "id", Type: "string", Required: true, Positional: true, PathParam: true},
+ },
+ EmbeddedPagedSubresources: []spec.EmbeddedPagedSubresource{
+ {
+ Property: "tracks",
+ ChildPath: "/playlists/{id}/tracks",
+ ItemsField: "items",
+ NextField: "next",
+ },
+ },
+ },
+ },
+ },
+ }
+
+ outDir := filepath.Join(t.TempDir(), "embedded-paged-promoted-pp-cli")
+ require.NoError(t, New(apiSpec, outDir).Generate())
+
+ promotedSrc, err := os.ReadFile(filepath.Join(outDir, "internal", "cli", "promoted_playlists.go"))
+ require.NoError(t, err)
+ assert.Contains(t, string(promotedSrc), "func fetchFullPlaylistsGetTracks(",
+ "promoted-command file should carry the same helper as the non-promoted file")
+
+ runGoCommand(t, outDir, "build", "./internal/cli")
+}
+
+// TestEmbeddedPagedHelperEmitted_HasMore exercises the boolean has_more
+// branch — the emitted helper must declare it stops after page 1 rather
+// than silently falling through and looping forever against a header-
+// driven cursor it cannot synthesize.
+func TestEmbeddedPagedHelperEmitted_HasMore(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := minimalSpec("embedded-paged-hasmore")
+ apiSpec.Resources = map[string]spec.Resource{
+ "customers": {
+ Description: "Customers",
+ Endpoints: map[string]spec.Endpoint{
+ "list": {Method: "GET", Path: "/customers", Description: "List customers"},
+ "get": {
+ Method: "GET",
+ Path: "/customers/{id}",
+ Description: "Get customer",
+ Params: []spec.Param{
+ {Name: "id", Type: "string", Required: true, Positional: true, PathParam: true},
+ },
+ EmbeddedPagedSubresources: []spec.EmbeddedPagedSubresource{
+ {
+ Property: "subscriptions",
+ ChildPath: "/customers/{id}/subscriptions",
+ ItemsField: "data",
+ NextField: "has_more",
+ NextIsBoolean: true,
+ },
+ },
+ },
+ },
+ },
+ }
+
+ outDir := filepath.Join(t.TempDir(), "embedded-paged-hasmore-pp-cli")
+ require.NoError(t, New(apiSpec, outDir).Generate())
+
+ getSrc, err := os.ReadFile(filepath.Join(outDir, "internal", "cli", "customers_get.go"))
+ require.NoError(t, err)
+ body := string(getSrc)
+ assert.Contains(t, body, "func fetchFullCustomersGetSubscriptions(")
+ assert.Contains(t, body, `fetchEmbeddedPagedSubresource(c, childPath, "has_more", false, true)`,
+ "has_more-style envelopes should delegate to the shared paginator with nextIsURL=false, nextIsBoolean=true")
+
+ runGoCommand(t, outDir, "build", "./internal/cli")
+}
+
+// TestEmbeddedPagedHelperNotEmittedWhenEmpty confirms the helper block
+// is gated correctly: endpoints without detected sub-resources must not
+// emit any fetchFull function (avoids template noise and stray
+// references to undefined identifiers).
+func TestEmbeddedPagedHelperNotEmittedWhenEmpty(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := minimalSpec("embedded-paged-empty")
+ apiSpec.Resources = map[string]spec.Resource{
+ "items": {
+ Description: "Items",
+ Endpoints: map[string]spec.Endpoint{
+ "list": {Method: "GET", Path: "/items", Description: "List items"},
+ "get": {
+ Method: "GET",
+ Path: "/items/{id}",
+ Description: "Get item",
+ Params: []spec.Param{
+ {Name: "id", Type: "string", Required: true, Positional: true, PathParam: true},
+ },
+ },
+ },
+ },
+ }
+
+ outDir := filepath.Join(t.TempDir(), "embedded-paged-empty-pp-cli")
+ require.NoError(t, New(apiSpec, outDir).Generate())
+
+ getSrc, err := os.ReadFile(filepath.Join(outDir, "internal", "cli", "items_get.go"))
+ require.NoError(t, err)
+ assert.NotContains(t, string(getSrc), "fetchFull",
+ "endpoints with no detected embedded paged sub-resources must not emit a helper")
+
+ helpersSrc, err := os.ReadFile(filepath.Join(outDir, "internal", "cli", "helpers.go"))
+ require.NoError(t, err)
+ assert.NotContains(t, string(helpersSrc), "fetchEmbeddedPagedSubresource",
+ "shared paginator must be gated on HasEmbeddedPaged so unused CLIs don't carry dead code")
+}
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index d5d2c4ec..ddc1ebf4 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -578,6 +578,7 @@ type HelperFlags struct {
HasMultiPositional bool // spec has endpoints with 2+ positional params → emit usageErr
HasDataLayer bool // CLI has a local store (sync/search) → emit provenance helpers
HasClientLimit bool // at least one endpoint needs client-side limit truncation → emit truncateJSONArray
+ HasEmbeddedPaged bool // at least one GET endpoint has detected embedded paged sub-resources → emit fetchEmbeddedPagedSubresource
}
// computeHelperFlags scans the spec's resources to determine which helpers are needed.
@@ -591,6 +592,9 @@ func computeHelperFlags(s *spec.APISpec) HelperFlags {
if endpointNeedsClientLimit(e) {
flags.HasClientLimit = true
}
+ if len(e.EmbeddedPagedSubresources) > 0 {
+ flags.HasEmbeddedPaged = true
+ }
positionalCount := 0
for _, p := range e.Params {
if p.Positional || p.PathParam {
@@ -612,6 +616,9 @@ func computeHelperFlags(s *spec.APISpec) HelperFlags {
if endpointNeedsClientLimit(e) {
flags.HasClientLimit = true
}
+ if len(e.EmbeddedPagedSubresources) > 0 {
+ flags.HasEmbeddedPaged = true
+ }
positionalCount := 0
for _, p := range e.Params {
if p.Positional || p.PathParam {
diff --git a/internal/generator/templates/command_endpoint.go.tmpl b/internal/generator/templates/command_endpoint.go.tmpl
index a81a253b..fa671a9f 100644
--- a/internal/generator/templates/command_endpoint.go.tmpl
+++ b/internal/generator/templates/command_endpoint.go.tmpl
@@ -784,3 +784,28 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
return cmd
}
+{{if and (eq .Endpoint.Method "GET") .Endpoint.EmbeddedPagedSubresources}}
+{{- $endpointCamel := camel .EndpointName}}
+{{- $funcPrefix := camel .FuncPrefix}}
+{{- $parentPath := .Endpoint.Path}}
+{{- range .Endpoint.EmbeddedPagedSubresources}}
+{{- $propCamel := camel .Property}}
+
+// fetchFull{{$funcPrefix}}{{$endpointCamel}}{{$propCamel}} returns every item
+// in the "{{.Property}}" sub-resource of GET {{$parentPath}}. The parent
+// response embeds at most the first page of this sub-resource, so any
+// caller that treats the embed as complete silently truncates; this
+// companion calls the dedicated child endpoint and paginates until
+// exhausted. pathParams must include every {placeholder} in the parent
+// path (e.g. {"id": "<value>"}).
+func fetchFull{{$funcPrefix}}{{$endpointCamel}}{{$propCamel}}(c interface {
+ GetWithHeaders(path string, params map[string]string, headers map[string]string) (json.RawMessage, error)
+}, pathParams map[string]string) ([]json.RawMessage, error) {
+ childPath := "{{.ChildPath}}"
+ for name, val := range pathParams {
+ childPath = replacePathParam(childPath, name, val)
+ }
+ return fetchEmbeddedPagedSubresource(c, childPath, "{{.NextField}}", {{if .NextIsURL}}true{{else}}false{{end}}, {{if .NextIsBoolean}}true{{else}}false{{end}})
+}
+{{- end}}
+{{- end}}
diff --git a/internal/generator/templates/command_promoted.go.tmpl b/internal/generator/templates/command_promoted.go.tmpl
index f0664903..82e432d6 100644
--- a/internal/generator/templates/command_promoted.go.tmpl
+++ b/internal/generator/templates/command_promoted.go.tmpl
@@ -334,3 +334,28 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
return cmd
}
+{{if and (eq .Endpoint.Method "GET") .Endpoint.EmbeddedPagedSubresources}}
+{{- $endpointCamel := camel .EndpointName}}
+{{- $funcPrefix := camel .FuncPrefix}}
+{{- $parentPath := .Endpoint.Path}}
+{{- range .Endpoint.EmbeddedPagedSubresources}}
+{{- $propCamel := camel .Property}}
+
+// fetchFull{{$funcPrefix}}{{$endpointCamel}}{{$propCamel}} returns every item
+// in the "{{.Property}}" sub-resource of GET {{$parentPath}}. The parent
+// response embeds at most the first page of this sub-resource, so any
+// caller that treats the embed as complete silently truncates; this
+// companion calls the dedicated child endpoint and paginates until
+// exhausted. pathParams must include every {placeholder} in the parent
+// path (e.g. {"id": "<value>"}).
+func fetchFull{{$funcPrefix}}{{$endpointCamel}}{{$propCamel}}(c interface {
+ GetWithHeaders(path string, params map[string]string, headers map[string]string) (json.RawMessage, error)
+}, pathParams map[string]string) ([]json.RawMessage, error) {
+ childPath := "{{.ChildPath}}"
+ for name, val := range pathParams {
+ childPath = replacePathParam(childPath, name, val)
+ }
+ return fetchEmbeddedPagedSubresource(c, childPath, "{{.NextField}}", {{if .NextIsURL}}true{{else}}false{{end}}, {{if .NextIsBoolean}}true{{else}}false{{end}})
+}
+{{- end}}
+{{- end}}
diff --git a/internal/generator/templates/helpers.go.tmpl b/internal/generator/templates/helpers.go.tmpl
index caa99397..fcc1e2bd 100644
--- a/internal/generator/templates/helpers.go.tmpl
+++ b/internal/generator/templates/helpers.go.tmpl
@@ -12,6 +12,9 @@ import (
"errors"
"fmt"
"io"
+{{- if .HasEmbeddedPaged}}
+ "net/url"
+{{- end}}
"os"
{{- if .HasDataLayer}}
"path/filepath"
@@ -744,6 +747,139 @@ func extractPaginatedItems(obj map[string]json.RawMessage) ([]json.RawMessage, b
return nil, false
}
+{{- if .HasEmbeddedPaged}}
+
+// embeddedPagedSubresourcePageCap bounds the page loop in
+// fetchEmbeddedPagedSubresource so a misbehaving server returning a
+// non-empty next URL on every response can't spin forever. 500 covers
+// the largest realistic embedded sub-resource (e.g. 50k items at 100
+// per page); pathological servers trip the cap and surface an error
+// instead of hanging.
+const embeddedPagedSubresourcePageCap = 500
+
+// fetchEmbeddedPagedSubresource paginates the embedded paged
+// sub-resource at childPath until exhausted, returning the
+// concatenated items. The generator emits per-endpoint thin wrappers
+// that call this with the detected envelope shape; consolidating the
+// loop here keeps the page cap, error handling, and envelope-walking
+// logic in one place.
+//
+// nextField names the envelope's "more pages" property. nextIsURL
+// distinguishes the two cursor shapes:
+//
+// - nextIsURL=true: the property carries a full URL the runtime can
+// GET directly to fetch the next page (envelope shape: items+next).
+// - nextIsURL=false: the property either carries an opaque cursor
+// token whose required query-param name isn't part of the embedded
+// envelope, or it's a has_more-style bool. In either case the
+// helper performs a single GET and emits a stderr truncation event
+// when the field indicates more pages exist — paginating further
+// requires API-specific arithmetic that cannot be synthesized
+// from the envelope alone, so callers must use the dedicated list
+// endpoint for full traversal.
+//
+// Envelope lookups are case-insensitive: API authors sometimes
+// describe envelopes in one casing in the schema and serve another on
+// the wire (`NextPageToken` vs `nextPageToken`).
+func fetchEmbeddedPagedSubresource(c interface {
+ GetWithHeaders(path string, params map[string]string, headers map[string]string) (json.RawMessage, error)
+}, childPath, nextField string, nextIsURL, nextIsBoolean bool) ([]json.RawMessage, error) {
+ all := make([]json.RawMessage, 0)
+ nextURL := ""
+ for page := 0; page < embeddedPagedSubresourcePageCap; page++ {
+ target := childPath
+ if nextURL != "" {
+ target = relativizeNextURL(nextURL)
+ }
+ data, err := c.GetWithHeaders(target, nil, nil)
+ if err != nil {
+ return nil, fmt.Errorf("paginating embedded sub-resource %q page %d: %w", childPath, page+1, err)
+ }
+ var obj map[string]json.RawMessage
+ if err := json.Unmarshal(data, &obj); err != nil {
+ return nil, fmt.Errorf("paginating embedded sub-resource %q page %d: unexpected response shape: %w", childPath, page+1, err)
+ }
+ if items, ok := extractPaginatedItems(obj); ok {
+ all = append(all, items...)
+ }
+ if !nextIsURL {
+ if moreRaw, ok := caseInsensitiveLookup(obj, nextField); ok {
+ if hasUnfollowableNextSignal(moreRaw, nextIsBoolean) {
+ emitEmbeddedPagedTruncationWarning(childPath)
+ }
+ }
+ return all, nil
+ }
+ nextRaw, ok := caseInsensitiveLookup(obj, nextField)
+ if !ok {
+ return all, nil
+ }
+ var nextStr string
+ if json.Unmarshal(nextRaw, &nextStr) != nil || nextStr == "" {
+ return all, nil
+ }
+ nextURL = nextStr
+ }
+ return all, fmt.Errorf("paginating embedded sub-resource %q: hit %d-page cap (likely a server returning a non-empty next URL on every response)", childPath, embeddedPagedSubresourcePageCap)
+}
+
+// hasUnfollowableNextSignal reports whether a non-URL next field
+// value indicates more pages exist (true bool, non-empty cursor
+// string) — the caller emits a truncation warning when this returns
+// true. Returns false on missing/null/false/empty signals.
+func hasUnfollowableNextSignal(raw json.RawMessage, nextIsBoolean bool) bool {
+ if nextIsBoolean {
+ var more bool
+ return json.Unmarshal(raw, &more) == nil && more
+ }
+ var cursor string
+ return json.Unmarshal(raw, &cursor) == nil && cursor != ""
+}
+
+func emitEmbeddedPagedTruncationWarning(childPath string) {
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, "warning: %s truncated; more pages available via the dedicated list endpoint.\n", childPath)
+ } else {
+ fmt.Fprintf(os.Stderr, `{"event":"truncated","sub_resource":%q}`+"\n", childPath)
+ }
+}
+
+// relativizeNextURL strips an absolute scheme+host from a next-page URL
+// so the client's BaseURL+path concat doesn't produce a double-prefixed
+// target. Vendors that return absolute next URLs (e.g. for offset-based
+// pagination) would otherwise concatenate against c.BaseURL on every
+// follow-up request and 404 from page 2 onward.
+func relativizeNextURL(raw string) string {
+ u, err := url.Parse(raw)
+ if err != nil || !u.IsAbs() {
+ return raw
+ }
+ path := u.Path
+ if path == "" {
+ path = "/"
+ }
+ if u.RawQuery != "" {
+ path += "?" + u.RawQuery
+ }
+ return path
+}
+
+// caseInsensitiveLookup returns obj[key] tolerating envelope casing
+// drift between the spec and the wire response.
+func caseInsensitiveLookup(obj map[string]json.RawMessage, key string) (json.RawMessage, bool) {
+ if raw, ok := obj[key]; ok {
+ return raw, true
+ }
+ lowered := strings.ToLower(key)
+ for k, raw := range obj {
+ if strings.ToLower(k) == lowered {
+ return raw, true
+ }
+ }
+ return nil, false
+}
+{{- end}}
+
func rawAtPath(obj map[string]json.RawMessage, path string) (json.RawMessage, bool) {
if raw, ok := obj[path]; ok {
return raw, true
diff --git a/internal/openapi/embedded_paged_test.go b/internal/openapi/embedded_paged_test.go
new file mode 100644
index 00000000..cec02dc2
--- /dev/null
+++ b/internal/openapi/embedded_paged_test.go
@@ -0,0 +1,437 @@
+package openapi
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/mvanhorn/cli-printing-press/v4/internal/spec"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestEmbeddedPagedSubresources_Spotify exercises the Spotify-shape:
+// GET /playlists/{id} returns a struct whose `tracks` property is a
+// PagingObject (items + next). The detector must surface tracks as a
+// companion-helper candidate.
+func TestEmbeddedPagedSubresources_Spotify(t *testing.T) {
+ t.Parallel()
+
+ doc := []byte(`
+openapi: "3.0.0"
+info:
+ title: Spotify Mock
+ version: "1.0"
+servers:
+ - url: https://api.spotify.example/v1
+paths:
+ /playlists/{id}:
+ get:
+ operationId: getPlaylist
+ parameters:
+ - { name: id, in: path, required: true, schema: { type: string } }
+ responses:
+ "200":
+ description: ok
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ id: { type: string }
+ name: { type: string }
+ tracks:
+ type: object
+ properties:
+ items:
+ type: array
+ items: { type: object }
+ next: { type: string }
+ total: { type: integer }
+`)
+ parsed, err := Parse(doc)
+ require.NoError(t, err)
+
+ var ep, ok = findGetEndpoint(parsed, "/playlists/{id}")
+ require.True(t, ok, "expected GET /playlists/{id} endpoint")
+ require.Len(t, ep.EmbeddedPagedSubresources, 1, "expected one detected sub-resource")
+ got := ep.EmbeddedPagedSubresources[0]
+ assert.Equal(t, "tracks", got.Property)
+ assert.Equal(t, "/playlists/{id}/tracks", got.ChildPath)
+ assert.Equal(t, "items", got.ItemsField)
+ assert.Equal(t, "next", got.NextField)
+ assert.True(t, got.NextIsURL, "`next` is the canonical URL-bearing next-page field")
+ assert.False(t, got.NextIsBoolean)
+}
+
+// TestEmbeddedPagedSubresources_HasMore covers the boolean has_more shape
+// used by Stripe-style envelopes nested under a parent GET.
+func TestEmbeddedPagedSubresources_HasMore(t *testing.T) {
+ t.Parallel()
+
+ doc := []byte(`
+openapi: "3.0.0"
+info: { title: Stripe Mock, version: "1.0" }
+servers: [{ url: https://api.stripe.example/v1 }]
+paths:
+ /customers/{id}:
+ get:
+ operationId: getCustomer
+ parameters:
+ - { name: id, in: path, required: true, schema: { type: string } }
+ responses:
+ "200":
+ description: ok
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ id: { type: string }
+ subscriptions:
+ type: object
+ properties:
+ data:
+ type: array
+ items: { type: object }
+ has_more: { type: boolean }
+`)
+ parsed, err := Parse(doc)
+ require.NoError(t, err)
+ ep, ok := findGetEndpoint(parsed, "/customers/{id}")
+ require.True(t, ok)
+ require.Len(t, ep.EmbeddedPagedSubresources, 1)
+ got := ep.EmbeddedPagedSubresources[0]
+ assert.Equal(t, "subscriptions", got.Property)
+ assert.Equal(t, "data", got.ItemsField)
+ assert.Equal(t, "has_more", got.NextField)
+ assert.True(t, got.NextIsBoolean)
+ assert.False(t, got.NextIsURL, "has_more envelopes carry no URL — runtime must single-fetch and warn")
+}
+
+// TestEmbeddedPagedSubresources_NoEnvelope ensures plain non-paged nested
+// objects don't trigger detection — a top-level scalar field, an array
+// without a next-page sibling, and an object missing the next signal must
+// all be skipped.
+func TestEmbeddedPagedSubresources_NoEnvelope(t *testing.T) {
+ t.Parallel()
+
+ doc := []byte(`
+openapi: "3.0.0"
+info: { title: Plain, version: "1.0" }
+servers: [{ url: https://api.plain.example/v1 }]
+paths:
+ /things/{id}:
+ get:
+ operationId: getThing
+ parameters:
+ - { name: id, in: path, required: true, schema: { type: string } }
+ responses:
+ "200":
+ description: ok
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ id: { type: string }
+ tags:
+ type: array
+ items: { type: string }
+ metadata:
+ type: object
+ properties:
+ created_at: { type: string }
+ partial_envelope:
+ type: object
+ properties:
+ items:
+ type: array
+ items: { type: object }
+`)
+ parsed, err := Parse(doc)
+ require.NoError(t, err)
+ ep, ok := findGetEndpoint(parsed, "/things/{id}")
+ require.True(t, ok)
+ assert.Empty(t, ep.EmbeddedPagedSubresources,
+ "plain scalars, top-level arrays, plain nested objects, and item-only envelopes without a next signal must not match")
+}
+
+// TestEmbeddedPagedSubresources_ListEndpointSkipped confirms list-style
+// endpoints (no path placeholder) are exempt from detection — they ARE
+// the paged endpoint and don't need a companion helper.
+func TestEmbeddedPagedSubresources_ListEndpointSkipped(t *testing.T) {
+ t.Parallel()
+
+ doc := []byte(`
+openapi: "3.0.0"
+info: { title: List Only, version: "1.0" }
+servers: [{ url: https://api.list.example/v1 }]
+paths:
+ /widgets:
+ get:
+ operationId: listWidgets
+ responses:
+ "200":
+ description: ok
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ data:
+ type: array
+ items: { type: object }
+ has_more: { type: boolean }
+`)
+ parsed, err := Parse(doc)
+ require.NoError(t, err)
+ ep, ok := findGetEndpoint(parsed, "/widgets")
+ require.True(t, ok)
+ assert.Empty(t, ep.EmbeddedPagedSubresources,
+ "list endpoints (no path placeholder) must not surface embedded sub-resources")
+}
+
+// TestEmbeddedPagedSubresources_CursorVsURL is the runtime-correctness
+// guard: detection must distinguish full-URL next fields (next, next_url)
+// from opaque cursor tokens (next_cursor, next_page_token, cursor),
+// because the runtime helper can follow the former with a direct GET
+// but cannot construct the next request from the latter without
+// API-specific cursor-to-query-param arithmetic. Misclassifying a
+// cursor as a URL would make the runtime issue GETs against the
+// cursor string itself, producing 404s — the opposite of the silent-
+// truncation bug this fix targets.
+func TestEmbeddedPagedSubresources_CursorVsURL(t *testing.T) {
+ t.Parallel()
+
+ doc := []byte(`
+openapi: "3.0.0"
+info: { title: KindCheck, version: "1.0" }
+servers: [{ url: https://api.kind.example/v1 }]
+paths:
+ /url-shape/{id}:
+ get:
+ operationId: getURLShape
+ parameters:
+ - { name: id, in: path, required: true, schema: { type: string } }
+ responses:
+ "200":
+ description: ok
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ tracks:
+ type: object
+ properties:
+ items: { type: array, items: { type: object } }
+ next: { type: string }
+ /cursor-shape/{id}:
+ get:
+ operationId: getCursorShape
+ parameters:
+ - { name: id, in: path, required: true, schema: { type: string } }
+ responses:
+ "200":
+ description: ok
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ children:
+ type: object
+ properties:
+ results: { type: array, items: { type: object } }
+ next_cursor: { type: string }
+ /token-shape/{id}:
+ get:
+ operationId: getTokenShape
+ parameters:
+ - { name: id, in: path, required: true, schema: { type: string } }
+ responses:
+ "200":
+ description: ok
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ events:
+ type: object
+ properties:
+ data: { type: array, items: { type: object } }
+ next_page_token: { type: string }
+`)
+ parsed, err := Parse(doc)
+ require.NoError(t, err)
+
+ urlEP, ok := findGetEndpoint(parsed, "/url-shape/{id}")
+ require.True(t, ok)
+ require.Len(t, urlEP.EmbeddedPagedSubresources, 1)
+ assert.True(t, urlEP.EmbeddedPagedSubresources[0].NextIsURL,
+ "`next` is a known URL-bearing field; the runtime can follow it directly")
+ assert.False(t, urlEP.EmbeddedPagedSubresources[0].NextIsBoolean)
+
+ cursorEP, ok := findGetEndpoint(parsed, "/cursor-shape/{id}")
+ require.True(t, ok)
+ require.Len(t, cursorEP.EmbeddedPagedSubresources, 1)
+ assert.False(t, cursorEP.EmbeddedPagedSubresources[0].NextIsURL,
+ "`next_cursor` is opaque; the runtime cannot GET it as a path and must emit a truncation event")
+ assert.False(t, cursorEP.EmbeddedPagedSubresources[0].NextIsBoolean)
+
+ tokenEP, ok := findGetEndpoint(parsed, "/token-shape/{id}")
+ require.True(t, ok)
+ require.Len(t, tokenEP.EmbeddedPagedSubresources, 1)
+ assert.False(t, tokenEP.EmbeddedPagedSubresources[0].NextIsURL,
+ "`next_page_token` is opaque; same constraint as next_cursor")
+ assert.False(t, tokenEP.EmbeddedPagedSubresources[0].NextIsBoolean)
+}
+
+// TestEmbeddedPagedSubresources_CaseInsensitive guards the detector
+// against schemas that declare envelope fields in non-canonical casing
+// (PascalCase `Items` / `NextPageToken`, screaming-snake `HAS_MORE`).
+// Detection must fire on those AND preserve the wire-side casing
+// rather than rewriting it to the canonical lowercase candidate —
+// the runtime helper now compensates for casing drift on its own,
+// but the parser shouldn't paper over real schema spellings.
+func TestEmbeddedPagedSubresources_CaseInsensitive(t *testing.T) {
+ t.Parallel()
+
+ doc := []byte(`
+openapi: "3.0.0"
+info: { title: PascalCase, version: "1.0" }
+servers: [{ url: https://api.pascal.example/v1 }]
+paths:
+ /pages/{id}:
+ get:
+ operationId: getPage
+ parameters:
+ - { name: id, in: path, required: true, schema: { type: string } }
+ responses:
+ "200":
+ description: ok
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ id: { type: string }
+ Children:
+ type: object
+ properties:
+ Items:
+ type: array
+ items: { type: object }
+ NextPageToken: { type: string }
+`)
+ parsed, err := Parse(doc)
+ require.NoError(t, err)
+ ep, ok := findGetEndpoint(parsed, "/pages/{id}")
+ require.True(t, ok)
+ require.Len(t, ep.EmbeddedPagedSubresources, 1)
+ got := ep.EmbeddedPagedSubresources[0]
+ assert.Equal(t, "Children", got.Property)
+ assert.Equal(t, "Items", got.ItemsField,
+ "detector must preserve the wire-side casing of the items field")
+ assert.Equal(t, "NextPageToken", got.NextField,
+ "detector must preserve the wire-side casing of the next-page field")
+}
+
+// TestEmbeddedPagedSubresources_MultipleProperties verifies the detector
+// reports every paged sub-resource on the same parent — e.g. GitHub-style
+// where a single GET response embeds both `commits` and `review_comments`
+// envelopes.
+func TestEmbeddedPagedSubresources_MultipleProperties(t *testing.T) {
+ t.Parallel()
+
+ doc := []byte(`
+openapi: "3.0.0"
+info: { title: Multi, version: "1.0" }
+servers: [{ url: https://api.multi.example/v1 }]
+paths:
+ /repos/{owner}/{repo}/pulls/{n}:
+ get:
+ operationId: getPull
+ parameters:
+ - { name: owner, in: path, required: true, schema: { type: string } }
+ - { name: repo, in: path, required: true, schema: { type: string } }
+ - { name: n, in: path, required: true, schema: { type: integer } }
+ responses:
+ "200":
+ description: ok
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ commits:
+ type: object
+ properties:
+ items:
+ type: array
+ items: { type: object }
+ next_page_token: { type: string }
+ review_comments:
+ type: object
+ properties:
+ data:
+ type: array
+ items: { type: object }
+ has_more: { type: boolean }
+`)
+ parsed, err := Parse(doc)
+ require.NoError(t, err)
+ ep, ok := findGetEndpoint(parsed, "/repos/{owner}/{repo}/pulls/{n}")
+ require.True(t, ok)
+ require.Len(t, ep.EmbeddedPagedSubresources, 2)
+ byProp := map[string]spec.EmbeddedPagedSubresource{}
+ for _, sub := range ep.EmbeddedPagedSubresources {
+ byProp[sub.Property] = sub
+ }
+ assert.Equal(t, "/repos/{owner}/{repo}/pulls/{n}/commits", byProp["commits"].ChildPath,
+ "multi-path-param parents must propagate every placeholder into ChildPath so the runtime helper substitutes them all")
+ assert.Equal(t, "/repos/{owner}/{repo}/pulls/{n}/review_comments", byProp["review_comments"].ChildPath)
+ assert.Equal(t, "next_page_token", byProp["commits"].NextField)
+ assert.False(t, byProp["commits"].NextIsURL,
+ "next_page_token is an opaque cursor, not a URL — runtime must NOT GET it as a path")
+ assert.True(t, byProp["review_comments"].NextIsBoolean)
+}
+
+// TestItemsFieldNamesMatchRuntimeExtractor guards against silent drift
+// between detect-time (itemsFieldNames in this package) and runtime
+// (the extractPaginatedItems helper emitted into every generated CLI).
+// If one list grows a new envelope key and the other doesn't, the
+// detector either fires on shapes the helper can't handle or skips
+// shapes it could.
+func TestItemsFieldNamesMatchRuntimeExtractor(t *testing.T) {
+ t.Parallel()
+ data, err := os.ReadFile(filepath.Join("..", "generator", "templates", "helpers.go.tmpl"))
+ require.NoError(t, err)
+ for _, name := range itemsFieldNames {
+ assert.Contains(t, string(data), `"`+name+`"`,
+ "runtime extractPaginatedItems must list %q so detection and runtime walks stay in sync", name)
+ }
+}
+
+func findGetEndpoint(parsed *spec.APISpec, path string) (spec.Endpoint, bool) {
+ if parsed == nil {
+ return spec.Endpoint{}, false
+ }
+ for _, r := range parsed.Resources {
+ for _, e := range r.Endpoints {
+ if e.Method == "GET" && e.Path == path {
+ return e, true
+ }
+ }
+ for _, sub := range r.SubResources {
+ for _, e := range sub.Endpoints {
+ if e.Method == "GET" && e.Path == path {
+ return e, true
+ }
+ }
+ }
+ }
+ return spec.Endpoint{}, false
+}
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index 0986df19..8a0864c7 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -1874,6 +1874,12 @@ func mapResources(doc *openapi3.T, out *spec.APISpec, basePath string) {
endpoint.Response, endpoint.ResponsePath = mapResponse(op, targetResourceName+"_"+endpointName, out)
if strings.ToUpper(method) == "GET" {
endpoint.Pagination = detectPagination(endpoint.Params, op)
+ // Only single-resource fetches (GET /resource/{id}) can carry
+ // embedded paged sub-resources; list endpoints ARE the paged
+ // endpoint themselves and don't need a companion helper.
+ if strings.Contains(path, "{") {
+ endpoint.EmbeddedPagedSubresources = detectEmbeddedPagedSubresources(op, path)
+ }
}
endpoint.NoAuth = operationAllowsAnonymous(op, doc)
@@ -4726,6 +4732,180 @@ func detectPagination(params []spec.Param, op *openapi3.Operation) *spec.Paginat
return &pag
}
+// itemsFieldNames lists JSON property names that, when typed as an array,
+// signal "this object is a paged envelope" — matches the runtime
+// extractPaginatedItems helper in templates/helpers.go.tmpl so detect-time
+// and runtime walks agree on what counts as a page of items.
+var itemsFieldNames = []string{"data", "items", "results", "messages", "members", "values"}
+
+// nextFieldURLNames lists string properties that carry a full URL to
+// the next page (resolvable directly with GET). The runtime helper
+// follows these without API-specific cursor arithmetic.
+var nextFieldURLNames = []string{"next", "next_url", "nexturl"}
+
+// nextFieldCursorNames lists string properties that carry an opaque
+// cursor token (e.g. next_page_token, next_cursor). These cannot be
+// followed without knowing which query parameter to set on the next
+// request — metadata the embedded envelope does not provide — so the
+// runtime helper treats their presence the same way it treats
+// has_more=true: fetch page 1, then emit a truncation event so callers
+// know data is incomplete.
+var nextFieldCursorNames = []string{"next_cursor", "nextcursor", "next_page_token", "nextpagetoken", "cursor"}
+
+// nextFieldBoolNames lists boolean-typed "more pages" flags.
+var nextFieldBoolNames = []string{"has_more", "hasmore"}
+
+// detectEmbeddedPagedSubresources walks the success-response schema of a
+// GET operation looking for top-level properties whose nested shape is a
+// paged envelope. Each match yields a companion-helper candidate; the
+// caller stores the result on Endpoint.EmbeddedPagedSubresources and the
+// generator emits fetchFull<Endpoint><Property> helpers from there.
+// Returns nil when nothing matches so callers can compare against nil.
+//
+// Detection is a two-part heuristic: the property schema must be
+// object-shaped AND carry both an array items field (items/data/
+// results/...) and a next-page signal (cursor/URL string or has_more-
+// style bool).
+func detectEmbeddedPagedSubresources(op *openapi3.Operation, parentPath string) []spec.EmbeddedPagedSubresource {
+ if op == nil || op.Responses == nil {
+ return nil
+ }
+ success := selectSuccessResponse(op.Responses)
+ if success == nil || success.Value == nil {
+ return nil
+ }
+ schemaRef := selectResponseSchema(success.Value)
+ if schemaRef == nil || schemaRef.Value == nil {
+ return nil
+ }
+
+ var out []spec.EmbeddedPagedSubresource
+ propNames := make([]string, 0, len(schemaRef.Value.Properties))
+ for name := range schemaRef.Value.Properties {
+ propNames = append(propNames, name)
+ }
+ sort.Strings(propNames)
+
+ for _, propName := range propNames {
+ propRef := schemaRef.Value.Properties[propName]
+ if propRef == nil || propRef.Value == nil {
+ continue
+ }
+ propSchema := propRef.Value
+ if !schemaHasObjectShape(propSchema) {
+ continue
+ }
+ lowered := loweredPropertyMap(propSchema)
+ itemsField, ok := findItemsField(propSchema, lowered)
+ if !ok {
+ continue
+ }
+ nextField, kind := findNextField(lowered)
+ if kind == nextKindNone {
+ continue
+ }
+ out = append(out, spec.EmbeddedPagedSubresource{
+ Property: propName,
+ ChildPath: joinChildPath(parentPath, propName),
+ ItemsField: itemsField,
+ NextField: nextField,
+ NextIsURL: kind == nextKindURL,
+ NextIsBoolean: kind == nextKindBoolean,
+ })
+ }
+ return out
+}
+
+func schemaHasObjectShape(s *openapi3.Schema) bool {
+ if s == nil {
+ return false
+ }
+ if s.Type != nil && s.Type.Is("object") {
+ return true
+ }
+ // Some specs omit `type: object` but declare properties anyway —
+ // treat that as object-shaped for detection purposes.
+ return len(s.Properties) > 0
+}
+
+// loweredPropertyMap returns a case-insensitive lookup from canonical
+// lowercase property name to the wire-side name actually declared on the
+// schema, so detection survives vendors that capitalize fields ("Items",
+// "NextPageToken") while preserving the original casing for emission.
+func loweredPropertyMap(s *openapi3.Schema) map[string]string {
+ out := make(map[string]string, len(s.Properties))
+ for name := range s.Properties {
+ out[strings.ToLower(name)] = name
+ }
+ return out
+}
+
+func findItemsField(s *openapi3.Schema, lowered map[string]string) (string, bool) {
+ if s == nil {
+ return "", false
+ }
+ for _, candidate := range itemsFieldNames {
+ actual, ok := lowered[candidate]
+ if !ok {
+ continue
+ }
+ ref := s.Properties[actual]
+ if ref == nil || ref.Value == nil {
+ continue
+ }
+ if ref.Value.Type != nil && ref.Value.Type.Is("array") {
+ return actual, true
+ }
+ }
+ return "", false
+}
+
+// nextFieldKind classifies the detected next-page signal so the
+// runtime can decide whether to follow it (URL) or stop and warn
+// (cursor / has_more bool).
+type nextFieldKind int
+
+const (
+ nextKindNone nextFieldKind = iota
+ nextKindURL
+ nextKindCursor
+ nextKindBoolean
+)
+
+func findNextField(lowered map[string]string) (string, nextFieldKind) {
+ for _, candidate := range nextFieldURLNames {
+ if actual, ok := lowered[candidate]; ok {
+ return actual, nextKindURL
+ }
+ }
+ for _, candidate := range nextFieldCursorNames {
+ if actual, ok := lowered[candidate]; ok {
+ return actual, nextKindCursor
+ }
+ }
+ for _, candidate := range nextFieldBoolNames {
+ if actual, ok := lowered[candidate]; ok {
+ return actual, nextKindBoolean
+ }
+ }
+ return "", nextKindNone
+}
+
+// joinChildPath returns parentPath + "/" + property — the conventional
+// URL shape for the dedicated child endpoint of an embedded paged
+// sub-resource (e.g. <api>/resource/{id}/<subresource>). APIs that
+// publish the sub-resource at a non-conventional URL must override via
+// the spec field after parsing.
+func joinChildPath(parentPath, property string) string {
+ if parentPath == "" || property == "" {
+ return ""
+ }
+ if strings.HasSuffix(parentPath, "/") {
+ return parentPath + property
+ }
+ return parentPath + "/" + property
+}
+
func humanizeEndpointName(name string) string {
words := strings.Split(strings.ReplaceAll(name, "_", "-"), "-")
if len(words) == 0 {
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 7c7b411a..3b0c5296 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -1041,17 +1041,24 @@ type Endpoint struct {
// the parser cannot describe at field level (currently used only by
// the BodyJSONFallback path). The typed body path uses per-Param
// Required flags instead; this field is ignored when Body is populated.
- BodyRequired bool `yaml:"body_required,omitempty" json:"body_required,omitempty"`
- RequestContentType string `yaml:"request_content_type,omitempty" json:"request_content_type,omitempty"`
- Response ResponseDef `yaml:"response" json:"response"`
- ResponseFormat string `yaml:"response_format,omitempty" json:"response_format,omitempty"` // json (default) or html
- HTMLExtract *HTMLExtract `yaml:"html_extract,omitempty" json:"html_extract,omitempty"` // extraction options when response_format is html
- Pagination *Pagination `yaml:"pagination" json:"pagination"`
- ResponsePath string `yaml:"response_path,omitempty" json:"response_path,omitempty"` // path to extract data array from response (e.g., "data", "results.items")
- Meta map[string]string `yaml:"meta,omitempty" json:"meta,omitempty"` // per-endpoint metadata (e.g., source_tier, source_count from crowd-sniff)
- HeaderOverrides []RequiredHeader `yaml:"header_overrides,omitempty" json:"header_overrides,omitempty"` // per-endpoint header overrides (e.g., different api-version)
- NoAuth bool `yaml:"no_auth,omitempty" json:"no_auth,omitempty"` // true when the endpoint does not require authentication
- Tier string `yaml:"tier,omitempty" json:"tier,omitempty"`
+ BodyRequired bool `yaml:"body_required,omitempty" json:"body_required,omitempty"`
+ RequestContentType string `yaml:"request_content_type,omitempty" json:"request_content_type,omitempty"`
+ Response ResponseDef `yaml:"response" json:"response"`
+ ResponseFormat string `yaml:"response_format,omitempty" json:"response_format,omitempty"` // json (default) or html
+ HTMLExtract *HTMLExtract `yaml:"html_extract,omitempty" json:"html_extract,omitempty"` // extraction options when response_format is html
+ Pagination *Pagination `yaml:"pagination" json:"pagination"`
+ // EmbeddedPagedSubresources names paged-envelope properties nested
+ // inside this endpoint's success response (e.g. GET /<resource>/{id}
+ // where the API caps the embedded sub-resource at the first page
+ // regardless of the actual total). The generator emits a
+ // fetchFull<Endpoint><Property> companion per entry so callers
+ // needing the full child collection don't silently truncate.
+ EmbeddedPagedSubresources []EmbeddedPagedSubresource `yaml:"embedded_paged_subresources,omitempty" json:"embedded_paged_subresources,omitempty"`
+ ResponsePath string `yaml:"response_path,omitempty" json:"response_path,omitempty"` // path to extract data array from response (e.g., "data", "results.items")
+ Meta map[string]string `yaml:"meta,omitempty" json:"meta,omitempty"` // per-endpoint metadata (e.g., source_tier, source_count from crowd-sniff)
+ HeaderOverrides []RequiredHeader `yaml:"header_overrides,omitempty" json:"header_overrides,omitempty"` // per-endpoint header overrides (e.g., different api-version)
+ NoAuth bool `yaml:"no_auth,omitempty" json:"no_auth,omitempty"` // true when the endpoint does not require authentication
+ Tier string `yaml:"tier,omitempty" json:"tier,omitempty"`
// IDField is the resolved primary-key field name for items returned by this
// endpoint, populated either by a path-item-level `x-resource-id` extension
// or, for OpenAPI specs, by walking the response schema (id → name → first
@@ -1296,6 +1303,24 @@ type Pagination struct {
HasMoreField string `yaml:"has_more_field" json:"has_more_field"` // response field indicating more pages (has_more)
}
+// EmbeddedPagedSubresource describes one paged-envelope property nested
+// inside a parent GET response. See Endpoint.EmbeddedPagedSubresources
+// for how this drives fetchFull<X> companion-helper emission.
+//
+// ItemsField records which array key the detector matched; it is
+// detection-provenance metadata, not a runtime override. Generated
+// helpers walk the envelope via extractPaginatedItems, which scans
+// every known items-style key, so a hand-authored spec changing
+// ItemsField does not change runtime behavior.
+type EmbeddedPagedSubresource struct {
+ Property string `yaml:"property" json:"property"` // JSON property name in the parent response (e.g. "tracks")
+ ChildPath string `yaml:"child_path" json:"child_path"` // sub-resource path; required, populated by the detector (parent.Path + "/" + Property)
+ ItemsField string `yaml:"items_field" json:"items_field"` // array property inside the envelope; detection-provenance metadata only
+ NextField string `yaml:"next_field" json:"next_field"` // next-page signal inside the envelope (URL string, opaque cursor, or has_more-style bool)
+ NextIsURL bool `yaml:"next_is_url,omitempty" json:"next_is_url,omitempty"` // true when NextField carries a full URL the runtime can GET directly (vs an opaque cursor that needs API-specific arithmetic)
+ NextIsBoolean bool `yaml:"next_is_boolean,omitempty" json:"next_is_boolean,omitempty"` // true when NextField is a has_more-style boolean rather than a cursor/URL string
+}
+
type TypeDef struct {
Fields []TypeField `yaml:"fields" json:"fields"`
}
diff --git a/testdata/golden/cases/generate-embedded-paged-api/artifacts.txt b/testdata/golden/cases/generate-embedded-paged-api/artifacts.txt
new file mode 100644
index 00000000..36446b3d
--- /dev/null
+++ b/testdata/golden/cases/generate-embedded-paged-api/artifacts.txt
@@ -0,0 +1,4 @@
+embedded-paged-api/internal/cli/helpers.go
+embedded-paged-api/internal/cli/playlists_get.go
+embedded-paged-api/internal/cli/pages_get.go
+embedded-paged-api/internal/cli/customers_get.go
diff --git a/testdata/golden/cases/generate-embedded-paged-api/command.txt b/testdata/golden/cases/generate-embedded-paged-api/command.txt
new file mode 100644
index 00000000..57d65a2f
--- /dev/null
+++ b/testdata/golden/cases/generate-embedded-paged-api/command.txt
@@ -0,0 +1,2 @@
+set -euo pipefail
+"$BINARY" generate --spec testdata/golden/fixtures/embedded-paged-api.yaml --output "$CASE_ACTUAL_DIR/embedded-paged-api" --force --spec-url file://testdata/golden/fixtures/embedded-paged-api.yaml --validate=false
diff --git a/testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/customers_get.go b/testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/customers_get.go
new file mode 100644
index 00000000..b7defffc
--- /dev/null
+++ b/testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/customers_get.go
@@ -0,0 +1,100 @@
+// Copyright 2026 printing-press-golden. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+
+ "github.com/spf13/cobra"
+)
+
+func newCustomersGetCmd(flags *rootFlags) *cobra.Command {
+
+ cmd := &cobra.Command{
+ Use: "get <id>",
+ Short: "Get",
+ Example: " embedded-paged-pp-cli customers get 550e8400-e29b-41d4-a716-446655440000",
+ Annotations: map[string]string{"pp:endpoint": "customers.get", "pp:method": "GET", "pp:path": "/customers/{id}", "mcp:read-only": "true"},
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if len(args) == 0 {
+ return cmd.Help()
+ }
+ c, err := flags.newClient()
+ if err != nil {
+ return err
+ }
+
+ path := "/customers/{id}"
+ path = replacePathParam(path, "id", args[0])
+ params := map[string]string{}
+ data, prov, err := resolveRead(cmd.Context(), c, flags, "customers", false, path, params, nil)
+ if err != nil {
+ return classifyAPIError(err, flags)
+ }
+ // Print provenance to stderr for human-facing output only.
+ // Machine-format flags (--json, --csv, --compact, --quiet, --plain,
+ // --select) and piped stdout suppress this line; the JSON envelope
+ // already carries meta.source for those consumers.
+ // SYNC: keep this gate aligned with command_promoted.go.tmpl.
+ if wantsHumanTable(cmd.OutOrStdout(), flags) {
+ var countItems []json.RawMessage
+ _ = json.Unmarshal(data, &countItems)
+ printProvenance(cmd, len(countItems), prov)
+ }
+ // For JSON output, wrap with provenance envelope before passing through flags.
+ // --select wins over --compact when both are set; --compact only runs when
+ // no explicit fields were requested. Explicit format flags (--csv, --quiet,
+ // --plain) opt out of the auto-JSON path so piped consumers that asked for
+ // a non-JSON format reach the standard pipeline below.
+ if flags.asJSON || (!isTerminal(cmd.OutOrStdout()) && !flags.csv && !flags.quiet && !flags.plain) {
+ filtered := data
+ if flags.selectFields != "" {
+ filtered = filterFields(filtered, flags.selectFields)
+ } else if flags.compact {
+ filtered = compactFields(filtered)
+ }
+ wrapped, wrapErr := wrapWithProvenance(filtered, prov)
+ if wrapErr != nil {
+ return wrapErr
+ }
+ return printOutput(cmd.OutOrStdout(), wrapped, true)
+ }
+ // For all other output modes (table, csv, plain, quiet), use the standard pipeline
+ if wantsHumanTable(cmd.OutOrStdout(), flags) {
+ var items []map[string]any
+ if json.Unmarshal(data, &items) == nil && len(items) > 0 {
+ if err := printAutoTable(cmd.OutOrStdout(), items); err != nil {
+ return err
+ }
+ if len(items) >= 25 {
+ fmt.Fprintf(os.Stderr, "\nShowing %d results. To narrow: add --limit, --json --select, or filter flags.\n", len(items))
+ }
+ return nil
+ }
+ }
+ return printOutputWithFlags(cmd.OutOrStdout(), data, flags)
+ },
+ }
+
+ return cmd
+}
+
+// fetchFullCustomersGetSubscriptions returns every item
+// in the "subscriptions" sub-resource of GET /customers/{id}. The parent
+// response embeds at most the first page of this sub-resource, so any
+// caller that treats the embed as complete silently truncates; this
+// companion calls the dedicated child endpoint and paginates until
+// exhausted. pathParams must include every {placeholder} in the parent
+// path (e.g. {"id": "<value>"}).
+func fetchFullCustomersGetSubscriptions(c interface {
+ GetWithHeaders(path string, params map[string]string, headers map[string]string) (json.RawMessage, error)
+}, pathParams map[string]string) ([]json.RawMessage, error) {
+ childPath := "/customers/{id}/subscriptions"
+ for name, val := range pathParams {
+ childPath = replacePathParam(childPath, name, val)
+ }
+ return fetchEmbeddedPagedSubresource(c, childPath, "has_more", false, true)
+}
diff --git a/testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/helpers.go b/testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/helpers.go
new file mode 100644
index 00000000..bfc80e0b
--- /dev/null
+++ b/testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/helpers.go
@@ -0,0 +1,1659 @@
+// Copyright 2026 printing-press-golden. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "bytes"
+ "embedded-paged-pp-cli/internal/client"
+ "embedded-paged-pp-cli/internal/cliutil"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "github.com/spf13/cobra"
+ "github.com/spf13/pflag"
+ "io"
+ "net/url"
+ "os"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strings"
+ "text/tabwriter"
+ "time"
+ "unicode"
+)
+
+var As = errors.As
+
+// noColor is set by the --no-color flag
+var noColor bool
+
+// humanFriendly is set by the --human-friendly flag; colors are off by default (agent-safe)
+var humanFriendly bool
+
+func colorEnabled() bool {
+ if noColor {
+ return false
+ }
+ if !humanFriendly {
+ return false
+ }
+ if os.Getenv("NO_COLOR") != "" {
+ return false
+ }
+ if os.Getenv("TERM") == "dumb" {
+ return false
+ }
+ return isTerminal(os.Stdout)
+}
+
+func isTerminal(w io.Writer) bool {
+ if f, ok := w.(*os.File); ok {
+ fi, err := f.Stat()
+ if err != nil {
+ return true
+ }
+ return (fi.Mode() & os.ModeCharDevice) != 0
+ }
+ return false
+}
+
+func bold(s string) string {
+ if !colorEnabled() {
+ return s
+ }
+ return "\033[1m" + s + "\033[0m"
+}
+
+func green(s string) string {
+ if !colorEnabled() {
+ return s
+ }
+ return "\033[32m" + s + "\033[0m"
+}
+
+func red(s string) string {
+ if !colorEnabled() {
+ return s
+ }
+ return "\033[31m" + s + "\033[0m"
+}
+
+func yellow(s string) string {
+ if !colorEnabled() {
+ return s
+ }
+ return "\033[33m" + s + "\033[0m"
+}
+
+type cliError struct {
+ code int
+ err error
+}
+
+func (e *cliError) Error() string { return e.err.Error() }
+func (e *cliError) Unwrap() error { return e.err }
+
+func usageErr(err error) error { return &cliError{code: 2, err: err} }
+func notFoundErr(err error) error { return &cliError{code: 3, err: err} }
+func authErr(err error) error { return &cliError{code: 4, err: err} }
+func apiErr(err error) error { return &cliError{code: 5, err: err} }
+func configErr(err error) error { return &cliError{code: 10, err: err} }
+func rateLimitErr(err error) error { return &cliError{code: 7, err: err} }
+
+// dryRunOK reports whether the command should short-circuit without doing any
+// real work because --dry-run was set. The verify pipeline probes hand-written
+// commands with --dry-run; commands that put validation in cobra's `Args:` or
+// `MarkFlagRequired` cannot reach a dry-run guard inside RunE because cobra
+// runs those checks before RunE. The verify-friendly pattern for hand-written
+// commands is:
+//
+// RunE: func(cmd *cobra.Command, args []string) error {
+// if len(args) == 0 {
+// return cmd.Help()
+// }
+// if dryRunOK(flags) {
+// return nil
+// }
+// // ... real work ...
+// }
+//
+// See SKILL.md "Phase 3: Build The GOAT" for the full pattern.
+func dryRunOK(flags *rootFlags) bool {
+ return flags != nil && flags.dryRun
+}
+
+// accessWarning describes an API access-denial that sync converts into a
+// non-fatal warning. It carries enough structured data for the sync_warning
+// JSON event without parsing free-form error strings downstream.
+type accessWarning struct {
+ Status int // HTTP status when applicable; 0 for GraphQL field-level denials.
+ Reason string // "forbidden" | "insufficient_access" | "unauthenticated"
+ Message string // human-readable detail (the API's body or GraphQL error message)
+}
+
+// syncErrorJSON returns a one-line JSON sync_error event. When err wraps a
+// *client.APIError, the structured status/method/path/body fields let
+// operators see the API's response body without parsing a wrapped error
+// string — required for diagnosing 4xx responses on endpoints whose OpenAPI
+// spec marks filter params optional but the API treats them as required.
+//
+// parent is non-empty only on the dependent-resource path, where it
+// identifies which parent ID's child request failed.
+func syncErrorJSON(resource, parent string, err error) string {
+ payload := struct {
+ Event string `json:"event"`
+ Resource string `json:"resource"`
+ Parent string `json:"parent,omitempty"`
+ Status int `json:"status,omitempty"`
+ Method string `json:"method,omitempty"`
+ Path string `json:"path,omitempty"`
+ Body string `json:"body,omitempty"`
+ Error string `json:"error"`
+ }{
+ Event: "sync_error",
+ Resource: resource,
+ Parent: parent,
+ Error: err.Error(),
+ }
+ var apiErr *client.APIError
+ if errors.As(err, &apiErr) {
+ payload.Status = apiErr.StatusCode
+ payload.Method = apiErr.Method
+ payload.Path = apiErr.Path
+ payload.Body = apiErr.Body
+ }
+ out, _ := json.Marshal(payload)
+ return string(out)
+}
+
+// syncUserParams carries user-supplied query parameters injected into every
+// sync HTTP request. perResource entries win over global on key conflict.
+type syncUserParams struct {
+ global map[string]string
+ perResource map[string]map[string]string
+}
+
+// parseSyncUserParams parses the repeatable --param key=value and
+// --resource-param resource:key=value flags. Returns usage errors keyed on the
+// specific invalid token so the user sees which entry was rejected.
+func parseSyncUserParams(globalFlags, perResourceFlags []string) (*syncUserParams, error) {
+ p := &syncUserParams{
+ global: map[string]string{},
+ perResource: map[string]map[string]string{},
+ }
+ for _, kv := range globalFlags {
+ k, v, ok := strings.Cut(kv, "=")
+ if !ok || k == "" {
+ return nil, fmt.Errorf("invalid --param %q: expected key=value", kv)
+ }
+ p.global[k] = v
+ }
+ for _, spec := range perResourceFlags {
+ resource, kv, ok := strings.Cut(spec, ":")
+ if !ok || resource == "" {
+ return nil, fmt.Errorf("invalid --resource-param %q: expected resource:key=value", spec)
+ }
+ k, v, ok := strings.Cut(kv, "=")
+ if !ok || k == "" {
+ return nil, fmt.Errorf("invalid --resource-param %q: expected resource:key=value", spec)
+ }
+ if p.perResource[resource] == nil {
+ p.perResource[resource] = map[string]string{}
+ }
+ p.perResource[resource][k] = v
+ }
+ return p, nil
+}
+
+// applyTo merges user params into the request map. Called after spec-derived
+// params (cursor, since, page-size, dates) so user flags can override them.
+func (p *syncUserParams) applyTo(resource string, params map[string]string) {
+ if p == nil {
+ return
+ }
+ for k, v := range p.global {
+ params[k] = v
+ }
+ for k, v := range p.perResource[resource] {
+ params[k] = v
+ }
+}
+
+// validateResourceNames returns a usage-shaped error when any --resource-param
+// key targets a resource not in known. Without this check a typo
+// (e.g. --resource-param chanels:mine=true) is a silent no-op: the param
+// never matches a real resource and sync proceeds with missing rows.
+func (p *syncUserParams) validateResourceNames(known []string) error {
+ if p == nil || len(p.perResource) == 0 {
+ return nil
+ }
+ set := make(map[string]struct{}, len(known))
+ for _, r := range known {
+ set[r] = struct{}{}
+ }
+ var unknown []string
+ for r := range p.perResource {
+ if _, ok := set[r]; !ok {
+ unknown = append(unknown, r)
+ }
+ }
+ if len(unknown) == 0 {
+ return nil
+ }
+ sort.Strings(unknown)
+ return fmt.Errorf("--resource-param references unknown resource(s): %s (known: %s)",
+ strings.Join(unknown, ", "), strings.Join(known, ", "))
+}
+
+// accessDenialPatterns matches API error bodies that indicate the request was
+// rejected for access-policy reasons rather than for input validity. Matching
+// is case-insensitive and uses word boundaries so common substrings inside
+// unrelated tokens (e.g. "author", "pagination_token", "insufficient_funds")
+// do not produce false positives. The set deliberately excludes brand names —
+// vendor-specific phrasings should be addressed at the spec/profiler level,
+// not in this universal classifier.
+var accessDenialPatterns = []*regexp.Regexp{
+ regexp.MustCompile(`\bforbidden\b`),
+ regexp.MustCompile(`\bunauthorized\b`),
+ regexp.MustCompile(`\bnot[\s_-]?authorized\b`),
+ regexp.MustCompile(`\bpermission[\s_-]?denied\b`),
+ regexp.MustCompile(`\baccess[\s_-]?denied\b`),
+ regexp.MustCompile(`\binsufficient[\s_-]?(scope|permission|privilege)`),
+ regexp.MustCompile(`\binvalid[\s_-]?scope\b`),
+ regexp.MustCompile(`\bmissing[\s_-]?scope\b`),
+ regexp.MustCompile(`\brequires?\s+(elevated|admin|enterprise|business|workspace|enterprise[\s_-]?tier)`),
+}
+
+// looksLikeAccessDenial reports whether body text describes an access-policy
+// rejection. Use it on response-body content (apiErr.Body), not on the full
+// error string — the request path can contain words like "auth" or "tokens"
+// that would produce false positives if the whole error message were scanned.
+func looksLikeAccessDenial(body string) bool {
+ lower := strings.ToLower(body)
+ for _, p := range accessDenialPatterns {
+ if p.MatchString(lower) {
+ return true
+ }
+ }
+ return false
+}
+
+// isSyncAccessWarning classifies err as an access-denial warning suitable for
+// sync's warn-and-continue path. It returns nil, false for any error that
+// should remain a hard sync failure: HTTP 401 (token-level auth failure
+// requiring re-auth), 5xx, network errors, and HTTP 400 responses whose
+// bodies do not match an access-policy pattern.
+//
+// Recognized warning shapes:
+// - HTTP 403 (per-resource ACL rejection)
+// - HTTP 400 + access-denial body keyword (insufficient scope, etc.)
+// - GraphQL response carrying only access-denial extension codes
+func isSyncAccessWarning(err error) (*accessWarning, bool) {
+ if err == nil {
+ return nil, false
+ }
+
+ var apiErr *client.APIError
+ if errors.As(err, &apiErr) {
+ switch apiErr.StatusCode {
+ case 403:
+ return &accessWarning{Status: 403, Reason: "forbidden", Message: apiErr.Body}, true
+ case 400:
+ if looksLikeAccessDenial(apiErr.Body) {
+ return &accessWarning{Status: 400, Reason: "insufficient_access", Message: apiErr.Body}, true
+ }
+ }
+ }
+
+ return nil, false
+}
+
+type noopResult struct {
+ Status string `json:"status"`
+ Reason string `json:"reason"`
+}
+
+func writeNoop(flags *rootFlags, reason, prose string) error {
+ if flags != nil && flags.asJSON {
+ return json.NewEncoder(os.Stdout).Encode(noopResult{Status: "noop", Reason: reason})
+ }
+ fmt.Fprintln(os.Stderr, prose)
+ return nil
+}
+
+func writeAPIErrorEnvelope(flags *rootFlags, err error, code int) {
+ if flags == nil || !flags.asJSON {
+ return
+ }
+ _ = json.NewEncoder(os.Stdout).Encode(map[string]any{
+ "error": err.Error(),
+ "code": code,
+ })
+}
+
+// classifyAPIError maps API errors to structured exit codes with actionable hints.
+func classifyAPIError(err error, flags *rootFlags) error {
+ msg := err.Error()
+ switch {
+ case strings.Contains(msg, "HTTP 409"):
+ if flags != nil && flags.idempotent {
+ return writeNoop(flags, "already_exists", "already exists (no-op)")
+ }
+ classified := apiErr(err)
+ writeAPIErrorEnvelope(flags, classified, ExitCode(classified))
+ return classified
+ case strings.Contains(msg, "HTTP 400") && cliutil.LooksLikeAuthError(msg):
+ return authErr(fmt.Errorf("%w\nhint: the API rejected the request — this usually means auth is missing or invalid."+
+ "\n Set your API key: export EMBEDDED_PAGED_API_KEY=<your-key>"+
+ "\n Run 'embedded-paged-pp-cli doctor' to check auth status."+
+ "\n Response: "+cliutil.SanitizeErrorBody(msg), err))
+ case strings.Contains(msg, "HTTP 401"):
+ return authErr(fmt.Errorf("%w\nhint: check your API key."+
+ " Set it with: export EMBEDDED_PAGED_API_KEY=<your-key>"+
+ "\n Run 'embedded-paged-pp-cli doctor' to check auth status.", err))
+ case strings.Contains(msg, "HTTP 403"):
+ return authErr(fmt.Errorf("%w\nhint: permission denied. Your credentials are valid but lack access to this resource."+
+ "\n Check that your API key has the required permissions."+
+ "\n Set it with: export EMBEDDED_PAGED_API_KEY=<your-key>"+
+ "\n Run 'embedded-paged-pp-cli doctor' to check auth status.", err))
+ case strings.Contains(msg, "HTTP 404"):
+ return notFoundErr(fmt.Errorf("%w\nhint: resource not found. Run the 'list' command to see available items", err))
+ case strings.Contains(msg, "HTTP 429"):
+ return rateLimitErr(err)
+ default:
+ return apiErr(err)
+ }
+}
+
+func truncate(s string, max int) string {
+ if len(s) <= max {
+ return s
+ }
+ if max <= 3 {
+ return s[:max]
+ }
+ return s[:max-3] + "..."
+}
+
+func newTabWriter(w io.Writer) *tabwriter.Writer {
+ return tabwriter.NewWriter(w, 2, 4, 2, ' ', 0)
+}
+func replacePathParam(path, name, value string) string {
+ return strings.ReplaceAll(path, "{"+name+"}", value)
+}
+
+// paginatedGet fetches pages and concatenates array results. The headers
+// argument carries per-endpoint required headers (e.g. cal-api-version) that
+// must be sent on every page request, including the first; pass nil when the
+// endpoint has no per-endpoint header overrides.
+func paginatedGet(c interface {
+ GetWithHeaders(path string, params map[string]string, headers map[string]string) (json.RawMessage, error)
+}, path string, params map[string]string, headers map[string]string, fetchAll bool, cursorParam, nextCursorPath, hasMoreField string) (json.RawMessage, error) {
+ // Cursor params are exempt from the "0"/"false" strip: offset-paginated
+ // APIs send offset=0 on the first page.
+ clean := map[string]string{}
+ for k, v := range params {
+ if v == "" {
+ continue
+ }
+ if k == cursorParam || (v != "0" && v != "false") {
+ clean[k] = v
+ }
+ }
+
+ if !fetchAll {
+ data, err := c.GetWithHeaders(path, clean, headers)
+ if err != nil {
+ return nil, err
+ }
+ emitTruncationWarning(data, nextCursorPath, hasMoreField)
+ return data, nil
+ }
+
+ // Fetch all pages
+ allItems := make([]json.RawMessage, 0)
+ page := 0
+ for {
+ page++
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, "fetching page %d...\n", page)
+ } else {
+ fmt.Fprintf(os.Stderr, `{"event":"page_fetch","page":%d}`+"\n", page)
+ }
+
+ data, err := c.GetWithHeaders(path, clean, headers)
+ if err != nil {
+ return nil, err
+ }
+
+ // Try to extract items array
+ var items []json.RawMessage
+ if json.Unmarshal(data, &items) == nil {
+ allItems = append(allItems, items...)
+ } else {
+ // Response is an object - look for array inside
+ var obj map[string]json.RawMessage
+ if json.Unmarshal(data, &obj) == nil {
+ if nested, ok := extractPaginatedItems(obj); ok {
+ allItems = append(allItems, nested...)
+ }
+
+ // Check for next cursor
+ if nextCursorPath != "" {
+ if tokenRaw, ok := rawAtPath(obj, nextCursorPath); ok {
+ var token string
+ if json.Unmarshal(tokenRaw, &token) == nil && token != "" {
+ clean[cursorParam] = token
+ continue
+ }
+ }
+ }
+
+ // Check has_more
+ if hasMoreField != "" {
+ if moreRaw, ok := rawAtPath(obj, hasMoreField); ok {
+ var more bool
+ if json.Unmarshal(moreRaw, &more) == nil && more {
+ continue
+ }
+ }
+ }
+ }
+ // No more pages
+ break
+ }
+
+ // For direct arrays, can't paginate without cursor
+ break
+ }
+
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, "fetched %d items across %d pages\n", len(allItems), page)
+ } else {
+ fmt.Fprintf(os.Stderr, `{"event":"complete","total":%d,"pages":%d}`+"\n", len(allItems), page)
+ }
+ result, _ := json.Marshal(allItems)
+ 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 {
+ var nested []json.RawMessage
+ if json.Unmarshal(arr, &nested) == nil {
+ return nested, true
+ }
+ }
+ }
+
+ var onlyArray []json.RawMessage
+ arrayCount := 0
+ for _, raw := range obj {
+ var candidate []json.RawMessage
+ if json.Unmarshal(raw, &candidate) == nil {
+ onlyArray = candidate
+ arrayCount++
+ }
+ }
+ if arrayCount == 1 {
+ return onlyArray, true
+ }
+ return nil, false
+}
+
+// embeddedPagedSubresourcePageCap bounds the page loop in
+// fetchEmbeddedPagedSubresource so a misbehaving server returning a
+// non-empty next URL on every response can't spin forever. 500 covers
+// the largest realistic embedded sub-resource (e.g. 50k items at 100
+// per page); pathological servers trip the cap and surface an error
+// instead of hanging.
+const embeddedPagedSubresourcePageCap = 500
+
+// fetchEmbeddedPagedSubresource paginates the embedded paged
+// sub-resource at childPath until exhausted, returning the
+// concatenated items. The generator emits per-endpoint thin wrappers
+// that call this with the detected envelope shape; consolidating the
+// loop here keeps the page cap, error handling, and envelope-walking
+// logic in one place.
+//
+// nextField names the envelope's "more pages" property. nextIsURL
+// distinguishes the two cursor shapes:
+//
+// - nextIsURL=true: the property carries a full URL the runtime can
+// GET directly to fetch the next page (envelope shape: items+next).
+// - nextIsURL=false: the property either carries an opaque cursor
+// token whose required query-param name isn't part of the embedded
+// envelope, or it's a has_more-style bool. In either case the
+// helper performs a single GET and emits a stderr truncation event
+// when the field indicates more pages exist — paginating further
+// requires API-specific arithmetic that cannot be synthesized
+// from the envelope alone, so callers must use the dedicated list
+// endpoint for full traversal.
+//
+// Envelope lookups are case-insensitive: API authors sometimes
+// describe envelopes in one casing in the schema and serve another on
+// the wire (`NextPageToken` vs `nextPageToken`).
+func fetchEmbeddedPagedSubresource(c interface {
+ GetWithHeaders(path string, params map[string]string, headers map[string]string) (json.RawMessage, error)
+}, childPath, nextField string, nextIsURL, nextIsBoolean bool) ([]json.RawMessage, error) {
+ all := make([]json.RawMessage, 0)
+ nextURL := ""
+ for page := 0; page < embeddedPagedSubresourcePageCap; page++ {
+ target := childPath
+ if nextURL != "" {
+ target = relativizeNextURL(nextURL)
+ }
+ data, err := c.GetWithHeaders(target, nil, nil)
+ if err != nil {
+ return nil, fmt.Errorf("paginating embedded sub-resource %q page %d: %w", childPath, page+1, err)
+ }
+ var obj map[string]json.RawMessage
+ if err := json.Unmarshal(data, &obj); err != nil {
+ return nil, fmt.Errorf("paginating embedded sub-resource %q page %d: unexpected response shape: %w", childPath, page+1, err)
+ }
+ if items, ok := extractPaginatedItems(obj); ok {
+ all = append(all, items...)
+ }
+ if !nextIsURL {
+ if moreRaw, ok := caseInsensitiveLookup(obj, nextField); ok {
+ if hasUnfollowableNextSignal(moreRaw, nextIsBoolean) {
+ emitEmbeddedPagedTruncationWarning(childPath)
+ }
+ }
+ return all, nil
+ }
+ nextRaw, ok := caseInsensitiveLookup(obj, nextField)
+ if !ok {
+ return all, nil
+ }
+ var nextStr string
+ if json.Unmarshal(nextRaw, &nextStr) != nil || nextStr == "" {
+ return all, nil
+ }
+ nextURL = nextStr
+ }
+ return all, fmt.Errorf("paginating embedded sub-resource %q: hit %d-page cap (likely a server returning a non-empty next URL on every response)", childPath, embeddedPagedSubresourcePageCap)
+}
+
+// hasUnfollowableNextSignal reports whether a non-URL next field
+// value indicates more pages exist (true bool, non-empty cursor
+// string) — the caller emits a truncation warning when this returns
+// true. Returns false on missing/null/false/empty signals.
+func hasUnfollowableNextSignal(raw json.RawMessage, nextIsBoolean bool) bool {
+ if nextIsBoolean {
+ var more bool
+ return json.Unmarshal(raw, &more) == nil && more
+ }
+ var cursor string
+ return json.Unmarshal(raw, &cursor) == nil && cursor != ""
+}
+
+func emitEmbeddedPagedTruncationWarning(childPath string) {
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, "warning: %s truncated; more pages available via the dedicated list endpoint.\n", childPath)
+ } else {
+ fmt.Fprintf(os.Stderr, `{"event":"truncated","sub_resource":%q}`+"\n", childPath)
+ }
+}
+
+// relativizeNextURL strips an absolute scheme+host from a next-page URL
+// so the client's BaseURL+path concat doesn't produce a double-prefixed
+// target. Vendors that return absolute next URLs (e.g. for offset-based
+// pagination) would otherwise concatenate against c.BaseURL on every
+// follow-up request and 404 from page 2 onward.
+func relativizeNextURL(raw string) string {
+ u, err := url.Parse(raw)
+ if err != nil || !u.IsAbs() {
+ return raw
+ }
+ path := u.Path
+ if path == "" {
+ path = "/"
+ }
+ if u.RawQuery != "" {
+ path += "?" + u.RawQuery
+ }
+ return path
+}
+
+// caseInsensitiveLookup returns obj[key] tolerating envelope casing
+// drift between the spec and the wire response.
+func caseInsensitiveLookup(obj map[string]json.RawMessage, key string) (json.RawMessage, bool) {
+ if raw, ok := obj[key]; ok {
+ return raw, true
+ }
+ lowered := strings.ToLower(key)
+ for k, raw := range obj {
+ if strings.ToLower(k) == lowered {
+ return raw, true
+ }
+ }
+ return nil, false
+}
+
+func rawAtPath(obj map[string]json.RawMessage, path string) (json.RawMessage, bool) {
+ if raw, ok := obj[path]; ok {
+ return raw, true
+ }
+
+ current := obj
+ parts := strings.Split(path, ".")
+ for i, part := range parts {
+ raw, ok := current[part]
+ if !ok {
+ return nil, false
+ }
+ if i == len(parts)-1 {
+ return raw, true
+ }
+ if err := json.Unmarshal(raw, ¤t); err != nil {
+ return nil, false
+ }
+ }
+ return nil, false
+}
+
+// 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.
+func filterFields(data json.RawMessage, fields string) json.RawMessage {
+ var paths [][]string
+ for _, f := range strings.Split(fields, ",") {
+ f = strings.TrimSpace(f)
+ if f == "" {
+ continue
+ }
+ parts := strings.Split(f, ".")
+ for i := range parts {
+ parts[i] = strings.ToLower(parts[i])
+ }
+ paths = append(paths, parts)
+ }
+ if len(paths) == 0 {
+ return data
+ }
+ return filterFieldsRec(data, paths)
+}
+
+// filterFieldsRec applies path filters to a JSON value. Each path is a list of
+// lowercase segments; arrays descend element-wise.
+func filterFieldsRec(data json.RawMessage, paths [][]string) json.RawMessage {
+ var arr []json.RawMessage
+ if err := json.Unmarshal(data, &arr); err == nil {
+ out := make([]json.RawMessage, len(arr))
+ for i, el := range arr {
+ out[i] = filterFieldsRec(el, paths)
+ }
+ result, _ := json.Marshal(out)
+ return result
+ }
+
+ var obj map[string]json.RawMessage
+ if err := json.Unmarshal(data, &obj); err == nil {
+ keepWhole := map[string]bool{}
+ subPaths := map[string][][]string{}
+ for _, p := range paths {
+ if len(p) == 0 {
+ continue
+ }
+ head := p[0]
+ if len(p) == 1 {
+ keepWhole[head] = true
+ } else {
+ subPaths[head] = append(subPaths[head], p[1:])
+ }
+ }
+ filtered := map[string]json.RawMessage{}
+ for k, v := range obj {
+ matched := matchSelectSegment(k, keepWhole, subPaths)
+ if matched == "" {
+ continue
+ }
+ if keepWhole[matched] {
+ filtered[k] = v
+ continue
+ }
+ if subs := subPaths[matched]; subs != nil {
+ filtered[k] = filterFieldsRec(v, subs)
+ }
+ }
+ result, _ := json.Marshal(filtered)
+ return result
+ }
+
+ return data
+}
+
+// matchSelectSegment returns the matching lowercase segment, or "" if no match.
+// Supports direct case-insensitive match and camelCase→kebab-case conversion.
+func matchSelectSegment(fieldName string, keepWhole map[string]bool, subPaths map[string][][]string) string {
+ lower := strings.ToLower(fieldName)
+ if keepWhole[lower] || subPaths[lower] != nil {
+ return lower
+ }
+ kebab := camelToKebab(fieldName)
+ if kebab != lower && (keepWhole[kebab] || subPaths[kebab] != nil) {
+ return kebab
+ }
+ return ""
+}
+
+// camelToKebab converts "orderDate" or "orderdate" to "order-date" by splitting on
+// uppercase boundaries. For already-lowercase input, splits on known word boundaries.
+func camelToKebab(s string) string {
+ var b strings.Builder
+ runes := []rune(s)
+ for i, r := range runes {
+ if i > 0 && unicode.IsUpper(r) && unicode.IsLower(runes[i-1]) {
+ b.WriteByte('-')
+ }
+ b.WriteRune(unicode.ToLower(r))
+ }
+ return b.String()
+}
+
+// printOutputWithFlags routes output through the right format based on flags.
+func printOutputWithFlags(w io.Writer, data json.RawMessage, flags *rootFlags) error {
+ // --select wins over --compact when both are set: an explicit field list
+ // is the user's authoritative request, so the high-gravity allow-list
+ // must not strip those fields out before --select can pick them. When
+ // only --compact is set (e.g., --agent without --select), the allow-list
+ // still runs.
+ if flags.selectFields != "" {
+ data = filterFields(data, flags.selectFields)
+ } else if flags.compact {
+ data = compactFields(data)
+ }
+ // --quiet: suppress all output, exit code communicates result
+ if flags.quiet {
+ return nil
+ }
+ // --csv: render as CSV
+ if flags.csv {
+ return printCSV(w, data)
+ }
+ return printOutput(w, data, flags.asJSON)
+}
+
+// extractResponseData unwraps common API response envelopes for display.
+// Many APIs return {"status":"success","data":[...]} instead of a bare array.
+// This extracts the inner data for output helpers (filterFields, compactFields,
+// printAutoTable) that expect arrays or flat objects.
+//
+// Only unwraps when a "status" field is present and indicates success — this
+// avoids false positives on APIs where "data" is a regular field (e.g., Stripe
+// returns {"data":[...],"has_more":true} where "data" is the list, not an
+// envelope wrapper).
+func extractResponseData(data json.RawMessage) json.RawMessage {
+ var envelope struct {
+ Status string `json:"status"`
+ Data json.RawMessage `json:"data"`
+ }
+ if err := json.Unmarshal(data, &envelope); err != nil {
+ return data
+ }
+ if envelope.Data == nil || envelope.Status == "" {
+ return data // No status field = not an envelope, might be regular "data" field
+ }
+ switch envelope.Status {
+ case "success", "ok", "OK", "Success":
+ return envelope.Data
+ default:
+ return data
+ }
+}
+
+// compactVerboseFields are the prose-shaped fields stripped by both the
+// list and single-object compact paths. Centralised so the contract stays
+// in lockstep across both helpers.
+var compactVerboseFields = map[string]bool{
+ "description": true, "body": true, "content": true,
+ "comments": true, "attachments": true, "html": true, "markdown": true,
+}
+
+// compactFields keeps only the most important fields for agent consumption.
+// For arrays: allowlist of high-gravity fields (no descriptions).
+// For single objects: blocklist that strips known-verbose fields (descriptions, comments, etc.).
+func compactFields(data json.RawMessage) json.RawMessage {
+ // Try array first
+ var items []map[string]any
+ if err := json.Unmarshal(data, &items); err == nil {
+ return compactListFields(items)
+ }
+
+ // Single object — use blocklist
+ var obj map[string]any
+ if err := json.Unmarshal(data, &obj); err == nil {
+ return compactObjectFields(obj)
+ }
+
+ return data
+}
+
+// compactListFields keeps only high-gravity fields for array responses.
+//
+// Two-layer keep rule:
+//
+// 1. A static allow-list covers canonical scalars (id/name/price/status/...).
+// 2. A data-driven extension also keeps any scalar key present in at least
+// 80% of input rows. This catches hand-written novel commands whose
+// output keys (object_name, match_key, snippet) aren't on the canonical
+// allow-list, without forcing every printed CLI to expand the list.
+//
+// Verbose fields (description, body, content, etc.) are excluded from the
+// data-driven extension regardless of frequency, so the compact intent
+// (short identifying values for agent consumption, not full prose) is
+// preserved.
+//
+// When an item still carries none of the keep keys, the original is
+// preserved so `--agent` does not silently emit {} for shapes whose key
+// names are entirely off-canonical.
+func compactListFields(items []map[string]any) json.RawMessage {
+ keepFields := map[string]bool{
+ // Identity
+ "id": true, "name": true, "title": true, "identifier": true,
+ "code": true, "slug": true, "key": true,
+ // Categorization
+ "status": true, "state": true, "type": true, "kind": true, "priority": true,
+ // Communication
+ "url": true, "email": true,
+ // Monetary
+ "price": true, "amount": true, "cost": true, "fare": true,
+ "rate": true, "currency": true,
+ // Metrics
+ "rating": true, "score": true, "count": true,
+ // Locale / geo
+ "language": true, "locale": true, "country": true, "region": true,
+ "city": true, "domain": true,
+ // Temporal
+ "created_at": true, "updated_at": true, "createdAt": true, "updatedAt": true,
+ "date": true,
+ // Versioning
+ "version": true,
+ }
+ if len(items) > 0 {
+ keyCounts := map[string]int{}
+ for _, item := range items {
+ for k, v := range item {
+ if compactVerboseFields[k] || !isCompactScalar(v) {
+ continue
+ }
+ keyCounts[k]++
+ }
+ }
+ // ceil(len(items) * 0.8) without importing math. Capped at len-1 for
+ // len >= 2 so a single missing row cannot veto a key on small lists
+ // (without the cap, ceil(0.8*n) == n for n in {2,3,4}, which silently
+ // reintroduces the partial-strip bug whenever a heterogeneous 2-4 row
+ // response mixes one allow-list key with novel keys).
+ threshold := (len(items)*4 + 4) / 5
+ if len(items) >= 2 && threshold > len(items)-1 {
+ threshold = len(items) - 1
+ }
+ for k, count := range keyCounts {
+ if count >= threshold {
+ keepFields[k] = true
+ }
+ }
+ }
+
+ filtered := make([]map[string]any, 0, len(items))
+ for _, item := range items {
+ compact := map[string]any{}
+ for k, v := range item {
+ if keepFields[k] {
+ compact[k] = v
+ }
+ }
+ if len(compact) == 0 {
+ compact = item
+ }
+ filtered = append(filtered, compact)
+ }
+ result, _ := json.Marshal(filtered)
+ return result
+}
+
+// isCompactScalar reports whether v is a small primitive (string, number,
+// bool, null) suitable for --compact projection. Objects and arrays are
+// rejected: they almost always represent nested structure that defeats the
+// "short identifying value" intent of --compact, and including them in the
+// data-driven keep set would bloat agent output.
+func isCompactScalar(v any) bool {
+ switch v.(type) {
+ case nil, bool, float64, string:
+ return true
+ default:
+ return false
+ }
+}
+
+// compactObjectFields strips known-verbose fields from single-object responses.
+// Uses a blocklist so it works across all API domains (project management, payments, CRM, etc.).
+func compactObjectFields(obj map[string]any) json.RawMessage {
+ compact := map[string]any{}
+ for k, v := range obj {
+ if !compactVerboseFields[k] {
+ compact[k] = v
+ }
+ }
+ result, _ := json.Marshal(compact)
+ return result
+}
+
+// printCSV renders JSON arrays as CSV with header row.
+func printCSV(w io.Writer, data json.RawMessage) error {
+ var items []map[string]any
+ if err := json.Unmarshal(data, &items); err != nil || len(items) == 0 {
+ // Single object or empty - just print as JSON
+ fmt.Fprintln(w, string(data))
+ return nil
+ }
+ // Collect all keys for header
+ keySet := map[string]bool{}
+ for _, item := range items {
+ for k := range item {
+ keySet[k] = true
+ }
+ }
+ var keys []string
+ for k := range keySet {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ // Header
+ fmt.Fprintln(w, strings.Join(keys, ","))
+ // Rows
+ for _, item := range items {
+ var vals []string
+ for _, k := range keys {
+ v := item[k]
+ if v == nil {
+ vals = append(vals, "")
+ } else {
+ s := fmt.Sprintf("%v", v)
+ if strings.ContainsAny(s, ",\"\n") {
+ s = `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
+ }
+ vals = append(vals, s)
+ }
+ }
+ fmt.Fprintln(w, strings.Join(vals, ","))
+ }
+ return nil
+}
+
+// printOutput auto-detects arrays and renders as tables, or prints raw JSON for objects.
+func printOutput(w io.Writer, data json.RawMessage, asJSON bool) error {
+ if !asJSON && !isTerminal(w) {
+ asJSON = true
+ }
+
+ if asJSON {
+ enc := json.NewEncoder(w)
+ enc.SetIndent("", " ")
+ return enc.Encode(data)
+ }
+
+ // Try to detect if response is an array
+ var items []map[string]any
+ if err := json.Unmarshal(data, &items); err == nil && len(items) > 0 {
+ if err := printAutoTable(w, items); err != nil {
+ return err
+ }
+ // Agent-friendly: show count and suggest narrowing when results are large
+ if len(items) >= 25 {
+ fmt.Fprintf(os.Stderr, "\nShowing %d results. To narrow: add --limit, --json --select, or filter flags.\n", len(items))
+ }
+ return nil
+ }
+
+ // Single object - pretty print
+ var obj map[string]any
+ if err := json.Unmarshal(data, &obj); err == nil {
+ enc := json.NewEncoder(w)
+ enc.SetIndent("", " ")
+ return enc.Encode(obj)
+ }
+
+ // Fallback: print raw
+ fmt.Fprintln(w, string(data))
+ return nil
+}
+
+// levenshteinDistance computes the edit distance between two strings using a two-row DP approach.
+func levenshteinDistance(a, b string) int {
+ if len(a) == 0 {
+ return len(b)
+ }
+ if len(b) == 0 {
+ return len(a)
+ }
+ if len(a) < len(b) {
+ a, b = b, a
+ }
+ prev := make([]int, len(b)+1)
+ curr := make([]int, len(b)+1)
+ for j := range prev {
+ prev[j] = j
+ }
+ for i := 1; i <= len(a); i++ {
+ curr[0] = i
+ for j := 1; j <= len(b); j++ {
+ cost := 1
+ if a[i-1] == b[j-1] {
+ cost = 0
+ }
+ ins := curr[j-1] + 1
+ del := prev[j] + 1
+ sub := prev[j-1] + cost
+ min := ins
+ if del < min {
+ min = del
+ }
+ if sub < min {
+ min = sub
+ }
+ curr[j] = min
+ }
+ prev, curr = curr, prev
+ }
+ return prev[len(b)]
+}
+
+// suggestFlag returns the closest known flag name to the unknown string, or "" if none is close enough.
+func suggestFlag(unknown string, cmd *cobra.Command) string {
+ unknown = strings.TrimLeft(unknown, "-")
+ best := ""
+ bestDist := 4 // only consider distance <= 3
+ check := func(name string) {
+ d := levenshteinDistance(unknown, name)
+ if d < bestDist && d*5 <= len(unknown)*2 {
+ bestDist = d
+ best = name
+ }
+ }
+ cmd.Flags().VisitAll(func(f *pflag.Flag) {
+ check(f.Name)
+ })
+ cmd.InheritedFlags().VisitAll(func(f *pflag.Flag) {
+ check(f.Name)
+ })
+ return best
+}
+
+// wantsHumanTable returns true when output should be a human-friendly table.
+// Smart default: terminal=table, pipe=JSON.
+// - Human in terminal: isTerminal()=true → table
+// - Claude Code/Codex bash tool: stdout piped → JSON
+// - --json/--csv/--compact/--agent: machine format → JSON
+func wantsHumanTable(w io.Writer, flags *rootFlags) bool {
+ if flags.asJSON || flags.csv || flags.compact || flags.quiet || flags.plain {
+ return false
+ }
+ if flags.selectFields != "" {
+ return false
+ }
+ return isTerminal(w)
+}
+
+func printAutoTable(w io.Writer, items []map[string]any) error {
+ if len(items) == 0 {
+ return nil
+ }
+
+ // Count scalar vs complex fields to decide format
+ scalarCount := 0
+ for _, v := range items[0] {
+ if isCompactScalar(v) {
+ scalarCount++
+ }
+ }
+
+ // Use sectional/card layout for complex items (many fields or nested data)
+ if len(items[0]) > 8 || scalarCount < len(items[0])-2 {
+ return printAutoCards(w, items)
+ }
+
+ headers := prioritizeHeaders(items[0])
+
+ // Limit to 6 columns max for readability
+ if len(headers) > 6 {
+ headers = headers[:6]
+ }
+
+ // Build rows
+ rows := make([][]string, 0, len(items))
+ for _, item := range items {
+ row := make([]string, len(headers))
+ for i, h := range headers {
+ row[i] = formatCellValue(item[h])
+ }
+ rows = append(rows, row)
+ }
+
+ // Print with tab alignment using tabwriter
+ tw := newTabWriter(w)
+ upperHeaders := make([]string, len(headers))
+ for i, h := range headers {
+ upperHeaders[i] = bold(strings.ToUpper(h))
+ }
+
+ fmt.Fprintln(tw, strings.Join(upperHeaders, "\t"))
+ for _, row := range rows {
+ fmt.Fprintln(tw, strings.Join(row, "\t"))
+ }
+ return tw.Flush()
+}
+
+// prioritizeHeaders orders scalar fields by importance for table display.
+func prioritizeHeaders(item map[string]any) []string {
+ return prioritizeFields(item, false)
+}
+
+// prioritizeAllHeaders orders all fields (including arrays) by importance for card display.
+func prioritizeAllHeaders(item map[string]any) []string {
+ return prioritizeFields(item, true)
+}
+
+// prioritizeFields orders fields by importance: identity → temporal → status → other.
+// When includeComplex is true, arrays and objects are included (for card layout).
+//
+// Uses exact-or-suffix matching to avoid false positives: "name" matches "Name" and
+// "UserName" but not "BuildingName" (because "Building" is not a known prefix that
+// indicates identity). The field is split on camelCase/snake_case boundaries and the
+// LAST segment is matched against patterns.
+func prioritizeFields(item map[string]any, includeComplex bool) []string {
+ // Priority tiers — matched against the last segment of the field name.
+ // "OrderDate" → last segment "date" → tier 1 (temporal).
+ // "BuildingName" → last segment "name" → tier 0... but we want to avoid this.
+ // Solution: exact match on the full lowered name OR suffix segment match,
+ // with a penalty for compound names that have a non-identity prefix.
+ type pattern struct {
+ word string
+ tier int
+ }
+ // Exact matches (full field name, case-insensitive) — highest confidence
+ exactMatches := map[string]int{
+ "id": 0, "name": 0, "title": 0, "slug": 0, "key": 0,
+ "date": 1, "created": 1, "updated": 1, "createdat": 1, "updatedat": 1,
+ "status": 2, "state": 2, "statuscode": 2,
+ "summary": 3, "description": 3, "price": 3, "amount": 3, "total": 3,
+ "cost": 3, "points": 3, "score": 3,
+ "type": 4, "kind": 4, "category": 4, "email": 4, "phone": 4, "url": 4,
+ }
+ // Suffix patterns — match when the field ends with this word (after splitting)
+ suffixMatches := map[string]int{
+ "id": 0, "name": 0, "title": 0,
+ "date": 1, "time": 1,
+ "status": 2, "state": 2, "code": 2,
+ "price": 3, "amount": 3, "total": 3, "cost": 3,
+ "summary": 3, "description": 3, "points": 3, "score": 3,
+ "type": 4, "kind": 4, "category": 4, "method": 4,
+ }
+
+ numTiers := 5
+
+ type scored struct {
+ name string
+ tier int
+ index int
+ }
+
+ var all []scored
+ idx := 0
+ for k, v := range item {
+ if !includeComplex {
+ switch v.(type) {
+ case []any, map[string]any:
+ continue
+ }
+ }
+ // Skip values that won't render usefully in cards
+ if includeComplex {
+ formatted := formatCellValue(v)
+ if formatted == "" {
+ continue
+ }
+ }
+
+ tier := numTiers // default: unclassified
+ lower := strings.ToLower(k)
+
+ // 1. Exact match on full field name
+ if t, ok := exactMatches[lower]; ok {
+ tier = t
+ } else {
+ // 2. Split camelCase into segments and match the last one
+ segments := splitCamelCase(lower)
+ if len(segments) > 0 {
+ lastSeg := segments[len(segments)-1]
+ if t, ok := suffixMatches[lastSeg]; ok {
+ // Compound names with identity suffixes (BuildingName, TipTime)
+ // get demoted one tier because the prefix dilutes the signal
+ if len(segments) > 1 {
+ tier = t + 1
+ } else {
+ tier = t
+ }
+ }
+ }
+ }
+
+ // Demote booleans to last
+ if _, ok := v.(bool); ok && tier >= numTiers {
+ tier = numTiers + 1
+ }
+ all = append(all, scored{name: k, tier: tier, index: idx})
+ idx++
+ }
+
+ sort.Slice(all, func(i, j int) bool {
+ if all[i].tier != all[j].tier {
+ return all[i].tier < all[j].tier
+ }
+ return all[i].index < all[j].index
+ })
+
+ headers := make([]string, len(all))
+ for i, s := range all {
+ headers[i] = s.name
+ }
+ return headers
+}
+
+// splitCamelCase splits "OrderDate" → ["order", "date"], "statusCode" → ["status", "code"],
+// "page_size" → ["page", "size"].
+func splitCamelCase(s string) []string {
+ var segments []string
+ var current strings.Builder
+ runes := []rune(s)
+ for i, r := range runes {
+ if r == '_' || r == '-' {
+ if current.Len() > 0 {
+ segments = append(segments, current.String())
+ current.Reset()
+ }
+ continue
+ }
+ if i > 0 && unicode.IsUpper(r) && unicode.IsLower(runes[i-1]) {
+ if current.Len() > 0 {
+ segments = append(segments, current.String())
+ current.Reset()
+ }
+ }
+ current.WriteRune(unicode.ToLower(r))
+ }
+ if current.Len() > 0 {
+ segments = append(segments, current.String())
+ }
+ return segments
+}
+
+// printAutoCards renders items as labeled cards — one block per item.
+// Used for complex responses with many fields or nested data.
+func printAutoCards(w io.Writer, items []map[string]any) error {
+ headers := prioritizeAllHeaders(items[0])
+
+ // Find the longest header for alignment (from fields we'll actually show)
+ maxLen := 0
+ for _, h := range headers {
+ if len(h) > maxLen {
+ maxLen = len(h)
+ }
+ }
+
+ for i, item := range items {
+ if i > 0 {
+ fmt.Fprintln(w)
+ }
+
+ // Card header: use first priority field as the card title
+ titleVal := formatCellValue(item[headers[0]])
+ if len(headers) > 1 {
+ secondVal := formatCellValue(item[headers[1]])
+ if secondVal != "" {
+ fmt.Fprintf(w, "%s %s — %s\n", bold(strings.ToUpper(headers[0])), titleVal, secondVal)
+ } else {
+ fmt.Fprintf(w, "%s %s\n", bold(strings.ToUpper(headers[0])), titleVal)
+ }
+ } else {
+ fmt.Fprintf(w, "%s %s\n", bold(strings.ToUpper(headers[0])), titleVal)
+ }
+
+ // Remaining fields indented — skip empty, zero, and false values
+ for _, h := range headers[2:] {
+ v := formatCellValue(item[h])
+ if v == "" || v == "false" || v == "0" || v == "[]" || v == "null" {
+ continue
+ }
+ // Multi-line values (nested arrays) start with \n
+ if strings.HasPrefix(v, "\n") {
+ fmt.Fprintf(w, " %s:%s\n", h, v)
+ } else {
+ fmt.Fprintf(w, " %-*s %s\n", maxLen, h+":", v)
+ }
+ }
+ }
+ return nil
+}
+
+func formatCellValue(v any) string {
+ switch val := v.(type) {
+ case string:
+ // Format ISO dates as just the date portion
+ if len(val) >= 19 && val[4] == '-' && val[7] == '-' && val[10] == 'T' {
+ return val[:10]
+ }
+ return truncate(val, 60)
+ case float64:
+ if val == float64(int64(val)) {
+ return fmt.Sprintf("%d", int64(val))
+ }
+ return fmt.Sprintf("%.2f", val)
+ case bool:
+ return fmt.Sprintf("%t", val)
+ case nil:
+ return ""
+ case []any:
+ if len(val) == 0 {
+ return ""
+ }
+ // If array contains objects, format each as a summary line
+ if obj, isObj := val[0].(map[string]any); isObj {
+ _ = obj
+ return formatObjectArray(val)
+ }
+ // Flatten simple arrays into comma-separated string
+ parts := make([]string, 0, len(val))
+ for _, item := range val {
+ if s, ok := item.(string); ok {
+ parts = append(parts, s)
+ } else {
+ b, _ := json.Marshal(item)
+ parts = append(parts, string(b))
+ }
+ }
+ return truncate(strings.Join(parts, ", "), 60)
+ case map[string]any:
+ return formatSingleObject(val)
+ default:
+ b, _ := json.Marshal(val)
+ return truncate(string(b), 60)
+ }
+}
+
+// formatObjectArray renders an array of objects as multi-line summary.
+// Each object is summarized by its most descriptive fields: name/title, qty, size, price.
+func formatObjectArray(items []any) string {
+ var lines []string
+ for _, raw := range items {
+ obj, ok := raw.(map[string]any)
+ if !ok {
+ continue
+ }
+ lines = append(lines, formatObjectSummary(obj))
+ }
+ if len(lines) == 0 {
+ return ""
+ }
+ // Multi-line: newline-prefixed so the card renderer can indent
+ return "\n" + strings.Join(lines, "\n")
+}
+
+// formatObjectSummary extracts the most useful fields from an object into a one-line summary.
+// Looks for: qty/count → name/title → size → price, in that order.
+func formatObjectSummary(obj map[string]any) string {
+ var parts []string
+
+ // Quantity
+ qty := findField(obj, "qty", "count", "quantity")
+ if qty != "" && qty != "1" && qty != "0" {
+ parts = append(parts, qty+"x")
+ } else if qty == "1" {
+ parts = append(parts, "1x")
+ }
+
+ // Name — check nested objects too (e.g., Side1.Name)
+ name := findField(obj, "name", "title", "label", "description")
+ if name == "" {
+ // Check nested objects for name
+ for _, key := range []string{"Side1", "side1", "Item", "item", "Product", "product"} {
+ if nested, ok := obj[key].(map[string]any); ok {
+ name = findField(nested, "name", "title", "label")
+ if name != "" {
+ break
+ }
+ }
+ }
+ }
+ if name != "" {
+ parts = append(parts, name)
+ }
+
+ // Size
+ size := findField(obj, "sizename", "size_name")
+ if size == "" {
+ size = findField(obj, "catname", "cat_name", "category")
+ }
+ if size != "" {
+ parts = append(parts, "—")
+ parts = append(parts, size)
+ }
+
+ // Price
+ price := findField(obj, "extprice", "price", "amount", "total")
+ if price != "" && price != "0" {
+ parts = append(parts, fmt.Sprintf("($%s)", price))
+ }
+
+ if len(parts) == 0 {
+ // Fallback: JSON summary
+ b, _ := json.Marshal(obj)
+ return truncate(string(b), 80)
+ }
+ return " " + strings.Join(parts, " ")
+}
+
+// formatSingleObject renders a single object by its most descriptive fields.
+func formatSingleObject(obj map[string]any) string {
+ name := findField(obj, "name", "title", "label", "description")
+ if name != "" {
+ return name
+ }
+ id := findField(obj, "id", "key", "code")
+ if id != "" {
+ return id
+ }
+ return ""
+}
+
+// findField searches an object for a field name (case-insensitive) and returns its formatted value.
+func findField(obj map[string]any, names ...string) string {
+ for _, name := range names {
+ for k, v := range obj {
+ if strings.EqualFold(k, name) {
+ return formatCellValue(v)
+ }
+ }
+ }
+ return ""
+}
+
+// DataProvenance describes where data came from and when it was last synced.
+type DataProvenance struct {
+ Source string `json:"source"` // "live" or "local"
+ SyncedAt *time.Time `json:"synced_at,omitempty"` // when local data was last synced
+ Reason string `json:"reason,omitempty"` // why local was used: "user_requested", "api_unreachable", "no_search_endpoint"
+ ResourceType string `json:"resource_type,omitempty"` // which resource type was queried
+ Freshness any `json:"freshness,omitempty"` // optional machine-owned freshness metadata for covered command paths
+}
+
+// printProvenance writes a one-line provenance message to stderr for TTY users.
+// Suppressed when stdout is piped or redirected — the JSON response envelope
+// already carries meta.source, so the stderr line is redundant and becomes
+// noise in agent flows that merge stderr into stdout.
+func printProvenance(cmd *cobra.Command, count int, prov DataProvenance) {
+ if !isTerminal(cmd.OutOrStdout()) {
+ return
+ }
+ if prov.Source == "live" {
+ fmt.Fprintf(cmd.ErrOrStderr(), "%d results (live)\n", count)
+ return
+ }
+ age := "unknown"
+ if prov.SyncedAt != nil {
+ d := time.Since(*prov.SyncedAt)
+ switch {
+ case d < time.Minute:
+ age = "just now"
+ case d < time.Hour:
+ age = fmt.Sprintf("%d minutes ago", int(d.Minutes()))
+ case d < 24*time.Hour:
+ age = fmt.Sprintf("%d hours ago", int(d.Hours()))
+ default:
+ age = fmt.Sprintf("%d days ago", int(d.Hours()/24))
+ }
+ }
+ prefix := ""
+ if prov.Reason == "api_unreachable" {
+ prefix = "API unreachable. "
+ }
+ fmt.Fprintf(cmd.ErrOrStderr(), "%s%d results (cached, synced %s)\n", prefix, count, age)
+}
+
+// unwrapSingleKeyArray flattens single-key collection envelopes
+// ({"results":[...]}, {"data":[...]}, etc.) so the agent envelope
+// emits a stable .results[] across APIs. Multi-key objects pass
+// through so cursor/pagination fields stay accessible; non-array
+// values pass through so non-collection responses aren't reshaped.
+//
+// The wrapper-key set is intentionally narrower than
+// extractPaginatedItems (which also walks domain-specific keys like
+// "messages", "members", "values" used by social/messaging APIs).
+// This helper only flattens canonical collection envelopes for
+// --json output; the pagination walker has a broader remit.
+func unwrapSingleKeyArray(data json.RawMessage) json.RawMessage {
+ leading := bytes.TrimLeft(data, " \t\r\n")
+ if len(leading) == 0 || leading[0] != '{' {
+ return data
+ }
+ var obj map[string]json.RawMessage
+ if err := json.Unmarshal(data, &obj); err != nil {
+ return data
+ }
+ if len(obj) != 1 {
+ return data
+ }
+ for key, val := range obj {
+ if key != "results" && key != "data" && key != "items" && key != "nodes" && key != "entries" && key != "records" {
+ return data
+ }
+ trimmed := bytes.TrimLeft(val, " \t\r\n")
+ if len(trimmed) == 0 || trimmed[0] != '[' {
+ return data
+ }
+ return val
+ }
+ return data
+}
+
+// wrapWithProvenance wraps response data in a provenance envelope:
+// {"results": ..., "meta": {...}}. When data is valid JSON, it embeds as
+// the parsed shape; when data is non-JSON (e.g., XML/RSS responses, plain
+// text), it embeds as a JSON string so json.Marshal doesn't choke on
+// "invalid character '<'" while still passing the raw payload through to
+// the consumer. Single-key array envelopes from the API (e.g.
+// {"results": [...]}, {"data": [...]}) are unwrapped first so the output
+// shape is the same regardless of the API's wrapper key.
+func wrapWithProvenance(data json.RawMessage, prov DataProvenance) (json.RawMessage, error) {
+ meta := map[string]any{"source": prov.Source}
+ if prov.SyncedAt != nil {
+ meta["synced_at"] = prov.SyncedAt.UTC().Format(time.RFC3339)
+ }
+ if prov.Reason != "" {
+ meta["reason"] = prov.Reason
+ }
+ if prov.ResourceType != "" {
+ meta["resource_type"] = prov.ResourceType
+ }
+ if prov.Freshness != nil {
+ meta["freshness"] = prov.Freshness
+ }
+ var results any
+ if json.Valid(data) {
+ results = json.RawMessage(unwrapSingleKeyArray(data))
+ } else {
+ results = string(data)
+ }
+ envelope := map[string]any{
+ "results": results,
+ "meta": meta,
+ }
+ return json.Marshal(envelope)
+}
+
+// defaultDBPath returns the canonical path for the local SQLite database.
+func defaultDBPath(name string) string {
+ home, _ := os.UserHomeDir()
+ return filepath.Join(home, ".local", "share", name, "data.db")
+}
diff --git a/testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/pages_get.go b/testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/pages_get.go
new file mode 100644
index 00000000..b4c93df1
--- /dev/null
+++ b/testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/pages_get.go
@@ -0,0 +1,100 @@
+// Copyright 2026 printing-press-golden. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+
+ "github.com/spf13/cobra"
+)
+
+func newPagesGetCmd(flags *rootFlags) *cobra.Command {
+
+ cmd := &cobra.Command{
+ Use: "get <id>",
+ Short: "Get",
+ Example: " embedded-paged-pp-cli pages get 550e8400-e29b-41d4-a716-446655440000",
+ Annotations: map[string]string{"pp:endpoint": "pages.get", "pp:method": "GET", "pp:path": "/pages/{id}", "mcp:read-only": "true"},
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if len(args) == 0 {
+ return cmd.Help()
+ }
+ c, err := flags.newClient()
+ if err != nil {
+ return err
+ }
+
+ path := "/pages/{id}"
+ path = replacePathParam(path, "id", args[0])
+ params := map[string]string{}
+ data, prov, err := resolveRead(cmd.Context(), c, flags, "pages", false, path, params, nil)
+ if err != nil {
+ return classifyAPIError(err, flags)
+ }
+ // Print provenance to stderr for human-facing output only.
+ // Machine-format flags (--json, --csv, --compact, --quiet, --plain,
+ // --select) and piped stdout suppress this line; the JSON envelope
+ // already carries meta.source for those consumers.
+ // SYNC: keep this gate aligned with command_promoted.go.tmpl.
+ if wantsHumanTable(cmd.OutOrStdout(), flags) {
+ var countItems []json.RawMessage
+ _ = json.Unmarshal(data, &countItems)
+ printProvenance(cmd, len(countItems), prov)
+ }
+ // For JSON output, wrap with provenance envelope before passing through flags.
+ // --select wins over --compact when both are set; --compact only runs when
+ // no explicit fields were requested. Explicit format flags (--csv, --quiet,
+ // --plain) opt out of the auto-JSON path so piped consumers that asked for
+ // a non-JSON format reach the standard pipeline below.
+ if flags.asJSON || (!isTerminal(cmd.OutOrStdout()) && !flags.csv && !flags.quiet && !flags.plain) {
+ filtered := data
+ if flags.selectFields != "" {
+ filtered = filterFields(filtered, flags.selectFields)
+ } else if flags.compact {
+ filtered = compactFields(filtered)
+ }
+ wrapped, wrapErr := wrapWithProvenance(filtered, prov)
+ if wrapErr != nil {
+ return wrapErr
+ }
+ return printOutput(cmd.OutOrStdout(), wrapped, true)
+ }
+ // For all other output modes (table, csv, plain, quiet), use the standard pipeline
+ if wantsHumanTable(cmd.OutOrStdout(), flags) {
+ var items []map[string]any
+ if json.Unmarshal(data, &items) == nil && len(items) > 0 {
+ if err := printAutoTable(cmd.OutOrStdout(), items); err != nil {
+ return err
+ }
+ if len(items) >= 25 {
+ fmt.Fprintf(os.Stderr, "\nShowing %d results. To narrow: add --limit, --json --select, or filter flags.\n", len(items))
+ }
+ return nil
+ }
+ }
+ return printOutputWithFlags(cmd.OutOrStdout(), data, flags)
+ },
+ }
+
+ return cmd
+}
+
+// fetchFullPagesGetChildren returns every item
+// in the "children" sub-resource of GET /pages/{id}. The parent
+// response embeds at most the first page of this sub-resource, so any
+// caller that treats the embed as complete silently truncates; this
+// companion calls the dedicated child endpoint and paginates until
+// exhausted. pathParams must include every {placeholder} in the parent
+// path (e.g. {"id": "<value>"}).
+func fetchFullPagesGetChildren(c interface {
+ GetWithHeaders(path string, params map[string]string, headers map[string]string) (json.RawMessage, error)
+}, pathParams map[string]string) ([]json.RawMessage, error) {
+ childPath := "/pages/{id}/children"
+ for name, val := range pathParams {
+ childPath = replacePathParam(childPath, name, val)
+ }
+ return fetchEmbeddedPagedSubresource(c, childPath, "next_cursor", false, false)
+}
diff --git a/testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/playlists_get.go b/testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/playlists_get.go
new file mode 100644
index 00000000..18949de7
--- /dev/null
+++ b/testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/playlists_get.go
@@ -0,0 +1,100 @@
+// Copyright 2026 printing-press-golden. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+
+ "github.com/spf13/cobra"
+)
+
+func newPlaylistsGetCmd(flags *rootFlags) *cobra.Command {
+
+ cmd := &cobra.Command{
+ Use: "get <id>",
+ Short: "Get",
+ Example: " embedded-paged-pp-cli playlists get 550e8400-e29b-41d4-a716-446655440000",
+ Annotations: map[string]string{"pp:endpoint": "playlists.get", "pp:method": "GET", "pp:path": "/playlists/{id}", "mcp:read-only": "true"},
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if len(args) == 0 {
+ return cmd.Help()
+ }
+ c, err := flags.newClient()
+ if err != nil {
+ return err
+ }
+
+ path := "/playlists/{id}"
+ path = replacePathParam(path, "id", args[0])
+ params := map[string]string{}
+ data, prov, err := resolveRead(cmd.Context(), c, flags, "playlists", false, path, params, nil)
+ if err != nil {
+ return classifyAPIError(err, flags)
+ }
+ // Print provenance to stderr for human-facing output only.
+ // Machine-format flags (--json, --csv, --compact, --quiet, --plain,
+ // --select) and piped stdout suppress this line; the JSON envelope
+ // already carries meta.source for those consumers.
+ // SYNC: keep this gate aligned with command_promoted.go.tmpl.
+ if wantsHumanTable(cmd.OutOrStdout(), flags) {
+ var countItems []json.RawMessage
+ _ = json.Unmarshal(data, &countItems)
+ printProvenance(cmd, len(countItems), prov)
+ }
+ // For JSON output, wrap with provenance envelope before passing through flags.
+ // --select wins over --compact when both are set; --compact only runs when
+ // no explicit fields were requested. Explicit format flags (--csv, --quiet,
+ // --plain) opt out of the auto-JSON path so piped consumers that asked for
+ // a non-JSON format reach the standard pipeline below.
+ if flags.asJSON || (!isTerminal(cmd.OutOrStdout()) && !flags.csv && !flags.quiet && !flags.plain) {
+ filtered := data
+ if flags.selectFields != "" {
+ filtered = filterFields(filtered, flags.selectFields)
+ } else if flags.compact {
+ filtered = compactFields(filtered)
+ }
+ wrapped, wrapErr := wrapWithProvenance(filtered, prov)
+ if wrapErr != nil {
+ return wrapErr
+ }
+ return printOutput(cmd.OutOrStdout(), wrapped, true)
+ }
+ // For all other output modes (table, csv, plain, quiet), use the standard pipeline
+ if wantsHumanTable(cmd.OutOrStdout(), flags) {
+ var items []map[string]any
+ if json.Unmarshal(data, &items) == nil && len(items) > 0 {
+ if err := printAutoTable(cmd.OutOrStdout(), items); err != nil {
+ return err
+ }
+ if len(items) >= 25 {
+ fmt.Fprintf(os.Stderr, "\nShowing %d results. To narrow: add --limit, --json --select, or filter flags.\n", len(items))
+ }
+ return nil
+ }
+ }
+ return printOutputWithFlags(cmd.OutOrStdout(), data, flags)
+ },
+ }
+
+ return cmd
+}
+
+// fetchFullPlaylistsGetTracks returns every item
+// in the "tracks" sub-resource of GET /playlists/{id}. The parent
+// response embeds at most the first page of this sub-resource, so any
+// caller that treats the embed as complete silently truncates; this
+// companion calls the dedicated child endpoint and paginates until
+// exhausted. pathParams must include every {placeholder} in the parent
+// path (e.g. {"id": "<value>"}).
+func fetchFullPlaylistsGetTracks(c interface {
+ GetWithHeaders(path string, params map[string]string, headers map[string]string) (json.RawMessage, error)
+}, pathParams map[string]string) ([]json.RawMessage, error) {
+ childPath := "/playlists/{id}/tracks"
+ for name, val := range pathParams {
+ childPath = replacePathParam(childPath, name, val)
+ }
+ return fetchEmbeddedPagedSubresource(c, childPath, "next", true, false)
+}
diff --git a/testdata/golden/expected/generate-embedded-paged-api/exit.txt b/testdata/golden/expected/generate-embedded-paged-api/exit.txt
new file mode 100644
index 00000000..573541ac
--- /dev/null
+++ b/testdata/golden/expected/generate-embedded-paged-api/exit.txt
@@ -0,0 +1 @@
+0
diff --git a/testdata/golden/expected/generate-embedded-paged-api/stderr.txt b/testdata/golden/expected/generate-embedded-paged-api/stderr.txt
new file mode 100644
index 00000000..a2af0a52
--- /dev/null
+++ b/testdata/golden/expected/generate-embedded-paged-api/stderr.txt
@@ -0,0 +1,2 @@
+warning: could not derive run_id from --research-dir; phase5 dogfood acceptance will refuse to write without it
+Generated embedded-paged at <ARTIFACT_DIR>/generate-embedded-paged-api/embedded-paged-api
diff --git a/testdata/golden/expected/generate-embedded-paged-api/stdout.txt b/testdata/golden/expected/generate-embedded-paged-api/stdout.txt
new file mode 100644
index 00000000..e69de29b
diff --git a/testdata/golden/fixtures/embedded-paged-api.yaml b/testdata/golden/fixtures/embedded-paged-api.yaml
new file mode 100644
index 00000000..324ef798
--- /dev/null
+++ b/testdata/golden/fixtures/embedded-paged-api.yaml
@@ -0,0 +1,137 @@
+openapi: "3.0.3"
+info:
+ title: Embedded Paged API
+ version: "1.0.0"
+ description: |
+ Golden fixture exercising the embedded-paged-sub-resource detection
+ and emission path. The parent GET responses embed three envelope
+ shapes the detector must distinguish: a URL-bearing `next`, an
+ opaque cursor `next_cursor`, and a boolean `has_more`. Generated
+ output should contain a `fetchFull<X>` wrapper per detected entry
+ plus the shared `fetchEmbeddedPagedSubresource` runtime helper.
+servers:
+ - url: https://api.embedded.example/v1
+security:
+ - ApiKeyAuth: []
+components:
+ securitySchemes:
+ ApiKeyAuth:
+ type: apiKey
+ in: header
+ name: X-API-Key
+ schemas:
+ Track:
+ type: object
+ properties:
+ id: { type: string }
+ title: { type: string }
+ Playlist:
+ type: object
+ properties:
+ id: { type: string }
+ name: { type: string }
+ tracks:
+ type: object
+ properties:
+ items:
+ type: array
+ items: { $ref: "#/components/schemas/Track" }
+ next: { type: string }
+ total: { type: integer }
+ Page:
+ type: object
+ properties:
+ id: { type: string }
+ title: { type: string }
+ children:
+ type: object
+ properties:
+ results:
+ type: array
+ items: { type: object }
+ next_cursor: { type: string }
+ Customer:
+ type: object
+ properties:
+ id: { type: string }
+ email: { type: string }
+ subscriptions:
+ type: object
+ properties:
+ data:
+ type: array
+ items: { type: object }
+ has_more: { type: boolean }
+paths:
+ /playlists:
+ get:
+ tags: [playlists]
+ operationId: listPlaylists
+ responses:
+ "200":
+ description: OK
+ content:
+ application/json:
+ schema:
+ type: array
+ items: { $ref: "#/components/schemas/Playlist" }
+ /playlists/{id}:
+ get:
+ tags: [playlists]
+ operationId: getPlaylist
+ parameters:
+ - { name: id, in: path, required: true, schema: { type: string } }
+ responses:
+ "200":
+ description: OK
+ content:
+ application/json:
+ schema: { $ref: "#/components/schemas/Playlist" }
+ /pages:
+ get:
+ tags: [pages]
+ operationId: listPages
+ responses:
+ "200":
+ description: OK
+ content:
+ application/json:
+ schema:
+ type: array
+ items: { $ref: "#/components/schemas/Page" }
+ /pages/{id}:
+ get:
+ tags: [pages]
+ operationId: getPage
+ parameters:
+ - { name: id, in: path, required: true, schema: { type: string } }
+ responses:
+ "200":
+ description: OK
+ content:
+ application/json:
+ schema: { $ref: "#/components/schemas/Page" }
+ /customers:
+ get:
+ tags: [customers]
+ operationId: listCustomers
+ responses:
+ "200":
+ description: OK
+ content:
+ application/json:
+ schema:
+ type: array
+ items: { $ref: "#/components/schemas/Customer" }
+ /customers/{id}:
+ get:
+ tags: [customers]
+ operationId: getCustomer
+ parameters:
+ - { name: id, in: path, required: true, schema: { type: string } }
+ responses:
+ "200":
+ description: OK
+ content:
+ application/json:
+ schema: { $ref: "#/components/schemas/Customer" }
← e21feae6 fix(cli): close ResolveByName json_extract injection + dedup
·
back to Cli Printing Press
·
fix(cli): support $-prefixed pagination params for Socrata-s 3819b67f →