fix(cli): compose --usage schemas from the runtime command tree
2Y
6h ago+1184+1184+0123456789012345678901234567890123456789-362-362-012345678901234567890123456789
Why
Generated CLIs serve --usage (the KDL machine schema) from strings rendered at generation time, one full string per command path. Anything attached to the command tree after generation is invisible to every ancestor's schema:
custom: truecommands filled in bycustom.Registerappear in root--usageas barecmd "<name>" help="…"nodes with no args or flags;- leaves that
custom.Registermounts under a generated group are missing from root--usageand from the group's--usage; - flags added to a generated command at runtime are missing from root
--usage.
Each such command's own --usage was correct, because a command marked with usage.MarkDynamic is rendered live from cobra. That live rendering was itself lossy next to the generated text: no suggestions, no default=, flags in alphabetical rather than declared order, generic <flag_name> placeholders, and arg help/requiredness taken from the prompt annotation instead of the generated node.
Tools and agents that discover a CLI through root --usage therefore miss hand-written commands and flags entirely.
What changed
templates/cli/includes/usage.ts:templateUsageSchemaMap(one complete KDL string per path, each ancestor repeating its whole subtree, plus dead alias-path keys) is replaced bytemplateUsageNodeMap. It emitsusageNodes: for each command path, the node's head line, aliases, args, flags (keyed by long name, withsettings/hiddenmarkers) and declared child names, each rendered once at indent 0.templates/cli/usage.go.stmpl:EmitSchemacomposes every schema, root and subtrees alike, from the assembled cobra tree (renderUsageNode):- generated nodes render verbatim;
- children keep their declared order; children removed or hidden at runtime are dropped; available children added at runtime are appended in cobra order;
NewRootCommandcallsusage.MarkGeneratedon the tree right beforecustom.Register, andusage.InitDefaultCommandsmarks only thehelp/completioncommands cobra itself creates (a customcompletionstays live). The mark records the command's flag-set identity. Only marked commands with unchanged flag sets use their table node; anything attached later, including a same-named replacement, and a command whose flags were replaced withResetFlags, renders live from cobra. Cobra metadata cannot tell an unchanged command from a replacement (generated intents register zero-value cobra defaults while the table documents the effective default), so identity is the signal;- a marked command flagged with
usage.MarkDynamic(porcelain extending it in place) takeshelpfrom its runtimeShort, keeps generated arg lines while thespeakeasy_prompt_argsnames match, keeps generated alias and flag lines for those it still has (flags generated visible but hidden at runtime are dropped), and renders runtime-added aliases and visible local flags live, so generatedsuggestions,default=and declared order survive; - settings flags stay on a command's own schema only, as before;
- only commands under the generated CLI root (matched by name) use the table; a detached command or group renders entirely from cobra.
- Commands that are unchanged at runtime render byte-identically to the previous output. Each node is stored once, so the generated
internal/usage/schema.gois roughly half its previous size. - Known limits, documented in the README: on a marked dynamic command, generated arg/flag help text wins over an in-place rewrite of a flag's
Usage; commands added in the rootcustom-commandsregion run before the mark and count as generated. templates/cli/usage_test.go.stmpl: new tests for runtime commands composed into ancestors (with byte-identity of the rest of the root), a dynamic command with runtime flags (own schema and root), a dynamic root (unchanged output; exact output after a runtime root flag), a generated command hidden at runtime, a detached leaf and group, same-named replacements with and withoutMarkDynamic(exact own and root blocks), a replaced intent withcobra.NoArgs, the generated mark on built-in and cobra commands, a customcompletionregistered before cobra's defaults, and a marked command afterResetFlags.pkg/generate/snapshots/cli_empty_body_go_test.go: theschema.gopattern follows theusageNodesentry shape.templates/cli/README.md:--usagesection describes the composition rules.- Review SDK
zSDKs/sdk-cli:internal/usage/schema.goandtests/usage_test.goregenerated. Root--usagenow lists the--greetingflag thatcustom.Registerattaches tohello; every other path is unchanged. - Changeset:
core, cli, fix, patch.
Testing
TARGET=primary make test-cli: 2185 tests pass (after merging currentmain).go test ./pkg/generate/snapshots -run TestSnapCLI: pass.make lint: pass.TARGET=review make build-cli: diff limited tointernal/usage/schema.go,tests/usage_test.goandgen.lock.- The new tests fail with the previous
schema.goand pass with this change. - Old vs new
--usageoutput compared for every command path:- review SDK: only root differs, by the runtime
--greetingflag; - a generated CLI whose
custom.Registeradds top-level commands, group leaves and flags on generated commands: unchanged commands are byte-identical; root and the affected groups gain the runtime commands and flags; dynamic commands keep their generatedsuggestions,default=and declared order.
- review SDK: only root differs, by the runtime
- All emitted schemas parse as KDL v2, including multi-line
helpvalues after re-indentation.
Public-safety check
- This change contains no credentials, customer documents, private repository URLs, private filesystem paths, or unredacted private logs.
- Title, body, comments, and commit messages name no customers or customer-derived identifiers, private paths or trackers, or workflow provenance, and are understandable without private context (
.agents/skills/public-repo-communication/SKILL.md). - Generated fixtures and review SDK changes are public-safe.
- I reviewed
git diff --check.