[object Object]

← back to Cli Printing Press

feat(cli): widen auth env-var model with kind/required/sensitive metadata (#632) (#639)

3bc1a23a1a7108cf097c3b0c8fd184959f83e1e9 · 2026-05-05 21:48:43 -0700 · Trevin Chow

* docs(cli): plan auth env-var model widening (#632)

Captures the decision artifact for the auth env-var model widening: kind classification (per_call / auth_flow_input / harvested), required-vs-optional flags, no first-class groups (AND = required:true; OR = description-only), per-CLI surgical aliases out of model, U1+U7 deferred to follow-up campaign.

Closes-after-merge: #632 (model). #630/#631 close when surgical library PRs land.

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

* feat(cli): widen auth env-var model with AuthEnvVar struct + x-auth-vars extension

Adds typed AuthEnvVar struct alongside legacy []string EnvVars with deterministic CanonicalEnvVar() selector, kind classification (per_call / auth_flow_input / harvested), required/sensitive flags, and pre-merge normalization for tier+global merge correctness. OpenAPI parser populates the rich model with conservative defaults (cookie schemes → harvested; OAuth2 client_credentials → auth_flow_input with sensitive=false for client_id, true for client_secret) and respects new x-auth-vars override extension. Builds on PR #633's helpers.

Implements U2 + U3 of #632. Plan: docs/plans/2026-05-05-007-feat-auth-envvar-model-widening-plan.md.

Co-Authored-By: Codex (gpt-5-codex high) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cli): route auth env-var rich model through templates + OpenClaw frontmatter

Migrates 9 generator templates to consume Auth.EnvVarSpecs (preferred) with EnvVars fallback. Machine-consumed surfaces (agent_context, mcp_tools, config) emit kind/required/sensitive per entry; first-element sites (auth, auth_browser, helpers, doctor, skill, readme) switch from index 0 to .Auth.CanonicalEnvVar.Name. SKILL.md frontmatter now declares OpenClaw requires.env (per_call+required only) and envVars (every entry, descriptions filtered by Sensitive). Doctor reporting becomes kind-aware: required missing → ERROR, auth_flow_input/harvested missing → INFO with kind-specific hint. Adds rich-auth golden fixture exercising every model dimension; cross-fixture goldens updated for the new emission shape (intentional).

Implements U4 + U5 of #632. Plan: docs/plans/2026-05-05-007-feat-auth-envvar-model-widening-plan.md.

Co-Authored-By: Codex (gpt-5-codex high) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cli): carry auth env-var rich model through manifests, verifier, doctor, scorer

ManifestAuth carries EnvVarSpecs alongside legacy EnvVars; both populate on emit. Climanifest merge runs pre-merge NormalizeEnvVarSpecs at per-tier and per-global level so override semantics survive when sides have different population. MCPB user_config filters by kind (skip auth_flow_input + harvested; per_call respects Required flag). Host auth doctor uses early-return dedup guard on classify.go to prevent mixed-version manifests from double-reporting credentials, with kind-aware status (per_call+required missing → ERROR; auth_flow_input/harvested missing → INFO; harvested treats on-disk auth file as valid). Scorer Auth/AuthProtocol dimensions regex-validated against new emission — no pattern changes needed; added inline pattern comments and an adversarial rich-shape-only scorecard test asserting the new emission scores correctly.

Implements U6a + U6b of #632. Plan: docs/plans/2026-05-05-007-feat-auth-envvar-model-widening-plan.md.

Co-Authored-By: Codex (gpt-5-codex high) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(cli): document auth env-var rich model + file campaign issue

Extends skills/printing-press/SKILL.md env_vars guidance to cover the new x-auth-vars extension, kind classification, Sensitive flag, and the description-only encoding for AND/OR cases. Adds a Slack-shaped OR example. Patches docs/SPEC-EXTENSIONS.md with the clarification that group IDs and legacy aliases are not parsed (they are encoded via per-var Required + Description). New solutions entry at docs/solutions/design-patterns/auth-envvar-rich-model-2026-05-05.md captures the additive-widening + lazy-back-derivation + canonical-selector + early-return-dedup patterns for future reference.

Implements U8 of #632. Plan: docs/plans/2026-05-05-007-feat-auth-envvar-model-widening-plan.md.

Closes #632.

Co-Authored-By: Codex (gpt-5-codex medium) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(cli): simplify auth env-var rich model implementation

Consolidates duplicate logic surfaced by parallel reuse/quality/efficiency review of the auth env-var widening branch:
- Adds ManifestAuth.EffectiveEnvVarSpecs method to dedup three identical legacy-coercion implementations across pipeline + authdoctor packages
- Drops requiredBoolField (parser.go) — character-identical to existing boolExtension
- Exports AllAuthEnvVarSpecsInferred from spec; deletes climanifest's hasConcreteAuthEnvVarSpec inverse
- Reuses authEnvVarSpecNames in climanifest instead of inline name extraction
- Adds idempotency guard to NormalizeEnvVarSpecs so repeated calls are O(1)
- Types AuthEnvVarStatus.Status as AuthEnvVarStatusCode (precedent: authdoctor.Status)
- Caches CanonicalEnvVar with $canonicalEnvVar in 4 templates (helpers, auth_browser, doctor, mcp_tools)
- Removes narrative comments restating field names / branch conditions (// canonical envVar, // rich model, // legacy fallback)
- Drops redundant default arm in mcpb_manifest kind switch
- Capacity hint on missingRequiredAuthEnvVars slice

Three config.go goldens regenerated for the intentional removal of the // canonical envVar comment from emission. No behavior changes.

Co-Authored-By: Codex (gpt-5-codex medium) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cli): apply safe_auto code-review findings on auth env-var rich model

Five concrete bug fixes surfaced by parallel code review (correctness, security, adversarial, testing, maintainability, project-standards, api-contract, reliability, agent-native, learnings):

1. OAuth2 client_credentials parser: applyAuthEnvVarDefaults now syncs auth.EnvVars to match the rich EnvVarSpecs (CLIENT_ID + CLIENT_SECRET). Previously left EnvVars stale, causing classify.go's disagreement warning to fire on every auth doctor run; downstream emission also flushed the OAuth2 CC golden's generated env var names from the stale combined form to the correct canonical form.

2. summarizeAuthEnvVars apiKey short-circuit now scopes to entries whose name matches cfg.EnvVar — multi-credential specs (FedEx 4-key, Slack BOT+USER) no longer mark all entries OK when only one --api-key is supplied.

3. sameAuthEnvVarNames in classify.go switched from positional to multiset comparison — eliminates spurious tier-merge ordering false positives where the same credential set appears in different orders.

4. NormalizeEnvVarSpecs idempotency guard: filters empty-Name entries consistently from both sides of the canonicalNames comparison so an empty entry no longer trips re-normalization that silently shrinks EnvVars.

5. README env-var table now includes Required and Description columns — humans get the same per-var metadata agents see in agent-context and MCP context tools.

Each fix has a regression test. golden.sh verify clean across 12 cases.

Co-Authored-By: Codex (gpt-5-codex medium) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cli): resolve P1+P2 residual code-review findings on auth env-var rich model

Four substantive correctness/contract fixes from the residual-work gate:

1. AuthHeader OR-pattern fan-out (R1, P1): config.go.tmpl AuthHeader detects all-optional per_call OR-case at template-render time and emits a fallback chain — checks each member's env var, returns the first non-empty. Closes the bug where a Slack-shaped CLI returned empty/'Bearer ' when the user exported only the non-canonical alternative.

2. Multi-OR-group rejection at validation (R2, P1): spec.Validate now rejects auth specs declaring 2+ independent OR-groups (e.g., `A OR B` for credential X + `C OR D` for credential Y). The current model encodes OR membership via per-var Required=false + description text and supports at most one OR-group per auth block. Single-group transitive OR-chains and AND-groups (all Required=true) continue to validate. Doctor template's single-boolean optional-satisfied state is now correct because validation rejects the multi-group case upstream.

3. auth doctor summary 'info' column moved to end (R5, P2): render.go now emits "%d ok, %d suspicious, %d not set, %d no auth, %d unknown, %d info" — preserves positional ordering of the original five columns; positional shell parsers no longer break when info kind is present.

4. Sensitive description placeholder per kind (R7, P2): agent_context.go.tmpl and mcp_tools.go.tmpl now emit kind-derived placeholders for Sensitive=true entries instead of suppressing description entirely. per_call → "Set to your API credential."; auth_flow_input → "Set during initial auth setup."; harvested → "Populated automatically by auth login." Matches plan's stated intent.

Each fix has a regression test. golden updates limited to the affected emission shapes.

Co-Authored-By: Codex (gpt-5-codex medium) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(cli): mark auth env-var widening plan as completed

Plan status active -> completed. Follow-up campaign filed at #638.

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

* refactor(cli): post-fix simplification — hoist regex, drop redundant casts and state, extract OR-case method

Eight cleanup fixes from a parallel reviewer pass on the post-review-fix commits:

1. Hoist `tokenPattern` to package-level `orGroupTokenRe` — matches the file's existing convention (8 other regexes are package-level vars) and avoids recompilation on every Validate call.

2. Drop `(printf "%s" .Kind)` redundant casts at 10 sites in agent_context.go.tmpl, mcp_tools.go.tmpl, config.go.tmpl. Go text/template compares typed string aliases directly to literals — `eq .Kind "per_call"` works without coercion. doctor.go.tmpl already uses the correct pattern.

3. Drop redundant `memberNames` map in `independentAuthORGroupsExample` — the existing `parent` map already has exactly those keys.

4. Delete narrative `// OR-case fan-out` comments x4 in config.go.tmpl. Per AGENTS.md "document why, not what" — the if-else chain is self-explanatory.

5. Extract `AuthConfig.IsAuthEnvVarORCase()` method on AuthConfig; replace the inline `$orMembers` accumulation in config.go.tmpl with a single method call. Cleaner, testable, matches CanonicalEnvVar precedent.

6. Drop `token :=` intermediate var in api_key OR fan-out — bearer_token branch doesn't use it; consistency win.

7. Guard `envVarNames` allocation behind `canonical` short-circuit in NormalizeEnvVarSpecs — skip the work when canonical is already false.

8. `order := make([]string, 0, len(members))` in independentAuthORGroupsExample matches the file's `make([]T, 0, cap)` convention.

Also removed a stale templates_test.go assertion that referenced the deleted "OR-case fan-out" comment.

Goldens stayed clean. All tests pass.

Co-Authored-By: Codex (gpt-5-codex medium) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cli): final code-review pass — 7 P1/P2 fixes on auth env-var rich model

Resolves the residual findings from the final pre-merge code review (10 reviewers, ~1632 lines incremental scope after the prior simplification round):

1. summarizeAuthEnvVars: --api-key without --env-var now scopes to the canonical env-var entry instead of regressing to "all required missing." cfg.EnvVar="" fallback was preventing the apiKey OK marker from satisfying any entry.

2. bearer_token OR-case AuthHeader: fan-out now falls through to the existing AccessToken fallback instead of premature return "". Brings bearer in line with cookie/composed OR-case branches.

3. OR-group validator: false-positive cases where descriptions contain " OR " but don't mutually cross-reference (e.g., "Set this OR use OAuth.") are no longer member of OR-group detection; validation accepts them. The genuine multi-OR-group case (cross-referencing) still rejects.

4. README leak: env-var table now applies the same Sensitive-aware filtering as agent_context and mcp_tools. Sensitive=true descriptions render the kind-derived placeholder instead of leaking user-authored description text.

5. README markdown table: pipe and newline characters in description are now escaped via AuthEnvVar.MarkdownDescription so they don't corrupt the table.

6. Sensitive placeholder dedup: extracted AuthEnvVarKind.SensitivePlaceholder() method on the typed kind. agent_context.go.tmpl, mcp_tools.go.tmpl (both top-level and tier-routing), and readme.md.tmpl now all call the same source-of-truth — adding a 4th kind would require updating one method, not 4 template sites.

8. IsAuthEnvVarORCase comment: rewritten to be self-contained (per AGENTS.md "Code comments must be self-contained") instead of referencing config.go.tmpl as load-bearing context.

Findings #7 (OR-case description clobbering on agent surfaces) and #9 (OR fan-out 4× duplication) deferred as design decisions; #10 and #11 acknowledged as advisory. golden.sh verify clean across 13 cases.

Co-Authored-By: Codex (gpt-5-codex medium) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cli): hide harvested auth vars from agent metadata

Harvested auth variables are populated by auth login, so they should not appear in user-facing OpenClaw envVars or agent-facing context metadata.

* chore(cli): ignore migration helper binary

* fix(cli): clarify auth helper guidance

Use API display names for auth command help instead of a single env var, and make harvested-token doctor hints match the emitted auth flow.

* refactor(cli): simplify auth env var handling

* fix(cli): remove unused manifest auth helper

---------

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

Files touched

Diff

commit 3bc1a23a1a7108cf097c3b0c8fd184959f83e1e9
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Tue May 5 21:48:43 2026 -0700

    feat(cli): widen auth env-var model with kind/required/sensitive metadata (#632) (#639)
    
    * docs(cli): plan auth env-var model widening (#632)
    
    Captures the decision artifact for the auth env-var model widening: kind classification (per_call / auth_flow_input / harvested), required-vs-optional flags, no first-class groups (AND = required:true; OR = description-only), per-CLI surgical aliases out of model, U1+U7 deferred to follow-up campaign.
    
    Closes-after-merge: #632 (model). #630/#631 close when surgical library PRs land.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * feat(cli): widen auth env-var model with AuthEnvVar struct + x-auth-vars extension
    
    Adds typed AuthEnvVar struct alongside legacy []string EnvVars with deterministic CanonicalEnvVar() selector, kind classification (per_call / auth_flow_input / harvested), required/sensitive flags, and pre-merge normalization for tier+global merge correctness. OpenAPI parser populates the rich model with conservative defaults (cookie schemes → harvested; OAuth2 client_credentials → auth_flow_input with sensitive=false for client_id, true for client_secret) and respects new x-auth-vars override extension. Builds on PR #633's helpers.
    
    Implements U2 + U3 of #632. Plan: docs/plans/2026-05-05-007-feat-auth-envvar-model-widening-plan.md.
    
    Co-Authored-By: Codex (gpt-5-codex high) <noreply@anthropic.com>
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * feat(cli): route auth env-var rich model through templates + OpenClaw frontmatter
    
    Migrates 9 generator templates to consume Auth.EnvVarSpecs (preferred) with EnvVars fallback. Machine-consumed surfaces (agent_context, mcp_tools, config) emit kind/required/sensitive per entry; first-element sites (auth, auth_browser, helpers, doctor, skill, readme) switch from index 0 to .Auth.CanonicalEnvVar.Name. SKILL.md frontmatter now declares OpenClaw requires.env (per_call+required only) and envVars (every entry, descriptions filtered by Sensitive). Doctor reporting becomes kind-aware: required missing → ERROR, auth_flow_input/harvested missing → INFO with kind-specific hint. Adds rich-auth golden fixture exercising every model dimension; cross-fixture goldens updated for the new emission shape (intentional).
    
    Implements U4 + U5 of #632. Plan: docs/plans/2026-05-05-007-feat-auth-envvar-model-widening-plan.md.
    
    Co-Authored-By: Codex (gpt-5-codex high) <noreply@anthropic.com>
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * feat(cli): carry auth env-var rich model through manifests, verifier, doctor, scorer
    
    ManifestAuth carries EnvVarSpecs alongside legacy EnvVars; both populate on emit. Climanifest merge runs pre-merge NormalizeEnvVarSpecs at per-tier and per-global level so override semantics survive when sides have different population. MCPB user_config filters by kind (skip auth_flow_input + harvested; per_call respects Required flag). Host auth doctor uses early-return dedup guard on classify.go to prevent mixed-version manifests from double-reporting credentials, with kind-aware status (per_call+required missing → ERROR; auth_flow_input/harvested missing → INFO; harvested treats on-disk auth file as valid). Scorer Auth/AuthProtocol dimensions regex-validated against new emission — no pattern changes needed; added inline pattern comments and an adversarial rich-shape-only scorecard test asserting the new emission scores correctly.
    
    Implements U6a + U6b of #632. Plan: docs/plans/2026-05-05-007-feat-auth-envvar-model-widening-plan.md.
    
    Co-Authored-By: Codex (gpt-5-codex high) <noreply@anthropic.com>
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * docs(cli): document auth env-var rich model + file campaign issue
    
    Extends skills/printing-press/SKILL.md env_vars guidance to cover the new x-auth-vars extension, kind classification, Sensitive flag, and the description-only encoding for AND/OR cases. Adds a Slack-shaped OR example. Patches docs/SPEC-EXTENSIONS.md with the clarification that group IDs and legacy aliases are not parsed (they are encoded via per-var Required + Description). New solutions entry at docs/solutions/design-patterns/auth-envvar-rich-model-2026-05-05.md captures the additive-widening + lazy-back-derivation + canonical-selector + early-return-dedup patterns for future reference.
    
    Implements U8 of #632. Plan: docs/plans/2026-05-05-007-feat-auth-envvar-model-widening-plan.md.
    
    Closes #632.
    
    Co-Authored-By: Codex (gpt-5-codex medium) <noreply@anthropic.com>
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * refactor(cli): simplify auth env-var rich model implementation
    
    Consolidates duplicate logic surfaced by parallel reuse/quality/efficiency review of the auth env-var widening branch:
    - Adds ManifestAuth.EffectiveEnvVarSpecs method to dedup three identical legacy-coercion implementations across pipeline + authdoctor packages
    - Drops requiredBoolField (parser.go) — character-identical to existing boolExtension
    - Exports AllAuthEnvVarSpecsInferred from spec; deletes climanifest's hasConcreteAuthEnvVarSpec inverse
    - Reuses authEnvVarSpecNames in climanifest instead of inline name extraction
    - Adds idempotency guard to NormalizeEnvVarSpecs so repeated calls are O(1)
    - Types AuthEnvVarStatus.Status as AuthEnvVarStatusCode (precedent: authdoctor.Status)
    - Caches CanonicalEnvVar with $canonicalEnvVar in 4 templates (helpers, auth_browser, doctor, mcp_tools)
    - Removes narrative comments restating field names / branch conditions (// canonical envVar, // rich model, // legacy fallback)
    - Drops redundant default arm in mcpb_manifest kind switch
    - Capacity hint on missingRequiredAuthEnvVars slice
    
    Three config.go goldens regenerated for the intentional removal of the // canonical envVar comment from emission. No behavior changes.
    
    Co-Authored-By: Codex (gpt-5-codex medium) <noreply@anthropic.com>
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * fix(cli): apply safe_auto code-review findings on auth env-var rich model
    
    Five concrete bug fixes surfaced by parallel code review (correctness, security, adversarial, testing, maintainability, project-standards, api-contract, reliability, agent-native, learnings):
    
    1. OAuth2 client_credentials parser: applyAuthEnvVarDefaults now syncs auth.EnvVars to match the rich EnvVarSpecs (CLIENT_ID + CLIENT_SECRET). Previously left EnvVars stale, causing classify.go's disagreement warning to fire on every auth doctor run; downstream emission also flushed the OAuth2 CC golden's generated env var names from the stale combined form to the correct canonical form.
    
    2. summarizeAuthEnvVars apiKey short-circuit now scopes to entries whose name matches cfg.EnvVar — multi-credential specs (FedEx 4-key, Slack BOT+USER) no longer mark all entries OK when only one --api-key is supplied.
    
    3. sameAuthEnvVarNames in classify.go switched from positional to multiset comparison — eliminates spurious tier-merge ordering false positives where the same credential set appears in different orders.
    
    4. NormalizeEnvVarSpecs idempotency guard: filters empty-Name entries consistently from both sides of the canonicalNames comparison so an empty entry no longer trips re-normalization that silently shrinks EnvVars.
    
    5. README env-var table now includes Required and Description columns — humans get the same per-var metadata agents see in agent-context and MCP context tools.
    
    Each fix has a regression test. golden.sh verify clean across 12 cases.
    
    Co-Authored-By: Codex (gpt-5-codex medium) <noreply@anthropic.com>
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * fix(cli): resolve P1+P2 residual code-review findings on auth env-var rich model
    
    Four substantive correctness/contract fixes from the residual-work gate:
    
    1. AuthHeader OR-pattern fan-out (R1, P1): config.go.tmpl AuthHeader detects all-optional per_call OR-case at template-render time and emits a fallback chain — checks each member's env var, returns the first non-empty. Closes the bug where a Slack-shaped CLI returned empty/'Bearer ' when the user exported only the non-canonical alternative.
    
    2. Multi-OR-group rejection at validation (R2, P1): spec.Validate now rejects auth specs declaring 2+ independent OR-groups (e.g., `A OR B` for credential X + `C OR D` for credential Y). The current model encodes OR membership via per-var Required=false + description text and supports at most one OR-group per auth block. Single-group transitive OR-chains and AND-groups (all Required=true) continue to validate. Doctor template's single-boolean optional-satisfied state is now correct because validation rejects the multi-group case upstream.
    
    3. auth doctor summary 'info' column moved to end (R5, P2): render.go now emits "%d ok, %d suspicious, %d not set, %d no auth, %d unknown, %d info" — preserves positional ordering of the original five columns; positional shell parsers no longer break when info kind is present.
    
    4. Sensitive description placeholder per kind (R7, P2): agent_context.go.tmpl and mcp_tools.go.tmpl now emit kind-derived placeholders for Sensitive=true entries instead of suppressing description entirely. per_call → "Set to your API credential."; auth_flow_input → "Set during initial auth setup."; harvested → "Populated automatically by auth login." Matches plan's stated intent.
    
    Each fix has a regression test. golden updates limited to the affected emission shapes.
    
    Co-Authored-By: Codex (gpt-5-codex medium) <noreply@anthropic.com>
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * docs(cli): mark auth env-var widening plan as completed
    
    Plan status active -> completed. Follow-up campaign filed at #638.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * refactor(cli): post-fix simplification — hoist regex, drop redundant casts and state, extract OR-case method
    
    Eight cleanup fixes from a parallel reviewer pass on the post-review-fix commits:
    
    1. Hoist `tokenPattern` to package-level `orGroupTokenRe` — matches the file's existing convention (8 other regexes are package-level vars) and avoids recompilation on every Validate call.
    
    2. Drop `(printf "%s" .Kind)` redundant casts at 10 sites in agent_context.go.tmpl, mcp_tools.go.tmpl, config.go.tmpl. Go text/template compares typed string aliases directly to literals — `eq .Kind "per_call"` works without coercion. doctor.go.tmpl already uses the correct pattern.
    
    3. Drop redundant `memberNames` map in `independentAuthORGroupsExample` — the existing `parent` map already has exactly those keys.
    
    4. Delete narrative `// OR-case fan-out` comments x4 in config.go.tmpl. Per AGENTS.md "document why, not what" — the if-else chain is self-explanatory.
    
    5. Extract `AuthConfig.IsAuthEnvVarORCase()` method on AuthConfig; replace the inline `$orMembers` accumulation in config.go.tmpl with a single method call. Cleaner, testable, matches CanonicalEnvVar precedent.
    
    6. Drop `token :=` intermediate var in api_key OR fan-out — bearer_token branch doesn't use it; consistency win.
    
    7. Guard `envVarNames` allocation behind `canonical` short-circuit in NormalizeEnvVarSpecs — skip the work when canonical is already false.
    
    8. `order := make([]string, 0, len(members))` in independentAuthORGroupsExample matches the file's `make([]T, 0, cap)` convention.
    
    Also removed a stale templates_test.go assertion that referenced the deleted "OR-case fan-out" comment.
    
    Goldens stayed clean. All tests pass.
    
    Co-Authored-By: Codex (gpt-5-codex medium) <noreply@anthropic.com>
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * fix(cli): final code-review pass — 7 P1/P2 fixes on auth env-var rich model
    
    Resolves the residual findings from the final pre-merge code review (10 reviewers, ~1632 lines incremental scope after the prior simplification round):
    
    1. summarizeAuthEnvVars: --api-key without --env-var now scopes to the canonical env-var entry instead of regressing to "all required missing." cfg.EnvVar="" fallback was preventing the apiKey OK marker from satisfying any entry.
    
    2. bearer_token OR-case AuthHeader: fan-out now falls through to the existing AccessToken fallback instead of premature return "". Brings bearer in line with cookie/composed OR-case branches.
    
    3. OR-group validator: false-positive cases where descriptions contain " OR " but don't mutually cross-reference (e.g., "Set this OR use OAuth.") are no longer member of OR-group detection; validation accepts them. The genuine multi-OR-group case (cross-referencing) still rejects.
    
    4. README leak: env-var table now applies the same Sensitive-aware filtering as agent_context and mcp_tools. Sensitive=true descriptions render the kind-derived placeholder instead of leaking user-authored description text.
    
    5. README markdown table: pipe and newline characters in description are now escaped via AuthEnvVar.MarkdownDescription so they don't corrupt the table.
    
    6. Sensitive placeholder dedup: extracted AuthEnvVarKind.SensitivePlaceholder() method on the typed kind. agent_context.go.tmpl, mcp_tools.go.tmpl (both top-level and tier-routing), and readme.md.tmpl now all call the same source-of-truth — adding a 4th kind would require updating one method, not 4 template sites.
    
    8. IsAuthEnvVarORCase comment: rewritten to be self-contained (per AGENTS.md "Code comments must be self-contained") instead of referencing config.go.tmpl as load-bearing context.
    
    Findings #7 (OR-case description clobbering on agent surfaces) and #9 (OR fan-out 4× duplication) deferred as design decisions; #10 and #11 acknowledged as advisory. golden.sh verify clean across 13 cases.
    
    Co-Authored-By: Codex (gpt-5-codex medium) <noreply@anthropic.com>
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * fix(cli): hide harvested auth vars from agent metadata
    
    Harvested auth variables are populated by auth login, so they should not appear in user-facing OpenClaw envVars or agent-facing context metadata.
    
    * chore(cli): ignore migration helper binary
    
    * fix(cli): clarify auth helper guidance
    
    Use API display names for auth command help instead of a single env var, and make harvested-token doctor hints match the emitted auth flow.
    
    * refactor(cli): simplify auth env var handling
    
    * fix(cli): remove unused manifest auth helper
    
    ---------
    
    Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 .gitignore                                         |    1 +
 docs/SPEC-EXTENSIONS.md                            |   39 +
 ...-05-007-feat-auth-envvar-model-widening-plan.md |  622 ++++++++++
 .../auth-envvar-rich-model-2026-05-05.md           |   51 +
 internal/authdoctor/classify.go                    |  115 +-
 internal/authdoctor/classify_test.go               |  127 +++
 internal/authdoctor/render.go                      |    4 +-
 internal/authdoctor/render_test.go                 |    4 +
 internal/authdoctor/types.go                       |    4 +
 internal/generator/auth_optional_test.go           |   22 +-
 internal/generator/generator.go                    |   36 +
 internal/generator/generator_test.go               |    2 +-
 internal/generator/skill_test.go                   |   48 +
 internal/generator/templates/agent_context.go.tmpl |   43 +-
 internal/generator/templates/auth.go.tmpl          |   10 +-
 internal/generator/templates/auth_browser.go.tmpl  |   31 +-
 .../templates/auth_client_credentials.go.tmpl      |    6 +-
 internal/generator/templates/auth_simple.go.tmpl   |   10 +-
 internal/generator/templates/config.go.tmpl        |  165 ++-
 internal/generator/templates/doctor.go.tmpl        |   92 +-
 internal/generator/templates/helpers.go.tmpl       |   43 +-
 internal/generator/templates/mcp_tools.go.tmpl     |   65 +-
 internal/generator/templates/readme.md.tmpl        |   48 +-
 internal/generator/templates/skill.md.tmpl         |   47 +-
 internal/generator/templates_test.go               |  160 +++
 internal/openapi/parser.go                         |  144 ++-
 internal/openapi/parser_test.go                    |  328 ++++++
 internal/pipeline/climanifest.go                   |   83 +-
 internal/pipeline/climanifest_test.go              |   79 ++
 internal/pipeline/mcpb_manifest.go                 |   75 +-
 internal/pipeline/publish.go                       |    1 +
 internal/pipeline/publish_test.go                  |   23 +
 internal/pipeline/runtime.go                       |  183 ++-
 internal/pipeline/runtime_test.go                  |   53 +
 internal/pipeline/scorecard.go                     |   13 +-
 internal/pipeline/scorecard_tier2_test.go          |   68 ++
 internal/pipeline/toolsmanifest.go                 |   50 +-
 internal/pipeline/toolsmanifest_test.go            |   18 +
 internal/spec/spec.go                              |  337 +++++-
 internal/spec/spec_test.go                         |  416 +++++++
 skills/printing-press/SKILL.md                     |   42 +
 .../generate-golden-api-rich-auth/artifacts.txt    |    9 +
 .../generate-golden-api-rich-auth/command.txt      |    2 +
 .../printing-press-oauth2-cc/internal/cli/auth.go  |   23 +-
 .../internal/client/client.go                      |    5 +-
 .../internal/config/config.go                      |   19 +-
 .../generate-golden-api-rich-auth/exit.txt         |    1 +
 .../printing-press-rich-auth/README.md             |  192 ++++
 .../printing-press-rich-auth/SKILL.md              |  192 ++++
 .../internal/cli/agent_context.go                  |  226 ++++
 .../printing-press-rich-auth/internal/cli/auth.go  |  176 +++
 .../internal/cli/doctor.go                         |  521 +++++++++
 .../internal/cli/helpers.go                        | 1198 ++++++++++++++++++++
 .../internal/config/config.go                      |  152 +++
 .../printing-press-rich-auth/internal/mcp/tools.go |  321 ++++++
 .../generate-golden-api-rich-auth/stderr.txt       |    2 +
 .../generate-golden-api-rich-auth/stdout.txt       |    0
 .../printing-press-golden/README.md                |    5 +-
 .../printing-press-golden/SKILL.md                 |    6 +-
 .../printing-press-golden/internal/cli/auth.go     |    2 +-
 .../printing-press-golden/internal/cli/doctor.go   |   31 +-
 .../printing-press-golden/internal/mcp/tools.go    |   10 +-
 .../tier-routing-golden/internal/cli/doctor.go     |   31 +-
 .../tier-routing-golden/internal/mcp/tools.go      |   30 +-
 testdata/golden/fixtures/golden-api-rich-auth.yaml |   74 ++
 65 files changed, 6654 insertions(+), 282 deletions(-)

diff --git a/.gitignore b/.gitignore
index 5792f3d2..31e3fa9d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,7 @@ catalog/specs/*.graphql
 /refactor/artifacts/
 /tests/artifacts/perf/
 /printing-press
+/migrate-skill-metadata
 library/
 dist/
 claude-upgrade-progress.json
diff --git a/docs/SPEC-EXTENSIONS.md b/docs/SPEC-EXTENSIONS.md
index b971dd28..597df884 100644
--- a/docs/SPEC-EXTENSIONS.md
+++ b/docs/SPEC-EXTENSIONS.md
@@ -20,6 +20,7 @@ in the same change as any new `Extensions["x-*"]` lookup in that file.
 | `x-auth-format` | `components.securitySchemes.<name>` | `APISpec.Auth.Format` | No |
 | `x-prefix` | `components.securitySchemes.<name>` | `APISpec.Auth.Format` | No |
 | `x-auth-env-vars` | `components.securitySchemes.<name>` | `APISpec.Auth.EnvVars` | No |
+| `x-auth-vars` | `components.securitySchemes.<name>` | `APISpec.Auth.EnvVarSpecs` | No |
 | `x-speakeasy-example` | `components.securitySchemes.<name>` | `APISpec.Auth.EnvVars` | No |
 | `x-auth-optional` | `components.securitySchemes.<name>` | `APISpec.Auth.Optional` | No |
 | `x-auth-key-url` | `components.securitySchemes.<name>` | `APISpec.Auth.KeyURL` | No |
@@ -240,6 +241,44 @@ Rules:
 - When at least one non-empty item is present, the list replaces the parser's
   generated env var names.
 
+### `x-auth-vars`
+
+Overrides the generated credential environment variable metadata.
+
+Parsed field: `APISpec.Auth.EnvVarSpecs`
+
+Rules:
+- Optional.
+- Must be a list of objects.
+- Each object must include `name`, `kind`, `required`, and `sensitive`.
+- `name` must be a non-empty string.
+- `kind` must be one of `per_call`, `auth_flow_input`, or `harvested`.
+- `required` and `sensitive` must be booleans.
+- `description` is optional and must be a string when present.
+- Group IDs and legacy aliases are not parsed. Express OR relationships in
+  `description` text and by marking each alternative `required: false`.
+- Use either `x-auth-env-vars` for legacy name-only overrides or `x-auth-vars`
+  for rich metadata. If both are present, `x-auth-vars` wins.
+- Malformed values are ignored with a warning, and the parser falls back to the
+  generated auth env-var defaults.
+
+Example:
+
+```yaml
+components:
+  securitySchemes:
+    apiKey:
+      type: apiKey
+      in: header
+      name: Authorization
+      x-auth-vars:
+        - name: TODOIST_API_KEY
+          kind: per_call
+          required: true
+          sensitive: true
+          description: Todoist API key.
+```
+
 ### `x-speakeasy-example`
 
 Uses a Speakeasy security-scheme example as the credential environment variable
diff --git a/docs/plans/2026-05-05-007-feat-auth-envvar-model-widening-plan.md b/docs/plans/2026-05-05-007-feat-auth-envvar-model-widening-plan.md
new file mode 100644
index 00000000..f86d82cd
--- /dev/null
+++ b/docs/plans/2026-05-05-007-feat-auth-envvar-model-widening-plan.md
@@ -0,0 +1,622 @@
+---
+title: Auth env-var model widening + library remediation
+type: feat
+status: completed
+date: 2026-05-05
+---
+
+# Auth env-var model widening + library remediation
+
+## Summary
+
+Widen the generator's auth env-var model from a flat `[]string` to a richer typed shape that carries kind classification (per-call user-supplied vs. auth-flow input vs. harvested byproduct), required-vs-optional flags, AND/OR group membership, and canonical-name-with-legacy-aliases. Route the richer model through every downstream surface — auth.go, doctor, MCP agent-context, helpers, SKILL.md body, OpenClaw frontmatter, manifests, verifier, scorer, host auth-doctor — so each one tells the truth about what users supply versus what the auth flow harvests. Build on PR #633's parser-side canonical extraction. Audit all 46 published CLIs, classify each into surgical / safe-regen / full-reprint remediation tiers, execute the surgical fixes inline, and queue the others as follow-up. Closes umbrella issue #632 and instances #630/#631.
+
+---
+
+## Problem Frame
+
+The generator's auth model is `Auth.EnvVars []string` — a flat list of names with no semantics. That single shape is being asked to drive at least seven downstream surfaces (auth.go, doctor, MCP agent-context, helpers.go error messages, SKILL.md body prose, install docs, the proposed OpenClaw frontmatter), and it can't carry enough information for any of them to render correctly. Concrete failure modes already shipped to the public library:
+
+- Spurious extra vars from multi-scheme specs (`TRIGGER_DEV_API_KEY` alongside `TRIGGER_SECRET_KEY`; `DUB_TOKEN` alongside `DUB_API_KEY`; `STEAM_API_KEY` alongside `STEAM_WEB_API_KEY`) — instance #630
+- Wrong names from slug-plus-scheme-name derivation (`FLIGHTGOAT_API_KEY_AUTH` for what is actually FlightAware's AeroAPI) — instance #631
+- Harvested-from-browser cookies advertised as user-supplied credentials (`ALLRECIPES_COOKIES`, Pagliacci) when in reality `auth login --chrome` populates them
+- OAuth2 client_id+secret flagged as required-per-call when they're only used during `auth login`
+- AND-groups (FedEx 4 keys; Kalshi key+private-key) and OR-groups (Slack bot/user; HubSpot access/private-app) collapsed into the same flat list with no relational structure
+- The first-element-only assumption in human-prose templates (`auth.go.tmpl`, `skill.md.tmpl`, `helpers.go.tmpl`) silently drops every var beyond `[0]`
+
+The OpenClaw frontmatter ask that surfaced this — declaring `requires.env` and `envVars` honestly — is blocked on the model: the frontmatter would either over-claim (mark harvested cookies as user-required, blocking the binary on a var the user never sets) or under-claim (omit referenced vars, triggering OpenClaw hub's metadata-mismatch warning). Both are wrong.
+
+---
+
+## Requirements
+
+- R1. The auth model in `internal/spec/spec.go` carries kind and required-vs-optional for each env var. AND/OR group semantics are encoded by per-var `Required` flags and `Description` text rather than first-class group structure (AND = each var has `required: true`; OR = each var has `required: false` and the description names the alternative). Legacy-alias backwards compat for renamed vars is handled per-CLI in surgical patches (U7), not at the model level
+- R2. The model is additive: existing `EnvVars []string` continues to work for templates that haven't migrated, and existing fixtures continue to pass goldens unchanged unless their behavior is intentionally updated
+- R3. A spec-extension override path lets spec authors declare the rich shape directly when heuristics get it wrong, mirroring the existing `x-auth-*` extension precedent and the pointer-rot rule on `docs/SPEC-EXTENSIONS.md`
+- R4. The OpenAPI parser populates the richer model from spec security schemes, falling back conservatively to the legacy single-string-list shape when classification cannot be made confidently
+- R5. All consuming templates that read `.Auth.EnvVars` render correctly against the richer model — first-element-only sites updated to choose the canonical entry intentionally, iterate sites updated to honor kind/required filtering. U4 and U5 must enumerate the full set (the codebase has 12 such templates today: `auth.go`, `auth_simple.go`, `auth_browser.go`, `auth_client_credentials.go`, `agent_context.go`, `mcp_tools.go`, `config.go`, `client.go`, `helpers.go`, `doctor.go`, `readme.md`, `skill.md`)
+- R6. Printed `SKILL.md` emits OpenClaw frontmatter `requires.env` (unconditionally-required vars only) and `envVars` (every referenced var with honest `required` flags and `description` text that conveys per-call vs. auth-flow vs. harvested)
+- R7. Tier-level auth (`tier.Auth.EnvVars`) honors the same widening; the merge logic in `internal/pipeline/climanifest.go` carries the richer model through to `.printing-press.json`
+- R8. The published `tools-manifest.json` (`ManifestAuth`) carries richer auth metadata in a backwards-compatible shape so the host `printing-press auth doctor` and any external readers continue to work against pre-widening manifests
+- R9. The Steinberger scorer's Auth and AuthProtocol dimensions are revalidated against the new emission shape and updated where the regex set drifts
+- R10. The host `printing-press auth doctor` consumes the richer model where it improves classification quality (kind-aware presence reporting); legacy single-list manifests continue to work
+- R11. The PR description files a follow-up campaign issue ("Library auth env-var remediation") to (a) audit all 46 published CLIs and produce the surgical / safe-regen / full-reprint matrix, and (b) execute the surgical-tier fixes. The audit and surgical fixes are blocked on the machine PR's release tag — they are not in this plan's PR scope
+- R12. Surgical-tier renames in U7 are clean breaks — no legacy-alias fallback. The published library is pre-official-launch; no committed user setups need preservation. Internal users with old env vars set will see ERROR-status doctor output naming the new canonical name, which is the appropriate signal to update. No model-level alias machinery; no per-CLI shim either
+- R13. Issue #632 closes when this plan's machine PR merges. Issues #630 and #631 are instances that close when their corresponding surgical library PRs land in the follow-up campaign (post-machine-release). PR #633's disposition is "absorbed by extension" — already merged on 2026-05-05; this plan extends its helpers
+- R14. Generator output stability holds: `scripts/golden.sh verify` passes, new fixtures cover each new model dimension (kind, AND-group, OR-group, canonical+alias), and any intentional golden updates are explained in the PR description
+- R15. The Printing Press skill (`skills/printing-press/SKILL.md`) is updated in the same PR per the AGENTS.md "machine change touches skill" rule
+
+---
+
+## Scope Boundaries
+
+- OpenClaw client-side or hub-side warning behavior — we are spec consumers, not the OpenClaw runtime
+- Broader auth-manager refactors (the unified-auth-manager track from `2026-04-19-003-feat-unified-auth-manager-plan.md`)
+- New auth mechanisms (OAuth2 PKCE variants, mTLS, SCIM provisioning) — this plan widens metadata for existing mechanisms, not adds new ones
+- Re-running browser-sniff or crowd-sniff against any API to discover env vars that weren't already discovered
+- Unrelated bugs in `auth.go` / `doctor.go` template emission surfaced incidentally during the audit
+- The `config-override` env-var category (e.g., `KALSHI_BASE_URL`, `KALSHI_ENV`) — these are runtime config, not auth, and don't belong in this widening
+
+### Deferred to Follow-Up Work
+
+- **Library auth env-var audit + surgical-tier remediation campaign**: filed as a follow-up issue, blocked on this plan's machine release. The campaign's first deliverable is the audit matrix (originally U1), which inventories all 46 published CLIs by env-var kind and assigns each to surgical / safe-regen / full-reprint tier. The campaign's second deliverable executes the surgical-tier fixes (originally U7) — per-CLI rename + alias-fallback shim + `x-auth-vars` spec block to prevent regen drift. Issues #630 and #631 close when their corresponding surgical PRs land. Removed from this plan's PR scope so the machine PR doesn't block on cross-repo work.
+- **Safe-regen tier library remediation**: bulk regen + republish PR for CLIs whose generator output changes but whose specs need no edits (analog to PR #213 from the cache-invalidation pattern). Acceptance criterion: `golden.sh verify` clean against pre-regen baseline plus expected diffs limited to the new model emission. Filed as a separate issue in the campaign above.
+- **Full-reprint tier library remediation**: per-CLI reprints for CLIs that need spec edits (`x-auth-vars` annotations) or sniff re-runs to classify kind correctly. Each gets its own retro-driven reprint following the existing reprint contract.
+- **OpenClaw hub validation integration**: if OpenClaw publishes a static-analysis schema or linter that runs against `SKILL.md` frontmatter, we'd integrate it as a CI check. Out of scope until OpenClaw publishes that surface.
+
+---
+
+## Context & Research
+
+### Relevant Code and Patterns
+
+- `internal/spec/spec.go:406-464` — `AuthConfig` model; existing additive growth pattern (`Optional`, `KeyURL`, `Title`, `Description`, `Inferred`, `RequiresBrowserSession`)
+- `internal/openapi/parser.go:30-41,466-487,515-535` — extension constants, default name derivation, override application via `applyAuthOverrideExtensions`. PR #633 sits on this exact code path
+- `internal/openapi/parser.go:490-503` — `isGenericAPIKeySchemeSuffix` (PR #633 expands the generic-suffix set)
+- `internal/generator/templates/skill.md.tmpl:6-14,173-208` — current frontmatter emission and body prose (first-element-only)
+- `internal/generator/templates/auth.go.tmpl:28-32`, `auth_browser.go.tmpl:103-873` — first-element-only human-prose surfaces
+- `internal/generator/templates/agent_context.go.tmpl:42-120`, `mcp_tools.go.tmpl:421-440` — full-list iteration for machine-consumed surfaces
+- `internal/generator/templates/auth_client_credentials.go.tmpl:28-267` — already handles positional pairs (`index 0` / `index 1`); precedent for AND-group emission
+- `internal/generator/templates/config.go.tmpl:34-284` — printed CLI's config; first-element assumption is most load-bearing here (AuthSource, Bearer-prefix builder, legacy-header rewrite)
+- `internal/generator/templates/doctor.go.tmpl:28,142,196,210-243,425` — mixed first-element + iterate; presence reporter
+- `internal/generator/templates/helpers.go.tmpl:279-340` — first-element-only error hints (6 sites)
+- `internal/generator/templates/readme.md.tmpl:72-455` — mixed; `(ne .Auth.Type "cookie") (ne .Auth.Type "composed")` guards already separate cookie path
+- `internal/pipeline/climanifest.go:67-83,271,293-321` — `manifestAuthEnvVars` global+tier merge; `.printing-press.json` shape
+- `internal/pipeline/toolsmanifest.go:41-66,195-197` — `ManifestAuth` published-manifest shape
+- `internal/pipeline/runtime.go:153-200` — verifier env-var aggregation
+- `internal/pipeline/mcpb_manifest.go:282-353` — MCPB `user_config` field emission (treats every entry as required user-config — must update with kind awareness)
+- `internal/authdoctor/classify.go:72-86` — host `auth doctor` consumer of `ManifestAuth.EnvVars`
+- `docs/SPEC-EXTENSIONS.md:12-32,228-240` — extension table and `x-auth-env-vars` reference; pointer-rot rule binds same-PR updates
+- `testdata/golden/fixtures/golden-api.yaml`, `golden-api-oauth2-cc.yaml`, `tier-routing-api.yaml` — existing auth fixtures
+- `skills/printing-press/SKILL.md:1480-1495` — env_vars authoring guidance (to extend)
+
+### Institutional Learnings
+
+- **HTTP cache invalidation precedent** (`docs/solutions/design-patterns/http-client-cache-invalidate-on-mutation-2026-05-05.md`): exact structural template for machine-change + library-backfill + per-CLI surgical patches. PR #521 (generator) + PR #213 (bulk backfill) + PR #237 (single-CLI patch). Frame this plan the same way: ship the model widening plus a backfill plan; otherwise pre-widening CLIs silently inherit the old shape forever.
+- **Lookup priority by convention owner** (`docs/solutions/best-practices/checkout-scoped-printing-press-output-layout-2026-03-28.md`): when canonical + legacy aliases coexist, label by convention (`// canonical envVar` / `// legacy alias`), never temporally (`// new` / `// old`). Add a both-keys-present regression test for any helper that walks an alias list.
+- **Conservative classification gate** (`docs/solutions/design-patterns/dry-run-default-for-mutator-probes-in-test-harnesses-2026-05-05.md`): when the generator can't confidently classify, fall through to legacy single-string-list behavior. Cost of being too aggressive (mis-tagging a per-call var as harvested) is much higher than too conservative.
+- **Validation must not mutate source dir** (`docs/solutions/best-practices/validation-must-not-mutate-source-directory-2026-03-29.md`): for safe-regen tier execution, snapshot/restore `go.mod`/`go.sum`, build to a temp dir, no compiled binaries in published artifacts.
+- **Scorecard architecture** (`docs/solutions/best-practices/steinberger-scorecard-scoring-architecture-2026-03-27.md`): Auth and AuthProtocol dimensions read against generated files; pattern strings drift silently. Same-PR scorer revalidation required per AGENTS.md.
+- **Sniff complementary discovery** (`docs/solutions/best-practices/sniff-and-crowd-sniff-complementary-discovery-2026-03-30.md`): existing repo vocabulary uses `cookie` / `bearer_token` / `api_key` / `oauth` for auth types. The harvested-from-browser kind maps onto the existing `Auth.Type = "cookie"` lineage from `2026-04-02-001-feat-browser-auth-cookie-runtime-plan.md`. Don't invent a parallel taxonomy.
+
+### External References
+
+- OpenClaw skill format spec (`https://github.com/openclaw/clawhub/blob/main/docs/skill-format.md`): `requires.env` lists unconditionally-required env vars; `envVars` carries detailed shape with `name`, `required`, `description`. Hub-side security analysis flags references-vs-declaration mismatches at publish time. Spec is **silent** on:
+  - Whether OpenClaw clients warn at runtime (only the hub validation is documented)
+  - How to declare harvested vs. user-supplied (no separate field; description-only)
+  - OR-group syntax (no native expression; description-only is the workaround)
+
+  Resolution adopted by this plan: declare every referenced var in `envVars` with `required: true|false` reflecting whether the binary blocks without it, and use `description` to convey kind ("populated by `auth login --chrome`", "only needed during initial auth setup", "set this OR `OTHER_VAR`"). Use `requires.env` only for vars where the binary cannot start without them.
+
+---
+
+## Key Technical Decisions
+
+- **Additive widening, not replacement**: keep `AuthConfig.EnvVars []string` as legacy field; add `EnvVarSpecs []AuthEnvVar` (or similar named field) alongside. Migration is per-template, not big-bang. Rationale: 12 templates and several non-template consumers cannot all migrate simultaneously without a goldens explosion. The additive pattern matches existing additive growth on `AuthConfig` (`Optional`, `KeyURL`, `Title`, `Description`, `Inferred`).
+
+- **Build on PR #633's already-merged parser changes**: PR #633 merged on 2026-05-05. Its parser-side canonical extraction (`applyAuthEnvVars`, `remapAuthFormatForEnvOverride`, generic-suffix expansion at `internal/openapi/parser.go:490-503,538+`) is now in `main` and is the base this plan extends. U3 wraps the rich-model override logic around those helpers rather than reimplementing them. Before U3 starts, read the merged diff to confirm `stringListExtension`'s polymorphism shape can absorb a list-of-objects variant; if it can't, the U3 implementer extends the helper rather than working around it.
+
+- **Why model widening, not just frontmatter emission tweaks**: a smaller fix — extend `skill.md.tmpl` to derive `requires.env` and `envVars` from existing signals (`Auth.Type == "cookie"` for harvested, OAuth2 grant detection for auth_flow_input, PR #633's canonical name) — was considered. It fails because every downstream surface beyond OpenClaw frontmatter needs the same kind/required information that the smaller fix would compute only inside the skill template. `helpers.go` 401 hints currently mention every env var the spec declares; without kind classification on the data side, the hint can't filter to per_call and will continue to advertise harvested cookies as user-required. `doctor` presence checks today produce ERROR for any unset env var; without kind in the model, doctor can't differentiate "user must set per-call API key" from "auth-login-flow input not yet captured." MCP `agent_context` emits the raw env var list to agents; without kind, agents can't tell which vars they should ask the user about. Each of these surfaces would need its own classification logic redoing the same heuristic work — and they would silently drift apart. The wider model is the right intervention because the classification is needed by 6+ surfaces, not 1.
+
+- **Kind enum: 3 values, conservative default**: `per_call` (default; user-supplied per command), `auth_flow_input` (used during `auth login`, not after), `harvested` (populated by auth flow into config). Excludes `config_override` (`*_BASE_URL`, etc.) — those are runtime config, not auth metadata, and routing them through this model would over-broaden scope. Rationale: each of the three kinds maps cleanly to a distinct downstream-surface behavior (frontmatter `required` flag, doctor presence-check semantics, error hint phrasing). A fourth kind for config-override would conflate concerns.
+
+- **No first-class AND/OR groups; encode via `Required` flag and description text**: AND is semantically `each var has required: true` (no separate "group" needed since all-required-independently is equivalent to all-required-together for downstream gates). OR is rare (3-4 CLIs in the audit: Slack bot/user, HubSpot access/private-app, Kalshi private-key path/inline) and gets `required: false` on each member with description text naming the alternative ("Set this OR `OTHER_VAR`"). Doctor template does **not** enforce OR satisfaction at runtime — the API's 401 response is the source of truth, and the helpers.go 401 hint surfaces the OR relationship from description text. Rationale: 42 of 46 CLIs have no group semantics, so a Groups map taxes every spec for the benefit of 4. Description-text encoding satisfies OpenClaw frontmatter (which is silent on groups anyway), keeps the doctor template simple, and trades minor doctor-UX precision for substantial model-simplicity gain.
+
+- **Canonical name only; no legacy aliases at any level**: each `AuthEnvVar` carries `Name string` (canonical) — no `LegacyAliases` field. The published library is pre-official-launch; surgical-tier renames in U7 are clean breaks rather than shimmed transitions. Internal users with old env vars set will see doctor ERROR naming the new canonical, which is the appropriate signal. Rationale: backwards-compat machinery only earns its keep when there are committed users to preserve; the maintainer is the only known consumer today. Promote alias support if the library officially ships and accumulates users.
+
+- **Spec-extension shape: introduce a new `x-auth-vars` key for the rich shape; preserve `x-auth-env-vars` as PR #633's legacy string/list-of-strings**: each key carries an unambiguous shape — `x-auth-env-vars` continues to accept string-or-list-of-strings (the form #633 shipped); `x-auth-vars` accepts a list-of-objects (each with `name`, `kind`, `required`, `group`, `aliases`, `description`). Avoids the three-arm type switch a polymorphic key would require, keeps reversibility high before any spec author adopts the new shape, and lets `docs/SPEC-EXTENSIONS.md` document each key with one set of `Rules:`. Update `docs/SPEC-EXTENSIONS.md` in the same PR per pointer-rot rule.
+
+- **Conservative parser default**: when the parser cannot confidently classify (no x-extension, no Speakeasy example, ambiguous scheme name), produce `EnvVarSpecs` entries with `kind: per_call`, `required: true`, no group, no aliases — matching legacy behavior exactly. Rationale: classification errors silently shipped to 46 CLIs are much worse than missing classifications that show up identically to today.
+
+- **OpenClaw frontmatter emission**: `requires.env` lists only `kind=per_call AND required=true AND group=none` vars. Everything else (auth_flow_input, harvested, OR-group members, optional per_call vars) lives in `envVars` with `required: true|false` reflecting binary-blocking semantics, and `description` text conveying the relationship. Rationale: OpenClaw hub will warn on referenced-but-undeclared vars; this satisfies the hub by declaring everything, while honoring the user's actual intent by only marking truly-required vars as `requires.env`.
+
+- **`tools-manifest.json` carries richer model**: `ManifestAuth` grows a new `EnvVarSpecs` field alongside the existing `EnvVars` slice. Old manifests (without the new field) continue to work — host `auth doctor` reads from the legacy field as fallback. Rationale: backwards compatibility for the host doctor consuming pre-widening published manifests.
+
+- **Surgical-tier remediation lives in this plan; safe-regen and full-reprint deferred**: U1's matrix output assigns each of 46 CLIs to a tier; U7 executes only surgical-tier fixes (rename in-place with legacy alias). Safe-regen (bulk regen + republish) and full-reprint (per-CLI spec edits + reprints) are filed as follow-up issues with clear acceptance criteria. Rationale: keeping all three tiers in one plan grows scope unboundedly and risks the model PR getting blocked on every-CLI churn.
+
+- **PR #633 disposition: already merged, absorb-and-extend**: PR #633 merged on 2026-05-05; its parser changes are the base this plan builds on. U8's disposition step becomes a confirmation ("rebased on post-#633 main; helpers extended, not duplicated") rather than a coordination decision.
+
+---
+
+## Open Questions
+
+### Resolved During Planning
+
+- **PR #633 disposition**: already merged on 2026-05-05; this plan extends its parser helpers rather than coordinating around its review. U8's disposition step is a confirmation, not a decision.
+- **Model representation (additive vs. replacement)**: additive. Decided in Key Technical Decisions.
+- **Kind enum scope**: 3 values (per_call, auth_flow_input, harvested), excluding config_override. Decided in Key Technical Decisions.
+- **AND/OR representation**: first-class fields, not description-only. Decided in Key Technical Decisions.
+- **OpenClaw warning concern**: the user's specific concern that exclusion would trigger warnings. Resolution: declare every referenced var in `envVars` with honest `required` flags. The hub-side static analysis is satisfied by declaration; the user's intent is honored by accurate `required` flags. The OpenClaw spec is silent on runtime warning behavior, so this is the safest declaration shape.
+
+### Deferred to Implementation
+
+- **Per-CLI tier assignment for the 46 CLIs**: the campaign issue's audit produces this matrix; the exact tier per CLI cannot be decided from outside the audit. Some CLIs may move tiers as the audit reveals constraints.
+- **MCPB `user_config` shape under kind awareness**: `internal/pipeline/mcpb_manifest.go:282-353` currently treats every entry as a required user-config field. Should `auth_flow_input` and `harvested` kinds be excluded from `user_config` entirely, or marked with a UI hint? Defer to U6a implementation; the right answer depends on what MCPB clients (Claude Desktop, etc.) do with optional / hidden entries.
+- **Whether the `Inferred bool` field on `AuthConfig` should be promoted to per-`AuthEnvVar`**: classification (kind, required) may be partly inferred. The existing `Inferred` flag is a coarse boolean on the whole auth block. Decide during U2 whether to keep it coarse or fan it out.
+
+### From 2026-05-05 review
+
+Items the document review surfaced and the user routed here rather than into the plan body:
+
+- **Sunset path for the legacy `EnvVars []string` field**: the additive widening keeps both representations indefinitely. The plan does not name when (or whether) `EnvVars` gets removed. Internal tooling can absorb dual-shape long-term, but a sunset criterion ("when all consumers migrate" / "next major version" / "never") would help future maintainers reason about the field. Adversarial finding 9 / product-lens finding 5.
+- **OpenClaw spec evolution monitoring**: the plan adopts "declare every referenced var" as the safe stance under spec ambiguity. If OpenClaw later defines runtime warning semantics that punish over-declaration (e.g., "envVars with required:false that are never read"), the model carries enough information to re-derive the correct frontmatter, but no monitoring trigger is named. Adversarial finding 7.
+- **Kind enum vocabulary for spec authors**: the 3-value enum (per_call / auth_flow_input / harvested) is generator-internal language. Spec authors writing `x-auth-vars` overrides have to learn it. Validation against real spec authors before committing the model would surface adoption friction. Product-lens finding 3.
+- **Opportunity cost vs unified-auth-manager track**: `2026-04-19-003-feat-unified-auth-manager-plan.md` is in scope-boundaries as deferred. Both plans touch overlapping code paths. Confirm with the unified-auth-manager owner that this widening is on-path, not a parallel surface that will need re-migration. Product-lens finding 7.
+- **Coarse vs per-EnvVar `Inferred` flag**: already deferred above; flagging here too because adversarial review noted that fanning out mid-U2 expands scope at the most sensitive point in the critical path. Decide explicitly in U2 KTD: "keep coarse; do not fan out."
+
+---
+
+## High-Level Technical Design
+
+> *This illustrates the intended shape of the model and how the surfaces consume it. Directional guidance for review, not implementation specification — the implementing agent should treat it as context, not code to reproduce.*
+
+### Data model shape (directional)
+
+```
+AuthConfig (existing, additive growth)
+├── EnvVars []string           // legacy — preserved, populated from EnvVarSpecs[*].Name during transition
+├── EnvVarSpecs []AuthEnvVar   // new — richer model, source of truth
+└── ...existing fields (Optional, KeyURL, Title, Description, Inferred, ...)
+
+AuthEnvVar (new)
+├── Name string                // canonical name as emitted to printed CLI
+├── Kind AuthEnvVarKind        // per_call | auth_flow_input | harvested
+├── Required bool              // does the binary block without this var?
+├── Sensitive bool             // is this a credential (true: API key, OAuth secret, token) or
+│                              // public configuration (false: OAuth client_id, account slug)?
+│                              // orthogonal to Kind; drives redaction policy in logs / agent-context
+├── Description string         // user-facing prose for SKILL.md envVars and OpenClaw description
+│                              // for OR-cases, the description names the alternative ("Set this OR OTHER_VAR")
+└── Inferred bool              // optional: was this auto-derived rather than spec-declared?
+```
+
+Note: no `Groups map`, no `GroupID`, no `LegacyAliases`. AND is encoded by per-var `Required: true`; OR is encoded by per-var `Required: false` plus description text. Doctor enforces presence of `Required: true` vars only; OR satisfaction is left to the API's 401 response and the helpers.go hint. Legacy-alias backwards compat for the 3 renames is per-CLI surgical work in U7's `config.go` patches.
+
+### Surface routing matrix (directional)
+
+| Surface | Reads | Behavior under richer model |
+|---|---|---|
+| `requires.env` (OpenClaw frontmatter) | EnvVarSpecs filtered to `kind=per_call AND required=true` | Only unconditionally-required user-supplied vars; OR-group members (all `required=false`) are excluded |
+| `envVars` (OpenClaw frontmatter) | All EnvVarSpecs | `required` reflects binary-blocking; `description` conveys kind and group relationships. For `Sensitive=true && Kind=auth_flow_input` entries (OAuth client_secret, etc.): description is generic ("Set during application setup") rather than naming the auth flow specifics, to avoid publishing secret-container narrative in a public hub artifact |
+| `auth.go` short-help | `CanonicalEnvVar().Name` | Single-name display |
+| `helpers.go` 401/403 hint | EnvVarSpecs filtered to `kind=per_call` | Hint mentions the per-call vars; harvested/auth-flow names omitted |
+| `doctor` presence | All EnvVarSpecs | Per-kind presence reporting; only `required=true` vars produce ERROR on missing; OR-case (all `required=false` with descriptive text) reports as INFO with the description hint |
+| MCP `agent_context` | All EnvVarSpecs, projected | Emit `name`, `kind`, `required`, `sensitive` for every entry. Emit `description` only for `Sensitive=false` entries (public configuration). Sensitive entries get a generic placeholder description to give agents enough to ask the user without leaking auth-flow specifics. Internal flags (`Inferred`, etc.) are not emitted |
+| `config.go` AuthSource | `CanonicalEnvVar()` only | Canonical lookup. Per-CLI legacy-alias shims (where renames apply) are added in U7's surgical patches, not in the template. |
+| `tools-manifest.json` | ManifestAuth.EnvVarSpecs (new) + EnvVars (legacy fallback) | Old manifests continue to work via legacy path |
+
+### Conservative parser fallback (directional)
+
+```
+parse spec security scheme
+  ↓
+canonical-name resolution (PR #633 path: x-speakeasy-example, x-auth-env-vars override, default derivation)
+  ↓
+classify kind:
+  - if spec says cookie/session-handshake → harvested
+  - if scheme is OAuth2 client_credentials with client_id+client_secret → auth_flow_input for both
+  - if x-auth-env-vars supplies kind explicitly → use it
+  - otherwise → per_call (legacy default)
+  ↓
+classify required:
+  - if Optional flag set on AuthConfig → required=false for derived vars
+  - if x-auth-env-vars supplies required explicitly → use it
+  - otherwise → required=true (legacy default)
+  ↓
+no group by default; only set when x-auth-env-vars or a known scheme pattern (e.g., FedEx-style scoped pairs) declares it
+```
+
+When in doubt, the parser produces a single-element `EnvVarSpecs` entry that's behaviorally identical to today's `EnvVars[0]`.
+
+---
+
+## Implementation Units
+
+> *U1 (audit) and U7 (surgical library fixes) have been moved to a follow-up campaign filed as a separate issue, blocked on this plan's machine release. They retain their original U-IDs (U1, U7) so cross-references in earlier discussions resolve correctly, but they are not part of this PR's deliverables. See "Deferred to Follow-Up Work" in Scope Boundaries.*
+
+- U2. **Widen `spec.AuthConfig` with `AuthEnvVar` struct and kind enum**
+
+**Goal:** Introduce the typed model in `internal/spec/spec.go` alongside the existing `EnvVars []string`. Define `AuthEnvVarKind` as a typed string with the three constants (per_call, auth_flow_input, harvested), `GroupMode` as a typed string with `and`/`or`. Wire the new fields through `AuthConfig` validation.
+
+**Requirements:** R1, R2, R12
+
+**Dependencies:** U1 may refine the field shape based on audit findings (e.g., audit may reveal a kind we haven't named) — but U2 can start in parallel; defer-final-shape until U1 lands.
+
+**Files:**
+- Modify: `internal/spec/spec.go`
+- Modify: `internal/spec/spec_test.go`
+- Test: `internal/spec/spec_test.go`
+
+**Approach:**
+- Add `AuthEnvVar` struct with `Name`, `Kind`, `Required`, `Sensitive`, `Description`, `Inferred` fields. `Sensitive` is orthogonal to `Kind`: distinguishes confidential credentials (API keys, OAuth client_secret, bearer tokens, harvested cookies) from public configuration (OAuth client_id, account slugs, host overrides). Defaults to `true` for `kind=per_call` and `kind=harvested`; defaults to `false` for `kind=auth_flow_input` only when the var is unambiguously public (e.g., the spec's OAuth grant explicitly exposes client_id as the "client identifier" — fall back to `true` when uncertain). Drives redaction policy in any future logger or agent-context filter that needs to distinguish credentials from configuration
+- Add `AuthEnvVarKind` typed string with three constants; existing `OAuth2GrantAuthorizationCode` const at line 468 is the precedent for typed-const-at-introduction
+- Add validation: each `EnvVarSpecs[i].Name` must be unique within the auth config
+- Define migration shim: if `EnvVarSpecs` is empty and `EnvVars` is non-empty, derive `EnvVarSpecs` lazily during read (kind=per_call, required=true, no group, no aliases). If both are populated, `EnvVarSpecs` wins; emit a one-time warning if they disagree on names
+- Define a `CanonicalEnvVar() *AuthEnvVar` method on `AuthConfig` (and tier-level Auth) with deterministic selection: first entry where `Kind == per_call && Required == true`; if no such entry, fall back to `EnvVarSpecs[0]`; if `EnvVarSpecs` is empty, fall back to lazy-derived from `EnvVars[0]`. Every consumer that today uses `index .Auth.EnvVars 0` or `EnvVarSpecs[0]` must call `CanonicalEnvVar()` instead. Rationale: ordering of `EnvVarSpecs` can shift between regens (spec source order vs. x-auth-vars-declared order), so `[0]` is non-deterministic for human-prose surfaces
+- Tier-level `Auth` honors the same widening — `internal/spec/spec.go:1369` (tier-credentials check) continues to require non-empty `EnvVars`, with `EnvVarSpecs` as alternative population source
+- **Pre-merge normalization**: lazy derivation runs at the per-tier and per-global `AuthConfig` level **before** the `climanifest.go:293-321` merge, not during merge. This guarantees every merge input is already in rich shape, eliminating the failure mode where one side has legacy `EnvVars` only and the other has `EnvVarSpecs` only, producing duplicated entries on merge instead of override semantics
+
+**Patterns to follow:**
+- Additive growth pattern already in `AuthConfig` (`Optional`, `KeyURL`, `Title`, `Description`, `Inferred`)
+- Typed-const introduction matching `OAuth2GrantAuthorizationCode` precedent (`internal/spec/spec.go:468`)
+- Validation pattern from existing `AuthConfig.Validate()` calls
+
+**Test scenarios:**
+- Happy path: spec with `EnvVarSpecs` populated parses, validates, exposes both legacy `EnvVars` (back-derived) and new `EnvVarSpecs`
+- Happy path: legacy spec with only `EnvVars []string` parses, validates, exposes lazy `EnvVarSpecs` derivation with kind=per_call defaults
+- Edge case: spec with both `EnvVars` and `EnvVarSpecs` populated and consistent — both fields readable, `EnvVarSpecs` wins for kind/required/group
+- Edge case: spec with both populated and inconsistent (different names) — validation produces a warning; `EnvVarSpecs` wins
+- Edge case: empty `EnvVarSpecs` + empty `EnvVars` — validation accepts (no-auth case, matches existing behavior)
+- OR-case: spec with two `EnvVarSpecs` entries both `Required: false` and description text mentioning each other — validates fine; doctor reports both as INFO; helpers.go hint surfaces the OR relationship
+- Edge case: tier-level `Auth` with `EnvVarSpecs` honors same validation rules
+- Edge case: tier populates only legacy `EnvVars` while global populates only `EnvVarSpecs` — pre-merge normalization derives tier's `EnvVarSpecs` lazily before climanifest merge; merge result reflects override semantics, not duplication
+- `CanonicalEnvVar()` deterministic selection: spec with `[{Kind: harvested}, {Kind: per_call, Required: true}]` returns the per_call entry, not `[0]`; spec with two per_call+required entries returns the first in source order; spec with only `EnvVars []string` returns lazy-derived entry from `EnvVars[0]`
+
+**Verification:**
+- `go test ./internal/spec/...` passes with new test cases
+- `go vet ./...` clean
+- `golangci-lint run ./...` clean
+- Existing fixture-driven tests still pass (legacy `EnvVars` path remains valid)
+
+---
+
+- U3. **OpenAPI parser populates rich model with conservative classification + spec-extension override**
+
+**Goal:** Update `internal/openapi/parser.go` to populate `EnvVarSpecs` and `Groups` from spec security schemes, with conservative defaults and a spec-extension override path. Build on PR #633's `applyAuthEnvVars` / `remapAuthFormatForEnvOverride` helpers.
+
+**Requirements:** R3, R4
+
+**Dependencies:** U2 (model must exist). PR #633 has already merged; its helpers are the base.
+
+**Files:**
+- Modify: `internal/openapi/parser.go`
+- Modify: `internal/openapi/parser_test.go`
+- Modify: `docs/SPEC-EXTENSIONS.md`
+- Test: `internal/openapi/parser_test.go`
+
+**Approach:**
+- Use the new `x-auth-vars` key for the rich list-of-objects shape (decided in Key Technical Decisions); leave `x-auth-env-vars` exactly as PR #633 shipped it (string-or-list-of-strings)
+- For each parsed security scheme: derive default `Name` (PR #633's path — `x-speakeasy-example` or scheme-name + scheme-type); set `Kind = per_call`, `Required = true`, no legacy aliases. This is the conservative fallback behavior
+- If the scheme has an extension override (`x-auth-vars`) declaring kind/required/aliases, replace the conservative values with the declared ones
+- Special-case `Auth.Type = "cookie"` schemes to default `Kind = harvested` (matches the existing cookie-runtime plan's lineage)
+- Special-case OAuth2 client_credentials grants to default `Kind = auth_flow_input` for both client_id and client_secret (matches `auth_client_credentials.go.tmpl`'s existing positional-pair handling); set `Sensitive = false` for client_id (public OAuth identifier) and `Sensitive = true` for client_secret (confidential)
+- Update `docs/SPEC-EXTENSIONS.md` Extensions table (lines 12-32) and the `x-auth-env-vars` spec block (lines 228-240) — pointer-rot rule binds same-PR update
+
+**Patterns to follow:**
+- PR #633's `applyAuthEnvVars` and `stringListExtension` (the new helpers it adds)
+- Existing `applyAuthOverrideExtensions` at `parser.go:515-` as the place to extend
+- `isGenericAPIKeySchemeSuffix` (PR #633 expansion) for scheme-name normalization
+- Conservative-gate pattern from the dry-run learning — when in doubt, fall through to legacy single-string behavior
+
+**Test scenarios:**
+- Happy path: spec with `x-auth-env-vars: [TODOIST_API_KEY]` produces single per_call/required entry; back-compat with PR #633
+- Happy path: spec with `x-auth-vars: [{name: TODOIST_API_KEY, kind: per_call, required: true}]` produces same shape via rich path
+- Happy path: spec with cookie auth scheme produces `Kind = harvested` entry
+- Happy path: spec with OAuth2 client_credentials grant produces two `Kind = auth_flow_input` entries (client_id, client_secret)
+- Happy path: spec with multi-var auth where all are required (e.g., FedEx-shape) produces N `EnvVarSpecs` entries each with `Required: true`; no group structure needed
+- Happy path: spec with OR-shape auth (e.g., Slack-shape) produces N entries each with `Required: false` and description text naming the alternative
+- Edge case: spec with no security schemes — parser produces empty `EnvVarSpecs` and empty `EnvVars` (no-auth case)
+- Edge case: spec with multiple security schemes pointing at the same logical credential (the #630 case) — without explicit override, parser produces multiple entries (preserving legacy behavior); with override, parser respects the override's consolidation
+- Edge case: spec with `x-auth-env-vars` declaring an aliased entry (`name: FLIGHTAWARE_API_KEY, legacy_aliases: [FLIGHTGOAT_API_KEY_AUTH]`) produces the canonical+aliases shape
+- Conservative gate: spec with malformed `x-auth-env-vars` extension — parser logs a warning and falls back to legacy default-derivation, does not panic or skip
+- Edge case: tier-level `x-auth-env-vars` honored independently per tier
+
+**Verification:**
+- `go test ./internal/openapi/...` passes
+- `scripts/golden.sh verify` clean against existing fixtures (no behavior change for legacy fixtures)
+- `docs/SPEC-EXTENSIONS.md` Extensions table includes the new shape and a `Rules:` block documents the polymorphic accept
+
+---
+
+- U4. **Migrate machine-consumed templates to richer model**
+
+**Goal:** Update `agent_context.go.tmpl`, `mcp_tools.go.tmpl`, and `config.go.tmpl` to consume `EnvVarSpecs` (preferred) with fallback to `EnvVars` (legacy). Templates emit kind, required, group membership, and aliases into the structured surfaces (MCP env_vars JSON, agent-context JSON, printed config struct).
+
+**Requirements:** R5, R7
+
+**Dependencies:** U2, U3
+
+**Files:**
+- Modify: `internal/generator/templates/agent_context.go.tmpl`
+- Modify: `internal/generator/templates/mcp_tools.go.tmpl`
+- Modify: `internal/generator/templates/config.go.tmpl`
+- Test: `testdata/golden/expected/...` (regen + verify)
+- Test: new fixture `testdata/golden/fixtures/golden-api-rich-auth.yaml` exercising kind, group, alias
+
+**Approach:**
+- `agent_context.go.tmpl`: replace `range .Auth.EnvVars` with `range .Auth.EnvVarSpecs` when populated; emit `{name, kind, required, group_id, aliases}` per entry
+- `mcp_tools.go.tmpl`: same pattern for the `env_vars` array at line 421-440 (and tier variant at 439-440); preserve the `index 0` first-element error-hint sites by switching to "find canonical entry" logic that picks the first per_call required entry rather than blindly `[0]`
+- `config.go.tmpl`: this is the most load-bearing first-element site (AuthSource, Bearer-prefix builder, legacy-header rewrite at lines 129, 170-208, 228-243); switch to `CanonicalEnvVar()`-based lookup. The template emits no alias fallback — that's per-CLI surgical work in U7 for the 3 renames, hand-edited into specific config.go files
+- Add new golden fixture exercising all model dimensions; existing fixtures remain unchanged unless behavior intentionally changes
+
+**Patterns to follow:**
+- `auth_client_credentials.go.tmpl` already handles positional pairs cleanly; use as template for multi-element handling
+- Lookup-priority-by-convention pattern from the canonical/legacy learning
+
+**Test scenarios:**
+- Happy path: agent-context JSON includes `kind` and `required` fields per env var when `EnvVarSpecs` populated
+- Happy path: MCP `env_vars` array honors per_call filter for error hints (auth_flow_input / harvested vars don't appear in the "set X to authenticate" hint)
+- Happy path: printed config.go reads canonical name first, falls back to legacy aliases
+- Edge case: spec with only legacy `EnvVars []string` — templates fall back to lazy-derived `EnvVarSpecs`, behave identically to today
+- Edge case: AND-group in spec — agent-context emits both members with same group_id; doctor logic (U5) treats them together
+- Edge case: OR-group — same shape; consumer behavior diverges in U5/U6
+- Integration: regen one canary CLI through the updated templates; inspect emitted Go source for correctness; build and run `doctor` against the canary
+
+**Verification:**
+- `scripts/golden.sh verify` clean against existing fixtures (no diff)
+- New rich-auth fixture's expected output captured and committed
+- Manual canary regen produces a building, running CLI
+
+---
+
+- U5. **Migrate human-prose templates and add OpenClaw frontmatter emission**
+
+**Goal:** Update `auth.go.tmpl`, `auth_browser.go.tmpl`, `helpers.go.tmpl`, `skill.md.tmpl`, `doctor.go.tmpl`, and `readme.md.tmpl` to consume the richer model. Add OpenClaw `requires.env` and `envVars` blocks to `skill.md.tmpl` frontmatter with kind-aware filtering and description content.
+
+**Requirements:** R5, R6
+
+**Dependencies:** U2, U3, U4
+
+**Files:**
+- Modify: `internal/generator/templates/skill.md.tmpl`
+- Modify: `internal/generator/templates/auth.go.tmpl`
+- Modify: `internal/generator/templates/auth_browser.go.tmpl`
+- Modify: `internal/generator/templates/helpers.go.tmpl`
+- Modify: `internal/generator/templates/doctor.go.tmpl`
+- Modify: `internal/generator/templates/readme.md.tmpl`
+- Test: `testdata/golden/expected/...` (regen + verify)
+
+**Approach:**
+- `skill.md.tmpl`: add new `requires.env:` block to OpenClaw frontmatter listing only per_call+required+ungrouped vars; add `envVars:` block listing every entry in `EnvVarSpecs` with `name`, `required`, `description`. Description text is constructed per kind:
+  - `per_call` + required: `"<existing description>"`
+  - `auth_flow_input`: `"Only needed during \`auth login\`; not required for normal use. <existing description>"`
+  - `harvested`: `"Populated automatically by \`auth login\`. <existing description>"`
+  - OR-group member: `"Set this OR <other-member-name>. <existing description>"`
+  - AND-group member: `"Required together with <other-member-names>. <existing description>"`
+- Body prose ("export FOO=...") at lines 173-208 switches from `index .Auth.EnvVars 0` to "the canonical per_call required entry" lookup
+- `auth.go.tmpl`, `auth_browser.go.tmpl`, `helpers.go.tmpl`: same canonical-entry lookup pattern for short-help and error hints
+- `doctor.go.tmpl`: presence-check logic becomes kind-aware — required per_call vars produce "missing" status; OR-group satisfied if any member set; AND-group requires all members; auth_flow_input vars get "informational" status only
+- `readme.md.tmpl`: env-var table at line 441 includes kind column; existing cookie/composed type guards continue to apply
+- Skip the OpenClaw frontmatter emission when `auth.type == "none"` (preserve existing behavior from `2026-04-26-002`)
+
+**Patterns to follow:**
+- Existing `(ne .Auth.Type "cookie") (ne .Auth.Type "composed")` guard pattern in readme.md.tmpl for type-aware branching
+- Kind-aware predicate pattern from the dry-run learning (`commandSupportsDryRun` style — sibling predicates that mirror existing API shape)
+
+**Test scenarios:**
+- Happy path: SKILL.md emits `requires.env` containing only the canonical per_call var; `envVars` includes all referenced vars with honest `required` flags
+- Happy path: harvested-cookie CLI (e.g., allrecipes after U7 surgical fix) emits `envVars: [{name: ALLRECIPES_COOKIES, required: false, description: "Populated automatically by auth login..."}]` and no `requires.env` entry for that var
+- Happy path: OAuth2 client_credentials CLI (e.g., google-photos) emits both client_id and client_secret as `required: false` with auth-flow-input description
+- Happy path: OR-group CLI (e.g., slack) emits both bot_token and user_token as `required: false` with description text indicating the OR relationship
+- Happy path: AND-group CLI (e.g., fedex) emits all 4 members as `required: true` with description text indicating the pair structure
+- Edge case: no-auth CLI (e.g., open-meteo) — skill frontmatter has no `requires.env` and no `envVars` blocks
+- Edge case: tier-routed CLI — frontmatter reflects merged global+tier vars
+- Edge case: legacy spec (no `EnvVarSpecs`) — frontmatter generated via lazy derivation, all vars marked `required: true` per_call (matches today's implicit assumption)
+- Doctor: canary CLI with mixed kinds — `doctor` reports per_call missing as ERROR, auth_flow_input missing as INFO, harvested with cookies-on-disk as OK
+- Auth.go short-help: only the canonical per_call var appears; aliases mentioned in long-help only
+- Helpers.go 401 hint: hint mentions per_call var, omits auth_flow_input and harvested
+
+**Verification:**
+- `scripts/golden.sh verify` clean
+- Manual canary regen for each kind (per_call, auth_flow_input, harvested, OR-group, AND-group); inspect emitted SKILL.md frontmatter against the OpenClaw spec shape
+- Build and run `--help` on each canary; output reflects expected canonical-entry behavior
+
+---
+
+- U6a. **Update manifest, verifier, MCPB, and host auth-doctor**
+
+**Goal:** Carry the richer model through `tools-manifest.json` (`ManifestAuth`), `internal/pipeline/climanifest.go` merge logic, `internal/pipeline/runtime.go` verifier, `internal/pipeline/mcpb_manifest.go` MCPB user_config, and the host `internal/authdoctor/classify.go`. These all share the `EnvVarSpecs` wire format and move together.
+
+**Requirements:** R7, R8, R10
+
+**Dependencies:** U2, U3, U4, U5
+
+**Files:**
+- Modify: `internal/pipeline/toolsmanifest.go`
+- Modify: `internal/pipeline/climanifest.go`
+- Modify: `internal/pipeline/runtime.go`
+- Modify: `internal/pipeline/mcpb_manifest.go`
+- Modify: `internal/pipeline/publish.go` (manifest preservation at line 219)
+- Modify: `internal/authdoctor/classify.go`
+- Test: each modified package's `_test.go`
+
+**Approach:**
+- `ManifestAuth`: add `EnvVarSpecs` field alongside existing `EnvVars`; both populated on emit; readers prefer `EnvVarSpecs` and fall back to `EnvVars` (treating each as kind=per_call required when only `EnvVars` present)
+- `manifestAuthEnvVars` merge at `climanifest.go:293-321`: extend to merge `EnvVarSpecs` global+tier with the same precedence rules
+- Verifier at `runtime.go:153-200`: discover env vars from emitted `config.go` continues to work; richer model adds kind-aware presence check for verify summary
+- MCPB `user_config` at `mcpb_manifest.go:282-353`: skip `auth_flow_input` and `harvested` entries (not user-config); per_call entries become `user_config` fields with `required` reflecting the model
+- Host `auth doctor` at `internal/authdoctor/classify.go:72-86`: prefer `EnvVarSpecs` for kind-aware presence reporting; fall back to legacy `EnvVars` slice for pre-widening manifests. **Critical dedup guard**: classify.go must use early-return — if `auth.EnvVarSpecs` is non-empty, iterate that and return; do NOT then also iterate `auth.EnvVars`. Otherwise mixed-version manifests (both fields populated, EnvVars back-derived from EnvVarSpecs for pre-widening reader compat) cause every credential to be reported twice as if it were two independent vars
+
+**Patterns to follow:**
+- Backwards-compat pattern: prefer rich field, fall back to legacy field (matches `2026-04-19-004-feat-auth-doctor-plan.md` KTD-2 "env-var-wins; store is additive")
+- Scorer dimension update from the scorecard architecture learning — same-PR validation
+- Lookup-priority-by-convention for legacy/canonical reads
+
+**Test scenarios:**
+- Happy path: published `tools-manifest.json` includes `EnvVarSpecs` field; legacy `EnvVars` field also populated for pre-widening readers
+- Happy path: host `auth doctor` reads richer model from new manifest; reports kind-aware status
+- Happy path: host `auth doctor` against pre-widening manifest (no `EnvVarSpecs` field) reads legacy field; behavior identical to today
+- Happy path: MCPB bundle's `user_config` excludes harvested and auth-flow-input entries; per_call entries appear with correct required flags
+- Edge case: manifest with empty `EnvVarSpecs` and non-empty `EnvVars` — host doctor lazily derives EnvVarSpecs at read time
+- Edge case: manifest with both populated and inconsistent — `EnvVarSpecs` wins; warning logged
+- Edge case (mixed-version dedup): manifest with both fields populated and consistent (EnvVars back-derived from EnvVarSpecs for pre-widening reader compat) — host doctor reports each credential exactly once via the `EnvVarSpecs` path, not twice. Add explicit test fixture for this case
+- Edge case: tier-routed CLI — merge produces correct global+tier rich shape
+- Verifier: new fixture exercising all kinds — verify pass rate reflects required-only blocking
+**Verification:**
+- `go test ./internal/pipeline/...` passes
+- `go test ./internal/authdoctor/...` passes
+- Manual: regen one canary CLI, publish to a temp library, run `printing-press auth doctor` against the canary
+- `scripts/golden.sh verify` clean
+
+---
+
+- U6b. **Revalidate Steinberger scorer Auth and AuthProtocol dimensions against new emission**
+
+**Goal:** Confirm the scorer's Auth and AuthProtocol pattern strings still match the post-widening emission shape; update any regex that drifted; add explicit unscored-dimension handling for cases where classification cannot be determined.
+
+**Requirements:** R9
+
+**Dependencies:** U6a (the new emission shape must be stable before scorer revalidates against it)
+
+**Files:**
+- Modify: `internal/pipeline/scorer/...` (Auth and AuthProtocol dimensions; exact path determined during implementation)
+- Test: `internal/pipeline/scorer/..._test.go`
+
+**Approach:**
+- Identify the regex set used for Auth and AuthProtocol scoring
+- Run the scorer against representative new-model emissions (the canary CLIs from U4/U5); diff scores against pre-widening baseline
+- Update regex strings only where drift exists; do not "fix" scoring outcomes — the goal is parity, not score inflation
+- Per the scorecard learning, mark dimensions as unscored (not midpoint) when evidence is missing
+- Same PR as U6a per AGENTS.md "update dependent verifiers in the same change"
+
+**Patterns to follow:**
+- Scorecard architecture learning's "regex strings drift silently" diagnostic
+- Unscored-dimension convention from the same learning
+
+**Test scenarios:**
+- Existing scoring fixture: produces stable Auth/AuthProtocol scores within tolerance after the widening
+- Rich-auth fixture from U4/U5: produces a defensible score (not midpoint, not zero) — exact value captured in the fixture's expected output
+- Adversarial scenario: a fixture intentionally emitting only the new shape (no legacy back-derivation) scores correctly against the regex set; if it doesn't, the regex needs updating
+- Unscored case: a fixture with kind-unknown classification produces an `unscored_dimensions` entry rather than a midpoint score
+
+**Verification:**
+- `go test ./internal/pipeline/scorer/...` passes
+- Score deltas against the existing baseline are explained in the PR description
+- No "score inflation" from regex updates — diffs are parity adjustments, not gaming
+
+---
+
+> *U7 has been moved to the follow-up campaign filed as a separate issue (see "Deferred to Follow-Up Work"). Original responsibility — surgical-tier library remediation across `mvanhorn/printing-press-library` — runs after this plan's machine release. Renames are clean breaks (pre-official-launch, no legacy-alias shims); each surgical PR also commits the corresponding `x-auth-vars` spec block to prevent regen drift. Details documented in the campaign issue.*
+
+- U8. **Documentation, goldens, skill update, and issue closeout**
+
+**Goal:** Final polish — update `docs/SPEC-EXTENSIONS.md`, regenerate goldens with intentional explanations, update `skills/printing-press/SKILL.md`, file a new `docs/solutions/` learning entry, decide PR #633 disposition, and close issues #630, #631, #632.
+
+**Requirements:** R3, R14, R15, R13
+
+**Dependencies:** U2, U3, U4, U5, U6a, U6b complete
+
+**Files:**
+- Modify: `docs/SPEC-EXTENSIONS.md`
+- Modify: `skills/printing-press/SKILL.md`
+- Create: `docs/solutions/design-patterns/auth-envvar-rich-model-2026-MM-DD.md`
+- Modify: `testdata/golden/expected/...` (intentional updates from U4/U5 work, captured here)
+- Modify: this plan's `status: active` → `status: completed`
+
+**Approach:**
+- `docs/SPEC-EXTENSIONS.md`: extend the Extensions table with the rich shape; update the `x-auth-env-vars` block (lines 228-240) with the new polymorphic accept and full `Rules:` documentation
+- `skills/printing-press/SKILL.md` env_vars guidance (lines 1480-1495): expand to cover kind classification, group declarations, and canonical+aliases pattern; show example spec snippets
+- New solutions doc: capture the rich-model design, why it widened, the OpenClaw frontmatter resolution, and the surgical/safe-regen/full-reprint tiering. Filed under `design-patterns/` with frontmatter (`module`, `tags`, `problem_type`)
+- Goldens: any intentional diff from U4/U5 captured here with explanation in the PR description (per AGENTS.md "explain it in your final response")
+- PR #633 disposition: already merged on 2026-05-05. Confirm in PR description that the rich model extends the merged helpers rather than duplicating them
+- Issue closeout: in the PR description, `Closes #632`. `#630` and `#631` close when their corresponding surgical library PRs land (the model PR fixes the generator, the surgical PRs fix the published instances)
+- File the follow-up campaign issue: title "Library auth env-var remediation campaign", body links this plan + #630 + #631 + #632, lists the 4 known surgical CLIs (flightgoat rename, dub rename, steam-web rename, trigger-dev spurious-var drop) as clean-break renames (no legacy-alias shims, pre-official-launch state), notes that the audit (originally U1) is the campaign's first deliverable
+
+**Patterns to follow:**
+- Pointer-rot rule for `docs/SPEC-EXTENSIONS.md`
+- Solutions doc shape (module, tags, problem_type frontmatter) from existing entries
+- Issue-close convention from existing PRs
+
+**Test scenarios:**
+- `golangci-lint run ./...` clean
+- `scripts/golden.sh verify` clean against committed expected files
+- Skill loads in Claude Code without errors (existing user pattern)
+- New solutions doc renders correctly and is discoverable by `ce-learnings-researcher`
+
+**Verification:**
+- All four `Closes #N` references in PR description are valid
+- PR #633 disposition is documented in the PR body
+- New solutions entry exists under `docs/solutions/design-patterns/`
+- Plan's status field updated when this PR merges
+
+---
+
+## System-Wide Impact
+
+- **Interaction graph**: 12 templates + 6 non-template consumers + 1 host command + 1 published-manifest schema + 1 spec-extension catalog. Every entry needs to honor the additive contract.
+- **Error propagation**: parser-level malformed `x-auth-env-vars` extension produces a logged warning + fallback to legacy single-list behavior; never aborts generation. Spec validation rejects structurally invalid groups (1-member, alias collisions). Runtime config-load in printed CLIs treats unset canonical + set legacy alias as success with a one-time stderr deprecation note.
+- **State lifecycle risks**: `tools-manifest.json` is preserved across re-publish at `internal/pipeline/publish.go:219`; the new `EnvVarSpecs` field must be preserved alongside `EnvVars` to avoid drift between published-manifest reads. Both fields populated on emit ensures pre-widening readers don't lose data.
+- **API surface parity**: every surface that exposes env-var info (CLI `--help`, `doctor`, MCP `agent-context`, `SKILL.md` frontmatter, README, install docs) renders the same canonical-vs-legacy distinction; the canonical name is the public contract.
+- **Integration coverage**: golden harness covers template emission deterministically; canary regen tests cover end-to-end (spec → emitted CLI → runtime behavior). Scorer dimension validation covers the regression risk that scoring patterns drift silently against the new emission.
+- **Unchanged invariants**: the no-auth case (`Auth.Type = "none"`) skips emission entirely; the cookie path (`Auth.Type = "cookie"`) keeps its existing template guards; tier-level auth merge logic preserves global+tier precedence; the existing `Optional bool` flag on `AuthConfig` continues to work alongside per-var `Required` flags (auth-block-level optional means whole-block; per-var required means individual).
+
+---
+
+## Risks & Dependencies
+
+| Risk | Mitigation |
+|---|---|
+| PR #633's `stringListExtension` polymorphism is incompatible with list-of-objects shape | U3 reads the merged #633 diff first; if the helper rejects list-of-objects, extend the helper to accept the third arm rather than working around it (helper extension is in-scope for U3) |
+| Goldens drift silently from kind/group emission | Explicit new fixtures for each new model dimension; existing fixtures unchanged unless intentional; `scripts/golden.sh verify` runs in CI and locally; PR description explains every intentional diff |
+| Surgical-tier library PRs land before machine PR merges, leaving orphan canonical names with no template support | Sequence: machine PR merges first; surgical PRs open only after a published release of the machine that emits the new shape; surgical PRs reference the published version in description |
+| Scorer Auth/AuthProtocol regex set drifts; scores fluctuate after widening | Same-PR scorer revalidation per learning #5; explicit unscored-dimension handling for unknown classifications; tolerance-based scoring tests |
+| Backwards compat breakage for users who set legacy env-var names | Legacy aliases first-class in the model; runtime config-load reads canonical-then-aliases; both-aliases-present test fixture; deprecation note (not error) when legacy is the only source |
+| Audit (U1) reveals a kind we haven't named | Plan accepts that U2 may be revised based on U1 findings; the additive model design absorbs new kinds without breaking existing ones; deferred-to-implementation question already flags this |
+| MCPB `user_config` shape under kind awareness uncertain | Defer to U6 implementation; the right answer depends on what MCPB clients do with hidden entries; conservative default is "skip" but UI hint may be better |
+| OpenClaw spec ambiguity on warning behavior | Plan adopts "declare everything in `envVars`" as the safe default; if OpenClaw clients later define warning semantics differently, the model already carries enough information to re-derive the correct frontmatter shape without changing the spec |
+
+---
+
+## Success Metrics
+
+The plan resolves in three milestones; each is user-visible at a different time. Naming them avoids "Closes #632" being misread as "the user-visible OpenClaw frontmatter problem is fully solved."
+
+| Milestone | When | What's true after |
+|---|---|---|
+| **Machine PR merges** | This plan ships | #632 closes. Generator emits correct frontmatter for any newly-generated CLI. The 46 already-published CLIs still emit the old (incorrect) frontmatter; OpenClaw hub will continue to warn on those until follow-up campaigns land. |
+| **Surgical-tier PRs land** | Follow-up campaign, post-machine-release | #630 and #631 close. The 4 known worst-offender CLIs (flightgoat, dub, steam-web rename + trigger-dev spurious var drop) emit correct frontmatter; user-visible auth-naming bugs in those CLIs are resolved. |
+| **Safe-regen + full-reprint campaigns complete** | Subsequent follow-up | All 46 CLIs in `mvanhorn/printing-press-library` emit correct OpenClaw frontmatter. The hub's metadata-mismatch warning surface goes quiet across the full library. |
+
+Reviewers and downstream readers should not interpret a single PR merge as full closure of the user-visible symptom. The campaign issue tracks the residual work explicitly.
+
+---
+
+## Documentation / Operational Notes
+
+- **Same-PR doc updates** (mandatory): `docs/SPEC-EXTENSIONS.md`, `skills/printing-press/SKILL.md`, this plan's status field
+- **Pre-merge release note**: machine release before any surgical-tier library PRs ship; tag in `version.go` per release-please flow
+- **Post-merge solutions entry**: file `docs/solutions/design-patterns/auth-envvar-rich-model-...` so the next plan touching auth metadata has prior art
+- **Rollout sequencing**: machine PR → release → surgical-tier library PRs → safe-regen follow-up → full-reprint follow-ups (each its own PR or batch)
+- **No feature flag**: the additive shape means there's nothing to gate behind a flag; old templates keep working through the lazy-derivation path during the transition
+- **Monitoring**: `printing-press auth doctor` results before-and-after for a sample of CLIs; verify no regression in classification quality
+
+---
+
+## Sources & References
+
+- **Issue**: [#632 — auth env-var model is too thin](https://github.com/mvanhorn/cli-printing-press/issues/632) (umbrella)
+- **Issue**: [#631 — flightgoat env-var name doesn't match underlying API](https://github.com/mvanhorn/cli-printing-press/issues/631) (instance)
+- **Issue**: [#630 — trigger-dev spurious env-var](https://github.com/mvanhorn/cli-printing-press/issues/630) (instance)
+- **PR**: [#633 — preserve canonical auth env hints](https://github.com/mvanhorn/cli-printing-press/pull/633) — **merged 2026-05-05**; this plan's U3 extends its helpers
+- **Related plan (active)**: `docs/plans/2026-03-31-001-fix-auth-envvar-hint-relevance-plan.md` — narrow upstream fix to crowd-sniff `extractEnvVarHint`; compatible with this widening
+- **Related plan (draft)**: `docs/plans/2026-04-02-001-feat-browser-auth-cookie-runtime-plan.md` — cookie auth flow; `Auth.Type = "cookie"` lineage maps onto `kind = harvested`
+- **Related plan (completed)**: `docs/plans/2026-04-19-004-feat-auth-doctor-plan.md` — host `auth doctor`; KTD-2 "env-var-wins; store is additive" honored here
+- **Related plan (active)**: `docs/plans/2026-04-26-002-feat-printing-press-p1-machine-fixes-plan.md` — preserves "auth.type=='none' skips emission" semantic
+- **Solutions**: `docs/solutions/design-patterns/http-client-cache-invalidate-on-mutation-2026-05-05.md` — machine + library remediation precedent
+- **Solutions**: `docs/solutions/best-practices/checkout-scoped-printing-press-output-layout-2026-03-28.md` — canonical/legacy lookup priority
+- **Solutions**: `docs/solutions/design-patterns/dry-run-default-for-mutator-probes-in-test-harnesses-2026-05-05.md` — conservative classification gate
+- **Solutions**: `docs/solutions/best-practices/validation-must-not-mutate-source-directory-2026-03-29.md` — safe-regen tier constraint
+- **Solutions**: `docs/solutions/best-practices/steinberger-scorecard-scoring-architecture-2026-03-27.md` — scorer revalidation
+- **Solutions**: `docs/solutions/best-practices/sniff-and-crowd-sniff-complementary-discovery-2026-03-30.md` — vocabulary alignment
+- **External**: [OpenClaw skill format spec](https://github.com/openclaw/clawhub/blob/main/docs/skill-format.md) — frontmatter `requires.env` and `envVars` reference
+- **External**: [OpenClaw clawhub repo](https://github.com/openclaw/clawhub) — hub validation context
diff --git a/docs/solutions/design-patterns/auth-envvar-rich-model-2026-05-05.md b/docs/solutions/design-patterns/auth-envvar-rich-model-2026-05-05.md
new file mode 100644
index 00000000..0097ab79
--- /dev/null
+++ b/docs/solutions/design-patterns/auth-envvar-rich-model-2026-05-05.md
@@ -0,0 +1,51 @@
+---
+title: "Auth env-var rich model: kind, required, sensitive, with legacy back-derivation"
+date: 2026-05-05
+category: design-patterns
+module: cli-printing-press-generator
+problem_type: design_pattern
+component: auth
+severity: medium
+applies_when:
+  - "One legacy list is driving multiple auth surfaces with different filtering rules"
+  - "Specs need to distinguish user-supplied credentials from auth-flow inputs or harvested browser/session values"
+  - "Generated docs, doctor output, MCP context, manifests, or helpers rederive auth env-var semantics independently"
+related_components:
+  - generator
+  - openapi-parser
+  - auth-doctor
+tags:
+  - auth
+  - env-vars
+  - generator
+  - model-design
+---
+
+# Auth env-var rich model: kind, required, sensitive, with legacy back-derivation
+
+## Context
+
+The original `Auth.EnvVars []string` model could name credentials, but it could not express kind, required-vs-optional, sensitive-vs-public-config, or OR semantics. Downstream surfaces tried to recover those semantics locally: SKILL frontmatter, doctor checks, MCP agent context, helpers, manifests, and host auth doctor each grew their own classification heuristics. That drift shipped as spurious names, harvested cookies advertised as user-required credentials, OAuth client credentials treated as per-call inputs, and human-prose templates choosing `[0]` as if ordering were semantic.
+
+## Guidance
+
+Use additive struct widening when a legacy scalar/list is already consumed broadly. Add `AuthEnvVar` beside legacy `EnvVars []string`, then normalize lazily so pre-widening specs still flow through the rich path. The rich model owns precedence: if `EnvVarSpecs` exists, back-derive `EnvVars` from it; if only `EnvVars` exists, derive inferred `per_call`, `required`, `sensitive` specs.
+
+Choose one deterministic selector for single-name human prose. `CanonicalEnvVar()` prefers the first required `per_call` entry, then falls back to the first normalized entry, so templates stop encoding accidental `[0]` ordering ambiguity. Use the 3-value kind enum (`per_call`, `auth_flow_input`, `harvested`) plus the orthogonal `Sensitive` flag to drive downstream filtering uniformly.
+
+Keep rare relationship semantics cheap until they earn a real field. AND/OR groups are not first-class because only 4 of 46 published CLIs need them; encode AND as each member `Required: true`, and OR as each alternative `Required: false` with description text naming the other option. Legacy aliases are not first-class either: the library is pre-official-launch, clean breaks are acceptable for renames, and alias support should be promoted only if rename frequency increases.
+
+Normalize before merging generated artifacts. `NormalizeEnvVarSpecs` must run at both per-global and per-tier levels before climanifest merge so tier overrides preserve their semantics. Mixed-version safety needs the same discipline: the `classify.go` double-report bug was preventable only by the dedup guard. Any future field that gets back-derived for legacy compatibility should share model precedence rules rather than letting each surface rederive them from scratch.
+
+## Code Patterns
+
+- `internal/spec/spec.go:467` defines `AuthEnvVar`; `internal/spec/spec.go:484` defines `CanonicalEnvVar()` and is the selector template for human-prose surfaces.
+- `internal/openapi/parser.go:583` shows `applyAuthVarsRichOverride`, the conservative parser gate: malformed rich overrides warn and fall back to generated defaults.
+- `internal/authdoctor/classify.go:100` shows the mixed-version safety pattern: prefer rich manifest specs, warn on disagreement, and fall back to legacy env vars.
+- `internal/pipeline/climanifest.go:336` shows pre-merge normalization and name dedup across global and tier auth blocks.
+
+## Related
+
+- Plan: `docs/plans/2026-05-05-007-feat-auth-envvar-model-widening-plan.md`
+- Umbrella issue: [#632](https://github.com/mvanhorn/cli-printing-press/issues/632)
+- Related plans: `docs/plans/2026-04-19-004-feat-auth-doctor-plan.md`, `docs/plans/2026-03-31-004-fix-auth-error-handling-plan.md`, `docs/plans/2026-03-31-001-fix-auth-envvar-hint-relevance-plan.md`, `docs/plans/2026-04-02-001-feat-browser-auth-cookie-runtime-plan.md`
diff --git a/internal/authdoctor/classify.go b/internal/authdoctor/classify.go
index 71cc7df2..0d80a99f 100644
--- a/internal/authdoctor/classify.go
+++ b/internal/authdoctor/classify.go
@@ -2,9 +2,13 @@ package authdoctor
 
 import (
 	"fmt"
+	"os"
+	"path/filepath"
 	"sort"
+	"strings"
 
 	"github.com/mvanhorn/cli-printing-press/v3/internal/pipeline"
+	"github.com/mvanhorn/cli-printing-press/v3/internal/spec"
 )
 
 // minLengthByType gives the minimum expected length of a well-formed
@@ -69,7 +73,11 @@ func classifyAuthBlock(slug string, auth pipeline.ManifestAuth, env getEnv, tier
 		return nil
 	}
 	displayAuthType := scopedAuthType(tierName, authType)
-	if len(auth.EnvVars) == 0 {
+	if len(auth.EnvVarSpecs) > 0 && len(auth.EnvVars) > 0 && !sameAuthEnvVarNames(auth.EnvVars, auth.EnvVarSpecs) {
+		fmt.Fprintln(os.Stderr, "warning: tools-manifest auth env_vars disagree with env_var_specs; using env_var_specs")
+	}
+	envVarSpecs := auth.EffectiveEnvVarSpecs()
+	if len(envVarSpecs) == 0 {
 		findings := []Finding{{
 			API:    slug,
 			Type:   displayAuthType,
@@ -82,9 +90,13 @@ func classifyAuthBlock(slug string, auth pipeline.ManifestAuth, env getEnv, tier
 		return findings
 	}
 
-	findings := make([]Finding, 0, len(auth.EnvVars))
-	for _, envVar := range auth.EnvVars {
-		findings = append(findings, classifyEnv(slug, displayAuthType, envVar, env))
+	findings := make([]Finding, 0, len(envVarSpecs))
+	harvestedAuthFileExists := false
+	if hasHarvestedEnvVar(envVarSpecs) {
+		harvestedAuthFileExists = authFileExists(slug)
+	}
+	for _, envVar := range envVarSpecs {
+		findings = append(findings, classifyEnvSpec(slug, displayAuthType, authType, envVar, env, harvestedAuthFileExists))
 	}
 	if auth.RequiresBrowserSession {
 		findings = append(findings, browserSessionProofFinding(slug, displayAuthType))
@@ -92,6 +104,33 @@ func classifyAuthBlock(slug string, auth pipeline.ManifestAuth, env getEnv, tier
 	return findings
 }
 
+func hasHarvestedEnvVar(envVarSpecs []spec.AuthEnvVar) bool {
+	for _, envVar := range envVarSpecs {
+		if envVar.Kind == spec.AuthEnvVarKindHarvested {
+			return true
+		}
+	}
+	return false
+}
+
+func sameAuthEnvVarNames(envVars []string, envVarSpecs []spec.AuthEnvVar) bool {
+	if len(envVars) != len(envVarSpecs) {
+		return false
+	}
+	counts := make(map[string]int, len(envVars))
+	for _, envVar := range envVars {
+		counts[strings.TrimSpace(envVar)]++
+	}
+	for _, envVarSpec := range envVarSpecs {
+		name := strings.TrimSpace(envVarSpec.Name)
+		if counts[name] == 0 {
+			return false
+		}
+		counts[name]--
+	}
+	return true
+}
+
 func scopedAuthType(tierName, authType string) string {
 	if tierName == "" {
 		return authType
@@ -135,6 +174,74 @@ func classifyEnv(slug, authType, envVar string, env getEnv) Finding {
 	return base
 }
 
+func classifyEnvSpec(slug, displayAuthType, authType string, envVar spec.AuthEnvVar, env getEnv, harvestedAuthFileExists bool) Finding {
+	kind := envVar.Kind
+	if kind == "" {
+		kind = spec.AuthEnvVarKindPerCall
+	}
+	switch kind {
+	case spec.AuthEnvVarKindAuthFlowInput:
+		return classifyInfoEnv(slug, displayAuthType, envVar.Name, env, "only needed during auth login")
+	case spec.AuthEnvVarKindHarvested:
+		if env(envVar.Name) != "" {
+			return classifyEnv(slug, displayAuthType, envVar.Name, env)
+		}
+		if harvestedAuthFileExists {
+			return Finding{
+				API:    slug,
+				Type:   displayAuthType,
+				EnvVar: envVar.Name,
+				Status: StatusOK,
+				Reason: "auth file present",
+			}
+		}
+		return Finding{
+			API:    slug,
+			Type:   displayAuthType,
+			EnvVar: envVar.Name,
+			Status: StatusInfo,
+			Reason: harvestedAuthReason(authType),
+		}
+	default:
+		if !envVar.Required && env(envVar.Name) == "" {
+			return classifyInfoEnv(slug, displayAuthType, envVar.Name, env, "optional auth env var is not set")
+		}
+		return classifyEnv(slug, displayAuthType, envVar.Name, env)
+	}
+}
+
+func harvestedAuthReason(authType string) string {
+	switch authType {
+	case "cookie", "composed":
+		return "populated by auth login; run auth login --chrome"
+	default:
+		return "populated by auth login; run the printed CLI's auth command"
+	}
+}
+
+func classifyInfoEnv(slug, authType, envVar string, env getEnv, reason string) Finding {
+	if env(envVar) != "" {
+		return classifyEnv(slug, authType, envVar, env)
+	}
+	return Finding{
+		API:    slug,
+		Type:   authType,
+		EnvVar: envVar,
+		Status: StatusInfo,
+		Reason: reason,
+	}
+}
+
+func authFileExists(slug string) bool {
+	home, err := os.UserHomeDir()
+	if err != nil {
+		return false
+	}
+	path := filepath.Join(home, ".config", slug+"-pp-cli", "config.toml")
+	info, err := os.Stat(path)
+	return err == nil && !info.IsDir()
+}
+
 // suspiciousReason returns a non-empty reason when a set value looks
 // obviously malformed. Returns empty when the value looks acceptable.
 func suspiciousReason(authType, value string) string {
diff --git a/internal/authdoctor/classify_test.go b/internal/authdoctor/classify_test.go
index 5814c104..6120613b 100644
--- a/internal/authdoctor/classify_test.go
+++ b/internal/authdoctor/classify_test.go
@@ -1,9 +1,12 @@
 package authdoctor
 
 import (
+	"os"
+	"path/filepath"
 	"testing"
 
 	"github.com/mvanhorn/cli-printing-press/v3/internal/pipeline"
+	"github.com/mvanhorn/cli-printing-press/v3/internal/spec"
 )
 
 func envFrom(m map[string]string) getEnv {
@@ -193,6 +196,130 @@ func TestClassifyComposedMultipleEnvVarsMixed(t *testing.T) {
 	}
 }
 
+func TestClassifyMixedVersionManifestUsesEnvVarSpecsOnce(t *testing.T) {
+	m := &pipeline.ToolsManifest{
+		Auth: pipeline.ManifestAuth{
+			Type:    "api_key",
+			EnvVars: []string{"RICH_TOKEN"},
+			EnvVarSpecs: []spec.AuthEnvVar{{
+				Name:      "RICH_TOKEN",
+				Kind:      spec.AuthEnvVarKindPerCall,
+				Required:  true,
+				Sensitive: true,
+			}},
+		},
+	}
+
+	findings := Classify("rich", m, envFrom(map[string]string{"RICH_TOKEN": "well-formed-token"}))
+	if len(findings) != 1 {
+		t.Fatalf("want one rich-path finding with no legacy duplicate, got %d: %+v", len(findings), findings)
+	}
+	if findings[0].EnvVar != "RICH_TOKEN" || findings[0].Status != StatusOK {
+		t.Fatalf("want single RICH_TOKEN OK finding, got %+v", findings[0])
+	}
+}
+
+func TestSameAuthEnvVarNamesIgnoresOrder(t *testing.T) {
+	if !sameAuthEnvVarNames([]string{"B", "A"}, []spec.AuthEnvVar{
+		{Name: "A"},
+		{Name: "B"},
+	}) {
+		t.Fatal("expected auth env var names with different order to match")
+	}
+}
+
+func TestClassifyEnvVarSpecsKindAwareReporting(t *testing.T) {
+	t.Setenv("HOME", t.TempDir())
+
+	m := &pipeline.ToolsManifest{
+		Auth: pipeline.ManifestAuth{
+			Type:    "composed",
+			EnvVars: []string{"PER_CALL_REQUIRED", "OAUTH_CLIENT_SECRET", "SESSION_COOKIE"},
+			EnvVarSpecs: []spec.AuthEnvVar{
+				{Name: "PER_CALL_REQUIRED", Kind: spec.AuthEnvVarKindPerCall, Required: true, Sensitive: true},
+				{Name: "PER_CALL_OPTIONAL", Kind: spec.AuthEnvVarKindPerCall, Required: false, Sensitive: true},
+				{Name: "OAUTH_CLIENT_SECRET", Kind: spec.AuthEnvVarKindAuthFlowInput, Required: false, Sensitive: true},
+				{Name: "SESSION_COOKIE", Kind: spec.AuthEnvVarKindHarvested, Required: false, Sensitive: true},
+			},
+		},
+	}
+
+	findings := Classify("rich", m, envFrom(nil))
+	if len(findings) != 4 {
+		t.Fatalf("want one finding per rich env var, got %d: %+v", len(findings), findings)
+	}
+	if findings[0].EnvVar != "PER_CALL_REQUIRED" || findings[0].Status != StatusNotSet {
+		t.Fatalf("required per-call missing should be not_set, got %+v", findings[0])
+	}
+	for _, idx := range []int{1, 2, 3} {
+		if findings[idx].Status != StatusInfo {
+			t.Fatalf("finding %d should be informational, got %+v", idx, findings[idx])
+		}
+	}
+	if findings[3].Reason != "populated by auth login; run auth login --chrome" {
+		t.Fatalf("composed harvested env var should keep chrome login hint, got %q", findings[3].Reason)
+	}
+}
+
+func TestClassifyHarvestedBearerEnvVarDoesNotSuggestChrome(t *testing.T) {
+	t.Setenv("HOME", t.TempDir())
+
+	m := &pipeline.ToolsManifest{
+		Auth: pipeline.ManifestAuth{
+			Type: "bearer_token",
+			EnvVarSpecs: []spec.AuthEnvVar{{
+				Name:      "BEARER_ACCESS_TOKEN",
+				Kind:      spec.AuthEnvVarKindHarvested,
+				Required:  false,
+				Sensitive: true,
+			}},
+		},
+	}
+
+	findings := Classify("bearer-api", m, envFrom(nil))
+	if len(findings) != 1 {
+		t.Fatalf("want one finding, got %d", len(findings))
+	}
+	if findings[0].Status != StatusInfo {
+		t.Fatalf("want missing harvested bearer token to report info, got %+v", findings[0])
+	}
+	if findings[0].Reason != "populated by auth login; run the printed CLI's auth command" {
+		t.Fatalf("bearer harvested env var should not suggest chrome login, got %q", findings[0].Reason)
+	}
+}
+
+func TestClassifyHarvestedEnvVarUsesAuthFile(t *testing.T) {
+	home := t.TempDir()
+	t.Setenv("HOME", home)
+	configDir := filepath.Join(home, ".config", "cookie-api-pp-cli")
+	if err := os.MkdirAll(configDir, 0o755); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(filepath.Join(configDir, "config.toml"), []byte("session = 'ok'\n"), 0o600); err != nil {
+		t.Fatal(err)
+	}
+
+	m := &pipeline.ToolsManifest{
+		Auth: pipeline.ManifestAuth{
+			Type: "cookie",
+			EnvVarSpecs: []spec.AuthEnvVar{{
+				Name:      "COOKIE_API_SESSION",
+				Kind:      spec.AuthEnvVarKindHarvested,
+				Required:  false,
+				Sensitive: true,
+			}},
+		},
+	}
+
+	findings := Classify("cookie-api", m, envFrom(nil))
+	if len(findings) != 1 {
+		t.Fatalf("want one finding, got %d", len(findings))
+	}
+	if findings[0].Status != StatusOK || findings[0].Reason != "auth file present" {
+		t.Fatalf("want harvested auth file to report ok, got %+v", findings[0])
+	}
+}
+
 func TestClassifyBrowserSessionAlsoReportsEnvVars(t *testing.T) {
 	m := &pipeline.ToolsManifest{
 		Auth: pipeline.ManifestAuth{
diff --git a/internal/authdoctor/render.go b/internal/authdoctor/render.go
index 59ee5701..b07fd888 100644
--- a/internal/authdoctor/render.go
+++ b/internal/authdoctor/render.go
@@ -74,8 +74,8 @@ func RenderTable(w io.Writer, findings []Finding) error {
 	}
 
 	s := Summarize(findings)
-	if _, err := fmt.Fprintf(w, "\nSummary: %d ok, %d suspicious, %d not set, %d no auth, %d unknown\n",
-		s.OK, s.Suspicious, s.NotSet, s.NoAuth, s.Unknown); err != nil {
+	if _, err := fmt.Fprintf(w, "\nSummary: %d ok, %d suspicious, %d not set, %d no auth, %d unknown, %d info\n",
+		s.OK, s.Suspicious, s.NotSet, s.NoAuth, s.Unknown, s.Info); err != nil {
 		return err
 	}
 
diff --git a/internal/authdoctor/render_test.go b/internal/authdoctor/render_test.go
index 495ecfb5..6e69a021 100644
--- a/internal/authdoctor/render_test.go
+++ b/internal/authdoctor/render_test.go
@@ -23,6 +23,7 @@ func TestRenderTableRows(t *testing.T) {
 		{API: "dub", Type: "bearer_token", EnvVar: "DUB_TOKEN", Status: StatusSuspicious, Fingerprint: "abc", Reason: "too short"},
 		{API: "hubspot", Type: "api_key", EnvVar: "HUBSPOT_ACCESS_TOKEN", Status: StatusOK, Fingerprint: "pat-..."},
 		{API: "hackernews", Type: "none", Status: StatusNoAuth},
+		{API: "slack", Type: "bearer_token", EnvVar: "SLACK_BOT_TOKEN", Status: StatusInfo, Reason: "set one of: SLACK_BOT_TOKEN or SLACK_USER_TOKEN"},
 	}
 	var buf bytes.Buffer
 	if err := RenderTable(&buf, findings); err != nil {
@@ -45,6 +46,9 @@ func TestRenderTableRows(t *testing.T) {
 	if !strings.Contains(out, "1 ok") || !strings.Contains(out, "1 suspicious") || !strings.Contains(out, "1 no auth") {
 		t.Errorf("summary counts wrong:\n%s", out)
 	}
+	if !strings.Contains(out, "Summary: 1 ok, 1 suspicious, 0 not set, 1 no auth, 0 unknown, 1 info") {
+		t.Errorf("summary ordering wrong:\n%s", out)
+	}
 }
 
 func TestRenderJSONShape(t *testing.T) {
diff --git a/internal/authdoctor/types.go b/internal/authdoctor/types.go
index 05fdffb3..39a6547e 100644
--- a/internal/authdoctor/types.go
+++ b/internal/authdoctor/types.go
@@ -7,6 +7,7 @@ const (
 	StatusOK         Status = "ok"
 	StatusSuspicious Status = "suspicious"
 	StatusNotSet     Status = "not_set"
+	StatusInfo       Status = "info"
 	StatusNoAuth     Status = "no_auth"
 	StatusUnknown    Status = "unknown"
 )
@@ -29,6 +30,7 @@ type Summary struct {
 	OK         int `json:"ok"`
 	Suspicious int `json:"suspicious"`
 	NotSet     int `json:"not_set"`
+	Info       int `json:"info"`
 	NoAuth     int `json:"no_auth"`
 	Unknown    int `json:"unknown"`
 }
@@ -44,6 +46,8 @@ func Summarize(findings []Finding) Summary {
 			s.Suspicious++
 		case StatusNotSet:
 			s.NotSet++
+		case StatusInfo:
+			s.Info++
 		case StatusNoAuth:
 			s.NoAuth++
 		case StatusUnknown:
diff --git a/internal/generator/auth_optional_test.go b/internal/generator/auth_optional_test.go
index ef4fdfdc..7b36e3c1 100644
--- a/internal/generator/auth_optional_test.go
+++ b/internal/generator/auth_optional_test.go
@@ -50,10 +50,10 @@ func TestAuthNotOptional_DoctorReportsFailure(t *testing.T) {
 		"default spec must not emit the optional-prefixed status")
 }
 
-// TestAuthOptional_AuthCmdShortNamesEnvVar verifies the `auth` subcommand's
-// help Short description names the specific env var and flags the optionality
-// when auth.optional is set.
-func TestAuthOptional_AuthCmdShortNamesEnvVar(t *testing.T) {
+// TestAuthOptional_AuthCmdShortUsesProseName verifies the `auth` subcommand's
+// help Short description names the API, not a single env var, while preserving
+// optional-auth framing.
+func TestAuthOptional_AuthCmdShortUsesProseName(t *testing.T) {
 	t.Parallel()
 
 	apiSpec := minimalSpec("opt-auth-short")
@@ -65,13 +65,13 @@ func TestAuthOptional_AuthCmdShortNamesEnvVar(t *testing.T) {
 
 	authSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "auth.go"))
 	require.NoError(t, err)
-	require.Contains(t, string(authSrc), `"Manage the optional OPT_AUTH_KEY`,
-		"auth parent command Short must name the env var and flag optionality")
+	require.Contains(t, string(authSrc), `"Manage optional authentication for Opt Auth Short"`,
+		"auth parent command Short must name the API and flag optionality")
 }
 
-// TestAuthRequired_AuthCmdShortNamesEnvVar verifies the default (required)
-// branch still names the env var — just without the "optional" flag.
-func TestAuthRequired_AuthCmdShortNamesEnvVar(t *testing.T) {
+// TestAuthRequired_AuthCmdShortUsesProseName verifies the default (required)
+// branch names the API instead of overfitting to one env var.
+func TestAuthRequired_AuthCmdShortUsesProseName(t *testing.T) {
 	t.Parallel()
 
 	apiSpec := minimalSpec("req-auth-short")
@@ -82,8 +82,8 @@ func TestAuthRequired_AuthCmdShortNamesEnvVar(t *testing.T) {
 
 	authSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "auth.go"))
 	require.NoError(t, err)
-	require.Contains(t, string(authSrc), `"Manage REQ_AUTH_KEY credentials"`,
-		"required-auth parent command Short names the env var without optional framing")
+	require.Contains(t, string(authSrc), `"Manage authentication for Req Auth Short"`,
+		"required-auth parent command Short names the API without optional framing")
 }
 
 // TestAuthOptional_ReadmeFramesAsOptional verifies the README template
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 56b9c00b..ada9d804 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -212,6 +212,9 @@ func New(s *spec.APISpec, outputDir string) *Generator {
 		"resolveEnvVarField":     resolveEnvVarField,
 		"authPlacement":          authPlacement,
 		"authParameterName":      authParameterName,
+		"authCommandShort":       authCommandShort,
+		"authHarvestedEnvHint":   authHarvestedEnvHint,
+		"hasAuthEnvVarKind":      hasAuthEnvVarKind,
 		"effectiveTier":          effectiveTier,
 		"effectiveSubTier":       effectiveSubTier,
 		"add":                    func(a, b int) int { return a + b },
@@ -798,6 +801,39 @@ func authParameterName(auth spec.AuthConfig) string {
 	return "Authorization"
 }
 
+func authCommandShort(api *spec.APISpec) string {
+	displayName := "this API"
+	if api != nil && strings.TrimSpace(api.EffectiveDisplayName()) != "" {
+		displayName = api.EffectiveDisplayName()
+	}
+	if api != nil && api.Auth.Optional {
+		return "Manage optional authentication for " + displayName
+	}
+	return "Manage authentication for " + displayName
+}
+
+func authHarvestedEnvHint(auth spec.AuthConfig) string {
+	switch {
+	case auth.Type == "cookie" || auth.Type == "composed":
+		return "populated automatically by auth login --chrome"
+	case auth.EffectiveOAuth2Grant() == spec.OAuth2GrantClientCredentials && auth.TokenURL != "":
+		return "populated automatically by auth login --client-id/--client-secret"
+	case auth.AuthorizationURL != "":
+		return "populated automatically by auth login"
+	default:
+		return "set with auth set-token"
+	}
+}
+
+func hasAuthEnvVarKind(envVarSpecs []spec.AuthEnvVar, kind string) bool {
+	for _, envVar := range envVarSpecs {
+		if string(envVar.Kind) == kind {
+			return true
+		}
+	}
+	return false
+}
+
 func effectiveTier(api *spec.APISpec, resource spec.Resource, endpoint spec.Endpoint) string {
 	if api == nil {
 		return ""
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 0f36e59e..62584d13 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -488,7 +488,7 @@ func TestGenerateAgentContextCommand(t *testing.T) {
 
 	var payload map[string]any
 	require.NoError(t, json.Unmarshal(out, &payload), "agent-context must emit valid JSON")
-	assert.Equal(t, "2", payload["schema_version"], "schema_version must be present")
+	assert.Equal(t, "3", payload["schema_version"], "schema_version must be present")
 	assert.Contains(t, payload, "cli")
 	assert.Contains(t, payload, "auth")
 	assert.Contains(t, payload, "commands")
diff --git a/internal/generator/skill_test.go b/internal/generator/skill_test.go
index 427ad57a..e98084f7 100644
--- a/internal/generator/skill_test.go
+++ b/internal/generator/skill_test.go
@@ -408,6 +408,54 @@ func TestSkillFrontmatterMetadataIsClawHubCompliantNestedYAML(t *testing.T) {
 		"metadata must not be a JSON-string blob anymore")
 }
 
+func TestSkillFrontmatterEnvVarsOmitsHarvestedAuthEnvVars(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("clawauth")
+	apiSpec.Auth = spec.AuthConfig{
+		Type:   "bearer_token",
+		Header: "Authorization",
+		Format: "Bearer {token}",
+		EnvVarSpecs: []spec.AuthEnvVar{
+			{Name: "CLAW_API_TOKEN", Kind: spec.AuthEnvVarKindPerCall, Required: true, Sensitive: true, Description: "API token."},
+			{Name: "CLAW_CLIENT_ID", Kind: spec.AuthEnvVarKindAuthFlowInput, Required: false, Sensitive: false, Description: "OAuth client id."},
+			{Name: "CLAW_SESSION_COOKIE", Kind: spec.AuthEnvVarKindHarvested, Required: false, Sensitive: true, Description: "Harvested session cookie."},
+		},
+	}
+	outputDir := filepath.Join(t.TempDir(), "clawauth-pp-cli")
+	gen := New(apiSpec, outputDir)
+	require.NoError(t, gen.Generate())
+
+	skill, err := os.ReadFile(filepath.Join(outputDir, "SKILL.md"))
+	require.NoError(t, err)
+	content := string(skill)
+
+	require.True(t, strings.HasPrefix(content, "---\n"))
+	end := strings.Index(content[4:], "\n---\n")
+	require.NotEqual(t, -1, end)
+	body := strings.TrimSuffix(strings.TrimPrefix(content[:4+end+5], "---\n"), "---\n")
+
+	var parsed struct {
+		Metadata struct {
+			Openclaw struct {
+				EnvVars []struct {
+					Name string `yaml:"name"`
+				} `yaml:"envVars"`
+			} `yaml:"openclaw"`
+		} `yaml:"metadata"`
+	}
+	require.NoError(t, yaml.Unmarshal([]byte(body), &parsed),
+		"frontmatter must parse as nested YAML; content was:\n%s", body)
+
+	var names []string
+	for _, envVar := range parsed.Metadata.Openclaw.EnvVars {
+		names = append(names, envVar.Name)
+	}
+	assert.ElementsMatch(t, []string{"CLAW_API_TOKEN", "CLAW_CLIENT_ID"}, names)
+	assert.NotContains(t, body, "CLAW_SESSION_COOKIE",
+		"harvested env vars are populated by auth login and must not be user-facing OpenClaw envVars")
+}
+
 // TestSkillFrontmatterMetadataDefaultsCategoryToOther asserts that when the
 // spec has no Category set, the install module path falls back to 'other'.
 func TestSkillFrontmatterMetadataDefaultsCategoryToOther(t *testing.T) {
diff --git a/internal/generator/templates/agent_context.go.tmpl b/internal/generator/templates/agent_context.go.tmpl
index dc1c9ce1..e0989eba 100644
--- a/internal/generator/templates/agent_context.go.tmpl
+++ b/internal/generator/templates/agent_context.go.tmpl
@@ -14,8 +14,8 @@ import (
 
 // agentContextSchemaVersion is bumped on any breaking change to the JSON
 // shape emitted by `agent-context`. Agents should check this before
-// parsing. Shape at v2 adds optional browser-sniff discovery context.
-const agentContextSchemaVersion = "2"
+// parsing. Shape at v3 adds kind-aware auth env var metadata.
+const agentContextSchemaVersion = "3"
 
 // agentContext is the structured description of this CLI consumed by AI
 // agents. Inspired by Cloudflare's /cdn-cgi/explorer/api runtime endpoint
@@ -38,8 +38,16 @@ type agentContextCLI struct {
 }
 
 type agentContextAuth struct {
-	Mode    string   `json:"mode"`
-	EnvVars []string `json:"env_vars"`
+	Mode    string                   `json:"mode"`
+	EnvVars []agentContextAuthEnvVar `json:"env_vars"`
+}
+
+type agentContextAuthEnvVar struct {
+	Name        string `json:"name"`
+	Kind        string `json:"kind"`
+	Required    bool   `json:"required"`
+	Sensitive   bool   `json:"sensitive"`
+	Description string `json:"description,omitempty"`
 }
 
 type agentContextDiscovery struct {
@@ -95,9 +103,32 @@ reading source. Schema is versioned via schema_version.`,
 }
 
 func buildAgentContext(rootCmd *cobra.Command) agentContext {
-	envVars := []string{
+	envVars := []agentContextAuthEnvVar{
+{{- if .Auth.EnvVarSpecs}}
+{{- range .Auth.EnvVarSpecs}}
+{{- if ne .Kind "harvested"}}
+		{
+			Name:      {{printf "%q" .Name}},
+			Kind:      {{printf "%q" .Kind}},
+			Required:  {{.Required}},
+			Sensitive: {{.Sensitive}},
+{{- if .Sensitive}}
+			Description: {{printf "%q" .Kind.SensitivePlaceholder}},
+{{- else if .Description}}
+			Description: {{printf "%q" .Description}},
+{{- end}}
+		},
+{{- end}}
+{{- end}}
+{{- else}}
 {{- range .Auth.EnvVars}}
-		{{printf "%q" .}},
+		{
+			Name:      {{printf "%q" .}},
+			Kind:      "per_call",
+			Required:  true,
+			Sensitive: true,
+		},
+{{- end}}
 {{- end}}
 	}
 	authMode := {{printf "%q" .Auth.Type}}
diff --git a/internal/generator/templates/auth.go.tmpl b/internal/generator/templates/auth.go.tmpl
index aee7d8ed..5b25b5ce 100644
--- a/internal/generator/templates/auth.go.tmpl
+++ b/internal/generator/templates/auth.go.tmpl
@@ -25,15 +25,7 @@ import (
 func newAuthCmd(flags *rootFlags) *cobra.Command {
 	cmd := &cobra.Command{
 		Use:   "auth",
-{{- if .Auth.EnvVars}}
-{{- if .Auth.Optional}}
-		Short: "Manage the optional {{index .Auth.EnvVars 0}} (enables features that require it; not needed for core commands)",
-{{- else}}
-		Short: "Manage {{index .Auth.EnvVars 0}} credentials",
-{{- end}}
-{{- else}}
-		Short: "Manage authentication",
-{{- end}}
+		Short: {{printf "%q" (authCommandShort .APISpec)}},
 	}
 
 	cmd.AddCommand(newAuthLoginCmd(flags))
diff --git a/internal/generator/templates/auth_browser.go.tmpl b/internal/generator/templates/auth_browser.go.tmpl
index cfc9de52..bfbebef9 100644
--- a/internal/generator/templates/auth_browser.go.tmpl
+++ b/internal/generator/templates/auth_browser.go.tmpl
@@ -2,6 +2,7 @@
 {{- $hasQueryRefresh := .HasGraphQLPersistedQueries -}}
 {{- $hasBrowserCapture := or $hasBrowserRefresh $hasQueryRefresh -}}
 {{- $hasCookieBrowserAuth := or (eq .Auth.Type "cookie") (eq .Auth.Type "composed") -}}
+{{- $canonicalEnvVar := .Auth.CanonicalEnvVar -}}
 // Copyright {{currentYear}} {{.Owner}}. Licensed under Apache-2.0. See LICENSE.
 // Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
 
@@ -49,7 +50,7 @@ import (
 func newAuthCmd(flags *rootFlags) *cobra.Command {
 	cmd := &cobra.Command{
 		Use:   "auth",
-		Short: "Manage authentication",
+		Short: {{printf "%q" (authCommandShort .APISpec)}},
 	}
 
 	cmd.AddCommand(newAuthLoginCmd(flags))
@@ -100,10 +101,10 @@ profile by name when the installed backend supports it.`,
 				return nil
 			}
 
-{{- if .Auth.EnvVars}}
+{{- with $canonicalEnvVar}}
 			// Check if already authenticated via env var
-			if v := os.Getenv("{{index .Auth.EnvVars 0}}"); v != "" {
-				fmt.Fprintf(cmd.OutOrStdout(), "Already authenticated via %s env var.\n", "{{index .Auth.EnvVars 0}}")
+			if v := os.Getenv("{{.Name}}"); v != "" {
+				fmt.Fprintf(cmd.OutOrStdout(), "Already authenticated via %s env var.\n", "{{.Name}}")
 				return nil
 			}
 {{- end}}
@@ -303,11 +304,13 @@ func newAuthLoginCmd(flags *rootFlags) *cobra.Command {
 
 {{- if eq .Auth.Type "none"}}
 The captured API surface does not require credentials.
-{{- else if .Auth.EnvVars}}
+{{- else}}
+{{- with $canonicalEnvVar}}
 Configure credentials with the documented environment variable before making live calls.
 {{- else}}
 No interactive login flow is configured for this CLI.
 {{- end}}
+{{- end}}
 {{- if $hasQueryRefresh}}
 
 Use auth refresh-queries when the site rotates GraphQL persisted-query hashes.
@@ -316,11 +319,13 @@ Use auth refresh-queries when the site rotates GraphQL persisted-query hashes.
 			w := cmd.OutOrStdout()
 {{- if eq .Auth.Type "none"}}
 			fmt.Fprintln(w, green("Auth: not required"))
-{{- else if .Auth.EnvVars}}
-			fmt.Fprintf(w, "Set %s before making live calls.\n", "{{index .Auth.EnvVars 0}}")
+{{- else}}
+{{- with $canonicalEnvVar}}
+			fmt.Fprintf(w, "Set %s before making live calls.\n", "{{.Name}}")
 {{- else}}
 			fmt.Fprintln(w, "No interactive login flow is configured for this CLI.")
 {{- end}}
+{{- end}}
 {{- if $hasQueryRefresh}}
 			fmt.Fprintf(w, "To refresh GraphQL persisted-query hashes, run:\n  {{.Name}}-pp-cli auth refresh-queries\n")
 {{- end}}
@@ -825,10 +830,10 @@ func newAuthStatusCmd(flags *rootFlags) *cobra.Command {
 				fmt.Fprintln(w, green("Auth: not required"))
 				return nil
 {{- else}}
-{{- if .Auth.EnvVars}}
-				if v := os.Getenv("{{index .Auth.EnvVars 0}}"); v != "" {
+{{- with $canonicalEnvVar}}
+				if v := os.Getenv("{{.Name}}"); v != "" {
 					fmt.Fprintln(w, green("Authenticated"))
-					fmt.Fprintf(w, "  Source: %s env var\n", "{{index .Auth.EnvVars 0}}")
+					fmt.Fprintf(w, "  Source: %s env var\n", "{{.Name}}")
 					return nil
 				}
 {{- end}}
@@ -869,9 +874,9 @@ func newAuthLogoutCmd(flags *rootFlags) *cobra.Command {
 			}
 {{- end}}
 
-{{- if .Auth.EnvVars}}
-			if os.Getenv("{{index .Auth.EnvVars 0}}") != "" {
-				fmt.Fprintf(cmd.OutOrStdout(), "Config cleared. Note: %s env var is still set.\n", "{{index .Auth.EnvVars 0}}")
+{{- with $canonicalEnvVar}}
+			if os.Getenv("{{.Name}}") != "" {
+				fmt.Fprintf(cmd.OutOrStdout(), "Config cleared. Note: %s env var is still set.\n", "{{.Name}}")
 				return nil
 			}
 {{- end}}
diff --git a/internal/generator/templates/auth_client_credentials.go.tmpl b/internal/generator/templates/auth_client_credentials.go.tmpl
index d3522b93..75881e52 100644
--- a/internal/generator/templates/auth_client_credentials.go.tmpl
+++ b/internal/generator/templates/auth_client_credentials.go.tmpl
@@ -25,11 +25,7 @@ import (
 func newAuthCmd(flags *rootFlags) *cobra.Command {
 	cmd := &cobra.Command{
 		Use:   "auth",
-{{- if .Auth.EnvVars}}
-		Short: "Manage {{index .Auth.EnvVars 0}} credentials (login, status, logout)",
-{{- else}}
-		Short: "Manage OAuth2 credentials (login, status, logout)",
-{{- end}}
+		Short: {{printf "%q" (authCommandShort .APISpec)}},
 	}
 
 	cmd.AddCommand(newAuthLoginCmd(flags))
diff --git a/internal/generator/templates/auth_simple.go.tmpl b/internal/generator/templates/auth_simple.go.tmpl
index 0fa68588..c415e494 100644
--- a/internal/generator/templates/auth_simple.go.tmpl
+++ b/internal/generator/templates/auth_simple.go.tmpl
@@ -16,15 +16,7 @@ import (
 func newAuthCmd(flags *rootFlags) *cobra.Command {
 	cmd := &cobra.Command{
 		Use:   "auth",
-{{- if .Auth.EnvVars}}
-{{- if .Auth.Optional}}
-		Short: "Manage the optional {{index .Auth.EnvVars 0}} (enables features that require it; not needed for core commands)",
-{{- else}}
-		Short: "Manage {{index .Auth.EnvVars 0}} credentials",
-{{- end}}
-{{- else}}
-		Short: "Manage authentication tokens",
-{{- end}}
+		Short: {{printf "%q" (authCommandShort .APISpec)}},
 	}
 
 	cmd.AddCommand(newAuthStatusCmd(flags))
diff --git a/internal/generator/templates/config.go.tmpl b/internal/generator/templates/config.go.tmpl
index d1a0d50e..e990394b 100644
--- a/internal/generator/templates/config.go.tmpl
+++ b/internal/generator/templates/config.go.tmpl
@@ -31,11 +31,20 @@ type Config struct {
 	ClientID       string `{{configTag .Config.Format}}:"client_id"`
 	ClientSecret   string `{{configTag .Config.Format}}:"client_secret"`
 	Path           string `{{configTag .Config.Format}}:"-"`
+{{- if .Auth.EnvVarSpecs}}
+{{- range .Auth.EnvVarSpecs}}
+{{- if not (envVarIsBuiltinField .Name)}}
+	{{envVarField .Name}} string `{{configTag $.Config.Format}}:"{{envVarPlaceholder .Name}}"`
+{{- end}}
+{{- end}}
+{{- else if .Auth.EnvVars}}
+	// fallback to legacy EnvVars when EnvVarSpecs is empty
 {{- range .Auth.EnvVars}}
 {{- if not (envVarIsBuiltinField .)}}
 	{{envVarField .}} string `{{configTag $.Config.Format}}:"{{envVarPlaceholder .}}"`
 {{- end}}
 {{- end}}
+{{- end}}
 {{- if .EndpointTemplateVars}}
 	// TemplateVars holds the runtime values for {placeholder} markers in
 	// BaseURL and the request path (e.g. Shopify's {shop}/{version}). Populated
@@ -84,11 +93,21 @@ func Load(configPath string) (*Config, error) {
 {{- if .Auth.Inferred}}
 	// Auth inferred from API description — verify the env var below is correct
 {{- end}}
+{{- if .Auth.EnvVarSpecs}}
+{{- range .Auth.EnvVarSpecs}}
+	if v := os.Getenv("{{.Name}}"); v != "" {
+		cfg.{{resolveEnvVarField .Name}} = v
+		cfg.AuthSource = "env:{{.Name}}"
+	}
+{{- end}}
+{{- else if .Auth.EnvVars}}
+	// fallback to legacy EnvVars when EnvVarSpecs is empty
 {{- range .Auth.EnvVars}}
 	if v := os.Getenv("{{.}}"); v != "" {
 		cfg.{{resolveEnvVarField .}} = v
 		cfg.AuthSource = "env:{{.}}"
 	}
+{{- end}}
 {{- end}}
 
 	// Base URL override (used by printing-press verify to point at mock/test servers)
@@ -115,6 +134,8 @@ func Load(configPath string) (*Config, error) {
 }
 
 func (c *Config) AuthHeader() string {
+{{- $canonicalEnvVar := .Auth.CanonicalEnvVar}}
+{{- $isAuthEnvVarORCase := .Auth.IsAuthEnvVarORCase}}
 {{- if and .BearerRefresh.Enabled (eq .Auth.Type "bearer_token") (ne .Auth.EffectiveOAuth2Grant "client_credentials")}}
 	if c.AuthHeaderVal != "" && c.AccessToken == "" {
 		return c.AuthHeaderVal
@@ -125,20 +146,60 @@ func (c *Config) AuthHeader() string {
 	}
 {{- end}}
 {{- if eq .Auth.Type "api_key"}}
-	{{- if gt (len .Auth.EnvVars) 0}}
-	token := c.{{resolveEnvVarField (index .Auth.EnvVars 0)}}
+{{- if $isAuthEnvVarORCase}}
+{{- range .Auth.EnvVarSpecs}}
+	if c.{{resolveEnvVarField .Name}} != "" {
+		{{- if $.Auth.Format}}
+		return applyAuthFormat("{{$.Auth.Format}}", map[string]string{
+			"{{envVarPlaceholder .Name}}": c.{{resolveEnvVarField .Name}},
+			"{{.Name}}": c.{{resolveEnvVarField .Name}},
+			"token": c.{{resolveEnvVarField .Name}},
+		})
+		{{- else}}
+		return c.{{resolveEnvVarField .Name}}
+		{{- end}}
+	}
+{{- end}}
+	return ""
+{{- else}}
+	{{- with $canonicalEnvVar}}
+	token := c.{{resolveEnvVarField .Name}}
 	if token == "" {
 		return ""
 	}
 	{{- end}}
+{{- if .Auth.EnvVarSpecs}}
+{{- range .Auth.EnvVarSpecs}}
+{{- if and (eq .Kind "per_call") .Required}}
+	if c.{{resolveEnvVarField .Name}} == "" {
+		return ""
+	}
+{{- end}}
+{{- end}}
+{{- else if .Auth.EnvVars}}
+	// fallback to legacy EnvVars when EnvVarSpecs is empty
 {{- range .Auth.EnvVars}}
 	if c.{{resolveEnvVarField .}} == "" {
 		return ""
 	}
+{{- end}}
 {{- end}}
 	{{- if .Auth.Format}}
 	replacements := map[string]string{
 		"token": token,
+	{{- if .Auth.EnvVarSpecs}}
+	{{- range .Auth.EnvVarSpecs}}
+	{{- if eq .Kind "per_call"}}
+	{{- if ne (envVarPlaceholder .Name) "token"}}
+		"{{envVarPlaceholder .Name}}": c.{{resolveEnvVarField .Name}},
+	{{- end}}
+	{{- if ne .Name "token"}}
+		"{{.Name}}": c.{{resolveEnvVarField .Name}},
+	{{- end}}
+	{{- end}}
+	{{- end}}
+	{{- else if .Auth.EnvVars}}
+	// fallback to legacy EnvVars when EnvVarSpecs is empty
 	{{- range .Auth.EnvVars}}
 	{{- if ne (envVarPlaceholder .) "token"}}
 		"{{envVarPlaceholder .}}": c.{{resolveEnvVarField .}},
@@ -147,13 +208,15 @@ func (c *Config) AuthHeader() string {
 		"{{.}}": c.{{resolveEnvVarField .}},
 	{{- end}}
 	{{- end}}
+	{{- end}}
 	}
 	return applyAuthFormat("{{.Auth.Format}}", replacements)
-	{{- else if gt (len .Auth.EnvVars) 0}}
+	{{- else if $canonicalEnvVar}}
 	return token
 	{{- else}}
 	return ""
 	{{- end}}
+{{- end}}
 {{- else if or (eq .Auth.Type "bearer_token") (eq .Auth.Type "oauth2")}}
 {{- if eq .Auth.EffectiveOAuth2Grant "client_credentials"}}
 	// Under OAuth2 client_credentials the env var is the Client ID, not a
@@ -166,17 +229,17 @@ func (c *Config) AuthHeader() string {
 		return "Bearer " + c.AccessToken
 		{{- end}}
 	}
-	{{- if gt (len .Auth.EnvVars) 0}}
-	if c.{{resolveEnvVarField (index .Auth.EnvVars 0)}} != "" {
-		c.AuthSource = "env:{{index .Auth.EnvVars 0}}"
-		{{- if .Auth.Format}}
-		return applyAuthFormat("{{.Auth.Format}}", map[string]string{
-			"{{envVarPlaceholder (index .Auth.EnvVars 0)}}": c.{{resolveEnvVarField (index .Auth.EnvVars 0)}},
-			"{{index .Auth.EnvVars 0}}": c.{{resolveEnvVarField (index .Auth.EnvVars 0)}},
-			"token": c.{{resolveEnvVarField (index .Auth.EnvVars 0)}},
+	{{- with $canonicalEnvVar}}
+	if c.{{resolveEnvVarField .Name}} != "" {
+		c.AuthSource = "env:{{.Name}}"
+		{{- if $.Auth.Format}}
+		return applyAuthFormat("{{$.Auth.Format}}", map[string]string{
+			"{{envVarPlaceholder .Name}}": c.{{resolveEnvVarField .Name}},
+			"{{.Name}}": c.{{resolveEnvVarField .Name}},
+			"token": c.{{resolveEnvVarField .Name}},
 		})
 		{{- else}}
-		return "Bearer " + c.{{resolveEnvVarField (index .Auth.EnvVars 0)}}
+		return "Bearer " + c.{{resolveEnvVarField .Name}}
 		{{- end}}
 	}
 	{{- end}}
@@ -196,20 +259,37 @@ func (c *Config) AuthHeader() string {
 {{- else}}
 	// Env-var token wins over file-stored AccessToken (env > config convention).
 {{- end}}
-	{{- if gt (len .Auth.EnvVars) 0}}
-	if c.{{resolveEnvVarField (index .Auth.EnvVars 0)}} != "" {
-		c.AuthSource = "env:{{index .Auth.EnvVars 0}}"
-		{{- if .Auth.Format}}
-		return applyAuthFormat("{{.Auth.Format}}", map[string]string{
-			"{{envVarPlaceholder (index .Auth.EnvVars 0)}}": c.{{resolveEnvVarField (index .Auth.EnvVars 0)}},
-			"{{index .Auth.EnvVars 0}}": c.{{resolveEnvVarField (index .Auth.EnvVars 0)}},
-			"token": c.{{resolveEnvVarField (index .Auth.EnvVars 0)}},
+{{- if $isAuthEnvVarORCase}}
+{{- range .Auth.EnvVarSpecs}}
+	if c.{{resolveEnvVarField .Name}} != "" {
+		c.AuthSource = "env:{{.Name}}"
+		{{- if $.Auth.Format}}
+		return applyAuthFormat("{{$.Auth.Format}}", map[string]string{
+			"{{envVarPlaceholder .Name}}": c.{{resolveEnvVarField .Name}},
+			"{{.Name}}": c.{{resolveEnvVarField .Name}},
+			"token": c.{{resolveEnvVarField .Name}},
 		})
 		{{- else}}
-		return "Bearer " + c.{{resolveEnvVarField (index .Auth.EnvVars 0)}}
+		return "Bearer " + c.{{resolveEnvVarField .Name}}
+		{{- end}}
+	}
+{{- end}}
+{{- else}}
+	{{- with $canonicalEnvVar}}
+	if c.{{resolveEnvVarField .Name}} != "" {
+		c.AuthSource = "env:{{.Name}}"
+		{{- if $.Auth.Format}}
+		return applyAuthFormat("{{$.Auth.Format}}", map[string]string{
+			"{{envVarPlaceholder .Name}}": c.{{resolveEnvVarField .Name}},
+			"{{.Name}}": c.{{resolveEnvVarField .Name}},
+			"token": c.{{resolveEnvVarField .Name}},
+		})
+		{{- else}}
+		return "Bearer " + c.{{resolveEnvVarField .Name}}
 		{{- end}}
 	}
 	{{- end}}
+{{- end}}
 {{- if not .BearerRefresh.Enabled}}
 	if c.AccessToken != "" {
 		c.AuthSource = "oauth2"
@@ -224,12 +304,21 @@ func (c *Config) AuthHeader() string {
 {{- end}}
 {{- else if eq .Auth.Type "cookie"}}
 	// Env-var token wins over file-stored AccessToken (env > config convention).
-	{{- if gt (len .Auth.EnvVars) 0}}
-	if c.{{resolveEnvVarField (index .Auth.EnvVars 0)}} != "" {
-		c.AuthSource = "env:{{index .Auth.EnvVars 0}}"
-		return c.{{resolveEnvVarField (index .Auth.EnvVars 0)}}
+{{- if $isAuthEnvVarORCase}}
+{{- range .Auth.EnvVarSpecs}}
+	if c.{{resolveEnvVarField .Name}} != "" {
+		c.AuthSource = "env:{{.Name}}"
+		return c.{{resolveEnvVarField .Name}}
+	}
+{{- end}}
+{{- else}}
+	{{- with $canonicalEnvVar}}
+	if c.{{resolveEnvVarField .Name}} != "" {
+		c.AuthSource = "env:{{.Name}}"
+		return c.{{resolveEnvVarField .Name}}
 	}
 	{{- end}}
+{{- end}}
 	if c.AccessToken != "" {
 		c.AuthSource = "browser"
 		return c.AccessToken
@@ -237,12 +326,21 @@ func (c *Config) AuthHeader() string {
 	return ""
 {{- else if eq .Auth.Type "composed"}}
 	// Env-var token wins over file-stored AccessToken (env > config convention).
-	{{- if gt (len .Auth.EnvVars) 0}}
-	if c.{{resolveEnvVarField (index .Auth.EnvVars 0)}} != "" {
-		c.AuthSource = "env:{{index .Auth.EnvVars 0}}"
-		return c.{{resolveEnvVarField (index .Auth.EnvVars 0)}}
+{{- if $isAuthEnvVarORCase}}
+{{- range .Auth.EnvVarSpecs}}
+	if c.{{resolveEnvVarField .Name}} != "" {
+		c.AuthSource = "env:{{.Name}}"
+		return c.{{resolveEnvVarField .Name}}
+	}
+{{- end}}
+{{- else}}
+	{{- with $canonicalEnvVar}}
+	if c.{{resolveEnvVarField .Name}} != "" {
+		c.AuthSource = "env:{{.Name}}"
+		return c.{{resolveEnvVarField .Name}}
 	}
 	{{- end}}
+{{- end}}
 	if c.AccessToken != "" {
 		c.AuthSource = "chrome-composed"
 		return c.AccessToken
@@ -281,10 +379,19 @@ func (c *Config) SaveBearerToken(accessToken string, refreshedAt time.Time) erro
 	c.AccessToken = accessToken
 	c.RefreshToken = ""
 	c.TokenExpiry = time.Time{}
+{{- if .Auth.EnvVarSpecs}}
+{{- range .Auth.EnvVarSpecs}}
+{{- if not (envVarIsBuiltinField .Name)}}
+	c.{{envVarField .Name}} = ""
+{{- end}}
+{{- end}}
+{{- else if .Auth.EnvVars}}
+	// fallback to legacy EnvVars when EnvVarSpecs is empty
 {{- range .Auth.EnvVars}}
 {{- if not (envVarIsBuiltinField .)}}
 	c.{{envVarField .}} = ""
 {{- end}}
+{{- end}}
 {{- end}}
 	c.BearerTokenRefreshedAt = refreshedAt
 	return c.save()
diff --git a/internal/generator/templates/doctor.go.tmpl b/internal/generator/templates/doctor.go.tmpl
index 6efad5c9..ebcb9a87 100644
--- a/internal/generator/templates/doctor.go.tmpl
+++ b/internal/generator/templates/doctor.go.tmpl
@@ -1,5 +1,6 @@
 // Copyright {{currentYear}} {{.Owner}}. Licensed under Apache-2.0. See LICENSE.
 // Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+{{- $canonicalEnvVar := .Auth.CanonicalEnvVar}}
 
 package cli
 
@@ -25,7 +26,7 @@ import (
 {{- if .BearerRefresh.Enabled}}
 	"net/http"
 {{- end}}
-{{- if or .Auth.EnvVars .HasStore .Auth.RequiresBrowserSession .HasTierRouting}}
+{{- if or .Auth.EnvVarSpecs .Auth.EnvVars .HasStore .Auth.RequiresBrowserSession .HasTierRouting}}
 	"os"
 {{- end}}
 {{- if or (eq .Auth.Type "cookie") (eq .Auth.Type "composed")}}
@@ -139,7 +140,9 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 				header := cfg.AuthHeader()
 				if header == "" {
 					report["auth"] = "inferred (not configured)"
-					report["auth_hint"] = "export {{index .Auth.EnvVars 0}}=<your-key>"
+{{- with $canonicalEnvVar}}
+					report["auth_hint"] = "export {{.Name}}=<your-key>"
+{{- end}}
 				} else {
 					report["auth"] = "inferred (configured — verify header and env var are correct)"
 					report["auth_source"] = cfg.AuthSource
@@ -193,8 +196,8 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 {{- else}}
 					report["auth"] = "not configured"
 {{- end}}
-{{- if .Auth.EnvVars}}
-					report["auth_hint"] = "export {{index .Auth.EnvVars 0}}=<your-key>"
+{{- with $canonicalEnvVar}}
+					report["auth_hint"] = "export {{.Name}}=<your-key>"
 {{- end}}
 {{- if .Auth.KeyURL}}
 					report["auth_key_url"] = "{{.Auth.KeyURL}}"
@@ -207,7 +210,65 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 {{- end}}
 
 			// Check auth environment variables
-{{- if .Auth.EnvVars}}
+{{- if .Auth.EnvVarSpecs}}
+			authEnvSet := []string{}
+			authEnvRequiredMissing := []string{}
+			authEnvInfo := []string{}
+			authEnvOptionalNames := []string{}
+			// Validation rejects multi-OR-group specs upstream, so the single optional-satisfied state is sufficient at runtime.
+			authEnvOptionalSatisfied := false
+{{- if hasAuthEnvVarKind .Auth.EnvVarSpecs "harvested"}}
+			authHeader := ""
+			if cfg != nil {
+				authHeader = cfg.AuthHeader()
+			}
+{{- end}}
+{{- range .Auth.EnvVarSpecs}}
+{{- if eq .Kind "per_call"}}
+{{- if .Required}}
+			if os.Getenv("{{.Name}}") != "" {
+				authEnvSet = append(authEnvSet, "{{.Name}}")
+			} else {
+				authEnvRequiredMissing = append(authEnvRequiredMissing, "{{.Name}}")
+			}
+{{- else}}
+			if strings.Contains({{printf "%q" .Description}}, " OR ") {
+				authEnvOptionalNames = append(authEnvOptionalNames, "{{.Name}}")
+				if os.Getenv("{{.Name}}") != "" {
+					authEnvSet = append(authEnvSet, "{{.Name}}")
+					authEnvOptionalSatisfied = true
+				}
+			} else if os.Getenv("{{.Name}}") != "" {
+				authEnvSet = append(authEnvSet, "{{.Name}}")
+			} else {
+				authEnvInfo = append(authEnvInfo, "{{.Name}} optional")
+			}
+{{- end}}
+{{- else if eq .Kind "auth_flow_input"}}
+			if os.Getenv("{{.Name}}") != "" {
+				authEnvSet = append(authEnvSet, "{{.Name}}")
+			} else {
+				authEnvInfo = append(authEnvInfo, "{{.Name}} set during auth login")
+			}
+{{- else if eq .Kind "harvested"}}
+			if os.Getenv("{{.Name}}") != "" || authHeader != "" {
+				authEnvSet = append(authEnvSet, "{{.Name}}")
+			} else {
+				authEnvInfo = append(authEnvInfo, {{printf "%q" (printf "%s %s" .Name (authHarvestedEnvHint $.Auth))}})
+			}
+{{- end}}
+{{- end}}
+			switch {
+			case len(authEnvRequiredMissing) > 0:
+				report["env_vars"] = "ERROR missing required: " + strings.Join(authEnvRequiredMissing, ", ")
+			case len(authEnvOptionalNames) > 1 && !authEnvOptionalSatisfied:
+				report["env_vars"] = "INFO set one of: " + strings.Join(authEnvOptionalNames, " or ")
+			case len(authEnvInfo) > 0:
+				report["env_vars"] = "INFO " + strings.Join(authEnvInfo, "; ")
+			default:
+				report["env_vars"] = fmt.Sprintf("OK %d/%d available", len(authEnvSet), {{len .Auth.EnvVarSpecs}})
+			}
+{{- else if .Auth.EnvVars}}
 			authEnvChecked := 0
 			authEnvSet := 0
 {{- range .Auth.EnvVars}}
@@ -225,7 +286,19 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 {{- if .HasTierRouting}}
 			tierEnvStatus := map[string]string{}
 {{- range $tierName, $tier := .TierRouting.Tiers}}
-{{- if $tier.Auth.EnvVars}}
+{{- if $tier.Auth.EnvVarSpecs}}
+			{
+				checked := 0
+				set := 0
+{{- range $tier.Auth.EnvVarSpecs}}
+				checked++
+				if os.Getenv("{{.Name}}") != "" {
+					set++
+				}
+{{- end}}
+				tierEnvStatus["{{$tierName}}"] = fmt.Sprintf("%d/%d set", set, checked)
+			}
+{{- else if $tier.Auth.EnvVars}}
 			{
 				checked := 0
 				set := 0
@@ -382,6 +455,7 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 			checkKeys := []struct{ key, label string }{
 				{"config", "Config"},
 				{"auth", "Auth"},
+				{"env_vars", "Env Vars"},
 {{- if .Auth.RequiresBrowserSession}}
 				{"browser_session_proof", "Browser Session Proof"},
 {{- end}}
@@ -396,6 +470,10 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 				s := fmt.Sprintf("%v", v)
 				indicator := green("OK")
 				switch {
+				case strings.HasPrefix(s, "INFO"):
+					indicator = yellow("INFO")
+				case strings.HasPrefix(s, "ERROR"):
+					indicator = red("FAIL")
 				case strings.HasPrefix(s, "optional"):
 					// Optional-auth CLI with no key set — informational, not a failure.
 					indicator = yellow("INFO")
@@ -422,7 +500,7 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 				}
 			}
 			// Print auth setup hints (indented under Auth line)
-{{- if .Auth.EnvVars}}
+{{- if $canonicalEnvVar}}
 			if hint, ok := report["auth_hint"]; ok {
 				fmt.Fprintf(w, "  hint: %v\n", hint)
 			}
diff --git a/internal/generator/templates/helpers.go.tmpl b/internal/generator/templates/helpers.go.tmpl
index 3464f62c..95f1f20b 100644
--- a/internal/generator/templates/helpers.go.tmpl
+++ b/internal/generator/templates/helpers.go.tmpl
@@ -1,5 +1,6 @@
 // Copyright {{currentYear}} {{.Owner}}. Licensed under Apache-2.0. See LICENSE.
 // Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+{{- $canonicalEnvVar := .Auth.CanonicalEnvVar}}
 
 package cli
 
@@ -302,8 +303,10 @@ func classifyAPIError(err error, flags *rootFlags) error {
 {{- if and .Auth.Type (ne .Auth.Type "none")}}
 	case strings.Contains(msg, "HTTP 400") && cliutil.LooksLikeAuthError(msg):
 		return authErr(fmt.Errorf("%w\nhint: the API rejected the request — this usually means auth is missing or invalid."+
-{{- if .Auth.EnvVars}}
-			"\n      Set your API key: export {{index .Auth.EnvVars 0}}=<your-key>"+
+{{- with $canonicalEnvVar}}
+{{- if eq .Kind "per_call"}}
+			"\n      Set your API key: export {{.Name}}=<your-key>"+
+{{- end}}
 {{- end}}
 {{- if .Auth.KeyURL}}
 			"\n      Get a key at: {{.Auth.KeyURL}}"+
@@ -314,8 +317,10 @@ func classifyAPIError(err error, flags *rootFlags) error {
 	case strings.Contains(msg, "HTTP 401"):
 {{- if eq .Auth.Type "oauth2"}}
 		return authErr(fmt.Errorf("%w\nhint: your token may have expired. Re-run '{{.Name}}-pp-cli auth login' to re-authenticate"+
-{{- if .Auth.EnvVars}}
-			"\n      or: export {{index .Auth.EnvVars 0}}=<your-token>"+
+{{- with $canonicalEnvVar}}
+{{- if eq .Kind "per_call"}}
+			"\n      or: export {{.Name}}=<your-token>"+
+{{- end}}
 {{- end}}
 {{- if .Auth.KeyURL}}
 			"\n      Get a key at: {{.Auth.KeyURL}}"+
@@ -323,8 +328,10 @@ func classifyAPIError(err error, flags *rootFlags) error {
 			"\n      Run '{{.Name}}-pp-cli doctor' to check auth status.", err))
 {{- else if eq .Auth.Type "bearer_token"}}
 		return authErr(fmt.Errorf("%w\nhint: check your token. Set it with: {{.Name}}-pp-cli auth set-token <token>"+
-{{- if .Auth.EnvVars}}
-			"\n      or: export {{index .Auth.EnvVars 0}}=<your-token>"+
+{{- with $canonicalEnvVar}}
+{{- if eq .Kind "per_call"}}
+			"\n      or: export {{.Name}}=<your-token>"+
+{{- end}}
 {{- end}}
 {{- if .Auth.KeyURL}}
 			"\n      Get a key at: {{.Auth.KeyURL}}"+
@@ -332,8 +339,10 @@ func classifyAPIError(err error, flags *rootFlags) error {
 			"\n      Run '{{.Name}}-pp-cli doctor' to check auth status.", err))
 {{- else if eq .Auth.Type "api_key"}}
 		return authErr(fmt.Errorf("%w\nhint: check your API key."+
-{{- if .Auth.EnvVars}}
-			" Set it with: export {{index .Auth.EnvVars 0}}=<your-key>"+
+{{- with $canonicalEnvVar}}
+{{- if eq .Kind "per_call"}}
+			" Set it with: export {{.Name}}=<your-key>"+
+{{- end}}
 {{- end}}
 {{- if .Auth.KeyURL}}
 			"\n      Get a key at: {{.Auth.KeyURL}}"+
@@ -341,8 +350,10 @@ func classifyAPIError(err error, flags *rootFlags) error {
 			"\n      Run '{{.Name}}-pp-cli doctor' to check auth status.", err))
 {{- else}}
 		return authErr(fmt.Errorf("%w\nhint: check your API credentials."+
-{{- if .Auth.EnvVars}}
-			" Set it with: export {{index .Auth.EnvVars 0}}=<your-key>"+
+{{- with $canonicalEnvVar}}
+{{- if eq .Kind "per_call"}}
+			" Set it with: export {{.Name}}=<your-key>"+
+{{- end}}
 {{- end}}
 {{- if .Auth.KeyURL}}
 			"\n      Get a key at: {{.Auth.KeyURL}}"+
@@ -352,8 +363,10 @@ func classifyAPIError(err error, flags *rootFlags) error {
 	case strings.Contains(msg, "HTTP 403"):
 {{- if eq .Auth.Type "oauth2"}}
 		return authErr(fmt.Errorf("%w\nhint: permission denied. Your token may lack required scopes. Re-run '{{.Name}}-pp-cli auth login' to re-authorize"+
-{{- if .Auth.EnvVars}}
-			"\n      or: export {{index .Auth.EnvVars 0}}=<your-token>"+
+{{- with $canonicalEnvVar}}
+{{- if eq .Kind "per_call"}}
+			"\n      or: export {{.Name}}=<your-token>"+
+{{- end}}
 {{- end}}
 {{- if .Auth.KeyURL}}
 			"\n      Get a key at: {{.Auth.KeyURL}}"+
@@ -365,8 +378,10 @@ func classifyAPIError(err error, flags *rootFlags) error {
 {{- else}}
 		return authErr(fmt.Errorf("%w\nhint: permission denied. Your credentials are valid but lack access to this resource."+
 			"\n      Check that your API key has the required permissions."+
-{{- if .Auth.EnvVars}}
-			"\n      Set it with: export {{index .Auth.EnvVars 0}}=<your-key>"+
+{{- with $canonicalEnvVar}}
+{{- if eq .Kind "per_call"}}
+			"\n      Set it with: export {{.Name}}=<your-key>"+
+{{- end}}
 {{- end}}
 {{- if .Auth.KeyURL}}
 			"\n      Get a key at: {{.Auth.KeyURL}}"+
diff --git a/internal/generator/templates/mcp_tools.go.tmpl b/internal/generator/templates/mcp_tools.go.tmpl
index 62c3d975..d6712317 100644
--- a/internal/generator/templates/mcp_tools.go.tmpl
+++ b/internal/generator/templates/mcp_tools.go.tmpl
@@ -1,5 +1,6 @@
 // Copyright {{currentYear}} {{.Owner}}. Licensed under Apache-2.0. See LICENSE.
 // Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+{{- $canonicalEnvVar := .Auth.CanonicalEnvVar}}
 
 package mcp
 
@@ -228,8 +229,8 @@ func makeAPIHandler(method, pathTemplate string, positionalParams []string) serv
 			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." +
-{{- if .Auth.EnvVars}}
-					{{printf "%q" (printf "\n      Set your API key: export %s=<your-key>" (index .Auth.EnvVars 0))}} +
+{{- with $canonicalEnvVar}}
+					{{printf "%q" (printf "\n      Set your API key: export %s=<your-key>" .Name)}} +
 {{- end}}
 {{- if .Auth.KeyURL}}
 					{{printf "%q" (printf "\n      Get a key at: %s" .Auth.KeyURL)}} +
@@ -247,8 +248,8 @@ func makeAPIHandler(method, pathTemplate string, positionalParams []string) serv
 {{- else}}
 					"\nhint: check your API credentials." +
 {{- end}}
-{{- if .Auth.EnvVars}}
-					{{printf "%q" (printf "\n      Set it with: export %s=<your-key>" (index .Auth.EnvVars 0))}} +
+{{- with $canonicalEnvVar}}
+					{{printf "%q" (printf "\n      Set it with: export %s=<your-key>" .Name)}} +
 {{- end}}
 {{- if .Auth.KeyURL}}
 					{{printf "%q" (printf "\n      Get a key at: %s" .Auth.KeyURL)}} +
@@ -263,8 +264,8 @@ func makeAPIHandler(method, pathTemplate string, positionalParams []string) serv
 {{- else}}
 					"\nhint: your credentials are valid but lack access to this resource." +
 {{- end}}
-{{- if .Auth.EnvVars}}
-					{{printf "%q" (printf "\n      Set it with: export %s=<your-key>" (index .Auth.EnvVars 0))}} +
+{{- with $canonicalEnvVar}}
+					{{printf "%q" (printf "\n      Set it with: export %s=<your-key>" .Name)}} +
 {{- end}}
 {{- if .Auth.KeyURL}}
 					{{printf "%q" (printf "\n      Get a key at: %s" .Auth.KeyURL)}} +
@@ -420,8 +421,30 @@ func handleContext(_ context.Context, _ mcplib.CallToolRequest) (*mcplib.CallToo
 {{- if and .Auth.Type (ne .Auth.Type "none")}}
 		"auth": map[string]any{
 			"type": {{printf "%q" .Auth.Type}},
-{{- if .Auth.EnvVars}}
-			"env_vars": []string{ {{- range .Auth.EnvVars}}{{printf "%q" .}}, {{end}} },
+{{- if .Auth.EnvVarSpecs}}
+			"env_vars": []map[string]any{
+{{- range .Auth.EnvVarSpecs}}
+{{- if ne .Kind "harvested"}}
+				{
+					"name": {{printf "%q" .Name}},
+					"kind": {{printf "%q" .Kind}},
+					"required": {{.Required}},
+					"sensitive": {{.Sensitive}},
+{{- if .Sensitive}}
+					"description": {{printf "%q" .Kind.SensitivePlaceholder}},
+{{- else if .Description}}
+					"description": {{printf "%q" .Description}},
+{{- end}}
+				},
+{{- end}}
+{{- end}}
+			},
+{{- else if .Auth.EnvVars}}
+			"env_vars": []map[string]any{
+{{- range .Auth.EnvVars}}
+				{"name": {{printf "%q" .}}, "kind": "per_call", "required": true, "sensitive": true},
+{{- end}}
+			},
 {{- end}}
 {{- if .Auth.KeyURL}}
 			"key_url": {{printf "%q" .Auth.KeyURL}},
@@ -438,8 +461,30 @@ func handleContext(_ context.Context, _ mcplib.CallToolRequest) (*mcplib.CallToo
 {{- if $tier.BaseURL}}
 					"base_url": {{printf "%q" $tier.BaseURL}},
 {{- end}}
-{{- if $tier.Auth.EnvVars}}
-					"env_vars": []string{ {{- range $tier.Auth.EnvVars}}{{printf "%q" .}}, {{end}} },
+{{- if $tier.Auth.EnvVarSpecs}}
+					"env_vars": []map[string]any{
+{{- range $tier.Auth.EnvVarSpecs}}
+{{- if ne .Kind "harvested"}}
+						{
+							"name": {{printf "%q" .Name}},
+							"kind": {{printf "%q" .Kind}},
+							"required": {{.Required}},
+							"sensitive": {{.Sensitive}},
+{{- if .Sensitive}}
+							"description": {{printf "%q" .Kind.SensitivePlaceholder}},
+{{- else if .Description}}
+							"description": {{printf "%q" .Description}},
+{{- end}}
+						},
+{{- end}}
+{{- end}}
+					},
+{{- else if $tier.Auth.EnvVars}}
+					"env_vars": []map[string]any{
+{{- range $tier.Auth.EnvVars}}
+						{"name": {{printf "%q" .}}, "kind": "per_call", "required": true, "sensitive": true},
+{{- end}}
+					},
 {{- end}}
 				},
 {{- end}}
diff --git a/internal/generator/templates/readme.md.tmpl b/internal/generator/templates/readme.md.tmpl
index 9184b500..40440dcc 100644
--- a/internal/generator/templates/readme.md.tmpl
+++ b/internal/generator/templates/readme.md.tmpl
@@ -1,4 +1,5 @@
 # {{.ProseName}} CLI
+{{- $canonicalEnvVar := .Auth.CanonicalEnvVar}}
 {{- if and .Narrative .Narrative.Headline}}
 
 **{{.Narrative.Headline}}**
@@ -69,13 +70,13 @@ export {{envName $.Name}}_{{upper .}}="{{if eq . "shop"}}<your-store>.myshopify.
 {{- end}}
 
 Get your API key from your API provider's developer portal. The key typically looks like a long alphanumeric string.
-{{- if .Auth.EnvVars}}
+{{- with $canonicalEnvVar}}
 
 ```bash
-export {{index .Auth.EnvVars 0}}="<paste-your-key>"
+export {{.Name}}="<paste-your-key>"
 ```
 
-You can also persist this in your config file at `{{.Config.Path}}`.
+You can also persist this in your config file at `{{$.Config.Path}}`.
 {{- end}}
 {{- else if eq .Auth.Type "oauth2"}}
 
@@ -106,12 +107,12 @@ If commands start returning auth errors after working for a while, refresh the p
 {{.Name}}-pp-cli refresh-bearer
 ```
 {{- end}}
-{{- if .Auth.EnvVars}}
+{{- with $canonicalEnvVar}}
 
 Or set it via environment variable:
 
 ```bash
-export {{index .Auth.EnvVars 0}}="your-token-here"
+export {{.Name}}="your-token-here"
 ```
 {{- end}}
 {{- else if or (eq .Auth.Type "cookie") (eq .Auth.Type "composed")}}
@@ -313,10 +314,10 @@ go install github.com/mvanhorn/printing-press-library/library/{{if .Category}}{{
 ```
 
 Then register it:
-{{- if and (eq .Auth.Type "api_key") .Auth.EnvVars}}
+{{- if and (eq .Auth.Type "api_key") $canonicalEnvVar}}
 
 ```bash
-claude mcp add {{.Name}} {{.Name}}-pp-mcp{{range .EndpointTemplateVars}} -e {{envName $.Name}}_{{upper .}}={{if eq . "shop"}}<your-store>.myshopify.com{{else if eq . "api_version"}}{{$.Version}}{{else}}<{{.}}>{{end}}{{end}} -e {{index .Auth.EnvVars 0}}=<your-key>
+claude mcp add {{.Name}} {{.Name}}-pp-mcp{{range .EndpointTemplateVars}} -e {{envName $.Name}}_{{upper .}}={{if eq . "shop"}}<your-store>.myshopify.com{{else if eq . "api_version"}}{{$.Version}}{{else}}<{{.}}>{{end}}{{end}} -e {{$canonicalEnvVar.Name}}=<your-key>
 ```
 {{- else if or (eq .Auth.Type "cookie") (eq .Auth.Type "composed")}}
 
@@ -334,10 +335,10 @@ claude mcp add {{.Name}} {{.Name}}-pp-mcp
 
 claude mcp add {{.Name}} {{.Name}}-pp-mcp
 ```
-{{- else if and (eq .Auth.Type "bearer_token") .Auth.EnvVars}}
+{{- else if and (eq .Auth.Type "bearer_token") $canonicalEnvVar}}
 
 ```bash
-claude mcp add {{.Name}} {{.Name}}-pp-mcp -e {{index .Auth.EnvVars 0}}=<your-token>
+claude mcp add {{.Name}} {{.Name}}-pp-mcp -e {{$canonicalEnvVar.Name}}=<your-token>
 ```
 {{- else if eq .Auth.Type "bearer_token"}}
 
@@ -373,7 +374,7 @@ The bundle reuses your local OAuth tokens — authenticate first if you haven't:
 ```bash
 {{.Name}}-pp-cli auth login
 ```
-{{- else if and (eq .Auth.Type "bearer_token") (not .Auth.EnvVars)}}
+{{- else if and (eq .Auth.Type "bearer_token") (not $canonicalEnvVar)}}
 
 Store your token first if you haven't:
 
@@ -386,8 +387,8 @@ To install:
 
 1. Download the `.mcpb` for your platform from the [latest release](https://github.com/mvanhorn/printing-press-library/releases/tag/{{.Name}}-current).
 2. Double-click the `.mcpb` file. Claude Desktop opens and walks you through the install.
-{{- if and .Auth.EnvVars (ne .Auth.Type "none") (ne .Auth.Type "cookie") (ne .Auth.Type "composed")}}
-3. Fill in `{{index .Auth.EnvVars 0}}` when Claude Desktop prompts you.
+{{- if and $canonicalEnvVar (ne .Auth.Type "none") (ne .Auth.Type "cookie") (ne .Auth.Type "composed")}}
+3. Fill in `{{$canonicalEnvVar.Name}}` when Claude Desktop prompts you.
 {{- end}}
 
 Requires Claude Desktop 1.0.0 or later. Pre-built bundles ship for macOS Apple Silicon (`darwin-arm64`) and Windows (`amd64`, `arm64`); for other platforms, use the manual config below.
@@ -407,12 +408,12 @@ Add to your Claude Desktop config (`~/Library/Application Support/Claude/claude_
 {
   "mcpServers": {
     "{{.Name}}": {
-      "command": "{{.Name}}-pp-mcp"{{- if and .Auth.EnvVars (ne .Auth.Type "none") (ne .Auth.Type "cookie") (ne .Auth.Type "composed")}},
+      "command": "{{.Name}}-pp-mcp"{{- if and $canonicalEnvVar (ne .Auth.Type "none") (ne .Auth.Type "cookie") (ne .Auth.Type "composed")}},
       "env": {
 {{- range .EndpointTemplateVars}}
         "{{envName $.Name}}_{{upper .}}": "{{if eq . "shop"}}<your-store>.myshopify.com{{else if eq . "api_version"}}{{$.Version}}{{else}}<{{.}}>{{end}}",
 {{- end}}
-        "{{index .Auth.EnvVars 0}}": "<your-key>"
+        "{{$canonicalEnvVar.Name}}": "<your-key>"
       }{{- end}}
     }
   }
@@ -432,14 +433,23 @@ Verifies configuration{{if and (ne .Auth.Type "") (ne .Auth.Type "none")}}, cred
 ## Configuration
 
 Config file: `{{.Config.Path}}`
-{{- if .Auth.EnvVars}}
+{{- if or .Auth.EnvVarSpecs .Auth.EnvVars}}
 
 Environment variables:
+
+| Name | Kind | Required | Description |
+| --- | --- | --- | --- |
 {{- range .EndpointTemplateVars}}
-- `{{envName $.Name}}_{{upper .}}`
+| `{{envName $.Name}}_{{upper .}}` | endpoint | Yes |  |
+{{- end}}
+{{- if .Auth.EnvVarSpecs}}
+{{- range .Auth.EnvVarSpecs}}
+| `{{.Name}}` | {{.Kind}} | {{if .Required}}Yes{{else}}No{{end}} | {{.MarkdownDescription}} |
 {{- end}}
+{{- else}}
 {{- range .Auth.EnvVars}}
-- `{{.}}`
+| `{{.}}` | per_call | Yes |  |
+{{- end}}
 {{- end}}
 {{- end}}
 
@@ -451,8 +461,8 @@ Environment variables:
 {{- if .BearerRefresh.Enabled}}
 - Run `{{.Name}}-pp-cli refresh-bearer` if a long-working public bearer token may have rotated
 {{- end}}
-{{- if .Auth.EnvVars}}
-- Verify the environment variable is set: `echo ${{index .Auth.EnvVars 0}}`
+{{- with $canonicalEnvVar}}
+- Verify the environment variable is set: `echo ${{.Name}}`
 {{- end}}
 
 {{- end}}
diff --git a/internal/generator/templates/skill.md.tmpl b/internal/generator/templates/skill.md.tmpl
index 0066e2dc..d7905eff 100644
--- a/internal/generator/templates/skill.md.tmpl
+++ b/internal/generator/templates/skill.md.tmpl
@@ -6,8 +6,42 @@ allowed-tools: "Read Bash"
 metadata:
   openclaw:
     requires:
+{{- if and (ne .Auth.Type "none") (or .Auth.EnvVarSpecs .Auth.EnvVars)}}
+{{- $wroteRequiredEnv := false}}
+      env: [{{- if .Auth.EnvVarSpecs}}{{range .Auth.EnvVarSpecs}}{{if and (eq .Kind "per_call") .Required}}{{if $wroteRequiredEnv}}, {{end}}{{printf "%q" .Name}}{{- $wroteRequiredEnv = true}}{{end}}{{end}}{{else}}{{range .Auth.EnvVars}}{{if $wroteRequiredEnv}}, {{end}}{{printf "%q" .}}{{- $wroteRequiredEnv = true}}{{end}}{{end}}]
+{{- end}}
       bins:
         - {{.Name}}-pp-cli
+{{- if and (ne .Auth.Type "none") (or .Auth.EnvVarSpecs .Auth.EnvVars)}}
+    envVars:
+{{- if .Auth.EnvVarSpecs}}
+{{- range .Auth.EnvVarSpecs}}
+{{- if ne .Kind "harvested"}}
+      - name: {{.Name}}
+        required: {{.Required}}
+{{- if and (eq .Kind "per_call") .Required}}
+        description: "{{if .Description}}{{yamlDoubleQuoted .Description}}{{else}}{{.Name}} credential.{{end}}"
+{{- else if and (eq .Kind "per_call") (not .Required)}}
+        description: "{{yamlDoubleQuoted (printf "Set this OR another listed env var. %s" .Description)}}"
+{{- else if and (eq .Kind "auth_flow_input") (not .Sensitive)}}
+        description: "{{yamlDoubleQuoted (printf "Only needed during `auth login`; not required for normal use. %s" .Description)}}"
+{{- else if and (eq .Kind "auth_flow_input") .Sensitive}}
+        description: "Set during application setup."
+{{- else if eq .Kind "harvested"}}
+        description: "Populated automatically by `auth login`."
+{{- else}}
+        description: "{{if .Description}}{{yamlDoubleQuoted .Description}}{{else}}{{.Name}} credential.{{end}}"
+{{- end}}
+{{- end}}
+{{- end}}
+{{- else}}
+{{- range .Auth.EnvVars}}
+      - name: {{.}}
+        required: true
+        description: "{{.}} credential."
+{{- end}}
+{{- end}}
+{{- end}}
     install:
       - kind: go
         bins: [{{.Name}}-pp-cli]
@@ -164,20 +198,21 @@ When you know what you want to do but not which command does it, ask the CLI dir
 {{- end}}
 
 ## Auth Setup
+{{- $canonicalEnvVar := .Auth.CanonicalEnvVar}}
 {{- if and .HasAuth .Narrative .Narrative.AuthNarrative}}
 
 {{.Narrative.AuthNarrative}}
 {{- else if eq .Auth.Type "api_key"}}
 
+{{- with $canonicalEnvVar}}
 Set your API key via environment variable:
-{{- if .Auth.EnvVars}}
 
 ```bash
-export {{index .Auth.EnvVars 0}}="<your-key>"
+export {{.Name}}="<your-key>"
 ```
-{{- end}}
 
-Or persist it in `{{.Config.Path}}`.
+Or persist it in `{{$.Config.Path}}`.
+{{- end}}
 {{- else if eq .Auth.Type "oauth2"}}
 
 Authenticate via the browser:
@@ -203,9 +238,9 @@ If authenticated commands start returning token errors after previously working,
 {{.Name}}-pp-cli refresh-bearer
 ```
 {{- end}}
-{{- if .Auth.EnvVars}}
+{{- with $canonicalEnvVar}}
 
-Or set `{{index .Auth.EnvVars 0}}` as an environment variable.
+Or set `{{.Name}}` as an environment variable.
 {{- end}}
 {{- else if or (eq .Auth.Type "cookie") (eq .Auth.Type "composed")}}
 
diff --git a/internal/generator/templates_test.go b/internal/generator/templates_test.go
new file mode 100644
index 00000000..fbce37b3
--- /dev/null
+++ b/internal/generator/templates_test.go
@@ -0,0 +1,160 @@
+package generator
+
+import (
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"github.com/mvanhorn/cli-printing-press/v3/internal/spec"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestDoctorTemplateRendersKindAwareAuthEnvPresence(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("doctor-rich-auth")
+	apiSpec.Auth = spec.AuthConfig{
+		Type: "api_key",
+		EnvVarSpecs: []spec.AuthEnvVar{
+			{Name: "RICH_AUTH_TOKEN", Kind: spec.AuthEnvVarKindPerCall, Required: true, Sensitive: true},
+			{Name: "RICH_AUTH_CLIENT_ID", Kind: spec.AuthEnvVarKindAuthFlowInput, Required: false, Sensitive: false},
+			{Name: "RICH_AUTH_CLIENT_SECRET", Kind: spec.AuthEnvVarKindAuthFlowInput, Required: false, Sensitive: true},
+			{Name: "RICH_AUTH_COOKIES", Kind: spec.AuthEnvVarKindHarvested, Required: false, Sensitive: true},
+			{Name: "RICH_AUTH_OPTIONAL_TOKEN", Kind: spec.AuthEnvVarKindPerCall, Required: false, Sensitive: true, Description: "Optional elevated-read token."},
+			{Name: "RICH_AUTH_ALT_TOKEN", Kind: spec.AuthEnvVarKindPerCall, Required: false, Sensitive: true, Description: "Set this OR RICH_AUTH_ALT_KEY."},
+			{Name: "RICH_AUTH_ALT_KEY", Kind: spec.AuthEnvVarKindPerCall, Required: false, Sensitive: true, Description: "Set this OR RICH_AUTH_ALT_TOKEN."},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), "doctor-rich-auth-pp-cli")
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	doctorSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "doctor.go"))
+	require.NoError(t, err)
+	content := string(doctorSrc)
+
+	require.Contains(t, content, `report["env_vars"] = "ERROR missing required: " + strings.Join(authEnvRequiredMissing, ", ")`)
+	require.Contains(t, content, `authEnvInfo = append(authEnvInfo, "RICH_AUTH_CLIENT_ID set during auth login")`)
+	require.Contains(t, content, `authEnvInfo = append(authEnvInfo, "RICH_AUTH_COOKIES set with auth set-token")`)
+	require.NotContains(t, content, `RICH_AUTH_COOKIES populated automatically by auth login --chrome`)
+	require.Contains(t, content, `report["env_vars"] = "INFO set one of: " + strings.Join(authEnvOptionalNames, " or ")`)
+}
+
+func TestMCPContextOmitsHarvestedAuthEnvVars(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("mcp-rich-auth")
+	apiSpec.Auth = spec.AuthConfig{
+		Type: "api_key",
+		EnvVarSpecs: []spec.AuthEnvVar{
+			{Name: "MCP_AUTH_TOKEN", Kind: spec.AuthEnvVarKindPerCall, Required: true, Sensitive: true},
+			{Name: "MCP_AUTH_CLIENT_ID", Kind: spec.AuthEnvVarKindAuthFlowInput, Required: false, Sensitive: false},
+			{Name: "MCP_AUTH_COOKIES", Kind: spec.AuthEnvVarKindHarvested, Required: false, Sensitive: true},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), "mcp-rich-auth-pp-cli")
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	mcpSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "mcp", "tools.go"))
+	require.NoError(t, err)
+	content := string(mcpSrc)
+
+	require.Contains(t, content, `"name": "MCP_AUTH_TOKEN"`)
+	require.Contains(t, content, `"name": "MCP_AUTH_CLIENT_ID"`)
+	require.NotContains(t, content, "MCP_AUTH_COOKIES")
+}
+
+func TestAgentContextOmitsHarvestedAuthEnvVars(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("agent-context-rich-auth")
+	apiSpec.Auth = spec.AuthConfig{
+		Type: "api_key",
+		EnvVarSpecs: []spec.AuthEnvVar{
+			{Name: "AGENT_CONTEXT_TOKEN", Kind: spec.AuthEnvVarKindPerCall, Required: true, Sensitive: true},
+			{Name: "AGENT_CONTEXT_CLIENT_ID", Kind: spec.AuthEnvVarKindAuthFlowInput, Required: false, Sensitive: false},
+			{Name: "AGENT_CONTEXT_COOKIES", Kind: spec.AuthEnvVarKindHarvested, Required: false, Sensitive: true},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), "agent-context-rich-auth-pp-cli")
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	agentContextSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "agent_context.go"))
+	require.NoError(t, err)
+	content := string(agentContextSrc)
+
+	require.Contains(t, content, `Name:      "AGENT_CONTEXT_TOKEN"`)
+	require.Contains(t, content, `Name:      "AGENT_CONTEXT_CLIENT_ID"`)
+	require.NotContains(t, content, "AGENT_CONTEXT_COOKIES")
+}
+
+func TestConfigTemplateRendersAuthHeaderORCaseFanOut(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("slack-auth")
+	apiSpec.Auth = spec.AuthConfig{
+		Type: "bearer_token",
+		EnvVarSpecs: []spec.AuthEnvVar{
+			{Name: "SLACK_BOT_TOKEN", Kind: spec.AuthEnvVarKindPerCall, Required: false, Sensitive: true, Description: "Set this OR SLACK_USER_TOKEN."},
+			{Name: "SLACK_USER_TOKEN", Kind: spec.AuthEnvVarKindPerCall, Required: false, Sensitive: true, Description: "Set this OR SLACK_BOT_TOKEN."},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), "slack-auth-pp-cli")
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	configSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "config", "config.go"))
+	require.NoError(t, err)
+	content := string(configSrc)
+
+	require.True(t,
+		strings.Contains(content, "if c.SlackBotToken != \"\"") &&
+			strings.Contains(content, "return \"Bearer \" + c.SlackBotToken"),
+		"generated AuthHeader should read SLACK_BOT_TOKEN:\n%s", content)
+	require.True(t,
+		strings.Contains(content, "if c.SlackUserToken != \"\"") &&
+			strings.Contains(content, "return \"Bearer \" + c.SlackUserToken"),
+		"generated AuthHeader should fall back to SLACK_USER_TOKEN:\n%s", content)
+}
+
+func TestAuthHeaderBearerORCaseFallsThroughToAccessToken(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("slack-auth-token")
+	apiSpec.Auth = spec.AuthConfig{
+		Type: "bearer_token",
+		EnvVarSpecs: []spec.AuthEnvVar{
+			{Name: "SLACK_BOT_TOKEN", Kind: spec.AuthEnvVarKindPerCall, Required: false, Sensitive: true, Description: "Set this OR SLACK_USER_TOKEN."},
+			{Name: "SLACK_USER_TOKEN", Kind: spec.AuthEnvVarKindPerCall, Required: false, Sensitive: true, Description: "Set this OR SLACK_BOT_TOKEN."},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), "slack-auth-token-pp-cli")
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	tidy := exec.Command("go", "mod", "tidy")
+	tidy.Dir = outputDir
+	out, err := tidy.CombinedOutput()
+	require.NoError(t, err, string(out))
+
+	cmd := exec.Command("go", "test", "./...")
+	cmd.Dir = outputDir
+	out, err = cmd.CombinedOutput()
+	require.NoError(t, err, string(out))
+
+	configSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "config", "config.go"))
+	require.NoError(t, err)
+	content := string(configSrc)
+
+	fanOutIdx := strings.Index(content, `if c.SlackUserToken != ""`)
+	accessTokenIdx := strings.Index(content, `if c.AccessToken != ""`)
+	require.NotEqual(t, -1, fanOutIdx, "generated AuthHeader should include OR-case fan-out:\n%s", content)
+	require.NotEqual(t, -1, accessTokenIdx, "generated AuthHeader should include AccessToken fallback:\n%s", content)
+	assert.Less(t, fanOutIdx, accessTokenIdx, "AccessToken fallback should remain reachable after OR fan-out")
+	require.NotContains(t, content[fanOutIdx:accessTokenIdx], `return ""`)
+}
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index ae2a3ff5..cb59b978 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -28,6 +28,7 @@ var (
 
 const (
 	extensionAuthEnvVars      = "x-auth-env-vars"
+	extensionAuthVars         = "x-auth-vars"
 	extensionAuthOptional     = "x-auth-optional"
 	extensionAuthKeyURL       = "x-auth-key-url"
 	extensionAuthTitle        = "x-auth-title"
@@ -483,8 +484,9 @@ func mapAuth(doc *openapi3.T, name string) spec.AuthConfig {
 			auth.EnvVars = []string{envPrefix + "_" + strings.ToUpper(schemeEnvSuffix)}
 		}
 	}
-
 	applyAuthOverrideExtensions(&auth, scheme.Extensions)
+	applyAuthEnvVarDefaults(&auth, envPrefix)
+	applyAuthVarsRichOverride(&auth, scheme.Extensions, fmt.Sprintf("components.securitySchemes.%s.%s", schemeName, extensionAuthVars))
 	return auth
 }
 
@@ -538,6 +540,146 @@ func applyAuthOverrideExtensions(auth *spec.AuthConfig, extensions map[string]an
 	}
 }
 
+func applyAuthEnvVarDefaults(auth *spec.AuthConfig, envPrefix string) {
+	if auth == nil {
+		return
+	}
+	// OAuth2 client_credentials default produces 2 entries (CLIENT_ID, CLIENT_SECRET).
+	// Skip the override when the spec already supplied an explicit list (>=2 entries via
+	// x-auth-env-vars); fall through to the per-name derivation below in that case.
+	if auth.OAuth2Grant == spec.OAuth2GrantClientCredentials && len(auth.EnvVars) <= 1 {
+		auth.EnvVarSpecs = []spec.AuthEnvVar{
+			{
+				Name:      envPrefix + "_CLIENT_ID",
+				Kind:      spec.AuthEnvVarKindAuthFlowInput,
+				Required:  true,
+				Sensitive: false,
+				Inferred:  true,
+			},
+			{
+				Name:      envPrefix + "_CLIENT_SECRET",
+				Kind:      spec.AuthEnvVarKindAuthFlowInput,
+				Required:  true,
+				Sensitive: true,
+				Inferred:  true,
+			},
+		}
+		auth.EnvVars = []string{auth.EnvVarSpecs[0].Name, auth.EnvVarSpecs[1].Name}
+		return
+	}
+	if len(auth.EnvVars) == 0 {
+		return
+	}
+	auth.EnvVarSpecs = make([]spec.AuthEnvVar, 0, len(auth.EnvVars))
+	for _, name := range auth.EnvVars {
+		if name = strings.TrimSpace(name); name == "" {
+			continue
+		}
+		envVar := spec.AuthEnvVar{
+			Name:      name,
+			Kind:      spec.AuthEnvVarKindPerCall,
+			Required:  true,
+			Sensitive: true,
+			Inferred:  true,
+		}
+		if auth.Type == "cookie" || strings.EqualFold(auth.In, "cookie") {
+			envVar.Kind = spec.AuthEnvVarKindHarvested
+		}
+		auth.EnvVarSpecs = append(auth.EnvVarSpecs, envVar)
+	}
+}
+
+func applyAuthVarsRichOverride(auth *spec.AuthConfig, extensions map[string]any, path string) {
+	if auth == nil || len(extensions) == 0 {
+		return
+	}
+	raw, ok := extensions[extensionAuthVars]
+	if !ok {
+		return
+	}
+	envVars, err := authVarsExtension(raw)
+	if err != nil {
+		warnf("%s is malformed: %v; falling back to generated auth env vars", path, err)
+		return
+	}
+	if len(envVars) == 0 {
+		warnf("%s is malformed: expected at least one auth var; falling back to generated auth env vars", path)
+		return
+	}
+	auth.EnvVarSpecs = envVars
+	auth.EnvVars = make([]string, 0, len(envVars))
+	for _, envVar := range envVars {
+		auth.EnvVars = append(auth.EnvVars, envVar.Name)
+	}
+}
+
+func authVarsExtension(raw any) ([]spec.AuthEnvVar, error) {
+	items, ok := raw.([]any)
+	if !ok {
+		return nil, fmt.Errorf("expected a list of objects")
+	}
+	out := make([]spec.AuthEnvVar, 0, len(items))
+	for i, item := range items {
+		m, ok := item.(map[string]any)
+		if !ok {
+			return nil, fmt.Errorf("item %d must be an object", i)
+		}
+		name, ok := requiredStringField(m, "name")
+		if !ok {
+			return nil, fmt.Errorf("item %d missing string field %q", i, "name")
+		}
+		kindText, ok := requiredStringField(m, "kind")
+		if !ok {
+			return nil, fmt.Errorf("item %d missing string field %q", i, "kind")
+		}
+		kind := spec.AuthEnvVarKind(kindText)
+		switch kind {
+		case spec.AuthEnvVarKindPerCall, spec.AuthEnvVarKindAuthFlowInput, spec.AuthEnvVarKindHarvested:
+		default:
+			return nil, fmt.Errorf("item %d kind %q is not recognized", i, kindText)
+		}
+		required, ok := boolExtension(m, "required")
+		if !ok {
+			return nil, fmt.Errorf("item %d missing boolean field %q", i, "required")
+		}
+		sensitive, ok := boolExtension(m, "sensitive")
+		if !ok {
+			return nil, fmt.Errorf("item %d missing boolean field %q", i, "sensitive")
+		}
+		description := ""
+		if rawDescription, ok := m["description"]; ok {
+			if description, ok = rawDescription.(string); !ok {
+				return nil, fmt.Errorf("item %d field %q must be a string", i, "description")
+			}
+			description = strings.TrimSpace(description)
+		}
+		out = append(out, spec.AuthEnvVar{
+			Name:        name,
+			Kind:        kind,
+			Required:    required,
+			Sensitive:   sensitive,
+			Description: description,
+		})
+	}
+	return out, nil
+}
+
+func requiredStringField(m map[string]any, name string) (string, bool) {
+	raw, ok := m[name]
+	if !ok {
+		return "", false
+	}
+	s, ok := raw.(string)
+	if !ok {
+		return "", false
+	}
+	s = strings.TrimSpace(s)
+	if s == "" {
+		return "", false
+	}
+	return s, true
+}
+
 func applyAuthEnvVars(auth *spec.AuthConfig, envVars []string) {
 	oldEnvVars := append([]string(nil), auth.EnvVars...)
 	auth.EnvVars = envVars
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index 6fa45ce3..f0d6eb09 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -1269,6 +1269,334 @@ paths:
 	assert.Equal(t, "Optional FlightAware AeroAPI credential for enriched flight data.", parsed.Auth.Description)
 }
 
+func TestOpenAPIAuthEnvVarsPopulateRichDefaults(t *testing.T) {
+	t.Parallel()
+
+	yamlSpec := []byte(`openapi: "3.0.3"
+info:
+  title: Todoist
+  version: "1.0.0"
+servers:
+  - url: https://api.todoist.com
+components:
+  securitySchemes:
+    ApiKeyAuth:
+      type: apiKey
+      in: header
+      name: Authorization
+      x-auth-env-vars:
+        - TODOIST_API_KEY
+paths:
+  /tasks:
+    get:
+      responses:
+        "200":
+          description: OK
+`)
+	parsed, err := Parse(yamlSpec)
+	require.NoError(t, err)
+
+	assert.Equal(t, []string{"TODOIST_API_KEY"}, parsed.Auth.EnvVars)
+	require.Len(t, parsed.Auth.EnvVarSpecs, 1)
+	assert.Equal(t, spec.AuthEnvVar{
+		Name:      "TODOIST_API_KEY",
+		Kind:      spec.AuthEnvVarKindPerCall,
+		Required:  true,
+		Sensitive: true,
+		Inferred:  true,
+	}, parsed.Auth.EnvVarSpecs[0])
+}
+
+func TestOpenAPIAuthVarsRichOverride(t *testing.T) {
+	t.Parallel()
+
+	yamlSpec := []byte(`openapi: "3.0.3"
+info:
+  title: Todoist
+  version: "1.0.0"
+servers:
+  - url: https://api.todoist.com
+components:
+  securitySchemes:
+    ApiKeyAuth:
+      type: apiKey
+      in: header
+      name: Authorization
+      x-auth-vars:
+        - name: TODOIST_API_KEY
+          kind: per_call
+          required: true
+          sensitive: true
+          description: Todoist API key.
+paths:
+  /tasks:
+    get:
+      responses:
+        "200":
+          description: OK
+`)
+	parsed, err := Parse(yamlSpec)
+	require.NoError(t, err)
+
+	assert.Equal(t, []string{"TODOIST_API_KEY"}, parsed.Auth.EnvVars)
+	require.Len(t, parsed.Auth.EnvVarSpecs, 1)
+	assert.Equal(t, spec.AuthEnvVar{
+		Name:        "TODOIST_API_KEY",
+		Kind:        spec.AuthEnvVarKindPerCall,
+		Required:    true,
+		Sensitive:   true,
+		Description: "Todoist API key.",
+	}, parsed.Auth.EnvVarSpecs[0])
+}
+
+func TestOpenAPIAuthVarsPreservesExplicitSensitiveFalse(t *testing.T) {
+	t.Parallel()
+
+	yamlSpec := []byte(`openapi: "3.0.3"
+info:
+  title: Public Slug API
+  version: "1.0.0"
+servers:
+  - url: https://api.example.com
+components:
+  securitySchemes:
+    AccountSlug:
+      type: apiKey
+      in: header
+      name: X-Account-Slug
+      x-auth-vars:
+        - name: PUBLIC_ACCOUNT_SLUG
+          kind: per_call
+          required: true
+          sensitive: false
+paths:
+  /items:
+    get:
+      responses:
+        "200":
+          description: OK
+`)
+	parsed, err := Parse(yamlSpec)
+	require.NoError(t, err)
+
+	require.Len(t, parsed.Auth.EnvVarSpecs, 1)
+	assert.False(t, parsed.Auth.EnvVarSpecs[0].Sensitive)
+}
+
+func TestOpenAPIAuthVarsMalformedFallsBackToDefaults(t *testing.T) {
+	yamlSpec := []byte(`openapi: "3.0.3"
+info:
+  title: Todoist
+  version: "1.0.0"
+servers:
+  - url: https://api.todoist.com
+components:
+  securitySchemes:
+    ApiKeyAuth:
+      type: apiKey
+      in: header
+      name: Authorization
+      x-auth-vars:
+        - name: TODOIST_API_KEY
+          kind: per_call
+paths:
+  /tasks:
+    get:
+      responses:
+        "200":
+          description: OK
+`)
+	var parsed *spec.APISpec
+	var err error
+	warnings := captureWarnings(t, func() {
+		parsed, err = Parse(yamlSpec)
+	})
+	require.NoError(t, err)
+
+	assert.Contains(t, warnings, "components.securitySchemes.ApiKeyAuth.x-auth-vars is malformed")
+	assert.Equal(t, []string{"TODOIST_API_KEY"}, parsed.Auth.EnvVars)
+	require.Len(t, parsed.Auth.EnvVarSpecs, 1)
+	assert.Equal(t, spec.AuthEnvVarKindPerCall, parsed.Auth.EnvVarSpecs[0].Kind)
+	assert.True(t, parsed.Auth.EnvVarSpecs[0].Required)
+	assert.True(t, parsed.Auth.EnvVarSpecs[0].Sensitive)
+}
+
+func TestOpenAPIAuthClassifiesCookieAndOAuth2ClientCredentialsEnvVars(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name string
+		body string
+		want []spec.AuthEnvVar
+	}{
+		{
+			name: "cookie auth is harvested",
+			body: `openapi: "3.0.3"
+info:
+  title: Cookie API
+  version: "1.0.0"
+servers:
+  - url: https://api.example.com
+components:
+  securitySchemes:
+    cookieAuth:
+      type: apiKey
+      in: cookie
+      name: sessionid
+paths:
+  /me:
+    get:
+      responses:
+        "200":
+          description: OK
+`,
+			want: []spec.AuthEnvVar{{
+				Name:      "COOKIE_COOKIE_AUTH",
+				Kind:      spec.AuthEnvVarKindHarvested,
+				Required:  true,
+				Sensitive: true,
+				Inferred:  true,
+			}},
+		},
+		{
+			name: "oauth2 client credentials are auth flow inputs",
+			body: `openapi: "3.0.3"
+info:
+  title: Auth0Mgmt
+  version: "1.0"
+servers:
+  - url: https://example.auth0.com
+components:
+  securitySchemes:
+    OAuth2:
+      type: oauth2
+      flows:
+        clientCredentials:
+          tokenUrl: https://example.auth0.com/oauth/token
+          scopes:
+            read:users: Read user profiles
+paths:
+  /api/v2/users:
+    get:
+      security:
+        - OAuth2: []
+      responses:
+        "200":
+          description: OK
+`,
+			want: []spec.AuthEnvVar{
+				{Name: "AUTH0MGMT_CLIENT_ID", Kind: spec.AuthEnvVarKindAuthFlowInput, Required: true, Sensitive: false, Inferred: true},
+				{Name: "AUTH0MGMT_CLIENT_SECRET", Kind: spec.AuthEnvVarKindAuthFlowInput, Required: true, Sensitive: true, Inferred: true},
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			parsed, err := Parse([]byte(tt.body))
+			require.NoError(t, err)
+			assert.Equal(t, tt.want, parsed.Auth.EnvVarSpecs)
+			if parsed.Auth.OAuth2Grant == spec.OAuth2GrantClientCredentials {
+				require.Len(t, parsed.Auth.EnvVars, len(parsed.Auth.EnvVarSpecs))
+				for i, envVar := range parsed.Auth.EnvVarSpecs {
+					assert.Equal(t, envVar.Name, parsed.Auth.EnvVars[i])
+				}
+			}
+		})
+	}
+}
+
+func TestOpenAPINoSecurityHasNoAuthEnvVars(t *testing.T) {
+	t.Parallel()
+
+	yamlSpec := []byte(`openapi: "3.0.3"
+info:
+  title: Public API
+  version: "1.0.0"
+servers:
+  - url: https://api.example.com
+paths:
+  /items:
+    get:
+      responses:
+        "200":
+          description: OK
+`)
+	parsed, err := Parse(yamlSpec)
+	require.NoError(t, err)
+
+	assert.Equal(t, "none", parsed.Auth.Type)
+	assert.Empty(t, parsed.Auth.EnvVars)
+	assert.Empty(t, parsed.Auth.EnvVarSpecs)
+}
+
+func TestOpenAPIAuthVarsCanConsolidateLegacyMultipleEnvVars(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name      string
+		extension string
+		want      []spec.AuthEnvVar
+	}{
+		{
+			name: "legacy override preserves multiple generated entries",
+			extension: `      x-auth-env-vars:
+        - TRIGGER_SECRET_KEY
+        - TRIGGER_DEV_API_KEY`,
+			want: []spec.AuthEnvVar{
+				{Name: "TRIGGER_SECRET_KEY", Kind: spec.AuthEnvVarKindPerCall, Required: true, Sensitive: true, Inferred: true},
+				{Name: "TRIGGER_DEV_API_KEY", Kind: spec.AuthEnvVarKindPerCall, Required: true, Sensitive: true, Inferred: true},
+			},
+		},
+		{
+			name: "rich override consolidates to declared credential",
+			extension: `      x-auth-env-vars:
+        - TRIGGER_SECRET_KEY
+        - TRIGGER_DEV_API_KEY
+      x-auth-vars:
+        - name: TRIGGER_SECRET_KEY
+          kind: per_call
+          required: true
+          sensitive: true`,
+			want: []spec.AuthEnvVar{
+				{Name: "TRIGGER_SECRET_KEY", Kind: spec.AuthEnvVarKindPerCall, Required: true, Sensitive: true},
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			yamlSpec := fmt.Appendf(nil, `openapi: "3.0.3"
+info:
+  title: Trigger Dev
+  version: "1.0.0"
+servers:
+  - url: https://api.trigger.dev
+components:
+  securitySchemes:
+    ApiKeyAuth:
+      type: apiKey
+      in: header
+      name: Authorization
+%s
+paths:
+  /runs:
+    get:
+      responses:
+        "200":
+          description: OK
+`, tt.extension)
+			parsed, err := Parse(yamlSpec)
+			require.NoError(t, err)
+			assert.Equal(t, tt.want, parsed.Auth.EnvVarSpecs)
+		})
+	}
+}
+
 func TestInferOperationLevelBearer(t *testing.T) {
 	t.Parallel()
 
diff --git a/internal/pipeline/climanifest.go b/internal/pipeline/climanifest.go
index dc20f28a..6f0e00ba 100644
--- a/internal/pipeline/climanifest.go
+++ b/internal/pipeline/climanifest.go
@@ -49,23 +49,24 @@ type CLIManifest struct {
 	// Owner is the attribution recorded in generated copyright headers
 	// (for example "hiten-shah"). Persisted here so subsequent regens
 	// preserve attribution regardless of who's running the generator.
-	Owner                string   `json:"owner,omitempty"`
-	SpecURL              string   `json:"spec_url,omitempty"`
-	SpecPath             string   `json:"spec_path,omitempty"`
-	SpecFormat           string   `json:"spec_format,omitempty"`
-	SpecChecksum         string   `json:"spec_checksum,omitempty"`
-	RunID                string   `json:"run_id,omitempty"`
-	CatalogEntry         string   `json:"catalog_entry,omitempty"`
-	Category             string   `json:"category,omitempty"`
-	Description          string   `json:"description,omitempty"`
-	MCPBinary            string   `json:"mcp_binary,omitempty"`
-	MCPToolCount         int      `json:"mcp_tool_count,omitempty"`
-	MCPPublicToolCount   int      `json:"mcp_public_tool_count,omitempty"`
-	MCPReady             string   `json:"mcp_ready,omitempty"`
-	APIVersion           string   `json:"api_version,omitempty"` // from the spec's info.version — provenance only, not the CLI version
-	AuthType             string   `json:"auth_type,omitempty"`
-	AuthEnvVars          []string `json:"auth_env_vars,omitempty"`
-	EndpointTemplateVars []string `json:"endpoint_template_vars,omitempty"`
+	Owner                string            `json:"owner,omitempty"`
+	SpecURL              string            `json:"spec_url,omitempty"`
+	SpecPath             string            `json:"spec_path,omitempty"`
+	SpecFormat           string            `json:"spec_format,omitempty"`
+	SpecChecksum         string            `json:"spec_checksum,omitempty"`
+	RunID                string            `json:"run_id,omitempty"`
+	CatalogEntry         string            `json:"catalog_entry,omitempty"`
+	Category             string            `json:"category,omitempty"`
+	Description          string            `json:"description,omitempty"`
+	MCPBinary            string            `json:"mcp_binary,omitempty"`
+	MCPToolCount         int               `json:"mcp_tool_count,omitempty"`
+	MCPPublicToolCount   int               `json:"mcp_public_tool_count,omitempty"`
+	MCPReady             string            `json:"mcp_ready,omitempty"`
+	APIVersion           string            `json:"api_version,omitempty"` // from the spec's info.version — provenance only, not the CLI version
+	AuthType             string            `json:"auth_type,omitempty"`
+	AuthEnvVars          []string          `json:"auth_env_vars,omitempty"`
+	AuthEnvVarSpecs      []spec.AuthEnvVar `json:"auth_env_var_specs,omitempty"`
+	EndpointTemplateVars []string          `json:"endpoint_template_vars,omitempty"`
 	// AuthKeyURL is the page where users register for an API key. Used by
 	// downstream emitters (MCPB manifest user_config descriptions, doctor
 	// hints) to point users at the right credential source.
@@ -268,7 +269,11 @@ func populateMCPMetadata(m *CLIManifest, parsed *spec.APISpec) {
 	m.MCPPublicToolCount = public
 	m.MCPReady = computeMCPReady(parsed.Auth.Type)
 	m.AuthType = parsed.Auth.Type
-	m.AuthEnvVars = manifestAuthEnvVars(parsed)
+	envVarSpecs := manifestAuthEnvVarSpecs(parsed)
+	m.AuthEnvVars = manifestAuthEnvVarNames(parsed, envVarSpecs)
+	if !spec.AllAuthEnvVarSpecsInferred(envVarSpecs) {
+		m.AuthEnvVarSpecs = envVarSpecs
+	}
 	m.EndpointTemplateVars = parsed.EndpointTemplateVars
 	m.AuthKeyURL = parsed.Auth.KeyURL
 	m.AuthTitle = parsed.Auth.Title
@@ -290,10 +295,13 @@ func populateMCPMetadata(m *CLIManifest, parsed *spec.APISpec) {
 	}
 }
 
-func manifestAuthEnvVars(parsed *spec.APISpec) []string {
+func manifestAuthEnvVarNames(parsed *spec.APISpec, envVarSpecs []spec.AuthEnvVar) []string {
 	if parsed == nil {
 		return nil
 	}
+	if len(envVarSpecs) > 0 {
+		return authEnvVarSpecNames(envVarSpecs)
+	}
 	seen := make(map[string]struct{})
 	var names []string
 	add := func(envVars []string) {
@@ -320,6 +328,43 @@ func manifestAuthEnvVars(parsed *spec.APISpec) []string {
 	return names
 }
 
+func manifestAuthEnvVarSpecs(parsed *spec.APISpec) []spec.AuthEnvVar {
+	if parsed == nil {
+		return nil
+	}
+	seen := make(map[string]int)
+	var specs []spec.AuthEnvVar
+	add := func(envVarSpecs []spec.AuthEnvVar) {
+		for _, envVar := range envVarSpecs {
+			if envVar.Name == "" {
+				continue
+			}
+			if idx, ok := seen[envVar.Name]; ok {
+				specs[idx] = envVar
+				continue
+			}
+			seen[envVar.Name] = len(specs)
+			specs = append(specs, envVar)
+		}
+	}
+
+	parsed.Auth.NormalizeEnvVarSpecs("")
+	add(parsed.Auth.EnvVarSpecs)
+
+	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 {
+		tier := parsed.TierRouting.Tiers[name]
+		tier.Auth.NormalizeEnvVarSpecs("")
+		parsed.TierRouting.Tiers[name] = tier
+		add(tier.Auth.EnvVarSpecs)
+	}
+	return specs
+}
+
 // 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 60f09fa3..c3e3eb20 100644
--- a/internal/pipeline/climanifest_test.go
+++ b/internal/pipeline/climanifest_test.go
@@ -776,6 +776,45 @@ func TestWriteMCPBManifest(t *testing.T) {
 		assert.Contains(t, key.Description, "https://fdc.nal.usda.gov/api-key-signup")
 	})
 
+	t.Run("rich auth user_config includes only per-call entries", func(t *testing.T) {
+		dir := t.TempDir()
+		writeManifest(t, dir, CLIManifest{
+			APIName:     "rich-auth",
+			DisplayName: "Rich Auth",
+			MCPBinary:   "rich-auth-pp-mcp",
+			MCPReady:    "full",
+			AuthType:    "api_key",
+			AuthEnvVars: []string{"RICH_API_KEY", "RICH_CLIENT_SECRET", "RICH_SESSION"},
+			AuthEnvVarSpecs: []spec.AuthEnvVar{
+				{Name: "RICH_API_KEY", Kind: spec.AuthEnvVarKindPerCall, Required: true, Sensitive: true, Description: "Per-call API key."},
+				{Name: "RICH_OPTIONAL", Kind: spec.AuthEnvVarKindPerCall, Required: false, Sensitive: false, Description: "Optional public selector."},
+				{Name: "RICH_CLIENT_SECRET", Kind: spec.AuthEnvVarKindAuthFlowInput, Required: false, Sensitive: true, Description: "Sensitive setup secret."},
+				{Name: "RICH_SESSION", Kind: spec.AuthEnvVarKindHarvested, Required: false, Sensitive: true, Description: "Harvested browser session."},
+			},
+		})
+
+		require.NoError(t, WriteMCPBManifest(dir))
+		got := readMCPBManifest(t, dir)
+
+		assert.Equal(t, "${user_config.rich_api_key}", got.Server.MCPConfig.Env["RICH_API_KEY"])
+		assert.Equal(t, "${user_config.rich_optional}", got.Server.MCPConfig.Env["RICH_OPTIONAL"])
+		assert.NotContains(t, got.Server.MCPConfig.Env, "RICH_CLIENT_SECRET")
+		assert.NotContains(t, got.Server.MCPConfig.Env, "RICH_SESSION")
+
+		required, ok := got.UserConfig["rich_api_key"]
+		require.True(t, ok)
+		assert.True(t, required.Required)
+		assert.Equal(t, "Per-call API key.", required.Description)
+
+		optional, ok := got.UserConfig["rich_optional"]
+		require.True(t, ok)
+		assert.False(t, optional.Required)
+		assert.False(t, optional.Sensitive)
+		assert.Equal(t, "Optional. Optional public selector.", optional.Description)
+		assert.NotContains(t, got.UserConfig, "rich_client_secret")
+		assert.NotContains(t, got.UserConfig, "rich_session")
+	})
+
 	t.Run("auth metadata overrides user_config title and description", func(t *testing.T) {
 		dir := t.TempDir()
 		writeManifest(t, dir, CLIManifest{
@@ -1127,6 +1166,7 @@ func TestPopulateMCPMetadata(t *testing.T) {
 	assert.Equal(t, "partial", m.MCPReady)
 	assert.Equal(t, "cookie", m.AuthType)
 	assert.Equal(t, []string{"TEST_AUTH"}, m.AuthEnvVars)
+	assert.Empty(t, m.AuthEnvVarSpecs)
 	assert.Equal(t, "https://auth.example.com", m.AuthKeyURL)
 	assert.True(t, m.AuthOptional)
 	assert.Equal(t, "Test Auth", m.AuthTitle)
@@ -1164,6 +1204,45 @@ func TestPopulateMCPMetadataIncludesTierEnvVars(t *testing.T) {
 	assert.Equal(t, []string{"GLOBAL_TOKEN", "PAID_KEY"}, m.AuthEnvVars)
 }
 
+func TestPopulateMCPMetadataMergesTierEnvVarSpecsWithTierOverride(t *testing.T) {
+	var m CLIManifest
+	populateMCPMetadata(&m, &spec.APISpec{
+		Name: "tiered-rich",
+		Auth: spec.AuthConfig{
+			Type: "bearer_token",
+			EnvVarSpecs: []spec.AuthEnvVar{
+				{Name: "SHARED_TOKEN", Kind: spec.AuthEnvVarKindPerCall, Required: true, Sensitive: true, Description: "Global shared token."},
+				{Name: "GLOBAL_ONLY", Kind: spec.AuthEnvVarKindPerCall, Required: true, Sensitive: true},
+			},
+		},
+		TierRouting: spec.TierRoutingConfig{
+			Tiers: map[string]spec.TierConfig{
+				"enterprise": {
+					Auth: spec.AuthConfig{
+						Type:    "api_key",
+						EnvVars: []string{"LEGACY_TIER_ONLY"},
+					},
+				},
+				"paid": {
+					Auth: spec.AuthConfig{
+						Type: "api_key",
+						EnvVarSpecs: []spec.AuthEnvVar{
+							{Name: "SHARED_TOKEN", Kind: spec.AuthEnvVarKindPerCall, Required: false, Sensitive: true, Description: "Tier shared token."},
+						},
+					},
+				},
+			},
+		},
+	})
+
+	assert.Equal(t, []string{"SHARED_TOKEN", "GLOBAL_ONLY", "LEGACY_TIER_ONLY"}, m.AuthEnvVars)
+	assert.Equal(t, []spec.AuthEnvVar{
+		{Name: "SHARED_TOKEN", Kind: spec.AuthEnvVarKindPerCall, Required: false, Sensitive: true, Description: "Tier shared token."},
+		{Name: "GLOBAL_ONLY", Kind: spec.AuthEnvVarKindPerCall, Required: true, Sensitive: true},
+		{Name: "LEGACY_TIER_ONLY", Kind: spec.AuthEnvVarKindPerCall, Required: true, Sensitive: true, Inferred: true},
+	}, m.AuthEnvVarSpecs)
+}
+
 // TestPopulateMCPMetadataDisplayNamePrecedence pins:
 //
 //	spec.DisplayName (explicit) > existing m.DisplayName (catalog) > EffectiveDisplayName fallback
diff --git a/internal/pipeline/mcpb_manifest.go b/internal/pipeline/mcpb_manifest.go
index 94ebcd6e..43599578 100644
--- a/internal/pipeline/mcpb_manifest.go
+++ b/internal/pipeline/mcpb_manifest.go
@@ -9,6 +9,7 @@ import (
 	"strings"
 
 	"github.com/mvanhorn/cli-printing-press/v3/internal/naming"
+	"github.com/mvanhorn/cli-printing-press/v3/internal/spec"
 	"github.com/mvanhorn/cli-printing-press/v3/internal/version"
 )
 
@@ -279,12 +280,13 @@ func loadExistingMCPBManifest(dir string) *existingMCPBManifest {
 // the value at runtime from what the user typed (or whatever the keychain
 // has cached). Empty list returns nil so the manifest stays compact.
 func buildMCPBEnv(m CLIManifest) map[string]string {
-	if len(m.AuthEnvVars) == 0 && len(m.EndpointTemplateVars) == 0 {
+	authEnvVarSpecs := mcpbUserConfigAuthEnvVars(m)
+	if len(authEnvVarSpecs) == 0 && len(m.EndpointTemplateVars) == 0 {
 		return nil
 	}
-	env := make(map[string]string, len(m.AuthEnvVars)+len(m.EndpointTemplateVars))
-	for _, name := range m.AuthEnvVars {
-		env[name] = "${user_config." + userConfigKey(name) + "}"
+	env := make(map[string]string, len(authEnvVarSpecs)+len(m.EndpointTemplateVars))
+	for _, envVar := range authEnvVarSpecs {
+		env[envVar.Name] = "${user_config." + userConfigKey(envVar.Name) + "}"
 	}
 	for _, templateVar := range m.EndpointTemplateVars {
 		name := endpointTemplateEnvVar(m.APIName, templateVar)
@@ -301,23 +303,20 @@ func buildMCPBEnv(m CLIManifest) map[string]string {
 // required. Endpoint template vars are always required because unresolved
 // placeholders make every request URL invalid.
 func buildMCPBUserConfig(m CLIManifest) map[string]MCPBVar {
-	if len(m.AuthEnvVars) == 0 && len(m.EndpointTemplateVars) == 0 {
+	authEnvVarSpecs := mcpbUserConfigAuthEnvVars(m)
+	if len(authEnvVarSpecs) == 0 && len(m.EndpointTemplateVars) == 0 {
 		return nil
 	}
-	// AuthOptional overrides the auth-type heuristic. api_key would otherwise
-	// be Required: true, but recipe-goat's USDA_FDC_API_KEY only powers an
-	// opt-in `--nutrition` flag — marking it required hides the fact that
-	// every other tool works without the key. The spec author's `optional:
-	// true` is the explicit signal.
-	required := authRequiresCredential(m.AuthType) && !m.AuthOptional
-	vars := make(map[string]MCPBVar, len(m.AuthEnvVars)+len(m.EndpointTemplateVars))
-	for _, name := range m.AuthEnvVars {
-		title, description := authUserConfigText(m, name, required)
-		vars[userConfigKey(name)] = MCPBVar{
+	vars := make(map[string]MCPBVar, len(authEnvVarSpecs)+len(m.EndpointTemplateVars))
+	singleAuthEnvVar := len(authEnvVarSpecs) == 1
+	for _, envVar := range authEnvVarSpecs {
+		required := envVar.Required && !m.AuthOptional
+		title, description := authUserConfigText(m, envVar, required, singleAuthEnvVar)
+		vars[userConfigKey(envVar.Name)] = MCPBVar{
 			Type:        mcpbVarTypeString,
 			Title:       title,
 			Description: description,
-			Sensitive:   true,
+			Sensitive:   envVar.Sensitive,
 			Required:    required,
 		}
 	}
@@ -334,6 +333,36 @@ func buildMCPBUserConfig(m CLIManifest) map[string]MCPBVar {
 	return vars
 }
 
+func mcpbUserConfigAuthEnvVars(m CLIManifest) []spec.AuthEnvVar {
+	envVarSpecs := (ManifestAuth{
+		EnvVars:     m.AuthEnvVars,
+		EnvVarSpecs: m.AuthEnvVarSpecs,
+	}).EffectiveEnvVarSpecs()
+	if len(m.AuthEnvVarSpecs) == 0 && len(m.AuthEnvVars) > 0 {
+		required := authRequiresCredential(m.AuthType)
+		for i := range envVarSpecs {
+			envVarSpecs[i].Required = required
+		}
+	}
+	if len(envVarSpecs) == 0 {
+		return nil
+	}
+	filtered := make([]spec.AuthEnvVar, 0, len(envVarSpecs))
+	for _, envVar := range envVarSpecs {
+		if envVar.Name == "" {
+			continue
+		}
+		switch envVar.Kind {
+		case "", spec.AuthEnvVarKindPerCall:
+			envVar.Kind = spec.AuthEnvVarKindPerCall
+			filtered = append(filtered, envVar)
+		case spec.AuthEnvVarKindAuthFlowInput, spec.AuthEnvVarKindHarvested:
+			continue
+		}
+	}
+	return filtered
+}
+
 func endpointTemplateEnvVar(apiName, templateVar string) string {
 	return strings.ToUpper(naming.Snake(apiName) + "_" + naming.Snake(templateVar))
 }
@@ -348,9 +377,9 @@ func endpointTemplateVarDescription(templateVar, envVar string) string {
 	return fmt.Sprintf("Sets %s for the endpoint template variable {%s}.", envVar, templateVar)
 }
 
-func authUserConfigText(m CLIManifest, envVar string, required bool) (string, string) {
-	title := envVar
-	if len(m.AuthEnvVars) == 1 {
+func authUserConfigText(m CLIManifest, envVar spec.AuthEnvVar, required bool, singleAuthEnvVar bool) (string, string) {
+	title := envVar.Name
+	if singleAuthEnvVar {
 		if override := strings.TrimSpace(m.AuthTitle); override != "" {
 			title = override
 		}
@@ -358,7 +387,13 @@ func authUserConfigText(m CLIManifest, envVar string, required bool) (string, st
 			return title, description
 		}
 	}
-	return title, envVarDescription(m, envVar, required)
+	if description := strings.TrimSpace(envVar.Description); description != "" {
+		if !required {
+			return title, "Optional. " + description
+		}
+		return title, description
+	}
+	return title, envVarDescription(m, envVar.Name, required)
 }
 
 func endpointTemplateDefault(m CLIManifest, templateVar string) string {
diff --git a/internal/pipeline/publish.go b/internal/pipeline/publish.go
index 0c2c2168..d30c04ab 100644
--- a/internal/pipeline/publish.go
+++ b/internal/pipeline/publish.go
@@ -217,6 +217,7 @@ func writeCLIManifestForPublish(state *PipelineState, dir string) error {
 			m.MCPReady = existing.MCPReady
 			m.AuthType = existing.AuthType
 			m.AuthEnvVars = existing.AuthEnvVars
+			m.AuthEnvVarSpecs = existing.AuthEnvVarSpecs
 			m.NovelFeatures = existing.NovelFeatures
 		}
 	}
diff --git a/internal/pipeline/publish_test.go b/internal/pipeline/publish_test.go
index 2f926f9a..07e88e6c 100644
--- a/internal/pipeline/publish_test.go
+++ b/internal/pipeline/publish_test.go
@@ -6,6 +6,7 @@ import (
 	"path/filepath"
 	"testing"
 
+	"github.com/mvanhorn/cli-printing-press/v3/internal/spec"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
@@ -225,6 +226,28 @@ func TestWriteCLIManifestForPublish_NovelFeaturesPreservedFromCarryForward(t *te
 	assert.Equal(t, "today", m.NovelFeatures[0].Command)
 }
 
+func TestWriteCLIManifestForPublish_AuthEnvVarSpecsPreservedFromCarryForward(t *testing.T) {
+	_, state := publishManifestEnvSetup(t, "20260505-auth-env-specs-carry-forward")
+
+	existing := CLIManifest{
+		SchemaVersion:   1,
+		APIName:         "test-api",
+		CLIName:         "test-api-pp-cli",
+		AuthType:        "api_key",
+		AuthEnvVars:     []string{"TEST_API_TOKEN"},
+		AuthEnvVarSpecs: []spec.AuthEnvVar{{Name: "TEST_API_TOKEN", Kind: spec.AuthEnvVarKindPerCall, Required: true, Sensitive: true}},
+	}
+	existingData, err := json.Marshal(existing)
+	require.NoError(t, err)
+	require.NoError(t, os.WriteFile(filepath.Join(state.WorkingDir, CLIManifestFilename), existingData, 0o644))
+
+	require.NoError(t, writeCLIManifestForPublish(state, state.WorkingDir))
+
+	m := readPublishedManifest(t, state.WorkingDir)
+	assert.Equal(t, []string{"TEST_API_TOKEN"}, m.AuthEnvVars)
+	assert.Equal(t, []spec.AuthEnvVar{{Name: "TEST_API_TOKEN", Kind: spec.AuthEnvVarKindPerCall, Required: true, Sensitive: true}}, m.AuthEnvVarSpecs)
+}
+
 // TestWriteCLIManifestForPublish_NovelFeaturesResearchOverridesCarryForward
 // covers the precedence rule: when both research.json (post-dogfood) and the
 // existing manifest (generate-time) have novel_features, research wins because
diff --git a/internal/pipeline/runtime.go b/internal/pipeline/runtime.go
index ba5e6eeb..165606db 100644
--- a/internal/pipeline/runtime.go
+++ b/internal/pipeline/runtime.go
@@ -8,7 +8,6 @@ import (
 	"os"
 	"path/filepath"
 	"regexp"
-	"slices"
 	"strings"
 	"time"
 
@@ -29,23 +28,40 @@ type VerifyConfig struct {
 
 // VerifyReport is the output of a runtime verification run.
 type VerifyReport struct {
-	Mode                   string          `json:"mode"` // "live" or "mock"
-	Total                  int             `json:"total"`
-	Passed                 int             `json:"passed"`
-	Failed                 int             `json:"failed"`
-	Critical               int             `json:"critical"`
-	PassRate               float64         `json:"pass_rate"`
-	DataPipeline           bool            `json:"data_pipeline"`
-	DataPipelineDetail     string          `json:"data_pipeline_detail,omitempty"` // PASS, WARN, SKIP, FAIL with context
-	Freshness              FreshnessResult `json:"freshness"`
-	BrowserSessionRequired bool            `json:"browser_session_required,omitempty"`
-	BrowserSessionProof    string          `json:"browser_session_proof,omitempty"`
-	BrowserSessionDetail   string          `json:"browser_session_detail,omitempty"`
-	Verdict                string          `json:"verdict"` // PASS, WARN, FAIL
-	Results                []CommandResult `json:"results"`
-	Binary                 string          `json:"binary"`
+	Mode                   string             `json:"mode"` // "live" or "mock"
+	Total                  int                `json:"total"`
+	Passed                 int                `json:"passed"`
+	Failed                 int                `json:"failed"`
+	Critical               int                `json:"critical"`
+	PassRate               float64            `json:"pass_rate"`
+	DataPipeline           bool               `json:"data_pipeline"`
+	DataPipelineDetail     string             `json:"data_pipeline_detail,omitempty"` // PASS, WARN, SKIP, FAIL with context
+	Freshness              FreshnessResult    `json:"freshness"`
+	BrowserSessionRequired bool               `json:"browser_session_required,omitempty"`
+	BrowserSessionProof    string             `json:"browser_session_proof,omitempty"`
+	BrowserSessionDetail   string             `json:"browser_session_detail,omitempty"`
+	AuthEnvVars            []AuthEnvVarStatus `json:"auth_env_vars,omitempty"`
+	Verdict                string             `json:"verdict"` // PASS, WARN, FAIL
+	Results                []CommandResult    `json:"results"`
+	Binary                 string             `json:"binary"`
 }
 
+type AuthEnvVarStatus struct {
+	Name     string               `json:"name"`
+	Kind     string               `json:"kind"`
+	Required bool                 `json:"required"`
+	Status   AuthEnvVarStatusCode `json:"status"`
+	Detail   string               `json:"detail,omitempty"`
+}
+
+type AuthEnvVarStatusCode string
+
+const (
+	AuthEnvVarStatusOK              AuthEnvVarStatusCode = "ok"
+	AuthEnvVarStatusMissingRequired AuthEnvVarStatusCode = "missing_required"
+	AuthEnvVarStatusMissingInfo     AuthEnvVarStatusCode = "missing_info"
+)
+
 // CommandResult is the test result for a single command.
 type CommandResult struct {
 	Command string `json:"command"`
@@ -146,25 +162,55 @@ func RunVerify(cfg VerifyConfig) (*VerifyReport, error) {
 		classifyCommandKind(&commands[i], spec)
 	}
 
-	// Collect auth env var names. Priority:
-	// 1. Spec's declared env vars (from securitySchemes or auth inference)
-	// 2. Env vars actually read by the CLI's config.go (ground truth)
-	// 3. Derived patterns from the API name (fallback)
-	authEnvVars := []string{envVarName}
-	if spec != nil && len(spec.Auth.EnvVars) > 0 {
-		authEnvVars = spec.Auth.EnvVars
+	// Collect auth env var names from the normalized spec model, then augment
+	// it with env vars actually read by the generated CLI's config.go.
+	authEnvVarSpecs := []apispec.AuthEnvVar{{
+		Name:      envVarName,
+		Kind:      apispec.AuthEnvVarKindPerCall,
+		Required:  true,
+		Sensitive: true,
+		Inferred:  true,
+	}}
+	if spec != nil {
+		spec.Auth.NormalizeEnvVarSpecs("")
+		if len(spec.Auth.EnvVarSpecs) > 0 {
+			authEnvVarSpecs = append([]apispec.AuthEnvVar(nil), spec.Auth.EnvVarSpecs...)
+		}
+	}
+	authEnvVarNameSet := make(map[string]struct{}, len(authEnvVarSpecs))
+	for _, envVar := range authEnvVarSpecs {
+		authEnvVarNameSet[envVar.Name] = struct{}{}
 	}
 	// Read the CLI's config.go to discover what env vars it actually reads.
 	// This catches cases where Claude wired a different env var name than
 	// what the spec declares or the API name implies.
 	if discovered := discoverCLIEnvVars(cfg.Dir); len(discovered) > 0 {
 		for _, ev := range discovered {
-			found := slices.Contains(authEnvVars, ev)
-			if !found {
-				authEnvVars = append(authEnvVars, ev)
+			if _, found := authEnvVarNameSet[ev]; found {
+				continue
 			}
+			authEnvVarNameSet[ev] = struct{}{}
+			authEnvVarSpecs = append(authEnvVarSpecs, apispec.AuthEnvVar{
+				Name:      ev,
+				Kind:      apispec.AuthEnvVarKindPerCall,
+				Required:  true,
+				Sensitive: true,
+				Inferred:  true,
+			})
 		}
 	}
+	authEnvVars := authEnvVarSpecNames(authEnvVarSpecs)
+	authEnvVarStatuses := summarizeAuthEnvVars(authEnvVarSpecs, cfg.APIKey, cfg.EnvVar, report.Mode)
+	if shouldReportAuthEnvVarStatuses(authEnvVarStatuses) {
+		report.AuthEnvVars = authEnvVarStatuses
+	}
+	for _, missing := range missingRequiredAuthEnvVars(authEnvVarStatuses) {
+		report.Results = append(report.Results, CommandResult{
+			Command: "auth-env:" + missing.Name,
+			Kind:    "auth",
+			Error:   missing.Detail,
+		})
+	}
 
 	// EndpointTemplateVars env names live in their own bucket so the
 	// --api-key overwrite path doesn't rewrite SHOPIFY_SHOP into the
@@ -258,6 +304,91 @@ func RunVerify(cfg VerifyConfig) (*VerifyReport, error) {
 	return report, nil
 }
 
+func authEnvVarSpecNames(envVarSpecs []apispec.AuthEnvVar) []string {
+	names := make([]string, 0, len(envVarSpecs))
+	seen := make(map[string]struct{}, len(envVarSpecs))
+	for _, envVar := range envVarSpecs {
+		name := strings.TrimSpace(envVar.Name)
+		if name == "" {
+			continue
+		}
+		if _, ok := seen[name]; ok {
+			continue
+		}
+		seen[name] = struct{}{}
+		names = append(names, name)
+	}
+	return names
+}
+
+func summarizeAuthEnvVars(envVarSpecs []apispec.AuthEnvVar, apiKey, apiKeyEnvVar, mode string) []AuthEnvVarStatus {
+	statuses := make([]AuthEnvVarStatus, 0, len(envVarSpecs))
+	if apiKey != "" && apiKeyEnvVar == "" {
+		for _, envVar := range envVarSpecs {
+			name := strings.TrimSpace(envVar.Name)
+			if name == "" {
+				continue
+			}
+			apiKeyEnvVar = name
+			kind := envVar.Kind
+			if kind == "" {
+				kind = apispec.AuthEnvVarKindPerCall
+			}
+			if kind == apispec.AuthEnvVarKindPerCall && envVar.Required {
+				break
+			}
+		}
+	}
+	for _, envVar := range envVarSpecs {
+		name := strings.TrimSpace(envVar.Name)
+		if name == "" {
+			continue
+		}
+		kind := envVar.Kind
+		if kind == "" {
+			kind = apispec.AuthEnvVarKindPerCall
+		}
+		status := AuthEnvVarStatus{
+			Name:     name,
+			Kind:     string(kind),
+			Required: envVar.Required,
+			Status:   AuthEnvVarStatusOK,
+		}
+		if os.Getenv(name) != "" || (apiKey != "" && name == apiKeyEnvVar) || mode == "mock" {
+			statuses = append(statuses, status)
+			continue
+		}
+		if kind == apispec.AuthEnvVarKindPerCall && envVar.Required {
+			status.Status = AuthEnvVarStatusMissingRequired
+			status.Detail = "required per-call auth env var is not set"
+		} else {
+			status.Status = AuthEnvVarStatusMissingInfo
+			status.Detail = "auth env var is not set but does not block verification"
+		}
+		statuses = append(statuses, status)
+	}
+	return statuses
+}
+
+func shouldReportAuthEnvVarStatuses(statuses []AuthEnvVarStatus) bool {
+	for _, status := range statuses {
+		if status.Status != AuthEnvVarStatusOK {
+			return true
+		}
+	}
+	return false
+}
+
+func missingRequiredAuthEnvVars(statuses []AuthEnvVarStatus) []AuthEnvVarStatus {
+	missing := make([]AuthEnvVarStatus, 0, len(statuses))
+	for _, status := range statuses {
+		if status.Status == AuthEnvVarStatusMissingRequired {
+			missing = append(missing, status)
+		}
+	}
+	return missing
+}
+
 // runSideEffectSafeCommandTests is the mock-mode counterpart to
 // runCommandTests for commands the side-effect classifier flagged. It
 // runs --help (read-only) and --dry-run (the command should honor it),
diff --git a/internal/pipeline/runtime_test.go b/internal/pipeline/runtime_test.go
index 24ef75c0..48a73870 100644
--- a/internal/pipeline/runtime_test.go
+++ b/internal/pipeline/runtime_test.go
@@ -926,6 +926,59 @@ func initRoot() {
 	assert.FileExists(t, report.Binary)
 }
 
+func TestSummarizeAuthEnvVarsKindAware(t *testing.T) {
+	t.Setenv("REQUIRED_TOKEN", "")
+	t.Setenv("SETUP_SECRET", "")
+	t.Setenv("SESSION_COOKIE", "")
+
+	got := summarizeAuthEnvVars([]apispec.AuthEnvVar{
+		{Name: "REQUIRED_TOKEN", Kind: apispec.AuthEnvVarKindPerCall, Required: true},
+		{Name: "OPTIONAL_TOKEN", Kind: apispec.AuthEnvVarKindPerCall, Required: false},
+		{Name: "SETUP_SECRET", Kind: apispec.AuthEnvVarKindAuthFlowInput, Required: false},
+		{Name: "SESSION_COOKIE", Kind: apispec.AuthEnvVarKindHarvested, Required: false},
+	}, "", "", "live")
+
+	require.Len(t, got, 4)
+	assert.Equal(t, AuthEnvVarStatusMissingRequired, got[0].Status)
+	assert.Equal(t, []AuthEnvVarStatus{got[0]}, missingRequiredAuthEnvVars(got))
+	assert.Equal(t, AuthEnvVarStatusMissingInfo, got[1].Status)
+	assert.Equal(t, AuthEnvVarStatusMissingInfo, got[2].Status)
+	assert.Equal(t, AuthEnvVarStatusMissingInfo, got[3].Status)
+
+	mock := summarizeAuthEnvVars([]apispec.AuthEnvVar{
+		{Name: "REQUIRED_TOKEN", Kind: apispec.AuthEnvVarKindPerCall, Required: true},
+	}, "", "", "mock")
+	require.Len(t, mock, 1)
+	assert.Equal(t, AuthEnvVarStatusOK, mock[0].Status)
+}
+
+func TestSummarizeAuthEnvVarsAPIKeyOnlySatisfiesConfiguredEnvVar(t *testing.T) {
+	t.Setenv("PRIMARY_TOKEN", "")
+	t.Setenv("SECONDARY_TOKEN", "")
+
+	got := summarizeAuthEnvVars([]apispec.AuthEnvVar{
+		{Name: "PRIMARY_TOKEN", Kind: apispec.AuthEnvVarKindPerCall, Required: true},
+		{Name: "SECONDARY_TOKEN", Kind: apispec.AuthEnvVarKindPerCall, Required: true},
+	}, "secret", "PRIMARY_TOKEN", "live")
+
+	require.Len(t, got, 2)
+	assert.Equal(t, AuthEnvVarStatusOK, got[0].Status)
+	assert.Equal(t, AuthEnvVarStatusMissingRequired, got[1].Status)
+	assert.Equal(t, []AuthEnvVarStatus{got[1]}, missingRequiredAuthEnvVars(got))
+}
+
+func TestSummarizeAuthEnvVarsApiKeyWithoutCanonicalEnvVar(t *testing.T) {
+	t.Setenv("PRIMARY_TOKEN", "")
+
+	got := summarizeAuthEnvVars([]apispec.AuthEnvVar{
+		{Name: "PRIMARY_TOKEN", Kind: apispec.AuthEnvVarKindPerCall, Required: true},
+	}, "secret", "", "live")
+
+	require.Len(t, got, 1)
+	assert.Equal(t, AuthEnvVarStatusOK, got[0].Status)
+	assert.Empty(t, missingRequiredAuthEnvVars(got))
+}
+
 // TestDiscoverCLIEnvVars_SkipsTemplateVarReads guards the verifier integration
 // for endpoint template vars: the helper must NOT report env vars that feed
 // Config.TemplateVars (Shopify's SHOPIFY_SHOP / SHOPIFY_API_VERSION shape) as
diff --git a/internal/pipeline/scorecard.go b/internal/pipeline/scorecard.go
index 43d7dbfc..90ef439f 100644
--- a/internal/pipeline/scorecard.go
+++ b/internal/pipeline/scorecard.go
@@ -347,7 +347,7 @@ func scoreAuth(dir string) int {
 	}
 
 	score := 0
-	// Presence: at least one env var
+	// Auth pattern: generated config reads credentials with os.Getenv.
 	if strings.Count(configContent, "os.Getenv") >= 1 {
 		score += 2
 	}
@@ -357,11 +357,11 @@ func scoreAuth(dir string) int {
 	if strings.Contains(configContent, "0o600") || strings.Contains(configContent, "0600") || strings.Contains(configContent, "0o700") || strings.Contains(configContent, "0700") {
 		score += 2
 	}
-	// Quality: token masking in output (showing partial token)
+	// Auth pattern: client code masks credentials before printing.
 	if strings.Contains(clientContent, "mask") || strings.Contains(clientContent, "***") || strings.Contains(clientContent, "last 4") || (strings.Contains(clientContent, "Authorization") && strings.Contains(clientContent, "[:")) {
 		score += 2
 	}
-	// Quality: multiple auth methods (env var + config + flag)
+	// Auth pattern: config supports multiple credential sources.
 	authSources := 0
 	if strings.Contains(configContent, "os.Getenv") {
 		authSources++
@@ -1731,14 +1731,17 @@ func evaluateAuthProtocol(dir string, spec *openAPISpecInfo) dimensionScore {
 		}
 		// Inferred auth — score based on what the CLI actually has
 		score := 1 // annotated as inferred (user knows to verify)
+		// AuthProtocol pattern: inferred auth must still read an env var.
 		if strings.Contains(configContent, "os.Getenv(") {
 			score += 4 // env var support present
 		}
+		// AuthProtocol pattern: standard/custom header auth in generated client.
 		if strings.Contains(clientContent, "Authorization") || strings.Contains(clientContent, "X-Api-Key") || strings.Contains(clientContent, "X-Auth-Token") || strings.Contains(clientContent, "X-Access-Token") {
 			score += 3 // client sends auth header (standard or custom)
 		}
 		// Query-param auth (e.g., TMDb ?api_key=, Google Maps ?key=):
 		// the client adds the API key to the URL query string instead of a header.
+		// AuthProtocol pattern: query-param auth writes known API-key parameter names.
 		if strings.Contains(clientContent, `q.Set("api_key"`) ||
 			strings.Contains(clientContent, `q.Set("key"`) ||
 			strings.Contains(clientContent, `q.Set("apikey"`) ||
@@ -1938,15 +1941,18 @@ func scoreAuthScheme(clientContent, configContent, authContent string, scheme op
 		return 0, false
 	}
 
+	// AuthProtocol pattern: generated clients use Header.Set/Add with the expected header name.
 	if headerAssignmentPresent(clientContent, headerName) {
 		headerNameMatched = true
 	}
 
+	// AuthProtocol pattern: query schemes touch URL query plumbing.
 	if scheme.In == "query" && (strings.Contains(clientContent, ".Query()") || strings.Contains(clientContent, "url.Values") || strings.Contains(clientContent, "RawQuery")) {
 		queryMatched = true
 	}
 
 	envNeedle := sanitizeEnvName(scheme.Key)
+	// AuthProtocol pattern: config.go contains the sanitized scheme key/env name.
 	if envNeedle != "" && strings.Contains(strings.ToUpper(configContent), envNeedle) {
 		envMatched = true
 	}
@@ -2085,6 +2091,7 @@ func headerAssignmentPresent(clientContent, headerName string) bool {
 }
 
 func authPrefixLiteralPresent(prefix string, contents ...string) bool {
+	// AuthProtocol pattern: literal auth prefixes are emitted as quoted strings.
 	doubleQuoted := `"` + prefix + ` "`
 	rawQuoted := "`" + prefix + " `"
 	for _, content := range contents {
diff --git a/internal/pipeline/scorecard_tier2_test.go b/internal/pipeline/scorecard_tier2_test.go
index 6b03b6a0..0f0e99eb 100644
--- a/internal/pipeline/scorecard_tier2_test.go
+++ b/internal/pipeline/scorecard_tier2_test.go
@@ -824,6 +824,74 @@ func (c Config) AuthHeader() string {
 		assert.GreaterOrEqual(t, sc.Steinberger.AuthProtocol, 7)
 	})
 
+	t.Run("rich env var specs only emission remains scoreable", func(t *testing.T) {
+		dir := t.TempDir()
+		writeScorecardFixture(t, dir, "internal/cli/auth.go", `
+package cli
+
+func newAuthCmd() {}
+`)
+		writeScorecardFixture(t, dir, "internal/client/client.go", `
+package client
+
+import "net/http"
+
+func setAuth(req *http.Request, authHeader string) {
+	req.Header.Set("Authorization", authHeader)
+}
+`)
+		writeScorecardFixture(t, dir, "internal/config/config.go", `
+package config
+
+import "os"
+
+type Config struct {
+	RichAuthApiKey string
+}
+
+func Load() Config {
+	return Config{RichAuthApiKey: os.Getenv("RICH_AUTH_API_KEY")}
+}
+
+func (c Config) AuthHeader() string {
+	return "Bearer " + c.RichAuthApiKey
+}
+`)
+
+		specPath := filepath.Join(dir, "spec-rich-env-var-specs.json")
+		writeScorecardFixture(t, dir, "spec-rich-env-var-specs.json", `{
+  "paths": {
+    "/items": {
+      "get": {
+        "security": [
+          {
+            "RICH_AUTH_API_KEY": []
+          }
+        ],
+        "responses": {
+          "200": { "description": "ok" }
+        }
+      }
+    }
+  },
+  "components": {
+    "securitySchemes": {
+      "RICH_AUTH_API_KEY": {
+        "type": "http",
+        "scheme": "bearer"
+      }
+    }
+  }
+}`)
+
+		pipelineDir := t.TempDir()
+		sc, err := RunScorecard(dir, pipelineDir, specPath, nil)
+		assert.NoError(t, err)
+		assert.NotContains(t, sc.UnscoredDimensions, "auth_protocol")
+		assert.GreaterOrEqual(t, sc.Steinberger.AuthProtocol, 7)
+		assert.Greater(t, sc.Steinberger.Auth, 0)
+	})
+
 	t.Run("single header auth scheme scores full runtime protocol", func(t *testing.T) {
 		dir := t.TempDir()
 		writeScorecardFixture(t, dir, "internal/client/client.go", `
diff --git a/internal/pipeline/toolsmanifest.go b/internal/pipeline/toolsmanifest.go
index ad97966c..d55e4e42 100644
--- a/internal/pipeline/toolsmanifest.go
+++ b/internal/pipeline/toolsmanifest.go
@@ -39,16 +39,44 @@ type ToolsManifest struct {
 // ManifestAuth captures the auth configuration needed to make authenticated
 // API requests at runtime.
 type ManifestAuth struct {
-	Type                           string   `json:"type"`
-	Header                         string   `json:"header,omitempty"`
-	Format                         string   `json:"format,omitempty"`
-	In                             string   `json:"in,omitempty"`
-	EnvVars                        []string `json:"env_vars,omitempty"`
-	KeyURL                         string   `json:"key_url,omitempty"`
-	CookieDomain                   string   `json:"cookie_domain,omitempty"`
-	RequiresBrowserSession         bool     `json:"requires_browser_session,omitempty"`
-	BrowserSessionValidationPath   string   `json:"browser_session_validation_path,omitempty"`
-	BrowserSessionValidationMethod string   `json:"browser_session_validation_method,omitempty"`
+	Type                           string            `json:"type"`
+	Header                         string            `json:"header,omitempty"`
+	Format                         string            `json:"format,omitempty"`
+	In                             string            `json:"in,omitempty"`
+	EnvVars                        []string          `json:"env_vars,omitempty"`
+	EnvVarSpecs                    []spec.AuthEnvVar `json:"env_var_specs,omitempty"`
+	KeyURL                         string            `json:"key_url,omitempty"`
+	CookieDomain                   string            `json:"cookie_domain,omitempty"`
+	RequiresBrowserSession         bool              `json:"requires_browser_session,omitempty"`
+	BrowserSessionValidationPath   string            `json:"browser_session_validation_path,omitempty"`
+	BrowserSessionValidationMethod string            `json:"browser_session_validation_method,omitempty"`
+}
+
+// EffectiveEnvVarSpecs returns the rich env-var spec list, preferring EnvVarSpecs
+// when present and falling back to legacy EnvVars synthesized as
+// per_call+required+sensitive+inferred.
+func (a ManifestAuth) EffectiveEnvVarSpecs() []spec.AuthEnvVar {
+	if len(a.EnvVarSpecs) > 0 {
+		return a.EnvVarSpecs
+	}
+	if len(a.EnvVars) == 0 {
+		return nil
+	}
+	envVarSpecs := make([]spec.AuthEnvVar, 0, len(a.EnvVars))
+	for _, name := range a.EnvVars {
+		name = strings.TrimSpace(name)
+		if name == "" {
+			continue
+		}
+		envVarSpecs = append(envVarSpecs, spec.AuthEnvVar{
+			Name:      name,
+			Kind:      spec.AuthEnvVarKindPerCall,
+			Required:  true,
+			Sensitive: true,
+			Inferred:  true,
+		})
+	}
+	return envVarSpecs
 }
 
 // ManifestTiers records per-tier routing and auth metadata so audit/doctor
@@ -189,12 +217,14 @@ func buildManifestTiers(tierRouting spec.TierRoutingConfig) *ManifestTiers {
 }
 
 func manifestAuth(auth spec.AuthConfig) ManifestAuth {
+	auth.NormalizeEnvVarSpecs("")
 	return ManifestAuth{
 		Type:                           auth.Type,
 		Header:                         auth.Header,
 		Format:                         normalizeAuthFormat(auth.Format, auth.EnvVars),
 		In:                             auth.In,
 		EnvVars:                        auth.EnvVars,
+		EnvVarSpecs:                    auth.EnvVarSpecs,
 		KeyURL:                         auth.KeyURL,
 		CookieDomain:                   auth.CookieDomain,
 		RequiresBrowserSession:         auth.RequiresBrowserSession,
diff --git a/internal/pipeline/toolsmanifest_test.go b/internal/pipeline/toolsmanifest_test.go
index 49b3cd76..01b86022 100644
--- a/internal/pipeline/toolsmanifest_test.go
+++ b/internal/pipeline/toolsmanifest_test.go
@@ -406,9 +406,27 @@ func TestWriteToolsManifest_AuthConfigRoundTrip(t *testing.T) {
 	assert.Equal(t, "{MY_KEY}", got.Auth.Format)
 	assert.Equal(t, "header", got.Auth.In)
 	assert.Equal(t, []string{"MY_KEY", "MY_BACKUP_KEY"}, got.Auth.EnvVars)
+	assert.Equal(t, []spec.AuthEnvVar{
+		{Name: "MY_KEY", Kind: spec.AuthEnvVarKindPerCall, Required: true, Sensitive: true, Inferred: true},
+		{Name: "MY_BACKUP_KEY", Kind: spec.AuthEnvVarKindPerCall, Required: true, Sensitive: true, Inferred: true},
+	}, got.Auth.EnvVarSpecs)
 	assert.Equal(t, "https://example.com/api-keys", got.Auth.KeyURL)
 }
 
+func TestManifestAuthEffectiveEnvVarSpecsLegacyFallback(t *testing.T) {
+	got := (ManifestAuth{
+		Type:    "api_key",
+		EnvVars: []string{"LEGACY_TOKEN"},
+	}).EffectiveEnvVarSpecs()
+	assert.Equal(t, []spec.AuthEnvVar{{
+		Name:      "LEGACY_TOKEN",
+		Kind:      spec.AuthEnvVarKindPerCall,
+		Required:  true,
+		Sensitive: true,
+		Inferred:  true,
+	}}, got)
+}
+
 func TestWriteToolsManifest_NoAuthEndpointsFlagged(t *testing.T) {
 	dir := t.TempDir()
 	parsed := &spec.APISpec{
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index ab718ed1..f4440798 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -404,22 +404,23 @@ func (c BearerRefreshConfig) Enabled() bool {
 }
 
 type AuthConfig struct {
-	Type             string   `yaml:"type" json:"type"` // api_key, oauth2, bearer_token, cookie, composed, session_handshake, none
-	Header           string   `yaml:"header" json:"header"`
-	Format           string   `yaml:"format" json:"format"`
-	EnvVars          []string `yaml:"env_vars" json:"env_vars"`
-	Optional         bool     `yaml:"optional,omitempty" json:"optional,omitempty"` // true when the key enhances a subset of features (e.g., USDA nutrition backfill) rather than gating core functionality; doctor treats unconfigured optional auth as INFO not FAIL and README frames the section as "Optional"
-	Scheme           string   `yaml:"scheme,omitempty" json:"scheme,omitempty"`     // OpenAPI security scheme name
-	In               string   `yaml:"in,omitempty" json:"in,omitempty"`             // header, query, cookie
-	KeyURL           string   `yaml:"key_url,omitempty" json:"key_url,omitempty"`   // URL where users can register for an API key
-	Title            string   `yaml:"title,omitempty" json:"title,omitempty"`       // user-facing credential field title for install/config surfaces
-	Description      string   `yaml:"description,omitempty" json:"description,omitempty"`
-	AuthorizationURL string   `yaml:"authorization_url,omitempty" json:"authorization_url,omitempty"`
-	TokenURL         string   `yaml:"token_url,omitempty" json:"token_url,omitempty"`
-	Scopes           []string `yaml:"scopes,omitempty" json:"scopes,omitempty"`
-	CookieDomain     string   `yaml:"cookie_domain,omitempty" json:"cookie_domain,omitempty"` // domain to read browser cookies from (e.g. ".notion.so")
-	Cookies          []string `yaml:"cookies,omitempty" json:"cookies,omitempty"`             // named cookies to extract for composed auth (e.g. ["customerId", "authToken"])
-	Inferred         bool     `yaml:"inferred,omitempty" json:"inferred,omitempty"`           // true when auth was inferred from spec description, not declared in securitySchemes
+	Type             string       `yaml:"type" json:"type"` // api_key, oauth2, bearer_token, cookie, composed, session_handshake, none
+	Header           string       `yaml:"header" json:"header"`
+	Format           string       `yaml:"format" json:"format"`
+	EnvVars          []string     `yaml:"env_vars" json:"env_vars"`
+	EnvVarSpecs      []AuthEnvVar `yaml:"env_var_specs,omitempty" json:"env_var_specs,omitempty"`
+	Optional         bool         `yaml:"optional,omitempty" json:"optional,omitempty"` // true when the key enhances a subset of features (e.g., USDA nutrition backfill) rather than gating core functionality; doctor treats unconfigured optional auth as INFO not FAIL and README frames the section as "Optional"
+	Scheme           string       `yaml:"scheme,omitempty" json:"scheme,omitempty"`     // OpenAPI security scheme name
+	In               string       `yaml:"in,omitempty" json:"in,omitempty"`             // header, query, cookie
+	KeyURL           string       `yaml:"key_url,omitempty" json:"key_url,omitempty"`   // URL where users can register for an API key
+	Title            string       `yaml:"title,omitempty" json:"title,omitempty"`       // user-facing credential field title for install/config surfaces
+	Description      string       `yaml:"description,omitempty" json:"description,omitempty"`
+	AuthorizationURL string       `yaml:"authorization_url,omitempty" json:"authorization_url,omitempty"`
+	TokenURL         string       `yaml:"token_url,omitempty" json:"token_url,omitempty"`
+	Scopes           []string     `yaml:"scopes,omitempty" json:"scopes,omitempty"`
+	CookieDomain     string       `yaml:"cookie_domain,omitempty" json:"cookie_domain,omitempty"` // domain to read browser cookies from (e.g. ".notion.so")
+	Cookies          []string     `yaml:"cookies,omitempty" json:"cookies,omitempty"`             // named cookies to extract for composed auth (e.g. ["customerId", "authToken"])
+	Inferred         bool         `yaml:"inferred,omitempty" json:"inferred,omitempty"`           // true when auth was inferred from spec description, not declared in securitySchemes
 
 	// VerifyPath is an optional path appended to base_url that the doctor
 	// command probes to validate credentials. Set this to a known-good
@@ -463,6 +464,176 @@ type AuthConfig struct {
 	OAuth2Grant string `yaml:"oauth2_grant,omitempty" json:"oauth2_grant,omitempty"`
 }
 
+type AuthEnvVar struct {
+	Name        string         `yaml:"name" json:"name"`
+	Kind        AuthEnvVarKind `yaml:"kind,omitempty" json:"kind,omitempty"`
+	Required    bool           `yaml:"required" json:"required"`
+	Sensitive   bool           `yaml:"sensitive" json:"sensitive"` // orthogonal to Kind; drives redaction policy
+	Description string         `yaml:"description,omitempty" json:"description,omitempty"`
+	Inferred    bool           `yaml:"inferred,omitempty" json:"inferred,omitempty"`
+}
+
+type AuthEnvVarKind string
+
+const (
+	AuthEnvVarKindPerCall       AuthEnvVarKind = "per_call"
+	AuthEnvVarKindAuthFlowInput AuthEnvVarKind = "auth_flow_input"
+	AuthEnvVarKindHarvested     AuthEnvVarKind = "harvested"
+)
+
+func (k AuthEnvVarKind) SensitivePlaceholder() string {
+	switch k {
+	case AuthEnvVarKindPerCall:
+		return "Set to your API credential."
+	case AuthEnvVarKindAuthFlowInput:
+		return "Set during initial auth setup."
+	case AuthEnvVarKindHarvested:
+		return "Populated automatically by auth login."
+	default:
+		return ""
+	}
+}
+
+func (v AuthEnvVar) MarkdownDescription() string {
+	if v.Sensitive {
+		return v.Kind.SensitivePlaceholder()
+	}
+	description := strings.ReplaceAll(v.Description, "|", `\|`)
+	description = strings.ReplaceAll(description, "\r\n", " ")
+	description = strings.ReplaceAll(description, "\n", " ")
+	return strings.ReplaceAll(description, "\r", " ")
+}
+
+// CanonicalEnvVar returns the deterministic canonical entry for human-prose surfaces.
+func (c *AuthConfig) CanonicalEnvVar() *AuthEnvVar {
+	if c == nil {
+		return nil
+	}
+	c.NormalizeEnvVarSpecs("")
+	for i := range c.EnvVarSpecs {
+		if c.EnvVarSpecs[i].Kind == AuthEnvVarKindPerCall && c.EnvVarSpecs[i].Required {
+			return &c.EnvVarSpecs[i]
+		}
+	}
+	if len(c.EnvVarSpecs) > 0 {
+		return &c.EnvVarSpecs[0]
+	}
+	return nil
+}
+
+// IsAuthEnvVarORCase reports whether all EnvVarSpecs are non-required per_call vars.
+// In this shape, no single var is the canonical credential; the runtime tries each
+// in turn and returns the first non-empty value. Returns false when EnvVarSpecs has
+// fewer than 2 entries, any entry is Required, or any entry is not Kind=per_call.
+func (c *AuthConfig) IsAuthEnvVarORCase() bool {
+	if c == nil || len(c.EnvVarSpecs) < 2 {
+		return false
+	}
+	for _, ev := range c.EnvVarSpecs {
+		if ev.Kind != AuthEnvVarKindPerCall || ev.Required {
+			return false
+		}
+	}
+	return true
+}
+
+func (c *AuthConfig) NormalizeEnvVarSpecs(context string) {
+	if c == nil {
+		return
+	}
+	if len(c.EnvVarSpecs) > 0 {
+		canonicalNames := make([]string, 0, len(c.EnvVarSpecs))
+		canonical := true
+		for _, envVar := range c.EnvVarSpecs {
+			name := strings.TrimSpace(envVar.Name)
+			if name == "" {
+				continue
+			}
+			if envVar.Name != name || envVar.Kind == "" {
+				canonical = false
+				break
+			}
+			canonicalNames = append(canonicalNames, name)
+		}
+		if canonical {
+			envVarNames := make([]string, 0, len(c.EnvVars))
+			for _, name := range c.EnvVars {
+				if name = strings.TrimSpace(name); name != "" {
+					envVarNames = append(envVarNames, name)
+				}
+			}
+			if sameStringSlice(envVarNames, canonicalNames) {
+				return
+			}
+		}
+	}
+	if len(c.EnvVarSpecs) == 0 {
+		if len(c.EnvVars) == 0 {
+			return
+		}
+		c.EnvVarSpecs = make([]AuthEnvVar, 0, len(c.EnvVars))
+		for _, name := range c.EnvVars {
+			if name = strings.TrimSpace(name); name != "" {
+				c.EnvVarSpecs = append(c.EnvVarSpecs, AuthEnvVar{
+					Name:      name,
+					Kind:      AuthEnvVarKindPerCall,
+					Required:  true,
+					Sensitive: true,
+					Inferred:  true,
+				})
+			}
+		}
+		return
+	}
+
+	specNames := make([]string, 0, len(c.EnvVarSpecs))
+	for i := range c.EnvVarSpecs {
+		c.EnvVarSpecs[i].Name = strings.TrimSpace(c.EnvVarSpecs[i].Name)
+		if c.EnvVarSpecs[i].Name == "" {
+			continue
+		}
+		if c.EnvVarSpecs[i].Kind == "" {
+			c.EnvVarSpecs[i].Kind = AuthEnvVarKindPerCall
+		}
+		specNames = append(specNames, c.EnvVarSpecs[i].Name)
+	}
+	if len(c.EnvVars) > 0 && !sameStringSlice(c.EnvVars, specNames) && !AllAuthEnvVarSpecsInferred(c.EnvVarSpecs) {
+		if context == "" {
+			context = "auth"
+		}
+		fmt.Fprintf(os.Stderr, "warning: %s env_vars disagree with env_var_specs; using env_var_specs\n", context)
+		c.EnvVars = specNames
+		return
+	}
+	if len(c.EnvVars) == 0 || sameStringSlice(c.EnvVars, specNames) {
+		c.EnvVars = specNames
+	}
+}
+
+func AllAuthEnvVarSpecsInferred(envVarSpecs []AuthEnvVar) bool {
+	if len(envVarSpecs) == 0 {
+		return false
+	}
+	for _, envVar := range envVarSpecs {
+		if !envVar.Inferred {
+			return false
+		}
+	}
+	return true
+}
+
+func sameStringSlice(a, b []string) bool {
+	if len(a) != len(b) {
+		return false
+	}
+	for i := range a {
+		if strings.TrimSpace(a[i]) != strings.TrimSpace(b[i]) {
+			return false
+		}
+	}
+	return true
+}
+
 // OAuth2GrantAuthorizationCode is the 3-legged user-OAuth flow (browser
 // redirect, callback server, code exchange at TokenURL).
 const OAuth2GrantAuthorizationCode = "authorization_code"
@@ -1008,6 +1179,8 @@ func snakeToPascal(s string) string {
 // fragments accidentally embedded in path strings.
 var pathParamRe = regexp.MustCompile(`\{([A-Za-z_][A-Za-z0-9_]*)\}`)
 
+var orGroupTokenRe = regexp.MustCompile(`\b[A-Z][A-Z0-9_]*\b`)
+
 // enrichPathParams walks every resource and sub-resource endpoint and ensures
 // each `{paramName}` placeholder in the endpoint path is represented in
 // Endpoint.Params with Positional: true, Required: true. The expandOperations
@@ -1209,6 +1382,7 @@ func singularize(s string) string {
 }
 
 func (s *APISpec) Validate() error {
+	s.NormalizeAuthEnvVarSpecs()
 	if s.Name == "" {
 		return fmt.Errorf("name is required")
 	}
@@ -1248,6 +1422,9 @@ func (s *APISpec) Validate() error {
 	if err := validateSessionHandshake(s.Auth); err != nil {
 		return err
 	}
+	if err := validateAuthEnvVarSpecs("auth", s.Auth); err != nil {
+		return err
+	}
 	if err := validateTierRouting(s); err != nil {
 		return err
 	}
@@ -1289,6 +1466,127 @@ func (s *APISpec) Validate() error {
 	return nil
 }
 
+func (s *APISpec) NormalizeAuthEnvVarSpecs() {
+	if s == nil {
+		return
+	}
+	s.Auth.NormalizeEnvVarSpecs("auth")
+	if !s.HasTierRouting() {
+		return
+	}
+	for name, tier := range s.TierRouting.Tiers {
+		tier.Auth.NormalizeEnvVarSpecs(fmt.Sprintf("tier_routing.tiers.%s.auth", name))
+		s.TierRouting.Tiers[name] = tier
+	}
+}
+
+func validateAuthEnvVarSpecs(context string, auth AuthConfig) error {
+	seen := map[string]struct{}{}
+	for i, envVar := range auth.EnvVarSpecs {
+		name := strings.TrimSpace(envVar.Name)
+		if name == "" {
+			return fmt.Errorf("%s.env_var_specs[%d].name is required", context, i)
+		}
+		if _, ok := seen[name]; ok {
+			return fmt.Errorf("%s.env_var_specs contains duplicate name %q", context, name)
+		}
+		seen[name] = struct{}{}
+		switch envVar.Kind {
+		case "", AuthEnvVarKindPerCall, AuthEnvVarKindAuthFlowInput, AuthEnvVarKindHarvested:
+		default:
+			return fmt.Errorf("%s.env_var_specs[%d].kind %q is not recognized (valid: %q, %q, %q)",
+				context, i, envVar.Kind, AuthEnvVarKindPerCall, AuthEnvVarKindAuthFlowInput, AuthEnvVarKindHarvested)
+		}
+	}
+	if example, ok := independentAuthORGroupsExample(auth.EnvVarSpecs); ok {
+		return fmt.Errorf("%s: detected 2+ independent OR-groups in EnvVarSpecs (e.g., %s). The current model encodes OR-group membership via per-var Required=false + description text and supports at most one OR-group per auth block; multi-OR-group specs are not supported. Either consolidate to a single OR-group (mark all non-required entries as members of one group via cross-referencing descriptions), or require all credentials (Required=true)", context, example)
+	}
+	return nil
+}
+
+func independentAuthORGroupsExample(envVarSpecs []AuthEnvVar) (string, bool) {
+	names := make(map[string]struct{}, len(envVarSpecs))
+	for _, envVar := range envVarSpecs {
+		name := strings.TrimSpace(envVar.Name)
+		if name != "" {
+			names[name] = struct{}{}
+		}
+	}
+
+	members := make([]AuthEnvVar, 0, len(envVarSpecs))
+	for _, envVar := range envVarSpecs {
+		name := strings.TrimSpace(envVar.Name)
+		if name == "" || envVar.Kind != AuthEnvVarKindPerCall || envVar.Required || !strings.Contains(envVar.Description, " OR ") {
+			continue
+		}
+		referencesSibling := false
+		for _, token := range orGroupTokenRe.FindAllString(envVar.Description, -1) {
+			if token == name {
+				continue
+			}
+			if _, ok := names[token]; ok {
+				referencesSibling = true
+				break
+			}
+		}
+		if !referencesSibling {
+			continue
+		}
+		members = append(members, envVar)
+	}
+	if len(members) < 2 {
+		return "", false
+	}
+
+	parent := make(map[string]string, len(members))
+	for _, member := range members {
+		parent[member.Name] = member.Name
+	}
+	var find func(string) string
+	find = func(name string) string {
+		if parent[name] != name {
+			parent[name] = find(parent[name])
+		}
+		return parent[name]
+	}
+	union := func(a, b string) {
+		rootA, rootB := find(a), find(b)
+		if rootA != rootB {
+			parent[rootB] = rootA
+		}
+	}
+
+	for _, member := range members {
+		for _, token := range orGroupTokenRe.FindAllString(member.Description, -1) {
+			if token == member.Name {
+				continue
+			}
+			if _, inGroup := parent[token]; inGroup {
+				union(member.Name, token)
+			}
+		}
+	}
+
+	groups := map[string][]string{}
+	order := make([]string, 0, len(members))
+	for _, member := range members {
+		root := find(member.Name)
+		if _, ok := groups[root]; !ok {
+			order = append(order, root)
+		}
+		groups[root] = append(groups[root], member.Name)
+	}
+	if len(groups) < 2 {
+		return "", false
+	}
+
+	parts := make([]string, 0, len(order))
+	for _, root := range order {
+		parts = append(parts, strings.Join(groups[root], " OR "))
+	}
+	return strings.Join(parts, "; "), true
+}
+
 func validateBearerRefresh(s *APISpec) error {
 	cfg := s.BearerRefresh
 	if !cfg.Enabled() {
@@ -1341,6 +1639,9 @@ func validateTierRouting(s *APISpec) error {
 		if strings.TrimSpace(tier.BaseURL) != "" {
 			anyTierBaseURL = true
 		}
+		if err := validateAuthEnvVarSpecs(fmt.Sprintf("tier_routing.tiers.%s.auth", name), tier.Auth); err != nil {
+			return err
+		}
 		if err := validateTier(name, tier, s.BaseURL); err != nil {
 			return err
 		}
@@ -1366,8 +1667,8 @@ func validateTier(name string, tier TierConfig, specBaseURL string) error {
 	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 len(tier.Auth.EnvVars) == 0 && len(tier.Auth.EnvVarSpecs) == 0 {
+		return fmt.Errorf("tier_routing tier %q auth.env_vars or auth.env_var_specs is required for %s auth", name, authType)
 	}
 	if err := validateTierAuthPlacement(name, tier.Auth); err != nil {
 		return err
diff --git a/internal/spec/spec_test.go b/internal/spec/spec_test.go
index c97c34cc..d9bae558 100644
--- a/internal/spec/spec_test.go
+++ b/internal/spec/spec_test.go
@@ -1,6 +1,8 @@
 package spec
 
 import (
+	"bytes"
+	"os"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
@@ -8,6 +10,27 @@ import (
 	"gopkg.in/yaml.v3"
 )
 
+func captureStderr(t *testing.T, fn func()) string {
+	t.Helper()
+
+	old := os.Stderr
+	r, w, err := os.Pipe()
+	require.NoError(t, err)
+	os.Stderr = w
+	defer func() {
+		os.Stderr = old
+	}()
+
+	fn()
+	require.NoError(t, w.Close())
+
+	var buf bytes.Buffer
+	_, err = buf.ReadFrom(r)
+	require.NoError(t, err)
+	require.NoError(t, r.Close())
+	return buf.String()
+}
+
 func TestParseStytch(t *testing.T) {
 	s, err := Parse("../../testdata/stytch.yaml")
 	require.NoError(t, err)
@@ -87,6 +110,399 @@ func TestValidation(t *testing.T) {
 	}
 }
 
+func TestAuthEnvVarSpecsNormalizeAndValidate(t *testing.T) {
+	baseSpec := func(auth AuthConfig) APISpec {
+		return APISpec{
+			Name:    "auth-api",
+			BaseURL: "https://api.example.com",
+			Auth:    auth,
+			Resources: map[string]Resource{
+				"items": {Endpoints: map[string]Endpoint{"list": {Method: "GET", Path: "/items"}}},
+			},
+		}
+	}
+
+	tests := []struct {
+		name              string
+		auth              AuthConfig
+		wantEnvVars       []string
+		wantEnvVarSpecs   []AuthEnvVar
+		wantWarning       string
+		wantCanonicalName string
+	}{
+		{
+			name: "rich specs back-derive legacy env vars",
+			auth: AuthConfig{
+				Type: "api_key",
+				EnvVarSpecs: []AuthEnvVar{{
+					Name:        "TODOIST_API_KEY",
+					Kind:        AuthEnvVarKindPerCall,
+					Required:    true,
+					Sensitive:   true,
+					Description: "Todoist API key.",
+				}},
+			},
+			wantEnvVars: []string{"TODOIST_API_KEY"},
+			wantEnvVarSpecs: []AuthEnvVar{{
+				Name:        "TODOIST_API_KEY",
+				Kind:        AuthEnvVarKindPerCall,
+				Required:    true,
+				Sensitive:   true,
+				Description: "Todoist API key.",
+			}},
+			wantCanonicalName: "TODOIST_API_KEY",
+		},
+		{
+			name:        "legacy env vars lazily derive rich specs",
+			auth:        AuthConfig{Type: "api_key", EnvVars: []string{"TODOIST_API_KEY"}},
+			wantEnvVars: []string{"TODOIST_API_KEY"},
+			wantEnvVarSpecs: []AuthEnvVar{{
+				Name:      "TODOIST_API_KEY",
+				Kind:      AuthEnvVarKindPerCall,
+				Required:  true,
+				Sensitive: true,
+				Inferred:  true,
+			}},
+			wantCanonicalName: "TODOIST_API_KEY",
+		},
+		{
+			name: "consistent legacy and rich specs keep rich metadata",
+			auth: AuthConfig{
+				Type:    "api_key",
+				EnvVars: []string{"PUBLIC_ACCOUNT_SLUG"},
+				EnvVarSpecs: []AuthEnvVar{{
+					Name:      "PUBLIC_ACCOUNT_SLUG",
+					Kind:      AuthEnvVarKindPerCall,
+					Required:  false,
+					Sensitive: false,
+				}},
+			},
+			wantEnvVars: []string{"PUBLIC_ACCOUNT_SLUG"},
+			wantEnvVarSpecs: []AuthEnvVar{{
+				Name:      "PUBLIC_ACCOUNT_SLUG",
+				Kind:      AuthEnvVarKindPerCall,
+				Required:  false,
+				Sensitive: false,
+			}},
+			wantCanonicalName: "PUBLIC_ACCOUNT_SLUG",
+		},
+		{
+			name: "inconsistent legacy and rich specs warn and rich specs win",
+			auth: AuthConfig{
+				Type:    "api_key",
+				EnvVars: []string{"WRONG_API_KEY"},
+				EnvVarSpecs: []AuthEnvVar{{
+					Name:      "TODOIST_API_KEY",
+					Kind:      AuthEnvVarKindPerCall,
+					Required:  true,
+					Sensitive: true,
+				}},
+			},
+			wantEnvVars: []string{"TODOIST_API_KEY"},
+			wantEnvVarSpecs: []AuthEnvVar{{
+				Name:      "TODOIST_API_KEY",
+				Kind:      AuthEnvVarKindPerCall,
+				Required:  true,
+				Sensitive: true,
+			}},
+			wantWarning:       "warning: auth env_vars disagree with env_var_specs; using env_var_specs",
+			wantCanonicalName: "TODOIST_API_KEY",
+		},
+		{
+			name:              "no-auth empty env vars remains empty",
+			auth:              AuthConfig{Type: "none"},
+			wantCanonicalName: "",
+		},
+		{
+			name: "or case accepts optional described alternatives",
+			auth: AuthConfig{
+				Type: "bearer_token",
+				EnvVarSpecs: []AuthEnvVar{
+					{Name: "SLACK_BOT_TOKEN", Kind: AuthEnvVarKindPerCall, Sensitive: true, Description: "Set this OR SLACK_USER_TOKEN."},
+					{Name: "SLACK_USER_TOKEN", Kind: AuthEnvVarKindPerCall, Sensitive: true, Description: "Set this OR SLACK_BOT_TOKEN."},
+				},
+			},
+			wantEnvVars: []string{"SLACK_BOT_TOKEN", "SLACK_USER_TOKEN"},
+			wantEnvVarSpecs: []AuthEnvVar{
+				{Name: "SLACK_BOT_TOKEN", Kind: AuthEnvVarKindPerCall, Sensitive: true, Description: "Set this OR SLACK_USER_TOKEN."},
+				{Name: "SLACK_USER_TOKEN", Kind: AuthEnvVarKindPerCall, Sensitive: true, Description: "Set this OR SLACK_BOT_TOKEN."},
+			},
+			wantCanonicalName: "SLACK_BOT_TOKEN",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			candidate := baseSpec(tt.auth)
+			stderr := captureStderr(t, func() {
+				require.NoError(t, candidate.Validate())
+			})
+
+			assert.Contains(t, stderr, tt.wantWarning)
+			assert.Equal(t, tt.wantEnvVars, candidate.Auth.EnvVars)
+			assert.Equal(t, tt.wantEnvVarSpecs, candidate.Auth.EnvVarSpecs)
+			if tt.wantCanonicalName == "" {
+				assert.Nil(t, candidate.Auth.CanonicalEnvVar())
+			} else if assert.NotNil(t, candidate.Auth.CanonicalEnvVar()) {
+				assert.Equal(t, tt.wantCanonicalName, candidate.Auth.CanonicalEnvVar().Name)
+			}
+		})
+	}
+}
+
+func TestAuthEnvVarSpecsRejectDuplicateNames(t *testing.T) {
+	s := APISpec{
+		Name:    "auth-api",
+		BaseURL: "https://api.example.com",
+		Auth: AuthConfig{
+			Type: "api_key",
+			EnvVarSpecs: []AuthEnvVar{
+				{Name: "TODOIST_API_KEY", Kind: AuthEnvVarKindPerCall, Required: true, Sensitive: true},
+				{Name: "TODOIST_API_KEY", Kind: AuthEnvVarKindPerCall, Required: true, Sensitive: true},
+			},
+		},
+		Resources: map[string]Resource{
+			"items": {Endpoints: map[string]Endpoint{"list": {Method: "GET", Path: "/items"}}},
+		},
+	}
+
+	require.ErrorContains(t, s.Validate(), `auth.env_var_specs contains duplicate name "TODOIST_API_KEY"`)
+}
+
+func TestAuthEnvVarSpecsRejectIndependentORGroups(t *testing.T) {
+	s := APISpec{
+		Name:    "auth-api",
+		BaseURL: "https://api.example.com",
+		Auth: AuthConfig{
+			Type: "bearer_token",
+			EnvVarSpecs: []AuthEnvVar{
+				{Name: "FIRST_TOKEN", Kind: AuthEnvVarKindPerCall, Sensitive: true, Description: "Set this OR SECOND_TOKEN."},
+				{Name: "SECOND_TOKEN", Kind: AuthEnvVarKindPerCall, Sensitive: true, Description: "Set this OR FIRST_TOKEN."},
+				{Name: "THIRD_TOKEN", Kind: AuthEnvVarKindPerCall, Sensitive: true, Description: "Set this OR FOURTH_TOKEN."},
+				{Name: "FOURTH_TOKEN", Kind: AuthEnvVarKindPerCall, Sensitive: true, Description: "Set this OR THIRD_TOKEN."},
+			},
+		},
+		Resources: map[string]Resource{
+			"items": {Endpoints: map[string]Endpoint{"list": {Method: "GET", Path: "/items"}}},
+		},
+	}
+
+	require.ErrorContains(t, s.Validate(), "auth: detected 2+ independent OR-groups in EnvVarSpecs")
+}
+
+func TestAuthEnvVarSpecsAcceptNonCrossReferencingORDescriptions(t *testing.T) {
+	s := APISpec{
+		Name:    "auth-api",
+		BaseURL: "https://api.example.com",
+		Auth: AuthConfig{
+			Type: "bearer_token",
+			EnvVarSpecs: []AuthEnvVar{
+				{Name: "FIRST_TOKEN", Kind: AuthEnvVarKindPerCall, Sensitive: true, Description: "Set this OR use OAuth."},
+				{Name: "SECOND_TOKEN", Kind: AuthEnvVarKindPerCall, Sensitive: true, Description: "Set this OR use dashboard."},
+			},
+		},
+		Resources: map[string]Resource{
+			"items": {Endpoints: map[string]Endpoint{"list": {Method: "GET", Path: "/items"}}},
+		},
+	}
+
+	require.NoError(t, s.Validate())
+}
+
+func TestAuthEnvVarSpecsAcceptTransitiveORGroup(t *testing.T) {
+	s := APISpec{
+		Name:    "auth-api",
+		BaseURL: "https://api.example.com",
+		Auth: AuthConfig{
+			Type: "bearer_token",
+			EnvVarSpecs: []AuthEnvVar{
+				{Name: "FIRST_TOKEN", Kind: AuthEnvVarKindPerCall, Sensitive: true, Description: "Set this OR SECOND_TOKEN."},
+				{Name: "SECOND_TOKEN", Kind: AuthEnvVarKindPerCall, Sensitive: true, Description: "Set this OR THIRD_TOKEN."},
+				{Name: "THIRD_TOKEN", Kind: AuthEnvVarKindPerCall, Sensitive: true, Description: "Set this OR FOURTH_TOKEN."},
+				{Name: "FOURTH_TOKEN", Kind: AuthEnvVarKindPerCall, Sensitive: true, Description: "Set this OR FIRST_TOKEN."},
+			},
+		},
+		Resources: map[string]Resource{
+			"items": {Endpoints: map[string]Endpoint{"list": {Method: "GET", Path: "/items"}}},
+		},
+	}
+
+	require.NoError(t, s.Validate())
+}
+
+func TestNormalizeEnvVarSpecsEmptyNameIsIdempotent(t *testing.T) {
+	auth := AuthConfig{
+		Type:    "api_key",
+		EnvVars: []string{"TODOIST_API_KEY"},
+		EnvVarSpecs: []AuthEnvVar{
+			{Name: "TODOIST_API_KEY", Kind: AuthEnvVarKindPerCall, Required: true, Sensitive: true},
+			{Name: "", Kind: AuthEnvVarKindPerCall, Required: true, Sensitive: true},
+		},
+	}
+
+	auth.NormalizeEnvVarSpecs("")
+	once := append([]string(nil), auth.EnvVars...)
+	auth.NormalizeEnvVarSpecs("")
+
+	assert.Equal(t, once, auth.EnvVars)
+}
+
+func TestAuthEnvVarSpecsParseYAML(t *testing.T) {
+	parsed, err := ParseBytes([]byte(`
+name: todoist
+base_url: https://api.todoist.com
+auth:
+  type: api_key
+  header: Authorization
+  env_var_specs:
+    - name: TODOIST_API_KEY
+      kind: per_call
+      required: true
+      sensitive: true
+resources:
+  tasks:
+    endpoints:
+      list:
+        method: GET
+        path: /tasks
+`))
+	require.NoError(t, err)
+
+	assert.Equal(t, []string{"TODOIST_API_KEY"}, parsed.Auth.EnvVars)
+	require.Len(t, parsed.Auth.EnvVarSpecs, 1)
+	assert.Equal(t, AuthEnvVarKindPerCall, parsed.Auth.EnvVarSpecs[0].Kind)
+	assert.True(t, parsed.Auth.EnvVarSpecs[0].Required)
+	assert.True(t, parsed.Auth.EnvVarSpecs[0].Sensitive)
+}
+
+func TestCanonicalEnvVarSelection(t *testing.T) {
+	tests := []struct {
+		name string
+		auth AuthConfig
+		want string
+	}{
+		{
+			name: "prefers first required per-call entry over first harvested entry",
+			auth: AuthConfig{EnvVarSpecs: []AuthEnvVar{
+				{Name: "SESSION_COOKIE", Kind: AuthEnvVarKindHarvested, Required: true, Sensitive: true},
+				{Name: "TODOIST_API_KEY", Kind: AuthEnvVarKindPerCall, Required: true, Sensitive: true},
+			}},
+			want: "TODOIST_API_KEY",
+		},
+		{
+			name: "uses first required per-call entry in source order",
+			auth: AuthConfig{EnvVarSpecs: []AuthEnvVar{
+				{Name: "FIRST_API_KEY", Kind: AuthEnvVarKindPerCall, Required: true, Sensitive: true},
+				{Name: "SECOND_API_KEY", Kind: AuthEnvVarKindPerCall, Required: true, Sensitive: true},
+			}},
+			want: "FIRST_API_KEY",
+		},
+		{
+			name: "falls back to lazy-derived legacy env var",
+			auth: AuthConfig{EnvVars: []string{"LEGACY_API_KEY"}},
+			want: "LEGACY_API_KEY",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := tt.auth.CanonicalEnvVar()
+			require.NotNil(t, got)
+			assert.Equal(t, tt.want, got.Name)
+		})
+	}
+}
+
+func TestIsAuthEnvVarORCase(t *testing.T) {
+	tests := []struct {
+		name string
+		auth AuthConfig
+		want bool
+	}{
+		{
+			name: "all required entries are not OR case",
+			auth: AuthConfig{EnvVarSpecs: []AuthEnvVar{
+				{Name: "FIRST_API_KEY", Kind: AuthEnvVarKindPerCall, Required: true},
+				{Name: "SECOND_API_KEY", Kind: AuthEnvVarKindPerCall, Required: true},
+			}},
+			want: false,
+		},
+		{
+			name: "all non-required per-call entries are OR case",
+			auth: AuthConfig{EnvVarSpecs: []AuthEnvVar{
+				{Name: "SLACK_BOT_TOKEN", Kind: AuthEnvVarKindPerCall},
+				{Name: "SLACK_USER_TOKEN", Kind: AuthEnvVarKindPerCall},
+			}},
+			want: true,
+		},
+		{
+			name: "mixed kinds are not OR case",
+			auth: AuthConfig{EnvVarSpecs: []AuthEnvVar{
+				{Name: "SESSION_COOKIE", Kind: AuthEnvVarKindHarvested},
+				{Name: "TODOIST_API_KEY", Kind: AuthEnvVarKindPerCall},
+			}},
+			want: false,
+		},
+		{
+			name: "single entry is not OR case",
+			auth: AuthConfig{EnvVarSpecs: []AuthEnvVar{
+				{Name: "TODOIST_API_KEY", Kind: AuthEnvVarKindPerCall},
+			}},
+			want: false,
+		},
+		{
+			name: "empty specs are not OR case",
+			auth: AuthConfig{},
+			want: false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert.Equal(t, tt.want, tt.auth.IsAuthEnvVarORCase())
+		})
+	}
+}
+
+func TestTierAuthEnvVarSpecsNormalizeAndValidate(t *testing.T) {
+	s := APISpec{
+		Name:    "tier-api",
+		BaseURL: "https://api.example.com",
+		Auth: AuthConfig{
+			Type: "bearer_token",
+			EnvVarSpecs: []AuthEnvVar{{
+				Name:      "GLOBAL_TOKEN",
+				Kind:      AuthEnvVarKindPerCall,
+				Required:  true,
+				Sensitive: true,
+			}},
+		},
+		TierRouting: TierRoutingConfig{
+			DefaultTier: "paid",
+			Tiers: map[string]TierConfig{
+				"paid": {
+					BaseURL: "https://api.example.com/paid",
+					Auth: AuthConfig{
+						Type:    "api_key",
+						Header:  "x-api-key",
+						EnvVars: []string{"PAID_API_KEY"},
+					},
+				},
+			},
+		},
+		Resources: map[string]Resource{
+			"items": {Endpoints: map[string]Endpoint{"list": {Method: "GET", Path: "/items", Tier: "paid"}}},
+		},
+	}
+
+	require.NoError(t, s.Validate())
+	assert.Equal(t, []string{"GLOBAL_TOKEN"}, s.Auth.EnvVars)
+	assert.Equal(t, []string{"PAID_API_KEY"}, s.TierRouting.Tiers["paid"].Auth.EnvVars)
+	require.Len(t, s.TierRouting.Tiers["paid"].Auth.EnvVarSpecs, 1)
+	assert.Equal(t, AuthEnvVarKindPerCall, s.TierRouting.Tiers["paid"].Auth.EnvVarSpecs[0].Kind)
+	assert.True(t, s.TierRouting.Tiers["paid"].Auth.EnvVarSpecs[0].Required)
+}
+
 // TestThrottleShapeShopifyValue pins the wire value of ThrottleShapeShopify
 // to "shopify" because the graphql_client.go.tmpl template gates its
 // Shopify parser block on the literal string "shopify" (Go templates can't
diff --git a/skills/printing-press/SKILL.md b/skills/printing-press/SKILL.md
index 081f3b69..b1f90569 100644
--- a/skills/printing-press/SKILL.md
+++ b/skills/printing-press/SKILL.md
@@ -1490,6 +1490,48 @@ name in `env_vars`; do not add guessed slug-based aliases. For OpenAPI specs,
 prefer `x-auth-env-vars` on the selected security scheme when the wrapper slug
 differs from the underlying API brand.
 
+For OpenAPI specs that need richer env-var metadata (kind classification,
+optional credentials, OR-group relationships), use `x-auth-vars` on the
+security scheme. See `docs/SPEC-EXTENSIONS.md` for the canonical schema.
+
+```yaml
+components:
+  securitySchemes:
+    slackBot:
+      type: apiKey
+      in: header
+      name: Authorization
+      x-auth-vars:
+        - name: SLACK_BOT_TOKEN
+          kind: per_call
+          required: false
+          sensitive: true
+          description: Set this OR `SLACK_USER_TOKEN` for workspace API calls.
+        - name: SLACK_USER_TOKEN
+          kind: per_call
+          required: false
+          sensitive: true
+          description: Set this OR `SLACK_BOT_TOKEN` for user-scoped API calls.
+```
+
+`kind` controls who supplies the value:
+- `per_call` is the default user-supplied credential used by normal commands.
+- `auth_flow_input` is only needed during `auth login`.
+- `harvested` is populated by the auth login flow into local config.
+
+`sensitive: true` means credential material that must be redacted in logs and
+agent context. Use `sensitive: false` for public configuration, such as an OAuth
+`client_id`.
+
+Encode AND/OR relationships with each var's `required` flag plus `description`
+text. There is no first-class group syntax. For OR cases, mark each alternative
+`required: false` and name the other option in the description. For AND cases,
+mark each required member `required: true`.
+
+The parser auto-classifies cookie schemes as `harvested` and OAuth2
+`client_credentials` inputs as `auth_flow_input`. Add `x-auth-vars` only when
+overriding those defaults or resolving multi-scheme ambiguity.
+
 For OpenAPI specs, add an `info.description` mention if one doesn't exist — the
 parser's `inferDescriptionAuth` will detect it automatically.
 
diff --git a/testdata/golden/cases/generate-golden-api-rich-auth/artifacts.txt b/testdata/golden/cases/generate-golden-api-rich-auth/artifacts.txt
new file mode 100644
index 00000000..222d9b35
--- /dev/null
+++ b/testdata/golden/cases/generate-golden-api-rich-auth/artifacts.txt
@@ -0,0 +1,9 @@
+# Locks rich AuthEnvVar template consumption across machine and prose surfaces.
+printing-press-rich-auth/SKILL.md
+printing-press-rich-auth/README.md
+printing-press-rich-auth/internal/cli/agent_context.go
+printing-press-rich-auth/internal/cli/auth.go
+printing-press-rich-auth/internal/cli/doctor.go
+printing-press-rich-auth/internal/cli/helpers.go
+printing-press-rich-auth/internal/config/config.go
+printing-press-rich-auth/internal/mcp/tools.go
diff --git a/testdata/golden/cases/generate-golden-api-rich-auth/command.txt b/testdata/golden/cases/generate-golden-api-rich-auth/command.txt
new file mode 100644
index 00000000..5d6f709c
--- /dev/null
+++ b/testdata/golden/cases/generate-golden-api-rich-auth/command.txt
@@ -0,0 +1,2 @@
+set -euo pipefail
+"$BINARY" generate --spec testdata/golden/fixtures/golden-api-rich-auth.yaml --output "$CASE_ACTUAL_DIR/printing-press-rich-auth" --force --spec-url file://testdata/golden/fixtures/golden-api-rich-auth.yaml --validate=false
diff --git a/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/cli/auth.go b/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/cli/auth.go
index db801a1a..11c0e4bb 100644
--- a/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/cli/auth.go
+++ b/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/cli/auth.go
@@ -25,7 +25,7 @@ import (
 func newAuthCmd(flags *rootFlags) *cobra.Command {
 	cmd := &cobra.Command{
 		Use:   "auth",
-		Short: "Manage PRINTING_PRESS_OAUTH2_OAUTH2_CC credentials (login, status, logout)",
+		Short: "Manage authentication for Printing Press Oauth2",
 	}
 
 	cmd.AddCommand(newAuthLoginCmd(flags))
@@ -50,7 +50,7 @@ Mint an OAuth2 bearer token via POST https://api.cc.example/oauth/token with
 grant_type=client_credentials and cache it on disk. Subsequent commands
 auto-refresh the token before expiry.
 
-Credentials default to PRINTING_PRESS_OAUTH2_OAUTH2_CC (Client ID) environment variables.
+Credentials default to PRINTING_PRESS_OAUTH2_CLIENT_ID (Client ID) and PRINTING_PRESS_OAUTH2_CLIENT_SECRET (Client Secret) environment variables.
 ` + "`" + `),
 		Example: strings.Trim(` + "`" + `
   # Use env vars
@@ -67,12 +67,13 @@ Credentials default to PRINTING_PRESS_OAUTH2_OAUTH2_CC (Client ID) environment v
 			}
 
 			if clientID == "" {
-				clientID = os.Getenv("PRINTING_PRESS_OAUTH2_OAUTH2_CC")
+				clientID = os.Getenv("PRINTING_PRESS_OAUTH2_CLIENT_ID")
 			}
 			if clientSecret == "" {
+				clientSecret = os.Getenv("PRINTING_PRESS_OAUTH2_CLIENT_SECRET")
 			}
 			if clientID == "" || clientSecret == "" {
-				return authErr(fmt.Errorf("client ID and secret required (set --client-id and --client-secret)"))
+				return authErr(fmt.Errorf("client ID and secret required (set --client-id/--client-secret or PRINTING_PRESS_OAUTH2_CLIENT_ID/PRINTING_PRESS_OAUTH2_CLIENT_SECRET)"))
 			}
 
 			tok, err := mintClientCredentialsToken(http.DefaultClient, "https://api.cc.example/oauth/token", clientID, clientSecret)
@@ -95,8 +96,8 @@ Credentials default to PRINTING_PRESS_OAUTH2_OAUTH2_CC (Client ID) environment v
 			return nil
 		},
 	}
-	cmd.Flags().StringVar(&clientID, "client-id", "", "OAuth2 Client ID; defaults to $PRINTING_PRESS_OAUTH2_OAUTH2_CC")
-	cmd.Flags().StringVar(&clientSecret, "client-secret", "", "OAuth2 Client Secret")
+	cmd.Flags().StringVar(&clientID, "client-id", "", "OAuth2 Client ID; defaults to $PRINTING_PRESS_OAUTH2_CLIENT_ID")
+	cmd.Flags().StringVar(&clientSecret, "client-secret", "", "OAuth2 Client Secret; defaults to $PRINTING_PRESS_OAUTH2_CLIENT_SECRET")
 
 	return cmd
 }
@@ -175,7 +176,8 @@ func newAuthStatusCmd(flags *rootFlags) *cobra.Command {
 				fmt.Fprintln(w, red("Not authenticated"))
 				fmt.Fprintln(w, "")
 				fmt.Fprintln(w, "Mint a token:")
-				fmt.Fprintln(w, "  export PRINTING_PRESS_OAUTH2_OAUTH2_CC=\"<client-id>\"")
+				fmt.Fprintln(w, "  export PRINTING_PRESS_OAUTH2_CLIENT_ID=\"<client-id>\"")
+				fmt.Fprintln(w, "  export PRINTING_PRESS_OAUTH2_CLIENT_SECRET=\"<client-secret>\"")
 				fmt.Fprintf(w, "  printing-press-oauth2-pp-cli auth login\n")
 				return authErr(fmt.Errorf("no credentials configured"))
 			}
@@ -236,8 +238,11 @@ func newAuthLogoutCmd(flags *rootFlags) *cobra.Command {
 			// Identify which (if any) auth env var is still exported so the
 			// JSON envelope and the human prose can both surface it.
 			envStillSet := ""
-			if envStillSet == "" && os.Getenv("PRINTING_PRESS_OAUTH2_OAUTH2_CC") != "" {
-				envStillSet = "PRINTING_PRESS_OAUTH2_OAUTH2_CC"
+			if envStillSet == "" && os.Getenv("PRINTING_PRESS_OAUTH2_CLIENT_ID") != "" {
+				envStillSet = "PRINTING_PRESS_OAUTH2_CLIENT_ID"
+			}
+			if envStillSet == "" && os.Getenv("PRINTING_PRESS_OAUTH2_CLIENT_SECRET") != "" {
+				envStillSet = "PRINTING_PRESS_OAUTH2_CLIENT_SECRET"
 			}
 
 			// JSON envelope: {cleared: true, note?: "<env_var> env var is still set"}.
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 f31f80d2..9483f84a 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
@@ -377,7 +377,10 @@ func resolveClientCredentials(cfg *config.Config) (string, string) {
 	id := cfg.ClientID
 	secret := cfg.ClientSecret
 	if id == "" {
-		id = os.Getenv("PRINTING_PRESS_OAUTH2_OAUTH2_CC")
+		id = os.Getenv("PRINTING_PRESS_OAUTH2_CLIENT_ID")
+	}
+	if secret == "" {
+		secret = os.Getenv("PRINTING_PRESS_OAUTH2_CLIENT_SECRET")
 	}
 	return id, secret
 }
diff --git a/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/config/config.go b/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/config/config.go
index d1bb9e62..02432df0 100644
--- a/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/config/config.go
+++ b/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/config/config.go
@@ -23,7 +23,8 @@ type Config struct {
 	ClientID       string `toml:"client_id"`
 	ClientSecret   string `toml:"client_secret"`
 	Path           string `toml:"-"`
-	PrintingPressOauth2Oauth2Cc string `toml:"press_oauth2_oauth2_cc"`
+	PrintingPressOauth2ClientId string `toml:"press_oauth2_client_id"`
+	PrintingPressOauth2ClientSecret string `toml:"press_oauth2_client_secret"`
 }
 
 func Load(configPath string) (*Config, error) {
@@ -51,9 +52,13 @@ func Load(configPath string) (*Config, error) {
 	}
 
 	// Env var overrides
-	if v := os.Getenv("PRINTING_PRESS_OAUTH2_OAUTH2_CC"); v != "" {
-		cfg.PrintingPressOauth2Oauth2Cc = v
-		cfg.AuthSource = "env:PRINTING_PRESS_OAUTH2_OAUTH2_CC"
+	if v := os.Getenv("PRINTING_PRESS_OAUTH2_CLIENT_ID"); v != "" {
+		cfg.PrintingPressOauth2ClientId = v
+		cfg.AuthSource = "env:PRINTING_PRESS_OAUTH2_CLIENT_ID"
+	}
+	if v := os.Getenv("PRINTING_PRESS_OAUTH2_CLIENT_SECRET"); v != "" {
+		cfg.PrintingPressOauth2ClientSecret = v
+		cfg.AuthSource = "env:PRINTING_PRESS_OAUTH2_CLIENT_SECRET"
 	}
 
 	// Base URL override (used by printing-press verify to point at mock/test servers)
@@ -73,9 +78,9 @@ func (c *Config) AuthHeader() string {
 		c.AuthSource = "oauth2"
 		return "Bearer " + c.AccessToken
 	}
-	if c.PrintingPressOauth2Oauth2Cc != "" {
-		c.AuthSource = "env:PRINTING_PRESS_OAUTH2_OAUTH2_CC"
-		return "Bearer " + c.PrintingPressOauth2Oauth2Cc
+	if c.PrintingPressOauth2ClientId != "" {
+		c.AuthSource = "env:PRINTING_PRESS_OAUTH2_CLIENT_ID"
+		return "Bearer " + c.PrintingPressOauth2ClientId
 	}
 	return ""
 }
diff --git a/testdata/golden/expected/generate-golden-api-rich-auth/exit.txt b/testdata/golden/expected/generate-golden-api-rich-auth/exit.txt
new file mode 100644
index 00000000..573541ac
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api-rich-auth/exit.txt
@@ -0,0 +1 @@
+0
diff --git a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/README.md b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/README.md
new file mode 100644
index 00000000..94b4c3ba
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/README.md
@@ -0,0 +1,192 @@
+# Printing Press Rich CLI
+
+Purpose-built fixture for rich auth env-var model coverage.
+
+## Install
+
+### Binary
+
+Download a pre-built binary for your platform from the [latest release](https://github.com/mvanhorn/printing-press-library/releases/tag/printing-press-rich-current). On macOS, clear the Gatekeeper quarantine: `xattr -d com.apple.quarantine <binary>`. On Unix, mark it executable: `chmod +x <binary>`.
+
+### Go
+
+```
+go install github.com/mvanhorn/printing-press-library/library/other/printing-press-rich/cmd/printing-press-rich-pp-cli@latest
+```
+
+## Quick Start
+
+### 1. Install
+
+See [Install](#install) above.
+
+### 2. Set Up Credentials
+
+Get your API key from your API provider's developer portal. The key typically looks like a long alphanumeric string.
+
+```bash
+export RICH_AUTH_API_KEY="<paste-your-key>"
+```
+
+You can also persist this in your config file at `~/.config/printing-press-rich-pp-cli/config.toml`.
+
+### 3. Verify Setup
+
+```bash
+printing-press-rich-pp-cli doctor
+```
+
+This checks your configuration and credentials.
+
+### 4. Try Your First Command
+
+```bash
+printing-press-rich-pp-cli items
+```
+
+## Usage
+
+Run `printing-press-rich-pp-cli --help` for the full command reference and flag list.
+
+## Commands
+
+### items
+
+Manage items
+
+- **`printing-press-rich-pp-cli items list`** - List items
+
+
+## Output Formats
+
+```bash
+# Human-readable table (default in terminal, JSON when piped)
+printing-press-rich-pp-cli items
+
+# JSON for scripting and agents
+printing-press-rich-pp-cli items --json
+
+# Filter to specific fields
+printing-press-rich-pp-cli items --json --select id,name,status
+
+# Dry run — show the request without sending
+printing-press-rich-pp-cli items --dry-run
+
+# Agent mode — JSON + compact + no prompts in one flag
+printing-press-rich-pp-cli items --agent
+```
+
+## Agent Usage
+
+This CLI is designed for AI agent consumption:
+
+- **Non-interactive** - never prompts, every input is a flag
+- **Pipeable** - `--json` output to stdout, errors to stderr
+- **Filterable** - `--select id,name` returns only fields you need
+- **Previewable** - `--dry-run` shows the request without sending
+- **Read-only by default** - this CLI does not create, update, delete, publish, send, or mutate remote resources
+- **Offline-friendly** - sync/search commands can use the local SQLite store when available
+- **Agent-safe by default** - no colors or formatting unless `--human-friendly` is set
+
+Exit codes: `0` success, `2` usage error, `3` not found, `4` auth error, `5` API error, `7` rate limited, `10` config error.
+
+## Use with Claude Code
+
+Install the focused skill — it auto-installs the CLI on first invocation:
+
+```bash
+npx skills add mvanhorn/printing-press-library/cli-skills/pp-printing-press-rich -g
+```
+
+Then invoke `/pp-printing-press-rich <query>` in Claude Code. The skill is the most efficient path — Claude Code drives the CLI directly without an MCP server in the middle.
+
+<details>
+<summary>Use as an MCP server in Claude Code (advanced)</summary>
+
+If you'd rather register this CLI as an MCP server in Claude Code, install the MCP binary first:
+
+```bash
+go install github.com/mvanhorn/printing-press-library/library/other/printing-press-rich/cmd/printing-press-rich-pp-mcp@latest
+```
+
+Then register it:
+
+```bash
+claude mcp add printing-press-rich printing-press-rich-pp-mcp -e RICH_AUTH_API_KEY=<your-key>
+```
+
+</details>
+
+## Use with Claude Desktop
+
+This CLI ships an [MCPB](https://github.com/modelcontextprotocol/mcpb) bundle — Claude Desktop's standard format for one-click MCP extension installs (no JSON config required).
+
+To install:
+
+1. Download the `.mcpb` for your platform from the [latest release](https://github.com/mvanhorn/printing-press-library/releases/tag/printing-press-rich-current).
+2. Double-click the `.mcpb` file. Claude Desktop opens and walks you through the install.
+3. Fill in `RICH_AUTH_API_KEY` when Claude Desktop prompts you.
+
+Requires Claude Desktop 1.0.0 or later. Pre-built bundles ship for macOS Apple Silicon (`darwin-arm64`) and Windows (`amd64`, `arm64`); for other platforms, use the manual config below.
+
+<details>
+<summary>Manual JSON config (advanced)</summary>
+
+If you can't use the MCPB bundle (older Claude Desktop, unsupported platform), install the MCP binary and configure it manually.
+
+```bash
+go install github.com/mvanhorn/printing-press-library/library/other/printing-press-rich/cmd/printing-press-rich-pp-mcp@latest
+```
+
+Add to your Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json`):
+
+```json
+{
+  "mcpServers": {
+    "printing-press-rich": {
+      "command": "printing-press-rich-pp-mcp",
+      "env": {
+        "RICH_AUTH_API_KEY": "<your-key>"
+      }
+    }
+  }
+}
+```
+
+</details>
+
+## Health Check
+
+```bash
+printing-press-rich-pp-cli doctor
+```
+
+Verifies configuration, credentials, and connectivity to the API.
+
+## Configuration
+
+Config file: `~/.config/printing-press-rich-pp-cli/config.toml`
+
+Environment variables:
+
+| Name | Kind | Required | Description |
+| --- | --- | --- | --- |
+| `RICH_AUTH_API_KEY` | per_call | Yes | Set to your API credential. |
+| `RICH_AUTH_CLIENT_ID` | auth_flow_input | No | OAuth application client identifier. |
+| `RICH_AUTH_CLIENT_SECRET` | auth_flow_input | No | Set during initial auth setup. |
+| `RICH_AUTH_SESSION_COOKIE` | harvested | No | Populated automatically by auth login. |
+| `RICH_AUTH_OPTIONAL_TOKEN` | per_call | No | Set to your API credential. |
+| `RICH_AUTH_BOT_TOKEN` | per_call | No | Set to your API credential. |
+| `RICH_AUTH_USER_TOKEN` | per_call | No | Set to your API credential. |
+
+## Troubleshooting
+**Authentication errors (exit code 4)**
+- Run `printing-press-rich-pp-cli doctor` to check credentials
+- Verify the environment variable is set: `echo $RICH_AUTH_API_KEY`
+**Not found errors (exit code 3)**
+- Check the resource ID is correct
+- Run the `list` command to see available items
+
+---
+
+Generated by [CLI Printing Press](https://github.com/mvanhorn/cli-printing-press)
diff --git a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/SKILL.md b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/SKILL.md
new file mode 100644
index 00000000..38add1b7
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/SKILL.md
@@ -0,0 +1,192 @@
+---
+name: pp-printing-press-rich
+description: "Printing Press CLI for Printing Press Rich. Purpose-built fixture for rich auth env-var model coverage."
+argument-hint: "<command> [args] | install cli|mcp"
+allowed-tools: "Read Bash"
+metadata:
+  openclaw:
+    requires:
+      env: ["RICH_AUTH_API_KEY"]
+      bins:
+        - printing-press-rich-pp-cli
+    envVars:
+      - name: RICH_AUTH_API_KEY
+        required: true
+        description: "Rich Auth API key."
+      - name: RICH_AUTH_CLIENT_ID
+        required: false
+        description: "Only needed during `auth login`; not required for normal use. OAuth application client identifier."
+      - name: RICH_AUTH_CLIENT_SECRET
+        required: false
+        description: "Set during application setup."
+      - name: RICH_AUTH_OPTIONAL_TOKEN
+        required: false
+        description: "Set this OR another listed env var. Optional token for elevated read limits."
+      - name: RICH_AUTH_BOT_TOKEN
+        required: false
+        description: "Set this OR another listed env var. Set this OR RICH_AUTH_USER_TOKEN for workspace access."
+      - name: RICH_AUTH_USER_TOKEN
+        required: false
+        description: "Set this OR another listed env var. Set this OR RICH_AUTH_BOT_TOKEN for workspace access."
+    install:
+      - kind: go
+        bins: [printing-press-rich-pp-cli]
+        module: github.com/mvanhorn/printing-press-library/library/other/printing-press-rich/cmd/printing-press-rich-pp-cli
+---
+
+# Printing Press Rich — Printing Press CLI
+
+Purpose-built fixture for rich auth env-var model coverage.
+
+## When Not to Use This CLI
+
+Do not activate this CLI for requests that require creating, updating, deleting, publishing, commenting, upvoting, inviting, ordering, sending messages, booking, purchasing, or changing remote state. This printed CLI exposes read-only commands for inspection, export, sync, and analysis.
+
+## Command Reference
+
+**items** — Manage items
+
+- `printing-press-rich-pp-cli items` — List items
+
+
+### Finding the right command
+
+When you know what you want to do but not which command does it, ask the CLI directly:
+
+```bash
+printing-press-rich-pp-cli which "<capability in your own words>"
+```
+
+`which` resolves a natural-language capability query to the best matching command from this CLI's curated feature index. Exit code `0` means at least one match; exit code `2` means no confident match — fall back to `--help` or use a narrower query.
+
+## Auth Setup
+Set your API key via environment variable:
+
+```bash
+export RICH_AUTH_API_KEY="<your-key>"
+```
+
+Or persist it in `~/.config/printing-press-rich-pp-cli/config.toml`.
+
+Run `printing-press-rich-pp-cli doctor` to verify setup.
+
+## Agent Mode
+
+Add `--agent` to any command. Expands to: `--json --compact --no-input --no-color --yes`.
+
+- **Pipeable** — JSON on stdout, errors on stderr
+- **Filterable** — `--select` keeps a subset of fields. Dotted paths descend into nested structures; arrays traverse element-wise. Critical for keeping context small on verbose APIs:
+
+  ```bash
+  printing-press-rich-pp-cli items --agent --select id,name,status
+  ```
+- **Previewable** — `--dry-run` shows the request without sending
+- **Offline-friendly** — sync/search commands can use the local SQLite store when available
+- **Non-interactive** — never prompts, every input is a flag
+- **Read-only** — do not use this CLI for create, update, delete, publish, comment, upvote, invite, order, send, or other mutating requests
+
+### Response envelope
+
+Commands that read from the local store or the API wrap output in a provenance envelope:
+
+```json
+{
+  "meta": {"source": "live" | "local", "synced_at": "...", "reason": "..."},
+  "results": <data>
+}
+```
+
+Parse `.results` for data and `.meta.source` to know whether it's live or local. A human-readable `N results (live)` summary is printed to stderr only when stdout is a terminal — piped/agent consumers get pure JSON on stdout.
+
+## Agent Feedback
+
+When you (or the agent) notice something off about this CLI, record it:
+
+```
+printing-press-rich-pp-cli feedback "the --since flag is inclusive but docs say exclusive"
+printing-press-rich-pp-cli feedback --stdin < notes.txt
+printing-press-rich-pp-cli feedback list --json --limit 10
+```
+
+Entries are stored locally at `~/.printing-press-rich-pp-cli/feedback.jsonl`. They are never POSTed unless `PRINTING_PRESS_RICH_FEEDBACK_ENDPOINT` is set AND either `--send` is passed or `PRINTING_PRESS_RICH_FEEDBACK_AUTO_SEND=true`. Default behavior is local-only.
+
+Write what *surprised* you, not a bug report. Short, specific, one line: that is the part that compounds.
+
+## Output Delivery
+
+Every command accepts `--deliver <sink>`. The output goes to the named sink in addition to (or instead of) stdout, so agents can route command results without hand-piping. Three sinks are supported:
+
+| Sink | Effect |
+|------|--------|
+| `stdout` | Default; write to stdout only |
+| `file:<path>` | Atomically write output to `<path>` (tmp + rename) |
+| `webhook:<url>` | POST the output body to the URL (`application/json` or `application/x-ndjson` when `--compact`) |
+
+Unknown schemes are refused with a structured error naming the supported set. Webhook failures return non-zero and log the URL + HTTP status on stderr.
+
+## Named Profiles
+
+A profile is a saved set of flag values, reused across invocations. Use it when a scheduled agent calls the same command every run with the same configuration - HeyGen's "Beacon" pattern.
+
+```
+printing-press-rich-pp-cli profile save briefing --json
+printing-press-rich-pp-cli --profile briefing items
+printing-press-rich-pp-cli profile list --json
+printing-press-rich-pp-cli profile show briefing
+printing-press-rich-pp-cli profile delete briefing --yes
+```
+
+Explicit flags always win over profile values; profile values win over defaults. `agent-context` lists all available profiles under `available_profiles` so introspecting agents discover them at runtime.
+
+## Exit Codes
+
+| Code | Meaning |
+|------|---------|
+| 0 | Success |
+| 2 | Usage error (wrong arguments) |
+| 3 | Resource not found |
+| 4 | Authentication required |
+| 5 | API error (upstream issue) |
+| 7 | Rate limited (wait and retry) |
+| 10 | Config error |
+
+## Argument Parsing
+
+Parse `$ARGUMENTS`:
+
+1. **Empty, `help`, or `--help`** → show `printing-press-rich-pp-cli --help` output
+2. **Starts with `install`** → ends with `mcp` → MCP installation; otherwise → CLI installation
+3. **Anything else** → Direct Use (execute as CLI command with `--agent`)
+
+## CLI Installation
+
+1. Check Go is installed: `go version` (requires Go 1.23+)
+2. Install:
+   ```bash
+   go install github.com/mvanhorn/printing-press-library/library/other/printing-press-rich/cmd/printing-press-rich-pp-cli@latest
+   ```
+3. Verify: `printing-press-rich-pp-cli --version`
+4. Ensure `$GOPATH/bin` (or `$HOME/go/bin`) is on `$PATH`.
+
+## MCP Server Installation
+
+1. Install the MCP server:
+   ```bash
+   go install github.com/mvanhorn/printing-press-library/library/other/printing-press-rich/cmd/printing-press-rich-pp-mcp@latest
+   ```
+2. Register with Claude Code:
+   ```bash
+   claude mcp add printing-press-rich-pp-mcp -- printing-press-rich-pp-mcp
+   ```
+3. Verify: `claude mcp list`
+
+## Direct Use
+
+1. Check if installed: `which printing-press-rich-pp-cli`
+   If not found, offer to install (see CLI Installation above).
+2. Match the user query to the best command from the Unique Capabilities and Command Reference above.
+3. Execute with the `--agent` flag:
+   ```bash
+   printing-press-rich-pp-cli <command> [subcommand] [args] --agent
+   ```
+4. If ambiguous, drill into subcommand help: `printing-press-rich-pp-cli <command> --help`.
diff --git a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/agent_context.go b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/agent_context.go
new file mode 100644
index 00000000..0bf32e43
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/agent_context.go
@@ -0,0 +1,226 @@
+// 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"
+	"os"
+	"sort"
+
+	"github.com/spf13/cobra"
+	"github.com/spf13/pflag"
+)
+
+// agentContextSchemaVersion is bumped on any breaking change to the JSON
+// shape emitted by `agent-context`. Agents should check this before
+// parsing. Shape at v3 adds kind-aware auth env var metadata.
+const agentContextSchemaVersion = "3"
+
+// agentContext is the structured description of this CLI consumed by AI
+// agents. Inspired by Cloudflare's /cdn-cgi/explorer/api runtime endpoint
+// (2026-04-13 Wrangler post): agents can introspect the live CLI without
+// parsing --help or reading source.
+type agentContext struct {
+	SchemaVersion               string                `json:"schema_version"`
+	CLI                         agentContextCLI       `json:"cli"`
+	Auth                        agentContextAuth      `json:"auth"`
+	Discovery                   *agentContextDiscovery `json:"discovery,omitempty"`
+	Commands                    []agentContextCommand `json:"commands"`
+	AvailableProfiles           []string              `json:"available_profiles"`
+	FeedbackEndpointConfigured  bool                  `json:"feedback_endpoint_configured"`
+}
+
+type agentContextCLI struct {
+	Name        string `json:"name"`
+	Description string `json:"description"`
+	Version     string `json:"version"`
+}
+
+type agentContextAuth struct {
+	Mode    string                   `json:"mode"`
+	EnvVars []agentContextAuthEnvVar `json:"env_vars"`
+}
+
+type agentContextAuthEnvVar struct {
+	Name        string `json:"name"`
+	Kind        string `json:"kind"`
+	Required    bool   `json:"required"`
+	Sensitive   bool   `json:"sensitive"`
+	Description string `json:"description,omitempty"`
+}
+
+type agentContextDiscovery struct {
+	Source            string   `json:"source"`
+	TargetURL         string   `json:"target_url,omitempty"`
+	EntryCount        int      `json:"entry_count,omitempty"`
+	APIEntryCount     int      `json:"api_entry_count,omitempty"`
+	Reachability      string   `json:"reachability,omitempty"`
+	Protocols         []string `json:"protocols,omitempty"`
+	AuthCandidates    []string `json:"auth_candidates,omitempty"`
+	Protections       []string `json:"protections,omitempty"`
+	GenerationHints   []string `json:"generation_hints,omitempty"`
+	Warnings          []string `json:"warnings,omitempty"`
+	CandidateCommands []string `json:"candidate_commands,omitempty"`
+}
+
+type agentContextCommand struct {
+	Name        string                `json:"name"`
+	Use         string                `json:"use,omitempty"`
+	Short       string                `json:"short,omitempty"`
+	Annotations map[string]string     `json:"annotations,omitempty"`
+	Flags       []agentContextFlag    `json:"flags,omitempty"`
+	Subcommands []agentContextCommand `json:"subcommands,omitempty"`
+}
+
+type agentContextFlag struct {
+	Name    string `json:"name"`
+	Type    string `json:"type"`
+	Usage   string `json:"usage,omitempty"`
+	Default string `json:"default,omitempty"`
+}
+
+func newAgentContextCmd(rootCmd *cobra.Command) *cobra.Command {
+	var pretty bool
+	cmd := &cobra.Command{
+		Use:         "agent-context",
+		Short:       "Emit structured JSON describing this CLI for agents",
+		Annotations: map[string]string{"mcp:read-only": "true"},
+		Long: `Outputs a machine-readable description of commands, flags, and auth so
+agents can introspect this CLI at runtime without parsing --help or
+reading source. Schema is versioned via schema_version.`,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			ctx := buildAgentContext(rootCmd)
+			enc := json.NewEncoder(os.Stdout)
+			if pretty {
+				enc.SetIndent("", "  ")
+			}
+			return enc.Encode(ctx)
+		},
+	}
+	cmd.Flags().BoolVar(&pretty, "pretty", false, "indent JSON output for human reading")
+	return cmd
+}
+
+func buildAgentContext(rootCmd *cobra.Command) agentContext {
+	envVars := []agentContextAuthEnvVar{
+		{
+			Name:      "RICH_AUTH_API_KEY",
+			Kind:      "per_call",
+			Required:  true,
+			Sensitive: true,
+			Description: "Set to your API credential.",
+		},
+		{
+			Name:      "RICH_AUTH_CLIENT_ID",
+			Kind:      "auth_flow_input",
+			Required:  false,
+			Sensitive: false,
+			Description: "OAuth application client identifier.",
+		},
+		{
+			Name:      "RICH_AUTH_CLIENT_SECRET",
+			Kind:      "auth_flow_input",
+			Required:  false,
+			Sensitive: true,
+			Description: "Set during initial auth setup.",
+		},
+		{
+			Name:      "RICH_AUTH_OPTIONAL_TOKEN",
+			Kind:      "per_call",
+			Required:  false,
+			Sensitive: true,
+			Description: "Set to your API credential.",
+		},
+		{
+			Name:      "RICH_AUTH_BOT_TOKEN",
+			Kind:      "per_call",
+			Required:  false,
+			Sensitive: true,
+			Description: "Set to your API credential.",
+		},
+		{
+			Name:      "RICH_AUTH_USER_TOKEN",
+			Kind:      "per_call",
+			Required:  false,
+			Sensitive: true,
+			Description: "Set to your API credential.",
+		},
+	}
+	authMode := "api_key"
+	if authMode == "" {
+		authMode = "none"
+	}
+	profiles := ListProfileNames()
+	if profiles == nil {
+		profiles = []string{}
+	}
+	return agentContext{
+		SchemaVersion: agentContextSchemaVersion,
+		CLI: agentContextCLI{
+			Name:        "printing-press-rich-pp-cli",
+			Description: "Purpose-built fixture for rich auth env-var model coverage.",
+			Version:     rootCmd.Version,
+		},
+		Auth: agentContextAuth{
+			Mode:    authMode,
+			EnvVars: envVars,
+		},
+		Discovery:                  buildAgentDiscoveryContext(),
+		Commands:                   collectAgentCommands(rootCmd),
+		AvailableProfiles:          profiles,
+		FeedbackEndpointConfigured: FeedbackEndpointConfigured(),
+	}
+}
+
+func buildAgentDiscoveryContext() *agentContextDiscovery {
+	return nil
+}
+
+// collectAgentCommands walks the cobra tree from the given command and
+// returns its direct children (skipping hidden commands and the
+// agent-context command itself to avoid self-reference). Each child is
+// recursed into if it has subcommands. Flags are captured via VisitAll.
+// Output is sorted by command name for stable diffs across regenerations.
+func collectAgentCommands(c *cobra.Command) []agentContextCommand {
+	children := c.Commands()
+	sort.Slice(children, func(i, j int) bool { return children[i].Name() < children[j].Name() })
+
+	out := make([]agentContextCommand, 0, len(children))
+	for _, sub := range children {
+		if sub.Hidden || sub.Name() == "agent-context" {
+			continue
+		}
+		entry := agentContextCommand{
+			Name:  sub.Name(),
+			Use:   sub.Use,
+			Short: sub.Short,
+		}
+		// Surface Cobra annotations (e.g., pp:endpoint, mcp:read-only) so
+		// agents and the live-dogfood classifier can detect destructive-at-auth
+		// endpoints without parsing source. Empty maps are stripped via
+		// omitempty in the struct tag.
+		if len(sub.Annotations) > 0 {
+			entry.Annotations = make(map[string]string, len(sub.Annotations))
+			for k, v := range sub.Annotations {
+				entry.Annotations[k] = v
+			}
+		}
+		sub.Flags().VisitAll(func(f *pflag.Flag) {
+			entry.Flags = append(entry.Flags, agentContextFlag{
+				Name:    f.Name,
+				Type:    f.Value.Type(),
+				Usage:   f.Usage,
+				Default: f.DefValue,
+			})
+		})
+		sort.Slice(entry.Flags, func(i, j int) bool {
+			return entry.Flags[i].Name < entry.Flags[j].Name
+		})
+		if len(sub.Commands()) > 0 {
+			entry.Subcommands = collectAgentCommands(sub)
+		}
+		out = append(out, entry)
+	}
+	return out
+}
diff --git a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/auth.go b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/auth.go
new file mode 100644
index 00000000..523863c7
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/auth.go
@@ -0,0 +1,176 @@
+// 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 (
+	"fmt"
+	"os"
+
+	"printing-press-rich-pp-cli/internal/config"
+	"github.com/spf13/cobra"
+)
+
+func newAuthCmd(flags *rootFlags) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "auth",
+		Short: "Manage authentication for Printing Press Rich",
+	}
+
+	cmd.AddCommand(newAuthStatusCmd(flags))
+	cmd.AddCommand(newAuthSetTokenCmd(flags))
+	cmd.AddCommand(newAuthLogoutCmd(flags))
+
+	return cmd
+}
+
+func newAuthStatusCmd(flags *rootFlags) *cobra.Command {
+	return &cobra.Command{
+		Use:   "status",
+		Short: "Show authentication status",
+		Example: "  printing-press-rich-pp-cli auth status",
+		RunE: func(cmd *cobra.Command, args []string) error {
+			cfg, err := config.Load(flags.configPath)
+			if err != nil {
+				return configErr(err)
+			}
+
+			w := cmd.OutOrStdout()
+			header := cfg.AuthHeader()
+			authed := header != ""
+			// JSON envelope: {authenticated, source, config}. When not
+			// authenticated, write the envelope first then return authErr
+			// so exit code carries the auth-failure signal.
+			if flags.asJSON {
+				out := map[string]any{
+					"authenticated": authed,
+					"source":        cfg.AuthSource,
+					"config":        cfg.Path,
+				}
+				if printErr := printJSONFiltered(w, out, flags); printErr != nil {
+					return printErr
+				}
+				if !authed {
+					return authErr(fmt.Errorf("no credentials configured"))
+				}
+				return nil
+			}
+			if !authed {
+				fmt.Fprintln(w, red("Not authenticated"))
+				fmt.Fprintln(w, "")
+				fmt.Fprintln(w, "Set your token:")
+				fmt.Fprintln(w, "  export RICH_AUTH_API_KEY=\"your-token-here\"")
+				fmt.Fprintln(w, "  export RICH_AUTH_CLIENT_ID=\"your-token-here\"")
+				fmt.Fprintln(w, "  export RICH_AUTH_CLIENT_SECRET=\"your-token-here\"")
+				fmt.Fprintln(w, "  export RICH_AUTH_SESSION_COOKIE=\"your-token-here\"")
+				fmt.Fprintln(w, "  export RICH_AUTH_OPTIONAL_TOKEN=\"your-token-here\"")
+				fmt.Fprintln(w, "  export RICH_AUTH_BOT_TOKEN=\"your-token-here\"")
+				fmt.Fprintln(w, "  export RICH_AUTH_USER_TOKEN=\"your-token-here\"")
+				fmt.Fprintf(w, "  printing-press-rich-pp-cli auth set-token <token>\n")
+				return authErr(fmt.Errorf("no credentials configured"))
+			}
+
+			fmt.Fprintln(w, green("Authenticated"))
+			fmt.Fprintf(w, "  Source: %s\n", cfg.AuthSource)
+			fmt.Fprintf(w, "  Config: %s\n", cfg.Path)
+			return nil
+		},
+	}
+}
+
+func newAuthSetTokenCmd(flags *rootFlags) *cobra.Command {
+	return &cobra.Command{
+		Use:   "set-token <token>",
+		Short: "Save an API token to the config file",
+		Example: "  printing-press-rich-pp-cli auth set-token sk_live_abc123",
+		Args:  cobra.ExactArgs(1),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			cfg, err := config.Load(flags.configPath)
+			if err != nil {
+				return configErr(err)
+			}
+
+			// Clear any legacy auth_header so AuthHeader() falls through to
+			// "Bearer " + AccessToken with the new token. Without this, a
+			// pre-existing auth_header value (common after regenerate) shadows
+			// the newly-saved access_token and set-token silently has no effect.
+			// Silent clear (no log line): a masked-tail variant could leak
+			// token bytes through scripted dogfood that captures stderr.
+			cfg.AuthHeaderVal = ""
+
+			// Save the token directly via the config's save mechanism
+			if err := cfg.SaveTokens("", "", args[0], "", cfg.TokenExpiry); err != nil {
+				return configErr(fmt.Errorf("saving token: %w", err))
+			}
+
+			// JSON envelope: {saved, config_path}.
+			if flags.asJSON {
+				return printJSONFiltered(cmd.OutOrStdout(), map[string]any{
+					"saved":       true,
+					"config_path": cfg.Path,
+				}, flags)
+			}
+			fmt.Fprintf(cmd.OutOrStdout(), "Token saved to %s\n", cfg.Path)
+			return nil
+		},
+	}
+}
+
+func newAuthLogoutCmd(flags *rootFlags) *cobra.Command {
+	return &cobra.Command{
+		Use:   "logout",
+		Short: "Clear stored credentials",
+		Example: "  printing-press-rich-pp-cli auth logout",
+		RunE: func(cmd *cobra.Command, args []string) error {
+			cfg, err := config.Load(flags.configPath)
+			if err != nil {
+				return configErr(err)
+			}
+
+			if err := cfg.ClearTokens(); err != nil {
+				return configErr(fmt.Errorf("clearing tokens: %w", err))
+			}
+
+			// Identify which (if any) auth env var is still exported so the
+			// JSON envelope and the human prose can both surface it.
+			envStillSet := ""
+			if envStillSet == "" && os.Getenv("RICH_AUTH_API_KEY") != "" {
+				envStillSet = "RICH_AUTH_API_KEY"
+			}
+			if envStillSet == "" && os.Getenv("RICH_AUTH_CLIENT_ID") != "" {
+				envStillSet = "RICH_AUTH_CLIENT_ID"
+			}
+			if envStillSet == "" && os.Getenv("RICH_AUTH_CLIENT_SECRET") != "" {
+				envStillSet = "RICH_AUTH_CLIENT_SECRET"
+			}
+			if envStillSet == "" && os.Getenv("RICH_AUTH_SESSION_COOKIE") != "" {
+				envStillSet = "RICH_AUTH_SESSION_COOKIE"
+			}
+			if envStillSet == "" && os.Getenv("RICH_AUTH_OPTIONAL_TOKEN") != "" {
+				envStillSet = "RICH_AUTH_OPTIONAL_TOKEN"
+			}
+			if envStillSet == "" && os.Getenv("RICH_AUTH_BOT_TOKEN") != "" {
+				envStillSet = "RICH_AUTH_BOT_TOKEN"
+			}
+			if envStillSet == "" && os.Getenv("RICH_AUTH_USER_TOKEN") != "" {
+				envStillSet = "RICH_AUTH_USER_TOKEN"
+			}
+
+			// JSON envelope: {cleared: true, note?: "<env_var> env var is still set"}.
+			if flags.asJSON {
+				out := map[string]any{"cleared": true}
+				if envStillSet != "" {
+					out["note"] = envStillSet + " env var is still set"
+				}
+				return printJSONFiltered(cmd.OutOrStdout(), out, flags)
+			}
+
+			if envStillSet != "" {
+				fmt.Fprintf(cmd.OutOrStdout(), "Config cleared. Note: %s env var is still set.\n", envStillSet)
+				return nil
+			}
+			fmt.Fprintln(cmd.OutOrStdout(), "Logged out. Credentials cleared.")
+			return nil
+		},
+	}
+}
diff --git a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/doctor.go b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/doctor.go
new file mode 100644
index 00000000..e39aa22f
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/doctor.go
@@ -0,0 +1,521 @@
+// 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"
+
+	"printing-press-rich-pp-cli/internal/client"
+	"printing-press-rich-pp-cli/internal/config"
+	"printing-press-rich-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",
+		Example: `  printing-press-rich-pp-cli doctor
+  printing-press-rich-pp-cli doctor --json
+  printing-press-rich-pp-cli doctor --fail-on warn`,
+		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 RICH_AUTH_API_KEY=<your-key>"
+				} else {
+					report["auth"] = "configured"
+					report["auth_source"] = cfg.AuthSource
+				}
+			}
+
+			// Check auth environment variables
+			authEnvSet := []string{}
+			authEnvRequiredMissing := []string{}
+			authEnvInfo := []string{}
+			authEnvOptionalNames := []string{}
+			// Validation rejects multi-OR-group specs upstream, so the single optional-satisfied state is sufficient at runtime.
+			authEnvOptionalSatisfied := false
+			authHeader := ""
+			if cfg != nil {
+				authHeader = cfg.AuthHeader()
+			}
+			if os.Getenv("RICH_AUTH_API_KEY") != "" {
+				authEnvSet = append(authEnvSet, "RICH_AUTH_API_KEY")
+			} else {
+				authEnvRequiredMissing = append(authEnvRequiredMissing, "RICH_AUTH_API_KEY")
+			}
+			if os.Getenv("RICH_AUTH_CLIENT_ID") != "" {
+				authEnvSet = append(authEnvSet, "RICH_AUTH_CLIENT_ID")
+			} else {
+				authEnvInfo = append(authEnvInfo, "RICH_AUTH_CLIENT_ID set during auth login")
+			}
+			if os.Getenv("RICH_AUTH_CLIENT_SECRET") != "" {
+				authEnvSet = append(authEnvSet, "RICH_AUTH_CLIENT_SECRET")
+			} else {
+				authEnvInfo = append(authEnvInfo, "RICH_AUTH_CLIENT_SECRET set during auth login")
+			}
+			if os.Getenv("RICH_AUTH_SESSION_COOKIE") != "" || authHeader != "" {
+				authEnvSet = append(authEnvSet, "RICH_AUTH_SESSION_COOKIE")
+			} else {
+				authEnvInfo = append(authEnvInfo, "RICH_AUTH_SESSION_COOKIE set with auth set-token")
+			}
+			if strings.Contains("Optional token for elevated read limits.", " OR ") {
+				authEnvOptionalNames = append(authEnvOptionalNames, "RICH_AUTH_OPTIONAL_TOKEN")
+				if os.Getenv("RICH_AUTH_OPTIONAL_TOKEN") != "" {
+					authEnvSet = append(authEnvSet, "RICH_AUTH_OPTIONAL_TOKEN")
+					authEnvOptionalSatisfied = true
+				}
+			} else if os.Getenv("RICH_AUTH_OPTIONAL_TOKEN") != "" {
+				authEnvSet = append(authEnvSet, "RICH_AUTH_OPTIONAL_TOKEN")
+			} else {
+				authEnvInfo = append(authEnvInfo, "RICH_AUTH_OPTIONAL_TOKEN optional")
+			}
+			if strings.Contains("Set this OR RICH_AUTH_USER_TOKEN for workspace access.", " OR ") {
+				authEnvOptionalNames = append(authEnvOptionalNames, "RICH_AUTH_BOT_TOKEN")
+				if os.Getenv("RICH_AUTH_BOT_TOKEN") != "" {
+					authEnvSet = append(authEnvSet, "RICH_AUTH_BOT_TOKEN")
+					authEnvOptionalSatisfied = true
+				}
+			} else if os.Getenv("RICH_AUTH_BOT_TOKEN") != "" {
+				authEnvSet = append(authEnvSet, "RICH_AUTH_BOT_TOKEN")
+			} else {
+				authEnvInfo = append(authEnvInfo, "RICH_AUTH_BOT_TOKEN optional")
+			}
+			if strings.Contains("Set this OR RICH_AUTH_BOT_TOKEN for workspace access.", " OR ") {
+				authEnvOptionalNames = append(authEnvOptionalNames, "RICH_AUTH_USER_TOKEN")
+				if os.Getenv("RICH_AUTH_USER_TOKEN") != "" {
+					authEnvSet = append(authEnvSet, "RICH_AUTH_USER_TOKEN")
+					authEnvOptionalSatisfied = true
+				}
+			} else if os.Getenv("RICH_AUTH_USER_TOKEN") != "" {
+				authEnvSet = append(authEnvSet, "RICH_AUTH_USER_TOKEN")
+			} else {
+				authEnvInfo = append(authEnvInfo, "RICH_AUTH_USER_TOKEN optional")
+			}
+			switch {
+			case len(authEnvRequiredMissing) > 0:
+				report["env_vars"] = "ERROR missing required: " + strings.Join(authEnvRequiredMissing, ", ")
+			case len(authEnvOptionalNames) > 1 && !authEnvOptionalSatisfied:
+				report["env_vars"] = "INFO set one of: " + strings.Join(authEnvOptionalNames, " or ")
+			case len(authEnvInfo) > 0:
+				report["env_vars"] = "INFO " + strings.Join(authEnvInfo, "; ")
+			default:
+				report["env_vars"] = fmt.Sprintf("OK %d/%d available", len(authEnvSet), 7)
+			}
+
+			// 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"] = "printing-press-rich-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"},
+				{"env_vars", "Env Vars"},
+				{"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, "INFO"):
+					indicator = yellow("INFO")
+				case strings.HasPrefix(s, "ERROR"):
+					indicator = red("FAIL")
+				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("printing-press-rich-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 'printing-press-rich-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 'printing-press-rich-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 'printing-press-rich-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 'printing-press-rich-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-golden-api-rich-auth/printing-press-rich-auth/internal/cli/helpers.go b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/helpers.go
new file mode 100644
index 00000000..5910fdd7
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/helpers.go
@@ -0,0 +1,1198 @@
+// 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"
+	"errors"
+	"fmt"
+	"io"
+	"os"
+	"path/filepath"
+	"regexp"
+	"sort"
+	"strings"
+	"text/tabwriter"
+	"time"
+	"unicode"
+	"printing-press-rich-pp-cli/internal/cliutil"
+	"printing-press-rich-pp-cli/internal/client"
+	"github.com/spf13/cobra"
+	"github.com/spf13/pflag"
+)
+
+var As = errors.As
+
+// noColor is set by the --no-color flag
+var noColor bool
+
+// humanFriendly is set by the --human-friendly flag; colors are off by default (agent-safe)
+var humanFriendly bool
+
+func colorEnabled() bool {
+	if noColor {
+		return false
+	}
+	if !humanFriendly {
+		return false
+	}
+	if os.Getenv("NO_COLOR") != "" {
+		return false
+	}
+	if os.Getenv("TERM") == "dumb" {
+		return false
+	}
+	return isTerminal(os.Stdout)
+}
+
+func isTerminal(w io.Writer) bool {
+	if f, ok := w.(*os.File); ok {
+		fi, err := f.Stat()
+		if err != nil {
+			return true
+		}
+		return (fi.Mode() & os.ModeCharDevice) != 0
+	}
+	return false
+}
+
+func bold(s string) string {
+	if !colorEnabled() {
+		return s
+	}
+	return "\033[1m" + s + "\033[0m"
+}
+
+func green(s string) string {
+	if !colorEnabled() {
+		return s
+	}
+	return "\033[32m" + s + "\033[0m"
+}
+
+func red(s string) string {
+	if !colorEnabled() {
+		return s
+	}
+	return "\033[31m" + s + "\033[0m"
+}
+
+func yellow(s string) string {
+	if !colorEnabled() {
+		return s
+	}
+	return "\033[33m" + s + "\033[0m"
+}
+
+type cliError struct {
+	code int
+	err  error
+}
+
+func (e *cliError) Error() string { return e.err.Error() }
+func (e *cliError) Unwrap() error { return e.err }
+
+func usageErr(err error) error    { return &cliError{code: 2, err: err} }
+func notFoundErr(err error) error { return &cliError{code: 3, err: err} }
+func authErr(err error) error     { return &cliError{code: 4, err: err} }
+func apiErr(err error) error      { return &cliError{code: 5, err: err} }
+func configErr(err error) error   { return &cliError{code: 10, err: err} }
+func rateLimitErr(err error) error { return &cliError{code: 7, err: err} }
+
+// dryRunOK reports whether the command should short-circuit without doing any
+// real work because --dry-run was set. The verify pipeline probes hand-written
+// commands with --dry-run; commands that put validation in cobra's `Args:` or
+// `MarkFlagRequired` cannot reach a dry-run guard inside RunE because cobra
+// runs those checks before RunE. The verify-friendly pattern for hand-written
+// commands is:
+//
+//	RunE: func(cmd *cobra.Command, args []string) error {
+//	    if len(args) == 0 {
+//	        return cmd.Help()
+//	    }
+//	    if dryRunOK(flags) {
+//	        return nil
+//	    }
+//	    // ... real work ...
+//	}
+//
+// See SKILL.md "Phase 3: Build The GOAT" for the full pattern.
+func dryRunOK(flags *rootFlags) bool {
+	return flags != nil && flags.dryRun
+}
+
+// accessWarning describes an API access-denial that sync converts into a
+// non-fatal warning. It carries enough structured data for the sync_warning
+// JSON event without parsing free-form error strings downstream.
+type accessWarning struct {
+	Status  int    // HTTP status when applicable; 0 for GraphQL field-level denials.
+	Reason  string // "forbidden" | "insufficient_access" | "unauthenticated"
+	Message string // human-readable detail (the API's body or GraphQL error message)
+}
+
+// accessDenialPatterns matches API error bodies that indicate the request was
+// rejected for access-policy reasons rather than for input validity. Matching
+// is case-insensitive and uses word boundaries so common substrings inside
+// unrelated tokens (e.g. "author", "pagination_token", "insufficient_funds")
+// do not produce false positives. The set deliberately excludes brand names —
+// vendor-specific phrasings should be addressed at the spec/profiler level,
+// not in this universal classifier.
+var accessDenialPatterns = []*regexp.Regexp{
+	regexp.MustCompile(`\bforbidden\b`),
+	regexp.MustCompile(`\bunauthorized\b`),
+	regexp.MustCompile(`\bnot[\s_-]?authorized\b`),
+	regexp.MustCompile(`\bpermission[\s_-]?denied\b`),
+	regexp.MustCompile(`\baccess[\s_-]?denied\b`),
+	regexp.MustCompile(`\binsufficient[\s_-]?(scope|permission|privilege)`),
+	regexp.MustCompile(`\binvalid[\s_-]?scope\b`),
+	regexp.MustCompile(`\bmissing[\s_-]?scope\b`),
+	regexp.MustCompile(`\brequires?\s+(elevated|admin|enterprise|business|workspace|enterprise[\s_-]?tier)`),
+}
+
+// looksLikeAccessDenial reports whether body text describes an access-policy
+// rejection. Use it on response-body content (apiErr.Body), not on the full
+// error string — the request path can contain words like "auth" or "tokens"
+// that would produce false positives if the whole error message were scanned.
+func looksLikeAccessDenial(body string) bool {
+	lower := strings.ToLower(body)
+	for _, p := range accessDenialPatterns {
+		if p.MatchString(lower) {
+			return true
+		}
+	}
+	return false
+}
+
+// isSyncAccessWarning classifies err as an access-denial warning suitable for
+// sync's warn-and-continue path. It returns nil, false for any error that
+// should remain a hard sync failure: HTTP 401 (token-level auth failure
+// requiring re-auth), 5xx, network errors, and HTTP 400 responses whose
+// bodies do not match an access-policy pattern.
+//
+// Recognized warning shapes:
+//   - HTTP 403 (per-resource ACL rejection)
+//   - HTTP 400 + access-denial body keyword (insufficient scope, etc.)
+//   - GraphQL response carrying only access-denial extension codes
+func isSyncAccessWarning(err error) (*accessWarning, bool) {
+	if err == nil {
+		return nil, false
+	}
+
+	var apiErr *client.APIError
+	if errors.As(err, &apiErr) {
+		switch apiErr.StatusCode {
+		case 403:
+			return &accessWarning{Status: 403, Reason: "forbidden", Message: apiErr.Body}, true
+		case 400:
+			if looksLikeAccessDenial(apiErr.Body) {
+				return &accessWarning{Status: 400, Reason: "insufficient_access", Message: apiErr.Body}, true
+			}
+		}
+	}
+
+	return nil, false
+}
+
+type noopResult struct {
+	Status string `json:"status"`
+	Reason string `json:"reason"`
+}
+
+func writeNoop(flags *rootFlags, reason, prose string) error {
+	if flags != nil && flags.asJSON {
+		return json.NewEncoder(os.Stdout).Encode(noopResult{Status: "noop", Reason: reason})
+	}
+	fmt.Fprintln(os.Stderr, prose)
+	return nil
+}
+
+func writeAPIErrorEnvelope(flags *rootFlags, err error, code int) {
+	if flags == nil || !flags.asJSON {
+		return
+	}
+	_ = json.NewEncoder(os.Stdout).Encode(map[string]any{
+		"error": err.Error(),
+		"code":  code,
+	})
+}
+
+// classifyAPIError maps API errors to structured exit codes with actionable hints.
+func classifyAPIError(err error, flags *rootFlags) error {
+	msg := err.Error()
+	switch {
+	case strings.Contains(msg, "HTTP 409"):
+		if flags != nil && flags.idempotent {
+			return writeNoop(flags, "already_exists", "already exists (no-op)")
+		}
+		classified := apiErr(err)
+		writeAPIErrorEnvelope(flags, classified, ExitCode(classified))
+		return classified
+	case strings.Contains(msg, "HTTP 400") && cliutil.LooksLikeAuthError(msg):
+		return authErr(fmt.Errorf("%w\nhint: the API rejected the request — this usually means auth is missing or invalid."+
+			"\n      Set your API key: export RICH_AUTH_API_KEY=<your-key>"+
+			"\n      Run 'printing-press-rich-pp-cli doctor' to check auth status."+
+			"\n      Response: "+cliutil.SanitizeErrorBody(msg), err))
+	case strings.Contains(msg, "HTTP 401"):
+		return authErr(fmt.Errorf("%w\nhint: check your API key."+
+			" Set it with: export RICH_AUTH_API_KEY=<your-key>"+
+			"\n      Run 'printing-press-rich-pp-cli doctor' to check auth status.", err))
+	case strings.Contains(msg, "HTTP 403"):
+		return authErr(fmt.Errorf("%w\nhint: permission denied. Your credentials are valid but lack access to this resource."+
+			"\n      Check that your API key has the required permissions."+
+			"\n      Set it with: export RICH_AUTH_API_KEY=<your-key>"+
+			"\n      Run 'printing-press-rich-pp-cli doctor' to check auth status.", err))
+	case strings.Contains(msg, "HTTP 404"):
+		return notFoundErr(fmt.Errorf("%w\nhint: resource not found. Run the 'list' command to see available items", err))
+	case strings.Contains(msg, "HTTP 429"):
+		return rateLimitErr(err)
+	default:
+		return apiErr(err)
+	}
+}
+
+func truncate(s string, max int) string {
+	if len(s) <= max {
+		return s
+	}
+	if max <= 3 {
+		return s[:max]
+	}
+	return s[:max-3] + "..."
+}
+
+func newTabWriter(w io.Writer) *tabwriter.Writer {
+	return tabwriter.NewWriter(w, 2, 4, 2, ' ', 0)
+}
+
+// paginatedGet fetches pages and concatenates array results. The headers
+// argument carries per-endpoint required headers (e.g. cal-api-version) that
+// must be sent on every page request, including the first; pass nil when the
+// endpoint has no per-endpoint header overrides.
+func paginatedGet(c interface {
+	GetWithHeaders(path string, params map[string]string, headers map[string]string) (json.RawMessage, error)
+}, path string, params map[string]string, headers map[string]string, fetchAll bool, cursorParam, nextCursorPath, hasMoreField string) (json.RawMessage, error) {
+	// Clean zero-value params
+	clean := map[string]string{}
+	for k, v := range params {
+		if v != "" && v != "0" && v != "false" {
+			clean[k] = v
+		}
+	}
+
+	if !fetchAll {
+		return c.GetWithHeaders(path, clean, headers)
+	}
+
+	// Fetch all pages
+	var allItems []json.RawMessage
+	page := 0
+	for {
+		page++
+		if humanFriendly {
+			fmt.Fprintf(os.Stderr, "fetching page %d...\n", page)
+		} else {
+			fmt.Fprintf(os.Stderr, `{"event":"page_fetch","page":%d}`+"\n", page)
+		}
+
+		data, err := c.GetWithHeaders(path, clean, headers)
+		if err != nil {
+			return nil, err
+		}
+
+		// Try to extract items array
+		var items []json.RawMessage
+		if json.Unmarshal(data, &items) == nil {
+			allItems = append(allItems, items...)
+		} else {
+			// Response is an object - look for array inside
+			var obj map[string]json.RawMessage
+			if json.Unmarshal(data, &obj) == nil {
+				// Try common data fields
+				for _, field := range []string{"data", "items", "results", "messages", "members", "values"} {
+					if arr, ok := obj[field]; ok {
+						var nested []json.RawMessage
+						if json.Unmarshal(arr, &nested) == nil {
+							allItems = append(allItems, nested...)
+							break
+						}
+					}
+				}
+
+				// Check for next cursor
+				if nextCursorPath != "" {
+					if tokenRaw, ok := obj[nextCursorPath]; ok {
+						var token string
+						if json.Unmarshal(tokenRaw, &token) == nil && token != "" {
+							clean[cursorParam] = token
+							continue
+						}
+					}
+				}
+
+				// Check has_more
+				if hasMoreField != "" {
+					if moreRaw, ok := obj[hasMoreField]; ok {
+						var more bool
+						if json.Unmarshal(moreRaw, &more) == nil && more {
+							continue
+						}
+					}
+				}
+			}
+			// No more pages
+			break
+		}
+
+		// For direct arrays, can't paginate without cursor
+		break
+	}
+
+	if humanFriendly {
+		fmt.Fprintf(os.Stderr, "fetched %d items across %d pages\n", len(allItems), page)
+	} else {
+		fmt.Fprintf(os.Stderr, `{"event":"complete","total":%d,"pages":%d}`+"\n", len(allItems), page)
+	}
+	result, _ := json.Marshal(allItems)
+	return json.RawMessage(result), nil
+}
+
+// printJSONFiltered marshals a Go-typed value through the same output
+// pipeline endpoint-mirror commands use. Hand-written novel commands that
+// build a typed slice/struct call this so --select, --compact, --csv, and
+// --quiet all behave the same way as on generator-emitted commands.
+func printJSONFiltered(w io.Writer, v any, flags *rootFlags) error {
+	raw, err := json.Marshal(v)
+	if err != nil {
+		return err
+	}
+	return printOutputWithFlags(w, json.RawMessage(raw), flags)
+}
+
+// filterFields keeps only the specified fields (comma-separated) from JSON objects/arrays.
+// Supports dotted paths like "events.shortName" to descend into nested structures.
+// Arrays are traversed element-wise: "events.shortName" keeps shortName on each event.
+func filterFields(data json.RawMessage, fields string) json.RawMessage {
+	var paths [][]string
+	for _, f := range strings.Split(fields, ",") {
+		f = strings.TrimSpace(f)
+		if f == "" {
+			continue
+		}
+		parts := strings.Split(f, ".")
+		for i := range parts {
+			parts[i] = strings.ToLower(parts[i])
+		}
+		paths = append(paths, parts)
+	}
+	if len(paths) == 0 {
+		return data
+	}
+	return filterFieldsRec(data, paths)
+}
+
+// filterFieldsRec applies path filters to a JSON value. Each path is a list of
+// lowercase segments; arrays descend element-wise.
+func filterFieldsRec(data json.RawMessage, paths [][]string) json.RawMessage {
+	var arr []json.RawMessage
+	if err := json.Unmarshal(data, &arr); err == nil {
+		out := make([]json.RawMessage, len(arr))
+		for i, el := range arr {
+			out[i] = filterFieldsRec(el, paths)
+		}
+		result, _ := json.Marshal(out)
+		return result
+	}
+
+	var obj map[string]json.RawMessage
+	if err := json.Unmarshal(data, &obj); err == nil {
+		keepWhole := map[string]bool{}
+		subPaths := map[string][][]string{}
+		for _, p := range paths {
+			if len(p) == 0 {
+				continue
+			}
+			head := p[0]
+			if len(p) == 1 {
+				keepWhole[head] = true
+			} else {
+				subPaths[head] = append(subPaths[head], p[1:])
+			}
+		}
+		filtered := map[string]json.RawMessage{}
+		for k, v := range obj {
+			matched := matchSelectSegment(k, keepWhole, subPaths)
+			if matched == "" {
+				continue
+			}
+			if keepWhole[matched] {
+				filtered[k] = v
+				continue
+			}
+			if subs := subPaths[matched]; subs != nil {
+				filtered[k] = filterFieldsRec(v, subs)
+			}
+		}
+		result, _ := json.Marshal(filtered)
+		return result
+	}
+
+	return data
+}
+
+// matchSelectSegment returns the matching lowercase segment, or "" if no match.
+// Supports direct case-insensitive match and camelCase→kebab-case conversion.
+func matchSelectSegment(fieldName string, keepWhole map[string]bool, subPaths map[string][][]string) string {
+	lower := strings.ToLower(fieldName)
+	if keepWhole[lower] || subPaths[lower] != nil {
+		return lower
+	}
+	kebab := camelToKebab(fieldName)
+	if kebab != lower && (keepWhole[kebab] || subPaths[kebab] != nil) {
+		return kebab
+	}
+	return ""
+}
+
+// camelToKebab converts "orderDate" or "orderdate" to "order-date" by splitting on
+// uppercase boundaries. For already-lowercase input, splits on known word boundaries.
+func camelToKebab(s string) string {
+	var b strings.Builder
+	runes := []rune(s)
+	for i, r := range runes {
+		if i > 0 && unicode.IsUpper(r) && unicode.IsLower(runes[i-1]) {
+			b.WriteByte('-')
+		}
+		b.WriteRune(unicode.ToLower(r))
+	}
+	return b.String()
+}
+
+// printOutputWithFlags routes output through the right format based on flags.
+func printOutputWithFlags(w io.Writer, data json.RawMessage, flags *rootFlags) error {
+	// --select wins over --compact when both are set: an explicit field list
+	// is the user's authoritative request, so the high-gravity allow-list
+	// must not strip those fields out before --select can pick them. When
+	// only --compact is set (e.g., --agent without --select), the allow-list
+	// still runs.
+	if flags.selectFields != "" {
+		data = filterFields(data, flags.selectFields)
+	} else if flags.compact {
+		data = compactFields(data)
+	}
+	// --quiet: suppress all output, exit code communicates result
+	if flags.quiet {
+		return nil
+	}
+	// --csv: render as CSV
+	if flags.csv {
+		return printCSV(w, data)
+	}
+	return printOutput(w, data, flags.asJSON)
+}
+
+// extractResponseData unwraps common API response envelopes for display.
+// Many APIs return {"status":"success","data":[...]} instead of a bare array.
+// This extracts the inner data for output helpers (filterFields, compactFields,
+// printAutoTable) that expect arrays or flat objects.
+//
+// Only unwraps when a "status" field is present and indicates success — this
+// avoids false positives on APIs where "data" is a regular field (e.g., Stripe
+// returns {"data":[...],"has_more":true} where "data" is the list, not an
+// envelope wrapper).
+func extractResponseData(data json.RawMessage) json.RawMessage {
+	var envelope struct {
+		Status string          `json:"status"`
+		Data   json.RawMessage `json:"data"`
+	}
+	if err := json.Unmarshal(data, &envelope); err != nil {
+		return data
+	}
+	if envelope.Data == nil || envelope.Status == "" {
+		return data // No status field = not an envelope, might be regular "data" field
+	}
+	switch envelope.Status {
+	case "success", "ok", "OK", "Success":
+		return envelope.Data
+	default:
+		return data
+	}
+}
+
+// compactFields keeps only the most important fields for agent consumption.
+// For arrays: allowlist of high-gravity fields (no descriptions).
+// For single objects: blocklist that strips known-verbose fields (descriptions, comments, etc.).
+func compactFields(data json.RawMessage) json.RawMessage {
+	// Try array first
+	var items []map[string]any
+	if err := json.Unmarshal(data, &items); err == nil {
+		return compactListFields(items)
+	}
+
+	// Single object — use blocklist
+	var obj map[string]any
+	if err := json.Unmarshal(data, &obj); err == nil {
+		return compactObjectFields(obj)
+	}
+
+	return data
+}
+
+// compactListFields keeps only high-gravity fields for array responses.
+func compactListFields(items []map[string]any) json.RawMessage {
+	keepFields := map[string]bool{
+		"id": true, "name": true, "title": true, "identifier": true,
+		"status": true, "state": true, "type": true, "priority": true,
+		"url": true, "email": true, "key": true,
+		"created_at": true, "updated_at": true, "createdAt": true, "updatedAt": true,
+	}
+
+	filtered := make([]map[string]any, 0, len(items))
+	for _, item := range items {
+		compact := map[string]any{}
+		for k, v := range item {
+			if keepFields[k] {
+				compact[k] = v
+			}
+		}
+		filtered = append(filtered, compact)
+	}
+	result, _ := json.Marshal(filtered)
+	return result
+}
+
+// compactObjectFields strips known-verbose fields from single-object responses.
+// Uses a blocklist so it works across all API domains (project management, payments, CRM, etc.).
+func compactObjectFields(obj map[string]any) json.RawMessage {
+	stripFields := map[string]bool{
+		"description": true, "body": true, "content": true,
+		"comments": true, "attachments": true, "html": true, "markdown": true,
+	}
+
+	compact := map[string]any{}
+	for k, v := range obj {
+		if !stripFields[k] {
+			compact[k] = v
+		}
+	}
+	result, _ := json.Marshal(compact)
+	return result
+}
+
+// printCSV renders JSON arrays as CSV with header row.
+func printCSV(w io.Writer, data json.RawMessage) error {
+	var items []map[string]any
+	if err := json.Unmarshal(data, &items); err != nil || len(items) == 0 {
+		// Single object or empty - just print as JSON
+		fmt.Fprintln(w, string(data))
+		return nil
+	}
+	// Collect all keys for header
+	keySet := map[string]bool{}
+	for _, item := range items {
+		for k := range item {
+			keySet[k] = true
+		}
+	}
+	var keys []string
+	for k := range keySet {
+		keys = append(keys, k)
+	}
+	sort.Strings(keys)
+	// Header
+	fmt.Fprintln(w, strings.Join(keys, ","))
+	// Rows
+	for _, item := range items {
+		var vals []string
+		for _, k := range keys {
+			v := item[k]
+			if v == nil {
+				vals = append(vals, "")
+			} else {
+				s := fmt.Sprintf("%v", v)
+				if strings.ContainsAny(s, ",\"\n") {
+					s = `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
+				}
+				vals = append(vals, s)
+			}
+		}
+		fmt.Fprintln(w, strings.Join(vals, ","))
+	}
+	return nil
+}
+
+// printOutput auto-detects arrays and renders as tables, or prints raw JSON for objects.
+func printOutput(w io.Writer, data json.RawMessage, asJSON bool) error {
+	if !asJSON && !isTerminal(w) {
+		asJSON = true
+	}
+
+	if asJSON {
+		enc := json.NewEncoder(w)
+		enc.SetIndent("", "  ")
+		return enc.Encode(data)
+	}
+
+	// Try to detect if response is an array
+	var items []map[string]any
+	if err := json.Unmarshal(data, &items); err == nil && len(items) > 0 {
+		if err := printAutoTable(w, items); err != nil {
+			return err
+		}
+		// Agent-friendly: show count and suggest narrowing when results are large
+		if len(items) >= 25 {
+			fmt.Fprintf(os.Stderr, "\nShowing %d results. To narrow: add --limit, --json --select, or filter flags.\n", len(items))
+		}
+		return nil
+	}
+
+	// Single object - pretty print
+	var obj map[string]any
+	if err := json.Unmarshal(data, &obj); err == nil {
+		enc := json.NewEncoder(w)
+		enc.SetIndent("", "  ")
+		return enc.Encode(obj)
+	}
+
+	// Fallback: print raw
+	fmt.Fprintln(w, string(data))
+	return nil
+}
+
+// levenshteinDistance computes the edit distance between two strings using a two-row DP approach.
+func levenshteinDistance(a, b string) int {
+	if len(a) == 0 {
+		return len(b)
+	}
+	if len(b) == 0 {
+		return len(a)
+	}
+	if len(a) < len(b) {
+		a, b = b, a
+	}
+	prev := make([]int, len(b)+1)
+	curr := make([]int, len(b)+1)
+	for j := range prev {
+		prev[j] = j
+	}
+	for i := 1; i <= len(a); i++ {
+		curr[0] = i
+		for j := 1; j <= len(b); j++ {
+			cost := 1
+			if a[i-1] == b[j-1] {
+				cost = 0
+			}
+			ins := curr[j-1] + 1
+			del := prev[j] + 1
+			sub := prev[j-1] + cost
+			min := ins
+			if del < min {
+				min = del
+			}
+			if sub < min {
+				min = sub
+			}
+			curr[j] = min
+		}
+		prev, curr = curr, prev
+	}
+	return prev[len(b)]
+}
+
+// suggestFlag returns the closest known flag name to the unknown string, or "" if none is close enough.
+func suggestFlag(unknown string, cmd *cobra.Command) string {
+	unknown = strings.TrimLeft(unknown, "-")
+	best := ""
+	bestDist := 4 // only consider distance <= 3
+	check := func(name string) {
+		d := levenshteinDistance(unknown, name)
+		if d < bestDist && d*5 <= len(unknown)*2 {
+			bestDist = d
+			best = name
+		}
+	}
+	cmd.Flags().VisitAll(func(f *pflag.Flag) {
+		check(f.Name)
+	})
+	cmd.InheritedFlags().VisitAll(func(f *pflag.Flag) {
+		check(f.Name)
+	})
+	return best
+}
+
+// wantsHumanTable returns true when output should be a human-friendly table.
+// Smart default: terminal=table, pipe=JSON.
+// - Human in terminal: isTerminal()=true → table
+// - Claude Code/Codex bash tool: stdout piped → JSON
+// - --json/--csv/--compact/--agent: machine format → JSON
+func wantsHumanTable(w io.Writer, flags *rootFlags) bool {
+	if flags.asJSON || flags.csv || flags.compact || flags.quiet || flags.plain {
+		return false
+	}
+	if flags.selectFields != "" {
+		return false
+	}
+	return isTerminal(w)
+}
+
+func printAutoTable(w io.Writer, items []map[string]any) error {
+	if len(items) == 0 {
+		return nil
+	}
+
+	// Count scalar vs complex fields to decide format
+	scalarCount := 0
+	for _, v := range items[0] {
+		switch v.(type) {
+		case string, float64, bool, nil:
+			scalarCount++
+		}
+	}
+
+	// Use sectional/card layout for complex items (many fields or nested data)
+	if len(items[0]) > 8 || scalarCount < len(items[0])-2 {
+		return printAutoCards(w, items)
+	}
+
+	headers := prioritizeHeaders(items[0])
+
+	// Limit to 6 columns max for readability
+	if len(headers) > 6 {
+		headers = headers[:6]
+	}
+
+	// Build rows
+	rows := make([][]string, 0, len(items))
+	for _, item := range items {
+		row := make([]string, len(headers))
+		for i, h := range headers {
+			row[i] = formatCellValue(item[h])
+		}
+		rows = append(rows, row)
+	}
+
+	// Print with tab alignment using tabwriter
+	tw := newTabWriter(w)
+	upperHeaders := make([]string, len(headers))
+	for i, h := range headers {
+		upperHeaders[i] = bold(strings.ToUpper(h))
+	}
+
+	fmt.Fprintln(tw, strings.Join(upperHeaders, "\t"))
+	for _, row := range rows {
+		fmt.Fprintln(tw, strings.Join(row, "\t"))
+	}
+	return tw.Flush()
+}
+
+// prioritizeHeaders orders scalar fields by importance for table display.
+func prioritizeHeaders(item map[string]any) []string {
+	return prioritizeFields(item, false)
+}
+
+// prioritizeAllHeaders orders all fields (including arrays) by importance for card display.
+func prioritizeAllHeaders(item map[string]any) []string {
+	return prioritizeFields(item, true)
+}
+
+// prioritizeFields orders fields by importance: identity → temporal → status → other.
+// When includeComplex is true, arrays and objects are included (for card layout).
+//
+// Uses exact-or-suffix matching to avoid false positives: "name" matches "Name" and
+// "UserName" but not "BuildingName" (because "Building" is not a known prefix that
+// indicates identity). The field is split on camelCase/snake_case boundaries and the
+// LAST segment is matched against patterns.
+func prioritizeFields(item map[string]any, includeComplex bool) []string {
+	// Priority tiers — matched against the last segment of the field name.
+	// "OrderDate" → last segment "date" → tier 1 (temporal).
+	// "BuildingName" → last segment "name" → tier 0... but we want to avoid this.
+	// Solution: exact match on the full lowered name OR suffix segment match,
+	// with a penalty for compound names that have a non-identity prefix.
+	type pattern struct {
+		word string
+		tier int
+	}
+	// Exact matches (full field name, case-insensitive) — highest confidence
+	exactMatches := map[string]int{
+		"id": 0, "name": 0, "title": 0, "slug": 0, "key": 0,
+		"date": 1, "created": 1, "updated": 1, "createdat": 1, "updatedat": 1,
+		"status": 2, "state": 2, "statuscode": 2,
+		"summary": 3, "description": 3, "price": 3, "amount": 3, "total": 3,
+		"cost": 3, "points": 3, "score": 3,
+		"type": 4, "kind": 4, "category": 4, "email": 4, "phone": 4, "url": 4,
+	}
+	// Suffix patterns — match when the field ends with this word (after splitting)
+	suffixMatches := map[string]int{
+		"id": 0, "name": 0, "title": 0,
+		"date": 1, "time": 1,
+		"status": 2, "state": 2, "code": 2,
+		"price": 3, "amount": 3, "total": 3, "cost": 3,
+		"summary": 3, "description": 3, "points": 3, "score": 3,
+		"type": 4, "kind": 4, "category": 4, "method": 4,
+	}
+
+	numTiers := 5
+
+	type scored struct {
+		name  string
+		tier  int
+		index int
+	}
+
+	var all []scored
+	idx := 0
+	for k, v := range item {
+		if !includeComplex {
+			switch v.(type) {
+			case []any, map[string]any:
+				continue
+			}
+		}
+		// Skip values that won't render usefully in cards
+		if includeComplex {
+			formatted := formatCellValue(v)
+			if formatted == "" {
+				continue
+			}
+		}
+
+		tier := numTiers // default: unclassified
+		lower := strings.ToLower(k)
+
+		// 1. Exact match on full field name
+		if t, ok := exactMatches[lower]; ok {
+			tier = t
+		} else {
+			// 2. Split camelCase into segments and match the last one
+			segments := splitCamelCase(lower)
+			if len(segments) > 0 {
+				lastSeg := segments[len(segments)-1]
+				if t, ok := suffixMatches[lastSeg]; ok {
+					// Compound names with identity suffixes (BuildingName, TipTime)
+					// get demoted one tier because the prefix dilutes the signal
+					if len(segments) > 1 {
+						tier = t + 1
+					} else {
+						tier = t
+					}
+				}
+			}
+		}
+
+		// Demote booleans to last
+		if _, ok := v.(bool); ok && tier >= numTiers {
+			tier = numTiers + 1
+		}
+		all = append(all, scored{name: k, tier: tier, index: idx})
+		idx++
+	}
+
+	sort.Slice(all, func(i, j int) bool {
+		if all[i].tier != all[j].tier {
+			return all[i].tier < all[j].tier
+		}
+		return all[i].index < all[j].index
+	})
+
+	headers := make([]string, len(all))
+	for i, s := range all {
+		headers[i] = s.name
+	}
+	return headers
+}
+
+// splitCamelCase splits "OrderDate" → ["order", "date"], "statusCode" → ["status", "code"],
+// "page_size" → ["page", "size"].
+func splitCamelCase(s string) []string {
+	var segments []string
+	var current strings.Builder
+	runes := []rune(s)
+	for i, r := range runes {
+		if r == '_' || r == '-' {
+			if current.Len() > 0 {
+				segments = append(segments, current.String())
+				current.Reset()
+			}
+			continue
+		}
+		if i > 0 && unicode.IsUpper(r) && unicode.IsLower(runes[i-1]) {
+			if current.Len() > 0 {
+				segments = append(segments, current.String())
+				current.Reset()
+			}
+		}
+		current.WriteRune(unicode.ToLower(r))
+	}
+	if current.Len() > 0 {
+		segments = append(segments, current.String())
+	}
+	return segments
+}
+
+// printAutoCards renders items as labeled cards — one block per item.
+// Used for complex responses with many fields or nested data.
+func printAutoCards(w io.Writer, items []map[string]any) error {
+	headers := prioritizeAllHeaders(items[0])
+
+	// Find the longest header for alignment (from fields we'll actually show)
+	maxLen := 0
+	for _, h := range headers {
+		if len(h) > maxLen {
+			maxLen = len(h)
+		}
+	}
+
+	for i, item := range items {
+		if i > 0 {
+			fmt.Fprintln(w)
+		}
+
+		// Card header: use first priority field as the card title
+		titleVal := formatCellValue(item[headers[0]])
+		if len(headers) > 1 {
+			secondVal := formatCellValue(item[headers[1]])
+			if secondVal != "" {
+				fmt.Fprintf(w, "%s %s — %s\n", bold(strings.ToUpper(headers[0])), titleVal, secondVal)
+			} else {
+				fmt.Fprintf(w, "%s %s\n", bold(strings.ToUpper(headers[0])), titleVal)
+			}
+		} else {
+			fmt.Fprintf(w, "%s %s\n", bold(strings.ToUpper(headers[0])), titleVal)
+		}
+
+		// Remaining fields indented — skip empty, zero, and false values
+		for _, h := range headers[2:] {
+			v := formatCellValue(item[h])
+			if v == "" || v == "false" || v == "0" || v == "[]" || v == "null" {
+				continue
+			}
+			// Multi-line values (nested arrays) start with \n
+			if strings.HasPrefix(v, "\n") {
+				fmt.Fprintf(w, "  %s:%s\n", h, v)
+			} else {
+				fmt.Fprintf(w, "  %-*s  %s\n", maxLen, h+":", v)
+			}
+		}
+	}
+	return nil
+}
+
+func formatCellValue(v any) string {
+	switch val := v.(type) {
+	case string:
+		// Format ISO dates as just the date portion
+		if len(val) >= 19 && val[4] == '-' && val[7] == '-' && val[10] == 'T' {
+			return val[:10]
+		}
+		return truncate(val, 60)
+	case float64:
+		if val == float64(int64(val)) {
+			return fmt.Sprintf("%d", int64(val))
+		}
+		return fmt.Sprintf("%.2f", val)
+	case bool:
+		return fmt.Sprintf("%t", val)
+	case nil:
+		return ""
+	case []any:
+		if len(val) == 0 {
+			return ""
+		}
+		// If array contains objects, format each as a summary line
+		if obj, isObj := val[0].(map[string]any); isObj {
+			_ = obj
+			return formatObjectArray(val)
+		}
+		// Flatten simple arrays into comma-separated string
+		parts := make([]string, 0, len(val))
+		for _, item := range val {
+			if s, ok := item.(string); ok {
+				parts = append(parts, s)
+			} else {
+				b, _ := json.Marshal(item)
+				parts = append(parts, string(b))
+			}
+		}
+		return truncate(strings.Join(parts, ", "), 60)
+	case map[string]any:
+		return formatSingleObject(val)
+	default:
+		b, _ := json.Marshal(val)
+		return truncate(string(b), 60)
+	}
+}
+
+// formatObjectArray renders an array of objects as multi-line summary.
+// Each object is summarized by its most descriptive fields: name/title, qty, size, price.
+func formatObjectArray(items []any) string {
+	var lines []string
+	for _, raw := range items {
+		obj, ok := raw.(map[string]any)
+		if !ok {
+			continue
+		}
+		lines = append(lines, formatObjectSummary(obj))
+	}
+	if len(lines) == 0 {
+		return ""
+	}
+	// Multi-line: newline-prefixed so the card renderer can indent
+	return "\n" + strings.Join(lines, "\n")
+}
+
+// formatObjectSummary extracts the most useful fields from an object into a one-line summary.
+// Looks for: qty/count → name/title → size → price, in that order.
+func formatObjectSummary(obj map[string]any) string {
+	var parts []string
+
+	// Quantity
+	qty := findField(obj, "qty", "count", "quantity")
+	if qty != "" && qty != "1" && qty != "0" {
+		parts = append(parts, qty+"x")
+	} else if qty == "1" {
+		parts = append(parts, "1x")
+	}
+
+	// Name — check nested objects too (e.g., Side1.Name)
+	name := findField(obj, "name", "title", "label", "description")
+	if name == "" {
+		// Check nested objects for name
+		for _, key := range []string{"Side1", "side1", "Item", "item", "Product", "product"} {
+			if nested, ok := obj[key].(map[string]any); ok {
+				name = findField(nested, "name", "title", "label")
+				if name != "" {
+					break
+				}
+			}
+		}
+	}
+	if name != "" {
+		parts = append(parts, name)
+	}
+
+	// Size
+	size := findField(obj, "sizename", "size_name")
+	if size == "" {
+		size = findField(obj, "catname", "cat_name", "category")
+	}
+	if size != "" {
+		parts = append(parts, "—")
+		parts = append(parts, size)
+	}
+
+	// Price
+	price := findField(obj, "extprice", "price", "amount", "total")
+	if price != "" && price != "0" {
+		parts = append(parts, fmt.Sprintf("($%s)", price))
+	}
+
+	if len(parts) == 0 {
+		// Fallback: JSON summary
+		b, _ := json.Marshal(obj)
+		return truncate(string(b), 80)
+	}
+	return "    " + strings.Join(parts, " ")
+}
+
+// formatSingleObject renders a single object by its most descriptive fields.
+func formatSingleObject(obj map[string]any) string {
+	name := findField(obj, "name", "title", "label", "description")
+	if name != "" {
+		return name
+	}
+	id := findField(obj, "id", "key", "code")
+	if id != "" {
+		return id
+	}
+	return ""
+}
+
+// findField searches an object for a field name (case-insensitive) and returns its formatted value.
+func findField(obj map[string]any, names ...string) string {
+	for _, name := range names {
+		for k, v := range obj {
+			if strings.EqualFold(k, name) {
+				return formatCellValue(v)
+			}
+		}
+	}
+	return ""
+}
+// DataProvenance describes where data came from and when it was last synced.
+type DataProvenance struct {
+	Source       string     `json:"source"`                  // "live" or "local"
+	SyncedAt    *time.Time `json:"synced_at,omitempty"`     // when local data was last synced
+	Reason      string     `json:"reason,omitempty"`        // why local was used: "user_requested", "api_unreachable", "no_search_endpoint"
+	ResourceType string    `json:"resource_type,omitempty"` // which resource type was queried
+	Freshness    any        `json:"freshness,omitempty"`     // optional machine-owned freshness metadata for covered command paths
+}
+
+// printProvenance writes a one-line provenance message to stderr for TTY users.
+// Suppressed when stdout is piped or redirected — the JSON response envelope
+// already carries meta.source, so the stderr line is redundant and becomes
+// noise in agent flows that merge stderr into stdout.
+func printProvenance(cmd *cobra.Command, count int, prov DataProvenance) {
+	if !isTerminal(cmd.OutOrStdout()) {
+		return
+	}
+	if prov.Source == "live" {
+		fmt.Fprintf(cmd.ErrOrStderr(), "%d results (live)\n", count)
+		return
+	}
+	age := "unknown"
+	if prov.SyncedAt != nil {
+		d := time.Since(*prov.SyncedAt)
+		switch {
+		case d < time.Minute:
+			age = "just now"
+		case d < time.Hour:
+			age = fmt.Sprintf("%d minutes ago", int(d.Minutes()))
+		case d < 24*time.Hour:
+			age = fmt.Sprintf("%d hours ago", int(d.Hours()))
+		default:
+			age = fmt.Sprintf("%d days ago", int(d.Hours()/24))
+		}
+	}
+	prefix := ""
+	if prov.Reason == "api_unreachable" {
+		prefix = "API unreachable. "
+	}
+	fmt.Fprintf(cmd.ErrOrStderr(), "%s%d results (cached, synced %s)\n", prefix, count, age)
+}
+
+// wrapWithProvenance wraps response data in a provenance envelope:
+// {"results": ..., "meta": {...}}. When data is valid JSON, it embeds as
+// the parsed shape; when data is non-JSON (e.g., XML/RSS responses, plain
+// text), it embeds as a JSON string so json.Marshal doesn't choke on
+// "invalid character '<'" while still passing the raw payload through to
+// the consumer.
+func wrapWithProvenance(data json.RawMessage, prov DataProvenance) (json.RawMessage, error) {
+	meta := map[string]any{"source": prov.Source}
+	if prov.SyncedAt != nil {
+		meta["synced_at"] = prov.SyncedAt.UTC().Format(time.RFC3339)
+	}
+	if prov.Reason != "" {
+		meta["reason"] = prov.Reason
+	}
+	if prov.ResourceType != "" {
+		meta["resource_type"] = prov.ResourceType
+	}
+	if prov.Freshness != nil {
+		meta["freshness"] = prov.Freshness
+	}
+	var results any = json.RawMessage(data)
+	if !json.Valid(data) {
+		results = string(data)
+	}
+	envelope := map[string]any{
+		"results": results,
+		"meta":    meta,
+	}
+	return json.Marshal(envelope)
+}
+
+// defaultDBPath returns the canonical path for the local SQLite database.
+func defaultDBPath(name string) string {
+	home, _ := os.UserHomeDir()
+	return filepath.Join(home, ".local", "share", name, "data.db")
+}
diff --git a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/config/config.go b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/config/config.go
new file mode 100644
index 00000000..8d3180d3
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/config/config.go
@@ -0,0 +1,152 @@
+// 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 config
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+	"time"
+
+	"github.com/pelletier/go-toml/v2"
+)
+
+type Config struct {
+	BaseURL        string `toml:"base_url"`
+	AuthHeaderVal  string `toml:"auth_header"`
+	AuthSource     string `toml:"-"`
+	AccessToken    string `toml:"access_token"`
+	RefreshToken   string `toml:"refresh_token"`
+	TokenExpiry    time.Time `toml:"token_expiry"`
+	ClientID       string `toml:"client_id"`
+	ClientSecret   string `toml:"client_secret"`
+	Path           string `toml:"-"`
+	RichAuthApiKey string `toml:"auth_api_key"`
+	RichAuthClientId string `toml:"auth_client_id"`
+	RichAuthClientSecret string `toml:"auth_client_secret"`
+	RichAuthSessionCookie string `toml:"auth_session_cookie"`
+	RichAuthOptionalToken string `toml:"auth_optional_token"`
+	RichAuthBotToken string `toml:"auth_bot_token"`
+	RichAuthUserToken string `toml:"auth_user_token"`
+}
+
+func Load(configPath string) (*Config, error) {
+	cfg := &Config{
+		BaseURL: "https://api.rich-auth.example/v1",
+	}
+
+	// Resolve config path
+	path := configPath
+	if path == "" {
+		path = os.Getenv("PRINTING_PRESS_RICH_CONFIG")
+	}
+	if path == "" {
+		home, _ := os.UserHomeDir()
+		path = filepath.Join(home, ".config", "printing-press-rich-pp-cli", "config.toml")
+	}
+	cfg.Path = path
+
+	// Try to load config file
+	data, err := os.ReadFile(path)
+	if err == nil {
+		if err := toml.Unmarshal(data, cfg); err != nil {
+			return nil, fmt.Errorf("parsing config %s: %w", path, err)
+		}
+	}
+
+	// Env var overrides
+	if v := os.Getenv("RICH_AUTH_API_KEY"); v != "" {
+		cfg.RichAuthApiKey = v
+		cfg.AuthSource = "env:RICH_AUTH_API_KEY"
+	}
+	if v := os.Getenv("RICH_AUTH_CLIENT_ID"); v != "" {
+		cfg.RichAuthClientId = v
+		cfg.AuthSource = "env:RICH_AUTH_CLIENT_ID"
+	}
+	if v := os.Getenv("RICH_AUTH_CLIENT_SECRET"); v != "" {
+		cfg.RichAuthClientSecret = v
+		cfg.AuthSource = "env:RICH_AUTH_CLIENT_SECRET"
+	}
+	if v := os.Getenv("RICH_AUTH_SESSION_COOKIE"); v != "" {
+		cfg.RichAuthSessionCookie = v
+		cfg.AuthSource = "env:RICH_AUTH_SESSION_COOKIE"
+	}
+	if v := os.Getenv("RICH_AUTH_OPTIONAL_TOKEN"); v != "" {
+		cfg.RichAuthOptionalToken = v
+		cfg.AuthSource = "env:RICH_AUTH_OPTIONAL_TOKEN"
+	}
+	if v := os.Getenv("RICH_AUTH_BOT_TOKEN"); v != "" {
+		cfg.RichAuthBotToken = v
+		cfg.AuthSource = "env:RICH_AUTH_BOT_TOKEN"
+	}
+	if v := os.Getenv("RICH_AUTH_USER_TOKEN"); v != "" {
+		cfg.RichAuthUserToken = v
+		cfg.AuthSource = "env:RICH_AUTH_USER_TOKEN"
+	}
+
+	// Base URL override (used by printing-press verify to point at mock/test servers)
+	if v := os.Getenv("PRINTING_PRESS_RICH_BASE_URL"); v != "" {
+		cfg.BaseURL = v
+	}
+	return cfg, nil
+}
+
+func (c *Config) AuthHeader() string {
+	if c.AuthHeaderVal != "" {
+		return c.AuthHeaderVal
+	}
+	token := c.RichAuthApiKey
+	if token == "" {
+		return ""
+	}
+	if c.RichAuthApiKey == "" {
+		return ""
+	}
+	return token
+}
+
+func applyAuthFormat(format string, replacements map[string]string) string {
+	if format == "" {
+		return ""
+	}
+	for key, value := range replacements {
+		format = strings.ReplaceAll(format, "{"+key+"}", value)
+	}
+	if strings.Contains(format, "{") {
+		return ""
+	}
+	return format
+}
+
+func (c *Config) SaveTokens(clientID, clientSecret, accessToken, refreshToken string, expiry time.Time) error {
+	c.ClientID = clientID
+	c.ClientSecret = clientSecret
+	c.AccessToken = accessToken
+	c.RefreshToken = refreshToken
+	c.TokenExpiry = expiry
+	return c.save()
+}
+
+func (c *Config) ClearTokens() error {
+	c.AccessToken = ""
+	c.RefreshToken = ""
+	c.TokenExpiry = time.Time{}
+	return c.save()
+}
+
+func (c *Config) save() error {
+	dir := filepath.Dir(c.Path)
+	if err := os.MkdirAll(dir, 0o700); err != nil {
+		return fmt.Errorf("creating config dir: %w", err)
+	}
+	data, err := toml.Marshal(c)
+	if err != nil {
+		return fmt.Errorf("marshaling config: %w", err)
+	}
+	return os.WriteFile(c.Path, data, 0o600)
+}
+
+// Ensure strings import is used
+var _ = strings.ReplaceAll
diff --git a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/mcp/tools.go b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/mcp/tools.go
new file mode 100644
index 00000000..20034836
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/mcp/tools.go
@@ -0,0 +1,321 @@
+// 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"
+	"printing-press-rich-pp-cli/internal/cli"
+	"printing-press-rich-pp-cli/internal/cliutil"
+	"printing-press-rich-pp-cli/internal/client"
+	"printing-press-rich-pp-cli/internal/config"
+	"printing-press-rich-pp-cli/internal/mcp/cobratree"
+	"printing-press-rich-pp-cli/internal/store"
+)
+
+// RegisterTools registers all API operations as MCP tools.
+func RegisterTools(s *server.MCPServer) {
+	s.AddTool(
+		mcplib.NewTool("items_list",
+			mcplib.WithDescription("List items. Returns array of Item."),
+			mcplib.WithReadOnlyHintAnnotation(true),
+			mcplib.WithDestructiveHintAnnotation(false),
+			mcplib.WithOpenWorldHintAnnotation(true),
+		),
+		makeAPIHandler("GET", "/items", []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 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
+		}
+
+		// 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()
+
+		// positionalParams mixes real URL path params with CLI positional
+		// args that map to query params (e.g. `search <query>` -> ?query=);
+		// the placeholder check below disambiguates them at runtime.
+		path := pathTemplate
+		pathParams := make(map[string]bool, len(positionalParams))
+		for _, p := range positionalParams {
+			placeholder := "{" + p + "}"
+			if !strings.Contains(pathTemplate, placeholder) {
+				continue
+			}
+			pathParams[p] = true
+			if v, ok := args[p]; ok {
+				path = strings.Replace(path, placeholder, fmt.Sprintf("%v", v), 1)
+			}
+		}
+
+		params := make(map[string]string)
+		for k, v := range args {
+			if pathParams[k] {
+				continue
+			}
+			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 RICH_AUTH_API_KEY=<your-key>" +
+					"\n      Run 'printing-press-rich-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 API key." +
+					"\n      Set it with: export RICH_AUTH_API_KEY=<your-key>" +
+					"\n      Run 'printing-press-rich-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 RICH_AUTH_API_KEY=<your-key>" +
+					"\n      Run 'printing-press-rich-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", "printing-press-rich-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", "printing-press-rich-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":         "printing-press-rich",
+		"description": "Purpose-built fixture for rich auth env-var model coverage.",
+		"archetype":   "generic",
+		"tool_count":  1,
+		// 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 printing-press-rich-pp-cli binary.",
+		"auth": map[string]any{
+			"type": "api_key",
+			"env_vars": []map[string]any{
+				{
+					"name": "RICH_AUTH_API_KEY",
+					"kind": "per_call",
+					"required": true,
+					"sensitive": true,
+					"description": "Set to your API credential.",
+				},
+				{
+					"name": "RICH_AUTH_CLIENT_ID",
+					"kind": "auth_flow_input",
+					"required": false,
+					"sensitive": false,
+					"description": "OAuth application client identifier.",
+				},
+				{
+					"name": "RICH_AUTH_CLIENT_SECRET",
+					"kind": "auth_flow_input",
+					"required": false,
+					"sensitive": true,
+					"description": "Set during initial auth setup.",
+				},
+				{
+					"name": "RICH_AUTH_OPTIONAL_TOKEN",
+					"kind": "per_call",
+					"required": false,
+					"sensitive": true,
+					"description": "Set to your API credential.",
+				},
+				{
+					"name": "RICH_AUTH_BOT_TOKEN",
+					"kind": "per_call",
+					"required": false,
+					"sensitive": true,
+					"description": "Set to your API credential.",
+				},
+				{
+					"name": "RICH_AUTH_USER_TOKEN",
+					"kind": "per_call",
+					"required": false,
+					"sensitive": true,
+					"description": "Set to your API credential.",
+				},
+			},
+		},
+		"resources": []map[string]any{
+			{
+				"name": "items",
+				"description": "Manage items",
+				"endpoints": []string{"list",  },
+				"syncable": true,
+			},
+		},
+		"query_tips": []string{
+			"Pagination uses cursor-based paging. Pass after 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-golden-api-rich-auth/stderr.txt b/testdata/golden/expected/generate-golden-api-rich-auth/stderr.txt
new file mode 100644
index 00000000..ef267b69
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api-rich-auth/stderr.txt
@@ -0,0 +1,2 @@
+warning: could not derive run_id from --research-dir; phase5 dogfood acceptance will refuse to write without it
+Generated printing-press-rich at <ARTIFACT_DIR>/generate-golden-api-rich-auth/printing-press-rich-auth
diff --git a/testdata/golden/expected/generate-golden-api-rich-auth/stdout.txt b/testdata/golden/expected/generate-golden-api-rich-auth/stdout.txt
new file mode 100644
index 00000000..e69de29b
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/README.md b/testdata/golden/expected/generate-golden-api/printing-press-golden/README.md
index 20fbee42..a3b0a6be 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/README.md
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/README.md
@@ -189,7 +189,10 @@ Verifies configuration, credentials, and connectivity to the API.
 Config file: `~/.config/printing-press-golden-pp-cli/config.toml`
 
 Environment variables:
-- `PRINTING_PRESS_GOLDEN_API_KEY`
+
+| Name | Kind | Required | Description |
+| --- | --- | --- | --- |
+| `PRINTING_PRESS_GOLDEN_API_KEY` | per_call | Yes | Set to your API credential. |
 
 ## Troubleshooting
 **Authentication errors (exit code 4)**
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/SKILL.md b/testdata/golden/expected/generate-golden-api/printing-press-golden/SKILL.md
index 5a2e3a37..80a176b3 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/SKILL.md
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/SKILL.md
@@ -6,8 +6,13 @@ allowed-tools: "Read Bash"
 metadata:
   openclaw:
     requires:
+      env: ["PRINTING_PRESS_GOLDEN_API_KEY"]
       bins:
         - printing-press-golden-pp-cli
+    envVars:
+      - name: PRINTING_PRESS_GOLDEN_API_KEY
+        required: true
+        description: "PRINTING_PRESS_GOLDEN_API_KEY credential."
     install:
       - kind: go
         bins: [printing-press-golden-pp-cli]
@@ -49,7 +54,6 @@ printing-press-golden-pp-cli which "<capability in your own words>"
 `which` resolves a natural-language capability query to the best matching command from this CLI's curated feature index. Exit code `0` means at least one match; exit code `2` means no confident match — fall back to `--help` or use a narrower query.
 
 ## Auth Setup
-
 Set your API key via environment variable:
 
 ```bash
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/auth.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/auth.go
index 48876560..2b2ae630 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/auth.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/auth.go
@@ -14,7 +14,7 @@ import (
 func newAuthCmd(flags *rootFlags) *cobra.Command {
 	cmd := &cobra.Command{
 		Use:   "auth",
-		Short: "Manage PRINTING_PRESS_GOLDEN_API_KEY credentials",
+		Short: "Manage authentication for Printing Press Studio",
 	}
 
 	cmd.AddCommand(newAuthStatusCmd(flags))
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/doctor.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/doctor.go
index 8ca98b6c..158362ac 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/doctor.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/doctor.go
@@ -97,16 +97,26 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 			}
 
 			// Check auth environment variables
-			authEnvChecked := 0
-			authEnvSet := 0
-			authEnvChecked++
+			authEnvSet := []string{}
+			authEnvRequiredMissing := []string{}
+			authEnvInfo := []string{}
+			authEnvOptionalNames := []string{}
+			// Validation rejects multi-OR-group specs upstream, so the single optional-satisfied state is sufficient at runtime.
+			authEnvOptionalSatisfied := false
 			if os.Getenv("PRINTING_PRESS_GOLDEN_API_KEY") != "" {
-				authEnvSet++
-			}
-			if authEnvSet == 0 {
-				report["env_vars"] = fmt.Sprintf("none set (checked %d)", authEnvChecked)
+				authEnvSet = append(authEnvSet, "PRINTING_PRESS_GOLDEN_API_KEY")
 			} else {
-				report["env_vars"] = fmt.Sprintf("%d/%d set", authEnvSet, authEnvChecked)
+				authEnvRequiredMissing = append(authEnvRequiredMissing, "PRINTING_PRESS_GOLDEN_API_KEY")
+			}
+			switch {
+			case len(authEnvRequiredMissing) > 0:
+				report["env_vars"] = "ERROR missing required: " + strings.Join(authEnvRequiredMissing, ", ")
+			case len(authEnvOptionalNames) > 1 && !authEnvOptionalSatisfied:
+				report["env_vars"] = "INFO set one of: " + strings.Join(authEnvOptionalNames, " or ")
+			case len(authEnvInfo) > 0:
+				report["env_vars"] = "INFO " + strings.Join(authEnvInfo, "; ")
+			default:
+				report["env_vars"] = fmt.Sprintf("OK %d/%d available", len(authEnvSet), 1)
 			}
 
 			// Check API connectivity and validate credentials.
@@ -216,6 +226,7 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 			checkKeys := []struct{ key, label string }{
 				{"config", "Config"},
 				{"auth", "Auth"},
+				{"env_vars", "Env Vars"},
 				{"api", "API"},
 				{"credentials", "Credentials"},
 			}
@@ -227,6 +238,10 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 				s := fmt.Sprintf("%v", v)
 				indicator := green("OK")
 				switch {
+				case strings.HasPrefix(s, "INFO"):
+					indicator = yellow("INFO")
+				case strings.HasPrefix(s, "ERROR"):
+					indicator = red("FAIL")
 				case strings.HasPrefix(s, "optional"):
 					// Optional-auth CLI with no key set — informational, not a failure.
 					indicator = yellow("INFO")
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/mcp/tools.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/mcp/tools.go
index b4e547e7..82c52533 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/mcp/tools.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/mcp/tools.go
@@ -357,7 +357,15 @@ func handleContext(_ context.Context, _ mcplib.CallToolRequest) (*mcplib.CallToo
 		"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 printing-press-golden-pp-cli binary.",
 		"auth": map[string]any{
 			"type": "api_key",
-			"env_vars": []string{"PRINTING_PRESS_GOLDEN_API_KEY",  },
+			"env_vars": []map[string]any{
+				{
+					"name": "PRINTING_PRESS_GOLDEN_API_KEY",
+					"kind": "per_call",
+					"required": true,
+					"sensitive": true,
+					"description": "Set to your API credential.",
+				},
+			},
 		},
 		"resources": []map[string]any{
 			{
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
index cf2052e6..e5d70b24 100644
--- 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
@@ -97,16 +97,26 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 			}
 
 			// Check auth environment variables
-			authEnvChecked := 0
-			authEnvSet := 0
-			authEnvChecked++
+			authEnvSet := []string{}
+			authEnvRequiredMissing := []string{}
+			authEnvInfo := []string{}
+			authEnvOptionalNames := []string{}
+			// Validation rejects multi-OR-group specs upstream, so the single optional-satisfied state is sufficient at runtime.
+			authEnvOptionalSatisfied := false
 			if os.Getenv("TIER_GLOBAL_TOKEN") != "" {
-				authEnvSet++
-			}
-			if authEnvSet == 0 {
-				report["env_vars"] = fmt.Sprintf("none set (checked %d)", authEnvChecked)
+				authEnvSet = append(authEnvSet, "TIER_GLOBAL_TOKEN")
 			} else {
-				report["env_vars"] = fmt.Sprintf("%d/%d set", authEnvSet, authEnvChecked)
+				authEnvRequiredMissing = append(authEnvRequiredMissing, "TIER_GLOBAL_TOKEN")
+			}
+			switch {
+			case len(authEnvRequiredMissing) > 0:
+				report["env_vars"] = "ERROR missing required: " + strings.Join(authEnvRequiredMissing, ", ")
+			case len(authEnvOptionalNames) > 1 && !authEnvOptionalSatisfied:
+				report["env_vars"] = "INFO set one of: " + strings.Join(authEnvOptionalNames, " or ")
+			case len(authEnvInfo) > 0:
+				report["env_vars"] = "INFO " + strings.Join(authEnvInfo, "; ")
+			default:
+				report["env_vars"] = fmt.Sprintf("OK %d/%d available", len(authEnvSet), 1)
 			}
 			tierEnvStatus := map[string]string{}
 			{
@@ -237,6 +247,7 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 			checkKeys := []struct{ key, label string }{
 				{"config", "Config"},
 				{"auth", "Auth"},
+				{"env_vars", "Env Vars"},
 				{"api", "API"},
 				{"credentials", "Credentials"},
 			}
@@ -248,6 +259,10 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 				s := fmt.Sprintf("%v", v)
 				indicator := green("OK")
 				switch {
+				case strings.HasPrefix(s, "INFO"):
+					indicator = yellow("INFO")
+				case strings.HasPrefix(s, "ERROR"):
+					indicator = red("FAIL")
 				case strings.HasPrefix(s, "optional"):
 					// Optional-auth CLI with no key set — informational, not a failure.
 					indicator = yellow("INFO")
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
index 2d4f27df..392d9e54 100644
--- 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
@@ -267,14 +267,30 @@ func handleContext(_ context.Context, _ mcplib.CallToolRequest) (*mcplib.CallToo
 		"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",  },
+			"env_vars": []map[string]any{
+				{
+					"name": "TIER_GLOBAL_TOKEN",
+					"kind": "per_call",
+					"required": true,
+					"sensitive": true,
+					"description": "Set to your API credential.",
+				},
+			},
 		},
 		"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",  },
+					"env_vars": []map[string]any{
+						{
+							"name": "TIER_ENTERPRISE_TOKEN",
+							"kind": "per_call",
+							"required": true,
+							"sensitive": true,
+							"description": "Set to your API credential.",
+						},
+					},
 				},
 				"free": map[string]any{
 					"auth_type": "none",
@@ -282,7 +298,15 @@ func handleContext(_ context.Context, _ mcplib.CallToolRequest) (*mcplib.CallToo
 				"paid": map[string]any{
 					"auth_type": "api_key",
 					"base_url": "https://paid.api.example.com",
-					"env_vars": []string{"TIER_PAID_KEY",  },
+					"env_vars": []map[string]any{
+						{
+							"name": "TIER_PAID_KEY",
+							"kind": "per_call",
+							"required": true,
+							"sensitive": true,
+							"description": "Set to your API credential.",
+						},
+					},
 				},
 			},
 			"endpoints": map[string]string{
diff --git a/testdata/golden/fixtures/golden-api-rich-auth.yaml b/testdata/golden/fixtures/golden-api-rich-auth.yaml
new file mode 100644
index 00000000..8102578e
--- /dev/null
+++ b/testdata/golden/fixtures/golden-api-rich-auth.yaml
@@ -0,0 +1,74 @@
+openapi: "3.0.3"
+info:
+  title: Printing Press Rich Auth API
+  version: "1.0.0"
+  description: Purpose-built fixture for rich auth env-var model coverage.
+servers:
+  - url: https://api.rich-auth.example/v1
+security:
+  - RichAuth: []
+components:
+  securitySchemes:
+    RichAuth:
+      type: apiKey
+      in: header
+      name: Authorization
+      x-auth-vars:
+        - name: RICH_AUTH_API_KEY
+          kind: per_call
+          required: true
+          sensitive: true
+          description: Rich Auth API key.
+        - name: RICH_AUTH_CLIENT_ID
+          kind: auth_flow_input
+          required: false
+          sensitive: false
+          description: OAuth application client identifier.
+        - name: RICH_AUTH_CLIENT_SECRET
+          kind: auth_flow_input
+          required: false
+          sensitive: true
+          description: OAuth application client secret.
+        - name: RICH_AUTH_SESSION_COOKIE
+          kind: harvested
+          required: false
+          sensitive: true
+          description: Browser session cookie captured by auth login.
+        - name: RICH_AUTH_OPTIONAL_TOKEN
+          kind: per_call
+          required: false
+          sensitive: true
+          description: Optional token for elevated read limits.
+        - name: RICH_AUTH_BOT_TOKEN
+          kind: per_call
+          required: false
+          sensitive: true
+          description: Set this OR RICH_AUTH_USER_TOKEN for workspace access.
+        - name: RICH_AUTH_USER_TOKEN
+          kind: per_call
+          required: false
+          sensitive: true
+          description: Set this OR RICH_AUTH_BOT_TOKEN for workspace access.
+  schemas:
+    Item:
+      type: object
+      properties:
+        id:
+          type: string
+        name:
+          type: string
+paths:
+  /items:
+    get:
+      tags: [items]
+      operationId: listItems
+      summary: List items
+      responses:
+        "200":
+          description: OK
+          content:
+            application/json:
+              schema:
+                type: array
+                items:
+                  $ref: "#/components/schemas/Item"

← 7eb951e0 feat(skills): flatten retro issues and dedup against open is  ·  back to Cli Printing Press  ·  fix(cli): kind-aware auth env-vars and promoted-command --li 0cf04f73 →