← back to Cli Printing Press
feat(cli): add spec-driven tier routing (#570)
5bc73909b66eaf2c38bdb1970ff37d8067c617f1 · 2026-05-04 00:34:30 -0700 · Trevin Chow
Files touched
M docs/SPEC-EXTENSIONS.mdA docs/plans/2026-05-04-001-feat-tier-routing-plan.mdM internal/authdoctor/classify.goM internal/authdoctor/classify_test.goM internal/generator/generator.goM internal/generator/generator_test.goM internal/generator/templates/auto_refresh.go.tmplM internal/generator/templates/client.go.tmplM internal/generator/templates/command_endpoint.go.tmplM internal/generator/templates/command_promoted.go.tmplM internal/generator/templates/doctor.go.tmplM internal/generator/templates/mcp_code_orch.go.tmplM internal/generator/templates/mcp_intents.go.tmplM internal/generator/templates/mcp_tools.go.tmplM internal/generator/templates/sync.go.tmplA internal/generator/tier_routing_test.goM internal/openapi/parser.goM internal/openapi/parser_test.goM internal/pipeline/climanifest.goM internal/pipeline/climanifest_test.goM internal/pipeline/toolsmanifest.goM internal/pipeline/toolsmanifest_test.goM internal/profiler/profiler.goM internal/spec/spec.goM internal/spec/spec_test.goM skills/printing-press/SKILL.mdA testdata/golden/cases/generate-tier-routing-api/artifacts.txtA testdata/golden/cases/generate-tier-routing-api/command.txtM testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/client/client.goA testdata/golden/expected/generate-tier-routing-api/exit.txtA testdata/golden/expected/generate-tier-routing-api/stderr.txtA testdata/golden/expected/generate-tier-routing-api/stdout.txtA testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/.printing-press.jsonA testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/doctor.goA testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/items_enterprise.goA testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/items_list.goA testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/items_premium.goA testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/sync.goA testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/client/client.goA testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/mcp/tools.goA testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/manifest.jsonA testdata/golden/fixtures/tier-routing-api.yaml
Diff
commit 5bc73909b66eaf2c38bdb1970ff37d8067c617f1
Author: Trevin Chow <trevin@trevinchow.com>
Date: Mon May 4 00:34:30 2026 -0700
feat(cli): add spec-driven tier routing (#570)
---
docs/SPEC-EXTENSIONS.md | 72 ++
.../plans/2026-05-04-001-feat-tier-routing-plan.md | 592 ++++++++++++++
internal/authdoctor/classify.go | 50 +-
internal/authdoctor/classify_test.go | 29 +
internal/generator/generator.go | 117 ++-
internal/generator/generator_test.go | 8 +-
internal/generator/templates/auto_refresh.go.tmpl | 2 +-
internal/generator/templates/client.go.tmpl | 236 +++++-
.../generator/templates/command_endpoint.go.tmpl | 3 +
.../generator/templates/command_promoted.go.tmpl | 3 +
internal/generator/templates/doctor.go.tmpl | 31 +-
internal/generator/templates/mcp_code_orch.go.tmpl | 13 +
internal/generator/templates/mcp_intents.go.tmpl | 10 +-
internal/generator/templates/mcp_tools.go.tmpl | 49 +-
internal/generator/templates/sync.go.tmpl | 35 +-
internal/generator/tier_routing_test.go | 172 ++++
internal/openapi/parser.go | 101 ++-
internal/openapi/parser_test.go | 156 ++++
internal/pipeline/climanifest.go | 33 +-
internal/pipeline/climanifest_test.go | 31 +
internal/pipeline/toolsmanifest.go | 207 +++--
internal/pipeline/toolsmanifest_test.go | 93 +++
internal/profiler/profiler.go | 29 +-
internal/spec/spec.go | 313 +++++++-
internal/spec/spec_test.go | 282 +++++++
skills/printing-press/SKILL.md | 59 ++
.../cases/generate-tier-routing-api/artifacts.txt | 9 +
.../cases/generate-tier-routing-api/command.txt | 2 +
.../internal/client/client.go | 6 +-
.../expected/generate-tier-routing-api/exit.txt | 1 +
.../expected/generate-tier-routing-api/stderr.txt | 1 +
.../expected/generate-tier-routing-api/stdout.txt | 0
.../tier-routing-golden/.printing-press.json | 24 +
.../tier-routing-golden/internal/cli/doctor.go | 473 +++++++++++
.../internal/cli/items_enterprise.go | 74 ++
.../tier-routing-golden/internal/cli/items_list.go | 76 ++
.../internal/cli/items_premium.go | 74 ++
.../tier-routing-golden/internal/cli/sync.go | 876 +++++++++++++++++++++
.../tier-routing-golden/internal/client/client.go | 550 +++++++++++++
.../tier-routing-golden/internal/mcp/tools.go | 318 ++++++++
.../tier-routing-golden/manifest.json | 55 ++
testdata/golden/fixtures/tier-routing-api.yaml | 49 ++
42 files changed, 5151 insertions(+), 163 deletions(-)
diff --git a/docs/SPEC-EXTENSIONS.md b/docs/SPEC-EXTENSIONS.md
index b32a0fd7..4a54c31e 100644
--- a/docs/SPEC-EXTENSIONS.md
+++ b/docs/SPEC-EXTENSIONS.md
@@ -15,6 +15,7 @@ in the same change as any new `Extensions["x-*"]` lookup in that file.
| `x-display-name` | `info` | `APISpec.DisplayName` | No |
| `x-website` | `info` | `APISpec.WebsiteURL` | No |
| `x-proxy-routes` | `info` | `APISpec.ProxyRoutes` | No |
+| `x-tier-routing` | root or `info` | `APISpec.TierRouting` | No |
| `x-auth-type` | `components.securitySchemes.<name>` | `APISpec.Auth.Type` | No |
| `x-auth-format` | `components.securitySchemes.<name>` | `APISpec.Auth.Format` | No |
| `x-prefix` | `components.securitySchemes.<name>` | `APISpec.Auth.Format` | No |
@@ -27,6 +28,7 @@ in the same change as any new `Extensions["x-*"]` lookup in that file.
| `x-auth-cookies` | `components.securitySchemes.<name>` | `APISpec.Auth.Cookies` | No |
| `x-resource-id` | path item | `Endpoint.IDField` | No |
| `x-critical` | path item | `Endpoint.Critical` | No |
+| `x-tier` | path item or operation | `Endpoint.Tier` | No |
## `info` Extensions
@@ -127,6 +129,46 @@ info:
/v1/publish: publishing
```
+### `x-tier-routing`
+
+Declares opt-in free/paid credential routing for APIs where some endpoints work
+without credentials and other endpoints require a separate paid key or token.
+
+Parsed field: `APISpec.TierRouting`
+
+Rules:
+- Optional.
+- May be declared at the OpenAPI root or under `info`.
+- Requires a `tiers` map when present.
+- `default_tier` is optional; endpoints without `x-tier` use global auth when it
+ is absent.
+- V1 tier auth supports only `none`, `api_key`, and `bearer_token`.
+- Credential-bearing tier `base_url` values must be HTTPS and cannot point at
+ loopback, private, link-local, or unrelated hosts unless
+ `allow_cross_host_auth: true` documents explicit review.
+- Incompatible with `client_pattern: proxy-envelope` and with resource-level
+ `base_url` overrides when any tier declares its own `base_url`.
+- Tier credential env vars are read from the environment at request time; they
+ are not serialized into generated config files.
+
+Example:
+
+```yaml
+x-tier-routing:
+ default_tier: free
+ tiers:
+ free:
+ auth:
+ type: none
+ paid:
+ base_url: https://paid.api.example.com
+ auth:
+ type: api_key
+ in: query
+ header: api_key
+ env_vars: [EXAMPLE_PAID_KEY]
+```
+
## Security Scheme Extensions
Security scheme extensions are read from
@@ -372,3 +414,33 @@ paths:
"200":
description: OK
```
+
+### `x-tier`
+
+Selects a tier declared by `x-tier-routing` for a path item or one operation.
+
+Parsed field: `Endpoint.Tier`
+
+Rules:
+- Optional.
+- Must be a string.
+- Operation-level `x-tier` overrides path-item-level `x-tier`.
+- The value must name a tier in `x-tier-routing.tiers`.
+- `security: []` / `security: [{}]` must not be combined with an auth-bearing
+ tier. Use a `none` tier for anonymous endpoints.
+
+Example:
+
+```yaml
+paths:
+ /public/search:
+ x-tier: free
+ get:
+ responses:
+ "200": {description: ok}
+ /premium/search:
+ get:
+ x-tier: paid
+ responses:
+ "200": {description: ok}
+```
diff --git a/docs/plans/2026-05-04-001-feat-tier-routing-plan.md b/docs/plans/2026-05-04-001-feat-tier-routing-plan.md
new file mode 100644
index 00000000..c4a39c14
--- /dev/null
+++ b/docs/plans/2026-05-04-001-feat-tier-routing-plan.md
@@ -0,0 +1,592 @@
+---
+title: "feat(cli): spec-driven tier routing for generated CLIs"
+type: feat
+status: active
+date: 2026-05-04
+---
+
+# feat(cli): spec-driven tier routing for generated CLIs
+
+## Overview
+
+Add an opt-in `tier_routing` contract to Printing Press specs so generated CLIs can route different endpoint groups through different credential/base-URL tiers without hand-written helper packages. This closes issue #539: Phase 1 can already notice when a free primary source would be accidentally gated by a paid secondary source, but the decision cannot currently survive into generated code.
+
+The core design is typed and explicit:
+
+- `APISpec.TierRouting` declares named tiers.
+- Each tier can provide a base URL and a limited auth block.
+- Resources and endpoints can select a tier; endpoint selection wins over resource selection.
+- Generated request paths, auth headers, MCP tools, and sync code use the selected tier only when the spec opts in.
+
+---
+
+## Problem Frame
+
+The generator currently assumes one global auth/base URL contract per printed CLI. `APISpec.BaseURL` and `APISpec.Auth` drive generated config, client, doctor, README, MCP metadata, and endpoint command behavior. `Resource.BaseURL` already handles host routing for resources on another host, but it does not solve credential routing: every request still uses the same generated `Config.AuthHeader()` value.
+
+This is wrong for combo or tiered APIs where:
+
+- headline/free commands should work without a paid credential;
+- paid enrichment commands should use a separate env var and sometimes a separate host;
+- the skill's source-priority/economics decision should become machine-readable generation input.
+
+Without a spec-level contract, agents either over-gate the whole CLI behind the paid key or hand-write per-CLI helper logic that does not compound.
+
+---
+
+## Requirements Trace
+
+- R1. Specs support a `tier_routing` block that can express named free/paid credential routing without per-CLI helper packages.
+- R2. Resources and endpoints can opt into a named tier, with endpoint-level selection overriding inherited resource-level selection.
+- R3. The generator emits tier routing only when the spec opts in; specs without `tier_routing` keep the existing single-auth/single-base behavior.
+- R4. Generated free-tier commands do not require or report missing paid-tier env vars before making free-tier requests, and env-sourced tier credentials are not serialized into config files.
+- R5. Generated paid-tier commands use the paid tier's configured env vars, auth format/header/query placement, and base URL when selected.
+- R6. MCP endpoint tools, intent/code orchestration paths, and sync/store reads preserve the same tier selection as the human CLI.
+- R7. Phase 2 spec enrichment docs explain detection/action/skip rules for adding `tier_routing`.
+- R8. OpenAPI parser support exists for authoring tier routing through documented `x-*` extensions when the source is OpenAPI.
+- R9. Generated README, doctor, manifest, and install metadata describe tier credentials without making paid-tier credentials globally required.
+
+---
+
+## Scope Boundaries
+
+- In scope: REST endpoint mirrors, internal YAML specs, OpenAPI extensions, generated CLI requests, generated MCP endpoint surfaces, generated sync/store reads, generated docs/spec references, and tests/fixtures.
+- In scope: tier auth modes that map cleanly to the current generated config/client model: `none`, `api_key`, and `bearer_token`.
+- Out of scope: OAuth2, session-handshake, cookie, and composed-auth tiers. These require stateful login/session machinery and should not be squeezed into the first tier-routing contract.
+- Out of scope: automatic inference of paid/free tiers from arbitrary vendor prose. Phase 2 docs may instruct agents when to add the block, but the generator should consume explicit spec data.
+- Out of scope: changing existing `auth.optional` semantics. Optional global auth remains separate from endpoint tier routing.
+- Out of scope: using tier routing with `client_pattern: proxy-envelope` in the initial implementation. Proxy-envelope posts all requests to one proxy endpoint, so per-tier base URL/auth selection needs a separate design.
+- Out of scope: retrofitting existing catalog entries, local library CLIs, or public library CLIs. This plan changes the machine for future generated CLIs; reprinting or migration is separate work.
+
+### Deferred to Follow-Up Work
+
+- OAuth/session-aware tier routing: separate issue once a real CLI needs multiple stateful auth mechanisms.
+- Scorecard/audit dimensions for detecting missed tier routing: follow-up after the generation contract exists and has examples.
+
+---
+
+## Context & Research
+
+### Relevant Code and Patterns
+
+- `internal/spec/spec.go` defines `APISpec.Auth`, `APISpec.BaseURL`, `Resource.BaseURL`, `Endpoint.Meta`, `Endpoint.HeaderOverrides`, and validation. This is the right layer for a typed tier-routing contract.
+- `internal/generator/templates/config.go.tmpl` loads auth env vars into generated config and emits `Config.AuthHeader()`.
+- `internal/generator/templates/client.go.tmpl` builds target URLs and applies auth on every request.
+- `internal/generator/templates/command_endpoint.go.tmpl` is the primary human CLI endpoint mirror path.
+- `internal/generator/templates/mcp_tools.go.tmpl`, `internal/generator/templates/mcp_intents.go.tmpl`, and `internal/generator/templates/mcp_code_orch.go.tmpl` are agent-facing request paths that must stay in parity with the CLI.
+- `internal/generator/templates/sync.go.tmpl` and `internal/generator/templates/data_source.go.tmpl` can call endpoint request paths outside the direct command handler, so tier metadata must be available there too.
+- `internal/openapi/parser.go` already maps Printing Press-specific `x-*` extensions into `APISpec`; `docs/SPEC-EXTENSIONS.md` is the canonical doc that must change with new extension lookups.
+- `skills/printing-press/references/spec-format.md` documents internal YAML fields, including current `base_url`, `auth`, resource `base_url`, and validation rules.
+- `skills/printing-press/SKILL.md` already has the source-priority economics check and records `auth_scoping` in `source-priority.json`, but it does not tell agents how to encode the decision into the spec.
+
+### Institutional Learnings
+
+- `docs/brainstorms/2026-03-31-auth-error-handling-requirements.md` established that auth checks must be per-call, not startup-wide, so unauthenticated endpoints keep working without credentials.
+- `docs/plans/2026-04-05-002-feat-per-endpoint-headers-and-auth-param-inference-plan.md` is the closest pattern for per-endpoint request customization. It added typed endpoint metadata plus generated request plumbing rather than hand-written per-CLI fixes.
+- `docs/solutions/best-practices/multi-source-api-discovery-design-2026-03-30.md` reinforces using explicit, testable base-URL configuration for multi-source external integrations.
+
+### External References
+
+- None. The work is an internal generator/spec contract. External API behavior motivates the feature, but local templates and specs determine the implementation.
+
+---
+
+## Key Technical Decisions
+
+- **Typed spec fields, not `Endpoint.Meta`:** Tier routing should be first-class because it controls auth and target hosts. `Meta` is useful for discovery confidence, but credential routing needs validation, docs, and template awareness.
+- **Named tiers with a small auth subset:** Use named tiers such as `free` and `paid`, each carrying `base_url` plus an auth block shaped like the existing auth fields. Limit first implementation to `none`, `api_key`, and `bearer_token` so generated code can stay stateless and per-request.
+- **Endpoint tier overrides resource tier:** Resource-level tiering covers common "all paid endpoints live under this resource" cases. Endpoint-level tiering covers mixed resources. Endpoint wins because it is the most specific declaration.
+- **No implicit global auth requirement from paid tiers:** A paid tier's env vars should be loaded and used only when a paid-tier request path is selected. Doctor/README may mention available tier credentials, but free-tier command execution must not preflight or fail on missing paid env vars.
+- **Selected tier auth is authoritative:** When an endpoint has an effective tier, that tier's auth controls the request. A selected `auth.type: none` suppresses global auth for that request; a selected paid tier does not fall back to global auth if its own credential is missing. Endpoints with no selector and no `default_tier` keep the existing global auth/base-URL behavior.
+- **Reuse existing request plumbing shape:** The generated client should keep current no-tier call behavior for non-tiered specs. When tier routing is enabled, request helpers should accept enough tier context to choose base URL and auth without duplicating request construction in command templates.
+- **MCP parity is part of the core feature:** Endpoint mirrors in `tools.go`, intents, and code orchestration must use the same tier route as CLI commands. Agent-facing parity is not a polish task.
+- **Validate ambiguous base URL combinations:** If an endpoint has an effective resource `base_url` and also selects a tier with `base_url`, validation should reject or require an explicit resolution. The plan recommends rejecting first; a future design can add precedence if a real API needs it.
+- **Credential routing has a trust boundary:** Auth-bearing tier base URLs must be absolute HTTPS URLs, must not target loopback/private/link-local hosts, and must either stay within the global API host family or carry explicit reviewed metadata allowing cross-host credential routing.
+- **Tier doctor checks are presence-only in v1:** Doctor can report configured/missing tier credentials, but it should not network-probe tier credentials until the spec model has tier-specific verify paths and host guarantees.
+
+---
+
+## Open Questions
+
+### Resolved During Planning
+
+- **Q: Should this be a new auth mode or a separate routing block?** Separate routing block. Global `auth` still describes the default CLI credential; tier routing describes endpoint-specific overrides.
+- **Q: Should resource `base_url` be reused for tiers?** No. Resource `base_url` is host routing; tier routing is credential/host selection. They interact but are not the same concept.
+- **Q: Should missing paid credentials fail at startup?** No. Existing auth-error requirements and mixed-auth behavior require per-call failures only.
+- **Q: Should OpenAPI support wait until after internal YAML support?** No. Issue #539 explicitly calls for parser support, and `docs/SPEC-EXTENSIONS.md` provides an existing extension path.
+- **Q: What happens when `tier_routing` exists but an endpoint has no selector and no default tier?** It keeps existing global auth/base behavior. Tier routing changes only endpoints with an effective selected tier.
+- **Q: Can `no_auth` and tier auth disagree?** No. `no_auth: true` and OpenAPI `security: []` are public-endpoint claims; they must not be combined with an effective auth-bearing tier. An effective `auth.type: none` should count as public for MCP descriptions without mutating `Endpoint.NoAuth`.
+
+### Deferred to Implementation
+
+- Exact generated helper names and method names are implementation details.
+- Whether tier auth formatting reuses `applyAuthFormat` directly or a small generated tier-auth wrapper depends on template ergonomics.
+- Whether no-tier generated output remains byte-identical for every file or only behaviorally equivalent depends on the cleanest template gating. The plan expects no visible behavior change for non-tiered specs.
+
+---
+
+## High-Level Technical Design
+
+> *This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce.*
+
+### Contract Sketch
+
+```yaml
+tier_routing:
+ default_tier: free
+ tiers:
+ free:
+ base_url: https://free-api.example.com
+ auth:
+ type: none
+ paid:
+ base_url: https://paid-api.example.com
+ auth:
+ type: api_key
+ header: Authorization
+ format: "Bearer {token}"
+ env_vars:
+ - PAID_API_KEY
+
+resources:
+ search:
+ tier: free
+ endpoints:
+ list:
+ method: GET
+ path: /search
+ enriched:
+ tier: paid
+ method: GET
+ path: /enriched-search
+```
+
+### Request Flow
+
+```mermaid
+flowchart TB
+ Spec[tier_routing in spec] --> Model[typed APISpec fields]
+ Model --> Validate[spec validation]
+ Model --> Templates[generator templates]
+ Templates --> Config[config loads tier env vars]
+ Templates --> Client[client resolves tier route per request]
+ Templates --> CLI[endpoint commands pass tier selection]
+ Templates --> MCP[MCP tools/intents/code orchestration pass tier selection]
+ Templates --> Sync[sync/store request paths pass tier selection]
+```
+
+---
+
+## Implementation Units
+
+```mermaid
+flowchart TB
+ U1[U1 Spec contract] --> U2[U2 OpenAPI parser]
+ U1 --> U3[U3 Generated config/client]
+ U3 --> U4[U4 CLI/MCP/sync parity]
+ U1 --> U5[U5 Docs and skill guidance]
+ U3 --> U6[U6 Generated metadata surfaces]
+ U2 --> U7[U7 Fixtures and validation]
+ U4 --> U7
+ U5 --> U7
+ U6 --> U7
+```
+
+- U1. **Add the tier-routing spec contract**
+
+**Goal:** Represent tier routing as typed, validated spec data.
+
+**Requirements:** R1, R2, R3, R5
+
+**Dependencies:** None
+
+**Files:**
+- Modify: `internal/spec/spec.go`
+- Test: `internal/spec/spec_test.go`
+- Test: `testdata/operations-shorthand.yaml` or a new compact internal YAML fixture if round-trip coverage needs a fixture
+
+**Approach:**
+- Add a root `TierRouting` block on `APISpec`.
+- Add named tier definitions with optional `base_url` and limited auth configuration.
+- Add resource and endpoint tier selectors.
+- Add helper behavior for resolving the effective tier for an endpoint from endpoint selector, inherited resource selector, and default tier.
+- Validate:
+ - every selector references a declared tier;
+ - `default_tier`, when set, references a declared tier, and when unset unselected endpoints fall back to global behavior;
+ - tier auth type is one of the supported initial stateless modes;
+ - tier env vars are present for auth modes that need them;
+ - tier `auth.in` is `header`, `query`, or empty only where the auth type has a clear default;
+ - tier auth header/query names are present when placement requires them;
+ - tier auth formats reference declared placeholders only;
+ - `no_auth: true` is not combined with an effective auth-bearing tier;
+ - `client_pattern: proxy-envelope` is rejected when tier routing is configured;
+ - auth-bearing tier base URLs satisfy the trust-boundary policy;
+ - tier `base_url` is not combined ambiguously with an effective resource `base_url`.
+
+**Execution note:** Start with failing spec round-trip and validation tests before changing templates.
+
+**Patterns to follow:**
+- `Resource.BaseURL` validation and `HasResourceBaseURLOverride()` in `internal/spec/spec.go`.
+- `Endpoint.HeaderOverrides` as typed per-endpoint request metadata.
+- `AuthConfig` validation patterns for supported auth modes and env vars.
+
+**Test scenarios:**
+- Happy path: YAML with `tier_routing`, two tiers, resource selector, and endpoint override unmarshals into the expected typed fields.
+- Happy path: endpoint selector overrides inherited resource selector.
+- Happy path: spec without `tier_routing` validates unchanged.
+- Error path: endpoint references an unknown tier and validation names the invalid selector.
+- Error path: tier with `api_key` or `bearer_token` but no env var fails validation.
+- Error path: unsupported tier auth type such as `oauth2` fails validation with an explicit unsupported-mode message.
+- Error path: malformed `auth.in`, missing header/query names, and undeclared auth-format placeholders fail validation.
+- Error path: `no_auth: true` plus auth-bearing selected tier fails validation.
+- Error path: auth-bearing tier base URL using `http://`, loopback, private-network, or unreviewed cross-host routing fails validation.
+- Error path: `client_pattern: proxy-envelope` plus `tier_routing` fails validation.
+- Error path: effective resource `base_url` plus selected tier `base_url` fails validation until a future design defines precedence.
+
+**Verification:**
+- Spec data can express #539's free/paid routing case without changing existing global auth semantics.
+
+---
+
+- U2. **Parse tier routing from OpenAPI extensions**
+
+**Goal:** Allow OpenAPI specs to declare the same tier-routing contract through documented Printing Press `x-*` extensions.
+
+**Requirements:** R1, R2, R8
+
+**Dependencies:** U1
+
+**Files:**
+- Modify: `internal/openapi/parser.go`
+- Modify: `docs/SPEC-EXTENSIONS.md`
+- Test: `internal/openapi/parser_test.go`
+- Create: `testdata/openapi/tier-routing.yaml`
+
+**Approach:**
+- Add `info.x-tier-routing` support that maps to the root `APISpec.TierRouting` block.
+- Add path-item and operation-level tier selector support. Operation-level selection should override path-item selection, mirroring endpoint specificity.
+- Define OpenAPI precedence explicitly: `x-tier` controls generated tier routing, but it must not contradict an operation that declares `security: []`; such conflicts should fail validation rather than silently choosing one source.
+- Keep extension parsing strict enough to avoid silent nonsense: non-map root blocks, non-string tier names, and unknown tier references should surface through spec validation.
+- Document every extension location and field in `docs/SPEC-EXTENSIONS.md` in the same change as parser code.
+
+**Patterns to follow:**
+- Existing `info.x-proxy-routes` parsing in `internal/openapi/parser.go`.
+- Existing security scheme `x-auth-*` parsing and documentation in `docs/SPEC-EXTENSIONS.md`.
+- Path-item metadata mapping for `x-resource-id` and `x-critical`.
+
+**Test scenarios:**
+- Happy path: `info.x-tier-routing` with `free` and `paid` tiers maps to `APISpec.TierRouting`.
+- Happy path: path-item `x-tier: paid` applies to all operations under that path.
+- Happy path: operation-level `x-tier: free` overrides path-item `paid`.
+- Error path: malformed `x-tier-routing` produces either an empty block that validation rejects or a direct parse error, depending on existing parser convention.
+- Error path: operation with `security: []` and `x-tier` selecting an auth-bearing tier fails validation.
+- Integration: generated `testdata/openapi/tier-routing.yaml` parses and validates.
+
+**Verification:**
+- OpenAPI authors can express the same route/credential split as internal YAML authors.
+
+---
+
+- U3. **Emit tier-aware generated config and client plumbing**
+
+**Goal:** Make generated runtime code able to resolve base URL and auth for the selected tier per request.
+
+**Requirements:** R3, R4, R5
+
+**Dependencies:** U1
+
+**Files:**
+- Modify: `internal/generator/templates/config.go.tmpl`
+- Modify: `internal/generator/templates/client.go.tmpl`
+- Modify: `internal/generator/generator.go`
+- Test: `internal/generator/generator_test.go`
+- Test: `internal/generator/auth_env_precedence_test.go` if tier auth overlaps existing precedence tests
+
+**Approach:**
+- Gate all new generated code behind `tier_routing` presence.
+- Resolve tier env vars without making them global auth env vars and without serializing env-sourced tier credentials into saved config.
+- Generate tier auth resolution that mirrors the existing `AuthHeader()` behavior for the supported stateless modes.
+- Make selected tier auth authoritative: `auth.type: none` sends no global auth, and missing selected-tier credentials do not fall back to global credentials.
+- Generate target URL resolution that chooses the selected tier's base URL when present, otherwise falls back to current base URL behavior.
+- Preserve existing endpoint-template-var substitution behavior for tier base URLs by collecting placeholders from tier base URLs into the generated template-var set and validating unresolved placeholders.
+- Keep non-tiered specs behaviorally unchanged.
+
+**Execution note:** Add generator characterization tests for a spec without `tier_routing` before changing common templates.
+
+**Patterns to follow:**
+- Env-var loading and built-in field de-duplication in `config.go.tmpl`.
+- `AuthHeader()` formatting and `applyAuthFormat` behavior in `config.go.tmpl`.
+- `buildURL`, `EndpointTemplateVars`, and resource `base_url` branches in `client.go.tmpl`.
+- Existing auth precedence tests in `internal/generator/auth_env_precedence_test.go`.
+
+**Test scenarios:**
+- Happy path: generated config includes paid tier env var storage when `tier_routing` declares a paid tier.
+- Happy path: free-tier request code path does not require the paid env var to be set.
+- Happy path: selected free tier with `auth.type: none` suppresses global auth for that request.
+- Happy path: paid-tier request code path formats its configured header using the paid env var.
+- Happy path: query-placed tier auth sends credentials as the configured query parameter.
+- Happy path: tier base URL routes paid and free requests to different hosts.
+- Edge case: tier base URL with trailing slash combines cleanly with endpoint paths.
+- Edge case: tier base URL with endpoint template variables emits actionable missing-env errors consistent with existing placeholder behavior.
+- Error path: env-provided paid-tier credential is not written to the generated config file by save/update paths.
+- Regression: generated output for a no-tier spec does not introduce tier env var or tier auth helper code.
+
+**Verification:**
+- A generated CLI can issue free and paid requests through one client without making paid auth global.
+
+---
+
+- U4. **Apply tier selection across CLI, MCP, and sync request paths**
+
+**Goal:** Ensure every generated surface that can call an endpoint uses the same effective tier.
+
+**Requirements:** R4, R5, R6
+
+**Dependencies:** U1, U3
+
+**Files:**
+- Modify: `internal/generator/generator.go`
+- Modify: `internal/generator/templates/command_endpoint.go.tmpl`
+- Modify: `internal/generator/templates/command_promoted.go.tmpl`
+- Modify: `internal/generator/templates/mcp_tools.go.tmpl`
+- Modify: `internal/generator/templates/mcp_intents.go.tmpl`
+- Modify: `internal/generator/templates/mcp_code_orch.go.tmpl`
+- Modify: `internal/profiler/profiler.go`
+- Modify: `internal/generator/templates/sync.go.tmpl`
+- Modify: `internal/generator/templates/auto_refresh.go.tmpl`
+- Modify: `internal/generator/templates/data_source.go.tmpl` if freshness/store read helpers need tier context
+- Test: `internal/generator/generator_test.go`
+- Test: `internal/generator/skill_test.go` if generated SKILL examples include tiered commands
+
+**Approach:**
+- Compute effective tier metadata once in generator template data rather than duplicating selector logic in each template.
+- Pass tier selection into endpoint handlers, promoted single-endpoint commands, raw MCP endpoint tools, MCP intents, code orchestration, and sync-generated request calls.
+- For sync and auto-refresh, either add effective tier fields to profiler syncable/dependent metadata or generate a path-to-tier registry; do not rely on path strings alone if two endpoints could share a path with different tier semantics.
+- Keep generated command annotations and MCP auth metadata coherent: endpoint tools for effective `auth.type: none` tiers should count as public for descriptions/readiness without overloading `Endpoint.NoAuth`.
+- Ensure resource `base_url` behavior remains unchanged for non-tiered resources.
+
+**Patterns to follow:**
+- `ResourceBaseURL` template data in `internal/generator/generator.go`.
+- Existing `HeaderOverrides` branching in `command_endpoint.go.tmpl`.
+- Resource-base-URL parity tests in `TestGenerateResourceBaseURLOverrideRoutesToOverrideHost`.
+- MCP handler path parity assertions in `internal/generator/generator_test.go`.
+
+**Test scenarios:**
+- Happy path: generated human CLI free command routes to free tier base URL and does not mention paid env var in the request path.
+- Happy path: generated human CLI paid command routes to paid tier base URL and uses paid auth.
+- Happy path: promoted single-endpoint resource receives the same tier behavior as non-promoted endpoint files.
+- Happy path: typed MCP endpoint tool uses the same tier path/auth as the CLI command.
+- Happy path: MCP intent/code-orchestration endpoint dispatch preserves tier selection.
+- Happy path: sync path for a tiered GET endpoint uses the endpoint's effective tier.
+- Happy path: auto-refresh/freshness path for a tiered resource uses the same effective tier as direct sync.
+- Regression: MCP public counts/descriptions treat effective no-auth tiers as public without mutating `Endpoint.NoAuth`.
+- Regression: resource `base_url` override tests continue passing for specs without tier routing.
+
+**Verification:**
+- There is no human/agent split where a free endpoint works in CLI but is gated or misrouted through MCP, or vice versa.
+
+---
+
+- U5. **Teach agents and docs when to author tier routing**
+
+**Goal:** Make Phase 2 enrichment produce the new spec contract instead of prose-only decisions or hand-written helper packages.
+
+**Requirements:** R1, R7, R8
+
+**Dependencies:** U1
+
+**Files:**
+- Modify: `skills/printing-press/SKILL.md`
+- Modify: `skills/printing-press/references/spec-format.md`
+- Test: documentation coverage is manual review plus any existing docs tests if applicable
+
+**Approach:**
+- Extend the Phase 2 pre-generation enrichment guidance with a tier-routing subsection.
+- Detection rules:
+ - use when research/source-priority says a free primary source would otherwise be gated by paid secondary auth;
+ - use when endpoints in the same generated CLI require different env vars or hosts by source/tier;
+ - skip when one optional global credential merely enriches all commands uniformly.
+- Action rules:
+ - add `tier_routing` to the spec;
+ - mark free resources/endpoints with the free tier;
+ - mark paid/enrichment endpoints with the paid tier;
+ - keep `no_auth: true` separate from tier routing; use it only for endpoints that are public independently of tier configuration.
+- Skip rules:
+ - do not invent tiers without research evidence;
+ - do not use tier routing for OAuth/session state in this first implementation;
+ - do not use tier routing to hide paid-only headline behavior.
+- Update the internal YAML spec reference with a compact example and validation rules.
+
+**Patterns to follow:**
+- Source-priority economics check in `skills/printing-press/SKILL.md`.
+- Auth enrichment guidance in Phase 2 of `skills/printing-press/SKILL.md`.
+- Existing internal YAML examples in `skills/printing-press/references/spec-format.md`.
+
+**Test scenarios:**
+- Test expectation: none for prose-only docs, but review should confirm docs name detection/action/skip rules and include a valid compact YAML example.
+
+**Verification:**
+- A future generation agent can read the skill and know when to add `tier_routing`, when not to add it, and what minimal spec shape to write.
+
+---
+
+- U6. **Update generated metadata and install surfaces**
+
+**Goal:** Make tier credentials visible to users and agents without turning every tier into globally required auth.
+
+**Requirements:** R4, R5, R9
+
+**Dependencies:** U1, U3
+
+**Files:**
+- Modify: `internal/generator/templates/readme.md.tmpl`
+- Modify: `internal/generator/templates/doctor.go.tmpl`
+- Modify: `internal/pipeline/toolsmanifest.go`
+- Modify: `internal/pipeline/toolsmanifest_test.go`
+- Modify: `internal/pipeline/climanifest.go`
+- Modify: `internal/pipeline/mcpb_manifest.go`
+- Modify: `internal/authdoctor/classify.go`
+- Test: `internal/pipeline/climanifest_test.go`
+- Test: `internal/pipeline/mcpb_manifest_test.go`
+- Test: `internal/authdoctor/*_test.go`
+- Modify: `internal/generator/templates/mcp_tools.go.tmpl` if runtime MCP metadata needs tier auth details beyond request dispatch
+- Test: `internal/generator/readme_test.go`
+- Test: `internal/generator/generator_test.go`
+- Test: `internal/pipeline/toolsmanifest_test.go`
+
+**Approach:**
+- README should document tier credentials as scoped setup: free/core commands first, paid/enrichment credentials only for tiered paid commands.
+- Doctor should report tier credential presence only in v1; it must not probe paid credentials against any host until tier-specific verify metadata exists.
+- `tools-manifest.json` should expose enough tier auth metadata for downstream MCP/auth-doctor tooling to know which env vars exist and whether they are globally required or tier-scoped.
+- `.printing-press.json` and MCPB/user-config generation must not mark paid-tier env vars as globally required for a CLI whose free tier works without them.
+- Update auth-doctor classification to understand tier-scoped auth instead of classifying tiered CLIs as globally paid, globally no-auth, or unknown.
+- Keep existing global auth metadata unchanged for specs without tier routing.
+
+**Patterns to follow:**
+- Optional auth README and doctor behavior in `internal/generator/auth_optional_test.go`.
+- Current manifest auth serialization in `internal/pipeline/toolsmanifest.go`.
+- CLI manifest and MCPB user-config generation in `internal/pipeline/climanifest.go` and `internal/pipeline/mcpb_manifest.go`.
+- Auth doctor manifest classification in `internal/authdoctor/classify.go`.
+- Existing MCP auth metadata in `internal/generator/templates/mcp_tools.go.tmpl`.
+
+**Test scenarios:**
+- Happy path: generated README names paid tier env vars under an optional/enrichment credential section, not as mandatory setup for all commands.
+- Happy path: doctor reports free-tier readiness when paid-tier env vars are absent and paid-tier status as not configured.
+- Happy path: `tools-manifest.json` includes tier-scoped env var metadata without changing the global required-auth classification for free-tier commands.
+- Happy path: MCPB user config does not require paid-tier env vars for a CLI with working free-tier commands.
+- Happy path: auth doctor reports tier-scoped credentials without treating missing paid credentials as a global failure.
+- Error path: doctor does not send a paid credential to the global/free host.
+- Regression: non-tiered API-key specs keep existing README, doctor, and manifest auth behavior.
+
+**Verification:**
+- Users and MCP hosts can discover paid tier credentials, but free-tier install/use remains possible without them.
+
+---
+
+- U7. **Add end-to-end fixtures and validation coverage**
+
+**Goal:** Prove the issue-level acceptance criteria across parser, generator, docs, and behavior-sensitive fixtures.
+
+**Requirements:** R1, R3, R4, R5, R6, R7, R8, R9
+
+**Dependencies:** U2, U3, U4, U5, U6
+
+**Files:**
+- Modify: `internal/spec/spec_test.go`
+- Modify: `internal/openapi/parser_test.go`
+- Modify: `internal/generator/generator_test.go`
+- Modify: `testdata/golden/fixtures/golden-api.yaml` only if the behavior should become a golden contract
+- Modify: `testdata/golden/cases/` and `testdata/golden/expected/` only if adding a golden case is more useful than focused generator tests
+- Create: `testdata/openapi/tier-routing.yaml`
+
+**Approach:**
+- Keep most coverage focused in package tests where assertions can inspect generated source and run a generated CLI against `httptest` servers.
+- Add golden coverage only if the generated artifact contract should be locked at the CLI fixture level. If focused generator tests already prove the behavior without noisy fixture churn, run golden verification unchanged.
+- Ensure at least one test exercises the exact #539 acceptance: a free-only credential path must not require paid-tier env vars.
+
+**Patterns to follow:**
+- Resource base URL generator tests around `TestGenerateResourceBaseURLOverrideRoutesToOverrideHost`.
+- Auth env precedence tests around generated `AuthHeader()` behavior.
+- OpenAPI extension parser tests for `x-auth-*` and `x-proxy-routes`.
+- Golden harness guidance in `AGENTS.md`: update goldens only for intentional captured artifact drift.
+
+**Test scenarios:**
+- Integration: generate a tiered CLI with free and paid endpoints, run the free command with no paid env var, and observe the free test server receives the request.
+- Integration: run the paid command with paid env var set, and observe the paid test server receives the expected auth header.
+- Integration: run the paid command without paid env var and confirm the failure is per-call, not a startup failure blocking free commands.
+- Integration: inspect generated MCP tools to confirm free and paid endpoints carry the same effective route/auth split as CLI handlers.
+- Integration: inspect generated `tools-manifest.json` to confirm paid tier env vars are tier-scoped and not globally required for free-tier commands.
+- Integration: inspect generated `.printing-press.json` and MCPB user config to confirm paid-tier env vars are not globally required.
+- Integration: run the free-path acceptance test outside mock-mode env injection, or explicitly scrub paid env vars, so verifier helper token injection cannot hide the bug.
+- Regression: run existing resource-base-URL tests to ensure multihost routing was not broken.
+- Regression: run existing auth env precedence tests to ensure global auth behavior remains intact for non-tiered specs.
+- Regression: run golden verification; update fixtures only if the captured generated artifact intentionally includes the new tier-routing example.
+
+**Verification:**
+- The plan's issue acceptance criteria are covered by concrete tests and docs, not just implementation structure.
+
+---
+
+## System-Wide Impact
+
+```mermaid
+flowchart TB
+ Spec[Spec authoring] --> Parser[OpenAPI/internal spec parser]
+ Parser --> Generator[Generator template data]
+ Generator --> HumanCLI[Human CLI endpoint commands]
+ Generator --> MCP[MCP tools/intents/code orchestration]
+ Generator --> Sync[Sync/store freshness paths]
+ Generator --> Docs[README/SKILL/spec docs]
+ HumanCLI --> Client[Generated client]
+ MCP --> Client
+ Sync --> Client
+ Client --> API[External API hosts]
+```
+
+- **Interaction graph:** Spec parsing feeds generator template data; generated CLI, MCP, and sync paths all converge on the generated client. Tier resolution must be centralized enough that these surfaces do not drift.
+- **Error propagation:** Missing tier credentials should surface only on tiered requests that need them. Free-tier requests should not fail early because paid-tier env vars are absent.
+- **State lifecycle risks:** Tier env vars are read from process environment/config at runtime. No new persistent auth state should be introduced in this plan.
+- **API surface parity:** Human CLI commands, typed MCP tools, intents, code orchestration, and sync must all use the same effective endpoint tier.
+- **Integration coverage:** Unit tests on spec parsing are insufficient. Generated CLI runtime tests against local servers are needed to prove env/base URL routing.
+- **Unchanged invariants:** Existing global `auth`, global `base_url`, resource `base_url`, `no_auth`, and `auth.optional` semantics remain valid. Tier routing is additive and opt-in.
+
+---
+
+## Risks & Dependencies
+
+| Risk | Mitigation |
+|------|------------|
+| Tier routing becomes a second, divergent auth system | Limit first implementation to stateless auth modes and reuse existing auth formatting/env-var patterns. |
+| Free endpoints still mention or require paid env vars through shared generated helpers | Add explicit free-command runtime tests with paid env vars unset. |
+| MCP tools drift from CLI behavior | Treat MCP parity as U4 core scope and assert generated MCP route/auth output. |
+| Resource `base_url` and tier `base_url` conflict silently | Validate ambiguous combinations up front instead of defining accidental precedence. |
+| Template changes alter non-tiered generated CLIs | Gate new code on `tier_routing` presence and add no-tier regression assertions. |
+| Agents overuse tier routing as a workaround for vague product scope | Skill docs must include skip rules and require research/source-priority evidence. |
+
+---
+
+## Documentation / Operational Notes
+
+- Update `skills/printing-press/SKILL.md` so Phase 2 agents can convert source-priority `auth_scoping` into spec data.
+- Update `skills/printing-press/references/spec-format.md` so internal YAML authors have a portable example.
+- Update `docs/SPEC-EXTENSIONS.md` in the same PR as any OpenAPI `x-*` parser support.
+- Mention in PR description that OAuth/session tiers are intentionally deferred.
+- Because this changes generated artifacts, run golden verification. Update goldens only if the fixture intentionally captures tier routing.
+
+---
+
+## Sources & References
+
+- Related issue: [#539](https://github.com/mvanhorn/cli-printing-press/issues/539)
+- Related code: `internal/spec/spec.go`
+- Related code: `internal/openapi/parser.go`
+- Related code: `internal/generator/templates/config.go.tmpl`
+- Related code: `internal/generator/templates/client.go.tmpl`
+- Related code: `internal/generator/templates/command_endpoint.go.tmpl`
+- Related code: `internal/generator/templates/mcp_tools.go.tmpl`
+- Related docs: `skills/printing-press/SKILL.md`
+- Related docs: `skills/printing-press/references/spec-format.md`
+- Related docs: `docs/SPEC-EXTENSIONS.md`
+- Related prior plan: `docs/plans/2026-04-05-002-feat-per-endpoint-headers-and-auth-param-inference-plan.md`
+- Related prior requirements: `docs/brainstorms/2026-03-31-auth-error-handling-requirements.md`
+- Institutional learning: `docs/solutions/best-practices/multi-source-api-discovery-design-2026-03-30.md`
diff --git a/internal/authdoctor/classify.go b/internal/authdoctor/classify.go
index b1ee36c7..71cc7df2 100644
--- a/internal/authdoctor/classify.go
+++ b/internal/authdoctor/classify.go
@@ -2,6 +2,7 @@ package authdoctor
import (
"fmt"
+ "sort"
"github.com/mvanhorn/cli-printing-press/v3/internal/pipeline"
)
@@ -41,38 +42,63 @@ func Classify(slug string, manifest *pipeline.ToolsManifest, env getEnv) []Findi
}}
}
- authType := manifest.Auth.Type
- if authType == "" || authType == "none" {
+ findings := classifyAuthBlock(slug, manifest.Auth, env, "")
+ if manifest.TierRouting != nil {
+ tierNames := make([]string, 0, len(manifest.TierRouting.Tiers))
+ for name := range manifest.TierRouting.Tiers {
+ tierNames = append(tierNames, name)
+ }
+ sort.Strings(tierNames)
+ for _, tierName := range tierNames {
+ findings = append(findings, classifyAuthBlock(slug, manifest.TierRouting.Tiers[tierName].Auth, env, tierName)...)
+ }
+ }
+ if len(findings) == 0 {
return []Finding{{
API: slug,
- Type: displayType(authType),
+ Type: displayType(manifest.Auth.Type),
Status: StatusNoAuth,
}}
}
+ return findings
+}
- if len(manifest.Auth.EnvVars) == 0 {
+func classifyAuthBlock(slug string, auth pipeline.ManifestAuth, env getEnv, tierName string) []Finding {
+ authType := auth.Type
+ if authType == "" || authType == "none" {
+ return nil
+ }
+ displayAuthType := scopedAuthType(tierName, authType)
+ if len(auth.EnvVars) == 0 {
findings := []Finding{{
API: slug,
- Type: authType,
+ Type: displayAuthType,
Status: StatusUnknown,
Reason: "auth type declared but no env_vars listed in manifest",
}}
- if manifest.Auth.RequiresBrowserSession {
- findings = append(findings, browserSessionProofFinding(slug, authType))
+ if auth.RequiresBrowserSession {
+ findings = append(findings, browserSessionProofFinding(slug, displayAuthType))
}
return findings
}
- findings := make([]Finding, 0, len(manifest.Auth.EnvVars))
- for _, envVar := range manifest.Auth.EnvVars {
- findings = append(findings, classifyEnv(slug, authType, envVar, env))
+ findings := make([]Finding, 0, len(auth.EnvVars))
+ for _, envVar := range auth.EnvVars {
+ findings = append(findings, classifyEnv(slug, displayAuthType, envVar, env))
}
- if manifest.Auth.RequiresBrowserSession {
- findings = append(findings, browserSessionProofFinding(slug, authType))
+ if auth.RequiresBrowserSession {
+ findings = append(findings, browserSessionProofFinding(slug, displayAuthType))
}
return findings
}
+func scopedAuthType(tierName, authType string) string {
+ if tierName == "" {
+ return authType
+ }
+ return fmt.Sprintf("tier:%s/%s", tierName, authType)
+}
+
func browserSessionProofFinding(slug, authType string) Finding {
return Finding{
API: slug,
diff --git a/internal/authdoctor/classify_test.go b/internal/authdoctor/classify_test.go
index d81bd01e..5814c104 100644
--- a/internal/authdoctor/classify_test.go
+++ b/internal/authdoctor/classify_test.go
@@ -215,3 +215,32 @@ func TestClassifyBrowserSessionAlsoReportsEnvVars(t *testing.T) {
t.Fatal("browser-session proof finding should explain the required doctor check")
}
}
+
+func TestClassifyTierRoutingEnvVars(t *testing.T) {
+ m := &pipeline.ToolsManifest{
+ Auth: pipeline.ManifestAuth{Type: "none"},
+ TierRouting: &pipeline.ManifestTiers{
+ DefaultTier: "free",
+ Tiers: map[string]pipeline.ManifestTier{
+ "free": {Auth: pipeline.ManifestAuth{Type: "none"}},
+ "paid": {
+ Auth: pipeline.ManifestAuth{
+ Type: "api_key",
+ EnvVars: []string{"PAID_KEY"},
+ },
+ },
+ },
+ },
+ }
+
+ findings := Classify("tiered", m, envFrom(map[string]string{"PAID_KEY": "paid-secret-value"}))
+ if len(findings) != 1 {
+ t.Fatalf("want paid tier env finding, got %d", len(findings))
+ }
+ if findings[0].Type != "tier:paid/api_key" {
+ t.Fatalf("want scoped tier auth type, got %q", findings[0].Type)
+ }
+ if findings[0].EnvVar != "PAID_KEY" || findings[0].Status != StatusOK {
+ t.Fatalf("want paid tier env var OK, got %+v", findings[0])
+ }
+}
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 68f47152..1053016f 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -210,9 +210,14 @@ func New(s *spec.APISpec, outputDir string) *Generator {
"envVarIsBuiltinField": envVarIsBuiltinField,
"envVarBuiltinFieldName": envVarBuiltinFieldName,
"resolveEnvVarField": resolveEnvVarField,
+ "authPlacement": authPlacement,
+ "authParameterName": authParameterName,
+ "effectiveTier": effectiveTier,
+ "effectiveSubTier": effectiveSubTier,
"add": func(a, b int) int { return a + b },
"oneline": naming.OneLine,
"composeMCPDesc": composeMCPDesc,
+ "composeMCPSubDesc": composeMCPSubDesc,
"mcpParamDesc": g.mcpParamDescription,
"flagName": flagName,
"paramIdent": paramIdent,
@@ -245,8 +250,8 @@ func New(s *spec.APISpec, outputDir string) *Generator {
"humanName": naming.HumanName,
"envPrefix": naming.EnvPrefix,
"mcpToolName": naming.SnakeIdentifier,
- "lookupEndpoint": func(resources map[string]spec.Resource, ref string) templateEndpoint {
- e, _ := lookupEndpointForTemplate(resources, ref)
+ "lookupEndpoint": func(api *spec.APISpec, ref string) templateEndpoint {
+ e, _ := lookupEndpointForTemplate(api, ref)
return e
},
"effectiveEndpointPath": effectiveEndpointPath,
@@ -608,6 +613,7 @@ type clientTemplateData struct {
type endpointTemplateData struct {
ResourceName string
ResourceBaseURL string
+ EffectiveTier string
FuncPrefix string
CommandPath string
EndpointName string
@@ -767,6 +773,41 @@ func hasAuth(auth spec.AuthConfig) bool {
return strings.TrimSpace(auth.Type) != "" && auth.Type != "none"
}
+func authPlacement(auth spec.AuthConfig) string {
+ if strings.TrimSpace(auth.In) == "query" {
+ return "query"
+ }
+ return "header"
+}
+
+func authParameterName(auth spec.AuthConfig) string {
+ if strings.TrimSpace(auth.Header) != "" {
+ return auth.Header
+ }
+ if authPlacement(auth) == "query" {
+ return "api_key"
+ }
+ return "Authorization"
+}
+
+func effectiveTier(api *spec.APISpec, resource spec.Resource, endpoint spec.Endpoint) string {
+ if api == nil {
+ return ""
+ }
+ return api.EffectiveTier(resource, endpoint)
+}
+
+func effectiveSubTier(api *spec.APISpec, parent spec.Resource, subResource spec.Resource, endpoint spec.Endpoint) string {
+ if api == nil {
+ return ""
+ }
+ effectiveResource := subResource
+ if effectiveResource.Tier == "" {
+ effectiveResource.Tier = parent.Tier
+ }
+ return api.EffectiveTier(effectiveResource, endpoint)
+}
+
func hasWriteCommands(resources map[string]spec.Resource) bool {
for _, resource := range resources {
if resourceHasWriteCommand(resource) {
@@ -1501,6 +1542,7 @@ func (g *Generator) renderResourceCommands(promotedResourceNames map[string]bool
epData := endpointTemplateData{
ResourceName: name,
ResourceBaseURL: strings.TrimRight(resource.BaseURL, "/"),
+ EffectiveTier: g.Spec.EffectiveTier(resource, endpoint),
FuncPrefix: name,
CommandPath: name,
EndpointName: eName,
@@ -1559,9 +1601,14 @@ func (g *Generator) renderResourceCommands(promotedResourceNames map[string]bool
for eName, endpoint := range subResource.Endpoints {
subKey := subName + "/" + eName
asyncInfo, isAsync := g.AsyncJobs[subKey]
+ effectiveResource := subResource
+ if effectiveResource.Tier == "" {
+ effectiveResource.Tier = resource.Tier
+ }
epData := endpointTemplateData{
ResourceName: subName,
ResourceBaseURL: subResourceBaseURL,
+ EffectiveTier: g.Spec.EffectiveTier(effectiveResource, endpoint),
FuncPrefix: name + "-" + subName,
CommandPath: name + " " + subName,
EndpointName: eName,
@@ -1960,25 +2007,27 @@ func (g *Generator) renderPromotedCommandFiles(promotedCommands []PromotedComman
resource := g.Spec.Resources[pc.ResourceName]
resource.BaseURL = strings.TrimRight(resource.BaseURL, "/")
promotedData := struct {
- PromotedName string
- ResourceName string
- EndpointName string
- Endpoint spec.Endpoint
- HasStore bool
- Resource spec.Resource
- FuncPrefix string
- IsReadOnly bool
+ PromotedName string
+ ResourceName string
+ EndpointName string
+ Endpoint spec.Endpoint
+ EffectiveTier string
+ HasStore bool
+ Resource spec.Resource
+ FuncPrefix string
+ IsReadOnly bool
*spec.APISpec
}{
- PromotedName: pc.PromotedName,
- ResourceName: pc.ResourceName,
- EndpointName: pc.EndpointName,
- Endpoint: pc.Endpoint,
- HasStore: g.VisionSet.Store,
- Resource: resource,
- FuncPrefix: pc.ResourceName,
- IsReadOnly: endpointIsReadCommand(pc.Endpoint, pc.EndpointName),
- APISpec: g.Spec,
+ PromotedName: pc.PromotedName,
+ ResourceName: pc.ResourceName,
+ EndpointName: pc.EndpointName,
+ Endpoint: pc.Endpoint,
+ EffectiveTier: g.Spec.EffectiveTier(resource, pc.Endpoint),
+ HasStore: g.VisionSet.Store,
+ Resource: resource,
+ FuncPrefix: pc.ResourceName,
+ IsReadOnly: endpointIsReadCommand(pc.Endpoint, pc.EndpointName),
+ APISpec: g.Spec,
}
promotedPath := filepath.Join("internal", "cli", "promoted_"+pc.PromotedName+".go")
if err := g.renderTemplate("command_promoted.go.tmpl", promotedPath, promotedData); err != nil {
@@ -2863,7 +2912,19 @@ func resolveEnvVarField(envVar string) string {
// internal/mcpdesc shapes the action sentence + Required/Optional
// parameter lines + Returns clause; this wrapper just packages the
// arguments into the Input struct.
-func composeMCPDesc(endpoint spec.Endpoint, noAuth bool, authType string, publicCount, totalCount int) string {
+func composeMCPDesc(api *spec.APISpec, resource spec.Resource, endpoint spec.Endpoint, publicCount, totalCount int) string {
+ authType, noAuth := api.EffectiveEndpointAuth(resource, endpoint)
+ return mcpdesc.Compose(mcpdesc.Input{
+ Endpoint: endpoint,
+ NoAuth: noAuth,
+ AuthType: authType,
+ PublicCount: publicCount,
+ TotalCount: totalCount,
+ })
+}
+
+func composeMCPSubDesc(api *spec.APISpec, parent spec.Resource, subResource spec.Resource, endpoint spec.Endpoint, publicCount, totalCount int) string {
+ authType, noAuth := api.EffectiveSubEndpointAuth(parent, subResource, endpoint)
return mcpdesc.Compose(mcpdesc.Input{
Endpoint: endpoint,
NoAuth: noAuth,
@@ -3214,17 +3275,21 @@ func graphqlFieldSelection(typeName string, types map[string]spec.TypeDef) []str
type templateEndpoint struct {
spec.Endpoint
EffectivePath string
+ EffectiveTier string
}
// lookupEndpointForTemplate resolves a dotted "resource.endpoint" (or
// "resource.sub_resource.endpoint") reference from the spec's resource map.
// Templates use it when rendering intent handler dispatch tables so each
// step's HTTP method and effective path are known at generate time.
-func lookupEndpointForTemplate(resources map[string]spec.Resource, ref string) (templateEndpoint, bool) {
+func lookupEndpointForTemplate(api *spec.APISpec, ref string) (templateEndpoint, bool) {
+ if api == nil {
+ return templateEndpoint{}, false
+ }
parts := strings.Split(ref, ".")
switch len(parts) {
case 2:
- r, ok := resources[parts[0]]
+ r, ok := api.Resources[parts[0]]
if !ok {
return templateEndpoint{}, false
}
@@ -3235,9 +3300,10 @@ func lookupEndpointForTemplate(resources map[string]spec.Resource, ref string) (
return templateEndpoint{
Endpoint: e,
EffectivePath: effectiveEndpointPath(r, e),
+ EffectiveTier: api.EffectiveTier(r, e),
}, true
case 3:
- r, ok := resources[parts[0]]
+ r, ok := api.Resources[parts[0]]
if !ok {
return templateEndpoint{}, false
}
@@ -3249,9 +3315,14 @@ func lookupEndpointForTemplate(resources map[string]spec.Resource, ref string) (
if !ok {
return templateEndpoint{}, false
}
+ effectiveSub := sub
+ if effectiveSub.Tier == "" {
+ effectiveSub.Tier = r.Tier
+ }
return templateEndpoint{
Endpoint: e,
EffectivePath: effectiveSubEndpointPath(r, sub, e),
+ EffectiveTier: api.EffectiveTier(effectiveSub, e),
}, true
default:
return templateEndpoint{}, false
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index f4786fcf..3d44d5da 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -711,8 +711,10 @@ func TestGenerateOAuth2ClientCredentialsClientRefresh(t *testing.T) {
"client-id resolver falls back to first env var")
assert.Contains(t, body, `os.Getenv("CCCLIENT_SECRET_KEY")`,
"client-secret resolver falls back to second env var")
- assert.Contains(t, body, "ccMu sync.Mutex",
- "client_credentials spec emits a mutex to serialize concurrent mints")
+ assert.Contains(t, body, "ccMu *sync.Mutex",
+ "client_credentials spec emits a shared mutex to serialize concurrent mints")
+ assert.Contains(t, body, "ccMu: &sync.Mutex{}",
+ "client_credentials spec initializes the shared mint mutex")
assert.Contains(t, body, "c.ccMu.Lock()",
"authHeader takes the mint mutex before re-checking the window")
}
@@ -757,7 +759,7 @@ func TestGenerateOAuth2AuthorizationCodeClientRefreshUnchanged(t *testing.T) {
"authorization_code spec must NOT emit client_credentials helpers")
assert.NotContains(t, body, "func needsClientCredentialsMint",
"authorization_code spec must NOT emit safety-window helper")
- assert.NotContains(t, body, "ccMu sync.Mutex",
+ assert.NotContains(t, body, "ccMu *sync.Mutex",
"authorization_code spec must NOT emit the client_credentials mint mutex")
}
diff --git a/internal/generator/templates/auto_refresh.go.tmpl b/internal/generator/templates/auto_refresh.go.tmpl
index 3d8669ea..0ed7f8a9 100644
--- a/internal/generator/templates/auto_refresh.go.tmpl
+++ b/internal/generator/templates/auto_refresh.go.tmpl
@@ -189,7 +189,7 @@ func runAutoRefresh(ctx context.Context, flags *rootFlags, db *store.Store, reso
return ctx.Err()
default:
}
- result := syncResource(c, db, resource, "", false, 1{{if .Pagination.DateRangeParam}}, ""{{end}})
+ result := syncResource({{if .HasTierRouting}}syncClientForResource(c, resource){{else}}c{{end}}, db, resource, "", false, 1{{if .Pagination.DateRangeParam}}, ""{{end}})
if result.Err != nil {
failures = append(failures, fmt.Sprintf("%s: %v", resource, result.Err))
}
diff --git a/internal/generator/templates/client.go.tmpl b/internal/generator/templates/client.go.tmpl
index c533257c..9e58d264 100644
--- a/internal/generator/templates/client.go.tmpl
+++ b/internal/generator/templates/client.go.tmpl
@@ -39,11 +39,15 @@ type Client struct {
NoCache bool
cacheDir string
limiter *cliutil.AdaptiveLimiter
+{{- if .HasTierRouting}}
+ requestTier string
+ limiters map[string]*cliutil.AdaptiveLimiter
+{{- end}}
{{- if eq .Auth.EffectiveOAuth2Grant "client_credentials"}}
// ccMu serializes OAuth2 client_credentials token mints so concurrent
// API calls inside the 60s pre-expiry window don't all dial the token
// endpoint and race on Config field writes / file persistence.
- ccMu sync.Mutex
+ ccMu *sync.Mutex
{{- end}}
{{- if eq .Auth.Type "session_handshake"}}
Session *SessionManager
@@ -58,6 +62,9 @@ type Client struct {
// itself reports. They compose: limiter caps RPS, Throttle gates on
// remaining query cost.
Throttle *ThrottleState
+{{- if .HasTierRouting}}
+ throttles map[string]*ThrottleState
+{{- end}}
// ThrottleMode is one of ThrottleModeStrict (default; block on
// WaitForBudget), ThrottleModeLenient (warn but proceed), or
// ThrottleModeOff (bypass). Wired from the --throttle-mode root flag.
@@ -65,6 +72,145 @@ type Client struct {
{{- end}}
}
+{{- if .HasTierRouting}}
+
+type requestAuth struct {
+ Value string
+ In string
+ Name string
+}
+
+func (c *Client) WithTier(tier string) *Client {
+ if c == nil || tier == "" {
+ return c
+ }
+ next := *c
+ next.requestTier = tier
+ next.limiter = c.limiterForTier(tier)
+{{- if .HasCostThrottling}}
+ next.Throttle = c.throttleForTier(tier)
+{{- end}}
+ return &next
+}
+
+func newTierLimiters(rateLimit float64) map[string]*cliutil.AdaptiveLimiter {
+ return map[string]*cliutil.AdaptiveLimiter{
+{{- range $tierName, $_ := .TierRouting.Tiers}}
+ {{printf "%q" $tierName}}: cliutil.NewAdaptiveLimiter(rateLimit),
+{{- end}}
+ }
+}
+
+func (c *Client) limiterForTier(tier string) *cliutil.AdaptiveLimiter {
+ if c == nil {
+ return nil
+ }
+ if tier == "" {
+ return c.limiter
+ }
+ if limiter, ok := c.limiters[tier]; ok && limiter != nil {
+ return limiter
+ }
+ return c.limiter
+}
+
+{{- if .HasCostThrottling}}
+
+func newTierThrottles() map[string]*ThrottleState {
+ return map[string]*ThrottleState{
+{{- range $tierName, $_ := .TierRouting.Tiers}}
+ {{printf "%q" $tierName}}: NewThrottleState(),
+{{- end}}
+ }
+}
+
+func (c *Client) throttleForTier(tier string) *ThrottleState {
+ if c == nil {
+ return nil
+ }
+ if tier == "" {
+ return c.Throttle
+ }
+ if throttle, ok := c.throttles[tier]; ok && throttle != nil {
+ return throttle
+ }
+ return c.Throttle
+}
+
+{{- end}}
+
+func (c *Client) baseURLForRequest() string {
+ switch c.requestTier {
+{{- range $tierName, $tier := .TierRouting.Tiers}}
+ case {{printf "%q" $tierName}}:
+ {{- if $tier.BaseURL}}
+ return strings.TrimRight({{printf "%q" $tier.BaseURL}}, "/")
+ {{- else}}
+ return c.BaseURL
+ {{- end}}
+{{- end}}
+ default:
+ return c.BaseURL
+ }
+}
+
+func (c *Client) authForRequest() (requestAuth, error) {
+ switch c.requestTier {
+{{- range $tierName, $tier := .TierRouting.Tiers}}
+ case {{printf "%q" $tierName}}:
+ {{- if or (eq $tier.Auth.Type "") (eq $tier.Auth.Type "none")}}
+ return requestAuth{}, nil
+ {{- else}}
+ {{- $placement := authPlacement $tier.Auth}}
+ {{- $name := authParameterName $tier.Auth}}
+ {{- if $tier.Auth.EnvVars}}
+ {{- range $i, $env := $tier.Auth.EnvVars}}
+ tierValue{{$i}} := os.Getenv({{printf "%q" $env}})
+ {{- end}}
+ if tierValue0 == "" {
+ return requestAuth{In: {{printf "%q" $placement}}, Name: {{printf "%q" $name}}}, nil
+ }
+ {{- if $tier.Auth.Format}}
+ value := applyTierAuthFormat({{printf "%q" $tier.Auth.Format}}, map[string]string{
+ "token": tierValue0,
+ "access_token": tierValue0,
+ {{- range $i, $env := $tier.Auth.EnvVars}}
+ {{printf "%q" (envVarPlaceholder $env)}}: tierValue{{$i}},
+ {{printf "%q" $env}}: tierValue{{$i}},
+ {{- end}}
+ })
+ {{- else if eq $tier.Auth.Type "bearer_token"}}
+ value := "Bearer " + tierValue0
+ {{- else}}
+ value := tierValue0
+ {{- end}}
+ return requestAuth{Value: value, In: {{printf "%q" $placement}}, Name: {{printf "%q" $name}}}, nil
+ {{- else}}
+ return requestAuth{In: {{printf "%q" $placement}}, Name: {{printf "%q" $name}}}, nil
+ {{- end}}
+ {{- end}}
+{{- end}}
+ default:
+ value, err := c.authHeader()
+ if err != nil {
+ return requestAuth{}, err
+ }
+ return requestAuth{Value: value, In: "{{authPlacement .Auth}}", Name: "{{authParameterName .Auth}}"}, nil
+ }
+}
+
+func applyTierAuthFormat(format string, replacements map[string]string) string {
+ for key, value := range replacements {
+ format = strings.ReplaceAll(format, "{"+key+"}", value)
+ }
+ if strings.Contains(format, "{") {
+ return ""
+ }
+ return format
+}
+
+{{- end}}
+
{{if eq .ClientPattern "proxy-envelope"}}
// proxyEnvelope is the JSON wrapper sent to the proxy endpoint.
type proxyEnvelope struct {
@@ -148,9 +294,18 @@ func New(cfg *config.Config, timeout time.Duration, rateLimit float64) *Client {
HTTPClient: httpClient,
cacheDir: cacheDir,
limiter: cliutil.NewAdaptiveLimiter(rateLimit),
+{{- if .HasTierRouting}}
+ limiters: newTierLimiters(rateLimit),
+{{- end}}
+{{- if eq .Auth.EffectiveOAuth2Grant "client_credentials"}}
+ ccMu: &sync.Mutex{},
+{{- end}}
Session: sess,
{{- if .HasCostThrottling}}
Throttle: NewThrottleState(),
+{{- if .HasTierRouting}}
+ throttles: newTierThrottles(),
+{{- end}}
ThrottleMode: ThrottleModeStrict,
{{- end}}
}
@@ -162,8 +317,17 @@ func New(cfg *config.Config, timeout time.Duration, rateLimit float64) *Client {
HTTPClient: httpClient,
cacheDir: cacheDir,
limiter: cliutil.NewAdaptiveLimiter(rateLimit),
+{{- if .HasTierRouting}}
+ limiters: newTierLimiters(rateLimit),
+{{- end}}
+{{- if eq .Auth.EffectiveOAuth2Grant "client_credentials"}}
+ ccMu: &sync.Mutex{},
+{{- end}}
{{- if .HasCostThrottling}}
Throttle: NewThrottleState(),
+{{- if .HasTierRouting}}
+ throttles: newTierThrottles(),
+{{- end}}
ThrottleMode: ThrottleModeStrict,
{{- end}}
}
@@ -200,6 +364,9 @@ func (c *Client) ProbeGet(path string) (int, error) {
func (c *Client) cacheKey(path string, params map[string]string) string {
key := path
+{{- if .HasTierRouting}}
+ key += "|tier=" + c.requestTier + "|base_url=" + c.baseURLForRequest()
+{{- end}}
for k, v := range params {
key += k + "=" + v
}
@@ -287,15 +454,18 @@ func (c *Client) do(method, path string, params map[string]string, body any, hea
endpointVars = c.Config.TemplateVars
}
{{- end}}
+{{- if .HasTierRouting}}
+ requestBaseURL := c.baseURLForRequest()
+{{- end}}
{{- if eq .ClientPattern "proxy-envelope"}}
// Proxy-envelope: all requests are POST'd to BaseURL with a JSON envelope
{{- if .EndpointTemplateVars}}
- targetURL, urlErr := buildURL(c.BaseURL, "", endpointVars)
+ targetURL, urlErr := buildURL({{if .HasTierRouting}}requestBaseURL{{else}}c.BaseURL{{end}}, "", endpointVars)
if urlErr != nil {
return nil, 0, urlErr
}
{{- else}}
- targetURL := c.BaseURL
+ targetURL := {{if .HasTierRouting}}requestBaseURL{{else}}c.BaseURL{{end}}
{{- end}}
{{- else}}
{{- if .EndpointTemplateVars}}
@@ -309,13 +479,13 @@ func (c *Client) do(method, path string, params map[string]string, body any, hea
if isAbsoluteURL(path) {
targetURL, urlErr = buildURL("", path, endpointVars)
} else {
- targetURL, urlErr = buildURL(c.BaseURL, path, endpointVars)
+ targetURL, urlErr = buildURL({{if .HasTierRouting}}requestBaseURL{{else}}c.BaseURL{{end}}, path, endpointVars)
}
if urlErr != nil {
return nil, 0, urlErr
}
{{- else}}
- targetURL, urlErr := buildURL(c.BaseURL, path, endpointVars)
+ targetURL, urlErr := buildURL({{if .HasTierRouting}}requestBaseURL{{else}}c.BaseURL{{end}}, path, endpointVars)
if urlErr != nil {
return nil, 0, urlErr
}
@@ -329,10 +499,10 @@ func (c *Client) do(method, path string, params map[string]string, body any, hea
if isAbsoluteURL(path) {
targetURL = path
} else {
- targetURL = c.BaseURL + path
+ targetURL = {{if .HasTierRouting}}requestBaseURL{{else}}c.BaseURL{{end}} + path
}
{{- else}}
- targetURL := c.BaseURL + path
+ targetURL := {{if .HasTierRouting}}requestBaseURL{{else}}c.BaseURL{{end}} + path
{{- end}}
{{- end}}
{{- end}}
@@ -354,7 +524,12 @@ func (c *Client) do(method, path string, params map[string]string, body any, hea
// exactly what would be sent. Uses only cached credentials; a token that
// requires a network refresh will be re-fetched on the live request path,
// not during dry-run.
+{{- if .HasTierRouting}}
+ authInfo, err := c.authForRequest()
+ authHeader := authInfo.Value
+{{- else}}
authHeader, err := c.authHeader()
+{{- end}}
if err != nil {
return nil, 0, err
}
@@ -363,12 +538,15 @@ func (c *Client) do(method, path string, params map[string]string, body any, hea
// (or empty). On the live path, ensure a fresh token if none is cached.
// Dry-run deliberately skips this — we want previews to reflect what's
// cached, never trigger a network fetch.
- if !c.DryRun && authHeader == "" && c.Session != nil {
+ if !c.DryRun && {{if .HasTierRouting}}c.requestTier == "" && {{end}}authHeader == "" && c.Session != nil {
tok, terr := c.Session.EnsureToken()
if terr != nil {
return nil, 0, terr
}
authHeader = tok
+{{- if .HasTierRouting}}
+ authInfo.Value = tok
+{{- end}}
}
{{- end}}
@@ -387,12 +565,15 @@ func (c *Client) do(method, path string, params map[string]string, body any, hea
// Re-resolve session token each iteration — may have been invalidated
// by a previous attempt returning 401/403.
- if authHeader == "" && c.Session != nil {
+ if {{if .HasTierRouting}}c.requestTier == "" && {{end}}authHeader == "" && c.Session != nil {
tok, terr := c.Session.EnsureToken()
if terr != nil {
return nil, 0, terr
}
authHeader = tok
+{{- if .HasTierRouting}}
+ authInfo.Value = tok
+{{- end}}
}
{{- end}}
@@ -442,7 +623,15 @@ func (c *Client) do(method, path string, params map[string]string, body any, hea
{{- end}}
if authHeader != "" {
-{{- if eq .Auth.Type "session_handshake"}}
+{{- if .HasTierRouting}}
+ if authInfo.In == "query" {
+ q := req.URL.Query()
+ q.Set(authInfo.Name, authHeader)
+ req.URL.RawQuery = q.Encode()
+ } else {
+ req.Header.Set(authInfo.Name, authHeader)
+ }
+{{- else if eq .Auth.Type "session_handshake"}}
{{- if eq .Auth.TokenParamIn "header"}}
req.Header.Set("{{.Auth.TokenParamName}}", authHeader)
{{- else}}
@@ -513,6 +702,9 @@ func (c *Client) do(method, path string, params map[string]string, body any, hea
if c.Session != nil && c.Session.ShouldInvalidate(resp.StatusCode) && attempt < maxRetries && authHeader != "" {
c.Session.Invalidate()
authHeader = "" // force re-fetch on next iteration
+{{- if .HasTierRouting}}
+ authInfo.Value = ""
+{{- end}}
lastErr = apiErr
continue
}
@@ -538,6 +730,10 @@ func (c *Client) do(method, path string, params map[string]string, body any, hea
// using the auth material already resolved in `do()`. Never triggers a network
// call — the caller is responsible for passing cached auth material only.
func (c *Client) dryRun(method, targetURL, path string, params map[string]string, body []byte, headerOverrides map[string]string, authHeader string) (json.RawMessage, int, error) {
+{{- if .HasTierRouting}}
+ authInfo, _ := c.authForRequest()
+ authInfo.Value = authHeader
+{{- end}}
{{- if eq .ClientPattern "proxy-envelope"}}
// Show the proxy envelope that would be sent
envelope := proxyEnvelope{
@@ -571,7 +767,16 @@ func (c *Client) dryRun(method, targetURL, path string, params map[string]string
queryPrinted = true
}
}
-{{- if eq .Auth.Type "session_handshake"}}
+{{- if .HasTierRouting}}
+ if authInfo.Value != "" && authInfo.In == "query" {
+ sep := "?"
+ if queryPrinted {
+ sep = "&"
+ }
+ fmt.Fprintf(os.Stderr, " %s%s=%s\n", sep, authInfo.Name, maskToken(authInfo.Value))
+ queryPrinted = true
+ }
+{{- else if eq .Auth.Type "session_handshake"}}
{{- if ne .Auth.TokenParamIn "header"}}
// Session-handshake token travels as a query parameter on the live
// request — surface it in the preview so users can verify wiring.
@@ -607,7 +812,11 @@ func (c *Client) dryRun(method, targetURL, path string, params map[string]string
}
}
{{- end}}
-{{- if eq .Auth.Type "session_handshake"}}
+{{- if .HasTierRouting}}
+ if authInfo.Value != "" && authInfo.In != "query" {
+ fmt.Fprintf(os.Stderr, " %s: %s\n", authInfo.Name, maskToken(authInfo.Value))
+ }
+{{- else if eq .Auth.Type "session_handshake"}}
{{- if eq .Auth.TokenParamIn "header"}}
if authHeader != "" {
fmt.Fprintf(os.Stderr, " %s: %s\n", "{{.Auth.TokenParamName}}", maskToken(authHeader))
@@ -778,6 +987,9 @@ func (c *Client) authHeader() (string, error) {
// 60s window avoids in-flight requests racing the expiry boundary.
// Double-checked lock so only one goroutine mints under contention.
if needsClientCredentialsMint(c.Config) {
+ if c.ccMu == nil {
+ c.ccMu = &sync.Mutex{}
+ }
c.ccMu.Lock()
if needsClientCredentialsMint(c.Config) {
clientID, clientSecret := resolveClientCredentials(c.Config)
diff --git a/internal/generator/templates/command_endpoint.go.tmpl b/internal/generator/templates/command_endpoint.go.tmpl
index cd0c5192..880c3bb2 100644
--- a/internal/generator/templates/command_endpoint.go.tmpl
+++ b/internal/generator/templates/command_endpoint.go.tmpl
@@ -116,6 +116,9 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
if err != nil {
return err
}
+{{- if .EffectiveTier}}
+ c = c.WithTier({{printf "%q" .EffectiveTier}})
+{{- end}}
path := "{{.ResourceBaseURL}}{{.Endpoint.Path}}"{{/* ResourceBaseURL is empty unless the resource declares an override; when set it makes path absolute and the client skips the c.BaseURL concat. */}}
{{- range $i, $p := .Endpoint.Params}}
diff --git a/internal/generator/templates/command_promoted.go.tmpl b/internal/generator/templates/command_promoted.go.tmpl
index f07b7253..28aa5bd1 100644
--- a/internal/generator/templates/command_promoted.go.tmpl
+++ b/internal/generator/templates/command_promoted.go.tmpl
@@ -84,6 +84,9 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
if err != nil {
return err
}
+{{- if .EffectiveTier}}
+ c = c.WithTier({{printf "%q" .EffectiveTier}})
+{{- end}}
path := "{{.Resource.BaseURL}}{{.Endpoint.Path}}"{{/* Resource.BaseURL is empty unless the resource declares an override; when set it makes path absolute and the client skips the c.BaseURL concat. */}}
{{- range $i, $p := .Endpoint.Params}}
diff --git a/internal/generator/templates/doctor.go.tmpl b/internal/generator/templates/doctor.go.tmpl
index fd6bfc80..4c49e24c 100644
--- a/internal/generator/templates/doctor.go.tmpl
+++ b/internal/generator/templates/doctor.go.tmpl
@@ -20,7 +20,7 @@ import (
{{- if .HasStore}}
"io"
{{- end}}
-{{- if or .Auth.EnvVars .HasStore .Auth.RequiresBrowserSession}}
+{{- if or .Auth.EnvVars .HasStore .Auth.RequiresBrowserSession .HasTierRouting}}
"os"
{{- end}}
{{- if or (eq .Auth.Type "cookie") (eq .Auth.Type "composed")}}
@@ -181,21 +181,42 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
{{- end}}
// Check auth environment variables
- {{- if .Auth.EnvVars}}
+{{- if .Auth.EnvVars}}
authEnvChecked := 0
authEnvSet := 0
- {{- range .Auth.EnvVars}}
+{{- range .Auth.EnvVars}}
authEnvChecked++
if os.Getenv("{{.}}") != "" {
authEnvSet++
}
- {{- end}}
+{{- end}}
if authEnvSet == 0 {
report["env_vars"] = fmt.Sprintf("none set (checked %d)", authEnvChecked)
} else {
report["env_vars"] = fmt.Sprintf("%d/%d set", authEnvSet, authEnvChecked)
}
- {{- end}}
+{{- end}}
+{{- if .HasTierRouting}}
+ tierEnvStatus := map[string]string{}
+{{- range $tierName, $tier := .TierRouting.Tiers}}
+{{- if $tier.Auth.EnvVars}}
+ {
+ checked := 0
+ set := 0
+{{- range $tier.Auth.EnvVars}}
+ checked++
+ if os.Getenv("{{.}}") != "" {
+ set++
+ }
+{{- end}}
+ tierEnvStatus["{{$tierName}}"] = fmt.Sprintf("%d/%d set", set, checked)
+ }
+{{- end}}
+{{- end}}
+ if len(tierEnvStatus) > 0 {
+ report["tier_env_vars"] = tierEnvStatus
+ }
+{{- end}}
// Check API connectivity and validate credentials.
//
diff --git a/internal/generator/templates/mcp_code_orch.go.tmpl b/internal/generator/templates/mcp_code_orch.go.tmpl
index 7aa8cf02..cf9029dd 100644
--- a/internal/generator/templates/mcp_code_orch.go.tmpl
+++ b/internal/generator/templates/mcp_code_orch.go.tmpl
@@ -56,6 +56,7 @@ type codeOrchEndpoint struct {
ID string
Method string
Path string
+ Tier string
Summary string
Positional []string
keywords []string
@@ -72,6 +73,9 @@ var codeOrchEndpoints = []codeOrchEndpoint{
ID: "{{$name}}.{{$eName}}",
Method: "{{upper $endpoint.Method}}",
Path: "{{$path}}",
+{{- if $.HasTierRouting}}
+ Tier: {{printf "%q" (effectiveTier $.APISpec $resource $endpoint)}},
+{{- end}}
Summary: {{printf "%q" (oneline $endpoint.Description)}},
Positional: []string{ {{- range $endpoint.Params}}{{if .Positional}}"{{.Name}}",{{end}}{{end}} },
keywords: codeOrchKeywords({{printf "%q" $name}}, {{printf "%q" $eName}}, {{printf "%q" (oneline $endpoint.Description)}}, {{printf "%q" $path}}),
@@ -84,6 +88,9 @@ var codeOrchEndpoints = []codeOrchEndpoint{
ID: "{{$name}}.{{$subName}}.{{$eName}}",
Method: "{{upper $endpoint.Method}}",
Path: "{{$path}}",
+{{- if $.HasTierRouting}}
+ Tier: {{printf "%q" (effectiveSubTier $.APISpec $resource $subResource $endpoint)}},
+{{- end}}
Summary: {{printf "%q" (oneline $endpoint.Description)}},
Positional: []string{ {{- range $endpoint.Params}}{{if .Positional}}"{{.Name}}",{{end}}{{end}} },
keywords: codeOrchKeywords({{printf "%q" $name}}, {{printf "%q" $eName}}, {{printf "%q" (oneline $endpoint.Description)}}, {{printf "%q" $path}}),
@@ -176,6 +183,9 @@ func handleCodeOrchSearch(ctx context.Context, req mcplib.CallToolRequest) (*mcp
"method": r.ep.Method,
"path": r.ep.Path,
"summary": r.ep.Summary,
+{{- if .HasTierRouting}}
+ "tier": r.ep.Tier,
+{{- end}}
"score": r.score,
})
}
@@ -210,6 +220,9 @@ func handleCodeOrchExecute(ctx context.Context, req mcplib.CallToolRequest) (*mc
if err != nil {
return mcplib.NewToolResultError(err.Error()), nil
}
+{{- if .HasTierRouting}}
+ c = c.WithTier(ep.Tier)
+{{- end}}
path := ep.Path
for _, p := range ep.Positional {
diff --git a/internal/generator/templates/mcp_intents.go.tmpl b/internal/generator/templates/mcp_intents.go.tmpl
index ff88ce53..bef73d39 100644
--- a/internal/generator/templates/mcp_intents.go.tmpl
+++ b/internal/generator/templates/mcp_intents.go.tmpl
@@ -143,6 +143,9 @@ func callIntentEndpoint(c *client.Client, ref string, params map[string]any) (an
if !ok {
return nil, fmt.Errorf("unknown endpoint %q", ref)
}
+{{- if .HasTierRouting}}
+ c = c.WithTier(ep.tier)
+{{- end}}
path := ep.path
query := map[string]string{}
@@ -200,6 +203,9 @@ func callIntentEndpoint(c *client.Client, ref string, params map[string]any) (an
type intentEndpointMeta struct {
method string
path string
+{{- if .HasTierRouting}}
+ tier string
+{{- end}}
}
// intentEndpoints is the generator-emitted lookup table covering every
@@ -210,8 +216,8 @@ type intentEndpointMeta struct {
var intentEndpoints = map[string]intentEndpointMeta{
{{- range .MCP.Intents}}
{{- range .Steps}}
-{{- $ep := lookupEndpoint $.Resources .Endpoint}}
- {{printf "%q" .Endpoint}}: {method: {{printf "%q" (upper $ep.Method)}}, path: {{printf "%q" $ep.EffectivePath}}},
+{{- $ep := lookupEndpoint $.APISpec .Endpoint}}
+ {{printf "%q" .Endpoint}}: {method: {{printf "%q" (upper $ep.Method)}}, path: {{printf "%q" $ep.EffectivePath}}{{if $.HasTierRouting}}, tier: {{printf "%q" $ep.EffectiveTier}}{{end}}},
{{- end}}
{{- end}}
}
diff --git a/internal/generator/templates/mcp_tools.go.tmpl b/internal/generator/templates/mcp_tools.go.tmpl
index db9ee8d6..06036158 100644
--- a/internal/generator/templates/mcp_tools.go.tmpl
+++ b/internal/generator/templates/mcp_tools.go.tmpl
@@ -38,7 +38,7 @@ func RegisterTools(s *server.MCPServer) {
{{- range $eName, $endpoint := $resource.Endpoints}}
s.AddTool(
mcplib.NewTool({{printf "%q" (printf "%s_%s" (snake $name) (snake $eName))}},
- mcplib.WithDescription({{printf "%q" (composeMCPDesc $endpoint $endpoint.NoAuth $.Auth.Type $.MCPPublicCount $.MCPTotalCount)}}),
+ mcplib.WithDescription({{printf "%q" (composeMCPDesc $.APISpec $resource $endpoint $.MCPPublicCount $.MCPTotalCount)}}),
{{- range $endpoint.Params}}
{{- if eq .Type "integer"}}
mcplib.WithNumber({{printf "%q" .Name}}{{if .Required}}, mcplib.Required(){{end}}, mcplib.Description({{printf "%q" (mcpParamDesc .)}})),
@@ -62,14 +62,18 @@ func RegisterTools(s *server.MCPServer) {
mcplib.WithOpenWorldHintAnnotation(true),
{{- end}}
),
+{{- if $.HasTierRouting}}
+ makeAPIHandler({{printf "%q" (upper $endpoint.Method)}}, {{printf "%q" (effectiveEndpointPath $resource $endpoint)}}, {{printf "%q" (effectiveTier $.APISpec $resource $endpoint)}}, []string{ {{- range $endpoint.Params}}{{if or .Positional .PathParam}}{{printf "%q" .Name}},{{end}}{{end}} }),
+{{- else}}
makeAPIHandler({{printf "%q" (upper $endpoint.Method)}}, {{printf "%q" (effectiveEndpointPath $resource $endpoint)}}, []string{ {{- range $endpoint.Params}}{{if or .Positional .PathParam}}{{printf "%q" .Name}},{{end}}{{end}} }),
+{{- end}}
)
{{- end}}
{{- range $subName, $subResource := $resource.SubResources}}
{{- range $eName, $endpoint := $subResource.Endpoints}}
s.AddTool(
mcplib.NewTool({{printf "%q" (printf "%s_%s_%s" (snake $name) (snake $subName) (snake $eName))}},
- mcplib.WithDescription({{printf "%q" (composeMCPDesc $endpoint $endpoint.NoAuth $.Auth.Type $.MCPPublicCount $.MCPTotalCount)}}),
+ mcplib.WithDescription({{printf "%q" (composeMCPSubDesc $.APISpec $resource $subResource $endpoint $.MCPPublicCount $.MCPTotalCount)}}),
{{- range $endpoint.Params}}
{{- if eq .Type "integer"}}
mcplib.WithNumber({{printf "%q" .Name}}{{if .Required}}, mcplib.Required(){{end}}, mcplib.Description({{printf "%q" (mcpParamDesc .)}})),
@@ -93,7 +97,11 @@ func RegisterTools(s *server.MCPServer) {
mcplib.WithOpenWorldHintAnnotation(true),
{{- end}}
),
+{{- if $.HasTierRouting}}
+ makeAPIHandler({{printf "%q" (upper $endpoint.Method)}}, {{printf "%q" (effectiveSubEndpointPath $resource $subResource $endpoint)}}, {{printf "%q" (effectiveSubTier $.APISpec $resource $subResource $endpoint)}}, []string{ {{- range $endpoint.Params}}{{if or .Positional .PathParam}}{{printf "%q" .Name}},{{end}}{{end}} }),
+{{- else}}
makeAPIHandler({{printf "%q" (upper $endpoint.Method)}}, {{printf "%q" (effectiveSubEndpointPath $resource $subResource $endpoint)}}, []string{ {{- range $endpoint.Params}}{{if or .Positional .PathParam}}{{printf "%q" .Name}},{{end}}{{end}} }),
+{{- end}}
)
{{- end}}
{{- end}}
@@ -149,12 +157,19 @@ func RegisterTools(s *server.MCPServer) {
}
// makeAPIHandler creates a generic MCP tool handler for an API endpoint.
+{{- if .HasTierRouting}}
+func makeAPIHandler(method, pathTemplate, tier string, positionalParams []string) server.ToolHandlerFunc {
+{{- else}}
func makeAPIHandler(method, pathTemplate string, positionalParams []string) server.ToolHandlerFunc {
+{{- end}}
return func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
c, err := newMCPClient()
if err != nil {
return mcplib.NewToolResultError(err.Error()), nil
}
+ {{- if .HasTierRouting}}
+ c = c.WithTier(tier)
+ {{- end}}
// mcp-go v0.47+ made CallToolParams.Arguments an `any` to support
// non-map payloads; GetArguments() returns the map[string]any shape
@@ -409,6 +424,36 @@ func handleContext(_ context.Context, _ mcplib.CallToolRequest) (*mcplib.CallToo
"key_url": {{printf "%q" .Auth.KeyURL}},
{{- end}}
},
+{{- end}}
+{{- if .HasTierRouting}}
+ "tier_routing": map[string]any{
+ "default_tier": {{printf "%q" .TierRouting.DefaultTier}},
+ "tiers": map[string]any{
+{{- range $tierName, $tier := .TierRouting.Tiers}}
+ {{printf "%q" $tierName}}: map[string]any{
+ "auth_type": {{printf "%q" $tier.Auth.Type}},
+{{- if $tier.BaseURL}}
+ "base_url": {{printf "%q" $tier.BaseURL}},
+{{- end}}
+{{- if $tier.Auth.EnvVars}}
+ "env_vars": []string{ {{- range $tier.Auth.EnvVars}}{{printf "%q" .}}, {{end}} },
+{{- end}}
+ },
+{{- end}}
+ },
+ "endpoints": map[string]string{
+{{- range $name, $resource := .Resources}}
+{{- range $eName, $endpoint := $resource.Endpoints}}
+ {{printf "%q" (printf "%s_%s" (snake $name) (snake $eName))}}: {{printf "%q" (effectiveTier $.APISpec $resource $endpoint)}},
+{{- end}}
+{{- range $subName, $subResource := $resource.SubResources}}
+{{- range $eName, $endpoint := $subResource.Endpoints}}
+ {{printf "%q" (printf "%s_%s_%s" (snake $name) (snake $subName) (snake $eName))}}: {{printf "%q" (effectiveSubTier $.APISpec $resource $subResource $endpoint)}},
+{{- end}}
+{{- end}}
+{{- end}}
+ },
+ },
{{- end}}
"resources": []map[string]any{
{{- range .DomainContext.Resources}}
diff --git a/internal/generator/templates/sync.go.tmpl b/internal/generator/templates/sync.go.tmpl
index 791d03a4..ef5b2a5a 100644
--- a/internal/generator/templates/sync.go.tmpl
+++ b/internal/generator/templates/sync.go.tmpl
@@ -15,6 +15,9 @@ import (
"sync/atomic"
"time"
+{{- if .HasTierRouting}}
+ "{{modulePath}}/internal/client"
+{{- end}}
"{{modulePath}}/internal/store"
"github.com/spf13/cobra"
)
@@ -164,7 +167,7 @@ Exit codes & warnings:
go func() {
defer wg.Done()
for resource := range work {
- res := syncResource(c, db, resource, sinceTS, full, maxPages{{if .Pagination.DateRangeParam}}, syncDates{{end}})
+ res := syncResource({{if .HasTierRouting}}syncClientForResource(c, resource){{else}}c{{end}}, db, resource, sinceTS, full, maxPages{{if .Pagination.DateRangeParam}}, syncDates{{end}})
results <- res
}
}()
@@ -897,6 +900,30 @@ func syncResourcePath(resource string) (string, error) {
return "", fmt.Errorf("unknown sync resource %q", resource)
}
+{{- if .HasTierRouting}}
+
+var syncResourceTiers = map[string]string{
+{{- range .SyncableResources}}
+{{- if .Tier}}
+ "{{.Name}}": {{printf "%q" .Tier}},
+{{- end}}
+{{- end}}
+{{- range .DependentSyncResources}}
+{{- if .Tier}}
+ "{{.Name}}": {{printf "%q" .Tier}},
+{{- end}}
+{{- end}}
+}
+
+func syncClientForResource(c *client.Client, resource string) *client.Client {
+ if tier, ok := syncResourceTiers[resource]; ok {
+ return c.WithTier(tier)
+ }
+ return c
+}
+
+{{- end}}
+
{{- if .DependentSyncResources}}
// dependentResourceDef describes a child resource that requires iterating parent IDs to sync.
@@ -916,13 +943,13 @@ func dependentResourceDefs() []dependentResourceDef {
}
// syncDependentResources iterates parent tables and syncs child resources per parent ID.
-func syncDependentResources(c interface {
+func syncDependentResources(c {{if .HasTierRouting}}*client.Client{{else}}interface {
Get(string, map[string]string) (json.RawMessage, error)
RateLimit() float64
-}, db *store.Store, sinceTS string, full bool, maxPages int{{if .Pagination.DateRangeParam}}, dates string{{end}}) []syncResult {
+}{{end}}, db *store.Store, sinceTS string, full bool, maxPages int{{if .Pagination.DateRangeParam}}, dates string{{end}}) []syncResult {
var results []syncResult
for _, dep := range dependentResourceDefs() {
- res := syncDependentResource(c, db, dep, sinceTS, full, maxPages{{if .Pagination.DateRangeParam}}, dates{{end}})
+ res := syncDependentResource({{if .HasTierRouting}}syncClientForResource(c, dep.Name){{else}}c{{end}}, db, dep, sinceTS, full, maxPages{{if .Pagination.DateRangeParam}}, dates{{end}})
results = append(results, res)
}
return results
diff --git a/internal/generator/tier_routing_test.go b/internal/generator/tier_routing_test.go
new file mode 100644
index 00000000..02e3b63c
--- /dev/null
+++ b/internal/generator/tier_routing_test.go
@@ -0,0 +1,172 @@
+package generator
+
+import (
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/mvanhorn/cli-printing-press/v3/internal/spec"
+ "github.com/stretchr/testify/require"
+)
+
+func TestTierRoutingEmitsTierAwareClientAndCommands(t *testing.T) {
+ t.Parallel()
+ apiSpec := minimalSpec("tiered")
+ apiSpec.TierRouting = spec.TierRoutingConfig{
+ DefaultTier: "free",
+ Tiers: map[string]spec.TierConfig{
+ "free": {
+ Auth: spec.AuthConfig{Type: "none"},
+ },
+ "paid": {
+ BaseURL: "https://paid.api.example.com",
+ Auth: spec.AuthConfig{
+ Type: "api_key",
+ In: "query",
+ Header: "api_key",
+ EnvVars: []string{"TIERED_PAID_KEY"},
+ },
+ },
+ "enterprise": {
+ Auth: spec.AuthConfig{
+ Type: "bearer_token",
+ Header: "Authorization",
+ Format: "Bearer {access_token}",
+ EnvVars: []string{"TIERED_ENTERPRISE_TOKEN"},
+ },
+ },
+ },
+ }
+ items := apiSpec.Resources["items"]
+ items.Endpoints["premium"] = spec.Endpoint{
+ Method: "GET",
+ Path: "/items/premium",
+ Description: "List premium items",
+ Tier: "paid",
+ }
+ items.Endpoints["enterprise"] = spec.Endpoint{
+ Method: "GET",
+ Path: "/items/enterprise",
+ Description: "List enterprise items",
+ Tier: "enterprise",
+ }
+ items.SubResources = map[string]spec.Resource{
+ "comments": {
+ Tier: "paid",
+ Endpoints: map[string]spec.Endpoint{
+ "list": {
+ Method: "GET",
+ Path: "/items/{item_id}/comments",
+ Description: "List paid comments",
+ Pagination: &spec.Pagination{Type: "cursor", CursorParam: "cursor", LimitParam: "limit"},
+ },
+ },
+ },
+ }
+ apiSpec.Resources["items"] = items
+ apiSpec.MCP = spec.MCPConfig{
+ Intents: []spec.Intent{
+ {
+ Name: "premium_lookup",
+ Description: "Look up premium items",
+ Steps: []spec.IntentStep{
+ {Endpoint: "items.premium", Capture: "premium"},
+ },
+ Returns: "premium",
+ },
+ },
+ }
+
+ outputDir := filepath.Join(t.TempDir(), "tiered-pp-cli")
+ require.NoError(t, New(apiSpec, outputDir).Generate())
+
+ clientSrc := readGeneratedFile(t, outputDir, "internal", "client", "client.go")
+ require.Contains(t, clientSrc, "requestTier string")
+ require.Contains(t, clientSrc, "limiters map[string]*cliutil.AdaptiveLimiter")
+ require.Contains(t, clientSrc, "next.limiter = c.limiterForTier(tier)")
+ require.Contains(t, clientSrc, `"paid": cliutil.NewAdaptiveLimiter(rateLimit)`)
+ require.Contains(t, clientSrc, `case "free":`)
+ require.Contains(t, clientSrc, `case "paid":`)
+ require.Contains(t, clientSrc, `return strings.TrimRight("https://paid.api.example.com", "/")`)
+ require.Contains(t, clientSrc, `os.Getenv("TIERED_PAID_KEY")`)
+ require.Contains(t, clientSrc, `"access_token": tierValue0`)
+ require.Contains(t, clientSrc, `q.Set(authInfo.Name, authHeader)`)
+ require.Contains(t, clientSrc, `key += "|tier=" + c.requestTier + "|base_url=" + c.baseURLForRequest()`)
+
+ freeCmd := readGeneratedFile(t, outputDir, "internal", "cli", "items_list.go")
+ require.Contains(t, freeCmd, `c = c.WithTier("free")`)
+ paidCmd := readGeneratedFile(t, outputDir, "internal", "cli", "items_premium.go")
+ require.Contains(t, paidCmd, `c = c.WithTier("paid")`)
+
+ mcpSrc := readGeneratedFile(t, outputDir, "internal", "mcp", "tools.go")
+ require.Contains(t, mcpSrc, `makeAPIHandler("GET", "/items", "free"`)
+ require.Contains(t, mcpSrc, `makeAPIHandler("GET", "/items/premium", "paid"`)
+ require.Contains(t, mcpSrc, `c = c.WithTier(tier)`)
+ require.Contains(t, mcpSrc, `"tier_routing": map[string]any`)
+ require.Contains(t, mcpSrc, `"items_premium": "paid"`)
+
+ intentsSrc := readGeneratedFile(t, outputDir, "internal", "mcp", "intents.go")
+ require.Contains(t, intentsSrc, `tier string`)
+ require.Contains(t, intentsSrc, `"items.premium": {method: "GET", path: "/items/premium", tier: "paid"}`)
+ require.Contains(t, intentsSrc, `c = c.WithTier(ep.tier)`)
+
+ syncSrc := readGeneratedFile(t, outputDir, "internal", "cli", "sync.go")
+ require.Contains(t, syncSrc, `func syncClientForResource(c *client.Client, resource string) *client.Client`)
+ require.Contains(t, syncSrc, `"items": "free"`)
+ require.Contains(t, syncSrc, `"comments": "paid"`)
+
+ doctorSrc := readGeneratedFile(t, outputDir, "internal", "cli", "doctor.go")
+ require.Contains(t, doctorSrc, `report["tier_env_vars"] = tierEnvStatus`)
+ require.Contains(t, doctorSrc, `os.Getenv("TIERED_PAID_KEY")`)
+
+ configSrc := readGeneratedFile(t, outputDir, "internal", "config", "config.go")
+ require.NotContains(t, configSrc, "TIERED_PAID_KEY",
+ "tier credentials must stay env-only and not become serialized config fields")
+
+ tidy := exec.Command("go", "mod", "tidy")
+ tidy.Dir = outputDir
+ tidyOutput, err := tidy.CombinedOutput()
+ require.NoError(t, err, string(tidyOutput))
+
+ cmd := exec.Command("go", "run", "./cmd/tiered-pp-cli", "items", "list", "--dry-run", "--json")
+ cmd.Dir = outputDir
+ cmd.Env = append(os.Environ(), "TIERED_PAID_KEY=")
+ output, err := cmd.CombinedOutput()
+ require.NoError(t, err, string(output))
+ require.NotContains(t, string(output), "TIERED_PAID_KEY")
+
+ cmd = exec.Command("go", "run", "./cmd/tiered-pp-cli", "items", "enterprise", "--dry-run", "--json")
+ cmd.Dir = outputDir
+ cmd.Env = append(os.Environ(), "TIERED_ENTERPRISE_TOKEN=enterprise-secret")
+ output, err = cmd.CombinedOutput()
+ require.NoError(t, err, string(output))
+ require.Contains(t, string(output), "Authorization: ****cret")
+
+ codeSpec := minimalSpec("tiered-code")
+ codeSpec.TierRouting = apiSpec.TierRouting
+ codeItems := codeSpec.Resources["items"]
+ codeItems.Endpoints["premium"] = spec.Endpoint{
+ Method: "GET",
+ Path: "/items/premium",
+ Description: "List premium items",
+ Tier: "paid",
+ }
+ codeSpec.Resources["items"] = codeItems
+ codeSpec.MCP = spec.MCPConfig{Orchestration: "code"}
+ codeOutputDir := filepath.Join(t.TempDir(), "tiered-code-pp-cli")
+ require.NoError(t, New(codeSpec, codeOutputDir).Generate())
+ codeOrchSrc := readGeneratedFile(t, codeOutputDir, "internal", "mcp", "code_orch.go")
+ require.Contains(t, codeOrchSrc, `Tier string`)
+ require.Contains(t, codeOrchSrc, `Tier: "paid"`)
+ require.Contains(t, codeOrchSrc, `"tier": r.ep.Tier`)
+ require.Contains(t, codeOrchSrc, `c = c.WithTier(ep.Tier)`)
+}
+
+func readGeneratedFile(t *testing.T, root string, parts ...string) string {
+ t.Helper()
+ data, err := os.ReadFile(filepath.Join(append([]string{root}, parts...)...))
+ require.NoError(t, err)
+ return strings.ReplaceAll(string(data), "\r\n", "\n")
+}
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index a931f271..0cbfca24 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -32,6 +32,12 @@ const (
extensionAuthKeyURL = "x-auth-key-url"
extensionAuthTitle = "x-auth-title"
extensionAuthDescription = "x-auth-description"
+ extensionTierRouting = "x-tier-routing"
+ extensionTier = "x-tier"
+ extensionAPIName = "x-api-name"
+ extensionDisplayName = "x-display-name"
+ extensionWebsite = "x-website"
+ extensionProxyRoutes = "x-proxy-routes"
)
// SetMaxResources overrides the default resource limit. When not called,
@@ -176,8 +182,8 @@ func parse(data []byte, lenient bool) (*spec.APISpec, error) {
if v := cleanSpecName(doc.Info.Title); v != "" && v != "api" {
name = v
}
- if name == "api" && doc.Info.Extensions != nil {
- if raw, ok := doc.Info.Extensions["x-api-name"]; ok {
+ if name == "api" {
+ if raw, ok := lookupOpenAPIInfoExtension(doc, extensionAPIName); ok {
if s, ok := raw.(string); ok {
if v := cleanSpecName(s); v != "" && v != "api" {
name = v
@@ -196,11 +202,9 @@ func parse(data []byte, lenient bool) (*spec.APISpec, error) {
// don't get flattened by EffectiveDisplayName's HumanName(slug)
// fallback (the slug is ASCII-folded for filesystem safety).
var displayName string
- if doc.Info != nil && doc.Info.Extensions != nil {
- if raw, ok := doc.Info.Extensions["x-display-name"]; ok {
- if s, ok := raw.(string); ok {
- displayName = strings.TrimSpace(s)
- }
+ if raw, ok := lookupOpenAPIInfoExtension(doc, extensionDisplayName); ok {
+ if s, ok := raw.(string); ok {
+ displayName = strings.TrimSpace(s)
}
}
if displayName == "" && doc.Info != nil {
@@ -215,8 +219,8 @@ func parse(data []byte, lenient bool) (*spec.APISpec, error) {
if doc.Info.Contact != nil && doc.Info.Contact.URL != "" {
websiteURL = doc.Info.Contact.URL
}
- if websiteURL == "" && doc.Info.Extensions != nil {
- if raw, ok := doc.Info.Extensions["x-website"]; ok {
+ if websiteURL == "" {
+ if raw, ok := lookupOpenAPIInfoExtension(doc, extensionWebsite); ok {
if s, ok := raw.(string); ok {
websiteURL = s
}
@@ -229,14 +233,12 @@ func parse(data []byte, lenient bool) (*spec.APISpec, error) {
// Extract x-proxy-routes extension for proxy-envelope client pattern
var proxyRoutes map[string]string
- if doc.Info != nil && doc.Info.Extensions != nil {
- if raw, ok := doc.Info.Extensions["x-proxy-routes"]; ok {
- if m, ok := raw.(map[string]any); ok {
- proxyRoutes = make(map[string]string, len(m))
- for k, v := range m {
- if s, ok := v.(string); ok {
- proxyRoutes[k] = s
- }
+ if raw, ok := lookupOpenAPIInfoExtension(doc, extensionProxyRoutes); ok {
+ if m, ok := raw.(map[string]any); ok {
+ proxyRoutes = make(map[string]string, len(m))
+ for k, v := range m {
+ if s, ok := v.(string); ok {
+ proxyRoutes[k] = s
}
}
}
@@ -268,6 +270,11 @@ func parse(data []byte, lenient bool) (*spec.APISpec, error) {
auth = spec.AuthConfig{Type: "none"}
}
+ tierRouting, err := parseTierRoutingExtension(doc)
+ if err != nil {
+ return nil, err
+ }
+
result := &spec.APISpec{
Name: name,
DisplayName: displayName,
@@ -278,6 +285,7 @@ func parse(data []byte, lenient bool) (*spec.APISpec, error) {
WebsiteURL: websiteURL,
ProxyRoutes: proxyRoutes,
Auth: auth,
+ TierRouting: tierRouting,
Config: spec.ConfigSpec{
Format: "toml",
Path: fmt.Sprintf("~/.config/%s-pp-cli/config.toml", name),
@@ -312,6 +320,44 @@ func parse(data []byte, lenient bool) (*spec.APISpec, error) {
return result, nil
}
+func parseTierRoutingExtension(doc *openapi3.T) (spec.TierRoutingConfig, error) {
+ raw, ok := lookupOpenAPIExtension(doc, extensionTierRouting)
+ if !ok {
+ return spec.TierRoutingConfig{}, nil
+ }
+ data, err := json.Marshal(raw)
+ if err != nil {
+ return spec.TierRoutingConfig{}, fmt.Errorf("marshaling %s: %w", extensionTierRouting, err)
+ }
+ var cfg spec.TierRoutingConfig
+ if err := json.Unmarshal(data, &cfg); err != nil {
+ return spec.TierRoutingConfig{}, fmt.Errorf("parsing %s: %w", extensionTierRouting, err)
+ }
+ return cfg, nil
+}
+
+func lookupOpenAPIExtension(doc *openapi3.T, key string) (any, bool) {
+ if doc != nil && doc.Extensions != nil {
+ if raw, ok := doc.Extensions[key]; ok {
+ return raw, true
+ }
+ }
+ if doc != nil && doc.Info != nil && doc.Info.Extensions != nil {
+ if raw, ok := doc.Info.Extensions[key]; ok {
+ return raw, true
+ }
+ }
+ return nil, false
+}
+
+func lookupOpenAPIInfoExtension(doc *openapi3.T, key string) (any, bool) {
+ if doc != nil && doc.Info != nil && doc.Info.Extensions != nil {
+ raw, ok := doc.Info.Extensions[key]
+ return raw, ok
+ }
+ return nil, false
+}
+
func mapAuth(doc *openapi3.T, name string) spec.AuthConfig {
auth := spec.AuthConfig{Type: "none"}
schemeName, scheme := selectSecurityScheme(doc)
@@ -1163,6 +1209,7 @@ func mapResources(doc *openapi3.T, out *spec.APISpec, basePath string) {
// disagree on the same identity.
pathResourceIDOverride := readPathItemResourceID(pathItem, path)
pathCritical := readPathItemCritical(pathItem, path)
+ pathTier := readTierExtension(pathItem.Extensions, fmt.Sprintf("path %q", path))
primaryName, subName := resourceAndSubFromPath(path, basePath, commonPrefix)
if primaryName == "" {
@@ -1263,6 +1310,10 @@ func mapResources(doc *openapi3.T, out *spec.APISpec, basePath string) {
Params: params,
Body: body,
}
+ endpoint.Tier = readTierExtension(op.Extensions, fmt.Sprintf("%s %q", strings.ToUpper(method), path))
+ if endpoint.Tier == "" {
+ endpoint.Tier = pathTier
+ }
endpoint.Response, endpoint.ResponsePath = mapResponse(op, endpointName)
if strings.ToUpper(method) == "GET" {
@@ -2200,6 +2251,22 @@ func readPathItemCritical(pathItem *openapi3.PathItem, path string) bool {
}
}
+func readTierExtension(extensions map[string]any, context string) string {
+ if extensions == nil {
+ return ""
+ }
+ raw, ok := extensions[extensionTier]
+ if !ok {
+ return ""
+ }
+ tier, ok := raw.(string)
+ if !ok {
+ warnf("%s: %s must be a string, got %T; ignoring", context, extensionTier, raw)
+ return ""
+ }
+ return strings.TrimSpace(tier)
+}
+
// resolveIDFieldFromResponseSchema implements tiers 2-4 of the IDField fallback
// chain: prefer "id", then "name", then the first scalar field listed in the
// response schema's `required:` array (walking properties in their schema order).
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index 961cdcef..a9b128ef 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -1510,6 +1510,162 @@ paths:
})
}
+func TestParseTierRoutingExtensions(t *testing.T) {
+ t.Parallel()
+ data := []byte(`
+openapi: 3.0.3
+info:
+ title: Tiered API
+ version: 1.0.0
+servers:
+ - url: https://api.example.com
+x-tier-routing:
+ default_tier: free
+ tiers:
+ free:
+ auth:
+ type: none
+ paid:
+ base_url: https://paid.api.example.com
+ auth:
+ type: api_key
+ in: query
+ header: api_key
+ env_vars: [TIERED_PAID_KEY]
+security:
+ - ApiKeyAuth: []
+components:
+ securitySchemes:
+ ApiKeyAuth:
+ type: apiKey
+ in: header
+ name: X-API-Key
+paths:
+ /items:
+ x-tier: free
+ get:
+ summary: List items
+ responses:
+ "200":
+ description: ok
+ /items/{id}:
+ get:
+ summary: Get item
+ x-tier: paid
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: ok
+`)
+
+ parsed, err := Parse(data)
+ require.NoError(t, err)
+ require.True(t, parsed.HasTierRouting())
+ assert.Equal(t, "free", parsed.TierRouting.DefaultTier)
+ assert.Equal(t, "none", parsed.TierRouting.Tiers["free"].Auth.Type)
+ assert.Equal(t, "https://paid.api.example.com", parsed.TierRouting.Tiers["paid"].BaseURL)
+ items := parsed.Resources["items"]
+ require.NotNil(t, items.Endpoints)
+ assert.Equal(t, "free", findEndpointByPath(items, "/items").Tier)
+ assert.Equal(t, "paid", findEndpointByPath(items, "/items/{id}").Tier)
+}
+
+func TestParseTierRoutingExtensionFromInfo(t *testing.T) {
+ t.Parallel()
+ data := []byte(`
+openapi: 3.0.3
+info:
+ title: Tiered API
+ version: 1.0.0
+ x-tier-routing:
+ default_tier: free
+ tiers:
+ free:
+ auth:
+ type: none
+servers:
+ - url: https://api.example.com
+paths:
+ /items:
+ get:
+ summary: List items
+ responses:
+ "200":
+ description: ok
+`)
+
+ parsed, err := Parse(data)
+ require.NoError(t, err)
+ require.True(t, parsed.HasTierRouting())
+ assert.Equal(t, "free", parsed.TierRouting.DefaultTier)
+ assert.Equal(t, "none", parsed.TierRouting.Tiers["free"].Auth.Type)
+}
+
+func TestParseTierRoutingRejectsAnonymousSecurityOnCredentialTier(t *testing.T) {
+ t.Parallel()
+ data := []byte(`
+openapi: 3.0.3
+info:
+ title: Contradictory Tier API
+ version: 1.0.0
+servers:
+ - url: https://api.example.com
+x-tier-routing:
+ default_tier: free
+ tiers:
+ free:
+ auth:
+ type: none
+ paid:
+ auth:
+ type: bearer_token
+ env_vars: [TIERED_PAID_TOKEN]
+security:
+ - ApiKeyAuth: []
+components:
+ securitySchemes:
+ ApiKeyAuth:
+ type: apiKey
+ in: header
+ name: X-API-Key
+paths:
+ /items:
+ get:
+ summary: List paid items
+ x-tier: paid
+ security: []
+ responses:
+ "200":
+ description: ok
+`)
+
+ _, err := Parse(data)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "no_auth")
+ assert.Contains(t, err.Error(), "paid")
+}
+
+func findEndpointByPath(resource spec.Resource, path string) spec.Endpoint {
+ for _, endpoint := range resource.Endpoints {
+ if endpoint.Path == path {
+ return endpoint
+ }
+ }
+ for _, sub := range resource.SubResources {
+ for _, endpoint := range sub.Endpoints {
+ if endpoint.Path == path {
+ return endpoint
+ }
+ }
+ }
+ return spec.Endpoint{}
+}
+
func TestPathPriorityScore(t *testing.T) {
t.Parallel()
diff --git a/internal/pipeline/climanifest.go b/internal/pipeline/climanifest.go
index f19e9edd..264a49f3 100644
--- a/internal/pipeline/climanifest.go
+++ b/internal/pipeline/climanifest.go
@@ -9,6 +9,7 @@ import (
"os"
"path/filepath"
"reflect"
+ "sort"
"strings"
"time"
@@ -266,7 +267,7 @@ func populateMCPMetadata(m *CLIManifest, parsed *spec.APISpec) {
m.MCPPublicToolCount = public
m.MCPReady = computeMCPReady(parsed.Auth.Type)
m.AuthType = parsed.Auth.Type
- m.AuthEnvVars = parsed.Auth.EnvVars
+ m.AuthEnvVars = manifestAuthEnvVars(parsed)
m.EndpointTemplateVars = parsed.EndpointTemplateVars
m.AuthKeyURL = parsed.Auth.KeyURL
m.AuthTitle = parsed.Auth.Title
@@ -288,6 +289,36 @@ func populateMCPMetadata(m *CLIManifest, parsed *spec.APISpec) {
}
}
+func manifestAuthEnvVars(parsed *spec.APISpec) []string {
+ if parsed == nil {
+ return nil
+ }
+ seen := make(map[string]struct{})
+ var names []string
+ add := func(envVars []string) {
+ for _, name := range envVars {
+ if name == "" {
+ continue
+ }
+ if _, ok := seen[name]; ok {
+ continue
+ }
+ seen[name] = struct{}{}
+ names = append(names, name)
+ }
+ }
+ add(parsed.Auth.EnvVars)
+ tierNames := make([]string, 0, len(parsed.TierRouting.Tiers))
+ for name := range parsed.TierRouting.Tiers {
+ tierNames = append(tierNames, name)
+ }
+ sort.Strings(tierNames)
+ for _, name := range tierNames {
+ add(parsed.TierRouting.Tiers[name].Auth.EnvVars)
+ }
+ return names
+}
+
// GenerateManifestParams holds the information available at generate time
// for writing a CLI manifest. Unlike PublishWorkingCLI (which has full
// PipelineState), the standalone generate command only knows the spec
diff --git a/internal/pipeline/climanifest_test.go b/internal/pipeline/climanifest_test.go
index 4b83a8df..199b68d2 100644
--- a/internal/pipeline/climanifest_test.go
+++ b/internal/pipeline/climanifest_test.go
@@ -1078,6 +1078,37 @@ func TestPopulateMCPMetadata(t *testing.T) {
assert.Equal(t, "Use this test credential.", m.AuthDescription)
}
+func TestPopulateMCPMetadataIncludesTierEnvVars(t *testing.T) {
+ var m CLIManifest
+ populateMCPMetadata(&m, &spec.APISpec{
+ Name: "tiered",
+ Auth: spec.AuthConfig{
+ Type: "bearer_token",
+ EnvVars: []string{"GLOBAL_TOKEN"},
+ },
+ TierRouting: spec.TierRoutingConfig{
+ Tiers: map[string]spec.TierConfig{
+ "free": {Auth: spec.AuthConfig{Type: "none"}},
+ "paid": {
+ Auth: spec.AuthConfig{
+ Type: "api_key",
+ EnvVars: []string{"PAID_KEY", "GLOBAL_TOKEN"},
+ },
+ },
+ },
+ },
+ Resources: map[string]spec.Resource{
+ "items": {
+ Endpoints: map[string]spec.Endpoint{
+ "list": {Method: "GET", Path: "/items"},
+ },
+ },
+ },
+ })
+
+ assert.Equal(t, []string{"GLOBAL_TOKEN", "PAID_KEY"}, m.AuthEnvVars)
+}
+
// TestPopulateMCPMetadataDisplayNamePrecedence pins:
//
// spec.DisplayName (explicit) > existing m.DisplayName (catalog) > EffectiveDisplayName fallback
diff --git a/internal/pipeline/toolsmanifest.go b/internal/pipeline/toolsmanifest.go
index 47aff511..ad97966c 100644
--- a/internal/pipeline/toolsmanifest.go
+++ b/internal/pipeline/toolsmanifest.go
@@ -31,6 +31,7 @@ type ToolsManifest struct {
MCPReady string `json:"mcp_ready"`
HTTPTransport string `json:"http_transport,omitempty"`
Auth ManifestAuth `json:"auth"`
+ TierRouting *ManifestTiers `json:"tier_routing,omitempty"`
RequiredHeaders []ManifestHeader `json:"required_headers"`
Tools []ManifestTool `json:"tools"`
}
@@ -50,12 +51,27 @@ type ManifestAuth struct {
BrowserSessionValidationMethod string `json:"browser_session_validation_method,omitempty"`
}
+// ManifestTiers records per-tier routing and auth metadata so audit/doctor
+// consumers can reason about generated CLIs whose endpoint credentials differ
+// from the global auth block.
+type ManifestTiers struct {
+ DefaultTier string `json:"default_tier,omitempty"`
+ Tiers map[string]ManifestTier `json:"tiers,omitempty"`
+}
+
+type ManifestTier struct {
+ BaseURL string `json:"base_url,omitempty"`
+ Auth ManifestAuth `json:"auth"`
+ AllowCrossHostAuth bool `json:"allow_cross_host_auth,omitempty"`
+}
+
// ManifestTool describes a single MCP tool derived from an API endpoint.
type ManifestTool struct {
Name string `json:"name"`
Description string `json:"description"`
Method string `json:"method"`
Path string `json:"path"`
+ Tier string `json:"tier,omitempty"`
NoAuth bool `json:"no_auth,omitempty"`
Params []ManifestParam `json:"params"`
HeaderOverrides []ManifestHeader `json:"header_overrides,omitempty"`
@@ -104,34 +120,25 @@ func WriteToolsManifest(dir string, parsed *spec.APISpec) error {
return fmt.Errorf("parsed spec is nil")
}
- total, public := parsed.CountMCPTools()
+ endpoints := manifestEndpointRecords(parsed)
+ total, public := manifestToolCounts(endpoints)
mcpReady := computeMCPReady(parsed.Auth.Type)
- // For cookie/composed auth, only include NoAuth endpoints.
- cookieOrComposed := parsed.Auth.Type == "cookie" || parsed.Auth.Type == "composed"
- paramDescriptions := mcpdesc.NewParamDescriptionCompactorForEndpoints(manifestEndpoints(parsed, cookieOrComposed))
+ paramDescriptions := mcpdesc.NewParamDescriptionCompactorForEndpoints(manifestEndpoints(endpoints))
manifest := ToolsManifest{
- APIName: parsed.Name,
- BaseURL: parsed.BaseURL,
- Description: parsed.Description,
- MCPReady: mcpReady,
- HTTPTransport: parsed.EffectiveHTTPTransport(),
- Auth: ManifestAuth{
- Type: parsed.Auth.Type,
- Header: parsed.Auth.Header,
- Format: normalizeAuthFormat(parsed.Auth.Format, parsed.Auth.EnvVars),
- In: parsed.Auth.In,
- EnvVars: parsed.Auth.EnvVars,
- KeyURL: parsed.Auth.KeyURL,
- CookieDomain: parsed.Auth.CookieDomain,
- RequiresBrowserSession: parsed.Auth.RequiresBrowserSession,
- BrowserSessionValidationPath: parsed.Auth.BrowserSessionValidationPath,
- BrowserSessionValidationMethod: parsed.Auth.BrowserSessionValidationMethod,
- },
+ APIName: parsed.Name,
+ BaseURL: parsed.BaseURL,
+ Description: parsed.Description,
+ MCPReady: mcpReady,
+ HTTPTransport: parsed.EffectiveHTTPTransport(),
+ Auth: manifestAuth(parsed.Auth),
RequiredHeaders: make([]ManifestHeader, 0, len(parsed.RequiredHeaders)),
Tools: make([]ManifestTool, 0),
}
+ if parsed.HasTierRouting() {
+ manifest.TierRouting = buildManifestTiers(parsed.TierRouting)
+ }
for _, rh := range parsed.RequiredHeaders {
manifest.RequiredHeaders = append(manifest.RequiredHeaders, ManifestHeader{
@@ -140,64 +147,139 @@ func WriteToolsManifest(dir string, parsed *spec.APISpec) error {
})
}
- // Iterate resources in sorted order for deterministic output.
+ for _, endpoint := range endpoints {
+ desc := mcpdesc.Compose(mcpdesc.Input{
+ Endpoint: endpoint.Endpoint,
+ NoAuth: endpoint.NoAuth,
+ AuthType: endpoint.AuthType,
+ PublicCount: public,
+ TotalCount: total,
+ })
+ tool := buildManifestTool(endpoint.ToolName, desc, endpoint.Endpoint, paramDescriptions.Description)
+ tool.Tier = endpoint.Tier
+ tool.NoAuth = endpoint.NoAuth
+ manifest.Tools = append(manifest.Tools, tool)
+ }
+
+ data, err := json.MarshalIndent(manifest, "", " ")
+ if err != nil {
+ return fmt.Errorf("marshaling tools manifest: %w", err)
+ }
+ data = append(data, '\n')
+
+ if err := os.WriteFile(filepath.Join(dir, ToolsManifestFilename), data, 0o644); err != nil {
+ return fmt.Errorf("writing tools manifest: %w", err)
+ }
+ return nil
+}
+
+func buildManifestTiers(tierRouting spec.TierRoutingConfig) *ManifestTiers {
+ tiers := make(map[string]ManifestTier, len(tierRouting.Tiers))
+ for name, tier := range tierRouting.Tiers {
+ tiers[name] = ManifestTier{
+ BaseURL: tier.BaseURL,
+ Auth: manifestAuth(tier.Auth),
+ AllowCrossHostAuth: tier.AllowCrossHostAuth,
+ }
+ }
+ return &ManifestTiers{
+ DefaultTier: tierRouting.DefaultTier,
+ Tiers: tiers,
+ }
+}
+
+func manifestAuth(auth spec.AuthConfig) ManifestAuth {
+ return ManifestAuth{
+ Type: auth.Type,
+ Header: auth.Header,
+ Format: normalizeAuthFormat(auth.Format, auth.EnvVars),
+ In: auth.In,
+ EnvVars: auth.EnvVars,
+ KeyURL: auth.KeyURL,
+ CookieDomain: auth.CookieDomain,
+ RequiresBrowserSession: auth.RequiresBrowserSession,
+ BrowserSessionValidationPath: auth.BrowserSessionValidationPath,
+ BrowserSessionValidationMethod: auth.BrowserSessionValidationMethod,
+ }
+}
+
+type manifestEndpointRecord struct {
+ ToolName string
+ Endpoint spec.Endpoint
+ Tier string
+ NoAuth bool
+ AuthType string
+}
+
+func manifestEndpointRecords(parsed *spec.APISpec) []manifestEndpointRecord {
+ if parsed == nil {
+ return nil
+ }
+ var records []manifestEndpointRecord
resourceNames := sortedResourceKeys(parsed.Resources)
for _, rName := range resourceNames {
resource := parsed.Resources[rName]
-
- // Top-level endpoints
endpointNames := sortedEndpointKeys(resource.Endpoints)
for _, eName := range endpointNames {
endpoint := resource.Endpoints[eName]
- if cookieOrComposed && !endpoint.NoAuth {
+ noAuth, authType, include := effectiveManifestEndpointAuth(parsed, resource, endpoint)
+ if !include {
continue
}
- toolName := mcpoverrides.ToolName(rName, "", eName)
- desc := mcpdesc.Compose(mcpdesc.Input{
- Endpoint: endpoint,
- NoAuth: endpoint.NoAuth,
- AuthType: parsed.Auth.Type,
- PublicCount: public,
- TotalCount: total,
+ records = append(records, manifestEndpointRecord{
+ ToolName: mcpoverrides.ToolName(rName, "", eName),
+ Endpoint: endpoint,
+ Tier: parsed.EffectiveTier(resource, endpoint),
+ NoAuth: noAuth,
+ AuthType: authType,
})
- tool := buildManifestTool(toolName, desc, endpoint, paramDescriptions.Description)
- manifest.Tools = append(manifest.Tools, tool)
}
-
- // Sub-resources
subNames := sortedResourceKeys(resource.SubResources)
for _, subName := range subNames {
subResource := resource.SubResources[subName]
+ effectiveSub := subResource
+ if effectiveSub.Tier == "" {
+ effectiveSub.Tier = resource.Tier
+ }
subEndpointNames := sortedEndpointKeys(subResource.Endpoints)
for _, eName := range subEndpointNames {
endpoint := subResource.Endpoints[eName]
- if cookieOrComposed && !endpoint.NoAuth {
+ noAuth, authType, include := effectiveManifestEndpointAuth(parsed, effectiveSub, endpoint)
+ if !include {
continue
}
- toolName := mcpoverrides.ToolName(rName, subName, eName)
- desc := mcpdesc.Compose(mcpdesc.Input{
- Endpoint: endpoint,
- NoAuth: endpoint.NoAuth,
- AuthType: parsed.Auth.Type,
- PublicCount: public,
- TotalCount: total,
+ records = append(records, manifestEndpointRecord{
+ ToolName: mcpoverrides.ToolName(rName, subName, eName),
+ Endpoint: endpoint,
+ Tier: parsed.EffectiveTier(effectiveSub, endpoint),
+ NoAuth: noAuth,
+ AuthType: authType,
})
- tool := buildManifestTool(toolName, desc, endpoint, paramDescriptions.Description)
- manifest.Tools = append(manifest.Tools, tool)
}
}
}
+ return records
+}
- data, err := json.MarshalIndent(manifest, "", " ")
- if err != nil {
- return fmt.Errorf("marshaling tools manifest: %w", err)
+func manifestToolCounts(records []manifestEndpointRecord) (total, public int) {
+ for _, record := range records {
+ total++
+ if record.NoAuth {
+ public++
+ }
}
- data = append(data, '\n')
+ return total, public
+}
- if err := os.WriteFile(filepath.Join(dir, ToolsManifestFilename), data, 0o644); err != nil {
- return fmt.Errorf("writing tools manifest: %w", err)
+func effectiveManifestEndpointAuth(parsed *spec.APISpec, resource spec.Resource, endpoint spec.Endpoint) (noAuth bool, authType string, include bool) {
+ authType, noAuth = parsed.EffectiveEndpointAuth(resource, endpoint)
+ if noAuth {
+ return true, authType, true
}
- return nil
+ if parsed.Auth.Type == "cookie" || parsed.Auth.Type == "composed" {
+ return false, authType, authType != parsed.Auth.Type
+ }
+ return noAuth, authType, true
}
// buildManifestTool creates a ManifestTool from an endpoint, classifying
@@ -260,21 +342,10 @@ func buildManifestTool(name, description string, ep spec.Endpoint, describeParam
return tool
}
-func manifestEndpoints(parsed *spec.APISpec, cookieOrComposed bool) []spec.Endpoint {
- var endpoints []spec.Endpoint
- for _, resource := range parsed.Resources {
- for _, endpoint := range resource.Endpoints {
- if !cookieOrComposed || endpoint.NoAuth {
- endpoints = append(endpoints, endpoint)
- }
- }
- for _, subResource := range resource.SubResources {
- for _, endpoint := range subResource.Endpoints {
- if !cookieOrComposed || endpoint.NoAuth {
- endpoints = append(endpoints, endpoint)
- }
- }
- }
+func manifestEndpoints(records []manifestEndpointRecord) []spec.Endpoint {
+ endpoints := make([]spec.Endpoint, 0, len(records))
+ for _, record := range records {
+ endpoints = append(endpoints, record.Endpoint)
}
return endpoints
}
diff --git a/internal/pipeline/toolsmanifest_test.go b/internal/pipeline/toolsmanifest_test.go
index 69f98650..49b3cd76 100644
--- a/internal/pipeline/toolsmanifest_test.go
+++ b/internal/pipeline/toolsmanifest_test.go
@@ -876,6 +876,99 @@ func TestWriteToolsManifest_EmptyParamType(t *testing.T) {
assert.Equal(t, "string", got.Tools[0].Params[0].Type, "empty type should default to string")
}
+func TestWriteToolsManifest_TierRoutingMetadata(t *testing.T) {
+ dir := t.TempDir()
+ parsed := &spec.APISpec{
+ Name: "tiered",
+ BaseURL: "https://api.example.com",
+ Auth: spec.AuthConfig{Type: "bearer_token", EnvVars: []string{"GLOBAL_TOKEN"}},
+ TierRouting: spec.TierRoutingConfig{
+ DefaultTier: "free",
+ Tiers: map[string]spec.TierConfig{
+ "free": {Auth: spec.AuthConfig{Type: "none"}},
+ "paid": {
+ BaseURL: "https://paid.api.example.com",
+ Auth: spec.AuthConfig{
+ Type: "api_key",
+ In: "query",
+ Header: "api_key",
+ EnvVars: []string{"PAID_KEY"},
+ },
+ },
+ },
+ },
+ Resources: map[string]spec.Resource{
+ "items": {
+ Tier: "free",
+ Endpoints: map[string]spec.Endpoint{
+ "list": {Method: "GET", Path: "/items"},
+ "premium": {Method: "GET", Path: "/items/premium", Tier: "paid"},
+ },
+ },
+ },
+ }
+
+ require.NoError(t, WriteToolsManifest(dir, parsed))
+ got, err := ReadToolsManifest(dir)
+ require.NoError(t, err)
+
+ require.NotNil(t, got.TierRouting)
+ assert.Equal(t, "free", got.TierRouting.DefaultTier)
+ assert.Equal(t, "none", got.TierRouting.Tiers["free"].Auth.Type)
+ assert.Equal(t, "https://paid.api.example.com", got.TierRouting.Tiers["paid"].BaseURL)
+ assert.Equal(t, []string{"PAID_KEY"}, got.TierRouting.Tiers["paid"].Auth.EnvVars)
+
+ require.Len(t, got.Tools, 2)
+ assert.Equal(t, "free", got.Tools[0].Tier)
+ assert.Equal(t, "paid", got.Tools[1].Tier)
+}
+
+func TestWriteToolsManifest_TierRoutingEffectiveAuth(t *testing.T) {
+ dir := t.TempDir()
+ parsed := &spec.APISpec{
+ Name: "tiered",
+ BaseURL: "https://api.example.com",
+ Auth: spec.AuthConfig{Type: "cookie", EnvVars: []string{"SESSION_COOKIE"}},
+ TierRouting: spec.TierRoutingConfig{
+ Tiers: map[string]spec.TierConfig{
+ "free": {Auth: spec.AuthConfig{Type: "none"}},
+ "paid": {
+ Auth: spec.AuthConfig{
+ Type: "api_key",
+ Header: "api_key",
+ EnvVars: []string{"PAID_KEY"},
+ },
+ },
+ },
+ },
+ Resources: map[string]spec.Resource{
+ "items": {
+ Tier: "free",
+ Endpoints: map[string]spec.Endpoint{
+ "list": {Method: "GET", Path: "/items"},
+ "premium": {Method: "GET", Path: "/items/premium", Tier: "paid"},
+ },
+ },
+ "global": {
+ Endpoints: map[string]spec.Endpoint{
+ "list": {Method: "GET", Path: "/global"},
+ },
+ },
+ },
+ }
+
+ require.NoError(t, WriteToolsManifest(dir, parsed))
+ got, err := ReadToolsManifest(dir)
+ require.NoError(t, err)
+
+ require.Len(t, got.Tools, 2)
+ assert.Equal(t, "items_list", got.Tools[0].Name)
+ assert.True(t, got.Tools[0].NoAuth)
+ assert.Equal(t, "items_premium", got.Tools[1].Name)
+ assert.False(t, got.Tools[1].NoAuth)
+ assert.Equal(t, "paid", got.Tools[1].Tier)
+}
+
func TestNormalizeAuthFormat(t *testing.T) {
tests := []struct {
name string
diff --git a/internal/profiler/profiler.go b/internal/profiler/profiler.go
index b53af569..3eff13f1 100644
--- a/internal/profiler/profiler.go
+++ b/internal/profiler/profiler.go
@@ -72,6 +72,7 @@ type DiscriminatorDispatch struct {
type SyncableResource struct {
Name string
Path string
+ Tier string
// IDField is the resolved primary-key field name for items returned by the
// list endpoint, populated from the chosen endpoint's resolved value (in
// turn populated by the OpenAPI parser's `x-resource-id` extension or the
@@ -96,6 +97,7 @@ type DependentResource struct {
ParentResource string // parent resource name, e.g. "channels"
ParentIDParam string // path param name, e.g. "channel_id"
Path string // full path template, e.g. "/channels/{channel_id}/messages"
+ Tier string
// IDField is the primary-key field name resolved from the spec
// (x-resource-id extension or the four-tier fallback chain). Empty when
@@ -180,8 +182,11 @@ func Profile(s *spec.APISpec) *APIProfile {
dateRangeParams := make(map[string]int)
responsePaths := make(map[string]int)
- var walk func(name string, r spec.Resource)
- walk = func(name string, r spec.Resource) {
+ var walk func(name string, r spec.Resource, inheritedTier string)
+ walk = func(name string, r spec.Resource, inheritedTier string) {
+ if r.Tier == "" {
+ r.Tier = inheritedTier
+ }
resourceName := strings.ToLower(name)
resourceHasGet := false
resourceHasPost := false
@@ -300,7 +305,7 @@ func Profile(s *spec.APISpec) *APIProfile {
// like GetFriendList?steamid=REQUIRED that need a parent ID)
if !strings.Contains(endpoint.Path, "{") && !hasRequiredScopeParams(endpoint) {
if existing, ok := syncable[resourceName]; !ok || len(endpoint.Path) < len(existing.Path) {
- syncable[resourceName] = metaFromEndpoint(endpoint, s.Types, resourceNameIndex)
+ syncable[resourceName] = metaFromEndpoint(s, r, endpoint, s.Types, resourceNameIndex)
}
}
@@ -319,7 +324,7 @@ func Profile(s *spec.APISpec) *APIProfile {
// Enum-expanded paths are more specific than generic resource
// paths, so they always win on name collision. This ensures
// deterministic output regardless of Go map iteration order.
- meta := metaFromEndpoint(endpoint, s.Types, resourceNameIndex)
+ meta := metaFromEndpoint(s, r, endpoint, s.Types, resourceNameIndex)
meta.Path = expandedPath
syncable[expandedName] = meta
}
@@ -330,13 +335,13 @@ func Profile(s *spec.APISpec) *APIProfile {
// annotations on a child path-item flow into the override
// and critical-resource maps.
if _, ok := parameterized[resourceName]; !ok {
- parameterized[resourceName] = metaFromEndpoint(endpoint, s.Types, resourceNameIndex)
+ parameterized[resourceName] = metaFromEndpoint(s, r, endpoint, s.Types, resourceNameIndex)
}
} else {
// Paginated endpoints override the path set above — they have
// richer pagination support for full data retrieval.
if existing, ok := syncable[resourceName]; !ok || len(endpoint.Path) < len(existing.Path) {
- syncable[resourceName] = metaFromEndpoint(endpoint, s.Types, resourceNameIndex)
+ syncable[resourceName] = metaFromEndpoint(s, r, endpoint, s.Types, resourceNameIndex)
}
}
}
@@ -347,7 +352,7 @@ func Profile(s *spec.APISpec) *APIProfile {
// Only include endpoints whose name suggests a collection (list, all,
// index, etc.) — exclude singular getters like "get" or "show".
if existing, ok := syncable[resourceName]; !ok || len(endpoint.Path) < len(existing.Path) {
- syncable[resourceName] = metaFromEndpoint(endpoint, s.Types, resourceNameIndex)
+ syncable[resourceName] = metaFromEndpoint(s, r, endpoint, s.Types, resourceNameIndex)
}
}
@@ -408,12 +413,12 @@ func Profile(s *spec.APISpec) *APIProfile {
subNames := sortedKeys(r.SubResources)
for _, subName := range subNames {
sub := r.SubResources[subName]
- walk(subName, sub)
+ walk(subName, sub, r.Tier)
}
}
for name, resource := range s.Resources {
- walk(name, resource)
+ walk(name, resource, "")
}
if p.TotalEndpoints > 0 {
@@ -960,6 +965,7 @@ func detectDependentResources(parameterized map[string]syncableMeta, syncable ma
ParentResource: parentResource,
ParentIDParam: paramName,
Path: path,
+ Tier: meta.Tier,
IDField: meta.IDField,
Critical: meta.Critical,
Discriminator: meta.Discriminator,
@@ -977,6 +983,7 @@ func detectDependentResources(parameterized map[string]syncableMeta, syncable ma
// converted into a SyncableResource at the end of Profile().
type syncableMeta struct {
Path string
+ Tier string
IDField string
Critical bool
Discriminator DiscriminatorDispatch
@@ -986,9 +993,10 @@ type syncableMeta struct {
// from path-item-level extensions (or, for IDField, from response-schema
// inference). Keeps the per-endpoint plumbing in one place so future profiler
// fields propagate uniformly.
-func metaFromEndpoint(e spec.Endpoint, types map[string]spec.TypeDef, resourceNameIndex map[string]string) syncableMeta {
+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,
Discriminator: discriminatorDispatchForEndpoint(e, types, resourceNameIndex),
@@ -1104,6 +1112,7 @@ func sortedSyncableResources(m map[string]syncableMeta) []SyncableResource {
resources[i] = SyncableResource{
Name: name,
Path: meta.Path,
+ Tier: meta.Tier,
IDField: meta.IDField,
Critical: meta.Critical,
Discriminator: meta.Discriminator,
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 7488eb4d..649044c5 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -3,6 +3,8 @@ package spec
import (
"encoding/json"
"fmt"
+ "net/netip"
+ "net/url"
"os"
"regexp"
"strings"
@@ -31,6 +33,15 @@ const (
ResponseFormatHTML = "html"
)
+const (
+ TierAuthTypeNone = "none"
+ TierAuthTypeAPIKey = "api_key"
+ TierAuthTypeBearerToken = "bearer_token"
+
+ TierAuthPlacementHeader = "header"
+ TierAuthPlacementQuery = "query"
+)
+
const (
HTMLExtractModePage = "page"
HTMLExtractModeLinks = "links"
@@ -97,6 +108,7 @@ type APISpec struct {
WebsiteURL string `yaml:"website_url,omitempty" json:"website_url,omitempty"` // product/company website (not the API base URL)
Category string `yaml:"category,omitempty" json:"category,omitempty"` // catalog category (e.g., productivity, developer-tools) — used for library install path
Auth AuthConfig `yaml:"auth" json:"auth"`
+ TierRouting TierRoutingConfig `yaml:"tier_routing,omitempty" json:"tier_routing,omitzero"`
RequiredHeaders []RequiredHeader `yaml:"required_headers,omitempty" json:"required_headers,omitempty"`
Config ConfigSpec `yaml:"config" json:"config"`
Resources map[string]Resource `yaml:"resources" json:"resources"`
@@ -108,6 +120,72 @@ type APISpec struct {
Throttling ThrottlingConfig `yaml:"throttling,omitempty" json:"throttling"` // cost-based throttling config; when Enabled with a recognized Shape, the generator emits a ThrottleState (generic harness) plus a per-Shape parser that reads the API's cost bucket. Only the "shopify" Shape ships in v1.
}
+type TierRoutingConfig struct {
+ DefaultTier string `yaml:"default_tier,omitempty" json:"default_tier,omitempty"`
+ Tiers map[string]TierConfig `yaml:"tiers,omitempty" json:"tiers,omitempty"`
+}
+
+type TierConfig struct {
+ BaseURL string `yaml:"base_url,omitempty" json:"base_url,omitempty"`
+ Auth AuthConfig `yaml:"auth,omitempty" json:"auth,omitzero"`
+ AllowCrossHostAuth bool `yaml:"allow_cross_host_auth,omitempty" json:"allow_cross_host_auth,omitempty"`
+}
+
+func (s *APISpec) HasTierRouting() bool {
+ if s == nil {
+ return false
+ }
+ return s.TierRouting.DefaultTier != "" || len(s.TierRouting.Tiers) > 0
+}
+
+func (s *APISpec) EffectiveTier(resource Resource, endpoint Endpoint) string {
+ name, _, ok := s.EffectiveTierConfig(resource, endpoint)
+ if !ok {
+ return ""
+ }
+ return name
+}
+
+func (s *APISpec) EffectiveTierConfig(resource Resource, endpoint Endpoint) (string, TierConfig, bool) {
+ if s == nil || !s.HasTierRouting() {
+ return "", TierConfig{}, false
+ }
+ tierName := strings.TrimSpace(endpoint.Tier)
+ if tierName == "" {
+ tierName = strings.TrimSpace(resource.Tier)
+ }
+ if tierName == "" {
+ tierName = strings.TrimSpace(s.TierRouting.DefaultTier)
+ }
+ if tierName == "" {
+ return "", TierConfig{}, false
+ }
+ tier, ok := s.TierRouting.Tiers[tierName]
+ return tierName, tier, ok
+}
+
+func (s *APISpec) EffectiveEndpointAuth(resource Resource, endpoint Endpoint) (authType string, noAuth bool) {
+ if endpoint.NoAuth {
+ return TierAuthTypeNone, true
+ }
+ authType = strings.TrimSpace(s.Auth.Type)
+ if _, tier, ok := s.EffectiveTierConfig(resource, endpoint); ok {
+ authType = normalizeTierAuthType(tier.Auth.Type)
+ }
+ if authType == TierAuthTypeNone {
+ return TierAuthTypeNone, true
+ }
+ return authType, false
+}
+
+func (s *APISpec) EffectiveSubEndpointAuth(parent Resource, subResource Resource, endpoint Endpoint) (authType string, noAuth bool) {
+ effectiveSub := subResource
+ if effectiveSub.Tier == "" {
+ effectiveSub.Tier = parent.Tier
+ }
+ return s.EffectiveEndpointAuth(effectiveSub, endpoint)
+}
+
// ThrottleShape names the API-specific cost-bucket parser the generator
// wires into the GraphQL client. The generic harness (bucket math, retry,
// --throttle-mode flag) is shape-agnostic; only the parser that reads the
@@ -568,6 +646,7 @@ type Resource struct {
// proxy-envelope client pattern, which POSTs every request to a
// single URL.
BaseURL string `yaml:"base_url,omitempty" json:"base_url,omitempty"`
+ Tier string `yaml:"tier,omitempty" json:"tier,omitempty"`
Endpoints map[string]Endpoint `yaml:"endpoints" json:"endpoints"`
SubResources map[string]Resource `yaml:"sub_resources,omitempty" json:"sub_resources,omitempty"`
}
@@ -614,6 +693,7 @@ type Endpoint struct {
Meta map[string]string `yaml:"meta,omitempty" json:"meta,omitempty"` // per-endpoint metadata (e.g., source_tier, source_count from crowd-sniff)
HeaderOverrides []RequiredHeader `yaml:"header_overrides,omitempty" json:"header_overrides,omitempty"` // per-endpoint header overrides (e.g., different api-version)
NoAuth bool `yaml:"no_auth,omitempty" json:"no_auth,omitempty"` // true when the endpoint does not require authentication
+ Tier string `yaml:"tier,omitempty" json:"tier,omitempty"`
// IDField is the resolved primary-key field name for items returned by this
// endpoint, populated either by a path-item-level `x-resource-id` extension
// or, for OpenAPI specs, by walking the response schema (id → name → first
@@ -1153,6 +1233,9 @@ func (s *APISpec) Validate() error {
if err := validateSessionHandshake(s.Auth); err != nil {
return err
}
+ if err := validateTierRouting(s); err != nil {
+ return err
+ }
if s.ClientPattern == "proxy-envelope" && s.HasResourceBaseURLOverride() {
return fmt.Errorf("resource base_url overrides are incompatible with client_pattern=proxy-envelope; the proxy POSTs every request to the spec-level BaseURL, so per-resource overrides would be silently ignored")
}
@@ -1191,6 +1274,232 @@ func (s *APISpec) Validate() error {
return nil
}
+func validateTierRouting(s *APISpec) error {
+ if s == nil || !s.HasTierRouting() {
+ return nil
+ }
+ if s.ClientPattern == "proxy-envelope" {
+ return fmt.Errorf("tier_routing is incompatible with client_pattern=proxy-envelope; tier routing needs per-request base URL and auth selection")
+ }
+ if len(s.TierRouting.Tiers) == 0 {
+ return fmt.Errorf("tier_routing.tiers is required when tier_routing is declared")
+ }
+ if s.TierRouting.DefaultTier != "" {
+ if _, ok := s.TierRouting.Tiers[s.TierRouting.DefaultTier]; !ok {
+ return fmt.Errorf("tier_routing.default_tier %q references unknown tier", s.TierRouting.DefaultTier)
+ }
+ }
+ anyTierBaseURL := false
+ for name, tier := range s.TierRouting.Tiers {
+ if strings.TrimSpace(name) == "" {
+ return fmt.Errorf("tier_routing.tiers contains an empty tier name")
+ }
+ if strings.TrimSpace(tier.BaseURL) != "" {
+ anyTierBaseURL = true
+ }
+ if err := validateTier(name, tier, s.BaseURL); err != nil {
+ return err
+ }
+ }
+ if anyTierBaseURL && s.HasResourceBaseURLOverride() {
+ return fmt.Errorf("resource base_url overrides are incompatible with tier_routing tier base_url overrides; choose one routing source")
+ }
+ for name, resource := range s.Resources {
+ if err := validateTierRoutingResource(s, name, resource, "", ""); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func validateTier(name string, tier TierConfig, specBaseURL string) error {
+ authType := normalizeTierAuthType(tier.Auth.Type)
+ switch authType {
+ case TierAuthTypeNone, TierAuthTypeAPIKey, TierAuthTypeBearerToken:
+ default:
+ return fmt.Errorf("tier_routing tier %q uses unsupported auth type %q; supported tier auth types are none, api_key, bearer_token", name, tier.Auth.Type)
+ }
+ if !tierAuthRequiresCredential(tier.Auth) {
+ return nil
+ }
+ if len(tier.Auth.EnvVars) == 0 {
+ return fmt.Errorf("tier_routing tier %q auth.env_vars is required for %s auth", name, authType)
+ }
+ if err := validateTierAuthPlacement(name, tier.Auth); err != nil {
+ return err
+ }
+ if err := validateTierAuthFormat(name, tier.Auth); err != nil {
+ return err
+ }
+ if err := validateCredentialTierBaseURL(name, tier, specBaseURL); err != nil {
+ return err
+ }
+ return nil
+}
+
+func normalizeTierAuthType(authType string) string {
+ authType = strings.TrimSpace(authType)
+ if authType == "" {
+ return TierAuthTypeNone
+ }
+ return authType
+}
+
+func tierAuthRequiresCredential(auth AuthConfig) bool {
+ switch normalizeTierAuthType(auth.Type) {
+ case TierAuthTypeAPIKey, TierAuthTypeBearerToken:
+ return true
+ default:
+ return false
+ }
+}
+
+func validateTierAuthPlacement(name string, auth AuthConfig) error {
+ placement := strings.TrimSpace(auth.In)
+ switch placement {
+ case "", TierAuthPlacementHeader, TierAuthPlacementQuery:
+ default:
+ return fmt.Errorf("tier_routing tier %q auth.in must be header or query", name)
+ }
+ if placement == TierAuthPlacementQuery && strings.TrimSpace(auth.Header) == "" {
+ return fmt.Errorf("tier_routing tier %q auth.header is required as the query parameter name", name)
+ }
+ if normalizeTierAuthType(auth.Type) == TierAuthTypeAPIKey && strings.TrimSpace(auth.Header) == "" {
+ return fmt.Errorf("tier_routing tier %q auth.header is required for api_key auth", name)
+ }
+ return nil
+}
+
+var authFormatPlaceholderRe = regexp.MustCompile(`\{([A-Za-z_][A-Za-z0-9_]*)\}`)
+
+func validateTierAuthFormat(name string, auth AuthConfig) error {
+ if strings.TrimSpace(auth.Format) == "" {
+ return nil
+ }
+ allowed := map[string]struct{}{
+ "token": {},
+ "access_token": {},
+ }
+ for _, envVar := range auth.EnvVars {
+ allowed[envVar] = struct{}{}
+ allowed[naming.EnvVarPlaceholder(envVar)] = struct{}{}
+ }
+ for _, match := range authFormatPlaceholderRe.FindAllStringSubmatch(auth.Format, -1) {
+ if _, ok := allowed[match[1]]; !ok {
+ return fmt.Errorf("tier_routing tier %q auth.format references undeclared placeholder %q", name, match[1])
+ }
+ }
+ return nil
+}
+
+func validateCredentialTierBaseURL(name string, tier TierConfig, specBaseURL string) error {
+ if strings.TrimSpace(tier.BaseURL) == "" {
+ return nil
+ }
+ parsed, err := url.Parse(tier.BaseURL)
+ if err != nil || parsed.Scheme == "" || parsed.Host == "" {
+ return fmt.Errorf("tier_routing tier %q base_url must be an absolute URL", name)
+ }
+ if parsed.Scheme != "https" {
+ return fmt.Errorf("tier_routing tier %q base_url must use https when carrying credentials", name)
+ }
+ host := normalizeURLHost(parsed.Hostname())
+ if unsafe := unsafeCredentialHostReason(host); unsafe != "" {
+ return fmt.Errorf("tier_routing tier %q base_url host %q is %s and cannot receive generated credentials", name, host, unsafe)
+ }
+ specHost := hostnameFromURL(specBaseURL)
+ if specHost == "" || sameHostFamily(specHost, host) || tier.AllowCrossHostAuth {
+ return nil
+ }
+ return fmt.Errorf("tier_routing tier %q base_url host %q is cross-host from spec base_url host %q; set allow_cross_host_auth after review", name, host, specHost)
+}
+
+func hostnameFromURL(raw string) string {
+ parsed, err := url.Parse(raw)
+ if err != nil {
+ return ""
+ }
+ return normalizeURLHost(parsed.Hostname())
+}
+
+func normalizeURLHost(host string) string {
+ return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
+}
+
+func unsafeCredentialHostReason(host string) string {
+ switch {
+ case host == "":
+ return "empty"
+ case host == "localhost" || strings.HasSuffix(host, ".localhost"):
+ return "loopback"
+ }
+ addr, err := netip.ParseAddr(host)
+ if err != nil {
+ return ""
+ }
+ switch {
+ case addr.IsLoopback():
+ return "loopback"
+ case addr.IsPrivate():
+ return "private"
+ case addr.IsLinkLocalUnicast():
+ return "link-local"
+ case addr.IsUnspecified():
+ return "unspecified"
+ default:
+ return ""
+ }
+}
+
+func sameHostFamily(specHost, tierHost string) bool {
+ specHost = normalizeURLHost(specHost)
+ tierHost = normalizeURLHost(tierHost)
+ return tierHost == specHost || strings.HasSuffix(tierHost, "."+specHost)
+}
+
+func validateTierRoutingResource(s *APISpec, resourcePath string, resource Resource, inheritedTier, inheritedBaseURL string) error {
+ resourceTier := strings.TrimSpace(resource.Tier)
+ if resourceTier == "" {
+ resourceTier = inheritedTier
+ }
+ resourceBaseURL := strings.TrimSpace(resource.BaseURL)
+ if resourceBaseURL == "" {
+ resourceBaseURL = inheritedBaseURL
+ }
+ if resourceTier != "" {
+ if _, ok := s.TierRouting.Tiers[resourceTier]; !ok {
+ return fmt.Errorf("resource %q references unknown tier %q", resourcePath, resourceTier)
+ }
+ }
+ effectiveResource := resource
+ effectiveResource.Tier = resourceTier
+ for endpointName, endpoint := range resource.Endpoints {
+ tierName, tier, ok := s.EffectiveTierConfig(effectiveResource, endpoint)
+ if tierName == "" {
+ continue
+ }
+ if !ok {
+ return fmt.Errorf("resource %q endpoint %q references unknown tier %q", resourcePath, endpointName, tierName)
+ }
+ if endpoint.NoAuth && tierAuthRequiresCredential(tier.Auth) {
+ return fmt.Errorf("resource %q endpoint %q declares no_auth but tier %q requires credentials", resourcePath, endpointName, tierName)
+ }
+ if tierAuthRequiresCredential(tier.Auth) && strings.TrimSpace(tier.BaseURL) == "" && resourceBaseURL != "" {
+ resourceTier := tier
+ resourceTier.BaseURL = resourceBaseURL
+ if err := validateCredentialTierBaseURL(tierName, resourceTier, s.BaseURL); err != nil {
+ return fmt.Errorf("resource %q endpoint %q: %w", resourcePath, endpointName, err)
+ }
+ }
+ }
+ for subName, sub := range resource.SubResources {
+ if err := validateTierRoutingResource(s, resourcePath+"."+subName, sub, resourceTier, resourceBaseURL); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
func validateEndpointResponseFormat(e Endpoint) error {
switch e.ResponseFormat {
case "", ResponseFormatJSON, ResponseFormatHTML:
@@ -1560,14 +1869,14 @@ func (s *APISpec) CountMCPTools() (total, public int) {
for _, r := range s.Resources {
for _, e := range r.Endpoints {
total++
- if e.NoAuth {
+ if _, noAuth := s.EffectiveEndpointAuth(r, e); noAuth {
public++
}
}
for _, sub := range r.SubResources {
for _, e := range sub.Endpoints {
total++
- if e.NoAuth {
+ if _, noAuth := s.EffectiveSubEndpointAuth(r, sub, e); noAuth {
public++
}
}
diff --git a/internal/spec/spec_test.go b/internal/spec/spec_test.go
index 46acc030..2022cee4 100644
--- a/internal/spec/spec_test.go
+++ b/internal/spec/spec_test.go
@@ -2474,3 +2474,285 @@ func TestValidateAcceptsResourceBaseURLWithoutProxyEnvelope(t *testing.T) {
})
}
}
+
+func TestParseTierRouting(t *testing.T) {
+ t.Parallel()
+ input := `
+name: tiered
+description: Tiered API
+version: 0.1.0
+base_url: https://api.example.com
+auth:
+ type: bearer_token
+ env_vars: [TIERED_TOKEN]
+tier_routing:
+ default_tier: free
+ tiers:
+ free:
+ auth:
+ type: none
+ paid:
+ base_url: https://paid.api.example.com
+ auth:
+ type: api_key
+ in: query
+ header: api_key
+ env_vars: [TIERED_PAID_KEY]
+resources:
+ results:
+ description: Search
+ tier: free
+ endpoints:
+ list:
+ method: GET
+ path: /search
+ description: Search public results
+ premium:
+ method: GET
+ path: /premium/search
+ description: Search premium results
+ tier: paid
+`
+
+ s, err := ParseBytes([]byte(input))
+ require.NoError(t, err)
+ require.True(t, s.HasTierRouting())
+ assert.Equal(t, "free", s.TierRouting.DefaultTier)
+ assert.Equal(t, "none", s.TierRouting.Tiers["free"].Auth.Type)
+ assert.Equal(t, "https://paid.api.example.com", s.TierRouting.Tiers["paid"].BaseURL)
+ assert.Equal(t, []string{"TIERED_PAID_KEY"}, s.TierRouting.Tiers["paid"].Auth.EnvVars)
+ assert.Equal(t, "free", s.Resources["results"].Tier)
+ assert.Equal(t, "paid", s.Resources["results"].Endpoints["premium"].Tier)
+ assert.Equal(t, "free", s.EffectiveTier(s.Resources["results"], s.Resources["results"].Endpoints["list"]))
+ assert.Equal(t, "paid", s.EffectiveTier(s.Resources["results"], s.Resources["results"].Endpoints["premium"]))
+}
+
+func TestValidateTierRouting(t *testing.T) {
+ t.Parallel()
+ cases := []struct {
+ name string
+ mutate func(*APISpec)
+ wantErr string
+ }{
+ {
+ name: "resource selector and endpoint override",
+ mutate: func(s *APISpec) {
+ resource := s.Resources["items"]
+ resource.Tier = "free"
+ endpoint := resource.Endpoints["get"]
+ endpoint.Tier = "paid"
+ resource.Endpoints["get"] = endpoint
+ s.Resources["items"] = resource
+ },
+ },
+ {
+ name: "no default leaves unselected endpoints on global auth",
+ mutate: func(s *APISpec) {
+ s.TierRouting.DefaultTier = ""
+ },
+ },
+ {
+ name: "unknown endpoint tier",
+ mutate: func(s *APISpec) {
+ resource := s.Resources["items"]
+ endpoint := resource.Endpoints["list"]
+ endpoint.Tier = "enterprise"
+ resource.Endpoints["list"] = endpoint
+ s.Resources["items"] = resource
+ },
+ wantErr: "unknown tier",
+ },
+ {
+ name: "credential tier requires env var",
+ mutate: func(s *APISpec) {
+ tier := s.TierRouting.Tiers["paid"]
+ tier.Auth.EnvVars = nil
+ s.TierRouting.Tiers["paid"] = tier
+ },
+ wantErr: "env_vars",
+ },
+ {
+ name: "unsupported auth type",
+ mutate: func(s *APISpec) {
+ tier := s.TierRouting.Tiers["paid"]
+ tier.Auth.Type = "oauth2"
+ s.TierRouting.Tiers["paid"] = tier
+ },
+ wantErr: "unsupported auth type",
+ },
+ {
+ name: "unsupported placement",
+ mutate: func(s *APISpec) {
+ tier := s.TierRouting.Tiers["paid"]
+ tier.Auth.In = "cookie"
+ s.TierRouting.Tiers["paid"] = tier
+ },
+ wantErr: "auth.in",
+ },
+ {
+ name: "query auth requires parameter name",
+ mutate: func(s *APISpec) {
+ tier := s.TierRouting.Tiers["paid"]
+ tier.Auth.In = "query"
+ tier.Auth.Header = ""
+ s.TierRouting.Tiers["paid"] = tier
+ },
+ wantErr: "header",
+ },
+ {
+ name: "format placeholder must be declared",
+ mutate: func(s *APISpec) {
+ tier := s.TierRouting.Tiers["paid"]
+ tier.Auth.Format = "Token {missing}"
+ s.TierRouting.Tiers["paid"] = tier
+ },
+ wantErr: "placeholder",
+ },
+ {
+ name: "no_auth conflicts with credential tier",
+ mutate: func(s *APISpec) {
+ resource := s.Resources["items"]
+ endpoint := resource.Endpoints["list"]
+ endpoint.NoAuth = true
+ endpoint.Tier = "paid"
+ resource.Endpoints["list"] = endpoint
+ s.Resources["items"] = resource
+ },
+ wantErr: "no_auth",
+ },
+ {
+ name: "proxy envelope conflict",
+ mutate: func(s *APISpec) {
+ s.ClientPattern = "proxy-envelope"
+ },
+ wantErr: "proxy-envelope",
+ },
+ {
+ name: "resource base url conflict",
+ mutate: func(s *APISpec) {
+ resource := s.Resources["items"]
+ resource.BaseURL = "https://other.example.com"
+ s.Resources["items"] = resource
+ },
+ wantErr: "base_url",
+ },
+ {
+ name: "auth-bearing tier through resource base url must pass host review",
+ mutate: func(s *APISpec) {
+ tier := s.TierRouting.Tiers["paid"]
+ tier.BaseURL = ""
+ s.TierRouting.Tiers["paid"] = tier
+ resource := s.Resources["items"]
+ resource.BaseURL = "https://paid.example.net"
+ endpoint := resource.Endpoints["list"]
+ endpoint.Tier = "paid"
+ resource.Endpoints["list"] = endpoint
+ s.Resources["items"] = resource
+ },
+ wantErr: "cross-host",
+ },
+ {
+ name: "auth-bearing tier through reviewed resource base url is accepted",
+ mutate: func(s *APISpec) {
+ tier := s.TierRouting.Tiers["paid"]
+ tier.BaseURL = ""
+ tier.AllowCrossHostAuth = true
+ s.TierRouting.Tiers["paid"] = tier
+ resource := s.Resources["items"]
+ resource.BaseURL = "https://paid.example.net"
+ endpoint := resource.Endpoints["list"]
+ endpoint.Tier = "paid"
+ resource.Endpoints["list"] = endpoint
+ s.Resources["items"] = resource
+ },
+ },
+ {
+ name: "auth-bearing tier base url must be https",
+ mutate: func(s *APISpec) {
+ tier := s.TierRouting.Tiers["paid"]
+ tier.BaseURL = "http://paid.api.example.com"
+ s.TierRouting.Tiers["paid"] = tier
+ },
+ wantErr: "https",
+ },
+ {
+ name: "auth-bearing tier base url rejects loopback",
+ mutate: func(s *APISpec) {
+ tier := s.TierRouting.Tiers["paid"]
+ tier.BaseURL = "https://127.0.0.1"
+ s.TierRouting.Tiers["paid"] = tier
+ },
+ wantErr: "loopback",
+ },
+ {
+ name: "cross host auth requires explicit review",
+ mutate: func(s *APISpec) {
+ tier := s.TierRouting.Tiers["paid"]
+ tier.BaseURL = "https://paid.example.net"
+ s.TierRouting.Tiers["paid"] = tier
+ },
+ wantErr: "cross-host",
+ },
+ {
+ name: "cross host auth accepts explicit review",
+ mutate: func(s *APISpec) {
+ tier := s.TierRouting.Tiers["paid"]
+ tier.BaseURL = "https://paid.example.net"
+ tier.AllowCrossHostAuth = true
+ s.TierRouting.Tiers["paid"] = tier
+ },
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ spec := validTierRoutingSpec()
+ tc.mutate(spec)
+ err := spec.Validate()
+ if tc.wantErr == "" {
+ require.NoError(t, err)
+ return
+ }
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), tc.wantErr)
+ })
+ }
+}
+
+func validTierRoutingSpec() *APISpec {
+ return &APISpec{
+ Name: "tiered",
+ Version: "0.1.0",
+ BaseURL: "https://api.example.com",
+ Auth: AuthConfig{
+ Type: "bearer_token",
+ EnvVars: []string{"TIERED_TOKEN"},
+ },
+ TierRouting: TierRoutingConfig{
+ DefaultTier: "free",
+ Tiers: map[string]TierConfig{
+ "free": {
+ Auth: AuthConfig{Type: "none"},
+ },
+ "paid": {
+ BaseURL: "https://paid.api.example.com",
+ Auth: AuthConfig{
+ Type: "api_key",
+ In: "query",
+ Header: "api_key",
+ EnvVars: []string{"TIERED_PAID_KEY"},
+ },
+ },
+ },
+ },
+ Resources: map[string]Resource{
+ "items": {
+ Endpoints: map[string]Endpoint{
+ "list": {Method: "GET", Path: "/items", Description: "List items"},
+ "get": {Method: "GET", Path: "/items/{id}", Description: "Get item"},
+ },
+ },
+ },
+ }
+}
diff --git a/skills/printing-press/SKILL.md b/skills/printing-press/SKILL.md
index cb990ecd..e3053976 100644
--- a/skills/printing-press/SKILL.md
+++ b/skills/printing-press/SKILL.md
@@ -1495,6 +1495,65 @@ template produces correct auth from the start.
APIs, public data feeds), don't invent auth. The signal must come from research — not
from guessing. No research mention of auth = no enrichment.
+#### Free/Paid Tier Routing Enrichment
+
+If Phase 1 finds that the headline commands should stay free but secondary
+enrichment needs a paid key, declare tier routing in the spec before generation.
+Do this only when research identifies a real split; do not invent tiers for a
+single-auth API.
+
+Detection signals:
+- Research or source-priority notes say the primary source is free but a secondary
+ source needs a paid/API key.
+- Some endpoints are documented as public while adjacent enrichment endpoints are
+ documented as paid, partner, premium, or quota-gated.
+- Browser/crowd sniffing found public endpoints and SDK/MCP research found a
+ separate credential for expanded coverage.
+
+Action:
+- Internal YAML: add `tier_routing` plus `tier` on the affected resource or
+ endpoint.
+- OpenAPI: add `x-tier-routing` at the root or under `info`, and add `x-tier`
+ to the path item or operation.
+- Use `auth.type: none` for the free tier.
+- Use only `api_key` or `bearer_token` for credential tiers in v1.
+- If a credential tier uses a different `base_url`, it must be HTTPS and same
+ host-family unless `allow_cross_host_auth: true` records explicit review.
+- Do not combine `no_auth: true` or OpenAPI `security: []` with a credential tier.
+
+Skip when:
+- All useful commands require the same credential.
+- The paid source would become the primary headline surface instead of enrichment.
+- The auth split requires OAuth, cookie, composed, or session-handshake tier auth;
+ handle that as a normal auth-mode decision for now.
+
+Example:
+
+```yaml
+tier_routing:
+ default_tier: free
+ tiers:
+ free:
+ auth: {type: none}
+ paid:
+ auth:
+ type: api_key
+ in: query
+ header: api_key
+ env_vars: [EXAMPLE_PAID_KEY]
+resources:
+ search:
+ tier: free
+ endpoints:
+ list:
+ method: GET
+ path: /search
+ enrich:
+ method: GET
+ path: /paid/search
+ tier: paid
+```
+
#### Tagging endpoints `no_auth: true` (composed/cookie auth APIs)
For APIs whose `auth.type` is `cookie`, `composed`, or `session_handshake` — i.e.,
diff --git a/testdata/golden/cases/generate-tier-routing-api/artifacts.txt b/testdata/golden/cases/generate-tier-routing-api/artifacts.txt
new file mode 100644
index 00000000..eb74b1ac
--- /dev/null
+++ b/testdata/golden/cases/generate-tier-routing-api/artifacts.txt
@@ -0,0 +1,9 @@
+tier-routing-golden/.printing-press.json
+tier-routing-golden/manifest.json
+tier-routing-golden/internal/client/client.go
+tier-routing-golden/internal/cli/items_list.go
+tier-routing-golden/internal/cli/items_premium.go
+tier-routing-golden/internal/cli/items_enterprise.go
+tier-routing-golden/internal/cli/sync.go
+tier-routing-golden/internal/cli/doctor.go
+tier-routing-golden/internal/mcp/tools.go
diff --git a/testdata/golden/cases/generate-tier-routing-api/command.txt b/testdata/golden/cases/generate-tier-routing-api/command.txt
new file mode 100644
index 00000000..9d06079e
--- /dev/null
+++ b/testdata/golden/cases/generate-tier-routing-api/command.txt
@@ -0,0 +1,2 @@
+set -euo pipefail
+"$BINARY" generate --spec testdata/golden/fixtures/tier-routing-api.yaml --output "$CASE_ACTUAL_DIR/tier-routing-golden" --force --spec-url file://testdata/golden/fixtures/tier-routing-api.yaml --validate=false
diff --git a/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/client/client.go b/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/client/client.go
index ad221050..a712d4ab 100644
--- a/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/client/client.go
+++ b/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/client/client.go
@@ -34,7 +34,7 @@ type Client struct {
// ccMu serializes OAuth2 client_credentials token mints so concurrent
// API calls inside the 60s pre-expiry window don't all dial the token
// endpoint and race on Config field writes / file persistence.
- ccMu sync.Mutex
+ ccMu *sync.Mutex
}
@@ -65,6 +65,7 @@ func New(cfg *config.Config, timeout time.Duration, rateLimit float64) *Client {
HTTPClient: httpClient,
cacheDir: cacheDir,
limiter: cliutil.NewAdaptiveLimiter(rateLimit),
+ ccMu: &sync.Mutex{},
}
}
@@ -328,6 +329,9 @@ func (c *Client) authHeader() (string, error) {
// 60s window avoids in-flight requests racing the expiry boundary.
// Double-checked lock so only one goroutine mints under contention.
if needsClientCredentialsMint(c.Config) {
+ if c.ccMu == nil {
+ c.ccMu = &sync.Mutex{}
+ }
c.ccMu.Lock()
if needsClientCredentialsMint(c.Config) {
clientID, clientSecret := resolveClientCredentials(c.Config)
diff --git a/testdata/golden/expected/generate-tier-routing-api/exit.txt b/testdata/golden/expected/generate-tier-routing-api/exit.txt
new file mode 100644
index 00000000..573541ac
--- /dev/null
+++ b/testdata/golden/expected/generate-tier-routing-api/exit.txt
@@ -0,0 +1 @@
+0
diff --git a/testdata/golden/expected/generate-tier-routing-api/stderr.txt b/testdata/golden/expected/generate-tier-routing-api/stderr.txt
new file mode 100644
index 00000000..1d686931
--- /dev/null
+++ b/testdata/golden/expected/generate-tier-routing-api/stderr.txt
@@ -0,0 +1 @@
+Generated tier-routing-golden at <ARTIFACT_DIR>/generate-tier-routing-api/tier-routing-golden
diff --git a/testdata/golden/expected/generate-tier-routing-api/stdout.txt b/testdata/golden/expected/generate-tier-routing-api/stdout.txt
new file mode 100644
index 00000000..e69de29b
diff --git a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/.printing-press.json b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/.printing-press.json
new file mode 100644
index 00000000..8d1a7a22
--- /dev/null
+++ b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/.printing-press.json
@@ -0,0 +1,24 @@
+{
+ "api_name": "tier-routing-golden",
+ "api_version": "1.0.0",
+ "auth_env_vars": [
+ "TIER_GLOBAL_TOKEN",
+ "TIER_ENTERPRISE_TOKEN",
+ "TIER_PAID_KEY"
+ ],
+ "auth_type": "bearer_token",
+ "cli_name": "tier-routing-golden-pp-cli",
+ "display_name": "Tier Routing Golden",
+ "generated_at": "<GENERATED_AT>",
+ "mcp_binary": "tier-routing-golden-pp-mcp",
+ "mcp_public_tool_count": 1,
+ "mcp_ready": "full",
+ "mcp_tool_count": 3,
+ "owner": "printing-press-golden",
+ "printing_press_version": "<PRINTING_PRESS_VERSION>",
+ "schema_version": 1,
+ "spec_checksum": "sha256:a059b4da997e0e835f0cdcd911ef5894fc048b50506bdcd90260a7478efb2958",
+ "spec_format": "internal",
+ "spec_path": "testdata/golden/fixtures/tier-routing-api.yaml",
+ "spec_url": "file://testdata/golden/fixtures/tier-routing-api.yaml"
+}
diff --git a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/doctor.go b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/doctor.go
new file mode 100644
index 00000000..0977580d
--- /dev/null
+++ b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/doctor.go
@@ -0,0 +1,473 @@
+// Copyright 2026 printing-press-golden. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "strings"
+ "time"
+
+ "tier-routing-golden-pp-cli/internal/client"
+ "tier-routing-golden-pp-cli/internal/config"
+ "tier-routing-golden-pp-cli/internal/store"
+ "github.com/spf13/cobra"
+)
+
+// looksLikeDoctorInterstitial reports whether the response body matches a known
+// bot-detection challenge page (Cloudflare, Akamai, Vercel, AWS WAF, DataDome,
+// PerimeterX). Only fires on the doctor probe — used to distinguish "transport
+// reached the wall" from "transport failed entirely." Returns the vendor name
+// when matched, or empty string when no match.
+//
+// Markers are anchored to <title> or vendor-specific strings to avoid
+// false-positives on benign content. For example, a recipe titled "Just A
+// Moment of Pause Cookies" must NOT match the Cloudflare challenge marker;
+// only "<title>just a moment" (the actual interstitial title) does.
+func looksLikeDoctorInterstitial(body []byte) string {
+ if len(body) == 0 {
+ return ""
+ }
+ limit := len(body)
+ if limit > 8192 {
+ limit = 8192
+ }
+ prefix := strings.ToLower(string(body[:limit]))
+ if !strings.Contains(prefix, "<title") {
+ // Every bot interstitial we recognize sets a <title>; bodies without
+ // one are body-only API responses, not challenge pages.
+ return ""
+ }
+ switch {
+ case strings.Contains(prefix, "<title>just a moment") || // CF JS challenge
+ strings.Contains(prefix, "challenges.cloudflare.com") || // CF Turnstile
+ (strings.Contains(prefix, "attention required") && strings.Contains(prefix, "cloudflare")):
+ return "Cloudflare"
+ case strings.Contains(prefix, "akamai") && (strings.Contains(prefix, "request unsuccessful") || strings.Contains(prefix, "access denied")):
+ return "Akamai"
+ case strings.Contains(prefix, "x-vercel-mitigated") || strings.Contains(prefix, "x-vercel-challenge-token") ||
+ (strings.Contains(prefix, "vercel") && strings.Contains(prefix, "challenge")):
+ return "Vercel"
+ case strings.Contains(prefix, "request blocked") && strings.Contains(prefix, "aws waf"):
+ return "AWS WAF"
+ case strings.Contains(prefix, "datadome") && (strings.Contains(prefix, "blocked") || strings.Contains(prefix, "captcha") || strings.Contains(prefix, "challenge")):
+ return "DataDome"
+ case strings.Contains(prefix, "perimeterx") || strings.Contains(prefix, "px-captcha"):
+ return "PerimeterX"
+ }
+ return ""
+}
+
+func newDoctorCmd(flags *rootFlags) *cobra.Command {
+ var failOn string
+ cmd := &cobra.Command{
+ Use: "doctor",
+ Short: "Check CLI health",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ report := map[string]any{}
+
+ // Check config
+ cfg, err := config.Load(flags.configPath)
+ if err != nil {
+ report["config"] = fmt.Sprintf("error: %s", err)
+ } else {
+ report["config"] = "ok"
+ report["config_path"] = cfg.Path
+ report["base_url"] = cfg.BaseURL
+ }
+
+ // Check auth
+ if cfg != nil {
+ header := cfg.AuthHeader()
+ if header == "" {
+ report["auth"] = "not configured"
+ report["auth_hint"] = "export TIER_GLOBAL_TOKEN=<your-key>"
+ } else {
+ report["auth"] = "configured"
+ report["auth_source"] = cfg.AuthSource
+ }
+ }
+
+ // Check auth environment variables
+ authEnvChecked := 0
+ authEnvSet := 0
+ authEnvChecked++
+ if os.Getenv("TIER_GLOBAL_TOKEN") != "" {
+ authEnvSet++
+ }
+ if authEnvSet == 0 {
+ report["env_vars"] = fmt.Sprintf("none set (checked %d)", authEnvChecked)
+ } else {
+ report["env_vars"] = fmt.Sprintf("%d/%d set", authEnvSet, authEnvChecked)
+ }
+ tierEnvStatus := map[string]string{}
+ {
+ checked := 0
+ set := 0
+ checked++
+ if os.Getenv("TIER_ENTERPRISE_TOKEN") != "" {
+ set++
+ }
+ tierEnvStatus["enterprise"] = fmt.Sprintf("%d/%d set", set, checked)
+ }
+ {
+ checked := 0
+ set := 0
+ checked++
+ if os.Getenv("TIER_PAID_KEY") != "" {
+ set++
+ }
+ tierEnvStatus["paid"] = fmt.Sprintf("%d/%d set", set, checked)
+ }
+ if len(tierEnvStatus) > 0 {
+ report["tier_env_vars"] = tierEnvStatus
+ }
+
+ // Check API connectivity and validate credentials.
+ //
+ // The doctor uses the same client every other command uses --
+ // flags.newClient() returns a *client.Client wrapping whatever
+ // transport the spec declared (Surf for browser-chrome, stdlib
+ // for standard). A separate stdlib http.Client would silently
+ // bypass that choice and report false negatives against
+ // Cloudflare-fronted, Akamai-fronted, or otherwise bot-detected
+ // sites. By going through flags.newClient(), the doctor's
+ // reachability verdict matches what real commands experience.
+ if cfg != nil && cfg.BaseURL != "" {
+ c, clientErr := flags.newClient()
+ if clientErr != nil {
+ report["api"] = fmt.Sprintf("client init error: %s", clientErr)
+ } else {
+ // Step 1: Basic reachability via the configured transport.
+ reachBody, reachErr := c.Get("/", nil)
+ var reachAPIErr *client.APIError
+ switch {
+ case reachErr == nil:
+ // 2xx response — clearly reachable. Still inspect the
+ // body for a known interstitial; some bot walls return
+ // 200 with a JS challenge page.
+ if vendor := looksLikeDoctorInterstitial(reachBody); vendor != "" {
+ report["api"] = fmt.Sprintf("blocked by %s interstitial — the configured transport reached the wall. Try a different network, wait for the IP-level rate limit to clear, or check that the browser-chrome transport is bound correctly.", vendor)
+ } else {
+ report["api"] = "reachable"
+ }
+ case errors.As(reachErr, &reachAPIErr):
+ // Non-2xx from the server. The network reached, the
+ // server responded — that's "reachable" for our
+ // purposes. Inspect the response body for a known
+ // interstitial first; otherwise note the status.
+ status := reachAPIErr.StatusCode
+ if vendor := looksLikeDoctorInterstitial([]byte(reachAPIErr.Body)); vendor != "" {
+ report["api"] = fmt.Sprintf("blocked by %s interstitial (HTTP %d) — the configured transport reached the wall.", vendor, status)
+ } else {
+ report["api"] = fmt.Sprintf("reachable (HTTP %d at /)", status)
+ }
+ default:
+ // Network-level failure: DNS, connection refused, TLS,
+ // transport init, etc. The transport itself didn't
+ // connect.
+ report["api"] = fmt.Sprintf("unreachable: %s", reachErr)
+ }
+
+ // Step 2: Validate credentials with an authenticated probe.
+ authHeader := cfg.AuthHeader()
+ if authHeader == "" {
+ // No auth configured — skip credential validation
+ } else if reachErr != nil && !errors.As(reachErr, &reachAPIErr) {
+ report["credentials"] = "skipped (API unreachable)"
+ } else {
+ verifyPath := "/"
+ authParams := map[string]string{}
+ authHeaders := map[string]string{}
+ authHeaders["Authorization"] = authHeader
+ authHeaders["User-Agent"] = "tier-routing-golden-pp-cli"
+ _, authErr := c.GetWithHeaders(verifyPath, authParams, authHeaders)
+ var authAPIErr *client.APIError
+ switch {
+ case authErr == nil:
+ report["credentials"] = "valid"
+ case errors.As(authErr, &authAPIErr):
+ switch {
+ case authAPIErr.StatusCode == 401 || authAPIErr.StatusCode == 403:
+ // The probe hit the bare base URL because no auth.verify_path
+ // is configured in the spec. Many APIs return 401/403 from a
+ // bare versioned root regardless of token validity (the path
+ // isn't routed but the gateway still demands credentials).
+ // Don't claim invalid without certainty — set verify_path to
+ // a known-good authenticated GET (e.g. /me, /v1/account, /user)
+ // for a definitive verdict.
+ report["credentials"] = fmt.Sprintf("inconclusive (HTTP %d from base URL — set auth.verify_path in spec for a definitive probe)", authAPIErr.StatusCode)
+ default:
+ // Non-auth HTTP error (404, 500, etc.) — don't blame credentials
+ report["credentials"] = fmt.Sprintf("ok (HTTP %d from %s, but auth was accepted)", authAPIErr.StatusCode, verifyPath)
+ }
+ default:
+ report["credentials"] = fmt.Sprintf("error: %s", authErr)
+ }
+ }
+ }
+ } else if cfg != nil && cfg.BaseURL == "" {
+ report["api"] = "not configured (set base_url in config file)"
+ }
+ // Cache health: only reported when this CLI has a local store.
+ // Surfaces rows + last_synced_at per resource, schema version,
+ // and a fresh/stale/unknown verdict so agents can introspect
+ // whether to trust the cached data before issuing queries.
+ report["cache"] = collectCacheReport(cmd.Context(), "")
+
+ report["version"] = version
+
+ if flags.asJSON {
+ if err := printJSONFiltered(cmd.OutOrStdout(), report, flags); err != nil {
+ return err
+ }
+ return doctorExitForFailOn(failOn, report)
+ }
+
+ // Human-readable output with color
+ w := cmd.OutOrStdout()
+ checkKeys := []struct{ key, label string }{
+ {"config", "Config"},
+ {"auth", "Auth"},
+ {"api", "API"},
+ {"credentials", "Credentials"},
+ }
+ for _, ck := range checkKeys {
+ v, ok := report[ck.key]
+ if !ok {
+ continue
+ }
+ s := fmt.Sprintf("%v", v)
+ indicator := green("OK")
+ switch {
+ case strings.HasPrefix(s, "optional"):
+ // Optional-auth CLI with no key set — informational, not a failure.
+ indicator = yellow("INFO")
+ case strings.HasPrefix(s, "inconclusive"):
+ // The credential probe could not produce a definitive verdict
+ // (typically because the bare base URL returns 401/403 even for
+ // valid tokens). Surface as WARN, not FAIL — the user's actual
+ // commands will reveal a real auth failure if one exists.
+ indicator = yellow("WARN")
+ case strings.Contains(s, "error") || strings.Contains(s, "not configured") || strings.Contains(s, "unreachable") || strings.Contains(s, "invalid") || strings.Contains(s, "missing"):
+ indicator = red("FAIL")
+ case s == "not required":
+ // Public APIs: no auth needed is a healthy state, not a warning.
+ indicator = green("OK")
+ case strings.Contains(s, "not ") || strings.Contains(s, "skipped") || strings.Contains(s, "inferred"):
+ indicator = yellow("WARN")
+ }
+ fmt.Fprintf(w, " %s %s: %s\n", indicator, ck.label, s)
+ }
+ // Print info keys without status indicator
+ for _, key := range []string{"config_path", "base_url", "auth_source", "version"} {
+ if v, ok := report[key]; ok {
+ fmt.Fprintf(w, " %s: %v\n", key, v)
+ }
+ }
+ // Print auth setup hints (indented under Auth line)
+ if hint, ok := report["auth_hint"]; ok {
+ fmt.Fprintf(w, " hint: %v\n", hint)
+ }
+ // Cache section: render after the primary health block so it
+ // sits next to version info, mirroring the JSON report layout.
+ if cacheAny, ok := report["cache"]; ok {
+ if cacheRep, ok := cacheAny.(map[string]any); ok {
+ renderCacheReport(w, cacheRep)
+ }
+ }
+ return doctorExitForFailOn(failOn, report)
+ },
+ }
+ cmd.Flags().StringVar(&failOn, "fail-on", "", "Exit non-zero when a health level is reached: stale, error. Default is never.")
+ return cmd
+}
+
+// doctorExitForFailOn returns a non-nil error when the report's worst
+// status meets or exceeds the --fail-on threshold. "error" always trips
+// when any section reports an error; "stale" also trips when the cache
+// section is stale. The default empty string means never fail on status.
+func doctorExitForFailOn(failOn string, report map[string]any) error {
+ if failOn == "" {
+ return nil
+ }
+ worstError := false
+ worstStale := false
+ for _, v := range report {
+ s, ok := v.(string)
+ if ok {
+ if strings.Contains(s, "error") || strings.Contains(s, "unreachable") || strings.Contains(s, "invalid") || strings.Contains(s, "missing") {
+ worstError = true
+ }
+ }
+ if m, ok := v.(map[string]any); ok {
+ if st, _ := m["status"].(string); st == "error" {
+ worstError = true
+ } else if st == "stale" {
+ worstStale = true
+ }
+ }
+ }
+ switch failOn {
+ case "error":
+ if worstError {
+ return fmt.Errorf("doctor: --fail-on=error triggered")
+ }
+ case "stale":
+ if worstError || worstStale {
+ return fmt.Errorf("doctor: --fail-on=stale triggered")
+ }
+ default:
+ return fmt.Errorf("doctor: unknown --fail-on value %q (valid: stale, error)", failOn)
+ }
+ return nil
+}
+
+// collectCacheReport opens the local store, reads per-resource sync state,
+// and returns a map summarising cache health. Never panics on missing DB
+// or open failure; returns a map with status=unknown or status=error so the
+// caller can render and agents can interpret.
+//
+// staleAfterSpec is the CLI's configured threshold (e.g. "6h"); empty means
+// use the runtime default. The default is deliberately conservative (6h)
+// because the alternative is no freshness story at all.
+func collectCacheReport(ctx context.Context, staleAfterSpec string) map[string]any {
+ report := map[string]any{}
+ dbPath := defaultDBPath("tier-routing-golden-pp-cli")
+ report["db_path"] = dbPath
+
+ fi, err := os.Stat(dbPath)
+ if err != nil {
+ if os.IsNotExist(err) {
+ report["status"] = "unknown"
+ report["hint"] = "Database not created yet; run 'tier-routing-golden-pp-cli sync' to hydrate."
+ return report
+ }
+ report["status"] = "error"
+ report["error"] = err.Error()
+ return report
+ }
+ report["db_bytes"] = fi.Size()
+
+ s, err := store.OpenWithContext(ctx, dbPath)
+ if err != nil {
+ report["status"] = "error"
+ report["error"] = err.Error()
+ return report
+ }
+ defer s.Close()
+
+ if v, verr := s.SchemaVersion(); verr == nil {
+ report["schema_version"] = v
+ }
+
+ staleAfter := 6 * time.Hour
+ if staleAfterSpec != "" {
+ if d, derr := time.ParseDuration(staleAfterSpec); derr == nil {
+ staleAfter = d
+ }
+ }
+
+ rows, qerr := s.DB().Query(`SELECT resource_type, COALESCE(total_count, 0), last_synced_at FROM sync_state ORDER BY resource_type`)
+ if qerr != nil {
+ // sync_state may not exist on a fresh DB that has migrated but not
+ // yet had any sync runs — treat as unknown rather than error.
+ report["status"] = "unknown"
+ report["hint"] = "No sync state recorded; run 'tier-routing-golden-pp-cli sync' to populate."
+ return report
+ }
+ defer rows.Close()
+
+ var resources []map[string]any
+ fresh := true
+ haveAny := false
+ oldest := time.Duration(0)
+ for rows.Next() {
+ var rtype string
+ var count int64
+ var lastSynced sql.NullTime
+ if err := rows.Scan(&rtype, &count, &lastSynced); err != nil {
+ continue
+ }
+ r := map[string]any{"type": rtype, "rows": count}
+ if lastSynced.Valid {
+ haveAny = true
+ r["last_synced_at"] = lastSynced.Time.UTC().Format(time.RFC3339)
+ age := time.Since(lastSynced.Time)
+ r["staleness"] = age.Round(time.Minute).String()
+ if age > staleAfter {
+ fresh = false
+ }
+ if age > oldest {
+ oldest = age
+ }
+ } else {
+ r["staleness"] = "never"
+ fresh = false
+ }
+ resources = append(resources, r)
+ }
+ report["resources"] = resources
+ report["stale_after"] = staleAfter.String()
+
+ switch {
+ case !haveAny && len(resources) == 0:
+ report["status"] = "unknown"
+ report["hint"] = "sync_state is empty; run 'tier-routing-golden-pp-cli sync' to hydrate."
+ case fresh:
+ report["status"] = "fresh"
+ default:
+ report["status"] = "stale"
+ report["oldest_age"] = oldest.Round(time.Minute).String()
+ report["hint"] = "Some resources are older than stale_after; run 'tier-routing-golden-pp-cli sync' to refresh."
+ }
+ return report
+}
+
+func renderCacheReport(w io.Writer, rep map[string]any) {
+ status, _ := rep["status"].(string)
+ indicator := green("OK")
+ switch status {
+ case "stale":
+ indicator = yellow("WARN")
+ case "error":
+ indicator = red("FAIL")
+ case "unknown":
+ indicator = yellow("INFO")
+ }
+ fmt.Fprintf(w, " %s Cache: %s\n", indicator, status)
+ if v, ok := rep["db_path"]; ok {
+ fmt.Fprintf(w, " db_path: %v\n", v)
+ }
+ if v, ok := rep["schema_version"]; ok {
+ fmt.Fprintf(w, " schema_version: %v\n", v)
+ }
+ if v, ok := rep["db_bytes"]; ok {
+ fmt.Fprintf(w, " db_bytes: %v\n", v)
+ }
+ if v, ok := rep["stale_after"]; ok {
+ fmt.Fprintf(w, " stale_after: %v\n", v)
+ }
+ if v, ok := rep["oldest_age"]; ok {
+ fmt.Fprintf(w, " oldest_age: %v\n", v)
+ }
+ if resourcesAny, ok := rep["resources"]; ok {
+ if resources, ok := resourcesAny.([]map[string]any); ok && len(resources) > 0 {
+ fmt.Fprintf(w, " resources:\n")
+ for _, r := range resources {
+ rtype, _ := r["type"].(string)
+ rows := r["rows"]
+ staleness, _ := r["staleness"].(string)
+ fmt.Fprintf(w, " - %s: %v rows, %s\n", rtype, rows, staleness)
+ }
+ }
+ }
+ if hint, ok := rep["hint"]; ok {
+ fmt.Fprintf(w, " hint: %v\n", hint)
+ }
+}
diff --git a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/items_enterprise.go b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/items_enterprise.go
new file mode 100644
index 00000000..cf1cc5a6
--- /dev/null
+++ b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/items_enterprise.go
@@ -0,0 +1,74 @@
+// Copyright 2026 printing-press-golden. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+
+ "github.com/spf13/cobra"
+)
+
+func newItemsEnterpriseCmd(flags *rootFlags) *cobra.Command {
+
+ cmd := &cobra.Command{
+ Use: "enterprise",
+ Short: "List enterprise items",
+ Example: " tier-routing-golden-pp-cli items enterprise",
+ Annotations: map[string]string{"pp:endpoint": "items.enterprise", "mcp:read-only": "true"},
+ RunE: func(cmd *cobra.Command, args []string) error {
+ c, err := flags.newClient()
+ if err != nil {
+ return err
+ }
+ c = c.WithTier("enterprise")
+
+ path := "/items/enterprise"
+ params := map[string]string{}
+ data, prov, err := resolveRead(cmd.Context(), c, flags, "items", false, path, params, nil)
+ if err != nil {
+ return classifyAPIError(err)
+ }
+ // Print provenance to stderr for human-facing output
+ {
+ var countItems []json.RawMessage
+ _ = json.Unmarshal(data, &countItems)
+ printProvenance(cmd, len(countItems), prov)
+ }
+ // For JSON output, wrap with provenance envelope before passing through flags.
+ // --select wins over --compact when both are set; --compact only runs when
+ // no explicit fields were requested.
+ if flags.asJSON || !isTerminal(cmd.OutOrStdout()) {
+ filtered := data
+ if flags.selectFields != "" {
+ filtered = filterFields(filtered, flags.selectFields)
+ } else if flags.compact {
+ filtered = compactFields(filtered)
+ }
+ wrapped, wrapErr := wrapWithProvenance(filtered, prov)
+ if wrapErr != nil {
+ return wrapErr
+ }
+ return printOutput(cmd.OutOrStdout(), wrapped, true)
+ }
+ // For all other output modes (table, csv, plain, quiet), use the standard pipeline
+ if wantsHumanTable(cmd.OutOrStdout(), flags) {
+ var items []map[string]any
+ if json.Unmarshal(data, &items) == nil && len(items) > 0 {
+ if err := printAutoTable(cmd.OutOrStdout(), items); err != nil {
+ return err
+ }
+ if len(items) >= 25 {
+ fmt.Fprintf(os.Stderr, "\nShowing %d results. To narrow: add --limit, --json --select, or filter flags.\n", len(items))
+ }
+ return nil
+ }
+ }
+ return printOutputWithFlags(cmd.OutOrStdout(), data, flags)
+ },
+ }
+
+ return cmd
+}
diff --git a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/items_list.go b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/items_list.go
new file mode 100644
index 00000000..a62bd071
--- /dev/null
+++ b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/items_list.go
@@ -0,0 +1,76 @@
+// Copyright 2026 printing-press-golden. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+
+ "github.com/spf13/cobra"
+)
+
+func newItemsListCmd(flags *rootFlags) *cobra.Command {
+ var flagAll bool
+
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "List free items",
+ Example: " tier-routing-golden-pp-cli items list",
+ Annotations: map[string]string{"pp:endpoint": "items.list", "mcp:read-only": "true"},
+ RunE: func(cmd *cobra.Command, args []string) error {
+ c, err := flags.newClient()
+ if err != nil {
+ return err
+ }
+ c = c.WithTier("free")
+
+ path := "/items"
+ data, prov, err := resolvePaginatedRead(cmd.Context(), c, flags, "items", path, map[string]string{
+ }, nil, flagAll, "cursor", "", "")
+ if err != nil {
+ return classifyAPIError(err)
+ }
+ // Print provenance to stderr for human-facing output
+ {
+ var countItems []json.RawMessage
+ _ = json.Unmarshal(data, &countItems)
+ printProvenance(cmd, len(countItems), prov)
+ }
+ // For JSON output, wrap with provenance envelope before passing through flags.
+ // --select wins over --compact when both are set; --compact only runs when
+ // no explicit fields were requested.
+ if flags.asJSON || !isTerminal(cmd.OutOrStdout()) {
+ filtered := data
+ if flags.selectFields != "" {
+ filtered = filterFields(filtered, flags.selectFields)
+ } else if flags.compact {
+ filtered = compactFields(filtered)
+ }
+ wrapped, wrapErr := wrapWithProvenance(filtered, prov)
+ if wrapErr != nil {
+ return wrapErr
+ }
+ return printOutput(cmd.OutOrStdout(), wrapped, true)
+ }
+ // For all other output modes (table, csv, plain, quiet), use the standard pipeline
+ if wantsHumanTable(cmd.OutOrStdout(), flags) {
+ var items []map[string]any
+ if json.Unmarshal(data, &items) == nil && len(items) > 0 {
+ if err := printAutoTable(cmd.OutOrStdout(), items); err != nil {
+ return err
+ }
+ if len(items) >= 25 {
+ fmt.Fprintf(os.Stderr, "\nShowing %d results. To narrow: add --limit, --json --select, or filter flags.\n", len(items))
+ }
+ return nil
+ }
+ }
+ return printOutputWithFlags(cmd.OutOrStdout(), data, flags)
+ },
+ }
+ cmd.Flags().BoolVar(&flagAll, "all", false, "Fetch all pages")
+
+ return cmd
+}
diff --git a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/items_premium.go b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/items_premium.go
new file mode 100644
index 00000000..80e7d7fb
--- /dev/null
+++ b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/items_premium.go
@@ -0,0 +1,74 @@
+// Copyright 2026 printing-press-golden. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+
+ "github.com/spf13/cobra"
+)
+
+func newItemsPremiumCmd(flags *rootFlags) *cobra.Command {
+
+ cmd := &cobra.Command{
+ Use: "premium",
+ Short: "List paid items",
+ Example: " tier-routing-golden-pp-cli items premium",
+ Annotations: map[string]string{"pp:endpoint": "items.premium", "mcp:read-only": "true"},
+ RunE: func(cmd *cobra.Command, args []string) error {
+ c, err := flags.newClient()
+ if err != nil {
+ return err
+ }
+ c = c.WithTier("paid")
+
+ path := "/items/premium"
+ params := map[string]string{}
+ data, prov, err := resolveRead(cmd.Context(), c, flags, "items", false, path, params, nil)
+ if err != nil {
+ return classifyAPIError(err)
+ }
+ // Print provenance to stderr for human-facing output
+ {
+ var countItems []json.RawMessage
+ _ = json.Unmarshal(data, &countItems)
+ printProvenance(cmd, len(countItems), prov)
+ }
+ // For JSON output, wrap with provenance envelope before passing through flags.
+ // --select wins over --compact when both are set; --compact only runs when
+ // no explicit fields were requested.
+ if flags.asJSON || !isTerminal(cmd.OutOrStdout()) {
+ filtered := data
+ if flags.selectFields != "" {
+ filtered = filterFields(filtered, flags.selectFields)
+ } else if flags.compact {
+ filtered = compactFields(filtered)
+ }
+ wrapped, wrapErr := wrapWithProvenance(filtered, prov)
+ if wrapErr != nil {
+ return wrapErr
+ }
+ return printOutput(cmd.OutOrStdout(), wrapped, true)
+ }
+ // For all other output modes (table, csv, plain, quiet), use the standard pipeline
+ if wantsHumanTable(cmd.OutOrStdout(), flags) {
+ var items []map[string]any
+ if json.Unmarshal(data, &items) == nil && len(items) > 0 {
+ if err := printAutoTable(cmd.OutOrStdout(), items); err != nil {
+ return err
+ }
+ if len(items) >= 25 {
+ fmt.Fprintf(os.Stderr, "\nShowing %d results. To narrow: add --limit, --json --select, or filter flags.\n", len(items))
+ }
+ return nil
+ }
+ }
+ return printOutputWithFlags(cmd.OutOrStdout(), data, flags)
+ },
+ }
+
+ return cmd
+}
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
new file mode 100644
index 00000000..08975ba3
--- /dev/null
+++ b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/sync.go
@@ -0,0 +1,876 @@
+// Copyright 2026 printing-press-golden. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/url"
+ "os"
+ "regexp"
+ "strconv"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+ "tier-routing-golden-pp-cli/internal/client"
+ "tier-routing-golden-pp-cli/internal/store"
+ "github.com/spf13/cobra"
+)
+
+// syncResult holds the outcome of syncing a single resource.
+type syncResult struct {
+ Resource string
+ Count int
+ Err error
+ Warn error
+ Duration time.Duration
+}
+
+func newSyncCmd(flags *rootFlags) *cobra.Command {
+ var resources []string
+ var full bool
+ var since string
+ var concurrency int
+ var dbPath string
+ var maxPages int
+ var latestOnly bool
+ var strict bool
+
+ cmd := &cobra.Command{
+ Use: "sync",
+ Short: "Sync API data to local SQLite for offline search and analysis",
+ Long: `Sync data from the API into a local SQLite database. Supports resumable
+incremental sync (only fetches new data since last sync) and full resync.
+Once synced, use the 'search' command for instant full-text search.
+
+Exit codes & warnings:
+ Resources the API denies access to (HTTP 403, or HTTP 400 with an
+ access-policy body) are reported as warnings rather than failing the
+ run. In --json mode each is emitted as a {"event":"sync_warning",...}
+ line carrying status, reason, and message fields, and a final
+ {"event":"sync_summary",...} aggregates the run.
+
+ Exit 0 when at least one resource synced and no resource flagged in
+ the spec as critical (x-critical: true) failed; non-critical failures
+ emit {"event":"sync_warning","reason":"exit_policy_default_changed",
+ ...} so callers can detect that a partial failure was tolerated. Pass
+ --strict to exit non-zero on any per-resource failure. Exit is always
+ non-zero when every selected resource failed, regardless of --strict.`,
+ Example: ` # Sync all resources
+ tier-routing-golden-pp-cli sync
+
+ # Sync specific resources only
+ tier-routing-golden-pp-cli sync --resources channels,messages
+
+ # Full resync (ignore previous checkpoint)
+ tier-routing-golden-pp-cli sync --full
+
+ # Incremental sync: only records from the last 7 days
+ tier-routing-golden-pp-cli sync --since 7d
+
+ # Parallel sync with 8 workers
+ tier-routing-golden-pp-cli sync --concurrency 8
+
+ # Latest-only: refresh head of each resource, no historical backfill
+ tier-routing-golden-pp-cli sync --latest-only`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ c, err := flags.newClient()
+ if err != nil {
+ return err
+ }
+ c.NoCache = true
+
+ if dbPath == "" {
+ dbPath = defaultDBPath("tier-routing-golden-pp-cli")
+ }
+
+ db, err := store.OpenWithContext(cmd.Context(), dbPath)
+ if err != nil {
+ return fmt.Errorf("opening local database: %w", err)
+ }
+ defer db.Close()
+
+ // If no specific resources, sync top-level resources
+ if len(resources) == 0 {
+ resources = defaultSyncResources()
+ }
+
+ // --full: clear all sync cursors before starting
+ if full {
+ for _, resource := range resources {
+ _ = db.SaveSyncState(resource, "", 0)
+ }
+ }
+
+ // --latest-only narrows to the first page of each resource
+ // ignoring the historical resume cursor. We cap maxPages at 1
+ // here rather than re-interpreting it downstream so the rest
+ // of the sync loop stays oblivious. Mutually-useful with
+ // --since: if the user set --since, that threshold still wins
+ // and we don't short-circuit historical context they asked for.
+ if latestOnly {
+ if since == "" {
+ maxPages = 1
+ // Clear the cursor so we start from the head each time;
+ // the goal of --latest-only is "refresh the top" not
+ // "resume from wherever I left off".
+ for _, resource := range resources {
+ existing, _, _, _ := db.GetSyncState(resource)
+ if existing != "" {
+ _ = db.SaveSyncState(resource, "", 0)
+ }
+ }
+ } else if humanFriendly {
+ fmt.Fprintln(os.Stderr, "warning: --latest-only ignored because --since is set; --since takes precedence")
+ }
+ }
+
+ // Resolve --since into an RFC3339 timestamp
+ sinceTS := ""
+ if since != "" {
+ ts, err := parseSinceDuration(since)
+ if err != nil {
+ return fmt.Errorf("invalid --since value %q: %w", since, err)
+ }
+ sinceTS = ts.Format(time.RFC3339)
+ }
+
+ // Worker pool: produce resources, N workers consume
+ if concurrency < 1 {
+ concurrency = 4
+ }
+
+ started := time.Now()
+ work := make(chan string, len(resources))
+ results := make(chan syncResult, len(resources))
+
+ var wg sync.WaitGroup
+ for i := 0; i < concurrency; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for resource := range work {
+ res := syncResource(syncClientForResource(c, resource), db, resource, sinceTS, full, maxPages)
+ results <- res
+ }
+ }()
+ }
+
+ // Enqueue all resources
+ for _, resource := range resources {
+ work <- resource
+ }
+ close(work)
+
+ // Collect results in a separate goroutine
+ go func() {
+ wg.Wait()
+ close(results)
+ }()
+
+ var totalSynced int
+ var errCount int
+ var criticalErrCount int
+ var warnCount int
+ var successCount int
+ for res := range results {
+ if res.Err != nil {
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, " %s: error: %v\n", res.Resource, res.Err)
+ }
+ errCount++
+ if criticalResources[res.Resource] {
+ criticalErrCount++
+ }
+ } else if res.Warn != nil {
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, " %s: warning: %v\n", res.Resource, res.Warn)
+ }
+ warnCount++
+ } else {
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, " %s: %d synced (done)\n", res.Resource, res.Count)
+ }
+ totalSynced += res.Count
+ successCount++
+ }
+ }
+
+ elapsed := time.Since(started)
+ totalResources := successCount + warnCount + errCount
+ if !humanFriendly {
+ fmt.Fprintf(os.Stderr, `{"event":"sync_summary","total_records":%d,"resources":%d,"success":%d,"warned":%d,"errored":%d,"duration_ms":%d}`+"\n",
+ totalSynced, totalResources, successCount, warnCount, errCount, elapsed.Milliseconds())
+ }
+ if warnCount > 0 {
+ fmt.Fprintf(os.Stderr, "Sync complete: %d records across %d resources (%d warned, %.1fs)\n",
+ totalSynced, totalResources, warnCount, elapsed.Seconds())
+ } else {
+ fmt.Fprintf(os.Stderr, "Sync complete: %d records across %d resources (%.1fs)\n",
+ totalSynced, totalResources, elapsed.Seconds())
+ }
+
+ // Exit-code policy:
+ // 1. --strict + any error -> non-zero (legacy contract)
+ // 2. any critical failure -> non-zero regardless of --strict
+ // 3. nothing synced -> non-zero (preserves "all-warned" / "all-errored" exit)
+ // 4. otherwise -> exit 0 (any data synced + no critical failed)
+ // When branch 4 suppresses what branch 1 would have rejected, emit a
+ // one-shot sync_warning with reason "exit_policy_default_changed" so
+ // CI scripts that depend on $? != 0 can discover the contract change
+ // without reading the CHANGELOG.
+ if strict && errCount > 0 {
+ return fmt.Errorf("%d resource(s) failed to sync", errCount)
+ }
+ if criticalErrCount > 0 {
+ return fmt.Errorf("%d critical resource(s) failed to sync", criticalErrCount)
+ }
+ if successCount == 0 {
+ if warnCount > 0 && errCount == 0 {
+ return fmt.Errorf("%d resource(s) skipped due to insufficient access", warnCount)
+ }
+ if errCount > 0 {
+ return fmt.Errorf("%d resource(s) failed to sync", errCount)
+ }
+ }
+ if errCount > 0 && !strict && criticalErrCount == 0 && successCount > 0 {
+ if !humanFriendly {
+ msg := fmt.Sprintf("%d resource(s) failed but exit code is 0 because the new default treats non-critical failures as warnings. Pass --strict to restore the old behavior, or annotate critical resources with x-critical: true. See CHANGELOG.", errCount)
+ fmt.Fprintf(os.Stderr, `{"event":"sync_warning","reason":"exit_policy_default_changed","errored":%d,"message":"%s"}`+"\n",
+ errCount, strings.ReplaceAll(msg, `"`, `\"`))
+ } else {
+ fmt.Fprintf(os.Stderr, "warning: %d resource(s) failed but exit code is 0 because the new default treats non-critical failures as warnings. Pass --strict to restore the old behavior, or annotate critical resources with x-critical: true.\n", errCount)
+ }
+ }
+ return nil
+ },
+ }
+
+ cmd.Flags().StringSliceVar(&resources, "resources", nil, "Comma-separated resource types to sync")
+ cmd.Flags().BoolVar(&full, "full", false, "Full resync (ignore previous checkpoint)")
+ 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/tier-routing-golden-pp-cli/data.db)")
+ cmd.Flags().IntVar(&maxPages, "max-pages", 100, "Maximum pages to fetch per resource (0 = unlimited; cap-hit emits a sync_warning event)")
+ cmd.Flags().BoolVar(&latestOnly, "latest-only", false, "Refresh head of each resource only; clears resume cursor and caps pages at 1. Mutually exclusive with --since (--since wins).")
+ cmd.Flags().BoolVar(&strict, "strict", false, "Exit non-zero on any per-resource failure (default: only critical failures or all-resource failure exit non-zero).")
+
+ return cmd
+}
+
+// syncResource handles the full paginated sync of a single resource.
+// It resumes from the last cursor unless sinceTS or full mode overrides it.
+func syncResource(c interface {
+ Get(string, map[string]string) (json.RawMessage, error)
+ RateLimit() float64
+}, db *store.Store, resource, sinceTS string, full bool, maxPages int) syncResult {
+ started := time.Now()
+
+ if !humanFriendly {
+ fmt.Fprintf(os.Stderr, `{"event":"sync_start","resource":"%s"}`+"\n", resource)
+ }
+
+ path, err := syncResourcePath(resource)
+ if err != nil {
+ return syncResult{Resource: resource, Err: err, Duration: time.Since(started)}
+ }
+ var totalCount int
+
+ // Resume cursor from sync_state (unless --full cleared it)
+ existingCursor, lastSynced, _, _ := db.GetSyncState(resource)
+
+ // Determine the since param value:
+ // 1. Explicit --since flag takes priority
+ // 2. Otherwise use last_synced_at from sync_state for incremental sync
+ sinceParam := determineSinceParam()
+ effectiveSince := sinceTS
+ if effectiveSince == "" && !lastSynced.IsZero() && !full {
+ effectiveSince = lastSynced.Format(time.RFC3339)
+ }
+
+ cursor := existingCursor
+ pageSize := determinePaginationDefaults()
+
+ var progressCount int64
+ pagesFetched := 0
+ lastNextCursor := ""
+ // extractFailureTotal accumulates per-item primary-key extraction
+ // misses across pages within this resource sync. Resource-level
+ // concurrency is 1 (one goroutine per resource via the work channel)
+ // so this counter cannot race. We emit one primary_key_unresolved
+ // sync_anomaly per resource per run when there's at least one miss
+ // (rate-limited via the anomalyEmitted flag) and a roll-up
+ // all_items_failed_id_extraction event when 100% of a single page
+ // failed extraction.
+ var extractFailureTotal int
+ var consumedTotal int
+ anomalyEmitted := false
+
+ 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
+ }
+
+ // Set since filter
+ if effectiveSince != "" {
+ params[sinceParam] = effectiveSince
+ }
+
+ data, err := c.Get(path, params)
+ if err != nil {
+ if w, ok := isSyncAccessWarning(err); ok {
+ if !humanFriendly {
+ fmt.Fprintf(os.Stderr, `{"event":"sync_warning","resource":"%s","status":%d,"reason":"%s","message":"%s"}`+"\n",
+ resource, w.Status, w.Reason, strings.ReplaceAll(w.Message, `"`, `\"`))
+ }
+ return syncResult{Resource: resource, Count: totalCount, Warn: fmt.Errorf("skipped %s: %s", resource, w.Reason), Duration: time.Since(started)}
+ }
+ if !humanFriendly {
+ fmt.Fprintf(os.Stderr, `{"event":"sync_error","resource":"%s","error":"%s"}`+"\n", resource, strings.ReplaceAll(err.Error(), `"`, `\"`))
+ }
+ return syncResult{Resource: resource, Count: totalCount, Err: fmt.Errorf("fetching %s: %w", resource, err), Duration: time.Since(started)}
+ }
+
+ // Try to extract items from the response.
+ // Strategy: try array first, then common wrapper keys.
+ items, nextCursor, hasMore := extractPageItems(data, pageSize.cursorParam)
+
+ if len(items) == 0 {
+ // Single object response - try to store as-is
+ if err := upsertSingleObject(db, resource, data); err != nil {
+ if !humanFriendly {
+ fmt.Fprintf(os.Stderr, `{"event":"sync_error","resource":"%s","error":"%s"}`+"\n", resource, strings.ReplaceAll(err.Error(), `"`, `\"`))
+ }
+ return syncResult{Resource: resource, Err: err, Duration: time.Since(started)}
+ }
+ totalCount++
+ break
+ }
+
+ // Batch upsert all items from this page. UpsertBatch returns
+ // (stored, extractFailures, err): stored counts rows actually
+ // landed; extractFailures counts items that survived JSON
+ // unmarshal but had no extractable primary key (templated
+ // IDField AND generic fallback both missed). Tracking these
+ // separately lets us emit precise sync_anomaly events: a
+ // roll-up "all_items_failed_id_extraction" when an entire
+ // page yields zero stored, a per-resource
+ // "primary_key_unresolved" the first time any single item
+ // fails, and the F4b "stored_count_zero_after_extraction"
+ // probe when extraction succeeded but rows still didn't land.
+ stored, extractFailures, err := upsertResourceBatch(db, resource, items)
+ if err != nil {
+ if !humanFriendly {
+ fmt.Fprintf(os.Stderr, `{"event":"sync_error","resource":"%s","error":"%s"}`+"\n", resource, strings.ReplaceAll(err.Error(), `"`, `\"`))
+ }
+ return syncResult{Resource: resource, Count: totalCount, Err: fmt.Errorf("upserting batch for %s: %w", resource, err), Duration: time.Since(started)}
+ }
+
+ consumedTotal += len(items)
+ extractFailureTotal += extractFailures
+
+ // When a non-empty page yielded zero stored rows, the API
+ // returned items in a shape we couldn't extract IDs from —
+ // likely scalar IDs (Firebase /topstories.json, GitHub user-
+ // repo lists) where the spec author should declare a hydration
+ // pattern, or an unrecognized primary-key field name.
+ if len(items) > 0 && stored == 0 {
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, "warning: %s returned %d items but stored 0 — the local store will be empty for this resource. Likely cause: scalar item shape rather than objects with extractable IDs.\n", resource, len(items))
+ } else {
+ fmt.Fprintf(os.Stderr, `{"event":"sync_anomaly","resource":"%s","consumed":%d,"stored":0,"reason":"all_items_failed_id_extraction"}`+"\n", resource, len(items))
+ }
+ anomalyEmitted = true
+ } else if extractFailures > 0 && !anomalyEmitted {
+ // Per-item primary-key resolution failure but at least one
+ // item landed — emit one structured warning per resource per
+ // sync run so users see the first occurrence of silent drops
+ // instead of waiting for total failure.
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, "\nwarning: %s had %d item(s) on this page with no extractable primary key — those rows were dropped silently. Annotate the spec with x-resource-id to fix.\n", resource, extractFailures)
+ } else {
+ fmt.Fprintf(os.Stderr, `{"event":"sync_anomaly","resource":"%s","consumed":%d,"stored":%d,"count":%d,"reason":"primary_key_unresolved"}`+"\n", resource, len(items), stored, extractFailures)
+ }
+ anomalyEmitted = true
+ }
+
+ totalCount += stored
+ atomic.AddInt64(&progressCount, int64(stored))
+
+ // Progress reporting (include rate limit info when active)
+ currentRate := c.RateLimit()
+ if humanFriendly {
+ if currentRate > 0 {
+ fmt.Fprintf(os.Stderr, "\r %s: %d synced [%.1f req/s]", resource, atomic.LoadInt64(&progressCount), currentRate)
+ } else {
+ fmt.Fprintf(os.Stderr, "\r %s: %d synced", resource, atomic.LoadInt64(&progressCount))
+ }
+ } else {
+ if currentRate > 0 {
+ fmt.Fprintf(os.Stderr, `{"event":"sync_progress","resource":"%s","fetched":%d,"rate_rps":%.1f}`+"\n", resource, atomic.LoadInt64(&progressCount), currentRate)
+ } else {
+ fmt.Fprintf(os.Stderr, `{"event":"sync_progress","resource":"%s","fetched":%d}`+"\n", resource, atomic.LoadInt64(&progressCount))
+ }
+ }
+
+ // 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
+ 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)
+ } else {
+ fmt.Fprintf(os.Stderr, `{"event":"sync_warning","resource":"%s","reason":"max_pages_cap_hit","message":"reached --max-pages cap of %d; data may be truncated. Re-run with --max-pages 0 (unlimited) or higher to verify."}`+"\n", resource, maxPages)
+ }
+ break
+ }
+
+ // Sticky-cursor detector: if the API echoes the same next cursor across
+ // consecutive pages without advancing, abort to prevent burning the
+ // --max-pages budget on a non-terminating loop. Checked AFTER the cap
+ // guard so cap-hit takes precedence; checked BEFORE the natural-end
+ // check below because the natural-end check would not catch a sticky
+ // non-empty cursor on its own.
+ if nextCursor != "" && nextCursor == lastNextCursor {
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, "\n %s: API returned the same next cursor across two pages; aborting to prevent budget waste.\n", resource)
+ } else {
+ fmt.Fprintf(os.Stderr, `{"event":"sync_warning","resource":"%s","reason":"stuck_pagination","message":"API returned the same next cursor across two pages for resource %s; aborting to prevent budget waste."}`+"\n", resource, resource)
+ }
+ break
+ }
+ lastNextCursor = nextCursor
+
+ // Determine if there are more pages
+ if !hasMore || len(items) < pageSize.limit || nextCursor == "" {
+ break
+ }
+
+ cursor = nextCursor
+ }
+
+ // Final sync state: clear cursor (sync is complete), update count
+ _ = db.SaveSyncState(resource, "", totalCount)
+
+ // F4b symptom probe: if items were consumed and successfully
+ // extracted (extractFailures < consumed) but nothing landed in
+ // the store, something downstream of extraction silently dropped
+ // rows — FTS5 trigger error, transaction rollback, character
+ // encoding. Emit a sync_anomaly so the symptom is visible the
+ // next time it recurs; the underlying root cause is held out for
+ // controlled repro.
+ if consumedTotal > 0 && totalCount == 0 && extractFailureTotal < consumedTotal {
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, "\nwarning: %s consumed %d items, extracted %d primary keys, but stored 0 rows — extraction succeeded yet nothing landed. Investigate FTS triggers / transaction rollback / encoding.\n", resource, consumedTotal, consumedTotal-extractFailureTotal)
+ } else {
+ fmt.Fprintf(os.Stderr, `{"event":"sync_anomaly","resource":"%s","consumed":%d,"stored":0,"extract_failures":%d,"reason":"stored_count_zero_after_extraction"}`+"\n", resource, consumedTotal, extractFailureTotal)
+ }
+ }
+
+ if !humanFriendly {
+ fmt.Fprintf(os.Stderr, `{"event":"sync_complete","resource":"%s","total":%d,"duration_ms":%d}`+"\n", resource, totalCount, time.Since(started).Milliseconds())
+ }
+
+ return syncResult{Resource: resource, Count: totalCount, Duration: time.Since(started)}
+}
+
+// paginationDefaults holds the resolved pagination parameter names and page size.
+type paginationDefaults struct {
+ cursorParam string
+ limitParam string
+ limit int
+}
+
+// determinePaginationDefaults returns the pagination parameter names to use.
+// Values are detected from the API spec by the profiler at generation time.
+func determinePaginationDefaults() paginationDefaults {
+ return paginationDefaults{
+ cursorParam: "cursor",
+ limitParam: "limit",
+ limit: 100,
+ }
+}
+
+// determineSinceParam returns the query parameter name for incremental sync filtering.
+func determineSinceParam() string {
+ return "since"
+}
+
+// extractPageItems attempts to extract an array of items and pagination cursor from a response.
+// It tries multiple strategies:
+// 1. Direct JSON array
+// 2. Common wrapper keys: "data", "results", "items", "records", "nodes", "entries"
+// It also extracts the next cursor from common response fields.
+func extractPageItems(data json.RawMessage, cursorParam string) ([]json.RawMessage, string, bool) {
+ // Strategy 1: direct array
+ var items []json.RawMessage
+ if err := json.Unmarshal(data, &items); err == nil {
+ return items, "", false
+ }
+
+ // Strategy 2: object with known wrapper keys
+ var envelope map[string]json.RawMessage
+ if err := json.Unmarshal(data, &envelope); err != nil {
+ return nil, "", false
+ }
+
+ // Try common item keys first (fast path)
+ itemKeys := []string{"data", "results", "items", "records", "nodes", "entries"}
+ for _, key := range itemKeys {
+ if raw, ok := envelope[key]; ok {
+ if err := json.Unmarshal(raw, &items); err == nil && len(items) > 0 {
+ nextCursor, hasMore := extractPaginationFromEnvelope(envelope, cursorParam)
+ return items, nextCursor, hasMore
+ }
+ }
+ }
+
+ // Fallback: try every key in the envelope. If exactly one maps to a JSON
+ // array with items, use it. This handles APIs that wrap responses with the
+ // resource name (e.g., {"markets": [...], "cursor": "..."}).
+ var arrayKey string
+ var arrayItems []json.RawMessage
+ arrayCount := 0
+ for key, raw := range envelope {
+ var candidate []json.RawMessage
+ if err := json.Unmarshal(raw, &candidate); err == nil && len(candidate) > 0 {
+ arrayKey = key
+ arrayItems = candidate
+ arrayCount++
+ }
+ }
+ if arrayCount == 1 {
+ nextCursor, hasMore := extractPaginationFromEnvelope(envelope, cursorParam)
+ _ = arrayKey // used for detection, items extracted above
+ return arrayItems, nextCursor, hasMore
+ }
+
+ return nil, "", false
+}
+
+// extractPaginationFromEnvelope extracts cursor and has_more from a response envelope.
+func extractPaginationFromEnvelope(envelope map[string]json.RawMessage, cursorParam string) (string, bool) {
+ var hasMore bool
+
+ nextCursor := nextCursorFromLinks(envelope, cursorParam)
+
+ // Try common cursor field names
+ cursorKeys := []string{
+ "next_cursor", "nextCursor", "cursor", "next_page_token",
+ "nextPageToken", "page_token", "after", "end_cursor", "endCursor",
+ }
+ if nextCursor == "" {
+ nextCursor = findCursorInMap(envelope, cursorKeys)
+ }
+
+ // If no top-level cursor was found, look one level deeper into well-known
+ // pagination wrapper objects. Slack returns {"messages":[...],
+ // "response_metadata":{"next_cursor":"..."}}; MongoDB Atlas uses
+ // "pagination"; many APIs use "meta" or "paging". Purely additive — only
+ // runs when the top-level scan returned empty — and uses the same
+ // cursorKeys set so wrapper contents go through the same name match.
+ if nextCursor == "" {
+ paginationWrapperKeys := []string{"response_metadata", "pagination", "meta", "paging"}
+ for _, wrapperKey := range paginationWrapperKeys {
+ rawWrapper, ok := envelope[wrapperKey]
+ if !ok {
+ continue
+ }
+ var inner map[string]json.RawMessage
+ if json.Unmarshal(rawWrapper, &inner) != nil {
+ continue
+ }
+ if c := findCursorInMap(inner, cursorKeys); c != "" {
+ nextCursor = c
+ break
+ }
+ }
+ }
+
+ // Try common has_more field names
+ hasMoreKeys := []string{"has_more", "hasMore", "has_next", "hasNext", "next_page"}
+ for _, key := range hasMoreKeys {
+ if raw, ok := envelope[key]; ok {
+ if err := json.Unmarshal(raw, &hasMore); err == nil {
+ break
+ }
+ }
+ }
+
+ // If we found a cursor, assume there are more pages even without explicit has_more
+ if nextCursor != "" && !hasMore {
+ hasMore = true
+ }
+
+ return nextCursor, hasMore
+}
+
+// nextCursorFromLinks extracts JSON:API-style pagination cursors from
+// {"links":{"next":"https://example.com/items?page[cursor]=..."}}.
+func nextCursorFromLinks(envelope map[string]json.RawMessage, cursorParam string) string {
+ rawLinks, ok := envelope["links"]
+ if !ok {
+ return ""
+ }
+ var links map[string]json.RawMessage
+ if json.Unmarshal(rawLinks, &links) != nil {
+ return ""
+ }
+ rawNext, ok := links["next"]
+ if !ok {
+ return ""
+ }
+ var nextURL string
+ if json.Unmarshal(rawNext, &nextURL) != nil || nextURL == "" {
+ return ""
+ }
+
+ cursorKeys := []string{cursorParam}
+ if cursorParam != "page[cursor]" {
+ cursorKeys = append(cursorKeys, "page[cursor]")
+ }
+ if cursorParam != "cursor" {
+ cursorKeys = append(cursorKeys, "cursor")
+ }
+ if cursorParam != "after" {
+ cursorKeys = append(cursorKeys, "after")
+ }
+
+ parsed, err := url.Parse(nextURL)
+ if err != nil {
+ return ""
+ }
+ values := parsed.Query()
+ for _, key := range cursorKeys {
+ if key == "" {
+ continue
+ }
+ if cursor := values.Get(key); cursor != "" {
+ return cursor
+ }
+ }
+ return ""
+}
+
+// findCursorInMap returns the first non-empty string-typed value in m
+// whose key matches one of cursorKeys. Used by extractPaginationFromEnvelope
+// to scan both the top-level envelope and well-known wrapper objects with
+// the same name-match rules — extracted so the two scans can't drift.
+func findCursorInMap(m map[string]json.RawMessage, cursorKeys []string) string {
+ for _, key := range cursorKeys {
+ raw, ok := m[key]
+ if !ok {
+ continue
+ }
+ var s string
+ if err := json.Unmarshal(raw, &s); err == nil && s != "" {
+ return s
+ }
+ }
+ return ""
+}
+
+type discriminatorDispatch struct {
+ Field string
+ Values map[string]string
+}
+
+var discriminatorDispatchers = map[string]discriminatorDispatch{
+}
+
+func upsertResourceBatch(db *store.Store, resource string, items []json.RawMessage) (int, int, error) {
+ if _, ok := discriminatorDispatchers[resource]; !ok {
+ return db.UpsertBatch(resource, items)
+ }
+
+ grouped := map[string][]json.RawMessage{}
+ order := []string{}
+ for _, item := range items {
+ target := resource
+ var obj map[string]any
+ if err := json.Unmarshal(item, &obj); err == nil {
+ target = resolveDiscriminatedResource(resource, obj)
+ }
+ if _, ok := grouped[target]; !ok {
+ order = append(order, target)
+ }
+ grouped[target] = append(grouped[target], item)
+ }
+
+ var stored, extractFailures int
+ for _, target := range order {
+ targetStored, targetExtractFailures, err := db.UpsertBatch(target, grouped[target])
+ if err != nil {
+ return stored, extractFailures + targetExtractFailures, err
+ }
+ stored += targetStored
+ extractFailures += targetExtractFailures
+ }
+ return stored, extractFailures, nil
+}
+
+func resolveDiscriminatedResource(resource string, obj map[string]any) string {
+ dispatcher, ok := discriminatorDispatchers[resource]
+ if !ok || dispatcher.Field == "" {
+ return resource
+ }
+ value := store.LookupFieldValue(obj, dispatcher.Field)
+ if value == nil {
+ return resource
+ }
+ if target, ok := dispatcher.Values[fmt.Sprintf("%v", value)]; ok && target != "" {
+ return target
+ }
+ return resource
+}
+
+// upsertSingleObject stores a non-array API response as a single record.
+func upsertSingleObject(db *store.Store, resource string, data json.RawMessage) error {
+ var obj map[string]any
+ if err := json.Unmarshal(data, &obj); err != nil {
+ // Not a JSON object either - store raw under resource name
+ return db.Upsert(resource, resource, data)
+ }
+
+ resource = resolveDiscriminatedResource(resource, obj)
+
+ id := extractID(resource, obj)
+ if id == "" {
+ id = resource
+ }
+
+ switch resource {
+ default:
+ return db.Upsert(resource, id, data)
+ }
+}
+
+// parseSinceDuration converts human-friendly duration strings into a time.Time.
+// Supported formats: "7d" (days), "24h" (hours), "30m" (minutes), "1w" (weeks).
+func parseSinceDuration(s string) (time.Time, error) {
+ re := regexp.MustCompile(`^(\d+)([dhwm])$`)
+ matches := re.FindStringSubmatch(strings.TrimSpace(s))
+ if matches == nil {
+ return time.Time{}, fmt.Errorf("expected format like 7d, 24h, 1w, or 30m")
+ }
+
+ n, err := strconv.Atoi(matches[1])
+ if err != nil {
+ return time.Time{}, err
+ }
+
+ now := time.Now()
+ switch matches[2] {
+ case "d":
+ return now.Add(-time.Duration(n) * 24 * time.Hour), nil
+ case "h":
+ return now.Add(-time.Duration(n) * time.Hour), nil
+ case "w":
+ return now.Add(-time.Duration(n) * 7 * 24 * time.Hour), nil
+ case "m":
+ return now.Add(-time.Duration(n) * time.Minute), nil
+ default:
+ return time.Time{}, fmt.Errorf("unknown unit %q", matches[2])
+ }
+}
+
+func defaultSyncResources() []string {
+ return []string{
+ "items",
+ }
+}
+
+// syncResourcePath maps resource names to their actual API endpoint paths.
+// For REST APIs this is typically "/<resource>". For non-REST APIs (e.g., Steam)
+// this preserves the actual endpoint path like "/ISteamApps/GetAppList/v2".
+func syncResourcePath(resource string) (string, error) {
+ paths := map[string]string{
+ "items": "/items",
+ }
+ if p, ok := paths[resource]; ok {
+ return p, nil
+ }
+ return "", fmt.Errorf("unknown sync resource %q", resource)
+}
+
+var syncResourceTiers = map[string]string{
+ "items": "free",
+}
+
+func syncClientForResource(c *client.Client, resource string) *client.Client {
+ if tier, ok := syncResourceTiers[resource]; ok {
+ return c.WithTier(tier)
+ }
+ return c
+}
+
+// resourceIDFieldOverrides projects per-resource IDField (set by the profiler
+// from x-resource-id or the response-schema fallback chain) into a runtime
+// lookup map. extractID consults this first so the templated path wins over
+// the generic fallback list; the generic list applies only when the override
+// is empty or the override field is absent on a given item.
+//
+// Includes both flat resources and dependent (parent-child) resources so
+// annotations on a child path-item are honored at runtime, not just on
+// flat paths.
+var resourceIDFieldOverrides = map[string]string{
+}
+
+// genericIDFieldFallbacks is the runtime safety net for resources that did
+// NOT receive a templated IDField. API-specific names belong in spec
+// annotations (x-resource-id), not this list.
+var genericIDFieldFallbacks = []string{"id", "ID", "name", "uuid", "slug", "key", "code", "uid"}
+
+// criticalResources is the template-time projection of per-resource Critical
+// (set by the profiler from the spec's path-item x-critical extension). It
+// is consulted at error-aggregation time so a non-critical failure can be
+// downgraded to a sync_warning + exit 0 unless --strict was passed.
+//
+// Includes both flat resources and dependent (parent-child) resources so a
+// failed child sync flagged x-critical: true exits non-zero just like a
+// flat-resource critical failure.
+var criticalResources = map[string]bool{
+}
+
+// extractID resolves an item's primary-key field. It consults the
+// per-resource templated override first; on miss, it falls through to the
+// generic fallback list. resource may be empty for callers that don't have
+// a resource context (only the generic list applies in that case).
+//
+// Field lookups go through store.LookupFieldValue so snake_case overrides
+// match camelCase JSON renderings. UpsertBatch resolves fields the same
+// way — divergence between the two paths produces silent drops on
+// heterogeneous payloads.
+func extractID(resource string, obj map[string]any) string {
+ if override, ok := resourceIDFieldOverrides[resource]; ok && override != "" {
+ if v := store.LookupFieldValue(obj, override); v != nil {
+ s := fmt.Sprintf("%v", v)
+ if s != "" && s != "<nil>" {
+ return s
+ }
+ }
+ }
+ for _, key := range genericIDFieldFallbacks {
+ if v := store.LookupFieldValue(obj, key); v != nil {
+ s := fmt.Sprintf("%v", v)
+ if s != "" && s != "<nil>" {
+ return s
+ }
+ }
+ }
+ return ""
+}
diff --git a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/client/client.go b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/client/client.go
new file mode 100644
index 00000000..af4eff03
--- /dev/null
+++ b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/client/client.go
@@ -0,0 +1,550 @@
+// Copyright 2026 printing-press-golden. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package client
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io"
+ "math"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+ "tier-routing-golden-pp-cli/internal/cliutil"
+ "tier-routing-golden-pp-cli/internal/config"
+)
+
+type Client struct {
+ BaseURL string
+ Config *config.Config
+ HTTPClient *http.Client
+ DryRun bool
+ NoCache bool
+ cacheDir string
+ limiter *cliutil.AdaptiveLimiter
+ requestTier string
+ limiters map[string]*cliutil.AdaptiveLimiter
+}
+
+type requestAuth struct {
+ Value string
+ In string
+ Name string
+}
+
+func (c *Client) WithTier(tier string) *Client {
+ if c == nil || tier == "" {
+ return c
+ }
+ next := *c
+ next.requestTier = tier
+ next.limiter = c.limiterForTier(tier)
+ return &next
+}
+
+func newTierLimiters(rateLimit float64) map[string]*cliutil.AdaptiveLimiter {
+ return map[string]*cliutil.AdaptiveLimiter{
+ "enterprise": cliutil.NewAdaptiveLimiter(rateLimit),
+ "free": cliutil.NewAdaptiveLimiter(rateLimit),
+ "paid": cliutil.NewAdaptiveLimiter(rateLimit),
+ }
+}
+
+func (c *Client) limiterForTier(tier string) *cliutil.AdaptiveLimiter {
+ if c == nil {
+ return nil
+ }
+ if tier == "" {
+ return c.limiter
+ }
+ if limiter, ok := c.limiters[tier]; ok && limiter != nil {
+ return limiter
+ }
+ return c.limiter
+}
+
+func (c *Client) baseURLForRequest() string {
+ switch c.requestTier {
+ case "enterprise":
+ return c.BaseURL
+ case "free":
+ return c.BaseURL
+ case "paid":
+ return strings.TrimRight("https://paid.api.example.com", "/")
+ default:
+ return c.BaseURL
+ }
+}
+
+func (c *Client) authForRequest() (requestAuth, error) {
+ switch c.requestTier {
+ case "enterprise":
+ tierValue0 := os.Getenv("TIER_ENTERPRISE_TOKEN")
+ if tierValue0 == "" {
+ return requestAuth{In: "header", Name: "Authorization"}, nil
+ }
+ value := applyTierAuthFormat("Bearer {access_token}", map[string]string{
+ "token": tierValue0,
+ "access_token": tierValue0,
+ "enterprise_token": tierValue0,
+ "TIER_ENTERPRISE_TOKEN": tierValue0,
+ })
+ return requestAuth{Value: value, In: "header", Name: "Authorization"}, nil
+ case "free":
+ return requestAuth{}, nil
+ case "paid":
+ tierValue0 := os.Getenv("TIER_PAID_KEY")
+ if tierValue0 == "" {
+ return requestAuth{In: "query", Name: "api_key"}, nil
+ }
+ value := tierValue0
+ return requestAuth{Value: value, In: "query", Name: "api_key"}, nil
+ default:
+ value, err := c.authHeader()
+ if err != nil {
+ return requestAuth{}, err
+ }
+ return requestAuth{Value: value, In: "header", Name: "Authorization"}, nil
+ }
+}
+
+func applyTierAuthFormat(format string, replacements map[string]string) string {
+ for key, value := range replacements {
+ format = strings.ReplaceAll(format, "{"+key+"}", value)
+ }
+ if strings.Contains(format, "{") {
+ return ""
+ }
+ return format
+}
+
+
+
+// APIError carries HTTP status information for structured exit codes.
+type APIError struct {
+ Method string
+ Path string
+ StatusCode int
+ Body string
+}
+
+func (e *APIError) Error() string {
+ return fmt.Sprintf("%s %s returned HTTP %d: %s", e.Method, e.Path, e.StatusCode, e.Body)
+}
+
+func newHTTPClient(timeout time.Duration, jar http.CookieJar) *http.Client {
+ return &http.Client{Timeout: timeout, Jar: jar}
+}
+
+func New(cfg *config.Config, timeout time.Duration, rateLimit float64) *Client {
+ homeDir, _ := os.UserHomeDir()
+ cacheDir := filepath.Join(homeDir, ".cache", "tier-routing-golden-pp-cli")
+ httpClient := newHTTPClient(timeout, nil)
+ return &Client{
+ BaseURL: strings.TrimRight(cfg.BaseURL, "/"),
+ Config: cfg,
+ HTTPClient: httpClient,
+ cacheDir: cacheDir,
+ limiter: cliutil.NewAdaptiveLimiter(rateLimit),
+ limiters: newTierLimiters(rateLimit),
+ }
+}
+
+// RateLimit returns the current effective rate limit in req/s. Returns 0 if disabled.
+func (c *Client) RateLimit() float64 {
+ return c.limiter.Rate()
+}
+
+func (c *Client) Get(path string, params map[string]string) (json.RawMessage, error) {
+ return c.GetWithHeaders(path, params, nil)
+}
+
+func (c *Client) GetWithHeaders(path string, params map[string]string, headers map[string]string) (json.RawMessage, error) {
+ // Check cache for GET requests
+ if !c.NoCache && !c.DryRun && c.cacheDir != "" {
+ if cached, ok := c.readCache(path, params); ok {
+ return cached, nil
+ }
+ }
+ result, _, err := c.do("GET", path, params, nil, headers)
+ if err == nil && !c.NoCache && !c.DryRun && c.cacheDir != "" {
+ c.writeCache(path, params, result)
+ }
+ return result, err
+}
+
+func (c *Client) ProbeGet(path string) (int, error) {
+ _, status, err := c.do("GET", path, nil, nil, nil)
+ return status, err
+}
+
+func (c *Client) cacheKey(path string, params map[string]string) string {
+ key := path
+ key += "|tier=" + c.requestTier + "|base_url=" + c.baseURLForRequest()
+ for k, v := range params {
+ key += k + "=" + v
+ }
+ h := sha256.Sum256([]byte(key))
+ return hex.EncodeToString(h[:8])
+}
+
+func (c *Client) readCache(path string, params map[string]string) (json.RawMessage, bool) {
+ cacheFile := filepath.Join(c.cacheDir, c.cacheKey(path, params)+".json")
+ info, err := os.Stat(cacheFile)
+ if err != nil || time.Since(info.ModTime()) > 5*time.Minute {
+ return nil, false
+ }
+ data, err := os.ReadFile(cacheFile)
+ if err != nil {
+ return nil, false
+ }
+ return json.RawMessage(data), true
+}
+
+func (c *Client) writeCache(path string, params map[string]string, data json.RawMessage) {
+ os.MkdirAll(c.cacheDir, 0o755)
+ cacheFile := filepath.Join(c.cacheDir, c.cacheKey(path, params)+".json")
+ os.WriteFile(cacheFile, []byte(data), 0o644)
+}
+
+func (c *Client) Post(path string, body any) (json.RawMessage, int, error) {
+ return c.do("POST", path, nil, body, nil)
+}
+
+func (c *Client) PostWithHeaders(path string, body any, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do("POST", path, nil, body, headers)
+}
+
+func (c *Client) Delete(path string) (json.RawMessage, int, error) {
+ return c.do("DELETE", path, nil, nil, nil)
+}
+
+func (c *Client) DeleteWithHeaders(path string, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do("DELETE", path, nil, nil, headers)
+}
+
+func (c *Client) Put(path string, body any) (json.RawMessage, int, error) {
+ return c.do("PUT", path, nil, body, nil)
+}
+
+func (c *Client) PutWithHeaders(path string, body any, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do("PUT", path, nil, body, headers)
+}
+
+func (c *Client) Patch(path string, body any) (json.RawMessage, int, error) {
+ return c.do("PATCH", path, nil, body, nil)
+}
+
+func (c *Client) PatchWithHeaders(path string, body any, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do("PATCH", path, nil, body, headers)
+}
+
+// do executes an HTTP request. headerOverrides, when non-nil, override global
+// RequiredHeaders for this specific request (used for per-endpoint API versioning).
+func (c *Client) do(method, path string, params map[string]string, body any, headerOverrides map[string]string) (json.RawMessage, int, error) {
+ requestBaseURL := c.baseURLForRequest()
+ targetURL := requestBaseURL + path
+
+ var bodyBytes []byte
+ if body != nil {
+ b, err := json.Marshal(body)
+ if err != nil {
+ return nil, 0, fmt.Errorf("marshaling body: %w", err)
+ }
+ bodyBytes = b
+ }
+
+ // Resolve auth material before the dry-run branch so --dry-run can preview
+ // exactly what would be sent. Uses only cached credentials; a token that
+ // requires a network refresh will be re-fetched on the live request path,
+ // not during dry-run.
+ authInfo, err := c.authForRequest()
+ authHeader := authInfo.Value
+ if err != nil {
+ return nil, 0, err
+ }
+
+ // Build the request for dry-run display or actual execution
+ if c.DryRun {
+ return c.dryRun(method, targetURL, path, params, bodyBytes, headerOverrides, authHeader)
+ }
+
+ const maxRetries = 3
+ var lastErr error
+
+ for attempt := 0; attempt <= maxRetries; attempt++ {
+ // Proactive rate limiting — wait before sending
+ c.limiter.Wait()
+ var bodyReader io.Reader
+ if bodyBytes != nil {
+ bodyReader = strings.NewReader(string(bodyBytes))
+ }
+
+ req, err := http.NewRequest(method, targetURL, bodyReader)
+ if err != nil {
+ return nil, 0, fmt.Errorf("creating request: %w", err)
+ }
+ if bodyBytes != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+
+ if params != nil {
+ q := req.URL.Query()
+ for k, v := range params {
+ if v != "" {
+ q.Set(k, v)
+ }
+ }
+ req.URL.RawQuery = q.Encode()
+ }
+
+ if authHeader != "" {
+ if authInfo.In == "query" {
+ q := req.URL.Query()
+ q.Set(authInfo.Name, authHeader)
+ req.URL.RawQuery = q.Encode()
+ } else {
+ req.Header.Set(authInfo.Name, authHeader)
+ }
+ }
+ // Per-endpoint header overrides (e.g., different API version per resource)
+ for k, v := range headerOverrides {
+ req.Header.Set(k, v)
+ }
+ req.Header.Set("User-Agent", "tier-routing-golden-pp-cli/1.0.0")
+
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ lastErr = fmt.Errorf("%s %s: %w", method, path, err)
+ continue
+ }
+
+ respBody, err := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ if err != nil {
+ return nil, 0, fmt.Errorf("reading response: %w", err)
+ }
+ respBody = sanitizeJSONResponse(respBody)
+
+ // Success
+ if resp.StatusCode < 400 {
+ c.limiter.OnSuccess()
+ return json.RawMessage(respBody), resp.StatusCode, nil
+ }
+
+ apiErr := &APIError{
+ Method: method,
+ Path: path,
+ StatusCode: resp.StatusCode,
+ Body: truncateBody(respBody),
+ }
+
+ // Rate limited - adjust adaptive limiter and retry
+ if resp.StatusCode == 429 && attempt < maxRetries {
+ c.limiter.OnRateLimit()
+ wait := cliutil.RetryAfter(resp)
+ fmt.Fprintf(os.Stderr, "rate limited, waiting %s (attempt %d/%d, rate adjusted to %.1f req/s)\n", wait, attempt+1, maxRetries, c.limiter.Rate())
+ time.Sleep(wait)
+ lastErr = apiErr
+ continue
+ }
+
+ // Server error - retry with backoff
+ if resp.StatusCode >= 500 && attempt < maxRetries {
+ wait := time.Duration(math.Pow(2, float64(attempt))) * time.Second
+ fmt.Fprintf(os.Stderr, "server error %d, retrying in %s (attempt %d/%d)\n", resp.StatusCode, wait, attempt+1, maxRetries)
+ time.Sleep(wait)
+ lastErr = apiErr
+ continue
+ }
+
+ // Client error or retries exhausted - return the error
+ return nil, resp.StatusCode, apiErr
+ }
+
+ return nil, 0, lastErr
+}
+
+// dryRun prints the outgoing request exactly as the live path would send it,
+// using the auth material already resolved in `do()`. Never triggers a network
+// call — the caller is responsible for passing cached auth material only.
+func (c *Client) dryRun(method, targetURL, path string, params map[string]string, body []byte, headerOverrides map[string]string, authHeader string) (json.RawMessage, int, error) {
+ authInfo, _ := c.authForRequest()
+ authInfo.Value = authHeader
+ fmt.Fprintf(os.Stderr, "%s %s\n", method, targetURL)
+ queryPrinted := false
+ if params != nil {
+ keys := make([]string, 0, len(params))
+ for k := range params {
+ if params[k] != "" {
+ keys = append(keys, k)
+ }
+ }
+ sort.Strings(keys)
+ for _, k := range keys {
+ sep := "?"
+ if queryPrinted {
+ sep = "&"
+ }
+ fmt.Fprintf(os.Stderr, " %s%s=%s\n", sep, k, params[k])
+ queryPrinted = true
+ }
+ }
+ if authInfo.Value != "" && authInfo.In == "query" {
+ sep := "?"
+ if queryPrinted {
+ sep = "&"
+ }
+ fmt.Fprintf(os.Stderr, " %s%s=%s\n", sep, authInfo.Name, maskToken(authInfo.Value))
+ queryPrinted = true
+ }
+ _ = queryPrinted
+ if body != nil {
+ var pretty json.RawMessage
+ if json.Unmarshal(body, &pretty) == nil {
+ enc := json.NewEncoder(os.Stderr)
+ enc.SetIndent(" ", " ")
+ fmt.Fprintf(os.Stderr, " Body:\n")
+ enc.Encode(pretty)
+ }
+ }
+ if authInfo.Value != "" && authInfo.In != "query" {
+ fmt.Fprintf(os.Stderr, " %s: %s\n", authInfo.Name, maskToken(authInfo.Value))
+ }
+ fmt.Fprintf(os.Stderr, "\n(dry run - no request sent)\n")
+ return json.RawMessage(`{"dry_run": true}`), 0, nil
+}
+
+func (c *Client) ConfiguredTimeout() time.Duration {
+ if c.HTTPClient != nil && c.HTTPClient.Timeout > 0 {
+ return c.HTTPClient.Timeout
+ }
+ return 30 * time.Second
+}
+
+func (c *Client) authHeader() (string, error) {
+ if c.Config == nil {
+ return "", nil
+ }
+ if c.Config.AccessToken != "" && !c.Config.TokenExpiry.IsZero() && time.Now().After(c.Config.TokenExpiry) && c.Config.RefreshToken != "" {
+ if err := c.refreshAccessToken(); err != nil {
+ return "", err
+ }
+ }
+ return c.Config.AuthHeader(), nil
+}
+
+func (c *Client) refreshAccessToken() error {
+ if c.Config == nil {
+ return nil
+ }
+ if c.Config.RefreshToken == "" {
+ return nil
+ }
+
+ tokenURL := ""
+ if tokenURL == "" {
+ return nil
+ }
+
+ params := url.Values{
+ "grant_type": {"refresh_token"},
+ "refresh_token": {c.Config.RefreshToken},
+ "client_id": {c.Config.ClientID},
+ }
+ if c.Config.ClientSecret != "" {
+ params.Set("client_secret", c.Config.ClientSecret)
+ }
+
+ resp, err := c.HTTPClient.PostForm(tokenURL, params)
+ if err != nil {
+ return fmt.Errorf("refreshing access token: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode >= 400 {
+ body, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("refreshing access token: HTTP %d: %s", resp.StatusCode, truncateBody(body))
+ }
+
+ var tokenResp struct {
+ AccessToken string `json:"access_token"`
+ RefreshToken string `json:"refresh_token"`
+ ExpiresIn int `json:"expires_in"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
+ return fmt.Errorf("parsing refresh response: %w", err)
+ }
+ if tokenResp.AccessToken == "" {
+ return fmt.Errorf("refreshing access token: no access token in response")
+ }
+
+ refreshToken := c.Config.RefreshToken
+ if tokenResp.RefreshToken != "" {
+ refreshToken = tokenResp.RefreshToken
+ }
+
+ expiry := time.Time{}
+ if tokenResp.ExpiresIn > 0 {
+ expiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
+ }
+
+ if err := c.Config.SaveTokens(c.Config.ClientID, c.Config.ClientSecret, tokenResp.AccessToken, refreshToken, expiry); err != nil {
+ return fmt.Errorf("saving refreshed token: %w", err)
+ }
+
+ return nil
+}
+
+// sanitizeJSONResponse strips known JSONP/XSSI prefixes and UTF-8 BOM from
+// response bodies so that downstream JSON parsing succeeds. For clean JSON
+// responses these checks are no-ops.
+func sanitizeJSONResponse(body []byte) []byte {
+ // UTF-8 BOM
+ body = bytes.TrimPrefix(body, []byte("\xEF\xBB\xBF"))
+
+ // JSONP/XSSI prefixes, ordered longest-first where prefixes overlap
+ prefixes := [][]byte{
+ []byte(")]}'\n"),
+ []byte(")]}'"),
+ []byte("{}&&"),
+ []byte("for(;;);"),
+ []byte("while(1);"),
+ }
+ for _, p := range prefixes {
+ if bytes.HasPrefix(body, p) {
+ body = bytes.TrimPrefix(body, p)
+ body = bytes.TrimLeft(body, " \t\r\n")
+ break
+ }
+ }
+ return body
+}
+
+
+// maskToken redacts all but the last 4 characters of a token for safe display.
+func maskToken(token string) string {
+ if token == "" {
+ return ""
+ }
+ if len(token) <= 4 {
+ return "****"
+ }
+ return "****" + token[len(token)-4:]
+}
+
+func truncateBody(b []byte) string {
+ s := string(b)
+ if len(s) > 200 {
+ return s[:200] + "..."
+ }
+ return s
+}
diff --git a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/mcp/tools.go b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/mcp/tools.go
new file mode 100644
index 00000000..4c0f61c1
--- /dev/null
+++ b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/mcp/tools.go
@@ -0,0 +1,318 @@
+// Copyright 2026 printing-press-golden. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package mcp
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ mcplib "github.com/mark3labs/mcp-go/mcp"
+ "github.com/mark3labs/mcp-go/server"
+ "tier-routing-golden-pp-cli/internal/cli"
+ "tier-routing-golden-pp-cli/internal/cliutil"
+ "tier-routing-golden-pp-cli/internal/client"
+ "tier-routing-golden-pp-cli/internal/config"
+ "tier-routing-golden-pp-cli/internal/mcp/cobratree"
+ "tier-routing-golden-pp-cli/internal/store"
+)
+
+// RegisterTools registers all API operations as MCP tools.
+func RegisterTools(s *server.MCPServer) {
+ s.AddTool(
+ mcplib.NewTool("items_enterprise",
+ mcplib.WithDescription("List enterprise items."),
+ mcplib.WithReadOnlyHintAnnotation(true),
+ mcplib.WithDestructiveHintAnnotation(false),
+ mcplib.WithOpenWorldHintAnnotation(true),
+ ),
+ makeAPIHandler("GET", "/items/enterprise", "enterprise", []string{ }),
+ )
+ s.AddTool(
+ mcplib.NewTool("items_list",
+ mcplib.WithDescription("List free items. (public)"),
+ mcplib.WithReadOnlyHintAnnotation(true),
+ mcplib.WithDestructiveHintAnnotation(false),
+ mcplib.WithOpenWorldHintAnnotation(true),
+ ),
+ makeAPIHandler("GET", "/items", "free", []string{ }),
+ )
+ s.AddTool(
+ mcplib.NewTool("items_premium",
+ mcplib.WithDescription("List paid items."),
+ mcplib.WithReadOnlyHintAnnotation(true),
+ mcplib.WithDestructiveHintAnnotation(false),
+ mcplib.WithOpenWorldHintAnnotation(true),
+ ),
+ makeAPIHandler("GET", "/items/premium", "paid", []string{ }),
+ )
+ // SQL tool — ad-hoc analysis on synced data without API calls
+ s.AddTool(
+ mcplib.NewTool("sql",
+ mcplib.WithDescription("Run read-only SQL against local database. Use for ad-hoc analysis, aggregations, and joins across synced resources. Requires sync first."),
+ mcplib.WithString("query", mcplib.Required(), mcplib.Description("SQL query (SELECT only). Tables match resource names.")),
+ mcplib.WithReadOnlyHintAnnotation(true),
+ mcplib.WithDestructiveHintAnnotation(false),
+ ),
+ handleSQL,
+ )
+
+ // Context tool — front-loaded domain knowledge for agents.
+ // Call this first to understand the API taxonomy, query patterns, and capabilities.
+ s.AddTool(
+ mcplib.NewTool("context",
+ mcplib.WithDescription("Get API domain context: resource taxonomy, auth requirements, query tips, and unique capabilities. Call this first."),
+ mcplib.WithReadOnlyHintAnnotation(true),
+ mcplib.WithDestructiveHintAnnotation(false),
+ ),
+ handleContext,
+ )
+
+ // Runtime Cobra-tree mirror — exposes every user-facing command that is
+ // not already covered by a typed endpoint or framework MCP tool.
+ cobratree.RegisterAll(s, cli.RootCmd(), cobratree.SiblingCLIPath)
+}
+
+// makeAPIHandler creates a generic MCP tool handler for an API endpoint.
+func makeAPIHandler(method, pathTemplate, tier string, positionalParams []string) server.ToolHandlerFunc {
+ return func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
+ c, err := newMCPClient()
+ if err != nil {
+ return mcplib.NewToolResultError(err.Error()), nil
+ }
+ c = c.WithTier(tier)
+
+ // mcp-go v0.47+ made CallToolParams.Arguments an `any` to support
+ // non-map payloads; GetArguments() returns the map[string]any shape
+ // we rely on here (or an empty map when the payload is something else).
+ args := req.GetArguments()
+
+ // Build path by substituting positional params
+ path := pathTemplate
+ for _, p := range positionalParams {
+ if v, ok := args[p]; ok {
+ path = strings.Replace(path, "{"+p+"}", fmt.Sprintf("%v", v), 1)
+ }
+ }
+
+ // Collect non-positional params as query params
+ params := make(map[string]string)
+ for k, v := range args {
+ isPositional := false
+ for _, p := range positionalParams {
+ if k == p {
+ isPositional = true
+ break
+ }
+ }
+ if !isPositional {
+ params[k] = fmt.Sprintf("%v", v)
+ }
+ }
+
+ var data json.RawMessage
+ switch method {
+ case "GET":
+ data, err = c.Get(path, params)
+ case "POST":
+ body, _ := json.Marshal(args)
+ data, _, err = c.Post(path, body)
+ case "PUT":
+ body, _ := json.Marshal(args)
+ data, _, err = c.Put(path, body)
+ case "PATCH":
+ body, _ := json.Marshal(args)
+ data, _, err = c.Patch(path, body)
+ case "DELETE":
+ data, _, err = c.Delete(path)
+ default:
+ return mcplib.NewToolResultError("unsupported method: " + method), nil
+ }
+
+ if err != nil {
+ msg := err.Error()
+ switch {
+ case strings.Contains(msg, "HTTP 409"):
+ return mcplib.NewToolResultText("already exists (no-op)"), nil
+ case strings.Contains(msg, "HTTP 400") && cliutil.LooksLikeAuthError(msg):
+ return mcplib.NewToolResultError("authentication error: " + cliutil.SanitizeErrorBody(msg) +
+ "\nhint: the API rejected the request — this usually means auth is missing or invalid." +
+ "\n Set your API key: export TIER_GLOBAL_TOKEN=<your-key>" +
+ "\n Run 'tier-routing-golden-pp-cli doctor' to check auth status."), nil
+ case strings.Contains(msg, "HTTP 401"):
+ return mcplib.NewToolResultError("authentication failed: " + cliutil.SanitizeErrorBody(msg) +
+ "\nhint: check your token." +
+ "\n Set it with: export TIER_GLOBAL_TOKEN=<your-key>" +
+ "\n Run 'tier-routing-golden-pp-cli doctor' to check auth status."), nil
+ case strings.Contains(msg, "HTTP 403"):
+ return mcplib.NewToolResultError("permission denied: " + cliutil.SanitizeErrorBody(msg) +
+ "\nhint: your credentials are valid but lack access to this resource." +
+ "\n Set it with: export TIER_GLOBAL_TOKEN=<your-key>" +
+ "\n Run 'tier-routing-golden-pp-cli doctor' to check auth status."), nil
+ case strings.Contains(msg, "HTTP 404"):
+ if method == "DELETE" {
+ return mcplib.NewToolResultText("already deleted (no-op)"), nil
+ }
+ return mcplib.NewToolResultError("not found: " + msg), nil
+ case strings.Contains(msg, "HTTP 429"):
+ return mcplib.NewToolResultError("rate limited: " + msg), nil
+ default:
+ return mcplib.NewToolResultError(msg), nil
+ }
+ }
+
+ // For GET responses, wrap bare arrays with count metadata
+ if method == "GET" {
+ trimmed := strings.TrimSpace(string(data))
+ if len(trimmed) > 0 && trimmed[0] == '[' {
+ var items []json.RawMessage
+ if json.Unmarshal(data, &items) == nil {
+ wrapped := map[string]any{
+ "count": len(items),
+ "items": items,
+ }
+ out, _ := json.Marshal(wrapped)
+ return mcplib.NewToolResultText(string(out)), nil
+ }
+ }
+ }
+ return mcplib.NewToolResultText(string(data)), nil
+ }
+}
+
+func newMCPClient() (*client.Client, error) {
+ home, _ := os.UserHomeDir()
+ cfgPath := filepath.Join(home, ".config", "tier-routing-golden-pp-cli", "config.toml")
+ cfg, err := config.Load(cfgPath)
+ if err != nil {
+ return nil, fmt.Errorf("loading config: %w", err)
+ }
+ c := client.New(cfg, 30*time.Second, 0)
+ // Agents calling through MCP need fresh data every call. The on-disk
+ // response cache survives across MCP server invocations, so a
+ // DELETE/PATCH followed by a GET would otherwise return the
+ // pre-mutation snapshot for up to the cache TTL. The interactive CLI
+ // constructs its own client and is unaffected.
+ c.NoCache = true
+ return c, nil
+}
+
+func dbPath() string {
+ home, _ := os.UserHomeDir()
+ return filepath.Join(home, ".local", "share", "tier-routing-golden-pp-cli", "data.db")
+}
+// Note: MCP tools use their own dbPath() because they are in a separate package (main, not cli).
+// The CLI's defaultDBPath() in the cli package uses the same canonical path.
+
+func handleSQL(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
+ args := req.GetArguments()
+ query, ok := args["query"].(string)
+ if !ok || query == "" {
+ return mcplib.NewToolResultError("query is required"), nil
+ }
+
+ // Block write operations
+ upper := strings.ToUpper(strings.TrimSpace(query))
+ for _, prefix := range []string{"INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "CREATE"} {
+ if strings.HasPrefix(upper, prefix) {
+ return mcplib.NewToolResultError("only SELECT queries are allowed"), nil
+ }
+ }
+
+ db, err := store.OpenWithContext(ctx, dbPath())
+ if err != nil {
+ return mcplib.NewToolResultError(fmt.Sprintf("opening database: %v", err)), nil
+ }
+ defer db.Close()
+
+ rows, err := db.Query(query)
+ if err != nil {
+ return mcplib.NewToolResultError(fmt.Sprintf("query failed: %v", err)), nil
+ }
+ defer rows.Close()
+
+ cols, _ := rows.Columns()
+ var results []map[string]any
+ for rows.Next() {
+ values := make([]any, len(cols))
+ ptrs := make([]any, len(cols))
+ for i := range values {
+ ptrs[i] = &values[i]
+ }
+ rows.Scan(ptrs...)
+ row := make(map[string]any)
+ for i, col := range cols {
+ row[col] = values[i]
+ }
+ results = append(results, row)
+ }
+
+ data, _ := json.MarshalIndent(results, "", " ")
+ return mcplib.NewToolResultText(string(data)), nil
+}
+
+func handleContext(_ context.Context, _ mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
+ ctx := map[string]any{
+ "api": "tier-routing-golden",
+ "description": "Golden fixture for tier-routed generated clients.",
+ "archetype": "generic",
+ "tool_count": 3,
+ // tool_surface tells agents which surface a capability lives on.
+ "tool_surface": "MCP exposes typed endpoint tools plus a runtime mirror of user-facing CLI commands. Endpoint tools keep typed schemas; command-mirror tools shell out to the companion tier-routing-golden-pp-cli binary.",
+ "auth": map[string]any{
+ "type": "bearer_token",
+ "env_vars": []string{"TIER_GLOBAL_TOKEN", },
+ },
+ "tier_routing": map[string]any{
+ "default_tier": "free",
+ "tiers": map[string]any{
+ "enterprise": map[string]any{
+ "auth_type": "bearer_token",
+ "env_vars": []string{"TIER_ENTERPRISE_TOKEN", },
+ },
+ "free": map[string]any{
+ "auth_type": "none",
+ },
+ "paid": map[string]any{
+ "auth_type": "api_key",
+ "base_url": "https://paid.api.example.com",
+ "env_vars": []string{"TIER_PAID_KEY", },
+ },
+ },
+ "endpoints": map[string]string{
+ "items_enterprise": "enterprise",
+ "items_list": "free",
+ "items_premium": "paid",
+ },
+ },
+ "resources": []map[string]any{
+ {
+ "name": "items",
+ "description": "Items",
+ "endpoints": []string{"enterprise", "list", "premium", },
+ "syncable": true,
+ },
+ },
+ "query_tips": []string{
+ "Pagination uses cursor-based paging. Pass cursor parameter for subsequent pages.",
+ "Control page size with the limit parameter (default 100).",
+ "Use the sql tool for ad-hoc analysis on synced data. Run sync first to populate the local database.",
+ "Use the search tool for full-text search across all synced resources. Faster than iterating list endpoints.",
+ "Prefer sql/search over repeated API calls when the data is already synced.",
+ },
+ }
+ data, _ := json.MarshalIndent(ctx, "", " ")
+ return mcplib.NewToolResultText(string(data)), nil
+}
+
+// RegisterNovelFeatureTools is kept as a compatibility no-op for older MCP
+// mains. New generated mains call RegisterTools only; RegisterTools now
+// includes the runtime Cobra-tree mirror.
+func RegisterNovelFeatureTools(s *server.MCPServer) {
+ _ = s
+}
diff --git a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/manifest.json b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/manifest.json
new file mode 100644
index 00000000..bbe082a7
--- /dev/null
+++ b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/manifest.json
@@ -0,0 +1,55 @@
+{
+ "author": {
+ "name": "CLI Printing Press"
+ },
+ "compatibility": {
+ "claude_desktop": ">=1.0.0",
+ "platforms": [
+ "darwin",
+ "linux",
+ "win32"
+ ]
+ },
+ "description": "Tier Routing Golden API surface as MCP tools.",
+ "display_name": "Tier Routing Golden",
+ "license": "Apache-2.0",
+ "manifest_version": "0.3",
+ "name": "tier-routing-golden-pp-mcp",
+ "server": {
+ "entry_point": "bin/tier-routing-golden-pp-mcp",
+ "mcp_config": {
+ "args": [],
+ "command": "${__dirname}/bin/tier-routing-golden-pp-mcp",
+ "env": {
+ "TIER_ENTERPRISE_TOKEN": "${user_config.tier_enterprise_token}",
+ "TIER_GLOBAL_TOKEN": "${user_config.tier_global_token}",
+ "TIER_PAID_KEY": "${user_config.tier_paid_key}"
+ }
+ },
+ "type": "binary"
+ },
+ "user_config": {
+ "tier_enterprise_token": {
+ "description": "Sets TIER_ENTERPRISE_TOKEN for the Tier Routing Golden MCP server.",
+ "required": true,
+ "sensitive": true,
+ "title": "TIER_ENTERPRISE_TOKEN",
+ "type": "string"
+ },
+ "tier_global_token": {
+ "description": "Sets TIER_GLOBAL_TOKEN for the Tier Routing Golden MCP server.",
+ "required": true,
+ "sensitive": true,
+ "title": "TIER_GLOBAL_TOKEN",
+ "type": "string"
+ },
+ "tier_paid_key": {
+ "description": "Sets TIER_PAID_KEY for the Tier Routing Golden MCP server.",
+ "required": true,
+ "sensitive": true,
+ "title": "TIER_PAID_KEY",
+ "type": "string"
+ }
+ },
+ "version": "<PRINTING_PRESS_VERSION>"
+}
diff --git a/testdata/golden/fixtures/tier-routing-api.yaml b/testdata/golden/fixtures/tier-routing-api.yaml
new file mode 100644
index 00000000..1ff5d1cf
--- /dev/null
+++ b/testdata/golden/fixtures/tier-routing-api.yaml
@@ -0,0 +1,49 @@
+name: tier-routing-golden
+description: Golden fixture for tier-routed generated clients.
+version: 1.0.0
+base_url: https://api.example.com
+auth:
+ type: bearer_token
+ env_vars: [TIER_GLOBAL_TOKEN]
+tier_routing:
+ default_tier: free
+ tiers:
+ free:
+ auth:
+ type: none
+ paid:
+ base_url: https://paid.api.example.com
+ auth:
+ type: api_key
+ in: query
+ header: api_key
+ env_vars: [TIER_PAID_KEY]
+ enterprise:
+ auth:
+ type: bearer_token
+ header: Authorization
+ format: Bearer {access_token}
+ env_vars: [TIER_ENTERPRISE_TOKEN]
+resources:
+ items:
+ description: Items
+ tier: free
+ endpoints:
+ list:
+ method: GET
+ path: /items
+ description: List free items
+ pagination:
+ type: cursor
+ cursor_param: cursor
+ limit_param: limit
+ premium:
+ method: GET
+ path: /items/premium
+ description: List paid items
+ tier: paid
+ enterprise:
+ method: GET
+ path: /items/enterprise
+ description: List enterprise items
+ tier: enterprise
← 314b5650 feat(cli): compact repeated MCP parameter descriptions (#569
·
back to Cli Printing Press
·
docs(cli): consolidate AGENTS.md via progressive disclosure 26fb45ea →