[object Object]

← back to Cli Printing Press

refactor(cli): share generated auth helpers (#312)

b8bb5c1ee3884526cb4dfec04379ff22a20c559f · 2026-04-26 14:19:01 -0700 · Trevin Chow

Files touched

Diff

commit b8bb5c1ee3884526cb4dfec04379ff22a20c559f
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Sun Apr 26 14:19:01 2026 -0700

    refactor(cli): share generated auth helpers (#312)
---
 internal/generator/generator_test.go               |   14 +-
 internal/generator/templates/cliutil_test.go.tmpl  |   17 +
 internal/generator/templates/cliutil_text.go.tmpl  |   38 +
 internal/generator/templates/helpers.go.tmpl       |   44 +-
 internal/generator/templates/mcp_tools.go.tmpl     |   48 +-
 .../golden/cases/generate-golden-api/artifacts.txt |    2 +
 .../expected/generate-golden-api/dogfood.json      |    2 +-
 .../printing-press-golden/internal/cli/helpers.go  | 1141 ++++++++++++++++++++
 .../printing-press-golden/internal/cliutil/text.go |   83 ++
 .../printing-press-golden/internal/mcp/tools.go    |   41 +-
 10 files changed, 1308 insertions(+), 122 deletions(-)

diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 9ca14b60..9fc73322 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -138,6 +138,8 @@ func TestGenerateCliutilPackage(t *testing.T) {
 		{"fanout.go", "type FanoutError struct"},
 		{"fanout.go", "type FanoutResult["},
 		{"text.go", "func CleanText("},
+		{"text.go", "func LooksLikeAuthError("},
+		{"text.go", "func SanitizeErrorBody("},
 	} {
 		data, err := os.ReadFile(filepath.Join(cliutilDir, probe.file))
 		require.NoError(t, err)
@@ -2003,15 +2005,15 @@ func TestGeneratedHelpers_AuthErrorWithEnvVarsAndKeyURL(t *testing.T) {
 
 	// 400 auth branch should be emitted
 	assert.Contains(t, content, `HTTP 400`)
-	assert.Contains(t, content, "looksLikeAuthError")
+	assert.Contains(t, content, "cliutil.LooksLikeAuthError")
 	// Env var should appear in error hints
 	assert.Contains(t, content, "STEAM_API_KEY")
 	// Key URL should appear in error hints
 	assert.Contains(t, content, "https://steamcommunity.com/dev/apikey")
 	// Doctor command hint
 	assert.Contains(t, content, "steamauth-pp-cli doctor")
-	// Sanitization helpers should be present
-	assert.Contains(t, content, "sanitizeErrorBody")
+	// Sanitization should route through shared cliutil helper
+	assert.Contains(t, content, "cliutil.SanitizeErrorBody")
 }
 
 func TestGeneratedHelpers_AuthErrorWithEnvVarsNoKeyURL(t *testing.T) {
@@ -2095,7 +2097,7 @@ func TestGeneratedHelpers_BearerTokenAuth(t *testing.T) {
 	assert.Contains(t, content, "check your token")
 	assert.Contains(t, content, "BEARER_TOKEN")
 	// 400 auth branch should be present (bearer_token is auth)
-	assert.Contains(t, content, "looksLikeAuthError")
+	assert.Contains(t, content, "cliutil.LooksLikeAuthError")
 }
 
 func TestGeneratedHelpers_NoAuth_No400Branch(t *testing.T) {
@@ -2132,8 +2134,8 @@ func TestGeneratedHelpers_NoAuth_No400Branch(t *testing.T) {
 	content := string(helpersGo)
 
 	// Should NOT have 400 auth branch
-	assert.NotContains(t, content, "looksLikeAuthError")
-	assert.NotContains(t, content, "sanitizeErrorBody")
+	assert.NotContains(t, content, "LooksLikeAuthError")
+	assert.NotContains(t, content, "SanitizeErrorBody")
 	// Should NOT import regexp
 	assert.NotContains(t, content, `"regexp"`)
 	// classifyAPIError should still exist
diff --git a/internal/generator/templates/cliutil_test.go.tmpl b/internal/generator/templates/cliutil_test.go.tmpl
index 91a86aac..91b78c45 100644
--- a/internal/generator/templates/cliutil_test.go.tmpl
+++ b/internal/generator/templates/cliutil_test.go.tmpl
@@ -82,6 +82,23 @@ func TestParseStoredTime(t *testing.T) {
 	}
 }
 
+{{- if and .Auth.Type (ne .Auth.Type "none")}}
+
+func TestAuthErrorHelpers(t *testing.T) {
+	if !LooksLikeAuthError("HTTP 400: missing api_key") {
+		t.Fatal("expected missing api_key to look like an auth error")
+	}
+	if LooksLikeAuthError("HTTP 400: malformed page number") {
+		t.Fatal("unexpected auth classification for non-auth message")
+	}
+
+	got := SanitizeErrorBody("token sk-abcdefghi Bearer abc.def key=secretvalue")
+	if got != "token [REDACTED] [REDACTED] [REDACTED]" {
+		t.Fatalf("SanitizeErrorBody redaction = %q", got)
+	}
+}
+{{- end}}
+
 // ---- FanoutRun ----
 
 func TestFanoutRunAllSucceed(t *testing.T) {
diff --git a/internal/generator/templates/cliutil_text.go.tmpl b/internal/generator/templates/cliutil_text.go.tmpl
index fd251cec..b455a6d1 100644
--- a/internal/generator/templates/cliutil_text.go.tmpl
+++ b/internal/generator/templates/cliutil_text.go.tmpl
@@ -5,6 +5,9 @@ package cliutil
 
 import (
 	"html"
+{{- if and .Auth.Type (ne .Auth.Type "none")}}
+	"regexp"
+{{- end}}
 	"strings"
 	"time"
 )
@@ -48,3 +51,38 @@ func ParseStoredTime(s string) time.Time {
 	}
 	return time.Time{}
 }
+
+{{- if and .Auth.Type (ne .Auth.Type "none")}}
+
+// LooksLikeAuthError checks if an error message body contains auth-related keywords.
+func LooksLikeAuthError(msg string) bool {
+	lower := strings.ToLower(msg)
+	patterns := []string{
+		`\bkey\b`,
+		`\btoken\b`,
+		`\bunauthorized\b`,
+		`\bapi_key\b`,
+		`missing.{0,20}key`,
+		`required.{0,20}key`,
+		`\bforbidden\b`,
+		`\bauthenticat`,
+		`\bcredential`,
+	}
+	for _, p := range patterns {
+		if matched, _ := regexp.MatchString(p, lower); matched {
+			return true
+		}
+	}
+	return false
+}
+
+// SanitizeErrorBody truncates and strips credential-shaped strings from error output.
+func SanitizeErrorBody(msg string) string {
+	if len(msg) > 200 {
+		msg = msg[:200] + "..."
+	}
+	credPatterns := regexp.MustCompile(`(?i)(sk-[a-zA-Z0-9]{8,}|sk_live_[a-zA-Z0-9]+|Bearer\s+[a-zA-Z0-9._\-]+|key=[a-zA-Z0-9._\-]+)`)
+	msg = credPatterns.ReplaceAllString(msg, "[REDACTED]")
+	return msg
+}
+{{- end}}
diff --git a/internal/generator/templates/helpers.go.tmpl b/internal/generator/templates/helpers.go.tmpl
index 1addb2e1..660cad72 100644
--- a/internal/generator/templates/helpers.go.tmpl
+++ b/internal/generator/templates/helpers.go.tmpl
@@ -12,7 +12,7 @@ import (
 {{- if .HasDataLayer}}
 	"path/filepath"
 {{- end}}
-{{- if and .Auth.Type (ne .Auth.Type "none")}}
+{{- if and (and .Auth.Type (ne .Auth.Type "none")) .HasDataLayer}}
 	"regexp"
 {{- end}}
 	"sort"
@@ -23,6 +23,9 @@ import (
 {{- end}}
 	"unicode"
 
+{{- if and .Auth.Type (ne .Auth.Type "none")}}
+	"{{modulePath}}/internal/cliutil"
+{{- end}}
 {{- if and (and .Auth.Type (ne .Auth.Type "none")) .HasDataLayer}}
 	"{{modulePath}}/internal/client"
 {{- end}}
@@ -108,41 +111,6 @@ func apiErr(err error) error      { return &cliError{code: 5, err: err} }
 func configErr(err error) error   { return &cliError{code: 10, err: err} }
 func rateLimitErr(err error) error { return &cliError{code: 7, err: err} }
 
-{{- if and .Auth.Type (ne .Auth.Type "none")}}
-// looksLikeAuthError checks if an error message body contains auth-related keywords.
-func looksLikeAuthError(msg string) bool {
-	lower := strings.ToLower(msg)
-	patterns := []string{
-		`\bkey\b`,
-		`\btoken\b`,
-		`\bunauthorized\b`,
-		`\bapi_key\b`,
-		`missing.{0,20}key`,
-		`required.{0,20}key`,
-		`\bforbidden\b`,
-		`\bauthenticat`,
-		`\bcredential`,
-	}
-	for _, p := range patterns {
-		if matched, _ := regexp.MatchString(p, lower); matched {
-			return true
-		}
-	}
-	return false
-}
-
-// sanitizeErrorBody truncates and strips credential-shaped strings from error output.
-func sanitizeErrorBody(msg string) string {
-	if len(msg) > 200 {
-		msg = msg[:200] + "..."
-	}
-	// Strip credential-shaped patterns
-	credPatterns := regexp.MustCompile(`(?i)(sk-[a-zA-Z0-9]{8,}|sk_live_[a-zA-Z0-9]+|Bearer\s+[a-zA-Z0-9._\-]+|key=[a-zA-Z0-9._\-]+)`)
-	msg = credPatterns.ReplaceAllString(msg, "[REDACTED]")
-	return msg
-}
-{{- end}}
-
 {{- if .HasDataLayer}}
 
 // accessWarning describes an API access-denial that sync converts into a
@@ -252,7 +220,7 @@ func classifyAPIError(err error) error {
 		fmt.Fprintln(os.Stderr, "already exists (no-op)")
 		return nil
 {{- if and .Auth.Type (ne .Auth.Type "none")}}
-	case strings.Contains(msg, "HTTP 400") && looksLikeAuthError(msg):
+	case strings.Contains(msg, "HTTP 400") && cliutil.LooksLikeAuthError(msg):
 		return authErr(fmt.Errorf("%w\nhint: the API rejected the request — this usually means auth is missing or invalid."+
 {{- if .Auth.EnvVars}}
 			"\n      Set your API key: export {{index .Auth.EnvVars 0}}=<your-key>"+
@@ -261,7 +229,7 @@ func classifyAPIError(err error) error {
 			"\n      Get a key at: {{.Auth.KeyURL}}"+
 {{- end}}
 			"\n      Run '{{.Name}}-pp-cli doctor' to check auth status."+
-			"\n      Response: "+sanitizeErrorBody(msg), err))
+			"\n      Response: "+cliutil.SanitizeErrorBody(msg), err))
 {{- end}}
 	case strings.Contains(msg, "HTTP 401"):
 {{- if eq .Auth.Type "oauth2"}}
diff --git a/internal/generator/templates/mcp_tools.go.tmpl b/internal/generator/templates/mcp_tools.go.tmpl
index cd0829de..a167575d 100644
--- a/internal/generator/templates/mcp_tools.go.tmpl
+++ b/internal/generator/templates/mcp_tools.go.tmpl
@@ -9,14 +9,14 @@ import (
 	"fmt"
 	"os"
 	"path/filepath"
-{{- if and .Auth.Type (ne .Auth.Type "none")}}
-	"regexp"
-{{- end}}
 	"strings"
 	"time"
 
 	mcplib "github.com/mark3labs/mcp-go/mcp"
 	"github.com/mark3labs/mcp-go/server"
+{{- if and .Auth.Type (ne .Auth.Type "none")}}
+	"{{modulePath}}/internal/cliutil"
+{{- end}}
 	"{{modulePath}}/internal/client"
 	"{{modulePath}}/internal/config"
 {{- if .VisionSet.Store}}
@@ -24,40 +24,6 @@ import (
 {{- end}}
 )
 
-{{- if and .Auth.Type (ne .Auth.Type "none")}}
-// looksLikeAuthError checks if an error message body contains auth-related keywords.
-func looksLikeAuthError(msg string) bool {
-	lower := strings.ToLower(msg)
-	patterns := []string{
-		`\bkey\b`,
-		`\btoken\b`,
-		`\bunauthorized\b`,
-		`\bapi_key\b`,
-		`missing.{0,20}key`,
-		`required.{0,20}key`,
-		`\bforbidden\b`,
-		`\bauthenticat`,
-		`\bcredential`,
-	}
-	for _, p := range patterns {
-		if matched, _ := regexp.MatchString(p, lower); matched {
-			return true
-		}
-	}
-	return false
-}
-
-// sanitizeErrorBody truncates and strips credential-shaped strings from error output.
-func sanitizeErrorBody(msg string) string {
-	if len(msg) > 200 {
-		msg = msg[:200] + "..."
-	}
-	credPatterns := regexp.MustCompile(`(?i)(sk-[a-zA-Z0-9]{8,}|sk_live_[a-zA-Z0-9]+|Bearer\s+[a-zA-Z0-9._\-]+|key=[a-zA-Z0-9._\-]+)`)
-	msg = credPatterns.ReplaceAllString(msg, "[REDACTED]")
-	return msg
-}
-{{- end}}
-
 // RegisterTools registers all API operations as MCP tools.
 func RegisterTools(s *server.MCPServer) {
 {{- if .MCP.IsCodeOrchestration}}
@@ -218,8 +184,8 @@ func makeAPIHandler(method, pathTemplate string, positionalParams []string) serv
 			case strings.Contains(msg, "HTTP 409"):
 				return mcplib.NewToolResultText("already exists (no-op)"), nil
 {{- if and .Auth.Type (ne .Auth.Type "none")}}
-			case strings.Contains(msg, "HTTP 400") && looksLikeAuthError(msg):
-				return mcplib.NewToolResultError("authentication error: " + sanitizeErrorBody(msg) +
+			case strings.Contains(msg, "HTTP 400") && cliutil.LooksLikeAuthError(msg):
+				return mcplib.NewToolResultError("authentication error: " + cliutil.SanitizeErrorBody(msg) +
 					"\nhint: the API rejected the request — this usually means auth is missing or invalid." +
 {{- if .Auth.EnvVars}}
 					{{printf "%q" (printf "\n      Set your API key: export %s=<your-key>" (index .Auth.EnvVars 0))}} +
@@ -230,7 +196,7 @@ func makeAPIHandler(method, pathTemplate string, positionalParams []string) serv
 					{{printf "%q" (printf "\n      Run '%s-pp-cli doctor' to check auth status." .Name)}}), nil
 {{- end}}
 			case strings.Contains(msg, "HTTP 401"):
-				return mcplib.NewToolResultError("authentication failed: " + {{if and .Auth.Type (ne .Auth.Type "none")}}sanitizeErrorBody(msg){{else}}msg{{end}} +
+				return mcplib.NewToolResultError("authentication failed: " + {{if and .Auth.Type (ne .Auth.Type "none")}}cliutil.SanitizeErrorBody(msg){{else}}msg{{end}} +
 {{- if eq .Auth.Type "oauth2"}}
 					{{printf "%q" (printf "\nhint: your token may have expired. Re-run '%s-pp-cli auth login' to re-authenticate" .Name)}} +
 {{- else if eq .Auth.Type "api_key"}}
@@ -248,7 +214,7 @@ func makeAPIHandler(method, pathTemplate string, positionalParams []string) serv
 {{- end}}
 					{{printf "%q" (printf "\n      Run '%s-pp-cli doctor' to check auth status." .Name)}}), nil
 			case strings.Contains(msg, "HTTP 403"):
-				return mcplib.NewToolResultError("permission denied: " + {{if and .Auth.Type (ne .Auth.Type "none")}}sanitizeErrorBody(msg){{else}}msg{{end}} +
+				return mcplib.NewToolResultError("permission denied: " + {{if and .Auth.Type (ne .Auth.Type "none")}}cliutil.SanitizeErrorBody(msg){{else}}msg{{end}} +
 {{- if eq .Auth.Type "oauth2"}}
 					{{printf "%q" (printf "\nhint: your token may lack required scopes. Re-run '%s-pp-cli auth login' to re-authorize" .Name)}} +
 {{- else}}
diff --git a/testdata/golden/cases/generate-golden-api/artifacts.txt b/testdata/golden/cases/generate-golden-api/artifacts.txt
index bed42101..bd427844 100644
--- a/testdata/golden/cases/generate-golden-api/artifacts.txt
+++ b/testdata/golden/cases/generate-golden-api/artifacts.txt
@@ -2,6 +2,7 @@ printing-press-golden/.printing-press.json
 printing-press-golden/go.mod
 printing-press-golden/cmd/printing-press-golden-pp-cli/main.go
 printing-press-golden/internal/client/client.go
+printing-press-golden/internal/cli/helpers.go
 printing-press-golden/internal/cli/root.go
 printing-press-golden/internal/cli/auth.go
 printing-press-golden/internal/cli/projects_list.go
@@ -9,6 +10,7 @@ printing-press-golden/internal/cli/projects_create.go
 printing-press-golden/internal/cli/projects_tasks_list-project.go
 printing-press-golden/internal/cli/projects_tasks_update-project.go
 printing-press-golden/internal/cli/promoted_public.go
+printing-press-golden/internal/cliutil/text.go
 printing-press-golden/internal/config/config.go
 printing-press-golden/internal/mcp/tools.go
 printing-press-golden/internal/types/types.go
diff --git a/testdata/golden/expected/generate-golden-api/dogfood.json b/testdata/golden/expected/generate-golden-api/dogfood.json
index 92e12e61..941dfc83 100644
--- a/testdata/golden/expected/generate-golden-api/dogfood.json
+++ b/testdata/golden/expected/generate-golden-api/dogfood.json
@@ -19,7 +19,7 @@
     "items": [
       "wrapResultsWithFreshness"
     ],
-    "total": 50
+    "total": 48
   },
   "dir": "<ARTIFACT_DIR>/generate-golden-api/printing-press-golden",
   "example_check": {
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/helpers.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/helpers.go
new file mode 100644
index 00000000..d09a982d
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/helpers.go
@@ -0,0 +1,1141 @@
+// Copyright 2026 printing-press-golden. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"os"
+	"path/filepath"
+	"regexp"
+	"sort"
+	"strings"
+	"text/tabwriter"
+	"time"
+	"unicode"
+	"printing-press-golden-pp-cli/internal/cliutil"
+	"printing-press-golden-pp-cli/internal/client"
+	"github.com/spf13/cobra"
+	"github.com/spf13/pflag"
+)
+
+var As = errors.As
+
+// noColor is set by the --no-color flag
+var noColor bool
+
+// humanFriendly is set by the --human-friendly flag; colors are off by default (agent-safe)
+var humanFriendly bool
+
+func colorEnabled() bool {
+	if noColor {
+		return false
+	}
+	if !humanFriendly {
+		return false
+	}
+	if os.Getenv("NO_COLOR") != "" {
+		return false
+	}
+	if os.Getenv("TERM") == "dumb" {
+		return false
+	}
+	return isTerminal(os.Stdout)
+}
+
+func isTerminal(w io.Writer) bool {
+	if f, ok := w.(*os.File); ok {
+		fi, err := f.Stat()
+		if err != nil {
+			return true
+		}
+		return (fi.Mode() & os.ModeCharDevice) != 0
+	}
+	return false
+}
+
+func bold(s string) string {
+	if !colorEnabled() {
+		return s
+	}
+	return "\033[1m" + s + "\033[0m"
+}
+
+func green(s string) string {
+	if !colorEnabled() {
+		return s
+	}
+	return "\033[32m" + s + "\033[0m"
+}
+
+func red(s string) string {
+	if !colorEnabled() {
+		return s
+	}
+	return "\033[31m" + s + "\033[0m"
+}
+
+func yellow(s string) string {
+	if !colorEnabled() {
+		return s
+	}
+	return "\033[33m" + s + "\033[0m"
+}
+
+type cliError struct {
+	code int
+	err  error
+}
+
+func (e *cliError) Error() string { return e.err.Error() }
+func (e *cliError) Unwrap() error { return e.err }
+
+func usageErr(err error) error    { return &cliError{code: 2, err: err} }
+func notFoundErr(err error) error { return &cliError{code: 3, err: err} }
+func authErr(err error) error     { return &cliError{code: 4, err: err} }
+func apiErr(err error) error      { return &cliError{code: 5, err: err} }
+func configErr(err error) error   { return &cliError{code: 10, err: err} }
+func rateLimitErr(err error) error { return &cliError{code: 7, err: err} }
+
+// accessWarning describes an API access-denial that sync converts into a
+// non-fatal warning. It carries enough structured data for the sync_warning
+// JSON event without parsing free-form error strings downstream.
+type accessWarning struct {
+	Status  int    // HTTP status when applicable; 0 for GraphQL field-level denials.
+	Reason  string // "forbidden" | "insufficient_access" | "unauthenticated"
+	Message string // human-readable detail (the API's body or GraphQL error message)
+}
+
+// accessDenialPatterns matches API error bodies that indicate the request was
+// rejected for access-policy reasons rather than for input validity. Matching
+// is case-insensitive and uses word boundaries so common substrings inside
+// unrelated tokens (e.g. "author", "pagination_token", "insufficient_funds")
+// do not produce false positives. The set deliberately excludes brand names —
+// vendor-specific phrasings should be addressed at the spec/profiler level,
+// not in this universal classifier.
+var accessDenialPatterns = []*regexp.Regexp{
+	regexp.MustCompile(`\bforbidden\b`),
+	regexp.MustCompile(`\bunauthorized\b`),
+	regexp.MustCompile(`\bnot[\s_-]?authorized\b`),
+	regexp.MustCompile(`\bpermission[\s_-]?denied\b`),
+	regexp.MustCompile(`\baccess[\s_-]?denied\b`),
+	regexp.MustCompile(`\binsufficient[\s_-]?(scope|permission|privilege)`),
+	regexp.MustCompile(`\binvalid[\s_-]?scope\b`),
+	regexp.MustCompile(`\bmissing[\s_-]?scope\b`),
+	regexp.MustCompile(`\brequires?\s+(elevated|admin|enterprise|business|workspace|enterprise[\s_-]?tier)`),
+}
+
+// looksLikeAccessDenial reports whether body text describes an access-policy
+// rejection. Use it on response-body content (apiErr.Body), not on the full
+// error string — the request path can contain words like "auth" or "tokens"
+// that would produce false positives if the whole error message were scanned.
+func looksLikeAccessDenial(body string) bool {
+	lower := strings.ToLower(body)
+	for _, p := range accessDenialPatterns {
+		if p.MatchString(lower) {
+			return true
+		}
+	}
+	return false
+}
+
+// isSyncAccessWarning classifies err as an access-denial warning suitable for
+// sync's warn-and-continue path. It returns nil, false for any error that
+// should remain a hard sync failure: HTTP 401 (token-level auth failure
+// requiring re-auth), 5xx, network errors, and HTTP 400 responses whose
+// bodies do not match an access-policy pattern.
+//
+// Recognized warning shapes:
+//   - HTTP 403 (per-resource ACL rejection)
+//   - HTTP 400 + access-denial body keyword (insufficient scope, etc.)
+//   - GraphQL response carrying only access-denial extension codes
+func isSyncAccessWarning(err error) (*accessWarning, bool) {
+	if err == nil {
+		return nil, false
+	}
+
+	var apiErr *client.APIError
+	if errors.As(err, &apiErr) {
+		switch apiErr.StatusCode {
+		case 403:
+			return &accessWarning{Status: 403, Reason: "forbidden", Message: apiErr.Body}, true
+		case 400:
+			if looksLikeAccessDenial(apiErr.Body) {
+				return &accessWarning{Status: 400, Reason: "insufficient_access", Message: apiErr.Body}, true
+			}
+		}
+	}
+
+	return nil, false
+}
+
+// classifyAPIError maps API errors to structured exit codes with actionable hints.
+func classifyAPIError(err error) error {
+	msg := err.Error()
+	switch {
+	case strings.Contains(msg, "HTTP 409"):
+		// 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 400") && cliutil.LooksLikeAuthError(msg):
+		return authErr(fmt.Errorf("%w\nhint: the API rejected the request — this usually means auth is missing or invalid."+
+			"\n      Set your API key: export PRINTING_PRESS_GOLDEN_API_KEY_AUTH=<your-key>"+
+			"\n      Run 'printing-press-golden-pp-cli doctor' to check auth status."+
+			"\n      Response: "+cliutil.SanitizeErrorBody(msg), err))
+	case strings.Contains(msg, "HTTP 401"):
+		return authErr(fmt.Errorf("%w\nhint: check your API key."+
+			" Set it with: export PRINTING_PRESS_GOLDEN_API_KEY_AUTH=<your-key>"+
+			"\n      Run 'printing-press-golden-pp-cli doctor' to check auth status.", err))
+	case strings.Contains(msg, "HTTP 403"):
+		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."+
+			"\n      Set it with: export PRINTING_PRESS_GOLDEN_API_KEY_AUTH=<your-key>"+
+			"\n      Run 'printing-press-golden-pp-cli doctor' to check auth status.", err))
+	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"):
+		return rateLimitErr(err)
+	default:
+		return apiErr(err)
+	}
+}
+
+func truncate(s string, max int) string {
+	if len(s) <= max {
+		return s
+	}
+	if max <= 3 {
+		return s[:max]
+	}
+	return s[:max-3] + "..."
+}
+
+func newTabWriter(w io.Writer) *tabwriter.Writer {
+	return tabwriter.NewWriter(w, 2, 4, 2, ' ', 0)
+}
+func replacePathParam(path, name, value string) string {
+	return strings.ReplaceAll(path, "{"+name+"}", value)
+}
+
+// paginatedGet fetches pages and concatenates array results.
+func paginatedGet(c interface {
+	Get(path string, params map[string]string) (json.RawMessage, error)
+}, path string, params map[string]string, fetchAll bool, cursorParam, nextCursorPath, hasMoreField string) (json.RawMessage, error) {
+	// Clean zero-value params
+	clean := map[string]string{}
+	for k, v := range params {
+		if v != "" && v != "0" && v != "false" {
+			clean[k] = v
+		}
+	}
+
+	if !fetchAll {
+		return c.Get(path, clean)
+	}
+
+	// Fetch all pages
+	var allItems []json.RawMessage
+	page := 0
+	for {
+		page++
+		if humanFriendly {
+			fmt.Fprintf(os.Stderr, "fetching page %d...\n", page)
+		} else {
+			fmt.Fprintf(os.Stderr, `{"event":"page_fetch","page":%d}`+"\n", page)
+		}
+
+		data, err := c.Get(path, clean)
+		if err != nil {
+			return nil, err
+		}
+
+		// Try to extract items array
+		var items []json.RawMessage
+		if json.Unmarshal(data, &items) == nil {
+			allItems = append(allItems, items...)
+		} else {
+			// Response is an object - look for array inside
+			var obj map[string]json.RawMessage
+			if json.Unmarshal(data, &obj) == nil {
+				// Try common data fields
+				for _, field := range []string{"data", "items", "results", "messages", "members", "values"} {
+					if arr, ok := obj[field]; ok {
+						var nested []json.RawMessage
+						if json.Unmarshal(arr, &nested) == nil {
+							allItems = append(allItems, nested...)
+							break
+						}
+					}
+				}
+
+				// Check for next cursor
+				if nextCursorPath != "" {
+					if tokenRaw, ok := obj[nextCursorPath]; ok {
+						var token string
+						if json.Unmarshal(tokenRaw, &token) == nil && token != "" {
+							clean[cursorParam] = token
+							continue
+						}
+					}
+				}
+
+				// Check has_more
+				if hasMoreField != "" {
+					if moreRaw, ok := obj[hasMoreField]; ok {
+						var more bool
+						if json.Unmarshal(moreRaw, &more) == nil && more {
+							continue
+						}
+					}
+				}
+			}
+			// No more pages
+			break
+		}
+
+		// For direct arrays, can't paginate without cursor
+		break
+	}
+
+	if humanFriendly {
+		fmt.Fprintf(os.Stderr, "fetched %d items across %d pages\n", len(allItems), page)
+	} else {
+		fmt.Fprintf(os.Stderr, `{"event":"complete","total":%d,"pages":%d}`+"\n", len(allItems), page)
+	}
+	result, _ := json.Marshal(allItems)
+	return json.RawMessage(result), nil
+}
+
+// filterFields keeps only the specified fields (comma-separated) from JSON objects/arrays.
+// Supports dotted paths like "events.shortName" to descend into nested structures.
+// Arrays are traversed element-wise: "events.shortName" keeps shortName on each event.
+func filterFields(data json.RawMessage, fields string) json.RawMessage {
+	var paths [][]string
+	for _, f := range strings.Split(fields, ",") {
+		f = strings.TrimSpace(f)
+		if f == "" {
+			continue
+		}
+		parts := strings.Split(f, ".")
+		for i := range parts {
+			parts[i] = strings.ToLower(parts[i])
+		}
+		paths = append(paths, parts)
+	}
+	if len(paths) == 0 {
+		return data
+	}
+	return filterFieldsRec(data, paths)
+}
+
+// filterFieldsRec applies path filters to a JSON value. Each path is a list of
+// lowercase segments; arrays descend element-wise.
+func filterFieldsRec(data json.RawMessage, paths [][]string) json.RawMessage {
+	var arr []json.RawMessage
+	if err := json.Unmarshal(data, &arr); err == nil {
+		out := make([]json.RawMessage, len(arr))
+		for i, el := range arr {
+			out[i] = filterFieldsRec(el, paths)
+		}
+		result, _ := json.Marshal(out)
+		return result
+	}
+
+	var obj map[string]json.RawMessage
+	if err := json.Unmarshal(data, &obj); err == nil {
+		keepWhole := map[string]bool{}
+		subPaths := map[string][][]string{}
+		for _, p := range paths {
+			if len(p) == 0 {
+				continue
+			}
+			head := p[0]
+			if len(p) == 1 {
+				keepWhole[head] = true
+			} else {
+				subPaths[head] = append(subPaths[head], p[1:])
+			}
+		}
+		filtered := map[string]json.RawMessage{}
+		for k, v := range obj {
+			matched := matchSelectSegment(k, keepWhole, subPaths)
+			if matched == "" {
+				continue
+			}
+			if keepWhole[matched] {
+				filtered[k] = v
+				continue
+			}
+			if subs := subPaths[matched]; subs != nil {
+				filtered[k] = filterFieldsRec(v, subs)
+			}
+		}
+		result, _ := json.Marshal(filtered)
+		return result
+	}
+
+	return data
+}
+
+// matchSelectSegment returns the matching lowercase segment, or "" if no match.
+// Supports direct case-insensitive match and camelCase→kebab-case conversion.
+func matchSelectSegment(fieldName string, keepWhole map[string]bool, subPaths map[string][][]string) string {
+	lower := strings.ToLower(fieldName)
+	if keepWhole[lower] || subPaths[lower] != nil {
+		return lower
+	}
+	kebab := camelToKebab(fieldName)
+	if kebab != lower && (keepWhole[kebab] || subPaths[kebab] != nil) {
+		return kebab
+	}
+	return ""
+}
+
+// camelToKebab converts "orderDate" or "orderdate" to "order-date" by splitting on
+// uppercase boundaries. For already-lowercase input, splits on known word boundaries.
+func camelToKebab(s string) string {
+	var b strings.Builder
+	runes := []rune(s)
+	for i, r := range runes {
+		if i > 0 && unicode.IsUpper(r) && unicode.IsLower(runes[i-1]) {
+			b.WriteByte('-')
+		}
+		b.WriteRune(unicode.ToLower(r))
+	}
+	return b.String()
+}
+
+// printOutputWithFlags routes output through the right format based on flags.
+func printOutputWithFlags(w io.Writer, data json.RawMessage, flags *rootFlags) error {
+	// --select wins over --compact when both are set: an explicit field list
+	// is the user's authoritative request, so the high-gravity allow-list
+	// must not strip those fields out before --select can pick them. When
+	// only --compact is set (e.g., --agent without --select), the allow-list
+	// still runs.
+	if flags.selectFields != "" {
+		data = filterFields(data, flags.selectFields)
+	} else if flags.compact {
+		data = compactFields(data)
+	}
+	// --quiet: suppress all output, exit code communicates result
+	if flags.quiet {
+		return nil
+	}
+	// --csv: render as CSV
+	if flags.csv {
+		return printCSV(w, data)
+	}
+	return printOutput(w, data, flags.asJSON)
+}
+
+// extractResponseData unwraps common API response envelopes for display.
+// Many APIs return {"status":"success","data":[...]} instead of a bare array.
+// This extracts the inner data for output helpers (filterFields, compactFields,
+// printAutoTable) that expect arrays or flat objects.
+//
+// Only unwraps when a "status" field is present and indicates success — this
+// avoids false positives on APIs where "data" is a regular field (e.g., Stripe
+// returns {"data":[...],"has_more":true} where "data" is the list, not an
+// envelope wrapper).
+func extractResponseData(data json.RawMessage) json.RawMessage {
+	var envelope struct {
+		Status string          `json:"status"`
+		Data   json.RawMessage `json:"data"`
+	}
+	if err := json.Unmarshal(data, &envelope); err != nil {
+		return data
+	}
+	if envelope.Data == nil || envelope.Status == "" {
+		return data // No status field = not an envelope, might be regular "data" field
+	}
+	switch envelope.Status {
+	case "success", "ok", "OK", "Success":
+		return envelope.Data
+	default:
+		return data
+	}
+}
+
+// compactFields keeps only the most important fields for agent consumption.
+// For arrays: allowlist of high-gravity fields (no descriptions).
+// For single objects: blocklist that strips known-verbose fields (descriptions, comments, etc.).
+func compactFields(data json.RawMessage) json.RawMessage {
+	// Try array first
+	var items []map[string]any
+	if err := json.Unmarshal(data, &items); err == nil {
+		return compactListFields(items)
+	}
+
+	// Single object — use blocklist
+	var obj map[string]any
+	if err := json.Unmarshal(data, &obj); err == nil {
+		return compactObjectFields(obj)
+	}
+
+	return data
+}
+
+// compactListFields keeps only high-gravity fields for array responses.
+func compactListFields(items []map[string]any) json.RawMessage {
+	keepFields := map[string]bool{
+		"id": true, "name": true, "title": true, "identifier": true,
+		"status": true, "state": true, "type": true, "priority": true,
+		"url": true, "email": true, "key": true,
+		"created_at": true, "updated_at": true, "createdAt": true, "updatedAt": true,
+	}
+
+	filtered := make([]map[string]any, 0, len(items))
+	for _, item := range items {
+		compact := map[string]any{}
+		for k, v := range item {
+			if keepFields[k] {
+				compact[k] = v
+			}
+		}
+		filtered = append(filtered, compact)
+	}
+	result, _ := json.Marshal(filtered)
+	return result
+}
+
+// compactObjectFields strips known-verbose fields from single-object responses.
+// Uses a blocklist so it works across all API domains (project management, payments, CRM, etc.).
+func compactObjectFields(obj map[string]any) json.RawMessage {
+	stripFields := map[string]bool{
+		"description": true, "body": true, "content": true,
+		"comments": true, "attachments": true, "html": true, "markdown": true,
+	}
+
+	compact := map[string]any{}
+	for k, v := range obj {
+		if !stripFields[k] {
+			compact[k] = v
+		}
+	}
+	result, _ := json.Marshal(compact)
+	return result
+}
+
+// printCSV renders JSON arrays as CSV with header row.
+func printCSV(w io.Writer, data json.RawMessage) error {
+	var items []map[string]any
+	if err := json.Unmarshal(data, &items); err != nil || len(items) == 0 {
+		// Single object or empty - just print as JSON
+		fmt.Fprintln(w, string(data))
+		return nil
+	}
+	// Collect all keys for header
+	keySet := map[string]bool{}
+	for _, item := range items {
+		for k := range item {
+			keySet[k] = true
+		}
+	}
+	var keys []string
+	for k := range keySet {
+		keys = append(keys, k)
+	}
+	sort.Strings(keys)
+	// Header
+	fmt.Fprintln(w, strings.Join(keys, ","))
+	// Rows
+	for _, item := range items {
+		var vals []string
+		for _, k := range keys {
+			v := item[k]
+			if v == nil {
+				vals = append(vals, "")
+			} else {
+				s := fmt.Sprintf("%v", v)
+				if strings.ContainsAny(s, ",\"\n") {
+					s = `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
+				}
+				vals = append(vals, s)
+			}
+		}
+		fmt.Fprintln(w, strings.Join(vals, ","))
+	}
+	return nil
+}
+
+// printOutput auto-detects arrays and renders as tables, or prints raw JSON for objects.
+func printOutput(w io.Writer, data json.RawMessage, asJSON bool) error {
+	if !asJSON && !isTerminal(w) {
+		asJSON = true
+	}
+
+	if asJSON {
+		enc := json.NewEncoder(w)
+		enc.SetIndent("", "  ")
+		return enc.Encode(data)
+	}
+
+	// Try to detect if response is an array
+	var items []map[string]any
+	if err := json.Unmarshal(data, &items); err == nil && len(items) > 0 {
+		if err := printAutoTable(w, items); err != nil {
+			return err
+		}
+		// Agent-friendly: show count and suggest narrowing when results are large
+		if len(items) >= 25 {
+			fmt.Fprintf(os.Stderr, "\nShowing %d results. To narrow: add --limit, --json --select, or filter flags.\n", len(items))
+		}
+		return nil
+	}
+
+	// Single object - pretty print
+	var obj map[string]any
+	if err := json.Unmarshal(data, &obj); err == nil {
+		enc := json.NewEncoder(w)
+		enc.SetIndent("", "  ")
+		return enc.Encode(obj)
+	}
+
+	// Fallback: print raw
+	fmt.Fprintln(w, string(data))
+	return nil
+}
+
+// levenshteinDistance computes the edit distance between two strings using a two-row DP approach.
+func levenshteinDistance(a, b string) int {
+	if len(a) == 0 {
+		return len(b)
+	}
+	if len(b) == 0 {
+		return len(a)
+	}
+	if len(a) < len(b) {
+		a, b = b, a
+	}
+	prev := make([]int, len(b)+1)
+	curr := make([]int, len(b)+1)
+	for j := range prev {
+		prev[j] = j
+	}
+	for i := 1; i <= len(a); i++ {
+		curr[0] = i
+		for j := 1; j <= len(b); j++ {
+			cost := 1
+			if a[i-1] == b[j-1] {
+				cost = 0
+			}
+			ins := curr[j-1] + 1
+			del := prev[j] + 1
+			sub := prev[j-1] + cost
+			min := ins
+			if del < min {
+				min = del
+			}
+			if sub < min {
+				min = sub
+			}
+			curr[j] = min
+		}
+		prev, curr = curr, prev
+	}
+	return prev[len(b)]
+}
+
+// suggestFlag returns the closest known flag name to the unknown string, or "" if none is close enough.
+func suggestFlag(unknown string, cmd *cobra.Command) string {
+	unknown = strings.TrimLeft(unknown, "-")
+	best := ""
+	bestDist := 4 // only consider distance <= 3
+	check := func(name string) {
+		d := levenshteinDistance(unknown, name)
+		if d < bestDist && d*5 <= len(unknown)*2 {
+			bestDist = d
+			best = name
+		}
+	}
+	cmd.Flags().VisitAll(func(f *pflag.Flag) {
+		check(f.Name)
+	})
+	cmd.InheritedFlags().VisitAll(func(f *pflag.Flag) {
+		check(f.Name)
+	})
+	return best
+}
+
+// wantsHumanTable returns true when output should be a human-friendly table.
+// Smart default: terminal=table, pipe=JSON.
+// - Human in terminal: isTerminal()=true → table
+// - Claude Code/Codex bash tool: stdout piped → JSON
+// - --json/--csv/--compact/--agent: machine format → JSON
+func wantsHumanTable(w io.Writer, flags *rootFlags) bool {
+	if flags.asJSON || flags.csv || flags.compact || flags.quiet || flags.plain {
+		return false
+	}
+	if flags.selectFields != "" {
+		return false
+	}
+	return isTerminal(w)
+}
+
+func printAutoTable(w io.Writer, items []map[string]any) error {
+	if len(items) == 0 {
+		return nil
+	}
+
+	// Count scalar vs complex fields to decide format
+	scalarCount := 0
+	for _, v := range items[0] {
+		switch v.(type) {
+		case string, float64, bool, nil:
+			scalarCount++
+		}
+	}
+
+	// Use sectional/card layout for complex items (many fields or nested data)
+	if len(items[0]) > 8 || scalarCount < len(items[0])-2 {
+		return printAutoCards(w, items)
+	}
+
+	headers := prioritizeHeaders(items[0])
+
+	// Limit to 6 columns max for readability
+	if len(headers) > 6 {
+		headers = headers[:6]
+	}
+
+	// Build rows
+	rows := make([][]string, 0, len(items))
+	for _, item := range items {
+		row := make([]string, len(headers))
+		for i, h := range headers {
+			row[i] = formatCellValue(item[h])
+		}
+		rows = append(rows, row)
+	}
+
+	// Print with tab alignment using tabwriter
+	tw := newTabWriter(w)
+	upperHeaders := make([]string, len(headers))
+	for i, h := range headers {
+		upperHeaders[i] = bold(strings.ToUpper(h))
+	}
+
+	fmt.Fprintln(tw, strings.Join(upperHeaders, "\t"))
+	for _, row := range rows {
+		fmt.Fprintln(tw, strings.Join(row, "\t"))
+	}
+	return tw.Flush()
+}
+
+// prioritizeHeaders orders scalar fields by importance for table display.
+func prioritizeHeaders(item map[string]any) []string {
+	return prioritizeFields(item, false)
+}
+
+// prioritizeAllHeaders orders all fields (including arrays) by importance for card display.
+func prioritizeAllHeaders(item map[string]any) []string {
+	return prioritizeFields(item, true)
+}
+
+// prioritizeFields orders fields by importance: identity → temporal → status → other.
+// When includeComplex is true, arrays and objects are included (for card layout).
+//
+// Uses exact-or-suffix matching to avoid false positives: "name" matches "Name" and
+// "UserName" but not "BuildingName" (because "Building" is not a known prefix that
+// indicates identity). The field is split on camelCase/snake_case boundaries and the
+// LAST segment is matched against patterns.
+func prioritizeFields(item map[string]any, includeComplex bool) []string {
+	// Priority tiers — matched against the last segment of the field name.
+	// "OrderDate" → last segment "date" → tier 1 (temporal).
+	// "BuildingName" → last segment "name" → tier 0... but we want to avoid this.
+	// Solution: exact match on the full lowered name OR suffix segment match,
+	// with a penalty for compound names that have a non-identity prefix.
+	type pattern struct {
+		word string
+		tier int
+	}
+	// Exact matches (full field name, case-insensitive) — highest confidence
+	exactMatches := map[string]int{
+		"id": 0, "name": 0, "title": 0, "slug": 0, "key": 0,
+		"date": 1, "created": 1, "updated": 1, "createdat": 1, "updatedat": 1,
+		"status": 2, "state": 2, "statuscode": 2,
+		"summary": 3, "description": 3, "price": 3, "amount": 3, "total": 3,
+		"cost": 3, "points": 3, "score": 3,
+		"type": 4, "kind": 4, "category": 4, "email": 4, "phone": 4, "url": 4,
+	}
+	// Suffix patterns — match when the field ends with this word (after splitting)
+	suffixMatches := map[string]int{
+		"id": 0, "name": 0, "title": 0,
+		"date": 1, "time": 1,
+		"status": 2, "state": 2, "code": 2,
+		"price": 3, "amount": 3, "total": 3, "cost": 3,
+		"summary": 3, "description": 3, "points": 3, "score": 3,
+		"type": 4, "kind": 4, "category": 4, "method": 4,
+	}
+
+	numTiers := 5
+
+	type scored struct {
+		name  string
+		tier  int
+		index int
+	}
+
+	var all []scored
+	idx := 0
+	for k, v := range item {
+		if !includeComplex {
+			switch v.(type) {
+			case []any, map[string]any:
+				continue
+			}
+		}
+		// Skip values that won't render usefully in cards
+		if includeComplex {
+			formatted := formatCellValue(v)
+			if formatted == "" {
+				continue
+			}
+		}
+
+		tier := numTiers // default: unclassified
+		lower := strings.ToLower(k)
+
+		// 1. Exact match on full field name
+		if t, ok := exactMatches[lower]; ok {
+			tier = t
+		} else {
+			// 2. Split camelCase into segments and match the last one
+			segments := splitCamelCase(lower)
+			if len(segments) > 0 {
+				lastSeg := segments[len(segments)-1]
+				if t, ok := suffixMatches[lastSeg]; ok {
+					// Compound names with identity suffixes (BuildingName, TipTime)
+					// get demoted one tier because the prefix dilutes the signal
+					if len(segments) > 1 {
+						tier = t + 1
+					} else {
+						tier = t
+					}
+				}
+			}
+		}
+
+		// Demote booleans to last
+		if _, ok := v.(bool); ok && tier >= numTiers {
+			tier = numTiers + 1
+		}
+		all = append(all, scored{name: k, tier: tier, index: idx})
+		idx++
+	}
+
+	sort.Slice(all, func(i, j int) bool {
+		if all[i].tier != all[j].tier {
+			return all[i].tier < all[j].tier
+		}
+		return all[i].index < all[j].index
+	})
+
+	headers := make([]string, len(all))
+	for i, s := range all {
+		headers[i] = s.name
+	}
+	return headers
+}
+
+// splitCamelCase splits "OrderDate" → ["order", "date"], "statusCode" → ["status", "code"],
+// "page_size" → ["page", "size"].
+func splitCamelCase(s string) []string {
+	var segments []string
+	var current strings.Builder
+	runes := []rune(s)
+	for i, r := range runes {
+		if r == '_' || r == '-' {
+			if current.Len() > 0 {
+				segments = append(segments, current.String())
+				current.Reset()
+			}
+			continue
+		}
+		if i > 0 && unicode.IsUpper(r) && unicode.IsLower(runes[i-1]) {
+			if current.Len() > 0 {
+				segments = append(segments, current.String())
+				current.Reset()
+			}
+		}
+		current.WriteRune(unicode.ToLower(r))
+	}
+	if current.Len() > 0 {
+		segments = append(segments, current.String())
+	}
+	return segments
+}
+
+// printAutoCards renders items as labeled cards — one block per item.
+// Used for complex responses with many fields or nested data.
+func printAutoCards(w io.Writer, items []map[string]any) error {
+	headers := prioritizeAllHeaders(items[0])
+
+	// Find the longest header for alignment (from fields we'll actually show)
+	maxLen := 0
+	for _, h := range headers {
+		if len(h) > maxLen {
+			maxLen = len(h)
+		}
+	}
+
+	for i, item := range items {
+		if i > 0 {
+			fmt.Fprintln(w)
+		}
+
+		// Card header: use first priority field as the card title
+		titleVal := formatCellValue(item[headers[0]])
+		if len(headers) > 1 {
+			secondVal := formatCellValue(item[headers[1]])
+			if secondVal != "" {
+				fmt.Fprintf(w, "%s %s — %s\n", bold(strings.ToUpper(headers[0])), titleVal, secondVal)
+			} else {
+				fmt.Fprintf(w, "%s %s\n", bold(strings.ToUpper(headers[0])), titleVal)
+			}
+		} else {
+			fmt.Fprintf(w, "%s %s\n", bold(strings.ToUpper(headers[0])), titleVal)
+		}
+
+		// Remaining fields indented — skip empty, zero, and false values
+		for _, h := range headers[2:] {
+			v := formatCellValue(item[h])
+			if v == "" || v == "false" || v == "0" || v == "[]" || v == "null" {
+				continue
+			}
+			// Multi-line values (nested arrays) start with \n
+			if strings.HasPrefix(v, "\n") {
+				fmt.Fprintf(w, "  %s:%s\n", h, v)
+			} else {
+				fmt.Fprintf(w, "  %-*s  %s\n", maxLen, h+":", v)
+			}
+		}
+	}
+	return nil
+}
+
+func formatCellValue(v any) string {
+	switch val := v.(type) {
+	case string:
+		// Format ISO dates as just the date portion
+		if len(val) >= 19 && val[4] == '-' && val[7] == '-' && val[10] == 'T' {
+			return val[:10]
+		}
+		return truncate(val, 60)
+	case float64:
+		if val == float64(int64(val)) {
+			return fmt.Sprintf("%d", int64(val))
+		}
+		return fmt.Sprintf("%.2f", val)
+	case bool:
+		return fmt.Sprintf("%t", val)
+	case nil:
+		return ""
+	case []any:
+		if len(val) == 0 {
+			return ""
+		}
+		// If array contains objects, format each as a summary line
+		if obj, isObj := val[0].(map[string]any); isObj {
+			_ = obj
+			return formatObjectArray(val)
+		}
+		// Flatten simple arrays into comma-separated string
+		parts := make([]string, 0, len(val))
+		for _, item := range val {
+			if s, ok := item.(string); ok {
+				parts = append(parts, s)
+			} else {
+				b, _ := json.Marshal(item)
+				parts = append(parts, string(b))
+			}
+		}
+		return truncate(strings.Join(parts, ", "), 60)
+	case map[string]any:
+		return formatSingleObject(val)
+	default:
+		b, _ := json.Marshal(val)
+		return truncate(string(b), 60)
+	}
+}
+
+// formatObjectArray renders an array of objects as multi-line summary.
+// Each object is summarized by its most descriptive fields: name/title, qty, size, price.
+func formatObjectArray(items []any) string {
+	var lines []string
+	for _, raw := range items {
+		obj, ok := raw.(map[string]any)
+		if !ok {
+			continue
+		}
+		lines = append(lines, formatObjectSummary(obj))
+	}
+	if len(lines) == 0 {
+		return ""
+	}
+	// Multi-line: newline-prefixed so the card renderer can indent
+	return "\n" + strings.Join(lines, "\n")
+}
+
+// formatObjectSummary extracts the most useful fields from an object into a one-line summary.
+// Looks for: qty/count → name/title → size → price, in that order.
+func formatObjectSummary(obj map[string]any) string {
+	var parts []string
+
+	// Quantity
+	qty := findField(obj, "qty", "count", "quantity")
+	if qty != "" && qty != "1" && qty != "0" {
+		parts = append(parts, qty+"x")
+	} else if qty == "1" {
+		parts = append(parts, "1x")
+	}
+
+	// Name — check nested objects too (e.g., Side1.Name)
+	name := findField(obj, "name", "title", "label", "description")
+	if name == "" {
+		// Check nested objects for name
+		for _, key := range []string{"Side1", "side1", "Item", "item", "Product", "product"} {
+			if nested, ok := obj[key].(map[string]any); ok {
+				name = findField(nested, "name", "title", "label")
+				if name != "" {
+					break
+				}
+			}
+		}
+	}
+	if name != "" {
+		parts = append(parts, name)
+	}
+
+	// Size
+	size := findField(obj, "sizename", "size_name")
+	if size == "" {
+		size = findField(obj, "catname", "cat_name", "category")
+	}
+	if size != "" {
+		parts = append(parts, "—")
+		parts = append(parts, size)
+	}
+
+	// Price
+	price := findField(obj, "extprice", "price", "amount", "total")
+	if price != "" && price != "0" {
+		parts = append(parts, fmt.Sprintf("($%s)", price))
+	}
+
+	if len(parts) == 0 {
+		// Fallback: JSON summary
+		b, _ := json.Marshal(obj)
+		return truncate(string(b), 80)
+	}
+	return "    " + strings.Join(parts, " ")
+}
+
+// formatSingleObject renders a single object by its most descriptive fields.
+func formatSingleObject(obj map[string]any) string {
+	name := findField(obj, "name", "title", "label", "description")
+	if name != "" {
+		return name
+	}
+	id := findField(obj, "id", "key", "code")
+	if id != "" {
+		return id
+	}
+	return ""
+}
+
+// findField searches an object for a field name (case-insensitive) and returns its formatted value.
+func findField(obj map[string]any, names ...string) string {
+	for _, name := range names {
+		for k, v := range obj {
+			if strings.EqualFold(k, name) {
+				return formatCellValue(v)
+			}
+		}
+	}
+	return ""
+}
+// DataProvenance describes where data came from and when it was last synced.
+type DataProvenance struct {
+	Source       string     `json:"source"`                  // "live" or "local"
+	SyncedAt    *time.Time `json:"synced_at,omitempty"`     // when local data was last synced
+	Reason      string     `json:"reason,omitempty"`        // why local was used: "user_requested", "api_unreachable", "no_search_endpoint"
+	ResourceType string    `json:"resource_type,omitempty"` // which resource type was queried
+	Freshness    any        `json:"freshness,omitempty"`     // optional machine-owned freshness metadata for covered command paths
+}
+
+// printProvenance writes a one-line provenance message to stderr for TTY users.
+// Suppressed when stdout is piped or redirected — the JSON response envelope
+// already carries meta.source, so the stderr line is redundant and becomes
+// noise in agent flows that merge stderr into stdout.
+func printProvenance(cmd *cobra.Command, count int, prov DataProvenance) {
+	if !isTerminal(cmd.OutOrStdout()) {
+		return
+	}
+	if prov.Source == "live" {
+		fmt.Fprintf(cmd.ErrOrStderr(), "%d results (live)\n", count)
+		return
+	}
+	age := "unknown"
+	if prov.SyncedAt != nil {
+		d := time.Since(*prov.SyncedAt)
+		switch {
+		case d < time.Minute:
+			age = "just now"
+		case d < time.Hour:
+			age = fmt.Sprintf("%d minutes ago", int(d.Minutes()))
+		case d < 24*time.Hour:
+			age = fmt.Sprintf("%d hours ago", int(d.Hours()))
+		default:
+			age = fmt.Sprintf("%d days ago", int(d.Hours()/24))
+		}
+	}
+	prefix := ""
+	if prov.Reason == "api_unreachable" {
+		prefix = "API unreachable. "
+	}
+	fmt.Fprintf(cmd.ErrOrStderr(), "%s%d results (cached, synced %s)\n", prefix, count, age)
+}
+
+// wrapWithProvenance wraps JSON data in a provenance envelope: {"results": ..., "meta": {...}}.
+func wrapWithProvenance(data json.RawMessage, prov DataProvenance) (json.RawMessage, error) {
+	meta := map[string]any{"source": prov.Source}
+	if prov.SyncedAt != nil {
+		meta["synced_at"] = prov.SyncedAt.UTC().Format(time.RFC3339)
+	}
+	if prov.Reason != "" {
+		meta["reason"] = prov.Reason
+	}
+	if prov.ResourceType != "" {
+		meta["resource_type"] = prov.ResourceType
+	}
+	if prov.Freshness != nil {
+		meta["freshness"] = prov.Freshness
+	}
+	envelope := map[string]any{
+		"results": json.RawMessage(data),
+		"meta":    meta,
+	}
+	return json.Marshal(envelope)
+}
+
+// wrapResultsWithFreshness gives hand-authored commands a small opt-in helper
+// for the generated provenance envelope without forcing arbitrary custom JSON
+// outputs to change shape.
+func wrapResultsWithFreshness(data json.RawMessage, flags *rootFlags) (json.RawMessage, error) {
+	prov := DataProvenance{}
+	if flags != nil {
+		prov.Source = flags.dataSource
+		prov.Freshness = flags.freshnessMeta
+	}
+	return wrapWithProvenance(data, prov)
+}
+
+// defaultDBPath returns the canonical path for the local SQLite database.
+func defaultDBPath(name string) string {
+	home, _ := os.UserHomeDir()
+	return filepath.Join(home, ".local", "share", name, "data.db")
+}
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cliutil/text.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cliutil/text.go
new file mode 100644
index 00000000..682d77d3
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cliutil/text.go
@@ -0,0 +1,83 @@
+// Copyright 2026 printing-press-golden. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cliutil
+
+import (
+	"html"
+	"regexp"
+	"strings"
+	"time"
+)
+
+// CleanText normalizes scraped text by trimming whitespace and decoding
+// HTML entities. Always use this when extracting strings from HTML or
+// schema.org JSON-LD. Skipping this step is how recipe-goat's
+// "The Food Lab&#39;s" bug shipped: schema.org strings passed through
+// unescaped because the JSON-LD parser didn't normalize.
+//
+// Single unescape pass: "&amp;amp;" -> "&amp;" (matches html.UnescapeString
+// stdlib behavior). If you need multiple passes you almost always have a
+// deeper escaping problem upstream — fix there, not here.
+func CleanText(s string) string {
+	return html.UnescapeString(strings.TrimSpace(s))
+}
+
+// ParseStoredTime parses timestamps read back from SQLite-backed generated
+// stores. modernc.org/sqlite can serialize time.Time using Go's native
+// time.String format, while hand-written sync code often stores RFC3339.
+// Use this helper instead of a single time.Parse(time.RFC3339, value) call
+// when scanning timestamp columns from the store.
+func ParseStoredTime(s string) time.Time {
+	s = strings.TrimSpace(s)
+	if s == "" {
+		return time.Time{}
+	}
+	for _, layout := range []string{
+		time.RFC3339Nano,
+		time.RFC3339,
+		"2006-01-02 15:04:05.999999999 -0700 MST",
+		"2006-01-02 15:04:05.999999 -0700 MST",
+		"2006-01-02 15:04:05.999 -0700 MST",
+		"2006-01-02 15:04:05 -0700 MST",
+		"2006-01-02 15:04:05.999999999 -0700",
+		"2006-01-02 15:04:05 -0700",
+	} {
+		if t, err := time.Parse(layout, s); err == nil {
+			return t
+		}
+	}
+	return time.Time{}
+}
+
+// LooksLikeAuthError checks if an error message body contains auth-related keywords.
+func LooksLikeAuthError(msg string) bool {
+	lower := strings.ToLower(msg)
+	patterns := []string{
+		`\bkey\b`,
+		`\btoken\b`,
+		`\bunauthorized\b`,
+		`\bapi_key\b`,
+		`missing.{0,20}key`,
+		`required.{0,20}key`,
+		`\bforbidden\b`,
+		`\bauthenticat`,
+		`\bcredential`,
+	}
+	for _, p := range patterns {
+		if matched, _ := regexp.MatchString(p, lower); matched {
+			return true
+		}
+	}
+	return false
+}
+
+// SanitizeErrorBody truncates and strips credential-shaped strings from error output.
+func SanitizeErrorBody(msg string) string {
+	if len(msg) > 200 {
+		msg = msg[:200] + "..."
+	}
+	credPatterns := regexp.MustCompile(`(?i)(sk-[a-zA-Z0-9]{8,}|sk_live_[a-zA-Z0-9]+|Bearer\s+[a-zA-Z0-9._\-]+|key=[a-zA-Z0-9._\-]+)`)
+	msg = credPatterns.ReplaceAllString(msg, "[REDACTED]")
+	return msg
+}
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/mcp/tools.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/mcp/tools.go
index 8346b0dd..8513a7c5 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/mcp/tools.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/mcp/tools.go
@@ -9,47 +9,16 @@ import (
 	"fmt"
 	"os"
 	"path/filepath"
-	"regexp"
 	"strings"
 	"time"
 
 	mcplib "github.com/mark3labs/mcp-go/mcp"
 	"github.com/mark3labs/mcp-go/server"
+	"printing-press-golden-pp-cli/internal/cliutil"
 	"printing-press-golden-pp-cli/internal/client"
 	"printing-press-golden-pp-cli/internal/config"
 	"printing-press-golden-pp-cli/internal/store"
 )
-// looksLikeAuthError checks if an error message body contains auth-related keywords.
-func looksLikeAuthError(msg string) bool {
-	lower := strings.ToLower(msg)
-	patterns := []string{
-		`\bkey\b`,
-		`\btoken\b`,
-		`\bunauthorized\b`,
-		`\bapi_key\b`,
-		`missing.{0,20}key`,
-		`required.{0,20}key`,
-		`\bforbidden\b`,
-		`\bauthenticat`,
-		`\bcredential`,
-	}
-	for _, p := range patterns {
-		if matched, _ := regexp.MatchString(p, lower); matched {
-			return true
-		}
-	}
-	return false
-}
-
-// sanitizeErrorBody truncates and strips credential-shaped strings from error output.
-func sanitizeErrorBody(msg string) string {
-	if len(msg) > 200 {
-		msg = msg[:200] + "..."
-	}
-	credPatterns := regexp.MustCompile(`(?i)(sk-[a-zA-Z0-9]{8,}|sk_live_[a-zA-Z0-9]+|Bearer\s+[a-zA-Z0-9._\-]+|key=[a-zA-Z0-9._\-]+)`)
-	msg = credPatterns.ReplaceAllString(msg, "[REDACTED]")
-	return msg
-}
 
 // RegisterTools registers all API operations as MCP tools.
 func RegisterTools(s *server.MCPServer) {
@@ -188,18 +157,18 @@ func makeAPIHandler(method, pathTemplate string, positionalParams []string) serv
 			switch {
 			case strings.Contains(msg, "HTTP 409"):
 				return mcplib.NewToolResultText("already exists (no-op)"), nil
-			case strings.Contains(msg, "HTTP 400") && looksLikeAuthError(msg):
-				return mcplib.NewToolResultError("authentication error: " + sanitizeErrorBody(msg) +
+			case strings.Contains(msg, "HTTP 400") && cliutil.LooksLikeAuthError(msg):
+				return mcplib.NewToolResultError("authentication error: " + cliutil.SanitizeErrorBody(msg) +
 					"\nhint: the API rejected the request — this usually means auth is missing or invalid." +
 					"\n      Set your API key: export PRINTING_PRESS_GOLDEN_API_KEY_AUTH=<your-key>" +
 					"\n      Run 'printing-press-golden-pp-cli doctor' to check auth status."), nil
 			case strings.Contains(msg, "HTTP 401"):
-				return mcplib.NewToolResultError("authentication failed: " + sanitizeErrorBody(msg) +
+				return mcplib.NewToolResultError("authentication failed: " + cliutil.SanitizeErrorBody(msg) +
 					"\nhint: check your API key." +
 					"\n      Set it with: export PRINTING_PRESS_GOLDEN_API_KEY_AUTH=<your-key>" +
 					"\n      Run 'printing-press-golden-pp-cli doctor' to check auth status."), nil
 			case strings.Contains(msg, "HTTP 403"):
-				return mcplib.NewToolResultError("permission denied: " + sanitizeErrorBody(msg) +
+				return mcplib.NewToolResultError("permission denied: " + cliutil.SanitizeErrorBody(msg) +
 					"\nhint: your credentials are valid but lack access to this resource." +
 					"\n      Set it with: export PRINTING_PRESS_GOLDEN_API_KEY_AUTH=<your-key>" +
 					"\n      Run 'printing-press-golden-pp-cli doctor' to check auth status."), nil

← fd2ea4f4 refactor(cli): simplify refactor-safe helpers (#311)  ·  back to Cli Printing Press  ·  refactor(cli): split generator render stages (#313) 79b134aa →