How-to › Ship a CLI or REPL

How to map API parameters to CLI arguments and flags#

Extend the command map with a parameter map: path parameters as positional arguments, query and header parameters as flags, and one input style for request bodies.

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

Read first: Derive a noun-verb command tree from an OpenAPI document

A user runs order create -f amount=1000 -f gift=false and the API answers 400. The CLI sent "1000" and "false", both strings, because a flag value is text and nobody converted it. The schema said integer and boolean, the help text said nothing, and the user has no way to see the request that went out.

What you get

You will end up with a parameter map beside your command map, and a CLI that reads it. Its dry run prints the exact request and refuses a body whose types disagree with the schema. This is for you if your commands exist and their arguments are still being decided one flag at a time.

Short answer

Make each path parameter a positional argument in path order, and each query or header parameter a flag named from the parameter with its case converted. Pick one body style on purpose: typed field flags, a whole document from a file or stdin, or a shorthand string. Then give the CLI a dry run that prints the request and refuses a body whose types disagree with the schema.

You will need

Node 22 or later, an OpenAPI 3 document, and the command map from deriving a command tree. Verified 2026-09-24 against Node 22.22.2. The Parameter Object gives every parameter a location, path, query, header or cookie, and the location decides what it becomes on the command line. The body has no location, which is why it needs a decision.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
aws --cli-input-json and --generate-cli-skeletonDeep bodies, scripted calls, and a template the CLI can print for any operationThe body is invisible to help and completion, and a typo in a key is found by the serverA person typing one field at a time from a prompt
gh api -f and -F field flagsFlat or shallow bodies typed by hand, with two spellings for string and typed valuesDeep bodies explode into bracketed keys, and the string-versus-typed choice is the user’sThe body is a document somebody already has in a file
restish shorthand body syntaxNested bodies from one argument, with coercion and file loading built inA grammar to learn, and the shell’s own quoting on top of itUsers who script the CLI and would rather write JSON
Stripe CLI -d parametersA form-encoded API where nested[param]=value is the wire format itselfEvery value is a string on the wire, so the type question is answered by the serverA JSON API, where the same syntax still needs a type per field

Field flags are discoverable: --help can list them and a shell can complete them, and a shipping[address][city] key is what that costs on a deep body. A document handles any schema and hides it from help, so the skeleton exists to give it back. Shorthand sits between the two, and its cost is a grammar. The CLI below implements all three, because the choice is a decision and the mechanism is small.

Read the map

Path parameters in path order become positional arguments. Query and header parameters become flags, named from the parameter with its case converted, so X-Tenant is --x-tenant and per_page is --per-page. Parameters declared on the path item apply to every operation under it, and an operation’s own entry wins on the same name and location. That is the rule the specification sets, and the one a hand-written CLI forgets first.

export function paramMap(doc, commands) {
  return commands.map((c) => {
    const item = doc.paths[c.path]
    const op = item[c.method.toLowerCase()]
    const params = parametersOf(item, op)
    const scope = new Set(c.scope ?? [])
    const order = [...c.path.matchAll(/\{([^}]+)\}/g)].map((m) => m[1])
    const positional = order
      .filter((n) => !scope.has(n))
      .map((n) => ({ name: n, type: typeOf(params.find((p) => p.in === 'path' && p.name === n)?.schema) }))
    const flags = params
      .filter((p) => p.in !== 'path' || scope.has(p.name))
      .map((p) => ({
        flag: kebab(p.name),
        name: p.name,
        in: p.in,
        type: typeOf(p.schema),
        required: p.in === 'path' || Boolean(p.required),
        repeatable: p.schema?.type === 'array',
        enum: p.schema?.enum ?? null,
      }))
    const body = op.requestBody?.content?.['application/json']?.schema ?? null
    return { ...c, positional, flags, body, bodyRequired: Boolean(op.requestBody?.required) }
  })
}

The scope list is the previous page’s decision carried forward. issue list flattened owner and repo into flags there, so they stay flags here. order-item get made no such decision, so both of its path parameters are positional, in the order the path names them.

node params.mjs
issue list      --owner* --repo* --per-page --state --labels[]  no body
order create    --x-tenant*                                     body: customer*, amount*, currency, gift, items[], shipping{}
order get       <id>                                            no body
order list      --x-tenant* --status --limit                    no body
order update    <id>                                            body: status, gift
order-item get  <id> <sku>                                      no body
wrote params.json, 6 commands (* required, [] repeatable)

