diff --git a/.changesets/1791330588-22ed09b7.yaml b/.changesets/1791330588-22ed09b7.yaml new file mode 100644 index 000000000..2b1396903 --- /dev/null +++ b/.changesets/1791330588-22ed09b7.yaml @@ -0,0 +1,10 @@ +id: 1791330588-22ed09b7 +features: + - cliCommands +targets: + - cli +type: feat +bump: patch +description: An intent command flag can bind one level beneath an object preset (to $.format.width under a $.format preset); the field is resolved and typed in the union member the preset selects, written into the preset object, and merged into a partial --body or --body-param object, which is refused when it selects another member +author: 2ynn +date: "2026-10-06" diff --git a/internal/extensions/cli_commands.go b/internal/extensions/cli_commands.go index 721b8b2dd..7c7908ba0 100644 --- a/internal/extensions/cli_commands.go +++ b/internal/extensions/cli_commands.go @@ -500,7 +500,7 @@ var CLIRuntimeHintReasons = []string{ // to the capability an author is asking for, so the error can name it instead // of pretending the key is a typo. var cliReservedCommandKeys = map[string]string{ - "payload": "payload templates require the request-plan.payload-holes capability, which is not part of v1; multi-segment binds stay hard errors until it ships", + "payload": "payload templates require the request-plan.payload-holes capability, which is not part of v1; binds deeper than one level beneath an object preset stay hard errors until it ships", "via": "via is reserved for a future routing capability and is not part of v1", "pos": "pos is reserved for a future positional-layout capability and is not part of v1", "format": "format is reserved for a future output-format capability and is not part of v1; use jq for projections", @@ -2144,12 +2144,15 @@ func (d *cliManifestDecoder) decodeBind(cmdKey, name string, node *yaml.Node) (* if err != nil { return nil, fmt.Errorf("line %d: command %q input %q: %w", node.Line, cmdKey, name, err) } - if len(segments) > 1 { - return nil, fmt.Errorf("line %d: command %q input %q: to: %s is a multi-segment body path; nested construction requires the request-plan.payload-holes capability and multi-segment binds stay hard errors until it ships", node.Line, cmdKey, name, raw) + if len(segments) > 2 { + return nil, fmt.Errorf("line %d: command %q input %q: to: %s is a multi-segment body path; a flag may bind one level beneath an object preset ($..), deeper construction requires the request-plan.payload-holes capability and stays a hard error until it ships", node.Line, cmdKey, name, raw) } if segments[0].IsIndex { return nil, fmt.Errorf("line %d: command %q input %q: to: %s addresses an array index at the body root, which cannot be a JSON object field", node.Line, cmdKey, name, raw) } + if len(segments) == 2 && segments[1].IsIndex { + return nil, fmt.Errorf("line %d: command %q input %q: to: %s addresses an array index; a nested bind targets a field of an object preset", node.Line, cmdKey, name, raw) + } return &CLICommandBind{In: "body", Pointer: cliSegmentsToPointer(segments), Mode: "set"}, nil case yaml.MappingNode: entries, err := cliMapEntries(node, fmt.Sprintf("command %q input %q to", cmdKey, name)) @@ -2406,6 +2409,20 @@ func (d *cliManifestDecoder) checkCommandInputs(cmd *CLICommand, key string) err } } } + for _, list := range [][]CLICommandInput{cmd.Args, cmd.Flags} { + for _, input := range list { + if input.Bind == nil || input.Bind.In != "body" { + continue + } + parent, _, nested := cliSplitNestedPointer(input.Bind.Pointer) + if !nested { + continue + } + if prev, ok := pointers[parent]; ok { + return fmt.Errorf("command %q: input %q binds %s and input %q binds %s beneath it; bind the object or its field, not both", key, prev, parent, input.Name, input.Bind.Pointer) + } + } + } return nil } diff --git a/internal/extensions/cli_commands_link.go b/internal/extensions/cli_commands_link.go index 96616db5b..9db65297f 100644 --- a/internal/extensions/cli_commands_link.go +++ b/internal/extensions/cli_commands_link.go @@ -799,6 +799,9 @@ func (d *cliManifestDecoder) checkDispatchValueDiscrimination(cmdKey string, mem // intent bindings: the property must exist with the same scalar type in every // member so the flag cannot silently select or invalidate a variant. func (d *cliManifestDecoder) linkOperationFlag(opID string, input *CLICommandInput, bodySchema any) error { + if _, _, nested := cliSplitNestedPointer(input.Bind.Pointer); nested { + return fmt.Errorf("operation %q flag %q binds %s; operation flags bind top-level body fields only (nested binds are for intent commands with an object preset)", opID, input.Name, input.Bind.Pointer) + } bodyMap, _ := bodySchema.(map[string]any) unionMap, _ := d.unionBodySchema(bodyMap) rawVariants := cliUnionMembers(unionMap) @@ -1311,6 +1314,9 @@ func (d *cliManifestDecoder) linkDispatchInput(cmdKey string, input *CLICommandI if isArg { what = "arg" } + if _, _, nested := cliSplitNestedPointer(input.Bind.Pointer); nested { + return fmt.Errorf("command %q: %s %q binds %s; nested binds are not part of route dispatch (routes: commands bind top-level body fields only)", cmdKey, what, input.Name, input.Bind.Pointer) + } var declaring []cliDispatchInputFacts for i := range links { link := &links[i] @@ -3403,6 +3409,57 @@ func (d *cliManifestDecoder) lookupProperty(cmdKey, what, pointer string, varian return facts, true, nil } +func cliSplitNestedPointer(pointer string) (parent, leaf string, ok bool) { + head, tail, found := strings.Cut(strings.TrimPrefix(pointer, "/"), "/") + if !found { + return "", "", false + } + return "/" + head, "/" + tail, true +} + +// nestedBindScope resolves the object a nested flag bind writes into: the +// property at parent, or the member of its union the object preset selects. +func (d *cliManifestDecoder) nestedBindScope(cmdKey, what, parent, leaf string, variant *cliResolvedSchema, presetValues map[string]any) (*cliResolvedSchema, map[string]any, error) { + pointer := parent + leaf + presetObj, ok := presetValues[parent].(map[string]any) + if !ok { + return nil, nil, fmt.Errorf("command %q %s %s nests beneath %s, which no object preset sets; a nested bind targets a field of an object preset (preset: {$.%s: {...}})", cmdKey, what, pointer, parent, cliPointerPropertyName(parent)) + } + flat := newCLIFlatUnion() + d.flattenUnion(variant.properties[cliPointerPropertyName(parent)], 0, nil, flat) + field := cliPointerPropertyName(leaf) + var scope *cliResolvedSchema + pinned := "" + switch { + case len(flat.members) == 1: + scope = flat.members[0] + if _, ok := presetObj[field]; ok && cliContains(flat.keys, field) { + pinned = field + } + case len(flat.members) > 1: + point, member := d.presetUnionMergePoint(parent, flat, presetObj) + if member == nil { + return nil, nil, fmt.Errorf("command %q %s %s: the preset at %s does not select exactly one member of its union, so the field cannot be resolved; pin the member's discriminator in the preset", cmdKey, what, pointer, parent) + } + pinned = point.Key + scope = member + } + if pinned != "" && pinned == field { + return nil, nil, fmt.Errorf("command %q %s %s targets the discriminator of the union at %s, which the preset pins; a flag there would switch members", cmdKey, what, pointer, parent) + } + if scope == nil { + return nil, nil, fmt.Errorf("command %q %s %s nests beneath %s, which is not an object property", cmdKey, what, pointer, parent) + } + if scope.enumerated { + return nil, nil, fmt.Errorf("command %q %s %s nests beneath %s, whose schema restricts the object to const/enum values; a field set by a flag would match none of them", cmdKey, what, pointer, parent) + } + leafPresets := map[string]any{} + if value, ok := presetObj[field]; ok { + leafPresets[leaf] = value + } + return scope, leafPresets, nil +} + func cliPointerPropertyName(pointer string) string { name := strings.TrimPrefix(pointer, "/") name = strings.ReplaceAll(name, "~1", "/") @@ -3429,10 +3486,27 @@ func (d *cliManifestDecoder) linkInput(cmdKey string, input *CLICommandInput, va return fmt.Errorf("command %q arg %q resolves to type %s; the variadic positional joins argv into a single string, so only string bindings are part of v1 (declare a flag instead)", cmdKey, input.Name, input.Type) } - facts, found, err := d.lookupProperty(cmdKey, what, input.Bind.Pointer, variant) + pointer := input.Bind.Pointer + parent, leaf, nested := cliSplitNestedPointer(pointer) + if nested { + if isArg { + return fmt.Errorf("command %q arg %q binds %s; the positional binds a top-level body field (declare a flag for a nested field)", cmdKey, input.Name, pointer) + } + scope, leafPresets, err := d.nestedBindScope(cmdKey, what, parent, leaf, variant, presetValues) + if err != nil { + return err + } + variant, presetValues, pointer = scope, leafPresets, leaf + what = fmt.Sprintf("flag %q bind beneath %s:", input.Name, parent) + } + + facts, found, err := d.lookupProperty(cmdKey, what, pointer, variant) if err != nil { return err } + if nested && found && facts.readOnly { + return fmt.Errorf("command %q %s %s targets a readOnly property; the server would reject or ignore it", cmdKey, what, input.Bind.Pointer) + } if !found { if input.Type == "" { return fmt.Errorf("command %q %s %s targets an undeclared property; declare an explicit type: (string, int, float, or bool)", cmdKey, what, input.Bind.Pointer) @@ -3442,7 +3516,7 @@ func (d *cliManifestDecoder) linkInput(cmdKey string, input *CLICommandInput, va return fmt.Errorf("command %q %s %s suggestions: %w", cmdKey, what, input.Bind.Pointer, err) } } - if value, ok := presetValues[input.Bind.Pointer]; ok && !isArg && cliIsPresetDefault(value, input.Type) { + if value, ok := presetValues[pointer]; ok && !isArg && cliIsPresetDefault(value, input.Type) { input.DefaultFrom = "preset" input.Default = value } @@ -3518,7 +3592,7 @@ func (d *cliManifestDecoder) linkInput(cmdKey string, input *CLICommandInput, va // Display-only defaults. A preset at the same pointer is what the request // carries when the flag is omitted, so it is the default help shows. - presetValue, presetCovered := presetValues[input.Bind.Pointer] + presetValue, presetCovered := presetValues[pointer] switch { case isArg: case presetCovered && cliIsPresetDefault(presetValue, input.Type): diff --git a/internal/extensions/cli_commands_test.go b/internal/extensions/cli_commands_test.go index 35d093e17..9f95cb7a1 100644 --- a/internal/extensions/cli_commands_test.go +++ b/internal/extensions/cli_commands_test.go @@ -1839,7 +1839,8 @@ commands: {`$['a','b']`, "union selector"}, {`$[-1]`, "negative index"}, {`$['unterminated]`, "unterminated"}, - {`$.content.parts`, "multi-segment"}, + {`$.content.parts.text`, "multi-segment"}, + {`$.content[0]`, "addresses an array index; a nested bind"}, {`$[0]`, "array index at the body root"}, } for _, testCase := range pathCases { @@ -3729,6 +3730,13 @@ operations: `, `both bind /stream`) requireDecodeError(t, ` version: 1 +operations: + StreamTask: + flags: + nested: {to: $.stream.level} +`, `operation "StreamTask" flag "nested" binds /stream/level; operation flags bind top-level body fields only`) + requireDecodeError(t, ` +version: 1 operations: StreamTask: flags: @@ -4522,6 +4530,9 @@ paths: properties: dpi: type: integer + serial: + type: string + readOnly: true format: $ref: '#/components/schemas/Format' palette: @@ -4627,6 +4638,23 @@ paths: - kind: png - preset: vector - type: string + mixed: + oneOf: + - $ref: '#/components/schemas/PngFormat' + - type: string + discriminator: + propertyName: kind + listedFields: + type: object + properties: + kind: + type: string + width: + type: integer + enum: + - kind: png + - kind: svg + width: 10 listedUnion: enum: - kind: png @@ -4859,6 +4887,87 @@ commands: }, got) } +func TestCLICommands_NestedPresetBinds(t *testing.T) { + manifest, err := decodeCLITestSpec(t, cliNestedPresetSpec, ` +version: 1 +commands: + draw: + op: CreateDrawing + preset: + $.format: + kind: png + $.mapped: + kind: png + width: 10 + $.plain: + kind: png + flags: + width: {to: $.format.width} + mapped-width: {to: $.mapped.width} + plain-width: {to: $.plain.width} +`) + require.NoError(t, err) + flags := map[string]CLICommandInput{} + for _, flag := range manifest.Commands[0].Flags { + flags[flag.Name] = flag + } + assert.Equal(t, "/format/width", flags["width"].Bind.Pointer) + assert.Equal(t, "int", flags["width"].Type) + assert.Nil(t, flags["width"].Default) + assert.Equal(t, "int", flags["mapped-width"].Type) + assert.Equal(t, "preset", flags["mapped-width"].DefaultFrom) + assert.EqualValues(t, 10, flags["mapped-width"].Default) + assert.Equal(t, "int", flags["plain-width"].Type) +} + +func TestCLICommands_NestedPresetBindErrors(t *testing.T) { + cases := []struct { + name string + input string + want string + }{ + {"no preset", "flags:\n dpi: {to: $.config.dpi}", `flag "dpi" bind /config/dpi nests beneath /config, which no object preset sets`}, + {"scalar preset", "preset:\n $.prompt: hello\n flags:\n x: {to: $.prompt.x}", `flag "x" bind /prompt/x nests beneath /prompt, which no object preset sets`}, + {"discriminator", "preset:\n $.format: {kind: png}\n flags:\n kind: {to: $.format.kind}", `targets the discriminator of the union at /format, which the preset pins`}, + {"discriminator of a single object member", "preset:\n $.mixed: {kind: png}\n flags:\n kind: {to: $.mixed.kind, type: string}", `targets the discriminator of the union at /mixed, which the preset pins`}, + {"no unique member", "preset:\n $.source: {url: https://example.com/a}\n flags:\n url: {to: $.source.url}", `the preset at /source does not select exactly one member of its union`}, + {"other member field", "preset:\n $.format: {kind: png}\n flags:\n scale: {to: $.format.scale}", `flag "scale" bind beneath /format: /scale does not resolve in PngFormat`}, + {"typo", "preset:\n $.format: {kind: png}\n flags:\n width: {to: $.format.widht}", `did you mean "width"`}, + {"readOnly field", "preset:\n $.config: {dpi: 72}\n flags:\n serial: {to: $.config.serial}", `flag "serial" bind beneath /config: /config/serial targets a readOnly property`}, + {"const/enum object", "preset:\n $.listedFields: {kind: png}\n flags:\n width: {to: $.listedFields.width}", `flag "width" bind /listedFields/width nests beneath /listedFields, whose schema restricts the object to const/enum values`}, + {"positional", "preset:\n $.format: {kind: png}\n args:\n w: {to: $.format.width, variadic: true, type: string}", `arg "w" binds /format/width; the positional binds a top-level body field`}, + {"parent flag first", "preset:\n $.format: {kind: png}\n flags:\n format: {to: $.format, type: string}\n width: {to: $.format.width}", `input "format" binds /format and input "width" binds /format/width beneath it`}, + {"nested flag first", "preset:\n $.format: {kind: png}\n flags:\n width: {to: $.format.width}\n format: {to: $.format, type: string}", `input "format" binds /format and input "width" binds /format/width beneath it`}, + {"positional parent", "preset:\n $.format: {kind: png}\n args:\n format: {to: $.format, variadic: true, type: string}\n flags:\n width: {to: $.format.width}", `input "format" binds /format and input "width" binds /format/width beneath it`}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + _, err := decodeCLITestSpec(t, cliNestedPresetSpec, "\nversion: 1\ncommands:\n draw:\n op: CreateDrawing\n "+testCase.input+"\n") + require.Error(t, err) + assert.Contains(t, err.Error(), testCase.want) + }) + } +} + +func TestCLICommands_NestedBindRejectedOnRouteDispatch(t *testing.T) { + formatProp := " format:\n oneOf:\n - {$ref: '#/components/schemas/PngFormat'}\n - {$ref: '#/components/schemas/SvgFormat'}\n" + spec := strings.ReplaceAll(cliRouteDispatchSpec, " background:\n", formatProp+" background:\n") + ` PngFormat: + type: object + properties: + kind: {const: png} + width: {type: integer} + SvgFormat: + type: object + properties: + kind: {const: svg} + scale: {type: number} +` + manifestYAML := strings.Replace(cliRouteDispatchManifest, " args:\n", " preset:\n $.format: {kind: png}\n args:\n", 1) + " width: {to: $.format.width}\n" + _, err := decodeCLITestSpec(t, spec, manifestYAML) + require.Error(t, err) + assert.Contains(t, err.Error(), `flag "width" binds /format/width; nested binds are not part of route dispatch`) +} + func TestCLICommands_InputNamePatternHyphens(t *testing.T) { for _, name := range []string{"a", "a1", "a-b", "a-b-c", "a1-2b", "engine-mode"} { assert.True(t, cliInputNamePattern.MatchString(name), name) diff --git a/templates/templates/cli/README.md b/templates/templates/cli/README.md index b84371da0..02c9cc68a 100644 --- a/templates/templates/cli/README.md +++ b/templates/templates/cli/README.md @@ -768,6 +768,7 @@ cli users create --no-interactive - 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) or pipes the body on stdin, the intent merges its presets into that JSON — caller keys win at every depth, presets fill the gaps including the key that selects the pinned request variant, a known discriminator value is filled in, and an object preset fills the missing keys of the caller'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 caller's discriminator is absent or agrees; elsewhere, including objects restricted to `const`/`enum` values, the caller's object is kept whole, so union members are never mixed (`PresetMergePoints`, derived by `presetMergePoints` and rendered into `PresetMerge.Nested`) — 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`). +- A declared intent flag may bind one level beneath an object preset (`to: $.format.width` under `preset: {$.format: {kind: png}}`). The linker resolves the field in the member the preset selects (the property itself when it is one object; for a union, the member `presetUnionMergePoint` identifies), inferring its type, schema enum suggestions, and a preset default when the preset object sets the field. It rejects a parent without an object preset, a union member the preset does not uniquely identify, the union's discriminator as the field, positional args, route-dispatch commands, operation-declared flags, a parent restricted to `const`/`enum` values, a `readOnly` field, and another input of the same command binding the parent object itself. Without a body the value is written into the preset object (`flagutil.SetBodyPath`); with one, `PresetMerge.MergeNestedInput` writes it into the caller's object (created when absent) before the preset fill, and refuses a caller object whose discriminator names another member, a non-object at the parent, and a field the caller already set. Deeper paths stay hard decode errors. **Annotation contract**: diff --git a/templates/templates/cli/auxiliary/internal/flagutil/flags.go.stmpl b/templates/templates/cli/auxiliary/internal/flagutil/flags.go.stmpl index c63c56ba5..8da6134a2 100644 --- a/templates/templates/cli/auxiliary/internal/flagutil/flags.go.stmpl +++ b/templates/templates/cli/auxiliary/internal/flagutil/flags.go.stmpl @@ -802,6 +802,14 @@ func bodyDecidesKey(raw []byte, key string) bool { // bodyFlagName is "" when the body was attached from stdin by AttachStdinBody. func MergeInputIntoBody(cmd *cobra.Command, bodyFlagName, key, input string, value any) error { + return MergeInputIntoBodyPath(cmd, bodyFlagName, []string{key}, input, value, nil) +} + +// BodyPathCheck vets a caller object on the path before an input is written +// beneath it; field is the object's dotted path. +type BodyPathCheck func(pointer, field, source string, existing json.RawMessage) error + +func MergeInputIntoBodyPath(cmd *cobra.Command, bodyFlagName string, path []string, input string, value any, check BodyPathCheck) error { source, raw := "stdin", []byte(nil) if bodyFlagName != "" { s, _ := GetStringFlag(cmd, bodyFlagName) @@ -824,14 +832,13 @@ func MergeInputIntoBody(cmd *cobra.Command, bodyFlagName, key, input string, val } return fmt.Errorf("cannot combine %s with %s: the body must be a JSON object", input, source) } - if _, present := obj[key]; present { - return fmt.Errorf("key %q is set both by %s and by %s; pass exactly one", key, input, source) - } encodedValue, err := json.Marshal(value) if err != nil { return err } - obj[key] = encodedValue + if err := mergeBodyPath(obj, path, "", "", input, source, encodedValue, check); err != nil { + return err + } merged, err := json.Marshal(obj) if err != nil { return err @@ -842,3 +849,52 @@ func MergeInputIntoBody(cmd *cobra.Command, bodyFlagName, key, input string, val cmd.SetIn(bytes.NewReader(merged)) return nil } + +func mergeBodyPath(obj map[string]json.RawMessage, path []string, pointer, field, input, source string, value json.RawMessage, check BodyPathCheck) error { + key := path[0] + pointer += "/" + presetPointerToken(key) + if field != "" { + field += "." + } + field += key + existing, present := obj[key] + if len(path) == 1 { + if present { + return fmt.Errorf("key %q is set both by %s and by %s; pass exactly one", field, input, source) + } + obj[key] = value + return nil + } + child := map[string]json.RawMessage{} + if present { + if err := json.Unmarshal(existing, &child); err != nil || child == nil { + return fmt.Errorf("cannot combine %s with %s: %s must be a JSON object", input, source, field) + } + if check != nil { + if err := check(pointer, field, source, existing); err != nil { + return err + } + } + } + if err := mergeBodyPath(child, path[1:], pointer, field, input, source, value, check); err != nil { + return err + } + encoded, err := json.Marshal(child) + if err != nil { + return err + } + obj[key] = encoded + return nil +} + +func SetBodyPath(body map[string]any, path []string, value any) { + for _, key := range path[:len(path)-1] { + child, ok := body[key].(map[string]any) + if !ok { + child = map[string]any{} + body[key] = child + } + body = child + } + body[path[len(path)-1]] = value +} diff --git a/templates/templates/cli/auxiliary/internal/flagutil/preset.go.stmpl b/templates/templates/cli/auxiliary/internal/flagutil/preset.go.stmpl index 64e998de6..b2d25a2da 100644 --- a/templates/templates/cli/auxiliary/internal/flagutil/preset.go.stmpl +++ b/templates/templates/cli/auxiliary/internal/flagutil/preset.go.stmpl @@ -249,6 +249,20 @@ func (m PresetMerge) fillPresetObject(user, preset json.RawMessage, pointer stri return merged, true } +// MergeNestedInput refuses a caller object that selects another member of a +// union the preset pins: the flag's field belongs to the preset's member. +func (m PresetMerge) MergeNestedInput(cmd *cobra.Command, bodyFlagName string, path []string, input string, value any) error { + return MergeInputIntoBodyPath(cmd, bodyFlagName, path, input, value, func(pointer, field, source string, existing json.RawMessage) error { + point, ok := m.Nested[pointer] + if !ok || point.admits(existing) { + return nil + } + var obj map[string]json.RawMessage + _ = json.Unmarshal(existing, &obj) + return fmt.Errorf("%s applies when %s.%s is %s, but %s sets %s", input, field, point.Key, point.Values[0], source, bytes.TrimSpace(obj[point.Key])) + }) +} + func presetPointerToken(key string) string { return strings.ReplaceAll(strings.ReplaceAll(key, "~", "~0"), "/", "~1") } diff --git a/templates/templates/cli/includes/intents.ts b/templates/templates/cli/includes/intents.ts index 631e28d29..f3f0a19ec 100644 --- a/templates/templates/cli/includes/intents.ts +++ b/templates/templates/cli/includes/intents.ts @@ -19,6 +19,7 @@ interface IntentBodyEntry { Key: string; // top-level body key (from a single-segment JSON Pointer) + Path?: string[]; // [Key, field] for a flag bound beneath an object preset Name?: string; // flag name SatisfiedBy?: string[]; Shorthand?: string; @@ -276,8 +277,9 @@ function intentFallbackFlag(entry: IntentBodyEntry): string { return `--${entry.Name} ${intentExamplePlaceholder(entry.Name || "value")}`; } -// Single-segment RFC 6901 pointer → top-level body key. The decoder only -// admits single-segment body pointers in v1, so a miss here is unreachable. +// 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. function intentPointerKey(pointer: string): string { if (!pointer || !pointer.startsWith("/")) return ""; const rest = pointer.slice(1); @@ -285,6 +287,19 @@ function intentPointerKey(pointer: string): string { return rest.replace(/~1/g, "/").replace(/~0/g, "~"); } +function intentHasOwn(obj: object, key: string): boolean { + return Object.prototype.hasOwnProperty.call(obj, key); +} + +// RFC 6901 pointer → decoded body path segments. +function intentPointerPath(pointer: string): string[] { + if (!pointer || !pointer.startsWith("/")) return []; + return pointer + .slice(1) + .split("/") + .map((token) => token.replace(/~1/g, "/").replace(/~0/g, "~")); +} + function mergeIntentTestValue(left: any, right: any): any { if (Array.isArray(left) && Array.isArray(right)) { const merged = [...left]; @@ -838,30 +853,40 @@ function collectIntentManifest(): IntentManifestCtx { Summary: a.Summary || "", Variadic: Boolean(a.Variadic), Required: Boolean(a.Required), - PresetCovered: dispatch ? false : key in presetObj, + PresetCovered: dispatch ? false : intentHasOwn(presetObj, key), Suggestions: (a.Enum || []).map((v: any) => `${v}`), RouteIDs: (a as any).RouteIDs || [], RequiredRouteIDs: (a as any).RequiredRouteIDs || [], Positional: true, }); - if (a.Required && (dispatch || !(key in presetObj))) minArgs = 1; + if (a.Required && (dispatch || !intentHasOwn(presetObj, key))) + minArgs = 1; break; // v1: one (variadic) positional joined with spaces } const flags: IntentBodyEntry[] = []; for (const f of cmd.Flags || []) { - const key = intentPointerKey(f.Bind?.Pointer || ""); + const path = intentPointerPath(f.Bind?.Pointer || ""); + const key = path[0] || ""; if (!key || !f.Name) continue; const declaredFlagName = sanitizeFlagNameWithReserved(f.Name); + const presetParent = presetObj[key]; + const presetCovered = + path.length > 1 + ? typeof presetParent === "object" && + presetParent !== null && + intentHasOwn(presetParent, path[1]) + : intentHasOwn(presetObj, key); flags.push({ Key: key, + Path: path, Name: declaredFlagName, Shorthand: f.Shorthand || "", Summary: intentFlagHelp(f), Type: f.Type || "string", Required: Boolean(f.Required), - PresetCovered: dispatch ? false : key in presetObj, + PresetCovered: dispatch ? false : presetCovered, DefaultValue: f.Default, HasDefault: f.Default !== undefined && f.Default !== null, Suggestions: (f.Enum || []).map((v: any) => `${v}`), diff --git a/templates/templates/cli/intentcmd.go.stmpl b/templates/templates/cli/intentcmd.go.stmpl index 6cb9b1b4d..f29cd636a 100644 --- a/templates/templates/cli/intentcmd.go.stmpl +++ b/templates/templates/cli/intentcmd.go.stmpl @@ -340,7 +340,11 @@ func runIntent{{$c.FuncName}}Cmd(cmd *cobra.Command, args []string) error { } {{- end}} {{- end}} + {{- if gt (len .Path) 1}} + if err := intent{{$c.FuncName}}Preset.MergeNestedInput(cmd, suppliedBodyFlag, {{template "bodyPath" .Path}}, "--{{.Name}}", v); err != nil { + {{- else}} if err := flagutil.MergeInputIntoBody(cmd, suppliedBodyFlag, "{{escapeGoString .Key}}", "--{{.Name}}", v); err != nil { + {{- end}} return flagutil.WithCLIValidation(err) } } @@ -419,7 +423,11 @@ func runIntent{{$c.FuncName}}Cmd(cmd *cobra.Command, args []string) error { {{- else}} v, _ := flagutil.GetStringFlag(cmd, "{{.Name}}") {{- end}} + {{- if gt (len .Path) 1}} + flagutil.SetBodyPath(body, {{template "bodyPath" .Path}}, v) + {{- else}} body["{{escapeGoString .Key}}"] = v + {{- end}} } {{- end}} {{- with $c.StreamToggle}} @@ -541,3 +549,5 @@ func newPollIntent{{$c.FuncName}}(cmd *cobra.Command, id string, params []output }, {{- end}} {{- end}} + +{{- define "bodyPath"}}[]string{ {{- range $i, $segment := .}}{{if $i}}, {{end}}"{{escapeGoString $segment}}"{{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 51c899911..48a0fce49 100644 --- a/templates/templates/cli/tests/primary/intents_test.go.stmpl +++ b/templates/templates/cli/tests/primary/intents_test.go.stmpl @@ -431,6 +431,11 @@ func TestIntentPresetFromArgs(t *testing.T) { assert.Equal(t, []interface{}{"image"}, body["output_modalities"]) assert.Equal(t, map[string]interface{}{"kind": "png"}, body["image_format"]) assert.Equal(t, "a lighthouse at dusk", body["input"]) + + h = NewCLITestHarness(t) + require.NoError(t, h.RunWithServer(s.URL, []string{"render", "a lighthouse at dusk", "--width", "10"})) + body = echoedJSON(t, h.GetStdout()) + assert.Equal(t, map[string]interface{}{"kind": "png", "width": float64(10)}, body["image_format"]) } // TestIntentPartialBodyKeepsPresets is the regression for the preset being @@ -470,6 +475,33 @@ func TestIntentPartialBodyKeepsPresets(t *testing.T) { assert.Equal(t, tc.want, body["image_format"]) }) } + + for _, tc := range []struct { + name string + body string + want map[string]interface{} + wantErr string + }{ + {name: "flag without the key", body: `{"input":"cat"}`, want: map[string]interface{}{"kind": "png", "width": float64(10)}}, + {name: "flag into a partial object", body: `{"input":"cat","image_format":{}}`, want: map[string]interface{}{"kind": "png", "width": float64(10)}}, + {name: "flag into the same kind", body: `{"input":"cat","image_format":{"kind":"png"}}`, want: map[string]interface{}{"kind": "png", "width": float64(10)}}, + {name: "flag and body set the field", body: `{"input":"cat","image_format":{"width":5}}`, wantErr: `key "image_format.width" is set both by --width and by --body`}, + {name: "flag into another kind", body: `{"input":"cat","image_format":{"kind":"svg"}}`, wantErr: `--width applies when image_format.kind is "png", but --body sets "svg"`}, + {name: "flag into a non-object", body: `{"input":"cat","image_format":"raw"}`, wantErr: `cannot combine --width with --body: image_format must be a JSON object`}, + } { + t.Run(tc.name, func(t *testing.T) { + h := NewCLITestHarness(t) + err := h.RunWithServer(s.URL, []string{"render", "--body", tc.body, "--width", "10"}) + if tc.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + return + } + require.NoError(t, err) + body := echoedJSON(t, h.GetStdout()) + assert.Equal(t, tc.want, body["image_format"]) + }) + } } func TestIntentPipedBodyKeepsPresets(t *testing.T) { @@ -513,6 +545,24 @@ func TestIntentPipedBodyKeepsPresets(t *testing.T) { assert.Zero(t, requests.Load()) } +func TestIntentNestedFlagIntoPipedBody(t *testing.T) { + s := newEchoServer() + defer s.Close() + + withDelayedStdinPipe(t, 0, `{"input":"cat","image_format":{}}`, true) + h := NewCLITestHarness(t) + require.NoError(t, h.RunWithServer(s.URL, []string{"render", "--width", "10"})) + body := echoedJSON(t, h.GetStdout()) + assert.Equal(t, "cat", body["input"]) + assert.Equal(t, map[string]interface{}{"kind": "png", "width": float64(10)}, body["image_format"]) + + withDelayedStdinPipe(t, 0, `{"input":"cat","image_format":{"width":5}}`, true) + h = NewCLITestHarness(t) + err := h.RunWithServer(s.URL, []string{"render", "--width", "10"}) + require.Error(t, err) + assert.Contains(t, err.Error(), `key "image_format.width" is set both by --width and by stdin`) +} + func TestIntentPartialBodyUserKeysWin(t *testing.T) { recordTest("intents-partial-body-user-keys-win") s := newEchoServer() diff --git a/tests/overlays/primary/cli/overlay.yaml b/tests/overlays/primary/cli/overlay.yaml index 3630b52cc..e776dbeda 100644 --- a/tests/overlays/primary/cli/overlay.yaml +++ b/tests/overlays/primary/cli/overlay.yaml @@ -172,6 +172,9 @@ actions: summary: Model to run defaultFrom: schema suggestions: [render-1, render-2] + width: + to: $.image_format.width + summary: Image width in pixels preset: $.model: render-1 $.output_modalities: image diff --git a/zSDKs/sdk-cli/.speakeasy/gen.lock b/zSDKs/sdk-cli/.speakeasy/gen.lock index 73c50bc75..a9f6f2052 100644 --- a/zSDKs/sdk-cli/.speakeasy/gen.lock +++ b/zSDKs/sdk-cli/.speakeasy/gen.lock @@ -244,11 +244,11 @@ trackedFiles: internal/flagutil/dispatch.go: last_write_checksum: sha1:fecd48dd69b4743ff5c6c1329a2071fe49d9423e internal/flagutil/flags.go: - last_write_checksum: sha1:7a7eb7d5cde0897d7008370c1ac083d154fbc65e + last_write_checksum: sha1:dcaf9e57c7fbfc01e64568da488aacc6ee9d158f internal/flagutil/metadata.go: last_write_checksum: sha1:1936c82d8e36a6612364827ffc05dcbea0e1dd8f internal/flagutil/preset.go: - last_write_checksum: sha1:51699bc288541a06bf60e5f6de73a8f48c42d6ff + last_write_checksum: sha1:a6cf90b7dd09d11ffbd438fc3aba76806de5752a internal/interactive/interactive.go: last_write_checksum: sha1:ba6979695c50a3c2385bc74a4676dfe88814a984 internal/interactive/policy.go: diff --git a/zSDKs/sdk-cli/internal/flagutil/flags.go b/zSDKs/sdk-cli/internal/flagutil/flags.go index 8741220e3..cda1dc19a 100644 --- a/zSDKs/sdk-cli/internal/flagutil/flags.go +++ b/zSDKs/sdk-cli/internal/flagutil/flags.go @@ -804,6 +804,14 @@ func bodyDecidesKey(raw []byte, key string) bool { // bodyFlagName is "" when the body was attached from stdin by AttachStdinBody. func MergeInputIntoBody(cmd *cobra.Command, bodyFlagName, key, input string, value any) error { + return MergeInputIntoBodyPath(cmd, bodyFlagName, []string{key}, input, value, nil) +} + +// BodyPathCheck vets a caller object on the path before an input is written +// beneath it; field is the object's dotted path. +type BodyPathCheck func(pointer, field, source string, existing json.RawMessage) error + +func MergeInputIntoBodyPath(cmd *cobra.Command, bodyFlagName string, path []string, input string, value any, check BodyPathCheck) error { source, raw := "stdin", []byte(nil) if bodyFlagName != "" { s, _ := GetStringFlag(cmd, bodyFlagName) @@ -826,14 +834,13 @@ func MergeInputIntoBody(cmd *cobra.Command, bodyFlagName, key, input string, val } return fmt.Errorf("cannot combine %s with %s: the body must be a JSON object", input, source) } - if _, present := obj[key]; present { - return fmt.Errorf("key %q is set both by %s and by %s; pass exactly one", key, input, source) - } encodedValue, err := json.Marshal(value) if err != nil { return err } - obj[key] = encodedValue + if err := mergeBodyPath(obj, path, "", "", input, source, encodedValue, check); err != nil { + return err + } merged, err := json.Marshal(obj) if err != nil { return err @@ -844,3 +851,52 @@ func MergeInputIntoBody(cmd *cobra.Command, bodyFlagName, key, input string, val cmd.SetIn(bytes.NewReader(merged)) return nil } + +func mergeBodyPath(obj map[string]json.RawMessage, path []string, pointer, field, input, source string, value json.RawMessage, check BodyPathCheck) error { + key := path[0] + pointer += "/" + presetPointerToken(key) + if field != "" { + field += "." + } + field += key + existing, present := obj[key] + if len(path) == 1 { + if present { + return fmt.Errorf("key %q is set both by %s and by %s; pass exactly one", field, input, source) + } + obj[key] = value + return nil + } + child := map[string]json.RawMessage{} + if present { + if err := json.Unmarshal(existing, &child); err != nil || child == nil { + return fmt.Errorf("cannot combine %s with %s: %s must be a JSON object", input, source, field) + } + if check != nil { + if err := check(pointer, field, source, existing); err != nil { + return err + } + } + } + if err := mergeBodyPath(child, path[1:], pointer, field, input, source, value, check); err != nil { + return err + } + encoded, err := json.Marshal(child) + if err != nil { + return err + } + obj[key] = encoded + return nil +} + +func SetBodyPath(body map[string]any, path []string, value any) { + for _, key := range path[:len(path)-1] { + child, ok := body[key].(map[string]any) + if !ok { + child = map[string]any{} + body[key] = child + } + body = child + } + body[path[len(path)-1]] = value +} diff --git a/zSDKs/sdk-cli/internal/flagutil/preset.go b/zSDKs/sdk-cli/internal/flagutil/preset.go index 48b28879c..109f2f52b 100644 --- a/zSDKs/sdk-cli/internal/flagutil/preset.go +++ b/zSDKs/sdk-cli/internal/flagutil/preset.go @@ -251,6 +251,20 @@ func (m PresetMerge) fillPresetObject(user, preset json.RawMessage, pointer stri return merged, true } +// MergeNestedInput refuses a caller object that selects another member of a +// union the preset pins: the flag's field belongs to the preset's member. +func (m PresetMerge) MergeNestedInput(cmd *cobra.Command, bodyFlagName string, path []string, input string, value any) error { + return MergeInputIntoBodyPath(cmd, bodyFlagName, path, input, value, func(pointer, field, source string, existing json.RawMessage) error { + point, ok := m.Nested[pointer] + if !ok || point.admits(existing) { + return nil + } + var obj map[string]json.RawMessage + _ = json.Unmarshal(existing, &obj) + return fmt.Errorf("%s applies when %s.%s is %s, but %s sets %s", input, field, point.Key, point.Values[0], source, bytes.TrimSpace(obj[point.Key])) + }) +} + func presetPointerToken(key string) string { return strings.ReplaceAll(strings.ReplaceAll(key, "~", "~0"), "/", "~1") }