How-to › Ship a CLI or REPL

How to derive a noun-verb command tree from an OpenAPI document#

Turn operationIds, tags and paths into a committed map of noun, verb and operationId that any CLI framework can implement, with collisions resolved in the map.

Audience
API producer
Level
intermediate
Topic
Build a CLI over an API
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
AWS CLI service-operation mappingA large API where the map must never drift from the spec, and users already know the operation namesEvery naming wart in the API becomes a command name, and every parameter becomes a flagThe operation names were written for a code generator rather than for typing
gh noun-verb conventionAn API with a handful of resources people work on by name, and a team willing to decide each verbA decision per endpoint, written down somewhere, and flattening rules for the nested resourcesHundreds of operations and nobody to own the naming
kubectl verb-noun conventionA small set of verbs that apply to every resource type, so one verb takes a type as its argumentAction endpoints have no slot, so every operation beyond CRUD grows a verb of its ownMost operations are actions rather than reads and writes of a type
Stripe CLI resource-verb conventionA resource-shaped API whose CLI is generated from the spec and mirrors the API referenceThe command tree is the API’s shape, so an awkward resource name in the API is an awkward commandThe 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.

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

  1. 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. getUser and get_user each 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

  2. The AWS CLI user guide describes two tiers of commands for Amazon S3. s3api exposes direct access to the S3 API operations. s3 is 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 custom aws s3 commands support neither --generate-cli-skeleton nor --cli-input-json, which the generated tier does. The hand-written commands gave up the machinery in exchange for the names. ↩︎ Back to text

  3. 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 pod1 and kubectl get po pod1 as 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 how gh issue list came to have gh issue ls beside it in its manual. ↩︎ Back to text

  4. 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

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.