Skip to content
10 changes: 10 additions & 0 deletions .changesets/1790909966-deaee7f1.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
id: 1790909966-deaee7f1
features:
- core
targets:
- cli
type: feat
bump: patch
description: derive offline catalog defaults from command presets and support validated enum groups
author: TristanSpeakEasy
date: "2026-10-02"
8 changes: 8 additions & 0 deletions internal/ast/typedef.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ type Enum struct {
Open bool `yaml:",omitempty"`
Format string `yaml:",omitempty"` // Whether the enum is templated as a native enum or union of literals. If empty use language default
Descriptions map[string]string `yaml:",omitempty"`
Groups map[string]string `yaml:",omitempty"`
}

// Clone creates a deep copy of the Enum
Expand All @@ -151,6 +152,7 @@ func (e *Enum) Clone() *Enum {

return &Enum{
Descriptions: maps.Clone(e.Descriptions),
Groups: maps.Clone(e.Groups),
Format: e.Format,
Names: slices.Clone(e.Names),
Open: e.Open,
Expand Down Expand Up @@ -1341,6 +1343,9 @@ func (t *TypeDef) IsEqual(other *TypeDef, opts ...IsEqualOpt) error {
if !maps.Equal(t.Enum.Descriptions, other.Enum.Descriptions) {
return ErrEnumMismatch.Wrap(fmt.Errorf("expected descriptions %v in %s, got %v in %s", t.Enum.Descriptions, t.getNameOrType(), other.Enum.Descriptions, other.getNameOrType()))
}
if !maps.Equal(t.Enum.Groups, other.Enum.Groups) {
return ErrEnumMismatch.Wrap(fmt.Errorf("expected groups %v in %s, got %v in %s", t.Enum.Groups, t.getNameOrType(), other.Enum.Groups, other.getNameOrType()))
}
}
case DataTypeUnion:
if t.Discriminator == nil && other.Discriminator != nil || t.Discriminator != nil && other.Discriminator == nil {
Expand Down Expand Up @@ -1858,6 +1863,9 @@ func (t *TypeDef) JSON() map[string]any {
if len(t.Enum.Descriptions) > 0 {
enumRet["descriptions"] = t.Enum.Descriptions
}
if len(t.Enum.Groups) > 0 {
enumRet["groups"] = t.Enum.Groups
}
ret["enum"] = enumRet
}

Expand Down
9 changes: 9 additions & 0 deletions internal/ast/typedef_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ import (
"gopkg.in/yaml.v3"
)

func TestTypeDef_IsEqualEnumGroups(t *testing.T) {
original := &TypeDef{Type: DataTypeEnum, Enum: &Enum{Values: []string{"alpha"}, Groups: map[string]string{"alpha": "Primary"}}}
cloned := original.Clone()
require.NoError(t, original.IsEqual(cloned))
cloned.Enum.Groups["alpha"] = "Secondary"
require.Equal(t, "Primary", original.Enum.Groups["alpha"])
require.Error(t, original.IsEqual(cloned))
}

func TestDiscriminatorMappings_Clone(t *testing.T) {
t.Parallel()

Expand Down
113 changes: 113 additions & 0 deletions internal/extensions/cli_catalog_defaults.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package extensions

import (
"fmt"
"reflect"
"slices"
"strings"
)

func (d *cliManifestDecoder) catalogDefaultsForPresets(presets []CLICommandPreset, variant *cliResolvedSchema) ([]CLICatalogDefault, error) {
var defaults []CLICatalogDefault
for _, preset := range presets {
schema, found := variant.properties[cliPointerPropertyName(preset.Bind.Pointer)]
if !found {
continue
}
facts, err := d.propertyFacts(schema, nil)
if err != nil {
return nil, err
}
values, err := d.catalogDefaultsForValue(preset.Value, facts)
if err != nil {
return nil, err
}
for _, value := range values {
if !slices.Contains(defaults, value) {
defaults = append(defaults, value)
}
}
}
return defaults, nil
}

func (d *cliManifestDecoder) catalogDefaultsForValue(value any, facts *cliPropertyFacts) ([]CLICatalogDefault, error) {
if len(facts.unionArms) > 0 {
var defaults []CLICatalogDefault
matched := false
for _, arm := range facts.unionArms {
armFacts, err := d.propertyFacts(arm, nil)
if err != nil {
return nil, err
}
if d.checkPresetValueStrict("", "", value, armFacts) != nil {
continue
}
candidate, err := d.catalogDefaultsForValue(value, armFacts)
if err != nil {
return nil, err
}
if len(candidate) == 0 {
continue
}
if matched && !reflect.DeepEqual(defaults, candidate) {
return nil, nil
}
defaults = candidate
matched = true
}
return defaults, nil
}

if items, ok := value.([]any); ok && facts.items != nil {
var defaults []CLICatalogDefault
for _, item := range items {
values, err := d.catalogDefaultsForValue(item, facts.items)
if err != nil {
return nil, err
}
for _, value := range values {
if !slices.Contains(defaults, value) {
defaults = append(defaults, value)
}
}
}
return defaults, nil
}

for _, member := range facts.enum {
if member == nil || !cliValuesEqual(value, member) {
continue
}
var defaults []CLICatalogDefault
for _, command := range facts.catalogCommands {
defaults = append(defaults, CLICatalogDefault{CatalogCommand: command, Value: fmt.Sprint(member)})
}
return defaults, nil
}
return nil, nil
}

func cliLabelCatalogDefaults(cmd *CLICommand, routes [][]CLICatalogDefault) []CLICatalogDefault {
var defaults []CLICatalogDefault
command := strings.Join(cmd.Path, " ")
for i, values := range routes {
for _, value := range values {
shared := true
for _, other := range routes {
if !slices.Contains(other, value) {
shared = false
break
}
}
value.Label = command
if !shared {
value.Label += " --" + cmd.Source.Routes[i].Selector
}
if !slices.Contains(defaults, value) {
defaults = append(defaults, value)
}
}
}
return defaults
}
120 changes: 120 additions & 0 deletions internal/extensions/cli_catalog_defaults_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package extensions

import (
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

const cliCatalogPresetSpec = `openapi: 3.1.0
info: {title: Catalog preset test, version: 1.0.0}
paths:
/widgets:
post:
operationId: createWidget
requestBody:
content:
application/json:
schema:
type: object
properties:
choice: {$ref: '#/components/schemas/ChoiceAlias'}
choices:
type: array
items: {$ref: '#/components/schemas/Choices'}
unrelated: {$ref: '#/components/schemas/UnrelatedChoices'}
responses:
'200': {description: OK}
components:
schemas:
ChoiceAlias:
allOf:
- $ref: '#/components/schemas/Choices'
Choices:
type: string
enum: [alpha, beta]
x-speakeasy-unknown-values: allow
x-speakeasy-cli-catalog: {command: choices}
UnrelatedChoices:
type: string
enum: [alpha, beta]
x-speakeasy-cli-catalog: {command: unrelated-choices}
`

func TestCLICatalogDefaults_LinkedPresets(t *testing.T) {
for _, tt := range []struct {
name string
preset string
want []CLICatalogDefault
}{
{name: "reference and composition", preset: "{$.choice: alpha}", want: []CLICatalogDefault{{CatalogCommand: "choices", Value: "alpha", Label: "create"}}},
{name: "array", preset: "{$.choices: [beta, alpha, beta]}", want: []CLICatalogDefault{{CatalogCommand: "choices", Value: "beta", Label: "create"}, {CatalogCommand: "choices", Value: "alpha", Label: "create"}}},
{name: "promoted array", preset: "{$.choices: beta}", want: []CLICatalogDefault{{CatalogCommand: "choices", Value: "beta", Label: "create"}}},
{name: "unknown open enum value", preset: "{$.choice: future}"},
{name: "separate same-valued enum", preset: "{$.unrelated: alpha}", want: []CLICatalogDefault{{CatalogCommand: "unrelated-choices", Value: "alpha", Label: "create"}}},
} {
t.Run(tt.name, func(t *testing.T) {
manifest, _, err := decodeCLIWithSpec(t, cliCatalogPresetSpec, "version: 1\ncommands:\n create:\n op: createWidget\n preset: "+tt.preset+"\n")
require.NoError(t, err)
require.Len(t, manifest.Commands, 1)
assert.Equal(t, tt.want, manifest.Commands[0].CatalogDefaults)
})
}
}

func TestCLICatalogDefaults_EffectiveRoutePresets(t *testing.T) {
spec := strings.ReplaceAll(cliRouteDispatchSpec, " background:\n", " choice: {$ref: '#/components/schemas/Choices'}\n background:\n")
spec += ` Choices:
type: string
enum: [alpha, beta]
x-speakeasy-cli-catalog: {command: choices}
`
for _, tt := range []struct {
name string
routePreset string
want []CLICatalogDefault
}{
{name: "shared", want: []CLICatalogDefault{{CatalogCommand: "choices", Value: "alpha", Label: "jobs run"}}},
{name: "route override", routePreset: " preset: {$.choice: beta}\n", want: []CLICatalogDefault{{CatalogCommand: "choices", Value: "alpha", Label: "jobs run --engine"}, {CatalogCommand: "choices", Value: "beta", Label: "jobs run --pipeline"}}},
} {
t.Run(tt.name, func(t *testing.T) {
manifestYAML := strings.Replace(cliRouteDispatchManifest, " op: createJob#PipelineJobParams\n", " op: createJob#PipelineJobParams\n"+tt.routePreset, 1)
manifestYAML += " preset: {$.choice: alpha}\n"
manifest, _, err := decodeCLIWithSpec(t, spec, manifestYAML)
require.NoError(t, err)
assert.Equal(t, tt.want, manifest.Commands[0].CatalogDefaults)
})
}
}

func TestCLICatalogDefaults_PartialRouteCoverage(t *testing.T) {
spec := strings.Replace(cliRouteDispatchSpec, " description: Engine selection.\n", " description: Engine selection.\n x-speakeasy-cli-catalog: {command: engines}\n", 1)
manifestYAML := strings.Replace(cliRouteDispatchManifest, " selector: engine\n", " selector: engine\n preset: {$.engine: text-2}\n", 1)
manifest, _, err := decodeCLIWithSpec(t, spec, manifestYAML)
require.NoError(t, err)
assert.Equal(t, []CLICatalogDefault{{CatalogCommand: "engines", Value: "text-2", Label: "jobs run --engine"}}, manifest.Commands[0].CatalogDefaults)
}

func TestCLICatalogDefaults_UnionAssociation(t *testing.T) {
d := &cliManifestDecoder{}
catalogArm := map[string]any{"type": "string", "enum": []any{"alpha"}, "x-speakeasy-cli-catalog": map[string]any{"command": "choices"}}
for _, tt := range []struct {
name string
other map[string]any
want []CLICatalogDefault
}{
{name: "unique matching arm", other: map[string]any{"type": "string", "enum": []any{"beta"}}, want: []CLICatalogDefault{{CatalogCommand: "choices", Value: "alpha"}}},
{name: "plain string arm", other: map[string]any{"type": "string"}, want: []CLICatalogDefault{{CatalogCommand: "choices", Value: "alpha"}}},
{name: "ambiguous matching arms", other: map[string]any{"type": "string", "enum": []any{"alpha"}, "x-speakeasy-cli-catalog": map[string]any{"command": "other-choices"}}},
} {
t.Run(tt.name, func(t *testing.T) {
facts, err := d.propertyFacts(map[string]any{"oneOf": []any{catalogArm, tt.other}}, nil)
require.NoError(t, err)
defaults, err := d.catalogDefaultsForValue("alpha", facts)
require.NoError(t, err)
assert.Equal(t, tt.want, defaults)
})
}
}
39 changes: 23 additions & 16 deletions internal/extensions/cli_commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -309,24 +309,31 @@ type CLICommandHelp struct {
Escalate string `json:"escalate,omitempty" yaml:"escalate,omitempty"`
}

type CLICatalogDefault struct {
CatalogCommand string `json:"catalogCommand" yaml:"catalogCommand"`
Value string `json:"value" yaml:"value"`
Label string `json:"label" yaml:"label"`
}

// CLICommand is one declared intent command.
type CLICommand struct {
ID string `json:"id" yaml:"id"`
Path []string `json:"path" yaml:"path"`
Category string `json:"category" yaml:"category"`
Summary string `json:"summary" yaml:"summary"`
Tagline string `json:"tagline" yaml:"tagline"`
Description string `json:"description" yaml:"description"`
Source CLICommandSource `json:"source" yaml:"source"`
Args []CLICommandInput `json:"args" yaml:"args"`
Flags []CLICommandInput `json:"flags" yaml:"flags"`
Presets []CLICommandPreset `json:"presets" yaml:"presets"`
Async *CLICommandAsync `json:"async,omitempty" yaml:"async,omitempty"`
Output *CLICommandOutput `json:"output" yaml:"output"`
Examples []CLICommandExample `json:"examples" yaml:"examples"`
Help *CLICommandHelp `json:"help,omitempty" yaml:"help,omitempty"`
Override bool `json:"override,omitempty" yaml:"override,omitempty"`
DispatchKeys []CLICommandDispatchKey `json:"dispatchKeys,omitempty" yaml:"dispatchKeys,omitempty"`
ID string `json:"id" yaml:"id"`
Path []string `json:"path" yaml:"path"`
Category string `json:"category" yaml:"category"`
Summary string `json:"summary" yaml:"summary"`
Tagline string `json:"tagline" yaml:"tagline"`
Description string `json:"description" yaml:"description"`
Source CLICommandSource `json:"source" yaml:"source"`
Args []CLICommandInput `json:"args" yaml:"args"`
Flags []CLICommandInput `json:"flags" yaml:"flags"`
Presets []CLICommandPreset `json:"presets" yaml:"presets"`
CatalogDefaults []CLICatalogDefault `json:"catalogDefaults,omitempty" yaml:"catalogDefaults,omitempty"`
Async *CLICommandAsync `json:"async,omitempty" yaml:"async,omitempty"`
Output *CLICommandOutput `json:"output" yaml:"output"`
Examples []CLICommandExample `json:"examples" yaml:"examples"`
Help *CLICommandHelp `json:"help,omitempty" yaml:"help,omitempty"`
Override bool `json:"override,omitempty" yaml:"override,omitempty"`
DispatchKeys []CLICommandDispatchKey `json:"dispatchKeys,omitempty" yaml:"dispatchKeys,omitempty"`
// Hints maps an error reason to agent-mode hint lines that are merged
// into the reason-first error envelope. CLI_* reasons are the closed
// namespace the generated runtime itself produces; external (server)
Expand Down
Loading
Loading