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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
aws --cli-input-json and --generate-cli-skeleton | 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 | 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 | 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 | 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.
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
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 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
-
The
gh apimanual documents-fas a string parameter and-Fas a typed one, with@pathor@-to read a value from a file or stdin,key[subkey]=valuefor nesting andkey[]=valuefor 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 -
The
restishinput 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,enumor 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 wherea[b][]=vcame from before any CLI used it. ↩︎ Back to text -
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
UserNamebeside a command-line parameter nameduser-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 -
The Stripe CLI documents
-d, --dataas additional data to send with an API request, with support for nested values in the formnested[param]=value, and showsstripe customers update cus_9s6XKzkNRiz8i3 -d "metadata[key]=value". The brackets are not a CLI invention. Stripe’s API is form-encoded, sometadata[key]=valueis the request body as the server reads it, and the CLI is passing the wire format through. ↩︎ Back to text