[object Object]

← back to Cli Printing Press

feat(cli): auth onboarding UX for generated CLIs (#78)

e11359923a2611098449a5a93e94b308cfbaab7e · 2026-03-30 16:47:06 -0700 · Matt Van Horn

Inspired by gogcli's auth setup flow, improves the generated CLI
experience for users who don't already know how to authenticate:

- doctor: validates credentials with a live API call (not just
  checking if a token string exists). Reports valid/invalid/unreachable.
- README: auth-first quickstart with numbered steps, credential format
  hints, and a safe first command. Conditional — no-auth APIs skip the
  auth step entirely.
- errors: 401/403 messages now include auth-type-specific guidance
  (OAuth2 vs API key vs bearer), credential format hints, and docs
  URL when available. 403 distinguishes permissions from bad credentials.

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

Files touched

Diff

commit e11359923a2611098449a5a93e94b308cfbaab7e
Author: Matt Van Horn <mvanhorn@users.noreply.github.com>
Date:   Mon Mar 30 16:47:06 2026 -0700

    feat(cli): auth onboarding UX for generated CLIs (#78)
    
    Inspired by gogcli's auth setup flow, improves the generated CLI
    experience for users who don't already know how to authenticate:
    
    - doctor: validates credentials with a live API call (not just
      checking if a token string exists). Reports valid/invalid/unreachable.
    - README: auth-first quickstart with numbered steps, credential format
      hints, and a safe first command. Conditional — no-auth APIs skip the
      auth step entirely.
    - errors: 401/403 messages now include auth-type-specific guidance
      (OAuth2 vs API key vs bearer), credential format hints, and docs
      URL when available. 403 distinguishes permissions from bad credentials.
    
    Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
 internal/generator/templates/doctor.go.tmpl  | 83 ++++++++++++++++++----------
 internal/generator/templates/helpers.go.tmpl | 27 ++++++++-
 internal/generator/templates/readme.md.tmpl  | 59 ++++++++++++++++++--
 3 files changed, 132 insertions(+), 37 deletions(-)

diff --git a/internal/generator/templates/doctor.go.tmpl b/internal/generator/templates/doctor.go.tmpl
index d89e7434..5c5d267f 100644
--- a/internal/generator/templates/doctor.go.tmpl
+++ b/internal/generator/templates/doctor.go.tmpl
@@ -61,45 +61,69 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 			}
 			{{- end}}
 
-			// Check API connectivity
+			// Check API connectivity and validate credentials
 			if cfg != nil && cfg.BaseURL != "" {
 				httpClient := &http.Client{Timeout: 5 * time.Second}
+				baseURL := strings.TrimRight(cfg.BaseURL, "/")
 
-				// Health check: HEAD request to base URL
-				headReq, _ := http.NewRequest("HEAD", cfg.BaseURL, nil)
+				// Step 1: Basic reachability (unauthenticated HEAD)
+				headReq, _ := http.NewRequest("HEAD", baseURL, nil)
 				headResp, headErr := httpClient.Do(headReq)
-				if headErr != nil {
-					report["api_head"] = fmt.Sprintf("unreachable: %s", headErr)
-				} else {
+				apiReachable := false
+				if headErr == nil {
 					headResp.Body.Close()
-					report["api_head"] = fmt.Sprintf("HTTP %d", headResp.StatusCode)
+					apiReachable = true
 				}
 
-				// Health check: try common health endpoints
-				healthPaths := []string{"/health", "/healthz", "/status", "/ping", ""}
-				reached := false
-				for _, p := range healthPaths {
-					healthURL := strings.TrimRight(cfg.BaseURL, "/") + p
-					healthResp, healthErr := httpClient.Get(healthURL)
-					if healthErr != nil {
-						continue
-					}
-					healthResp.Body.Close()
-					if healthResp.StatusCode >= 200 && healthResp.StatusCode < 400 {
-						report["api"] = fmt.Sprintf("reachable (HTTP %d at %s)", healthResp.StatusCode, p)
-						reached = true
+				// If HEAD failed, try GET on common health endpoints
+				if !apiReachable {
+					for _, p := range []string{"/health", "/healthz", "/status", "/ping", ""} {
+						healthResp, healthErr := httpClient.Get(baseURL + p)
+						if healthErr != nil {
+							continue
+						}
+						healthResp.Body.Close()
+						apiReachable = true
 						break
 					}
 				}
 
-				if !reached {
-					// Fall back to GET on base URL
-					fallbackResp, fallbackErr := httpClient.Get(cfg.BaseURL)
-					if fallbackErr != nil {
-						report["api"] = fmt.Sprintf("unreachable: %s", fallbackErr)
+				if !apiReachable {
+					report["api"] = fmt.Sprintf("unreachable: %s", headErr)
+				} else {
+					report["api"] = "reachable"
+				}
+
+				// Step 2: Validate credentials with an authenticated request
+				authHeader := cfg.AuthHeader()
+				if authHeader == "" {
+					// No auth configured — skip credential validation
+				} else if !apiReachable {
+					report["credentials"] = "skipped (API unreachable)"
+				} else {
+					authReq, _ := http.NewRequest("GET", baseURL, nil)
+{{- if and .Auth .Auth.In (eq .Auth.In "query")}}
+					q := authReq.URL.Query()
+					q.Set("{{if .Auth.Header}}{{.Auth.Header}}{{else}}api_key{{end}}", authHeader)
+					authReq.URL.RawQuery = q.Encode()
+{{- else}}
+					authReq.Header.Set("{{if .Auth.Header}}{{.Auth.Header}}{{else}}Authorization{{end}}", authHeader)
+{{- end}}
+					authReq.Header.Set("User-Agent", "{{.Name}}-pp-cli")
+					authResp, authErr := httpClient.Do(authReq)
+					if authErr != nil {
+						report["credentials"] = "error: could not reach API"
 					} else {
-						fallbackResp.Body.Close()
-						report["api"] = fmt.Sprintf("degraded (HTTP %d)", fallbackResp.StatusCode)
+						authResp.Body.Close()
+						switch {
+						case authResp.StatusCode >= 200 && authResp.StatusCode < 300:
+							report["credentials"] = "valid"
+						case authResp.StatusCode == 401 || authResp.StatusCode == 403:
+							report["credentials"] = fmt.Sprintf("invalid (HTTP %d) — check your credentials", authResp.StatusCode)
+						default:
+							// Non-auth HTTP error (404, 500, etc.) — don't blame credentials
+							report["credentials"] = fmt.Sprintf("ok (HTTP %d from base URL, but auth was accepted)", authResp.StatusCode)
+						}
 					}
 				}
 			} else if cfg != nil && cfg.BaseURL == "" {
@@ -118,6 +142,7 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 				{"config", "Config"},
 				{"auth", "Auth"},
 				{"api", "API"},
+				{"credentials", "Credentials"},
 			}
 			for _, ck := range checkKeys {
 				v, ok := report[ck.key]
@@ -126,9 +151,9 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 				}
 				s := fmt.Sprintf("%v", v)
 				indicator := green("OK")
-				if strings.Contains(s, "error") || strings.Contains(s, "not configured") || strings.Contains(s, "unreachable") {
+				if strings.Contains(s, "error") || strings.Contains(s, "not configured") || strings.Contains(s, "unreachable") || strings.Contains(s, "invalid") {
 					indicator = red("FAIL")
-				} else if strings.Contains(s, "not ") {
+				} else if strings.Contains(s, "not ") || strings.Contains(s, "skipped") {
 					indicator = yellow("WARN")
 				}
 				fmt.Fprintf(w, "  %s %s: %s\n", indicator, ck.label, s)
diff --git a/internal/generator/templates/helpers.go.tmpl b/internal/generator/templates/helpers.go.tmpl
index 0413e589..c10e3a43 100644
--- a/internal/generator/templates/helpers.go.tmpl
+++ b/internal/generator/templates/helpers.go.tmpl
@@ -103,8 +103,31 @@ func classifyAPIError(err error) error {
 		// 409 Conflict = resource already exists. For agents retrying creates, this is success.
 		fmt.Fprintln(os.Stderr, "already exists (no-op)")
 		return nil
-	case strings.Contains(msg, "HTTP 401") || strings.Contains(msg, "HTTP 403"):
-		return authErr(fmt.Errorf("%w\nhint: check your API credentials. Run '{{.Name}}-pp-cli doctor' to verify auth, or set the required environment variable", err))
+	case strings.Contains(msg, "HTTP 401"):
+{{- if eq .Auth.Type "oauth2"}}
+		return authErr(fmt.Errorf("%w\nhint: your token may have expired. Re-run '{{.Name}}-pp-cli auth login' to re-authenticate", err))
+{{- else if eq .Auth.Type "bearer_token"}}
+		return authErr(fmt.Errorf("%w\nhint: check your token. Set it with: {{.Name}}-pp-cli auth set-token <token>"+
+{{- if .Auth.EnvVars}}
+			"\n      or: export {{index .Auth.EnvVars 0}}=your-token"+
+{{- end}}
+			"\n      verify setup: {{.Name}}-pp-cli doctor", err))
+{{- else if eq .Auth.Type "api_key"}}
+		return authErr(fmt.Errorf("%w\nhint: check your API key."+
+{{- if .Auth.EnvVars}}
+			" Set it with: export {{index .Auth.EnvVars 0}}=your-key"+
+{{- end}}
+			"\n      verify setup: {{.Name}}-pp-cli doctor", err))
+{{- else}}
+		return authErr(fmt.Errorf("%w\nhint: check your API credentials. Run '{{.Name}}-pp-cli doctor' to verify auth", err))
+{{- end}}
+	case strings.Contains(msg, "HTTP 403"):
+{{- if eq .Auth.Type "oauth2"}}
+		return authErr(fmt.Errorf("%w\nhint: permission denied. Your token may lack required scopes. Re-run '{{.Name}}-pp-cli auth login' to re-authorize", err))
+{{- else}}
+		return authErr(fmt.Errorf("%w\nhint: permission denied. Your credentials are valid but lack access to this resource."+
+			"\n      Check that your API key has the required permissions", err))
+{{- end}}
 	case strings.Contains(msg, "HTTP 404"):
 		return notFoundErr(fmt.Errorf("%w\nhint: resource not found. Run the 'list' command to see available items", err))
 	case strings.Contains(msg, "HTTP 429"):
diff --git a/internal/generator/templates/readme.md.tmpl b/internal/generator/templates/readme.md.tmpl
index eec58589..f2248df2 100644
--- a/internal/generator/templates/readme.md.tmpl
+++ b/internal/generator/templates/readme.md.tmpl
@@ -22,18 +22,65 @@ Download from [Releases](https://github.com/{{.Owner}}/{{.Name}}-pp-cli/releases
 
 ## Quick Start
 
-```bash
+### 1. Install
+
+See [Install](#install) above.
+{{- if eq .Auth.Type "api_key"}}
+
+### 2. Set Up Credentials
+
+Get your API key from your API provider's developer portal. The key typically looks like a long alphanumeric string.
 {{- if .Auth.EnvVars}}
-# 1. Set your API credentials
+
+```bash
 export {{index .Auth.EnvVars 0}}="your-key-here"
+```
+
+You can also persist this in your config file at `{{.Config.Path}}`.
+{{- end}}
+{{- else if eq .Auth.Type "oauth2"}}
+
+### 2. Authenticate
+
+Authorize via your browser:
+
+```bash
+{{.Name}}-pp-cli auth login
+```
+
+This opens a browser window to complete the OAuth2 flow. Your tokens are stored locally and refreshed automatically.
+{{- else if eq .Auth.Type "bearer_token"}}
+
+### 2. Set Up Credentials
+
+Get your access token from your API provider's developer portal, then store it:
+
+```bash
+{{.Name}}-pp-cli auth set-token YOUR_TOKEN_HERE
+```
+{{- if .Auth.EnvVars}}
+
+Or set it via environment variable:
+
+```bash
+export {{index .Auth.EnvVars 0}}="your-token-here"
+```
+{{- end}}
 {{- end}}
 
-# {{if .Auth.EnvVars}}2{{else}}1{{end}}. Verify everything works
+### {{if and (ne .Auth.Type "") (ne .Auth.Type "none")}}3{{else}}2{{end}}. Verify Setup
+
+```bash
 {{.Name}}-pp-cli doctor
+```
+
+This checks your configuration{{if and (ne .Auth.Type "") (ne .Auth.Type "none")}} and credentials{{end}}.
 
-# 3. Start using it
-{{- range $name, $resource := .Resources}}
-{{$.Name}}-pp-cli {{$name}} --help
+### {{if and (ne .Auth.Type "") (ne .Auth.Type "none")}}4{{else}}3{{end}}. Try Your First Command
+
+```bash
+{{- range $name, $_ := .Resources}}
+{{$.Name}}-pp-cli {{$name}} list
 {{- break}}
 {{- end}}
 ```

← bd844414 docs(cli): add local build and plugin dev instructions to RE  ·  back to Cli Printing Press  ·  feat(skills): read MCP source code during ecosystem absorb ( 3093e11a →