[object Object]

← back to Cli Printing Press

fix(cli): route explicit --csv/--quiet/--plain above piped-pipe gate (#968)

ab6edbefb709697a129a42668a16a31c04749bcf · 2026-05-10 13:33:55 -0700 · Trevin Chow

The auto-JSON gate at the top of command_endpoint.go.tmpl and
command_promoted.go.tmpl read `flags.asJSON || !isTerminal(...)`, so
piped output unconditionally rendered as JSON regardless of an explicit
format flag. Agents and shell pipelines (the common case) silently lost
`--csv` to a JSON envelope; the documented `--csv` flag never reached
printCSV.

Add `&& !flags.csv && !flags.quiet && !flags.plain` to the auto-JSON
clause at every gate site so an explicit format choice opts out of the
auto-JSON path and falls through to the standard pipeline
(printOutputWithFlags), where printCSV / printOutput already handle
each flag. This makes the smart-default principle (terminal=table,
pipe=JSON) yield to the user's explicit override.

Drop the now-redundant `if flags.csv { ... }` short-circuit in
command_promoted.go.tmpl: with the gate fix, --csv naturally falls
through to printOutputWithFlags via the same path as the other format
flags, so the per-flag carve-out is dead.

Bugs #2 (nested-envelope pagination introspection) and #3 (silent
--quiet, missing printPlain) from the same issue are independent
template/parser surfaces and ship as follow-up PRs.

Refs #918

Files touched

Diff

commit ab6edbefb709697a129a42668a16a31c04749bcf
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Sun May 10 13:33:55 2026 -0700

    fix(cli): route explicit --csv/--quiet/--plain above piped-pipe gate (#968)
    
    The auto-JSON gate at the top of command_endpoint.go.tmpl and
    command_promoted.go.tmpl read `flags.asJSON || !isTerminal(...)`, so
    piped output unconditionally rendered as JSON regardless of an explicit
    format flag. Agents and shell pipelines (the common case) silently lost
    `--csv` to a JSON envelope; the documented `--csv` flag never reached
    printCSV.
    
    Add `&& !flags.csv && !flags.quiet && !flags.plain` to the auto-JSON
    clause at every gate site so an explicit format choice opts out of the
    auto-JSON path and falls through to the standard pipeline
    (printOutputWithFlags), where printCSV / printOutput already handle
    each flag. This makes the smart-default principle (terminal=table,
    pipe=JSON) yield to the user's explicit override.
    
    Drop the now-redundant `if flags.csv { ... }` short-circuit in
    command_promoted.go.tmpl: with the gate fix, --csv naturally falls
    through to printOutputWithFlags via the same path as the other format
    flags, so the per-flag carve-out is dead.
    
    Bugs #2 (nested-envelope pagination introspection) and #3 (silent
    --quiet, missing printPlain) from the same issue are independent
    template/parser surfaces and ship as follow-up PRs.
    
    Refs #918
---
 internal/generator/generator_test.go               | 41 ++++++++++++++++++++--
 .../generator/templates/command_endpoint.go.tmpl   |  8 +++--
 .../generator/templates/command_promoted.go.tmpl   | 10 +++---
 .../internal/cli/projects_create.go                |  2 +-
 .../internal/cli/projects_list.go                  |  6 ++--
 .../internal/cli/projects_tasks_list-project.go    |  6 ++--
 .../internal/cli/projects_tasks_update-project.go  |  2 +-
 .../internal/cli/promoted_public.go                | 10 +++---
 .../internal/cli/stores_create.go                  |  2 +-
 .../internal/cli/items_enterprise.go               |  6 ++--
 .../tier-routing-golden/internal/cli/items_list.go |  6 ++--
 .../internal/cli/items_premium.go                  |  6 ++--
 12 files changed, 75 insertions(+), 30 deletions(-)

diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index bbc1c17d..438a08eb 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -3195,8 +3195,10 @@ func TestGeneratedOutput_MutatingCommandsHaveEnvelope(t *testing.T) {
 	assert.Contains(t, content, `"resource":`)
 	assert.Contains(t, content, `"status":   statusCode`)
 	assert.Contains(t, content, `"success":  statusCode >= 200 && statusCode < 300`)
-	// Envelope fires on both --json and auto-JSON (piped/non-TTY)
-	assert.Contains(t, content, `flags.asJSON || !isTerminal(cmd.OutOrStdout())`)
+	// Envelope fires on --json and on piped output, but explicit format flags
+	// (--csv, --quiet, --plain) opt out of the auto-JSON path so piped agents
+	// that asked for a non-JSON format actually get it.
+	assert.Contains(t, content, `flags.asJSON || (!isTerminal(cmd.OutOrStdout()) && !flags.csv && !flags.quiet && !flags.plain)`)
 
 	// --quiet is respected before envelope output
 	assert.Contains(t, content, "if flags.quiet {")
@@ -3217,6 +3219,41 @@ func TestGeneratedOutput_MutatingCommandsHaveEnvelope(t *testing.T) {
 	assert.Contains(t, content, `envelope["success"] = false`)
 }
 
+// TestPipedJsonGateRespectsExplicitFormatFlags pins the contract: the
+// piped-output auto-JSON gate must defer to explicit --csv / --quiet /
+// --plain flags so piped consumers that asked for a non-JSON format
+// actually get it. Before the fix, the gate read
+// `flags.asJSON || !isTerminal(...)` and emitted JSON whenever stdout was
+// piped, which is the common case for agents and shell pipelines, so
+// `--csv | head` produced JSON instead of CSV. The fix adds the
+// `&& !flags.csv && !flags.quiet && !flags.plain` clause so an explicit
+// format choice opts out of the auto-JSON path.
+//
+// Read the templates directly to pin every gate site at once: the
+// command_endpoint and command_promoted templates emit into hundreds of
+// generated files (pet_add.go, pet_list.go, every promoted command), and
+// a per-file assertion would miss any new gate copies that drift in.
+func TestPipedJsonGateRespectsExplicitFormatFlags(t *testing.T) {
+	t.Parallel()
+
+	expected := `flags.asJSON || (!isTerminal(cmd.OutOrStdout()) && !flags.csv && !flags.quiet && !flags.plain)`
+	stale := `flags.asJSON || !isTerminal(cmd.OutOrStdout())`
+
+	for _, path := range []string{
+		filepath.Join("templates", "command_endpoint.go.tmpl"),
+		filepath.Join("templates", "command_promoted.go.tmpl"),
+	} {
+		data, err := os.ReadFile(path)
+		require.NoError(t, err, "template must exist: %s", path)
+		body := string(data)
+
+		assert.Contains(t, body, expected,
+			"%s must gate auto-JSON behind format-flag escape hatch so piped --csv/--quiet/--plain reach the standard pipeline", path)
+		assert.NotContains(t, body, stale,
+			"%s still contains the bare piped-pipe gate; every site must include the format-flag escape hatch", path)
+	}
+}
+
 func TestGeneratedOutput_GetCommandsLackMutationEnvelope(t *testing.T) {
 	t.Parallel()
 
diff --git a/internal/generator/templates/command_endpoint.go.tmpl b/internal/generator/templates/command_endpoint.go.tmpl
index 23fd1638..20e9d52c 100644
--- a/internal/generator/templates/command_endpoint.go.tmpl
+++ b/internal/generator/templates/command_endpoint.go.tmpl
@@ -477,7 +477,7 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
 					}
 				}
 			}
