[object Object]

← back to Cli Printing Press

fix(cli): complete bearer refresh and path handling (#584)

ebfa6bf1ff71f7b88d6acdf62627da5c784c29d1 · 2026-05-04 17:55:48 -0700 · Trevin Chow

Files touched

Diff

commit ebfa6bf1ff71f7b88d6acdf62627da5c784c29d1
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Mon May 4 17:55:48 2026 -0700

    fix(cli): complete bearer refresh and path handling (#584)
---
 AGENTS.md                                          |   3 +-
 docs/CATALOG.md                                    |   6 +
 internal/catalog/catalog.go                        |  34 ++++++
 internal/catalog/catalog_test.go                   |  41 +++++++
 internal/cli/catalog.go                            |   3 +
 internal/cli/root.go                               |   6 +
 internal/generator/endpoint_is_write_test.go       |  35 ++++++
 internal/generator/generator.go                    |  33 +++---
 internal/generator/generator_test.go               | 111 ++++++++++++++++++
 .../generator/templates/command_promoted.go.tmpl   |  13 ++-
 internal/generator/templates/config.go.tmpl        |  44 +++++++
 internal/generator/templates/doctor.go.tmpl        | 126 +++++++++++++++++++++
 internal/generator/templates/readme.md.tmpl        |  12 ++
 internal/generator/templates/root.go.tmpl          |   3 +
 internal/generator/templates/skill.md.tmpl         |   9 ++
 internal/spec/spec.go                              |  96 +++++++++++-----
 internal/spec/spec_test.go                         |  92 ++++++++++++++-
 17 files changed, 621 insertions(+), 46 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md
index 7ef91d61..7bd40ed5 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -114,8 +114,9 @@ When adding or editing `catalog/*.yaml`, the entry must pass `internal/catalog`
 - `spec_url`, when present, must use HTTPS.
 - `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.
 - Rebuild the binary after editing; `catalog.FS` is a Go embed.
-See [`docs/CATALOG.md`](docs/CATALOG.md) for validation rationale and the wrapper-only entry shape.
+See [`docs/CATALOG.md`](docs/CATALOG.md) for validation rationale, the wrapper-only entry shape, and bearer-refresh metadata.
 
 ## Testing
 When you change code, check for a `_test.go` file in the same package. If one exists, read it; your change likely requires a test update. If tests fail after your change, investigate whether it is a bug in your code or a stale test; do not just delete the test.
diff --git a/docs/CATALOG.md b/docs/CATALOG.md
index de32ebc9..754ccd7d 100644
--- a/docs/CATALOG.md
+++ b/docs/CATALOG.md
@@ -18,3 +18,9 @@ Wrapper-only entries are the carve-out where `spec_url` and `spec_format` stop b
 - `spec_format` is optional, but if present it must still be one of the allowed formats.
 
 Use the wrapper-only carve-out only when the API is genuinely reached through wrapper libraries rather than a direct spec. If the validator or enum values change, update both this doc and the inline `AGENTS.md` rule together.
+
+## Bearer refresh metadata
+
+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.
diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go
index cee4b78d..b71f6a5d 100644
--- a/internal/catalog/catalog.go
+++ b/internal/catalog/catalog.go
@@ -90,6 +90,15 @@ type WrapperLibrary struct {
 	Notes           string `yaml:"notes,omitempty"`
 }
 
+type BearerRefresh struct {
+	BundleURL string `yaml:"bundle_url,omitempty"`
+	Pattern   string `yaml:"pattern,omitempty"`
+}
+
+func (b BearerRefresh) enabled() bool {
+	return strings.TrimSpace(b.BundleURL) != "" || strings.TrimSpace(b.Pattern) != ""
+}
+
 var validIntegrationModes = map[string]struct{}{
 	"native":      {},
 	"subprocess":  {},
@@ -124,6 +133,9 @@ type Entry struct {
 	// ProxyRoutes maps path prefixes to backend service names for proxy-envelope APIs.
 	// Only relevant when ClientPattern is "proxy-envelope".
 	ProxyRoutes map[string]string `yaml:"proxy_routes,omitempty"`
+	// BearerRefresh describes how a printed CLI can fetch a rotating public
+	// client bearer token from the source site's browser bundle.
+	BearerRefresh BearerRefresh `yaml:"bearer_refresh,omitempty"`
 	// WrapperLibraries lists reverse-engineered community libraries the generator
 	// can use as implementation backing when no official spec exists. When this
 	// list is non-empty, spec_url and spec_format are optional.
@@ -279,10 +291,32 @@ func (e *Entry) Validate() error {
 			return fmt.Errorf("http_transport must be one of: standard, browser-chrome, browser-chrome-h3")
 		}
 	}
+	if err := validateBearerRefresh(e.BearerRefresh); err != nil {
+		return err
+	}
 
 	return nil
 }
 
+func validateBearerRefresh(cfg BearerRefresh) error {
+	if !cfg.enabled() {
+		return nil
+	}
+	if strings.TrimSpace(cfg.BundleURL) == "" {
+		return fmt.Errorf("bearer_refresh.bundle_url is required when bearer_refresh is declared")
+	}
+	if strings.TrimSpace(cfg.Pattern) == "" {
+		return fmt.Errorf("bearer_refresh.pattern is required when bearer_refresh is declared")
+	}
+	if !strings.HasPrefix(cfg.BundleURL, "https://") {
+		return fmt.Errorf(`bearer_refresh.bundle_url must start with "https://"`)
+	}
+	if _, err := regexp.Compile(cfg.Pattern); err != nil {
+		return fmt.Errorf("bearer_refresh.pattern is not a valid regexp: %w", err)
+	}
+	return nil
+}
+
 // PublicCategories returns the sorted list of user-facing categories.
 // It excludes "example", which is internal-only for test fixtures.
 func PublicCategories() []string {
diff --git a/internal/catalog/catalog_test.go b/internal/catalog/catalog_test.go
index 73d8067e..6c0af93a 100644
--- a/internal/catalog/catalog_test.go
+++ b/internal/catalog/catalog_test.go
@@ -133,6 +133,29 @@ func TestValidateEntry(t *testing.T) {
 			},
 			wantErr: "http_transport must be one of",
 		},
+		{
+			name: "bearer refresh missing bundle URL",
+			mutate: func(e *Entry) {
+				e.BearerRefresh.Pattern = `AAAA[^"]+`
+			},
+			wantErr: "bearer_refresh.bundle_url is required",
+		},
+		{
+			name: "bearer refresh non https bundle URL",
+			mutate: func(e *Entry) {
+				e.BearerRefresh.BundleURL = "http://example.com/main.js"
+				e.BearerRefresh.Pattern = `AAAA[^"]+`
+			},
+			wantErr: `bearer_refresh.bundle_url must start with "https://"`,
+		},
+		{
+			name: "bearer refresh invalid pattern",
+			mutate: func(e *Entry) {
+				e.BearerRefresh.BundleURL = "https://example.com/main.js"
+				e.BearerRefresh.Pattern = `[`
+			},
+			wantErr: "bearer_refresh.pattern is not a valid regexp",
+		},
 	}
 
 	for _, tt := range tests {
@@ -269,6 +292,24 @@ func TestOptionalFieldsOmittedValid(t *testing.T) {
 	assert.Nil(t, entry.AuthRequired)
 	assert.Empty(t, entry.ClientPattern)
 	assert.Empty(t, entry.HTTPTransport)
+	assert.Empty(t, entry.BearerRefresh.BundleURL)
+}
+
+func TestBearerRefreshValid(t *testing.T) {
+	entry := Entry{
+		Name:        "browser-api",
+		DisplayName: "Browser API",
+		Description: "A browser-facing API with a rotating public bearer",
+		Category:    "social-and-messaging",
+		SpecURL:     "https://example.com/openapi.yaml",
+		SpecFormat:  "yaml",
+		Tier:        "community",
+		BearerRefresh: BearerRefresh{
+			BundleURL: "https://example.com/main.js",
+			Pattern:   `"(AAAAAAAA[^"]+)"`,
+		},
+	}
+	require.NoError(t, entry.Validate())
 }
 
 func TestWrapperOnlyEntryValid(t *testing.T) {
diff --git a/internal/cli/catalog.go b/internal/cli/catalog.go
index ad02bd7b..817ac132 100644
--- a/internal/cli/catalog.go
+++ b/internal/cli/catalog.go
@@ -132,6 +132,9 @@ func newCatalogShowCmd() *cobra.Command {
 			if entry.AuthRequired != nil {
 				fmt.Printf("Auth Required:  %v\n", *entry.AuthRequired)
 			}
+			if entry.BearerRefresh.BundleURL != "" {
+				fmt.Printf("Bearer Refresh: %s\n", entry.BearerRefresh.BundleURL)
+			}
 			if entry.Notes != "" {
 				fmt.Printf("Notes:          %s\n", entry.Notes)
 			}
diff --git a/internal/cli/root.go b/internal/cli/root.go
index cfc26a65..50d66bd8 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -1102,4 +1102,10 @@ func enrichSpecFromCatalog(apiSpec *spec.APISpec) {
 	if entry.HTTPTransport != "" && apiSpec.HTTPTransport == "" {
 		apiSpec.HTTPTransport = entry.HTTPTransport
 	}
+	if entry.BearerRefresh.BundleURL != "" && apiSpec.BearerRefresh.BundleURL == "" {
+		apiSpec.BearerRefresh.BundleURL = entry.BearerRefresh.BundleURL
+	}
+	if entry.BearerRefresh.Pattern != "" && apiSpec.BearerRefresh.Pattern == "" {
+		apiSpec.BearerRefresh.Pattern = entry.BearerRefresh.Pattern
+	}
 }
diff --git a/internal/generator/endpoint_is_write_test.go b/internal/generator/endpoint_is_write_test.go
index ed02dd01..109949d9 100644
--- a/internal/generator/endpoint_is_write_test.go
+++ b/internal/generator/endpoint_is_write_test.go
@@ -331,6 +331,41 @@ func TestPromotedCommandVerbBranching(t *testing.T) {
 	}
 }
 
+func TestPromotedCommandSubstitutesFlagPathParams(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("promoted-path-param")
+	apiSpec.Resources = map[string]spec.Resource{
+		"followers": {
+			Description: "Followers",
+			Endpoints: map[string]spec.Endpoint{
+				"list": {
+					Method:      "GET",
+					Path:        "/users/{userId}/followers",
+					Description: "List followers",
+					Params: []spec.Param{
+						{Name: "userId", Type: "string", PathParam: true, Default: "me", Description: "User ID"},
+						{Name: "limit", Type: "int", Description: "Page size"},
+					},
+				},
+			},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), "promoted-path-param-pp-cli")
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	src := readPromotedCommandFile(t, outputDir)
+	assert.Contains(t, src, `path = replacePathParam(path, "userId", fmt.Sprintf("%v", flagUserId))`,
+		"promoted command must substitute flag-backed path params before making the request")
+	assert.Contains(t, src, `params["limit"] = fmt.Sprintf("%v", flagLimit)`,
+		"ordinary non-positional flags still belong in query params")
+	assert.NotContains(t, src, `params["userId"]`,
+		"path params must not also be sent as query params")
+	assert.NotContains(t, src, `"userId": fmt.Sprintf("%v", flagUserID)`,
+		"path params must not be passed to paginated query maps")
+}
+
 // readPromotedCommandFile finds the single promoted_*.go file the generator
 // emits for a fixture spec with one resource. Naming varies (resource name
 // vs. kebabed endpoint name vs. camelCase), so the lookup glob-matches.
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index d8ae7beb..58fb758e 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -543,8 +543,10 @@ func computeHelperFlags(s *spec.APISpec) HelperFlags {
 			}
 			positionalCount := 0
 			for _, p := range e.Params {
-				if p.Positional {
+				if p.Positional || p.PathParam {
 					flags.HasPathParams = true
+				}
+				if p.Positional {
 					positionalCount++
 				}
 			}
@@ -562,8 +564,10 @@ func computeHelperFlags(s *spec.APISpec) HelperFlags {
 				}
 				positionalCount := 0
 				for _, p := range e.Params {
-					if p.Positional {
+					if p.Positional || p.PathParam {
 						flags.HasPathParams = true
+					}
+					if p.Positional {
 						positionalCount++
 					}
 				}
@@ -3136,18 +3140,19 @@ type PromotedCommand struct {
 // builtinCommands lists command names that must not be used for promoted commands
 // because they collide with the CLI's own built-in commands.
 var builtinCommands = map[string]bool{
-	"version":    true,
-	"help":       true,
-	"doctor":     true,
-	"auth":       true,
-	"sync":       true,
-	"search":     true,
-	"export":     true,
-	"import":     true,
-	"completion": true,
-	"workflow":   true,
-	"tail":       true,
-	"analytics":  true,
+	"version":        true,
+	"help":           true,
+	"doctor":         true,
+	"auth":           true,
+	"sync":           true,
+	"search":         true,
+	"export":         true,
+	"import":         true,
+	"completion":     true,
+	"refresh-bearer": true,
+	"workflow":       true,
+	"tail":           true,
+	"analytics":      true,
 }
 
 // buildPromotedCommands scans spec resources and returns safe top-level shortcuts.
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index b3efd4b8..ac27bbfa 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -567,6 +567,112 @@ func TestGeneratedOutput_READMEBearerTokenMCPSetup(t *testing.T) {
 	assert.NotContains(t, content, "bearer-pp-cli auth login\n\nclaude mcp add bearer bearer-pp-mcp")
 }
 
+func TestGenerateBearerRefreshDoctorCommand(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := &spec.APISpec{
+		Name:    "refreshbearer",
+		Version: "0.1.0",
+		BaseURL: "https://api.example.com",
+		BearerRefresh: spec.BearerRefreshConfig{
+			BundleURL: "https://cdn.example.com/main.js",
+			Pattern:   `"(AAAAAAAA[^"]+)"`,
+		},
+		Auth: spec.AuthConfig{
+			Type:    "bearer_token",
+			Header:  "Authorization",
+			Format:  "Bearer {token}",
+			EnvVars: []string{"REFRESHBEARER_PUBLIC_BEARER"},
+		},
+		Config: spec.ConfigSpec{
+			Format: "toml",
+			Path:   "~/.config/refreshbearer-pp-cli/config.toml",
+		},
+		Resources: map[string]spec.Resource{
+			"items": {
+				Description: "Manage items",
+				Endpoints: map[string]spec.Endpoint{
+					"list": {
+						Method:      "GET",
+						Path:        "/items",
+						Description: "List items",
+					},
+				},
+			},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	doctorGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "doctor.go"))
+	require.NoError(t, err)
+	doctor := string(doctorGo)
+	assert.Contains(t, doctor, `"refresh-bearer"`)
+	assert.Contains(t, doctor, `"https://cdn.example.com/main.js"`)
+	assert.Contains(t, doctor, "func newRefreshBearerCmd(")
+	assert.Contains(t, doctor, "func runBearerRefresh(")
+	assert.Contains(t, doctor, "func extractBearerToken(")
+
+	rootGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "root.go"))
+	require.NoError(t, err)
+	assert.Contains(t, string(rootGo), "rootCmd.AddCommand(newRefreshBearerCmd(flags))",
+		"bearer refresh must be a non-framework command so the Cobra-tree MCP mirror exposes it")
+
+	configGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "config", "config.go"))
+	require.NoError(t, err)
+	configContent := string(configGo)
+	assert.Contains(t, configContent, "BearerTokenRefreshedAt time.Time")
+	assert.Contains(t, configContent, "func (c *Config) SaveBearerToken(")
+	assert.Contains(t, configContent, `c.AuthSource = "bearer_refresh"`)
+	assert.Contains(t, configContent, "c.RefreshbearerPublicBearer = \"\"")
+
+	readme, err := os.ReadFile(filepath.Join(outputDir, "README.md"))
+	require.NoError(t, err)
+	assert.Contains(t, string(readme), "refreshbearer-pp-cli doctor --refresh-bearer")
+	assert.Contains(t, string(readme), "refreshbearer-pp-cli refresh-bearer")
+
+	configTest := `package config
+
+import (
+	"path/filepath"
+	"testing"
+	"time"
+)
+
+func TestBearerRefreshSavedTokenWinsOverEnvBackedField(t *testing.T) {
+	cfg := &Config{AccessToken: "fresh-token", RefreshbearerPublicBearer: "stale-token"}
+	if got := cfg.AuthHeader(); got != "Bearer fresh-token" {
+		t.Fatalf("AuthHeader() = %q, want refreshed token", got)
+	}
+	if cfg.AuthSource != "bearer_refresh" {
+		t.Fatalf("AuthSource = %q, want bearer_refresh", cfg.AuthSource)
+	}
+}
+
+func TestSaveBearerTokenClearsEnvBackedField(t *testing.T) {
+	cfg := &Config{
+		Path:                filepath.Join(t.TempDir(), "config.toml"),
+		RefreshbearerPublicBearer: "stale-token",
+	}
+	if err := cfg.SaveBearerToken("fresh-token", time.Unix(123, 0)); err != nil {
+		t.Fatal(err)
+	}
+	if cfg.RefreshbearerPublicBearer != "" {
+		t.Fatalf("RefreshbearerPublicBearer = %q, want cleared", cfg.RefreshbearerPublicBearer)
+	}
+	if got := cfg.AuthHeader(); got != "Bearer fresh-token" {
+		t.Fatalf("AuthHeader() = %q, want refreshed token", got)
+	}
+}
+`
+	require.NoError(t, os.WriteFile(filepath.Join(outputDir, "internal", "config", "bearer_refresh_test.go"), []byte(configTest), 0o644))
+
+	runGoCommand(t, outputDir, "mod", "tidy")
+	runGoCommand(t, outputDir, "test", "./internal/config", "-run", "TestBearerRefresh")
+	runGoCommand(t, outputDir, "build", "./...")
+}
+
 func TestGenerateOAuth2ClientCredentialsAuthTemplate(t *testing.T) {
 	t.Parallel()
 
@@ -5003,6 +5109,11 @@ func TestGenerateOperationRoutingPathParamDefault(t *testing.T) {
 		`path = replacePathParam(path, "pathQueryId", fmt.Sprintf("%v", flagPathQueryId))`,
 		"generated command must substitute the path template before calling the API")
 
+	helpersGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "helpers.go"))
+	require.NoError(t, err)
+	assert.Contains(t, string(helpersGo), "func replacePathParam(path, name, value string) string",
+		"helpers.go must emit replacePathParam for flag-shaped path params")
+
 	mcpGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "mcp", "tools.go"))
 	require.NoError(t, err)
 	assert.Regexp(t,
diff --git a/internal/generator/templates/command_promoted.go.tmpl b/internal/generator/templates/command_promoted.go.tmpl
index 6127d5ef..b6a521f5 100644
--- a/internal/generator/templates/command_promoted.go.tmpl
+++ b/internal/generator/templates/command_promoted.go.tmpl
@@ -109,6 +109,11 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
 {{- end}}
 {{- end}}
 {{- end}}
+{{- range .Endpoint.Params}}
+{{- if and .PathParam (not .Positional)}}
+			path = replacePathParam(path, "{{.Name}}", fmt.Sprintf("%v", flag{{camel (paramIdent .)}}))
+{{- end}}
+{{- end}}
 
 {{- if .Endpoint.UsesHTMLResponse}}
 			htmlRequestParams := map[string]string{}
@@ -116,7 +121,7 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
 {{- if and .Positional (not (pathContainsParam $.Endpoint.Path .Name))}}
 			htmlRequestParams["{{.Name}}"] = args[{{$i}}]
 {{- end}}
-{{- if not .Positional}}
+{{- if and (not .Positional) (not .PathParam)}}
 			if flag{{camel (paramIdent .)}} != {{zeroValForParam .Name .Type}} {
 				htmlRequestParams["{{.Name}}"] = fmt.Sprintf("%v", flag{{camel (paramIdent .)}})
 			}
@@ -131,7 +136,7 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
 {{- if and .Positional (not (pathContainsParam $.Endpoint.Path .Name))}}
 				"{{.Name}}": args[{{$i}}],
 {{- end}}
-{{- if not .Positional}}
+{{- if and (not .Positional) (not .PathParam)}}
 				"{{.Name}}": fmt.Sprintf("%v", flag{{camel (paramIdent .)}}),
 {{- end}}
 {{- end}}
@@ -142,7 +147,7 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
 {{- if and .Positional (not (pathContainsParam $.Endpoint.Path .Name))}}
 				"{{.Name}}": args[{{$i}}],
 {{- end}}
-{{- if not .Positional}}
+{{- if and (not .Positional) (not .PathParam)}}
 				"{{.Name}}": fmt.Sprintf("%v", flag{{camel (paramIdent .)}}),
 {{- end}}
 {{- end}}
@@ -155,7 +160,7 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
 {{- if and .Positional (not (pathContainsParam $.Endpoint.Path .Name))}}
 			params["{{.Name}}"] = args[{{$i}}]
 {{- end}}
-{{- if not .Positional}}
+{{- if and (not .Positional) (not .PathParam)}}
 			if flag{{camel (paramIdent .)}} != {{zeroValForParam .Name .Type}} {
 				params["{{.Name}}"] = fmt.Sprintf("%v", flag{{camel (paramIdent .)}})
 			}
diff --git a/internal/generator/templates/config.go.tmpl b/internal/generator/templates/config.go.tmpl
index 1dfcd814..dd55f37d 100644
--- a/internal/generator/templates/config.go.tmpl
+++ b/internal/generator/templates/config.go.tmpl
@@ -25,6 +25,9 @@ type Config struct {
 	AccessToken    string `{{configTag .Config.Format}}:"access_token"`
 	RefreshToken   string `{{configTag .Config.Format}}:"refresh_token"`
 	TokenExpiry    time.Time `{{configTag .Config.Format}}:"token_expiry"`
+{{- if .BearerRefresh.Enabled}}
+	BearerTokenRefreshedAt time.Time `{{configTag .Config.Format}}:"bearer_token_refreshed_at"`
+{{- end}}
 	ClientID       string `{{configTag .Config.Format}}:"client_id"`
 	ClientSecret   string `{{configTag .Config.Format}}:"client_secret"`
 	Path           string `{{configTag .Config.Format}}:"-"`
@@ -112,9 +115,15 @@ func Load(configPath string) (*Config, error) {
 }
 
 func (c *Config) AuthHeader() string {
+{{- if and .BearerRefresh.Enabled (eq .Auth.Type "bearer_token") (ne .Auth.EffectiveOAuth2Grant "client_credentials")}}
+	if c.AuthHeaderVal != "" && c.AccessToken == "" {
+		return c.AuthHeaderVal
+	}
+{{- else}}
 	if c.AuthHeaderVal != "" {
 		return c.AuthHeaderVal
 	}
+{{- end}}
 {{- if eq .Auth.Type "api_key"}}
 	{{- if gt (len .Auth.EnvVars) 0}}
 	token := c.{{resolveEnvVarField (index .Auth.EnvVars 0)}}
@@ -172,8 +181,21 @@ func (c *Config) AuthHeader() string {
 	}
 	{{- end}}
 	return ""
+{{- else}}
+{{- if .BearerRefresh.Enabled}}
+	// Refreshed public bearer tokens must win over stale generated env-var
+	// fields so a successful refresh changes subsequent API calls.
+	if c.AccessToken != "" {
+		c.AuthSource = "bearer_refresh"
+		{{- if .Auth.Format}}
+		return applyAuthFormat("{{.Auth.Format}}", map[string]string{"access_token": c.AccessToken, "token": c.AccessToken})
+		{{- else}}
+		return "Bearer " + c.AccessToken
+		{{- end}}
+	}
 {{- else}}
 	// Env-var token wins over file-stored AccessToken (env > config convention).
+{{- end}}
 	{{- if gt (len .Auth.EnvVars) 0}}
 	if c.{{resolveEnvVarField (index .Auth.EnvVars 0)}} != "" {
 		c.AuthSource = "env:{{index .Auth.EnvVars 0}}"
@@ -188,6 +210,7 @@ func (c *Config) AuthHeader() string {
 		{{- end}}
 	}
 	{{- end}}
+{{- if not .BearerRefresh.Enabled}}
 	if c.AccessToken != "" {
 		c.AuthSource = "oauth2"
 		{{- if .Auth.Format}}
@@ -196,6 +219,7 @@ func (c *Config) AuthHeader() string {
 		return "Bearer " + c.AccessToken
 		{{- end}}
 	}
+{{- end}}
 	return ""
 {{- end}}
 {{- else if eq .Auth.Type "cookie"}}
@@ -251,10 +275,30 @@ func (c *Config) SaveTokens(clientID, clientSecret, accessToken, refreshToken st
 	return c.save()
 }
 
+{{- if .BearerRefresh.Enabled}}
+func (c *Config) SaveBearerToken(accessToken string, refreshedAt time.Time) error {
+	c.AuthHeaderVal = ""
+	c.AccessToken = accessToken
+	c.RefreshToken = ""
+	c.TokenExpiry = time.Time{}
+{{- range .Auth.EnvVars}}
+{{- if not (envVarIsBuiltinField .)}}
+	c.{{envVarField .}} = ""
+{{- end}}
+{{- end}}
+	c.BearerTokenRefreshedAt = refreshedAt
+	return c.save()
+}
+
+{{- end}}
+
 func (c *Config) ClearTokens() error {
 	c.AccessToken = ""
 	c.RefreshToken = ""
 	c.TokenExpiry = time.Time{}
+{{- if .BearerRefresh.Enabled}}
+	c.BearerTokenRefreshedAt = time.Time{}
+{{- end}}
 	return c.save()
 }
 
diff --git a/internal/generator/templates/doctor.go.tmpl b/internal/generator/templates/doctor.go.tmpl
index 1bb0107a..6efad5c9 100644
--- a/internal/generator/templates/doctor.go.tmpl
+++ b/internal/generator/templates/doctor.go.tmpl
@@ -19,6 +19,11 @@ import (
 	"fmt"
 {{- if .HasStore}}
 	"io"
+{{- else if .BearerRefresh.Enabled}}
+	"io"
+{{- end}}
+{{- if .BearerRefresh.Enabled}}
+	"net/http"
 {{- end}}
 {{- if or .Auth.EnvVars .HasStore .Auth.RequiresBrowserSession .HasTierRouting}}
 	"os"
@@ -28,10 +33,15 @@ import (
 {{- end}}
 {{- if .Auth.RequiresBrowserSession}}
 	"path/filepath"
+{{- end}}
+{{- if .BearerRefresh.Enabled}}
+	"regexp"
 {{- end}}
 	"strings"
 {{- if .HasStore}}
 	"time"
+{{- else if .BearerRefresh.Enabled}}
+	"time"
 {{- end}}
 
 	"{{modulePath}}/internal/client"
@@ -88,6 +98,11 @@ func looksLikeDoctorInterstitial(body []byte) string {
 
 func newDoctorCmd(flags *rootFlags) *cobra.Command {
 	var failOn string
+{{- if .BearerRefresh.Enabled}}
+	var refreshBearer bool
+	var refreshBearerBundleURL string
+	var refreshBearerPattern string
+{{- end}}
 	cmd := &cobra.Command{
 		Use:   "doctor",
 		Short: "Check CLI health",
@@ -106,6 +121,14 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 				report["config_path"] = cfg.Path
 				report["base_url"] = cfg.BaseURL
 			}
+{{- if .BearerRefresh.Enabled}}
+			if refreshBearer {
+				if cfg == nil {
+					return configErr(fmt.Errorf("config unavailable"))
+				}
+				return runBearerRefresh(cmd, cfg, refreshBearerBundleURL, refreshBearerPattern, flags)
+			}
+{{- end}}
 
 			// Check auth
 {{- if or (eq .Auth.Type "") (eq .Auth.Type "none")}}
@@ -422,9 +445,112 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 		},
 	}
 	cmd.Flags().StringVar(&failOn, "fail-on", "", "Exit non-zero when a health level is reached: stale, error. Default is never.")
+{{- if .BearerRefresh.Enabled}}
+	cmd.Flags().BoolVar(&refreshBearer, "refresh-bearer", false, "Fetch and store the current public bearer token")
+	cmd.Flags().StringVar(&refreshBearerBundleURL, "bearer-bundle-url", {{printf "%q" .BearerRefresh.BundleURL}}, "Override the browser bundle URL used by --refresh-bearer")
+	cmd.Flags().StringVar(&refreshBearerPattern, "bearer-pattern", {{printf "%q" .BearerRefresh.Pattern}}, "Override the regexp used by --refresh-bearer")
+{{- end}}
 	return cmd
 }
 
+{{- if .BearerRefresh.Enabled}}
+func newRefreshBearerCmd(flags *rootFlags) *cobra.Command {
+	bundleURL := {{printf "%q" .BearerRefresh.BundleURL}}
+	pattern := {{printf "%q" .BearerRefresh.Pattern}}
+	cmd := &cobra.Command{
+		Use:   "refresh-bearer",
+		Short: "Fetch and store the current public bearer token",
+		Args:  cobra.NoArgs,
+		Example: `  {{.Name}}-pp-cli refresh-bearer
+  {{.Name}}-pp-cli refresh-bearer --json`,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			cfg, err := config.Load(flags.configPath)
+			if err != nil {
+				return configErr(err)
+			}
+			return runBearerRefresh(cmd, cfg, bundleURL, pattern, flags)
+		},
+	}
+	cmd.Flags().StringVar(&bundleURL, "bearer-bundle-url", bundleURL, "Override the browser bundle URL used to refresh the bearer token")
+	cmd.Flags().StringVar(&pattern, "bearer-pattern", pattern, "Override the regexp used to refresh the bearer token")
+	return cmd
+}
+
+func runBearerRefresh(cmd *cobra.Command, cfg *config.Config, bundleURL, pattern string, flags *rootFlags) error {
+	if bundleURL == "" {
+		return configErr(fmt.Errorf("bearer refresh bundle URL is not configured"))
+	}
+	if pattern == "" {
+		return configErr(fmt.Errorf("bearer refresh pattern is not configured"))
+	}
+	req, err := http.NewRequestWithContext(cmd.Context(), http.MethodGet, bundleURL, nil)
+	if err != nil {
+		return configErr(fmt.Errorf("preparing bearer refresh request: %w", err))
+	}
+	req.Header.Set("User-Agent", "{{.Name}}-pp-cli")
+
+	httpClient := &http.Client{Timeout: 30 * time.Second}
+	resp, err := httpClient.Do(req)
+	if err != nil {
+		return apiErr(fmt.Errorf("fetching bearer bundle: %w", err))
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+		return apiErr(fmt.Errorf("fetching bearer bundle: HTTP %d", resp.StatusCode))
+	}
+
+	body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
+	if err != nil {
+		return apiErr(fmt.Errorf("reading bearer bundle: %w", err))
+	}
+	token, err := extractBearerToken(body, pattern)
+	if err != nil {
+		return apiErr(err)
+	}
+	if err := cfg.SaveBearerToken(token, time.Now().UTC()); err != nil {
+		return configErr(fmt.Errorf("saving bearer token: %w", err))
+	}
+
+	out := map[string]any{
+		"refreshed":   true,
+		"config_path": cfg.Path,
+		"source":      bundleURL,
+	}
+	if flags.asJSON {
+		return printJSONFiltered(cmd.OutOrStdout(), out, flags)
+	}
+	fmt.Fprintf(cmd.OutOrStdout(), "Bearer token refreshed and saved to %s\n", cfg.Path)
+	return nil
+}
+
+func extractBearerToken(body []byte, pattern string) (string, error) {
+	re, err := regexp.Compile(pattern)
+	if err != nil {
+		return "", fmt.Errorf("bearer refresh pattern is not a valid regexp: %w", err)
+	}
+	matches := re.FindSubmatch(body)
+	if len(matches) == 0 {
+		return "", fmt.Errorf("bearer token not found in bundle")
+	}
+	token := ""
+	for _, match := range matches[1:] {
+		if len(match) > 0 {
+			token = string(match)
+			break
+		}
+	}
+	if token == "" {
+		token = string(matches[0])
+	}
+	token = strings.Trim(token, " \t\r\n\"'`")
+	if token == "" {
+		return "", fmt.Errorf("bearer token match was empty")
+	}
+	return token, nil
+}
+
+{{- end}}
+
 {{- if .Auth.RequiresBrowserSession}}
 type doctorBrowserSessionProof struct {
 	APIName               string `json:"api_name"`
diff --git a/internal/generator/templates/readme.md.tmpl b/internal/generator/templates/readme.md.tmpl
index 5e344f08..2d43cd24 100644
--- a/internal/generator/templates/readme.md.tmpl
+++ b/internal/generator/templates/readme.md.tmpl
@@ -97,6 +97,15 @@ 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 .BearerRefresh.Enabled}}
+
+If commands start returning auth errors after working for a while, refresh the public client bearer token from the live web bundle:
+
+```bash
+{{.Name}}-pp-cli doctor --refresh-bearer
+{{.Name}}-pp-cli refresh-bearer
+```
+{{- end}}
 {{- if .Auth.EnvVars}}
 
 Or set it via environment variable:
@@ -439,6 +448,9 @@ Environment variables:
 {{- if .HasAuth}}
 **Authentication errors (exit code 4)**
 - Run `{{.Name}}-pp-cli doctor` to check credentials
+{{- if .BearerRefresh.Enabled}}
+- Run `{{.Name}}-pp-cli refresh-bearer` if a long-working public bearer token may have rotated
+{{- end}}
 {{- if .Auth.EnvVars}}
 - Verify the environment variable is set: `echo ${{index .Auth.EnvVars 0}}`
 {{- end}}
diff --git a/internal/generator/templates/root.go.tmpl b/internal/generator/templates/root.go.tmpl
index cb00a9bb..ca935e29 100644
--- a/internal/generator/templates/root.go.tmpl
+++ b/internal/generator/templates/root.go.tmpl
@@ -249,6 +249,9 @@ Run '{{.Name}}-pp-cli doctor' to verify auth and connectivity.{{backtick}},
 {{- end}}
 {{- end}}
 	rootCmd.AddCommand(newDoctorCmd(flags))
+{{- if .BearerRefresh.Enabled}}
+	rootCmd.AddCommand(newRefreshBearerCmd(flags))
+{{- end}}
 {{- if .HasAuthCommand}}
 	rootCmd.AddCommand(newAuthCmd(flags))
 {{- end}}
diff --git a/internal/generator/templates/skill.md.tmpl b/internal/generator/templates/skill.md.tmpl
index e8418a9a..07b95437 100644
--- a/internal/generator/templates/skill.md.tmpl
+++ b/internal/generator/templates/skill.md.tmpl
@@ -186,6 +186,15 @@ Store your access token:
 ```bash
 {{.Name}}-pp-cli auth set-token YOUR_TOKEN_HERE
 ```
+{{- if .BearerRefresh.Enabled}}
+
+If authenticated commands start returning token errors after previously working, refresh the public client bearer token:
+
+```bash
+{{.Name}}-pp-cli doctor --refresh-bearer
+{{.Name}}-pp-cli refresh-bearer
+```
+{{- end}}
 {{- if .Auth.EnvVars}}
 
 Or set `{{index .Auth.EnvVars 0}}` as an environment variable.
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 649044c5..ab718ed1 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -105,6 +105,7 @@ type APISpec struct {
 	ClientPattern        string              `yaml:"client_pattern,omitempty" json:"client_pattern,omitempty"` // rest (default), proxy-envelope — affects generated HTTP client
 	HTTPTransport        string              `yaml:"http_transport,omitempty" json:"http_transport,omitempty"` // standard (default for official APIs), browser-chrome, or browser-chrome-h3
 	ProxyRoutes          map[string]string   `yaml:"proxy_routes,omitempty" json:"proxy_routes,omitempty"`     // path prefix → service name for proxy-envelope routing
+	BearerRefresh        BearerRefreshConfig `yaml:"bearer_refresh,omitempty" json:"bearer_refresh,omitzero"`  // live-source metadata for rotating public client bearer tokens
 	WebsiteURL           string              `yaml:"website_url,omitempty" json:"website_url,omitempty"`       // product/company website (not the API base URL)
 	Category             string              `yaml:"category,omitempty" json:"category,omitempty"`             // catalog category (e.g., productivity, developer-tools) — used for library install path
 	Auth                 AuthConfig          `yaml:"auth" json:"auth"`
@@ -393,6 +394,15 @@ type RequiredHeader struct {
 	Value string `yaml:"value" json:"value"`
 }
 
+type BearerRefreshConfig struct {
+	BundleURL string `yaml:"bundle_url,omitempty" json:"bundle_url,omitempty"`
+	Pattern   string `yaml:"pattern,omitempty" json:"pattern,omitempty"`
+}
+
+func (c BearerRefreshConfig) Enabled() bool {
+	return strings.TrimSpace(c.BundleURL) != "" || strings.TrimSpace(c.Pattern) != ""
+}
+
 type AuthConfig struct {
 	Type             string   `yaml:"type" json:"type"` // api_key, oauth2, bearer_token, cookie, composed, session_handshake, none
 	Header           string   `yaml:"header" json:"header"`
@@ -888,6 +898,7 @@ var ReservedCLIResourceNames = map[string]struct{}{
 	"html_extract":     {},
 	"import":           {},
 	"profile":          {},
+	"refresh_bearer":   {},
 	"root":             {},
 	"search":           {},
 	"share_commands":   {},
@@ -906,32 +917,33 @@ var ReservedCLIResourceNames = map[string]struct{}{
 // cobra-Use shadowing (kebab-case). Hand-maintained; drift invariants
 // enforced by tests in reserved_drift_test.go.
 var ReservedCobraUseNames = map[string]struct{}{
-	"about":         {},
-	"agent-context": {},
-	"analytics":     {},
-	"api":           {},
-	"auth":          {},
-	"completion":    {},
-	"doctor":        {},
-	"export":        {},
-	"feedback":      {},
-	"health":        {},
-	"help":          {},
-	"import":        {},
-	"jobs":          {},
-	"load":          {},
-	"orphans":       {},
-	"profile":       {},
-	"search":        {},
-	"share":         {},
-	"similar":       {},
-	"sql":           {},
-	"stale":         {},
-	"sync":          {},
-	"tail":          {},
-	"version":       {},
-	"which":         {},
-	"workflow":      {},
+	"about":          {},
+	"agent-context":  {},
+	"analytics":      {},
+	"api":            {},
+	"auth":           {},
+	"completion":     {},
+	"doctor":         {},
+	"export":         {},
+	"feedback":       {},
+	"health":         {},
+	"help":           {},
+	"import":         {},
+	"jobs":           {},
+	"load":           {},
+	"orphans":        {},
+	"profile":        {},
+	"refresh-bearer": {},
+	"search":         {},
+	"share":          {},
+	"similar":        {},
+	"sql":            {},
+	"stale":          {},
+	"sync":           {},
+	"tail":           {},
+	"version":        {},
+	"which":          {},
+	"workflow":       {},
 }
 
 // validateReservedNames rejects specs whose top-level resource names would
@@ -1227,6 +1239,9 @@ func (s *APISpec) Validate() error {
 	if err := validateThrottling(s.Throttling); err != nil {
 		return err
 	}
+	if err := validateBearerRefresh(s); err != nil {
+		return err
+	}
 	if err := validateOAuth2Grant(s.Auth); err != nil {
 		return err
 	}
@@ -1274,6 +1289,35 @@ func (s *APISpec) Validate() error {
 	return nil
 }
 
+func validateBearerRefresh(s *APISpec) error {
+	cfg := s.BearerRefresh
+	if !cfg.Enabled() {
+		return nil
+	}
+	if s.Auth.Type != "bearer_token" {
+		return fmt.Errorf(`bearer_refresh requires auth.type "bearer_token"`)
+	}
+	if s.Auth.OAuth2Grant == OAuth2GrantClientCredentials {
+		return fmt.Errorf("bearer_refresh is incompatible with auth.oauth2_grant %q", OAuth2GrantClientCredentials)
+	}
+	if s.HasTierRouting() {
+		return fmt.Errorf("bearer_refresh is incompatible with tier_routing auth")
+	}
+	if strings.TrimSpace(cfg.BundleURL) == "" {
+		return fmt.Errorf("bearer_refresh.bundle_url is required when bearer_refresh is declared")
+	}
+	if strings.TrimSpace(cfg.Pattern) == "" {
+		return fmt.Errorf("bearer_refresh.pattern is required when bearer_refresh is declared")
+	}
+	if !strings.HasPrefix(cfg.BundleURL, "https://") {
+		return fmt.Errorf(`bearer_refresh.bundle_url must start with "https://"`)
+	}
+	if _, err := regexp.Compile(cfg.Pattern); err != nil {
+		return fmt.Errorf("bearer_refresh.pattern is not a valid regexp: %w", err)
+	}
+	return nil
+}
+
 func validateTierRouting(s *APISpec) error {
 	if s == nil || !s.HasTierRouting() {
 		return nil
diff --git a/internal/spec/spec_test.go b/internal/spec/spec_test.go
index 2022cee4..c97c34cc 100644
--- a/internal/spec/spec_test.go
+++ b/internal/spec/spec_test.go
@@ -97,6 +97,96 @@ func TestThrottleShapeShopifyValue(t *testing.T) {
 		"changing this value requires updating graphql_client.go.tmpl's gate to match")
 }
 
+func TestBearerRefreshValidate(t *testing.T) {
+	base := APISpec{
+		Name:    "browser-api",
+		BaseURL: "https://api.example.com",
+		Auth:    AuthConfig{Type: "bearer_token"},
+		Resources: map[string]Resource{
+			"items": {Endpoints: map[string]Endpoint{"list": {Method: "GET", Path: "/items"}}},
+		},
+	}
+
+	valid := base
+	valid.BearerRefresh = BearerRefreshConfig{
+		BundleURL: "https://example.com/main.js",
+		Pattern:   `"(AAAAAAAA[^"]+)"`,
+	}
+	require.NoError(t, valid.Validate())
+
+	missingPattern := base
+	missingPattern.BearerRefresh = BearerRefreshConfig{BundleURL: "https://example.com/main.js"}
+	require.ErrorContains(t, missingPattern.Validate(), "bearer_refresh.pattern is required")
+
+	invalidURL := base
+	invalidURL.BearerRefresh = BearerRefreshConfig{BundleURL: "http://example.com/main.js", Pattern: `AAAA`}
+	require.ErrorContains(t, invalidURL.Validate(), `bearer_refresh.bundle_url must start with "https://"`)
+
+	invalidPattern := base
+	invalidPattern.BearerRefresh = BearerRefreshConfig{BundleURL: "https://example.com/main.js", Pattern: `[`}
+	require.ErrorContains(t, invalidPattern.Validate(), "bearer_refresh.pattern is not a valid regexp")
+
+	for _, tc := range []struct {
+		name    string
+		mutate  func(*APISpec)
+		wantErr string
+	}{
+		{
+			name: "api key auth",
+			mutate: func(s *APISpec) {
+				s.Auth.Type = "api_key"
+			},
+			wantErr: `bearer_refresh requires auth.type "bearer_token"`,
+		},
+		{
+			name: "no auth",
+			mutate: func(s *APISpec) {
+				s.Auth.Type = "none"
+			},
+			wantErr: `bearer_refresh requires auth.type "bearer_token"`,
+		},
+		{
+			name: "cookie auth",
+			mutate: func(s *APISpec) {
+				s.Auth.Type = "cookie"
+			},
+			wantErr: `bearer_refresh requires auth.type "bearer_token"`,
+		},
+		{
+			name: "composed auth",
+			mutate: func(s *APISpec) {
+				s.Auth.Type = "composed"
+			},
+			wantErr: `bearer_refresh requires auth.type "bearer_token"`,
+		},
+		{
+			name: "client credentials",
+			mutate: func(s *APISpec) {
+				s.Auth.OAuth2Grant = OAuth2GrantClientCredentials
+			},
+			wantErr: `bearer_refresh is incompatible with auth.oauth2_grant "client_credentials"`,
+		},
+		{
+			name: "tier routing",
+			mutate: func(s *APISpec) {
+				s.TierRouting = TierRoutingConfig{
+					DefaultTier: "free",
+					Tiers: map[string]TierConfig{
+						"free": {Auth: AuthConfig{Type: "none"}},
+					},
+				}
+			},
+			wantErr: "bearer_refresh is incompatible with tier_routing auth",
+		},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			candidate := valid
+			tc.mutate(&candidate)
+			require.ErrorContains(t, candidate.Validate(), tc.wantErr)
+		})
+	}
+}
+
 // TestThrottlingValidate guards the named-adapter contract: enabling
 // throttling without a Shape (or with an unrecognized one) must fail at
 // spec-load time, not silently emit Shopify-shape parser code for an API
@@ -2205,7 +2295,7 @@ resources:
 		// Pin a baseline. Removing any of these from ReservedCLIResourceNames
 		// without first removing the corresponding generator template is a
 		// regression that will reintroduce silent overwrites.
-		mustReserve := []string{"feedback", "doctor", "auth", "helpers", "agent_context", "profile", "deliver", "which", "sync", "tail", "search", "client", "cache", "export", "import"}
+		mustReserve := []string{"feedback", "doctor", "auth", "helpers", "agent_context", "profile", "deliver", "which", "sync", "tail", "search", "client", "cache", "export", "import", "refresh_bearer"}
 		for _, name := range mustReserve {
 			_, ok := ReservedCLIResourceNames[name]
 			assert.True(t, ok, "%q must remain in ReservedCLIResourceNames — losing it would reintroduce silent template overwrites", name)

← 67c977d8 test(cli): live-dogfood resolve-success and search error_pat  ·  back to Cli Printing Press  ·  chore(main): release 3.9.0 (#531) 76d32e83 →