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/1792192360-339cd950.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
id: 1792192360-339cd950
features:
- core
targets:
- cli
type: fix
bump: patch
description: compose root and group --usage schemas from the runtime command tree so commands and flags attached after generation appear
author: 2ynn
date: "2026-10-16"
12 changes: 6 additions & 6 deletions pkg/generate/snapshots/cli_empty_body_go_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -228,12 +228,12 @@ func TestSnapCLIEmptyBodyExamplesAndFlags(t *testing.T) {
}
usageSource, err := os.ReadFile(filepath.Join(outputDir, "internal", "usage", "schema.go"))
require.NoError(t, err)
usagePattern := regexp.MustCompile(`"widgets (optional|optionalexample|required|formoptional|formrequired)":\s*"(.*)"`)
usageSchemas := usagePattern.FindAllStringSubmatch(string(usageSource), -1)
require.Len(t, usageSchemas, 5)
for _, schema := range usageSchemas {
require.Contains(t, schema[2], "--body <body>")
require.NotContains(t, schema[2], "--body-param")
usagePattern := regexp.MustCompile(`"widgets (optional|optionalexample|required|formoptional|formrequired)":\s*\{(.*)\}`)
usageNodes := usagePattern.FindAllStringSubmatch(string(usageSource), -1)
require.Len(t, usageNodes, 5)
for _, node := range usageNodes {
require.Contains(t, node[2], "--body <body>")
require.NotContains(t, node[2], "--body-param")
}
for _, name := range []string{"required", "formrequired"} {
command := readCommand(name)
Expand Down
2 changes: 1 addition & 1 deletion templates/templates/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1405,7 +1405,7 @@ The KDL schema is built at generation time from the command tree in `includes/us

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 (`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 required-flag/flag-group validators can win. `output.InstallErrorHandling` skips its eager typed copy of the latter two checks under `--usage`, but does not suppress Cobra's originals; such failures retain 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. Commands absent from the static KDL table use live Cobra schema emission, including declared positional `arg` nodes. 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. `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.

### Generated README & Command Docs

Expand Down
210 changes: 115 additions & 95 deletions templates/templates/cli/includes/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1441,104 +1441,92 @@ function renderUsageFlag(flag: UsageFlagDef, indent: number): string[] {
];
}

function renderUsageCommand(
cmd: UsageCommandDef,
indent: number,
includeSettings = false,
): string[] {
const lines: string[] = [];
const childLines: string[] = [];
for (const alias of cmd.aliases || []) {
childLines.push(`${" ".repeat(indent + 1)}alias ${kdlQuoted(alias)}`);
}
for (const arg of cmd.args || []) {
const props: string[] = [];
pushIf(
props,
arg.help ? kdlPropAt("help", arg.help, " ".repeat(indent + 2)) : "",
);
pushIf(props, arg.required ? kdlProp("required", true) : "");
pushIf(props, arg.variadic ? kdlProp("var", true) : "");
const line = `${" ".repeat(indent + 1)}arg ${kdlQuoted(arg.name)}${
props.length ? ` ${props.join(" ")}` : ""
}`;
if (!arg.suggestions || arg.suggestions.length === 0) {
childLines.push(line);
continue;
}
childLines.push(
`${line} {`,
`${" ".repeat(indent + 2)}suggestions ${arg.suggestions
.map(kdlQuoted)
.join(" ")}`,
`${" ".repeat(indent + 1)}}`,
);
}
for (const flag of cmd.flags || []) {
if (flag.settings && !includeSettings) continue;
childLines.push(...renderUsageFlag(flag, indent + 1));
}
for (const child of cmd.commands || []) {
childLines.push(...renderUsageCommand(child, indent + 1));
}

function renderUsageArg(arg: UsageArgDef, indent: number): string[] {
const props: string[] = [];
pushIf(
props,
cmd.help ? kdlPropAt("help", cmd.help, " ".repeat(indent + 1)) : "",
arg.help ? kdlPropAt("help", arg.help, " ".repeat(indent + 1)) : "",
);
if (childLines.length > 0) {
lines.push(
`${" ".repeat(indent)}cmd ${kdlQuoted(cmd.name)}${
props.length ? ` ${props.join(" ")}` : ""
} {`,
);
lines.push(...childLines);
lines.push(`${" ".repeat(indent)}}`);
return lines;
}
pushIf(props, arg.required ? kdlProp("required", true) : "");
pushIf(props, arg.variadic ? kdlProp("var", true) : "");
const line = `${" ".repeat(indent)}arg ${kdlQuoted(arg.name)}${
props.length ? ` ${props.join(" ")}` : ""
}`;
if (!arg.suggestions || arg.suggestions.length === 0) return [line];
return [
`${line} {`,
`${" ".repeat(indent + 1)}suggestions ${arg.suggestions
.map(kdlQuoted)
.join(" ")}`,
`${" ".repeat(indent)}}`,
];
}

lines.push(
`${" ".repeat(indent)}cmd ${kdlQuoted(cmd.name)}${
props.length ? ` ${props.join(" ")}` : ""
}`,
);
return lines;
function renderUsageRootHead(): string {
return [
`name ${kdlQuoted(getHumanCliName())}`,
`bin ${kdlQuoted(sanitizeCliName())}`,
`about ${kdlStringAt(usageRootAbout(), " ")}`,
`version ${kdlQuoted(context.Global.Config.SDKVersion)}`,
`config {`,
` file ${kdlQuoted(`~/.config/${sanitizeCliName()}/config.yaml`)}`,
`}`,
].join("\n");
}

function renderUsageCommandHead(cmd: UsageCommandDef): string {
return `cmd ${kdlQuoted(cmd.name)}${
cmd.help ? ` ${kdlPropAt("help", cmd.help, " ")}` : ""
}`;
}

function usageFlagKey(flag: UsageFlagDef): string {
return /(?:^|\s)--([^\s<]+)/.exec(flag.spec)?.[1] ?? flag.spec;
}

interface UsageNodeItem {
key: string;
text: string;
settings?: boolean;
hidden?: boolean;
}

// Rendered at indent 0; the runtime re-indents each line by depth.
interface UsageNodeEntry {
head: string;
aliases: UsageNodeItem[];
args: UsageNodeItem[];
flags: UsageNodeItem[];
children: string[];
}

function collectCommandPathSchemas(
function collectUsageNodes(
cmd: UsageCommandDef,
path: string[],
out: Map<string, string>,
includeRootMetadata: boolean,
out: Map<string, UsageNodeEntry>,
isRoot: boolean,
) {
const lines: string[] = [];
if (includeRootMetadata) {
lines.push(`name ${kdlQuoted(getHumanCliName())}`);
lines.push(`bin ${kdlQuoted(sanitizeCliName())}`);
lines.push(`about ${kdlStringAt(usageRootAbout(), " ")}`);
lines.push(`version ${kdlQuoted(context.Global.Config.SDKVersion)}`);
lines.push(`config {`);
lines.push(
` file ${kdlQuoted(`~/.config/${sanitizeCliName()}/config.yaml`)}`,
);
lines.push(`}`);
for (const flag of cmd.flags || []) {
lines.push(...renderUsageFlag(flag, 0));
}
for (const child of cmd.commands || []) {
lines.push(...renderUsageCommand(child, 0));
}
} else {
lines.push(...renderUsageCommand(cmd, 0, true));
}
const schema = lines.join("\n") + "\n";
out.set(path.join(" "), schema);
for (const alias of cmd.aliases || []) {
out.set([...path.slice(0, -1), alias].join(" "), schema);
}
const depth = isRoot ? 0 : 1;
out.set(path.join(" "), {
head: isRoot ? renderUsageRootHead() : renderUsageCommandHead(cmd),
aliases: (cmd.aliases || []).map((alias) => ({
key: alias,
text: `${" ".repeat(depth)}alias ${kdlQuoted(alias)}`,
})),
args: (cmd.args || []).map((arg) => ({
key: arg.name,
text: renderUsageArg(arg, depth).join("\n"),
})),
flags: (cmd.flags || []).map((flag) => ({
key: usageFlagKey(flag),
text: renderUsageFlag(flag, depth).join("\n"),
settings: flag.settings,
hidden: flag.hidden,
})),
children: (cmd.commands || []).map((child) => child.name),
});
for (const child of cmd.commands || []) {
collectCommandPathSchemas(child, [...path, child.name], out, false);
collectUsageNodes(child, [...path, child.name], out, false);
}
}

Expand Down Expand Up @@ -2402,15 +2390,47 @@ registerTemplateFunc(
templatePlannedUsageCmdName,
);

function templateUsageSchemaMap(): string {
const root = buildRootUsageCommand();
const schemas = new Map<string, string>();
collectCommandPathSchemas(root, [], schemas, true);
const entries = Array.from(schemas.entries()).map(
([k, v]) => `${goStringLiteral(k)}: ${goTemplateSafeStringExpr(v)}`,
function goUsageNodeItems(items: UsageNodeItem[]): string {
return `[]usageItem{ ${items
.map((item) => {
const fields = [
`key: ${goStringLiteral(item.key)}`,
`text: ${goTemplateSafeStringExpr(item.text)}`,
];
if (item.settings) fields.push("settings: true");
if (item.hidden) fields.push("hidden: true");
return `{${fields.join(", ")} }`;
})
.join(", ")} }`;
}

function goUsageNodeEntry(node: UsageNodeEntry): string {
const fields = [`head: ${goTemplateSafeStringExpr(node.head)}`];
if (node.aliases.length) {
fields.push(`aliases: ${goUsageNodeItems(node.aliases)}`);
}
if (node.args.length) {
fields.push(`args: ${goUsageNodeItems(node.args)}`);
}
if (node.flags.length) {
fields.push(`flags: ${goUsageNodeItems(node.flags)}`);
}
if (node.children.length) {
fields.push(
`children: []string{ ${node.children.map(goStringLiteral).join(", ")} }`,
);
}
return `{ ${fields.join(", ")} }`;
}

function templateUsageNodeMap(): string {
const nodes = new Map<string, UsageNodeEntry>();
collectUsageNodes(buildRootUsageCommand(), [], nodes, true);
const entries = Array.from(nodes.entries()).map(
([path, node]) => `${goStringLiteral(path)}: ${goUsageNodeEntry(node)}`,
);
return `var usageSchemas = map[string]string{\n\t${entries.join(
return `var usageNodes = map[string]usageNode{\n\t${entries.join(
",\n\t",
)},\n}`;
}
registerTemplateFunc("templateUsageSchemaMap", templateUsageSchemaMap);
registerTemplateFunc("templateUsageNodeMap", templateUsageNodeMap);
8 changes: 4 additions & 4 deletions templates/templates/cli/root.go.stmpl
Original file line number Diff line number Diff line change
Expand Up @@ -269,15 +269,15 @@ func NewRootCommand() (*cobra.Command, error) {
}
{{- end}}

usage.MarkGenerated(rootCmd)
{{- if isCustomCommandsEnabled}}
{{- addImport "internal/cli/custom" true}}

custom.Register(rootCmd)
{{- end}}

// Cobra creates its default help and completion commands lazily inside Execute.
rootCmd.InitDefaultHelpCmd()
rootCmd.InitDefaultCompletionCmd()
usage.InitDefaultCommands(rootCmd)
{{- if hasCliIntents}}
applyDeclaredCommandOrder(rootCmd)
{{- end}}
Expand Down Expand Up @@ -1080,15 +1080,15 @@ func NewRootCommand() (*cobra.Command, error) {
}
{{- end}}

usage.MarkGenerated(rootCmd)
{{- if isCustomCommandsEnabled}}
{{- addImport "internal/cli/custom" true}}

custom.Register(rootCmd)
{{- end}}

// Cobra creates its default help and completion commands lazily inside Execute.
rootCmd.InitDefaultHelpCmd()
rootCmd.InitDefaultCompletionCmd()
usage.InitDefaultCommands(rootCmd)
{{- if hasCliIntents}}
applyDeclaredCommandOrder(rootCmd)
{{- end}}
Expand Down
Loading
Loading