[object Object]

← back to Cli Printing Press

feat(cli): catalog auth_env_vars declares canonical credential env vars (#1508)

fe4a5ec38c5e379c1da491725b3e5b6675e3f094 · 2026-05-16 02:26:00 -0700 · Trevin Chow

* fix(skills): surface novel-feature hand-code scope at Phase Gate 1.5

Closes #1450

Phase Gate 1.5 lacked a signal that approving "N novel features scored
>=5/10" committed the agent to ~50-150 LoC of hand-written Go per
feature, plus root.go wiring, for every transcendence row the generator
won't auto-emit. Users approved without seeing that scope, and the gap
surfaced later as validate-narrative / verify-skill failures.

Two coordinated edits:

* novel-features-subagent.md: Pass 3 gains a fifth force-answer question
  ("Buildability"), tagging each survivor `spec-emits` or `hand-code`.
  The output contract requires the survivors table to extend the
  rubric's transcendence format with a Buildability column; the
  SKILL-side handler reads that column for the gate showcase.

* SKILL.md Phase Gate 1.5: the prose showcase now covers four
  must-haves, with a new "Hand-code commitment" item that states the
  auto-emitted vs hand-code split and lists the hand-code feature names
  before the AskUserQuestion fires.

* fix(skills): align Step 1.5c transcendence table template with Buildability column

Greptile P1 on PR #1501: the canonical four-column transcendence table
template at Step 1.5c didn't carry the new Buildability column, so an
agent writing the manifest from that template would silently drop the
column and Phase Gate 1.5's hand-code count would lose its source of
truth in the manifest. Update the template to include Buildability and
fix the stale "already matches" wording.

* fix(skills): add Buildability column to rubric Transcendence Table Format

Greptile 2nd-pass on PR #1501: the subagent reads absorb-scoring.md as
its operational playbook, so the canonical Transcendence Table Format
also needs the new Buildability column. Without it, the subagent has no
guidance on where to place the column in its Survivors output, leaving
the manifest parse step's Buildability lookup brittle.

Clarify the relationship between the existing "How It Works" column (the
buildability proof sentence) and the new "Buildability" column (the
spec-emits / hand-code tag) so the two distinct concepts don't get
conflated.

* fix(skills): tighten Buildability wording per PR review

Two minor wording cleanups flagged in Greptile's 5/5 review:

* novel-features-subagent.md Output contract: now that absorb-scoring.md
  also includes Buildability, "extends with one additional column" is
  self-contradictory. Switch to "matching the rubric's format (which
  includes the Buildability column)".

* SKILL.md Phase Gate 1.5: by gate time the agent reads the manifest,
  not the archived brainstorm. Point at the manifest transcendence
  table's Buildability column as the source of truth.

* feat(cli): catalog auth_env_vars declares canonical credential env vars

Catalog entries can now declare auth_env_vars: an ordered list of
canonical credential env var names (STRIPE_SECRET_KEY, GITHUB_TOKEN, etc.).
The generator merges them in front of the parser's name-derived default and
emits config.go reading each in declared order, so an operator who exports
any one satisfies auth. The parser's name-derived default trails as a
backwards-compat fallback so existing setups don't need a rename.

Catalog-mode generation (printing-press generate <name>) bypasses the
spec-edit step the SKILL's Pre-Generation Auth Enrichment uses, so this
is the only way to declare canonical names without hand-patching the
generated CLI on every regen.

Seeded for stripe, github, discord, sentry. Stytch (HTTP Basic pair)
intentionally deferred — basic auth treats env vars as a credential
pair, not as alternatives, so the override is a no-op for basic
auth.Format shapes; declare basic-auth pairs via x-auth-env-vars on
the security scheme instead.

Closes #1482

* fix(cli): reject whitespace-padded auth_env_vars at validation time

Greptile P2 from PR #1508 review: the validator trimmed each name before
running the regex check but stored the original (untrimmed) string in
entry.AuthEnvVars, so a YAML entry like "  STRIPE_KEY  " passed
validation and was only normalized at runtime in mergeAuthEnvVars. Add
an explicit whitespace-rejection step so the stored slice always matches
what was validated, with a distinct error message that points at the
specific YAML hygiene issue rather than the regex shape.

Refs #1482

---------

Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>

Files touched

Diff

