← back to Cli Printing Press
fix(cli): preserve canonical auth env hints (#633)
6b5945fdec5af4255b655ebda6a20faf4305b990 · 2026-05-05 13:32:44 -0700 · Trevin Chow
Files touched
M docs/SPEC-EXTENSIONS.mdM internal/openapi/parser.goM internal/openapi/parser_test.goM skills/printing-press/SKILL.md
Diff
commit 6b5945fdec5af4255b655ebda6a20faf4305b990
Author: Trevin Chow <trevin@trevinchow.com>
Date: Tue May 5 13:32:44 2026 -0700
fix(cli): preserve canonical auth env hints (#633)
---
docs/SPEC-EXTENSIONS.md | 16 +++++++
internal/openapi/parser.go | 85 ++++++++++++++++++++++++++++------
internal/openapi/parser_test.go | 100 ++++++++++++++++++++++++++++++++++++++++
skills/printing-press/SKILL.md | 5 ++
4 files changed, 193 insertions(+), 13 deletions(-)
diff --git a/docs/SPEC-EXTENSIONS.md b/docs/SPEC-EXTENSIONS.md
index 4a54c31e..b971dd28 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-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 |
| `x-auth-title` | `components.securitySchemes.<name>` | `APISpec.Auth.Title` | No |
@@ -239,6 +240,21 @@ Rules:
- When at least one non-empty item is present, the list replaces the parser's
generated env var names.
+### `x-speakeasy-example`
+
+Uses a Speakeasy security-scheme example as the credential environment variable
+name when it is shaped like a shell env var.
+
+Parsed field: `APISpec.Auth.EnvVars`
+
+Rules:
+- Optional.
+- Must be a string shaped like an uppercase environment variable name, for
+ example `DUB_API_KEY`.
+- Ignored when `x-auth-env-vars` is present.
+- Ignored when the selected auth config has multiple env vars.
+- Ignored when the value looks like a token value instead of an env var name.
+
### `x-auth-optional`
Marks the credential as optional for install/config surfaces.
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index 0cbfca24..b416e158 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -27,17 +27,18 @@ var (
)
const (
- extensionAuthEnvVars = "x-auth-env-vars"
- extensionAuthOptional = "x-auth-optional"
- extensionAuthKeyURL = "x-auth-key-url"
- extensionAuthTitle = "x-auth-title"
- extensionAuthDescription = "x-auth-description"
- extensionTierRouting = "x-tier-routing"
- extensionTier = "x-tier"
- extensionAPIName = "x-api-name"
- extensionDisplayName = "x-display-name"
- extensionWebsite = "x-website"
- extensionProxyRoutes = "x-proxy-routes"
+ extensionAuthEnvVars = "x-auth-env-vars"
+ extensionAuthOptional = "x-auth-optional"
+ extensionAuthKeyURL = "x-auth-key-url"
+ extensionAuthTitle = "x-auth-title"
+ extensionAuthDescription = "x-auth-description"
+ extensionSpeakeasyExample = "x-speakeasy-example"
+ extensionTierRouting = "x-tier-routing"
+ extensionTier = "x-tier"
+ extensionAPIName = "x-api-name"
+ extensionDisplayName = "x-display-name"
+ extensionWebsite = "x-website"
+ extensionProxyRoutes = "x-proxy-routes"
)
// SetMaxResources overrides the default resource limit. When not called,
@@ -490,7 +491,7 @@ func mapAuth(doc *openapi3.T, name string) spec.AuthConfig {
func isGenericAPIKeySchemeSuffix(suffix string) bool {
normalized := strings.ReplaceAll(strings.ToLower(strings.TrimSpace(suffix)), "_", "")
switch normalized {
- case "", "apikey", "apikeyauth":
+ case "", "auth", "authentication", "apikey", "apikeyauth", "default":
return true
}
for _, prefix := range []string{"apikeyv", "apikeyauthv"} {
@@ -517,7 +518,11 @@ func applyAuthOverrideExtensions(auth *spec.AuthConfig, extensions map[string]an
return
}
if envVars := stringListExtension(extensions, extensionAuthEnvVars); len(envVars) > 0 {
- auth.EnvVars = envVars
+ applyAuthEnvVars(auth, envVars)
+ } else if len(auth.EnvVars) == 1 {
+ if envVar := envVarExtension(extensions, extensionSpeakeasyExample); envVar != "" {
+ applyAuthEnvVars(auth, []string{envVar})
+ }
}
if optional, ok := boolExtension(extensions, extensionAuthOptional); ok {
auth.Optional = optional
@@ -533,6 +538,60 @@ func applyAuthOverrideExtensions(auth *spec.AuthConfig, extensions map[string]an
}
}
+func applyAuthEnvVars(auth *spec.AuthConfig, envVars []string) {
+ oldEnvVars := append([]string(nil), auth.EnvVars...)
+ auth.EnvVars = envVars
+ remapAuthFormatForEnvOverride(auth, oldEnvVars, envVars)
+}
+
+func remapAuthFormatForEnvOverride(auth *spec.AuthConfig, oldEnvVars, newEnvVars []string) {
+ if auth.Format == "" || len(oldEnvVars) != 1 || len(newEnvVars) != 1 {
+ return
+ }
+ oldPlaceholder := naming.EnvVarPlaceholder(oldEnvVars[0])
+ newPlaceholder := naming.EnvVarPlaceholder(newEnvVars[0])
+ if oldPlaceholder == "" || newPlaceholder == "" {
+ return
+ }
+ auth.Format = strings.ReplaceAll(auth.Format, "{"+oldPlaceholder+"}", "{"+newPlaceholder+"}")
+ auth.Format = strings.ReplaceAll(auth.Format, "{"+oldEnvVars[0]+"}", "{"+newPlaceholder+"}")
+}
+
+func envVarExtension(extensions map[string]any, name string) string {
+ value, ok := extensions[name]
+ if !ok {
+ return ""
+ }
+ s, ok := value.(string)
+ if !ok {
+ return ""
+ }
+ s = strings.TrimSpace(s)
+ if !isUpperEnvVarName(s) {
+ return ""
+ }
+ return s
+}
+
+func isUpperEnvVarName(s string) bool {
+ if s == "" {
+ return false
+ }
+ for i, r := range s {
+ if r == '_' || ('A' <= r && r <= 'Z') {
+ continue
+ }
+ if '0' <= r && r <= '9' {
+ if i == 0 {
+ return false
+ }
+ continue
+ }
+ return false
+ }
+ return true
+}
+
func stringExtension(extensions map[string]any, name string) string {
value, ok := extensions[name]
if !ok {
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index a9b128ef..6b5d4c31 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -1096,6 +1096,7 @@ func TestGenericAPIKeySchemeNamesUseAPIKeyEnvVar(t *testing.T) {
"ApiKeyAuth",
"APIKey",
"ApiKeyAuth_v2",
+ "auth",
}
for _, schemeName := range tests {
@@ -1130,6 +1131,105 @@ paths:
}
}
+func TestSpeakeasyAuthExampleOverridesDerivedEnvVar(t *testing.T) {
+ t.Parallel()
+
+ yamlSpec := []byte(`openapi: "3.0.3"
+info:
+ title: Dub API
+ version: "1.0.0"
+servers:
+ - url: https://api.dub.co
+components:
+ securitySchemes:
+ token:
+ type: http
+ scheme: bearer
+ x-speakeasy-example: DUB_API_KEY
+paths:
+ /links:
+ get:
+ security:
+ - token: []
+ responses:
+ "200":
+ description: OK
+`)
+ parsed, err := Parse(yamlSpec)
+ require.NoError(t, err)
+
+ assert.Equal(t, "bearer_token", parsed.Auth.Type)
+ assert.Equal(t, []string{"DUB_API_KEY"}, parsed.Auth.EnvVars)
+}
+
+func TestSpeakeasyAuthExampleRemapsInferredFormatPlaceholder(t *testing.T) {
+ t.Parallel()
+
+ yamlSpec := []byte(`openapi: "3.0.3"
+info:
+ title: Discord
+ version: "1.0.0"
+servers:
+ - url: https://discord.com/api
+components:
+ securitySchemes:
+ BotToken:
+ type: apiKey
+ in: header
+ name: Authorization
+ x-speakeasy-example: DISCORD_TOKEN
+paths:
+ /users/@me:
+ get:
+ security:
+ - BotToken: []
+ responses:
+ "200":
+ description: OK
+`)
+ parsed, err := Parse(yamlSpec)
+ require.NoError(t, err)
+
+ assert.Equal(t, []string{"DISCORD_TOKEN"}, parsed.Auth.EnvVars)
+ assert.Equal(t, "Bot {token}", parsed.Auth.Format)
+}
+
+func TestSpeakeasyAuthExampleDoesNotOverrideExplicitEnvVars(t *testing.T) {
+ t.Parallel()
+
+ yamlSpec := []byte(`openapi: "3.0.3"
+info:
+ title: OAuth Client
+ version: "1.0.0"
+servers:
+ - url: https://api.example.com
+components:
+ securitySchemes:
+ ClientCredentials:
+ type: oauth2
+ x-auth-env-vars:
+ - OAUTH_CLIENT_ID
+ - OAUTH_CLIENT_SECRET
+ x-speakeasy-example: OAUTH_TOKEN
+ flows:
+ clientCredentials:
+ tokenUrl: https://api.example.com/oauth/token
+ scopes: {}
+paths:
+ /widgets:
+ get:
+ security:
+ - ClientCredentials: []
+ responses:
+ "200":
+ description: OK
+`)
+ parsed, err := Parse(yamlSpec)
+ require.NoError(t, err)
+
+ assert.Equal(t, []string{"OAUTH_CLIENT_ID", "OAUTH_CLIENT_SECRET"}, parsed.Auth.EnvVars)
+}
+
func TestOpenAPIAuthOverrideExtensions(t *testing.T) {
t.Parallel()
diff --git a/skills/printing-press/SKILL.md b/skills/printing-press/SKILL.md
index d484faff..9ea83c9e 100644
--- a/skills/printing-press/SKILL.md
+++ b/skills/printing-press/SKILL.md
@@ -1485,6 +1485,11 @@ auth:
- <API_NAME>_TOKEN # bearer_token → _TOKEN, api_key → _API_KEY
```
+When research or source metadata names a real env var, use only that canonical
+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, add an `info.description` mention if one doesn't exist — the
parser's `inferDescriptionAuth` will detect it automatically.
← 013f92ad fix(cli): correct no-auth 403 hints (#629)
·
back to Cli Printing Press
·
fix(cli): infer bearer auth from inline params (#634) b4a95cb7 →