[object Object]

← back to Cli Printing Press

feat(cli): GraphQL credential probe in doctor + actionable copy (#1701)

b534884fcd9707d1cdcd72698df6f0312efdbfdb · 2026-05-19 19:01:32 -0700 · Trevin Chow

* feat(cli): verify GraphQL credentials in doctor + actionable unverified copy

The doctor Credentials check emitted `WARN Credentials: present (not
verified — set auth.verify_path in spec ...)`. Two problems: the operator
running the CLI doesn't own the spec, and there was no next step. GraphQL
APIs (Linear, GitHub, Shopify) also couldn't declare a probe at all since
there's no REST verify endpoint to point at.

- Add `auth.verify_query` spec field. When set, doctor POSTs
  `{"query": "<value>"}` to base_url and treats HTTP 2xx + no top-level
  `errors` as verified, 401/403 as rejected. Routed through
  PostQueryWithParamsAndHeaders so verify-mode doesn't suppress the read.
  VerifyPath wins when both are set (REST probe is cheaper).
- Replace the unverified-state copy with a runnable suggestion resolved at
  doctor-time: walk the cobra tree for the first endpoint-mirror leaf with a
  list/get verb and no positional args. Gated on the pp:endpoint annotation
  so it never suggests local-read framework commands, and recurses through
  Hidden resource parents (whose endpoint leaves stay runnable).
- Downgrade the unverified state WARN -> INFO; "we didn't check" is not a
  warning and shouldn't render yellow in CI dashboards.

Additive: behavior for CLIs that already set auth.verify_path is unchanged.

* fix(cli): split 403 from 401 in doctor GraphQL credential probe

The GraphQL probe collapsed HTTP 401 and 403 into "invalid — check your
credentials". 403 means a valid-but-scope-limited token, so that copy told
operators to replace a working token when they only needed to add scopes.
Mirror the REST probe's split: 401 -> invalid, 403 -> scope-limited.

Files touched

Diff

commit b534884fcd9707d1cdcd72698df6f0312efdbfdb
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Tue May 19 19:01:32 2026 -0700

    feat(cli): GraphQL credential probe in doctor + actionable copy (#1701)
    
    * feat(cli): verify GraphQL credentials in doctor + actionable unverified copy
    
    The doctor Credentials check emitted `WARN Credentials: present (not
    verified — set auth.verify_path in spec ...)`. Two problems: the operator
    running the CLI doesn't own the spec, and there was no next step. GraphQL
    APIs (Linear, GitHub, Shopify) also couldn't declare a probe at all since
    there's no REST verify endpoint to point at.
    
    - Add `auth.verify_query` spec field. When set, doctor POSTs
      `{"query": "<value>"}` to base_url and treats HTTP 2xx + no top-level
      `errors` as verified, 401/403 as rejected. Routed through
      PostQueryWithParamsAndHeaders so verify-mode doesn't suppress the read.
      VerifyPath wins when both are set (REST probe is cheaper).
    - Replace the unverified-state copy with a runnable suggestion resolved at
      doctor-time: walk the cobra tree for the first endpoint-mirror leaf with a
      list/get verb and no positional args. Gated on the pp:endpoint annotation
      so it never suggests local-read framework commands, and recurses through
      Hidden resource parents (whose endpoint leaves stay runnable).
    - Downgrade the unverified state WARN -> INFO; "we didn't check" is not a
      warning and shouldn't render yellow in CI dashboards.
    
    Additive: behavior for CLIs that already set auth.verify_path is unchanged.
    
    * fix(cli): split 403 from 401 in doctor GraphQL credential probe
    
    The GraphQL probe collapsed HTTP 401 and 403 into "invalid — check your
    credentials". 403 means a valid-but-scope-limited token, so that copy told
    operators to replace a working token when they only needed to add scopes.
    Mirror the REST probe's split: 401 -> invalid, 403 -> scope-limited.
---
 internal/generator/auth_status_test.go             |  15 +-
 .../generator/doctor_credentials_probe_test.go     | 220 +++++++++++++++++++++
 internal/generator/generator_test.go               |   8 +-
 internal/generator/templates/doctor.go.tmpl        | 139 ++++++++++++-
 internal/spec/spec.go                              |  11 ++
 .../internal/cli/doctor.go                         |  85 +++++++-
 .../printing-press-golden/internal/cli/doctor.go   |  85 +++++++-
 .../tier-routing-golden/internal/cli/doctor.go     |  85 +++++++-
 8 files changed, 633 insertions(+), 15 deletions(-)

diff --git a/internal/generator/auth_status_test.go b/internal/generator/auth_status_test.go
index 33e8693b..f17bed88 100644
--- a/internal/generator/auth_status_test.go
+++ b/internal/generator/auth_status_test.go
@@ -20,10 +20,17 @@ func TestDoctorWithoutVerifyPathDoesNotClaimCredentialsValid(t *testing.T) {
 	require.NoError(t, err)
 	src := string(doctorSrc)
 
-	require.Contains(t, src, `report["credentials"] = "present (not verified — set auth.verify_path in spec for an API acceptance check)"`,
-		"doctor must not report API credential validity from a bare base URL probe")
-	require.NotContains(t, src, `report["credentials"] = "valid"`,
-		"without auth.verify_path, a 2xx base URL response does not prove the API accepted the credentials")
+	require.Contains(t, src, `"present, not verified. Run `,
+		"doctor must not report API credential validity from a bare base URL probe; the unverified copy must use the new INFO phrasing")
+	require.Contains(t, src, "suggestion := suggestReadCommand(cmd.Root())",
+		"doctor must walk the cobra tree for a runnable read command instead of nagging the user to edit a spec they don't own")
+	require.NotContains(t, src, `set auth.verify_path in spec`,
+		"the old WARN copy that pointed users at a spec field they can't edit must be gone")
+	// Both probe branches (VerifyPath and VerifyQuery) emit `report["credentials"] = "valid"`
+	// when the probe succeeds; anchor on the verify-path-shaped invocation so this
+	// assertion stays specific to "no REST probe ran" rather than "the literal `valid` never appears."
+	require.NotContains(t, src, `c.GetWithHeaders(verifyPath`,
+		"without auth.verify_path, doctor must not emit a REST verification call")
 	require.NotContains(t, src, "but auth was accepted",
 		"without auth.verify_path, non-auth HTTP statuses do not prove the API accepted the credentials")
 }
diff --git a/internal/generator/doctor_credentials_probe_test.go b/internal/generator/doctor_credentials_probe_test.go
new file mode 100644
index 00000000..30f78dd5
--- /dev/null
+++ b/internal/generator/doctor_credentials_probe_test.go
@@ -0,0 +1,220 @@
+package generator
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// TestGeneratedDoctor_EmitsGraphQLProbeWhenVerifyQuerySet wires the new
+// Auth.VerifyQuery spec field through Generate(): a GraphQL CLI that opts
+// in by setting verify_query must emit a doctor.go that POSTs the declared
+// query and parses the response envelope for top-level errors.
+func TestGeneratedDoctor_EmitsGraphQLProbeWhenVerifyQuerySet(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("doctor-graphql-probe")
+	apiSpec.Auth.VerifyQuery = "{ viewer { id } }"
+
+	outputDir := filepath.Join(t.TempDir(), "doctor-graphql-probe-pp-cli")
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	doctorGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "doctor.go"))
+	require.NoError(t, err)
+	content := string(doctorGo)
+
+	assert.Contains(t, content, `verifyBody := map[string]string{"query": "{ viewer { id } }"}`,
+		"doctor should serialize the spec-declared verify_query into the request body")
+	assert.Contains(t, content, `c.PostQueryWithParamsAndHeaders("",`,
+		"doctor should route the GraphQL probe through PostQueryWithParamsAndHeaders so verify-mode does not short-circuit reads")
+	assert.Contains(t, content, `Errors []json.RawMessage `+"`json:\"errors\"`",
+		"doctor should parse the response for a top-level errors array")
+	assert.Contains(t, content, `"rejected — GraphQL response contained top-level errors"`,
+		"doctor must distinguish a 2xx-with-errors GraphQL response from a true valid token")
+	assert.NotContains(t, content, `"present, not verified.`,
+		"the unverified-fallback branch must not be rendered when verify_query is set")
+
+	// The GraphQL probe must split 401 from 403 the same way the REST probe
+	// does: 403 means a valid-but-scope-limited token, so telling the operator
+	// to "check your credentials" (replace the token) would be wrong guidance.
+	assert.Contains(t, content, `case gqlAPIErr.StatusCode == 401:`,
+		"GraphQL probe must keep a dedicated 401 branch so an invalid token reads as invalid")
+	assert.Contains(t, content, `case gqlAPIErr.StatusCode == 403:`,
+		"GraphQL probe must split 403 so a scope-limited token is not misreported as invalid")
+	assert.Contains(t, content, `scope-limited (HTTP %d) — credentials are valid but lack permission for this endpoint.`,
+		"GraphQL probe's 403 message must point at scope, not the credential value")
+	assert.NotContains(t, content, `gqlAPIErr.StatusCode == 401 || gqlAPIErr.StatusCode == 403`,
+		"GraphQL probe must not collapse 401 and 403 into a single invalid branch")
+}
+
+// TestGeneratedDoctor_PreferVerifyPathOverVerifyQuery pins the precedence
+// order: when a spec author declares both, the REST probe wins because it's
+// cheaper than a GraphQL POST. The verify_query branch must not emit.
+func TestGeneratedDoctor_PreferVerifyPathOverVerifyQuery(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("doctor-both-probes")
+	apiSpec.Auth.VerifyPath = "/v1/account"
+	apiSpec.Auth.VerifyQuery = "{ viewer { id } }"
+
+	outputDir := filepath.Join(t.TempDir(), "doctor-both-probes-pp-cli")
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	doctorGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "doctor.go"))
+	require.NoError(t, err)
+	content := string(doctorGo)
+
+	assert.Contains(t, content, `verifyPath := "/v1/account"`,
+		"VerifyPath should win when both probes are declared")
+	assert.NotContains(t, content, `verifyBody := map[string]string{"query":`,
+		"the GraphQL probe must not emit when a REST verify_path is declared")
+}
+
+// TestGeneratedDoctor_UnverifiedMessageSuggestsReadCommand checks the
+// no-probe path: when neither VerifyPath nor VerifyQuery is set, the doctor
+// must suggest a runtime-discovered read command instead of nagging the
+// user to edit a spec they don't own.
+func TestGeneratedDoctor_UnverifiedMessageSuggestsReadCommand(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("doctor-unverified-message")
+
+	outputDir := filepath.Join(t.TempDir(), "doctor-unverified-message-pp-cli")
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	doctorGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "doctor.go"))
+	require.NoError(t, err)
+	content := string(doctorGo)
+
+	assert.Contains(t, content, `func suggestReadCommand(root *cobra.Command) string`,
+		"doctor template must emit the runtime helper that picks a read command")
+	assert.Contains(t, content, `suggestion := suggestReadCommand(cmd.Root())`,
+		"the unverified branch must call the helper at doctor-time")
+	assert.Contains(t, content, `"present, not verified. Run `,
+		"the unverified message must use the new INFO copy")
+	assert.NotContains(t, content, `"present (not verified — set auth.verify_path in spec for an API acceptance check)"`,
+		"the old WARN copy that scolded the user for spec settings must be gone")
+
+	// minimalSpec ships an items.list endpoint; the helper plus the renderer
+	// switch make "not verified" render as yellow INFO, not WARN.
+	assert.Contains(t, content, `case strings.Contains(s, "not verified"):`,
+		"the renderer must downgrade the unverified-state to INFO; without the case it falls through to the WARN catch-all")
+	assert.Contains(t, content, `indicator = yellow("INFO")`,
+		"the unverified case must paint yellow INFO, not red FAIL or yellow WARN")
+}
+
+// TestGeneratedDoctor_SuggestReadCommandHelperGatesOnEndpointAndArgs guards
+// the shape of the runtime helper. It MUST:
+//
+//   - Only suggest commands carrying the pp:endpoint annotation, so the
+//     suggestion actually exercises the token rather than reading a local
+//     file (`feedback list`, `profile list`). Without this gate the doctor
+//     could recreate the false-confidence failure mode that the new copy
+//     was designed to prevent.
+//   - Reject Use strings containing `<` or `[` (the positional-arg markers
+//     emitted for `get <id>`-style endpoint commands), because their runtime
+//     body prints help when args are empty, so the suggestion would not
+//     actually call the API.
+//   - Restrict to list/get verbs.
+//   - Probe the Args validator with [] as a final defense.
+func TestGeneratedDoctor_SuggestReadCommandHelperGatesOnEndpointAndArgs(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("doctor-suggest-helper")
+
+	outputDir := filepath.Join(t.TempDir(), "doctor-suggest-helper-pp-cli")
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	doctorGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "doctor.go"))
+	require.NoError(t, err)
+	content := string(doctorGo)
+
+	assert.Contains(t, content, `if cmd.Annotations["pp:endpoint"] == ""`,
+		"helper must reject framework commands without a pp:endpoint annotation")
+	assert.Contains(t, content, `strings.ContainsAny(cmd.Use, "<[")`,
+		"helper must reject endpoint commands whose Use advertises positional path params")
+	assert.Contains(t, content, `verb := strings.ToLower(strings.SplitN(cmd.Use, " ", 2)[0])`,
+		"helper must extract the leading verb from Use, not the full string")
+	assert.Contains(t, content, `if verb != "list" && verb != "get"`,
+		"helper must restrict to list/get verbs only")
+	assert.Contains(t, content, `cmd.Args(cmd, []string{}) == nil`,
+		"helper must probe the Args validator with an empty arg list as a final defense")
+
+	// Hidden-parent traversal: printed CLIs mark raw resource parents Hidden
+	// to keep --help curated, but their endpoint leaves stay runnable. The
+	// walk MUST recurse into hidden parents (otherwise the suggestion is ""
+	// in nearly every CLI), while isSuggestableReadLeaf MUST still reject a
+	// leaf that is itself hidden.
+	assert.Contains(t, content, `if cmd == nil || cmd.Hidden || cmd.HasSubCommands() || !cmd.Runnable()`,
+		"isSuggestableReadLeaf must reject a leaf that is itself hidden")
+	assert.NotContains(t, content, `for _, child := range cmd.Commands() {
+			if child.Hidden {
+				continue
+			}`,
+		"the walk must recurse through hidden resource parents, not skip them — skipping makes the suggestion empty in nearly every CLI")
+}
+
+// TestGeneratedDoctor_SuggestHelperOnlyEmittedWhenNeeded pins the template
+// gate that wraps the helper functions: when a spec declares any probe
+// (VerifyPath or VerifyQuery), the helpers are dead code in the printed
+// CLI and should not emit at all. The opposite case (no probe declared)
+// must still emit them, because the unverified-state branch calls into
+// suggestReadCommand at doctor-time.
+func TestGeneratedDoctor_SuggestHelperOnlyEmittedWhenNeeded(t *testing.T) {
+	t.Parallel()
+
+	cases := []struct {
+		name      string
+		configure func(*minimalDoctorSpec)
+		wantEmit  bool
+	}{
+		{
+			name:      "no probe — helpers required by the unverified branch",
+			configure: func(s *minimalDoctorSpec) {},
+			wantEmit:  true,
+		},
+		{
+			name:      "verify_path set — helpers are dead code",
+			configure: func(s *minimalDoctorSpec) { s.VerifyPath = "/v1/account" },
+			wantEmit:  false,
+		},
+		{
+			name:      "verify_query set — helpers are dead code",
+			configure: func(s *minimalDoctorSpec) { s.VerifyQuery = "{ viewer { id } }" },
+			wantEmit:  false,
+		},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+			apiSpec := minimalSpec("doctor-helper-gate")
+			cfg := &minimalDoctorSpec{}
+			tc.configure(cfg)
+			apiSpec.Auth.VerifyPath = cfg.VerifyPath
+			apiSpec.Auth.VerifyQuery = cfg.VerifyQuery
+
+			outputDir := filepath.Join(t.TempDir(), "doctor-helper-gate-pp-cli")
+			require.NoError(t, New(apiSpec, outputDir).Generate())
+
+			doctorGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "doctor.go"))
+			require.NoError(t, err)
+			content := string(doctorGo)
+
+			if tc.wantEmit {
+				assert.Contains(t, content, `func suggestReadCommand(root *cobra.Command) string`,
+					"helper must emit when no probe is declared (unverified branch calls it)")
+			} else {
+				assert.NotContains(t, content, `func suggestReadCommand(root *cobra.Command) string`,
+					"helper must not emit when a probe is declared — dead code in the printed CLI")
+			}
+		})
+	}
+}
+
+type minimalDoctorSpec struct {
+	VerifyPath  string
+	VerifyQuery string
+}
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index a3d85a88..70550f1d 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -6986,11 +6986,15 @@ func TestGeneratedDoctor_NoVerifyPathReportsCredentialsPresent(t *testing.T) {
 	content := string(doctorGo)
 
 	// Without spec verify_path, doctor reports local credential presence
-	// without pretending the API accepted the key.
+	// without pretending the API accepted the key. The "not verified" copy
+	// now suggests a read command (resolved at doctor-time from the cobra
+	// tree) instead of pointing the user at a spec field they don't own.
 	assert.NotContains(t, content, `verifyPath := "/"`)
 	assert.NotContains(t, content, `c.GetWithHeaders(verifyPath`)
 	assert.NotContains(t, content, `&http.Client{`)
-	assert.Contains(t, content, `"present (not verified — set auth.verify_path in spec for an API acceptance check)"`)
+	assert.Contains(t, content, `"present, not verified. Run `)
+	assert.NotContains(t, content, `set auth.verify_path in spec`,
+		"the old WARN copy that scolded the user about a spec field must be gone")
 	assert.NotContains(t, content, `"inconclusive (HTTP %d from base URL — set auth.verify_path in spec for a definitive probe)"`)
 	assert.NotContains(t, content, `"invalid (HTTP %d) — check your credentials"`)
 }