commit fe4a5ec38c5e379c1da491725b3e5b6675e3f094
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Sat May 16 02:26:00 2026 -0700

    feat(cli): catalog auth_env_vars declares canonical credential env vars (#1508)
    
    * fix(skills): surface novel-feature hand-code scope at Phase Gate 1.5
    
    Closes #1450
    
    Phase Gate 1.5 lacked a signal that approving "N novel features scored
    >=5/10" committed the agent to ~50-150 LoC of hand-written Go per
    feature, plus root.go wiring, for every transcendence row the generator
    won't auto-emit. Users approved without seeing that scope, and the gap
    surfaced later as validate-narrative / verify-skill failures.
    
    Two coordinated edits:
    
    * novel-features-subagent.md: Pass 3 gains a fifth force-answer question
      ("Buildability"), tagging each survivor `spec-emits` or `hand-code`.
      The output contract requires the survivors table to extend the
      rubric's transcendence format with a Buildability column; the
      SKILL-side handler reads that column for the gate showcase.
    
    * SKILL.md Phase Gate 1.5: the prose showcase now covers four
      must-haves, with a new "Hand-code commitment" item that states the
      auto-emitted vs hand-code split and lists the hand-code feature names
      before the AskUserQuestion fires.
    
    * fix(skills): align Step 1.5c transcendence table template with Buildability column
    
    Greptile P1 on PR #1501: the canonical four-column transcendence table
    template at Step 1.5c didn't carry the new Buildability column, so an
    agent writing the manifest from that template would silently drop the
    column and Phase Gate 1.5's hand-code count would lose its source of
    truth in the manifest. Update the template to include Buildability and
    fix the stale "already matches" wording.
    
    * fix(skills): add Buildability column to rubric Transcendence Table Format
    
    Greptile 2nd-pass on PR #1501: the subagent reads absorb-scoring.md as
    its operational playbook, so the canonical Transcendence Table Format
    also needs the new Buildability column. Without it, the subagent has no
    guidance on where to place the column in its Survivors output, leaving
    the manifest parse step's Buildability lookup brittle.
    
    Clarify the relationship between the existing "How It Works" column (the
    buildability proof sentence) and the new "Buildability" column (the
    spec-emits / hand-code tag) so the two distinct concepts don't get
    conflated.
    
    * fix(skills): tighten Buildability wording per PR review
    
    Two minor wording cleanups flagged in Greptile's 5/5 review:
    
    * novel-features-subagent.md Output contract: now that absorb-scoring.md
      also includes Buildability, "extends with one additional column" is
      self-contradictory. Switch to "matching the rubric's format (which
      includes the Buildability column)".
    
    * SKILL.md Phase Gate 1.5: by gate time the agent reads the manifest,
      not the archived brainstorm. Point at the manifest transcendence
      table's Buildability column as the source of truth.
    
    * feat(cli): catalog auth_env_vars declares canonical credential env vars
    
    Catalog entries can now declare auth_env_vars: an ordered list of
    canonical credential env var names (STRIPE_SECRET_KEY, GITHUB_TOKEN, etc.).
    The generator merges them in front of the parser's name-derived default and
    emits config.go reading each in declared order, so an operator who exports
    any one satisfies auth. The parser's name-derived default trails as a
    backwards-compat fallback so existing setups don't need a rename.
    
    Catalog-mode generation (printing-press generate <name>) bypasses the
    spec-edit step the SKILL's Pre-Generation Auth Enrichment uses, so this
    is the only way to declare canonical names without hand-patching the
    generated CLI on every regen.
    
    Seeded for stripe, github, discord, sentry. Stytch (HTTP Basic pair)
    intentionally deferred — basic auth treats env vars as a credential
    pair, not as alternatives, so the override is a no-op for basic
    auth.Format shapes; declare basic-auth pairs via x-auth-env-vars on
    the security scheme instead.
    
    Closes #1482
    
    * fix(cli): reject whitespace-padded auth_env_vars at validation time
    
    Greptile P2 from PR #1508 review: the validator trimmed each name before
    running the regex check but stored the original (untrimmed) string in
    entry.AuthEnvVars, so a YAML entry like "  STRIPE_KEY  " passed
    validation and was only normalized at runtime in mergeAuthEnvVars. Add
    an explicit whitespace-rejection step so the stored slice always matches
    what was validated, with a distinct error message that points at the
    specific YAML hygiene issue rather than the regex shape.
    
    Refs #1482
    
    ---------
    
    Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
---
 AGENTS.md                                      |  1 +
 catalog/discord.yaml                           |  3 +
 catalog/github.yaml                            |  3 +
 catalog/sentry.yaml                            |  2 +
 catalog/stripe.yaml                            |  3 +
 docs/CATALOG.md                                | 18 ++++++
 docs/SPEC-EXTENSIONS.md                        |  9 +++
 internal/catalog/catalog.go                    | 43 ++++++++++++
 internal/catalog/catalog_test.go               | 74 +++++++++++++++++++++
 internal/catalogmeta/catalogmeta.go            | 52 +++++++++++++++
 internal/catalogmeta/catalogmeta_test.go       | 90 ++++++++++++++++++++++++++
 internal/cli/root.go                           |  1 +
 internal/generator/auth_env_precedence_test.go | 78 ++++++++++++++++++++++
 internal/spec/spec.go                          | 18 ++++++
 skills/printing-press/SKILL.md                 |  6 +-
 15 files changed, 400 insertions(+), 1 deletion(-)

diff --git a/AGENTS.md b/AGENTS.md
index 3e5d5310..8f2ea645 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -181,6 +181,7 @@ When adding or editing `catalog/*.yaml`, first decide whether the entry belongs
 - `bearer_refresh`, when present, must include `bundle_url` and `pattern`; `bundle_url` must use HTTPS, and `pattern` must compile as a Go regexp.
 - `auth_key_url`, when present, must use HTTPS. It overrides any URL inferred from the spec and surfaces in the printed CLI as `Get a key at: <URL>`.
 - `auth_instructions`, when present, is a one-line string rendered under the URL. It overrides any `x-auth-instructions` value from the spec.
