Skip to content
Open
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/1792716144-d8272bd9.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
id: 1792716144-d8272bd9
features:
- cliCommands
targets:
- cli
type: feat
bump: patch
description: let declarative command flags imply other request body values, such as a background flag that turns streaming off
author: TristanSpeakEasy
date: "2026-10-23"
30 changes: 29 additions & 1 deletion internal/extensions/cli_commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ type CLICommandInput struct {
// SuggestionsDeclared records that Enum came from the manifest, so help
// shows the whole authored list instead of the capped schema sample.
SuggestionsDeclared bool `json:"suggestionsDeclared,omitempty" yaml:"suggestionsDeclared,omitempty"`
// Implies lists root-level body values filled when the request body sets
// this bool flag's key to true and does not set the implied key itself.
Implies []CLICommandPreset `json:"implies,omitempty" yaml:"implies,omitempty"`
// RouteIDs lists the dispatch routes whose request variant declares this
// input's bound property. RequiredRouteIDs is the subset on which the
// property remains required after schema defaults and effective presets.
Expand Down Expand Up @@ -544,7 +547,7 @@ var cliOperationReservedFlagNames = map[string]string{
}

var cliInputKeys = []string{
"to", "type", "required", "variadic", "shorthand", "summary", "default", "defaultFrom", "suggestions",
"to", "type", "required", "variadic", "shorthand", "summary", "default", "defaultFrom", "suggestions", "implies",
}

var (
Expand Down Expand Up @@ -828,6 +831,9 @@ func checkOperationInputs(opID string, flags []CLICommandInput) error {
shorthands := map[string]string{}
pointers := map[string]string{}
for _, flag := range flags {
if len(flag.Implies) > 0 {
return fmt.Errorf("operation %q flag %q: implies is only supported on x-speakeasy-cli-commands commands", opID, flag.Name)
}
if flag.Shorthand != "" {
if previous, ok := shorthands[flag.Shorthand]; ok {
return fmt.Errorf("operation %q: flags %q and %q both use shorthand -%s", opID, previous, flag.Name, flag.Shorthand)
Expand Down Expand Up @@ -2095,6 +2101,18 @@ func (d *cliManifestDecoder) decodeInput(cmdKey, name string, node *yaml.Node, i
}
input.Enum = values
input.SuggestionsDeclared = true
case "implies":
if isArg {
return nil, fmt.Errorf("line %d: command %q arg %q: implies is only valid on flags", entry.Value.Line, cmdKey, name)
}
implies, err := d.decodePresets(fmt.Sprintf("%s flag %s implies", cmdKey, name), entry.Value)
if err != nil {
return nil, err
}
if len(implies) == 0 {
return nil, fmt.Errorf("line %d: command %q flag %q: implies must set at least one body value", entry.Value.Line, cmdKey, name)
}
input.Implies = implies
case "mode":
return nil, fmt.Errorf("line %d: command %q input %q: mode lives inside the to: object and only \"set\" is part of v1 (append/merge are reserved capabilities)", entry.Key.Line, cmdKey, name)
default:
Expand All @@ -2114,6 +2132,16 @@ func (d *cliManifestDecoder) decodeInput(cmdKey, name string, node *yaml.Node, i
return nil, err
}
input.Bind = bind
if len(input.Implies) > 0 {
if bind.In != "body" || strings.Count(bind.Pointer, "/") != 1 {
return nil, fmt.Errorf("command %q flag %q: implies requires the flag to bind a root-level body property", cmdKey, name)
}
for _, implied := range input.Implies {
if implied.Bind.Pointer == bind.Pointer {
return nil, fmt.Errorf("command %q flag %q: implies cannot set the flag's own property %s", cmdKey, name, bind.Pointer)
}
}
}
return input, nil
}

Expand Down
46 changes: 46 additions & 0 deletions internal/extensions/cli_commands_link.go
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,9 @@ func (d *cliManifestDecoder) linkSingleRouteCommand(cmd *CLICommand) error {
if err := d.linkInput(key, &cmd.Flags[i], variant, presetPointers, presetValues, false); err != nil {
return err
}
if err := d.linkImplies(key, &cmd.Flags[i], []*cliResolvedSchema{variant}); err != nil {
return err
}
}

d.checkSatisfiability(key, cmd, variant, presetPointers)
Expand Down Expand Up @@ -691,6 +694,15 @@ func (d *cliManifestDecoder) linkDispatchCommand(cmd *CLICommand) error {
if err := d.linkDispatchInput(key, &cmd.Flags[i], links, false); err != nil {
return err
}
var variants []*cliResolvedSchema
for _, link := range links {
if cliContains(cmd.Flags[i].RouteIDs, link.route.ID) {
variants = append(variants, link.variant)
}
}
if err := d.linkImplies(key, &cmd.Flags[i], variants); err != nil {
return err
}
}
if err := d.linkDispatchAnchors(key, cmd, links); err != nil {
return err
Expand Down Expand Up @@ -3950,6 +3962,40 @@ func cliSortedKeys(m map[string]bool) []string {
return keys
}

// linkImplies checks a flag's implied values against every request variant
// that declares the flag. Each implied property must exist on all of them and
// normalize to the same value, because one runtime rule serves every route.
func (d *cliManifestDecoder) linkImplies(cmdKey string, input *CLICommandInput, variants []*cliResolvedSchema) error {
if len(input.Implies) == 0 {
return nil
}
if input.Type != "bool" {
return fmt.Errorf("command %q flag %q: implies requires a bool flag, but the flag is %s", cmdKey, input.Name, input.Type)
}
for i := range input.Implies {
pointer := input.Implies[i].Bind.Pointer
name := cliPointerPropertyName(pointer)
var normalized any
for j, variant := range variants {
if _, declared := variant.properties[name]; !declared {
return fmt.Errorf("command %q flag %q: implied property %s is not declared by %s", cmdKey, input.Name, pointer, cliSchemaLabel(variant))
}
candidate := input.Implies[i]
if err := d.linkPreset(cmdKey, &candidate, variant); err != nil {
return fmt.Errorf("flag %q implies: %w", input.Name, err)
}
if j > 0 && !cliValuesEqual(normalized, candidate.Value) {
return fmt.Errorf("command %q flag %q: implied value for %s normalizes differently across request variants", cmdKey, input.Name, pointer)
}
normalized = candidate.Value
}
if len(variants) > 0 {
input.Implies[i].Value = normalized
}
}
return nil
}

func (d *cliManifestDecoder) linkPreset(cmdKey string, preset *CLICommandPreset, variant *cliResolvedSchema) error {
facts, found, err := d.lookupProperty(cmdKey, "preset "+preset.Bind.Pointer, preset.Bind.Pointer, variant)
if err != nil {
Expand Down
88 changes: 88 additions & 0 deletions internal/extensions/cli_commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5423,3 +5423,91 @@ operations:
assert.Contains(t, warnings[1], "output.stream.metadata.select")
assert.Contains(t, warnings[1], "could not be verified")
}

func TestCLICommands_FlagImplies(t *testing.T) {
manifest, _, err := decodeCLITest(t, `
version: 1
commands:
render:
op: CreateTask#CreateRenderTaskParams
flags:
background: {to: $.background, implies: {$.priority: 1}}
`)
require.NoError(t, err)
background := manifest.Commands[0].Flags[0]
assert.Equal(t, "bool", background.Type)
require.Len(t, background.Implies, 1)
assert.Equal(t, "/priority", background.Implies[0].Bind.Pointer)
assert.EqualValues(t, 1, background.Implies[0].Value)

streamed := strings.ReplaceAll(cliRouteDispatchSpec,
" background:\n type: boolean\n description: Run in the background.\n",
" background:\n type: boolean\n description: Run in the background.\n stream:\n type: boolean\n default: true\n")
dispatch := strings.Replace(cliRouteDispatchManifest,
" background: {to: $.background}\n",
" background: {to: $.background, implies: {$.stream: false}}\n", 1)
manifest, err = decodeCLITestSpec(t, streamed, dispatch)
require.NoError(t, err)
flag := cliFindDispatchFlag(manifest.Commands[0].Flags, "background")
require.NotNil(t, flag)
require.Len(t, flag.Implies, 1)
assert.Equal(t, CLICommandPreset{Bind: CLICommandBind{In: "body", Pointer: "/stream", Mode: "set"}, Value: false}, flag.Implies[0])

_, err = decodeCLITestSpec(t, cliRouteDispatchSpec, strings.Replace(cliRouteDispatchManifest,
" background: {to: $.background}\n",
" background: {to: $.background, implies: {$.output_modes: sync}}\n", 1))
require.Error(t, err)
assert.Contains(t, err.Error(), `implied property /output_modes is not declared by`)

for _, tc := range []struct{ manifest, want string }{
{`
version: 1
commands:
render:
op: CreateTask#CreateRenderTaskParams
args:
prompt: {to: $.input, type: string, variadic: true, implies: {$.priority: 1}}
`, `implies is only valid on flags`},
{`
version: 1
commands:
render:
op: CreateTask#CreateRenderTaskParams
flags:
engine: {to: $.engine, implies: {$.priority: 1}}
`, `implies requires a bool flag`},
{`
version: 1
commands:
render:
op: CreateTask#CreateRenderTaskParams
flags:
background: {to: $.background, implies: {$.background: false}}
`, `implies cannot set the flag's own property /background`},
{`
version: 1
commands:
render:
op: CreateTask#CreateRenderTaskParams
flags:
background: {to: $.background, implies: {$.priority: fast}}
`, `not an integer`},
{`
version: 1
commands:
render:
op: CreateTask#CreateRenderTaskParams
flags:
background: {to: $.background, implies: {}}
`, `implies must set at least one body value`},
{`
version: 1
operations:
CreateUpload:
flags:
name: {to: $.name, implies: {$.other: 1}}
`, `implies is only supported on x-speakeasy-cli-commands commands`},
} {
requireDecodeError(t, tc.manifest, tc.want)
}
}
9 changes: 9 additions & 0 deletions templates/templates/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,15 @@ There may be at most one explicit `default: true`. For safety, it must match the

Selection happens before route-local presets or schema defaults are applied. After selection, required inputs are checked for that route, declared inputs are merged, then the effective preset is filled (command preset first, route preset overriding it; user keys always win at every depth, and an object preset fills the missing keys of the user's object for the same key only at the merge points the schema proves safe — plain objects, map entries, and union members the preset uniquely identifies while the user's discriminator is absent or agrees; elsewhere, including objects restricted to `const`/`enum` values, the user's object is kept whole). Conditionally required route inputs are reported before shared requirements, so optional pipeline-only evidence can produce `required flag --pipeline not set for the Pipeline variant`. Only inputs required on every route are annotated as required for interactive prompting; anchors and conditionally required flags are not prompted because the prompt engine has no one-of primitive. A shared required positional may be prompted, after which the explicit default or later flag evidence selects the route.

A bool flag bound to a root-level body property may declare `implies:`, a map of root-level body values to fill when the final request body sets that property to `true`:

```yaml
flags:
background: {to: $.background, implies: {$.stream: false}}
```

The rule runs after `--body`/stdin, declared inputs and presets are merged, so it applies to `--background` and to `--body '{"background":true}'` alike, and any implied key the request already carries (for example an explicit `--stream`) is left alone. The linker requires every implied property to exist on each request variant that declares the flag, with the same normalized value. `implies:` is rejected on positional arguments and on `operations:` flags.

`--body` remains the advanced escape and must contain a JSON object for dispatch. Its `@path`, `@-`, and stdin forms are resolved exactly once; the runtime selects from the object's keys, merges inputs and the selected preset, and replays the final object through the generic `--body` flag for the normal generated `BuildRequest` path. A body key unique to an unrouted union member returns a typed error naming the generated-operation escape. Unknown keys remain owned by `BuildRequest`'s normal body-key verification. Dispatch requires the generated operation to have flag metadata and the generic `--body` surface; pure `BuildRequestBody` operations are gated.

JSON-body intent commands, including single-route commands, never register backing request-body metadata: no inherited whole-union JSON flag, expanded body fields, or body defaults. They retain non-body path/query/header metadata, operation security, the JSON body flag, `--schema`, and declared flags. Body-less intents retain their parameter flags; multipart intents retain their upload fields because they have no JSON body escape. Use the generated operation command for the full JSON body-field flag surface. Dispatch help puts route-specific flags under `<Label> variant Flags:` (or `A / B variants` for a strict multi-route subset), leaves shared flags ungrouped, and appends the anchor/default summary. The static `--usage` KDL contains exactly the same surface.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"io"
"math/big"
"os"
"reflect"
"strconv"
"strings"
Expand Down Expand Up @@ -359,3 +360,73 @@ func exactJSONEqual(a, b any) bool {
return reflect.DeepEqual(a, b)
}
}

type ImpliedBody struct {
Key string
Values string
}

// ApplyImpliedBody fills each rule's values into the request body when the
// body sets the rule's key to true. Keys the request already carries win.
// The body is the body flag when set, else the body AttachStdinBody attached.
func ApplyImpliedBody(cmd *cobra.Command, bodyFlag string, rules []ImpliedBody) error {
if len(rules) == 0 {
return nil
}
if FlagChanged(cmd, bodyFlag) {
raw, _ := GetStringFlag(cmd, bodyFlag)
implied, changed, err := impliedBody(raw, rules)
if err != nil || !changed {
return err
}
return cmd.Flags().Set(bodyFlag, implied)
}
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)
}
implied, changed, err := impliedBody(string(data), rules)
if err != nil {
return err
}
if !changed {
implied = string(data)
}
cmd.SetIn(strings.NewReader(implied))
return nil
}

func impliedBody(raw string, rules []ImpliedBody) (string, bool, error) {
var body map[string]json.RawMessage
if err := json.Unmarshal([]byte(raw), &body); err != nil || body == nil {
return raw, false, nil
}
changed := false
for _, rule := range rules {
if !bytes.Equal(bytes.TrimSpace(body[rule.Key]), []byte("true")) {
continue
}
var values map[string]json.RawMessage
if err := json.Unmarshal([]byte(rule.Values), &values); err != nil {
return raw, false, err
}
for key, value := range values {
if _, set := body[key]; !set {
body[key] = value
changed = true
}
}
}
if !changed {
return raw, false, nil
}
encoded, err := json.Marshal(body)
if err != nil {
return raw, false, err
}
return string(encoded), true, nil
}
17 changes: 17 additions & 0 deletions templates/templates/cli/includes/intents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ interface IntentCmdCtx {
ArgsHelp: string; // "Arguments:" section lines for the help page
Flags: IntentBodyEntry[];
PromptFlags: IntentBodyEntry[]; // required declared flags a preset does not cover (interactive prompting)
Implied: { Key: string; Values: string }[]; // flagutil.ImpliedBody rules from flag implies:
PresetJSON: string; // JSON object merged into the body before user inputs
// Partial-body preset merge (flagutil.PresetMerge): how the pinned request
// variant is told apart from its union siblings, so a caller-supplied
Expand Down Expand Up @@ -279,6 +280,21 @@ function intentFallbackFlag(entry: IntentBodyEntry): string {
return `--${entry.Name} ${intentExamplePlaceholder(entry.Name || "value")}`;
}

function intentImpliedRules(flags: any[]): { Key: string; Values: string }[] {
const rules: { Key: string; Values: string }[] = [];
for (const f of flags) {
const implies: any[] = f.Implies || [];
const key = intentPointerKey(f.Bind?.Pointer || "");
if (!implies.length || !key) continue;
const values: Record<string, any> = Object.create(null);
for (const implied of implies) {
values[intentPointerKey(implied.Bind?.Pointer || "")] = implied.Value;
}
rules.push({ Key: key, Values: JSON.stringify(values) });
}
return rules;
}

// Single-segment RFC 6901 pointer → top-level body key. Args, presets and
// dispatch keys bind single-segment pointers only, so a miss here is
// unreachable; flags may nest and use intentPointerPath.
Expand Down Expand Up @@ -1289,6 +1305,7 @@ function collectIntentManifest(): IntentManifestCtx {
.join("\n"),
Flags: flags,
PromptFlags: flags.filter((f) => f.Required && !f.PresetCovered),
Implied: intentImpliedRules(cmd.Flags || []),
PresetJSON: presetJSON,
CommandDisplay: path.join(" "),
VariantLabel: (route?.RequestVariant || "").replace(
Expand Down
9 changes: 9 additions & 0 deletions templates/templates/cli/intentcmd.go.stmpl
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,15 @@ func runIntent{{$c.FuncName}}Cmd(cmd *cobra.Command, args []string) error {
{{- end}}
{{- end}}
{{- end}}
{{- if and $c.BodyFlag $c.Implied}}
if err := flagutil.ApplyImpliedBody(cmd, "{{$c.BodyFlag}}", []flagutil.ImpliedBody{
Comment thread
TristanSpeakEasy marked this conversation as resolved.
{{- range $c.Implied}}
{Key: "{{escapeGoString .Key}}", Values: "{{escapeGoString .Values}}"},
{{- end}}
}); err != nil {
return err
}
{{- end}}
{{- if $c.AsyncJSON}}
if err := output.ValidateAsyncFlags(cmd); err != nil {
return err
Expand Down
2 changes: 1 addition & 1 deletion zSDKs/sdk-cli/.speakeasy/gen.lock

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

Loading
Loading