feat(cli): keep auto-assigned flag shorthands stable across regenerations

TR
14h ago
+1138-31

Why

Generated CLI operation commands give each top-level flag a -<first letter> shorthand only while no other generated flag on that operation starts with the same letter. When an API adds an optional body or query field that shares an initial with a flag that already shipped with a shorthand, the existing flag silently loses it. For example, adding nick_name to an operation that already exposed -n, --names drops -n, which breaks scripts that use it.

Required path parameters already won these collisions, but nothing else did, and the generator had no memory of which letters had shipped.

What changed

Shorthands are recorded in gen.lock and kept

The generator now records each operation's assigned letters in a new cliShorthands section of gen.lock, and later generations keep them:

cliShorthands:
  CreatePet:
    flags:
      names: n
      tag: t
    retired:
      p: { flag: price, freedIn: 1.4.0 }

resolveOperationShorthands (includes/metadata.ts) decides each operation's letters in this order:

  1. preferShorthand pins (below).
  2. Letters recorded in the lock. A recorded letter also beats a new required path parameter.
  3. The existing first-letter rule (computeFlagShorthands) for the remaining flags, using only letters that are still free.

When a recorded flag disappears, its letter moves to retired instead of being handed straight to the next flag with that initial, so old scripts can't silently hit a different flag. A retired letter:

  • goes back to its flag if that flag returns,
  • can be reclaimed by a pin,
  • is otherwise freed by the next breaking release: a higher major version, or a higher minor while pre-1.0, judged from the version being generated.

If the version is unknown, retired letters stay reserved. A recorded letter that becomes reserved (for example -a once the operation paginates) is dropped.

With no lock section the result is identical to the previous rule, so the first generation after upgrading only records the existing letters. Regenerating the review CLI adds the lock section and changes no generated code.

Plumbing:

  • getBaseTemplateConfigs exposes the lock section to CLI templates as CLIShorthands.
  • operationShorthands resolves each operation once per template run. Both templateFlagMetadataVar and operationAutoShorthandOwners use it, so runtime FlagMeta, --usage KDL and the declared-shorthand collision checks all see one assignment.
  • The result is reported through a new recordCLIShorthands JS callback to Subsystem.CLIShorthands, and updateLockFile replaces the lock section with what the main template run recorded. Removed operations drop out, and snippet-only runs leave the section untouched.

preferShorthand pins a letter on purpose

Operation entries under x-speakeasy-cli-commands.operations accept preferShorthand, a list of generated flag names that take their first letter even when the lock or another flag holds it:

x-speakeasy-cli-commands:
  version: 1
  operations:
    CreatePet:
      preferShorthand: [names]
  • Decoder (internal/extensions/cli_commands.go):
    • preferShorthand must be a non-empty list of unique, top-level kebab-case flag names.
    • Two entries can't share an initial, and the reserved persistent letters (o, q, H, d, h) are rejected.
    • An operation entry may declare preferShorthand on its own.
    • An operation-declared flag whose explicit shorthand equals a pinned letter is a decode error.
  • Template validation (includes/intents.ts):
    • A pin that names no generated flag on the operation fails generation.
    • A pin starting with a on a paginated operation fails generation, because -a belongs to --all.
    • Operation and intent flags that declare a pinned or recorded letter are rejected by the existing collision checks.

The CLI README, typeDefs.d.ts and common.d.ts are updated, and there are changesets for both behaviours.

Testing

  • TestResolveOperationShorthands (pkg/templates) runs the resolver in a JS runtime against 15 cases:
    • no lock (unchanged behaviour),
    • recorded letters kept, including against a new path parameter,
    • pins winning,
    • retirement, a returning flag, and a pin reclaiming a letter,
    • the major and pre-1.0 minor resets,
    • an empty version,
    • a letter that became reserved.
  • TestSnapCLIStickyShorthands generates names/tag, regenerates with a colliding nick_name and checks that names keeps -n in both the command metadata and the usage schema. It then removes names and checks that -n is retired rather than given to --nick-name.
  • TestSnapCLIPreferShorthand: a pinned flag keeps its letter, an unknown pin fails generation, and an intent flag reusing a pinned letter fails generation.
  • go test ./internal/extensions/ covers decoding and every new validation error.
  • go test ./pkg/generate/snapshots/ -run TestSnapCLI, make check-template-cli and TARGET=review make build-cli pass. Only the review gen.lock changes.
  • golangci-lint run on the changed packages is clean. Full make lint reports a gofmt finding in templates/perms.go, which this PR doesn't touch, and the file is gofmt-clean locally.
  • Not run: the full make test-cli runtime suite.

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 (.claude/skills/public-repo-communication/SKILL.md).
  • Generated fixtures and review SDK changes are public-safe.
  • I reviewed git diff --check.