+- `auth_env_vars`, when present, is an ordered list of canonical credential env var names (`^[A-Z][A-Z0-9_]*$`, no duplicates, no empties). The generator merges them in front of the parser's name-derived default and emits `config.go` reading each in order. Ignored for HTTP Basic auth.
 - `base_url`, when present, must use HTTPS. Use it only when the upstream spec omits `servers:` and the correct API origin is known.
 - Rebuild the binary after editing; `catalog.FS` is a Go embed.
 See [`docs/CATALOG.md`](docs/CATALOG.md) for the inclusion rubric, evidence checklist, validation rationale, wrapper-only entry shape, and bearer-refresh metadata.
diff --git a/catalog/discord.yaml b/catalog/discord.yaml
index e801e266..f850da98 100644
--- a/catalog/discord.yaml
+++ b/catalog/discord.yaml
@@ -10,4 +10,7 @@ verified_date: "2026-03-23"
 homepage: https://discord.com/developers/docs
 auth_key_url: https://discord.com/developers/applications
 auth_instructions: "Create an application, add a bot, and copy the bot token from the Bot tab."
+auth_env_vars:
+  - DISCORD_TOKEN      # canonical for community discord libraries
+  - DISCORD_BOT_TOKEN  # common alias used by discord.py / discord.js docs
 notes: Very large spec (1MB+). Generator truncates to 50 resources / 20 endpoints per resource.
diff --git a/catalog/github.yaml b/catalog/github.yaml
index 192c113d..4c3102e0 100644
--- a/catalog/github.yaml
+++ b/catalog/github.yaml
@@ -10,6 +10,9 @@ verified_date: "2026-03-23"
 homepage: https://docs.github.com/en/rest
 auth_key_url: https://github.com/settings/tokens
 auth_instructions: "Generate a fine-grained personal access token (or classic) with the scopes your workflow needs."
+auth_env_vars:
+  - GITHUB_TOKEN  # canonical: gh CLI, every GitHub SDK
+  - GH_TOKEN      # gh CLI alias
 notes: Very large spec. Generator truncates significantly.
 known_alternatives:
   - name: gh
diff --git a/catalog/sentry.yaml b/catalog/sentry.yaml
index 4ce05f4c..6df0ec50 100644
--- a/catalog/sentry.yaml
+++ b/catalog/sentry.yaml
@@ -10,4 +10,6 @@ verified_date: "2026-03-24"
 homepage: https://sentry.io
 auth_key_url: https://sentry.io/settings/account/api/auth-tokens/
 auth_instructions: "Create an auth token with the scopes your workflow needs (project:read, event:read, etc.)."
+auth_env_vars:
+  - SENTRY_AUTH_TOKEN  # canonical: sentry-cli, @sentry/* SDKs
 notes: "126 paths, bearer auth. Templated base URL {region}.sentry.io needs manual config."
diff --git a/catalog/stripe.yaml b/catalog/stripe.yaml
index ae8aa08b..3400714a 100644
--- a/catalog/stripe.yaml
+++ b/catalog/stripe.yaml
@@ -10,6 +10,9 @@ verified_date: "2026-03-23"
 homepage: https://stripe.com/docs/api
 auth_key_url: https://dashboard.stripe.com/apikeys
 auth_instructions: "Use a test mode key (sk_test_*) unless you intend to charge live cards."
+auth_env_vars:
+  - STRIPE_SECRET_KEY  # canonical: stripe-cli, stripe-go, stripe-node, stripe-python
+  - STRIPE_API_KEY     # common alias
 notes: Very large spec (~500 endpoints). Generator truncates significantly.
 known_alternatives:
   - name: stripe-cli
diff --git a/docs/CATALOG.md b/docs/CATALOG.md
index 304a5da3..9872d52d 100644
--- a/docs/CATALOG.md
+++ b/docs/CATALOG.md
@@ -98,3 +98,21 @@ Catalog entries may also declare `auth_instructions:` — a one-line string of f
 Catalog `auth_instructions` overrides any value from the spec's [`x-auth-instructions`](SPEC-EXTENSIONS.md#x-auth-instructions) extension. The printed CLI surfaces it in auth prompts, `doctor`, and the new `auth setup` command (which also takes `--launch` to open the URL in a browser).
 
 Catalog entries may declare `base_url:` when the upstream spec intentionally omits `servers:` and the correct API origin is known. The value must be HTTPS and is used only when the parsed spec has no usable base URL.
