Your API has ninety operations and the CLI has to give each one a name. Somebody types the first
twenty commands into a framework by hand, the third contributor names theirs differently, and
getUser and get_user both come out as get-user. Nothing records which endpoint a command
means, so every rename is a guess and every argument about naming is settled in code.
What you get
You will end up with a committed command map: a JSON file of noun, verb and operationId, derived by rules you can read. A check fails when two operations claim one name. This is for you if a CLI over your API is naming its commands in code.
Short answer
Read the noun off the last collection segment of the path. Take the verb from the method: GET on a collection is list, GET on an item is get, POST is create, PATCH or PUT is update, DELETE is delete. An outer resource’s parameters become scope flags and an action segment becomes the verb. Write the result to a file, and fix every collision in that file rather than in code.
You will need
Node 22 or later, and an OpenAPI 3 document whose operations carry an operationId and tags.
Verified 2026-09-24 against Node 22.22.2. The document here is a twelve-operation shop, small
enough to read and awkward enough to hold the three cases that need a decision. The
OpenAPI Specification requires
every operationId to be unique and says the value is case-sensitive,1 which is the rule that
lets two ids collide the moment a CLI folds their case.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| AWS CLI service-operation mapping | A large API where the map must never drift from the spec, and users already know the operation names | Every naming wart in the API becomes a command name, and every parameter becomes a flag | The operation names were written for a code generator rather than for typing |
gh noun-verb convention | An API with a handful of resources people work on by name, and a team willing to decide each verb | A decision per endpoint, written down somewhere, and flattening rules for the nested resources | Hundreds of operations and nobody to own the naming |
| kubectl verb-noun convention | A small set of verbs that apply to every resource type, so one verb takes a type as its argument | Action endpoints have no slot, so every operation beyond CRUD grows a verb of its own | Most operations are actions rather than reads and writes of a type |
| Stripe CLI resource-verb convention | A resource-shaped API whose CLI is generated from the spec and mirrors the API reference | The command tree is the API’s shape, so an awkward resource name in the API is an awkward command | The CLI has to read better than the API does |
The two mechanical conventions, the AWS CLI and the Stripe CLI, never drift because nothing is
decided: the command is the operation. That is also why aws s3api has a hand-written aws s3
standing beside it.2 The two designed conventions, gh and kubectl, read better. Each costs a
decision per endpoint, and a decision nobody wrote down is made differently by the next person.
The map below is where those decisions go.
Read the noun and the verb off the path
The rules are short, and the one that needs a decision is marked as such.
/**
* A literal segment after an item parameter is an action when it is not a
* plural noun: `/orders/{id}/cancel` acts, `/repos/{owner}/{repo}/issues` is
* a nested collection. The plural test is the judgement call, and the one an
* override most often corrects.
*/
export const isAction = (segments) => {
const last = segments.at(-1)
return !isParam(last) && segments.length > 1 && isParam(segments.at(-2)) && !last.endsWith('s')
}
/** The verb, from the method and whether the path ends on a collection, an item or an action. */
export function verbOf(method, segments) {
const last = segments.at(-1)
const onItem = isParam(last)
if (isAction(segments) && method === 'post') return kebab(last)
switch (method) {
case 'get': return onItem ? 'get' : 'list'
case 'post': return 'create'
case 'put':
case 'patch': return 'update'
case 'delete': return 'delete'
default: return method
}
}
/** The noun: the last literal segment that names a collection, singular. */
export function nounOf(segments) {
const literals = segments.filter((s) => !isParam(s))
const noun = isAction(segments) ? literals.at(-2) : literals.at(-1)
return singular(noun)
}
verbOf is the method table, with RFC 9110
supplying the meanings: GET on a collection lists, GET on an item gets, POST creates, PUT and
PATCH update, DELETE deletes. An action such as POST /orders/{id}/cancel is the exception. Its
verb is the last segment, and its noun is the collection before the item parameter.
nounOf takes the last literal segment of the
path template and makes it singular. For
/repos/{owner}/{repo}/issues that is issue, and owner and repo become scope flags rather
than nouns of their own. That is the flattening: issue list --owner <owner> --repo <repo>.
gh folds the pair into one
--repo OWNER/REPO flag, which is a decision the
map can record later. The mechanical result is two flags.
isAction is the decision written as a rule. A literal segment after an item parameter is an
action unless it looks plural, so /orders/{id}/cancel acts and /orders/{id}/items is a nested
collection. A path such as /orders/{id}/status reads as an action under that rule and is a
sub-resource, which is the kind of case the overrides file exists for.
Write the map, then read it
node derive.mjs
admin-user get <id> GET /admin/users/{id} get_user (noun, name from overrides.json)
health check GET /health healthCheck (noun, verb, singleton from overrides.json)
issue create --owner --repo POST /repos/{owner}/{repo}/issues createIssue
issue get <number> --owner --repo GET /repos/{owner}/{repo}/issues/{number} getIssue
issue list --owner --repo GET /repos/{owner}/{repo}/issues listIssues
order cancel <id> POST /orders/{id}/cancel cancelOrder
order create POST /orders postCreateOrder (name from overrides.json)
order delete <id> DELETE /orders/{id} deleteOrder
order get <id> GET /orders/{id} getOrder
order list GET /orders listOrders
order update <id> PATCH /orders/{id} updateOrder
user get <id> GET /users/{id} getUser
wrote commands.json, 12 commands
Twelve operations, twelve commands, one file. Each row carries the operationId, the method and
the path, so the map is the record of which endpoint a command means. The three rows marked
from overrides.json are the three the rules got wrong, and the file says so beside each one.
Resolve collisions in the map, not in code
Run the same derivation with the overrides switched off.
node derive.mjs --no-overrides
health list GET /health healthCheck
issue create --owner --repo POST /repos/{owner}/{repo}/issues createIssue
issue get <number> --owner --repo GET /repos/{owner}/{repo}/issues/{number} getIssue
issue list --owner --repo GET /repos/{owner}/{repo}/issues listIssues
order cancel <id> POST /orders/{id}/cancel cancelOrder
order create POST /orders postCreateOrder
order delete <id> DELETE /orders/{id} deleteOrder
order get <id> GET /orders/{id} getOrder
order list GET /orders listOrders
order update <id> PATCH /orders/{id} updateOrder
user get <id> GET /admin/users/{id} get_user
user get <id> GET /users/{id} getUser
collision command "user get" is claimed by get_user and getUser
collision name "get-user" is claimed by get_user and getUser
warning postCreateOrder encodes the verb twice; its one-to-one name would be post-create-order
The run stops with exit code 1 instead of writing a file. getUser and get_user are both legal ids, they name
different endpoints, and both become user get in the noun-verb tree and get-user in the
one-to-one one. The doubled verb in postCreateOrder is reported too. A mechanical mapping
turns it into post-create-order, in the same way the AWS CLI turns GetObject into
get-object, and
the only difference is that Amazon’s operation was named for it.
The fix is three entries in a file a reviewer can read.
{
"get_user": { "noun": "admin-user", "name": "get-admin-user", "why": "collides with getUser once both are kebab-cased" },
"postCreateOrder": { "name": "create-order", "why": "the operationId encodes the verb twice" },
"healthCheck": { "noun": "health", "verb": "check", "singleton": true, "why": "GET on a singleton is not a list" }
}
Each override names the operation it touches, what it changes, and why. derive.mjs does not
know the words admin-user or create-order, so the next regeneration cannot lose them, and the
next reader of the map can see which names were decided rather than derived.
Render the same map in four conventions
node conventions.mjs listIssues cancelOrder get_user
get_user GET /admin/users/{id}
gh admin-user get <id>
kubectl get admin-user <id>
aws shop get-admin-user --id <id>
stripe admin-users retrieve <id>
listIssues GET /repos/{owner}/{repo}/issues
gh issue list --owner <owner> --repo <repo>
kubectl get issues --owner <owner> --repo <repo>
aws shop list-issues --owner <owner> --repo <repo>
stripe issues list --owner <owner> --repo <repo>
cancelOrder POST /orders/{id}/cancel
gh order cancel <id>
kubectl cancel order <id>
aws shop cancel-order --id <id>
stripe orders cancel <id>
Three things to read off that. The nested resource is the same command in all four, differing
only in word order and in whether the noun is plural.3 The action has no natural place in the
verb-noun grammar, where cancel becomes a verb the tool has to invent, while the other three
attach it to the resource. And the collision is handled the same way everywhere, because it was
fixed in the map before any convention saw it.
Check it worked
node --test derive.test.mjs
1..10
# tests 10
# suites 0
# pass 10
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 149.783291
The two tests to keep are the collision pair. One asserts that both collisions are reported with
the overrides off. The next asserts that the overrides resolve them and change nothing else, by
comparing every row without an override against the plain derivation. A last test loads commands.json and
compares it with a fresh run, so a map edited by hand fails until it is regenerated.
When it goes wrong
Two operations still collide after the noun comes from the tag. Taking the noun from the operation’s first tag instead of the path is the other rule people reach for, and it moves the problem rather than solving it.
node derive.mjs --noun-from tag --no-overrides
admin get <id> GET /admin/users/{id} get_user
health list GET /health healthCheck
issue create --owner --repo POST /repos/{owner}/{repo}/issues createIssue
issue get <number> --owner --repo GET /repos/{owner}/{repo}/issues/{number} getIssue
issue list --owner --repo GET /repos/{owner}/{repo}/issues listIssues
order cancel <id> POST /orders/{id}/cancel cancelOrder
order create POST /orders postCreateOrder
order delete <id> DELETE /orders/{id} deleteOrder
order get <id> GET /orders/{id} getOrder
order list GET /orders listOrders
order update <id> PATCH /orders/{id} updateOrder
user get <id> GET /users/{id} getUser
collision name "get-user" is claimed by get_user and getUser
warning postCreateOrder encodes the verb twice; its one-to-one name would be post-create-order
get_user becomes admin get, which nobody would type, and the one-to-one name get-user still
collides. Tags group the API reference and were not written to be typed. Use the path by
default and reach for the tag through an override, one operation at a time.
A sub-resource is treated as an action. /orders/{id}/status ends in a singular literal, so the
plural test calls it an action and derives status as a verb. Add an override with verb: get
and noun: order-status, and keep the rule as it is. A rule that handles every irregular plural
in English is a rule nobody can predict.
The generated tree reads like the API. That is the AWS CLI outcome, and it is not a bug in the
derivation.4 If the operation names were written for a code generator, post-create-order is
what the mechanical map produces, and the fix belongs in the API document rather than in the CLI.
The map drifts from the document. Regenerate it in the build and fail on a difference, as the last test does. A map edited by hand and never regenerated is the situation it was meant to replace.
When not to do this
Do not build a noun-verb tree for an API whose users already know the operation names. The AWS
CLI maps one command per operation, and its users type describe-instances because the API
reference says DescribeInstances. A designed tree would cost them the reference, and the
reference is what they read.
Do not derive the tree at runtime from the document. The rules here are deterministic, and running them when the CLI starts means an edit to the document renames a command without a review. Derive once, commit the map, and let the framework read the file.
Do not resolve a collision by adjusting the rules. A rule tuned so that get_user comes out
differently will move some other name six months from now, and to a user the move is a
regression. An override names the one operation it touches and nothing else.
Do not hide the operationId. Whatever the command is called, help output and machine-readable output carry the id, because it is the one name the API reference, the SDK and the CLI share.
Related how-tos
Last verified
Verified 2026-09-24 against Node 22.22.2. Every output block is what the command preceding it
printed, against the twelve-operation document in the sample directory. The four conventions are
rendered from their published documentation, not by running gh, kubectl, aws or stripe.
Footnotes
-
The Operation Object says the id MUST be unique among all operations described in the API, and that the value is case-sensitive. Tools MAY use it to identify an operation, it adds, so it is RECOMMENDED to follow common programming naming conventions. The specification does not say whose conventions.
getUserandget_usereach follow one, and a CLI that folds case is the tool the sentence was warning about, on the other side of the MAY. ↩︎ Back to text -
The AWS CLI user guide describes two tiers of commands for Amazon S3.
s3apiexposes direct access to the S3 API operations.s3is a set of custom high-level commands made for the CLI that simplify common tasks such as syncing objects and buckets. The mechanical mapping carries the longer name and the hand-written tier the shorter one. The skeleton page then records that the customaws s3commands support neither--generate-cli-skeletonnor--cli-input-json, which the generated tier does. The hand-written commands gave up the machinery in exchange for the names. ↩︎ Back to text -
The verb-noun order buys kubectl a grammar the noun-first tools do not have. Its reference documents
kubectl get pod pod1,kubectl get pods pod1andkubectl get po pod1as the same command, because the type is an argument and resource types accept the singular, plural, or abbreviated forms. A noun-first tree registers one spelling and adds the others as aliases, one at a time, which is howgh issue listcame to havegh issue lsbeside it in its manual. ↩︎ Back to text -
The Stripe CLI’s resource commands are generated from Stripe’s OpenAPI document by a Go program that imports a case-conversion library,
strcase, to get from the document’s names to the command line’s. Case conversion is a dependency of the mapping, then, and the collisions this page resolves by hand are the ones a library produces at speed. ↩︎ Back to text