[object Object]

← back to Cli Printing Press

feat(cli): point users at where to get a token (URL + instructions + auth setup --launch) (#871)

4fac827e8c3e90d77ef2c89017eab6f8c81cf747 · 2026-05-11 01:34:18 -0500 · Giuliano Pezzolo Giacaglia

* feat(cli): infer and surface auth key URL on token prompts

When a printed CLI prompts for a missing token, point the user at the page
where they can obtain one. Adds a parser inference fallback (security scheme
description -> info.description with auth cues -> externalDocs.url ->
info.contact.url), a new catalog auth_key_url field that overrides
inference, and backfilled URLs for 14 catalog entries.

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

* feat(cli): add auth setup command with --launch and per-API instructions

Pairs the URL printed by auth prompts with one-line free-form guidance
("Settings -> Personal access tokens -> Generate new") and a new
`auth setup` command that prints the same steps. `--launch` opens the URL
in the user's default browser, short-circuited by cliutil.IsVerifyEnv()
so verifier subprocesses don't pop tabs.

Adds spec.Auth.Instructions, x-auth-instructions extension, catalog
auth_instructions field (overrides spec). Backfills the 14 catalog
entries that already declared auth_key_url. Goldens updated for the new
auth setup subcommand emitted by auth.go.tmpl and auth_simple.go.tmpl.

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

* docs(cli): point skill template at auth setup for credential discovery

Per AGENTS.md Skill Authoring rule: when a machine change adds an
agent-relevant command, update the skill template in the same PR. The
api_key, oauth2, and bearer_token branches now name `auth setup` as the
discovery step before the existing token-storage commands.

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

* fix(cli): stop labeling docs URLs as credential URLs

The previous inference fell back to externalDocs.url and info.contact.url
when picking Auth.KeyURL, which produced misleading "Get a key at: <docs-url>"
prompts (e.g. Figma's externalDocs is a docs landing page, not a credentials
page). Drop those two fallbacks. KeyURL now requires either an explicit
x-auth-key-url, a catalog auth_key_url override, or an auth-cue-anchored URL
extracted from a security scheme description or info.description.

When KeyURL is empty but the spec has a docs URL (already populated as
WebsiteURL from externalDocs/contact/x-website), the printed CLI surfaces
it under a separate `See API docs: <URL>` line in helpers, doctor,
mcp/tools, and the auth setup command. Same URL, honest framing.

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

* fix(cli): address PR #871 review comments

- parser.go: scan for $ref now handles both JSON ("$ref": ...) and YAML
  ($ref: ...) formats so the InternalizeRefs skip optimization no longer
  silently bypasses external-ref resolution for YAML specs.
- auth.go.tmpl: auth setup --launch now uses openSetupURL which returns
  an error and prints a manual-fallback line, matching auth_simple's
  behavior. openBrowser remains for auth login.
- docs/CATALOG.md: corrects the inference fallback list to match the
  parser (security scheme description + info.description with cues only;
  externalDocs.url and info.contact.url are intentionally not fallbacks).

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

* fix(cli): escape Auth.Instructions in generated Go string literals

Address greptile P1 (and Champworks' review): .Auth.Instructions was
embedded directly inside Go double-quoted string literals in three
templates, so any catalog auth_instructions or x-auth-instructions
value containing " or \ would produce a syntax error in the generated
CLI. The 14 backfilled catalog entries are plain ASCII so no current
output breaks, but the template path accepts arbitrary user text.

Switch all 9 sites to the same pattern mcp_tools.go.tmpl already uses:
{{printf "%q" (printf "<prefix>%s" .Auth.Instructions)}}. This is
byte-identical to the previous output for ASCII-safe values (no golden
drift) but properly escapes special characters.

Sites updated: auth.go.tmpl (1), auth_simple.go.tmpl (1),
helpers.go.tmpl (7 — 401/403 hint paths across api_key, bearer_token,
oauth2, and the no-auth fallback).

Add TestGeneratedHelpers_AuthInstructionsWithSpecialChars_Compiles as
a regression test: generates a CLI with Instructions containing both
" and \, and asserts the result compiles. Verified by temporarily
reverting one template — the test fails with three concrete syntax
errors in auth.go (unexpected name, unknown escape, newline in
string), confirming it catches the bug.

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

---------

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

Files touched

Diff

commit 4fac827e8c3e90d77ef2c89017eab6f8c81cf747
Author: Giuliano Pezzolo Giacaglia <raulzito234@gmail.com>
Date:   Mon May 11 01:34:18 2026 -0500

    feat(cli): point users at where to get a token (URL + instructions + auth setup --launch) (#871)
    
    * feat(cli): infer and surface auth key URL on token prompts
    
    When a printed CLI prompts for a missing token, point the user at the page
    where they can obtain one. Adds a parser inference fallback (security scheme
    description -> info.description with auth cues -> externalDocs.url ->
    info.contact.url), a new catalog auth_key_url field that overrides
    inference, and backfilled URLs for 14 catalog entries.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * feat(cli): add auth setup command with --launch and per-API instructions
    
    Pairs the URL printed by auth prompts with one-line free-form guidance
    ("Settings -> Personal access tokens -> Generate new") and a new
    `auth setup` command that prints the same steps. `--launch` opens the URL
    in the user's default browser, short-circuited by cliutil.IsVerifyEnv()
    so verifier subprocesses don't pop tabs.
    
    Adds spec.Auth.Instructions, x-auth-instructions extension, catalog
    auth_instructions field (overrides spec). Backfills the 14 catalog
    entries that already declared auth_key_url. Goldens updated for the new
    auth setup subcommand emitted by auth.go.tmpl and auth_simple.go.tmpl.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * docs(cli): point skill template at auth setup for credential discovery
    
    Per AGENTS.md Skill Authoring rule: when a machine change adds an
    agent-relevant command, update the skill template in the same PR. The
    api_key, oauth2, and bearer_token branches now name `auth setup` as the
    discovery step before the existing token-storage commands.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * fix(cli): stop labeling docs URLs as credential URLs
    
    The previous inference fell back to externalDocs.url and info.contact.url
    when picking Auth.KeyURL, which produced misleading "Get a key at: <docs-url>"
    prompts (e.g. Figma's externalDocs is a docs landing page, not a credentials
    page). Drop those two fallbacks. KeyURL now requires either an explicit
    x-auth-key-url, a catalog auth_key_url override, or an auth-cue-anchored URL
    extracted from a security scheme description or info.description.
    
    When KeyURL is empty but the spec has a docs URL (already populated as
    WebsiteURL from externalDocs/contact/x-website), the printed CLI surfaces
    it under a separate `See API docs: <URL>` line in helpers, doctor,
    mcp/tools, and the auth setup command. Same URL, honest framing.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * fix(cli): address PR #871 review comments
    
    - parser.go: scan for $ref now handles both JSON ("$ref": ...) and YAML
      ($ref: ...) formats so the InternalizeRefs skip optimization no longer
      silently bypasses external-ref resolution for YAML specs.
    - auth.go.tmpl: auth setup --launch now uses openSetupURL which returns
      an error and prints a manual-fallback line, matching auth_simple's
      behavior. openBrowser remains for auth login.
    - docs/CATALOG.md: corrects the inference fallback list to match the
      parser (security scheme description + info.description with cues only;
      externalDocs.url and info.contact.url are intentionally not fallbacks).
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * fix(cli): escape Auth.Instructions in generated Go string literals
    
    Address greptile P1 (and Champworks' review): .Auth.Instructions was
    embedded directly inside Go double-quoted string literals in three
    templates, so any catalog auth_instructions or x-auth-instructions
    value containing " or \ would produce a syntax error in the generated
    CLI. The 14 backfilled catalog entries are plain ASCII so no current
    output breaks, but the template path accepts arbitrary user text.
    
    Switch all 9 sites to the same pattern mcp_tools.go.tmpl already uses:
    {{printf "%q" (printf "<prefix>%s" .Auth.Instructions)}}. This is
    byte-identical to the previous output for ASCII-safe values (no golden
    drift) but properly escapes special characters.
    
    Sites updated: auth.go.tmpl (1), auth_simple.go.tmpl (1),
    helpers.go.tmpl (7 — 401/403 hint paths across api_key, bearer_token,
    oauth2, and the no-auth fallback).
    
    Add TestGeneratedHelpers_AuthInstructionsWithSpecialChars_Compiles as
    a regression test: generates a CLI with Instructions containing both
    " and \, and asserts the result compiles. Verified by temporarily
    reverting one template — the test fails with three concrete syntax
    errors in auth.go (unexpected name, unknown escape, newline in
    string), confirming it catches the bug.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    ---------
    
    Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 AGENTS.md                                          |   2 +
 catalog/asana.yaml                                 |   2 +
 catalog/digitalocean.yaml                          |   2 +
 catalog/discord.yaml                               |   2 +
 catalog/front.yaml                                 |   2 +
 catalog/github.yaml                                |   2 +
 catalog/hubspot.yaml                               |   2 +
 catalog/launchdarkly.yaml                          |   2 +
 catalog/mercury.yaml                               |   2 +
 catalog/pipedrive.yaml                             |   2 +
 catalog/plaid.yaml                                 |   2 +
 catalog/sentry.yaml                                |   2 +
 catalog/stripe.yaml                                |   2 +
 catalog/stytch.yaml                                |   2 +
 catalog/twilio.yaml                                |   2 +
 docs/CATALOG.md                                    |  17 ++
 docs/SPEC-EXTENSIONS.md                            |  36 ++++
 internal/catalog/catalog.go                        |  16 ++
 internal/catalog/catalog_test.go                   |   7 +
 internal/cli/root.go                               |   6 +
 internal/generator/generator_test.go               |  41 +++++
 internal/generator/templates/auth.go.tmpl          |  73 ++++++++
 internal/generator/templates/auth_simple.go.tmpl   |  88 ++++++++++
 internal/generator/templates/doctor.go.tmpl        |  14 ++
 internal/generator/templates/helpers.go.tmpl       |  35 ++++
 internal/generator/templates/mcp_tools.go.tmpl     |  20 +++
 internal/generator/templates/skill.md.tmpl         |   6 +-
 internal/openapi/parser.go                         | 123 +++++++++++++-
 internal/openapi/parser_test.go                    | 186 +++++++++++++++++++++
 internal/spec/spec.go                              |  11 +-
 .../printing-press-rich-auth/SKILL.md              |   2 +-
 .../printing-press-rich-auth/internal/cli/auth.go  |  32 +++-
 .../expected/generate-golden-api/dogfood.json      |   4 +-
 .../printing-press-golden/SKILL.md                 |   2 +-
 .../printing-press-golden/internal/cli/auth.go     |  29 +++-
 35 files changed, 763 insertions(+), 15 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md
index 8e073654..fadfcf0c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -162,6 +162,8 @@ When adding or editing `catalog/*.yaml`, first decide whether the entry belongs
 - `category` must be one of `ai`, `auth`, `cloud`, `commerce`, `developer-tools`, `devices`, `food-and-dining`, `marketing`, `media-and-entertainment`, `monitoring`, `payments`, `productivity`, `project-management`, `sales-and-crm`, `social-and-messaging`, `travel`, or `other`. The validator also accepts `example` as a test-only catch-all; do not use it for real catalog entries.
 - `tier` must be `official` or `community`.
 - `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.
 - 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/asana.yaml b/catalog/asana.yaml
index ec373e0e..187ee2e5 100644
--- a/catalog/asana.yaml
+++ b/catalog/asana.yaml
@@ -8,4 +8,6 @@ openapi_version: "3.0"
 tier: official
 verified_date: "2026-03-23"
 homepage: https://developers.asana.com/reference
+auth_key_url: https://app.asana.com/0/my-apps
+auth_instructions: "Create a personal access token under Apps & Integrations."
 notes: None.
diff --git a/catalog/digitalocean.yaml b/catalog/digitalocean.yaml
index fba59e7e..558f9836 100644
--- a/catalog/digitalocean.yaml
+++ b/catalog/digitalocean.yaml
@@ -8,4 +8,6 @@ openapi_version: "3.0"
 tier: official
 verified_date: "2026-03-23"
 homepage: https://docs.digitalocean.com/reference/api
+auth_key_url: https://cloud.digitalocean.com/account/api/tokens
+auth_instructions: "Generate a personal access token with read or write scope as needed."
 notes: None.
diff --git a/catalog/discord.yaml b/catalog/discord.yaml
index 879563a4..e801e266 100644
--- a/catalog/discord.yaml
+++ b/catalog/discord.yaml
@@ -8,4 +8,6 @@ openapi_version: "3.1"
 tier: official
 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."
 notes: Very large spec (1MB+). Generator truncates to 50 resources / 20 endpoints per resource.
diff --git a/catalog/front.yaml b/catalog/front.yaml
index 1ee987e2..de0a5b4b 100644
--- a/catalog/front.yaml
+++ b/catalog/front.yaml
@@ -8,4 +8,6 @@ openapi_version: "3.0"
 tier: official
 verified_date: "2026-03-23"
 homepage: https://dev.frontapp.com/reference
+auth_key_url: https://app.frontapp.com/settings/tokens
+auth_instructions: "Generate an API token under Settings -> Developers -> API tokens."
 notes: None.
diff --git a/catalog/github.yaml b/catalog/github.yaml
index 69d9b507..192c113d 100644
--- a/catalog/github.yaml
+++ b/catalog/github.yaml
@@ -8,6 +8,8 @@ openapi_version: "3.0"
 tier: official
 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."
 notes: Very large spec. Generator truncates significantly.
 known_alternatives:
   - name: gh
diff --git a/catalog/hubspot.yaml b/catalog/hubspot.yaml
index 36800e75..03e6502b 100644
--- a/catalog/hubspot.yaml
+++ b/catalog/hubspot.yaml
@@ -8,4 +8,6 @@ openapi_version: "3.0"
 tier: official
 verified_date: "2026-03-23"
 homepage: https://developers.hubspot.com/docs/api
+auth_key_url: https://app.hubspot.com/private-apps
+auth_instructions: "Create a private app, grant CRM scopes, and copy the access token."
 notes: Only covers CRM Contacts endpoint. HubSpot has many separate specs.
diff --git a/catalog/launchdarkly.yaml b/catalog/launchdarkly.yaml
index fdb99b18..feaec3dc 100644
--- a/catalog/launchdarkly.yaml
+++ b/catalog/launchdarkly.yaml
@@ -8,4 +8,6 @@ openapi_version: "3.0"
 tier: community
 verified_date: "2026-03-24"
 homepage: https://launchdarkly.com
+auth_key_url: https://app.launchdarkly.com/settings/authorization
+auth_instructions: "Create an access token with the role and project scope your workflow needs."
 notes: "221 paths, API key auth. Flat 'api' resource due to /api/v2/ prefix."
diff --git a/catalog/mercury.yaml b/catalog/mercury.yaml
index d16bbcbd..29565983 100644
--- a/catalog/mercury.yaml
+++ b/catalog/mercury.yaml
@@ -8,6 +8,8 @@ openapi_version: "3.0"
 tier: official
 verified_date: "2026-05-06"
 homepage: https://docs.mercury.com/reference
+auth_key_url: https://app.mercury.com/settings/tokens
+auth_instructions: "Create an API token under Settings -> API and treat it as production credentials."
 owner_name: Cathryn Lavery
 mcp:
   transport: [stdio, http]
diff --git a/catalog/pipedrive.yaml b/catalog/pipedrive.yaml
index f2bb3e3b..b85936aa 100644
--- a/catalog/pipedrive.yaml
+++ b/catalog/pipedrive.yaml
@@ -8,4 +8,6 @@ openapi_version: "3.0"
 tier: official
 verified_date: "2026-03-25"
 homepage: https://developers.pipedrive.com
+auth_key_url: https://app.pipedrive.com/settings/personal/api
+auth_instructions: "Copy your personal API token from Personal preferences -> API."
 notes: "Zero CLI competition. CRM from the terminal."
diff --git a/catalog/plaid.yaml b/catalog/plaid.yaml
index ffccb912..8cdb0375 100644
--- a/catalog/plaid.yaml
+++ b/catalog/plaid.yaml
@@ -8,6 +8,8 @@ openapi_version: "3.0"
 tier: community
 verified_date: "2026-03-25"
 homepage: https://plaid.com/docs/api
+auth_key_url: https://dashboard.plaid.com/team/keys
+auth_instructions: "Use Sandbox keys (PLAID_ENV=sandbox) until you have completed Plaid review for production."
 notes: "Fintech devs' go-to banking API. Abandoned community CLI (57 stars) proves demand."
 known_alternatives:
   - name: plaid-cli
diff --git a/catalog/sentry.yaml b/catalog/sentry.yaml
index fb4d786f..4ce05f4c 100644
--- a/catalog/sentry.yaml
+++ b/catalog/sentry.yaml
@@ -8,4 +8,6 @@ openapi_version: "3.0"
 tier: community
 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.)."
 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 e278b23c..ae8aa08b 100644
--- a/catalog/stripe.yaml
+++ b/catalog/stripe.yaml
@@ -8,6 +8,8 @@ openapi_version: "3.0"
 tier: official
 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."
 notes: Very large spec (~500 endpoints). Generator truncates significantly.
 known_alternatives:
   - name: stripe-cli
diff --git a/catalog/stytch.yaml b/catalog/stytch.yaml
index d4133322..6f306256 100644
--- a/catalog/stytch.yaml
+++ b/catalog/stytch.yaml
@@ -8,4 +8,6 @@ openapi_version: "3.0"
 tier: official
 verified_date: "2026-03-23"
 homepage: https://stytch.com/docs
+auth_key_url: https://stytch.com/dashboard/api-keys
+auth_instructions: "Use Test environment keys until you have switched the project to Live."
 notes: None.
diff --git a/catalog/twilio.yaml b/catalog/twilio.yaml
index 2ceed19d..c6dc0777 100644
--- a/catalog/twilio.yaml
+++ b/catalog/twilio.yaml
@@ -8,4 +8,6 @@ openapi_version: "3.0"
 tier: official
 verified_date: "2026-03-23"
 homepage: https://www.twilio.com/docs/usage/api
+auth_key_url: https://console.twilio.com/
+auth_instructions: "Copy your Account SID and Auth Token from the console homepage."
 notes: None.
diff --git a/docs/CATALOG.md b/docs/CATALOG.md
index dd4492ad..6e167285 100644
--- a/docs/CATALOG.md
+++ b/docs/CATALOG.md
@@ -79,3 +79,20 @@ If the validator or enum values change, update both this doc and the inline `AGE
 Catalog entries for browser-facing APIs with rotating public client bearer tokens may declare `bearer_refresh`. When present, both `bearer_refresh.bundle_url` and `bearer_refresh.pattern` are required, the bundle URL must use HTTPS, and the pattern must compile as a Go regexp.
 
 The generator copies this metadata into the printed CLI so `doctor --refresh-bearer` and the agent-accessible `refresh-bearer` command can refresh the user's stored token from the live source bundle.
+
+## Auth key URL
+
+Catalog entries may declare `auth_key_url:` — an HTTPS page where the user can obtain credentials (personal access token, API key, OAuth client, etc.). The generator surfaces it in the printed CLI's auth prompts and `doctor` output as `Get a key at: <URL>`.
+
+Precedence:
+- Catalog `auth_key_url` overrides any URL from the spec.
+- Otherwise, an OpenAPI spec's [`x-auth-key-url`](SPEC-EXTENSIONS.md#x-auth-key-url) is used.
+- Otherwise, the parser infers a URL from the selected security scheme's `description`, then from `info.description` when the surrounding text mentions credential cues. `externalDocs.url` and `info.contact.url` are intentionally **not** fallbacks — those typically point at the docs landing page or company homepage, not the keys UI. When `KeyURL` is empty, the printed CLI surfaces those URLs under a separate `See API docs:` line instead. See [`SPEC-EXTENSIONS.md`](SPEC-EXTENSIONS.md#x-auth-key-url) for details.
+
+Set `auth_key_url:` when the inference would land on a generic homepage and you know the specific token-acquisition page. The validator only checks that the URL starts with `https://`; it does not probe reachability.
+
+## Auth instructions
+
+Catalog entries may also declare `auth_instructions:` — a one-line string of free-form guidance ("Settings → Personal access tokens → Generate new") that the printed CLI prints under the `Get a key at:` line. Use this when the URL lands on a docs page rather than the keys UI: the URL says where to start, the instruction says what to do once there.
+
+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).
diff --git a/docs/SPEC-EXTENSIONS.md b/docs/SPEC-EXTENSIONS.md
index d29933e3..517a2148 100644
--- a/docs/SPEC-EXTENSIONS.md
+++ b/docs/SPEC-EXTENSIONS.md
@@ -374,6 +374,42 @@ Rules:
 - Leading and trailing whitespace is trimmed.
 - The parser does not validate the URL shape.
 
+When the extension is absent and the spec has any auth, the parser falls back
+through the following sources in order and uses the first plausible HTTPS URL:
+
+1. The selected security scheme's `description` (extracted via regex).
+2. `info.description`, but only when the surrounding text mentions
+   credential-related cues (`token`, `api key`, `credential`, `register`,
+   `sign up`, etc.) so an unrelated URL doesn't get picked.
+
+`externalDocs.url` and `info.contact.url` are intentionally **not** fallbacks
+for `KeyURL`. Those almost always point at the API's docs landing page or the
+company homepage, neither of which is where users actually create a token.
+When `KeyURL` ends up empty, the printed CLI uses `WebsiteURL` (already
+populated from `externalDocs.url`, `info.contact.url`, and `x-website`) under
+a separate `See API docs: <URL>` line — honest framing for those URLs.
+
+Catalog YAML's `auth_key_url:` (see [`CATALOG.md`](CATALOG.md)) overrides the
+inference. The result drives the printed CLI's `Get a key at: <URL>` output in
+auth prompts and `doctor`.
+
+### `x-auth-instructions`
+
+Free-form one-line guidance shown alongside `x-auth-key-url`, e.g. "Settings →
+Personal access tokens → Generate new". The printed CLI surfaces this under
+the URL in auth prompts, `doctor`, and the `auth setup` command.
+
+Parsed field: `APISpec.Auth.Instructions`
+
+Rules:
+- Optional.
+- Must be a string.
+- Leading and trailing whitespace is trimmed.
+- Use this when `x-auth-key-url` lands on a docs page rather than the keys UI;
+  the URL says where to start, the instruction says what to do once there.
+
+Catalog YAML's `auth_instructions:` overrides any spec-supplied value.
+
 ### `x-auth-title`
 
 Overrides the title shown for the credential field in install/config surfaces.
diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go
index 46aa05e1..2fef309b 100644
--- a/internal/catalog/catalog.go
+++ b/internal/catalog/catalog.go
@@ -128,6 +128,19 @@ type Entry struct {
 	SpecSource string `yaml:"spec_source,omitempty"`
 	// AuthRequired indicates whether the API needs authentication. Empty means unknown.
 	AuthRequired *bool `yaml:"auth_required,omitempty"`
+	// AuthKeyURL is an HTTPS URL pointing the user at the page where they can
+	// obtain credentials (a personal access token, API key, OAuth client, etc.).
+	// Surfaces as "Get a key at: <URL>" in the printed CLI's auth prompts and
+	// doctor output. Overrides any URL inferred from the spec; the spec's
+	// x-auth-key-url and parser inference fallbacks are used when this is empty.
+	AuthKeyURL string `yaml:"auth_key_url,omitempty"`
+	// AuthInstructions is one-line free-form guidance shown alongside
+	// AuthKeyURL — e.g. "Settings → Personal access tokens → Generate new".
+	// Renders under the URL in auth prompts and doctor output, and is the
+	// human-readable companion to the URL when the URL is a generic docs page
+	// rather than a deep link to the keys UI. Overrides any spec-supplied
+	// x-auth-instructions value.
+	AuthInstructions string `yaml:"auth_instructions,omitempty"`
 	// ClientPattern describes the HTTP client pattern needed. Empty defaults to "rest".
 	// Values: rest, proxy-envelope, graphql.
 	ClientPattern string `yaml:"client_pattern,omitempty"`
@@ -301,6 +314,9 @@ func (e *Entry) Validate() error {
 	if err := validateBearerRefresh(e.BearerRefresh); err != nil {
 		return err
 	}
+	if e.AuthKeyURL != "" && !strings.HasPrefix(e.AuthKeyURL, "https://") {
+		return fmt.Errorf(`auth_key_url must start with "https://"`)
+	}
 
 	return nil
 }
diff --git a/internal/catalog/catalog_test.go b/internal/catalog/catalog_test.go
index 31fa0691..cc14be63 100644
--- a/internal/catalog/catalog_test.go
+++ b/internal/catalog/catalog_test.go
@@ -167,6 +167,13 @@ func TestValidateEntry(t *testing.T) {
 			},
 			wantErr: "bearer_refresh.pattern is not a valid regexp",
 		},
+		{
+			name: "non https auth_key_url",
+			mutate: func(e *Entry) {
+				e.AuthKeyURL = "http://example.com/keys"
+			},
+			wantErr: `auth_key_url must start with "https://"`,
+		},
 	}
 
 	for _, tt := range tests {
diff --git a/internal/cli/root.go b/internal/cli/root.go
index 077e709a..033465f7 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -1589,6 +1589,12 @@ func enrichSpecFromCatalogEntry(apiSpec *spec.APISpec, entry *catalog.Entry) {
 	if entry.BearerRefresh.Pattern != "" && apiSpec.BearerRefresh.Pattern == "" {
 		apiSpec.BearerRefresh.Pattern = entry.BearerRefresh.Pattern
 	}
+	if entry.AuthKeyURL != "" && apiSpec.Auth.Type != "none" {
+		apiSpec.Auth.KeyURL = entry.AuthKeyURL
+	}
+	if entry.AuthInstructions != "" && apiSpec.Auth.Type != "none" {
+		apiSpec.Auth.Instructions = entry.AuthInstructions
+	}
 }
 
 func mcpConfigured(m spec.MCPConfig) bool {
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index cbb91ee1..184f755c 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -4627,6 +4627,47 @@ func TestGeneratedHelpers_AuthWithKeyURL_Compiles(t *testing.T) {
 	runGoCommand(t, outputDir, "build", "./...")
 }
 
+// Adversarial Instructions values must not break generated Go. The auth setup,
+// helpers (401/403 hint paths), and MCP tool description templates all embed
+// .Auth.Instructions inside Go string literals; without %q escaping a value
+// containing " or \ produces a syntax error at template-render time.
+func TestGeneratedHelpers_AuthInstructionsWithSpecialChars_Compiles(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := &spec.APISpec{
+		Name:    "instrescape",
+		Version: "0.1.0",
+		BaseURL: "https://api.example.com",
+		Auth: spec.AuthConfig{
+			Type:         "api_key",
+			Header:       "Authorization",
+			In:           "header",
+			EnvVars:      []string{"INSTRESCAPE_API_KEY"},
+			KeyURL:       "https://example.com/keys",
+			Instructions: `Settings → "Personal access tokens" → \Generate new\`,
+		},
+		Config: spec.ConfigSpec{
+			Format: "toml",
+			Path:   "~/.config/instrescape-pp-cli/config.toml",
+		},
+		Resources: map[string]spec.Resource{
+			"users": {
+				Description: "Manage users",
+				Endpoints: map[string]spec.Endpoint{
+					"list": {Method: "GET", Path: "/users", Description: "List users"},
+				},
+			},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), "instrescape-pp-cli")
+	gen := New(apiSpec, outputDir)
+	require.NoError(t, gen.Generate())
+
+	runGoCommand(t, outputDir, "mod", "tidy")
+	runGoCommand(t, outputDir, "build", "./...")
+}
+
 // --- Unit 4: Doctor Auth Hint Tests ---
 
 func TestGeneratedDoctor_AuthHintsWithKeyURL(t *testing.T) {
diff --git a/internal/generator/templates/auth.go.tmpl b/internal/generator/templates/auth.go.tmpl
index 71bb8a30..f9eb72d2 100644
--- a/internal/generator/templates/auth.go.tmpl
+++ b/internal/generator/templates/auth.go.tmpl
@@ -18,6 +18,9 @@ import (
 	"strings"
 	"time"
 
+{{- if or .Auth.KeyURL .WebsiteURL}}
+	"{{modulePath}}/internal/cliutil"
+{{- end}}
 	"{{modulePath}}/internal/config"
 	"github.com/spf13/cobra"
 )
@@ -28,6 +31,7 @@ func newAuthCmd(flags *rootFlags) *cobra.Command {
 		Short: {{printf "%q" (authCommandShort .APISpec)}},
 	}
 
+	cmd.AddCommand(newAuthSetupCmd(flags))
 	cmd.AddCommand(newAuthLoginCmd(flags))
 	cmd.AddCommand(newAuthStatusCmd(flags))
 	cmd.AddCommand(newAuthLogoutCmd(flags))
@@ -35,6 +39,75 @@ func newAuthCmd(flags *rootFlags) *cobra.Command {
 	return cmd
 }
 
+// newAuthSetupCmd prints concrete steps for registering an OAuth app and
+// obtaining client credentials. Side-effect rule: print by default, --launch
+// to open the registration URL, short-circuit when the verifier is running.
+func newAuthSetupCmd(_ *rootFlags) *cobra.Command {
+	var launch bool
+	cmd := &cobra.Command{
+		Use:   "setup",
+		Short: "Print steps for registering an OAuth app (use --launch to open the URL)",
+		Example: "  {{.Name}}-pp-cli auth setup\n  {{.Name}}-pp-cli auth setup --launch",
+		RunE: func(cmd *cobra.Command, args []string) error {
+			w := cmd.OutOrStdout()
+{{- if .Auth.KeyURL}}
+			fmt.Fprintln(w, "Register an app and copy your credentials at: {{.Auth.KeyURL}}")
+{{- else if .WebsiteURL}}
+			fmt.Fprintln(w, "See API docs for OAuth app registration: {{.WebsiteURL}}")
+{{- else}}
+			fmt.Fprintln(w, "No setup URL is configured for this CLI; check the API's docs.")
+{{- end}}
+{{- if .Auth.Instructions}}
+			fmt.Fprintln(w, {{printf "%q" (printf "  %s" .Auth.Instructions)}})
+{{- end}}
+			fmt.Fprintln(w, "")
+			fmt.Fprintln(w, "Then run:")
+			fmt.Fprintln(w, "  {{.Name}}-pp-cli auth login --client-id <id> --client-secret <secret>")
+			if !launch {
+				return nil
+			}
+{{- if or .Auth.KeyURL .WebsiteURL}}
+			launchURL := {{if .Auth.KeyURL}}"{{.Auth.KeyURL}}"{{else}}"{{.WebsiteURL}}"{{end}}
+			if cliutil.IsVerifyEnv() {
+				fmt.Fprintf(w, "would launch: %s\n", launchURL)
+				return nil
+			}
+			if err := openSetupURL(launchURL); err != nil {
+				fmt.Fprintf(cmd.ErrOrStderr(), "could not open browser automatically: %v\nopen this URL manually: %s\n", err, launchURL)
+			}
+{{- else}}
+			fmt.Fprintln(cmd.ErrOrStderr(), "no setup URL configured; cannot launch")
+{{- end}}
+			return nil
+		},
+	}
+	cmd.Flags().BoolVar(&launch, "launch", false, "Open the setup URL in your default browser")
+	return cmd
+}
+
+{{- if or .Auth.KeyURL .WebsiteURL}}
+
+// openSetupURL opens url in the OS default browser and returns any error
+// from the launch. Distinct from openBrowser (used by `auth login`) because
+// `auth setup --launch` prints a manual-fallback message on failure rather
+// than silently failing. Per the side-effect rule, the caller short-circuits
+// with cliutil.IsVerifyEnv() before this is reached.
+func openSetupURL(url string) error {
+	var c *exec.Cmd
+	switch runtime.GOOS {
+	case "darwin":
+		c = exec.Command("open", url)
+	case "linux":
+		c = exec.Command("xdg-open", url)
+	case "windows":
+		c = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
+	default:
+		return fmt.Errorf("unsupported OS: %s", runtime.GOOS)
+	}
+	return c.Start()
+}
+{{- end}}
+
 func newAuthLoginCmd(flags *rootFlags) *cobra.Command {
 	var clientID string
 	var clientSecret string
diff --git a/internal/generator/templates/auth_simple.go.tmpl b/internal/generator/templates/auth_simple.go.tmpl
index db6109ee..d6e0bc92 100644
--- a/internal/generator/templates/auth_simple.go.tmpl
+++ b/internal/generator/templates/auth_simple.go.tmpl
@@ -5,10 +5,17 @@ package cli
 
 import (
 	"fmt"
+{{- if or .Auth.KeyURL .WebsiteURL}}
+	"os/exec"
+	"runtime"
+{{- end}}
 {{- if .Auth.EnvVars}}
 	"os"
 {{- end}}
 
+{{- if or .Auth.KeyURL .WebsiteURL}}
+	"{{modulePath}}/internal/cliutil"
+{{- end}}
 	"{{modulePath}}/internal/config"
 	"github.com/spf13/cobra"
 )
@@ -19,6 +26,7 @@ func newAuthCmd(flags *rootFlags) *cobra.Command {
 		Short: {{printf "%q" (authCommandShort .APISpec)}},
 	}
 
+	cmd.AddCommand(newAuthSetupCmd(flags))
 	cmd.AddCommand(newAuthStatusCmd(flags))
 	cmd.AddCommand(newAuthSetTokenCmd(flags))
 	cmd.AddCommand(newAuthLogoutCmd(flags))
@@ -26,6 +34,86 @@ func newAuthCmd(flags *rootFlags) *cobra.Command {
 	return cmd
 }
 
+// newAuthSetupCmd prints concrete steps for getting a credential. Side-effect
+// rule: print by default, --launch opt-in to open the URL, short-circuit when
+// the verifier is running this in a sandboxed subprocess.
+func newAuthSetupCmd(_ *rootFlags) *cobra.Command {
+	var launch bool
+	cmd := &cobra.Command{
+		Use:   "setup",
+		Short: "Print steps for obtaining a credential (use --launch to open the URL)",
+		Example: "  {{.Name}}-pp-cli auth setup\n  {{.Name}}-pp-cli auth setup --launch",
+		RunE: func(cmd *cobra.Command, args []string) error {
+			w := cmd.OutOrStdout()
+{{- if .Auth.KeyURL}}
+			fmt.Fprintln(w, "Get a key at: {{.Auth.KeyURL}}")
+{{- else if .WebsiteURL}}
+			fmt.Fprintln(w, "See API docs: {{.WebsiteURL}}")
+{{- else}}
+			fmt.Fprintln(w, "No setup URL is configured for this CLI; check the API's docs.")
+{{- end}}
+{{- if .Auth.Instructions}}
+			fmt.Fprintln(w, {{printf "%q" (printf "  %s" .Auth.Instructions)}})
+{{- end}}
+{{- if .Auth.EnvVarSpecs}}
+			fmt.Fprintln(w, "")
+			fmt.Fprintln(w, "Then set:")
+{{- range .Auth.EnvVarSpecs}}
+{{- if isRequestAuthEnvVar .}}
+			fmt.Fprintln(w, "  export {{.Name}}=\"<your-token>\"")
+{{- end}}
+{{- end}}
+			fmt.Fprintln(w, "  {{.Name}}-pp-cli auth set-token <token>")
+{{- else if .Auth.EnvVars}}
+			fmt.Fprintln(w, "")
+			fmt.Fprintln(w, "Then set:")
+{{- range .Auth.EnvVars}}
+			fmt.Fprintln(w, "  export {{.}}=\"<your-token>\"")
+{{- end}}
+			fmt.Fprintln(w, "  {{.Name}}-pp-cli auth set-token <token>")
+{{- end}}
+			if !launch {
+				return nil
+			}
+{{- if or .Auth.KeyURL .WebsiteURL}}
+			launchURL := {{if .Auth.KeyURL}}"{{.Auth.KeyURL}}"{{else}}"{{.WebsiteURL}}"{{end}}
+			if cliutil.IsVerifyEnv() {
+				fmt.Fprintf(w, "would launch: %s\n", launchURL)
+				return nil
+			}
+			if err := openSetupURL(launchURL); err != nil {
+				fmt.Fprintf(cmd.ErrOrStderr(), "could not open browser automatically: %v\nopen this URL manually: %s\n", err, launchURL)
+			}
+{{- else}}
+			fmt.Fprintln(cmd.ErrOrStderr(), "no setup URL configured; cannot launch")
+{{- end}}
+			return nil
+		},
+	}
+	cmd.Flags().BoolVar(&launch, "launch", false, "Open the setup URL in your default browser")
+	return cmd
+}
+
+{{- if or .Auth.KeyURL .WebsiteURL}}
+
+// openSetupURL opens url in the OS default browser. Per the side-effect rule,
+// the caller short-circuits with cliutil.IsVerifyEnv() before this is reached.
+func openSetupURL(url string) error {
+	var c *exec.Cmd
+	switch runtime.GOOS {
+	case "darwin":
+		c = exec.Command("open", url)
+	case "linux":
+		c = exec.Command("xdg-open", url)
+	case "windows":
+		c = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
+	default:
+		return fmt.Errorf("unsupported OS: %s", runtime.GOOS)
+	}
+	return c.Start()
+}
+{{- end}}
+
 func newAuthStatusCmd(flags *rootFlags) *cobra.Command {
 	return &cobra.Command{
 		Use:   "status",
diff --git a/internal/generator/templates/doctor.go.tmpl b/internal/generator/templates/doctor.go.tmpl
index 99a88721..02be3b72 100644
--- a/internal/generator/templates/doctor.go.tmpl
+++ b/internal/generator/templates/doctor.go.tmpl
@@ -205,6 +205,11 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 {{- end}}
 {{- if .Auth.KeyURL}}
 					report["auth_key_url"] = "{{.Auth.KeyURL}}"
+{{- else if .WebsiteURL}}
+					report["auth_docs_url"] = "{{.WebsiteURL}}"
+{{- end}}
+{{- if .Auth.Instructions}}
+					report["auth_instructions"] = {{printf "%q" .Auth.Instructions}}
 {{- end}}
 				} else {
 					authConfigured = true
@@ -523,6 +528,15 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 			if keyURL, ok := report["auth_key_url"]; ok {
 				fmt.Fprintf(w, "  Get a key at: %v\n", keyURL)
 			}
+{{- else if .WebsiteURL}}
+			if docsURL, ok := report["auth_docs_url"]; ok {
+				fmt.Fprintf(w, "  See API docs: %v\n", docsURL)
+			}
+{{- end}}
+{{- if .Auth.Instructions}}
+			if instructions, ok := report["auth_instructions"]; ok {
+				fmt.Fprintf(w, "  %v\n", instructions)
+			}
 {{- end}}
 {{- if .HasStore}}
 			// Cache section: render after the primary health block so it
diff --git a/internal/generator/templates/helpers.go.tmpl b/internal/generator/templates/helpers.go.tmpl
index 969a6dcc..976511d3 100644
--- a/internal/generator/templates/helpers.go.tmpl
+++ b/internal/generator/templates/helpers.go.tmpl
@@ -310,6 +310,11 @@ func classifyAPIError(err error, flags *rootFlags) error {
 {{- end}}
 {{- if .Auth.KeyURL}}
 			"\n      Get a key at: {{.Auth.KeyURL}}"+
+{{- else if .WebsiteURL}}
+			"\n      See API docs: {{.WebsiteURL}}"+
+{{- end}}
+{{- if .Auth.Instructions}}
+			{{printf "%q" (printf "\n      %s" .Auth.Instructions)}}+
 {{- end}}
 			"\n      Run '{{.Name}}-pp-cli doctor' to check auth status."+
 			"\n      Response: "+cliutil.SanitizeErrorBody(msg), err))
@@ -324,6 +329,11 @@ func classifyAPIError(err error, flags *rootFlags) error {
 {{- end}}
 {{- if .Auth.KeyURL}}
 			"\n      Get a key at: {{.Auth.KeyURL}}"+
+{{- else if .WebsiteURL}}
+			"\n      See API docs: {{.WebsiteURL}}"+
+{{- end}}
+{{- if .Auth.Instructions}}
+			{{printf "%q" (printf "\n      %s" .Auth.Instructions)}}+
 {{- end}}
 			"\n      Run '{{.Name}}-pp-cli doctor' to check auth status.", err))
 {{- else if eq .Auth.Type "bearer_token"}}
@@ -335,6 +345,11 @@ func classifyAPIError(err error, flags *rootFlags) error {
 {{- end}}
 {{- if .Auth.KeyURL}}
 			"\n      Get a key at: {{.Auth.KeyURL}}"+
+{{- else if .WebsiteURL}}
+			"\n      See API docs: {{.WebsiteURL}}"+
+{{- end}}
+{{- if .Auth.Instructions}}
+			{{printf "%q" (printf "\n      %s" .Auth.Instructions)}}+
 {{- end}}
 			"\n      Run '{{.Name}}-pp-cli doctor' to check auth status.", err))
 {{- else if eq .Auth.Type "api_key"}}
@@ -346,6 +361,11 @@ func classifyAPIError(err error, flags *rootFlags) error {
 {{- end}}
 {{- if .Auth.KeyURL}}
 			"\n      Get a key at: {{.Auth.KeyURL}}"+
+{{- else if .WebsiteURL}}
+			"\n      See API docs: {{.WebsiteURL}}"+
+{{- end}}
+{{- if .Auth.Instructions}}
+			{{printf "%q" (printf "\n      %s" .Auth.Instructions)}}+
 {{- end}}
 			"\n      Run '{{.Name}}-pp-cli doctor' to check auth status.", err))
 {{- else}}
@@ -357,6 +377,11 @@ func classifyAPIError(err error, flags *rootFlags) error {
 {{- end}}
 {{- if .Auth.KeyURL}}
 			"\n      Get a key at: {{.Auth.KeyURL}}"+
+{{- else if .WebsiteURL}}
+			"\n      See API docs: {{.WebsiteURL}}"+
+{{- end}}
+{{- if .Auth.Instructions}}
+			{{printf "%q" (printf "\n      %s" .Auth.Instructions)}}+
 {{- end}}
 			"\n      Run '{{.Name}}-pp-cli doctor' to check auth status.", err))
 {{- end}}
@@ -370,6 +395,11 @@ func classifyAPIError(err error, flags *rootFlags) error {
 {{- end}}
 {{- if .Auth.KeyURL}}
 			"\n      Get a key at: {{.Auth.KeyURL}}"+
+{{- else if .WebsiteURL}}
+			"\n      See API docs: {{.WebsiteURL}}"+
+{{- end}}
+{{- if .Auth.Instructions}}
+			{{printf "%q" (printf "\n      %s" .Auth.Instructions)}}+
 {{- end}}
 			"\n      Run '{{.Name}}-pp-cli doctor' to check auth status.", err))
 {{- else if or (eq .Auth.Type "") (eq .Auth.Type "none")}}
@@ -385,6 +415,11 @@ func classifyAPIError(err error, flags *rootFlags) error {
 {{- end}}
 {{- if .Auth.KeyURL}}
 			"\n      Get a key at: {{.Auth.KeyURL}}"+
+{{- else if .WebsiteURL}}
+			"\n      See API docs: {{.WebsiteURL}}"+
+{{- end}}
+{{- if .Auth.Instructions}}
+			{{printf "%q" (printf "\n      %s" .Auth.Instructions)}}+
 {{- end}}
 			"\n      Run '{{.Name}}-pp-cli doctor' to check auth status.", err))
 {{- end}}
diff --git a/internal/generator/templates/mcp_tools.go.tmpl b/internal/generator/templates/mcp_tools.go.tmpl
index db8d5932..3d696d53 100644
--- a/internal/generator/templates/mcp_tools.go.tmpl
+++ b/internal/generator/templates/mcp_tools.go.tmpl
@@ -394,6 +394,11 @@ func makeAPIHandler(method, pathTemplate string, bindings []mcpParamBinding, pos
 {{- end}}
 {{- if .Auth.KeyURL}}
 					{{printf "%q" (printf "\n      Get a key at: %s" .Auth.KeyURL)}} +
+{{- else if .WebsiteURL}}
+					{{printf "%q" (printf "\n      See API docs: %s" .WebsiteURL)}} +
+{{- end}}
+{{- if .Auth.Instructions}}
+					{{printf "%q" (printf "\n      %s" .Auth.Instructions)}} +
 {{- end}}
 					{{printf "%q" (printf "\n      Run '%s-pp-cli doctor' to check auth status." .Name)}}), nil
 {{- end}}
@@ -413,6 +418,11 @@ func makeAPIHandler(method, pathTemplate string, bindings []mcpParamBinding, pos
 {{- end}}
 {{- if .Auth.KeyURL}}
 					{{printf "%q" (printf "\n      Get a key at: %s" .Auth.KeyURL)}} +
+{{- else if .WebsiteURL}}
+					{{printf "%q" (printf "\n      See API docs: %s" .WebsiteURL)}} +
+{{- end}}
+{{- if .Auth.Instructions}}
+					{{printf "%q" (printf "\n      %s" .Auth.Instructions)}} +
 {{- end}}
 					{{printf "%q" (printf "\n      Run '%s-pp-cli doctor' to check auth status." .Name)}}), nil
 			case strings.Contains(msg, "HTTP 403"):
@@ -429,6 +439,11 @@ func makeAPIHandler(method, pathTemplate string, bindings []mcpParamBinding, pos
 {{- end}}
 {{- if .Auth.KeyURL}}
 					{{printf "%q" (printf "\n      Get a key at: %s" .Auth.KeyURL)}} +
+{{- else if .WebsiteURL}}
+					{{printf "%q" (printf "\n      See API docs: %s" .WebsiteURL)}} +
+{{- end}}
+{{- if .Auth.Instructions}}
+					{{printf "%q" (printf "\n      %s" .Auth.Instructions)}} +
 {{- end}}
 					{{printf "%q" (printf "\n      Run '%s-pp-cli doctor' to check auth status." .Name)}}), nil
 			case strings.Contains(msg, "HTTP 404"):
@@ -658,6 +673,11 @@ func handleContext(_ context.Context, _ mcplib.CallToolRequest) (*mcplib.CallToo
 {{- end}}
 {{- if .Auth.KeyURL}}
 			"key_url": {{printf "%q" .Auth.KeyURL}},
+{{- else if .WebsiteURL}}
+			"docs_url": {{printf "%q" .WebsiteURL}},
+{{- end}}
+{{- if .Auth.Instructions}}
+			"instructions": {{printf "%q" .Auth.Instructions}},
 {{- end}}
 		},
 {{- end}}
diff --git a/internal/generator/templates/skill.md.tmpl b/internal/generator/templates/skill.md.tmpl
index 2bd2e9c2..9417618c 100644
--- a/internal/generator/templates/skill.md.tmpl
+++ b/internal/generator/templates/skill.md.tmpl
@@ -201,7 +201,7 @@ When you know what you want to do but not which command does it, ask the CLI dir
 {{- else if eq .Auth.Type "api_key"}}
 
 {{- with $canonicalEnvVar}}
-Set your API key via environment variable:
+Run `{{$.Name}}-pp-cli auth setup` to print the URL and steps for getting a key (add `--launch` to open the URL). Then set:
 
 ```bash
 export {{.Name}}="<your-key>"
@@ -211,7 +211,7 @@ Or persist it in `{{$.Config.Path}}`.
 {{- end}}
 {{- else if eq .Auth.Type "oauth2"}}
 
-Authenticate via the browser:
+Run `{{.Name}}-pp-cli auth setup` for the registration URL and steps (add `--launch` to open the URL). Then authenticate:
 
 ```bash
 {{.Name}}-pp-cli auth login
@@ -220,7 +220,7 @@ Authenticate via the browser:
 Tokens are stored locally and refreshed automatically.
 {{- else if eq .Auth.Type "bearer_token"}}
 
-Store your access token:
+Run `{{.Name}}-pp-cli auth setup` for the URL and steps to obtain a token (add `--launch` to open the URL). Then store it:
 
 ```bash
 {{.Name}}-pp-cli auth set-token YOUR_TOKEN_HERE
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index 1f5151dc..ffdcf568 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -1,6 +1,7 @@
 package openapi
 
 import (
+	"bytes"
 	"context"
 	"encoding/json"
 	"fmt"
@@ -34,6 +35,7 @@ const (
 	extensionAuthVars         = "x-auth-vars"
 	extensionAuthOptional     = "x-auth-optional"
 	extensionAuthKeyURL       = "x-auth-key-url"
+	extensionAuthInstructions = "x-auth-instructions"
 	extensionAuthTitle        = "x-auth-title"
 	extensionAuthDescription  = "x-auth-description"
 	extensionSpeakeasyExample = "x-speakeasy-example"
@@ -227,7 +229,47 @@ func parseWithLocation(data []byte, lenient bool, location *url.URL) (*spec.APIS
 		fmt.Fprintf(os.Stderr, "info: spec loaded after stripping broken references\n")
 	}
 
-	doc.InternalizeRefs(context.Background(), nil)
+	// Skip InternalizeRefs when the spec has no external $refs (every $ref starts
+	// with '#'). The Render Public API spec has 314 cross-referenced schemas;
+	// kin-openapi's DefaultRefNameResolver-driven recursion runs > 10 minutes
+	// on it. Lazy ref resolution still works for in-document refs during access.
+	//
+	// Format-agnostic scan: matches both JSON (`"$ref": "..."`) and YAML
+	// (`$ref: '...'` or unquoted `$ref: ./schemas/foo.yaml`). A `$ref` whose
+	// value's first non-quote character is anything but '#' is external, and
+	// we must call InternalizeRefs to resolve it.
+	hasExternalRef := false
+	for off := 0; off < len(data); {
+		idx := bytes.Index(data[off:], []byte("$ref"))
+		if idx < 0 {
+			break
+		}
+		off += idx + len("$ref")
+		// Skip optional closing quote (JSON `"$ref"`) and whitespace before the colon.
+		for off < len(data) && (data[off] == '"' || data[off] == '\'' || data[off] == ' ' || data[off] == '\t') {
+			off++
+		}
+		if off >= len(data) || data[off] != ':' {
+			continue
+		}
+		off++
+		for off < len(data) && (data[off] == ' ' || data[off] == '\t') {
+			off++
+		}
+		if off < len(data) && (data[off] == '"' || data[off] == '\'') {
+			off++
+		}
+		if off >= len(data) {
+			break
+		}
+		if data[off] != '#' {
+			hasExternalRef = true
+			break
+		}
+	}
+	if hasExternalRef {
+		doc.InternalizeRefs(context.Background(), nil)
+	}
 
 	name := "api"
 	description := ""
@@ -325,6 +367,9 @@ func parseWithLocation(data []byte, lenient bool, location *url.URL) (*spec.APIS
 	if auth.Type != "none" && allOperationsAllowAnonymous(doc) {
 		auth = spec.AuthConfig{Type: "none"}
 	}
+	if auth.Type != "none" && auth.KeyURL == "" {
+		auth.KeyURL = inferAuthKeyURL(doc, auth.Scheme)
+	}
 
 	tierRouting, err := parseTypedExtension[spec.TierRoutingConfig](doc, extensionTierRouting)
 	if err != nil {
@@ -595,6 +640,79 @@ func allDigits(s string) bool {
 	return true
 }
 
+// inferAuthKeyURL returns a best-effort HTTPS URL pointing the user at where
+// they can obtain a credential when x-auth-key-url is not set. Precedence:
+//  1. URL embedded in the selected security scheme's description
+//  2. URL embedded in info.description, but only when the surrounding text
+//     mentions auth/credential cues (so we don't pick a URL describing an
+//     unrelated feature)
+//
+// Returns "" when no plausible URL is found. The printed CLI surfaces the
+// result as "Get a key at: <URL>", so a wrong URL here is worse than no URL.
+// We deliberately do NOT fall back to externalDocs.url or info.contact.url —
+// those almost always point at the API's docs landing page or the company
+// homepage, neither of which is where users actually create a token. When this
+// returns "", the printed CLI falls back to a separate "See API docs: <URL>"
+// line driven by WebsiteURL, which is honest framing for those URLs.
+func inferAuthKeyURL(doc *openapi3.T, schemeName string) string {
+	if doc == nil {
+		return ""
+	}
+	if schemeName != "" && doc.Components != nil {
+		if ref, ok := doc.Components.SecuritySchemes[schemeName]; ok {
+			if scheme := securitySchemeValue(ref); scheme != nil {
+				if u := firstHTTPSURL(scheme.Description); u != "" {
+					return u
+				}
+			}
+		}
+	}
+	if doc.Info != nil {
+		if u := firstAuthRelatedURL(doc.Info.Description); u != "" {
+			return u
+		}
+	}
+	return ""
+}
+
+var httpsURLPattern = regexp.MustCompile(`https://[^\s)>\]"',]+`)
+
+// firstHTTPSURL returns the first https:// substring found in s, with trailing
+// sentence punctuation trimmed.
+func firstHTTPSURL(s string) string {
+	if s == "" {
+		return ""
+	}
+	m := httpsURLPattern.FindString(s)
+	return strings.TrimRight(m, ".,;:!?)")
+}
+
+// firstAuthRelatedURL returns the first HTTPS URL in s, but only when s also
+// contains language indicating the URL is about credentials. Avoids picking a
+// URL that happens to appear in a description of an unrelated feature.
+func firstAuthRelatedURL(s string) string {
+	if s == "" {
+		return ""
+	}
+	lower := strings.ToLower(s)
+	cues := []string{
+		"token", "api key", "api_key", "apikey",
+		"credential", "register", "sign up", "signup",
+		"create an app", "create an application", "personal access",
+	}
+	matched := false
+	for _, c := range cues {
+		if strings.Contains(lower, c) {
+			matched = true
+			break
+		}
+	}
+	if !matched {
+		return ""
+	}
+	return firstHTTPSURL(s)
+}
+
 func applyAuthOverrideExtensions(auth *spec.AuthConfig, extensions map[string]any) {
 	if auth == nil || len(extensions) == 0 {
 		return
@@ -612,6 +730,9 @@ func applyAuthOverrideExtensions(auth *spec.AuthConfig, extensions map[string]an
 	if keyURL := stringExtension(extensions, extensionAuthKeyURL); keyURL != "" {
 		auth.KeyURL = keyURL
 	}
+	if instructions := stringExtension(extensions, extensionAuthInstructions); instructions != "" {
+		auth.Instructions = instructions
+	}
 	if title := stringExtension(extensions, extensionAuthTitle); title != "" {
 		auth.Title = title
 	}
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index 663bae21..eec0eb40 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -2140,6 +2140,7 @@ components:
         - FLIGHTAWARE_API_KEY
       x-auth-optional: true
       x-auth-key-url: https://flightaware.com/commercial/aeroapi/
+      x-auth-instructions: Sign up for FlightAware AeroAPI and copy the personal API key.
       x-auth-title: FlightAware AeroAPI Key
       x-auth-description: Optional FlightAware AeroAPI credential for enriched flight data.
 paths:
@@ -2156,10 +2157,195 @@ paths:
 	assert.Equal(t, []string{"FLIGHTAWARE_API_KEY"}, parsed.Auth.EnvVars)
 	assert.True(t, parsed.Auth.Optional)
 	assert.Equal(t, "https://flightaware.com/commercial/aeroapi/", parsed.Auth.KeyURL)
+	assert.Equal(t, "Sign up for FlightAware AeroAPI and copy the personal API key.", parsed.Auth.Instructions)
 	assert.Equal(t, "FlightAware AeroAPI Key", parsed.Auth.Title)
 	assert.Equal(t, "Optional FlightAware AeroAPI credential for enriched flight data.", parsed.Auth.Description)
 }
 
+func TestOpenAPIAuthKeyURLInference(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name     string
+		yaml     string
+		expected string
+	}{
+		{
+			name: "explicit x-auth-key-url wins over inference",
+			yaml: `openapi: "3.0.3"
+info:
+  title: Example
+  version: "1.0.0"
+externalDocs:
+  url: https://docs.example.com/rest-api/
+servers:
+  - url: https://api.example.com
+components:
+  securitySchemes:
+    ApiKeyAuth:
+      type: apiKey
+      in: header
+      name: x-apikey
+      description: "Visit https://example.com/wrong-page to get a key"
+      x-auth-key-url: https://example.com/keys
+paths:
+  /ping:
+    get:
+      responses:
+        "200": { description: OK }
+`,
+			expected: "https://example.com/keys",
+		},
+		{
+			name: "url from security scheme description",
+			yaml: `openapi: "3.0.3"
+info:
+  title: Example
+  version: "1.0.0"
+externalDocs:
+  url: https://docs.example.com/rest-api/
+servers:
+  - url: https://api.example.com
+components:
+  securitySchemes:
+    ApiKeyAuth:
+      type: apiKey
+      in: header
+      name: x-apikey
+      description: "Generate a token at https://example.com/account/api-keys."
+paths:
+  /ping:
+    get:
+      responses:
+        "200": { description: OK }
+`,
+			expected: "https://example.com/account/api-keys",
+		},
+		{
+			name: "no inference when only externalDocs.url is set (docs URL is not a credentials page)",
+			yaml: `openapi: "3.0.3"
+info:
+  title: Figma API
+  version: "1.0.0"
+externalDocs:
+  url: https://developers.figma.com/docs/rest-api/
+servers:
+  - url: https://api.figma.com
+components:
+  securitySchemes:
+    PersonalAccessToken:
+      type: apiKey
+      in: header
+      name: X-Figma-Token
+paths:
+  /ping:
+    get:
+      responses:
+        "200": { description: OK }
+`,
+			expected: "",
+		},
+		{
+			name: "no inference when only info.contact.url is set (homepage is not a credentials page)",
+			yaml: `openapi: "3.0.3"
+info:
+  title: Example
+  version: "1.0.0"
+  contact:
+    url: https://example.com/developers
+servers:
+  - url: https://api.example.com
+components:
+  securitySchemes:
+    ApiKeyAuth:
+      type: apiKey
+      in: header
+      name: x-apikey
+paths:
+  /ping:
+    get:
+      responses:
+        "200": { description: OK }
+`,
+			expected: "",
+		},
+		{
+			name: "info.description URL only used when auth-related cues present",
+			yaml: `openapi: "3.0.3"
+info:
+  title: Example
+  version: "1.0.0"
+  description: "Generate an API key at https://example.com/account/keys before calling."
+servers:
+  - url: https://api.example.com
+components:
+  securitySchemes:
+    ApiKeyAuth:
+      type: apiKey
+      in: header
+      name: x-apikey
+paths:
+  /ping:
+    get:
+      responses:
+        "200": { description: OK }
+`,
+			expected: "https://example.com/account/keys",
+		},
+		{
+			name: "info.description URL ignored without auth cue",
+			yaml: `openapi: "3.0.3"
+info:
+  title: Example
+  version: "1.0.0"
+  description: "See https://example.com/changelog for release notes."
+  contact:
+    url: https://example.com/developers
+servers:
+  - url: https://api.example.com
+components:
+  securitySchemes:
+    ApiKeyAuth:
+      type: apiKey
+      in: header
+      name: x-apikey
+paths:
+  /ping:
+    get:
+      responses:
+        "200": { description: OK }
+`,
+			expected: "",
+		},
+		{
+			name: "no inference when auth.type is none",
+			yaml: `openapi: "3.0.3"
+info:
+  title: Example
+  version: "1.0.0"
+externalDocs:
+  url: https://docs.example.com/rest-api/
+servers:
+  - url: https://api.example.com
+paths:
+  /ping:
+    get:
+      responses:
+        "200": { description: OK }
+`,
+			expected: "",
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			parsed, err := Parse([]byte(tc.yaml))
+			require.NoError(t, err)
+			assert.Equal(t, tc.expected, parsed.Auth.KeyURL)
+		})
+	}
+}
+
 func TestOpenAPIAuthEnvVarsPopulateRichDefaults(t *testing.T) {
 	t.Parallel()
 
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 82f0da2b..218a3412 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -447,11 +447,12 @@ type AuthConfig struct {
 	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
+	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
+	Instructions     string       `yaml:"instructions,omitempty" json:"instructions,omitempty"` // one-line guidance shown alongside KeyURL, e.g. "Settings → Personal access tokens → Generate new"
+	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"`
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
index 855f1005..4c02acbf 100644
--- 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
@@ -53,7 +53,7 @@ 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:
+Run `printing-press-rich-pp-cli auth setup` to print the URL and steps for getting a key (add `--launch` to open the URL). Then set:
 
 ```bash
 export RICH_AUTH_API_KEY="<your-key>"
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
index 7cbab816..6e4c71a6 100644
--- 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
@@ -6,7 +6,6 @@ package cli
 import (
 	"fmt"
 	"os"
-
 	"printing-press-rich-pp-cli/internal/config"
 	"github.com/spf13/cobra"
 )
@@ -17,6 +16,7 @@ func newAuthCmd(flags *rootFlags) *cobra.Command {
 		Short: "Manage authentication for Printing Press Rich",
 	}
 
+	cmd.AddCommand(newAuthSetupCmd(flags))
 	cmd.AddCommand(newAuthStatusCmd(flags))
 	cmd.AddCommand(newAuthSetTokenCmd(flags))
 	cmd.AddCommand(newAuthLogoutCmd(flags))
@@ -24,6 +24,36 @@ func newAuthCmd(flags *rootFlags) *cobra.Command {
 	return cmd
 }
 
+// newAuthSetupCmd prints concrete steps for getting a credential. Side-effect
+// rule: print by default, --launch opt-in to open the URL, short-circuit when
+// the verifier is running this in a sandboxed subprocess.
+func newAuthSetupCmd(_ *rootFlags) *cobra.Command {
+	var launch bool
+	cmd := &cobra.Command{
+		Use:   "setup",
+		Short: "Print steps for obtaining a credential (use --launch to open the URL)",
+		Example: "  printing-press-rich-pp-cli auth setup\n  printing-press-rich-pp-cli auth setup --launch",
+		RunE: func(cmd *cobra.Command, args []string) error {
+			w := cmd.OutOrStdout()
+			fmt.Fprintln(w, "No setup URL is configured for this CLI; check the API's docs.")
+			fmt.Fprintln(w, "")
+			fmt.Fprintln(w, "Then set:")
+			fmt.Fprintln(w, "  export RICH_AUTH_API_KEY=\"<your-token>\"")
+			fmt.Fprintln(w, "  export RICH_AUTH_OPTIONAL_TOKEN=\"<your-token>\"")
+			fmt.Fprintln(w, "  export RICH_AUTH_BOT_TOKEN=\"<your-token>\"")
+			fmt.Fprintln(w, "  export RICH_AUTH_USER_TOKEN=\"<your-token>\"")
+			fmt.Fprintln(w, "  printing-press-rich-pp-cli auth set-token <token>")
+			if !launch {
+				return nil
+			}
+			fmt.Fprintln(cmd.ErrOrStderr(), "no setup URL configured; cannot launch")
+			return nil
+		},
+	}
+	cmd.Flags().BoolVar(&launch, "launch", false, "Open the setup URL in your default browser")
+	return cmd
+}
+
 func newAuthStatusCmd(flags *rootFlags) *cobra.Command {
 	return &cobra.Command{
 		Use:   "status",
diff --git a/testdata/golden/expected/generate-golden-api/dogfood.json b/testdata/golden/expected/generate-golden-api/dogfood.json
index c144e26f..9961935d 100644
--- a/testdata/golden/expected/generate-golden-api/dogfood.json
+++ b/testdata/golden/expected/generate-golden-api/dogfood.json
@@ -71,8 +71,8 @@
   "verdict": "WARN",
   "wiring_check": {
     "command_tree": {
-      "defined": 40,
-      "registered": 40
+      "defined": 41,
+      "registered": 41
     },
     "config_consistency": {
       "consistent": true
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 260a1e42..a6e1aa68 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
@@ -62,7 +62,7 @@ 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:
+Run `printing-press-golden-pp-cli auth setup` to print the URL and steps for getting a key (add `--launch` to open the URL). Then set:
 
 ```bash
 export PRINTING_PRESS_GOLDEN_API_KEY="<your-key>"
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 7bb622d5..2503fdbe 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
@@ -6,7 +6,6 @@ package cli
 import (
 	"fmt"
 	"os"
-
 	"printing-press-golden-pp-cli/internal/config"
 	"github.com/spf13/cobra"
 )
@@ -17,6 +16,7 @@ func newAuthCmd(flags *rootFlags) *cobra.Command {
 		Short: "Manage authentication for Printing Press Studio",
 	}
 
+	cmd.AddCommand(newAuthSetupCmd(flags))
 	cmd.AddCommand(newAuthStatusCmd(flags))
 	cmd.AddCommand(newAuthSetTokenCmd(flags))
 	cmd.AddCommand(newAuthLogoutCmd(flags))
@@ -24,6 +24,33 @@ func newAuthCmd(flags *rootFlags) *cobra.Command {
 	return cmd
 }
 
+// newAuthSetupCmd prints concrete steps for getting a credential. Side-effect
+// rule: print by default, --launch opt-in to open the URL, short-circuit when
+// the verifier is running this in a sandboxed subprocess.
+func newAuthSetupCmd(_ *rootFlags) *cobra.Command {
+	var launch bool
+	cmd := &cobra.Command{
+		Use:   "setup",
+		Short: "Print steps for obtaining a credential (use --launch to open the URL)",
+		Example: "  printing-press-golden-pp-cli auth setup\n  printing-press-golden-pp-cli auth setup --launch",
+		RunE: func(cmd *cobra.Command, args []string) error {
+			w := cmd.OutOrStdout()
+			fmt.Fprintln(w, "No setup URL is configured for this CLI; check the API's docs.")
+			fmt.Fprintln(w, "")
+			fmt.Fprintln(w, "Then set:")
+			fmt.Fprintln(w, "  export PRINTING_PRESS_GOLDEN_API_KEY=\"<your-token>\"")
+			fmt.Fprintln(w, "  printing-press-golden-pp-cli auth set-token <token>")
+			if !launch {
+				return nil
+			}
+			fmt.Fprintln(cmd.ErrOrStderr(), "no setup URL configured; cannot launch")
+			return nil
+		},
+	}
+	cmd.Flags().BoolVar(&launch, "launch", false, "Open the setup URL in your default browser")
+	return cmd
+}
+
 func newAuthStatusCmd(flags *rootFlags) *cobra.Command {
 	return &cobra.Command{
 		Use:   "status",

← a3e07d0a feat(cli): mechanical PII gate before promote/publish (#958)  ·  back to Cli Printing Press  ·  fix(generator): rename trailing '_test' stems to avoid Go te a03a7b81 →