From 5a4c97cc1a4d4088ce1359fd31580b4d36b96216 Mon Sep 17 00:00:00 2001 From: 2ynn Date: Mon, 19 Oct 2026 13:52:57 -0400 Subject: [PATCH 1/3] fix(cli): list the flags each command registers in --usage `--usage` disagreed with the flags commands accept in three ways: - A body-only operation whose JSON/form body is not expanded into field flags (union, map, array or scalar body) registers one flag named after the request body (e.g. `--request`), but `--usage` listed `--body`, which the command rejects, and omitted the real flag. A multipart body-only operation without flag metadata also lacked its body flag. `--usage` decided the flag with `templateHasBodyFlag`, while `opcmd.go.stmpl` used separate inline logic. - cobra's `completion bash|zsh|fish|powershell` accept `--no-descriptions`, which the `completion` nodes did not list. - Flags added at runtime to a generated command were listed only when the command was marked with `usage.MarkDynamic`. `operationUsesMetadataFlags` (side-effect free) now makes the request strategy decision for both `opcmd.go.stmpl` and `--usage`; `getOperationWholeBodyUsageFlag` lists the whole-body flag the command registers, with its help text and `[required]` marker. The completion shell nodes list `--no-descriptions`. A command rendered from its `usageNodes` entry also lists visible local flags the entry does not have, so `MarkDynamic` is only needed when help, aliases or args change at runtime. Generated command files are unchanged. Tests: `TestUsageSchema_FlagsMatchRegisteredFlags` checks every command's own `--usage` against its registered flags in both directions; `TestUsageSchema_RuntimeFlagsKeepGeneratedLines` replaces `TestUsageSchema_DynamicCommandKeepsGeneratedLines` and runs with and without `MarkDynamic`. README `--usage` section updated; review SDK regenerated. --- .changesets/1792431953-ffc1a3ac.yaml | 10 +++ templates/templates/cli/README.md | 4 +- templates/templates/cli/includes/metadata.ts | 13 +++ .../templates/cli/includes/templating.ts | 14 +-- templates/templates/cli/includes/usage.ts | 37 ++++++-- templates/templates/cli/opcmd.go.stmpl | 14 +-- templates/templates/cli/usage.go.stmpl | 26 +++--- templates/templates/cli/usage_test.go.stmpl | 88 ++++++++++++++----- zSDKs/sdk-cli/.speakeasy/gen.lock | 4 +- zSDKs/sdk-cli/internal/usage/schema.go | 36 ++++---- zSDKs/sdk-cli/tests/usage_test.go | 86 +++++++++++++----- 11 files changed, 236 insertions(+), 96 deletions(-) create mode 100644 .changesets/1792431953-ffc1a3ac.yaml diff --git a/.changesets/1792431953-ffc1a3ac.yaml b/.changesets/1792431953-ffc1a3ac.yaml new file mode 100644 index 000000000..fe54b3b1e --- /dev/null +++ b/.changesets/1792431953-ffc1a3ac.yaml @@ -0,0 +1,10 @@ +id: 1792431953-ffc1a3ac +features: + - core +targets: + - cli +type: fix +bump: patch +description: 'list the flags each command registers in --usage: the whole-body flag of body-only operations under its registered name, --no-descriptions on completion shells, and flags added to generated commands at runtime' +author: 2ynn +date: "2026-10-19" diff --git a/templates/templates/cli/README.md b/templates/templates/cli/README.md index b0639f7bd..b48b26ea2 100644 --- a/templates/templates/cli/README.md +++ b/templates/templates/cli/README.md @@ -1401,11 +1401,11 @@ In both help styles, `includes/descriptions.ts` omits invented payloads for opti The compact layout and `Just works:` heading are runtime help contracts. Cobra markdown generation keeps its existing `Examples` heading and operation-doc footer, and the explorer continues to read `Short`, `Long`, and `Example` without a heading rewrite. Because synthesized-operation filtering happens while `templateCmdExample` builds the command's `Example`, a rejected synthesized example is absent from docs and the explorer too; authored examples remain unchanged everywhere. Custom commands that currently append Defaults/Machine/Globals prose to `Example` should migrate that prose to the annotations above to avoid a duplicate footer, set `speakeasy_help_footer: "false"` while retaining their own footer, or choose `helpStyle: full` during migration. -The KDL schema is built at generation time from the command tree in `includes/usage.ts` and emitted at runtime by `usage.EmitSchema()`. It includes command names, aliases, help text, flags, defaults, config metadata, and the config file location. Generated flags include the same auto-assigned short forms as the parser and `--help` (for example, `flag "-i --id "` when `--id` receives `-i`). Shorthand assignment still reserves inherited global letters and the pagination `-a`; route-dispatch intents expose only shorthands for the non-body flags they register. +The KDL schema is built at generation time from the command tree in `includes/usage.ts` and emitted at runtime by `usage.EmitSchema()`. It includes command names, aliases, help text, flags, defaults, config metadata, and the config file location. Generated flags include the same auto-assigned short forms as the parser and `--help` (for example, `flag "-i --id "` when `--id` receives `-i`). Shorthand assignment still reserves inherited global letters and the pagination `-a`; route-dispatch intents expose only shorthands for the non-body flags they register. An operation's whole-body flag is listed under the name the command registers (`operationUsesMetadataFlags` in `includes/metadata.ts`, shared with `opcmd.go.stmpl`): `--body` alongside flag metadata, otherwise the single flag named after the request body (e.g. `--request`) for body-only operations whose body is not expanded into field flags. Declared commands (`x-speakeasy-cli-commands`) are part of the schema: bound intents mount into the KDL tree under their declared parent path with the same flag surface the runtime registers (declared positional `arg` nodes, operation security flags, the body flag, `--schema`, and declared flags — plus non-body parameter metadata for JSON-body intents (multipart intents keep their upload fields); `buildIntentUsageCommand` in `includes/usage.ts`), and planned placeholders appear with their summary. For commands added after static schema generation, live schema emission reads `speakeasy_prompt_args` and emits the same `arg` nodes. Once Cobra validation succeeds, `--usage` on an intent or planned command emits KDL and wins over the command's other runtime surfaces, including `--schema` and the bare-invocation usage error (`runIntent*Cmd` in `intentcmd.go.stmpl` and `newPlannedCmd` in `intents.go.stmpl` check `usage.UsageRequested` first, matching operation commands). -**`--usage` is one root-level contract**: `NewRootCommand` creates cobra's default `help` and `completion` commands (`usage.InitDefaultCommands`, wrapping `InitDefaultHelpCmd`/`InitDefaultCompletionCmd`, which cobra would otherwise add lazily inside `Execute`), applies declared command order, calls `interactive.Intercept(rootCmd)` when interactive mode is enabled, and then calls `usage.Intercept(rootCmd)` as the outer/last interceptor. Both see every generated, intent, catalog, auth and hand-written (`custom.Register` and root custom region) command. The usage interceptor walks the tree and (a) wraps each command's `Run`/`RunE` — non-runnable groups such as `auth` get a `RunE` that falls back to help — so `--usage` emits the command's KDL schema and nothing runs, and (b) folds every lifecycle hook (`PersistentPreRun(E)`, `PreRun(E)`, `PostRun(E)`, `PersistentPostRun(E)`) into a no-op under `--usage`, so a custom pre-run cannot mutate state either. Cobra's validation still surrounds those hooks: unknown flags, `--help`, non-deferred `Args` validation (`op bogus --usage` is a `NoArgs` error), and Cobra's post-PreRun flag-group validators can win. Required-flag validation cannot: under `--usage`, `output.InstallErrorHandling` skips its eager typed required-flag and flag-group checks and clears the required-flag annotations (`usage.DisarmRequiredFlags`, re-armed after `RunE`) so Cobra's own required-flag check passes; a flag-group failure retains Cobra's existing untyped/runtime classification. The interactive Args wrapper explicitly does not defer under `--usage`, and the interactive PreRunE wrapper also checks the usage/doc-surface gates. Per-command `usage.UsageRequested` checks remain (harmless) as the documented precedence. `usage.EmitSchema` composes every schema, root and subtrees alike, from the assembled Cobra tree: the generated `usageNodes` table holds each node's own lines (head, aliases, args, flags) and its declared child names, so children keep their declared order, children the runtime removed or hid are dropped, and available children attached at runtime are appended. A command absent from the table renders live from Cobra, including declared positional `arg` nodes. `NewRootCommand` calls `usage.MarkGenerated` on the tree right before `custom.Register`, and `usage.InitDefaultCommands` marks only the `help`/`completion` commands Cobra itself creates (a custom `completion` registered earlier stays unmarked). Only marked commands under the generated root use their table node, so a command attached later — including a same-named replacement of a generated command — renders live from Cobra, as does a command detached from the root or a marked command whose flag sets were replaced (`ResetFlags`). Commands added in the root `custom-commands` region run before the mark and count as generated. `usage.MarkDynamic` is for porcelain that extends a generated command in place (custom placeholders, `custom.Register` adding flags): such a command renders its `help` from the runtime `Short`, keeps its generated arg lines while the runtime `speakeasy_prompt_args` names match them, keeps its generated alias and flag lines for the aliases and flags it still has (a flag generated visible and hidden at runtime is dropped; one generated with `hide=#true` keeps that line), and renders runtime-added aliases and visible local flags live, so generated `suggestions`, `default=`, help text and declared order survive. Commands unchanged at runtime render byte-identically to the generated text. The KDL root tree carries the built-in `auth` group (`login`/`whoami`/`logout`, unless a generated command already owns `auth` by name or alias — cobra dispatches to the first registered), `explore`, catalog commands (`x-speakeasy-cli-catalog`; a catalog whose exact name is a generated group lends the group its summary, one that only matches an alias is unreachable and skipped), and cobra's `help` and `completion` (`bash`/`zsh`/`fish`/`powershell`). `cmd/gendocs` skips `help`/`completion` (utility surface, same filter as the explorer) so the docs set is unchanged. +**`--usage` is one root-level contract**: `NewRootCommand` creates cobra's default `help` and `completion` commands (`usage.InitDefaultCommands`, wrapping `InitDefaultHelpCmd`/`InitDefaultCompletionCmd`, which cobra would otherwise add lazily inside `Execute`), applies declared command order, calls `interactive.Intercept(rootCmd)` when interactive mode is enabled, and then calls `usage.Intercept(rootCmd)` as the outer/last interceptor. Both see every generated, intent, catalog, auth and hand-written (`custom.Register` and root custom region) command. The usage interceptor walks the tree and (a) wraps each command's `Run`/`RunE` — non-runnable groups such as `auth` get a `RunE` that falls back to help — so `--usage` emits the command's KDL schema and nothing runs, and (b) folds every lifecycle hook (`PersistentPreRun(E)`, `PreRun(E)`, `PostRun(E)`, `PersistentPostRun(E)`) into a no-op under `--usage`, so a custom pre-run cannot mutate state either. Cobra's validation still surrounds those hooks: unknown flags, `--help`, non-deferred `Args` validation (`op bogus --usage` is a `NoArgs` error), and Cobra's post-PreRun flag-group validators can win. Required-flag validation cannot: under `--usage`, `output.InstallErrorHandling` skips its eager typed required-flag and flag-group checks and clears the required-flag annotations (`usage.DisarmRequiredFlags`, re-armed after `RunE`) so Cobra's own required-flag check passes; a flag-group failure retains Cobra's existing untyped/runtime classification. The interactive Args wrapper explicitly does not defer under `--usage`, and the interactive PreRunE wrapper also checks the usage/doc-surface gates. Per-command `usage.UsageRequested` checks remain (harmless) as the documented precedence. `usage.EmitSchema` composes every schema, root and subtrees alike, from the assembled Cobra tree: the generated `usageNodes` table holds each node's own lines (head, aliases, args, flags) and its declared child names, so children keep their declared order, children the runtime removed or hid are dropped, and available children attached at runtime are appended. A command absent from the table renders live from Cobra, including declared positional `arg` nodes. `NewRootCommand` calls `usage.MarkGenerated` on the tree right before `custom.Register`, and `usage.InitDefaultCommands` marks only the `help`/`completion` commands Cobra itself creates (a custom `completion` registered earlier stays unmarked). Only marked commands under the generated root use their table node, so a command attached later — including a same-named replacement of a generated command — renders live from Cobra, as does a command detached from the root or a marked command whose flag sets were replaced (`ResetFlags`). Commands added in the root `custom-commands` region run before the mark and count as generated. Visible local flags a marked command gains at runtime (for example from `custom.Register`) render live after its generated lines, whether or not it is dynamic. `usage.MarkDynamic` is for porcelain that changes a generated command's help, aliases or args in place (custom placeholders): such a command renders its `help` from the runtime `Short`, keeps its generated arg lines while the runtime `speakeasy_prompt_args` names match them, keeps its generated alias and flag lines for the aliases and flags it still has (a flag generated visible and hidden at runtime is dropped; one generated with `hide=#true` keeps that line), and renders runtime-added aliases live, so generated `suggestions`, `default=`, help text and declared order survive. Commands unchanged at runtime render byte-identically to the generated text. The KDL root tree carries the built-in `auth` group (`login`/`whoami`/`logout`, unless a generated command already owns `auth` by name or alias — cobra dispatches to the first registered), `explore`, catalog commands (`x-speakeasy-cli-catalog`; a catalog whose exact name is a generated group lends the group its summary, one that only matches an alias is unreachable and skipped), and cobra's `help` and `completion` (`bash`/`zsh`/`fish`/`powershell`, each with `--no-descriptions`). `cmd/gendocs` skips `help`/`completion` (utility surface, same filter as the explorer) so the docs set is unchanged. ### Generated README & Command Docs diff --git a/templates/templates/cli/includes/metadata.ts b/templates/templates/cli/includes/metadata.ts index d4a572cc7..9b1eb14a6 100644 --- a/templates/templates/cli/includes/metadata.ts +++ b/templates/templates/cli/includes/metadata.ts @@ -48,6 +48,19 @@ function operationHasFlagMetadata(op: Operation): boolean { } registerTemplateFunc("operationHasFlagMetadata", operationHasFlagMetadata); +/** + * Whether the operation command registers its inputs from flag metadata + * (BuildRequest) rather than one whole-body flag (BuildRequestBody). + * Side-effect free (no addImport), so it is safe to call while other files render. + */ +function operationUsesMetadataFlags(op: Operation): boolean { + const built = buildOperationMetadataEntries(op); + return ( + built !== null && (built.entries.length > 0 || built.hadCandidateFields) + ); +} +registerTemplateFunc("operationUsesMetadataFlags", operationUsesMetadataFlags); + function getMixedRequestMetadataFields(op: Operation): FieldDef[] { const fields = op.Request.Field.Type.Fields || []; if (!templateHasBodyFlag(op)) return fields; diff --git a/templates/templates/cli/includes/templating.ts b/templates/templates/cli/includes/templating.ts index d5d1df19d..0dac85642 100644 --- a/templates/templates/cli/includes/templating.ts +++ b/templates/templates/cli/includes/templating.ts @@ -123,6 +123,14 @@ registerTemplateFunc("getRequestBodyFlagName", getRequestBodyFlagName); * Uses the type name to create a user-friendly description. */ function templateRequestBodyDescription(field: FieldDef): string { + return escapeGoString(requestBodyDescription(field)); +} +registerTemplateFunc( + "templateRequestBodyDescription", + templateRequestBodyDescription, +); + +function requestBodyDescription(field: FieldDef): string { let desc = ""; // Use the field's own description if available @@ -152,12 +160,8 @@ function templateRequestBodyDescription(field: FieldDef): string { desc += ". Can also be provided via stdin; @path reads a file, @- reads stdin to EOF."; - return escapeGoString(desc); + return desc; } -registerTemplateFunc( - "templateRequestBodyDescription", - templateRequestBodyDescription, -); /** * Check if an operation has any flags to register. diff --git a/templates/templates/cli/includes/usage.ts b/templates/templates/cli/includes/usage.ts index ba5a80ef4..bbd500d66 100644 --- a/templates/templates/cli/includes/usage.ts +++ b/templates/templates/cli/includes/usage.ts @@ -529,14 +529,35 @@ function getOperationNonBodyUsageFlags(op: Operation): UsageFlagDef[] { return withAutoShorthands(op, flags, true); } +// Mirrors the whole-body flag opcmd.go.stmpl registers. +function getOperationWholeBodyUsageFlag(op: Operation): UsageFlagDef | null { + if (!op.Request) return null; + if (operationUsesMetadataFlags(op)) { + if (!templateHasBodyFlag(op)) return null; + return { + spec: usageFlagSpec("body", undefined, "body"), + help: templateBodyFlagDescription(op), + }; + } + if (op.Request.IsRequestBody) { + const name = getRequestBodyFlagName(op.Request.RequestBody); + return { + spec: usageFlagSpec(name, undefined, name.replace(/-/g, "_")), + help: requestBodyDescription(op.Request.RequestBody), + }; + } + return { + spec: usageFlagSpec("body", undefined, "body"), + help: "Request body (JSON)", + }; +} + function getOperationFieldUsageFlags(op: Operation): UsageFlagDef[] { const flags = getOperationBodyFieldUsageFlags(op); - if (templateHasBodyFlag(op)) { - flags.push({ - spec: usageFlagSpec("body", undefined, "body"), - help: templateBodyFlagDescription(op), - }); + const bodyFlag = getOperationWholeBodyUsageFlag(op); + if (bodyFlag) { + flags.push(bodyFlag); } if (hasBodySchemaForOp(op)) { @@ -1403,6 +1424,12 @@ function buildRootUsageCommand(): UsageCommandDef { commands: ["bash", "zsh", "fish", "powershell"].map((shell) => ({ name: shell, help: `Generate the autocompletion script for ${shell}`, + flags: [ + { + spec: usageFlagSpec("no-descriptions"), + help: "disable completion descriptions", + }, + ], commands: [], })), }); diff --git a/templates/templates/cli/opcmd.go.stmpl b/templates/templates/cli/opcmd.go.stmpl index c5b6b1549..374fd83d4 100644 --- a/templates/templates/cli/opcmd.go.stmpl +++ b/templates/templates/cli/opcmd.go.stmpl @@ -15,19 +15,7 @@ {{- $paginationProbeHasContinuation := paginationProbeHasContinuation $paginationProbe -}} {{- $positional := getOperationPositional $op -}} {{- /* Compute request building strategy once — used by both init and run */ -}} -{{- $useMetadataFlags := false -}} -{{- if $op.Request -}} -{{- if $op.Request.IsRequestBody -}} -{{- $sm := $op.GetSerializationMethod -}} -{{- if or (and (eq $sm "multipart") (operationHasFlagMetadata $op)) (and (or (eq $sm "json") (eq $sm "form")) (isRequestBodyExpandable $op)) -}} -{{- $useMetadataFlags = true -}} -{{- end -}} -{{- else -}} -{{- if (operationHasFlagMetadata $op) -}} -{{- $useMetadataFlags = true -}} -{{- end -}} -{{- end -}} -{{- end -}} +{{- $useMetadataFlags := operationUsesMetadataFlags $op -}} {{- addImport "github.com/spf13/cobra" -}} {{- addImport "internal/output" true -}} {{- addImport "internal/client" true -}} diff --git a/templates/templates/cli/usage.go.stmpl b/templates/templates/cli/usage.go.stmpl index 390abb7c9..47351217f 100644 --- a/templates/templates/cli/usage.go.stmpl +++ b/templates/templates/cli/usage.go.stmpl @@ -132,7 +132,7 @@ func renderUsageNode(b *strings.Builder, cmd *cobra.Command, path string, indent if dynamic { body = mergedUsageBody(cmd, node, own, itemPrefix) } else { - body = staticUsageBody(node, own) + body = append(staticUsageBody(node, own), addedUsageFlags(cmd, node.flags, itemPrefix)...) } children := usageChildren(cmd, node.children) block := !isRoot && (len(body) > 0 || len(children) > 0) @@ -180,7 +180,6 @@ func staticUsageBody(node usageNode, own bool) []string { } func mergedUsageBody(cmd *cobra.Command, node usageNode, own bool, prefix string) []string { - local := cmd.LocalFlags() var body []string listed := map[string]bool{} for _, alias := range node.aliases { @@ -201,20 +200,27 @@ func mergedUsageBody(cmd *cobra.Command, node usageNode, own bool, prefix string } else { body = append(body, liveUsageArgs(cmd, prefix)...) } - listed = map[string]bool{} for _, flag := range node.flags { - listed[flag.key] = true f := cmd.Flags().Lookup(flag.key) if f != nil && (own || !flag.settings) && (flag.hidden || !f.Hidden) { body = append(body, flag.text) } } - local.VisitAll(func(f *pflag.Flag) { + return append(body, addedUsageFlags(cmd, node.flags, prefix)...) +} + +func addedUsageFlags(cmd *cobra.Command, generated []usageItem, prefix string) []string { + listed := map[string]bool{} + for _, flag := range generated { + listed[flag.key] = true + } + var lines []string + cmd.LocalFlags().VisitAll(func(f *pflag.Flag) { if !f.Hidden && f.Name != "help" && !listed[f.Name] { - body = append(body, liveUsageFlag(f, prefix)) + lines = append(lines, liveUsageFlag(f, prefix)) } }) - return body + return lines } func usageChildren(cmd *cobra.Command, declared []string) []*cobra.Command { @@ -250,9 +256,9 @@ func writeUsageIndented(b *strings.Builder, text string, indent int) { } } -// MarkDynamic makes --usage render a generated command's runtime help, aliases, -// args and added flags from its cobra definition, keeping generated lines for -// the inputs it still has. +// MarkDynamic makes --usage render a generated command's runtime help, aliases +// and args from its cobra definition, keeping generated lines for the inputs it +// still has. func MarkDynamic(cmd *cobra.Command) { if cmd.Annotations == nil { cmd.Annotations = map[string]string{} diff --git a/templates/templates/cli/usage_test.go.stmpl b/templates/templates/cli/usage_test.go.stmpl index 6a07f5aed..29ecc1678 100644 --- a/templates/templates/cli/usage_test.go.stmpl +++ b/templates/templates/cli/usage_test.go.stmpl @@ -8,9 +8,7 @@ {{- addImport "github.com/spf13/cobra" -}} {{- addImport "internal/cli" true -}} {{- addImport "internal/usage" true -}} -{{- if $custom -}} {{- addImport "github.com/spf13/pflag" -}} -{{- end -}} {{- if templatePlannedUsageTestEnabled -}} {{- addImport "internal/clierrors" true -}} {{- end -}} @@ -240,28 +238,43 @@ func TestUsageSchema_ComposesRuntimeCommands(t *testing.T) { assert.Contains(t, group.String(), "\n cmd \"runtime-leaf\" help=\"Runtime leaf\" {\n") } -func TestUsageSchema_DynamicCommandKeepsGeneratedLines(t *testing.T) { - root, err := cli.NewRootCommand() - require.NoError(t, err) - configure, _, err := root.Find([]string{"configure"}) - require.NoError(t, err) - - var ownBefore, rootBefore strings.Builder - require.NoError(t, usage.EmitSchema(configure, &ownBefore)) - require.NoError(t, usage.EmitSchema(root, &rootBefore)) - configure.Flags().String("runtime-flag", "", "Runtime flag") - usage.MarkDynamic(configure) - - var ownAfter, rootAfter strings.Builder - require.NoError(t, usage.EmitSchema(configure, &ownAfter)) - require.NoError(t, usage.EmitSchema(root, &rootAfter)) - flag := " flag \"--runtime-flag \" help=\"Runtime flag\"\n" - assert.Equal(t, strings.TrimSuffix(ownBefore.String(), "}\n")+flag+"}\n", ownAfter.String()) +func TestUsageSchema_RuntimeFlagsKeepGeneratedLines(t *testing.T) { + for _, tc := range []struct { + name string + dynamic bool + }{ {name: "generated"}, {name: "dynamic", dynamic: true} } { + t.Run(tc.name, func(t *testing.T) { + root, err := cli.NewRootCommand() + require.NoError(t, err) + configure, _, err := root.Find([]string{"configure"}) + require.NoError(t, err) + + var ownBefore, rootBefore strings.Builder + require.NoError(t, usage.EmitSchema(configure, &ownBefore)) + require.NoError(t, usage.EmitSchema(root, &rootBefore)) + configure.Flags().String("runtime-flag", "", "Runtime flag") + root.PersistentFlags().String("runtime-root", "", "Runtime root flag") + if tc.dynamic { + usage.MarkDynamic(configure) + usage.MarkDynamic(root) + } - start := strings.Index(rootBefore.String(), "\ncmd \"configure\"") - require.GreaterOrEqual(t, start, 0) - end := start + strings.Index(rootBefore.String()[start:], "\n}\n") + 1 - assert.Equal(t, rootBefore.String()[:end]+flag+rootBefore.String()[end:], rootAfter.String()) + var ownAfter, rootAfter strings.Builder + require.NoError(t, usage.EmitSchema(configure, &ownAfter)) + require.NoError(t, usage.EmitSchema(root, &rootAfter)) + flag := " flag \"--runtime-flag \" help=\"Runtime flag\"\n" + assert.Equal(t, strings.TrimSuffix(ownBefore.String(), "}\n")+flag+"}\n", ownAfter.String()) + + before := rootBefore.String() + children := strings.Index(before, "\ncmd \"") + 1 + require.Positive(t, children) + start := strings.Index(before, "\ncmd \"configure\"") + require.GreaterOrEqual(t, start, 0) + end := start + strings.Index(before[start:], "\n}\n") + 1 + rootFlag := "flag \"--runtime-root \" help=\"Runtime root flag\"\n" + assert.Equal(t, before[:children]+rootFlag+before[children:end]+flag+before[end:], rootAfter.String()) + }) + } } func TestUsageSchema_DynamicRoot(t *testing.T) { @@ -393,6 +406,35 @@ func TestUsageSchema_ResetFlagsRendersLive(t *testing.T) { assert.Contains(t, rootOut.String(), "\n"+flag) } +func TestUsageSchema_FlagsMatchRegisteredFlags(t *testing.T) { + root, err := cli.NewRootCommand() + require.NoError(t, err) + flagLine := regexp.MustCompile(`(?m)^ flag "(?:-\S )?--([^\s"<]+)`) + var walk func(cmd *cobra.Command) + walk = func(cmd *cobra.Command) { + for _, child := range cmd.Commands() { + walk(child) + } + if cmd == root { + return + } + local := cmd.LocalFlags() + var out strings.Builder + require.NoError(t, usage.EmitSchema(cmd, &out)) + listed := map[string]bool{} + for _, match := range flagLine.FindAllStringSubmatch(out.String(), -1) { + listed[match[1]] = true + assert.NotNil(t, cmd.Flags().Lookup(match[1]), "%s --usage lists unregistered --%s", cmd.CommandPath(), match[1]) + } + local.VisitAll(func(f *pflag.Flag) { + if !f.Hidden && f.Name != "help" { + assert.True(t, listed[f.Name], "%s --usage omits --%s", cmd.CommandPath(), f.Name) + } + }) + } + walk(root) +} + func TestUsageSchema_DetachedCommand(t *testing.T) { leaf := &cobra.Command{Use: "detached", Short: "Detached"} var out strings.Builder diff --git a/zSDKs/sdk-cli/.speakeasy/gen.lock b/zSDKs/sdk-cli/.speakeasy/gen.lock index 171ba9a83..da3d1cb72 100644 --- a/zSDKs/sdk-cli/.speakeasy/gen.lock +++ b/zSDKs/sdk-cli/.speakeasy/gen.lock @@ -660,7 +660,7 @@ trackedFiles: internal/usage/bodyschemas.go: last_write_checksum: sha1:775249b69ed405ad072c16a76fe6113f375b3818 internal/usage/schema.go: - last_write_checksum: sha1:13350f445bdbbcc99e06b70d23316ca7e4a632b9 + last_write_checksum: sha1:a97ab4b8337ff1e155e8dee94a57ee06de1559e5 scripts/install.ps1: last_write_checksum: sha1:9dcf40ea00ecb8ceda83d5056f57235b43cf658a scripts/install.sh: @@ -1122,7 +1122,7 @@ trackedFiles: tests/types_test.go: last_write_checksum: sha1:5ce31f08a3214779ed561d0f37279eb4d5202723 tests/usage_test.go: - last_write_checksum: sha1:6dd7b7205958c3f3f7ea18c7cbb579497327a900 + last_write_checksum: sha1:08d035295338efe1b9dc0a52f9561bfec3953fdc examples: _operation_with_leading_and_trailing_underscores_: speakeasy-default-operation-with-leading-and-trailing-underscores: diff --git a/zSDKs/sdk-cli/internal/usage/schema.go b/zSDKs/sdk-cli/internal/usage/schema.go index e28fa7dde..a53ce873f 100644 --- a/zSDKs/sdk-cli/internal/usage/schema.go +++ b/zSDKs/sdk-cli/internal/usage/schema.go @@ -47,12 +47,12 @@ var usageNodes = map[string]usageNode{ "auth logout": {head: "cmd \"logout\" help=\"Clear all stored authentication credentials\""}, "auth whoami": {head: "cmd \"whoami\" help=\"Display current authentication and global parameter configuration\""}, "binary-and-string-upload": {head: "cmd \"binary-and-string-upload\" help=\"Binary And String Upload\"", aliases: []usageItem{{key: "basu", text: " alias \"basu\""}}, flags: []usageItem{{key: "binary", text: " flag \"-b --binary \" help=\"binary data (file: or b64:)\""}, {key: "string", text: " flag \"-s --string \" help=\"string value\""}, {key: "body", text: " flag \"--body \" help=\"Request body as JSON (alternative to individual flags). Can also be provided via stdin; @path reads a file, @- reads stdin to EOF. Use --schema to print the exact JSON Schema.\""}, {key: "schema", text: " flag \"--schema\" help=\"Print the exact JSON Schema of the request body and exit\""}}}, - "chat": {head: "cmd \"chat\" help=\"Chat\"", flags: []usageItem{{key: "body", text: " flag \"--body \" help=\"Request body as JSON (alternative to individual flags). Can also be provided via stdin; @path reads a file, @- reads stdin to EOF. Use --schema to print the exact JSON Schema.\""}, {key: "schema", text: " flag \"--schema\" help=\"Print the exact JSON Schema of the request body and exit\""}, {key: "stream", text: " flag \"--stream\" help=\"Stream events as they arrive\" default=#true"}, {key: "max-tokens", text: " flag \"--max-tokens \" help=\"Maximum number of tokens to generate\""}, {key: "temperature", text: " flag \"--temperature \" help=\"Sampling temperature\""}}}, + "chat": {head: "cmd \"chat\" help=\"Chat\"", flags: []usageItem{{key: "request", text: " flag \"--request \" help=\"Request body as JSON [required]. Can also be provided via stdin; @path reads a file, @- reads stdin to EOF.\""}, {key: "schema", text: " flag \"--schema\" help=\"Print the exact JSON Schema of the request body and exit\""}, {key: "stream", text: " flag \"--stream\" help=\"Stream events as they arrive\" default=#true"}, {key: "max-tokens", text: " flag \"--max-tokens \" help=\"Maximum number of tokens to generate\""}, {key: "temperature", text: " flag \"--temperature \" help=\"Sampling temperature\""}}}, "completion": {head: "cmd \"completion\" help=\"Generate the autocompletion script for the specified shell\"", children: []string{"bash", "fish", "powershell", "zsh"}}, - "completion bash": {head: "cmd \"bash\" help=\"Generate the autocompletion script for bash\""}, - "completion fish": {head: "cmd \"fish\" help=\"Generate the autocompletion script for fish\""}, - "completion powershell": {head: "cmd \"powershell\" help=\"Generate the autocompletion script for powershell\""}, - "completion zsh": {head: "cmd \"zsh\" help=\"Generate the autocompletion script for zsh\""}, + "completion bash": {head: "cmd \"bash\" help=\"Generate the autocompletion script for bash\"", flags: []usageItem{{key: "no-descriptions", text: " flag \"--no-descriptions\" help=\"disable completion descriptions\""}}}, + "completion fish": {head: "cmd \"fish\" help=\"Generate the autocompletion script for fish\"", flags: []usageItem{{key: "no-descriptions", text: " flag \"--no-descriptions\" help=\"disable completion descriptions\""}}}, + "completion powershell": {head: "cmd \"powershell\" help=\"Generate the autocompletion script for powershell\"", flags: []usageItem{{key: "no-descriptions", text: " flag \"--no-descriptions\" help=\"disable completion descriptions\""}}}, + "completion zsh": {head: "cmd \"zsh\" help=\"Generate the autocompletion script for zsh\"", flags: []usageItem{{key: "no-descriptions", text: " flag \"--no-descriptions\" help=\"disable completion descriptions\""}}}, "configure": {head: "cmd \"configure\" help=\"Configure authentication, global parameters, and preferences\"", flags: []usageItem{{key: "default-output-format", text: " flag \"--default-output-format \" help=\"Store the default output format without opening the form. Options: pretty, json, yaml, table, toon. Pass an empty value to clear it.\" {\n suggestions \"pretty\" \"json\" \"yaml\" \"table\" \"toon\"\n }"}, {key: "username", text: " flag \"--username \" help=\"HTTP Basic username\" env=\"CLI_USERNAME\" config=\"security.username\"", settings: true}, {key: "password", text: " flag \"--password \" help=\"HTTP Basic password\" env=\"CLI_PASSWORD\" config=\"security.password\"", settings: true}, {key: "bearer-auth", text: " flag \"--bearer-auth \" help=\"HTTP Bearer\" env=\"CLI_BEARER_AUTH\" config=\"security.bearer_auth\"", settings: true}, {key: "my-api-key", text: " flag \"--my-api-key \" help=\"API Key\" env=\"CLI_MY_API_KEY\" config=\"security.my_api_key\"", settings: true}, {key: "oauth2", text: " flag \"--oauth2 \" help=\"OAuth2 Authorization\" env=\"CLI_OAUTH2\" config=\"security.oauth2\"", settings: true}, {key: "app-id", text: " flag \"--app-id \" help=\"Custom authentication credential\" env=\"CLI_APP_ID\" config=\"security.app_id\"", settings: true}, {key: "secret", text: " flag \"--secret \" help=\"Custom authentication credential\" env=\"CLI_SECRET\" config=\"security.secret\"", settings: true}, {key: "mobile-auth", text: " flag \"--mobile-auth \" help=\"\"\"\n OAuth2 Password flow, a flow that is too\n long to describe in a single line.\n \"\"\" env=\"CLI_MOBILE_AUTH\" config=\"security.mobile_auth\"", settings: true}, {key: "client-id", text: " flag \"--client-id \" help=\"Client Credentials flow. client identifier\" env=\"CLI_CLIENT_ID\" config=\"security.client_id\"", settings: true}, {key: "client-secret", text: " flag \"--client-secret \" help=\"Client Credentials flow. client secret\" env=\"CLI_CLIENT_SECRET\" config=\"security.client_secret\"", settings: true}, {key: "token-url", text: " flag \"--token-url \" help=\"Client Credentials flow. token URL\" env=\"CLI_TOKEN_URL\" config=\"security.token_url\"", settings: true}, {key: "query-param1", text: " flag \"--query-param1 \" help=\"A long winded, multi-line description\" env=\"CLI_QUERY_PARAM1\" config=\"globals.query_param1\"", settings: true}, {key: "deprecated-query-param1", text: " flag \"--deprecated-query-param1 \" help=\"A deprecated description\" env=\"CLI_DEPRECATED_QUERY_PARAM1\" config=\"globals.deprecated_query_param1\"", settings: true}, {key: "deprecated-query-param2", text: " flag \"--deprecated-query-param2 \" help=\"Global deprecated-query-param2 parameter\" env=\"CLI_DEPRECATED_QUERY_PARAM2\" config=\"globals.deprecated_query_param2\"", settings: true}, {key: "lone-query-param", text: " flag \"--lone-query-param \" help=\"Global lone-query-param parameter\" env=\"CLI_LONE_QUERY_PARAM\" config=\"globals.lone_query_param\"", settings: true}}}, "create-user": {head: "cmd \"create-user\" help=\"Create User\"", aliases: []usageItem{{key: "cu", text: " alias \"cu\""}}, flags: []usageItem{{key: "id", text: " flag \"-i --id \" help=\"string value\""}, {key: "email", text: " flag \"-e --email \" help=\"[required]\""}, {key: "first-name", text: " flag \"-f --first-name \" help=\"string value\""}, {key: "last-name", text: " flag \"-l --last-name \" help=\"string value\""}, {key: "age", text: " flag \"--age \" help=\"number value\""}, {key: "postal-code", text: " flag \"-p --postal-code \" help=\"string value\""}, {key: "gender", text: " flag \"-g --gender \" help=\"options: male, female, other\""}, {key: "associated-ids", text: " flag \"--associated-ids \" help=\"list of values\" var=#true"}, {key: "metadata", text: " flag \"--metadata \" help=\"JSON object\""}, {key: "metadata-any", text: " flag \"--metadata-any \" help=\"A metadata object with additionalProperties true (any type) - should be flattened\""}, {key: "body", text: " flag \"--body \" help=\"Request body as JSON (alternative to individual flags). Can also be provided via stdin; @path reads a file, @- reads stdin to EOF. Use --schema to print the exact JSON Schema.\""}, {key: "schema", text: " flag \"--schema\" help=\"Print the exact JSON Schema of the request body and exit\""}}}, "create-with-union": {head: "cmd \"create-with-union\" help=\"Create with discriminated union request body\"", aliases: []usageItem{{key: "cwu", text: " alias \"cwu\""}}, flags: []usageItem{{key: "dry-run-param", text: " flag \"--dry-run-param\" help=\"If true, validates without creating\""}, {key: "name", text: " flag \"-n --name \" help=\"Name for the shape [required]\""}, {key: "shape", text: " flag \"-s --shape \" help=\"{ \\\"type\\\": string, \\\"radius\\\": number, ... } | { \\\"type\\\": string, \\\"width\\\": number, \\\"height\\\": number, ... }\""}, {key: "shape.circle", text: " flag \"--shape.circle \" help=\"Circle variant as JSON\""}, {key: "shape.circle.radius", text: " flag \"--shape.circle.radius \" help=\"The radius of the circle [required]\""}, {key: "shape.circle.color-param", text: " flag \"--shape.circle.color-param \" help=\"Optional color of the circle\""}, {key: "shape.rectangle", text: " flag \"--shape.rectangle \" help=\"Rectangle variant as JSON\""}, {key: "shape.rectangle.width", text: " flag \"--shape.rectangle.width \" help=\"The width of the rectangle [required]\""}, {key: "shape.rectangle.height", text: " flag \"--shape.rectangle.height \" help=\"The height of the rectangle [required]\""}, {key: "shape.rectangle.color-param", text: " flag \"--shape.rectangle.color-param \" help=\"Optional color of the rectangle\""}, {key: "description", text: " flag \"--description \" help=\"Optional description\""}, {key: "body", text: " flag \"--body \" help=\"Request body as JSON (alternative to individual flags). Can also be provided via stdin; @path reads a file, @- reads stdin to EOF. Use --schema to print the exact JSON Schema.\""}, {key: "schema", text: " flag \"--schema\" help=\"Print the exact JSON Schema of the request body and exit\""}}}, @@ -225,7 +225,7 @@ func renderUsageNode(b *strings.Builder, cmd *cobra.Command, path string, indent if dynamic { body = mergedUsageBody(cmd, node, own, itemPrefix) } else { - body = staticUsageBody(node, own) + body = append(staticUsageBody(node, own), addedUsageFlags(cmd, node.flags, itemPrefix)...) } children := usageChildren(cmd, node.children) block := !isRoot && (len(body) > 0 || len(children) > 0) @@ -273,7 +273,6 @@ func staticUsageBody(node usageNode, own bool) []string { } func mergedUsageBody(cmd *cobra.Command, node usageNode, own bool, prefix string) []string { - local := cmd.LocalFlags() var body []string listed := map[string]bool{} for _, alias := range node.aliases { @@ -294,20 +293,27 @@ func mergedUsageBody(cmd *cobra.Command, node usageNode, own bool, prefix string } else { body = append(body, liveUsageArgs(cmd, prefix)...) } - listed = map[string]bool{} for _, flag := range node.flags { - listed[flag.key] = true f := cmd.Flags().Lookup(flag.key) if f != nil && (own || !flag.settings) && (flag.hidden || !f.Hidden) { body = append(body, flag.text) } } - local.VisitAll(func(f *pflag.Flag) { + return append(body, addedUsageFlags(cmd, node.flags, prefix)...) +} + +func addedUsageFlags(cmd *cobra.Command, generated []usageItem, prefix string) []string { + listed := map[string]bool{} + for _, flag := range generated { + listed[flag.key] = true + } + var lines []string + cmd.LocalFlags().VisitAll(func(f *pflag.Flag) { if !f.Hidden && f.Name != "help" && !listed[f.Name] { - body = append(body, liveUsageFlag(f, prefix)) + lines = append(lines, liveUsageFlag(f, prefix)) } }) - return body + return lines } func usageChildren(cmd *cobra.Command, declared []string) []*cobra.Command { @@ -343,9 +349,9 @@ func writeUsageIndented(b *strings.Builder, text string, indent int) { } } -// MarkDynamic makes --usage render a generated command's runtime help, aliases, -// args and added flags from its cobra definition, keeping generated lines for -// the inputs it still has. +// MarkDynamic makes --usage render a generated command's runtime help, aliases +// and args from its cobra definition, keeping generated lines for the inputs it +// still has. func MarkDynamic(cmd *cobra.Command) { if cmd.Annotations == nil { cmd.Annotations = map[string]string{} diff --git a/zSDKs/sdk-cli/tests/usage_test.go b/zSDKs/sdk-cli/tests/usage_test.go index 51db7dda3..60f6f9c61 100644 --- a/zSDKs/sdk-cli/tests/usage_test.go +++ b/zSDKs/sdk-cli/tests/usage_test.go @@ -227,28 +227,43 @@ func TestUsageSchema_ComposesRuntimeCommands(t *testing.T) { assert.Contains(t, group.String(), "\n cmd \"runtime-leaf\" help=\"Runtime leaf\" {\n") } -func TestUsageSchema_DynamicCommandKeepsGeneratedLines(t *testing.T) { - root, err := cli.NewRootCommand() - require.NoError(t, err) - configure, _, err := root.Find([]string{"configure"}) - require.NoError(t, err) - - var ownBefore, rootBefore strings.Builder - require.NoError(t, usage.EmitSchema(configure, &ownBefore)) - require.NoError(t, usage.EmitSchema(root, &rootBefore)) - configure.Flags().String("runtime-flag", "", "Runtime flag") - usage.MarkDynamic(configure) - - var ownAfter, rootAfter strings.Builder - require.NoError(t, usage.EmitSchema(configure, &ownAfter)) - require.NoError(t, usage.EmitSchema(root, &rootAfter)) - flag := " flag \"--runtime-flag \" help=\"Runtime flag\"\n" - assert.Equal(t, strings.TrimSuffix(ownBefore.String(), "}\n")+flag+"}\n", ownAfter.String()) +func TestUsageSchema_RuntimeFlagsKeepGeneratedLines(t *testing.T) { + for _, tc := range []struct { + name string + dynamic bool + }{{name: "generated"}, {name: "dynamic", dynamic: true}} { + t.Run(tc.name, func(t *testing.T) { + root, err := cli.NewRootCommand() + require.NoError(t, err) + configure, _, err := root.Find([]string{"configure"}) + require.NoError(t, err) + + var ownBefore, rootBefore strings.Builder + require.NoError(t, usage.EmitSchema(configure, &ownBefore)) + require.NoError(t, usage.EmitSchema(root, &rootBefore)) + configure.Flags().String("runtime-flag", "", "Runtime flag") + root.PersistentFlags().String("runtime-root", "", "Runtime root flag") + if tc.dynamic { + usage.MarkDynamic(configure) + usage.MarkDynamic(root) + } - start := strings.Index(rootBefore.String(), "\ncmd \"configure\"") - require.GreaterOrEqual(t, start, 0) - end := start + strings.Index(rootBefore.String()[start:], "\n}\n") + 1 - assert.Equal(t, rootBefore.String()[:end]+flag+rootBefore.String()[end:], rootAfter.String()) + var ownAfter, rootAfter strings.Builder + require.NoError(t, usage.EmitSchema(configure, &ownAfter)) + require.NoError(t, usage.EmitSchema(root, &rootAfter)) + flag := " flag \"--runtime-flag \" help=\"Runtime flag\"\n" + assert.Equal(t, strings.TrimSuffix(ownBefore.String(), "}\n")+flag+"}\n", ownAfter.String()) + + before := rootBefore.String() + children := strings.Index(before, "\ncmd \"") + 1 + require.Positive(t, children) + start := strings.Index(before, "\ncmd \"configure\"") + require.GreaterOrEqual(t, start, 0) + end := start + strings.Index(before[start:], "\n}\n") + 1 + rootFlag := "flag \"--runtime-root \" help=\"Runtime root flag\"\n" + assert.Equal(t, before[:children]+rootFlag+before[children:end]+flag+before[end:], rootAfter.String()) + }) + } } func TestUsageSchema_DynamicRoot(t *testing.T) { @@ -380,6 +395,35 @@ func TestUsageSchema_ResetFlagsRendersLive(t *testing.T) { assert.Contains(t, rootOut.String(), "\n"+flag) } +func TestUsageSchema_FlagsMatchRegisteredFlags(t *testing.T) { + root, err := cli.NewRootCommand() + require.NoError(t, err) + flagLine := regexp.MustCompile(`(?m)^ flag "(?:-\S )?--([^\s"<]+)`) + var walk func(cmd *cobra.Command) + walk = func(cmd *cobra.Command) { + for _, child := range cmd.Commands() { + walk(child) + } + if cmd == root { + return + } + local := cmd.LocalFlags() + var out strings.Builder + require.NoError(t, usage.EmitSchema(cmd, &out)) + listed := map[string]bool{} + for _, match := range flagLine.FindAllStringSubmatch(out.String(), -1) { + listed[match[1]] = true + assert.NotNil(t, cmd.Flags().Lookup(match[1]), "%s --usage lists unregistered --%s", cmd.CommandPath(), match[1]) + } + local.VisitAll(func(f *pflag.Flag) { + if !f.Hidden && f.Name != "help" { + assert.True(t, listed[f.Name], "%s --usage omits --%s", cmd.CommandPath(), f.Name) + } + }) + } + walk(root) +} + func TestUsageSchema_DetachedCommand(t *testing.T) { leaf := &cobra.Command{Use: "detached", Short: "Detached"} var out strings.Builder From 1f3e44fabfaf99ed60c96471d46120a76bf5ffa6 Mon Sep 17 00:00:00 2001 From: 2ynn Date: Mon, 19 Oct 2026 15:42:02 -0400 Subject: [PATCH 2/3] fix(cli): check the --usage table, follow runtime-hidden flags, mark only enforced bodies required - Every command rendered from its `usageNodes` entry now takes its flags from cobra (`usageFlagLines`), marked dynamic or not: generated flag lines for the flags it still shows (a flag hidden at runtime is dropped), then visible local flags added at runtime. `usageFlagLines` reads `LocalFlags()` first, which merges persistent flags into `Flags()` before the lookups; without it a root marked dynamic before its first render dropped every generated root flag. - The whole-body flag help says `[required]` only when the body is neither optional nor nullable, matching `templateRequestBodyRequired`. This also corrects `--help` for nullable body-only operations. - `operationUsesMetadataFlags` replaces `intentOperationHasMeta`; the unused `operationHasFlagMetadata` is removed. - `--usage` reads the unescaped `--body` help (`bodyFlagDescription`); the template wrapper still escapes it for Go source. Tests: the empty-body snapshot test checks the generated `usageNodes` entries of body-only operations (registered whole-body flag, no `--body`, `[required]` exactly when the command requires it) and `--no-descriptions` on the completion shells, since the rendered output also lists flags missing from the table. `TestUsageSchema_FlagsMatchRegisteredFlags` covers the root; new `TestUsageSchema_RuntimeHiddenFlagDropped`; `TestUsageSchema_DynamicRoot` marks a fresh root dynamic before its first render. README and review SDK updated. --- .changesets/1792431953-ffc1a3ac.yaml | 2 +- .../snapshots/cli_empty_body_go_test.go | 18 +++++++++ templates/templates/cli/README.md | 4 +- templates/templates/cli/includes/intents.ts | 23 ++---------- templates/templates/cli/includes/metadata.ts | 28 +++++++------- .../templates/cli/includes/templating.ts | 2 +- templates/templates/cli/includes/usage.ts | 2 +- templates/templates/cli/usage.go.stmpl | 37 ++++++++----------- templates/templates/cli/usage_test.go.stmpl | 23 +++++++++++- zSDKs/sdk-cli/.speakeasy/gen.lock | 4 +- zSDKs/sdk-cli/internal/usage/schema.go | 37 ++++++++----------- zSDKs/sdk-cli/tests/usage_test.go | 23 +++++++++++- 12 files changed, 115 insertions(+), 88 deletions(-) diff --git a/.changesets/1792431953-ffc1a3ac.yaml b/.changesets/1792431953-ffc1a3ac.yaml index fe54b3b1e..c8f7a8f6c 100644 --- a/.changesets/1792431953-ffc1a3ac.yaml +++ b/.changesets/1792431953-ffc1a3ac.yaml @@ -5,6 +5,6 @@ targets: - cli type: fix bump: patch -description: 'list the flags each command registers in --usage: the whole-body flag of body-only operations under its registered name, --no-descriptions on completion shells, and flags added to generated commands at runtime' +description: 'list the flags each command registers in --usage: the whole-body flag of body-only operations under its registered name, --no-descriptions on completion shells, and flags added to generated commands at runtime; --help and --usage mark a whole-body flag [required] only when the body is neither optional nor nullable' author: 2ynn date: "2026-10-19" diff --git a/pkg/generate/snapshots/cli_empty_body_go_test.go b/pkg/generate/snapshots/cli_empty_body_go_test.go index 02469ac55..c3246fb09 100644 --- a/pkg/generate/snapshots/cli_empty_body_go_test.go +++ b/pkg/generate/snapshots/cli_empty_body_go_test.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "regexp" + "strings" "testing" "github.com/speakeasy-api/openapi-generation/v2/pkg/generate/snapshots/snaptest" @@ -235,6 +236,23 @@ func TestSnapCLIEmptyBodyExamplesAndFlags(t *testing.T) { require.Contains(t, node[2], "--body ") require.NotContains(t, node[2], "--body-param") } + wholeBodyFlagPattern := regexp.MustCompile(`AnnotationWholeBodyFlag\] = "([^"]+)"`) + for _, name := range []string{"standaloneoptional", "standalonerequired", "standalonenullable"} { + command := readCommand(name) + flagName := wholeBodyFlagPattern.FindStringSubmatch(command) + require.Len(t, flagName, 2, name) + node := regexp.MustCompile(`"widgets ` + name + `":\s*\{(.*)\}`).FindStringSubmatch(string(usageSource)) + require.Len(t, node, 2, name) + require.Contains(t, node[1], "--"+flagName[1]+" <", name) + require.NotContains(t, node[1], "--body ", name) + required := strings.Contains(command, `PromptFlagSpec{Required: true, Kind: "json", BodyFlag: true}`) + require.Equal(t, required, strings.Contains(node[1], "[required]"), name) + } + completionNodes := regexp.MustCompile(`"completion (bash|zsh|fish|powershell)":\s*\{(.*)\}`).FindAllStringSubmatch(string(usageSource), -1) + require.Len(t, completionNodes, 4) + for _, node := range completionNodes { + require.Contains(t, node[2], `\"--no-descriptions\"`, node[1]) + } for _, name := range []string{"required", "formrequired"} { command := readCommand(name) require.Regexp(t, examplePattern, command) diff --git a/templates/templates/cli/README.md b/templates/templates/cli/README.md index b48b26ea2..c8cf8fa82 100644 --- a/templates/templates/cli/README.md +++ b/templates/templates/cli/README.md @@ -1401,11 +1401,11 @@ In both help styles, `includes/descriptions.ts` omits invented payloads for opti The compact layout and `Just works:` heading are runtime help contracts. Cobra markdown generation keeps its existing `Examples` heading and operation-doc footer, and the explorer continues to read `Short`, `Long`, and `Example` without a heading rewrite. Because synthesized-operation filtering happens while `templateCmdExample` builds the command's `Example`, a rejected synthesized example is absent from docs and the explorer too; authored examples remain unchanged everywhere. Custom commands that currently append Defaults/Machine/Globals prose to `Example` should migrate that prose to the annotations above to avoid a duplicate footer, set `speakeasy_help_footer: "false"` while retaining their own footer, or choose `helpStyle: full` during migration. -The KDL schema is built at generation time from the command tree in `includes/usage.ts` and emitted at runtime by `usage.EmitSchema()`. It includes command names, aliases, help text, flags, defaults, config metadata, and the config file location. Generated flags include the same auto-assigned short forms as the parser and `--help` (for example, `flag "-i --id "` when `--id` receives `-i`). Shorthand assignment still reserves inherited global letters and the pagination `-a`; route-dispatch intents expose only shorthands for the non-body flags they register. An operation's whole-body flag is listed under the name the command registers (`operationUsesMetadataFlags` in `includes/metadata.ts`, shared with `opcmd.go.stmpl`): `--body` alongside flag metadata, otherwise the single flag named after the request body (e.g. `--request`) for body-only operations whose body is not expanded into field flags. +The KDL schema is built at generation time from the command tree in `includes/usage.ts` and emitted at runtime by `usage.EmitSchema()`. It includes command names, aliases, help text, flags, defaults, config metadata, and the config file location. Generated flags include the same auto-assigned short forms as the parser and `--help` (for example, `flag "-i --id "` when `--id` receives `-i`). Shorthand assignment still reserves inherited global letters and the pagination `-a`; route-dispatch intents expose only shorthands for the non-body flags they register. An operation's whole-body flag is listed under the name the command registers: `--body` alongside flag metadata, otherwise the single flag named after the request body (e.g. `--request`) for body-only operations whose body is not expanded into field flags (`operationUsesMetadataFlags` in `includes/metadata.ts`, shared with `opcmd.go.stmpl` and `includes/intents.ts`). Its help text matches `--help`; `[required]` marks a body that is neither optional nor nullable. Declared commands (`x-speakeasy-cli-commands`) are part of the schema: bound intents mount into the KDL tree under their declared parent path with the same flag surface the runtime registers (declared positional `arg` nodes, operation security flags, the body flag, `--schema`, and declared flags — plus non-body parameter metadata for JSON-body intents (multipart intents keep their upload fields); `buildIntentUsageCommand` in `includes/usage.ts`), and planned placeholders appear with their summary. For commands added after static schema generation, live schema emission reads `speakeasy_prompt_args` and emits the same `arg` nodes. Once Cobra validation succeeds, `--usage` on an intent or planned command emits KDL and wins over the command's other runtime surfaces, including `--schema` and the bare-invocation usage error (`runIntent*Cmd` in `intentcmd.go.stmpl` and `newPlannedCmd` in `intents.go.stmpl` check `usage.UsageRequested` first, matching operation commands). -**`--usage` is one root-level contract**: `NewRootCommand` creates cobra's default `help` and `completion` commands (`usage.InitDefaultCommands`, wrapping `InitDefaultHelpCmd`/`InitDefaultCompletionCmd`, which cobra would otherwise add lazily inside `Execute`), applies declared command order, calls `interactive.Intercept(rootCmd)` when interactive mode is enabled, and then calls `usage.Intercept(rootCmd)` as the outer/last interceptor. Both see every generated, intent, catalog, auth and hand-written (`custom.Register` and root custom region) command. The usage interceptor walks the tree and (a) wraps each command's `Run`/`RunE` — non-runnable groups such as `auth` get a `RunE` that falls back to help — so `--usage` emits the command's KDL schema and nothing runs, and (b) folds every lifecycle hook (`PersistentPreRun(E)`, `PreRun(E)`, `PostRun(E)`, `PersistentPostRun(E)`) into a no-op under `--usage`, so a custom pre-run cannot mutate state either. Cobra's validation still surrounds those hooks: unknown flags, `--help`, non-deferred `Args` validation (`op bogus --usage` is a `NoArgs` error), and Cobra's post-PreRun flag-group validators can win. Required-flag validation cannot: under `--usage`, `output.InstallErrorHandling` skips its eager typed required-flag and flag-group checks and clears the required-flag annotations (`usage.DisarmRequiredFlags`, re-armed after `RunE`) so Cobra's own required-flag check passes; a flag-group failure retains Cobra's existing untyped/runtime classification. The interactive Args wrapper explicitly does not defer under `--usage`, and the interactive PreRunE wrapper also checks the usage/doc-surface gates. Per-command `usage.UsageRequested` checks remain (harmless) as the documented precedence. `usage.EmitSchema` composes every schema, root and subtrees alike, from the assembled Cobra tree: the generated `usageNodes` table holds each node's own lines (head, aliases, args, flags) and its declared child names, so children keep their declared order, children the runtime removed or hid are dropped, and available children attached at runtime are appended. A command absent from the table renders live from Cobra, including declared positional `arg` nodes. `NewRootCommand` calls `usage.MarkGenerated` on the tree right before `custom.Register`, and `usage.InitDefaultCommands` marks only the `help`/`completion` commands Cobra itself creates (a custom `completion` registered earlier stays unmarked). Only marked commands under the generated root use their table node, so a command attached later — including a same-named replacement of a generated command — renders live from Cobra, as does a command detached from the root or a marked command whose flag sets were replaced (`ResetFlags`). Commands added in the root `custom-commands` region run before the mark and count as generated. Visible local flags a marked command gains at runtime (for example from `custom.Register`) render live after its generated lines, whether or not it is dynamic. `usage.MarkDynamic` is for porcelain that changes a generated command's help, aliases or args in place (custom placeholders): such a command renders its `help` from the runtime `Short`, keeps its generated arg lines while the runtime `speakeasy_prompt_args` names match them, keeps its generated alias and flag lines for the aliases and flags it still has (a flag generated visible and hidden at runtime is dropped; one generated with `hide=#true` keeps that line), and renders runtime-added aliases live, so generated `suggestions`, `default=`, help text and declared order survive. Commands unchanged at runtime render byte-identically to the generated text. The KDL root tree carries the built-in `auth` group (`login`/`whoami`/`logout`, unless a generated command already owns `auth` by name or alias — cobra dispatches to the first registered), `explore`, catalog commands (`x-speakeasy-cli-catalog`; a catalog whose exact name is a generated group lends the group its summary, one that only matches an alias is unreachable and skipped), and cobra's `help` and `completion` (`bash`/`zsh`/`fish`/`powershell`, each with `--no-descriptions`). `cmd/gendocs` skips `help`/`completion` (utility surface, same filter as the explorer) so the docs set is unchanged. +**`--usage` is one root-level contract**: `NewRootCommand` creates cobra's default `help` and `completion` commands (`usage.InitDefaultCommands`, wrapping `InitDefaultHelpCmd`/`InitDefaultCompletionCmd`, which cobra would otherwise add lazily inside `Execute`), applies declared command order, calls `interactive.Intercept(rootCmd)` when interactive mode is enabled, and then calls `usage.Intercept(rootCmd)` as the outer/last interceptor. Both see every generated, intent, catalog, auth and hand-written (`custom.Register` and root custom region) command. The usage interceptor walks the tree and (a) wraps each command's `Run`/`RunE` — non-runnable groups such as `auth` get a `RunE` that falls back to help — so `--usage` emits the command's KDL schema and nothing runs, and (b) folds every lifecycle hook (`PersistentPreRun(E)`, `PreRun(E)`, `PostRun(E)`, `PersistentPostRun(E)`) into a no-op under `--usage`, so a custom pre-run cannot mutate state either. Cobra's validation still surrounds those hooks: unknown flags, `--help`, non-deferred `Args` validation (`op bogus --usage` is a `NoArgs` error), and Cobra's post-PreRun flag-group validators can win. Required-flag validation cannot: under `--usage`, `output.InstallErrorHandling` skips its eager typed required-flag and flag-group checks and clears the required-flag annotations (`usage.DisarmRequiredFlags`, re-armed after `RunE`) so Cobra's own required-flag check passes; a flag-group failure retains Cobra's existing untyped/runtime classification. The interactive Args wrapper explicitly does not defer under `--usage`, and the interactive PreRunE wrapper also checks the usage/doc-surface gates. Per-command `usage.UsageRequested` checks remain (harmless) as the documented precedence. `usage.EmitSchema` composes every schema, root and subtrees alike, from the assembled Cobra tree: the generated `usageNodes` table holds each node's own lines (head, aliases, args, flags) and its declared child names, so children keep their declared order, children the runtime removed or hid are dropped, and available children attached at runtime are appended. A command absent from the table renders live from Cobra, including declared positional `arg` nodes. `NewRootCommand` calls `usage.MarkGenerated` on the tree right before `custom.Register`, and `usage.InitDefaultCommands` marks only the `help`/`completion` commands Cobra itself creates (a custom `completion` registered earlier stays unmarked). Only marked commands under the generated root use their table node, so a command attached later — including a same-named replacement of a generated command — renders live from Cobra, as does a command detached from the root or a marked command whose flag sets were replaced (`ResetFlags`). Commands added in the root `custom-commands` region run before the mark and count as generated. Whether or not a marked command is dynamic, its flags follow cobra: generated flag lines are kept, in declared order, for the flags it still shows (a flag generated visible and hidden at runtime is dropped; one generated with `hide=#true` keeps that line), and visible local flags it gains at runtime (for example from `custom.Register`) render live after them. `usage.MarkDynamic` is for porcelain that changes a generated command's help, aliases or args in place (custom placeholders): such a command renders its `help` from the runtime `Short`, keeps its generated arg lines while the runtime `speakeasy_prompt_args` names match them, keeps its generated alias lines for the aliases it still has, and renders runtime-added aliases live, so generated `suggestions`, `default=`, help text and declared order survive. Commands unchanged at runtime render byte-identically to the generated text. The KDL root tree carries the built-in `auth` group (`login`/`whoami`/`logout`, unless a generated command already owns `auth` by name or alias — cobra dispatches to the first registered), `explore`, catalog commands (`x-speakeasy-cli-catalog`; a catalog whose exact name is a generated group lends the group its summary, one that only matches an alias is unreachable and skipped), and cobra's `help` and `completion` (`bash`/`zsh`/`fish`/`powershell`, each with `--no-descriptions`). `cmd/gendocs` skips `help`/`completion` (utility surface, same filter as the explorer) so the docs set is unchanged. ### Generated README & Command Docs diff --git a/templates/templates/cli/includes/intents.ts b/templates/templates/cli/includes/intents.ts index 7220ffe00..960b2356e 100644 --- a/templates/templates/cli/includes/intents.ts +++ b/templates/templates/cli/includes/intents.ts @@ -842,7 +842,7 @@ function collectIntentManifest(): IntentManifestCtx { const hintsJSON = Object.keys(sortedHints).length > 0 ? JSON.stringify(sortedHints) : ""; - const hasMeta = intentOperationHasMeta(found.op); + const hasMeta = operationUsesMetadataFlags(found.op); const args: IntentBodyEntry[] = []; let minArgs = 0; for (const a of cmd.Args || []) { @@ -1451,7 +1451,7 @@ function cliOperationDeclaration(op: Operation): any | null { function operationBodyFlagSurfaces(op: Operation): string[] { if (!op.Request) return []; const names: string[] = []; - if (intentOperationHasMeta(op)) { + if (operationUsesMetadataFlags(op)) { if (templateHasBodyFlag(op)) names.push("body"); const whole = wholeBodyFlagName(op); if (whole) names.push(whole); @@ -1493,7 +1493,7 @@ function operationGeneratedFlagNames(op: Operation): Set { if (templateHasBodyFlag(op)) names.add("body"); const whole = wholeBodyFlagName(op); if (whole) names.add(whole); - if (op.Request?.IsRequestBody && !intentOperationHasMeta(op)) { + if (op.Request?.IsRequestBody && !operationUsesMetadataFlags(op)) { names.add(getRequestBodyFlagName(op.Request.RequestBody)); } if (hasBodySchemaForOp(op)) names.add("schema"); @@ -1705,23 +1705,6 @@ function collectOperationNonBodyFlagMeta( return flags; } -// intentOperationHasMeta mirrors templateFlagMetadataVar's decision about -// whether the operation emits a flag-metadata variable, without that -// function's addImport side effect (this runs while other files render). -function intentOperationHasMeta(op: Operation): boolean { - if (!op.Request) return false; - if (op.Request.IsRequestBody) { - const serMethod = op.SerializationMethod?.toString() || ""; - if (serMethod === "multipart" || isRequestBodyExpandable(op)) { - const fields = op.Request.RequestBody.Type.Fields || []; - return fields.some((f: FieldDef) => !f.Const); - } - return false; // complex JSON bodies use BuildRequestBody, no metadata var - } - const fields = op.Request.Field.Type.Fields || []; - return fields.some((f: FieldDef) => !f.Const); -} - // Two bound commands emitted into different packages that share a Go // identifier cannot both be imported from intents.go; fail generation with // the fix named instead of emitting uncompilable code. diff --git a/templates/templates/cli/includes/metadata.ts b/templates/templates/cli/includes/metadata.ts index 9b1eb14a6..2ce248395 100644 --- a/templates/templates/cli/includes/metadata.ts +++ b/templates/templates/cli/includes/metadata.ts @@ -39,18 +39,12 @@ function isRequestBodyExpandable(op: Operation): boolean { } registerTemplateFunc("isRequestBodyExpandable", isRequestBodyExpandable); -/** - * Check if an operation will generate flag metadata. - * Returns false for operations with scalar request bodies or empty request types. - */ -function operationHasFlagMetadata(op: Operation): boolean { - return templateFlagMetadataVar(op) !== ""; -} -registerTemplateFunc("operationHasFlagMetadata", operationHasFlagMetadata); - /** * Whether the operation command registers its inputs from flag metadata - * (BuildRequest) rather than one whole-body flag (BuildRequestBody). + * (BuildRequest) rather than one whole-body flag (BuildRequestBody). Mirrors + * templateFlagMetadataVar's decision about whether the operation emits a + * flag-metadata variable. Returns false for operations with scalar request + * bodies or empty request types. * Side-effect free (no addImport), so it is safe to call while other files render. */ function operationUsesMetadataFlags(op: Operation): boolean { @@ -1003,6 +997,14 @@ registerTemplateFunc("templateEmptyBodyRequired", templateEmptyBodyRequired); * Generate the description for the --body flag. */ function templateBodyFlagDescription(op: Operation): string { + return escapeGoString(bodyFlagDescription(op)); +} +registerTemplateFunc( + "templateBodyFlagDescription", + templateBodyFlagDescription, +); + +function bodyFlagDescription(op: Operation): string { const hasEmptyBody = op.Request?.RequestBody && isEmptyRequestBodyClass(op.Request.RequestBody) && @@ -1013,12 +1015,8 @@ function templateBodyFlagDescription(op: Operation): string { if (hasBodySchemaForOp(op)) { description += " Use --schema to print the exact JSON Schema."; } - return escapeGoString(description); + return description; } -registerTemplateFunc( - "templateBodyFlagDescription", - templateBodyFlagDescription, -); /** * Generate the BuildRequest call expression (no `return` prefix). diff --git a/templates/templates/cli/includes/templating.ts b/templates/templates/cli/includes/templating.ts index 0dac85642..f87f7d2f9 100644 --- a/templates/templates/cli/includes/templating.ts +++ b/templates/templates/cli/includes/templating.ts @@ -154,7 +154,7 @@ function requestBodyDescription(field: FieldDef): string { desc = "Request body as JSON"; } - if (!field.Optional) { + if (!field.Optional && !field.Nullable) { desc += " [required]"; } desc += diff --git a/templates/templates/cli/includes/usage.ts b/templates/templates/cli/includes/usage.ts index bbd500d66..2eb022da9 100644 --- a/templates/templates/cli/includes/usage.ts +++ b/templates/templates/cli/includes/usage.ts @@ -536,7 +536,7 @@ function getOperationWholeBodyUsageFlag(op: Operation): UsageFlagDef | null { if (!templateHasBodyFlag(op)) return null; return { spec: usageFlagSpec("body", undefined, "body"), - help: templateBodyFlagDescription(op), + help: bodyFlagDescription(op), }; } if (op.Request.IsRequestBody) { diff --git a/templates/templates/cli/usage.go.stmpl b/templates/templates/cli/usage.go.stmpl index 47351217f..6e4d3ad1e 100644 --- a/templates/templates/cli/usage.go.stmpl +++ b/templates/templates/cli/usage.go.stmpl @@ -132,7 +132,7 @@ func renderUsageNode(b *strings.Builder, cmd *cobra.Command, path string, indent if dynamic { body = mergedUsageBody(cmd, node, own, itemPrefix) } else { - body = append(staticUsageBody(node, own), addedUsageFlags(cmd, node.flags, itemPrefix)...) + body = staticUsageBody(cmd, node, own, itemPrefix) } children := usageChildren(cmd, node.children) block := !isRoot && (len(body) > 0 || len(children) > 0) @@ -162,8 +162,7 @@ func renderUsageNode(b *strings.Builder, cmd *cobra.Command, path string, indent } } -// Settings flags are declared once on the root, so only a command's own schema lists them. -func staticUsageBody(node usageNode, own bool) []string { +func staticUsageBody(cmd *cobra.Command, node usageNode, own bool, prefix string) []string { var body []string for _, alias := range node.aliases { body = append(body, alias.text) @@ -171,12 +170,7 @@ func staticUsageBody(node usageNode, own bool) []string { for _, arg := range node.args { body = append(body, arg.text) } - for _, flag := range node.flags { - if own || !flag.settings { - body = append(body, flag.text) - } - } - return body + return append(body, usageFlagLines(cmd, node.flags, own, prefix)...) } func mergedUsageBody(cmd *cobra.Command, node usageNode, own bool, prefix string) []string { @@ -200,22 +194,23 @@ func mergedUsageBody(cmd *cobra.Command, node usageNode, own bool, prefix string } else { body = append(body, liveUsageArgs(cmd, prefix)...) } - for _, flag := range node.flags { - f := cmd.Flags().Lookup(flag.key) - if f != nil && (own || !flag.settings) && (flag.hidden || !f.Hidden) { - body = append(body, flag.text) - } - } - return append(body, addedUsageFlags(cmd, node.flags, prefix)...) + return append(body, usageFlagLines(cmd, node.flags, own, prefix)...) } -func addedUsageFlags(cmd *cobra.Command, generated []usageItem, prefix string) []string { +// Settings flags are declared once on the root, so only a command's own schema lists them. +func usageFlagLines(cmd *cobra.Command, generated []usageItem, own bool, prefix string) []string { + // LocalFlags merges persistent flags into Flags() before the lookups below. + local := cmd.LocalFlags() + var lines []string listed := map[string]bool{} for _, flag := range generated { listed[flag.key] = true + f := cmd.Flags().Lookup(flag.key) + if f != nil && (own || !flag.settings) && (flag.hidden || !f.Hidden) { + lines = append(lines, flag.text) + } } - var lines []string - cmd.LocalFlags().VisitAll(func(f *pflag.Flag) { + local.VisitAll(func(f *pflag.Flag) { if !f.Hidden && f.Name != "help" && !listed[f.Name] { lines = append(lines, liveUsageFlag(f, prefix)) } @@ -257,8 +252,8 @@ func writeUsageIndented(b *strings.Builder, text string, indent int) { } // MarkDynamic makes --usage render a generated command's runtime help, aliases -// and args from its cobra definition, keeping generated lines for the inputs it -// still has. +// and args from its cobra definition, keeping generated lines for the aliases +// and args it still has. func MarkDynamic(cmd *cobra.Command) { if cmd.Annotations == nil { cmd.Annotations = map[string]string{} diff --git a/templates/templates/cli/usage_test.go.stmpl b/templates/templates/cli/usage_test.go.stmpl index 29ecc1678..0ac495b46 100644 --- a/templates/templates/cli/usage_test.go.stmpl +++ b/templates/templates/cli/usage_test.go.stmpl @@ -283,6 +283,9 @@ func TestUsageSchema_DynamicRoot(t *testing.T) { var before strings.Builder require.NoError(t, usage.EmitSchema(root, &before)) + + root, err = cli.NewRootCommand() + require.NoError(t, err) usage.MarkDynamic(root) var unchanged strings.Builder @@ -298,6 +301,20 @@ func TestUsageSchema_DynamicRoot(t *testing.T) { assert.Equal(t, want, after.String()) } +func TestUsageSchema_RuntimeHiddenFlagDropped(t *testing.T) { + root, err := cli.NewRootCommand() + require.NoError(t, err) + configure, _, err := root.Find([]string{"configure"}) + require.NoError(t, err) + + var before, after strings.Builder + require.NoError(t, usage.EmitSchema(configure, &before)) + require.Contains(t, before.String(), "--default-output-format") + require.NoError(t, configure.Flags().MarkHidden("default-output-format")) + require.NoError(t, usage.EmitSchema(configure, &after)) + assert.NotContains(t, after.String(), "--default-output-format") +} + func TestUsageSchema_HiddenGeneratedCommandDropped(t *testing.T) { root, err := cli.NewRootCommand() require.NoError(t, err) @@ -409,14 +426,16 @@ func TestUsageSchema_ResetFlagsRendersLive(t *testing.T) { func TestUsageSchema_FlagsMatchRegisteredFlags(t *testing.T) { root, err := cli.NewRootCommand() require.NoError(t, err) - flagLine := regexp.MustCompile(`(?m)^ flag "(?:-\S )?--([^\s"<]+)`) + childFlagLine := regexp.MustCompile(`(?m)^ flag "(?:-\S )?--([^\s"<]+)`) + rootFlagLine := regexp.MustCompile(`(?m)^flag "(?:-\S )?--([^\s"<]+)`) var walk func(cmd *cobra.Command) walk = func(cmd *cobra.Command) { for _, child := range cmd.Commands() { walk(child) } + flagLine := childFlagLine if cmd == root { - return + flagLine = rootFlagLine } local := cmd.LocalFlags() var out strings.Builder diff --git a/zSDKs/sdk-cli/.speakeasy/gen.lock b/zSDKs/sdk-cli/.speakeasy/gen.lock index da3d1cb72..d7ad71607 100644 --- a/zSDKs/sdk-cli/.speakeasy/gen.lock +++ b/zSDKs/sdk-cli/.speakeasy/gen.lock @@ -660,7 +660,7 @@ trackedFiles: internal/usage/bodyschemas.go: last_write_checksum: sha1:775249b69ed405ad072c16a76fe6113f375b3818 internal/usage/schema.go: - last_write_checksum: sha1:a97ab4b8337ff1e155e8dee94a57ee06de1559e5 + last_write_checksum: sha1:92fdc3775fa21a6a8283bcaa762beac6faf40e57 scripts/install.ps1: last_write_checksum: sha1:9dcf40ea00ecb8ceda83d5056f57235b43cf658a scripts/install.sh: @@ -1122,7 +1122,7 @@ trackedFiles: tests/types_test.go: last_write_checksum: sha1:5ce31f08a3214779ed561d0f37279eb4d5202723 tests/usage_test.go: - last_write_checksum: sha1:08d035295338efe1b9dc0a52f9561bfec3953fdc + last_write_checksum: sha1:669cde3d70a1cad63c4d9ca19b4d1ec5db180f39 examples: _operation_with_leading_and_trailing_underscores_: speakeasy-default-operation-with-leading-and-trailing-underscores: diff --git a/zSDKs/sdk-cli/internal/usage/schema.go b/zSDKs/sdk-cli/internal/usage/schema.go index a53ce873f..6a254fedc 100644 --- a/zSDKs/sdk-cli/internal/usage/schema.go +++ b/zSDKs/sdk-cli/internal/usage/schema.go @@ -225,7 +225,7 @@ func renderUsageNode(b *strings.Builder, cmd *cobra.Command, path string, indent if dynamic { body = mergedUsageBody(cmd, node, own, itemPrefix) } else { - body = append(staticUsageBody(node, own), addedUsageFlags(cmd, node.flags, itemPrefix)...) + body = staticUsageBody(cmd, node, own, itemPrefix) } children := usageChildren(cmd, node.children) block := !isRoot && (len(body) > 0 || len(children) > 0) @@ -255,8 +255,7 @@ func renderUsageNode(b *strings.Builder, cmd *cobra.Command, path string, indent } } -// Settings flags are declared once on the root, so only a command's own schema lists them. -func staticUsageBody(node usageNode, own bool) []string { +func staticUsageBody(cmd *cobra.Command, node usageNode, own bool, prefix string) []string { var body []string for _, alias := range node.aliases { body = append(body, alias.text) @@ -264,12 +263,7 @@ func staticUsageBody(node usageNode, own bool) []string { for _, arg := range node.args { body = append(body, arg.text) } - for _, flag := range node.flags { - if own || !flag.settings { - body = append(body, flag.text) - } - } - return body + return append(body, usageFlagLines(cmd, node.flags, own, prefix)...) } func mergedUsageBody(cmd *cobra.Command, node usageNode, own bool, prefix string) []string { @@ -293,22 +287,23 @@ func mergedUsageBody(cmd *cobra.Command, node usageNode, own bool, prefix string } else { body = append(body, liveUsageArgs(cmd, prefix)...) } - for _, flag := range node.flags { - f := cmd.Flags().Lookup(flag.key) - if f != nil && (own || !flag.settings) && (flag.hidden || !f.Hidden) { - body = append(body, flag.text) - } - } - return append(body, addedUsageFlags(cmd, node.flags, prefix)...) + return append(body, usageFlagLines(cmd, node.flags, own, prefix)...) } -func addedUsageFlags(cmd *cobra.Command, generated []usageItem, prefix string) []string { +// Settings flags are declared once on the root, so only a command's own schema lists them. +func usageFlagLines(cmd *cobra.Command, generated []usageItem, own bool, prefix string) []string { + // LocalFlags merges persistent flags into Flags() before the lookups below. + local := cmd.LocalFlags() + var lines []string listed := map[string]bool{} for _, flag := range generated { listed[flag.key] = true + f := cmd.Flags().Lookup(flag.key) + if f != nil && (own || !flag.settings) && (flag.hidden || !f.Hidden) { + lines = append(lines, flag.text) + } } - var lines []string - cmd.LocalFlags().VisitAll(func(f *pflag.Flag) { + local.VisitAll(func(f *pflag.Flag) { if !f.Hidden && f.Name != "help" && !listed[f.Name] { lines = append(lines, liveUsageFlag(f, prefix)) } @@ -350,8 +345,8 @@ func writeUsageIndented(b *strings.Builder, text string, indent int) { } // MarkDynamic makes --usage render a generated command's runtime help, aliases -// and args from its cobra definition, keeping generated lines for the inputs it -// still has. +// and args from its cobra definition, keeping generated lines for the aliases +// and args it still has. func MarkDynamic(cmd *cobra.Command) { if cmd.Annotations == nil { cmd.Annotations = map[string]string{} diff --git a/zSDKs/sdk-cli/tests/usage_test.go b/zSDKs/sdk-cli/tests/usage_test.go index 60f6f9c61..5c9a80765 100644 --- a/zSDKs/sdk-cli/tests/usage_test.go +++ b/zSDKs/sdk-cli/tests/usage_test.go @@ -272,6 +272,9 @@ func TestUsageSchema_DynamicRoot(t *testing.T) { var before strings.Builder require.NoError(t, usage.EmitSchema(root, &before)) + + root, err = cli.NewRootCommand() + require.NoError(t, err) usage.MarkDynamic(root) var unchanged strings.Builder @@ -287,6 +290,20 @@ func TestUsageSchema_DynamicRoot(t *testing.T) { assert.Equal(t, want, after.String()) } +func TestUsageSchema_RuntimeHiddenFlagDropped(t *testing.T) { + root, err := cli.NewRootCommand() + require.NoError(t, err) + configure, _, err := root.Find([]string{"configure"}) + require.NoError(t, err) + + var before, after strings.Builder + require.NoError(t, usage.EmitSchema(configure, &before)) + require.Contains(t, before.String(), "--default-output-format") + require.NoError(t, configure.Flags().MarkHidden("default-output-format")) + require.NoError(t, usage.EmitSchema(configure, &after)) + assert.NotContains(t, after.String(), "--default-output-format") +} + func TestUsageSchema_HiddenGeneratedCommandDropped(t *testing.T) { root, err := cli.NewRootCommand() require.NoError(t, err) @@ -398,14 +415,16 @@ func TestUsageSchema_ResetFlagsRendersLive(t *testing.T) { func TestUsageSchema_FlagsMatchRegisteredFlags(t *testing.T) { root, err := cli.NewRootCommand() require.NoError(t, err) - flagLine := regexp.MustCompile(`(?m)^ flag "(?:-\S )?--([^\s"<]+)`) + childFlagLine := regexp.MustCompile(`(?m)^ flag "(?:-\S )?--([^\s"<]+)`) + rootFlagLine := regexp.MustCompile(`(?m)^flag "(?:-\S )?--([^\s"<]+)`) var walk func(cmd *cobra.Command) walk = func(cmd *cobra.Command) { for _, child := range cmd.Commands() { walk(child) } + flagLine := childFlagLine if cmd == root { - return + flagLine = rootFlagLine } local := cmd.LocalFlags() var out strings.Builder From a1103b5e3dd703d77de249baaf8e9c7a06c47d48 Mon Sep 17 00:00:00 2001 From: 2ynn Date: Mon, 19 Oct 2026 18:53:40 -0400 Subject: [PATCH 3/3] docs(cli): limit the [required] note to the named whole-body flag --- templates/templates/cli/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/templates/cli/README.md b/templates/templates/cli/README.md index c8cf8fa82..903766a8e 100644 --- a/templates/templates/cli/README.md +++ b/templates/templates/cli/README.md @@ -1401,7 +1401,7 @@ In both help styles, `includes/descriptions.ts` omits invented payloads for opti The compact layout and `Just works:` heading are runtime help contracts. Cobra markdown generation keeps its existing `Examples` heading and operation-doc footer, and the explorer continues to read `Short`, `Long`, and `Example` without a heading rewrite. Because synthesized-operation filtering happens while `templateCmdExample` builds the command's `Example`, a rejected synthesized example is absent from docs and the explorer too; authored examples remain unchanged everywhere. Custom commands that currently append Defaults/Machine/Globals prose to `Example` should migrate that prose to the annotations above to avoid a duplicate footer, set `speakeasy_help_footer: "false"` while retaining their own footer, or choose `helpStyle: full` during migration. -The KDL schema is built at generation time from the command tree in `includes/usage.ts` and emitted at runtime by `usage.EmitSchema()`. It includes command names, aliases, help text, flags, defaults, config metadata, and the config file location. Generated flags include the same auto-assigned short forms as the parser and `--help` (for example, `flag "-i --id "` when `--id` receives `-i`). Shorthand assignment still reserves inherited global letters and the pagination `-a`; route-dispatch intents expose only shorthands for the non-body flags they register. An operation's whole-body flag is listed under the name the command registers: `--body` alongside flag metadata, otherwise the single flag named after the request body (e.g. `--request`) for body-only operations whose body is not expanded into field flags (`operationUsesMetadataFlags` in `includes/metadata.ts`, shared with `opcmd.go.stmpl` and `includes/intents.ts`). Its help text matches `--help`; `[required]` marks a body that is neither optional nor nullable. +The KDL schema is built at generation time from the command tree in `includes/usage.ts` and emitted at runtime by `usage.EmitSchema()`. It includes command names, aliases, help text, flags, defaults, config metadata, and the config file location. Generated flags include the same auto-assigned short forms as the parser and `--help` (for example, `flag "-i --id "` when `--id` receives `-i`). Shorthand assignment still reserves inherited global letters and the pagination `-a`; route-dispatch intents expose only shorthands for the non-body flags they register. An operation's whole-body flag is listed under the name the command registers: `--body` alongside flag metadata, otherwise the single flag named after the request body (e.g. `--request`) for body-only operations whose body is not expanded into field flags (`operationUsesMetadataFlags` in `includes/metadata.ts`, shared with `opcmd.go.stmpl` and `includes/intents.ts`). Its help text matches `--help`. The flag named after the request body says `[required]` when the body is neither optional nor nullable; `--body` alongside flag metadata is an alternative to the field flags and never says `[required]`. Declared commands (`x-speakeasy-cli-commands`) are part of the schema: bound intents mount into the KDL tree under their declared parent path with the same flag surface the runtime registers (declared positional `arg` nodes, operation security flags, the body flag, `--schema`, and declared flags — plus non-body parameter metadata for JSON-body intents (multipart intents keep their upload fields); `buildIntentUsageCommand` in `includes/usage.ts`), and planned placeholders appear with their summary. For commands added after static schema generation, live schema emission reads `speakeasy_prompt_args` and emits the same `arg` nodes. Once Cobra validation succeeds, `--usage` on an intent or planned command emits KDL and wins over the command's other runtime surfaces, including `--schema` and the bare-invocation usage error (`runIntent*Cmd` in `intentcmd.go.stmpl` and `newPlannedCmd` in `intents.go.stmpl` check `usage.UsageRequested` first, matching operation commands).