From 4e22bfc14065b78da9c0dfaf08e6080f5432cb19 Mon Sep 17 00:00:00 2001 From: 2ynn Date: Tue, 6 Oct 2026 19:21:10 -0400 Subject: [PATCH 01/11] fix(cli): fill nested keys of a partial object from an intent's object preset An intent's preset was merged into a caller-supplied --body/--body-param per top-level key only, so a partial object for a preset key (e.g. setting one option of a union-typed property) replaced the preset object whole and dropped the union discriminator the preset pinned. Object presets now fill the missing keys of the caller's object at the merge points the schema proves safe; caller keys always win. The linker walks each object preset alongside its schema (named properties, map entries through additionalProperties, and properties declared beside a union) and records a merge point per JSON pointer: plain objects and map entries unconditionally, and union members only when the preset identifies exactly one member through the union's declared discriminator, or a const/single-enum key whose preset value no other member accepts, with explicit and implicit mapping aliases. At runtime a caller object is filled only at a merge point and, on a union, only while its discriminator is absent or selects the preset's member. Elsewhere the caller's object is kept whole, so union members are not mixed. --- .changesets/1791324779-24e1ea55.yaml | 10 + internal/extensions/cli_commands.go | 15 + internal/extensions/cli_commands_link.go | 270 ++++++++++++++++++ internal/extensions/cli_commands_test.go | 267 +++++++++++++++++ templates/templates/cli/README.md | 4 +- .../internal/flagutil/preset.go.stmpl | 71 ++++- templates/templates/cli/includes/intents.ts | 25 ++ templates/templates/cli/intentcmd.go.stmpl | 12 + .../cli/tests/primary/intents_test.go.stmpl | 81 +++++- tests/overlays/primary/cli/overlay.yaml | 4 + .../primary/cli-intent-preset-merge.yaml | 28 ++ zSDKs/sdk-cli/.speakeasy/gen.lock | 2 +- zSDKs/sdk-cli/internal/flagutil/preset.go | 73 ++++- 13 files changed, 855 insertions(+), 7 deletions(-) create mode 100644 .changesets/1791324779-24e1ea55.yaml diff --git a/.changesets/1791324779-24e1ea55.yaml b/.changesets/1791324779-24e1ea55.yaml new file mode 100644 index 000000000..ab104a0a2 --- /dev/null +++ b/.changesets/1791324779-24e1ea55.yaml @@ -0,0 +1,10 @@ +id: 1791324779-24e1ea55 +features: + - cliCommands +targets: + - cli +type: fix +bump: patch +description: An intent command's object preset now fills the missing keys of a partial object passed for the same key in --body or --body-param, so a nested discriminator such as a pinned union variant's type is no longer dropped; an object naming another variant is still sent as given +author: 2ynn +date: "2026-10-06" diff --git a/internal/extensions/cli_commands.go b/internal/extensions/cli_commands.go index 3a17d1337..721b8b2dd 100644 --- a/internal/extensions/cli_commands.go +++ b/internal/extensions/cli_commands.go @@ -96,6 +96,21 @@ type CLICommandRoute struct { // usage error instead of a silently re-targeted request. Nil when the // request body is not a union. Selectors *CLIVariantSelectors `json:"selectors,omitempty" yaml:"selectors,omitempty"` + + // PresetMergePoints lists the JSON pointers at which an object-valued + // preset fills the missing keys of a caller's object. A caller's object + // at any other pointer is kept whole. + PresetMergePoints []CLIPresetMergePoint `json:"presetMergePoints,omitempty" yaml:"presetMergePoints,omitempty"` +} + +// CLIPresetMergePoint is a pointer at which a preset object fills a caller's +// object. On a union, Key is the discriminator the preset sets and Values +// every value selecting the preset's member: a caller's object setting Key to +// any other value is kept whole. +type CLIPresetMergePoint struct { + Pointer string `json:"pointer" yaml:"pointer"` + Key string `json:"key,omitempty" yaml:"key,omitempty"` + Values []any `json:"values,omitempty" yaml:"values,omitempty"` } // CLICommandDispatchKey records body-key membership across the complete diff --git a/internal/extensions/cli_commands_link.go b/internal/extensions/cli_commands_link.go index 44ec08014..10587c022 100644 --- a/internal/extensions/cli_commands_link.go +++ b/internal/extensions/cli_commands_link.go @@ -473,6 +473,7 @@ func (d *cliManifestDecoder) linkSingleRouteCommand(cmd *CLICommand) error { return err } cmd.Source.Routes[0].Selectors = selectors + cmd.Source.Routes[0].PresetMergePoints = d.presetMergePoints(variant, cmd.Presets) for i := range cmd.Args { if err := d.linkInput(key, &cmd.Args[i], variant, presetPointers, presetValues, true); err != nil { return err @@ -674,6 +675,7 @@ func (d *cliManifestDecoder) linkDispatchCommand(cmd *CLICommand) error { return err } links[i].route.Selectors = selectors + links[i].route.PresetMergePoints = d.presetMergePoints(links[i].variant, links[i].route.Presets) } for i := range cmd.Args { @@ -2415,6 +2417,274 @@ func (d *cliManifestDecoder) pinnedDiscriminatorValues(disc map[string]any, key, return accepted } +// cliFlatUnion is a union property flattened through nested oneOf/anyOf +// layers: its object members (each carrying the properties declared beside +// the unions above it), the discriminator keys the layers declare, and their +// explicit mappings (value -> member reference). +type cliFlatUnion struct { + members []*cliResolvedSchema + keys []string + mappings map[string]string +} + +func (d *cliManifestDecoder) flattenUnion(schema any, depth int, inherited *cliResolvedSchema, out *cliFlatUnion) { + if depth > 8 { + return + } + resolved, err := d.resolveObjectSchema(schema, nil) + if err != nil { + return + } + if resolved.unionMembers == nil { + if len(resolved.propOrder) > 0 || resolved.explicitOpen || resolved.raw["type"] == "object" { + cliInheritProperties(resolved, inherited) + out.members = append(out.members, resolved) + } + return + } + d.collectDiscriminators(schema, depth, out) + cliInheritProperties(resolved, inherited) + for _, member := range resolved.unionMembers { + d.flattenUnion(member, depth+1, resolved, out) + } +} + +func cliInheritProperties(target, parent *cliResolvedSchema) { + if parent == nil { + return + } + for _, name := range parent.propOrder { + if _, ok := target.properties[name]; !ok { + target.properties[name] = parent.properties[name] + target.propOrder = append(target.propOrder, name) + } + } +} + +func cliAdditionalPropertiesSchema(object *cliResolvedSchema) (map[string]any, bool) { + schema, ok := object.raw["additionalProperties"].(map[string]any) + return schema, ok && len(schema) > 0 +} + +// excludesValue reports whether a property schema's enum rules out value. +func (d *cliManifestDecoder) excludesValue(schema, value any) bool { + facts, err := d.propertyFacts(schema, nil) + if err != nil { + return false + } + return len(facts.enum) > 0 && !cliContainsValue(facts.enum, value) +} + +// collectDiscriminators records the discriminator objects declared on a union +// node, including those reached through its $ref and allOf layers. +func (d *cliManifestDecoder) collectDiscriminators(schema any, depth int, out *cliFlatUnion) { + schemaMap, ok := schema.(map[string]any) + if !ok || depth > 8 { + return + } + if disc, ok := schemaMap["discriminator"].(map[string]any); ok { + if key, ok := disc["propertyName"].(string); ok && key != "" && !cliContains(out.keys, key) { + out.keys = append(out.keys, key) + } + if mapping, ok := disc["mapping"].(map[string]any); ok { + for value, ref := range mapping { + if ref, ok := ref.(string); ok { + out.mappings[value] = ref + } + } + } + } + if ref, ok := schemaMap["$ref"].(string); ok { + d.collectDiscriminators(d.schemaIndex.components[strings.TrimPrefix(ref, cliComponentRefPrefix)], depth+1, out) + } + if allOf, ok := schemaMap["allOf"].([]any); ok { + for _, branch := range allOf { + d.collectDiscriminators(branch, depth+1, out) + } + } +} + +func (d *cliManifestDecoder) constPropertyValue(schema any) (any, bool) { + facts, err := d.propertyFacts(schema, nil) + if err != nil { + return nil, false + } + if facts.hasConst { + return facts.constVal, true + } + if len(facts.enum) == 1 { + return facts.enum[0], true + } + return nil, false +} + +// presetMergePoints walks each object-valued preset alongside its schema +// and records, keyed by JSON pointer, every object of the preset value that +// may fill a caller's object at the same pointer: plain objects and map +// entries unconditionally, union members only when the preset identifies one +// member, guarded by the discriminator it sets. Elsewhere the caller's +// object is kept whole. +func (d *cliManifestDecoder) presetMergePoints(variant *cliResolvedSchema, presets []CLICommandPreset) []CLIPresetMergePoint { + var out []CLIPresetMergePoint + for _, preset := range presets { + value, ok := preset.Value.(map[string]any) + if !ok { + continue + } + propSchema, ok := variant.properties[cliPointerPropertyName(preset.Bind.Pointer)] + if !ok { + continue + } + d.collectPresetMergePoints(preset.Bind.Pointer, propSchema, value, 0, &out) + } + return out +} + +func (d *cliManifestDecoder) collectPresetMergePoints(pointer string, schema any, value map[string]any, depth int, out *[]CLIPresetMergePoint) { + if depth > 8 { + return + } + flat := &cliFlatUnion{mappings: map[string]string{}} + d.flattenUnion(schema, 0, nil, flat) + point := CLIPresetMergePoint{Pointer: pointer} + var object *cliResolvedSchema + switch { + case len(flat.members) == 1: + object = flat.members[0] + case len(flat.members) > 1: + point, object = d.presetUnionMergePoint(pointer, flat, value) + } + if object == nil { + return + } + *out = append(*out, point) + names := make([]string, 0, len(value)) + for name := range value { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + child, ok := value[name].(map[string]any) + if !ok { + continue + } + childSchema, ok := object.properties[name] + if !ok { + childSchema, ok = cliAdditionalPropertiesSchema(object) + } + if !ok { + continue + } + token := strings.ReplaceAll(strings.ReplaceAll(name, "~", "~0"), "/", "~1") + d.collectPresetMergePoints(pointer+"/"+token, childSchema, child, depth+1, out) + } +} + +// presetUnionMergePoint finds the discriminator a preset object sets on a +// union: a key the union declares as its discriminator, else a key whose +// const/single-enum values differ across members, whose preset value is the +// const of exactly one member, and which no other member declaring it can +// also accept. The accepted values are the preset's value plus every value +// mapped (explicitly, or implicitly by component name) to the member it +// selects. member is nil when the preset does not select exactly one member. +func (d *cliManifestDecoder) presetUnionMergePoint(pointer string, flat *cliFlatUnion, value map[string]any) (guard CLIPresetMergePoint, member *cliResolvedSchema) { + guard.Pointer = pointer + key := "" + for _, candidate := range flat.keys { + if _, ok := value[candidate]; ok { + key = candidate + break + } + } + declared := key != "" + if !declared { + names := make([]string, 0, len(value)) + for name := range value { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + var distinct []any + matching := 0 + overlapping := false + for _, m := range flat.members { + prop, declares := m.properties[name] + if !declares { + continue + } + v, ok := d.constPropertyValue(prop) + if !ok { + overlapping = overlapping || !d.excludesValue(prop, value[name]) + continue + } + if !cliContainsValue(distinct, v) { + distinct = append(distinct, v) + } + if cliValuesEqual(v, value[name]) { + matching++ + } + } + if len(distinct) >= 2 && matching == 1 && !overlapping { + key = name + break + } + } + } + if key == "" { + return guard, nil + } + + pinned := value[key] + mappedRef, hasMapped := flat.mappings[fmt.Sprint(pinned)] + selectedRefs := map[string]bool{} + if hasMapped { + selectedRefs[mappedRef] = true + } + var selected []*cliResolvedSchema + for _, m := range flat.members { + ref := "" + if m.refName != "" { + ref = cliComponentRefPrefix + m.refName + } + v, ok := d.constPropertyValue(m.properties[key]) + if (ok && cliValuesEqual(v, pinned)) || (declared && ref != "" && (ref == mappedRef || m.refName == pinned)) { + selected = append(selected, m) + if ref != "" { + selectedRefs[ref] = true + } + } + } + + values := []any{pinned} + aliases := make([]string, 0, len(flat.mappings)) + mapped := map[string]bool{} + for alias, ref := range flat.mappings { + mapped[ref] = true + if selectedRefs[ref] { + aliases = append(aliases, alias) + } + } + if declared { + for ref := range selectedRefs { + if !mapped[ref] { + aliases = append(aliases, strings.TrimPrefix(ref, cliComponentRefPrefix)) + } + } + } + sort.Strings(aliases) + for _, alias := range aliases { + if !cliContainsValue(values, alias) { + values = append(values, alias) + } + } + guard.Key = key + guard.Values = values + if len(selected) == 1 { + member = selected[0] + } + return guard, member +} + func cliContainsValue(list []any, want any) bool { for _, v := range list { if cliValuesEqual(v, want) { diff --git a/internal/extensions/cli_commands_test.go b/internal/extensions/cli_commands_test.go index 8e74fba82..f58036007 100644 --- a/internal/extensions/cli_commands_test.go +++ b/internal/extensions/cli_commands_test.go @@ -4046,6 +4046,30 @@ func TestCLICommands_RouteDispatchPresetRules(t *testing.T) { `command "jobs run" route "engine": preset batch is required by another member of the request body union but only optional on the pinned variant EngineJobParams, so a body built from the presets could be read as that other variant; drop that preset (bind an arg or flag instead) or add a discriminator to the union`) } +// A command-level object preset over a union property shared by every route +// records its discriminator on each route's effective preset set. +func TestCLICommands_RouteDispatchPresetMergePoints(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) + manifest, err := decodeCLITestSpec(t, spec, manifestYAML) + require.NoError(t, err) + want := []CLIPresetMergePoint{{Pointer: "/format", Key: "kind", Values: []any{"png"}}} + for _, route := range manifest.Commands[0].Source.Routes { + assert.Equal(t, want, route.PresetMergePoints, route.ID) + } +} + func TestCLICommands_RouteDispatchCrossRouteFacts(t *testing.T) { defaultSpec := strings.Replace(cliRouteDispatchSpec, " output_modes:\n type: string\n", " output_modes:\n type: string\n mode:\n type: string\n default: one\n", 1) defaultSpec = strings.Replace(defaultSpec, " pipeline_config:\n type: string\n", " pipeline_config:\n type: string\n mode:\n type: string\n default: two\n", 1) @@ -4445,6 +4469,249 @@ commands: } +const cliNestedPresetSpec = `openapi: 3.1.0 +info: + title: Drawings + version: 1.0.0 +paths: + /drawings: + post: + operationId: CreateDrawing + requestBody: + content: + application/json: + schema: + type: object + properties: + prompt: + type: string + format: + oneOf: + - $ref: '#/components/schemas/Format' + - type: array + items: + $ref: '#/components/schemas/Format' + mapped: + $ref: '#/components/schemas/MappedFormat' + described: + $ref: '#/components/schemas/MappedFormat' + description: Same union behind a $ref with sibling keywords + implicit: + $ref: '#/components/schemas/ImplicitFormat' + source: + oneOf: + - $ref: '#/components/schemas/UrlSource' + - $ref: '#/components/schemas/FileSource' + versioned: + oneOf: + - $ref: '#/components/schemas/VersionedPng' + - $ref: '#/components/schemas/VersionedSvg' + tiered: + oneOf: + - $ref: '#/components/schemas/VersionedPng' + - $ref: '#/components/schemas/VersionedSvg' + - $ref: '#/components/schemas/TieredPdf' + custom: + oneOf: + - $ref: '#/components/schemas/PngFormat' + - type: object + additionalProperties: + type: string + config: + type: object + properties: + dpi: + type: integer + format: + $ref: '#/components/schemas/Format' + palette: + type: object + additionalProperties: + $ref: '#/components/schemas/Format' + composed: + allOf: + - type: object + properties: + format: + $ref: '#/components/schemas/Format' + - oneOf: + - type: object + properties: + mode: + const: fast + - type: object + properties: + mode: + const: slow + overlap: + oneOf: + - $ref: '#/components/schemas/PngFormat' + - $ref: '#/components/schemas/SvgFormat' + - type: object + properties: + kind: + type: string + enum: [png, raw] + label: + type: string + plain: + type: object + properties: + kind: + type: string + width: + type: integer + responses: + "200": + description: OK +components: + schemas: + Format: + oneOf: + - $ref: '#/components/schemas/PngFormat' + - $ref: '#/components/schemas/SvgFormat' + - type: object + additionalProperties: true + MappedFormat: + oneOf: + - $ref: '#/components/schemas/PngFormat' + - $ref: '#/components/schemas/SvgFormat' + discriminator: + propertyName: kind + mapping: + png: '#/components/schemas/PngFormat' + portable: '#/components/schemas/PngFormat' + svg: '#/components/schemas/SvgFormat' + ImplicitFormat: + oneOf: + - $ref: '#/components/schemas/PngFormat' + - $ref: '#/components/schemas/SvgFormat' + discriminator: + propertyName: kind + UrlSource: + type: object + required: [url] + properties: + url: + type: string + FileSource: + type: object + required: [path] + properties: + path: + type: string + VersionedPng: + type: object + properties: + api: + const: v1 + kind: + const: png + VersionedSvg: + type: object + properties: + api: + const: v1 + kind: + const: svg + TieredPdf: + type: object + properties: + api: + const: v2 + kind: + const: pdf + PngFormat: + type: object + required: [kind] + properties: + kind: + const: png + width: + type: integer + SvgFormat: + type: object + required: [kind] + properties: + kind: + type: string + enum: [svg] + scale: + type: number +` + +// An object-valued preset records a merge point for every object of its value +// the schema proves safe to fill into a caller's object: plain objects and +// map entries, and union members the preset identifies uniquely, guarded by +// their discriminator. Unions without one (no discriminator, overlapping +// accepted values, map-shaped members) get none. +func TestCLICommands_PresetMergePoints(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 + $.described: + kind: png + $.implicit: + kind: png + $.source: + url: https://example.com/a + $.versioned: + api: v1 + kind: png + $.tiered: + api: v1 + kind: png + $.custom: + kind: png + $.config: + dpi: 72 + format: + kind: png + width: 10 + $.palette: + logo: + kind: png + width: 10 + $.composed: + mode: fast + format: + kind: png + width: 10 + $.overlap: + kind: png +`) + require.NoError(t, err) + got := map[string]CLIPresetMergePoint{} + for _, disc := range manifest.Commands[0].Source.Routes[0].PresetMergePoints { + got[disc.Pointer] = disc + } + assert.Equal(t, map[string]CLIPresetMergePoint{ + "/format": {Pointer: "/format", Key: "kind", Values: []any{"png"}}, + "/mapped": {Pointer: "/mapped", Key: "kind", Values: []any{"png", "portable"}}, + "/described": {Pointer: "/described", Key: "kind", Values: []any{"png", "portable"}}, + "/implicit": {Pointer: "/implicit", Key: "kind", Values: []any{"png", "PngFormat"}}, + "/versioned": {Pointer: "/versioned", Key: "kind", Values: []any{"png"}}, + "/tiered": {Pointer: "/tiered", Key: "kind", Values: []any{"png"}}, + "/config/format": {Pointer: "/config/format", Key: "kind", Values: []any{"png"}}, + "/plain": {Pointer: "/plain"}, + "/config": {Pointer: "/config"}, + "/palette": {Pointer: "/palette"}, + "/palette/logo": {Pointer: "/palette/logo", Key: "kind", Values: []any{"png"}}, + "/composed": {Pointer: "/composed", Key: "mode", Values: []any{"fast"}}, + "/composed/format": {Pointer: "/composed/format", Key: "kind", Values: []any{"png"}}, + }, got) +} + 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 4da45394f..2612be023 100644 --- a/templates/templates/cli/README.md +++ b/templates/templates/cli/README.md @@ -572,7 +572,7 @@ At runtime, every explicitly changed declared input and every recognized key in There may be at most one explicit `default: true`. For safety, it must match the union member selected by the generated `UnionMeta.DefaultJSON` rule (one unique non-null schema default on a distinguishing field), or its effective command-plus-route preset must supply a distinguishing key. A command with no default reports `pass one of --engine, --pipeline` when the invocation supplies no distinguishing evidence. -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 shallowly (command preset first, route preset overriding it; user keys always win). 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. +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 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. `--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. @@ -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), 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 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`). **Annotation contract**: diff --git a/templates/templates/cli/auxiliary/internal/flagutil/preset.go.stmpl b/templates/templates/cli/auxiliary/internal/flagutil/preset.go.stmpl index 1e7092cbf..25218c2a0 100644 --- a/templates/templates/cli/auxiliary/internal/flagutil/preset.go.stmpl +++ b/templates/templates/cli/auxiliary/internal/flagutil/preset.go.stmpl @@ -117,9 +117,15 @@ type PresetMerge struct { DiscriminatorKey string DiscriminatorValue string DiscriminatorAliases []string // every discriminator value (JSON text) selecting the pinned variant + Nested map[string]PresetMergePoint // JSON pointer -> where a preset object fills the user's object Escape string } +type PresetMergePoint struct { + Key string // discriminator the user's object must leave absent or set to one of Values ("" = none) + Values []string // JSON text of every value selecting the preset's union member +} + type PresetConflictError struct { Command string Variant string @@ -189,8 +195,13 @@ func MergePresetObject(user map[string]json.RawMessage, m PresetMerge) error { return fmt.Errorf("invalid preset for %q: %w", m.Command, err) } for key, value := range preset { - if _, present := user[key]; !present { + existing, present := user[key] + if !present { user[key] = value + continue + } + if merged, ok := m.fillPresetObject(existing, value, "/"+presetPointerToken(key)); ok { + user[key] = merged } } } @@ -204,6 +215,64 @@ func MergePresetObject(user map[string]json.RawMessage, m PresetMerge) error { return nil } +// fillPresetObject adds the preset's keys missing from the user's object at +// pointer, at every depth; user keys always win. ok is false unless both are +// objects, pointer is a merge point, and the user's object stays on the +// preset's union member. +func (m PresetMerge) fillPresetObject(user, preset json.RawMessage, pointer string) (json.RawMessage, bool) { + if point, ok := m.Nested[pointer]; !ok || !point.admits(user) { + return nil, false + } + var userObj, presetObj map[string]json.RawMessage + if json.Unmarshal(user, &userObj) != nil || userObj == nil { + return nil, false + } + if json.Unmarshal(preset, &presetObj) != nil || presetObj == nil { + return nil, false + } + for key, value := range presetObj { + existing, present := userObj[key] + if !present { + userObj[key] = value + continue + } + if merged, ok := m.fillPresetObject(existing, value, pointer+"/"+presetPointerToken(key)); ok { + userObj[key] = merged + } + } + merged, err := json.Marshal(userObj) + if err != nil { + return nil, false + } + return merged, true +} + +func presetPointerToken(key string) string { + return strings.ReplaceAll(strings.ReplaceAll(key, "~", "~0"), "/", "~1") +} + +// admits reports whether the user's object stays on the preset's member: +// its discriminator is absent or names one of the preset's values. +func (d PresetMergePoint) admits(user json.RawMessage) bool { + if d.Key == "" { + return true + } + var obj map[string]json.RawMessage + if json.Unmarshal(user, &obj) != nil { + return true + } + raw, present := obj[d.Key] + if !present { + return true + } + for _, value := range d.Values { + if jsonEqual(raw, json.RawMessage(value)) { + return true + } + } + return false +} + func (m PresetMerge) selectsPinnedVariant(raw json.RawMessage) bool { if jsonEqual(raw, json.RawMessage(m.DiscriminatorValue)) { return true diff --git a/templates/templates/cli/includes/intents.ts b/templates/templates/cli/includes/intents.ts index 580b67564..631e28d29 100644 --- a/templates/templates/cli/includes/intents.ts +++ b/templates/templates/cli/includes/intents.ts @@ -45,6 +45,14 @@ interface IntentDispatchRoute { PresetJSON: string; VariantLabel: string; ForeignSelectors: string[]; + PresetMergePoints: IntentPresetMergePoint[]; +} + +// Pointer at which a preset object fills a caller object (flagutil.PresetMergePoint). +interface IntentPresetMergePoint { + Pointer: string; + Key: string; + ValuesJSON: string[]; } interface IntentDispatchKey { @@ -99,6 +107,7 @@ interface IntentCmdCtx { DiscriminatorKey: string; DiscriminatorValueJSON: string; // JSON text of the pinned discriminator value ("" when unknown) DiscriminatorAliasesJSON: string[]; // JSON text of every value that selects the pinned variant + PresetMergePoints: IntentPresetMergePoint[]; EscapeCommand: string; // " agent run": full-control invocation for conflict errors HasPresetMerge: boolean; // a caller-supplied body needs merging/checking (presets or variant selectors exist) HintsJSON: string; // JSON object of reason → hint lines (agent envelope) @@ -610,6 +619,18 @@ function intentPresetJSON(presets: any[]): string { return Object.keys(obj).length > 0 ? JSON.stringify(obj) : ""; } +function intentPresetMergePoints( + discriminators: any[], +): IntentPresetMergePoint[] { + return (discriminators || []) + .map((d: any) => ({ + Pointer: d.Pointer || "", + Key: d.Key || "", + ValuesJSON: (d.Values || []).map((v: any) => JSON.stringify(v)), + })) + .filter((d) => d.Pointer !== ""); +} + function intentRouteGroup(routeIDs: string[], routes: any[]): string { if (routeIDs.length === 0 || routeIDs.length === routes.length) return ""; const labels = routes @@ -1173,6 +1194,9 @@ function collectIntentManifest(): IntentManifestCtx { "", ), ForeignSelectors: dispatchRoute.Selectors?.Foreign || [], + PresetMergePoints: intentPresetMergePoints( + dispatchRoute.PresetMergePoints, + ), })) : []; const dispatchKeys: IntentDispatchKey[] = dispatch @@ -1253,6 +1277,7 @@ function collectIntentManifest(): IntentManifestCtx { DiscriminatorAliasesJSON: (selectors?.DiscriminatorAliases || []).map( (v: any) => JSON.stringify(v), ), + PresetMergePoints: intentPresetMergePoints(route?.PresetMergePoints), EscapeCommand: escapeCommand, HasPresetMerge: !dispatch && diff --git a/templates/templates/cli/intentcmd.go.stmpl b/templates/templates/cli/intentcmd.go.stmpl index edbdddb63..52eb2d6eb 100644 --- a/templates/templates/cli/intentcmd.go.stmpl +++ b/templates/templates/cli/intentcmd.go.stmpl @@ -188,6 +188,7 @@ var intent{{$c.FuncName}}Preset = flagutil.PresetMerge{ DiscriminatorAliases: []string{ {{- range $i, $v := $c.DiscriminatorAliasesJSON}}{{if $i}}, {{end}}"{{escapeGoString $v}}"{{end -}} }, {{- end}} {{- end}} + {{- template "presetMergePoints" $c.PresetMergePoints}} Escape: "{{escapeGoString $c.EscapeCommand}}", } {{- end}} @@ -207,6 +208,7 @@ var intent{{$c.FuncName}}Dispatch = flagutil.DispatchTable{ Command: "{{escapeGoString $c.CommandDisplay}}", Variant: "{{escapeGoString $route.VariantLabel}}", Preset: "{{escapeGoString $route.PresetJSON}}", Foreign: []string{ {{- range $i, $key := $route.ForeignSelectors}}{{if $i}}, {{end}}"{{escapeGoString $key}}"{{end -}} }, + {{- template "presetMergePoints" $route.PresetMergePoints}} Escape: "{{escapeGoString $c.EscapeCommand}}", }, }, @@ -524,3 +526,13 @@ func newPollIntent{{$c.FuncName}}(cmd *cobra.Command, id string, params []output }, nil } {{- end}} + +{{- define "presetMergePoints"}} +{{- if .}} + Nested: map[string]flagutil.PresetMergePoint{ + {{- range $d := .}} + "{{escapeGoString $d.Pointer}}": {Key: "{{escapeGoString $d.Key}}", Values: []string{ {{- range $i, $v := $d.ValuesJSON}}{{if $i}}, {{end}}"{{escapeGoString $v}}"{{end -}} }}, + {{- end}} + }, +{{- 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..ba55c4e7c 100644 --- a/templates/templates/cli/tests/primary/intents_test.go.stmpl +++ b/templates/templates/cli/tests/primary/intents_test.go.stmpl @@ -45,6 +45,30 @@ func TestIntentPresetMergeMatrix(t *testing.T) { Escape: "cli request-bodies body-variant-overloads-discriminated", } noPreset := flagutil.PresetMerge{Command: "alias", Variant: "CliIntentModelRequest", Foreign: []string{"agent"}} + nestedPreset := flagutil.PresetMerge{ + Command: "draw", + Preset: `{"format":{"kind":"png","quality":"high"}}`, + Nested: map[string]flagutil.PresetMergePoint{"/format": {Key: "kind", Values: []string{`"png"`, `"portable"`}}}, + } + numericPreset := flagutil.PresetMerge{ + Command: "draw", + Preset: `{"format":{"version":1,"strict":true}}`, + Nested: map[string]flagutil.PresetMergePoint{"/format": {Key: "version", Values: []string{`1`}}}, + } + undiscriminatedPreset := flagutil.PresetMerge{ + Command: "fetch", + Preset: `{"source":{"url":"https://example.com/a"}}`, + } + plainPreset := flagutil.PresetMerge{ + Command: "cfg", + Preset: `{"config":{"a":1,"deep":{"b":2}}}`, + Nested: map[string]flagutil.PresetMergePoint{"/config": {}, "/config/deep": {}}, + } + deepPreset := flagutil.PresetMerge{ + Command: "draw", + Preset: `{"config":{"dpi":72,"format":{"kind":"png","width":10}}}`, + Nested: map[string]flagutil.PresetMergePoint{"/config": {}, "/config/format": {Key: "kind", Values: []string{`"png"`}}}, + } cases := []struct { name string @@ -60,7 +84,24 @@ func TestIntentPresetMergeMatrix(t *testing.T) { {name: "own selector present: user wins, other presets fill", merge: renderPreset, body: `{"model":"other-2","input":"cat"}`, want: `{"input":"cat","model":"other-2","output_modalities":["image"]}`}, {name: "user overrides a non-selector preset", merge: renderPreset, body: `{"output_modalities":["text"],"input":"cat"}`, want: `{"input":"cat","model":"render-1","output_modalities":["text"]}`}, {name: "explicit null keeps the user's null (own selector)", merge: renderPreset, body: `{"model":null,"input":"cat"}`, want: `{"input":"cat","model":null,"output_modalities":["image"]}`}, - {name: "shallow merge: nested object replaced whole, not merged", merge: flagutil.PresetMerge{Command: "cfg", Preset: `{"config":{"a":1,"b":2}}`}, body: `{"config":{"c":3}}`, want: `{"config":{"c":3}}`}, + {name: "nested object: missing keys filled at every merge point", merge: plainPreset, body: `{"config":{"c":3,"deep":{}}}`, want: `{"config":{"a":1,"c":3,"deep":{"b":2}}}`}, + {name: "nested object: user keys win at every depth", merge: plainPreset, body: `{"config":{"a":9,"deep":{"b":8}}}`, want: `{"config":{"a":9,"deep":{"b":8}}}`}, + {name: "nested object: no merge point keeps the user's object whole", merge: flagutil.PresetMerge{Command: "cfg", Preset: `{"config":{"a":1}}`}, body: `{"config":{"c":3}}`, want: `{"config":{"c":3}}`}, + {name: "nested object: merge point stops at an unlisted child", merge: flagutil.PresetMerge{Command: "cfg", Preset: `{"config":{"a":1,"deep":{"b":2}}}`, Nested: map[string]flagutil.PresetMergePoint{"/config": {}}}, body: `{"config":{"deep":{"c":3}}}`, want: `{"config":{"a":1,"deep":{"c":3}}}`}, + {name: "nested discriminator absent: filled from the preset", merge: nestedPreset, body: `{"format":{"width":10}}`, want: `{"format":{"kind":"png","quality":"high","width":10}}`}, + {name: "nested discriminator equal: other preset keys fill", merge: nestedPreset, body: `{"format":{"kind":"png","width":10}}`, want: `{"format":{"kind":"png","quality":"high","width":10}}`}, + {name: "nested discriminator alias: other preset keys fill", merge: nestedPreset, body: `{"format":{"kind":"portable"}}`, want: `{"format":{"kind":"portable","quality":"high"}}`}, + {name: "nested discriminator differs: user object kept as is", merge: nestedPreset, body: `{"format":{"kind":"svg"}}`, want: `{"format":{"kind":"svg"}}`}, + {name: "nested preset key set to a scalar: user wins", merge: nestedPreset, body: `{"format":"raw"}`, want: `{"format":"raw"}`}, + {name: "nested preset key set to an array: user wins", merge: nestedPreset, body: `{"format":[{"kind":"svg"}]}`, want: `{"format":[{"kind":"svg"}]}`}, + {name: "nested preset key set to null: user wins", merge: nestedPreset, body: `{"format":null}`, want: `{"format":null}`}, + {name: "nested preset key absent: preset object added", merge: nestedPreset, body: `{"other":1}`, want: `{"other":1,"format":{"kind":"png","quality":"high"}}`}, + {name: "nested numeric discriminator equal: preset keys fill", merge: numericPreset, body: `{"format":{"version":1}}`, want: `{"format":{"version":1,"strict":true}}`}, + {name: "nested numeric discriminator differs: user object kept as is", merge: numericPreset, body: `{"format":{"version":2}}`, want: `{"format":{"version":2}}`}, + {name: "union without a merge point: user object kept as is", merge: undiscriminatedPreset, body: `{"source":{"path":"/x"}}`, want: `{"source":{"path":"/x"}}`}, + {name: "undiscriminated union absent: preset object added", merge: undiscriminatedPreset, body: `{}`, want: `{"source":{"url":"https://example.com/a"}}`}, + {name: "union below a plain object: same member filled", merge: deepPreset, body: `{"config":{"format":{"scale":2}}}`, want: `{"config":{"dpi":72,"format":{"kind":"png","scale":2,"width":10}}}`}, + {name: "union below a plain object: other member kept, siblings filled", merge: deepPreset, body: `{"config":{"format":{"kind":"svg","scale":2}}}`, want: `{"config":{"dpi":72,"format":{"kind":"svg","scale":2}}}`}, {name: "raw values are preserved (large integer, precise decimal)", merge: renderPreset, body: `{"input":"cat","n":12345678901234567890,"d":0.10000000000000000001}`, want: `{"d":0.10000000000000000001,"input":"cat","model":"render-1","n":12345678901234567890,"output_modalities":["image"]}`}, {name: "foreign selector: hard usage error", merge: renderPreset, body: `{"agent":"helper","input":"cat"}`, wantErr: `"render" presets the CliIntentModelRequest request variant, but the supplied body names "agent"`}, {name: "foreign selector alongside own selector: still an error", merge: renderPreset, body: `{"agent":"helper","model":"x"}`, wantErr: `names "agent"`}, @@ -123,9 +164,26 @@ func TestIntentRouteDispatchFlagPathsAndPresets(t *testing.T) { assert.Equal(t, "draw a lighthouse", body["input"]) assert.Equal(t, true, body["background"], "command preset applies to the model route") assert.Equal(t, []interface{}{"image"}, body["output_modalities"], "model preset applies only after model selection") + assert.Equal(t, map[string]interface{}{"kind": "png"}, body["image_format"]) assert.NotContains(t, body, "token_budget") }) + for _, tc := range []struct { + name string + body string + want map[string]interface{} + }{ + {name: "model body kind filled", body: `{"model":"text-2","input":"x","image_format":{"width":10}}`, want: map[string]interface{}{"kind": "png", "width": float64(10)}}, + {name: "model body other kind kept", body: `{"model":"text-2","input":"x","image_format":{"kind":"svg"}}`, want: map[string]interface{}{"kind": "svg"}}, + } { + t.Run(tc.name, func(t *testing.T) { + h := NewCLITestHarness(t) + require.NoError(t, h.RunWithServer(s.URL, []string{"cli-intents", "cli-intent-run", "--body", tc.body})) + body := echoedJSON(t, h.GetStdout()) + assert.Equal(t, tc.want, body["image_format"]) + }) + } + t.Run("agent", func(t *testing.T) { h := NewCLITestHarness(t) require.NoError(t, h.RunWithServer(s.URL, []string{"cli-intents", "cli-intent-run", "summarize", "--agent", "helper", "--agent-profile", "standard"})) @@ -136,6 +194,7 @@ func TestIntentRouteDispatchFlagPathsAndPresets(t *testing.T) { assert.Equal(t, true, body["background"], "command preset applies to the agent route") assert.Equal(t, float64(512), body["token_budget"], "agent preset applies only after agent selection") assert.NotContains(t, body, "output_modalities") + assert.NotContains(t, body, "image_format") }) } @@ -359,6 +418,7 @@ func TestIntentPresetFromArgs(t *testing.T) { body := echoedJSON(t, h.GetStdout()) assert.Equal(t, "render-1", body["model"]) 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"]) } @@ -380,6 +440,25 @@ func TestIntentPartialBodyKeepsPresets(t *testing.T) { assert.Equal(t, "cat", body["input"]) }) } + + // An object preset over a union keeps its kind under a partial object + // for the same key, unless the caller's object names another kind. + for _, tc := range []struct { + name string + body string + want map[string]interface{} + }{ + {name: "kind filled", body: `{"input":"cat","image_format":{"width":10}}`, want: map[string]interface{}{"kind": "png", "width": float64(10)}}, + {name: "same kind", body: `{"input":"cat","image_format":{"kind":"png","width":10}}`, want: map[string]interface{}{"kind": "png", "width": float64(10)}}, + {name: "other kind kept", body: `{"input":"cat","image_format":{"kind":"svg","scale":2}}`, want: map[string]interface{}{"kind": "svg", "scale": float64(2)}}, + } { + t.Run(tc.name, func(t *testing.T) { + h := NewCLITestHarness(t) + require.NoError(t, h.RunWithServer(s.URL, []string{"render", "--body", tc.body})) + body := echoedJSON(t, h.GetStdout()) + assert.Equal(t, tc.want, body["image_format"]) + }) + } } func TestIntentPartialBodyUserKeysWin(t *testing.T) { diff --git a/tests/overlays/primary/cli/overlay.yaml b/tests/overlays/primary/cli/overlay.yaml index b5ee7efab..3630b52cc 100644 --- a/tests/overlays/primary/cli/overlay.yaml +++ b/tests/overlays/primary/cli/overlay.yaml @@ -175,6 +175,8 @@ actions: preset: $.model: render-1 $.output_modalities: image + $.image_format: + kind: png hints: CLI_VALIDATION: Print the exact request schema with --schema examples: @@ -193,6 +195,8 @@ actions: selector: model preset: $.output_modalities: image + $.image_format: + kind: png agent: label: Agent op: cliIntentRun#CliIntentAgentRequest diff --git a/tests/specs/fragments/primary/cli-intent-preset-merge.yaml b/tests/specs/fragments/primary/cli-intent-preset-merge.yaml index 830df4224..6afba9a4c 100644 --- a/tests/specs/fragments/primary/cli-intent-preset-merge.yaml +++ b/tests/specs/fragments/primary/cli-intent-preset-merge.yaml @@ -71,8 +71,36 @@ components: items: type: string enum: [text, image] + image_format: + description: >- + Union of objects told apart by a constant kind property. An intent + presets one member as an object; a partial object for this key in + a caller-supplied body must keep the preset's kind. + oneOf: + - $ref: "#/components/schemas/CliIntentPngFormat" + - $ref: "#/components/schemas/CliIntentSvgFormat" background: type: boolean + CliIntentPngFormat: + type: object + required: + - kind + properties: + kind: + type: string + const: png + width: + type: integer + CliIntentSvgFormat: + type: object + required: + - kind + properties: + kind: + type: string + const: svg + scale: + type: number CliIntentAgentRequest: type: object required: diff --git a/zSDKs/sdk-cli/.speakeasy/gen.lock b/zSDKs/sdk-cli/.speakeasy/gen.lock index f914fd188..3b7cafd8d 100644 --- a/zSDKs/sdk-cli/.speakeasy/gen.lock +++ b/zSDKs/sdk-cli/.speakeasy/gen.lock @@ -248,7 +248,7 @@ trackedFiles: internal/flagutil/metadata.go: last_write_checksum: sha1:1936c82d8e36a6612364827ffc05dcbea0e1dd8f internal/flagutil/preset.go: - last_write_checksum: sha1:a580b7d4b00cf469415bc53d40f4981d60091423 + last_write_checksum: sha1:fa4bcabb1f8f06183bdfb341f370dbdd028b6e0e internal/interactive/interactive.go: last_write_checksum: sha1:ba6979695c50a3c2385bc74a4676dfe88814a984 internal/interactive/policy.go: diff --git a/zSDKs/sdk-cli/internal/flagutil/preset.go b/zSDKs/sdk-cli/internal/flagutil/preset.go index 208582c4a..3f6d8ada4 100644 --- a/zSDKs/sdk-cli/internal/flagutil/preset.go +++ b/zSDKs/sdk-cli/internal/flagutil/preset.go @@ -118,10 +118,16 @@ type PresetMerge struct { Foreign []string // selector keys declared only by other union members DiscriminatorKey string DiscriminatorValue string - DiscriminatorAliases []string // every discriminator value (JSON text) selecting the pinned variant + DiscriminatorAliases []string // every discriminator value (JSON text) selecting the pinned variant + Nested map[string]PresetMergePoint // JSON pointer -> where a preset object fills the user's object Escape string } +type PresetMergePoint struct { + Key string // discriminator the user's object must leave absent or set to one of Values ("" = none) + Values []string // JSON text of every value selecting the preset's union member +} + type PresetConflictError struct { Command string Variant string @@ -191,8 +197,13 @@ func MergePresetObject(user map[string]json.RawMessage, m PresetMerge) error { return fmt.Errorf("invalid preset for %q: %w", m.Command, err) } for key, value := range preset { - if _, present := user[key]; !present { + existing, present := user[key] + if !present { user[key] = value + continue + } + if merged, ok := m.fillPresetObject(existing, value, "/"+presetPointerToken(key)); ok { + user[key] = merged } } } @@ -206,6 +217,64 @@ func MergePresetObject(user map[string]json.RawMessage, m PresetMerge) error { return nil } +// fillPresetObject adds the preset's keys missing from the user's object at +// pointer, at every depth; user keys always win. ok is false unless both are +// objects, pointer is a merge point, and the user's object stays on the +// preset's union member. +func (m PresetMerge) fillPresetObject(user, preset json.RawMessage, pointer string) (json.RawMessage, bool) { + if point, ok := m.Nested[pointer]; !ok || !point.admits(user) { + return nil, false + } + var userObj, presetObj map[string]json.RawMessage + if json.Unmarshal(user, &userObj) != nil || userObj == nil { + return nil, false + } + if json.Unmarshal(preset, &presetObj) != nil || presetObj == nil { + return nil, false + } + for key, value := range presetObj { + existing, present := userObj[key] + if !present { + userObj[key] = value + continue + } + if merged, ok := m.fillPresetObject(existing, value, pointer+"/"+presetPointerToken(key)); ok { + userObj[key] = merged + } + } + merged, err := json.Marshal(userObj) + if err != nil { + return nil, false + } + return merged, true +} + +func presetPointerToken(key string) string { + return strings.ReplaceAll(strings.ReplaceAll(key, "~", "~0"), "/", "~1") +} + +// admits reports whether the user's object stays on the preset's member: +// its discriminator is absent or names one of the preset's values. +func (d PresetMergePoint) admits(user json.RawMessage) bool { + if d.Key == "" { + return true + } + var obj map[string]json.RawMessage + if json.Unmarshal(user, &obj) != nil { + return true + } + raw, present := obj[d.Key] + if !present { + return true + } + for _, value := range d.Values { + if jsonEqual(raw, json.RawMessage(value)) { + return true + } + } + return false +} + func (m PresetMerge) selectsPinnedVariant(raw json.RawMessage) bool { if jsonEqual(raw, json.RawMessage(m.DiscriminatorValue)) { return true From 598a809c65239e0fd3976d66c0683c1d9b0661d9 Mon Sep 17 00:00:00 2001 From: 2ynn Date: Wed, 7 Oct 2026 09:32:27 -0400 Subject: [PATCH 02/11] fix(cli): tighten intent preset merge points for open union members and composed maps - Keep union members that can still accept an object (no type, or an open object) when flattening, so a remaining constrained member is not treated as the only candidate. - In anyOf unions, a member that does not declare the candidate discriminator and is not closed also accepts the preset's value, so the const-inferred discriminator no longer identifies a member there. oneOf keeps the inference: an object matching a const member cannot also be valid for a catch-all member. - Scope discriminator mappings to their propertyName and treat a value that layers map to different members as ambiguous, instead of letting the last layer win. - Read additionalProperties schemas through allOf layers, so preset objects inside composed maps get merge points. - Compare discriminator values with exact JSON numbers, so distinct large integers no longer collide through float64. --- internal/extensions/cli_commands_link.go | 87 +++++++++++++++---- internal/extensions/cli_commands_test.go | 52 +++++++++++ .../internal/flagutil/preset.go.stmpl | 62 +++++++++++-- .../cli/tests/primary/intents_test.go.stmpl | 8 ++ zSDKs/sdk-cli/.speakeasy/gen.lock | 2 +- zSDKs/sdk-cli/internal/flagutil/preset.go | 62 +++++++++++-- 6 files changed, 244 insertions(+), 29 deletions(-) diff --git a/internal/extensions/cli_commands_link.go b/internal/extensions/cli_commands_link.go index 10587c022..722bad4ba 100644 --- a/internal/extensions/cli_commands_link.go +++ b/internal/extensions/cli_commands_link.go @@ -86,7 +86,8 @@ type cliResolvedSchema struct { // additionalProperties: false somewhere in the composition; a closed // layer constrains the conjunction, so it wins over an open one. explicitClosed bool - unionMembers []any // oneOf/anyOf member schemas (raw), when present + unionMembers []any // oneOf/anyOf member schemas (raw), when present + additional map[string]any // additionalProperties schema, across allOf layers raw map[string]any } @@ -2420,11 +2421,14 @@ func (d *cliManifestDecoder) pinnedDiscriminatorValues(disc map[string]any, key, // cliFlatUnion is a union property flattened through nested oneOf/anyOf // layers: its object members (each carrying the properties declared beside // the unions above it), the discriminator keys the layers declare, and their -// explicit mappings (value -> member reference). +// explicit mappings (key -> value -> member reference; "" when layers map the +// same value to different members). inclusive marks an anyOf layer, where an +// object may match several members at once. type cliFlatUnion struct { - members []*cliResolvedSchema - keys []string - mappings map[string]string + members []*cliResolvedSchema + keys []string + mappings map[string]map[string]string + inclusive bool } func (d *cliManifestDecoder) flattenUnion(schema any, depth int, inherited *cliResolvedSchema, out *cliFlatUnion) { @@ -2436,19 +2440,41 @@ func (d *cliManifestDecoder) flattenUnion(schema any, depth int, inherited *cliR return } if resolved.unionMembers == nil { - if len(resolved.propOrder) > 0 || resolved.explicitOpen || resolved.raw["type"] == "object" { + if cliMayAcceptObject(resolved) { cliInheritProperties(resolved, inherited) out.members = append(out.members, resolved) } return } d.collectDiscriminators(schema, depth, out) + if _, exclusive := resolved.raw["oneOf"]; !exclusive { + out.inclusive = true + } cliInheritProperties(resolved, inherited) for _, member := range resolved.unionMembers { d.flattenUnion(member, depth+1, resolved, out) } } +// cliMayAcceptObject reports whether a union member can validate a JSON +// object: it declares properties, is object-typed, or leaves its type open. +func cliMayAcceptObject(member *cliResolvedSchema) bool { + if len(member.propOrder) > 0 || member.explicitOpen { + return true + } + switch kind := member.raw["type"].(type) { + case string: + return kind == "object" + case []any: + return slices.Contains(kind, any("object")) + case nil: + _, hasConst := member.raw["const"] + _, hasEnum := member.raw["enum"] + return !hasConst && !hasEnum + } + return false +} + func cliInheritProperties(target, parent *cliResolvedSchema) { if parent == nil { return @@ -2462,8 +2488,7 @@ func cliInheritProperties(target, parent *cliResolvedSchema) { } func cliAdditionalPropertiesSchema(object *cliResolvedSchema) (map[string]any, bool) { - schema, ok := object.raw["additionalProperties"].(map[string]any) - return schema, ok && len(schema) > 0 + return object.additional, object.additional != nil } // excludesValue reports whether a property schema's enum rules out value. @@ -2483,14 +2508,23 @@ func (d *cliManifestDecoder) collectDiscriminators(schema any, depth int, out *c return } if disc, ok := schemaMap["discriminator"].(map[string]any); ok { - if key, ok := disc["propertyName"].(string); ok && key != "" && !cliContains(out.keys, key) { + key, _ := disc["propertyName"].(string) + if key != "" && !cliContains(out.keys, key) { out.keys = append(out.keys, key) } - if mapping, ok := disc["mapping"].(map[string]any); ok { + if mapping, ok := disc["mapping"].(map[string]any); ok && key != "" { + if out.mappings[key] == nil { + out.mappings[key] = map[string]string{} + } for value, ref := range mapping { - if ref, ok := ref.(string); ok { - out.mappings[value] = ref + ref, ok := ref.(string) + if !ok { + continue + } + if existing, seen := out.mappings[key][value]; seen && existing != ref { + ref = "" } + out.mappings[key][value] = ref } } } @@ -2544,7 +2578,7 @@ func (d *cliManifestDecoder) collectPresetMergePoints(pointer string, schema any if depth > 8 { return } - flat := &cliFlatUnion{mappings: map[string]string{}} + flat := &cliFlatUnion{mappings: map[string]map[string]string{}} d.flattenUnion(schema, 0, nil, flat) point := CLIPresetMergePoint{Pointer: pointer} var object *cliResolvedSchema @@ -2610,6 +2644,7 @@ func (d *cliManifestDecoder) presetUnionMergePoint(pointer string, flat *cliFlat for _, m := range flat.members { prop, declares := m.properties[name] if !declares { + overlapping = overlapping || (flat.inclusive && !m.explicitClosed) continue } v, ok := d.constPropertyValue(prop) @@ -2635,7 +2670,10 @@ func (d *cliManifestDecoder) presetUnionMergePoint(pointer string, flat *cliFlat } pinned := value[key] - mappedRef, hasMapped := flat.mappings[fmt.Sprint(pinned)] + mappedRef, hasMapped := flat.mappings[key][fmt.Sprint(pinned)] + if hasMapped && mappedRef == "" { + return guard, nil + } selectedRefs := map[string]bool{} if hasMapped { selectedRefs[mappedRef] = true @@ -2656,9 +2694,12 @@ func (d *cliManifestDecoder) presetUnionMergePoint(pointer string, flat *cliFlat } values := []any{pinned} - aliases := make([]string, 0, len(flat.mappings)) + aliases := make([]string, 0, len(flat.mappings[key])) mapped := map[string]bool{} - for alias, ref := range flat.mappings { + for alias, ref := range flat.mappings[key] { + if ref == "" { + continue + } mapped[ref] = true if selectedRefs[ref] { aliases = append(aliases, alias) @@ -2941,6 +2982,7 @@ func (d *cliManifestDecoder) resolveObjectSchema(schema any, seen []string) (*cl if resolvedBranch.explicitClosed { out.explicitClosed = true } + out.additional = cliConjoinSchemas(out.additional, resolvedBranch.additional) // A union nested in an allOf branch constrains the composed // object; the artifact walk expands it against the merged view. if resolvedBranch.unionMembers != nil && out.unionMembers == nil { @@ -2988,6 +3030,9 @@ func (d *cliManifestDecoder) resolveObjectSchema(schema any, seen []string) (*cl } case map[string]any: out.explicitOpen = true + if len(additional) > 0 { + out.additional = cliConjoinSchemas(out.additional, additional) + } } if out.explicitClosed { // The conjunction of an open and a closed layer is closed. @@ -3000,6 +3045,16 @@ func (d *cliManifestDecoder) resolveObjectSchema(schema any, seen []string) (*cl return out, nil } +func cliConjoinSchemas(a, b map[string]any) map[string]any { + switch { + case a == nil: + return b + case b == nil || reflect.DeepEqual(a, b): + return a + } + return map[string]any{"allOf": []any{a, b}} +} + func cliUnionMembers(schema map[string]any) []any { for _, unionKey := range []string{"oneOf", "anyOf"} { if list, ok := schema[unionKey].([]any); ok && len(list) > 0 { diff --git a/internal/extensions/cli_commands_test.go b/internal/extensions/cli_commands_test.go index f58036007..6903c84ba 100644 --- a/internal/extensions/cli_commands_test.go +++ b/internal/extensions/cli_commands_test.go @@ -4528,6 +4528,29 @@ paths: type: object additionalProperties: $ref: '#/components/schemas/Format' + layered: + allOf: + - type: object + additionalProperties: + $ref: '#/components/schemas/Format' + inclusive: + anyOf: + - $ref: '#/components/schemas/PngFormat' + - $ref: '#/components/schemas/SvgFormat' + - type: object + additionalProperties: true + empty: + oneOf: + - $ref: '#/components/schemas/PngFormat' + - {} + remapped: + oneOf: + - $ref: '#/components/schemas/SwappedFormat' + - $ref: '#/components/schemas/MappedFormat' + scoped: + oneOf: + - $ref: '#/components/schemas/ImplicitFormat' + - $ref: '#/components/schemas/ApiFormat' composed: allOf: - type: object @@ -4572,6 +4595,20 @@ components: - $ref: '#/components/schemas/SvgFormat' - type: object additionalProperties: true + SwappedFormat: + oneOf: + - $ref: '#/components/schemas/VersionedSvg' + discriminator: + propertyName: kind + mapping: + portable: '#/components/schemas/VersionedSvg' + ApiFormat: + oneOf: + - $ref: '#/components/schemas/VersionedSvg' + discriminator: + propertyName: api + mapping: + png: '#/components/schemas/VersionedSvg' MappedFormat: oneOf: - $ref: '#/components/schemas/PngFormat' @@ -4682,6 +4719,17 @@ commands: logo: kind: png width: 10 + $.layered: + logo: + kind: png + $.inclusive: + kind: png + $.empty: + kind: png + $.remapped: + kind: png + $.scoped: + kind: png $.composed: mode: fast format: @@ -4707,6 +4755,10 @@ commands: "/config": {Pointer: "/config"}, "/palette": {Pointer: "/palette"}, "/palette/logo": {Pointer: "/palette/logo", Key: "kind", Values: []any{"png"}}, + "/layered": {Pointer: "/layered"}, + "/layered/logo": {Pointer: "/layered/logo", Key: "kind", Values: []any{"png"}}, + "/scoped": {Pointer: "/scoped", Key: "kind", Values: []any{"png", "PngFormat"}}, + "/remapped": {Pointer: "/remapped", Key: "kind", Values: []any{"png"}}, "/composed": {Pointer: "/composed", Key: "mode", Values: []any{"fast"}}, "/composed/format": {Pointer: "/composed/format", Key: "kind", Values: []any{"png"}}, }, got) diff --git a/templates/templates/cli/auxiliary/internal/flagutil/preset.go.stmpl b/templates/templates/cli/auxiliary/internal/flagutil/preset.go.stmpl index 25218c2a0..64e998de6 100644 --- a/templates/templates/cli/auxiliary/internal/flagutil/preset.go.stmpl +++ b/templates/templates/cli/auxiliary/internal/flagutil/preset.go.stmpl @@ -4,6 +4,8 @@ import ( "bytes" "encoding/json" "fmt" + "io" + "math/big" "reflect" "strconv" "strings" @@ -286,12 +288,60 @@ func (m PresetMerge) selectsPinnedVariant(raw json.RawMessage) bool { } func jsonEqual(a, b json.RawMessage) bool { - var av, bv any - if err := json.Unmarshal(a, &av); err != nil { - return false + av, aok := decodeExactJSON(a) + bv, bok := decodeExactJSON(b) + return aok && bok && exactJSONEqual(av, bv) +} + +func decodeExactJSON(raw json.RawMessage) (any, bool) { + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + var v any + if err := dec.Decode(&v); err != nil { + return nil, false + } + if _, err := dec.Token(); err != io.EOF { + return nil, false } - if err := json.Unmarshal(b, &bv); err != nil { - return false + return v, true +} + +// exactJSONEqual compares numbers by exact value: float64 decoding would make +// distinct large integers equal. +func exactJSONEqual(a, b any) bool { + switch av := a.(type) { + case json.Number: + bv, ok := b.(json.Number) + if !ok { + return false + } + ar, aok := new(big.Rat).SetString(av.String()) + br, bok := new(big.Rat).SetString(bv.String()) + return aok && bok && ar.Cmp(br) == 0 + case map[string]any: + bv, ok := b.(map[string]any) + if !ok || len(av) != len(bv) { + return false + } + for key, value := range av { + other, ok := bv[key] + if !ok || !exactJSONEqual(value, other) { + return false + } + } + return true + case []any: + bv, ok := b.([]any) + if !ok || len(av) != len(bv) { + return false + } + for i := range av { + if !exactJSONEqual(av[i], bv[i]) { + return false + } + } + return true + default: + return reflect.DeepEqual(a, b) } - return reflect.DeepEqual(av, bv) } diff --git a/templates/templates/cli/tests/primary/intents_test.go.stmpl b/templates/templates/cli/tests/primary/intents_test.go.stmpl index ba55c4e7c..e3d12a547 100644 --- a/templates/templates/cli/tests/primary/intents_test.go.stmpl +++ b/templates/templates/cli/tests/primary/intents_test.go.stmpl @@ -55,6 +55,11 @@ func TestIntentPresetMergeMatrix(t *testing.T) { Preset: `{"format":{"version":1,"strict":true}}`, Nested: map[string]flagutil.PresetMergePoint{"/format": {Key: "version", Values: []string{`1`}}}, } + largeNumericPreset := flagutil.PresetMerge{ + Command: "draw", + Preset: `{"format":{"version":12345678901234567890,"strict":true}}`, + Nested: map[string]flagutil.PresetMergePoint{"/format": {Key: "version", Values: []string{`12345678901234567890`}}}, + } undiscriminatedPreset := flagutil.PresetMerge{ Command: "fetch", Preset: `{"source":{"url":"https://example.com/a"}}`, @@ -98,6 +103,9 @@ func TestIntentPresetMergeMatrix(t *testing.T) { {name: "nested preset key absent: preset object added", merge: nestedPreset, body: `{"other":1}`, want: `{"other":1,"format":{"kind":"png","quality":"high"}}`}, {name: "nested numeric discriminator equal: preset keys fill", merge: numericPreset, body: `{"format":{"version":1}}`, want: `{"format":{"version":1,"strict":true}}`}, {name: "nested numeric discriminator differs: user object kept as is", merge: numericPreset, body: `{"format":{"version":2}}`, want: `{"format":{"version":2}}`}, + {name: "nested numeric discriminator in another notation: preset keys fill", merge: numericPreset, body: `{"format":{"version":1.0}}`, want: `{"format":{"strict":true,"version":1.0}}`}, + {name: "nested large integer discriminator differs past float64 precision: user object kept as is", merge: largeNumericPreset, body: `{"format":{"version":12345678901234567891}}`, want: `{"format":{"version":12345678901234567891}}`}, + {name: "nested large integer discriminator equal: preset keys fill", merge: largeNumericPreset, body: `{"format":{"version":12345678901234567890}}`, want: `{"format":{"strict":true,"version":12345678901234567890}}`}, {name: "union without a merge point: user object kept as is", merge: undiscriminatedPreset, body: `{"source":{"path":"/x"}}`, want: `{"source":{"path":"/x"}}`}, {name: "undiscriminated union absent: preset object added", merge: undiscriminatedPreset, body: `{}`, want: `{"source":{"url":"https://example.com/a"}}`}, {name: "union below a plain object: same member filled", merge: deepPreset, body: `{"config":{"format":{"scale":2}}}`, want: `{"config":{"dpi":72,"format":{"kind":"png","scale":2,"width":10}}}`}, diff --git a/zSDKs/sdk-cli/.speakeasy/gen.lock b/zSDKs/sdk-cli/.speakeasy/gen.lock index 3b7cafd8d..81d6621e6 100644 --- a/zSDKs/sdk-cli/.speakeasy/gen.lock +++ b/zSDKs/sdk-cli/.speakeasy/gen.lock @@ -248,7 +248,7 @@ trackedFiles: internal/flagutil/metadata.go: last_write_checksum: sha1:1936c82d8e36a6612364827ffc05dcbea0e1dd8f internal/flagutil/preset.go: - last_write_checksum: sha1:fa4bcabb1f8f06183bdfb341f370dbdd028b6e0e + last_write_checksum: sha1:51699bc288541a06bf60e5f6de73a8f48c42d6ff internal/interactive/interactive.go: last_write_checksum: sha1:ba6979695c50a3c2385bc74a4676dfe88814a984 internal/interactive/policy.go: diff --git a/zSDKs/sdk-cli/internal/flagutil/preset.go b/zSDKs/sdk-cli/internal/flagutil/preset.go index 3f6d8ada4..48b28879c 100644 --- a/zSDKs/sdk-cli/internal/flagutil/preset.go +++ b/zSDKs/sdk-cli/internal/flagutil/preset.go @@ -6,6 +6,8 @@ import ( "bytes" "encoding/json" "fmt" + "io" + "math/big" "reflect" "strconv" "strings" @@ -288,12 +290,60 @@ func (m PresetMerge) selectsPinnedVariant(raw json.RawMessage) bool { } func jsonEqual(a, b json.RawMessage) bool { - var av, bv any - if err := json.Unmarshal(a, &av); err != nil { - return false + av, aok := decodeExactJSON(a) + bv, bok := decodeExactJSON(b) + return aok && bok && exactJSONEqual(av, bv) +} + +func decodeExactJSON(raw json.RawMessage) (any, bool) { + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + var v any + if err := dec.Decode(&v); err != nil { + return nil, false + } + if _, err := dec.Token(); err != io.EOF { + return nil, false } - if err := json.Unmarshal(b, &bv); err != nil { - return false + return v, true +} + +// exactJSONEqual compares numbers by exact value: float64 decoding would make +// distinct large integers equal. +func exactJSONEqual(a, b any) bool { + switch av := a.(type) { + case json.Number: + bv, ok := b.(json.Number) + if !ok { + return false + } + ar, aok := new(big.Rat).SetString(av.String()) + br, bok := new(big.Rat).SetString(bv.String()) + return aok && bok && ar.Cmp(br) == 0 + case map[string]any: + bv, ok := b.(map[string]any) + if !ok || len(av) != len(bv) { + return false + } + for key, value := range av { + other, ok := bv[key] + if !ok || !exactJSONEqual(value, other) { + return false + } + } + return true + case []any: + bv, ok := b.([]any) + if !ok || len(av) != len(bv) { + return false + } + for i := range av { + if !exactJSONEqual(av[i], bv[i]) { + return false + } + } + return true + default: + return reflect.DeepEqual(a, b) } - return reflect.DeepEqual(av, bv) } From 7ef3f945546a192e0f3a2867627ef2641c8fd4e6 Mon Sep 17 00:00:00 2001 From: 2ynn Date: Wed, 7 Oct 2026 10:49:38 -0400 Subject: [PATCH 03/11] fix(cli): only infer a preset's union member when no other member can accept it A merge point inferred from a const discriminator was emitted when another member could still accept the caller's object, so preset fields from one member could be filled into an object valid only for another. - A member that does not declare the inferred key now counts as a possible match unless it is closed or places no constraint on an object at all (a catch-all, which no fill can invalidate); this replaces the oneOf/anyOf distinction, which also misread a oneOf composed through allOf. - A property declared both beside a union and on one of its members is the conjunction of the two, so a member's broad redeclaration no longer hides an inherited nested union. - Members without a type whose const or enum lists an object are kept as object members instead of being dropped. --- internal/extensions/cli_commands_link.go | 79 +++++++++++++++++++----- internal/extensions/cli_commands_test.go | 56 +++++++++++++++++ 2 files changed, 120 insertions(+), 15 deletions(-) diff --git a/internal/extensions/cli_commands_link.go b/internal/extensions/cli_commands_link.go index 722bad4ba..a33c0031f 100644 --- a/internal/extensions/cli_commands_link.go +++ b/internal/extensions/cli_commands_link.go @@ -2422,13 +2422,17 @@ func (d *cliManifestDecoder) pinnedDiscriminatorValues(disc map[string]any, key, // layers: its object members (each carrying the properties declared beside // the unions above it), the discriminator keys the layers declare, and their // explicit mappings (key -> value -> member reference; "" when layers map the -// same value to different members). inclusive marks an anyOf layer, where an -// object may match several members at once. +// same value to different members). fallbacks marks members that accept every +// object on their own, so filling keys into an object can never invalidate it. type cliFlatUnion struct { members []*cliResolvedSchema keys []string mappings map[string]map[string]string - inclusive bool + fallbacks map[*cliResolvedSchema]bool +} + +func newCLIFlatUnion() *cliFlatUnion { + return &cliFlatUnion{mappings: map[string]map[string]string{}, fallbacks: map[*cliResolvedSchema]bool{}} } func (d *cliManifestDecoder) flattenUnion(schema any, depth int, inherited *cliResolvedSchema, out *cliFlatUnion) { @@ -2441,15 +2445,15 @@ func (d *cliManifestDecoder) flattenUnion(schema any, depth int, inherited *cliR } if resolved.unionMembers == nil { if cliMayAcceptObject(resolved) { + if cliAcceptsEveryObject(resolved) { + out.fallbacks[resolved] = true + } cliInheritProperties(resolved, inherited) out.members = append(out.members, resolved) } return } d.collectDiscriminators(schema, depth, out) - if _, exclusive := resolved.raw["oneOf"]; !exclusive { - out.inclusive = true - } cliInheritProperties(resolved, inherited) for _, member := range resolved.unionMembers { d.flattenUnion(member, depth+1, resolved, out) @@ -2457,7 +2461,8 @@ func (d *cliManifestDecoder) flattenUnion(schema any, depth int, inherited *cliR } // cliMayAcceptObject reports whether a union member can validate a JSON -// object: it declares properties, is object-typed, or leaves its type open. +// object: it declares properties, is object-typed, leaves its type open, or +// lists an object among its const/enum values. func cliMayAcceptObject(member *cliResolvedSchema) bool { if len(member.propOrder) > 0 || member.explicitOpen { return true @@ -2468,21 +2473,64 @@ func cliMayAcceptObject(member *cliResolvedSchema) bool { case []any: return slices.Contains(kind, any("object")) case nil: - _, hasConst := member.raw["const"] - _, hasEnum := member.raw["enum"] - return !hasConst && !hasEnum + if value, ok := member.raw["const"]; ok { + _, isObject := value.(map[string]any) + return isObject + } + if values, ok := member.raw["enum"].([]any); ok { + return slices.ContainsFunc(values, func(value any) bool { + _, isObject := value.(map[string]any) + return isObject + }) + } + return true } return false } +// cliAcceptsEveryObject reports whether a member, on its own, places no +// constraint on an object: no properties, required keys, value schema or +// other validation keyword. +func cliAcceptsEveryObject(member *cliResolvedSchema) bool { + if len(member.propOrder) > 0 || len(member.required) > 0 || member.additional != nil || member.explicitClosed || member.unionMembers != nil { + return false + } + for keyword, value := range member.raw { + switch keyword { + case "type": + if value != "object" { + return false + } + case "additionalProperties": + if value != true { + if schema, ok := value.(map[string]any); !ok || len(schema) > 0 { + return false + } + } + case "title", "description", "nullable", "example", "examples", "deprecated": + default: + if !strings.HasPrefix(keyword, "x-") { + return false + } + } + } + return true +} + +// cliInheritProperties adds the properties declared beside a union to one of +// its members; a property both declare is the conjunction of the two. func cliInheritProperties(target, parent *cliResolvedSchema) { if parent == nil { return } for _, name := range parent.propOrder { - if _, ok := target.properties[name]; !ok { + existing, ok := target.properties[name] + switch { + case !ok: target.properties[name] = parent.properties[name] target.propOrder = append(target.propOrder, name) + case !reflect.DeepEqual(existing, parent.properties[name]): + target.properties[name] = map[string]any{"allOf": []any{parent.properties[name], existing}} } } } @@ -2578,7 +2626,7 @@ func (d *cliManifestDecoder) collectPresetMergePoints(pointer string, schema any if depth > 8 { return } - flat := &cliFlatUnion{mappings: map[string]map[string]string{}} + flat := newCLIFlatUnion() d.flattenUnion(schema, 0, nil, flat) point := CLIPresetMergePoint{Pointer: pointer} var object *cliResolvedSchema @@ -2617,8 +2665,9 @@ func (d *cliManifestDecoder) collectPresetMergePoints(pointer string, schema any // presetUnionMergePoint finds the discriminator a preset object sets on a // union: a key the union declares as its discriminator, else a key whose // const/single-enum values differ across members, whose preset value is the -// const of exactly one member, and which no other member declaring it can -// also accept. The accepted values are the preset's value plus every value +// const of exactly one member, and which no other member can also accept: a +// member declaring it must rule the value out by enum, a member not declaring +// it must be closed or accept every object. The accepted values are the preset's value plus every value // mapped (explicitly, or implicitly by component name) to the member it // selects. member is nil when the preset does not select exactly one member. func (d *cliManifestDecoder) presetUnionMergePoint(pointer string, flat *cliFlatUnion, value map[string]any) (guard CLIPresetMergePoint, member *cliResolvedSchema) { @@ -2644,7 +2693,7 @@ func (d *cliManifestDecoder) presetUnionMergePoint(pointer string, flat *cliFlat for _, m := range flat.members { prop, declares := m.properties[name] if !declares { - overlapping = overlapping || (flat.inclusive && !m.explicitClosed) + overlapping = overlapping || (!m.explicitClosed && !flat.fallbacks[m]) continue } v, ok := d.constPropertyValue(prop) diff --git a/internal/extensions/cli_commands_test.go b/internal/extensions/cli_commands_test.go index 6903c84ba..3cbcbac80 100644 --- a/internal/extensions/cli_commands_test.go +++ b/internal/extensions/cli_commands_test.go @@ -4566,6 +4566,46 @@ paths: properties: mode: const: slow + labelled: + oneOf: + - $ref: '#/components/schemas/PngFormat' + - $ref: '#/components/schemas/SvgFormat' + - type: object + required: [label] + additionalProperties: + type: string + constant: + oneOf: + - $ref: '#/components/schemas/PngFormat' + - const: + kind: svg + wrapped: + allOf: + - type: object + - oneOf: + - $ref: '#/components/schemas/PngFormat' + - $ref: '#/components/schemas/SvgFormat' + - type: object + additionalProperties: true + refined: + allOf: + - type: object + properties: + format: + $ref: '#/components/schemas/Format' + - oneOf: + - type: object + properties: + mode: + const: fast + format: + type: object + - type: object + properties: + mode: + const: slow + format: + type: object overlap: oneOf: - $ref: '#/components/schemas/PngFormat' @@ -4735,6 +4775,18 @@ commands: format: kind: png width: 10 + $.labelled: + kind: png + width: 10 + $.constant: + kind: png + $.wrapped: + kind: png + $.refined: + mode: fast + format: + kind: png + width: 10 $.overlap: kind: png `) @@ -4761,6 +4813,10 @@ commands: "/remapped": {Pointer: "/remapped", Key: "kind", Values: []any{"png"}}, "/composed": {Pointer: "/composed", Key: "mode", Values: []any{"fast"}}, "/composed/format": {Pointer: "/composed/format", Key: "kind", Values: []any{"png"}}, + "/inclusive": {Pointer: "/inclusive", Key: "kind", Values: []any{"png"}}, + "/wrapped": {Pointer: "/wrapped", Key: "kind", Values: []any{"png"}}, + "/refined": {Pointer: "/refined", Key: "mode", Values: []any{"fast"}}, + "/refined/format": {Pointer: "/refined/format", Key: "kind", Values: []any{"png"}}, }, got) } From 1ee3e9b22dcedd3581da67480add473fcf7ef0ca Mon Sep 17 00:00:00 2001 From: 2ynn Date: Tue, 6 Oct 2026 20:05:52 -0400 Subject: [PATCH 04/11] feat(cli): bind intent flags one level beneath an object preset An intent flag could only bind a top-level body field, so a field of an object the command presets (one option of a union-typed property the preset pins to a member) had no flag and was reachable only through --body. A flag's `to:` may now name a field one level beneath an object preset (`$.format.width` under `preset: {$.format: {kind: png}}`). The linker resolves the field in the property's object, or in the union member the preset selects, and infers its type, enum suggestions and any preset default. It rejects a parent without an object preset, a union member the preset does not uniquely identify, the union's discriminator, positional args, route dispatch commands and operation-declared flags; deeper paths stay decode errors. Without a body the value is written into the preset object. With --body or --body-param it is merged into the caller's object, created when absent, and the preset then fills the discriminator. A caller object naming another member, a non-object parent, and a field the caller already set are usage errors. --- .changesets/1791330588-22ed09b7.yaml | 10 +++ internal/extensions/cli_commands.go | 23 ++++- internal/extensions/cli_commands_link.go | 67 +++++++++++++- internal/extensions/cli_commands_test.go | 88 ++++++++++++++++++- templates/templates/cli/README.md | 1 + .../internal/flagutil/flags.go.stmpl | 64 +++++++++++++- .../internal/flagutil/preset.go.stmpl | 14 +++ templates/templates/cli/includes/intents.ts | 28 +++++- templates/templates/cli/intentcmd.go.stmpl | 10 +++ .../cli/tests/primary/intents_test.go.stmpl | 32 +++++++ tests/overlays/primary/cli/overlay.yaml | 3 + zSDKs/sdk-cli/.speakeasy/gen.lock | 4 +- zSDKs/sdk-cli/internal/flagutil/flags.go | 64 +++++++++++++- zSDKs/sdk-cli/internal/flagutil/preset.go | 14 +++ 14 files changed, 401 insertions(+), 21 deletions(-) create mode 100644 .changesets/1791330588-22ed09b7.yaml 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 a33c0031f..d56255d48 100644 --- a/internal/extensions/cli_commands_link.go +++ b/internal/extensions/cli_commands_link.go @@ -796,6 +796,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) @@ -1308,6 +1311,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] @@ -3386,6 +3392,48 @@ 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) + var scope *cliResolvedSchema + switch { + case len(flat.members) == 1: + scope = flat.members[0] + 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) + } + if point.Key == cliPointerPropertyName(leaf) { + 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) + } + scope = member + } + 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) + } + leafPresets := map[string]any{} + if value, ok := presetObj[cliPointerPropertyName(leaf)]; ok { + leafPresets[leaf] = value + } + return scope, leafPresets, nil +} + func cliPointerPropertyName(pointer string) string { name := strings.TrimPrefix(pointer, "/") name = strings.ReplaceAll(name, "~1", "/") @@ -3412,7 +3460,20 @@ 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 + if parent, leaf, nested := cliSplitNestedPointer(pointer); 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 } @@ -3425,7 +3486,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 } @@ -3501,7 +3562,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 3cbcbac80..9d8f7de71 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: @@ -4820,6 +4828,84 @@ 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`}, + {"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"`}, + {"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 2612be023..a8e78e871 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), 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 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, 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 fad551831..4a0cc2f39 100644 --- a/templates/templates/cli/auxiliary/internal/flagutil/flags.go.stmpl +++ b/templates/templates/cli/auxiliary/internal/flagutil/flags.go.stmpl @@ -783,6 +783,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) @@ -805,14 +813,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 @@ -823,3 +830,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..4a4d7d278 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,15 @@ function intentPointerKey(pointer: string): string { return rest.replace(/~1/g, "/").replace(/~0/g, "~"); } +// 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]; @@ -850,18 +861,27 @@ function collectIntentManifest(): IntentManifestCtx { 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 && + path[1] in presetParent + : key in presetObj; 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 52eb2d6eb..1aabd3be6 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}} @@ -536,3 +544,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 e3d12a547..0b5199ae2 100644 --- a/templates/templates/cli/tests/primary/intents_test.go.stmpl +++ b/templates/templates/cli/tests/primary/intents_test.go.stmpl @@ -428,6 +428,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 @@ -467,6 +472,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 TestIntentPartialBodyUserKeysWin(t *testing.T) { 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 81d6621e6..72aecad18 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:a81b13ee97e283dccec6da827fe81159f2ce3ec5 + last_write_checksum: sha1:71808d00fb1889b06be9621e932dde94796c9339 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 e285840af..80d96427c 100644 --- a/zSDKs/sdk-cli/internal/flagutil/flags.go +++ b/zSDKs/sdk-cli/internal/flagutil/flags.go @@ -785,6 +785,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) @@ -807,14 +815,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 @@ -825,3 +832,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") } From af895b11a1b5936bf35e8556762b3da0fecfd0c9 Mon Sep 17 00:00:00 2001 From: 2ynn Date: Wed, 7 Oct 2026 11:14:57 -0400 Subject: [PATCH 05/11] fix(cli): keep a caller's object whole where the schema restricts it to const/enum values An object restricted to const or enum values must equal one of them, so filling preset keys into a caller's object there can only produce a value matching none: preset {kind: png} filled into a caller's {preset: vector} under enum [{kind: png}, {preset: vector}] sent {preset: vector, kind: png}. Such objects, untyped or object-typed, standalone or as a union member, or under a union whose layer declares the restriction, no longer get a merge point. The union-through-allOf fixture now uses anyOf: a oneOf with a member that accepts every object cannot validate any object the pinned member accepts. --- internal/extensions/cli_commands_link.go | 25 +++++++++++++----- internal/extensions/cli_commands_test.go | 32 +++++++++++++++++++++++- templates/templates/cli/README.md | 4 +-- 3 files changed, 52 insertions(+), 9 deletions(-) diff --git a/internal/extensions/cli_commands_link.go b/internal/extensions/cli_commands_link.go index a33c0031f..005cd4e99 100644 --- a/internal/extensions/cli_commands_link.go +++ b/internal/extensions/cli_commands_link.go @@ -88,7 +88,10 @@ type cliResolvedSchema struct { explicitClosed bool unionMembers []any // oneOf/anyOf member schemas (raw), when present additional map[string]any // additionalProperties schema, across allOf layers - raw map[string]any + // const or enum somewhere in the composition: the value must equal a + // listed one, so filling keys into it can only break that equality. + enumerated bool + raw map[string]any } // cliPropertyFacts is the classification of a single property schema used for @@ -2517,12 +2520,16 @@ func cliAcceptsEveryObject(member *cliResolvedSchema) bool { return true } -// cliInheritProperties adds the properties declared beside a union to one of -// its members; a property both declare is the conjunction of the two. +// cliInheritProperties adds the properties and any const/enum restriction +// declared beside a union to one of its members; a property both declare is +// the conjunction of the two. func cliInheritProperties(target, parent *cliResolvedSchema) { if parent == nil { return } + if parent.enumerated { + target.enumerated = true + } for _, name := range parent.propOrder { existing, ok := target.properties[name] switch { @@ -2604,8 +2611,8 @@ func (d *cliManifestDecoder) constPropertyValue(schema any) (any, bool) { // and records, keyed by JSON pointer, every object of the preset value that // may fill a caller's object at the same pointer: plain objects and map // entries unconditionally, union members only when the preset identifies one -// member, guarded by the discriminator it sets. Elsewhere the caller's -// object is kept whole. +// member, guarded by the discriminator it sets. Elsewhere, including objects +// restricted to const/enum values, the caller's object is kept whole. func (d *cliManifestDecoder) presetMergePoints(variant *cliResolvedSchema, presets []CLICommandPreset) []CLIPresetMergePoint { var out []CLIPresetMergePoint for _, preset := range presets { @@ -2636,7 +2643,7 @@ func (d *cliManifestDecoder) collectPresetMergePoints(pointer string, schema any case len(flat.members) > 1: point, object = d.presetUnionMergePoint(pointer, flat, value) } - if object == nil { + if object == nil || object.enumerated { return } *out = append(*out, point) @@ -2995,9 +3002,12 @@ func (d *cliManifestDecoder) resolveObjectSchema(schema any, seen []string) (*cl return resolved, nil } + _, hasConst := schemaMap["const"] + _, hasEnum := schemaMap["enum"] out := &cliResolvedSchema{ properties: map[string]any{}, required: map[string]bool{}, + enumerated: hasConst || hasEnum, raw: schemaMap, } @@ -3031,6 +3041,9 @@ func (d *cliManifestDecoder) resolveObjectSchema(schema any, seen []string) (*cl if resolvedBranch.explicitClosed { out.explicitClosed = true } + if resolvedBranch.enumerated { + out.enumerated = true + } out.additional = cliConjoinSchemas(out.additional, resolvedBranch.additional) // A union nested in an allOf branch constrains the composed // object; the artifact walk expands it against the merged view. diff --git a/internal/extensions/cli_commands_test.go b/internal/extensions/cli_commands_test.go index 3cbcbac80..aab54f02e 100644 --- a/internal/extensions/cli_commands_test.go +++ b/internal/extensions/cli_commands_test.go @@ -4582,7 +4582,7 @@ paths: wrapped: allOf: - type: object - - oneOf: + - anyOf: - $ref: '#/components/schemas/PngFormat' - $ref: '#/components/schemas/SvgFormat' - type: object @@ -4606,6 +4606,28 @@ paths: const: slow format: type: object + listed: + enum: + - kind: png + - preset: vector + typedListed: + type: object + enum: + - kind: png + - preset: vector + listedMember: + oneOf: + - enum: + - kind: png + - preset: vector + - type: string + listedUnion: + enum: + - kind: png + - kind: svg + oneOf: + - $ref: '#/components/schemas/PngFormat' + - $ref: '#/components/schemas/SvgFormat' overlap: oneOf: - $ref: '#/components/schemas/PngFormat' @@ -4787,6 +4809,14 @@ commands: format: kind: png width: 10 + $.listed: + kind: png + $.typedListed: + kind: png + $.listedMember: + kind: png + $.listedUnion: + kind: png $.overlap: kind: png `) diff --git a/templates/templates/cli/README.md b/templates/templates/cli/README.md index 2612be023..4ce0ff250 100644 --- a/templates/templates/cli/README.md +++ b/templates/templates/cli/README.md @@ -572,7 +572,7 @@ At runtime, every explicitly changed declared input and every recognized key in There may be at most one explicit `default: true`. For safety, it must match the union member selected by the generated `UnionMeta.DefaultJSON` rule (one unique non-null schema default on a distinguishing field), or its effective command-plus-route preset must supply a distinguishing key. A command with no default reports `pass one of --engine, --pipeline` when the invocation supplies no distinguishing evidence. -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 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. +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. `--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. @@ -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 — 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 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`). +- 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 — 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`). **Annotation contract**: From 418b63de84497814fdbc1144ca565cef140be40d Mon Sep 17 00:00:00 2001 From: 2ynn Date: Wed, 7 Oct 2026 11:16:51 -0400 Subject: [PATCH 06/11] fix(cli): reject a nested flag bind beneath an object restricted to const/enum values A field written by a flag into an object the schema restricts to const or enum values makes the object match none of them, so the linker now rejects such a bind instead of resolving the field. --- internal/extensions/cli_commands_link.go | 3 +++ internal/extensions/cli_commands_test.go | 12 ++++++++++++ templates/templates/cli/README.md | 2 +- 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/internal/extensions/cli_commands_link.go b/internal/extensions/cli_commands_link.go index bb3b731f6..1711a5254 100644 --- a/internal/extensions/cli_commands_link.go +++ b/internal/extensions/cli_commands_link.go @@ -3440,6 +3440,9 @@ func (d *cliManifestDecoder) nestedBindScope(cmdKey, what, parent, leaf string, 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[cliPointerPropertyName(leaf)]; ok { leafPresets[leaf] = value diff --git a/internal/extensions/cli_commands_test.go b/internal/extensions/cli_commands_test.go index 521768089..0bd6ce5ee 100644 --- a/internal/extensions/cli_commands_test.go +++ b/internal/extensions/cli_commands_test.go @@ -4629,6 +4629,17 @@ paths: - kind: png - preset: vector - type: string + listedFields: + type: object + properties: + kind: + type: string + width: + type: integer + enum: + - kind: png + - kind: svg + width: 10 listedUnion: enum: - kind: png @@ -4903,6 +4914,7 @@ func TestCLICommands_NestedPresetBindErrors(t *testing.T) { {"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"`}, + {"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`}, diff --git a/templates/templates/cli/README.md b/templates/templates/cli/README.md index 56850e745..805db741d 100644 --- a/templates/templates/cli/README.md +++ b/templates/templates/cli/README.md @@ -768,7 +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), 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, 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. +- 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, 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**: From 27c53bc0e233ce6f699af5bddad07596b4c4835c Mon Sep 17 00:00:00 2001 From: 2ynn Date: Wed, 7 Oct 2026 11:44:39 -0400 Subject: [PATCH 07/11] fix(cli): treat a nullable catch-all union member as accepting every object A member typed as a list that includes object (e.g. type: [object, "null"]) with no other constraint accepts every object just like type: object, but was not recognized as a catch-all, so an otherwise identified preset member lost its merge point. --- internal/extensions/cli_commands_link.go | 6 +++++- internal/extensions/cli_commands_test.go | 9 +++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/internal/extensions/cli_commands_link.go b/internal/extensions/cli_commands_link.go index 005cd4e99..96616db5b 100644 --- a/internal/extensions/cli_commands_link.go +++ b/internal/extensions/cli_commands_link.go @@ -2501,7 +2501,11 @@ func cliAcceptsEveryObject(member *cliResolvedSchema) bool { for keyword, value := range member.raw { switch keyword { case "type": - if value != "object" { + if types, ok := value.([]any); ok { + if !slices.Contains(types, any("object")) { + return false + } + } else if value != "object" { return false } case "additionalProperties": diff --git a/internal/extensions/cli_commands_test.go b/internal/extensions/cli_commands_test.go index aab54f02e..35d093e17 100644 --- a/internal/extensions/cli_commands_test.go +++ b/internal/extensions/cli_commands_test.go @@ -4539,6 +4539,12 @@ paths: - $ref: '#/components/schemas/SvgFormat' - type: object additionalProperties: true + nullable: + anyOf: + - $ref: '#/components/schemas/PngFormat' + - $ref: '#/components/schemas/SvgFormat' + - type: [object, "null"] + additionalProperties: true empty: oneOf: - $ref: '#/components/schemas/PngFormat' @@ -4784,6 +4790,8 @@ commands: $.layered: logo: kind: png + $.nullable: + kind: png $.inclusive: kind: png $.empty: @@ -4844,6 +4852,7 @@ commands: "/composed": {Pointer: "/composed", Key: "mode", Values: []any{"fast"}}, "/composed/format": {Pointer: "/composed/format", Key: "kind", Values: []any{"png"}}, "/inclusive": {Pointer: "/inclusive", Key: "kind", Values: []any{"png"}}, + "/nullable": {Pointer: "/nullable", Key: "kind", Values: []any{"png"}}, "/wrapped": {Pointer: "/wrapped", Key: "kind", Values: []any{"png"}}, "/refined": {Pointer: "/refined", Key: "mode", Values: []any{"fast"}}, "/refined/format": {Pointer: "/refined/format", Key: "kind", Values: []any{"png"}}, From 01f4c7fda4c8fbb825104c61515d1611bb1f3924 Mon Sep 17 00:00:00 2001 From: 2ynn Date: Wed, 7 Oct 2026 13:03:14 -0400 Subject: [PATCH 08/11] fix(cli): reject readOnly nested intent binds and test preset keys as own properties - A flag bound beneath an object preset to a readOnly field is now a decode error, as a preset targeting a readOnly property already is. - Preset coverage checks used `in`, so a key named like an Object.prototype member (toString, constructor) counted as preset and a required input was treated as covered; they now check own properties. --- internal/extensions/cli_commands_link.go | 6 +++++- internal/extensions/cli_commands_test.go | 4 ++++ templates/templates/cli/README.md | 2 +- templates/templates/cli/includes/intents.ts | 13 +++++++++---- 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/internal/extensions/cli_commands_link.go b/internal/extensions/cli_commands_link.go index 87e0f09e6..97996c6a4 100644 --- a/internal/extensions/cli_commands_link.go +++ b/internal/extensions/cli_commands_link.go @@ -3481,7 +3481,8 @@ func (d *cliManifestDecoder) linkInput(cmdKey string, input *CLICommandInput, va } pointer := input.Bind.Pointer - if parent, leaf, nested := cliSplitNestedPointer(pointer); nested { + 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) } @@ -3497,6 +3498,9 @@ func (d *cliManifestDecoder) linkInput(cmdKey string, input *CLICommandInput, va 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) diff --git a/internal/extensions/cli_commands_test.go b/internal/extensions/cli_commands_test.go index 2aab283a4..b0488bfa4 100644 --- a/internal/extensions/cli_commands_test.go +++ b/internal/extensions/cli_commands_test.go @@ -4530,6 +4530,9 @@ paths: properties: dpi: type: integer + serial: + type: string + readOnly: true format: $ref: '#/components/schemas/Format' palette: @@ -4923,6 +4926,7 @@ func TestCLICommands_NestedPresetBindErrors(t *testing.T) { {"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`}, diff --git a/templates/templates/cli/README.md b/templates/templates/cli/README.md index 805db741d..ea4323dbb 100644 --- a/templates/templates/cli/README.md +++ b/templates/templates/cli/README.md @@ -768,7 +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), 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, 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. +- 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/includes/intents.ts b/templates/templates/cli/includes/intents.ts index 4a4d7d278..f3f0a19ec 100644 --- a/templates/templates/cli/includes/intents.ts +++ b/templates/templates/cli/includes/intents.ts @@ -287,6 +287,10 @@ 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 []; @@ -849,13 +853,14 @@ 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 } @@ -870,8 +875,8 @@ function collectIntentManifest(): IntentManifestCtx { path.length > 1 ? typeof presetParent === "object" && presetParent !== null && - path[1] in presetParent - : key in presetObj; + intentHasOwn(presetParent, path[1]) + : intentHasOwn(presetObj, key); flags.push({ Key: key, From ddbec03a210d2d6c5fd12b3893a9b8a7a7676c86 Mon Sep 17 00:00:00 2001 From: 2ynn Date: Wed, 7 Oct 2026 14:21:59 -0400 Subject: [PATCH 09/11] chore(cli): regenerate review CLI SDK --- zSDKs/sdk-cli/.speakeasy/gen.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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: From 9fb27173561bdfb868132b637f0c41a257dea80e Mon Sep 17 00:00:00 2001 From: 2ynn Date: Wed, 7 Oct 2026 14:21:59 -0400 Subject: [PATCH 10/11] test(cli): cover a nested intent flag merged into a body piped on stdin --- .../cli/tests/primary/intents_test.go.stmpl | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/templates/templates/cli/tests/primary/intents_test.go.stmpl b/templates/templates/cli/tests/primary/intents_test.go.stmpl index 35fe3d5dd..48a0fce49 100644 --- a/templates/templates/cli/tests/primary/intents_test.go.stmpl +++ b/templates/templates/cli/tests/primary/intents_test.go.stmpl @@ -545,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() From 694004c5ac0f570c8e9030d8dead56698c1c4600 Mon Sep 17 00:00:00 2001 From: 2ynn Date: Wed, 7 Oct 2026 15:02:29 -0400 Subject: [PATCH 11/11] fix(cli): reject a nested flag on a pinned discriminator when the union has one object member A union with a single object member (the others non-object) skipped the pinned-discriminator check, so a flag could overwrite the declared discriminator the preset pins and send an object matching no member. --- internal/extensions/cli_commands_link.go | 14 ++++++++++---- internal/extensions/cli_commands_test.go | 7 +++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/internal/extensions/cli_commands_link.go b/internal/extensions/cli_commands_link.go index 97996c6a4..9db65297f 100644 --- a/internal/extensions/cli_commands_link.go +++ b/internal/extensions/cli_commands_link.go @@ -3427,20 +3427,26 @@ func (d *cliManifestDecoder) nestedBindScope(cmdKey, what, parent, leaf string, } 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) } - if point.Key == cliPointerPropertyName(leaf) { - 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) - } + 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) } @@ -3448,7 +3454,7 @@ func (d *cliManifestDecoder) nestedBindScope(cmdKey, what, parent, leaf string, 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[cliPointerPropertyName(leaf)]; ok { + if value, ok := presetObj[field]; ok { leafPresets[leaf] = value } return scope, leafPresets, nil diff --git a/internal/extensions/cli_commands_test.go b/internal/extensions/cli_commands_test.go index b0488bfa4..9f95cb7a1 100644 --- a/internal/extensions/cli_commands_test.go +++ b/internal/extensions/cli_commands_test.go @@ -4638,6 +4638,12 @@ paths: - kind: png - preset: vector - type: string + mixed: + oneOf: + - $ref: '#/components/schemas/PngFormat' + - type: string + discriminator: + propertyName: kind listedFields: type: object properties: @@ -4923,6 +4929,7 @@ func TestCLICommands_NestedPresetBindErrors(t *testing.T) { {"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"`},