← back to Cli Printing Press
refactor(cli): share generated auth helpers (#312)
b8bb5c1ee3884526cb4dfec04379ff22a20c559f · 2026-04-26 14:19:01 -0700 · Trevin Chow
Files touched
M internal/generator/generator_test.goM internal/generator/templates/cliutil_test.go.tmplM internal/generator/templates/cliutil_text.go.tmplM internal/generator/templates/helpers.go.tmplM internal/generator/templates/mcp_tools.go.tmplM testdata/golden/cases/generate-golden-api/artifacts.txtM testdata/golden/expected/generate-golden-api/dogfood.jsonA testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/helpers.goA testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cliutil/text.goM testdata/golden/expected/generate-golden-api/printing-press-golden/internal/mcp/tools.go
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's" bug shipped: schema.org strings passed through
+// unescaped because the JSON-LD parser didn't normalize.
+//
+// Single unescape pass: "&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 →