+
+## `auth_env_vars`
+
+Catalog entries may declare `auth_env_vars:` — an ordered list of canonical credential env var names this API's ecosystem already uses (`STRIPE_SECRET_KEY` for stripe-cli / stripe-go / stripe-node / stripe-python; `GITHUB_TOKEN` for `gh` and every GitHub SDK; `DISCORD_TOKEN` for community Discord libraries; `SENTRY_AUTH_TOKEN` for sentry-cli and the `@sentry/*` SDKs). Catalog-mode generation runs `printing-press generate <name>` straight from the catalog spec URL, bypassing the [Pre-Generation Auth Enrichment](../skills/printing-press/SKILL.md#pre-generation-auth-enrichment) step that would otherwise add [`x-auth-env-vars`](SPEC-EXTENSIONS.md#x-auth-env-vars) to the spec by hand. Declaring the canonical names here applies them automatically on every regen.
+
+Rules:
+- Optional. When empty, the generator's name-derived default (`<API>_BEARER_AUTH` / `<API>_TOKEN` / `<API>_API_KEY` by auth type) is used unchanged.
+- Each entry must match `^[A-Z][A-Z0-9_]*$` (uppercase letters, digits, underscores; must start with a letter). Duplicates and empty entries are rejected at validation time.
+- The catalog list takes precedence; the parser's name-derived default trails as a backwards-compat fallback so operators on existing setups don't need a rename to keep auth working. Generated `config.go` reads each env var in declared order and returns the first non-empty value.
+- The field is ignored for HTTP Basic auth (credential-pair shape, e.g. Twilio's `TWILIO_ACCOUNT_SID` + `TWILIO_AUTH_TOKEN`). Declare basic-auth env var pairs via the spec's [`x-auth-env-vars`](SPEC-EXTENSIONS.md#x-auth-env-vars) extension instead.
+
+Example (`catalog/stripe.yaml`):
+
+```yaml
+auth_env_vars:
+  - STRIPE_SECRET_KEY  # canonical: stripe-cli, stripe-go, stripe-node, stripe-python
+  - STRIPE_API_KEY     # common alias
+```
diff --git a/docs/SPEC-EXTENSIONS.md b/docs/SPEC-EXTENSIONS.md
index 5631ff7b..3b12411d 100644
--- a/docs/SPEC-EXTENSIONS.md
+++ b/docs/SPEC-EXTENSIONS.md
@@ -344,6 +344,15 @@ Rules:
 - When at least one non-empty item is present, the list replaces the parser's
   generated env var names.
 
+Catalog-driven equivalent: when a catalog entry declares `auth_env_vars`, the
+generator layers the canonical names on top of the parser-derived default at
+runtime without editing the upstream spec. The catalog list takes precedence,
+the parser default trails as a backwards-compat fallback, and the rebuilt env
+var list is emitted as an OR-case (any one satisfies auth). The catalog field
+is ignored for HTTP Basic auth (credential-pair shape); declare basic-auth
+env var pairs via `x-auth-env-vars` on the security scheme instead. See
+[`docs/CATALOG.md`](CATALOG.md#auth_env_vars).
+
 ### `x-auth-vars`
 
 Overrides the generated credential environment variable metadata.
diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go
index e48a8b27..fe3daeb2 100644
--- a/internal/catalog/catalog.go
+++ b/internal/catalog/catalog.go
@@ -15,6 +15,12 @@ import (
 
 var namePattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
 
+// authEnvVarPattern matches POSIX-shell-shaped environment variable names:
+// uppercase ASCII letters, digits, and underscores, starting with a letter.
+// Catalog-declared auth env vars feed directly into generated config.go reads,
+// so the validator rejects shapes the generator could not emit safely.
+var authEnvVarPattern = regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`)
+
 // Public categories first, alphabetized. "other" and "example" are explicitly
 // special (catch-all / test-only) and kept at the end.
 var validCategories = map[string]struct{}{
@@ -142,6 +148,16 @@ type Entry struct {
 	// rather than a deep link to the keys UI. Overrides any spec-supplied
 	// x-auth-instructions value.
 	AuthInstructions string `yaml:"auth_instructions,omitempty"`
+	// AuthEnvVars lists canonical credential env var names this API's
+	// ecosystem already uses (e.g. STRIPE_SECRET_KEY for stripe-cli /
+	// stripe-go / stripe-node / stripe-python). The generator emits config.go
+	// reads in declared order so an operator who exports any one of them
+	// satisfies auth. Catalog-mode generation bypasses the spec-edit step
+	// that x-auth-env-vars covers, so this is the only place to declare the
+	// canonical names without hand-editing the generated CLI. The generator
+	// appends its name-derived fallback (e.g. STRIPE_BEARER_AUTH) as the last
+	// entry so operators on existing setups don't need a migration.
+	AuthEnvVars []string `yaml:"auth_env_vars,omitempty"`
 	// ClientPattern describes the HTTP client pattern needed. Empty defaults to "rest".
 	// Values: rest, proxy-envelope, graphql.
 	ClientPattern string `yaml:"client_pattern,omitempty"`
@@ -321,10 +337,37 @@ func (e *Entry) Validate() error {
 	if e.AuthKeyURL != "" && !strings.HasPrefix(e.AuthKeyURL, "https://") {
 		return fmt.Errorf(`auth_key_url must start with "https://"`)
 	}
+	if err := validateAuthEnvVars(e.AuthEnvVars); err != nil {
+		return err
+	}
 
 	return nil
 }
 
+func validateAuthEnvVars(envVars []string) error {
+	if len(envVars) == 0 {
+		return nil
+	}
+	seen := make(map[string]struct{}, len(envVars))
+	for i, name := range envVars {
+		trimmed := strings.TrimSpace(name)
+		if trimmed == "" {
+			return fmt.Errorf("auth_env_vars[%d] must not be empty", i)
+		}
+		if trimmed != name {
+			return fmt.Errorf("auth_env_vars[%d] %q must not have leading or trailing whitespace", i, name)
+		}
+		if !authEnvVarPattern.MatchString(name) {
+			return fmt.Errorf("auth_env_vars[%d] %q must be uppercase letters, digits, or underscores starting with a letter", i, name)
+		}
+		if _, dup := seen[name]; dup {
+			return fmt.Errorf("auth_env_vars[%d] %q is a duplicate", i, name)
+		}
+		seen[name] = struct{}{}
+	}
+	return nil
+}
+
 func validateBearerRefresh(cfg BearerRefresh) error {
 	if !cfg.enabled() {
 		return nil
diff --git a/internal/catalog/catalog_test.go b/internal/catalog/catalog_test.go
index 942e8a3d..b23f8082 100644
--- a/internal/catalog/catalog_test.go
+++ b/internal/catalog/catalog_test.go
@@ -183,6 +183,48 @@ func TestValidateEntry(t *testing.T) {
 			},
 			wantErr: `auth_key_url must start with "https://"`,
 		},
+		{
+			name: "auth_env_vars empty entry",
+			mutate: func(e *Entry) {
+				e.AuthEnvVars = []string{"STRIPE_SECRET_KEY", " "}
+			},
+			wantErr: "auth_env_vars[1] must not be empty",
+		},
+		{
+			name: "auth_env_vars leading whitespace rejected",
+			mutate: func(e *Entry) {
+				e.AuthEnvVars = []string{"  STRIPE_KEY"}
+			},
+			wantErr: "must not have leading or trailing whitespace",
+		},
+		{
+			name: "auth_env_vars trailing whitespace rejected",
+			mutate: func(e *Entry) {
+				e.AuthEnvVars = []string{"STRIPE_KEY  "}
+			},
+			wantErr: "must not have leading or trailing whitespace",
+		},
+		{
+			name: "auth_env_vars lowercase rejected",
+			mutate: func(e *Entry) {
+				e.AuthEnvVars = []string{"stripe_secret_key"}
+			},
+			wantErr: "must be uppercase letters",
+		},
+		{
+			name: "auth_env_vars duplicate rejected",
+			mutate: func(e *Entry) {
+				e.AuthEnvVars = []string{"STRIPE_SECRET_KEY", "STRIPE_SECRET_KEY"}
+			},
+			wantErr: `auth_env_vars[1] "STRIPE_SECRET_KEY" is a duplicate`,
+		},
+		{
+			name: "auth_env_vars leading digit rejected",
+			mutate: func(e *Entry) {
+				e.AuthEnvVars = []string{"1STRIPE_KEY"}
+			},
+			wantErr: "must be uppercase letters",
+		},
 	}
 
 	for _, tt := range tests {
@@ -322,6 +364,38 @@ func TestOptionalFieldsOmittedValid(t *testing.T) {
 	assert.Empty(t, entry.BearerRefresh.BundleURL)
 }
 
+func TestAuthEnvVarsValid(t *testing.T) {
+	entry := Entry{
+		Name:        "stripe",
+		DisplayName: "Stripe",
+		Description: "Payments API",
+		Category:    "payments",
+		SpecURL:     "https://example.com/openapi.yaml",
+		SpecFormat:  "yaml",
+		Tier:        "official",
+		AuthEnvVars: []string{"STRIPE_SECRET_KEY", "STRIPE_API_KEY"},
+	}
+	require.NoError(t, entry.Validate())
+}
+
+func TestAuthEnvVarsParse(t *testing.T) {
+	data := []byte(`
+name: stripe
+display_name: Stripe
+description: Payments
+category: payments
+spec_url: https://example.com/openapi.yaml
+spec_format: yaml
+tier: official
+auth_env_vars:
+  - STRIPE_SECRET_KEY
+  - STRIPE_API_KEY
+`)
+	entry, err := ParseEntry(data)
+	require.NoError(t, err)
+	assert.Equal(t, []string{"STRIPE_SECRET_KEY", "STRIPE_API_KEY"}, entry.AuthEnvVars)
+}
+
 func TestBearerRefreshValid(t *testing.T) {
 	entry := Entry{
 		Name:        "browser-api",
diff --git a/internal/catalogmeta/catalogmeta.go b/internal/catalogmeta/catalogmeta.go
index 845f6cb2..ef8047ee 100644
--- a/internal/catalogmeta/catalogmeta.go
+++ b/internal/catalogmeta/catalogmeta.go
@@ -8,6 +8,57 @@ import (
 	"github.com/mvanhorn/cli-printing-press/v4/internal/spec"
 )
 
+// ApplyCatalogAuthEnvVars overrides the parser-derived auth env var list with
+// curator-declared canonical names from a catalog entry. The catalog names
+// take precedence; previously declared names (the parser's default
+// <API>_BEARER_AUTH / <API>_TOKEN, or any x-auth-env-vars values) are
+// appended as trailing fallbacks so operators on existing setups don't need
+// a rename to keep auth working. EnvVarSpecs are rebuilt as a non-required
+// OR-case (any matching env var satisfies auth) so the generator's
+// AuthHeader() code reads each in declared order and returns the first
+// non-empty value.
+//
+// The override is a no-op when catalogEnvVars is empty, the spec carries no
+// auth surface, or the spec declares HTTP Basic auth (auth.Format contains
+// "Basic "). Basic auth treats env vars as a credential pair (username:
+// password), not as alternatives, so layering an OR-case list on top would
+// generate code that returns an empty Authorization header. Curators who need
+// to override basic-auth env var names should declare them via the spec's
+// x-auth-env-vars extension instead.
+func ApplyCatalogAuthEnvVars(auth *spec.AuthConfig, catalogEnvVars []string) {
+	if auth == nil || len(catalogEnvVars) == 0 || auth.Type == "" || auth.Type == "none" {
+		return
+	}
+	if strings.Contains(strings.ToLower(auth.Format), "basic ") {
+		return
+	}
+	merged := mergeAuthEnvVars(catalogEnvVars, auth.EnvVars)
+	if len(merged) == 0 {
+		return
+	}
+	auth.EnvVars = merged
+	auth.EnvVarSpecs = spec.NewORCaseEnvVarSpecs(merged)
+}
+
+func mergeAuthEnvVars(catalog, existing []string) []string {
+	seen := make(map[string]struct{}, len(catalog)+len(existing))
+	merged := make([]string, 0, len(catalog)+len(existing))
+	for _, source := range [][]string{catalog, existing} {
+		for _, name := range source {
+			name = strings.TrimSpace(name)
+			if name == "" {
+				continue
+			}
+			if _, dup := seen[name]; dup {
+				continue
+			}
+			seen[name] = struct{}{}
+			merged = append(merged, name)
+		}
+	}
+	return merged
+}
+
 func RebaseAuthEnvPrefix(auth *spec.AuthConfig, oldName, newName string) {
 	if auth == nil || oldName == "" || newName == "" || oldName == newName {
 		return
@@ -56,6 +107,7 @@ func ApplyRuntimeMetadata(apiSpec *spec.APISpec, entry *catalog.Entry) {
 	if entry.AuthInstructions != "" {
 		apiSpec.Auth.Instructions = entry.AuthInstructions
 	}
+	ApplyCatalogAuthEnvVars(&apiSpec.Auth, entry.AuthEnvVars)
 	if entry.ClientPattern != "" {
 		apiSpec.ClientPattern = entry.ClientPattern
 	}
diff --git a/internal/catalogmeta/catalogmeta_test.go b/internal/catalogmeta/catalogmeta_test.go
new file mode 100644
index 00000000..c7c739cc
--- /dev/null
+++ b/internal/catalogmeta/catalogmeta_test.go
@@ -0,0 +1,90 @@
+package catalogmeta
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/mvanhorn/cli-printing-press/v4/internal/spec"
+)
+
+func TestApplyCatalogAuthEnvVars_PrependsCatalogNamesAndPreservesFallback(t *testing.T) {
+	auth := &spec.AuthConfig{
+		Type:    "bearer_token",
+		EnvVars: []string{"STRIPE_BEARER_AUTH"},
+		EnvVarSpecs: []spec.AuthEnvVar{
+			{Name: "STRIPE_BEARER_AUTH", Kind: spec.AuthEnvVarKindPerCall, Required: true, Sensitive: true, Inferred: true},
+		},
+	}
+
+	ApplyCatalogAuthEnvVars(auth, []string{"STRIPE_SECRET_KEY", "STRIPE_API_KEY"})
+
+	assert.Equal(t, []string{"STRIPE_SECRET_KEY", "STRIPE_API_KEY", "STRIPE_BEARER_AUTH"}, auth.EnvVars)
+	require.Len(t, auth.EnvVarSpecs, 3)
+	for _, ev := range auth.EnvVarSpecs {
+		assert.Equal(t, spec.AuthEnvVarKindPerCall, ev.Kind)
+		assert.False(t, ev.Required, "OR-case entries must not be Required for IsAuthEnvVarORCase to fire")
+		assert.True(t, ev.Sensitive)
+	}
+	assert.True(t, auth.IsAuthEnvVarORCase(), "rebuilt EnvVarSpecs should produce OR-case auth")
+}
+
+func TestApplyCatalogAuthEnvVars_NoopWhenCatalogListEmpty(t *testing.T) {
+	auth := &spec.AuthConfig{
+		Type:    "bearer_token",
+		EnvVars: []string{"STRIPE_BEARER_AUTH"},
+	}
+
+	ApplyCatalogAuthEnvVars(auth, nil)
+
+	assert.Equal(t, []string{"STRIPE_BEARER_AUTH"}, auth.EnvVars)
+}
+
+func TestApplyCatalogAuthEnvVars_NoopWhenAuthTypeNone(t *testing.T) {
+	auth := &spec.AuthConfig{Type: "none"}
+
+	ApplyCatalogAuthEnvVars(auth, []string{"FOO"})
+
+	assert.Empty(t, auth.EnvVars)
+	assert.Empty(t, auth.EnvVarSpecs)
+}
+
+func TestApplyCatalogAuthEnvVars_DedupesBetweenCatalogAndExisting(t *testing.T) {
+	auth := &spec.AuthConfig{
+		Type:    "bearer_token",
+		EnvVars: []string{"GITHUB_TOKEN", "GITHUB_BEARER_AUTH"},
+	}
+
+	ApplyCatalogAuthEnvVars(auth, []string{"GITHUB_TOKEN", "GH_TOKEN"})
+
+	assert.Equal(t, []string{"GITHUB_TOKEN", "GH_TOKEN", "GITHUB_BEARER_AUTH"}, auth.EnvVars)
+}
+
+func TestApplyCatalogAuthEnvVars_NoopForBasicAuth(t *testing.T) {
+	// Basic auth treats env vars as a credential pair, not as alternatives.
+	// Stacking an OR-case list on basic auth would produce config.go that
+	// returns an empty Authorization header, so the override bails out.
+	auth := &spec.AuthConfig{
+		Type:    "api_key",
+		Format:  "Basic {credentials}",
+		EnvVars: []string{"STYTCH_PROJECT_ID", "STYTCH_SECRET"},
+	}
+
+	ApplyCatalogAuthEnvVars(auth, []string{"STYTCH_API_KEY"})
+
+	assert.Equal(t, []string{"STYTCH_PROJECT_ID", "STYTCH_SECRET"}, auth.EnvVars,
+		"basic-auth env vars must survive a catalog override attempt")
+	assert.Empty(t, auth.EnvVarSpecs, "EnvVarSpecs must not be rebuilt for basic-auth specs")
+}
+
+func TestApplyCatalogAuthEnvVars_TrimsWhitespaceAndSkipsEmpty(t *testing.T) {
+	auth := &spec.AuthConfig{
+		Type:    "bearer_token",
+		EnvVars: []string{"  ", "DISCORD_BEARER_AUTH"},
+	}
+
+	ApplyCatalogAuthEnvVars(auth, []string{"  DISCORD_TOKEN  ", "DISCORD_BOT_TOKEN"})
+
+	assert.Equal(t, []string{"DISCORD_TOKEN", "DISCORD_BOT_TOKEN", "DISCORD_BEARER_AUTH"}, auth.EnvVars)
+}
diff --git a/internal/cli/root.go b/internal/cli/root.go
index b53c52a2..257c0e94 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -1602,6 +1602,7 @@ func enrichSpecFromCatalogEntry(apiSpec *spec.APISpec, entry *catalog.Entry) {
 	if entry.AuthInstructions != "" && apiSpec.Auth.Type != "none" {
 		apiSpec.Auth.Instructions = entry.AuthInstructions
 	}
+	catalogmeta.ApplyCatalogAuthEnvVars(&apiSpec.Auth, entry.AuthEnvVars)
 }
 
 func mcpConfigured(m spec.MCPConfig) bool {
diff --git a/internal/generator/auth_env_precedence_test.go b/internal/generator/auth_env_precedence_test.go
index 5165ec93..287558bf 100644
--- a/internal/generator/auth_env_precedence_test.go
+++ b/internal/generator/auth_env_precedence_test.go
@@ -6,6 +6,7 @@ import (
 	"strings"
 	"testing"
 
+	"github.com/mvanhorn/cli-printing-press/v4/internal/catalogmeta"
 	"github.com/mvanhorn/cli-printing-press/v4/internal/spec"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
@@ -402,6 +403,83 @@ func TestTierRouting_BearerPrefix(t *testing.T) {
 		"default Bearer literal must not leak when tier prefix is overridden")
 }
 
+// TestCatalogAuthEnvVars_GenerateReadsCatalogNamesFirst pins the issue #1482
+// acceptance criterion: when a catalog entry declares auth_env_vars, the
+// generator emits config.go reading the catalog-declared names first, in
+// order, with the parser's name-derived default trailing as a fallback so
+// operators who already export the legacy name keep working without a
+// migration.
+func TestCatalogAuthEnvVars_GenerateReadsCatalogNamesFirst(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("stripe")
+	apiSpec.Auth = spec.AuthConfig{
+		Type:    "bearer_token",
+		Header:  "Authorization",
+		EnvVars: []string{"STRIPE_BEARER_AUTH"},
+		EnvVarSpecs: []spec.AuthEnvVar{
+			{Name: "STRIPE_BEARER_AUTH", Kind: spec.AuthEnvVarKindPerCall, Required: true, Sensitive: true, Inferred: true},
+		},
+	}
+
+	catalogmeta.ApplyCatalogAuthEnvVars(&apiSpec.Auth, []string{"STRIPE_SECRET_KEY", "STRIPE_API_KEY"})
+
+	outputDir := filepath.Join(t.TempDir(), "stripe-pp-cli")
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	cfgSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "config", "config.go"))
+	require.NoError(t, err)
+	content := string(cfgSrc)
+
+	for _, name := range []string{"STRIPE_SECRET_KEY", "STRIPE_API_KEY", "STRIPE_BEARER_AUTH"} {
+		field := resolveEnvVarField(name)
+		assert.Contains(t, content, "if v := os.Getenv(\""+name+"\"); v != \"\" {",
+			"Load() must read env var %s", name)
+		assert.Contains(t, content, "cfg."+field, "Config struct must carry field for %s", name)
+	}
+
+	body := authHeaderBody(t, content)
+	secretIdx := strings.Index(body, "if c."+resolveEnvVarField("STRIPE_SECRET_KEY")+` != ""`)
+	apiIdx := strings.Index(body, "if c."+resolveEnvVarField("STRIPE_API_KEY")+` != ""`)
+	bearerIdx := strings.Index(body, "if c."+resolveEnvVarField("STRIPE_BEARER_AUTH")+` != ""`)
+
+	require.NotEqual(t, -1, secretIdx, "AuthHeader must check STRIPE_SECRET_KEY first")
+	require.NotEqual(t, -1, apiIdx, "AuthHeader must check STRIPE_API_KEY")
+	require.NotEqual(t, -1, bearerIdx, "AuthHeader must retain STRIPE_BEARER_AUTH fallback")
+	assert.Less(t, secretIdx, apiIdx, "STRIPE_SECRET_KEY must be tried before STRIPE_API_KEY")
+	assert.Less(t, apiIdx, bearerIdx, "STRIPE_API_KEY must be tried before legacy STRIPE_BEARER_AUTH fallback")
+}
+
+// TestCatalogAuthEnvVars_GenerateUnchangedWithoutCatalogList pins the
+// negative acceptance criterion: an API without catalog auth_env_vars
+// continues to emit only the parser's name-derived default env var, so
+// existing CLIs regenerate to byte-equivalent config.go.
+func TestCatalogAuthEnvVars_GenerateUnchangedWithoutCatalogList(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("legacy")
+	apiSpec.Auth = spec.AuthConfig{
+		Type:    "bearer_token",
+		Header:  "Authorization",
+		EnvVars: []string{"LEGACY_BEARER_AUTH"},
+		EnvVarSpecs: []spec.AuthEnvVar{
+			{Name: "LEGACY_BEARER_AUTH", Kind: spec.AuthEnvVarKindPerCall, Required: true, Sensitive: true, Inferred: true},
+		},
+	}
+
+	catalogmeta.ApplyCatalogAuthEnvVars(&apiSpec.Auth, nil)
+
+	outputDir := filepath.Join(t.TempDir(), "legacy-pp-cli")
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	cfgSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "config", "config.go"))
+	require.NoError(t, err)
+	content := string(cfgSrc)
+
+	assert.Contains(t, content, "if v := os.Getenv(\"LEGACY_BEARER_AUTH\"); v != \"\"")
+	assert.NotContains(t, content, "STRIPE_SECRET_KEY")
+}
+
 // authHeaderBody slices out just the AuthHeader function body so precedence
 // assertions can't be tricked by a matching pattern in unrelated code
 // further down the file.
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 3c00fb3e..308aa79a 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -773,6 +773,24 @@ func (c *AuthConfig) CanonicalEnvVar() *AuthEnvVar {
 	return nil
 }
 
+// NewORCaseEnvVarSpecs builds the EnvVarSpecs slice for the OR-case shape
+// IsAuthEnvVarORCase validates: each entry is per_call, non-required, and
+// sensitive. The runtime tries each in turn and returns the first non-empty
+// value. Distinct from the per_call construction in NormalizeEnvVarSpecs,
+// which defaults to Required=true for the canonical-credential shape.
+func NewORCaseEnvVarSpecs(names []string) []AuthEnvVar {
+	specs := make([]AuthEnvVar, 0, len(names))
+	for _, name := range names {
+		specs = append(specs, AuthEnvVar{
+			Name:      name,
+			Kind:      AuthEnvVarKindPerCall,
+			Required:  false,
+			Sensitive: true,
+		})
+	}
+	return specs
+}
+
 // IsAuthEnvVarORCase reports whether all EnvVarSpecs are non-required per_call vars.
 // In this shape, no single var is the canonical credential; the runtime tries each
 // in turn and returns the first non-empty value. Returns false when EnvVarSpecs has
diff --git a/skills/printing-press/SKILL.md b/skills/printing-press/SKILL.md
index 18cd6256..f4bc614b 100644
--- a/skills/printing-press/SKILL.md
+++ b/skills/printing-press/SKILL.md
@@ -1630,7 +1630,11 @@ auth signals, enrich the spec before generation:
 3. Check Phase 1.6 Pre-Browser-Sniff Auth Intelligence results (if the user confirmed auth)
 
 If any source identified auth, **edit the spec YAML** to add the auth section before
-running generate. For internal YAML specs:
+running generate. Catalog-mode runs (`printing-press generate <name>` where `<name>`
+is in `catalog/`) can skip the spec edit when the catalog entry declares
+`auth_env_vars` — those canonical names are applied automatically and the
+parser's name-derived default name is retained as a trailing fallback so
+operators on existing setups don't need a rename. For internal YAML specs:
 
 ```yaml
 auth:

← bf7f3465 fix(cli): route promoted-command file stem through safeResou  ·  back to Cli Printing Press  ·  fix(cli): preserve body/content payload on single-object com c7e1b82b →