An array parameter is a repeatable flag. --labels bug --labels docs becomes labels=bug&labels=docs, which is the form style with explode set that the specification makes the default for a query array. The map records repeatable, and the CLI declares the flag with multiple in parseArgs, so a second --labels appends rather than replaces.

node cli.mjs issue list --owner voxgig --repo sdkgen --labels bug --labels docs --per-page 5 --dry-run
GET https://api.shop.example/repos/voxgig/sdkgen/issues?per_page=5&labels=bug&labels=docs

Build the body three ways

The same order, sent three ways. The request printed is the request that would go out, and the three are identical where they overlap.

Field flags follow gh api: -f is always a string, -F is typed, and brackets nest.1

node cli.mjs order create --x-tenant acme -f customer=cus_8f2 -F amount=1000 -F gift=false -F 'shipping[address][city]=Cork' -F 'items[][sku]=SKU-1' -F 'items[][qty]=2' --dry-run
POST https://api.shop.example/orders
x-tenant: acme
content-type: application/json

{
  "customer": "cus_8f2",
  "amount": 1000,
  "gift": false,
  "shipping": {
    "address": {
      "city": "Cork"
    }
  },
  "items": [
    {
      "sku": "SKU-1",
      "qty": 2
    }
  ]
}

A document follows --input FILE, with - for stdin, as gh api and aws --cli-input-json both spell it.

node cli.mjs order create --x-tenant acme --input order.json --dry-run
POST https://api.shop.example/orders
x-tenant: acme
content-type: application/json

{
  "customer": "cus_8f2",
  "amount": 1000,
  "gift": false,
  "shipping": {
    "address": {
      "line1": "1 Main St",
      "city": "Cork"
    }
  },
  "items": [
    {
      "sku": "SKU-1",
      "qty": 2
    }
  ]
}

Shorthand follows a subset of what restish accepts: pairs separated by commas, dots for nesting, [] to append, braces for an inline object, and scalars coerced unless quoted.2

node cli.mjs order create --x-tenant acme --body 'customer: cus_8f2, amount: 1000, gift: false, shipping.address.city: Cork, items[]: {sku: SKU-1, qty: 2}' --dry-run
POST https://api.shop.example/orders
x-tenant: acme
content-type: application/json

{
  "customer": "cus_8f2",
  "amount": 1000,
  "gift": false,
  "shipping": {
    "address": {
      "city": "Cork"
    }
  },
  "items": [
    {
      "sku": "SKU-1",
      "qty": 2
    }
  ]
}

The CLI accepts one style per call and refuses two, because a body assembled from a file and a flag is a body nobody can predict. Whichever style built it, the result is checked against the schema before anything is sent.

Give the document style its template back

A document hides the schema from --help. The AWS CLI’s answer is a skeleton, and it costs twelve lines to offer the same.3

export function skeleton(schema) {
  if (!schema) return null
  if ('default' in schema) return schema.default
  switch (schema.type) {
    case 'object': return Object.fromEntries(Object.entries(schema.properties ?? {}).map(([k, v]) => [k, skeleton(v)]))
    case 'array': return [skeleton(schema.items)]
    case 'integer':
    case 'number': return 0
    case 'boolean': return false
    default: return schema.enum ? schema.enum[0] : ''
  }
}
node cli.mjs order create --generate-skeleton
{
  "customer": "",
  "amount": 0,
  "currency": "EUR",
  "gift": false,
  "items": [
    {
      "sku": "",
      "qty": 0
    }
  ],
  "shipping": {
    "address": {
      "line1": "",
      "city": ""
    },
    "express": false
  }
}

Every property is present, every value is empty in the type the schema names, and a default is filled in where the schema has one. Edit it, save it, pass it to --input. A test asserts the skeleton type-checks unedited, so the template can never teach the wrong type.

Check it worked

node --test params.test.mjs
1..11
# tests 11
# suites 0
# pass 11
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 109.097451

The test to read is the last but one. It runs the CLI with a fake fetch three times. A dry run never calls it, a body with problems is refused before it, and a clean run calls it once with the body the dry run printed. The count of calls is the assertion, because the whole point of a dry run is what it does not do.

