← back to Cli Printing Press
fix(cli): sync pagination, auth aliases, narrative timing, and codex prompt (#1341)
ac3e15b4e2665106f3c524d60622aed198b4f098 · 2026-05-19 01:58:44 -0700 · salmonumbrella
* fix: sync pagination, auth aliases, narrative timing, and codex prompt
Six independent generator-level findings surfaced during a downstream CLI
generation run against an offset-paginated REST API. Each is generic; the
discovery context is not in the diff or test data.
1. AuthHeader OR-semantics for legacy x-auth-env-vars (spec.go)
IsAuthEnvVarORCase only returned true when all EnvVarSpecs were
non-required per_call. Specs using the legacy x-auth-env-vars list
form (or a populated EnvVars slice with default Required=true) fell
through to the canonical-only path: only the first env-var alias
reached AuthHeader, the rest were populated by Load() but never
read. Updated IsAuthEnvVarORCase to also return true when:
* EnvVarSpecs has 2+ entries that are all request-credentials, OR
* legacy EnvVars list has 2+ entries.
New pin: TestAuthHeader_LegacyEnvVarsList_OrSemantics.
2. validate-narrative side-effect classifier (narrativecheck.go)
--full-examples ran every command whose --help advertised --dry-run,
including auth set-token YOUR_TOKEN_HERE and auth logout. The first
persisted the literal placeholder to the user's ~/.config; the
second wiped saved credentials. Added isSideEffectfulNarrativeExample
skip-classifier covering auth set-token, auth logout, auth setup,
auth login, --launch, and bare --apply. Skipped commands return
StatusUnsupported with an explanatory reason rather than executing.
New pins: TestRunFullExample_SkipsAuthSetToken,
TestRunFullExample_SkipsAuthLogout.
3. Empty-array marshalling guidance (codex-delegation.md)
Codex-generated novel-feature commands declared var results []T,
which marshals nil to JSON null. Empty list outputs broke jq '.[]'
agent pipelines. Updated the transcendence-command prompt template
to require results := make([]T, 0) for all list-shape outputs, with
a paragraph at the top of CONVENTIONS spelling out the contract.
4. Shipcheck leg ordering (shipcheck.go)
dogfood ran first and synthesized README.md and SKILL.md from
research.json.novel_features_built — including any planned commands
whose command-paths didn't actually resolve against the built binary.
validate-narrative ran later and caught them but the user-facing
files were already wrong. Reordered: verify, validate-narrative,
dogfood, workflow-verify, verify-skill, scorecard. Comment updated
to reflect the dependency: verify builds the binary; validate-narrative
checks research.json against the binary BEFORE dogfood renders from it.
5. Offset+has_more pagination loop (sync.go.tmpl)
The sync loop's break-condition required nextCursor != "" alongside
hasMore=true. APIs using offset+has_more (no cursor token in the
response) returned hasMore=true with empty nextCursor on every page,
so the loop broke after page 1 and only the first page worth of
records reached the local store. Fixed at both occurrences (linear
and parent-child sync paths): when cursorParam=="offset" and the
envelope returned no cursor despite hasMore, compute nextOffset
client-side as currentOffset + pageSize.limit. Cursor-based APIs
that genuinely return no cursor still break (existing behavior;
silent infinite-loop guard). New pin:
TestGeneratedSyncAdvancesOffsetWhenHasMoreWithoutCursor.
6. Per-resource pagination param gate (profiler.go + sync.go.tmpl)
Sync sent ?limit= and ?offset= to every list endpoint regardless of
whether the spec declared those params for that endpoint. List
endpoints that don't paginate (typical for /me, /settings, small
reference resources) rejected the synthetic params with HTTP 400
"Invalid query parameter". Added profiler.SupportsPagination per
syncable+dependent resource (detected from endpoint params at
generate time). Generator template emits resourceSupportsPagination()
from the per-resource map; sync gates limit/offset setting on its
return value. New pin:
TestGeneratedSyncGatesPaginationParamsPerResource.
Test coverage:
* go build ./... — pass
* go vet ./... — pass
* go test ./... — pass (30 packages, ~13min runtime)
* fix: use exact side-effect flag matching
* fix: detect cursor-only pagination support
* test(cli): refresh sync pagination goldens
---------
Co-authored-by: Trevin Chow <trevin@trevinchow.com>
Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
Files touched
M internal/cli/shipcheck.goM internal/cli/shipcheck_test.goM internal/generator/auth_env_precedence_test.goM internal/generator/generator.goM internal/generator/generator_test.goM internal/generator/templates/sync.go.tmplM internal/narrativecheck/narrativecheck.goM internal/narrativecheck/narrativecheck_test.goM internal/profiler/profiler.goM internal/profiler/profiler_test.goM internal/spec/spec.goM internal/spec/spec_test.goM skills/printing-press/references/codex-delegation.mdM testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/sync.goM testdata/golden/expected/generate-sync-walker-api/sync-walker-golden/internal/cli/sync.goM testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/sync.go
Diff
commit ac3e15b4e2665106f3c524d60622aed198b4f098
Author: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date: Tue May 19 01:58:44 2026 -0700
fix(cli): sync pagination, auth aliases, narrative timing, and codex prompt (#1341)
* fix: sync pagination, auth aliases, narrative timing, and codex prompt
Six independent generator-level findings surfaced during a downstream CLI
generation run against an offset-paginated REST API. Each is generic; the
discovery context is not in the diff or test data.
1. AuthHeader OR-semantics for legacy x-auth-env-vars (spec.go)
IsAuthEnvVarORCase only returned true when all EnvVarSpecs were
non-required per_call. Specs using the legacy x-auth-env-vars list
form (or a populated EnvVars slice with default Required=true) fell
through to the canonical-only path: only the first env-var alias
reached AuthHeader, the rest were populated by Load() but never
read. Updated IsAuthEnvVarORCase to also return true when:
* EnvVarSpecs has 2+ entries that are all request-credentials, OR
* legacy EnvVars list has 2+ entries.
New pin: TestAuthHeader_LegacyEnvVarsList_OrSemantics.
2. validate-narrative side-effect classifier (narrativecheck.go)
--full-examples ran every command whose --help advertised --dry-run,
including auth set-token YOUR_TOKEN_HERE and auth logout. The first
persisted the literal placeholder to the user's ~/.config; the
second wiped saved credentials. Added isSideEffectfulNarrativeExample
skip-classifier covering auth set-token, auth logout, auth setup,
auth login, --launch, and bare --apply. Skipped commands return
StatusUnsupported with an explanatory reason rather than executing.
New pins: TestRunFullExample_SkipsAuthSetToken,
TestRunFullExample_SkipsAuthLogout.
3. Empty-array marshalling guidance (codex-delegation.md)
Codex-generated novel-feature commands declared var results []T,
which marshals nil to JSON null. Empty list outputs broke jq '.[]'
agent pipelines. Updated the transcendence-command prompt template
to require results := make([]T, 0) for all list-shape outputs, with
a paragraph at the top of CONVENTIONS spelling out the contract.
4. Shipcheck leg ordering (shipcheck.go)
dogfood ran first and synthesized README.md and SKILL.md from
research.json.novel_features_built — including any planned commands
whose command-paths didn't actually resolve against the built binary.
validate-narrative ran later and caught them but the user-facing
files were already wrong. Reordered: verify, validate-narrative,
dogfood, workflow-verify, verify-skill, scorecard. Comment updated
to reflect the dependency: verify builds the binary; validate-narrative
checks research.json against the binary BEFORE dogfood renders from it.
5. Offset+has_more pagination loop (sync.go.tmpl)
The sync loop's break-condition required nextCursor != "" alongside
hasMore=true. APIs using offset+has_more (no cursor token in the
response) returned hasMore=true with empty nextCursor on every page,
so the loop broke after page 1 and only the first page worth of
records reached the local store. Fixed at both occurrences (linear
and parent-child sync paths): when cursorParam=="offset" and the
envelope returned no cursor despite hasMore, compute nextOffset
client-side as currentOffset + pageSize.limit. Cursor-based APIs
that genuinely return no cursor still break (existing behavior;
silent infinite-loop guard). New pin:
TestGeneratedSyncAdvancesOffsetWhenHasMoreWithoutCursor.
6. Per-resource pagination param gate (profiler.go + sync.go.tmpl)
Sync sent ?limit= and ?offset= to every list endpoint regardless of
whether the spec declared those params for that endpoint. List
endpoints that don't paginate (typical for /me, /settings, small
reference resources) rejected the synthetic params with HTTP 400
"Invalid query parameter". Added profiler.SupportsPagination per
syncable+dependent resource (detected from endpoint params at
generate time). Generator template emits resourceSupportsPagination()
from the per-resource map; sync gates limit/offset setting on its
return value. New pin:
TestGeneratedSyncGatesPaginationParamsPerResource.
Test coverage:
* go build ./... — pass
* go vet ./... — pass
* go test ./... — pass (30 packages, ~13min runtime)
* fix: use exact side-effect flag matching
* fix: detect cursor-only pagination support
* test(cli): refresh sync pagination goldens
---------
Co-authored-by: Trevin Chow <trevin@trevinchow.com>
Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
---
internal/cli/shipcheck.go | 62 +++----
internal/cli/shipcheck_test.go | 4 +-
internal/generator/auth_env_precedence_test.go | 35 ++++
internal/generator/generator.go | 68 +++++---
internal/generator/generator_test.go | 185 ++++++++++++++++++++-
internal/generator/templates/sync.go.tmpl | 77 +++++++--
internal/narrativecheck/narrativecheck.go | 20 +++
internal/narrativecheck/narrativecheck_test.go | 87 ++++++++++
internal/profiler/profiler.go | 109 +++++++-----
internal/profiler/profiler_test.go | 23 +++
internal/spec/spec.go | 34 ++--
internal/spec/spec_test.go | 9 +-
.../printing-press/references/codex-delegation.md | 15 ++
.../printing-press-golden/internal/cli/sync.go | 77 +++++++--
.../sync-walker-golden/internal/cli/sync.go | 73 ++++++--
.../tier-routing-golden/internal/cli/sync.go | 50 ++++--
16 files changed, 748 insertions(+), 180 deletions(-)
diff --git a/internal/cli/shipcheck.go b/internal/cli/shipcheck.go
index f0d6a4a9..2dc5fb0c 100644
--- a/internal/cli/shipcheck.go
+++ b/internal/cli/shipcheck.go
@@ -66,23 +66,10 @@ type shipcheckLeg struct {
}
// shipcheckLegs enumerates the six legs in canonical execution order.
-// Order matters: dogfood writes research.json updates that scorecard
-// later consumes, verify builds the CLI binary validate-narrative uses,
-// and scorecard should see all earlier validation failures first.
+// Order matters: verify builds the binary; validate-narrative checks
+// research.json command paths against the binary BEFORE dogfood synthesizes
+// README/SKILL from those commands.
var shipcheckLegs = []shipcheckLeg{
- {
- name: "dogfood",
- args: func(o *shipcheckOpts) []string {
- a := []string{"dogfood", "--dir", o.dir}
- if o.spec != "" {
- a = append(a, "--spec", o.spec)
- }
- if o.researchDir != "" {
- a = append(a, "--research-dir", o.researchDir)
- }
- return a
- },
- },
{
name: "verify",
args: func(o *shipcheckOpts) []string {
@@ -102,6 +89,31 @@ var shipcheckLegs = []shipcheckLeg{
return a
},
},
+ {
+ name: "validate-narrative",
+ args: func(o *shipcheckOpts) []string {
+ return []string{
+ "validate-narrative",
+ "--strict",
+ "--full-examples",
+ "--research", shipcheckResearchPath(o),
+ "--binary", shipcheckCLIPath(o),
+ }
+ },
+ },
+ {
+ name: "dogfood",
+ args: func(o *shipcheckOpts) []string {
+ a := []string{"dogfood", "--dir", o.dir}
+ if o.spec != "" {
+ a = append(a, "--spec", o.spec)
+ }
+ if o.researchDir != "" {
+ a = append(a, "--research-dir", o.researchDir)
+ }
+ return a
+ },
+ },
{
name: "workflow-verify",
args: func(o *shipcheckOpts) []string {
@@ -118,18 +130,6 @@ var shipcheckLegs = []shipcheckLeg{
return a
},
},
- {
- name: "validate-narrative",
- args: func(o *shipcheckOpts) []string {
- return []string{
- "validate-narrative",
- "--strict",
- "--full-examples",
- "--research", shipcheckResearchPath(o),
- "--binary", shipcheckCLIPath(o),
- }
- },
- },
{
name: "scorecard",
args: func(o *shipcheckOpts) []string {
@@ -369,17 +369,17 @@ func newShipcheckCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "shipcheck",
- Short: "Run all six verification legs (dogfood, verify, workflow-verify, verify-skill, validate-narrative, scorecard) as one canonical Phase 4 sweep",
+ Short: "Run all six verification legs (verify, validate-narrative, dogfood, workflow-verify, verify-skill, scorecard) as one canonical Phase 4 sweep",
Long: `shipcheck runs every Phase 4 verification leg in sequence and aggregates their
exit codes into a single verdict. It is the canonical local invocation that
matches what the public-library CI runs.
Legs (in canonical order):
- dogfood — structural validation against the source spec
verify — runtime command testing (with --fix to auto-repair common breakage)
+ validate-narrative — README/SKILL narrative commands against the built CLI
+ dogfood — structural validation against the source spec
workflow-verify — primary workflow end-to-end against the verification manifest
verify-skill — SKILL.md flag/positional/command consistency with the shipped CLI
- validate-narrative — README/SKILL narrative commands against the built CLI
scorecard — Steinberger quality bar (with --live-check sampled output probes)
In default mode, every leg streams its full output to the terminal as it runs
diff --git a/internal/cli/shipcheck_test.go b/internal/cli/shipcheck_test.go
index d017f61c..ab958b19 100644
--- a/internal/cli/shipcheck_test.go
+++ b/internal/cli/shipcheck_test.go
@@ -129,8 +129,8 @@ func TestShipcheck_AllLegsPass(t *testing.T) {
t.Fatalf("expected %d leg invocations; got %d: %v", len(shipcheckLegs), len(invocations), invocations)
}
- // Confirm canonical order: dogfood, verify, workflow-verify, verify-skill, validate-narrative, scorecard.
- wantOrder := []string{"dogfood", "verify", "workflow-verify", "verify-skill", "validate-narrative", "scorecard"}
+ // Confirm canonical order: verify, validate-narrative, dogfood, workflow-verify, verify-skill, scorecard.
+ wantOrder := []string{"verify", "validate-narrative", "dogfood", "workflow-verify", "verify-skill", "scorecard"}
for i, want := range wantOrder {
// argv[0] is the stub binary path; argv[1] is the leg name.
if len(invocations[i]) < 2 {
diff --git a/internal/generator/auth_env_precedence_test.go b/internal/generator/auth_env_precedence_test.go
index 287558bf..5a33a768 100644
--- a/internal/generator/auth_env_precedence_test.go
+++ b/internal/generator/auth_env_precedence_test.go
@@ -137,6 +137,41 @@ func TestAuthLoginEnvVarsUseShellSafePrefix(t *testing.T) {
require.NotContains(t, content, `HYPHEN-API_CLIENT_ID`)
}
+func TestAuthHeader_LegacyEnvVarsList_OrSemantics(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := minimalSpec("legacy-env-or")
+ apiSpec.Auth = spec.AuthConfig{
+ Type: "bearer_token",
+ Header: "Authorization",
+ EnvVars: []string{"FOO_ACCESS_TOKEN", "FOO_API_KEY", "FOO_TOKEN"},
+ }
+
+ outputDir := filepath.Join(t.TempDir(), "legacy-env-or-pp-cli")
+ require.NoError(t, New(apiSpec, outputDir).Generate())
+
+ const testSrc = `package config
+
+import "testing"
+
+func TestAuthHeaderUsesLegacyAliasEnvVar(t *testing.T) {
+ t.Setenv("FOO_ACCESS_TOKEN", "")
+ t.Setenv("FOO_API_KEY", "alias-token")
+ t.Setenv("FOO_TOKEN", "")
+
+ cfg, err := Load("")
+ if err != nil {
+ t.Fatalf("Load() error = %v", err)
+ }
+ if got := cfg.AuthHeader(); got != "Bearer alias-token" {
+ t.Fatalf("AuthHeader() = %q, want %q", got, "Bearer alias-token")
+ }
+}
+`
+ require.NoError(t, os.WriteFile(filepath.Join(outputDir, "internal", "config", "auth_header_legacy_envvars_test.go"), []byte(testSrc), 0o644))
+ runGoCommand(t, outputDir, "test", "./internal/config", "-run", "TestAuthHeaderUsesLegacyAliasEnvVar")
+}
+
// TestAuthHeader_EnvVarWinsOverFileToken pins env-first precedence for
// the non-client_credentials cases — plain bearer_token (PAT-style),
// cookie, and composed all follow the env > config convention so a
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 7387aade..66190703 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -2110,17 +2110,18 @@ func (g *Generator) renderStoreFiles(schema []TableDef) error {
type visionRenderData struct {
*spec.APISpec
- SyncableResources []profiler.SyncableResource
- DependentSyncResources []profiler.DependentResource
- SearchableFields map[string][]string
- Tables []TableDef
- Pagination profiler.PaginationProfile
- SearchEndpointPath string
- SearchQueryParam string
- SearchEndpointMethod string
- SearchBodyFields []profiler.SearchBodyField
- GraphQLFieldPaths map[string]string
- AgentMoneyWorkflow AgentMoneyWorkflow
+ SyncableResources []profiler.SyncableResource
+ DependentSyncResources []profiler.DependentResource
+ PaginationSupportedResources []string
+ SearchableFields map[string][]string
+ Tables []TableDef
+ Pagination profiler.PaginationProfile
+ SearchEndpointPath string
+ SearchQueryParam string
+ SearchEndpointMethod string
+ SearchBodyFields []profiler.SearchBodyField
+ GraphQLFieldPaths map[string]string
+ AgentMoneyWorkflow AgentMoneyWorkflow
}
type resourceIDFieldOverrideEntry struct {
@@ -2184,6 +2185,26 @@ func criticalResourceEntries(syncable []profiler.SyncableResource, dependent []p
return entries
}
+func paginationSupportedResources(syncable []profiler.SyncableResource, dependent []profiler.DependentResource) []string {
+ supported := map[string]bool{}
+ for _, resource := range syncable {
+ if resource.SupportsPagination {
+ supported[resource.Name] = true
+ }
+ }
+ for _, resource := range dependent {
+ if resource.SupportsPagination {
+ supported[resource.Name] = true
+ }
+ }
+ names := make([]string, 0, len(supported))
+ for name := range supported {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+ return names
+}
+
func (g *Generator) visionRenderData(schema []TableDef) visionRenderData {
gqlFieldPaths := map[string]string{}
for rName, r := range g.Spec.Resources {
@@ -2193,18 +2214,19 @@ func (g *Generator) visionRenderData(schema []TableDef) visionRenderData {
}
return visionRenderData{
- APISpec: g.Spec,
- SyncableResources: g.profile.SyncableResources,
- DependentSyncResources: g.profile.DependentSyncResources,
- SearchableFields: g.profile.SearchableFields,
- Tables: schema,
- Pagination: g.profile.Pagination,
- SearchEndpointPath: g.profile.SearchEndpointPath,
- SearchQueryParam: g.profile.SearchQueryParam,
- SearchEndpointMethod: g.profile.SearchEndpointMethod,
- SearchBodyFields: g.profile.SearchBodyFields,
- GraphQLFieldPaths: gqlFieldPaths,
- AgentMoneyWorkflow: detectAgentMoneyWorkflow(g.Spec, g.PromotedEndpointNames),
+ APISpec: g.Spec,
+ SyncableResources: g.profile.SyncableResources,
+ DependentSyncResources: g.profile.DependentSyncResources,
+ PaginationSupportedResources: paginationSupportedResources(g.profile.SyncableResources, g.profile.DependentSyncResources),
+ SearchableFields: g.profile.SearchableFields,
+ Tables: schema,
+ Pagination: g.profile.Pagination,
+ SearchEndpointPath: g.profile.SearchEndpointPath,
+ SearchQueryParam: g.profile.SearchQueryParam,
+ SearchEndpointMethod: g.profile.SearchEndpointMethod,
+ SearchBodyFields: g.profile.SearchBodyFields,
+ GraphQLFieldPaths: gqlFieldPaths,
+ AgentMoneyWorkflow: detectAgentMoneyWorkflow(g.Spec, g.PromotedEndpointNames),
}
}
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 59ed68b6..162f48ea 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -8581,6 +8581,71 @@ func TestGeneratedGraphQLSyncForcesSingleWorkerUnderVerifyEnv(t *testing.T) {
runGoCommand(t, outputDir, "build", "./...")
}
+func TestGeneratedSyncAdvancesOffsetWhenHasMoreWithoutCursor(t *testing.T) {
+ t.Parallel()
+
+ offsetPaged := func(path string) spec.Endpoint {
+ return spec.Endpoint{
+ Method: "GET",
+ Path: path,
+ Description: "List records",
+ Response: spec.ResponseDef{Type: "array"},
+ Pagination: &spec.Pagination{Type: "offset", CursorParam: "offset", LimitParam: "limit", HasMoreField: "has_more"},
+ }
+ }
+ apiSpec := &spec.APISpec{
+ Name: "offsetsync",
+ Version: "0.1.0",
+ BaseURL: "https://api.example.com",
+ Auth: spec.AuthConfig{
+ Type: "api_key",
+ Header: "Authorization",
+ Format: "Bearer {token}",
+ EnvVars: []string{"OFFSETSYNC_API_KEY"},
+ },
+ Config: spec.ConfigSpec{
+ Format: "toml",
+ Path: "~/.config/offsetsync-pp-cli/config.toml",
+ },
+ Resources: map[string]spec.Resource{
+ "accounts": {
+ Description: "Accounts",
+ Endpoints: map[string]spec.Endpoint{
+ "list": offsetPaged("/accounts"),
+ },
+ },
+ "transactions": {
+ Description: "Transactions",
+ Endpoints: map[string]spec.Endpoint{
+ "list": offsetPaged("/accounts/{account_id}/transactions"),
+ },
+ },
+ },
+ }
+
+ outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+ gen := New(apiSpec, outputDir)
+ require.NoError(t, gen.Generate())
+
+ syncGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "sync.go"))
+ require.NoError(t, err)
+ syncContent := string(syncGo)
+
+ assert.NotContains(t, syncContent, `if !hasMore || len(items) < pageSize.limit || nextCursor == ""`,
+ "sync loops must not require an API-returned next cursor for offset pagination")
+ assert.Contains(t, syncContent, `if !hasMore || len(items) < pageSize.limit {`,
+ "sync loops must break only on real done signals before handling cursor advancement")
+ assert.Contains(t, syncContent, `if pageSize.cursorParam == "offset" {`,
+ "offset pagination must advance the cursor client-side when has_more is true")
+ assert.Contains(t, syncContent, `nextCursor = strconv.Itoa(currentOffset + pageSize.limit)`,
+ "offset pagination must compute the next offset from the current cursor and limit")
+ assert.GreaterOrEqual(t, strings.Count(syncContent, `currentOffset, _ := strconv.Atoi(cursor)`), 2,
+ "offset advancement must be emitted in both flat and dependent-resource sync loops")
+
+ runGoCommand(t, outputDir, "mod", "tidy")
+ runGoCommand(t, outputDir, "build", "./...")
+}
+
// TestGeneratedSyncGatesSinceParamPerResource pins the fix for issue #900:
// generators must only inject the incremental-cursor query parameter on
// resources whose list endpoint actually declares it. Resources that don't
@@ -8666,14 +8731,21 @@ func TestGeneratedSyncGatesSinceParamPerResource(t *testing.T) {
assert.Contains(t, syncContent, "func syncResourceSinceParam(resource string) string",
"per-resource helper must be emitted")
+ sinceHelperStart := strings.Index(syncContent, "func syncResourceSinceParam(resource string) string")
+ require.NotEqual(t, -1, sinceHelperStart, "sync.go must emit syncResourceSinceParam")
+ sinceHelperBody := syncContent[sinceHelperStart:]
+ if nextFunc := strings.Index(sinceHelperBody[1:], "\nfunc "); nextFunc != -1 {
+ sinceHelperBody = sinceHelperBody[:nextFunc+1]
+ }
+
// events declares since → switch case present with the literal param name.
- assert.Contains(t, syncContent, `case "events":`,
+ assert.Contains(t, sinceHelperBody, `case "events":`,
"events resource must appear in the per-resource switch")
- assert.Contains(t, syncContent, `return "since"`,
+ assert.Contains(t, sinceHelperBody, `return "since"`,
"events resource must map to its declared param name")
// users declares no since-like param → no case for it (falls through to "").
- assert.NotContains(t, syncContent, `case "users":`,
+ assert.NotContains(t, sinceHelperBody, `case "users":`,
"users resource has no since-like param and must not appear in the switch — empty result skips the cursor")
// Pin the full warning JSON shape so future template churn can't silently
@@ -8705,6 +8777,113 @@ func TestGeneratedSyncGatesSinceParamPerResource(t *testing.T) {
runGoCommand(t, outputDir, "build", "./...")
}
+func TestGeneratedSyncGatesPaginationParamsPerResource(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := &spec.APISpec{
+ Name: "mixedpaging",
+ Version: "0.1.0",
+ BaseURL: "https://api.example.com",
+ Auth: spec.AuthConfig{
+ Type: "api_key",
+ Header: "Authorization",
+ Format: "Bearer {token}",
+ EnvVars: []string{"MIXEDPAGING_API_KEY"},
+ },
+ Config: spec.ConfigSpec{
+ Format: "toml",
+ Path: "~/.config/mixedpaging-pp-cli/config.toml",
+ },
+ Resources: map[string]spec.Resource{
+ "accounts": {
+ Description: "Accounts",
+ Endpoints: map[string]spec.Endpoint{
+ "list": {
+ Method: "GET",
+ Path: "/accounts",
+ Description: "List accounts",
+ Response: spec.ResponseDef{Type: "array"},
+ Params: []spec.Param{
+ {Name: "limit", Type: "integer"},
+ {Name: "offset", Type: "integer"},
+ },
+ Pagination: &spec.Pagination{Type: "offset", CursorParam: "offset", LimitParam: "limit"},
+ },
+ },
+ },
+ "transactions": {
+ Description: "Transactions",
+ Endpoints: map[string]spec.Endpoint{
+ "list": {
+ Method: "GET",
+ Path: "/accounts/{account_id}/transactions",
+ Description: "List transactions",
+ Response: spec.ResponseDef{Type: "array"},
+ Params: []spec.Param{
+ {Name: "limit", Type: "integer"},
+ {Name: "offset", Type: "integer"},
+ },
+ Pagination: &spec.Pagination{Type: "offset", CursorParam: "offset", LimitParam: "limit"},
+ },
+ },
+ },
+ "categories": {
+ Description: "Categories",
+ Endpoints: map[string]spec.Endpoint{
+ "list": {
+ Method: "GET",
+ Path: "/categories",
+ Description: "List categories",
+ Response: spec.ResponseDef{Type: "array"},
+ },
+ },
+ },
+ "budget_settings": {
+ Description: "Budget settings",
+ Endpoints: map[string]spec.Endpoint{
+ "list": {
+ Method: "GET",
+ Path: "/budgets/settings",
+ Description: "Get budget settings",
+ Response: spec.ResponseDef{Type: "object"},
+ },
+ },
+ },
+ },
+ }
+
+ outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+ gen := New(apiSpec, outputDir)
+ require.NoError(t, gen.Generate())
+
+ syncGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "sync.go"))
+ require.NoError(t, err)
+ syncContent := string(syncGo)
+
+ helperStart := strings.Index(syncContent, "func resourceSupportsPagination(resource string) bool")
+ require.NotEqual(t, -1, helperStart, "sync.go must emit resourceSupportsPagination")
+ helperBody := syncContent[helperStart:]
+ if nextFunc := strings.Index(helperBody[1:], "\nfunc "); nextFunc != -1 {
+ helperBody = helperBody[:nextFunc+1]
+ }
+
+ assert.Contains(t, helperBody, `case "accounts":`,
+ "accounts declares limit/offset and must opt into pagination params")
+ assert.Contains(t, helperBody, `case "transactions":`,
+ "dependent transactions declares limit/offset and must opt into pagination params")
+ assert.NotContains(t, helperBody, `case "categories":`,
+ "categories declares no limit param and must not receive pagination params")
+ assert.NotContains(t, helperBody, `case "budget_settings":`,
+ "/budgets/settings is non-paginated and must fall through to false")
+ assert.Contains(t, syncContent, `if resourceSupportsPagination(resource) {`,
+ "flat sync loop must gate page-size and cursor params per resource")
+ assert.Contains(t, syncContent, `if resourceSupportsPagination(dep.Name) {`,
+ "dependent sync loop must gate page-size and cursor params per resource")
+
+ runGoCommand(t, outputDir, "mod", "tidy")
+ runGoCommand(t, outputDir, "build", "./...")
+}
+
func TestGeneratedSyncTreatsEmptyWrappedPageAsSuccessfulZeroRecords(t *testing.T) {
t.Parallel()
diff --git a/internal/generator/templates/sync.go.tmpl b/internal/generator/templates/sync.go.tmpl
index 19d76c47..a447e024 100644
--- a/internal/generator/templates/sync.go.tmpl
+++ b/internal/generator/templates/sync.go.tmpl
@@ -554,12 +554,11 @@ func syncResource(c interface {
for {
params := map[string]string{}
- // Set page size
- params[pageSize.limitParam] = strconv.Itoa(pageSize.limit)
-
- // Set cursor for resume
- if cursor != "" {
- params[pageSize.cursorParam] = cursor
+ if resourceSupportsPagination(resource) {
+ params[pageSize.limitParam] = strconv.Itoa(pageSize.limit)
+ if cursor != "" {
+ params[pageSize.cursorParam] = cursor
+ }
}
// Set since filter
@@ -707,12 +706,6 @@ func syncResource(c interface {
}
}
- // Save cursor after each page for resumability
- if err := db.SaveSyncState(resource, nextCursor, totalCount); err != nil {
- // Non-fatal: log and continue
- fmt.Fprintf(os.Stderr, "\nwarning: failed to save sync state for %s: %v\n", resource, err)
- }
-
pagesFetched++
// Enforce page ceiling to prevent runaway syncs on large-catalog APIs.
@@ -747,10 +740,31 @@ func syncResource(c interface {
}
lastNextCursor = nextCursor
- // Determine if there are more pages
- if !hasMore || len(items) < pageSize.limit || nextCursor == "" {
+ // Determine if there are more pages.
+ if !resourceSupportsPagination(resource) {
+ break
+ }
+ if !hasMore || len(items) < pageSize.limit {
break
}
+ if nextCursor == "" {
+ if pageSize.cursorParam == "offset" {
+ // Cursor-based APIs return the next cursor in the envelope.
+ // Offset-based APIs carry their pagination position client-side.
+ currentOffset, _ := strconv.Atoi(cursor)
+ nextCursor = strconv.Itoa(currentOffset + pageSize.limit)
+ } else {
+ // A cursor-based API reporting has_more without a next cursor
+ // cannot advance safely; stop instead of looping silently.
+ break
+ }
+ }
+
+ // Save cursor after each page for resumability
+ if err := db.SaveSyncState(resource, nextCursor, totalCount); err != nil {
+ // Non-fatal: log and continue
+ fmt.Fprintf(os.Stderr, "\nwarning: failed to save sync state for %s: %v\n", resource, err)
+ }
cursor = nextCursor
}
@@ -799,6 +813,16 @@ func determinePaginationDefaults() paginationDefaults {
}
}
+func resourceSupportsPagination(resource string) bool {
+ switch resource {
+{{- range .PaginationSupportedResources}}
+ case "{{.}}":
+ return true
+{{- end}}
+ }
+ return false
+}
+
// syncResourceSinceParam returns the query parameter name this resource's
// list endpoint declares for incremental temporal filtering, or "" when the
// endpoint declares none. Skipping the param for "" resources avoids
@@ -1368,9 +1392,11 @@ func syncDependentResource(c interface {
for {
params := map[string]string{}
- params[pageSize.limitParam] = strconv.Itoa(pageSize.limit)
- if cursor != "" {
- params[pageSize.cursorParam] = cursor
+ if resourceSupportsPagination(dep.Name) {
+ params[pageSize.limitParam] = strconv.Itoa(pageSize.limit)
+ if cursor != "" {
+ params[pageSize.cursorParam] = cursor
+ }
}
if depSinceTS != "" {
params[depSinceParam] = depSinceTS
@@ -1498,9 +1524,24 @@ func syncDependentResource(c interface {
break
}
lastNextCursor = nextCursor
- if !hasMore || len(items) < pageSize.limit || nextCursor == "" {
+ if !resourceSupportsPagination(dep.Name) {
break
}
+ if !hasMore || len(items) < pageSize.limit {
+ break
+ }
+ if nextCursor == "" {
+ if pageSize.cursorParam == "offset" {
+ // Cursor-based APIs return the next cursor in the envelope.
+ // Offset-based APIs carry their pagination position client-side.
+ currentOffset, _ := strconv.Atoi(cursor)
+ nextCursor = strconv.Itoa(currentOffset + pageSize.limit)
+ } else {
+ // A cursor-based API reporting has_more without a next cursor
+ // cannot advance safely; stop instead of looping silently.
+ break
+ }
+ }
cursor = nextCursor
}
diff --git a/internal/narrativecheck/narrativecheck.go b/internal/narrativecheck/narrativecheck.go
index 7819caed..213b6af1 100644
--- a/internal/narrativecheck/narrativecheck.go
+++ b/internal/narrativecheck/narrativecheck.go
@@ -294,6 +294,11 @@ func classifyFullExample(ctx context.Context, binaryPath, command string, helpOu
}
args := append([]string(nil), tokens[1:]...)
+ if isSideEffectfulNarrativeExample(args) {
+ r.Status = StatusUnsupported
+ r.Error = "full-example validation skipped: command is side-effectful (auth/launch/apply)"
+ return r
+ }
if !hasEnabledBoolFlag(args, "--dry-run") {
if !helpAdvertisesDryRun(helpOut) {
r.Status = StatusUnsupported
@@ -321,6 +326,21 @@ func classifyFullExample(ctx context.Context, binaryPath, command string, helpOu
return r
}
+func isSideEffectfulNarrativeExample(args []string) bool {
+ if len(args) >= 2 && args[0] == "auth" {
+ switch args[1] {
+ case "set-token", "logout", "setup", "login":
+ return true
+ }
+ }
+
+ if hasEnabledBoolFlag(args, "--launch") {
+ return true
+ }
+ hasApply := hasEnabledBoolFlag(args, "--apply")
+ return hasApply && !hasEnabledBoolFlag(args, "--dry-run")
+}
+
func hasEnabledBoolFlag(args []string, flag string) bool {
for _, arg := range args {
if arg == flag || arg == flag+"=true" {
diff --git a/internal/narrativecheck/narrativecheck_test.go b/internal/narrativecheck/narrativecheck_test.go
index ce77601a..b643b1cb 100644
--- a/internal/narrativecheck/narrativecheck_test.go
+++ b/internal/narrativecheck/narrativecheck_test.go
@@ -234,6 +234,93 @@ func TestClassifyFullExample_ReportsUnsupportedWhenDryRunUnavailable(t *testing.
}
}
+func TestRunFullExample_SkipsAuthSetToken(t *testing.T) {
+ t.Parallel()
+
+ got := classifyFullExample(
+ context.Background(),
+ "/not/invoked",
+ "stub auth set-token YOUR_TOKEN_HERE",
+ []byte(" --dry-run Show request without sending"),
+ Result{Section: SectionQuickstart, Command: "stub auth set-token YOUR_TOKEN_HERE", Words: "auth set-token YOUR_TOKEN_HERE"},
+ )
+ if got.Status != StatusUnsupported {
+ t.Fatalf("Status = %q, want %q", got.Status, StatusUnsupported)
+ }
+ if got.Error != "full-example validation skipped: command is side-effectful (auth/launch/apply)" {
+ t.Errorf("Error = %q", got.Error)
+ }
+}
+
+func TestRunFullExample_SkipsAuthLogout(t *testing.T) {
+ t.Parallel()
+
+ got := classifyFullExample(
+ context.Background(),
+ "/not/invoked",
+ "stub auth logout",
+ []byte(" --dry-run Show request without sending"),
+ Result{Section: SectionRecipes, Command: "stub auth logout", Words: "auth logout"},
+ )
+ if got.Status != StatusUnsupported {
+ t.Fatalf("Status = %q, want %q", got.Status, StatusUnsupported)
+ }
+ if got.Error != "full-example validation skipped: command is side-effectful (auth/launch/apply)" {
+ t.Errorf("Error = %q", got.Error)
+ }
+}
+
+func TestIsSideEffectfulNarrativeExample_UsesExactFlagMatches(t *testing.T) {
+ t.Parallel()
+
+ cases := []struct {
+ name string
+ args []string
+ want bool
+ }{
+ {
+ name: "launch flag is side effectful",
+ args: []string{"widgets", "create", "--launch"},
+ want: true,
+ },
+ {
+ name: "launch true flag is side effectful",
+ args: []string{"widgets", "create", "--launch=true"},
+ want: true,
+ },
+ {
+ name: "launch substring flag is not side effectful",
+ args: []string{"widgets", "create", "--launch-app"},
+ want: false,
+ },
+ {
+ name: "apply without dry run is side effectful",
+ args: []string{"widgets", "create", "--apply"},
+ want: true,
+ },
+ {
+ name: "apply with dry run is not side effectful",
+ args: []string{"widgets", "create", "--apply", "--dry-run"},
+ want: false,
+ },
+ {
+ name: "apply substring flag is not side effectful",
+ args: []string{"widgets", "create", "--apply-template"},
+ want: false,
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ got := isSideEffectfulNarrativeExample(tc.args)
+ if got != tc.want {
+ t.Fatalf("isSideEffectfulNarrativeExample(%v) = %v, want %v", tc.args, got, tc.want)
+ }
+ })
+ }
+}
+
// TestValidate_EmptyResearchFlagsResearchEmpty covers the LLM-omitted-
// both-sections case.
func TestValidate_EmptyResearchFlagsResearchEmpty(t *testing.T) {
diff --git a/internal/profiler/profiler.go b/internal/profiler/profiler.go
index 21ccbaf1..ac596533 100644
--- a/internal/profiler/profiler.go
+++ b/internal/profiler/profiler.go
@@ -96,6 +96,12 @@ type SyncableResource struct {
// run when --since/incremental sync was requested.
SinceParam string
+ // SupportsPagination is true when the chosen list endpoint declares a
+ // cursor or page-size parameter. The sync template uses this to avoid
+ // sending synthetic limit/offset params to strict non-paginated list
+ // endpoints.
+ SupportsPagination bool
+
// Discriminator routes heterogeneous response items to concrete typed
// resources before storage. Empty when the endpoint returns a homogeneous
// resource.
@@ -130,6 +136,11 @@ type DependentResource struct {
// syncs.
SinceParam string
+ // SupportsPagination mirrors SyncableResource.SupportsPagination for child
+ // paths so dependent syncs skip synthetic limit/offset params on endpoints
+ // that do not declare page-size pagination.
+ SupportsPagination bool
+
// Discriminator routes heterogeneous dependent-resource response items to
// concrete typed resources before storage.
Discriminator DiscriminatorDispatch
@@ -1063,15 +1074,16 @@ func detectDependentResources(parameterized map[string]parameterizedEntry, synca
}
deps = append(deps, DependentResource{
- Name: emittedName,
- ParentResource: parentResource,
- ParentIDParam: paramName,
- Path: entry.meta.Path,
- Tier: entry.meta.Tier,
- IDField: entry.meta.IDField,
- Critical: entry.meta.Critical,
- SinceParam: entry.meta.SinceParam,
- Discriminator: entry.meta.Discriminator,
+ Name: emittedName,
+ ParentResource: parentResource,
+ ParentIDParam: paramName,
+ Path: entry.meta.Path,
+ Tier: entry.meta.Tier,
+ IDField: entry.meta.IDField,
+ Critical: entry.meta.Critical,
+ SinceParam: entry.meta.SinceParam,
+ SupportsPagination: entry.meta.SupportsPagination,
+ Discriminator: entry.meta.Discriminator,
})
}
// Sort for deterministic output
@@ -1168,16 +1180,17 @@ func applySpecWalkers(s *spec.APISpec, deps []DependentResource, syncable map[st
}
meta := metaFromEndpoint(s, r, e, types, resourceNameIndex)
deps = append(deps, DependentResource{
- Name: spec.ToSnakeCase(resourceName),
- ParentResource: parent,
- ParentIDParam: keyParam,
- Path: e.Path,
- Tier: meta.Tier,
- IDField: meta.IDField,
- Critical: meta.Critical,
- SinceParam: meta.SinceParam,
- Discriminator: meta.Discriminator,
- KeyField: keyField,
+ Name: spec.ToSnakeCase(resourceName),
+ ParentResource: parent,
+ ParentIDParam: keyParam,
+ Path: e.Path,
+ Tier: meta.Tier,
+ IDField: meta.IDField,
+ Critical: meta.Critical,
+ SinceParam: meta.SinceParam,
+ SupportsPagination: meta.SupportsPagination,
+ Discriminator: meta.Discriminator,
+ KeyField: keyField,
})
byPath[lookupKey] = len(deps) - 1
}
@@ -1250,12 +1263,13 @@ func resolveParentResource(walkParent, paramName string, syncable map[string]syn
// is still selecting between candidates (e.g., flat vs. paginated). It is
// converted into a SyncableResource at the end of Profile().
type syncableMeta struct {
- Path string
- Tier string
- IDField string
- Critical bool
- SinceParam string
- Discriminator DiscriminatorDispatch
+ Path string
+ Tier string
+ IDField string
+ Critical bool
+ SinceParam string
+ SupportsPagination bool
+ Discriminator DiscriminatorDispatch
}
type syncableCandidate struct {
@@ -1278,12 +1292,13 @@ type parameterizedEntry struct {
// fields propagate uniformly.
func metaFromEndpoint(s *spec.APISpec, resource spec.Resource, e spec.Endpoint, types map[string]spec.TypeDef, resourceNameIndex map[string]string) syncableMeta {
return syncableMeta{
- Path: e.Path,
- Tier: s.EffectiveTier(resource, e),
- IDField: e.IDField,
- Critical: e.Critical,
- SinceParam: detectEndpointSinceParam(e.Params),
- Discriminator: discriminatorDispatchForEndpoint(e, types, resourceNameIndex),
+ Path: e.Path,
+ Tier: s.EffectiveTier(resource, e),
+ IDField: e.IDField,
+ Critical: e.Critical,
+ SinceParam: detectEndpointSinceParam(e.Params),
+ SupportsPagination: endpointSupportsPagination(e),
+ Discriminator: discriminatorDispatchForEndpoint(e, types, resourceNameIndex),
}
}
@@ -1302,6 +1317,23 @@ func detectEndpointSinceParam(params []spec.Param) string {
return ""
}
+func endpointSupportsPagination(endpoint spec.Endpoint) bool {
+ if endpoint.Pagination != nil &&
+ (strings.TrimSpace(endpoint.Pagination.LimitParam) != "" ||
+ strings.TrimSpace(endpoint.Pagination.CursorParam) != "") {
+ return true
+ }
+ for _, param := range endpoint.Params {
+ if param.PathParam || param.Positional {
+ continue
+ }
+ if pageSizeParamCandidates[strings.ToLower(param.Name)] {
+ return true
+ }
+ }
+ return false
+}
+
func applySyncCandidates(syncable map[string]syncableMeta, candidates map[string][]syncableCandidate) {
resourceNames := sortedKeys(candidates)
for _, resourceName := range resourceNames {
@@ -1491,13 +1523,14 @@ func sortedSyncableResources(m map[string]syncableMeta) []SyncableResource {
for i, name := range names {
meta := m[name]
resources[i] = SyncableResource{
- Name: name,
- Path: meta.Path,
- Tier: meta.Tier,
- IDField: meta.IDField,
- Critical: meta.Critical,
- SinceParam: meta.SinceParam,
- Discriminator: meta.Discriminator,
+ Name: name,
+ Path: meta.Path,
+ Tier: meta.Tier,
+ IDField: meta.IDField,
+ Critical: meta.Critical,
+ SinceParam: meta.SinceParam,
+ SupportsPagination: meta.SupportsPagination,
+ Discriminator: meta.Discriminator,
}
}
return resources
diff --git a/internal/profiler/profiler_test.go b/internal/profiler/profiler_test.go
index 8cf41aad..381610d5 100644
--- a/internal/profiler/profiler_test.go
+++ b/internal/profiler/profiler_test.go
@@ -834,6 +834,29 @@ func TestProfileDependentResources(t *testing.T) {
assert.Equal(t, "/channels/{channelId}/messages", dep.Path)
}
+func TestProfileSyncableResourceSupportsCursorOnlyPagination(t *testing.T) {
+ s := &spec.APISpec{
+ Name: "cursor-only",
+ Resources: map[string]spec.Resource{
+ "events": {
+ Endpoints: map[string]spec.Endpoint{
+ "list": {
+ Method: "GET",
+ Path: "/events",
+ Response: spec.ResponseDef{Type: "array"},
+ Pagination: &spec.Pagination{CursorParam: "starting_after"},
+ },
+ },
+ },
+ },
+ }
+
+ profile := Profile(s)
+ require.Len(t, profile.SyncableResources, 1)
+ assert.Equal(t, "events", profile.SyncableResources[0].Name)
+ assert.True(t, profile.SyncableResources[0].SupportsPagination, "cursor-only pagination must keep sync from stopping after the first page")
+}
+
// TestProfileDependentResources_SharedSubResourceShardsByParent pins the
// fix for issue #694. When the same sub-resource leaf name (e.g. "commits")
// appears under multiple parents (e.g. /gists/{gist_id}/commits and
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 6bf8a94b..6ba2e960 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -785,11 +785,11 @@ func (c *AuthConfig) CanonicalEnvVar() *AuthEnvVar {
return nil
}
-// NewORCaseEnvVarSpecs builds the EnvVarSpecs slice for the OR-case shape
-// IsAuthEnvVarORCase validates: each entry is per_call, non-required, and
-// sensitive. The runtime tries each in turn and returns the first non-empty
-// value. Distinct from the per_call construction in NormalizeEnvVarSpecs,
-// which defaults to Required=true for the canonical-credential shape.
+// NewORCaseEnvVarSpecs builds the canonical EnvVarSpecs slice for the OR-case
+// shape: each entry is per_call, non-required, and sensitive. The runtime tries
+// each in turn and returns the first non-empty value. Distinct from the per_call
+// construction in NormalizeEnvVarSpecs, which defaults to Required=true for the
+// canonical-credential shape.
func NewORCaseEnvVarSpecs(names []string) []AuthEnvVar {
specs := make([]AuthEnvVar, 0, len(names))
for _, name := range names {
@@ -803,20 +803,28 @@ func NewORCaseEnvVarSpecs(names []string) []AuthEnvVar {
return specs
}
-// IsAuthEnvVarORCase reports whether all EnvVarSpecs are non-required per_call vars.
-// In this shape, no single var is the canonical credential; the runtime tries each
-// in turn and returns the first non-empty value. Returns false when EnvVarSpecs has
-// fewer than 2 entries, any entry is Required, or any entry is not Kind=per_call.
+// IsAuthEnvVarORCase reports whether the auth config declares multiple request
+// credential aliases. In this shape, no single var is the canonical credential;
+// the runtime tries each in turn and returns the first non-empty value.
+// Recognizes both the canonical form produced by NewORCaseEnvVarSpecs (per_call,
+// non-required) and the legacy x-auth-env-vars form (EnvVars list, or per_call
+// entries with the default Required=true).
func (c *AuthConfig) IsAuthEnvVarORCase() bool {
- if c == nil || len(c.EnvVarSpecs) < 2 {
+ if c == nil {
return false
}
- for _, ev := range c.EnvVarSpecs {
- if !ev.IsRequestCredential() || ev.Required {
+ if len(c.EnvVarSpecs) > 0 {
+ if len(c.EnvVarSpecs) < 2 {
return false
}
+ for _, ev := range c.EnvVarSpecs {
+ if !ev.IsRequestCredential() {
+ return false
+ }
+ }
+ return true
}
- return true
+ return len(c.EnvVars) >= 2
}
func (c *AuthConfig) NormalizeEnvVarSpecs(context string) {
diff --git a/internal/spec/spec_test.go b/internal/spec/spec_test.go
index 57bc93de..8ae009f4 100644
--- a/internal/spec/spec_test.go
+++ b/internal/spec/spec_test.go
@@ -739,12 +739,17 @@ func TestIsAuthEnvVarORCase(t *testing.T) {
want bool
}{
{
- name: "all required entries are not OR case",
+ name: "all required per-call entries are OR case",
auth: AuthConfig{EnvVarSpecs: []AuthEnvVar{
{Name: "FIRST_API_KEY", Kind: AuthEnvVarKindPerCall, Required: true},
{Name: "SECOND_API_KEY", Kind: AuthEnvVarKindPerCall, Required: true},
}},
- want: false,
+ want: true,
+ },
+ {
+ name: "legacy env vars list is OR case",
+ auth: AuthConfig{EnvVars: []string{"FIRST_API_KEY", "SECOND_API_KEY"}},
+ want: true,
},
{
name: "all non-required per-call entries are OR case",
diff --git a/skills/printing-press/references/codex-delegation.md b/skills/printing-press/references/codex-delegation.md
index 976eabb3..6af9a59f 100644
--- a/skills/printing-press/references/codex-delegation.md
+++ b/skills/printing-press/references/codex-delegation.md
@@ -71,6 +71,9 @@ When `CODEX_MODE` is true, delegate code-writing tasks to Codex CLI. Claude stil
All templates follow this structure. Paste ACTUAL CODE in the CURRENT CODE section — never descriptions of code.
+All CONVENTIONS lists apply the empty-collection rule to every list-shaped
+output: initialize empty result slices so JSON output is `[]`, not `null`.
+
**Store table task:**
```
TASK: Add <entity> table with Upsert and Search methods to the SQLite store.
@@ -127,6 +130,12 @@ Must support: --json, --select, --compact, --limit, --dry-run (for mutations).
Must have realistic --help examples with domain-specific values.
CONVENTIONS:
+- EMPTY-COLLECTION CONVENTION:
+ When the command's primary output is a list/array/cluster, declare it
+ as `results := make([]T, 0)` (not `var results []T`). Nil-slice marshals
+ to JSON `null`, which breaks `jq '.[]'` agent pipelines; an initialized
+ empty slice marshals to `[]` and lets downstream consumers iterate
+ uniformly across empty and non-empty results.
- Package: cli
- Use cobra.Command pattern matching existing commands
- Error handling: return fmt.Errorf with context
@@ -168,6 +177,12 @@ This command ONLY works because all data is in local SQLite.
Must support: --json, --select, --compact, --limit.
CONVENTIONS:
+- EMPTY-COLLECTION CONVENTION:
+ When the command's primary output is a list/array/cluster, declare it
+ as `results := make([]T, 0)` (not `var results []T`). Nil-slice marshals
+ to JSON `null`, which breaks `jq '.[]'` agent pipelines; an initialized
+ empty slice marshals to `[]` and lets downstream consumers iterate
+ uniformly across empty and non-empty results.
- Package: cli
- Query across tables using db methods, not raw SQL in CLI layer
- Format output as a table by default, JSON with --json
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/sync.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/sync.go
index 22400033..33651807 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/sync.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/sync.go
@@ -444,12 +444,11 @@ func syncResource(c interface {
for {
params := map[string]string{}
- // Set page size
- params[pageSize.limitParam] = strconv.Itoa(pageSize.limit)
-
- // Set cursor for resume
- if cursor != "" {
- params[pageSize.cursorParam] = cursor
+ if resourceSupportsPagination(resource) {
+ params[pageSize.limitParam] = strconv.Itoa(pageSize.limit)
+ if cursor != "" {
+ params[pageSize.cursorParam] = cursor
+ }
}
// Set since filter
@@ -590,12 +589,6 @@ func syncResource(c interface {
}
}
- // Save cursor after each page for resumability
- if err := db.SaveSyncState(resource, nextCursor, totalCount); err != nil {
- // Non-fatal: log and continue
- fmt.Fprintf(os.Stderr, "\nwarning: failed to save sync state for %s: %v\n", resource, err)
- }
-
pagesFetched++
// Enforce page ceiling to prevent runaway syncs on large-catalog APIs.
@@ -630,10 +623,31 @@ func syncResource(c interface {
}
lastNextCursor = nextCursor
- // Determine if there are more pages
- if !hasMore || len(items) < pageSize.limit || nextCursor == "" {
+ // Determine if there are more pages.
+ if !resourceSupportsPagination(resource) {
+ break
+ }
+ if !hasMore || len(items) < pageSize.limit {
break
}
+ if nextCursor == "" {
+ if pageSize.cursorParam == "offset" {
+ // Cursor-based APIs return the next cursor in the envelope.
+ // Offset-based APIs carry their pagination position client-side.
+ currentOffset, _ := strconv.Atoi(cursor)
+ nextCursor = strconv.Itoa(currentOffset + pageSize.limit)
+ } else {
+ // A cursor-based API reporting has_more without a next cursor
+ // cannot advance safely; stop instead of looping silently.
+ break
+ }
+ }
+
+ // Save cursor after each page for resumability
+ if err := db.SaveSyncState(resource, nextCursor, totalCount); err != nil {
+ // Non-fatal: log and continue
+ fmt.Fprintf(os.Stderr, "\nwarning: failed to save sync state for %s: %v\n", resource, err)
+ }
cursor = nextCursor
}
@@ -682,6 +696,16 @@ func determinePaginationDefaults() paginationDefaults {
}
}
+func resourceSupportsPagination(resource string) bool {
+ switch resource {
+ case "projects":
+ return true
+ case "tasks":
+ return true
+ }
+ return false
+}
+
// syncResourceSinceParam returns the query parameter name this resource's
// list endpoint declares for incremental temporal filtering, or "" when the
// endpoint declares none. Skipping the param for "" resources avoids
@@ -1177,9 +1201,11 @@ func syncDependentResource(c interface {
for {
params := map[string]string{}
- params[pageSize.limitParam] = strconv.Itoa(pageSize.limit)
- if cursor != "" {
- params[pageSize.cursorParam] = cursor
+ if resourceSupportsPagination(dep.Name) {
+ params[pageSize.limitParam] = strconv.Itoa(pageSize.limit)
+ if cursor != "" {
+ params[pageSize.cursorParam] = cursor
+ }
}
if depSinceTS != "" {
params[depSinceParam] = depSinceTS
@@ -1302,9 +1328,24 @@ func syncDependentResource(c interface {
break
}
lastNextCursor = nextCursor
- if !hasMore || len(items) < pageSize.limit || nextCursor == "" {
+ if !resourceSupportsPagination(dep.Name) {
break
}
+ if !hasMore || len(items) < pageSize.limit {
+ break
+ }
+ if nextCursor == "" {
+ if pageSize.cursorParam == "offset" {
+ // Cursor-based APIs return the next cursor in the envelope.
+ // Offset-based APIs carry their pagination position client-side.
+ currentOffset, _ := strconv.Atoi(cursor)
+ nextCursor = strconv.Itoa(currentOffset + pageSize.limit)
+ } else {
+ // A cursor-based API reporting has_more without a next cursor
+ // cannot advance safely; stop instead of looping silently.
+ break
+ }
+ }
cursor = nextCursor
}
diff --git a/testdata/golden/expected/generate-sync-walker-api/sync-walker-golden/internal/cli/sync.go b/testdata/golden/expected/generate-sync-walker-api/sync-walker-golden/internal/cli/sync.go
index eb60d6ea..2d1c390f 100644
--- a/testdata/golden/expected/generate-sync-walker-api/sync-walker-golden/internal/cli/sync.go
+++ b/testdata/golden/expected/generate-sync-walker-api/sync-walker-golden/internal/cli/sync.go
@@ -444,12 +444,11 @@ func syncResource(c interface {
for {
params := map[string]string{}
- // Set page size
- params[pageSize.limitParam] = strconv.Itoa(pageSize.limit)
-
- // Set cursor for resume
- if cursor != "" {
- params[pageSize.cursorParam] = cursor
+ if resourceSupportsPagination(resource) {
+ params[pageSize.limitParam] = strconv.Itoa(pageSize.limit)
+ if cursor != "" {
+ params[pageSize.cursorParam] = cursor
+ }
}
// Set since filter
@@ -590,12 +589,6 @@ func syncResource(c interface {
}
}
- // Save cursor after each page for resumability
- if err := db.SaveSyncState(resource, nextCursor, totalCount); err != nil {
- // Non-fatal: log and continue
- fmt.Fprintf(os.Stderr, "\nwarning: failed to save sync state for %s: %v\n", resource, err)
- }
-
pagesFetched++
// Enforce page ceiling to prevent runaway syncs on large-catalog APIs.
@@ -630,10 +623,31 @@ func syncResource(c interface {
}
lastNextCursor = nextCursor
- // Determine if there are more pages
- if !hasMore || len(items) < pageSize.limit || nextCursor == "" {
+ // Determine if there are more pages.
+ if !resourceSupportsPagination(resource) {
break
}
+ if !hasMore || len(items) < pageSize.limit {
+ break
+ }
+ if nextCursor == "" {
+ if pageSize.cursorParam == "offset" {
+ // Cursor-based APIs return the next cursor in the envelope.
+ // Offset-based APIs carry their pagination position client-side.
+ currentOffset, _ := strconv.Atoi(cursor)
+ nextCursor = strconv.Itoa(currentOffset + pageSize.limit)
+ } else {
+ // A cursor-based API reporting has_more without a next cursor
+ // cannot advance safely; stop instead of looping silently.
+ break
+ }
+ }
+
+ // Save cursor after each page for resumability
+ if err := db.SaveSyncState(resource, nextCursor, totalCount); err != nil {
+ // Non-fatal: log and continue
+ fmt.Fprintf(os.Stderr, "\nwarning: failed to save sync state for %s: %v\n", resource, err)
+ }
cursor = nextCursor
}
@@ -682,6 +696,12 @@ func determinePaginationDefaults() paginationDefaults {
}
}
+func resourceSupportsPagination(resource string) bool {
+ switch resource {
+ }
+ return false
+}
+
// syncResourceSinceParam returns the query parameter name this resource's
// list endpoint declares for incremental temporal filtering, or "" when the
// endpoint declares none. Skipping the param for "" resources avoids
@@ -1169,9 +1189,11 @@ func syncDependentResource(c interface {
for {
params := map[string]string{}
- params[pageSize.limitParam] = strconv.Itoa(pageSize.limit)
- if cursor != "" {
- params[pageSize.cursorParam] = cursor
+ if resourceSupportsPagination(dep.Name) {
+ params[pageSize.limitParam] = strconv.Itoa(pageSize.limit)
+ if cursor != "" {
+ params[pageSize.cursorParam] = cursor
+ }
}
if depSinceTS != "" {
params[depSinceParam] = depSinceTS
@@ -1294,9 +1316,24 @@ func syncDependentResource(c interface {
break
}
lastNextCursor = nextCursor
- if !hasMore || len(items) < pageSize.limit || nextCursor == "" {
+ if !resourceSupportsPagination(dep.Name) {
break
}
+ if !hasMore || len(items) < pageSize.limit {
+ break
+ }
+ if nextCursor == "" {
+ if pageSize.cursorParam == "offset" {
+ // Cursor-based APIs return the next cursor in the envelope.
+ // Offset-based APIs carry their pagination position client-side.
+ currentOffset, _ := strconv.Atoi(cursor)
+ nextCursor = strconv.Itoa(currentOffset + pageSize.limit)
+ } else {
+ // A cursor-based API reporting has_more without a next cursor
+ // cannot advance safely; stop instead of looping silently.
+ break
+ }
+ }
cursor = nextCursor
}
diff --git a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/sync.go b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/sync.go
index 90d43f1c..ae10ff69 100644
--- a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/sync.go
+++ b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/sync.go
@@ -418,12 +418,11 @@ func syncResource(c interface {
for {
params := map[string]string{}
- // Set page size
- params[pageSize.limitParam] = strconv.Itoa(pageSize.limit)
-
- // Set cursor for resume
- if cursor != "" {
- params[pageSize.cursorParam] = cursor
+ if resourceSupportsPagination(resource) {
+ params[pageSize.limitParam] = strconv.Itoa(pageSize.limit)
+ if cursor != "" {
+ params[pageSize.cursorParam] = cursor
+ }
}
// Set since filter
@@ -564,12 +563,6 @@ func syncResource(c interface {
}
}
- // Save cursor after each page for resumability
- if err := db.SaveSyncState(resource, nextCursor, totalCount); err != nil {
- // Non-fatal: log and continue
- fmt.Fprintf(os.Stderr, "\nwarning: failed to save sync state for %s: %v\n", resource, err)
- }
-
pagesFetched++
// Enforce page ceiling to prevent runaway syncs on large-catalog APIs.
@@ -604,10 +597,31 @@ func syncResource(c interface {
}
lastNextCursor = nextCursor
- // Determine if there are more pages
- if !hasMore || len(items) < pageSize.limit || nextCursor == "" {
+ // Determine if there are more pages.
+ if !resourceSupportsPagination(resource) {
+ break
+ }
+ if !hasMore || len(items) < pageSize.limit {
break
}
+ if nextCursor == "" {
+ if pageSize.cursorParam == "offset" {
+ // Cursor-based APIs return the next cursor in the envelope.
+ // Offset-based APIs carry their pagination position client-side.
+ currentOffset, _ := strconv.Atoi(cursor)
+ nextCursor = strconv.Itoa(currentOffset + pageSize.limit)
+ } else {
+ // A cursor-based API reporting has_more without a next cursor
+ // cannot advance safely; stop instead of looping silently.
+ break
+ }
+ }
+
+ // Save cursor after each page for resumability
+ if err := db.SaveSyncState(resource, nextCursor, totalCount); err != nil {
+ // Non-fatal: log and continue
+ fmt.Fprintf(os.Stderr, "\nwarning: failed to save sync state for %s: %v\n", resource, err)
+ }
cursor = nextCursor
}
@@ -656,6 +670,14 @@ func determinePaginationDefaults() paginationDefaults {
}
}
+func resourceSupportsPagination(resource string) bool {
+ switch resource {
+ case "items":
+ return true
+ }
+ return false
+}
+
// syncResourceSinceParam returns the query parameter name this resource's
// list endpoint declares for incremental temporal filtering, or "" when the
// endpoint declares none. Skipping the param for "" resources avoids
← 275646a3 feat(cli): add auth set-token escape hatch for cookie-auth C
·
back to Cli Printing Press
·
fix(cli): dogfood prefers bundled <dir>/spec.yaml over calle 17cd2774 →