fix(cli): compose --usage schemas from the runtime command tree

2Y
6h ago
+1184-362

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: true commands filled in by custom.Register appear in root --usage as bare cmd "<name>" help="…" nodes with no args or flags;
  • leaves that custom.Register mounts under a generated group are missing from root --usage and 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 by templateUsageNodeMap. It emits usageNodes: for each command path, the node's head line, aliases, args, flags (keyed by long name, with settings / hidden markers) and declared child names, each rendered once at indent 0.
  • templates/cli/usage.go.stmpl: EmitSchema composes 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;
    • 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 stays 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 with ResetFlags, 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) takes help from its runtime Short, keeps generated arg lines while the speakeasy_prompt_args names 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 generated suggestions, 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.go is 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 root custom-commands region 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 without MarkDynamic (exact own and root blocks), a replaced intent with cobra.NoArgs, the generated mark on built-in and cobra commands, a custom completion registered before cobra's defaults, and a marked command after ResetFlags.
  • pkg/generate/snapshots/cli_empty_body_go_test.go: the schema.go pattern follows the usageNodes entry shape.
  • templates/cli/README.md: --usage section describes the composition rules.
  • Review SDK zSDKs/sdk-cli: internal/usage/schema.go and tests/usage_test.go regenerated. Root --usage now lists the --greeting flag that custom.Register attaches to hello; every other path is unchanged.
  • Changeset: core, cli, fix, patch.

Testing

  • TARGET=primary make test-cli: 2185 tests pass (after merging current main).
  • go test ./pkg/generate/snapshots -run TestSnapCLI: pass.
  • make lint: pass.
  • TARGET=review make build-cli: diff limited to internal/usage/schema.go, tests/usage_test.go and gen.lock.
  • The new tests fail with the previous schema.go and pass with this change.
  • Old vs new --usage output compared for every command path:
    • review SDK: only root differs, by the runtime --greeting flag;
    • a generated CLI whose custom.Register adds 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 generated suggestions, default= and declared order.
  • All emitted schemas parse as KDL v2, including multi-line help values 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.