When it goes wrong

The API rejects a body the CLI was happy to send. Every value came in through -f, so every value is a string, and the schema said otherwise.

node cli.mjs order create --x-tenant acme -f customer=cus_8f2 -f amount=1000 -f gift=false --dry-run
POST https://api.shop.example/orders
x-tenant: acme
content-type: application/json

{
  "customer": "cus_8f2",
  "amount": "1000",
  "gift": "false"
}

not sent: 2 problems
  amount: schema says integer, the body has the string "1000"
  gift: schema says boolean, the body has the string "false"

The dry run shows the quotes, names the two fields, and exits 1. Without --dry-run the same check runs and the request is refused, which is the difference between this CLI and one that lets the server explain. Use -F for the two values and the problems disappear. The check is the problems function, and it walks arrays and nested objects too, so items[0].qty is reported by its path.

An array arrives as its last element. The flag was declared once and given twice, and the parser kept the second. Declare an array parameter with multiple, which the map’s repeatable field exists to drive.

A required header is missing and the server answers 401 instead of 400. Mark header parameters required in the document and the CLI refuses before sending, with --x-tenant is required.

A number in the query string is text on the wire whatever you do. The CLI checks --limit ten against the integer type and refuses it, but --limit 5 is limit=5 either way. Type checking a query parameter is validation, not conversion, and the specification serializes every query value as a string.

When not to do this

Do not offer all three body styles to your users because this page’s sample does. The sample implements them to compare them. A CLI that takes fields, documents and shorthand has three ways to be wrong and three sections of help, and the choice belongs to whoever knows the users.

Do not type-check the body against the schema and then skip the request when the schema is out of date. A schema check that refuses a valid request is worse than no check, because the user cannot get past it. Keep --force or an equivalent, and keep the document current.

Do not coerce -f values by guessing. A customer id that happens to be digits is a string. A CLI that turns it into a number because it looks like one has invented a bug the API never had. The two spellings exist so the user says which is which.

Do not send a form-encoded API a JSON body because the map says the parameters are nested. The Stripe CLI’s nested[param]=value is the wire format of a form-encoded API, and a JSON API needs a type per field that a form never carries.4

Last verified

Verified 2026-09-24 against Node 22.22.2. Every output block is what the command preceding it printed, against the six-operation document in the sample directory. No request was sent: every command on the page runs with --dry-run, and the one test that exercises sending does so against a fake fetch. The gh, aws, restish and Stripe conventions are described from their documentation, not by running the tools.

Footnotes

  1. The gh api manual documents -f as a string parameter and -F as a typed one, with @path or @- to read a value from a file or stdin, key[subkey]=value for nesting and key[]=value for arrays. Its example for reading a nested value from a file is -F 'files[myfile.txt][content]=@myfile.txt', in which the file name is a key, the file is a value, and the two are the same file. The syntax was worked out for a gist, which is a file whose content is a file. ↩︎ Back to text

  2. The restish input guide says that shorthand coerces common scalar values and that quotes force a string when the exact text matters. It then says what the tool does not do: it does not reject body fields because the schema says a value has a different type, enum or shape. It sends what you ask for and lets the server validate. That is the other answer to this page’s pitfall, and it is a defensible one for a tool whose users are debugging the server. The shorthand library behind it lists the W3C’s HTML JSON form submission draft among its inspirations, which is where a[b][]=v came from before any CLI used it. ↩︎ Back to text

  3. The AWS CLI’s skeleton page recommends generating the template rather than writing the file by hand. The reason it gives is that the parameter names in the file are not the names on the command line. Its example is an API parameter named UserName beside a command-line parameter named user-name, which the page describes as altered capitalization and a missing dash. The skeleton exists, in part, so that a user never has to know the conversion this page’s map performs. ↩︎ Back to text

  4. The Stripe CLI documents -d, --data as additional data to send with an API request, with support for nested values in the form nested[param]=value, and shows stripe customers update cus_9s6XKzkNRiz8i3 -d "metadata[key]=value". The brackets are not a CLI invention. Stripe’s API is form-encoded, so metadata[key]=value is the request body as the server reads it, and the CLI is passing the wire format through. ↩︎ 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.