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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changesets/1791330588-22ed09b7.yaml
Original file line number Diff line number Diff line change
@@ -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"
23 changes: 20 additions & 3 deletions internal/extensions/cli_commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 ($.<preset>.<field>), 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))
Expand Down Expand Up @@ -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
}

Expand Down
80 changes: 77 additions & 3 deletions internal/extensions/cli_commands_link.go
Original file line number Diff line number Diff line change
Expand Up @@ -799,6 +799,9 @@ func (d *cliManifestDecoder) checkDispatchValueDiscrimination(cmdKey string, mem
// intent bindings: the property must exist with the same scalar type in every
// member so the flag cannot silently select or invalidate a variant.
func (d *cliManifestDecoder) linkOperationFlag(opID string, input *CLICommandInput, bodySchema any) error {
if _, _, nested := cliSplitNestedPointer(input.Bind.Pointer); nested {
return fmt.Errorf("operation %q flag %q binds %s; operation flags bind top-level body fields only (nested binds are for intent commands with an object preset)", opID, input.Name, input.Bind.Pointer)
}
bodyMap, _ := bodySchema.(map[string]any)
unionMap, _ := d.unionBodySchema(bodyMap)
rawVariants := cliUnionMembers(unionMap)
Expand Down Expand Up @@ -1311,6 +1314,9 @@ func (d *cliManifestDecoder) linkDispatchInput(cmdKey string, input *CLICommandI
if isArg {
what = "arg"
}
if _, _, nested := cliSplitNestedPointer(input.Bind.Pointer); nested {
return fmt.Errorf("command %q: %s %q binds %s; nested binds are not part of route dispatch (routes: commands bind top-level body fields only)", cmdKey, what, input.Name, input.Bind.Pointer)
}
var declaring []cliDispatchInputFacts
for i := range links {
link := &links[i]
Expand Down Expand Up @@ -3403,6 +3409,57 @@ func (d *cliManifestDecoder) lookupProperty(cmdKey, what, pointer string, varian
return facts, true, nil
}

func cliSplitNestedPointer(pointer string) (parent, leaf string, ok bool) {
head, tail, found := strings.Cut(strings.TrimPrefix(pointer, "/"), "/")
if !found {
return "", "", false
}
return "/" + head, "/" + tail, true
}

// nestedBindScope resolves the object a nested flag bind writes into: the
// property at parent, or the member of its union the object preset selects.
func (d *cliManifestDecoder) nestedBindScope(cmdKey, what, parent, leaf string, variant *cliResolvedSchema, presetValues map[string]any) (*cliResolvedSchema, map[string]any, error) {
pointer := parent + leaf
presetObj, ok := presetValues[parent].(map[string]any)
if !ok {
return nil, nil, fmt.Errorf("command %q %s %s nests beneath %s, which no object preset sets; a nested bind targets a field of an object preset (preset: {$.%s: {...}})", cmdKey, what, pointer, parent, cliPointerPropertyName(parent))
}
flat := newCLIFlatUnion()
d.flattenUnion(variant.properties[cliPointerPropertyName(parent)], 0, nil, flat)
field := cliPointerPropertyName(leaf)
var scope *cliResolvedSchema
pinned := ""
switch {
case len(flat.members) == 1:
Comment thread
2ynn marked this conversation as resolved.
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)
Comment thread
2ynn marked this conversation as resolved.
if member == nil {
return nil, nil, fmt.Errorf("command %q %s %s: the preset at %s does not select exactly one member of its union, so the field cannot be resolved; pin the member's discriminator in the preset", cmdKey, what, pointer, parent)
}
pinned = point.Key
scope = member
}
if pinned != "" && pinned == field {
return nil, nil, fmt.Errorf("command %q %s %s targets the discriminator of the union at %s, which the preset pins; a flag there would switch members", cmdKey, what, pointer, parent)
}
if scope == nil {
return nil, nil, fmt.Errorf("command %q %s %s nests beneath %s, which is not an object property", cmdKey, what, pointer, parent)
}
if scope.enumerated {
return nil, nil, fmt.Errorf("command %q %s %s nests beneath %s, whose schema restricts the object to const/enum values; a field set by a flag would match none of them", cmdKey, what, pointer, parent)
}
leafPresets := map[string]any{}
if value, ok := presetObj[field]; ok {
leafPresets[leaf] = value
}
return scope, leafPresets, nil
}

func cliPointerPropertyName(pointer string) string {
name := strings.TrimPrefix(pointer, "/")
name = strings.ReplaceAll(name, "~1", "/")
Expand All @@ -3429,10 +3486,27 @@ func (d *cliManifestDecoder) linkInput(cmdKey string, input *CLICommandInput, va
return fmt.Errorf("command %q arg %q resolves to type %s; the variadic positional joins argv into a single string, so only string bindings are part of v1 (declare a flag instead)", cmdKey, input.Name, input.Type)
}

facts, found, err := d.lookupProperty(cmdKey, what, input.Bind.Pointer, variant)
pointer := input.Bind.Pointer
parent, leaf, nested := cliSplitNestedPointer(pointer)
if nested {
if isArg {
return fmt.Errorf("command %q arg %q binds %s; the positional binds a top-level body field (declare a flag for a nested field)", cmdKey, input.Name, pointer)
}
scope, leafPresets, err := d.nestedBindScope(cmdKey, what, parent, leaf, variant, presetValues)
if err != nil {
return err
}
variant, presetValues, pointer = scope, leafPresets, leaf
Comment thread
2ynn marked this conversation as resolved.
what = fmt.Sprintf("flag %q bind beneath %s:", input.Name, parent)
}

facts, found, err := d.lookupProperty(cmdKey, what, pointer, variant)
if err != nil {
return err
}
if nested && found && facts.readOnly {
return fmt.Errorf("command %q %s %s targets a readOnly property; the server would reject or ignore it", cmdKey, what, input.Bind.Pointer)
}
if !found {
if input.Type == "" {
return fmt.Errorf("command %q %s %s targets an undeclared property; declare an explicit type: (string, int, float, or bool)", cmdKey, what, input.Bind.Pointer)
Expand All @@ -3442,7 +3516,7 @@ func (d *cliManifestDecoder) linkInput(cmdKey string, input *CLICommandInput, va
return fmt.Errorf("command %q %s %s suggestions: %w", cmdKey, what, input.Bind.Pointer, err)
}
}
if value, ok := presetValues[input.Bind.Pointer]; ok && !isArg && cliIsPresetDefault(value, input.Type) {
if value, ok := presetValues[pointer]; ok && !isArg && cliIsPresetDefault(value, input.Type) {
input.DefaultFrom = "preset"
input.Default = value
}
Expand Down Expand Up @@ -3518,7 +3592,7 @@ func (d *cliManifestDecoder) linkInput(cmdKey string, input *CLICommandInput, va

// Display-only defaults. A preset at the same pointer is what the request
// carries when the flag is omitted, so it is the default help shows.
presetValue, presetCovered := presetValues[input.Bind.Pointer]
presetValue, presetCovered := presetValues[pointer]
switch {
case isArg:
case presetCovered && cliIsPresetDefault(presetValue, input.Type):
Expand Down
111 changes: 110 additions & 1 deletion internal/extensions/cli_commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -4522,6 +4530,9 @@ paths:
properties:
dpi:
type: integer
serial:
type: string
readOnly: true
format:
$ref: '#/components/schemas/Format'
palette:
Expand Down Expand Up @@ -4627,6 +4638,23 @@ paths:
- kind: png
- preset: vector
- type: string
mixed:
oneOf:
- $ref: '#/components/schemas/PngFormat'
- type: string
discriminator:
propertyName: kind
listedFields:
type: object
properties:
kind:
type: string
width:
type: integer
enum:
- kind: png
- kind: svg
width: 10
listedUnion:
enum:
- kind: png
Expand Down Expand Up @@ -4859,6 +4887,87 @@ commands:
}, got)
}

func TestCLICommands_NestedPresetBinds(t *testing.T) {
manifest, err := decodeCLITestSpec(t, cliNestedPresetSpec, `
version: 1
commands:
draw:
op: CreateDrawing
preset:
$.format:
kind: png
$.mapped:
kind: png
width: 10
$.plain:
kind: png
flags:
width: {to: $.format.width}
mapped-width: {to: $.mapped.width}
plain-width: {to: $.plain.width}
`)
require.NoError(t, err)
flags := map[string]CLICommandInput{}
for _, flag := range manifest.Commands[0].Flags {
flags[flag.Name] = flag
}
assert.Equal(t, "/format/width", flags["width"].Bind.Pointer)
assert.Equal(t, "int", flags["width"].Type)
assert.Nil(t, flags["width"].Default)
assert.Equal(t, "int", flags["mapped-width"].Type)
assert.Equal(t, "preset", flags["mapped-width"].DefaultFrom)
assert.EqualValues(t, 10, flags["mapped-width"].Default)
assert.Equal(t, "int", flags["plain-width"].Type)
}

func TestCLICommands_NestedPresetBindErrors(t *testing.T) {
cases := []struct {
name string
input string
want string
}{
{"no preset", "flags:\n dpi: {to: $.config.dpi}", `flag "dpi" bind /config/dpi nests beneath /config, which no object preset sets`},
{"scalar preset", "preset:\n $.prompt: hello\n flags:\n x: {to: $.prompt.x}", `flag "x" bind /prompt/x nests beneath /prompt, which no object preset sets`},
{"discriminator", "preset:\n $.format: {kind: png}\n flags:\n kind: {to: $.format.kind}", `targets the discriminator of the union at /format, which the preset pins`},
{"discriminator of a single object member", "preset:\n $.mixed: {kind: png}\n flags:\n kind: {to: $.mixed.kind, type: string}", `targets the discriminator of the union at /mixed, which the preset pins`},
{"no unique member", "preset:\n $.source: {url: https://example.com/a}\n flags:\n url: {to: $.source.url}", `the preset at /source does not select exactly one member of its union`},
{"other member field", "preset:\n $.format: {kind: png}\n flags:\n scale: {to: $.format.scale}", `flag "scale" bind beneath /format: /scale does not resolve in PngFormat`},
{"typo", "preset:\n $.format: {kind: png}\n flags:\n width: {to: $.format.widht}", `did you mean "width"`},
{"readOnly field", "preset:\n $.config: {dpi: 72}\n flags:\n serial: {to: $.config.serial}", `flag "serial" bind beneath /config: /config/serial targets a readOnly property`},
{"const/enum object", "preset:\n $.listedFields: {kind: png}\n flags:\n width: {to: $.listedFields.width}", `flag "width" bind /listedFields/width nests beneath /listedFields, whose schema restricts the object to const/enum values`},
{"positional", "preset:\n $.format: {kind: png}\n args:\n w: {to: $.format.width, variadic: true, type: string}", `arg "w" binds /format/width; the positional binds a top-level body field`},
{"parent flag first", "preset:\n $.format: {kind: png}\n flags:\n format: {to: $.format, type: string}\n width: {to: $.format.width}", `input "format" binds /format and input "width" binds /format/width beneath it`},
{"nested flag first", "preset:\n $.format: {kind: png}\n flags:\n width: {to: $.format.width}\n format: {to: $.format, type: string}", `input "format" binds /format and input "width" binds /format/width beneath it`},
{"positional parent", "preset:\n $.format: {kind: png}\n args:\n format: {to: $.format, variadic: true, type: string}\n flags:\n width: {to: $.format.width}", `input "format" binds /format and input "width" binds /format/width beneath it`},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
_, err := decodeCLITestSpec(t, cliNestedPresetSpec, "\nversion: 1\ncommands:\n draw:\n op: CreateDrawing\n "+testCase.input+"\n")
require.Error(t, err)
assert.Contains(t, err.Error(), testCase.want)
})
}
}

func TestCLICommands_NestedBindRejectedOnRouteDispatch(t *testing.T) {
formatProp := " format:\n oneOf:\n - {$ref: '#/components/schemas/PngFormat'}\n - {$ref: '#/components/schemas/SvgFormat'}\n"
spec := strings.ReplaceAll(cliRouteDispatchSpec, " background:\n", formatProp+" background:\n") + ` PngFormat:
type: object
properties:
kind: {const: png}
width: {type: integer}
SvgFormat:
type: object
properties:
kind: {const: svg}
scale: {type: number}
`
manifestYAML := strings.Replace(cliRouteDispatchManifest, " args:\n", " preset:\n $.format: {kind: png}\n args:\n", 1) + " width: {to: $.format.width}\n"
_, err := decodeCLITestSpec(t, spec, manifestYAML)
require.Error(t, err)
assert.Contains(t, err.Error(), `flag "width" binds /format/width; nested binds are not part of route dispatch`)
}

func TestCLICommands_InputNamePatternHyphens(t *testing.T) {
for _, name := range []string{"a", "a1", "a-b", "a-b-c", "a1-2b", "engine-mode"} {
assert.True(t, cliInputNamePattern.MatchString(name), name)
Expand Down
Loading
Loading