← 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
M internal/generator/generator_test.goM internal/generator/templates/command_endpoint.go.tmplM internal/generator/templates/command_promoted.go.tmplM testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_create.goM testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_list.goM testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_tasks_list-project.goM testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_tasks_update-project.goM testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/promoted_public.goM testdata/golden/expected/generate-public-param-names/public-param-golden/internal/cli/stores_create.goM testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/items_enterprise.goM testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/items_list.goM testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/items_premium.go
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 →