From 01a6f00e8ad53268b196b28e498f8491213f8280 Mon Sep 17 00:00:00 2001 From: 2ynn Date: Tue, 22 Sep 2026 09:52:29 -0400 Subject: [PATCH 1/2] fix(cli): report CLI_UNAVAILABLE for planned commands given any argv Planned placeholder commands declared `Args: cobra.NoArgs`, so any positional argument or unknown flag failed argument parsing before `RunE` ran. The user then saw `unknown command`, `Reason: CLI_VALIDATION`, exit 2, and a `--dry-run` hint for a command that has no request to preview. Bare invocation of the same command correctly reported `CLI_UNAVAILABLE`, exit 1. Match the custom-command placeholder: accept arbitrary args and whitelist unknown flags so the `CLI_UNAVAILABLE` branch is reached regardless of what the user typed. `--help` and `--usage` are unchanged. Tests pin exit 1 across plain, agent and JSON modes for a planned command with an argument and with an unknown flag, and the agent envelope reason, error type and absence of parse-error text. --- .changesets/1790084751-8116f03c.yaml | 10 +++++++ templates/templates/cli/intents.go.stmpl | 7 ++--- .../cli/tests/primary/clierrors_test.go.stmpl | 27 +++++++++++++++++++ .../cli/tests/primary/exitcodes_test.go.stmpl | 2 ++ zSDKs/sdk-cli/.speakeasy/gen.lock | 2 +- zSDKs/sdk-cli/internal/cli/intents.go | 7 ++--- 6 files changed, 48 insertions(+), 7 deletions(-) create mode 100644 .changesets/1790084751-8116f03c.yaml diff --git a/.changesets/1790084751-8116f03c.yaml b/.changesets/1790084751-8116f03c.yaml new file mode 100644 index 00000000..16d9619a --- /dev/null +++ b/.changesets/1790084751-8116f03c.yaml @@ -0,0 +1,10 @@ +id: 1790084751-8116f03c +features: + - core +targets: + - cli +type: fix +bump: patch +description: planned commands report CLI_UNAVAILABLE regardless of positional arguments or flags +author: 2ynn +date: "2026-09-22" diff --git a/templates/templates/cli/intents.go.stmpl b/templates/templates/cli/intents.go.stmpl index 2f56a53b..99fd58e0 100644 --- a/templates/templates/cli/intents.go.stmpl +++ b/templates/templates/cli/intents.go.stmpl @@ -114,9 +114,10 @@ func initIntentCmds(rootCmd *cobra.Command) error { func newPlannedCmd(name, short, long, groupID, note string) *cobra.Command { cmd := &cobra.Command{ - Use: name, - Short: short, - Args: cobra.NoArgs, + Use: name, + Short: short, + Args: cobra.ArbitraryArgs, + FParseErrWhitelist: cobra.FParseErrWhitelist{UnknownFlags: true}, RunE: func(cmd *cobra.Command, args []string) error { if usage.UsageRequested(cmd) { return usage.EmitSchema(cmd, cmd.OutOrStdout()) diff --git a/templates/templates/cli/tests/primary/clierrors_test.go.stmpl b/templates/templates/cli/tests/primary/clierrors_test.go.stmpl index 7a8b9bb7..f1196f7d 100644 --- a/templates/templates/cli/tests/primary/clierrors_test.go.stmpl +++ b/templates/templates/cli/tests/primary/clierrors_test.go.stmpl @@ -64,6 +64,33 @@ func TestCLIErrorAgentModeMatrix(t *testing.T) { } } +// A planned command has no input contract to violate: whatever argv it is +// given, it reports CLI_UNAVAILABLE rather than a CLI_VALIDATION parse error. +func TestCLIErrorPlannedCommandIgnoresArgv(t *testing.T) { + for _, args := range [][]string{ + {"--agent-mode", "archive"}, + {"--agent-mode", "archive", "hello world"}, + {"--agent-mode", "archive", "--count", "3"}, + {"--agent-mode", "archive", "hello", "--count", "3"}, + } { + t.Run(strings.Join(args[1:], " "), func(t *testing.T) { + resetAgentMode(t) + h := NewCLITestHarness(t) + err := h.RunRaw(args) + require.Error(t, err) + + envelope := agentEnvelope(t, h.GetStderr()) + assert.Equal(t, "unsupported_error", envelope["error_type"]) + assert.Equal(t, "CLI_UNAVAILABLE", envelope["error_reason"]) + assert.Equal(t, float64(1), envelope["exit_code"]) + assert.NotContains(t, envelope["error"], "unknown command") + assert.NotContains(t, envelope["error"], "unknown flag") + assert.NotContains(t, h.GetStderr(), "--dry-run") + assert.Empty(t, strings.TrimSpace(h.GetStdout())) + }) + } +} + // TestCLIErrorFlagValuesAreNotRenderingFlags: a flag-looking VALUE of a real // flag must not switch the rendering mode: `--body --agent-mode` is a body of // "--agent-mode" (invalid JSON, plain error), not agent mode; `--body -o json` diff --git a/templates/templates/cli/tests/primary/exitcodes_test.go.stmpl b/templates/templates/cli/tests/primary/exitcodes_test.go.stmpl index 238732cd..11c17b46 100644 --- a/templates/templates/cli/tests/primary/exitcodes_test.go.stmpl +++ b/templates/templates/cli/tests/primary/exitcodes_test.go.stmpl @@ -166,6 +166,8 @@ func TestExitCodesAcrossRenderingModes(t *testing.T) { {name: "missing required flag", args: []string{"custom-required-flags"}, want: 2}, {name: "malformed body", args: []string{"request-bodies", "request-body-post-flag-defaults-body-merge", "--body", "{"}, want: 2}, {name: "planned command", args: []string{"archive"}, want: 1}, + {name: "planned command with argument", args: []string{"archive", "hello world"}, want: 1}, + {name: "planned command with unknown flag", args: []string{"archive", "--count", "3"}, want: 1}, {name: "builtin stray argument", args: []string{"version", "extra"}, want: 2}, {name: "HTTP 400", args: []string{"errors", "status-get-error", "--status-code", "400"}, serverURL: s.URL, want: 2}, {name: "HTTP 401", args: []string{"errors", "status-get-error", "--status-code", "401"}, serverURL: s.URL, want: 3}, diff --git a/zSDKs/sdk-cli/.speakeasy/gen.lock b/zSDKs/sdk-cli/.speakeasy/gen.lock index 4b50cc13..ede91576 100644 --- a/zSDKs/sdk-cli/.speakeasy/gen.lock +++ b/zSDKs/sdk-cli/.speakeasy/gen.lock @@ -126,7 +126,7 @@ trackedFiles: internal/cli/intent_say.go: last_write_checksum: sha1:d7137e29ad2375390c175b9e79f0a557341b2e95 internal/cli/intents.go: - last_write_checksum: sha1:11c1961232079be8b4d3b5e5498c7e61c2810c03 + last_write_checksum: sha1:997b951af6d215576d30574a54022937beedd890 internal/cli/intents_order_test.go: last_write_checksum: sha1:16b6e0b6b7a669f7d12e25d02079fcb6c1bdb878 internal/cli/login.go: diff --git a/zSDKs/sdk-cli/internal/cli/intents.go b/zSDKs/sdk-cli/internal/cli/intents.go index 762636f7..0d96eec5 100644 --- a/zSDKs/sdk-cli/internal/cli/intents.go +++ b/zSDKs/sdk-cli/internal/cli/intents.go @@ -84,9 +84,10 @@ func initIntentCmds(rootCmd *cobra.Command) error { func newPlannedCmd(name, short, long, groupID, note string) *cobra.Command { cmd := &cobra.Command{ - Use: name, - Short: short, - Args: cobra.NoArgs, + Use: name, + Short: short, + Args: cobra.ArbitraryArgs, + FParseErrWhitelist: cobra.FParseErrWhitelist{UnknownFlags: true}, RunE: func(cmd *cobra.Command, args []string) error { if usage.UsageRequested(cmd) { return usage.EmitSchema(cmd, cmd.OutOrStdout()) From 50a88c32ae1807e0b25f1df7eb32f60b343762df Mon Sep 17 00:00:00 2001 From: 2ynn Date: Tue, 22 Sep 2026 10:16:58 -0400 Subject: [PATCH 2/2] test(cli): pin rendered sentinel and review-variant argv coverage Assert the planned-command envelope error is marked rendered, matching the sibling agent-mode matrix, so a regression that drops the sentinel would print the error twice and fail here. Add a review-variant test next to the existing planned `--usage` case covering a positional argument and an unknown flag: both must exit 1 with `CLI_UNAVAILABLE`. --- .changesets/1790084751-8116f03c.yaml | 2 +- .../cli/tests/primary/clierrors_test.go.stmpl | 3 +-- templates/templates/cli/usage_test.go.stmpl | 20 +++++++++++++++++++ zSDKs/sdk-cli/.speakeasy/gen.lock | 2 +- zSDKs/sdk-cli/tests/usage_test.go | 18 +++++++++++++++++ 5 files changed, 41 insertions(+), 4 deletions(-) diff --git a/.changesets/1790084751-8116f03c.yaml b/.changesets/1790084751-8116f03c.yaml index 16d9619a..d896c541 100644 --- a/.changesets/1790084751-8116f03c.yaml +++ b/.changesets/1790084751-8116f03c.yaml @@ -5,6 +5,6 @@ targets: - cli type: fix bump: patch -description: planned commands report CLI_UNAVAILABLE regardless of positional arguments or flags +description: planned commands report CLI_UNAVAILABLE for arbitrary positional arguments and unknown flags author: 2ynn date: "2026-09-22" diff --git a/templates/templates/cli/tests/primary/clierrors_test.go.stmpl b/templates/templates/cli/tests/primary/clierrors_test.go.stmpl index f1196f7d..6c691e27 100644 --- a/templates/templates/cli/tests/primary/clierrors_test.go.stmpl +++ b/templates/templates/cli/tests/primary/clierrors_test.go.stmpl @@ -64,8 +64,6 @@ func TestCLIErrorAgentModeMatrix(t *testing.T) { } } -// A planned command has no input contract to violate: whatever argv it is -// given, it reports CLI_UNAVAILABLE rather than a CLI_VALIDATION parse error. func TestCLIErrorPlannedCommandIgnoresArgv(t *testing.T) { for _, args := range [][]string{ {"--agent-mode", "archive"}, @@ -78,6 +76,7 @@ func TestCLIErrorPlannedCommandIgnoresArgv(t *testing.T) { h := NewCLITestHarness(t) err := h.RunRaw(args) require.Error(t, err) + assert.True(t, output.IsRendered(err), "a CLI error rendered in the envelope must be marked so main prints nothing further") envelope := agentEnvelope(t, h.GetStderr()) assert.Equal(t, "unsupported_error", envelope["error_type"]) diff --git a/templates/templates/cli/usage_test.go.stmpl b/templates/templates/cli/usage_test.go.stmpl index 76edfffd..4acaef90 100644 --- a/templates/templates/cli/usage_test.go.stmpl +++ b/templates/templates/cli/usage_test.go.stmpl @@ -9,6 +9,9 @@ {{- addImport "github.com/spf13/pflag" -}} {{- addImport "internal/cli" true -}} {{- end -}} +{{- if templatePlannedUsageTestEnabled -}} + {{- addImport "internal/clierrors" true -}} +{{- end -}} package tests {{genImports}} @@ -311,6 +314,23 @@ func TestUsageSchema_PlannedCommand(t *testing.T) { assert.Contains(t, stdout, "cmd \"{{templatePlannedUsageCmdName}}\"") assert.Empty(t, stderr) } + +func TestPlannedCommand_ArgvReportsUnavailable(t *testing.T) { + for _, extra := range [][]string{ + {"hello"}, + {"--count", "3"}, + } { + t.Run(strings.Join(extra, " "), func(t *testing.T) { + h := NewCLITestHarness(t) + err := h.RunBare(append([]string{ {{templatePlannedUsagePathArgs}} }, extra...)) + require.Error(t, err) + + assert.Equal(t, clierrors.ExitRuntime, clierrors.ExitCode(err)) + assert.Contains(t, h.GetStderr(), `"error_reason": "CLI_UNAVAILABLE"`) + assert.Empty(t, strings.TrimSpace(h.GetStdout())) + }) + } +} {{- end }} {{- if $custom }} diff --git a/zSDKs/sdk-cli/.speakeasy/gen.lock b/zSDKs/sdk-cli/.speakeasy/gen.lock index ede91576..f8c1205f 100644 --- a/zSDKs/sdk-cli/.speakeasy/gen.lock +++ b/zSDKs/sdk-cli/.speakeasy/gen.lock @@ -1086,7 +1086,7 @@ trackedFiles: tests/types_test.go: last_write_checksum: sha1:5ce31f08a3214779ed561d0f37279eb4d5202723 tests/usage_test.go: - last_write_checksum: sha1:318b3a28489fe849a72321a9bab87713b12112f2 + last_write_checksum: sha1:7968373b2942b3a209b6f001753ef565e79dad79 examples: _operation_with_leading_and_trailing_underscores_: speakeasy-default-operation-with-leading-and-trailing-underscores: diff --git a/zSDKs/sdk-cli/tests/usage_test.go b/zSDKs/sdk-cli/tests/usage_test.go index ec55673c..6c7b8ea4 100644 --- a/zSDKs/sdk-cli/tests/usage_test.go +++ b/zSDKs/sdk-cli/tests/usage_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "openapi/internal/cli" + "openapi/internal/clierrors" "regexp" "strings" "testing" @@ -282,6 +283,23 @@ func TestUsageSchema_PlannedCommand(t *testing.T) { assert.Empty(t, stderr) } +func TestPlannedCommand_ArgvReportsUnavailable(t *testing.T) { + for _, extra := range [][]string{ + {"hello"}, + {"--count", "3"}, + } { + t.Run(strings.Join(extra, " "), func(t *testing.T) { + h := NewCLITestHarness(t) + err := h.RunBare(append([]string{"archive"}, extra...)) + require.Error(t, err) + + assert.Equal(t, clierrors.ExitRuntime, clierrors.ExitCode(err)) + assert.Contains(t, h.GetStderr(), `"error_reason": "CLI_UNAVAILABLE"`) + assert.Empty(t, strings.TrimSpace(h.GetStdout())) + }) + } +} + func TestUsageSchema_CustomCommand(t *testing.T) { h := NewCLITestHarness(t) err := h.RunBare([]string{"hello", "--usage"})