• Toggle Theme
  • Search Site
  • View as Mobile

A while back I wrote about oparser, the forgiving key-value parser behind markdown-magic's JSX-style comment options. The idea was simple: take loose, human-written text and do the obvious thing instead of crashing on a missing quote.

Once it existed, it kept pulling me toward the same problem somewhere else: CLI arguments.

Command-line flags are config too, typed by a tired human into a terminal with no autocomplete and whatever syntax they half-remember. Standard parsers are strict in the wrong spots, so I built @davidwells/dx-args, a forgiving argv parser that keeps working when someone types a slightly imperfect command.

Where normal arg parsers fall short

I like mri and minimist. They're small, fast, and handle the common case. Two things bite real users:

// 1. Values come back as strings, with no real types.
parse(['--count', '3'])      // count is '3', not the number 3
parse(['--items=[1,2,3]'])   // items is the string '[1,2,3]', not an array
parse(['--config={x:1}'])    // config is a string, not an object
// so you write JSON.parse-and-pray code yourself, per flag, forever

// 2. Single-dash long options explode into short flags.
parse(['-stage', 'prod'])    // { s: true, t: true, a: true, g: true, e: true }
// nobody who typed -stage meant -s -t -a -g -e. they meant stage.

Both are the same bug: the user did the reasonable thing and the tool punished them.

What dx-args does

It wraps mri, keeps oparser as the value parser, and adds argv normalization, glob grouping, and single-dash recovery.

const { dxParse } = require('@davidwells/dx-args')

const result = dxParse(['-files', 'README.md', '-dry'])

result.mergedOptions  // { files: 'README.md', dry: true }
result.globGroups     // [{ key: 'files', rawKey: '-files', values: ['README.md'] }]

-files stays files instead of detonating into short flags, and the path lands in globGroups so the CLI can treat files differently from options.

It accepts whatever shape the flag shows up in:

InputmergedOptions
--stage prod{ stage: 'prod' }
--stage=prod{ stage: 'prod' }
stage = prod{ stage: 'prod' }
-stage prod{ stage: 'prod' }
--no-cache{ cache: false }
--count 3{ count: 3 }
--items=[1,2,3]{ items: [1, 2, 3] }

oparser does the typing

After normalization, each key=value chunk goes to oparser.parse(), so complex values just work:

dxParse(['--array=[1,2,3]'])               // { array: [1, 2, 3] }
dxParse(['--config={"foo":"bar"}'])        // { config: { foo: 'bar' } }
dxParse(['word', '=', '{ foo: bar }'])     // { word: { foo: 'bar' } }

That last value isn't valid JSON, but oparser still returns a real object. dx-args inherits all of that for free because the gnarly parsing lives in one package.

Using it

markdown-magic's own CLI runs on it:

const { dxParse } = require('@davidwells/dx-args')

const config = dxParse(rawArgv, {
  globKeys: ['files', 'file', 'path', 'ignore']
})

globKeys says which flags carry file globs, so these both behave the way a user expects:

md-magic -files README.md -config md.config.js -dry
md-magic --path "docs/**/*.md" --ignore "dist/**/*.md"

Takeaway

Build the forgiving value parser once, as its own package with its own tests, and it stops being a cost. oparser made markdown comments forgiving, then made CLI args forgiving almost for free.

If you maintain a CLI, check the two failures above: does -longflag explode, and can a user pass an array or object without hand-rolling JSON.parse? If either answer is bad, a thin forgiving layer fixes both.