← 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
M internal/generator/templates/doctor.go.tmplM internal/generator/templates/helpers.go.tmplM internal/generator/templates/readme.md.tmpl
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 →