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

Source: https://voxgig.com/howto/map-api-parameters-to-cli-flags-and-arguments

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

## 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](/howto/derive-a-command-tree-from-operation-ids). Verified 2026-09-24
against Node 22.22.2. The
[Parameter Object](https://spec.openapis.org/oas/v3.1.0.html#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

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [`aws --cli-input-json` and `--generate-cli-skeleton`](https://docs.aws.amazon.com/cli/latest/userguide/cli-usage-skeleton.html) | Deep bodies, scripted calls, and a template the CLI can print for any operation | The body is invisible to help and completion, and a typo in a key is found by the server | A person typing one field at a time from a prompt |
| [`gh api -f` and `-F` field flags](https://cli.github.com/manual/gh_api) | Flat or shallow bodies typed by hand, with two spellings for string and typed values | Deep bodies explode into bracketed keys, and the string-versus-typed choice is the user's | The body is a document somebody already has in a file |
| [`restish` shorthand body syntax](https://rest.sh/docs/reference/shorthand/) | Nested bodies from one argument, with coercion and file loading built in | A grammar to learn, and the shell's own quoting on top of it | Users who script the CLI and would rather write JSON |
| [Stripe CLI `-d` parameters](https://docs.stripe.com/cli/post) | A form-encoded API where `nested[param]=value` is the wire format itself | Every value is a string on the wire, so the type question is answered by the server | A 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.

```js title="params.mjs"
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.

```bash
node params.mjs
```

```text output
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](https://spec.openapis.org/oas/v3.1.0.html#style-values) makes the default for a
query array. The map records `repeatable`, and the CLI declares the flag with `multiple` in
[`parseArgs`](https://nodejs.org/api/util.html#utilparseargsconfig), so a second `--labels`
appends rather than replaces.

```bash
node cli.mjs issue list --owner voxgig --repo sdkgen --labels bug --labels docs --per-page 5 --dry-run
```

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

```bash
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
```

```text output
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`](https://docs.aws.amazon.com/cli/latest/userguide/cli-usage-parameters-file.html)
both spell it.

```bash
node cli.mjs order create --x-tenant acme --input order.json --dry-run
```

```text output
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`](https://rest.sh/docs/guides/input/) accepts: pairs
separated by commas, dots for nesting, `[]` to append, braces for an inline object, and scalars
coerced unless quoted.[^2]

```bash
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
```

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

```js title="cli.mjs"
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] : ''
  }
}
```

```bash
node cli.mjs order create --generate-skeleton
```

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

```bash
node --test params.test.mjs
```

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

```bash
node cli.mjs order create --x-tenant acme -f customer=cus_8f2 -f amount=1000 -f gift=false --dry-run
```

```text output
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](https://learn.openapis.org/specification/parameters.html)
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]

## Related how-tos

- [Derive a noun-verb command tree from an OpenAPI document](/howto/derive-a-command-tree-from-operation-ids)

- [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)

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

[^1]: The [`gh api` manual](https://cli.github.com/manual/gh_api) 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.

[^2]: The `restish` [input guide](https://rest.sh/docs/guides/input/) 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](https://github.com/danielgtaylor/shorthand) behind it lists the W3C's
[HTML JSON form submission](https://www.w3.org/TR/html-json-forms/) draft among its
inspirations, which is where `a[b][]=v` came from before any CLI used it.

[^3]: The AWS CLI's [skeleton page](https://docs.aws.amazon.com/cli/latest/userguide/cli-usage-skeleton.html)
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.

[^4]: The Stripe CLI [documents](https://docs.stripe.com/cli/post) `-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.