diff --git a/.changesets/1792716144-d8272bd9.yaml b/.changesets/1792716144-d8272bd9.yaml new file mode 100644 index 000000000..b9cfb3335 --- /dev/null +++ b/.changesets/1792716144-d8272bd9.yaml @@ -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" diff --git a/internal/extensions/cli_commands.go b/internal/extensions/cli_commands.go index 0c073cd11..78cdeebd5 100644 --- a/internal/extensions/cli_commands.go +++ b/internal/extensions/cli_commands.go @@ -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. @@ -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 ( @@ -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) @@ -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: @@ -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 } diff --git a/internal/extensions/cli_commands_link.go b/internal/extensions/cli_commands_link.go index ddc6dfb9c..217fd7025 100644 --- a/internal/extensions/cli_commands_link.go +++ b/internal/extensions/cli_commands_link.go @@ -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) @@ -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 @@ -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 { diff --git a/internal/extensions/cli_commands_test.go b/internal/extensions/cli_commands_test.go index eca76ed66..be008bda0 100644 --- a/internal/extensions/cli_commands_test.go +++ b/internal/extensions/cli_commands_test.go @@ -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) + } +} diff --git a/templates/templates/cli/README.md b/templates/templates/cli/README.md index 903766a8e..8948d0f91 100644 --- a/templates/templates/cli/README.md +++ b/templates/templates/cli/README.md @@ -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 `