[object Object]

← back to Cli Printing Press

fix(cli): movie-goat retro — generator param handling, write-through, sync ceiling, scorer (#172)

ad9e4aee960e981ad11b318b7632444213f714c8 · 2026-04-11 11:46:44 -0700 · Trevin Chow

* fix(cli): generator param handling — query params, required defaults, dot columns

Three generator template fixes from the movie-goat retro:

1. Positional params that don't appear in the URL path template are now
   emitted as query params instead of replacePathParam calls. Fixes silent
   search failures on APIs like TMDb where `query` is positional but sent
   as ?query=, not substituted into the path.

2. Required-flag checks are now skipped when the param has a default value.
   Fixes enum params like time-window (default "day") erroring with
   "required flag not set" despite having a valid default.

3. Dots in param names are replaced with underscores in SQLite column
   names. Fixes runtime crash from invalid SQL like
   `primary_release_date.gte TEXT` → `primary_release_date_gte TEXT`.

Both command_endpoint.go.tmpl and command_promoted.go.tmpl are fixed.
Includes toSnakeCase test coverage and retro/plan docs.

Closes #171 (WU-1, WU-2, WU-3)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(cli): add write-through caching to data source template

Live API responses are now automatically upserted into the local SQLite
store, so FTS search grows organically with usage. Every movies search,
trending lookup, or detail fetch adds results to the searchable corpus.

Handles three response shapes:
- Direct JSON arrays
- Envelope objects with results/data/items keys (TMDb, Stripe, etc.)
- Single objects with an id field (detail responses)

Best-effort: write failures are silently ignored since the live result
already succeeded. The store import was already present in the template.

From movie-goat retro WU-4 (#171).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(cli): add sync page ceiling to prevent runaway pagination

Sync command now has --max-pages flag (default 10) that caps how many
pages are fetched per resource. Prevents unbounded pagination on
large-catalog APIs like TMDb (500+ pages of popular movies).

- --max-pages 10 (default): safe for all APIs, fetches ~200 items
- --max-pages 0: unlimited, backwards compatible for bounded APIs
- Logs "reached --max-pages limit" when ceiling is hit

From movie-goat retro WU-5 (#171).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(cli): scorecard recognizes query-param auth as valid

The auth_protocol scorer now detects query-param API key patterns
(q.Set("api_key"), params["apikey"], etc.) in addition to Authorization
header patterns. This fixes false 3/10 scores on CLIs that correctly
use query-param auth (TMDb, OMDb, Google Maps, YouTube Data API).

Previously, the inferred-auth scoring path only checked for header-based
auth patterns, penalizing CLIs where the API key is sent as ?api_key=
in the URL query string.

From movie-goat retro WU-6 (#171).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(cli): fix TestPromoteWorkingCLI_MinimalStateNoRunstate for slug-keyed library

Test expected library directory at library/test-pp-cli but
PromoteWorkingCLI now uses slug-keyed paths (library/test).
Updated test assertion to match the current slug-keyed convention.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit ad9e4aee960e981ad11b318b7632444213f714c8
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Sat Apr 11 11:46:44 2026 -0700

    fix(cli): movie-goat retro — generator param handling, write-through, sync ceiling, scorer (#172)
    
    * fix(cli): generator param handling — query params, required defaults, dot columns
    
    Three generator template fixes from the movie-goat retro:
    
    1. Positional params that don't appear in the URL path template are now
       emitted as query params instead of replacePathParam calls. Fixes silent
       search failures on APIs like TMDb where `query` is positional but sent
       as ?query=, not substituted into the path.
    
    2. Required-flag checks are now skipped when the param has a default value.
       Fixes enum params like time-window (default "day") erroring with
       "required flag not set" despite having a valid default.
    
    3. Dots in param names are replaced with underscores in SQLite column
       names. Fixes runtime crash from invalid SQL like
       `primary_release_date.gte TEXT` → `primary_release_date_gte TEXT`.
    
    Both command_endpoint.go.tmpl and command_promoted.go.tmpl are fixed.
    Includes toSnakeCase test coverage and retro/plan docs.
    
    Closes #171 (WU-1, WU-2, WU-3)
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * fix(cli): add write-through caching to data source template
    
    Live API responses are now automatically upserted into the local SQLite
    store, so FTS search grows organically with usage. Every movies search,
    trending lookup, or detail fetch adds results to the searchable corpus.
    
    Handles three response shapes:
    - Direct JSON arrays
    - Envelope objects with results/data/items keys (TMDb, Stripe, etc.)
    - Single objects with an id field (detail responses)
    
    Best-effort: write failures are silently ignored since the live result
    already succeeded. The store import was already present in the template.
    
    From movie-goat retro WU-4 (#171).
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * fix(cli): add sync page ceiling to prevent runaway pagination
    
    Sync command now has --max-pages flag (default 10) that caps how many
    pages are fetched per resource. Prevents unbounded pagination on
    large-catalog APIs like TMDb (500+ pages of popular movies).
    
    - --max-pages 10 (default): safe for all APIs, fetches ~200 items
    - --max-pages 0: unlimited, backwards compatible for bounded APIs
    - Logs "reached --max-pages limit" when ceiling is hit
    
    From movie-goat retro WU-5 (#171).
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * fix(cli): scorecard recognizes query-param auth as valid
    
    The auth_protocol scorer now detects query-param API key patterns
    (q.Set("api_key"), params["apikey"], etc.) in addition to Authorization
    header patterns. This fixes false 3/10 scores on CLIs that correctly
    use query-param auth (TMDb, OMDb, Google Maps, YouTube Data API).
    
    Previously, the inferred-auth scoring path only checked for header-based
    auth patterns, penalizing CLIs where the API key is sent as ?api_key=
    in the URL query string.
    
    From movie-goat retro WU-6 (#171).
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * fix(cli): fix TestPromoteWorkingCLI_MinimalStateNoRunstate for slug-keyed library
    
    Test expected library directory at library/test-pp-cli but
    PromoteWorkingCLI now uses slug-keyed paths (library/test).
    Updated test assertion to match the current slug-keyed convention.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    ---------
    
    Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
 ...-04-11-002-fix-generator-param-handling-plan.md | 177 +++++++++++++++++
 docs/retros/2026-04-11-movie-goat-retro.md         | 221 +++++++++++++++++++++
 internal/generator/generator.go                    |   3 +
 internal/generator/schema_builder.go               |   1 +
 internal/generator/schema_builder_test.go          |  37 ++++
 .../generator/templates/command_endpoint.go.tmpl   |  21 +-
 .../generator/templates/command_promoted.go.tmpl   |  19 +-
 internal/generator/templates/data_source.go.tmpl   |  56 ++++++
 internal/generator/templates/sync.go.tmpl          |  17 +-
 internal/pipeline/lock_test.go                     |   4 +-
 internal/pipeline/scorecard.go                     |  10 +
 11 files changed, 553 insertions(+), 13 deletions(-)

diff --git a/docs/plans/2026-04-11-002-fix-generator-param-handling-plan.md b/docs/plans/2026-04-11-002-fix-generator-param-handling-plan.md
new file mode 100644
index 00000000..3706e428
--- /dev/null
+++ b/docs/plans/2026-04-11-002-fix-generator-param-handling-plan.md
@@ -0,0 +1,177 @@
+---
+title: "fix: Generator param handling — positional query params, required defaults, dot column names"
+type: fix
+status: active
+date: 2026-04-11
+origin: docs/retros/2026-04-11-movie-goat-retro.md
+---
+
+# fix: Generator param handling — positional query params, required defaults, dot column names
+
+## Overview
+
+Three generator bugs discovered during the movie-goat session cause silent failures, usability errors, and runtime crashes in printed CLIs. All three are in the generator's template/schema layer and affect most future CLIs — not just movie-goat.
+
+## Problem Frame
+
+1. **Positional params emitted as path substitutions even when they're query params.** Search endpoints like `/search/movie?query=Inception` get `replacePathParam(path, "query", args[0])` which silently drops the query because `{query}` doesn't exist in the path. Search commands return 0 results with no error.
+
+2. **Required-flag check emitted even when a default exists.** Enum params with `required: true` and `default: "day"` still error with "required flag not set" when invoked without the flag, despite the default being perfectly valid.
+
+3. **Dot-notation param names produce invalid SQLite column names.** Params like `primary_release_date.gte` pass through `toSnakeCase()` unchanged, producing `CREATE TABLE (..., primary_release_date.gte TEXT)` which crashes with a SQL syntax error on first database access.
+
+## Requirements Trace
+
+- R1. Positional params that don't appear in the path template must be emitted as query params
+- R2. Required-flag checks must be skipped when the param has a non-nil default value
+- R3. SQLite column names must be valid identifiers — dots replaced with underscores
+
+## Scope Boundaries
+
+- These are generator template and schema builder fixes only
+- No changes to the spec parser, OpenAPI parser, or spec format
+- No changes to printed CLIs — they benefit on next generation
+
+## Context & Research
+
+### Relevant Code and Patterns
+
+- `internal/generator/templates/command_endpoint.go.tmpl` lines 70-83 — positional param emission
+- `internal/generator/templates/command_endpoint.go.tmpl` lines 46-51 — required check guard
+- `internal/generator/schema_builder.go` `toSnakeCase()` lines 361-377 — column name conversion
+- `internal/generator/schema_builder.go` `safeSQLName()` lines 338-343 — SQL reserved word quoting
+
+### Institutional Learnings
+
+- (see origin: `docs/retros/2026-04-11-movie-goat-retro.md`) — F1, F2, F3 findings with full session evidence
+
+## Key Technical Decisions
+
+- **Positional query params: check path template, not param metadata.** The param's `.Positional` flag means "take from args" but doesn't distinguish path-segment from query-string. The path template (`/movie/{movieId}` vs `/search/movie`) is the authoritative source for where the value goes.
+- **Dot replacement in toSnakeCase, not quoting in safeSQLName.** Replacing dots with underscores is cleaner than quoting — `vote_average_gte` is a better column name than `"vote_average.gte"`. The column is internal to the store, not user-facing.
+
+## Open Questions
+
+### Resolved During Planning
+
+- **Should the template use a Go template function or inline logic?** Inline `strings.Contains` check in the template is sufficient — no new template function needed. The path string is available at template render time.
+- **Should `toSnakeCase` also handle other special chars?** Yes, but scoped to common API param characters: dots, brackets, slashes. Dots are the priority since they're confirmed to cause crashes. Brackets and slashes can be deferred.
+
+### Deferred to Implementation
+
+- Exact behavior when a positional param name partially matches a path segment (e.g., param `id` with path `/items/{itemId}`) — unlikely edge case, verify with a test
+
+## Implementation Units
+
+- [ ] **Unit 1: Fix positional param emission in command template**
+
+**Goal:** Positional params only use `replacePathParam` when their name appears as `{name}` in the endpoint path. Otherwise, they go into the query params map.
+
+**Requirements:** R1
+
+**Dependencies:** None
+
+**Files:**
+- Modify: `internal/generator/templates/command_endpoint.go.tmpl`
+- Modify: `internal/generator/generator.go` (add `pathContainsParam` template function)
+- Test: `internal/generator/generator_test.go`
+
+**Approach:**
+- Add a template function `pathContainsParam(path, name string) bool` that checks `strings.Contains(path, "{"+name+"}")` to `generator.go`'s funcMap
+- In the template, wrap the `replacePathParam` emission for positional params in `{{if pathContainsParam .Endpoint.Path .Name}}`. If false, emit `params["{{.Name}}"] = args[{{$i}}]` instead
+- The non-paginated branch (line 114+) and paginated branch (line 97+) both need the positional param added to their params maps when it's a query param
+
+**Patterns to follow:**
+- Existing template functions in `generator.go` funcMap (e.g., `flagName`, `camel`, `lower`)
+- The `{{if and (not .Positional) (not .PathParam)}}` pattern already used for query params on lines 99 and 107
+
+**Test scenarios:**
+- Happy path: spec with positional `query` param on path `/search/movie` (no `{query}` in path) -> generated code emits `params["query"] = args[0]`, NOT `replacePathParam`
+- Happy path: spec with positional `movieId` param on path `/movie/{movieId}` -> generated code emits `replacePathParam(path, "movieId", args[0])` as before
+- Edge case: spec with two positional params, one in path and one not (e.g., `seriesId` in `/tv/{seriesId}/season` and `query` not in path) -> first uses replacePathParam, second uses params map
+- Edge case: positional param name is substring of path segment (param `id` with path `/items/{itemId}`) -> should NOT match, should go to query params
+
+**Verification:**
+- Generate a CLI from a spec with search-style positional query params and verify the output code uses the params map
+- `go test ./internal/generator/...` passes
+
+- [ ] **Unit 2: Skip required check when default exists**
+
+**Goal:** Params with `required: true` AND a non-nil `.Default` should not emit the "required flag not set" guard.
+
+**Requirements:** R2
+
+**Dependencies:** None (can be done in parallel with Unit 1)
+
+**Files:**
+- Modify: `internal/generator/templates/command_endpoint.go.tmpl`
+- Test: `internal/generator/generator_test.go`
+
+**Approach:**
+- Change line 47 from `{{if and .Required (not .Positional)}}` to `{{if and .Required (not .Positional) (not .Default)}}`
+- Same change for body params on line 56-57: add `(not .Default)` to the condition
+
+**Patterns to follow:**
+- The existing `(not .Positional)` guard on the same line
+
+**Test scenarios:**
+- Happy path: param with `required: true, default: "day"` -> no required check emitted in generated code
+- Happy path: param with `required: true, default: nil` -> required check still emitted
+- Edge case: param with `required: false, default: "day"` -> no required check (not required)
+- Edge case: body param with `required: true, default: "json"` -> no required check for body params either
+
+**Verification:**
+- Generate a CLI from a spec with enum params that have defaults and verify the commands work without passing the flag
+- `go test ./internal/generator/...` passes
+
+- [ ] **Unit 3: Sanitize dots in SQLite column names**
+
+**Goal:** `toSnakeCase()` replaces dots with underscores so generated SQLite migrations use valid column names.
+
+**Requirements:** R3
+
+**Dependencies:** None (can be done in parallel with Units 1 and 2)
+
+**Files:**
+- Modify: `internal/generator/schema_builder.go`
+- Test: `internal/generator/schema_builder_test.go` (new file)
+
+**Approach:**
+- Add `s = strings.ReplaceAll(s, ".", "_")` as the first line of `toSnakeCase()`, before the hyphen replacement
+- This converts `primary_release_date.gte` -> `primary_release_date_gte` and `vote_average.gte` -> `vote_average_gte`
+
+**Patterns to follow:**
+- The existing `strings.ReplaceAll(s, "-", "_")` on line 363
+
+**Test scenarios:**
+- Happy path: `toSnakeCase("primary_release_date.gte")` -> `"primary_release_date_gte"`
+- Happy path: `toSnakeCase("vote_average.gte")` -> `"vote_average_gte"`
+- Happy path: `toSnakeCase("field.nested.deep")` -> `"field_nested_deep"`
+- Edge case: `toSnakeCase("movie_id")` -> `"movie_id"` (no dots, unchanged)
+- Edge case: `toSnakeCase("camelCase")` -> `"camel_case"` (existing behavior preserved)
+- Edge case: `toSnakeCase("kebab-case")` -> `"kebab_case"` (existing behavior preserved)
+- Edge case: `toSnakeCase("with.dots-and-hyphens")` -> `"with_dots_and_hyphens"`
+
+**Verification:**
+- `go test ./internal/generator/...` passes with new table-driven test cases
+- Generate a CLI from a spec with dot-notation params and verify the SQLite migration creates the table without errors
+
+## System-Wide Impact
+
+- **Interaction graph:** These are template-level fixes — they affect every future `printing-press generate` run. No runtime interaction changes.
+- **Error propagation:** Unit 1 fixes a silent failure (0 results instead of error). Units 2 and 3 fix loud failures (error messages, crashes) — both are better after the fix.
+- **Unchanged invariants:** The spec parser, OpenAPI parser, and all existing non-positional query param handling are unaffected. Path params that correctly appear in the path template still work as before.
+
+## Risks & Dependencies
+
+| Risk | Mitigation |
+|------|------------|
+| Unit 1 template change could affect non-search positional params | The `pathContainsParam` check is precise — only skips `replacePathParam` when the param name is NOT in the path. Existing path params are unaffected. |
+| Unit 3 dot replacement could collide with existing column names | Unlikely — `vote_average.gte` and `vote_average_gte` are semantically the same param. If a spec has both, the duplicate column would be caught by the `CREATE TABLE` statement. |
+
+## Sources & References
+
+- **Origin document:** [docs/retros/2026-04-11-movie-goat-retro.md](docs/retros/2026-04-11-movie-goat-retro.md) — findings F1, F2, F3
+- Related issue: [mvanhorn/cli-printing-press#171](https://github.com/mvanhorn/cli-printing-press/issues/171)
+- Template: `internal/generator/templates/command_endpoint.go.tmpl`
+- Schema builder: `internal/generator/schema_builder.go`
diff --git a/docs/retros/2026-04-11-movie-goat-retro.md b/docs/retros/2026-04-11-movie-goat-retro.md
new file mode 100644
index 00000000..7ccdc55f
--- /dev/null
+++ b/docs/retros/2026-04-11-movie-goat-retro.md
@@ -0,0 +1,221 @@
+# Printing Press Retro: movie-goat
+
+## Session Stats
+- API: TMDb + OMDb (multi-source movie CLI)
+- Spec source: Internal YAML (hand-written for TMDb API v3)
+- Scorecard: 87/100 (Grade A, after polish)
+- Verify pass rate: 100% (after polish)
+- Fix loops: 2 (shipcheck + polish)
+- Manual code edits: 12+
+- Features built from scratch: 7 transcendence commands + OMDb client + write-through caching + sync ceiling
+
+## Findings
+
+### F1. Positional query params treated as path params (bug)
+- **What happened:** Search endpoints like `/search/movie?query=Inception` had `query` defined as positional+required in the spec. The generator emitted `replacePathParam(path, "query", args[0])` which tries to substitute `{query}` in the URL path — but `/search/movie` has no `{query}` path segment. The query was silently dropped, returning 0 results.
+- **Scorer correct?** N/A — scorer doesn't check this. Live testing caught it.
+- **Root cause:** `command_endpoint.go.tmpl` line 77 — ALL positional params go through `replacePathParam()`. The template doesn't distinguish between path-segment positional params (like `{movieId}` in `/movie/{movieId}`) and query-string positional params (like `query` in `/search/movie?query=...`). The decision is made purely by `.Positional=true`, not by whether the param name appears in the path template.
+- **Cross-API check:** Every API with search endpoints has this pattern. TMDb, Stripe (`/search`), Algolia, Elasticsearch — any API where the primary search term is positional but sent as a query param.
+- **Frequency:** Most APIs with search endpoints.
+- **Fallback:** Claude catches this when the first search returns empty results, but only after a live test. If testing is skipped, the CLI ships broken.
+- **Worth a Printing Press fix?** Yes — this is a common pattern that fails silently.
+- **Inherent or fixable:** Fixable. The template should check whether the param name appears as `{paramName}` in the endpoint path before calling `replacePathParam`. If the param is positional but NOT in the path template, add it to the query params map instead.
+- **Durable fix:** In `command_endpoint.go.tmpl`, before emitting `replacePathParam` for a positional param, check `strings.Contains(path, "{"+paramName+"}")`. If false, emit `params[paramName] = args[N]` instead. This is a template-level fix — no spec changes needed.
+- **Test:** Positive: search endpoint with positional `query` param → query goes as `?query=...`. Negative: path endpoint with `{movieId}` positional → still uses `replacePathParam`.
+- **Evidence:** `movies search "Inception"` returned 0 results until manually fixed. Fixed in 4 files (movies_search, tv_search, people_search, search_multi).
+
+### F2. Required-flag check emitted despite default value (bug)
+- **What happened:** Trending command had `time-window` with `required: true` and `default: "day"`. The generator emitted `if !cmd.Flags().Changed("time-window") { return error }` even though the flag has a perfectly good default. Running `movie-goat-pp-cli trending` without `--time-window` errored, despite the flag already being "day".
+- **Scorer correct?** N/A — not a scoring issue, a usability bug.
+- **Root cause:** `command_endpoint.go.tmpl` lines 46-51. The required check tests `{{if and .Required (not .Positional)}}` but does NOT check `.Default`. Any required non-positional param gets the guard, even with a default.
+- **Cross-API check:** Any API with enum params that have sensible defaults. TMDb trending (`day`/`week`), Stripe API version headers, pagination params with defaults.
+- **Frequency:** Most APIs — enum params with defaults are extremely common.
+- **Fallback:** Claude or the user notices the error on first run and removes the check. Simple fix but annoying.
+- **Worth a Printing Press fix?** Yes — one-line template change eliminates a recurring manual fix.
+- **Inherent or fixable:** Fixable. Add `(not .Default)` to the condition.
+- **Durable fix:** Change template line to `{{if and .Required (not .Positional) (not .Default)}}`. Skip the required check when a default exists.
+- **Test:** Positive: param with `required: true, default: "day"` → no required check emitted. Negative: param with `required: true, default: nil` → required check still emitted.
+- **Evidence:** `trending` command required `--time-window` despite default. Fixed in 4 files (promoted_trending, trending_movies, trending_tv, trending_people).
+
+### F3. SQLite column names with dots are invalid SQL (bug)
+- **What happened:** The spec had params named `primary_release_date.gte` and `vote_average.gte` (TMDb's dot-notation filter syntax). The schema builder preserved the dots in column names, producing `CREATE TABLE discover (..., primary_release_date.gte TEXT, ...)` which is invalid SQL. The CLI crashed on first SQLite access.
+- **Scorer correct?** N/A — runtime crash, not a scoring issue.
+- **Root cause:** `schema_builder.go` `toSnakeCase()` function (lines 361-377) handles hyphens and camelCase but does NOT handle dots. `safeSQLName()` (lines 336-343) only quotes SQL reserved words, not identifiers with special characters.
+- **Cross-API check:** Any API with dot-notation params. TMDb (`vote_average.gte`), Elasticsearch (`field.nested`), GraphQL-derived specs, any API that uses dots for namespacing.
+- **Frequency:** API subclass: APIs with dot-notation filter params. Moderate frequency.
+- **Fallback:** Immediate crash on database access — Claude catches it, but only after testing.
+- **Worth a Printing Press fix?** Yes — silent crash from a naming convention is unacceptable.
+- **Inherent or fixable:** Fixable. Replace dots with underscores in `toSnakeCase()` or quote all identifiers in `safeSQLName()`.
+- **Durable fix:** In `toSnakeCase()`, add `s = strings.ReplaceAll(s, ".", "_")` before the existing transformations. This converts `primary_release_date.gte` → `primary_release_date_gte`. Alternative: wrap all column names in double-quotes in the SQL template, but replacing dots is cleaner.
+- **Test:** Positive: param `vote_average.gte` → column `vote_average_gte`. Negative: param `movie_id` (no dots) → unchanged.
+- **Evidence:** `Error: opening local database: migration failed: SQL logic error: near ".": syntax error`
+
+### F4. No write-through caching — local store only grows via explicit sync (template gap)
+- **What happened:** The `resolveRead` function in `data_source.go.tmpl` fetches from the live API and returns the result — but never writes it to the local SQLite store. The FTS search index only contains data from explicit `sync` runs. For a large-catalog API like TMDb (900K+ movies), running `sync` to populate the store is impractical. The user expected that browsing the CLI would naturally build up a searchable local corpus.
+- **Scorer correct?** N/A — not scored. This is a UX gap.
+- **Root cause:** `data_source.go.tmpl` lines 86-91 and 93-97 — the "live" and "auto" cases return data immediately without touching the store. The store is write-only from `sync.go`.
+- **Cross-API check:** This matters most for large-catalog APIs (TMDb, Spotify, Steam, npm) where sync-everything is infeasible. For small, bounded APIs (Linear, Cal.com), sync-everything works fine and write-through adds complexity without clear benefit.
+- **Frequency:** API subclass: large-catalog APIs where full sync is impractical. Growing as the Printing Press takes on more public catalog APIs.
+- **Fallback:** Claude builds it manually each time, but it's non-trivial: needs response envelope detection, ID extraction, SQLite upsert, and handling of both array and single-object responses. Took ~4 iterations in this session to get right (goroutine timing, envelope parsing, FTS indexing).
+- **Worth a Printing Press fix?** Yes, but conditionally. Write-through should be opt-in or auto-detected based on catalog size. For bounded APIs, it's unnecessary overhead. For large catalogs, it's essential UX.
+- **Inherent or fixable:** Fixable. The template already has the store import and `resolveRead` knows the resource type. Adding `store.Upsert()` after a successful live read is ~20 lines. The nuance is response envelope detection (TMDb wraps in `{"results": [...]}`), which the sync code already handles via `extractPageItems`.
+- **Durable fix:** Add a `writeThroughCache(resourceType, data)` function to `data_source.go.tmpl` that: (1) opens the store, (2) tries to extract items from common envelope patterns (`results`, `data`, `items`, or raw array), (3) upserts each item with its `id` field. Call it from `resolveRead` after successful live reads. Gate it behind a template flag (e.g., `WriteThrough bool` on the generator config) so bounded APIs can skip it.
+- **Test:** Positive: `movies search "Inception"` → results appear in `search --data-source local`. Negative: bounded API (Linear) → write-through disabled, sync is the only path.
+- **Evidence:** Required 4 iterations: async goroutine (process exited too early), envelope parsing (TMDb wraps in `results`), single-object handling, and search command FTS branch fix. All manual.
+
+### F5. Sync has no page ceiling — unbounded APIs can sync forever (template gap)
+- **What happened:** The `sync` command paginates through all available pages with no limit. For TMDb's `/movie/popular` (500+ pages, 10K+ movies), a `sync --full` would make thousands of API calls, take forever, and fill the local store with data the user doesn't need. We manually added `--max-pages 5` (default) to cap it.
+- **Scorer correct?** N/A — not scored.
+- **Root cause:** `sync.go.tmpl` has no concept of a page ceiling. The pagination loop runs until `hasMore=false` or `nextCursor=""`. This works for bounded APIs (fetch all my Linear issues) but is dangerous for public catalog APIs.
+- **Cross-API check:** Same pattern as F4 — large-catalog APIs. TMDb popular/trending, npm search, Spotify catalog, Steam game listings.
+- **Frequency:** API subclass: large-catalog public APIs. Same subclass as F4.
+- **Fallback:** Claude or the user notices the sync running forever and kills it. Then manually adds a page limit flag. Easy fix but should be a default.
+- **Worth a Printing Press fix?** Yes. A sensible default ceiling (e.g., 10 pages) with `--max-pages` override is trivial to add and prevents runaway syncs.
+- **Inherent or fixable:** Fixable. Add `--max-pages` flag to `sync.go.tmpl` with a default of 10. Check `pagesFetched >= maxPages` in the pagination loop.
+- **Durable fix:** In `sync.go.tmpl`: add `maxPages int` variable, `--max-pages` flag with default 10, page counter in the loop, break when ceiling is reached. Log "reached --max-pages limit (N pages, M items)" so the user knows it was capped, not empty.
+- **Test:** Positive: `sync --max-pages 3` → syncs exactly 3 pages. `sync --max-pages 0` → unlimited. Negative: bounded API sync → still completes normally (ceiling not reached).
+- **Evidence:** User said "Storing 11M movies sounds like a failure waiting to happen" and we added `--max-pages 5` manually.
+
+### F6. Search command hardcodes one search endpoint (template gap)
+- **What happened:** The generated `search` command (the promoted, user-facing one) hardcoded `GET /search/tv` as the live search endpoint instead of using the multi-search endpoint `/search/multi`. This meant the top-level `search` command only found TV shows, not movies or people.
+- **Scorer correct?** N/A — not scored.
+- **Root cause:** The generator picks a search endpoint for the promoted `search` command, but the heuristic picked `/search/tv` (the last search-type endpoint alphabetically?) instead of `/search/multi` which covers all types.
+- **Cross-API check:** Any API with multiple search endpoints where one is a superset. TMDb (`/search/multi` > `/search/movie`), GitHub (`/search/repositories` vs `/search/code` vs general search).
+- **Frequency:** API subclass: APIs with multiple search scopes.
+- **Fallback:** Claude notices during testing and changes the path. Simple one-line fix.
+- **Worth a Printing Press fix?** Medium. The generator should prefer multi/global search endpoints over resource-specific ones.
+- **Durable fix:** When selecting the promoted search endpoint, prefer endpoints with "multi" or "all" in the path. If none exist, prefer the endpoint that covers the most resource types.
+- **Test:** Positive: API with `/search/multi` and `/search/movie` → promoted search uses `/search/multi`.
+- **Evidence:** `search "Inception" --data-source local` returned nothing because live search was hitting `/search/tv` which doesn't return movies.
+
+### F7. Auth protocol scorer doesn't recognize query-param auth (scorer bug)
+- **What happened:** Scorecard auth_protocol scored 3/10. The scorer checked whether the client uses `Bearer` prefix in the Authorization header. TMDb v3 sends the API key as `?api_key=<key>` query parameter — a valid and documented auth method that the scorer doesn't recognize.
+- **Scorer correct?** No — scorer bug. Query-param API key auth is a legitimate auth pattern used by TMDb, OMDb, Google Maps, and many other APIs.
+- **Root cause:** The scorecard's auth_protocol dimension likely grep-checks for `Authorization` header or `Bearer` prefix. Query-param auth (`?api_key=`) is invisible to this check.
+- **Cross-API check:** TMDb, OMDb, Google Maps, YouTube Data API, many legacy REST APIs. Query-param auth is the second most common auth pattern after Bearer headers.
+- **Frequency:** API subclass: APIs using query-param auth. Moderate frequency.
+- **Fallback:** The score is cosmetic — the CLI works correctly. But it misleads users into thinking auth is broken.
+- **Worth a Printing Press fix?** Yes — fix the scorer, not the Printing Press. The scorer should recognize query-param auth as valid.
+- **Durable fix:** In the scorecard command, check for query-param auth patterns: `url.Values`, `q.Set("api_key"`, `q.Set("key"`, `params["apikey"]` in addition to `Authorization` header patterns.
+- **Test:** Positive: CLI with `q.Set("api_key", ...)` → auth_protocol PASS. Negative: CLI with no auth at all → auth_protocol FAIL.
+- **Evidence:** Scorecard auth_protocol 3/10 despite the CLI authenticating correctly against TMDb.
+
+### F8. No multi-API enrichment scaffolding (missing scaffolding)
+- **What happened:** The entire OMDb enrichment layer — client, config field, wiring into `movies get`, response merging — was built from scratch. The Printing Press has no concept of a secondary API that enriches the primary API's responses.
+- **Scorer correct?** N/A.
+- **Root cause:** The Printing Press assumes one API per CLI. The spec format has one `base_url`, one `auth` block. There's no way to declare "primary API: TMDb, enrichment API: OMDb" in the spec.
+- **Cross-API check:** Multi-source CLIs are rare but growing: movie CLIs (TMDb + OMDb), travel CLIs (Google Flights + Kayak), shopping CLIs (price comparison). Also relevant: any CLI that cross-references with a public data source.
+- **Frequency:** API subclass: multi-source CLIs. Low frequency currently, but the "movie-goat" pattern suggests this will become a differentiator.
+- **Fallback:** Claude builds it by hand each time. The OMDb client was ~50 lines. The wiring was another ~30. Not terrible, but error-prone (auth, response merging, graceful degradation).
+- **Worth a Printing Press fix?** P3 — interesting but low frequency. The complexity of supporting arbitrary secondary APIs in the spec format is high relative to the frequency.
+- **Inherent or fixable:** Partially fixable. A lightweight approach: support an `enrichment` block in the spec with a second `base_url` and `auth`, plus field-mapping rules. Heavy approach: full multi-API spec support. The lightweight approach covers the 80% case.
+- **Durable fix:** Skip for now. Note as a future direction. The hand-built approach works well enough for the few CLIs that need it.
+- **Test:** N/A — future feature.
+- **Evidence:** Built `internal/omdb/client.go` and wired it into `movies_get.go` entirely by hand.
+
+## Prioritized Improvements
+
+### P1 — High priority
+| Finding | Title | Component | Frequency | Fallback Reliability | Complexity | Guards |
+|---------|-------|-----------|-----------|---------------------|------------|--------|
+| F1 | Positional query params treated as path params | Generator template `command_endpoint.go.tmpl` | Most APIs with search | Low — silent failure, 0 results | Small | Check if param name appears in path template |
+| F2 | Required check ignores default value | Generator template `command_endpoint.go.tmpl` | Most APIs | Medium — easy to fix but annoying | Small | Add `(not .Default)` to condition |
+| F3 | SQLite column names with dots invalid | `schema_builder.go` `toSnakeCase()` | APIs with dot-notation params | Low — immediate crash | Small | Replace dots with underscores |
+
+### P2 — Medium priority
+| Finding | Title | Component | Frequency | Fallback Reliability | Complexity | Guards |
+|---------|-------|-----------|-----------|---------------------|------------|--------|
+| F4 | No write-through caching | Generator template `data_source.go.tmpl` | Large-catalog APIs | Low — 4 iterations to get right manually | Medium | Gate behind config flag for large catalogs |
+| F5 | Sync no page ceiling | Generator template `sync.go.tmpl` | Large-catalog APIs | Medium — user kills it, adds flag | Small | Add `--max-pages` with default 10 |
+| F7 | Scorer doesn't recognize query-param auth | Scorecard command | APIs with query-param auth | N/A — cosmetic but misleading | Small | Check for `q.Set("api_key"` patterns |
+
+### P3 — Low priority
+| Finding | Title | Component | Frequency | Fallback Reliability | Complexity | Guards |
+|---------|-------|-----------|-----------|---------------------|------------|--------|
+| F6 | Search command picks wrong endpoint | Generator promoted command selection | APIs with multiple search scopes | High — one-line fix | Small | Prefer "multi"/"all" endpoints |
+| F8 | No multi-API enrichment scaffolding | Spec format + generator | Multi-source CLIs | Medium — ~80 lines manual | Large | Future direction |
+
+### Skip
+| Finding | Title | Why unlikely to recur |
+|---------|-------|----------------------|
+| Genre-bridge recommendation algorithm | Custom to movie recommendation use case | Genuinely domain-specific, not a template pattern |
+| Async goroutine write-through timing | Implementation detail | Synchronous write-through is the correct default |
+
+## Work Units
+
+### WU-1: Fix positional query param emission (from F1)
+- **Goal:** Positional params that don't appear in the path template should be emitted as query params, not path substitutions
+- **Target:** `internal/generator/templates/command_endpoint.go.tmpl` around line 77
+- **Acceptance criteria:**
+  - Positive: spec with `query` positional param on `/search/movie` path → emits `params["query"] = args[0]`
+  - Negative: spec with `movieId` positional param on `/movie/{movieId}` path → still emits `replacePathParam`
+- **Scope boundary:** Does not change how the spec parser classifies params. Template-only fix.
+- **Dependencies:** None
+- **Complexity:** Small
+
+### WU-2: Skip required check when default exists (from F2)
+- **Goal:** Params with `required: true` AND a non-nil `default` should not emit the "required flag not set" guard
+- **Target:** `internal/generator/templates/command_endpoint.go.tmpl` lines 46-51
+- **Acceptance criteria:**
+  - Positive: `required: true, default: "day"` → no required check emitted
+  - Negative: `required: true, default: nil` → required check still emitted
+- **Scope boundary:** Does not change how defaults are applied to flags (that already works).
+- **Dependencies:** None
+- **Complexity:** Small
+
+### WU-3: Sanitize dots in SQLite column names (from F3)
+- **Goal:** Column names derived from params with dots should produce valid SQL
+- **Target:** `internal/generator/schema_builder.go` `toSnakeCase()` function (line 361+)
+- **Acceptance criteria:**
+  - Positive: param `vote_average.gte` → column `vote_average_gte`
+  - Negative: param `movie_id` → column `movie_id` (unchanged)
+  - Negative: param `field.nested.deep` → column `field_nested_deep`
+- **Scope boundary:** Only affects SQLite column name generation. Does not change flag names or API param names.
+- **Dependencies:** None
+- **Complexity:** Small
+
+### WU-4: Add write-through caching to data source template (from F4)
+- **Goal:** Live API responses are automatically upserted into the local SQLite store so FTS search grows organically with usage
+- **Target:** `internal/generator/templates/data_source.go.tmpl`
+- **Acceptance criteria:**
+  - Positive: after a live `movies search "X"`, `search "X" --data-source local` finds results
+  - Positive: handles TMDb-style `{"results": [...]}` envelope AND raw arrays AND single objects
+  - Negative: bounded-API CLIs (Linear, Cal.com) don't get write-through unless opted in
+- **Scope boundary:** Does not change sync behavior. Write-through is additive, not a replacement for sync.
+- **Dependencies:** WU-3 (column names must be valid for the store to work)
+- **Complexity:** Medium
+
+### WU-5: Add sync page ceiling (from F5)
+- **Goal:** Sync command has a `--max-pages` flag with a sensible default to prevent runaway pagination on large-catalog APIs
+- **Target:** `internal/generator/templates/sync.go.tmpl`
+- **Acceptance criteria:**
+  - Positive: `sync --max-pages 3` → fetches exactly 3 pages per resource
+  - Positive: `sync --max-pages 0` → unlimited (backwards compatible)
+  - Positive: logs "reached --max-pages limit (N pages, M items)" when ceiling is hit
+- **Scope boundary:** Does not change pagination logic or sync state management.
+- **Dependencies:** None
+- **Complexity:** Small
+
+### WU-6: Fix scorecard auth_protocol for query-param auth (from F7)
+- **Goal:** Scorecard recognizes query-param API key auth as a valid auth pattern
+- **Target:** Scorecard command (likely `internal/cli/scorecard.go` or scoring helper)
+- **Acceptance criteria:**
+  - Positive: CLI with `q.Set("api_key", config.ApiKey)` → auth_protocol scores >= 8/10
+  - Negative: CLI with no auth code at all → auth_protocol still fails
+- **Scope boundary:** Scorer only. Does not change how the generator emits auth code.
+- **Dependencies:** None
+- **Complexity:** Small
+
+## Anti-patterns
+- **Don't assume all positional params are path segments.** The path template is the source of truth for where params go, not the `.Positional` flag.
+- **Don't emit guards that contradict defaults.** If a param has a default, it's never "not set."
+- **Don't trust user-facing names as valid SQL identifiers.** API param names can contain dots, slashes, brackets — always sanitize before using as column names.
+- **Don't assume sync-everything is safe.** The generator should have a concept of catalog size and adjust sync defaults accordingly.
+
+## What the Printing Press Got Right
+- **Quality gates passed first try.** All 7 gates (go mod tidy, go vet, go build, binary, --help, version, doctor) passed on the initial generation. No compilation errors from the generator itself.
+- **The command tree is excellent.** 23 commands covering search, discover, trending, genres, people, TV — all correctly structured with flags, examples, and output modes. The `--json`, `--select`, `--compact`, `--agent` flags work on every command.
+- **The data source layer is well-designed.** The `auto/live/local` dispatch with provenance metadata is a strong pattern. Write-through was additive, not a replacement.
+- **The MCP server was generated automatically.** 25 tools, stdio transport, ready to use. No manual work.
+- **Scorecard 87/100 after polish.** Strong baseline that only needed 6 transcendence commands and 2 infrastructure fixes (auth, write-through) to be publish-ready.
+- **The absorb gate works.** Researching 12 tools and cataloging 24 features ensured nothing was missed from the competitive landscape.
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index d4d14c18..f6cf6ca9 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -164,6 +164,9 @@ func New(s *spec.APISpec, outputDir string) *Generator {
 		},
 		"envName":  func(s string) string { return strings.ToUpper(strings.ReplaceAll(s, "-", "_")) },
 		"safeName": safeSQLName,
+		"pathContainsParam": func(path, name string) bool {
+			return strings.Contains(path, "{"+name+"}")
+		},
 		"safeJoin": func(fields []string, sep string) string {
 			safe := make([]string, len(fields))
 			for i, f := range fields {
diff --git a/internal/generator/schema_builder.go b/internal/generator/schema_builder.go
index 1341b089..7d525a73 100644
--- a/internal/generator/schema_builder.go
+++ b/internal/generator/schema_builder.go
@@ -360,6 +360,7 @@ func allFieldsAreColumns(fields []string, columns []ColumnDef) bool {
 
 // toSnakeCase converts camelCase, PascalCase, or kebab-case to snake_case.
 func toSnakeCase(s string) string {
+	s = strings.ReplaceAll(s, ".", "_")
 	s = strings.ReplaceAll(s, "-", "_")
 
 	var result strings.Builder
diff --git a/internal/generator/schema_builder_test.go b/internal/generator/schema_builder_test.go
new file mode 100644
index 00000000..615109da
--- /dev/null
+++ b/internal/generator/schema_builder_test.go
@@ -0,0 +1,37 @@
+package generator
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestToSnakeCase(t *testing.T) {
+	tests := []struct {
+		input    string
+		expected string
+	}{
+		{"camelCase", "camel_case"},
+		{"kebab-case", "kebab_case"},
+		{"snake_case", "snake_case"},
+		{"PascalCase", "pascal_case"},
+		{"movie_id", "movie_id"},
+		// Dot-notation params (TMDb, Elasticsearch style)
+		{"primary_release_date.gte", "primary_release_date_gte"},
+		{"vote_average.gte", "vote_average_gte"},
+		{"vote_average.lte", "vote_average_lte"},
+		{"vote_count.gte", "vote_count_gte"},
+		{"field.nested.deep", "field_nested_deep"},
+		// Combined dots and hyphens
+		{"with.dots-and-hyphens", "with_dots_and_hyphens"},
+		// No transformation needed
+		{"simple", "simple"},
+		{"", ""},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.input, func(t *testing.T) {
+			assert.Equal(t, tt.expected, toSnakeCase(tt.input))
+		})
+	}
+}
diff --git a/internal/generator/templates/command_endpoint.go.tmpl b/internal/generator/templates/command_endpoint.go.tmpl
index 80999af9..d0e95fd6 100644
--- a/internal/generator/templates/command_endpoint.go.tmpl
+++ b/internal/generator/templates/command_endpoint.go.tmpl
@@ -44,7 +44,7 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
 			}
 {{- end}}
 {{- range .Endpoint.Params}}
-{{- if and .Required (not .Positional)}}
+{{- if and .Required (not .Positional) (not .Default)}}
 			if !cmd.Flags().Changed("{{flagName .Name}}") && !flags.dryRun {
 				return fmt.Errorf("required flag \"%s\" not set", "{{flagName .Name}}")
 			}
@@ -53,7 +53,7 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
 {{- if or (eq .Endpoint.Method "POST") (eq .Endpoint.Method "PUT") (eq .Endpoint.Method "PATCH")}}
 			if !stdinBody {
 {{- range .Endpoint.Body}}
-{{- if .Required}}
+{{- if and .Required (not .Default)}}
 				if !cmd.Flags().Changed("{{flagName .Name}}") && !flags.dryRun {
 					return fmt.Errorf("required flag \"%s\" not set", "{{flagName .Name}}")
 				}
@@ -74,9 +74,11 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
 				return usageErr(fmt.Errorf("{{.Name}} is required\nUsage: %s %s <%s>", cmd.Root().Name(), cmd.CommandPath(), "{{.Name}}"))
 			}
 {{- end}}
+{{- if pathContainsParam $.Endpoint.Path .Name}}
 			path = replacePathParam(path, "{{.Name}}", args[{{$i}}])
 {{- end}}
 {{- end}}
+{{- end}}
 {{- range .Endpoint.Params}}
 {{- if and .PathParam (not .Positional)}}
 			path = replacePathParam(path, "{{.Name}}", fmt.Sprintf("%v", flag{{camel .Name}}))
@@ -95,7 +97,10 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
 {{- if .Endpoint.Pagination}}
 {{- if .HasStore}}
 			data, prov, err := resolvePaginatedRead(c, flags, "{{lower .ResourceName}}", path, map[string]string{
-{{- range .Endpoint.Params}}
+{{- range $i, $p := .Endpoint.Params}}
+{{- if and .Positional (not (pathContainsParam $.Endpoint.Path .Name))}}
+				"{{.Name}}": args[{{$i}}],
+{{- end}}
 {{- if and (not .Positional) (not .PathParam)}}
 				"{{.Name}}": fmt.Sprintf("%v", flag{{camel .Name}}),
 {{- end}}
@@ -103,7 +108,10 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
 			}, flagAll, "{{.Endpoint.Pagination.CursorParam}}", "{{.Endpoint.Pagination.NextCursorPath}}", "{{.Endpoint.Pagination.HasMoreField}}")
 {{- else}}
 			data, err := paginatedGet(c, path, map[string]string{
-{{- range .Endpoint.Params}}
+{{- range $i, $p := .Endpoint.Params}}
+{{- if and .Positional (not (pathContainsParam $.Endpoint.Path .Name))}}
+				"{{.Name}}": args[{{$i}}],
+{{- end}}
 {{- if and (not .Positional) (not .PathParam)}}
 				"{{.Name}}": fmt.Sprintf("%v", flag{{camel .Name}}),
 {{- end}}
@@ -112,7 +120,10 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
 {{- end}}
 {{- else}}
 			params := map[string]string{}
-{{- range .Endpoint.Params}}
+{{- range $i, $p := .Endpoint.Params}}
+{{- if and .Positional (not (pathContainsParam $.Endpoint.Path .Name))}}
+			params["{{.Name}}"] = args[{{$i}}]
+{{- end}}
 {{- if and (not .Positional) (not .PathParam)}}
 			if flag{{camel .Name}} != {{zeroValForParam .Name .Type}} {
 				params["{{.Name}}"] = fmt.Sprintf("%v", flag{{camel .Name}})
diff --git a/internal/generator/templates/command_promoted.go.tmpl b/internal/generator/templates/command_promoted.go.tmpl
index ee5fe358..11114fa6 100644
--- a/internal/generator/templates/command_promoted.go.tmpl
+++ b/internal/generator/templates/command_promoted.go.tmpl
@@ -28,7 +28,7 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
 		Example: "  {{modulePath}} {{.PromotedName}}",
 		RunE: func(cmd *cobra.Command, args []string) error {
 {{- range .Endpoint.Params}}
-{{- if and .Required (not .Positional)}}
+{{- if and .Required (not .Positional) (not .Default)}}
 			if !cmd.Flags().Changed("{{flagName .Name}}") && !flags.dryRun {
 				return fmt.Errorf("required flag \"%s\" not set", "{{flagName .Name}}")
 			}
@@ -45,14 +45,19 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
 			if len(args) < {{add $i 1}} {
 				return usageErr(fmt.Errorf("{{.Name}} is required\nUsage: %s %s <%s>", cmd.Root().Name(), cmd.CommandPath(), "{{.Name}}"))
 			}
+{{- if pathContainsParam $.Endpoint.Path .Name}}
 			path = replacePathParam(path, "{{.Name}}", args[{{$i}}])
 {{- end}}
 {{- end}}
+{{- end}}
 
 {{- if .Endpoint.Pagination}}
 {{- if .HasStore}}
 			data, prov, err := resolvePaginatedRead(c, flags, "{{lower .ResourceName}}", path, map[string]string{
-{{- range .Endpoint.Params}}
+{{- range $i, $p := .Endpoint.Params}}
+{{- if and .Positional (not (pathContainsParam $.Endpoint.Path .Name))}}
+				"{{.Name}}": args[{{$i}}],
+{{- end}}
 {{- if not .Positional}}
 				"{{.Name}}": fmt.Sprintf("%v", flag{{camel .Name}}),
 {{- end}}
@@ -60,7 +65,10 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
 			}, flagAll, "{{.Endpoint.Pagination.CursorParam}}", "{{.Endpoint.Pagination.NextCursorPath}}", "{{.Endpoint.Pagination.HasMoreField}}")
 {{- else}}
 			data, err := paginatedGet(c, path, map[string]string{
-{{- range .Endpoint.Params}}
+{{- range $i, $p := .Endpoint.Params}}
+{{- if and .Positional (not (pathContainsParam $.Endpoint.Path .Name))}}
+				"{{.Name}}": args[{{$i}}],
+{{- end}}
 {{- if not .Positional}}
 				"{{.Name}}": fmt.Sprintf("%v", flag{{camel .Name}}),
 {{- end}}
@@ -69,7 +77,10 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
 {{- end}}
 {{- else}}
 			params := map[string]string{}
-{{- range .Endpoint.Params}}
+{{- range $i, $p := .Endpoint.Params}}
+{{- if and .Positional (not (pathContainsParam $.Endpoint.Path .Name))}}
+			params["{{.Name}}"] = args[{{$i}}]
+{{- end}}
 {{- if not .Positional}}
 			if flag{{camel .Name}} != {{zeroVal .Type}} {
 				params["{{.Name}}"] = fmt.Sprintf("%v", flag{{camel .Name}})
diff --git a/internal/generator/templates/data_source.go.tmpl b/internal/generator/templates/data_source.go.tmpl
index f557e8de..74d1ae11 100644
--- a/internal/generator/templates/data_source.go.tmpl
+++ b/internal/generator/templates/data_source.go.tmpl
@@ -88,11 +88,13 @@ func resolveRead(c *client.Client, flags *rootFlags, resourceType string, isList
 		if err != nil {
 			return nil, DataProvenance{}, err
 		}
+		writeThroughCache(resourceType, data)
 		return data, DataProvenance{Source: "live"}, nil
 
 	default: // "auto"
 		data, err := c.Get(path, params)
 		if err == nil {
+			writeThroughCache(resourceType, data)
 			return data, DataProvenance{Source: "live"}, nil
 		}
 		if !isNetworkError(err) {
@@ -120,11 +122,13 @@ func resolvePaginatedRead(c *client.Client, flags *rootFlags, resourceType strin
 		if err != nil {
 			return nil, DataProvenance{}, err
 		}
+		writeThroughCache(resourceType, data)
 		return data, DataProvenance{Source: "live"}, nil
 
 	default: // "auto"
 		data, err := paginatedGet(c, path, params, fetchAll, cursorParam, nextCursorPath, hasMoreField)
 		if err == nil {
+			writeThroughCache(resourceType, data)
 			return data, DataProvenance{Source: "live"}, nil
 		}
 		if !isNetworkError(err) {
@@ -138,6 +142,58 @@ func resolvePaginatedRead(c *client.Client, flags *rootFlags, resourceType strin
 	}
 }
 
+// writeThroughCache upserts live API results into the local SQLite store so
+// FTS search covers everything the user has looked up — not just explicit syncs.
+// Best-effort: failures are silently ignored (the live result already succeeded).
+func writeThroughCache(resourceType string, data json.RawMessage) {
+	db, err := store.Open(defaultDBPath("{{.Name}}-pp-cli"))
+	if err != nil {
+		return
+	}
+	defer db.Close()
+
+	// Collect items to upsert from various response shapes
+	var items []json.RawMessage
+
+	// Try direct array first
+	if json.Unmarshal(data, &items) != nil || len(items) == 0 {
+		items = nil
+		// Try object — check for common envelope patterns (results, data, items)
+		var envelope map[string]json.RawMessage
+		if json.Unmarshal(data, &envelope) == nil {
+			for _, key := range []string{"results", "data", "items"} {
+				if raw, ok := envelope[key]; ok {
+					var arr []json.RawMessage
+					if json.Unmarshal(raw, &arr) == nil && len(arr) > 0 {
+						items = arr
+						break
+					}
+				}
+			}
+			// Single object with an id field (e.g., detail response)
+			if items == nil {
+				if idRaw, ok := envelope["id"]; ok {
+					id := strings.Trim(string(idRaw), "\"")
+					_ = db.Upsert(resourceType, id, data)
+					return
+				}
+			}
+		}
+	}
+
+	// Upsert each item individually
+	for _, item := range items {
+		var obj map[string]json.RawMessage
+		if json.Unmarshal(item, &obj) != nil {
+			continue
+		}
+		if idRaw, ok := obj["id"]; ok {
+			id := strings.Trim(string(idRaw), "\"")
+			_ = db.Upsert(resourceType, id, item)
+		}
+	}
+}
+
 // resolveLocal reads data from the local SQLite store.
 // Note: local reads return ALL synced data for the resource type. Endpoint-specific
 // filters (query params, path scoping like /teams/{id}/users) are NOT applied locally.
diff --git a/internal/generator/templates/sync.go.tmpl b/internal/generator/templates/sync.go.tmpl
index 7cf43088..2c84209d 100644
--- a/internal/generator/templates/sync.go.tmpl
+++ b/internal/generator/templates/sync.go.tmpl
@@ -32,6 +32,7 @@ func newSyncCmd(flags *rootFlags) *cobra.Command {
 	var since string
 	var concurrency int
 	var dbPath string
+	var maxPages int
 {{- if .Pagination.DateRangeParam}}
 	var dates string
 {{- end}}
@@ -119,7 +120,7 @@ Once synced, use the 'search' command for instant full-text search.`,
 				go func() {
 					defer wg.Done()
 					for resource := range work {
-						res := syncResource(c, db, resource, sinceTS, full{{if .Pagination.DateRangeParam}}, syncDates{{end}})
+						res := syncResource(c, db, resource, sinceTS, full, maxPages{{if .Pagination.DateRangeParam}}, syncDates{{end}})
 						results <- res
 					}
 				}()
@@ -169,6 +170,7 @@ Once synced, use the 'search' command for instant full-text search.`,
 	cmd.Flags().StringVar(&since, "since", "", "Incremental sync duration (e.g. 7d, 24h, 1w, 30m)")
 	cmd.Flags().IntVar(&concurrency, "concurrency", 4, "Number of parallel sync workers")
 	cmd.Flags().StringVar(&dbPath, "db", "", "Database path (default: ~/.local/share/{{.Name}}-pp-cli/data.db)")
+	cmd.Flags().IntVar(&maxPages, "max-pages", 10, "Maximum pages to fetch per resource (0 = unlimited)")
 {{- if .Pagination.DateRangeParam}}
 	cmd.Flags().StringVar(&dates, "dates", "", "Date or date range to sync (passed as {{.Pagination.DateRangeParam}} query parameter)")
 {{- end}}
@@ -181,7 +183,7 @@ Once synced, use the 'search' command for instant full-text search.`,
 func syncResource(c interface {
 	Get(string, map[string]string) (json.RawMessage, error)
 	RateLimit() float64
-}, db *store.Store, resource, sinceTS string, full bool{{if .Pagination.DateRangeParam}}, dates string{{end}}) syncResult {
+}, db *store.Store, resource, sinceTS string, full bool, maxPages int{{if .Pagination.DateRangeParam}}, dates string{{end}}) syncResult {
 	started := time.Now()
 
 	if !humanFriendly {
@@ -207,6 +209,7 @@ func syncResource(c interface {
 	pageSize := determinePaginationDefaults()
 
 	var progressCount int64
+	pagesFetched := 0
 
 	for {
 		params := map[string]string{}
@@ -288,6 +291,16 @@ func syncResource(c interface {
 			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
+		if maxPages > 0 && pagesFetched >= maxPages {
+			if humanFriendly {
+				fmt.Fprintf(os.Stderr, "\n  %s: reached --max-pages limit (%d pages, %d items)\n", resource, maxPages, totalCount)
+			}
+			break
+		}
+
 		// Determine if there are more pages
 		if !hasMore || len(items) < pageSize.limit || nextCursor == "" {
 			break
diff --git a/internal/pipeline/lock_test.go b/internal/pipeline/lock_test.go
index b3d9ca54..f31cce8f 100644
--- a/internal/pipeline/lock_test.go
+++ b/internal/pipeline/lock_test.go
@@ -412,8 +412,8 @@ func TestPromoteWorkingCLI_MinimalStateNoRunstate(t *testing.T) {
 	err := PromoteWorkingCLI("test-pp-cli", workDir, state)
 	require.NoError(t, err)
 
-	// Verify library dir exists with copied content.
-	libDir := filepath.Join(PublishedLibraryRoot(), "test-pp-cli")
+	// Verify library dir exists with copied content (slug-keyed, not CLI-named).
+	libDir := filepath.Join(PublishedLibraryRoot(), "test")
 	_, err = os.Stat(filepath.Join(libDir, "go.mod"))
 	assert.NoError(t, err)
 	_, err = os.Stat(filepath.Join(libDir, "main.go"))
diff --git a/internal/pipeline/scorecard.go b/internal/pipeline/scorecard.go
index 2dca43c5..2c12ccb6 100644
--- a/internal/pipeline/scorecard.go
+++ b/internal/pipeline/scorecard.go
@@ -1155,6 +1155,16 @@ func evaluateAuthProtocol(dir string, spec *openAPISpecInfo) dimensionScore {
 		if strings.Contains(clientContent, "Authorization") || strings.Contains(clientContent, "X-Api-Key") || strings.Contains(clientContent, "X-Auth-Token") || strings.Contains(clientContent, "X-Access-Token") {
 			score += 3 // client sends auth header (standard or custom)
 		}
+		// Query-param auth (e.g., TMDb ?api_key=, Google Maps ?key=):
+		// the client adds the API key to the URL query string instead of a header.
+		if strings.Contains(clientContent, `q.Set("api_key"`) ||
+			strings.Contains(clientContent, `q.Set("key"`) ||
+			strings.Contains(clientContent, `q.Set("apikey"`) ||
+			strings.Contains(clientContent, `q.Set("apiKey"`) ||
+			strings.Contains(clientContent, `params["api_key"]`) ||
+			strings.Contains(clientContent, `params["apikey"]`) {
+			score += 3 // client sends auth via query param
+		}
 		return dimensionScore{scored: true, score: score}
 	}
 	authContent := readFileContent(filepath.Join(dir, "internal", "cli", "auth.go"))

← ebd1d048 chore(skills): remove duplicate version fields from marketpl  ·  back to Cli Printing Press  ·  fix(cli): default empty version to 1.0.0 and normalize to se f07a795d →