From 2627f2d85a303d856280af7ea2c61a8983743418 Mon Sep 17 00:00:00 2001 From: Tristan Cartledge Date: Fri, 2 Oct 2026 13:10:20 +1000 Subject: [PATCH 1/7] feat(cli): add catalog command defaults and groups --- .changesets/1790909966-deaee7f1.yaml | 10 ++ templates/templates/cli/README.md | 27 ++++ templates/templates/cli/catalog.go.stmpl | 17 ++- .../templates/cli/includes/templating.ts | 82 ++++++++++- .../cli/tests/primary/catalog_test.go.stmpl | 132 +++++++++++++++--- .../primary/cli-catalog-options.yaml | 124 ++++++++++++++++ 6 files changed, 368 insertions(+), 24 deletions(-) create mode 100644 .changesets/1790909966-deaee7f1.yaml create mode 100644 tests/specs/fragments/primary/cli-catalog-options.yaml diff --git a/.changesets/1790909966-deaee7f1.yaml b/.changesets/1790909966-deaee7f1.yaml new file mode 100644 index 00000000..ad994aa6 --- /dev/null +++ b/.changesets/1790909966-deaee7f1.yaml @@ -0,0 +1,10 @@ +id: 1790909966-deaee7f1 +features: + - core +targets: + - cli +type: feat +bump: patch +description: support per-command defaults and groups in offline catalogs +author: TristanSpeakEasy +date: "2026-10-02" diff --git a/templates/templates/cli/README.md b/templates/templates/cli/README.md index d7d2a8c9..cc491e06 100644 --- a/templates/templates/cli/README.md +++ b/templates/templates/cli/README.md @@ -20,6 +20,7 @@ Generates a fully functional Go CLI from an OpenAPI specification. The generated - [Flag Metadata Generation](#flag-metadata-generation) - [Union Type Handling](#union-type-handling) - [Declarative Intent Commands and Route Dispatch](#declarative-intent-commands-and-route-dispatch) + - [Offline Enum Catalogs](#offline-enum-catalogs) - [Output Formatting](#output-formatting) - [Interactive Mode](#interactive-mode) - [Agent Mode](#agent-mode) @@ -576,6 +577,32 @@ Dispatch commands never register backing request-body metadata: no whole-union J `override: true` replaces only the generated registration at the operation's exact canonical command path. The command must target that same operation, cover every component member, and replace a non-promoted leaf. The operation file is still generated so the intent reuses its metadata and run function. Runtime registration, KDL usage, generated README command trees and examples all omit the generated operation entry and insert the intent in manifest order; generated Arazzo tests that still encode the removed flag surface are skipped, while dedicated intent tests cover the replacement. Without `override`, the generated operation remains available as the full-control escape. +### Offline Enum Catalogs + +**Files**: `includes/templating.ts` (`collectCliCatalogs`), `catalog.go.stmpl`, and `main.ts` + +An enum schema annotated with `x-speakeasy-cli-catalog` generates an offline listing command. The extension accepts `command`, `summary`, `description`, and a scalar `default`, plus optional per-command defaults and ordered groups: + +```yaml +x-speakeasy-cli-catalog: + command: widget-options + summary: List available widget options + default: alpha + defaults: + alpha: [create-widget, inspect-widget] + gamma: render-widget + groups: + - title: Primary + values: [beta, alpha] + - title: Secondary + values: [gamma] +``` + +- `defaults` maps enum values to a command name or an array of command names. These are catalog annotations, not changes to command presets. Non-empty command defaults produce the label ` (default: , ...)`; otherwise the scalar catalog default produces ` (default)`. Only the scalar extension `default` sets the machine-output `default` boolean, with no fallback to the schema's `default`. +- Non-empty `groups` produces titled sections in declaration order, with values in each group's declared order. Unknown enum references are ignored and repeated references retain only their first occurrence. Remaining enum values appear in a trailing `Other` section in their original schema order. Each group requires a non-blank string title and a values array; malformed group definitions are skipped. Valid empty groups retain their headings. +- Grouped human output uses `:` headings, two-space item indentation, and one blank line between groups. Omitted or empty groups keep the flat, unindented output. Catalogs with non-empty `defaults` or `groups` use a label width of `max(42, longest label length + 2)`, followed by a separator space. Catalogs without these options retain the original fixed width of 42, including legacy long-label spacing. +- Machine output remains a flat array in the same order as human output. Every object retains `value`, `description`, and the boolean `default`. `default_for` is emitted only for non-empty command defaults; `group` is emitted only for grouped catalogs, including the `Other` remainder. Malformed command-default entries are ignored; arrays retain only non-blank strings. + ### Output Formatting **Files**: `auxiliary/internal/output/` diff --git a/templates/templates/cli/catalog.go.stmpl b/templates/templates/cli/catalog.go.stmpl index 4cb6ac27..d4e165a8 100644 --- a/templates/templates/cli/catalog.go.stmpl +++ b/templates/templates/cli/catalog.go.stmpl @@ -46,15 +46,28 @@ func new{{.FuncName}}CatalogCmd() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { values := []map[string]interface{}{ {{- range .Values}} - {"value": "{{escapeGoString .Value}}", "description": "{{escapeGoString .Description}}", "default": {{if .IsDefault}}true{{else}}false{{end}}}, + {"value": "{{escapeGoString .Value}}", "description": "{{escapeGoString .Description}}", "default": {{if .IsDefault}}true{{else}}false{{end}}{{if .DefaultFor}}, "default_for": []string{ {{range .DefaultFor}}"{{escapeGoString .}}", {{end}}}{{end}}{{if .Group}}, "group": "{{escapeGoString .Group}}"{{end}}}, {{- end}} } if output.IsMachineMode(cmd) { return output.LocalResult(cmd, values) } out := cmd.OutOrStdout() + {{- $width := .ColWidth}} + {{- if .Groups}} + {{- range $index, $group := .Groups}} + {{- if $index}} + fmt.Fprintln(out) + {{- end}} + fmt.Fprintln(out, "{{escapeGoString .Title}}:") {{- range .Values}} - fmt.Fprintf(out, "%-42s %s\n", "{{escapeGoString .Value}}{{if .IsDefault}} (default){{end}}", "{{escapeGoString .Description}}") + fmt.Fprintf(out, " %-{{$width}}s %s\n", "{{escapeGoString .Label}}", "{{escapeGoString .Description}}") + {{- end}} + {{- end}} + {{- else}} + {{- range .Values}} + fmt.Fprintf(out, "%-{{$width}}s %s\n", "{{escapeGoString .Label}}", "{{escapeGoString .Description}}") + {{- end}} {{- end}} return nil }, diff --git a/templates/templates/cli/includes/templating.ts b/templates/templates/cli/includes/templating.ts index c3775055..3569aa53 100644 --- a/templates/templates/cli/includes/templating.ts +++ b/templates/templates/cli/includes/templating.ts @@ -622,6 +622,9 @@ interface CliCatalogValue { Value: string; Description: string; IsDefault: boolean; + DefaultFor: string[]; + Group: string; + Label: string; } interface CliCatalog { @@ -630,6 +633,8 @@ interface CliCatalog { Summary: string; Description: string; Values: CliCatalogValue[]; + Groups: { Title: string; Values: CliCatalogValue[] }[]; + ColWidth: number; } /** @@ -673,23 +678,96 @@ function collectCliCatalogs(): CliCatalog[] { byFuncName.set(funcName, `${ext.command}`); const defaultValue = ext.default !== undefined ? `${ext.default}` : ""; + const defaults = + ext.defaults && + typeof ext.defaults === "object" && + !Array.isArray(ext.defaults) + ? ext.defaults + : {}; const values: CliCatalogValue[] = (t.Enum.Values || []).map( (v: string) => { const description = t.Enum.Descriptions?.[v]; + const declared = Object.prototype.hasOwnProperty.call(defaults, v) + ? defaults[v] + : undefined; + const defaultFor = + typeof declared === "string" + ? [declared].filter((name) => name.trim().length > 0) + : Array.isArray(declared) + ? declared.filter( + (name: any) => + typeof name === "string" && name.trim().length > 0, + ) + : []; + const isDefault = defaultValue !== "" && v === defaultValue; return { Value: v, Description: typeof description === "string" ? description : "", - IsDefault: defaultValue !== "" && v === defaultValue, + IsDefault: isDefault, + DefaultFor: defaultFor, + Group: "", + Label: + defaultFor.length > 0 + ? `${v} (default: ${defaultFor.join(", ")})` + : isDefault + ? `${v} (default)` + : v, }; }, ); if (values.length === 0) continue; + const groups: CliCatalog["Groups"] = []; + const hasGroups = Array.isArray(ext.groups) && ext.groups.length > 0; + if (hasGroups) { + const byValue = new Map( + values.map((value) => [value.Value, value]), + ); + const grouped = new Set<string>(); + for (const group of ext.groups) { + if ( + !group || + typeof group.title !== "string" || + !group.title.trim() || + !Array.isArray(group.values) + ) + continue; + const groupValues: CliCatalogValue[] = []; + for (const name of group.values) { + const value = byValue.get(name); + if (!value || grouped.has(name)) continue; + grouped.add(name); + value.Group = group.title; + groupValues.push(value); + } + groups.push({ Title: group.title, Values: groupValues }); + } + const remaining = values.filter( + (value) => !grouped.has(value.Value), + ); + if (remaining.length > 0) { + for (const value of remaining) value.Group = "Other"; + groups.push({ Title: "Other", Values: remaining }); + } + } + const hasDefaults = Object.keys(defaults).length > 0; catalogs.push({ Command: sanitizeCLICommand(`${ext.command}`), FuncName: sanitizeClassName(`${ext.command}`), Summary: `${ext.summary || `List available ${ext.command}`}`, Description: `${ext.description || ""}`, - Values: values, + Values: hasGroups + ? groups.flatMap((group) => group.Values) + : values, + Groups: groups, + ColWidth: + hasDefaults || hasGroups + ? Math.max( + 42, + ...values.map( + (value) => Array.from(value.Label).length + 2, + ), + ) + : 42, }); } } diff --git a/templates/templates/cli/tests/primary/catalog_test.go.stmpl b/templates/templates/cli/tests/primary/catalog_test.go.stmpl index 4ff56444..78f6df9d 100644 --- a/templates/templates/cli/tests/primary/catalog_test.go.stmpl +++ b/templates/templates/cli/tests/primary/catalog_test.go.stmpl @@ -2,41 +2,133 @@ package tests import ( "encoding/json" + "fmt" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +var catalogOutputCases = []struct { + command string + human string + json string +}{ + { + command: "catalog-legacy", + human: fmt.Sprintf("%-42s %s\n%-42s %s\n", + "catalog-value-with-a-name-longer-than-forty-two-characters (default)", "Long option.", + "beta", "Second option."), + json: `[{"default":true,"description":"Long option.","value":"catalog-value-with-a-name-longer-than-forty-two-characters"},{"default":false,"description":"Second option.","value":"beta"}]`, + }, + { + command: "catalog-empty", + human: fmt.Sprintf("%-42s %s\n%-42s %s\n", + "alpha (default)", "First option.", "beta", "Second option."), + json: `[ + {"default":true,"description":"First option.","value":"alpha"}, + {"default":false,"description":"Second option.","value":"beta"} + ]`, + }, + { + command: "catalog-defaults", + human: fmt.Sprintf("%-48s %s\n%-48s %s\n%-48s %s\n%-48s %s\n%-48s %s\n", + "alpha (default: create-widget, inspect-widget)", "First option.", + "beta", "Second option.", "gamma (default: render)", "Third option.", + "delta", "Fourth option.", "epsilon", "Fifth option."), + json: `[ + {"default":false,"default_for":["create-widget","inspect-widget"],"description":"First option.","value":"alpha"}, + {"default":false,"description":"Second option.","value":"beta"}, + {"default":false,"default_for":["render"],"description":"Third option.","value":"gamma"}, + {"default":false,"description":"Fourth option.","value":"delta"}, + {"default":false,"description":"Fifth option.","value":"epsilon"} + ]`, + }, + { + command: "catalog-groups", + human: "Primary \"Widgets\":\n" + fmt.Sprintf(" %-42s %s\n %-42s %s\n", + "beta", "Second option.", "alpha", "First option.") + + "\nSecondary:\n" + fmt.Sprintf(" %-42s %s\n", "gamma", "Third option.") + + "\nOther:\n" + fmt.Sprintf(" %-42s %s\n %-42s %s\n", + "delta (default)", "Fourth option.", "epsilon", "Fifth option."), + json: `[ + {"default":false,"description":"Second option.","group":"Primary \"Widgets\"","value":"beta"}, + {"default":false,"description":"First option.","group":"Primary \"Widgets\"","value":"alpha"}, + {"default":false,"description":"Third option.","group":"Secondary","value":"gamma"}, + {"default":true,"description":"Fourth option.","group":"Other","value":"delta"}, + {"default":false,"description":"Fifth option.","group":"Other","value":"epsilon"} + ]`, + }, + { + command: "catalog-combined", + human: "Primary \"Widgets\":\n" + fmt.Sprintf(" %-48s %s\n %-48s %s\n", + "beta", "Second option.", "alpha (default: create-widget, inspect-widget)", "First option.") + + "\nSecondary:\n" + fmt.Sprintf(" %-48s %s\n", "gamma (default: render)", "Third option.") + + "\nOther:\n" + fmt.Sprintf(" %-48s %s\n %-48s %s\n", + "delta", "Fourth option.", "epsilon", "Fifth option."), + json: `[ + {"default":false,"description":"Second option.","group":"Primary \"Widgets\"","value":"beta"}, + {"default":true,"default_for":["create-widget","inspect-widget"],"description":"First option.","group":"Primary \"Widgets\"","value":"alpha"}, + {"default":false,"default_for":["render"],"description":"Third option.","group":"Secondary","value":"gamma"}, + {"default":false,"description":"Fourth option.","group":"Other","value":"delta"}, + {"default":false,"description":"Fifth option.","group":"Other","value":"epsilon"} + ]`, + }, + { + command: "catalog-malformed", + human: "Valid:\n" + fmt.Sprintf(" %-42s %s\n", "alpha (default: render)", "First option.") + + "\nOther:\n" + fmt.Sprintf(" %-42s %s\n %-42s %s\n", "beta", "Second option.", "gamma", "Third option."), + json: `[ + {"default":false,"default_for":["render"],"description":"First option.","group":"Valid","value":"alpha"}, + {"default":false,"description":"Second option.","group":"Other","value":"beta"}, + {"default":false,"description":"Third option.","group":"Other","value":"gamma"} + ]`, + }, +} + func TestCatalogListsValues(t *testing.T) { recordTest("catalog-lists-values") h := NewCLITestHarness(t) - err := h.RunRaw([]string{"hygiene-models"}) - require.NoError(t, err) - stdout := h.GetStdout() - assert.Contains(t, stdout, "standard") - assert.Contains(t, stdout, "(default)") - assert.Contains(t, stdout, "fast") + require.NoError(t, h.RunRaw([]string{"hygiene-models"})) + assert.Equal(t, fmt.Sprintf("%-42s %s\n%-42s %s\n", "standard (default)", "", "fast", ""), h.GetStdout()) + + for _, tc := range catalogOutputCases { + t.Run(tc.command, func(t *testing.T) { + h := NewCLITestHarness(t) + require.NoError(t, h.RunRaw([]string{tc.command})) + assert.Equal(t, tc.human, h.GetStdout()) + assert.Empty(t, h.GetStderr()) + }) + } } func TestCatalogStructuredOutput(t *testing.T) { recordTest("catalog-structured-output") - s := newEchoServer() - defer s.Close() - h := NewCLITestHarness(t) - err := h.RunWithServer(s.URL, []string{"hygiene-models"}) - require.NoError(t, err) - var values []struct { - Value string `json:"value"` - Description string `json:"description"` - Default bool `json:"default"` - } - require.NoError(t, json.Unmarshal([]byte(h.GetStdout()), &values)) - require.NotEmpty(t, values) - assert.Equal(t, "standard", values[0].Value) - assert.True(t, values[0].Default) + require.NoError(t, h.RunRaw([]string{"hygiene-models", "-o", "json"})) + assert.Equal(t, `[{"default":true,"description":"","value":"standard"},{"default":false,"description":"","value":"fast"}]`+"\n", h.GetStdout()) assert.Empty(t, h.GetStderr()) + + for _, tc := range catalogOutputCases { + t.Run(tc.command, func(t *testing.T) { + h := NewCLITestHarness(t) + require.NoError(t, h.RunRaw([]string{tc.command, "-o", "json"})) + assert.JSONEq(t, tc.json, h.GetStdout()) + if tc.command == "catalog-legacy" { + assert.Equal(t, tc.json+"\n", h.GetStdout()) + } + assert.Empty(t, h.GetStderr()) + + var values []map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(h.GetStdout()), &values)) + seen := make(map[string]bool) + for _, value := range values { + name := value["value"].(string) + assert.False(t, seen[name], "enum values must appear only once") + seen[name] = true + } + }) + } } func TestCatalogRejectsPositionalArgument(t *testing.T) { diff --git a/tests/specs/fragments/primary/cli-catalog-options.yaml b/tests/specs/fragments/primary/cli-catalog-options.yaml new file mode 100644 index 00000000..953d6db6 --- /dev/null +++ b/tests/specs/fragments/primary/cli-catalog-options.yaml @@ -0,0 +1,124 @@ +openapi: 3.1.0 +security: + - apiKeyAuth: [] + - oauth2: [] + - clientCredentials: [read, write] + - customSchemeAppId: [] + - basicHttp: [] + - accessToken: [] + - {} +paths: + /anything/cli/catalogOptions: + post: + operationId: cliCatalogOptionsPost + description: >- + Enum catalogs with per-command defaults and ordered groups, including + duplicate references, unknown references, and ungrouped enum values. + tags: [cliCatalogOptions] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + legacy: + $ref: "#/components/schemas/CliCatalogLegacy" + empty: + $ref: "#/components/schemas/CliCatalogEmpty" + defaults: + $ref: "#/components/schemas/CliCatalogDefaults" + groups: + $ref: "#/components/schemas/CliCatalogGroups" + combined: + $ref: "#/components/schemas/CliCatalogCombined" + malformed: + $ref: "#/components/schemas/CliCatalogMalformed" + responses: + "200": + description: OK + content: + application/json: + schema: + type: object +components: + schemas: + CliCatalogLegacy: + type: string + description: A scalar catalog default preserves fixed-width output even for long labels. + enum: [catalog-value-with-a-name-longer-than-forty-two-characters, beta] + x-speakeasy-enum-descriptions: [Long option., Second option.] + x-speakeasy-cli-catalog: + command: catalog-legacy + summary: List legacy options + description: Prints options with the original catalog format. + default: catalog-value-with-a-name-longer-than-forty-two-characters + CliCatalogEmpty: + type: string + description: Empty optional catalog metadata preserves the ungrouped format. + enum: [alpha, beta] + x-speakeasy-enum-descriptions: [First option., Second option.] + x-speakeasy-cli-catalog: + command: catalog-empty + default: alpha + defaults: {} + groups: [] + CliCatalogDefaults: + type: string + description: Command defaults do not imply a global catalog default, even with a schema default. + default: alpha + enum: [alpha, beta, gamma, delta, epsilon] + x-speakeasy-enum-descriptions: [First option., Second option., Third option., Fourth option., Fifth option.] + x-speakeasy-cli-catalog: + command: catalog-defaults + defaults: + alpha: [create-widget, inspect-widget] + gamma: render + delta: [] + unknown: ignored + CliCatalogGroups: + type: string + description: Ordered groups deduplicate enum references and retain unmatched values in schema order. + enum: [alpha, beta, gamma, delta, epsilon] + x-speakeasy-enum-descriptions: [First option., Second option., Third option., Fourth option., Fifth option.] + x-speakeasy-cli-catalog: + command: catalog-groups + default: delta + groups: + - title: 'Primary "Widgets"' + values: [beta, alpha, alpha, unknown] + - title: Secondary + values: [beta, gamma] + CliCatalogCombined: + type: string + description: Grouped catalogs retain global default metadata while displaying command-specific defaults. + enum: [alpha, beta, gamma, delta, epsilon] + x-speakeasy-enum-descriptions: [First option., Second option., Third option., Fourth option., Fifth option.] + x-speakeasy-cli-catalog: + command: catalog-combined + default: alpha + defaults: + alpha: [create-widget, inspect-widget] + gamma: render + groups: + - title: 'Primary "Widgets"' + values: [beta, alpha, alpha, unknown] + - title: Secondary + values: [beta, gamma] + CliCatalogMalformed: + type: string + description: Malformed optional catalog entries do not discard valid enum values or metadata. + enum: [alpha, beta, gamma] + x-speakeasy-enum-descriptions: [First option., Second option., Third option.] + x-speakeasy-cli-catalog: + command: catalog-malformed + defaults: + alpha: [render, null, 123, ""] + beta: 123 + groups: + - title: "" + values: [alpha] + - title: Invalid + values: alpha + - title: Valid + values: [alpha] From 6800fac266772b15a1f980095654a904319f7188 Mon Sep 17 00:00:00 2001 From: Tristan Cartledge <tristan@speakeasyapi.dev> Date: Fri, 2 Oct 2026 13:27:15 +1000 Subject: [PATCH 2/7] test(cli): refresh release workflow snapshots --- pkg/generate/snapshots/cli_release_go_test.go | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/pkg/generate/snapshots/cli_release_go_test.go b/pkg/generate/snapshots/cli_release_go_test.go index b2efee14..d96dce60 100644 --- a/pkg/generate/snapshots/cli_release_go_test.go +++ b/pkg/generate/snapshots/cli_release_go_test.go @@ -74,6 +74,15 @@ jobs: with: fetch-depth: 0 + - name: Check release version + run: | + tag_version="${GITHUB_REF_NAME#v}" + cli_version=$(sed -n 's/^var Version = "\(.*\)"$/\1/p' internal/cli/version.go) + if [ -z "$cli_version" ] || [ "$tag_version" != "$cli_version" ]; then + printf '::error::Release tag %s does not match generated CLI version %s. Set cli.version in .speakeasy/gen.yaml to %s and regenerate before tagging.\n' "$GITHUB_REF_NAME" "$cli_version" "$tag_version" + exit 1 + fi + - name: Setup Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: @@ -1070,6 +1079,15 @@ jobs: with: fetch-depth: 0 + - name: Check release version + run: | + tag_version="${GITHUB_REF_NAME#v}" + cli_version=$(sed -n 's/^var Version = "\(.*\)"$/\1/p' internal/cli/version.go) + if [ -z "$cli_version" ] || [ "$tag_version" != "$cli_version" ]; then + printf '::error::Release tag %s does not match generated CLI version %s. Set cli.version in .speakeasy/gen.yaml to %s and regenerate before tagging.\n' "$GITHUB_REF_NAME" "$cli_version" "$tag_version" + exit 1 + fi + - name: Setup Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: @@ -2117,6 +2135,15 @@ jobs: with: fetch-depth: 0 + - name: Check release version + run: | + tag_version="${GITHUB_REF_NAME#v}" + cli_version=$(sed -n 's/^var Version = "\(.*\)"$/\1/p' internal/cli/version.go) + if [ -z "$cli_version" ] || [ "$tag_version" != "$cli_version" ]; then + printf '::error::Release tag %s does not match generated CLI version %s. Set cli.version in .speakeasy/gen.yaml to %s and regenerate before tagging.\n' "$GITHUB_REF_NAME" "$cli_version" "$tag_version" + exit 1 + fi + - name: Setup Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: From dd77aeafc9ddd5d7ff2b1bf216b25ed2adf856dd Mon Sep 17 00:00:00 2001 From: Tristan Cartledge <tristan@speakeasyapi.dev> Date: Fri, 2 Oct 2026 13:37:47 +1000 Subject: [PATCH 3/7] fix(cli): normalize catalog defaults and groups --- templates/templates/cli/README.md | 6 +- .../templates/cli/includes/templating.ts | 34 +++++++---- .../cli/tests/primary/catalog_test.go.stmpl | 19 ++++-- .../primary/cli-catalog-options.yaml | 59 +++++++++++++++---- 4 files changed, 85 insertions(+), 33 deletions(-) diff --git a/templates/templates/cli/README.md b/templates/templates/cli/README.md index cc491e06..16a0f206 100644 --- a/templates/templates/cli/README.md +++ b/templates/templates/cli/README.md @@ -598,9 +598,9 @@ x-speakeasy-cli-catalog: values: [gamma] ``` -- `defaults` maps enum values to a command name or an array of command names. These are catalog annotations, not changes to command presets. Non-empty command defaults produce the label `<value> (default: <command>, ...)`; otherwise the scalar catalog default produces `<value> (default)`. Only the scalar extension `default` sets the machine-output `default` boolean, with no fallback to the schema's `default`. -- Non-empty `groups` produces titled sections in declaration order, with values in each group's declared order. Unknown enum references are ignored and repeated references retain only their first occurrence. Remaining enum values appear in a trailing `Other` section in their original schema order. Each group requires a non-blank string title and a values array; malformed group definitions are skipped. Valid empty groups retain their headings. -- Grouped human output uses `<title>:` headings, two-space item indentation, and one blank line between groups. Omitted or empty groups keep the flat, unindented output. Catalogs with non-empty `defaults` or `groups` use a label width of `max(42, longest label length + 2)`, followed by a separator space. Catalogs without these options retain the original fixed width of 42, including legacy long-label spacing. +- `defaults` maps enum values to a command name or an array of command names. Command names are trimmed and blank or non-string entries are ignored. These are catalog annotations, not changes to command presets. Non-empty command defaults produce the label `<value> (default: <command>, ...)`; otherwise the scalar catalog default produces `<value> (default)`. Only the scalar extension `default` sets the machine-output `default` boolean, with no fallback to the schema's `default`. +- Groups with matching enum values produce titled sections in declaration order, with values in each group's declared order. Titles are trimmed, unknown enum references are ignored, and repeated references retain only their first occurrence. Each group requires a non-blank string title and a values array; malformed definitions and groups containing no unclaimed enum values are skipped. If at least one declared group matches, remaining enum values appear in a trailing `Other` section in their original schema order. If none matches, output remains flat in schema order with no `Other` section or machine-output `group` fields. +- Grouped human output uses `<title>:` headings, two-space item indentation, and one blank line between groups. Catalogs with resolved command defaults or non-empty resolved groups use a label width of `max(42, longest label length + 2)`, followed by a separator space. When neither option resolves to effective data, omitted, empty, malformed or otherwise ineffective options retain the original fixed width of 42 and flat output, including legacy long-label spacing. - Machine output remains a flat array in the same order as human output. Every object retains `value`, `description`, and the boolean `default`. `default_for` is emitted only for non-empty command defaults; `group` is emitted only for grouped catalogs, including the `Other` remainder. Malformed command-default entries are ignored; arrays retain only non-blank strings. ### Output Formatting diff --git a/templates/templates/cli/includes/templating.ts b/templates/templates/cli/includes/templating.ts index 3569aa53..8742da81 100644 --- a/templates/templates/cli/includes/templating.ts +++ b/templates/templates/cli/includes/templating.ts @@ -690,15 +690,18 @@ function collectCliCatalogs(): CliCatalog[] { const declared = Object.prototype.hasOwnProperty.call(defaults, v) ? defaults[v] : undefined; - const defaultFor = + const commands = typeof declared === "string" - ? [declared].filter((name) => name.trim().length > 0) + ? [declared] : Array.isArray(declared) - ? declared.filter( - (name: any) => - typeof name === "string" && name.trim().length > 0, - ) + ? declared : []; + const defaultFor = commands + .filter( + (name: unknown): name is string => typeof name === "string", + ) + .map((name: string) => name.trim()) + .filter((name: string) => name.length > 0); const isDefault = defaultValue !== "" && v === defaultValue; return { Value: v, @@ -717,8 +720,7 @@ function collectCliCatalogs(): CliCatalog[] { ); if (values.length === 0) continue; const groups: CliCatalog["Groups"] = []; - const hasGroups = Array.isArray(ext.groups) && ext.groups.length > 0; - if (hasGroups) { + if (Array.isArray(ext.groups)) { const byValue = new Map( values.map((value) => [value.Value, value]), ); @@ -727,29 +729,35 @@ function collectCliCatalogs(): CliCatalog[] { if ( !group || typeof group.title !== "string" || - !group.title.trim() || !Array.isArray(group.values) ) continue; + const title = group.title.trim(); + if (!title) continue; const groupValues: CliCatalogValue[] = []; for (const name of group.values) { const value = byValue.get(name); if (!value || grouped.has(name)) continue; grouped.add(name); - value.Group = group.title; + value.Group = title; groupValues.push(value); } - groups.push({ Title: group.title, Values: groupValues }); + if (groupValues.length > 0) { + groups.push({ Title: title, Values: groupValues }); + } } const remaining = values.filter( (value) => !grouped.has(value.Value), ); - if (remaining.length > 0) { + if (groups.length > 0 && remaining.length > 0) { for (const value of remaining) value.Group = "Other"; groups.push({ Title: "Other", Values: remaining }); } } - const hasDefaults = Object.keys(defaults).length > 0; + const hasDefaults = values.some( + (value) => value.DefaultFor.length > 0, + ); + const hasGroups = groups.length > 0; catalogs.push({ Command: sanitizeCLICommand(`${ext.command}`), FuncName: sanitizeClassName(`${ext.command}`), diff --git a/templates/templates/cli/tests/primary/catalog_test.go.stmpl b/templates/templates/cli/tests/primary/catalog_test.go.stmpl index 78f6df9d..30e7d577 100644 --- a/templates/templates/cli/tests/primary/catalog_test.go.stmpl +++ b/templates/templates/cli/tests/primary/catalog_test.go.stmpl @@ -21,6 +21,13 @@ var catalogOutputCases = []struct { "beta", "Second option."), json: `[{"default":true,"description":"Long option.","value":"catalog-value-with-a-name-longer-than-forty-two-characters"},{"default":false,"description":"Second option.","value":"beta"}]`, }, + { + command: "catalog-noop", + human: fmt.Sprintf("%-42s %s\n%-42s %s\n", + "catalog-value-with-a-name-longer-than-forty-two-characters (default)", "Long option.", + "beta", "Second option."), + json: `[{"default":true,"description":"Long option.","value":"catalog-value-with-a-name-longer-than-forty-two-characters"},{"default":false,"description":"Second option.","value":"beta"}]`, + }, { command: "catalog-empty", human: fmt.Sprintf("%-42s %s\n%-42s %s\n", @@ -76,12 +83,12 @@ var catalogOutputCases = []struct { }, { command: "catalog-malformed", - human: "Valid:\n" + fmt.Sprintf(" %-42s %s\n", "alpha (default: render)", "First option.") + - "\nOther:\n" + fmt.Sprintf(" %-42s %s\n %-42s %s\n", "beta", "Second option.", "gamma", "Third option."), + human: fmt.Sprintf("%-42s %s\n%-42s %s\n%-42s %s\n", + "alpha (default: render)", "First option.", "beta", "Second option.", "gamma", "Third option."), json: `[ - {"default":false,"default_for":["render"],"description":"First option.","group":"Valid","value":"alpha"}, - {"default":false,"description":"Second option.","group":"Other","value":"beta"}, - {"default":false,"description":"Third option.","group":"Other","value":"gamma"} + {"default":false,"default_for":["render"],"description":"First option.","value":"alpha"}, + {"default":false,"description":"Second option.","value":"beta"}, + {"default":false,"description":"Third option.","value":"gamma"} ]`, }, } @@ -114,7 +121,7 @@ func TestCatalogStructuredOutput(t *testing.T) { h := NewCLITestHarness(t) require.NoError(t, h.RunRaw([]string{tc.command, "-o", "json"})) assert.JSONEq(t, tc.json, h.GetStdout()) - if tc.command == "catalog-legacy" { + if tc.command == "catalog-legacy" || tc.command == "catalog-noop" { assert.Equal(t, tc.json+"\n", h.GetStdout()) } assert.Empty(t, h.GetStderr()) diff --git a/tests/specs/fragments/primary/cli-catalog-options.yaml b/tests/specs/fragments/primary/cli-catalog-options.yaml index 953d6db6..c4a4f949 100644 --- a/tests/specs/fragments/primary/cli-catalog-options.yaml +++ b/tests/specs/fragments/primary/cli-catalog-options.yaml @@ -34,6 +34,8 @@ paths: $ref: "#/components/schemas/CliCatalogCombined" malformed: $ref: "#/components/schemas/CliCatalogMalformed" + noop: + $ref: "#/components/schemas/CliCatalogNoop" responses: "200": description: OK @@ -72,8 +74,8 @@ components: x-speakeasy-cli-catalog: command: catalog-defaults defaults: - alpha: [create-widget, inspect-widget] - gamma: render + alpha: [" create-widget ", " inspect-widget "] + gamma: " render " delta: [] unknown: ignored CliCatalogGroups: @@ -85,9 +87,15 @@ components: command: catalog-groups default: delta groups: - - title: 'Primary "Widgets"' + - title: ' Primary "Widgets" ' values: [beta, alpha, alpha, unknown] - - title: Secondary + - title: EmptyGroup + values: [unknown] + - title: NoValues + values: [] + - title: DuplicateGroup + values: [alpha, beta] + - title: " Secondary " values: [beta, gamma] CliCatalogCombined: type: string @@ -98,12 +106,18 @@ components: command: catalog-combined default: alpha defaults: - alpha: [create-widget, inspect-widget] - gamma: render + alpha: [" create-widget ", " inspect-widget "] + gamma: " render " groups: - - title: 'Primary "Widgets"' + - title: ' Primary "Widgets" ' values: [beta, alpha, alpha, unknown] - - title: Secondary + - title: EmptyGroup + values: [unknown] + - title: NoValues + values: [] + - title: DuplicateGroup + values: [alpha, beta] + - title: " Secondary " values: [beta, gamma] CliCatalogMalformed: type: string @@ -113,12 +127,35 @@ components: x-speakeasy-cli-catalog: command: catalog-malformed defaults: - alpha: [render, null, 123, ""] + alpha: [" render ", null, 123, "", " "] beta: 123 groups: - title: "" values: [alpha] - title: Invalid values: alpha - - title: Valid - values: [alpha] + - title: EmptyGroup + values: [unknown] + - title: NoValues + values: [] + CliCatalogNoop: + type: string + description: Ineffective optional catalog metadata preserves fixed-width output for long enum values. + enum: [catalog-value-with-a-name-longer-than-forty-two-characters, beta] + x-speakeasy-enum-descriptions: [Long option., Second option.] + x-speakeasy-cli-catalog: + command: catalog-noop + default: catalog-value-with-a-name-longer-than-forty-two-characters + defaults: + catalog-value-with-a-name-longer-than-forty-two-characters: [" ", "", null, 123] + beta: [] + unknown: ignored + groups: + - title: "" + values: [beta] + - title: Invalid + values: beta + - title: EmptyGroup + values: [unknown] + - title: NoValues + values: [] From 74631fa6ad40453be90d3e6917dc66d7325da542 Mon Sep 17 00:00:00 2001 From: Tristan Cartledge <tristan@speakeasyapi.dev> Date: Fri, 2 Oct 2026 14:12:36 +1000 Subject: [PATCH 4/7] fix(php): qualify enum defaults in model constructors --- .changesets/1790914252-bbb55bb2.yaml | 10 ++++++++++ .../Tests/primary/RequestBodiesAdditionalTest.php | 12 ++++++++++++ templates/templates/php/includes/sanitization.ts | 2 +- templates/templates/php/includes/templating.ts | 1 + 4 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 .changesets/1790914252-bbb55bb2.yaml diff --git a/.changesets/1790914252-bbb55bb2.yaml b/.changesets/1790914252-bbb55bb2.yaml new file mode 100644 index 00000000..0883ee3d --- /dev/null +++ b/.changesets/1790914252-bbb55bb2.yaml @@ -0,0 +1,10 @@ +id: 1790914252-bbb55bb2 +features: + - enums +targets: + - php +type: fix +bump: patch +description: qualify enum defaults in model constructors +author: TristanSpeakEasy +date: "2026-10-02" diff --git a/templates/templates/php/Tests/primary/RequestBodiesAdditionalTest.php b/templates/templates/php/Tests/primary/RequestBodiesAdditionalTest.php index 0b11b821..7b0158ce 100644 --- a/templates/templates/php/Tests/primary/RequestBodiesAdditionalTest.php +++ b/templates/templates/php/Tests/primary/RequestBodiesAdditionalTest.php @@ -4,11 +4,23 @@ use OpenAPI\OpenAPI\Tests\CommonHelpers; use OpenAPI\OpenAPI\Tests\Helpers\Helpers; +use OpenAPI\OpenAPI\Models\Operations; use OpenAPI\OpenAPI\Models\Shared; use PHPUnit\Framework\TestCase; final class RequestBodiesAdditionalTest extends TestCase { + public function testSharedEnumConstructorDefault(): void + { + $request = new Operations\CliCatalogOptionsPostRequestBody(); + + $this->assertSame(Shared\CliCatalogDefaults::Alpha, $request->defaults); + + $request = new Operations\CliCatalogOptionsPostRequestBody(defaults: Shared\CliCatalogDefaults::Beta); + + $this->assertSame(Shared\CliCatalogDefaults::Beta, $request->defaults); + } + public function testRequestBodiesBase64FileInputIdempotent(): void { CommonHelpers::recordTest('request-bodies-base64-file-input-idempotent'); diff --git a/templates/templates/php/includes/sanitization.ts b/templates/templates/php/includes/sanitization.ts index a4c5ee7a..fc7eee95 100644 --- a/templates/templates/php/includes/sanitization.ts +++ b/templates/templates/php/includes/sanitization.ts @@ -550,7 +550,7 @@ function templateEnumValue( } return `${sanitizeClass( fieldDef.Type, - fieldDef.Type.OutputLocation, + additionalContext?.usageLocation ?? fieldDef.Type.OutputLocation, false, qualification, )}::${enumNames[idx]}`; diff --git a/templates/templates/php/includes/templating.ts b/templates/templates/php/includes/templating.ts index e3d9726f..568ec70c 100644 --- a/templates/templates/php/includes/templating.ts +++ b/templates/templates/php/includes/templating.ts @@ -843,6 +843,7 @@ function templateModelConstructorArgs(modelType: TypeDef) { Qualification.TYPE, )} $${sanitizeFieldName(field.Name)} = ${templateConstOrDefaultValue( field, + { usageLocation: modelType.OutputLocation }, )}`, ); } else if (field.Optional || field.Nullable) { From bfe14f519216618ea1728d04d52615867928e066 Mon Sep 17 00:00:00 2001 From: Tristan Cartledge <tristan@speakeasyapi.dev> Date: Mon, 5 Oct 2026 09:33:37 +1000 Subject: [PATCH 5/7] Revert "fix(php): qualify enum defaults in model constructors" This reverts commit 74631fa6ad40453be90d3e6917dc66d7325da542. --- .changesets/1790914252-bbb55bb2.yaml | 10 ---------- .../Tests/primary/RequestBodiesAdditionalTest.php | 12 ------------ templates/templates/php/includes/sanitization.ts | 2 +- templates/templates/php/includes/templating.ts | 1 - 4 files changed, 1 insertion(+), 24 deletions(-) delete mode 100644 .changesets/1790914252-bbb55bb2.yaml diff --git a/.changesets/1790914252-bbb55bb2.yaml b/.changesets/1790914252-bbb55bb2.yaml deleted file mode 100644 index 0883ee3d..00000000 --- a/.changesets/1790914252-bbb55bb2.yaml +++ /dev/null @@ -1,10 +0,0 @@ -id: 1790914252-bbb55bb2 -features: - - enums -targets: - - php -type: fix -bump: patch -description: qualify enum defaults in model constructors -author: TristanSpeakEasy -date: "2026-10-02" diff --git a/templates/templates/php/Tests/primary/RequestBodiesAdditionalTest.php b/templates/templates/php/Tests/primary/RequestBodiesAdditionalTest.php index 7b0158ce..0b11b821 100644 --- a/templates/templates/php/Tests/primary/RequestBodiesAdditionalTest.php +++ b/templates/templates/php/Tests/primary/RequestBodiesAdditionalTest.php @@ -4,23 +4,11 @@ use OpenAPI\OpenAPI\Tests\CommonHelpers; use OpenAPI\OpenAPI\Tests\Helpers\Helpers; -use OpenAPI\OpenAPI\Models\Operations; use OpenAPI\OpenAPI\Models\Shared; use PHPUnit\Framework\TestCase; final class RequestBodiesAdditionalTest extends TestCase { - public function testSharedEnumConstructorDefault(): void - { - $request = new Operations\CliCatalogOptionsPostRequestBody(); - - $this->assertSame(Shared\CliCatalogDefaults::Alpha, $request->defaults); - - $request = new Operations\CliCatalogOptionsPostRequestBody(defaults: Shared\CliCatalogDefaults::Beta); - - $this->assertSame(Shared\CliCatalogDefaults::Beta, $request->defaults); - } - public function testRequestBodiesBase64FileInputIdempotent(): void { CommonHelpers::recordTest('request-bodies-base64-file-input-idempotent'); diff --git a/templates/templates/php/includes/sanitization.ts b/templates/templates/php/includes/sanitization.ts index fc7eee95..a4c5ee7a 100644 --- a/templates/templates/php/includes/sanitization.ts +++ b/templates/templates/php/includes/sanitization.ts @@ -550,7 +550,7 @@ function templateEnumValue( } return `${sanitizeClass( fieldDef.Type, - additionalContext?.usageLocation ?? fieldDef.Type.OutputLocation, + fieldDef.Type.OutputLocation, false, qualification, )}::${enumNames[idx]}`; diff --git a/templates/templates/php/includes/templating.ts b/templates/templates/php/includes/templating.ts index 568ec70c..e3d9726f 100644 --- a/templates/templates/php/includes/templating.ts +++ b/templates/templates/php/includes/templating.ts @@ -843,7 +843,6 @@ function templateModelConstructorArgs(modelType: TypeDef) { Qualification.TYPE, )} $${sanitizeFieldName(field.Name)} = ${templateConstOrDefaultValue( field, - { usageLocation: modelType.OutputLocation }, )}`, ); } else if (field.Optional || field.Nullable) { From 09dfd393de0d532b1ad6bb146af6cb7b4c9b1535 Mon Sep 17 00:00:00 2001 From: Tristan Cartledge <tristan@speakeasyapi.dev> Date: Mon, 5 Oct 2026 10:09:57 +1000 Subject: [PATCH 6/7] feat(cli): derive catalog defaults from presets and validate enum groups --- .changesets/1790909966-deaee7f1.yaml | 2 +- internal/ast/typedef.go | 8 + internal/ast/typedef_test.go | 9 + internal/extensions/cli_catalog_defaults.go | 113 +++++++++ .../extensions/cli_catalog_defaults_test.go | 120 ++++++++++ internal/extensions/cli_commands.go | 39 ++-- internal/extensions/cli_commands_link.go | 52 +++-- internal/extensions/enum_groups_test.go | 75 ++++++ internal/extensions/enums.go | 60 +++++ internal/extensions/extensions.go | 2 + internal/schemas/schemas.go | 17 ++ internal/validation/validateenums.go | 12 + internal/validation/validateenums_test.go | 40 ++++ templates/templates/cli/README.md | 91 ++++++-- .../templates/cli/includes/templating.ts | 218 ++++++++---------- .../cli/tests/primary/catalog_test.go.stmpl | 22 +- .../templates/common/common/typeDefs.d.ts | 6 + tests/overlays/primary/cli/overlay.yaml | 16 ++ .../primary/cli-catalog-options.yaml | 92 ++------ 19 files changed, 738 insertions(+), 256 deletions(-) create mode 100644 internal/extensions/cli_catalog_defaults.go create mode 100644 internal/extensions/cli_catalog_defaults_test.go create mode 100644 internal/extensions/enum_groups_test.go diff --git a/.changesets/1790909966-deaee7f1.yaml b/.changesets/1790909966-deaee7f1.yaml index ad994aa6..9571de52 100644 --- a/.changesets/1790909966-deaee7f1.yaml +++ b/.changesets/1790909966-deaee7f1.yaml @@ -5,6 +5,6 @@ targets: - cli type: feat bump: patch -description: support per-command defaults and groups in offline catalogs +description: derive offline catalog defaults from command presets and support validated enum groups author: TristanSpeakEasy date: "2026-10-02" diff --git a/internal/ast/typedef.go b/internal/ast/typedef.go index 23038353..9b56440c 100644 --- a/internal/ast/typedef.go +++ b/internal/ast/typedef.go @@ -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 @@ -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, @@ -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 { @@ -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 } diff --git a/internal/ast/typedef_test.go b/internal/ast/typedef_test.go index 180ea18c..aefbbd81 100644 --- a/internal/ast/typedef_test.go +++ b/internal/ast/typedef_test.go @@ -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() diff --git a/internal/extensions/cli_catalog_defaults.go b/internal/extensions/cli_catalog_defaults.go new file mode 100644 index 00000000..1785b9f7 --- /dev/null +++ b/internal/extensions/cli_catalog_defaults.go @@ -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 +} diff --git a/internal/extensions/cli_catalog_defaults_test.go b/internal/extensions/cli_catalog_defaults_test.go new file mode 100644 index 00000000..b39045c6 --- /dev/null +++ b/internal/extensions/cli_catalog_defaults_test.go @@ -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) + }) + } +} diff --git a/internal/extensions/cli_commands.go b/internal/extensions/cli_commands.go index e09aa8ae..3a17d133 100644 --- a/internal/extensions/cli_commands.go +++ b/internal/extensions/cli_commands.go @@ -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) diff --git a/internal/extensions/cli_commands_link.go b/internal/extensions/cli_commands_link.go index ccc38e5a..44ec0801 100644 --- a/internal/extensions/cli_commands_link.go +++ b/internal/extensions/cli_commands_link.go @@ -93,19 +93,20 @@ type cliResolvedSchema struct { // cliPropertyFacts is the classification of a single property schema used for // inference and preset validation. type cliPropertyFacts struct { - kinds []string // resolved scalar kind(s): string,int,float,bool — or array,object - enum []any - openEnum bool - defaultVal any - hasDefault bool - constVal any - hasConst bool - description string - readOnly bool - arrayOfStr bool // exactly array<string> (for bounded promotion) - items *cliPropertyFacts - itemsErr error - unionArms []any // raw member schemas when the property is a union + kinds []string // resolved scalar kind(s): string,int,float,bool — or array,object + enum []any + catalogCommands []string + openEnum bool + defaultVal any + hasDefault bool + constVal any + hasConst bool + description string + readOnly bool + arrayOfStr bool // exactly array<string> (for bounded promotion) + items *cliPropertyFacts + itemsErr error + unionArms []any // raw member schemas when the property is a union } func (d *cliManifestDecoder) linkManifest(manifest *CLICommandManifest) error { @@ -484,6 +485,11 @@ func (d *cliManifestDecoder) linkSingleRouteCommand(cmd *CLICommand) error { } d.checkSatisfiability(key, cmd, variant, presetPointers) + defaults, err := d.catalogDefaultsForPresets(cmd.Presets, variant) + if err != nil { + return err + } + cmd.CatalogDefaults = cliLabelCatalogDefaults(cmd, [][]CLICatalogDefault{defaults}) return nil } @@ -687,9 +693,16 @@ func (d *cliManifestDecoder) linkDispatchCommand(cmd *CLICommand) error { return err } cmd.DispatchKeys = cliBuildDispatchKeys(cmd.Source.Routes, members) - for _, link := range links { + catalogDefaults := make([][]CLICatalogDefault, len(links)) + for i, link := range links { d.checkDispatchSatisfiability(key, cmd, link) + defaults, err := d.catalogDefaultsForPresets(link.route.Presets, link.variant) + if err != nil { + return err + } + catalogDefaults[i] = defaults } + cmd.CatalogDefaults = cliLabelCatalogDefaults(cmd, catalogDefaults) return nil } @@ -2733,6 +2746,12 @@ func cliUnionMembers(schema map[string]any) []any { // when either layer declares it. func mergePropertyFacts(ref, sibling *cliPropertyFacts) (*cliPropertyFacts, error) { out := *ref + out.catalogCommands = slices.Clone(ref.catalogCommands) + for _, command := range sibling.catalogCommands { + if !slices.Contains(out.catalogCommands, command) { + out.catalogCommands = append(out.catalogCommands, command) + } + } if sibling.kinds != nil { switch { case out.kinds == nil: @@ -2894,6 +2913,11 @@ func (d *cliManifestDecoder) propertyFacts(schema any, seen []string) (*cliPrope } facts := &cliPropertyFacts{} + if catalog, ok := schemaMap["x-speakeasy-cli-catalog"].(map[string]any); ok { + if command, ok := catalog["command"].(string); ok && command != "" { + facts.catalogCommands = []string{command} + } + } if desc, ok := schemaMap["description"].(string); ok { facts.description = desc } diff --git a/internal/extensions/enum_groups_test.go b/internal/extensions/enum_groups_test.go new file mode 100644 index 00000000..5fd4e1c6 --- /dev/null +++ b/internal/extensions/enum_groups_test.go @@ -0,0 +1,75 @@ +package extensions + +import ( + "testing" + + "github.com/speakeasy-api/openapi-generation/v2/internal/types" + oasextensions "github.com/speakeasy-api/openapi/extensions" + "github.com/speakeasy-api/openapi/jsonschema/oas3" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestGetEnumGroups(t *testing.T) { + var enumNode yaml.Node + require.NoError(t, yaml.Unmarshal([]byte("[red, null, blue, blue]"), &enumNode)) + schema := &oas3.Schema{ + Enum: enumNode.Content[0].Content, + Extensions: oasextensions.New(), + } + var groupNode yaml.Node + require.NoError(t, yaml.Unmarshal([]byte("[ ' Primary ', '', Secondary, 'Ignored duplicate' ]"), &groupNode)) + schema.Extensions.Set(ExtEnumGroups.Name(), groupNode.Content[0]) + + groups, groupMap, err := New(types.NewTargetFromTemplate("go")).GetEnumGroups(schema) + require.NoError(t, err) + require.Equal(t, []string{"Primary", "", "Secondary", "Ignored duplicate"}, groups) + require.Nil(t, groupMap) + + var mapNode yaml.Node + require.NoError(t, yaml.Unmarshal([]byte("red: ' First '\nblue: ''"), &mapNode)) + schema.Extensions.Set(ExtEnumGroups.Name(), mapNode.Content[0]) + groups, groupMap, err = New(types.NewTargetFromTemplate("go")).GetEnumGroups(schema) + require.NoError(t, err) + require.Nil(t, groups) + require.Equal(t, map[string]string{"red": "First"}, groupMap) +} + +func TestGetEnumGroupsScalarKeys(t *testing.T) { + var enumNode, groupNode yaml.Node + require.NoError(t, yaml.Unmarshal([]byte("[1, 2]"), &enumNode)) + require.NoError(t, yaml.Unmarshal([]byte("1: ' Primary '\n'2': Secondary"), &groupNode)) + schema := &oas3.Schema{Enum: enumNode.Content[0].Content, Extensions: oasextensions.New()} + schema.Extensions.Set(ExtEnumGroups.Name(), groupNode.Content[0]) + groups, groupMap, err := New(types.NewTargetFromTemplate("go")).GetEnumGroups(schema) + require.NoError(t, err) + require.Nil(t, groups) + require.Equal(t, map[string]string{"1": "Primary", "2": "Secondary"}, groupMap) +} + +func TestGetEnumGroupsRejectsInvalidValues(t *testing.T) { + var enumNode yaml.Node + require.NoError(t, yaml.Unmarshal([]byte("[red, blue]"), &enumNode)) + cases := []struct { + name string + raw string + }{ + {name: "non-string list member", raw: "[Primary, 1]"}, + {name: "wrong list length", raw: "[Primary]"}, + {name: "non-string map value", raw: "red: 1"}, + {name: "unknown map key", raw: "green: Other"}, + {name: "duplicate map key", raw: "red: Primary\nred: Secondary"}, + {name: "scalar", raw: "Primary"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var groupNode yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(tc.raw), &groupNode)) + extensions := oasextensions.New() + extensions.Set(ExtEnumGroups.Name(), groupNode.Content[0]) + schema := &oas3.Schema{Enum: enumNode.Content[0].Content, Extensions: extensions} + _, _, err := New(types.NewTargetFromTemplate("go")).GetEnumGroups(schema) + require.Error(t, err) + }) + } +} diff --git a/internal/extensions/enums.go b/internal/extensions/enums.go index 37d67492..11301fb7 100644 --- a/internal/extensions/enums.go +++ b/internal/extensions/enums.go @@ -2,9 +2,11 @@ package extensions import ( "fmt" + "strings" "github.com/speakeasy-api/openapi-generation/v2/pkg/errors" "github.com/speakeasy-api/openapi/jsonschema/oas3" + "gopkg.in/yaml.v3" ) func (e *Extensions) GetEnumNames(schema *oas3.Schema) ([]string, map[any]string, error) { @@ -55,6 +57,64 @@ func (e *Extensions) GetEnumDescriptions(schema *oas3.Schema) ([]string, map[any return nil, descriptionMap, nil } +func (e *Extensions) GetEnumGroups(schema *oas3.Schema) ([]string, map[string]string, error) { + if schema.GetExtensions().Len() == 0 { + return nil, nil, nil + } + + node, ok := e.findExtension(schema.GetExtensions(), ExtEnumGroups) + if !ok { + return nil, nil, nil + } + + values := schema.GetEnum() + switch node.Kind { + case yaml.SequenceNode: + if len(node.Content) != len(values) { + return nil, nil, errors.NewValidationError("`x-speakeasy-enum-groups` array must be the same length as enum values array", node, nil) + } + groups := make([]string, len(node.Content)) + for i, item := range node.Content { + if item == nil || item.Kind != yaml.ScalarNode || item.Tag != "!!str" { + return nil, nil, errors.NewValidationError("`x-speakeasy-enum-groups` entries must be strings", item, nil) + } + groups[i] = strings.TrimSpace(item.Value) + } + return groups, nil, nil + case yaml.MappingNode: + if len(node.Content)%2 != 0 { + return nil, nil, errors.NewValidationError("`x-speakeasy-enum-groups` must be a map of enum values to group titles", node, nil) + } + knownValues := make(map[string]struct{}, len(values)) + for _, value := range values { + if value != nil && value.Kind == yaml.ScalarNode && value.Tag != "!!null" { + knownValues[value.Value] = struct{}{} + } + } + groups := make(map[string]string, len(node.Content)/2) + seen := make(map[string]struct{}, len(node.Content)/2) + for i := 0; i < len(node.Content); i += 2 { + key, title := node.Content[i], node.Content[i+1] + if key == nil || key.Kind != yaml.ScalarNode || key.Tag == "!!null" || title == nil || title.Kind != yaml.ScalarNode || title.Tag != "!!str" { + return nil, nil, errors.NewValidationError("`x-speakeasy-enum-groups` keys must be enum values and titles must be strings", node, nil) + } + if _, ok := knownValues[key.Value]; !ok { + return nil, nil, errors.NewValidationError(fmt.Sprintf("`x-speakeasy-enum-groups` map contains key `%s` that does not exist in enum values", key.Value), key, nil) + } + if _, exists := seen[key.Value]; exists { + return nil, nil, errors.NewValidationError(fmt.Sprintf("`x-speakeasy-enum-groups` map contains duplicate key `%s`", key.Value), key, nil) + } + seen[key.Value] = struct{}{} + if trimmed := strings.TrimSpace(title.Value); trimmed != "" { + groups[key.Value] = trimmed + } + } + return nil, groups, nil + default: + return nil, nil, errors.NewValidationError("`x-speakeasy-enum-groups` must be either an array or a map", node, nil) + } +} + func (e *Extensions) IsOpenEnum(schema *oas3.Schema) (bool, error) { if schema.GetExtensions().Len() == 0 { return false, nil diff --git a/internal/extensions/extensions.go b/internal/extensions/extensions.go index 950aa404..0fa14909 100644 --- a/internal/extensions/extensions.go +++ b/internal/extensions/extensions.go @@ -94,6 +94,7 @@ const ( ExtGoOptionalMethodArguments ExtCLICommands ExtCLIErrors + ExtEnumGroups ) var extensionNames = map[Extension]string{ @@ -162,6 +163,7 @@ var extensionNames = map[Extension]string{ ExtPublicExports: "x-speakeasy-exports", ExtCLICommands: "x-speakeasy-cli-commands", ExtCLIErrors: "x-speakeasy-cli-errors", + ExtEnumGroups: "x-speakeasy-enum-groups", ExtGoOptionalMethodArguments: "x-speakeasy-go-optional-method-arguments", } diff --git a/internal/schemas/schemas.go b/internal/schemas/schemas.go index b837bedf..33152e73 100644 --- a/internal/schemas/schemas.go +++ b/internal/schemas/schemas.go @@ -1422,6 +1422,22 @@ func (s *Schemas) handleEnum(ctx context.Context, params Params, nullable bool) return nil, err } + groupList, groupMap, err := s.Subsystem.Extensions.GetEnumGroups(schema) + if err != nil { + return nil, err + } + enumGroups := groupMap + if len(groupList) > 0 { + enumGroups = make(map[string]string) + for i, group := range groupList { + if i < len(schema.Enum) && schema.Enum[i] != nil && schema.Enum[i].Tag != "!!null" && group != "" { + if _, exists := enumGroups[schema.Enum[i].Value]; !exists { + enumGroups[schema.Enum[i].Value] = group + } + } + } + } + if len(schema.GetEnum()) == 1 && schema.GetEnum()[0].Tag == "!!null" { schema.Nullable = pointer.From(true) schema.Const = schema.GetEnum()[0] @@ -1551,6 +1567,7 @@ func (s *Schemas) handleEnum(ctx context.Context, params Params, nullable bool) Values: enumValues, Names: names, Descriptions: enumDescriptions, + Groups: enumGroups, Open: isOpen, Format: enumFormat, }, diff --git a/internal/validation/validateenums.go b/internal/validation/validateenums.go index 01c2d2e0..1ccedb02 100644 --- a/internal/validation/validateenums.go +++ b/internal/validation/validateenums.go @@ -7,6 +7,8 @@ import ( "slices" "strconv" + "github.com/speakeasy-api/openapi-generation/v2/internal/extensions" + "github.com/speakeasy-api/openapi-generation/v2/internal/types" "github.com/speakeasy-api/openapi-generation/v2/internal/validation/sanitization" "github.com/speakeasy-api/openapi/linter" "github.com/speakeasy-api/openapi/openapi" @@ -83,6 +85,16 @@ func (r *ValidateEnums) Run(ctx context.Context, docInfo *linter.DocumentInfo[*o continue // Not an enum } + if _, _, err := extensions.New(types.NewTargetFromTemplate("")).GetEnumGroups(schema); err != nil { + extensionNode, _ := schema.GetExtensions().Get(extensions.ExtEnumGroups.Name()) + validationErrors = append(validationErrors, &validation.Error{ + Rule: r.ID(), + Severity: r.DefaultSeverity(), + Node: extensionNode, + UnderlyingError: err, + }) + } + schemaNode := schemaRef.GetRootNode() if schemaNode == nil { continue diff --git a/internal/validation/validateenums_test.go b/internal/validation/validateenums_test.go index 98f2ac40..3405dd40 100644 --- a/internal/validation/validateenums_test.go +++ b/internal/validation/validateenums_test.go @@ -2,6 +2,7 @@ package validation_test import ( "context" + "strings" "testing" "github.com/speakeasy-api/openapi-generation/v2/internal/types" @@ -788,6 +789,45 @@ paths: } } +func Test_ValidateEnums_EnumGroups(t *testing.T) { + cases := []struct { + name string + groups string + wantText string + }{ + {name: "unknown enum key", groups: " green: Other", wantText: "map contains key `green` that does not exist"}, + {name: "wrong positional length", groups: " - First", wantText: "array must be the same length as enum values array"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + v, err := validation.NewValidator(&config.Configuration{}, validation.RulesetSpeakeasyRecommended, validation.WithFilteredRules([]string{(&validation.ValidateEnums{}).ID()})) + require.NoError(t, err) + spec := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +paths: {} +components: + schemas: + Status: + type: string + enum: [red, null, blue] + x-speakeasy-enum-groups: +` + tc.groups + result := validateSpec(v, context.Background(), []byte(spec), "", types.NewTargetFromTemplate("go")) + errs := result.GetValidationErrors() + require.NotEmpty(t, errs) + var found bool + for _, validationErr := range errs { + if strings.Contains(validationErr.Error(), tc.wantText) { + found = true + } + } + assert.True(t, found) + }) + } +} + // Test_ValidateEnums_NullableWithCustomNames tests that nullable enums with // x-speakeasy-enums work correctly (null should be skipped in both arrays) func Test_ValidateEnums_NullableWithCustomNames(t *testing.T) { diff --git a/templates/templates/cli/README.md b/templates/templates/cli/README.md index 16a0f206..a5fefef8 100644 --- a/templates/templates/cli/README.md +++ b/templates/templates/cli/README.md @@ -18,16 +18,19 @@ Generates a fully functional Go CLI from an OpenAPI specification. The generated - [Positional path parameter](#positional-path-parameter) - [Metadata-Driven Request Building](#metadata-driven-request-building) - [Flag Metadata Generation](#flag-metadata-generation) + - [Union Type Handling](#union-type-handling) - [Declarative Intent Commands and Route Dispatch](#declarative-intent-commands-and-route-dispatch) - [Offline Enum Catalogs](#offline-enum-catalogs) - [Output Formatting](#output-formatting) + - [Interactive Mode](#interactive-mode) - [Agent Mode](#agent-mode) - [Exit Codes and Error Boundaries](#exit-codes-and-error-boundaries) - [Pagination](#pagination) - [Streaming (SSE \& JSONL)](#streaming-sse--jsonl) - [Streamed-event projection (`x-speakeasy-cli-commands` `output.stream.select`)](#streamed-event-projection-x-speakeasy-cli-commands-outputstreamselect) + - [Binary Downloads](#binary-downloads) - [Bytes / Base64 Request Input](#bytes--base64-request-input) - [Retries \& Timeout](#retries--timeout) @@ -581,27 +584,81 @@ Dispatch commands never register backing request-body metadata: no whole-union J **Files**: `includes/templating.ts` (`collectCliCatalogs`), `catalog.go.stmpl`, and `main.ts` -An enum schema annotated with `x-speakeasy-cli-catalog` generates an offline listing command. The extension accepts `command`, `summary`, `description`, and a scalar `default`, plus optional per-command defaults and ordered groups: +An enum schema annotated with `x-speakeasy-cli-catalog` generates an offline listing command. The catalog extension accepts `command`, `summary`, `description`, and the existing scalar `default`. Command-specific defaults come from actual command presets, not a second catalog configuration. Groups are schema metadata declared with `x-speakeasy-enum-groups`, alongside enum descriptions. + +For example, these commands share one enum, but select different options by default: ```yaml -x-speakeasy-cli-catalog: - command: widget-options - summary: List available widget options - default: alpha - defaults: - alpha: [create-widget, inspect-widget] - gamma: render-widget - groups: - - title: Primary - values: [beta, alpha] - - title: Secondary - values: [gamma] +openapi: 3.1.0 +info: {title: Widget API, version: 1.0.0} +x-speakeasy-cli-commands: + version: 1 + commands: + create-widget: + op: createWidget + preset: {$.mode: alpha} + inspect-widget: + op: createWidget + preset: {$.mode: alpha} + render-widget: + op: createWidget + preset: {$.mode: gamma} +paths: + /widgets: + post: + operationId: createWidget + requestBody: + content: + application/json: + schema: + type: object + properties: + mode: {$ref: "#/components/schemas/WidgetMode"} + responses: + "200": {description: OK} +components: + schemas: + WidgetMode: + type: string + enum: [alpha, beta, gamma, delta] + x-speakeasy-enum-descriptions: + alpha: First option. + beta: Second option. + gamma: Third option. + delta: Fourth option. + x-speakeasy-enum-groups: + alpha: Primary + beta: Primary + gamma: Secondary + x-speakeasy-cli-catalog: + command: widget-options + summary: List available widget options ``` -- `defaults` maps enum values to a command name or an array of command names. Command names are trimmed and blank or non-string entries are ignored. These are catalog annotations, not changes to command presets. Non-empty command defaults produce the label `<value> (default: <command>, ...)`; otherwise the scalar catalog default produces `<value> (default)`. Only the scalar extension `default` sets the machine-output `default` boolean, with no fallback to the schema's `default`. -- Groups with matching enum values produce titled sections in declaration order, with values in each group's declared order. Titles are trimmed, unknown enum references are ignored, and repeated references retain only their first occurrence. Each group requires a non-blank string title and a values array; malformed definitions and groups containing no unclaimed enum values are skipped. If at least one declared group matches, remaining enum values appear in a trailing `Other` section in their original schema order. If none matches, output remains flat in schema order with no `Other` section or machine-output `group` fields. -- Grouped human output uses `<title>:` headings, two-space item indentation, and one blank line between groups. Catalogs with resolved command defaults or non-empty resolved groups use a label width of `max(42, longest label length + 2)`, followed by a separator space. When neither option resolves to effective data, omitted, empty, malformed or otherwise ineffective options retain the original fixed width of 42 and flat output, including legacy long-label spacing. -- Machine output remains a flat array in the same order as human output. Every object retains `value`, `description`, and the boolean `default`. `default_for` is emitted only for non-empty command defaults; `group` is emitted only for grouped catalogs, including the `Other` remainder. Malformed command-default entries are ignored; arrays retain only non-blank strings. +`cli widget-options` displays: + +```text +Primary: + alpha (default: create-widget, inspect-widget) First option. + beta Second option. + +Secondary: + gamma (default: render-widget) Third option. + +Other: + delta Fourth option. +``` + +Each enum value becomes one `CliCatalogValue` row. `CliCatalog.Groups` organises those same rows into human-output sections; `CliCatalog.Values` flattens them in the same order for machine output. Grouping does not create additional enum values or change request values. + +- Go links each effective preset to its resolved enum schema, including references and array-item enums. Route-local presets override command presets. When a command's routes select different defaults, labels include the actual route selector, such as `create --image`; defaults shared by every route use the plain command name. Unknown values accepted by an open enum are not catalog entries, and ambiguous property unions do not assign a preset to unrelated catalogs. +- Only the scalar catalog `default` sets the existing machine-output `default` boolean. It remains independent of schema defaults and command presets. Command defaults produce `<value> (default: <command>, ...)`; otherwise the scalar catalog default produces `<value> (default)`. +- `x-speakeasy-enum-groups` accepts a value-to-title map, or a positional string list matching the original enum length, for example `[Primary, Primary, Secondary, ""]`. The list includes slots for null and duplicate enum members; null slots do not create rows and duplicate values use their first non-empty title. Go validates the extension before rendering. Unknown values, duplicate map keys, non-string titles, malformed shapes and wrong-length lists are errors. Titles are trimmed; missing map entries and empty titles are ungrouped. +- Groups appear in first-seen enum order, and values within each group retain enum order. If at least one group exists, remaining values appear in a trailing `Other` section. If all titles are empty or no groups are declared, output stays flat without `group` fields. +- Grouped human output uses `<title>:` headings, two-space indentation and a blank line between groups. Effective command defaults or groups use a label width of `max(42, longest label length + 2)`. Catalogs without either retain the original fixed width of 42, including legacy long-label spacing. +- Machine output remains a flat array with `value`, `description` and `default`. Non-empty command defaults add `default_for`; grouped catalogs add `group`, including the `Other` remainder. + +The nested catalog `defaults` and `groups` keys are not supported. Use command presets and the schema-level enum group extension instead. ### Output Formatting diff --git a/templates/templates/cli/includes/templating.ts b/templates/templates/cli/includes/templating.ts index 8742da81..5a6c8fbb 100644 --- a/templates/templates/cli/includes/templating.ts +++ b/templates/templates/cli/includes/templating.ts @@ -652,140 +652,104 @@ function collectCliCatalogs(): CliCatalog[] { const byCommand = new Map<string, string>(); const byFuncName = new Map<string, string>(); let collisionError = ""; - try { - const buckets = context.Global.AST.BucketedTypes; - for (const [, models] of sequencedMapEntries(buckets)) { - for (const [, types] of sequencedMapEntries(models)) { - for (const t of types as TypeDef[]) { - const ext: any = t.Extensions?.All?.["x-speakeasy-cli-catalog"]; - if (!ext || typeof ext !== "object" || !ext.command) continue; - if (t.Type?.toString() !== "enum" || !t.Enum) continue; - if (seen.has(ext.command)) continue; - seen.add(ext.command); - const commandName = sanitizeCLICommand(`${ext.command}`); - const funcName = sanitizeClassName(`${ext.command}`); - const priorCommand = byCommand.get(commandName); - const priorFunc = byFuncName.get(funcName); - if (priorCommand !== undefined && priorCommand !== `${ext.command}`) { - collisionError = `x-speakeasy-cli-catalog: commands "${priorCommand}" and "${ext.command}" normalize to the same CLI command "${commandName}"; rename one`; - break; - } - if (priorFunc !== undefined && priorFunc !== `${ext.command}`) { - collisionError = `x-speakeasy-cli-catalog: commands "${priorFunc}" and "${ext.command}" normalize to the same generated identifier "${funcName}"; rename one`; - break; - } - byCommand.set(commandName, `${ext.command}`); - byFuncName.set(funcName, `${ext.command}`); - const defaultValue = - ext.default !== undefined ? `${ext.default}` : ""; - const defaults = - ext.defaults && - typeof ext.defaults === "object" && - !Array.isArray(ext.defaults) - ? ext.defaults - : {}; - const values: CliCatalogValue[] = (t.Enum.Values || []).map( - (v: string) => { - const description = t.Enum.Descriptions?.[v]; - const declared = Object.prototype.hasOwnProperty.call(defaults, v) - ? defaults[v] - : undefined; - const commands = - typeof declared === "string" - ? [declared] - : Array.isArray(declared) - ? declared - : []; - const defaultFor = commands - .filter( - (name: unknown): name is string => typeof name === "string", - ) - .map((name: string) => name.trim()) - .filter((name: string) => name.length > 0); - const isDefault = defaultValue !== "" && v === defaultValue; - return { - Value: v, - Description: typeof description === "string" ? description : "", - IsDefault: isDefault, - DefaultFor: defaultFor, - Group: "", - Label: - defaultFor.length > 0 - ? `${v} (default: ${defaultFor.join(", ")})` - : isDefault - ? `${v} (default)` - : v, - }; - }, + const buckets = context.Global.AST.BucketedTypes; + for (const [, models] of sequencedMapEntries(buckets)) { + for (const [, types] of sequencedMapEntries(models)) { + for (const t of types as TypeDef[]) { + const ext: any = t.Extensions?.All?.["x-speakeasy-cli-catalog"]; + if (!ext || typeof ext !== "object" || !ext.command) continue; + if (t.Type?.toString() !== "enum" || !t.Enum) continue; + if (seen.has(ext.command)) continue; + seen.add(ext.command); + const commandName = sanitizeCLICommand(`${ext.command}`); + const funcName = sanitizeClassName(`${ext.command}`); + const priorCommand = byCommand.get(commandName); + const priorFunc = byFuncName.get(funcName); + if (priorCommand !== undefined && priorCommand !== `${ext.command}`) { + collisionError = `x-speakeasy-cli-catalog: commands "${priorCommand}" and "${ext.command}" normalize to the same CLI command "${commandName}"; rename one`; + break; + } + if (priorFunc !== undefined && priorFunc !== `${ext.command}`) { + collisionError = `x-speakeasy-cli-catalog: commands "${priorFunc}" and "${ext.command}" normalize to the same generated identifier "${funcName}"; rename one`; + break; + } + byCommand.set(commandName, `${ext.command}`); + byFuncName.set(funcName, `${ext.command}`); + const defaultValue = ext.default !== undefined ? `${ext.default}` : ""; + if (ext.defaults !== undefined || ext.groups !== undefined) { + throw new Error( + "x-speakeasy-cli-catalog: derive command defaults from command presets and declare groups with x-speakeasy-enum-groups", ); - if (values.length === 0) continue; - const groups: CliCatalog["Groups"] = []; - if (Array.isArray(ext.groups)) { - const byValue = new Map( - values.map((value) => [value.Value, value]), - ); - const grouped = new Set<string>(); - for (const group of ext.groups) { - if ( - !group || - typeof group.title !== "string" || - !Array.isArray(group.values) - ) - continue; - const title = group.title.trim(); - if (!title) continue; - const groupValues: CliCatalogValue[] = []; - for (const name of group.values) { - const value = byValue.get(name); - if (!value || grouped.has(name)) continue; - grouped.add(name); - value.Group = title; - groupValues.push(value); - } - if (groupValues.length > 0) { - groups.push({ Title: title, Values: groupValues }); - } - } - const remaining = values.filter( - (value) => !grouped.has(value.Value), - ); - if (groups.length > 0 && remaining.length > 0) { - for (const value of remaining) value.Group = "Other"; - groups.push({ Title: "Other", Values: remaining }); - } + } + const defaults = new Map<string, string[]>(); + for (const cmd of context.Global.AST.CLICommands?.Commands || []) { + for (const entry of cmd.CatalogDefaults || []) { + if (entry.CatalogCommand !== ext.command) continue; + const labels = defaults.get(entry.Value) || []; + if (!labels.includes(entry.Label)) labels.push(entry.Label); + defaults.set(entry.Value, labels); } - const hasDefaults = values.some( - (value) => value.DefaultFor.length > 0, - ); - const hasGroups = groups.length > 0; - catalogs.push({ - Command: sanitizeCLICommand(`${ext.command}`), - FuncName: sanitizeClassName(`${ext.command}`), - Summary: `${ext.summary || `List available ${ext.command}`}`, - Description: `${ext.description || ""}`, - Values: hasGroups - ? groups.flatMap((group) => group.Values) - : values, - Groups: groups, - ColWidth: - hasDefaults || hasGroups - ? Math.max( - 42, - ...values.map( - (value) => Array.from(value.Label).length + 2, - ), - ) - : 42, - }); } + const values: CliCatalogValue[] = (t.Enum.Values || []).map( + (v: string) => { + const description = t.Enum.Descriptions?.[v]; + const defaultFor = defaults.get(v) || []; + const isDefault = defaultValue !== "" && v === defaultValue; + return { + Value: v, + Description: typeof description === "string" ? description : "", + IsDefault: isDefault, + DefaultFor: defaultFor, + Group: t.Enum.Groups?.[v] || "", + Label: + defaultFor.length > 0 + ? `${v} (default: ${defaultFor.join(", ")})` + : isDefault + ? `${v} (default)` + : v, + }; + }, + ); + if (values.length === 0) continue; + const groups: CliCatalog["Groups"] = []; + const byTitle = new Map<string, CliCatalog["Groups"][number]>(); + for (const value of values) { + if (!value.Group) continue; + let group = byTitle.get(value.Group); + if (!group) { + group = { Title: value.Group, Values: [] }; + byTitle.set(value.Group, group); + groups.push(group); + } + group.Values.push(value); + } + const remaining = values.filter((value) => !value.Group); + if (groups.length > 0 && remaining.length > 0) { + for (const value of remaining) value.Group = "Other"; + groups.push({ Title: "Other", Values: remaining }); + } + const hasDefaults = values.some((value) => value.DefaultFor.length > 0); + const hasGroups = groups.length > 0; + catalogs.push({ + Command: sanitizeCLICommand(`${ext.command}`), + FuncName: sanitizeClassName(`${ext.command}`), + Summary: `${ext.summary || `List available ${ext.command}`}`, + Description: `${ext.description || ""}`, + Values: hasGroups ? groups.flatMap((group) => group.Values) : values, + Groups: groups, + ColWidth: + hasDefaults || hasGroups + ? Math.max( + 42, + ...values.map((value) => Array.from(value.Label).length + 2), + ) + : 42, + }); } } - } catch (e) { - // Catalog rendering is best-effort; a malformed extension never breaks generation. } if (collisionError) { - // Identifier collisions would emit uncompilable Go; unlike malformed - // extensions they must fail generation with the collision named. + // Identifier collisions would emit uncompilable Go. throw new Error(collisionError); } return catalogs; diff --git a/templates/templates/cli/tests/primary/catalog_test.go.stmpl b/templates/templates/cli/tests/primary/catalog_test.go.stmpl index 30e7d577..0db83577 100644 --- a/templates/templates/cli/tests/primary/catalog_test.go.stmpl +++ b/templates/templates/cli/tests/primary/catalog_test.go.stmpl @@ -41,12 +41,12 @@ var catalogOutputCases = []struct { command: "catalog-defaults", human: fmt.Sprintf("%-48s %s\n%-48s %s\n%-48s %s\n%-48s %s\n%-48s %s\n", "alpha (default: create-widget, inspect-widget)", "First option.", - "beta", "Second option.", "gamma (default: render)", "Third option.", + "beta", "Second option.", "gamma (default: choose-gamma)", "Third option.", "delta", "Fourth option.", "epsilon", "Fifth option."), json: `[ {"default":false,"default_for":["create-widget","inspect-widget"],"description":"First option.","value":"alpha"}, {"default":false,"description":"Second option.","value":"beta"}, - {"default":false,"default_for":["render"],"description":"Third option.","value":"gamma"}, + {"default":false,"default_for":["choose-gamma"],"description":"Third option.","value":"gamma"}, {"default":false,"description":"Fourth option.","value":"delta"}, {"default":false,"description":"Fifth option.","value":"epsilon"} ]`, @@ -54,13 +54,13 @@ var catalogOutputCases = []struct { { command: "catalog-groups", human: "Primary \"Widgets\":\n" + fmt.Sprintf(" %-42s %s\n %-42s %s\n", - "beta", "Second option.", "alpha", "First option.") + + "alpha", "First option.", "beta", "Second option.") + "\nSecondary:\n" + fmt.Sprintf(" %-42s %s\n", "gamma", "Third option.") + "\nOther:\n" + fmt.Sprintf(" %-42s %s\n %-42s %s\n", "delta (default)", "Fourth option.", "epsilon", "Fifth option."), json: `[ - {"default":false,"description":"Second option.","group":"Primary \"Widgets\"","value":"beta"}, {"default":false,"description":"First option.","group":"Primary \"Widgets\"","value":"alpha"}, + {"default":false,"description":"Second option.","group":"Primary \"Widgets\"","value":"beta"}, {"default":false,"description":"Third option.","group":"Secondary","value":"gamma"}, {"default":true,"description":"Fourth option.","group":"Other","value":"delta"}, {"default":false,"description":"Fifth option.","group":"Other","value":"epsilon"} @@ -69,24 +69,24 @@ var catalogOutputCases = []struct { { command: "catalog-combined", human: "Primary \"Widgets\":\n" + fmt.Sprintf(" %-48s %s\n %-48s %s\n", - "beta", "Second option.", "alpha (default: create-widget, inspect-widget)", "First option.") + - "\nSecondary:\n" + fmt.Sprintf(" %-48s %s\n", "gamma (default: render)", "Third option.") + + "alpha (default: create-widget, inspect-widget)", "First option.", "beta", "Second option.") + + "\nSecondary:\n" + fmt.Sprintf(" %-48s %s\n", "gamma (default: choose-gamma)", "Third option.") + "\nOther:\n" + fmt.Sprintf(" %-48s %s\n %-48s %s\n", "delta", "Fourth option.", "epsilon", "Fifth option."), json: `[ - {"default":false,"description":"Second option.","group":"Primary \"Widgets\"","value":"beta"}, {"default":true,"default_for":["create-widget","inspect-widget"],"description":"First option.","group":"Primary \"Widgets\"","value":"alpha"}, - {"default":false,"default_for":["render"],"description":"Third option.","group":"Secondary","value":"gamma"}, + {"default":false,"description":"Second option.","group":"Primary \"Widgets\"","value":"beta"}, + {"default":false,"default_for":["choose-gamma"],"description":"Third option.","group":"Secondary","value":"gamma"}, {"default":false,"description":"Fourth option.","group":"Other","value":"delta"}, {"default":false,"description":"Fifth option.","group":"Other","value":"epsilon"} ]`, }, { - command: "catalog-malformed", + command: "catalog-ungrouped", human: fmt.Sprintf("%-42s %s\n%-42s %s\n%-42s %s\n", - "alpha (default: render)", "First option.", "beta", "Second option.", "gamma", "Third option."), + "alpha (default: choose-gamma)", "First option.", "beta", "Second option.", "gamma", "Third option."), json: `[ - {"default":false,"default_for":["render"],"description":"First option.","value":"alpha"}, + {"default":false,"default_for":["choose-gamma"],"description":"First option.","value":"alpha"}, {"default":false,"description":"Second option.","value":"beta"}, {"default":false,"description":"Third option.","value":"gamma"} ]`, diff --git a/templates/templates/common/common/typeDefs.d.ts b/templates/templates/common/common/typeDefs.d.ts index d07d67dd..d1b543c8 100644 --- a/templates/templates/common/common/typeDefs.d.ts +++ b/templates/templates/common/common/typeDefs.d.ts @@ -281,6 +281,7 @@ declare global { Type: TypeDef; Values: string[]; Descriptions?: Record<string, string>; + Groups?: Record<string, string>; Names: string[]; Open: boolean; Format: "enum" | "union" | ""; @@ -2349,6 +2350,11 @@ declare global { Examples: CLICommandExample[] | null; Help?: CLICommandHelp | null; Hints?: Record<string, string[]>; + CatalogDefaults?: { + CatalogCommand: string; + Value: string; + Label: string; + }[]; }; type CLICommandManifest = { diff --git a/tests/overlays/primary/cli/overlay.yaml b/tests/overlays/primary/cli/overlay.yaml index 406aa686..e2926c6c 100644 --- a/tests/overlays/primary/cli/overlay.yaml +++ b/tests/overlays/primary/cli/overlay.yaml @@ -73,6 +73,22 @@ actions: defaultFrom: schema summary: Stream events as they arrive commands: + create-widget: + op: cliCatalogOptionsPost + preset: + $.defaults: alpha + $.combined: alpha + inspect-widget: + op: cliCatalogOptionsPost + preset: + $.defaults: alpha + $.combined: alpha + choose-gamma: + op: cliCatalogOptionsPost + preset: + $.defaults: gamma + $.combined: gamma + $.ungrouped: alpha generate: category: Create summary: Generate a reply from a prompt diff --git a/tests/specs/fragments/primary/cli-catalog-options.yaml b/tests/specs/fragments/primary/cli-catalog-options.yaml index c4a4f949..8f176d27 100644 --- a/tests/specs/fragments/primary/cli-catalog-options.yaml +++ b/tests/specs/fragments/primary/cli-catalog-options.yaml @@ -11,9 +11,7 @@ paths: /anything/cli/catalogOptions: post: operationId: cliCatalogOptionsPost - description: >- - Enum catalogs with per-command defaults and ordered groups, including - duplicate references, unknown references, and ungrouped enum values. + description: Enum catalogs whose defaults come from command presets and whose groups retain enum declaration order, including ungrouped remainder values. tags: [cliCatalogOptions] requestBody: required: true @@ -32,8 +30,8 @@ paths: $ref: "#/components/schemas/CliCatalogGroups" combined: $ref: "#/components/schemas/CliCatalogCombined" - malformed: - $ref: "#/components/schemas/CliCatalogMalformed" + ungrouped: + $ref: "#/components/schemas/CliCatalogUngrouped" noop: $ref: "#/components/schemas/CliCatalogNoop" responses: @@ -57,105 +55,59 @@ components: default: catalog-value-with-a-name-longer-than-forty-two-characters CliCatalogEmpty: type: string - description: Empty optional catalog metadata preserves the ungrouped format. + description: Empty enum group metadata preserves the ungrouped catalog format. enum: [alpha, beta] x-speakeasy-enum-descriptions: [First option., Second option.] + x-speakeasy-enum-groups: {} x-speakeasy-cli-catalog: command: catalog-empty default: alpha - defaults: {} - groups: [] CliCatalogDefaults: type: string - description: Command defaults do not imply a global catalog default, even with a schema default. + description: Command presets do not imply a global catalog default, even with a schema default. default: alpha enum: [alpha, beta, gamma, delta, epsilon] x-speakeasy-enum-descriptions: [First option., Second option., Third option., Fourth option., Fifth option.] x-speakeasy-cli-catalog: command: catalog-defaults - defaults: - alpha: [" create-widget ", " inspect-widget "] - gamma: " render " - delta: [] - unknown: ignored CliCatalogGroups: type: string - description: Ordered groups deduplicate enum references and retain unmatched values in schema order. + description: Value-keyed enum groups follow enum order and retain unmatched values under Other. enum: [alpha, beta, gamma, delta, epsilon] x-speakeasy-enum-descriptions: [First option., Second option., Third option., Fourth option., Fifth option.] + x-speakeasy-enum-groups: + beta: ' Primary "Widgets" ' + alpha: ' Primary "Widgets" ' + gamma: " Secondary " x-speakeasy-cli-catalog: command: catalog-groups default: delta - groups: - - title: ' Primary "Widgets" ' - values: [beta, alpha, alpha, unknown] - - title: EmptyGroup - values: [unknown] - - title: NoValues - values: [] - - title: DuplicateGroup - values: [alpha, beta] - - title: " Secondary " - values: [beta, gamma] CliCatalogCombined: type: string - description: Grouped catalogs retain global default metadata while displaying command-specific defaults. + description: Grouped catalogs retain global default metadata while displaying preset-derived command defaults. enum: [alpha, beta, gamma, delta, epsilon] x-speakeasy-enum-descriptions: [First option., Second option., Third option., Fourth option., Fifth option.] + x-speakeasy-enum-groups: [' Primary "Widgets" ', ' Primary "Widgets" ', " Secondary ", "", ""] x-speakeasy-cli-catalog: command: catalog-combined default: alpha - defaults: - alpha: [" create-widget ", " inspect-widget "] - gamma: " render " - groups: - - title: ' Primary "Widgets" ' - values: [beta, alpha, alpha, unknown] - - title: EmptyGroup - values: [unknown] - - title: NoValues - values: [] - - title: DuplicateGroup - values: [alpha, beta] - - title: " Secondary " - values: [beta, gamma] - CliCatalogMalformed: + CliCatalogUngrouped: type: string - description: Malformed optional catalog entries do not discard valid enum values or metadata. + description: Empty group titles leave values ungrouped while retaining preset-derived defaults. enum: [alpha, beta, gamma] x-speakeasy-enum-descriptions: [First option., Second option., Third option.] + x-speakeasy-enum-groups: + alpha: " " + beta: "" x-speakeasy-cli-catalog: - command: catalog-malformed - defaults: - alpha: [" render ", null, 123, "", " "] - beta: 123 - groups: - - title: "" - values: [alpha] - - title: Invalid - values: alpha - - title: EmptyGroup - values: [unknown] - - title: NoValues - values: [] + command: catalog-ungrouped CliCatalogNoop: type: string - description: Ineffective optional catalog metadata preserves fixed-width output for long enum values. + description: Empty group titles preserve fixed-width output for long enum values. enum: [catalog-value-with-a-name-longer-than-forty-two-characters, beta] x-speakeasy-enum-descriptions: [Long option., Second option.] + x-speakeasy-enum-groups: + beta: " " x-speakeasy-cli-catalog: command: catalog-noop default: catalog-value-with-a-name-longer-than-forty-two-characters - defaults: - catalog-value-with-a-name-longer-than-forty-two-characters: [" ", "", null, 123] - beta: [] - unknown: ignored - groups: - - title: "" - values: [beta] - - title: Invalid - values: beta - - title: EmptyGroup - values: [unknown] - - title: NoValues - values: [] From 3d145c85fde39dbfe588bc2d2daab67fb99a998e Mon Sep 17 00:00:00 2001 From: Tristan Cartledge <tristan@speakeasyapi.dev> Date: Mon, 5 Oct 2026 10:31:09 +1000 Subject: [PATCH 7/7] fix(cli): merge catalog remainder without reordering groups --- templates/templates/cli/README.md | 10 +++++----- templates/templates/cli/includes/templating.ts | 7 ++++++- .../cli/tests/primary/catalog_test.go.stmpl | 13 +++++++++++++ .../fragments/primary/cli-catalog-options.yaml | 12 ++++++++++++ 4 files changed, 36 insertions(+), 6 deletions(-) diff --git a/templates/templates/cli/README.md b/templates/templates/cli/README.md index 8987d194..870a85da 100644 --- a/templates/templates/cli/README.md +++ b/templates/templates/cli/README.md @@ -639,14 +639,14 @@ components: ```text Primary: - alpha (default: create-widget, inspect-widget) First option. - beta Second option. + alpha (default: create-widget, inspect-widget) First option. + beta Second option. Secondary: - gamma (default: render-widget) Third option. + gamma (default: render-widget) Third option. Other: - delta Fourth option. + delta Fourth option. ``` Each enum value becomes one `CliCatalogValue` row. `CliCatalog.Groups` organises those same rows into human-output sections; `CliCatalog.Values` flattens them in the same order for machine output. Grouping does not create additional enum values or change request values. @@ -654,7 +654,7 @@ Each enum value becomes one `CliCatalogValue` row. `CliCatalog.Groups` organises - Go links each effective preset to its resolved enum schema, including references and array-item enums. Route-local presets override command presets. When a command's routes select different defaults, labels include the actual route selector, such as `create --image`; defaults shared by every route use the plain command name. Unknown values accepted by an open enum are not catalog entries, and ambiguous property unions do not assign a preset to unrelated catalogs. - Only the scalar catalog `default` sets the existing machine-output `default` boolean. It remains independent of schema defaults and command presets. Command defaults produce `<value> (default: <command>, ...)`; otherwise the scalar catalog default produces `<value> (default)`. - `x-speakeasy-enum-groups` accepts a value-to-title map, or a positional string list matching the original enum length, for example `[Primary, Primary, Secondary, ""]`. The list includes slots for null and duplicate enum members; null slots do not create rows and duplicate values use their first non-empty title. Go validates the extension before rendering. Unknown values, duplicate map keys, non-string titles, malformed shapes and wrong-length lists are errors. Titles are trimmed; missing map entries and empty titles are ungrouped. -- Groups appear in first-seen enum order, and values within each group retain enum order. If at least one group exists, remaining values appear in a trailing `Other` section. If all titles are empty or no groups are declared, output stays flat without `group` fields. +- Groups appear in first-seen enum order, and values within each group retain enum order. If at least one group exists, remaining values appear in a trailing `Other` section, unless a group is already titled `Other`. In that case, the remaining values join that group in enum order without changing its section position. If all titles are empty or no groups are declared, output stays flat without `group` fields. - Grouped human output uses `<title>:` headings, two-space indentation and a blank line between groups. Effective command defaults or groups use a label width of `max(42, longest label length + 2)`. Catalogs without either retain the original fixed width of 42, including legacy long-label spacing. - Machine output remains a flat array with `value`, `description` and `default`. Non-empty command defaults add `default_for`; grouped catalogs add `group`, including the `Other` remainder. diff --git a/templates/templates/cli/includes/templating.ts b/templates/templates/cli/includes/templating.ts index 5a6c8fbb..09ae38a8 100644 --- a/templates/templates/cli/includes/templating.ts +++ b/templates/templates/cli/includes/templating.ts @@ -726,7 +726,12 @@ function collectCliCatalogs(): CliCatalog[] { const remaining = values.filter((value) => !value.Group); if (groups.length > 0 && remaining.length > 0) { for (const value of remaining) value.Group = "Other"; - groups.push({ Title: "Other", Values: remaining }); + const other = byTitle.get("Other"); + if (other) { + other.Values = values.filter((value) => value.Group === "Other"); + } else { + groups.push({ Title: "Other", Values: remaining }); + } } const hasDefaults = values.some((value) => value.DefaultFor.length > 0); const hasGroups = groups.length > 0; diff --git a/templates/templates/cli/tests/primary/catalog_test.go.stmpl b/templates/templates/cli/tests/primary/catalog_test.go.stmpl index 0db83577..f1bcaf46 100644 --- a/templates/templates/cli/tests/primary/catalog_test.go.stmpl +++ b/templates/templates/cli/tests/primary/catalog_test.go.stmpl @@ -81,6 +81,19 @@ var catalogOutputCases = []struct { {"default":false,"description":"Fifth option.","group":"Other","value":"epsilon"} ]`, }, + { + command: "catalog-other", + human: "Other:\n" + fmt.Sprintf(" %-42s %s\n %-42s %s\n %-42s %s\n %-42s %s\n", + "alpha", "First option.", "beta", "Second option.", "gamma", "Third option.", "epsilon", "Fifth option.") + + "\nPrimary:\n" + fmt.Sprintf(" %-42s %s\n", "delta", "Fourth option."), + json: `[ + {"default":false,"description":"First option.","group":"Other","value":"alpha"}, + {"default":false,"description":"Second option.","group":"Other","value":"beta"}, + {"default":false,"description":"Third option.","group":"Other","value":"gamma"}, + {"default":false,"description":"Fifth option.","group":"Other","value":"epsilon"}, + {"default":false,"description":"Fourth option.","group":"Primary","value":"delta"} + ]`, + }, { command: "catalog-ungrouped", human: fmt.Sprintf("%-42s %s\n%-42s %s\n%-42s %s\n", diff --git a/tests/specs/fragments/primary/cli-catalog-options.yaml b/tests/specs/fragments/primary/cli-catalog-options.yaml index 8f176d27..b259a951 100644 --- a/tests/specs/fragments/primary/cli-catalog-options.yaml +++ b/tests/specs/fragments/primary/cli-catalog-options.yaml @@ -34,6 +34,8 @@ paths: $ref: "#/components/schemas/CliCatalogUngrouped" noop: $ref: "#/components/schemas/CliCatalogNoop" + other: + $ref: "#/components/schemas/CliCatalogOther" responses: "200": description: OK @@ -101,6 +103,16 @@ components: beta: "" x-speakeasy-cli-catalog: command: catalog-ungrouped + CliCatalogOther: + type: string + description: An explicit Other group retains its first-seen position and merges ungrouped values in enum declaration order. + enum: [alpha, beta, gamma, delta, epsilon] + x-speakeasy-enum-descriptions: [First option., Second option., Third option., Fourth option., Fifth option.] + x-speakeasy-enum-groups: + beta: Other + delta: Primary + x-speakeasy-cli-catalog: + command: catalog-other CliCatalogNoop: type: string description: Empty group titles preserve fixed-width output for long enum values.