[object Object]

← back to Cli Printing Press

fix(cli): require explicit retry no-ops (#635)

cb3ef87308c006da61b3b5e7305b0b2095540f15 · 2026-05-05 13:43:50 -0700 · Trevin Chow

* fix(cli): require explicit retry no-ops

* docs(cli): document generated helper contracts

Files touched

Diff

commit cb3ef87308c006da61b3b5e7305b0b2095540f15
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Tue May 5 13:43:50 2026 -0700

    fix(cli): require explicit retry no-ops (#635)
    
    * fix(cli): require explicit retry no-ops
    
    * docs(cli): document generated helper contracts
---
 ...ient-cache-invalidate-on-mutation-2026-05-05.md |   2 +-
 ...otent-noops-and-export-validation-2026-05-05.md | 165 ++++++++++++++++++
 internal/generator/generator.go                    |  19 +-
 internal/generator/generator_test.go               | 192 +++++++++++++++++++++
 .../generator/templates/command_endpoint.go.tmpl   |   4 +-
 .../generator/templates/command_promoted.go.tmpl   |   2 +-
 internal/generator/templates/export.go.tmpl        |  20 ++-
 internal/generator/templates/helpers.go.tmpl       |  45 +++--
 internal/generator/templates/readme.md.tmpl        |   2 +-
 internal/generator/templates/root.go.tmpl          |   8 +
 internal/generator/templates/search.go.tmpl        |   2 +-
 internal/generator/templates/skill.md.tmpl         |   2 +
 internal/generator/vision_templates.go             |  35 ++++
 .../expected/generate-golden-api/dogfood.json      |   4 +-
 .../printing-press-golden/README.md                |   2 +-
 .../printing-press-golden/SKILL.md                 |   1 +
 .../printing-press-golden/internal/cli/helpers.go  |  34 +++-
 .../internal/cli/projects_create.go                |   2 +-
 .../internal/cli/projects_list.go                  |   2 +-
 .../internal/cli/projects_tasks_list-project.go    |   2 +-
 .../internal/cli/projects_tasks_update-project.go  |   2 +-
 .../internal/cli/promoted_public.go                |   2 +-
 .../printing-press-golden/internal/cli/root.go     |   2 +
 .../internal/cli/items_enterprise.go               |   2 +-
 .../tier-routing-golden/internal/cli/items_list.go |   2 +-
 .../internal/cli/items_premium.go                  |   2 +-
 26 files changed, 518 insertions(+), 39 deletions(-)

diff --git a/docs/solutions/design-patterns/http-client-cache-invalidate-on-mutation-2026-05-05.md b/docs/solutions/design-patterns/http-client-cache-invalidate-on-mutation-2026-05-05.md
index f8dda5c2..40554e03 100644
--- a/docs/solutions/design-patterns/http-client-cache-invalidate-on-mutation-2026-05-05.md
+++ b/docs/solutions/design-patterns/http-client-cache-invalidate-on-mutation-2026-05-05.md
@@ -139,5 +139,5 @@ Both legs (MCP-side `NoCache=true` from PR #521 (session history) and CLI-side `
 - **PR #213** (`mvanhorn/printing-press-library`) — bulk backfill of `NoCache=true` across 38 published CLIs that pre-dated PR #521. (session history)
 - **Issue #515** — Dub retro that originally surfaced the MCP-side stale-read on Create → warm-cache → Delete → stale 200. (session history)
 - **PR #237** (`mvanhorn/printing-press-library`) commit `d26a2862` — the cal-com CLI-side `invalidateCache()` fix this doc captures. Printed-CLI-level patch; does NOT yet propagate to other CLIs.
-- **Generator-template gap** (open) — emit `invalidateCache()` and the `do()` success-branch hook in every printed CLI's `client.go` template so future CLIs inherit it without per-CLI patching. Worth filing as a P1 retro candidate alongside the cal-com retro #597 series; the same template that PR #521 touched is the obvious landing site.
+- **Issue #603** (closed 2026-05-05) — emits `invalidateCache()` and the `do()` success-branch hook in every printed CLI's `client.go` template so future CLIs inherit it without per-CLI patching.
 - **MEMORY.md note** "MCP server should default to NoCache=true" (auto memory [claude]) — captures the MCP-side workaround. With this fix, the note's "agents driving the API through MCP get stale snapshots after DELETE/PATCH" caveat is now resolved at the client level; `NoCache=true` remains the right MCP default for freshness reasons unrelated to mutations.
diff --git a/docs/solutions/logic-errors/generated-cli-idempotent-noops-and-export-validation-2026-05-05.md b/docs/solutions/logic-errors/generated-cli-idempotent-noops-and-export-validation-2026-05-05.md
new file mode 100644
index 00000000..d1b524bc
--- /dev/null
+++ b/docs/solutions/logic-errors/generated-cli-idempotent-noops-and-export-validation-2026-05-05.md
@@ -0,0 +1,165 @@
+---
+title: Generated CLI retry no-ops and export commands require explicit API-shape checks
+date: 2026-05-05
+category: logic-errors
+module: cli-printing-press-generator
+problem_type: logic_error
+component: tooling
+symptoms:
+  - "Generated helpers silently swallowed HTTP 409 create conflicts and DELETE 404s"
+  - "Generated export was emitted unconditionally and accepted resources without a bare GET collection endpoint"
+  - "Export typo arguments fell through to upstream HTTP responses instead of failing as usage errors"
+root_cause: missing_validation
+resolution_type: code_fix
+severity: high
+tags:
+  - generated-cli
+  - idempotency
+  - export
+  - http-errors
+  - json-output
+  - generator-templates
+---
+
+# Generated CLI retry no-ops and export commands require explicit API-shape checks
+
+## Problem
+
+The Printing Press generator emitted two helper surfaces that treated API state and API shape as implicit:
+
+- Create helpers converted HTTP 409 conflicts into successful no-ops without the caller opting into retry semantics.
+- Delete helpers converted HTTP 404 responses into successful no-ops without the caller saying a missing target was acceptable.
+- The generic `export` command was generated for API specs that had resources but no bare `GET /<resource>` collection endpoint.
+
+Those behaviors look convenient in one retry path, but they compound poorly across every printed CLI. A real create conflict or delete miss becomes indistinguishable from a successful mutation, and an export command can exist for a resource the API cannot actually export.
+
+## Symptoms
+
+- A generated create command could exit 0 after an upstream HTTP 409, masking "already exists" as success.
+- A generated delete command could exit 0 after an upstream HTTP 404, masking "already deleted" as success.
+- Under `--json`, the no-op path did not have a structured envelope that machines could distinguish from a normal success payload.
+- `export <resource>` accepted arbitrary resource names before checking generated metadata, so typos could fall through into config loading, client setup, and upstream HTTP responses.
+- In session history, the Hacker News reprint surfaced the same export-shape bug: `export stories` returned Google sign-in HTML because the Firebase API uses `/topstories.json` and `/item/<id>.json`, not a bare `GET /stories` collection endpoint.
+
+## What Didn't Work
+
+Fixing one printed CLI would not compound. The issue was in the generator templates, so every future CLI needed the safer default.
+
+Helper-level error classification alone was too implicit. The templates could detect "HTTP 409" or "HTTP 404", but without a root flag they had no signal that the caller was intentionally running an idempotent retry flow.
+
+Dogfood evidence alone was noisy. Placeholder-heavy printed CLIs can fail export in several ways; the generator rule needed to be tied to actual endpoint shape, not just one API's confusing response body.
+
+Generic resource-name export overfit the resource inventory. A resource list says what the API talks about; it does not prove the API has a collection endpoint suitable for streaming/export.
+
+## Solution
+
+### 1. Require an explicit root flag before returning a retry no-op
+
+Generated CLIs now expose opt-in root flags for the retry cases:
+
+```go
+rootCmd.PersistentFlags().BoolVar(&flags.idempotent, "idempotent", false, "Treat already-existing create results as a successful no-op")
+rootCmd.PersistentFlags().BoolVar(&flags.ignoreMissing, "ignore-missing", false, "Treat missing delete targets as a successful no-op")
+```
+
+The generated helpers thread the root flags into the error classifiers. A conflict only becomes exit 0 when the user opted in:
+
+```go
+case strings.Contains(msg, "HTTP 409"):
+	if flags != nil && flags.idempotent {
+		return writeNoop(flags, "already_exists", "already exists (no-op)")
+	}
+	classified := apiErr(err)
+	writeAPIErrorEnvelope(flags, classified, ExitCode(classified))
+	return classified
+```
+
+Delete uses the same shape for missing targets:
+
+```go
+if strings.Contains(msg, "HTTP 404") && flags != nil && flags.ignoreMissing {
+	return writeNoop(flags, "already_deleted", "already deleted (no-op)")
+}
+```
+
+With `--json`, those opt-in no-ops emit a machine-readable envelope:
+
+```json
+{"status":"noop","reason":"already_deleted","message":"already deleted (no-op)"}
+```
+
+### 2. Emit export only for actual bare collection endpoints
+
+The export template is now conditional. It is emitted only when the spec has at least one resource backed by a bare `GET /<resource>` endpoint:
+
+```go
+func constrainVisionTemplates(api *spec.APISpec, set VisionTemplateSet) VisionTemplateSet {
+	if set.Export && len(exportableResources(api)) == 0 {
+		set.Export = false
+	}
+	return set
+}
+```
+
+The resource discovery intentionally checks endpoint shape, not just resource names:
+
+```go
+if endpoint.Method == "GET" && endpoint.Path == "/"+resourceName {
+	resources = append(resources, resourceName)
+}
+```
+
+### 3. Validate export arguments before config and API work
+
+Generated export commands build a static valid-resource set from the exportable endpoints and reject unknown resources at argument validation time:
+
+```go
+validResources := map[string]bool{
+	"items": true,
+}
+
+if !validResources[resource] {
+	return usageErr("unknown export resource %q (valid: %s)", resource, strings.Join(exportableResources, ", "))
+}
+```
+
+That keeps typos and unsupported resources in CLI-usage territory instead of turning them into auth, config, network, or upstream-content failures.
+
+### 4. Lock the behavior at the generator layer
+
+Cover the new contract with generator/template tests plus goldens:
+
+- `TestGeneratedHelpers_IdempotentNoopsRequireOptIn`
+- `TestGeneratedExport_ValidatesResourceArgument`
+- `TestGeneratedExport_OmittedWithoutBareCollectionEndpoint`
+- `scripts/golden.sh verify`
+
+The tests matter because both fixes are emitted behavior. A passing Go unit test on one helper is not enough if a future template path can silently regenerate the old surface.
+
+## Why This Works
+
+The fix separates "the HTTP request succeeded" from "the caller accepts this retry state." A create conflict and a delete miss remain errors by default; explicit retry flags are the only path that turns them into successful no-ops.
+
+The JSON no-op envelope gives agents a stable branch that is distinct from a normal success payload. They can treat `status: "noop"` as intentional control flow instead of inferring semantics from free-form text or exit code alone.
+
+The export command now mirrors the API contract rather than the resource inventory. If the API has no bare collection endpoint, the generated CLI does not promise a generic export workflow it cannot honor.
+
+Early argument validation also avoids avoidable side effects. When a resource can be validated from generated metadata, do that before loading config, building clients, or making network calls.
+
+## Prevention
+
+- For generated helpers, require an explicit root flag before converting an upstream 4xx into exit 0.
+- Gate generated command emission on actual endpoint capability, not inferred resource names.
+- Validate arguments backed solely by generated metadata before config, client, auth, or API work.
+- When a generator change affects CLI surface or machine-readable output, add focused generator tests and golden coverage in the same PR.
+- Use printed-CLI dogfood to decide whether a finding is systemic or API-specific, then land systemic fixes in generator templates.
+
+## Related
+
+- [Issue #599](https://github.com/mvanhorn/cli-printing-press/issues/599) - idempotent helpers respect `--json` and require opt-in no-ops.
+- [Issue #593](https://github.com/mvanhorn/cli-printing-press/issues/593) - export validates resource arguments and emits conditionally.
+- [Issue #597](https://github.com/mvanhorn/cli-printing-press/issues/597) - retro series that included the retry no-op finding.
+- [Issue #586](https://github.com/mvanhorn/cli-printing-press/issues/586) - retro series that included the export-shape finding.
+- [PR #635](https://github.com/mvanhorn/cli-printing-press/pull/635) - generator fix for both contracts.
+- [HTTP client response cache must invalidate on successful mutations](../design-patterns/http-client-cache-invalidate-on-mutation-2026-05-05.md) - neighboring generator-template mutation-safety pattern.
+- [Dry-run is the default for mutator probes in generated test harnesses](../design-patterns/dry-run-default-for-mutator-probes-in-test-harnesses-2026-05-05.md) - sibling pattern for making mutation intent explicit.
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 8bf275b9..56b9c00b 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -244,12 +244,13 @@ func New(s *spec.APISpec, outputDir string) *Generator {
 		"graphqlFieldSelection": func(typeName string, types map[string]spec.TypeDef) []string {
 			return graphqlFieldSelection(typeName, types)
 		},
-		"isGraphQL":   isGraphQLSpec,
-		"backtick":    func() string { return "`" },
-		"kebab":       toKebab,
-		"humanName":   naming.HumanName,
-		"envPrefix":   naming.EnvPrefix,
-		"mcpToolName": naming.SnakeIdentifier,
+		"isGraphQL":           isGraphQLSpec,
+		"exportableResources": exportableResources,
+		"backtick":            func() string { return "`" },
+		"kebab":               toKebab,
+		"humanName":           naming.HumanName,
+		"envPrefix":           naming.EnvPrefix,
+		"mcpToolName":         naming.SnakeIdentifier,
 		"lookupEndpoint": func(api *spec.APISpec, ref string) templateEndpoint {
 			e, _ := lookupEndpointForTemplate(api, ref)
 			return e
@@ -645,6 +646,7 @@ type readmeTemplateData struct {
 	HasDataLayer      bool
 	HasAsyncJobs      bool
 	HasWriteCommands  bool
+	HasDelete         bool
 	HasAuth           bool
 	FreshnessCommands []string
 	TrafficAnalysis   *trafficAnalysisTemplateData
@@ -699,6 +701,7 @@ func (g *Generator) readmeData() *readmeTemplateData {
 		HasDataLayer:          g.VisionSet.Store,
 		HasAsyncJobs:          len(g.AsyncJobs) > 0,
 		HasWriteCommands:      hasWriteCommands(g.Spec.Resources),
+		HasDelete:             computeHelperFlags(g.Spec).HasDelete,
 		HasAuth:               hasAuth(g.Spec.Auth),
 		FreshnessCommands:     g.freshnessCommandPaths(),
 		TrafficAnalysis:       g.trafficAnalysisData(),
@@ -1199,6 +1202,7 @@ func (g *Generator) prepareOutput() error {
 	if g.profile == nil {
 		g.profile = profiler.Profile(g.Spec)
 	}
+	g.VisionSet = constrainVisionTemplates(g.Spec, g.VisionSet)
 	if err := g.validateFreshnessCommandCoverage(); err != nil {
 		return err
 	}
@@ -2070,6 +2074,7 @@ func (g *Generator) renderRootProjectFiles(promotedCommands []PromotedCommand, p
 	// rootCmd.AddCommand(newAuthCmd) so the root binary does not reference an
 	// undefined symbol when auth.go was skipped.
 	hasAuthCommand := g.shouldEmitAuth()
+	helperFlags := computeHelperFlags(g.Spec)
 
 	rootData := struct {
 		*spec.APISpec
@@ -2085,6 +2090,7 @@ func (g *Generator) renderRootProjectFiles(promotedCommands []PromotedCommand, p
 		HasAsyncJobs          bool
 		AsyncJobCount         int
 		HasAuthCommand        bool
+		HasDelete             bool
 	}{
 		APISpec:               g.Spec,
 		VisionSet:             g.VisionSet,
@@ -2099,6 +2105,7 @@ func (g *Generator) renderRootProjectFiles(promotedCommands []PromotedCommand, p
 		HasAsyncJobs:          len(g.AsyncJobs) > 0,
 		AsyncJobCount:         len(g.AsyncJobs),
 		HasAuthCommand:        hasAuthCommand,
+		HasDelete:             helperFlags.HasDelete,
 	}
 	if err := g.renderTemplate("root.go.tmpl", filepath.Join("internal", "cli", "root.go"), rootData); err != nil {
 		return fmt.Errorf("rendering root: %w", err)
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index ac20bb17..0f36e59e 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -2,6 +2,7 @@ package generator
 
 import (
 	"encoding/json"
+	"errors"
 	"fmt"
 	"go/parser"
 	"go/token"
@@ -2879,6 +2880,197 @@ func TestGeneratedHelpers_ConditionalClassifyDeleteError(t *testing.T) {
 	})
 }
 
+func TestGeneratedHelpers_IdempotentNoopsRequireOptIn(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := &spec.APISpec{
+		Name:    "testidempotent",
+		Version: "0.1.0",
+		BaseURL: "https://api.example.com",
+		Resources: map[string]spec.Resource{
+			"teams": {
+				Description: "Manage teams",
+				Endpoints: map[string]spec.Endpoint{
+					"create": {Method: "POST", Path: "/teams", Description: "Create team"},
+					"delete": {Method: "DELETE", Path: "/teams/{id}", Description: "Delete team"},
+				},
+			},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), "testidempotent-pp-cli")
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	testPath := filepath.Join(outputDir, "internal", "cli", "idempotent_helpers_test.go")
+	inlineTest := `package cli
+
+import (
+	"encoding/json"
+	"errors"
+	"io"
+	"os"
+	"testing"
+)
+
+func captureStdoutStderr(t *testing.T, fn func() error) (string, string, error) {
+	t.Helper()
+	oldOut := os.Stdout
+	oldErr := os.Stderr
+	outR, outW, err := os.Pipe()
+	if err != nil {
+		t.Fatal(err)
+	}
+	errR, errW, err := os.Pipe()
+	if err != nil {
+		t.Fatal(err)
+	}
+	os.Stdout = outW
+	os.Stderr = errW
+	callErr := fn()
+	_ = outW.Close()
+	_ = errW.Close()
+	os.Stdout = oldOut
+	os.Stderr = oldErr
+	out, err := io.ReadAll(outR)
+	if err != nil {
+		t.Fatal(err)
+	}
+	errOut, err := io.ReadAll(errR)
+	if err != nil {
+		t.Fatal(err)
+	}
+	return string(out), string(errOut), callErr
+}
+
+func requireNoopJSON(t *testing.T, body, reason string) {
+	t.Helper()
+	var got map[string]string
+	if err := json.Unmarshal([]byte(body), &got); err != nil {
+		t.Fatalf("noop output must be JSON: %v; body=%q", err, body)
+	}
+	if got["status"] != "noop" || got["reason"] != reason {
+		t.Fatalf("unexpected noop envelope: %#v", got)
+	}
+}
+
+func TestClassifyAPIError409RequiresIdempotent(t *testing.T) {
+	err := classifyAPIError(errors.New("HTTP 409: conflict"), &rootFlags{})
+	if err == nil {
+		t.Fatal("409 without --idempotent must be an error")
+	}
+	if ExitCode(err) != 5 {
+		t.Fatalf("409 should classify as API error, got exit %d", ExitCode(err))
+	}
+
+	stdout, stderr, err := captureStdoutStderr(t, func() error {
+		return classifyAPIError(errors.New("HTTP 409: conflict"), &rootFlags{idempotent: true, asJSON: true})
+	})
+	if err != nil {
+		t.Fatalf("idempotent 409 returned error: %v", err)
+	}
+	if stderr != "" {
+		t.Fatalf("json noop should not write stderr, got %q", stderr)
+	}
+	requireNoopJSON(t, stdout, "already_exists")
+}
+
+func TestClassifyDeleteError404RequiresIgnoreMissing(t *testing.T) {
+	err := classifyDeleteError(errors.New("HTTP 404: not found"), &rootFlags{})
+	if err == nil {
+		t.Fatal("404 delete without --ignore-missing must be an error")
+	}
+	if ExitCode(err) != 3 {
+		t.Fatalf("404 should classify as not found, got exit %d", ExitCode(err))
+	}
+
+	stdout, stderr, err := captureStdoutStderr(t, func() error {
+		return classifyDeleteError(errors.New("HTTP 404: not found"), &rootFlags{ignoreMissing: true, asJSON: true})
+	})
+	if err != nil {
+		t.Fatalf("ignore-missing 404 returned error: %v", err)
+	}
+	if stderr != "" {
+		t.Fatalf("json noop should not write stderr, got %q", stderr)
+	}
+	requireNoopJSON(t, stdout, "already_deleted")
+}
+`
+	require.NoError(t, os.WriteFile(testPath, []byte(inlineTest), 0o644))
+
+	runGoCommandRequired(t, outputDir, "test", "./internal/cli")
+}
+
+func TestGeneratedExport_ValidatesResourceArgument(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := &spec.APISpec{
+		Name:    "testexport",
+		Version: "0.1.0",
+		BaseURL: "https://api.example.com",
+		Config:  spec.ConfigSpec{Format: "toml", Path: "~/.config/testexport-pp-cli/config.toml"},
+		Resources: map[string]spec.Resource{
+			"stories": {Endpoints: map[string]spec.Endpoint{"list": {Method: "GET", Path: "/stories"}}},
+			"items":   {Endpoints: map[string]spec.Endpoint{"list": {Method: "GET", Path: "/items"}}},
+			"users":   {Endpoints: map[string]spec.Endpoint{"list": {Method: "GET", Path: "/users"}}},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), "testexport-pp-cli")
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	rootGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "root.go"))
+	require.NoError(t, err)
+	assert.Contains(t, string(rootGo), "rootCmd.AddCommand(newExportCmd(flags))")
+
+	exportGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "export.go"))
+	require.NoError(t, err)
+	exportContent := string(exportGo)
+	assert.Contains(t, exportContent, `"items": true`)
+	assert.Contains(t, exportContent, `"stories": true`)
+	assert.Contains(t, exportContent, `"users": true`)
+	assert.Contains(t, exportContent, `unknown resource %q; valid: %s`)
+
+	runGoCommandRequired(t, outputDir, "build", "-o", "./testexport-pp-cli", "./cmd/testexport-pp-cli")
+	cmd := exec.Command(filepath.Join(outputDir, "testexport-pp-cli"), "export", "storiez")
+	out, err := cmd.CombinedOutput()
+	require.Error(t, err)
+	assert.Contains(t, string(out), `unknown resource "storiez"; valid: items, stories, users`)
+	if exitErr, ok := err.(*exec.ExitError); ok {
+		assert.Equal(t, 2, exitErr.ExitCode())
+	} else {
+		t.Fatalf("expected ExitError, got %T", err)
+	}
+}
+
+func TestGeneratedExport_OmittedWithoutBareCollectionEndpoint(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := &spec.APISpec{
+		Name:    "testnoexport",
+		Version: "0.1.0",
+		BaseURL: "https://api.example.com",
+		Config:  spec.ConfigSpec{Format: "toml", Path: "~/.config/testnoexport-pp-cli/config.toml"},
+		Resources: map[string]spec.Resource{
+			"stories": {Endpoints: map[string]spec.Endpoint{"get": {Method: "GET", Path: "/api/v1/stories/{id}"}}},
+			"items":   {Endpoints: map[string]spec.Endpoint{"get": {Method: "GET", Path: "/api/v1/items/{id}"}}},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), "testnoexport-pp-cli")
+	gen := New(apiSpec, outputDir)
+	gen.VisionSet = VisionTemplateSet{Export: true}
+	require.NoError(t, gen.Generate())
+
+	rootGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "root.go"))
+	require.NoError(t, err)
+	assert.NotContains(t, string(rootGo), "newExportCmd")
+
+	_, err = os.Stat(filepath.Join(outputDir, "internal", "cli", "export.go"))
+	assert.True(t, errors.Is(err, os.ErrNotExist), "export.go should not be emitted for APIs without bare collection endpoints")
+
+	runGoCommandRequired(t, outputDir, "build", "./cmd/testnoexport-pp-cli")
+}
+
 func TestGeneratedHelpers_ConditionalDataLayerFunctions(t *testing.T) {
 	t.Parallel()
 
diff --git a/internal/generator/templates/command_endpoint.go.tmpl b/internal/generator/templates/command_endpoint.go.tmpl
index 92c62328..3dce55e0 100644
--- a/internal/generator/templates/command_endpoint.go.tmpl
+++ b/internal/generator/templates/command_endpoint.go.tmpl
@@ -320,11 +320,11 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
 {{- end}}
 {{- if eq .Endpoint.Method "DELETE"}}
 			if err != nil {
-				return classifyDeleteError(err)
+				return classifyDeleteError(err, flags)
 			}
 {{- else}}
 			if err != nil {
-				return classifyAPIError(err)
+				return classifyAPIError(err, flags)
 			}
 {{- end}}
 {{- if endpointNeedsClientLimit .Endpoint}}
diff --git a/internal/generator/templates/command_promoted.go.tmpl b/internal/generator/templates/command_promoted.go.tmpl
index ef768ddb..9cc6a463 100644
--- a/internal/generator/templates/command_promoted.go.tmpl
+++ b/internal/generator/templates/command_promoted.go.tmpl
@@ -191,7 +191,7 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
 {{- end}}
 {{- end}}
 			if err != nil {
-				return classifyAPIError(err)
+				return classifyAPIError(err, flags)
 			}
 
 {{- if .Endpoint.UsesHTMLResponse}}
diff --git a/internal/generator/templates/export.go.tmpl b/internal/generator/templates/export.go.tmpl
index 7d9c3870..3b2eaa0e 100644
--- a/internal/generator/templates/export.go.tmpl
+++ b/internal/generator/templates/export.go.tmpl
@@ -8,10 +8,12 @@ import (
 	"encoding/json"
 	"fmt"
 	"os"
+	"strings"
 
 	"github.com/spf13/cobra"
 )
 
+{{- $exportableResources := exportableResources .}}
 func newExportCmd(flags *rootFlags) *cobra.Command {
 	var format string
 	var outputFile string
@@ -34,6 +36,21 @@ large datasets as it has no memory pressure.`,
   {{.Name}}-pp-cli export <resource> --format jsonl | jq '.id'`,
 		Args: cobra.MinimumNArgs(1),
 		RunE: func(cmd *cobra.Command, args []string) error {
+			validResources := map[string]bool{
+{{- range $exportableResources}}
+				{{printf "%q" .}}: true,
+{{- end}}
+			}
+			validResourceList := []string{
+{{- range $exportableResources}}
+				{{printf "%q" .}},
+{{- end}}
+			}
+			resource := args[0]
+			if !validResources[resource] {
+				return usageErr(fmt.Errorf("unknown resource %q; valid: %s", resource, strings.Join(validResourceList, ", ")))
+			}
+
 			c, err := flags.newClient()
 			if err != nil {
 				return err
@@ -42,7 +59,6 @@ large datasets as it has no memory pressure.`,
 				c.NoCache = true
 			}
 
-			resource := args[0]
 			path := "/" + resource
 			if len(args) > 1 {
 				path += "/" + args[1]
@@ -64,7 +80,7 @@ large datasets as it has no memory pressure.`,
 
 			data, err := c.Get(path, nil)
 			if err != nil {
-				return classifyAPIError(err)
+				return classifyAPIError(err, flags)
 			}
 
 			switch format {
diff --git a/internal/generator/templates/helpers.go.tmpl b/internal/generator/templates/helpers.go.tmpl
index af888476..3464f62c 100644
--- a/internal/generator/templates/helpers.go.tmpl
+++ b/internal/generator/templates/helpers.go.tmpl
@@ -265,14 +265,40 @@ func extractGraphQLObject(data json.RawMessage, field string) (json.RawMessage,
 }
 {{- end}}
 
+type noopResult struct {
+	Status string `json:"status"`
+	Reason string `json:"reason"`
+}
+
+func writeNoop(flags *rootFlags, reason, prose string) error {
+	if flags != nil && flags.asJSON {
+		return json.NewEncoder(os.Stdout).Encode(noopResult{Status: "noop", Reason: reason})
+	}
+	fmt.Fprintln(os.Stderr, prose)
+	return nil
+}
+
+func writeAPIErrorEnvelope(flags *rootFlags, err error, code int) {
+	if flags == nil || !flags.asJSON {
+		return
+	}
+	_ = json.NewEncoder(os.Stdout).Encode(map[string]any{
+		"error": err.Error(),
+		"code":  code,
+	})
+}
+
 // classifyAPIError maps API errors to structured exit codes with actionable hints.
-func classifyAPIError(err error) error {
+func classifyAPIError(err error, flags *rootFlags) 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
+		if flags != nil && flags.idempotent {
+			return writeNoop(flags, "already_exists", "already exists (no-op)")
+		}
+		classified := apiErr(err)
+		writeAPIErrorEnvelope(flags, classified, ExitCode(classified))
+		return classified
 {{- if and .Auth.Type (ne .Auth.Type "none")}}
 	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."+
@@ -357,14 +383,13 @@ func classifyAPIError(err error) error {
 }
 
 {{- if .HasDelete}}
-// classifyDeleteError treats 404 as success for DELETE (already deleted = idempotent no-op).
-func classifyDeleteError(err error) error {
+// classifyDeleteError maps DELETE errors and supports explicit idempotent no-op handling.
+func classifyDeleteError(err error, flags *rootFlags) error {
 	msg := err.Error()
-	if strings.Contains(msg, "HTTP 404") {
-		fmt.Fprintln(os.Stderr, "already deleted (no-op)")
-		return nil
+	if strings.Contains(msg, "HTTP 404") && flags != nil && flags.ignoreMissing {
+		return writeNoop(flags, "already_deleted", "already deleted (no-op)")
 	}
-	return classifyAPIError(err)
+	return classifyAPIError(err, flags)
 }
 {{- end}}
 
diff --git a/internal/generator/templates/readme.md.tmpl b/internal/generator/templates/readme.md.tmpl
index 2d43cd24..9184b500 100644
--- a/internal/generator/templates/readme.md.tmpl
+++ b/internal/generator/templates/readme.md.tmpl
@@ -246,7 +246,7 @@ This CLI is designed for AI agent consumption:
 - **Filterable** - `--select id,name` returns only fields you need
 - **Previewable** - `--dry-run` shows the request without sending
 {{- if .HasWriteCommands}}
-- **Retryable** - creates return "already exists" on retry, deletes return "already deleted"
+- **Explicit retries** - add `--idempotent` to create retries{{if .HasDelete}} and `--ignore-missing` to delete retries{{end}} when a no-op success is acceptable
 - **Confirmable** - `--yes` for explicit confirmation of destructive actions
 - **Piped input** - write commands can accept structured input when their help lists `--stdin`
 {{- else}}
diff --git a/internal/generator/templates/root.go.tmpl b/internal/generator/templates/root.go.tmpl
index ca935e29..73f759a2 100644
--- a/internal/generator/templates/root.go.tmpl
+++ b/internal/generator/templates/root.go.tmpl
@@ -29,6 +29,10 @@ type rootFlags struct {
 	dryRun       bool
 	noCache      bool
 	noInput      bool
+	idempotent   bool
+{{- if .HasDelete}}
+	ignoreMissing bool
+{{- end}}
 	yes          bool
 	agent        bool
 	selectFields string
@@ -147,6 +151,10 @@ Run '{{.Name}}-pp-cli doctor' to verify auth and connectivity.{{backtick}},
 	rootCmd.PersistentFlags().BoolVar(&flags.dryRun, "dry-run", false, "Show request without sending")
 	rootCmd.PersistentFlags().BoolVar(&flags.noCache, "no-cache", false, "Bypass response cache")
 	rootCmd.PersistentFlags().BoolVar(&flags.noInput, "no-input", false, "Disable all interactive prompts (for CI/agents)")
+	rootCmd.PersistentFlags().BoolVar(&flags.idempotent, "idempotent", false, "Treat already-existing create results as a successful no-op")
+{{- if .HasDelete}}
+	rootCmd.PersistentFlags().BoolVar(&flags.ignoreMissing, "ignore-missing", false, "Treat missing delete targets as a successful no-op")
+{{- end}}
 	rootCmd.PersistentFlags().StringVar(&flags.selectFields, "select", "", "Comma-separated fields to include in output (e.g. --select id,name,status)")
 	rootCmd.PersistentFlags().BoolVar(&flags.yes, "yes", false, "Skip confirmation prompts (for agents and scripts)")
 	rootCmd.PersistentFlags().BoolVar(&noColor, "no-color", false, "Disable colored output")
diff --git a/internal/generator/templates/search.go.tmpl b/internal/generator/templates/search.go.tmpl
index bb67679f..142ffc9f 100644
--- a/internal/generator/templates/search.go.tmpl
+++ b/internal/generator/templates/search.go.tmpl
@@ -141,7 +141,7 @@ In local mode: searches locally synced data only.`,
 				}
 				// Check if it's a network error for auto-mode fallback
 				if flags.dataSource == "live" || !isNetworkError(getErr) {
-					return classifyAPIError(getErr)
+					return classifyAPIError(getErr, flags)
 				}
 				// auto mode + network error: fall through to local FTS
 				fmt.Fprintf(cmd.ErrOrStderr(), "API unreachable, falling back to local search.\n")
diff --git a/internal/generator/templates/skill.md.tmpl b/internal/generator/templates/skill.md.tmpl
index 0509b2a5..0066e2dc 100644
--- a/internal/generator/templates/skill.md.tmpl
+++ b/internal/generator/templates/skill.md.tmpl
@@ -245,6 +245,8 @@ Add `--agent` to any command. Expands to: `--json --compact --no-input --no-colo
 - **Non-interactive** — never prompts, every input is a flag
 {{- if not .HasWriteCommands}}
 - **Read-only** — do not use this CLI for create, update, delete, publish, comment, upvote, invite, order, send, or other mutating requests
+{{- else}}
+- **Explicit retries** — use `--idempotent` only when an already-existing create should count as success{{if .HasDelete}}, and `--ignore-missing` only when a missing delete target should count as success{{end}}
 {{- end}}
 {{- if .HasDataLayer}}
 
diff --git a/internal/generator/vision_templates.go b/internal/generator/vision_templates.go
index db3d50e4..e9d78ac1 100644
--- a/internal/generator/vision_templates.go
+++ b/internal/generator/vision_templates.go
@@ -1,6 +1,10 @@
 package generator
 
 import (
+	"sort"
+	"strings"
+
+	"github.com/mvanhorn/cli-printing-press/v3/internal/spec"
 	"github.com/mvanhorn/cli-printing-press/v3/internal/vision"
 )
 
@@ -157,6 +161,37 @@ func SelectVisionTemplates(plan *vision.VisionaryPlan) VisionTemplateSet {
 	return set
 }
 
+func constrainVisionTemplates(api *spec.APISpec, set VisionTemplateSet) VisionTemplateSet {
+	if set.Export && len(exportableResources(api)) == 0 {
+		set.Export = false
+	}
+	return set
+}
+
+func exportableResources(api *spec.APISpec) []string {
+	if api == nil {
+		return nil
+	}
+	names := make([]string, 0, len(api.Resources))
+	for name, resource := range api.Resources {
+		if hasBareCollectionEndpoint(name, resource) {
+			names = append(names, name)
+		}
+	}
+	sort.Strings(names)
+	return names
+}
+
+func hasBareCollectionEndpoint(resourceName string, resource spec.Resource) bool {
+	wantPath := "/" + resourceName
+	for _, endpoint := range resource.Endpoints {
+		if strings.EqualFold(endpoint.Method, "GET") && endpoint.Path == wantPath {
+			return true
+		}
+	}
+	return false
+}
+
 func (s VisionTemplateSet) HasWorkflows() bool {
 	return len(s.Workflows) > 0
 }
diff --git a/testdata/golden/expected/generate-golden-api/dogfood.json b/testdata/golden/expected/generate-golden-api/dogfood.json
index 20ee0049..8e10242c 100644
--- a/testdata/golden/expected/generate-golden-api/dogfood.json
+++ b/testdata/golden/expected/generate-golden-api/dogfood.json
@@ -12,11 +12,11 @@
   },
   "dead_flags": {
     "dead": 0,
-    "total": 17
+    "total": 18
   },
   "dead_functions": {
     "dead": 0,
-    "total": 49
+    "total": 51
   },
   "dir": "<ARTIFACT_DIR>/generate-golden-api/printing-press-golden",
   "example_check": {
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/README.md b/testdata/golden/expected/generate-golden-api/printing-press-golden/README.md
index 8f3e4e8b..20fbee42 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/README.md
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/README.md
@@ -103,7 +103,7 @@ This CLI is designed for AI agent consumption:
 - **Pipeable** - `--json` output to stdout, errors to stderr
 - **Filterable** - `--select id,name` returns only fields you need
 - **Previewable** - `--dry-run` shows the request without sending
-- **Retryable** - creates return "already exists" on retry, deletes return "already deleted"
+- **Explicit retries** - add `--idempotent` to create retries when a no-op success is acceptable
 - **Confirmable** - `--yes` for explicit confirmation of destructive actions
 - **Piped input** - write commands can accept structured input when their help lists `--stdin`
 - **Offline-friendly** - sync/search commands can use the local SQLite store when available
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/SKILL.md b/testdata/golden/expected/generate-golden-api/printing-press-golden/SKILL.md
index 5c368b91..5a2e3a37 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/SKILL.md
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/SKILL.md
@@ -73,6 +73,7 @@ Add `--agent` to any command. Expands to: `--json --compact --no-input --no-colo
 - **Previewable** — `--dry-run` shows the request without sending
 - **Offline-friendly** — sync/search commands can use the local SQLite store when available
 - **Non-interactive** — never prompts, every input is a flag
+- **Explicit retries** — use `--idempotent` only when an already-existing create should count as success
 
 ### Response envelope
 
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
index a5704a99..e8ebdddf 100644
--- 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
@@ -194,14 +194,40 @@ func isSyncAccessWarning(err error) (*accessWarning, bool) {
 	return nil, false
 }
 
+type noopResult struct {
+	Status string `json:"status"`
+	Reason string `json:"reason"`
+}
+
+func writeNoop(flags *rootFlags, reason, prose string) error {
+	if flags != nil && flags.asJSON {
+		return json.NewEncoder(os.Stdout).Encode(noopResult{Status: "noop", Reason: reason})
+	}
+	fmt.Fprintln(os.Stderr, prose)
+	return nil
+}
+
+func writeAPIErrorEnvelope(flags *rootFlags, err error, code int) {
+	if flags == nil || !flags.asJSON {
+		return
+	}
+	_ = json.NewEncoder(os.Stdout).Encode(map[string]any{
+		"error": err.Error(),
+		"code":  code,
+	})
+}
+
 // classifyAPIError maps API errors to structured exit codes with actionable hints.
-func classifyAPIError(err error) error {
+func classifyAPIError(err error, flags *rootFlags) 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
+		if flags != nil && flags.idempotent {
+			return writeNoop(flags, "already_exists", "already exists (no-op)")
+		}
+		classified := apiErr(err)
+		writeAPIErrorEnvelope(flags, classified, ExitCode(classified))
+		return classified
 	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=<your-key>"+
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_create.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_create.go
index 578330a7..3f038212 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_create.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_create.go
@@ -63,7 +63,7 @@ func newProjectsCreateCmd(flags *rootFlags) *cobra.Command {
 			}
 			data, statusCode, err := c.Post(path, body)
 			if err != nil {
-				return classifyAPIError(err)
+				return classifyAPIError(err, flags)
 			}
 			if wantsHumanTable(cmd.OutOrStdout(), flags) {
 				// Check if response contains an array (directly or wrapped in "data")
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_list.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_list.go
index 1384bd9f..03b564fc 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_list.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_list.go
@@ -48,7 +48,7 @@ func newProjectsListCmd(flags *rootFlags) *cobra.Command {
 				"cursor": fmt.Sprintf("%v", flagCursor),
 			}, nil, flagAll, "cursor", "", "")
 			if err != nil {
-				return classifyAPIError(err)
+				return classifyAPIError(err, flags)
 			}
 			// Print provenance to stderr for human-facing output
 			{
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_tasks_list-project.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_tasks_list-project.go
index ff35ec4f..1d3ad90e 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_tasks_list-project.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_tasks_list-project.go
@@ -53,7 +53,7 @@ func newProjectsTasksListProjectCmd(flags *rootFlags) *cobra.Command {
 				"cursor": fmt.Sprintf("%v", flagCursor),
 			}, nil, flagAll, "cursor", "", "")
 			if err != nil {
-				return classifyAPIError(err)
+				return classifyAPIError(err, flags)
 			}
 			// Print provenance to stderr for human-facing output
 			{
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_tasks_update-project.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_tasks_update-project.go
index 0587b7d1..42660f7b 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_tasks_update-project.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_tasks_update-project.go
@@ -66,7 +66,7 @@ func newProjectsTasksUpdateProjectCmd(flags *rootFlags) *cobra.Command {
 			}
 			data, statusCode, err := c.Patch(path, body)
 			if err != nil {
-				return classifyAPIError(err)
+				return classifyAPIError(err, flags)
 			}
 			if wantsHumanTable(cmd.OutOrStdout(), flags) {
 				// Check if response contains an array (directly or wrapped in "data")
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/promoted_public.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/promoted_public.go
index 57fce0ef..e56bc33d 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/promoted_public.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/promoted_public.go
@@ -29,7 +29,7 @@ func newPublicPromotedCmd(flags *rootFlags) *cobra.Command {
 			params := map[string]string{}
 			data, prov, err := resolveRead(cmd.Context(), c, flags, "public", false, path, params, nil)
 			if err != nil {
-				return classifyAPIError(err)
+				return classifyAPIError(err, flags)
 			}
 			// Unwrap API response envelopes (e.g. {"status":"success","data":[...]})
 			// so output helpers see the inner data, not the wrapper.
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/root.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/root.go
index eb51e521..96aa19de 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/root.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/root.go
@@ -29,6 +29,7 @@ type rootFlags struct {
 	dryRun       bool
 	noCache      bool
 	noInput      bool
+	idempotent   bool
 	yes          bool
 	agent        bool
 	selectFields string
@@ -101,6 +102,7 @@ Run 'printing-press-golden-pp-cli doctor' to verify auth and connectivity.`,
 	rootCmd.PersistentFlags().BoolVar(&flags.dryRun, "dry-run", false, "Show request without sending")
 	rootCmd.PersistentFlags().BoolVar(&flags.noCache, "no-cache", false, "Bypass response cache")
 	rootCmd.PersistentFlags().BoolVar(&flags.noInput, "no-input", false, "Disable all interactive prompts (for CI/agents)")
+	rootCmd.PersistentFlags().BoolVar(&flags.idempotent, "idempotent", false, "Treat already-existing create results as a successful no-op")
 	rootCmd.PersistentFlags().StringVar(&flags.selectFields, "select", "", "Comma-separated fields to include in output (e.g. --select id,name,status)")
 	rootCmd.PersistentFlags().BoolVar(&flags.yes, "yes", false, "Skip confirmation prompts (for agents and scripts)")
 	rootCmd.PersistentFlags().BoolVar(&noColor, "no-color", false, "Disable colored output")
diff --git a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/items_enterprise.go b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/items_enterprise.go
index c8bd6924..67184800 100644
--- a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/items_enterprise.go
+++ b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/items_enterprise.go
@@ -29,7 +29,7 @@ func newItemsEnterpriseCmd(flags *rootFlags) *cobra.Command {
 			params := map[string]string{}
 			data, prov, err := resolveRead(cmd.Context(), c, flags, "items", false, path, params, nil)
 			if err != nil {
-				return classifyAPIError(err)
+				return classifyAPIError(err, flags)
 			}
 			// Print provenance to stderr for human-facing output
 			{
diff --git a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/items_list.go b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/items_list.go
index e8376e6e..3fe6400e 100644
--- a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/items_list.go
+++ b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/items_list.go
@@ -30,7 +30,7 @@ func newItemsListCmd(flags *rootFlags) *cobra.Command {
 			data, prov, err := resolvePaginatedRead(cmd.Context(), c, flags, "items", path, map[string]string{
 			}, nil, flagAll, "cursor", "", "")
 			if err != nil {
-				return classifyAPIError(err)
+				return classifyAPIError(err, flags)
 			}
 			// Print provenance to stderr for human-facing output
 			{
diff --git a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/items_premium.go b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/items_premium.go
index 1a594ba2..249ba74a 100644
--- a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/items_premium.go
+++ b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/items_premium.go
@@ -29,7 +29,7 @@ func newItemsPremiumCmd(flags *rootFlags) *cobra.Command {
 			params := map[string]string{}
 			data, prov, err := resolveRead(cmd.Context(), c, flags, "items", false, path, params, nil)
 			if err != nil {
-				return classifyAPIError(err)
+				return classifyAPIError(err, flags)
 			}
 			// Print provenance to stderr for human-facing output
 			{

← b4a95cb7 fix(cli): infer bearer auth from inline params (#634)  ·  back to Cli Printing Press  ·  feat(skills): data-driven Phase 6 menu and hold-path support 0310d3fb →