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

Source: https://voxgig.com/howto/derive-a-command-tree-from-operation-ids

- Audience: api-producer
- Level: intermediate
- Verified: 2026-09-24
- Published: 2026-09-24

## 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](https://spec.openapis.org/oas/v3.1.0.html#operation-object) 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](https://docs.aws.amazon.com/cli/latest/userguide/cli-usage-commandstructure.html) | 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](https://cli.github.com/manual/gh) | 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](https://kubernetes.io/docs/reference/kubectl/) | 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](https://docs.stripe.com/cli/resources) | 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.

```js title="derive.mjs"
/**
 * 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](https://www.rfc-editor.org/rfc/rfc9110.html)
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](https://learn.openapis.org/specification/paths.html) 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`](https://cli.github.com/manual/gh_issue_list) 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

```bash
node derive.mjs
```

```text output
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.

```bash
node derive.mjs --no-overrides
```

```text output
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`](https://docs.aws.amazon.com/cli/latest/reference/s3api/get-object.html), 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.

```json title="overrides.json"
{
  "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

```bash
node conventions.mjs listIssues cancelOrder get_user
```

```text output
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

```bash
node --test derive.test.mjs
```

```text output
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.

```bash
node derive.mjs --noun-from tag --no-overrides
```

```text output
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

- [Design a CLI a coding agent can drive](/howto/design-a-cli-a-coding-agent-can-drive)

- [Write CLI help that people and agents can both use](/howto/help-text-people-and-agents-can-use)

- [Decide between generating and hand-writing a client](/howto/generate-or-hand-write-a-small-api-client)

## 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`.

[^1]: The [Operation Object](https://spec.openapis.org/oas/v3.1.0.html#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.

[^2]: The AWS CLI user guide describes
[two tiers of commands](https://docs.aws.amazon.com/cli/latest/userguide/cli-services-s3.html)
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](https://docs.aws.amazon.com/cli/latest/userguide/cli-usage-skeleton.html) 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.

[^3]: The verb-noun order buys kubectl a grammar the noun-first tools do not have. Its
[reference](https://kubernetes.io/docs/reference/kubectl/) 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.

[^4]: The Stripe CLI's resource commands are generated from Stripe's OpenAPI document by
[a Go program](https://github.com/stripe/stripe-cli/blob/master/pkg/gen/gen_resources_cmds.go)
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.