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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changesets/1792431953-ffc1a3ac.yaml
Original file line number Diff line number Diff line change
@@ -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; --help and --usage mark a whole-body flag [required] only when the body is neither optional nor nullable'
author: 2ynn
date: "2026-10-19"
18 changes: 18 additions & 0 deletions pkg/generate/snapshots/cli_empty_body_go_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"

"github.com/speakeasy-api/openapi-generation/v2/pkg/generate/snapshots/snaptest"
Expand Down Expand Up @@ -235,6 +236,23 @@ func TestSnapCLIEmptyBodyExamplesAndFlags(t *testing.T) {
require.Contains(t, node[2], "--body <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 <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)
Expand Down
4 changes: 2 additions & 2 deletions templates/templates/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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 <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).

**`--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. 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

Expand Down
23 changes: 3 additions & 20 deletions templates/templates/cli/includes/intents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 || []) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1493,7 +1493,7 @@ function operationGeneratedFlagNames(op: Operation): Set<string> {
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");
Expand Down Expand Up @@ -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.
Expand Down
31 changes: 21 additions & 10 deletions templates/templates/cli/includes/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,20 @@ 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.
* Whether the operation command registers its inputs from flag metadata
* (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 operationHasFlagMetadata(op: Operation): boolean {
return templateFlagMetadataVar(op) !== "";
function operationUsesMetadataFlags(op: Operation): boolean {
const built = buildOperationMetadataEntries(op);
return (
built !== null && (built.entries.length > 0 || built.hadCandidateFields)
);
}
registerTemplateFunc("operationHasFlagMetadata", operationHasFlagMetadata);
registerTemplateFunc("operationUsesMetadataFlags", operationUsesMetadataFlags);

function getMixedRequestMetadataFields(op: Operation): FieldDef[] {
const fields = op.Request.Field.Type.Fields || [];
Expand Down Expand Up @@ -990,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) &&
Expand All @@ -1000,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).
Expand Down
16 changes: 10 additions & 6 deletions templates/templates/cli/includes/templating.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -146,18 +154,14 @@ function templateRequestBodyDescription(field: FieldDef): string {
desc = "Request body as JSON";
}

if (!field.Optional) {
if (!field.Optional && !field.Nullable) {
desc += " [required]";
}
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.
Expand Down
Loading
Loading