From fd24b277f023e8fd26fc9312f7974e61131a59d9 Mon Sep 17 00:00:00 2001 From: 2ynn Date: Wed, 7 Oct 2026 11:51:24 -0400 Subject: [PATCH 1/3] fix(cli): merge an intent's presets into a request body piped on stdin A whole body piped on stdin took request control like --body, but the preset merge ran only on a body flag the caller set, so a piped body skipped the presets and the variant guard: `echo '{"input":"cat"}' | cli render` sent the operation's default model instead of the preset one, and a piped body naming another variant was sent instead of refused. --body @- with the same bytes was merged. The non-dispatch intent run function now merges the preset into a body attached from stdin (flagutil.MergePresetStdinBody), as route-dispatch intents already do. --- .changesets/1791388271-e35b0f02.yaml | 10 ++++++ templates/templates/cli/README.md | 4 +-- .../internal/flagutil/flags.go.stmpl | 19 ++++++++++ templates/templates/cli/intentcmd.go.stmpl | 5 +++ .../cli/tests/primary/intents_test.go.stmpl | 35 +++++++++++++++++++ zSDKs/sdk-cli/internal/flagutil/flags.go | 19 ++++++++++ 6 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 .changesets/1791388271-e35b0f02.yaml diff --git a/.changesets/1791388271-e35b0f02.yaml b/.changesets/1791388271-e35b0f02.yaml new file mode 100644 index 000000000..7c3e0a84a --- /dev/null +++ b/.changesets/1791388271-e35b0f02.yaml @@ -0,0 +1,10 @@ +id: 1791388271-e35b0f02 +features: + - cliCommands +targets: + - cli +type: fix +bump: patch +description: An intent command now merges its presets into a request body piped on stdin, as it does for --body, so a piped partial body keeps the command's pinned model and request variant, and a piped body naming another variant is a validation error instead of being sent +author: 2ynn +date: "2026-10-07" diff --git a/templates/templates/cli/README.md b/templates/templates/cli/README.md index 4da45394f..b90da7313 100644 --- a/templates/templates/cli/README.md +++ b/templates/templates/cli/README.md @@ -767,7 +767,7 @@ cli users create --no-interactive - Cobra validates positionals before lifecycle hooks, so the Args wrapper may defer an arity error only when an interactive prompt can supply a missing declared required positional. The PreRunE wrapper applies answers and always re-runs a deferred validator. Because Cobra computed its argument slice once, RunE and PostRunE wrappers receive the effective injected args through per-command execution state. - Positional prompting requires an explicit `interactive.Declare` contract; commands without `speakeasy_prompt_args` retain their original `Args` function unchanged. In v1, expanded union variant flags carry `speakeasy:union-member` and are never prompted independently, because requiring every variant at once would be incorrect. Callers select/populate unions through their existing top-level or variant surfaces. - Route-dispatch intents annotate only inputs required by every route. Anchors and conditionally required inputs are deliberately not marked required: the current prompt plan has no one-of choice, so prompting both anchors (e.g. `--engine` and `--pipeline`) would create an invalid request. The shared prompt runs first; route selection or a typed conditional-required error follows in `RunE`. -- Declared intent commands (`x-speakeasy-cli-commands`) keep their presets under a partial body: when the caller passes the intent's JSON body flag (`--body`, or the operation's single JSON body flag for metadata-free requests), the intent merges its presets into that JSON per key — caller keys win, presets fill the gaps including the key that selects the pinned request variant, a known discriminator value is filled in — instead of handing the body to the operation untouched, where the union's default variant would silently replace the pinned one. A body that names another variant (a foreign selector key, or the discriminator with a different value) is a typed `CLI_VALIDATION` usage error naming the intent, the variant, and the full-control escape command. The selector knowledge (`CLIVariantSelectors`: own/foreign distinguishing keys, discriminator key + pinned value) is derived by the decoder's link step (`variantSelectors` in `cli_commands_link.go`) and rendered into `flagutil.PresetMerge` (`intentcmd.go.stmpl`, runtime in `auxiliary/internal/flagutil/preset.go.stmpl`). +- Declared intent commands (`x-speakeasy-cli-commands`) keep their presets under a partial body: when the caller passes the intent's JSON body flag (`--body`, or the operation's single JSON body flag for metadata-free requests) or pipes the body on stdin, the intent merges its presets into that JSON per key — caller keys win, presets fill the gaps including the key that selects the pinned request variant, a known discriminator value is filled in — instead of handing the body to the operation untouched, where the union's default variant would silently replace the pinned one. A body that names another variant (a foreign selector key, or the discriminator with a different value) is a typed `CLI_VALIDATION` usage error naming the intent, the variant, and the full-control escape command. The selector knowledge (`CLIVariantSelectors`: own/foreign distinguishing keys, discriminator key + pinned value) is derived by the decoder's link step (`variantSelectors` in `cli_commands_link.go`) and rendered into `flagutil.PresetMerge` (`intentcmd.go.stmpl`, runtime in `auxiliary/internal/flagutil/preset.go.stmpl`). **Annotation contract**: @@ -1707,7 +1707,7 @@ The `--body` flag is registered on all metadata-driven operations that have a bo - `--body` and the metadata flag that carries the whole body field (e.g. `--body-param`, or `--request-body` for a non-expanded body) both set: error; neither silently wins. That whole-body metadata flag has `--body`'s precedence over stdin: when it is set, stdin is not read. - Required union fields: after `BuildRequest` or `BuildRequestBody` has merged every source, a reflection preflight walks the request body through structs, pointers, slices, arrays, and maps. A union value (identified by `union:"member"` fields) with no selected member and no non-empty `union:"unknown"` raw value fails as `*flagutil.MissingRequiredFieldError` / `CLI_VALIDATION`, naming its JSON-tag path (`input`, `nested.input`, `items[0].input`) and its generated union flag when metadata provides one. A missing root union instead says that the request body selects no variant and names the whole-body and union flags. Builder errors still win — notably, selecting an expanded discriminated variant while omitting one of that variant's required flags retains the precise `missing required flag` error. Nil pointer unions remain optional; selected members are traversed for nested required unions; and fields omitted by the SDK's `omitempty`/`omitzero` rules are skipped. Metadata-backed form/multipart bodies with no `--body` flag are included only for fields tagged `,json`, because those are the fields their serializer passes through union JSON marshaling. This runs before dry-run or live SDK invocation, so serializer text is not parsed as a fallback. Intent commands are covered because they synthesize or merge the body flag and then call the backing operation's same request builder. -**Declared intent commands** (`intentcmd.go.stmpl`): a whole request supplied up front — the JSON body flag (normally `--body`, or the operation's single body flag when it has no metadata) or piped stdin — takes request control and the declared inputs are not synthesized into a competing body. Stdin is read once with `flagutil.AttachStdinBody` (same mode-aware `ReadStdinBody` contract: nothing read on a TTY, EOF is no body, agent mode bounded by the stdin deadline) and re-attached as the command input so `BuildRequest` reads it exactly like the operation path; piped JSON therefore runs the request instead of landing on the help page, and interactive prompting (TTY only) is untouched. A positional argument or declared flag given alongside a supplied body is merged by `flagutil.MergeInputIntoBody` (typed: bool/int/float/string): if the body lacks the bound key the input is injected, if it already sets it the invocation errors naming both sources. +**Declared intent commands** (`intentcmd.go.stmpl`): a whole request supplied up front — the JSON body flag (normally `--body`, or the operation's single body flag when it has no metadata) or piped stdin — takes request control and the declared inputs are not synthesized into a competing body. Stdin is read once with `flagutil.AttachStdinBody` (same mode-aware `ReadStdinBody` contract: nothing read on a TTY, EOF is no body, agent mode bounded by the stdin deadline) and re-attached as the command input so `BuildRequest` reads it exactly like the operation path; piped JSON therefore runs the request instead of landing on the help page, and interactive prompting (TTY only) is untouched. A positional argument or declared flag given alongside a supplied body is merged by `flagutil.MergeInputIntoBody` (typed: bool/int/float/string): if the body lacks the bound key the input is injected, if it already sets it the invocation errors naming both sources. The preset merge then runs on a piped body exactly as on the body flag (`flagutil.MergePresetStdinBody`), so piping a partial body keeps the intent's pinned variant and a piped body naming another variant is the same `CLI_VALIDATION` error. **`@` forms of the body flag** (`flagutil.ResolveBodyFlagValue`, applied to `--body` and single `--request-body`-style flags in `BuildRequest`/`BuildRequestBody`): `--body @path` reads the file at `path`; `--body @-` reads stdin to EOF with a blocking read (no probe window); `--body @@...` is the literal escape (the value minus one `@`) for a raw body whose first byte must be `@`. Any other value is used verbatim. A supplied intent `--body` flows through `BuildRequest` unchanged, so the forms apply to intent commands too; a synthesized intent body never starts with `@`. A failed local read is explicitly `CLI_VALIDATION` and names both the path and flag; it is never inferred to be a connection problem from the wrapped OS error. diff --git a/templates/templates/cli/auxiliary/internal/flagutil/flags.go.stmpl b/templates/templates/cli/auxiliary/internal/flagutil/flags.go.stmpl index fad551831..c63c56ba5 100644 --- a/templates/templates/cli/auxiliary/internal/flagutil/flags.go.stmpl +++ b/templates/templates/cli/auxiliary/internal/flagutil/flags.go.stmpl @@ -745,6 +745,25 @@ func AttachStdinBody(cmd *cobra.Command, bodyFlag string) (bool, error) { return true, nil } +// MergePresetStdinBody merges an intent's preset into the body AttachStdinBody +// re-attached from stdin, as MergePresetBody does for a body flag. +func MergePresetStdinBody(cmd *cobra.Command, m PresetMerge) error { + in := cmd.InOrStdin() + if in == os.Stdin { + return nil + } + data, err := io.ReadAll(in) + if err != nil { + return fmt.Errorf("failed to read stdin: %w", err) + } + merged, err := MergePresetBody(string(data), m) + if err != nil { + return err + } + cmd.SetIn(strings.NewReader(merged)) + return nil +} + func DefaultBodyInput(cmd *cobra.Command, bodyFlags []string, key, flagName string, value any) error { for _, name := range bodyFlags { if name != "" && FlagChanged(cmd, name) { diff --git a/templates/templates/cli/intentcmd.go.stmpl b/templates/templates/cli/intentcmd.go.stmpl index edbdddb63..90760e88a 100644 --- a/templates/templates/cli/intentcmd.go.stmpl +++ b/templates/templates/cli/intentcmd.go.stmpl @@ -447,6 +447,11 @@ func runIntent{{$c.FuncName}}Cmd(cmd *cobra.Command, args []string) error { return err } } + if suppliedBodyFlag == "" { + if err := flagutil.MergePresetStdinBody(cmd, intent{{$c.FuncName}}Preset); err != nil { + return err + } + } } {{- end}} {{- end}} diff --git a/templates/templates/cli/tests/primary/intents_test.go.stmpl b/templates/templates/cli/tests/primary/intents_test.go.stmpl index 5709aaf21..16e23e1c5 100644 --- a/templates/templates/cli/tests/primary/intents_test.go.stmpl +++ b/templates/templates/cli/tests/primary/intents_test.go.stmpl @@ -382,6 +382,41 @@ func TestIntentPartialBodyKeepsPresets(t *testing.T) { } } +// A JSON body piped on stdin is a partial body like --body: the intent's +// presets fill its gaps, its own keys win, and a body naming another variant +// is refused. +func TestIntentPipedBodyKeepsPresets(t *testing.T) { + recordTest("intents-piped-body-keeps-presets") + s := newEchoServer() + defer s.Close() + + for _, bodyArgs := range [][]string{nil, {"--body", "@-"}} { + withDelayedStdinPipe(t, 0, `{"input":"cat"}`, true) + h := NewCLITestHarness(t) + require.NoError(t, h.RunWithServer(s.URL, append([]string{"render"}, bodyArgs...))) + body := echoedJSON(t, h.GetStdout()) + assert.Equal(t, "render-1", body["model"], "preset selector must survive a piped body") + assert.Equal(t, []interface{}{"image"}, body["output_modalities"]) + assert.Equal(t, "cat", body["input"]) + } + + withDelayedStdinPipe(t, 0, `{"model":"other-2"}`, true) + h := NewCLITestHarness(t) + require.NoError(t, h.RunWithServer(s.URL, []string{"render", "from-argument"})) + body := echoedJSON(t, h.GetStdout()) + assert.Equal(t, "other-2", body["model"]) + assert.Equal(t, []interface{}{"image"}, body["output_modalities"]) + assert.Equal(t, "from-argument", body["input"]) + + withDelayedStdinPipe(t, 0, `{"agent":"helper","input":"cat"}`, true) + h = NewCLITestHarness(t) + err := h.RunWithServer(s.URL, []string{"render"}) + require.Error(t, err) + assert.Contains(t, err.Error(), `"render" presets the CliIntentModelRequest request variant`) + assert.Contains(t, err.Error(), `names "agent"`) + assert.Empty(t, strings.TrimSpace(h.GetStdout()), "nothing must be sent") +} + func TestIntentPartialBodyUserKeysWin(t *testing.T) { recordTest("intents-partial-body-user-keys-win") s := newEchoServer() diff --git a/zSDKs/sdk-cli/internal/flagutil/flags.go b/zSDKs/sdk-cli/internal/flagutil/flags.go index e285840af..8741220e3 100644 --- a/zSDKs/sdk-cli/internal/flagutil/flags.go +++ b/zSDKs/sdk-cli/internal/flagutil/flags.go @@ -747,6 +747,25 @@ func AttachStdinBody(cmd *cobra.Command, bodyFlag string) (bool, error) { return true, nil } +// MergePresetStdinBody merges an intent's preset into the body AttachStdinBody +// re-attached from stdin, as MergePresetBody does for a body flag. +func MergePresetStdinBody(cmd *cobra.Command, m PresetMerge) error { + in := cmd.InOrStdin() + if in == os.Stdin { + return nil + } + data, err := io.ReadAll(in) + if err != nil { + return fmt.Errorf("failed to read stdin: %w", err) + } + merged, err := MergePresetBody(string(data), m) + if err != nil { + return err + } + cmd.SetIn(strings.NewReader(merged)) + return nil +} + func DefaultBodyInput(cmd *cobra.Command, bodyFlags []string, key, flagName string, value any) error { for _, name := range bodyFlags { if name != "" && FlagChanged(cmd, name) { From 19478b3ab4da0f9196f06c92b96072c3de9369c0 Mon Sep 17 00:00:00 2001 From: 2ynn Date: Wed, 7 Oct 2026 12:06:40 -0400 Subject: [PATCH 2/3] chore(cli): regenerate review CLI SDK --- zSDKs/sdk-cli/.speakeasy/gen.lock | 12 ++++++------ zSDKs/sdk-cli/internal/cli/intent_invite.go | 5 +++++ zSDKs/sdk-cli/internal/cli/intent_observe.go | 5 +++++ zSDKs/sdk-cli/internal/cli/intent_produce.go | 5 +++++ zSDKs/sdk-cli/internal/cli/intent_render.go | 5 +++++ zSDKs/sdk-cli/internal/cli/intent_say.go | 5 +++++ 6 files changed, 31 insertions(+), 6 deletions(-) diff --git a/zSDKs/sdk-cli/.speakeasy/gen.lock b/zSDKs/sdk-cli/.speakeasy/gen.lock index f914fd188..87f7f2def 100644 --- a/zSDKs/sdk-cli/.speakeasy/gen.lock +++ b/zSDKs/sdk-cli/.speakeasy/gen.lock @@ -118,15 +118,15 @@ trackedFiles: internal/cli/intent_enroll.go: last_write_checksum: sha1:0b9f2ab0e65a37413096390bffc7793ad7bb54cc internal/cli/intent_invite.go: - last_write_checksum: sha1:a90f39ad98e0fe14446aa34ee09f912ec4143e3e + last_write_checksum: sha1:793f73ebb8278cf397e5a6a0fefb0ce1d34ffb08 internal/cli/intent_observe.go: - last_write_checksum: sha1:0ba220ea9b1c72b5be13a6eb8cd01b46783359bb + last_write_checksum: sha1:a431c4a089387daea5b9f974ae5b4d1663414026 internal/cli/intent_produce.go: - last_write_checksum: sha1:c0b552ae90de890d610a7ba3e9c460053ea5b4e5 + last_write_checksum: sha1:41e8095d62f9bab26b77c3be790b4ed1eac7f8f1 internal/cli/intent_render.go: - last_write_checksum: sha1:e2a15348c10c96dffd27cb4a91805386b8cb2b0c + last_write_checksum: sha1:9b211c0615b4df7cad91ca9434669eebcc7282e3 internal/cli/intent_say.go: - last_write_checksum: sha1:8d0ae5e57f3f635d46c26e92c75016472ccd9888 + last_write_checksum: sha1:e11155fa56f0d704c70ee884ad5f5d60a8b8ad1a internal/cli/intents.go: last_write_checksum: sha1:997b951af6d215576d30574a54022937beedd890 internal/cli/intents_order_test.go: @@ -244,7 +244,7 @@ trackedFiles: internal/flagutil/dispatch.go: last_write_checksum: sha1:fecd48dd69b4743ff5c6c1329a2071fe49d9423e internal/flagutil/flags.go: - last_write_checksum: sha1:a81b13ee97e283dccec6da827fe81159f2ce3ec5 + last_write_checksum: sha1:7a7eb7d5cde0897d7008370c1ac083d154fbc65e internal/flagutil/metadata.go: last_write_checksum: sha1:1936c82d8e36a6612364827ffc05dcbea0e1dd8f internal/flagutil/preset.go: diff --git a/zSDKs/sdk-cli/internal/cli/intent_invite.go b/zSDKs/sdk-cli/internal/cli/intent_invite.go index 92936e414..0a7045bfb 100644 --- a/zSDKs/sdk-cli/internal/cli/intent_invite.go +++ b/zSDKs/sdk-cli/internal/cli/intent_invite.go @@ -172,6 +172,11 @@ func runIntentInviteCmd(cmd *cobra.Command, args []string) error { return err } } + if suppliedBodyFlag == "" { + if err := flagutil.MergePresetStdinBody(cmd, intentInvitePreset); err != nil { + return err + } + } } if f := cmd.Flag("jq"); f != nil && !f.Changed { _ = f.Value.Set(".id") diff --git a/zSDKs/sdk-cli/internal/cli/intent_observe.go b/zSDKs/sdk-cli/internal/cli/intent_observe.go index d1f580406..d4a0140b0 100644 --- a/zSDKs/sdk-cli/internal/cli/intent_observe.go +++ b/zSDKs/sdk-cli/internal/cli/intent_observe.go @@ -143,6 +143,11 @@ func runIntentObserveCmd(cmd *cobra.Command, args []string) error { return err } } + if suppliedBodyFlag == "" { + if err := flagutil.MergePresetStdinBody(cmd, intentObservePreset); err != nil { + return err + } + } } if err := output.ValidateAsyncFlags(cmd); err != nil { return err diff --git a/zSDKs/sdk-cli/internal/cli/intent_produce.go b/zSDKs/sdk-cli/internal/cli/intent_produce.go index 8648dedbe..698d88bc4 100644 --- a/zSDKs/sdk-cli/internal/cli/intent_produce.go +++ b/zSDKs/sdk-cli/internal/cli/intent_produce.go @@ -155,6 +155,11 @@ func runIntentProduceCmd(cmd *cobra.Command, args []string) error { return err } } + if suppliedBodyFlag == "" { + if err := flagutil.MergePresetStdinBody(cmd, intentProducePreset); err != nil { + return err + } + } } if err := output.ValidateAsyncFlags(cmd); err != nil { return err diff --git a/zSDKs/sdk-cli/internal/cli/intent_render.go b/zSDKs/sdk-cli/internal/cli/intent_render.go index 3b3c32cdc..695eca04a 100644 --- a/zSDKs/sdk-cli/internal/cli/intent_render.go +++ b/zSDKs/sdk-cli/internal/cli/intent_render.go @@ -144,6 +144,11 @@ func runIntentRenderCmd(cmd *cobra.Command, args []string) error { return err } } + if suppliedBodyFlag == "" { + if err := flagutil.MergePresetStdinBody(cmd, intentRenderPreset); err != nil { + return err + } + } } return runRenderAssetCmd(cmd, nil) } diff --git a/zSDKs/sdk-cli/internal/cli/intent_say.go b/zSDKs/sdk-cli/internal/cli/intent_say.go index db058c009..412950b97 100644 --- a/zSDKs/sdk-cli/internal/cli/intent_say.go +++ b/zSDKs/sdk-cli/internal/cli/intent_say.go @@ -160,6 +160,11 @@ func runIntentSayCmd(cmd *cobra.Command, args []string) error { return err } } + if suppliedBodyFlag == "" { + if err := flagutil.MergePresetStdinBody(cmd, intentSayPreset); err != nil { + return err + } + } } return runChatCmd(cmd, nil) } From 2d0e0c84ddcf9f1fe65458e3a4fd915b18f14bea Mon Sep 17 00:00:00 2001 From: 2ynn Date: Wed, 7 Oct 2026 12:36:30 -0400 Subject: [PATCH 3/3] test(cli): assert the piped-body variant conflict sends nothing and is typed --- .../cli/tests/primary/intents_test.go.stmpl | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/templates/templates/cli/tests/primary/intents_test.go.stmpl b/templates/templates/cli/tests/primary/intents_test.go.stmpl index 16e23e1c5..b2f41cb0d 100644 --- a/templates/templates/cli/tests/primary/intents_test.go.stmpl +++ b/templates/templates/cli/tests/primary/intents_test.go.stmpl @@ -3,7 +3,10 @@ package tests import ( "encoding/json" "errors" + "net/http" + "net/http/httptest" "strings" + "sync/atomic" "testing" "github.com/stretchr/testify/assert" @@ -382,11 +385,7 @@ func TestIntentPartialBodyKeepsPresets(t *testing.T) { } } -// A JSON body piped on stdin is a partial body like --body: the intent's -// presets fill its gaps, its own keys win, and a body naming another variant -// is refused. func TestIntentPipedBodyKeepsPresets(t *testing.T) { - recordTest("intents-piped-body-keeps-presets") s := newEchoServer() defer s.Close() @@ -408,13 +407,23 @@ func TestIntentPipedBodyKeepsPresets(t *testing.T) { assert.Equal(t, []interface{}{"image"}, body["output_modalities"]) assert.Equal(t, "from-argument", body["input"]) + var requests atomic.Int32 + counting := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + })) + defer counting.Close() withDelayedStdinPipe(t, 0, `{"agent":"helper","input":"cat"}`, true) h = NewCLITestHarness(t) - err := h.RunWithServer(s.URL, []string{"render"}) + err := h.RunWithServer(counting.URL, []string{"render"}) require.Error(t, err) assert.Contains(t, err.Error(), `"render" presets the CliIntentModelRequest request variant`) assert.Contains(t, err.Error(), `names "agent"`) - assert.Empty(t, strings.TrimSpace(h.GetStdout()), "nothing must be sent") + assert.Contains(t, err.Error(), `use "cli cli-intents cli-intent-run"`) + var conflict *flagutil.PresetConflictError + require.True(t, errors.As(err, &conflict)) + assert.Equal(t, "CLI_VALIDATION", conflict.CLIReason()) + assert.Equal(t, clierrors.ExitUsage, clierrors.ExitCode(err)) + assert.Zero(t, requests.Load()) } func TestIntentPartialBodyUserKeysWin(t *testing.T) {