-			if flags.asJSON || !isTerminal(cmd.OutOrStdout()) {
+			if flags.asJSON || (!isTerminal(cmd.OutOrStdout()) && !flags.csv && !flags.quiet && !flags.plain) {
 				if flags.quiet {
 					return nil
 				}
@@ -526,8 +526,10 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
 			}
 			// For JSON output, wrap with provenance envelope before passing through flags.
 			// --select wins over --compact when both are set; --compact only runs when
-			// no explicit fields were requested.
-			if flags.asJSON || !isTerminal(cmd.OutOrStdout()) {
+			// no explicit fields were requested. Explicit format flags (--csv, --quiet,
+			// --plain) opt out of the auto-JSON path so piped consumers that asked for
+			// a non-JSON format reach the standard pipeline below.
+			if flags.asJSON || (!isTerminal(cmd.OutOrStdout()) && !flags.csv && !flags.quiet && !flags.plain) {
 				filtered := data
 				if flags.selectFields != "" {
 					filtered = filterFields(filtered, flags.selectFields)
diff --git a/internal/generator/templates/command_promoted.go.tmpl b/internal/generator/templates/command_promoted.go.tmpl
index f899bdf7..9cc134e4 100644
--- a/internal/generator/templates/command_promoted.go.tmpl
+++ b/internal/generator/templates/command_promoted.go.tmpl
@@ -242,14 +242,12 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
 				}
 				printProvenance(cmd, len(countItems), prov)
 			}
-			// CSV bypasses JSON pipe path so --csv works when piped
-			if flags.csv {
-				return printOutputWithFlags(cmd.OutOrStdout(), data, flags)
-			}
 			// For JSON output, wrap with provenance envelope. --select wins over
 			// --compact when both are set; --compact only runs when no explicit
-			// fields were requested.
-			if flags.asJSON || !isTerminal(cmd.OutOrStdout()) {
+			// fields were requested. Explicit format flags (--csv, --quiet, --plain)
+			// opt out of the auto-JSON path so piped consumers that asked for a
+			// non-JSON format reach the standard pipeline below.
+			if flags.asJSON || (!isTerminal(cmd.OutOrStdout()) && !flags.csv && !flags.quiet && !flags.plain) {
 				filtered := data
 				if flags.selectFields != "" {
 					filtered = filterFields(filtered, flags.selectFields)
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 3f038212..444fff2d 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
@@ -85,7 +85,7 @@ func newProjectsCreateCmd(flags *rootFlags) *cobra.Command {
 					}
 				}
 			}
-			if flags.asJSON || !isTerminal(cmd.OutOrStdout()) {
+			if flags.asJSON || (!isTerminal(cmd.OutOrStdout()) && !flags.csv && !flags.quiet && !flags.plain) {
 				if flags.quiet {
 					return nil
 				}
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 03b564fc..2e250f60 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
@@ -58,8 +58,10 @@ func newProjectsListCmd(flags *rootFlags) *cobra.Command {
 			}
 			// For JSON output, wrap with provenance envelope before passing through flags.
 			// --select wins over --compact when both are set; --compact only runs when
-			// no explicit fields were requested.
-			if flags.asJSON || !isTerminal(cmd.OutOrStdout()) {
+			// no explicit fields were requested. Explicit format flags (--csv, --quiet,
+			// --plain) opt out of the auto-JSON path so piped consumers that asked for
+			// a non-JSON format reach the standard pipeline below.
+			if flags.asJSON || (!isTerminal(cmd.OutOrStdout()) && !flags.csv && !flags.quiet && !flags.plain) {
 				filtered := data
 				if flags.selectFields != "" {
 					filtered = filterFields(filtered, flags.selectFields)
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 1d3ad90e..14a04515 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
@@ -63,8 +63,10 @@ func newProjectsTasksListProjectCmd(flags *rootFlags) *cobra.Command {
 			}
 			// For JSON output, wrap with provenance envelope before passing through flags.
 			// --select wins over --compact when both are set; --compact only runs when
-			// no explicit fields were requested.
-			if flags.asJSON || !isTerminal(cmd.OutOrStdout()) {
+			// no explicit fields were requested. Explicit format flags (--csv, --quiet,
+			// --plain) opt out of the auto-JSON path so piped consumers that asked for
+			// a non-JSON format reach the standard pipeline below.
+			if flags.asJSON || (!isTerminal(cmd.OutOrStdout()) && !flags.csv && !flags.quiet && !flags.plain) {
 				filtered := data
 				if flags.selectFields != "" {
 					filtered = filterFields(filtered, flags.selectFields)
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 42660f7b..6fb67a3e 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
@@ -88,7 +88,7 @@ func newProjectsTasksUpdateProjectCmd(flags *rootFlags) *cobra.Command {
 					}
 				}
 			}
-			if flags.asJSON || !isTerminal(cmd.OutOrStdout()) {
+			if flags.asJSON || (!isTerminal(cmd.OutOrStdout()) && !flags.csv && !flags.quiet && !flags.plain) {
 				if flags.quiet {
 					return nil
 				}
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 e56bc33d..592dd582 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
@@ -44,14 +44,12 @@ func newPublicPromotedCmd(flags *rootFlags) *cobra.Command {
 				}
 				printProvenance(cmd, len(countItems), prov)
 			}
-			// CSV bypasses JSON pipe path so --csv works when piped
-			if flags.csv {
-				return printOutputWithFlags(cmd.OutOrStdout(), data, flags)
-			}
 			// For JSON output, wrap with provenance envelope. --select wins over
 			// --compact when both are set; --compact only runs when no explicit
-			// fields were requested.
-			if flags.asJSON || !isTerminal(cmd.OutOrStdout()) {
+			// fields were requested. Explicit format flags (--csv, --quiet, --plain)
+			// opt out of the auto-JSON path so piped consumers that asked for a
+			// non-JSON format reach the standard pipeline below.
+			if flags.asJSON || (!isTerminal(cmd.OutOrStdout()) && !flags.csv && !flags.quiet && !flags.plain) {
 				filtered := data
 				if flags.selectFields != "" {
 					filtered = filterFields(filtered, flags.selectFields)
diff --git a/testdata/golden/expected/generate-public-param-names/public-param-golden/internal/cli/stores_create.go b/testdata/golden/expected/generate-public-param-names/public-param-golden/internal/cli/stores_create.go
index 0a0554ae..94b550ba 100644
--- a/testdata/golden/expected/generate-public-param-names/public-param-golden/internal/cli/stores_create.go
+++ b/testdata/golden/expected/generate-public-param-names/public-param-golden/internal/cli/stores_create.go
@@ -74,7 +74,7 @@ func newStoresCreateCmd(flags *rootFlags) *cobra.Command {
 					}
 				}
 			}
-			if flags.asJSON || !isTerminal(cmd.OutOrStdout()) {
+			if flags.asJSON || (!isTerminal(cmd.OutOrStdout()) && !flags.csv && !flags.quiet && !flags.plain) {
 				if flags.quiet {
 					return nil
 				}
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 67184800..5410ebbc 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
@@ -39,8 +39,10 @@ func newItemsEnterpriseCmd(flags *rootFlags) *cobra.Command {
 			}
 			// For JSON output, wrap with provenance envelope before passing through flags.
 			// --select wins over --compact when both are set; --compact only runs when
-			// no explicit fields were requested.
-			if flags.asJSON || !isTerminal(cmd.OutOrStdout()) {
+			// no explicit fields were requested. Explicit format flags (--csv, --quiet,
+			// --plain) opt out of the auto-JSON path so piped consumers that asked for
+			// a non-JSON format reach the standard pipeline below.
+			if flags.asJSON || (!isTerminal(cmd.OutOrStdout()) && !flags.csv && !flags.quiet && !flags.plain) {
 				filtered := data
 				if flags.selectFields != "" {
 					filtered = filterFields(filtered, flags.selectFields)
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 3fe6400e..88da2dd2 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
@@ -40,8 +40,10 @@ func newItemsListCmd(flags *rootFlags) *cobra.Command {
 			}
 			// For JSON output, wrap with provenance envelope before passing through flags.
 			// --select wins over --compact when both are set; --compact only runs when
-			// no explicit fields were requested.
-			if flags.asJSON || !isTerminal(cmd.OutOrStdout()) {
+			// no explicit fields were requested. Explicit format flags (--csv, --quiet,
+			// --plain) opt out of the auto-JSON path so piped consumers that asked for
+			// a non-JSON format reach the standard pipeline below.
+			if flags.asJSON || (!isTerminal(cmd.OutOrStdout()) && !flags.csv && !flags.quiet && !flags.plain) {
 				filtered := data
 				if flags.selectFields != "" {
 					filtered = filterFields(filtered, flags.selectFields)
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 249ba74a..b0617769 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
@@ -39,8 +39,10 @@ func newItemsPremiumCmd(flags *rootFlags) *cobra.Command {
 			}
 			// For JSON output, wrap with provenance envelope before passing through flags.
 			// --select wins over --compact when both are set; --compact only runs when
-			// no explicit fields were requested.
-			if flags.asJSON || !isTerminal(cmd.OutOrStdout()) {
+			// no explicit fields were requested. Explicit format flags (--csv, --quiet,
+			// --plain) opt out of the auto-JSON path so piped consumers that asked for
+			// a non-JSON format reach the standard pipeline below.
+			if flags.asJSON || (!isTerminal(cmd.OutOrStdout()) && !flags.csv && !flags.quiet && !flags.plain) {
 				filtered := data
 				if flags.selectFields != "" {
 					filtered = filterFields(filtered, flags.selectFields)

← ebc8cd81 feat(cli): emit nested-object body fields as parent-prefixed  ·  back to Cli Printing Press  ·  fix(skills): preflight Go toolchain before generation runs ( 0562bca8 →