feat(cli): keep auto-assigned flag shorthands stable across regenerations
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:
preferShorthandpins (below).- Letters recorded in the lock. A recorded letter also beats a new required path parameter.
- 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:
getBaseTemplateConfigsexposes the lock section to CLI templates asCLIShorthands.operationShorthandsresolves each operation once per template run. BothtemplateFlagMetadataVarandoperationAutoShorthandOwnersuse it, so runtimeFlagMeta,--usageKDL and the declared-shorthand collision checks all see one assignment.- The result is reported through a new
recordCLIShorthandsJS callback toSubsystem.CLIShorthands, andupdateLockFilereplaces 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):preferShorthandmust 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
preferShorthandon its own. - An operation-declared flag whose explicit
shorthandequals 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
aon a paginated operation fails generation, because-abelongs 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.
TestSnapCLIStickyShorthandsgeneratesnames/tag, regenerates with a collidingnick_nameand checks thatnameskeeps-nin both the command metadata and the usage schema. It then removesnamesand checks that-nis 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-cliandTARGET=review make build-clipass. Only the reviewgen.lockchanges.golangci-lint runon the changed packages is clean. Fullmake lintreports a gofmt finding intemplates/perms.go, which this PR doesn't touch, and the file is gofmt-clean locally.- Not run: the full
make test-cliruntime 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.