diff --git a/internal/generator/templates/doctor.go.tmpl b/internal/generator/templates/doctor.go.tmpl
index 2f11b088..9d63de1c 100644
--- a/internal/generator/templates/doctor.go.tmpl
+++ b/internal/generator/templates/doctor.go.tmpl
@@ -11,6 +11,8 @@ import (
 {{- if .Auth.RequiresBrowserSession}}
 	"crypto/sha256"
 	"encoding/hex"
+{{- end}}
+{{- if or .Auth.RequiresBrowserSession .Auth.VerifyQuery}}
 	"encoding/json"
 {{- end}}
 {{- if .HasStore}}
@@ -98,6 +100,82 @@ func looksLikeDoctorInterstitial(body []byte) string {
 	return ""
 }
 
+{{- if and (not .Auth.VerifyPath) (not .Auth.VerifyQuery)}}
+// suggestReadCommand walks the Cobra tree to find an endpoint-mirror command
+// an operator can run to confirm credentials work end-to-end. Picks the
+// first leaf that (a) carries the `pp:endpoint` annotation, so it actually
+// dials the API rather than reading a local file like `feedback list` or
+// `profile list`; (b) has a list/get verb; and (c) takes no positional
+// arguments, so the suggestion is copy-paste runnable. Returns the dotted
+// command path (e.g. "issues list") or "" when no such command exists —
+// common in mutation-only CLIs and in CLIs where every read command has
+// required positional arguments.
+func suggestReadCommand(root *cobra.Command) string {
+	if root == nil {
+		return ""
+	}
+	var found string
+	var walk func(*cobra.Command, []string)
+	walk = func(cmd *cobra.Command, path []string) {
+		if found != "" {
+			return
+		}
+		for _, child := range cmd.Commands() {
+			childPath := append(append([]string{}, path...), child.Name())
+			if isSuggestableReadLeaf(child) {
+				found = strings.Join(childPath, " ")
+				return
+			}
+			// Recurse even into Hidden parents: printed CLIs mark raw
+			// resource parents Hidden to keep --help curated, but their
+			// endpoint leaves remain runnable (`<cli> projects list`
+			// works). Skipping hidden subtrees would make this return ""
+			// in nearly every CLI. isSuggestableReadLeaf still rejects a
+			// leaf that is itself Hidden.
+			walk(child, childPath)
+			if found != "" {
+				return
+			}
+		}
+	}
+	walk(root, nil)
+	return found
+}
+
+func isSuggestableReadLeaf(cmd *cobra.Command) bool {
+	if cmd == nil || cmd.Hidden || cmd.HasSubCommands() || !cmd.Runnable() {
+		return false
+	}
+	// Only endpoint-mirror commands count; framework commands like
+	// `feedback list` and `profile list` read local files and would
+	// recreate the false-confidence failure mode the suggestion is
+	// supposed to avoid.
+	if cmd.Annotations["pp:endpoint"] == "" {
+		return false
+	}
+	verb := strings.ToLower(strings.SplitN(cmd.Use, " ", 2)[0])
+	if verb != "list" && verb != "get" {
+		return false
+	}
+	// Endpoint commands with positional path params advertise them in
+	// Use as `<id>` (required) or `[id]` (optional). The runtime body
+	// rejects empty args by printing help, so suggesting one would not
+	// actually exercise the token — reject before the Args probe below.
+	if strings.ContainsAny(cmd.Use, "<[") {
+		return false
+	}
+	// Probe the Args validator with an empty positional-arg list. A nil
+	// validator accepts anything (including zero args); a non-nil validator
+	// that returns nil for [] accepts zero args. Either qualifies — the
+	// suggestion `<cli> list` is then a complete command.
+	if cmd.Args == nil {
+		return true
+	}
+	return cmd.Args(cmd, []string{}) == nil
+}
+
+{{- end}}
+
 func newDoctorCmd(flags *rootFlags) *cobra.Command {
 	var failOn string
 {{- if .BearerRefresh.Enabled}}
@@ -415,11 +493,12 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 					} else if reachErr != nil && !errors.As(reachErr, &reachAPIErr) {
 						report["credentials"] = "skipped (API unreachable)"
 					} else {
-{{- if .Auth.VerifyPath}}
-						verifyPath := "{{.Auth.VerifyPath}}"
-						if !strings.HasPrefix(verifyPath, "/") {
-							verifyPath = "/" + verifyPath
-						}
+{{- if or .Auth.VerifyPath .Auth.VerifyQuery}}
+						// Shared auth-header setup for both probe variants below.
+						// Kept hoisted out of the per-probe branches because the
+						// per-API auth-placement, RequiredHeaders, and User-Agent
+						// fallback logic is independent of which verb the probe
+						// dials.
 						authParams := map[string]string{}
 						authHeaders := map[string]string{}
 {{- if and .Auth .Auth.In (eq .Auth.In "query")}}
@@ -446,6 +525,12 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 {{- if and (not .UsesBrowserManagedUserAgent) (not (.HasRequiredHeader "User-Agent")) (not $authUsesUA)}}
 						authHeaders["User-Agent"] = "{{.Name}}-pp-cli"
 {{- end}}
+{{- end}}
+{{- if .Auth.VerifyPath}}
+						verifyPath := "{{.Auth.VerifyPath}}"
+						if !strings.HasPrefix(verifyPath, "/") {
+							verifyPath = "/" + verifyPath
+						}
 						_, authErr := c.GetWithHeaders(verifyPath, authParams, authHeaders)
 						var authAPIErr *client.APIError
 						switch {
@@ -464,8 +549,45 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 						default:
 							report["credentials"] = fmt.Sprintf("error: %s", authErr)
 						}
+{{- else if .Auth.VerifyQuery}}
+						// GraphQL credential probe. POST a spec-declared query to
+						// base_url and treat HTTP 2xx with no top-level `errors`
+						// array as verified. Goes through doRead/PostQueryWith…
+						// so the verify-mode short-circuit doesn't suppress the
+						// probe under PRINTING_PRESS_VERIFY=1.
+						authHeaders["Content-Type"] = "application/json"
+						verifyBody := map[string]string{"query": {{printf "%q" .Auth.VerifyQuery}}}
+						respBody, _, gqlErr := c.PostQueryWithParamsAndHeaders("", authParams, verifyBody, authHeaders)
+						var gqlAPIErr *client.APIError
+						switch {
+						case gqlErr == nil:
+							var envelope struct {
+								Errors []json.RawMessage `json:"errors"`
+							}
+							if jerr := json.Unmarshal(respBody, &envelope); jerr == nil && len(envelope.Errors) > 0 {
+								report["credentials"] = "rejected — GraphQL response contained top-level errors"
+							} else {
+								report["credentials"] = "valid"
+							}
+						case errors.As(gqlErr, &gqlAPIErr):
+							switch {
+							case gqlAPIErr.StatusCode == 401:
+								report["credentials"] = fmt.Sprintf("invalid (HTTP %d) — check your credentials", gqlAPIErr.StatusCode)
+							case gqlAPIErr.StatusCode == 403:
+								report["credentials"] = fmt.Sprintf("scope-limited (HTTP %d) — credentials are valid but lack permission for this endpoint. Check your dashboard's API key scope.", gqlAPIErr.StatusCode)
+							default:
+								report["credentials"] = fmt.Sprintf("ok (HTTP %d from GraphQL probe, but auth was accepted)", gqlAPIErr.StatusCode)
+							}
+						default:
+							report["credentials"] = fmt.Sprintf("error: %s", gqlErr)
+						}
 {{- else}}
-						report["credentials"] = "present (not verified — set auth.verify_path in spec for an API acceptance check)"
+						suggestion := suggestReadCommand(cmd.Root())
+						if suggestion != "" {
+							report["credentials"] = fmt.Sprintf("present, not verified. Run `%s %s` to confirm the token works end-to-end.", "{{.Name}}-pp-cli", suggestion)
+						} else {
+							report["credentials"] = "present, not verified. Run any read command to confirm the token works end-to-end."
+						}
 {{- end}}
 {{- end}}
 					}
@@ -536,6 +658,11 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 					indicator = yellow("INFO")
 				case strings.Contains(s, "scope-limited"):
 					indicator = yellow("WARN")
+				case strings.Contains(s, "not verified"):
+					// "present, not verified" — credentials are loaded but no
+					// probe ran. Informational, not a warning; a clean config
+					// shouldn't render yellow WARN in CI dashboards.
+					indicator = yellow("INFO")
 				case strings.Contains(s, "error") || strings.Contains(s, "not configured") || strings.Contains(s, "unreachable") || strings.Contains(s, "invalid") || strings.Contains(s, "missing"):
 					indicator = red("FAIL")
 				case s == "not required":
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 71d48e9d..b34985cc 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -593,6 +593,17 @@ type AuthConfig struct {
 	// still demands credentials in a meaningful context).
 	VerifyPath string `yaml:"verify_path,omitempty" json:"verify_path,omitempty"`
 
+	// VerifyQuery is an optional GraphQL document the doctor command POSTs as
+	// {"query": "<VerifyQuery>"} against base_url to validate credentials.
+	// GraphQL APIs that don't expose a REST verify endpoint can opt in by
+	// setting this to a small viewer-style query (Linear, GitHub, Shopify
+	// conventionally use `{ viewer { id } }`, but the field is opaque to the
+	// generator — any query that 2xx-and-no-`errors` for a valid token works).
+	// When set, the doctor treats HTTP 2xx with no top-level `errors` array
+	// as verified and 401/403 as rejected. Mutually informative with
+	// VerifyPath: if both are set, VerifyPath wins (REST probe is cheaper).
+	VerifyQuery string `yaml:"verify_query,omitempty" json:"verify_query,omitempty"`
+
 	// Browser-session verification fields. Used when a website-facing CLI
 	// depends on browser-derived cookies or clearance state for its required
 	// happy path. The generator emits validation and proof handling, and the
diff --git a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/doctor.go b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/doctor.go
index dd5b2e38..f41f26b4 100644
--- a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/doctor.go
+++ b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/doctor.go
@@ -64,6 +64,79 @@ func looksLikeDoctorInterstitial(body []byte) string {
 	return ""
 }
 
+// suggestReadCommand walks the Cobra tree to find an endpoint-mirror command
+// an operator can run to confirm credentials work end-to-end. Picks the
+// first leaf that (a) carries the `pp:endpoint` annotation, so it actually
+// dials the API rather than reading a local file like `feedback list` or
+// `profile list`; (b) has a list/get verb; and (c) takes no positional
+// arguments, so the suggestion is copy-paste runnable. Returns the dotted
+// command path (e.g. "issues list") or "" when no such command exists —
+// common in mutation-only CLIs and in CLIs where every read command has
+// required positional arguments.
+func suggestReadCommand(root *cobra.Command) string {
+	if root == nil {
+		return ""
+	}
+	var found string
+	var walk func(*cobra.Command, []string)
+	walk = func(cmd *cobra.Command, path []string) {
+		if found != "" {
+			return
+		}
+		for _, child := range cmd.Commands() {
+			childPath := append(append([]string{}, path...), child.Name())
+			if isSuggestableReadLeaf(child) {
+				found = strings.Join(childPath, " ")
+				return
+			}
+			// Recurse even into Hidden parents: printed CLIs mark raw
+			// resource parents Hidden to keep --help curated, but their
+			// endpoint leaves remain runnable (`<cli> projects list`
+			// works). Skipping hidden subtrees would make this return ""
+			// in nearly every CLI. isSuggestableReadLeaf still rejects a
+			// leaf that is itself Hidden.
+			walk(child, childPath)
+			if found != "" {
+				return
+			}
+		}
+	}
+	walk(root, nil)
+	return found
+}
+
+func isSuggestableReadLeaf(cmd *cobra.Command) bool {
+	if cmd == nil || cmd.Hidden || cmd.HasSubCommands() || !cmd.Runnable() {
+		return false
+	}
+	// Only endpoint-mirror commands count; framework commands like
+	// `feedback list` and `profile list` read local files and would
+	// recreate the false-confidence failure mode the suggestion is
+	// supposed to avoid.
+	if cmd.Annotations["pp:endpoint"] == "" {
+		return false
+	}
+	verb := strings.ToLower(strings.SplitN(cmd.Use, " ", 2)[0])
+	if verb != "list" && verb != "get" {
+		return false
+	}
+	// Endpoint commands with positional path params advertise them in
+	// Use as `<id>` (required) or `[id]` (optional). The runtime body
+	// rejects empty args by printing help, so suggesting one would not
+	// actually exercise the token — reject before the Args probe below.
+	if strings.ContainsAny(cmd.Use, "<[") {
+		return false
+	}
+	// Probe the Args validator with an empty positional-arg list. A nil
+	// validator accepts anything (including zero args); a non-nil validator
+	// that returns nil for [] accepts zero args. Either qualifies — the
+	// suggestion `<cli> list` is then a complete command.
+	if cmd.Args == nil {
+		return true
+	}
+	return cmd.Args(cmd, []string{}) == nil
+}
+
 func newDoctorCmd(flags *rootFlags) *cobra.Command {
 	var failOn string
 	cmd := &cobra.Command{
@@ -235,7 +308,12 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 					} else if reachErr != nil && !errors.As(reachErr, &reachAPIErr) {
 						report["credentials"] = "skipped (API unreachable)"
 					} else {
-						report["credentials"] = "present (not verified — set auth.verify_path in spec for an API acceptance check)"
+						suggestion := suggestReadCommand(cmd.Root())
+						if suggestion != "" {
+							report["credentials"] = fmt.Sprintf("present, not verified. Run `%s %s` to confirm the token works end-to-end.", "printing-press-rich-pp-cli", suggestion)
+						} else {
+							report["credentials"] = "present, not verified. Run any read command to confirm the token works end-to-end."
+						}
 					}
 				}
 			} else if cfg != nil && cfg.BaseURL == "" {
@@ -298,6 +376,11 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 					indicator = yellow("INFO")
 				case strings.Contains(s, "scope-limited"):
 					indicator = yellow("WARN")
+				case strings.Contains(s, "not verified"):
+					// "present, not verified" — credentials are loaded but no
+					// probe ran. Informational, not a warning; a clean config
+					// shouldn't render yellow WARN in CI dashboards.
+					indicator = yellow("INFO")
 				case strings.Contains(s, "error") || strings.Contains(s, "not configured") || strings.Contains(s, "unreachable") || strings.Contains(s, "invalid") || strings.Contains(s, "missing"):
 					indicator = red("FAIL")
 				case s == "not required":
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/doctor.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/doctor.go
index 7d2cd071..b88a448d 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/doctor.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/doctor.go
@@ -64,6 +64,79 @@ func looksLikeDoctorInterstitial(body []byte) string {
 	return ""
 }
 
+// suggestReadCommand walks the Cobra tree to find an endpoint-mirror command
+// an operator can run to confirm credentials work end-to-end. Picks the
+// first leaf that (a) carries the `pp:endpoint` annotation, so it actually
+// dials the API rather than reading a local file like `feedback list` or
+// `profile list`; (b) has a list/get verb; and (c) takes no positional
+// arguments, so the suggestion is copy-paste runnable. Returns the dotted
+// command path (e.g. "issues list") or "" when no such command exists —
+// common in mutation-only CLIs and in CLIs where every read command has
+// required positional arguments.
+func suggestReadCommand(root *cobra.Command) string {
+	if root == nil {
+		return ""
+	}
+	var found string
+	var walk func(*cobra.Command, []string)
+	walk = func(cmd *cobra.Command, path []string) {
+		if found != "" {
+			return
+		}
+		for _, child := range cmd.Commands() {
+			childPath := append(append([]string{}, path...), child.Name())
+			if isSuggestableReadLeaf(child) {
+				found = strings.Join(childPath, " ")
+				return
+			}
+			// Recurse even into Hidden parents: printed CLIs mark raw
+			// resource parents Hidden to keep --help curated, but their
+			// endpoint leaves remain runnable (`<cli> projects list`
+			// works). Skipping hidden subtrees would make this return ""
+			// in nearly every CLI. isSuggestableReadLeaf still rejects a
+			// leaf that is itself Hidden.
+			walk(child, childPath)
+			if found != "" {
+				return
+			}
+		}
+	}
+	walk(root, nil)
+	return found
+}
+
+func isSuggestableReadLeaf(cmd *cobra.Command) bool {
+	if cmd == nil || cmd.Hidden || cmd.HasSubCommands() || !cmd.Runnable() {
+		return false
+	}
+	// Only endpoint-mirror commands count; framework commands like
+	// `feedback list` and `profile list` read local files and would
+	// recreate the false-confidence failure mode the suggestion is
+	// supposed to avoid.
+	if cmd.Annotations["pp:endpoint"] == "" {
+		return false
+	}
+	verb := strings.ToLower(strings.SplitN(cmd.Use, " ", 2)[0])
+	if verb != "list" && verb != "get" {
+		return false
+	}
+	// Endpoint commands with positional path params advertise them in
+	// Use as `<id>` (required) or `[id]` (optional). The runtime body
+	// rejects empty args by printing help, so suggesting one would not
+	// actually exercise the token — reject before the Args probe below.
+	if strings.ContainsAny(cmd.Use, "<[") {
+		return false
+	}
+	// Probe the Args validator with an empty positional-arg list. A nil
+	// validator accepts anything (including zero args); a non-nil validator
+	// that returns nil for [] accepts zero args. Either qualifies — the
+	// suggestion `<cli> list` is then a complete command.
+	if cmd.Args == nil {
+		return true
+	}
+	return cmd.Args(cmd, []string{}) == nil
+}
+
 func newDoctorCmd(flags *rootFlags) *cobra.Command {
 	var failOn string
 	cmd := &cobra.Command{
@@ -183,7 +256,12 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 					} else if reachErr != nil && !errors.As(reachErr, &reachAPIErr) {
 						report["credentials"] = "skipped (API unreachable)"
 					} else {
-						report["credentials"] = "present (not verified — set auth.verify_path in spec for an API acceptance check)"
+						suggestion := suggestReadCommand(cmd.Root())
+						if suggestion != "" {
+							report["credentials"] = fmt.Sprintf("present, not verified. Run `%s %s` to confirm the token works end-to-end.", "printing-press-golden-pp-cli", suggestion)
+						} else {
+							report["credentials"] = "present, not verified. Run any read command to confirm the token works end-to-end."
+						}
 					}
 				}
 			} else if cfg != nil && cfg.BaseURL == "" {
@@ -246,6 +324,11 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 					indicator = yellow("INFO")
 				case strings.Contains(s, "scope-limited"):
 					indicator = yellow("WARN")
+				case strings.Contains(s, "not verified"):
+					// "present, not verified" — credentials are loaded but no
+					// probe ran. Informational, not a warning; a clean config
+					// shouldn't render yellow WARN in CI dashboards.
+					indicator = yellow("INFO")
 				case strings.Contains(s, "error") || strings.Contains(s, "not configured") || strings.Contains(s, "unreachable") || strings.Contains(s, "invalid") || strings.Contains(s, "missing"):
 					indicator = red("FAIL")
 				case s == "not required":
diff --git a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/doctor.go b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/doctor.go
index 8a4b0bcf..6f9d47f5 100644
--- a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/doctor.go
+++ b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/doctor.go
@@ -64,6 +64,79 @@ func looksLikeDoctorInterstitial(body []byte) string {
 	return ""
 }
 
+// suggestReadCommand walks the Cobra tree to find an endpoint-mirror command
+// an operator can run to confirm credentials work end-to-end. Picks the
+// first leaf that (a) carries the `pp:endpoint` annotation, so it actually
+// dials the API rather than reading a local file like `feedback list` or
+// `profile list`; (b) has a list/get verb; and (c) takes no positional
+// arguments, so the suggestion is copy-paste runnable. Returns the dotted
+// command path (e.g. "issues list") or "" when no such command exists —
+// common in mutation-only CLIs and in CLIs where every read command has
+// required positional arguments.
+func suggestReadCommand(root *cobra.Command) string {
+	if root == nil {
+		return ""
+	}
+	var found string
+	var walk func(*cobra.Command, []string)
+	walk = func(cmd *cobra.Command, path []string) {
+		if found != "" {
+			return
+		}
+		for _, child := range cmd.Commands() {
+			childPath := append(append([]string{}, path...), child.Name())
+			if isSuggestableReadLeaf(child) {
+				found = strings.Join(childPath, " ")
+				return
+			}
+			// Recurse even into Hidden parents: printed CLIs mark raw
+			// resource parents Hidden to keep --help curated, but their
+			// endpoint leaves remain runnable (`<cli> projects list`
+			// works). Skipping hidden subtrees would make this return ""
+			// in nearly every CLI. isSuggestableReadLeaf still rejects a
+			// leaf that is itself Hidden.
+			walk(child, childPath)
+			if found != "" {
+				return
+			}
+		}
+	}
+	walk(root, nil)
+	return found
+}
+
+func isSuggestableReadLeaf(cmd *cobra.Command) bool {
+	if cmd == nil || cmd.Hidden || cmd.HasSubCommands() || !cmd.Runnable() {
+		return false
+	}
+	// Only endpoint-mirror commands count; framework commands like
+	// `feedback list` and `profile list` read local files and would
+	// recreate the false-confidence failure mode the suggestion is
+	// supposed to avoid.
+	if cmd.Annotations["pp:endpoint"] == "" {
+		return false
+	}
+	verb := strings.ToLower(strings.SplitN(cmd.Use, " ", 2)[0])
+	if verb != "list" && verb != "get" {
+		return false
+	}
+	// Endpoint commands with positional path params advertise them in
+	// Use as `<id>` (required) or `[id]` (optional). The runtime body
+	// rejects empty args by printing help, so suggesting one would not
+	// actually exercise the token — reject before the Args probe below.
+	if strings.ContainsAny(cmd.Use, "<[") {
+		return false
+	}
+	// Probe the Args validator with an empty positional-arg list. A nil
+	// validator accepts anything (including zero args); a non-nil validator
+	// that returns nil for [] accepts zero args. Either qualifies — the
+	// suggestion `<cli> list` is then a complete command.
+	if cmd.Args == nil {
+		return true
+	}
+	return cmd.Args(cmd, []string{}) == nil
+}
+
 func newDoctorCmd(flags *rootFlags) *cobra.Command {
 	var failOn string
 	cmd := &cobra.Command{
@@ -205,7 +278,12 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 					} else if reachErr != nil && !errors.As(reachErr, &reachAPIErr) {
 						report["credentials"] = "skipped (API unreachable)"
 					} else {
-						report["credentials"] = "present (not verified — set auth.verify_path in spec for an API acceptance check)"
+						suggestion := suggestReadCommand(cmd.Root())
+						if suggestion != "" {
+							report["credentials"] = fmt.Sprintf("present, not verified. Run `%s %s` to confirm the token works end-to-end.", "tier-routing-golden-pp-cli", suggestion)
+						} else {
+							report["credentials"] = "present, not verified. Run any read command to confirm the token works end-to-end."
+						}
 					}
 				}
 			} else if cfg != nil && cfg.BaseURL == "" {
@@ -268,6 +346,11 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 					indicator = yellow("INFO")
 				case strings.Contains(s, "scope-limited"):
 					indicator = yellow("WARN")
+				case strings.Contains(s, "not verified"):
+					// "present, not verified" — credentials are loaded but no
+					// probe ran. Informational, not a warning; a clean config
+					// shouldn't render yellow WARN in CI dashboards.
+					indicator = yellow("INFO")
 				case strings.Contains(s, "error") || strings.Contains(s, "not configured") || strings.Contains(s, "unreachable") || strings.Contains(s, "invalid") || strings.Contains(s, "missing"):
 					indicator = red("FAIL")
 				case s == "not required":

← 5261ae73 fix(cli): return failure exit from verify json (#1693)  ·  back to Cli Printing Press  ·  fix(cli): resolve shipcheck CLI binary name from .printing-p 9eff1e20 →