Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changesets/1791388271-e35b0f02.yaml
Original file line number Diff line number Diff line change
@@ -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"
4 changes: 2 additions & 2 deletions templates/templates/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**:

Expand Down Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
5 changes: 5 additions & 0 deletions templates/templates/cli/intentcmd.go.stmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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}}
Expand Down
44 changes: 44 additions & 0 deletions templates/templates/cli/tests/primary/intents_test.go.stmpl
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ package tests
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -382,6 +385,47 @@ func TestIntentPartialBodyKeepsPresets(t *testing.T) {
}
}

func TestIntentPipedBodyKeepsPresets(t *testing.T) {
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"])

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(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.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) {
recordTest("intents-partial-body-user-keys-win")
s := newEchoServer()
Expand Down
12 changes: 6 additions & 6 deletions zSDKs/sdk-cli/.speakeasy/gen.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions zSDKs/sdk-cli/internal/cli/intent_invite.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
5 changes: 5 additions & 0 deletions zSDKs/sdk-cli/internal/cli/intent_observe.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions zSDKs/sdk-cli/internal/cli/intent_produce.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions zSDKs/sdk-cli/internal/cli/intent_render.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
5 changes: 5 additions & 0 deletions zSDKs/sdk-cli/internal/cli/intent_say.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
19 changes: 19 additions & 0 deletions zSDKs/sdk-cli/internal/flagutil/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading