$ cnpm install @molt/command
???? Type-safe CLI command definition and execution.
npm add @molt/command zod
import { Command } from '../src/index.js'
import { z } from 'zod'
const args = Command.create()
.parameter(`filePath`, z.string().describe(`Path to the file to convert.`))
.parameter(`to`, z.enum([`json`, `yaml`, `toml`]).describe(`Format to convert to.`))
.parameter(
`from`,
z
.enum([`json`, `yaml`, `toml`])
.optional()
.describe(`Format to convert from. By default inferred from the file extension.`),
)
.parameter(
`verbose v`,
z.boolean().default(false).describe(`Log detailed progress as conversion executes.`),
)
.parameter(
`move m`,
z.boolean().default(false).describe(`Delete the original file after it has been converted.`),
)
.parse()
$ mybin --file ./music.yaml --to json
Autogenerated help:
$ mybin --help
Automatic casting and validation based on specified Zod types.
Normalization between camel and kebab case and optional dash prefix:
const args = Command.create()
.parameter('--do-it-a', z.boolean())
.parameter('--doItB', z.boolean())
.parameter('doItC', z.boolean())
.parse()
args1.doItA
args2.doItB
args3.doItC
Specify one or multiple (aka. aliases) short and long flags:
Command.create().parameter('-f --force --forcefully', z.boolean()).parse()
Use Zod .default(...) method for setting default values.
const args = Command.create().parameter('--path', z.string().default('./a/b/c')).parse()
args.path === './a/b/c/' // $ mybin
args.path === '/over/ride' // $ mybin --path /over/ride
Define mutually exclusive parameters declaratively.
Use Zod .describe(...) to add parameter descriptions in autogenerated docs.
????♀️ Expressive argument passing
Pass via environment variables (customizable)
const args = Command.create().parameter('--path', z.string()).parse()
args.path === './a/b/c/' // $ CLI_PARAM_PATH='./a/b/c' mybin
Kebab or camel case flags
$ mybin --do-it
$ mybin --doIt
Parameter stacking e.g. mybin -abc instead of mybin -a -b -c
Separator of = or space, e.g. mybin -a=foo -b=bar or mybin -a foo -b bar
A video introduction if you like that format:
You can define parameters as a zod object schema using regular property names. These are flags for your CLI but arguments can also be passed by environment variables so in a way this is a neutral form that doesn't privilege either argument passing mechanism.
const args = Command.create()
.parameter('foo', z.string())
.parameter('bar', z.number())
.parameter('qux', z.boolean())
.parse()
args.foo
args.bar
args.qux
You can also define them using flag syntax if you prefer. Thanks to @molt/types this style doesn't sacrifice any type safety.
const args = Command.create()
.parameter('--foo', z.string())
.parameter('--bar', z.number())
.parameter('--qux', z.boolean())
.parse()
args.foo
args.bar
args.qux
You can give your parameters short and long names, as well as aliases.
A set of parameter names gets normalized into its canonical name internally (again thanks to @molt/types this is all represented statically as well). The canonical name choice is as follows:
const args = Command.create()
.parameter('--foobar --foo -f ', z.string())
.parameter('--bar -b -x', z.number())
.parameter('-q --qux', z.boolean())
.parameter('-m -n', z.boolean())
.parse()
// $ mybin --foobar moo --bar 2 --qux -m
// $ mybin --foo moo -x 2 --qux -m
// $ mybin -f moo -b 1 -q -n
args.foobar === 'moo'
args.bar === 1
args.qux === true
args.m === true
If you prefer you can use a dash-prefix free syntax:
const args = Command.create()
.parameter('foobar foo f ', z.string())
.parameter('bar b x', z.number())
.parameter('q qux', z.boolean())
.parameter('m n', z.boolean())
.parse()
You can use kebab or camel case (and likewise your users can pass flags in either style). Canonical form internally uses camel case.
const args = Command.create().parameter('foo-bar', z.string()).parameter('quxLot', z.string()).parse()
// $ mybin --foo-bar moo --qux-lot zoo
// $ mybin --fooBar moo --quxLot zoo
args.fooBar === 'moo'
args.quxLot === 'zoo'
Duplicate parameter names will be caught statically via TypeScript.
const args = Command.create()
.parameter('f foo bar', z.string())
.parameter('bar', z.string()) // <-- TS error: already taken
.parameter('f', z.string()) // <-- TS error: already taken
.parameter('foo', z.string()) // <-- TS error: already taken
.parameter('help', z.string()) // <-- TS error: reserved name
.parameter('h', z.string()) // <-- TS error: reserved name
.parse()
This section covers how parameters are typed via a subset of Zod schemas and the types used will affect parsing.
Only the following set of Zod types are supported. In addition to the docs below, static typing will raise an error if you pass in something invalid.
z.boolean()
z.string()
z.enum(['...', '...'])
z.nativeEnum({'...':'...', '...':'...'})
z.number()
z.union([...]) // where ... can be any other type above.
Validation methods like .min(1) and .regex(/.../) should all just work.
The following modifiers are accepted:
.optional()
.default(...)
If both optional and default are used then default takes precedence.
The describe method is used for adding docs. It can show up in any part of the chain. All the following are fine:
z.string().describe('...').optional()
z.string().optional().describe('...')
z.string().min(1).describe('...').optional()
"true" or "1" for true and "false" or "0" for false.Examples:
const args = Command.create().parameter('f force forcefully', z.boolean()).parse()
// $ CLI_PARAM_NO_F='true' mybin
// $ CLI_PARAM_NO_FORCE='true' mybin
// $ CLI_PARAM_NO_FORCEFULLY='true' mybin
// $ CLI_PARAM_F='false' mybin
// $ CLI_PARAM_FORCE='false' mybin
// $ CLI_PARAM_FORCEFULLY='false' mybin
// $ mybin --no-f
// $ mybin --noF
// $ mybin --no-force
// $ mybin --noForce
// $ mybin --no-forcefully
// $ mybin --noForcefully
args.force === false
// $ CLI_PARAM_NO_F='false' mybin
// $ CLI_PARAM_NO_FORCE='false' mybin
// $ CLI_PARAM_NO_FORCEFULLY='false' mybin
// $ CLI_PARAM_F='true' mybin
// $ CLI_PARAM_FORCE='true' mybin
// $ CLI_PARAM_FORCEFULLY='true' mybin
// $ mybin -f
// $ mybin --force
// $ mybin --forcefully
args.force === true
trimtoLowerCasetoUpperCasestartsWith - A prefix-string the value must be begin with.endsWith - A suffix-string the value must end with.includes - A sub-string the value must exactly contain.regex - An arbitrary Regular Expression that the value must conform to.min - The minimum allowed string lengthmax - The maximum allowed string lengthlength - An exact length the string must bepattern - Different well known patterns that the value must conform to.
email - An emailip - An IP address. Can be configured:
url - A URLemoji - An emojiulid - A ULIDuuid - A UUIDcuid - A CUIDcuid2 - A CUID v2dateTime - An ISO DateTime. Can be configured:
offsetNumber() function.min - The minimum allowed number.max - the maximum allowed number.multipleOf - The multiple that the given number must be of. For example 20, 15, 10,5 would all be allowed if multipleOf was 5 since all those numbers are divisible by 5.intIf no variant is a boolean then flag expects an argument.
If one variant is a boolean then flag will interpret no argument as being an argument of the boolean variant. For example given this CLI:
Command.create().parameter('xee', z.union([z.boolean(), z.number()]))
A user could call your CLI in any of these ways:
$ mybin --xee
$ mybin --no-xee
$ mybin --xee 1
When a parameter is a union type, the variant that can first successfully parse the given value becomes the interpreted type for the given value. Variant parsers are tried in order of most specific to least, which is: enum, number, boolean, string. So for example if you had a union parameter like this:
Command.create().parameter('xee', z.union([z.string(), z.number()]))
By default help rendering will render something like so:
Command.create().parameter('xee', z.union([z.string(), z.number()]).description('Blah blah blah.'))
PARAMETERS
Name Type/Description Default
xee string | number REQUIRED
Blah blah blah.
When the parameters have descriptions then it will cause an expanded layout e.g.:
Command.create().parameter(
'xee',
z
.union([
z.string().description('Blah blah blah string.'),
z.number().description('Blah blah blah number.'),
])
.description('Blah blah blah Overview'),
)
PARAMETERS
Name Type/Description Default
xee ┌─union REQUIRED
│ Blah blah blah overview.
│
◒ string
│ Blah blah blah string.
│
◒ number
│ Blah blah blah number.
└─
You can force expanded layout even when parameters do not have descriptions via the settings, e.g.:
Command.create()
.parameter('xee', z.union([z.string(), z.number()]))
.parameter('foo', z.union([z.string(), z.number()]))
.settings({
helpRendering: {
union: {
mode: 'expandAlways',
},
},
})
PARAMETERS
Name Type/Description Default
xee ┌─union REQUIRED
◒ string
◒ number
└─
foo ┌─union REQUIRED
│ Blah blah blah string.
◒ string
◒ number
└─
You can make Molt Command interactively prompt users for arguments. This enables richer experiences for your users, like:
Example:
$ mybin --filePath ./a/b/c.yaml
1/1 to
❯ jsonn
Invalid value: Value is not a member of the enum.
❯ json
Command.eventPatterns for you.Command.create().settings({
prompt: {
enabled: false,
when: Command.eventPatterns.rejectedMissingOrInvalid,
},
})
TTY (process.stdout.isTTY === false) then prompts are always disabled.await the return of .parse().You can enable parameter prompts conditionally by pattern matching on their parse event emitted during when Command runs. Every parameter whose parse event matches with your given pattern will subsequently be prompted for.
All defined parameters emit parse events, irregardless if arguments were given, or from where those arguments originated (line, environment). Therefore this gives you lots of flexibility about when to prompt your user for input. For example:
All you need to do is pass a pattern to prompt either at the parameter level or the command level settings. There are three parse events you can match against:
Each event type share some core properties but also have their own unique fields. For example with Accepted you can match against what the value given was and with Rejected you can match against the specific error that occurred.
const args = await Command.create()
.parameter(`filePath`, z.string())
.parameter(`to`, {
schema: z.enum([`json`, `yaml`, `toml`]),
prompt: {
result: 'rejected',
error: 'ErrorMissingArgument',
},
})
.parse()
The pattern matching library will be open-sourced and thoroughly documented in the future.
Passing true will enable using the default event pattern.
const args = await Command.create()
.parameter(`filePath`, z.string())
.parameter(`to`, {
schema: z.enum([`json`, `yaml`, `toml`]),
prompt: true,
})
.parse()
You can enable prompt when one of the built-in event patterns occur:
const args = await Command.create()
.parameter(`filePath`, z.string())
.parameter(`to`, {
schema: z.enum([`json`, `yaml`, `toml`]),
prompt: {
when: Command.EventPatterns.rejectedMissingOrInvalid,
},
})
.parse()
Or when one of multiple events occurs:
const args = await Command.create()
.parameter(`filePath`, z.string())
.parameter(`to`, {
schema: z.enum([`json`, `yaml`, `toml`]),
prompt: {
when: [Command.EventPatterns.rejectedMissingOrInvalid, Command.EventPatterns.omittedWithoutDefault],
},
})
.parse()
You can enable prompt when your given event pattern occurs.
const args = await Command.create()
.parameter(`filePath`, z.string())
.parameter(`to`, {
schema: z.enum([`json`, `yaml`, `toml`]),
prompt: {
when: {
rejected: {
reason: 'missing',
},
},
},
})
.parse()
You can configure prompts for the entire instance in the settings. The configuration mirrors the parameter level. Parameter level overrides command level.
Enable explicitly with shorthand approach using a boolean:
const args = await Command.create()
.parameter(`filePath`, z.string())
.parameter(`to`, z.enum([`json`, `yaml`, `toml`]))
.settings({ prompt: true })
.parse()
Enable explicitly with longhand approach using the enabled nested property and include a condition.
Note that in the following enabled could be omitted because passing an object implies enabled: true by default.
const args = await Command.create()
.parameter(`filePath`, z.string())
.parameter(`to`, z.enum([`json`, `yaml`, `toml`]))
.settings({
prompt: {
enabled: true,
when: {
rejected: {
reason: 'missing',
},
},
},
})
.parse()
This section is about users passing arguments via the command line (as opposed to the environment), also known as "flags", to the parameters you've defined for your CLI.
Arguments can be separated from parameters using the following characters:
Examples:
$ mybin --foo=moo
$ mybin --foo= moo
$ mybin --foo = moo
$ mybin --foo moo
Note that when = is attached to the value side then it is considered part of the value:
$ mybin --foo =moo
Boolean short flags can be stacked. Imagine you have defined three parameters a, b, c. They could be passed like so:
$ mybin -abc
The last short flag does not have to be boolean flag. For example if there were a d parameter taking a string, this could work:
$ mybin -abcd foobar
You can write flags in kebab or camel case:
$ mybin --foo-bar moo
$ mybin --fooBar moo
Parameter arguments can be passed by environment variables instead of flags.
Environment arguments have lower precedence than Flags, so if an argument is available from both places, the environment argument is ignored while the flag argument is used.
By default environment arguments can be set using one of the following naming conventions (note: Molt reads environment variables with case-insensitivity):
CLI_PARAMETER_{parameter_name}
CLI_PARAM_{parameter_name}
const args = Command.create().parameter('--path', z.string()).parse()
args.path === './a/b/c/' // $ CLI_PARAMETER_PATH='./a/b/c' mybin
You can toggle environment arguments on/off. It is on by default.
const command = Command.create().parameter('--path', z.string()).settings({
environment: false,
})
// $ CLI_PARAMETER_PATH='./a/b/c' mybin
// Throws error because no argument given for "path"
command.parse()
You can also toggle with the environment variable CLI_SETTINGS_READ_ARGUMENTS_FROM_ENVIRONMENT (case insensitive):
const command = Command.create().parameter('--path', z.string())
// $ CLI_SETTINGS_READ_ARGUMENTS_FROM_ENVIRONMENT='false' CLI_PARAMETER_PATH='./a/b/c' mybin
// Throws error because no argument given for "path"
command.parse()
You can toggle environment on for just one or some parameters.
const args = Command.create()
.parameter('--foo', z.string())
.parameter('--bar', z.string().default('not_from_env'))
.settings({ environment: { foo: true } })
.parse()
// $ CLI_PARAMETER_FOO='foo' CLI_PARAMETER_BAR='bar' mybin
args.foo === 'foo'
args.bar === 'not_from_env'
You can toggle environment on except for just one or some parameters.
const args = Command.create()
.parameter('--foo', z.string().default('not_from_env'))
.parameter('--bar', z.string().default('not_from_env'))
.parameter('--qux', z.string().default('not_from_env'))
.settings({ environment: { $default: true, bar: false } })
.parse()
// $ CLI_PARAMETER_FOO='foo' CLI_PARAMETER_BAR='bar' CLI_PARAMETER_QUX='qux' mybin
args.foo === 'foo'
args.bar === 'not_from_env'
args.qux === 'qux'
You can customize the environment variable name prefix:
const args = Command.create()
.parameter('--path', z.string())
// o-- case insensitive
.settings({ environment: { $default: { prefix: 'foo' } } })
.parse()
args.path === './a/b/c/' // $ FOO_PATH='./a/b/c' mybin
You can pass a list of accepted prefixes instead of just one. Earlier ones take precedence over later ones:
const args = Command.create()
.parameter('--path', z.string())
// o---------o--- case insensitive
.settings({ environment: { $default: { prefix: ['foobar', 'foo'] } } })
.parse()
args.path === './a/b/c/' // $ FOOBAR_PATH='./a/b/c' mybin
args.path === './a/b/c/' // $ FOO_PATH='./a/b/c' mybin
args.path === './a/b/c/' // $ FOO_PATH='./x/y/z' FOOBAR_PATH='./a/b/c' mybin
You can customize the environment variable name prefix for just one or some parameters.
const args = Command.create()
.parameter('--foo', z.string().default('not_from_env'))
.parameter('--bar', z.string().default('not_from_env'))
.parameter('--qux', z.string().default('not_from_env'))
.settings({ environment: { bar: { prefix: 'MOO' } } })
.parse()
// $ CLI_PARAMETER_FOO='foo' MOO_BAR='bar' CLI_PARAMETER_QUX='qux' mybin
args.foo === 'foo'
args.bar === 'bar'
args.qux === 'qux'
You can customize the environment variable name prefix except for just one or some parameters.
const args = Command.create()
.parameter('--foo', z.string().default('not_from_env'))
.parameter('--bar', z.string().default('not_from_env'))
.parameter('--qux', z.string().default('not_from_env'))
.settings({ environment: { $default: { enabled: true, prefix: 'MOO' }, bar: { prefix: true } } })
.parse()
// $ MOO_FOO='foo' CLI_PARAM_BAR='bar' MOO_QUX='qux' mybin
args.foo === 'foo'
args.bar === 'bar'
args.qux === 'qux'
You can remove the prefix altogether. Pretty and convenient, but be careful for unexpected use of variables in host environment that would affect your CLI execution!
const args = Command.create()
.parameter('--path', z.string())
.settings({ environment: { $default: { prefix: false } } })
.parse()
args.path === './a/b/c/' // $ PATH='./a/b/c' mybin
You can disable environment variable name prefixes for just one or some parameters.
const args = Command.create()
.parameter('--foo', z.string().default('not_from_env'))
.parameter('--bar', z.string().default('not_from_env'))
.parameter('--qux', z.string().default('not_from_env'))
.settings({ environment: { bar: { prefix: false } } })
.parse()
// $ CLI_PARAMETER_FOO='foo' BAR='bar' CLI_PARAMETER_QUX='qux' mybin
args.foo === 'foo'
args.bar === 'bar'
args.qux === 'qux'
You can disable environment variable name prefixes except for just one or some parameters.
const args = Command.create()
.parameter('--foo', z.string().default('not_from_env'))
.parameter('--bar', z.string().default('not_from_env'))
.parameter('--qux', z.string().default('not_from_env'))
.settings({ environment: { $default: { enabled: true, prefix: false }, bar: { prefix: true } } })
.parse()
// $ FOO='foo' CLI_PARAM_BAR='bar' QUX='qux' mybin
args.foo === 'foo'
args.bar === 'bar'
args.qux === 'qux'
Environment variables are considered in a case insensitive way so all of these work:
const args = Command.create().parameter('--path', z.string()).parse()
// $ CLI_PARAM_PATH='./a/b/c' mybin
// $ cli_param_path='./a/b/c' mybin
// $ cLi_pAraM_paTh='./a/b/c' mybin
args.path === './a/b/c/'
By default, when a prefix is defined, a typo will raise an error:
const command = Command.create().parameter('--path', z.string())
// $ CLI_PARAM_PAH='./a/b/c' mybin
// Throws error because there is no parameter named "pah" defined.
command.parse()
If you pass arguments for a parameter multiple times under different environment variable name aliases an error will be raised.
const command = Command.create().parameter('--path', z.string())
// $ CLI_PARAMETER_PAH='./1/2/3' CLI_PARAM_PAH='./a/b/c' mybin
/ole/ Throws error because user intent is ambiguous.
command.parse()
With the chaining API you can declaratively state that two or more parameters are mutually exclusive using the parametersExclusive method.
Here is an example where you might want this feature. You are building a CLI for publishing software packages that allows the user to specify the version to publish either by semver level to bump by OR an exact version.
// prettier-ignore
const args = Command.create()
.parametersExclusive(`method`, (_) =>
_.parameter(`v version`, z.string().regex(semverRegex()))
.parameter(`b bump`, z.enum([`major`, `minor`, `patch`]))
)
There are three key benefits to this method:
In the above example args will end up with a method property whose type is:
// prettier-ignore
type Method =
| { _tag: 'version', value: string }
| { _tag: 'bump', value: 'major' | 'minor' | 'patch' }
You automatically get a proper TypeScript-ready discriminant property based on the canonical names of your parameters. This helps you to write type-safe code. Also, it pairs well with Alge ???? :). In the following example Semver.inc expects a strongly typed semver bump level of 'major'|'minor'|'patch':
const newVersion = Alge.match(args.method)
.bump(({ value }) => Semver.inc(pkg.version, value))
.version(({ value }) => value)
.done()
By default, input for a group of mutually exclusive parameters is required. You can mark the group as being optional:
// prettier-ignore
const args = Command.create()
.parametersExclusive(`method`, (_) =>
_.parameter(`v version`, z.string().regex(semverRegex()))
.parameter(`b bump`, z.enum([`major`, `minor`, `patch`]))
.optional()
)
By default, input for a group of mutually exclusive parameters is required. You can mark the group as being optional for users via a default so that internally there is always a value:
// prettier-ignore
const args = Command.create()
.parametersExclusive(`method`, (_) =>
_.parameter(`v version`, z.string().regex(semverRegex()))
.parameter(`b bump`, z.enum([`major`, `minor`, `patch`]))
.optional()
.default('bump', 'patch')
)
Your users will clearly see that these parameters are mutually exclusive. Here's an example from the CLI/script Molt itself uses to publish new releases:
You can give your command a description similar to how you can give each of your parameters a description.
const args = Command.create()
.description(
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam.',
)
.parameter(/* ... */)
Descriptions will show up in the auto generated help.
You can control certain settings about the command centrally using the .settings method. Sometimes the options here approximate the same options passable to parameter level settings, with the difference that configuration here affects all parameters at once. However, parameter level settings will always override command level ones.
Settings documentation is not co-located. Documentation for various features will mention when there are command level settings available.
Command.create().settings({...})
Say you want this CLI design:
mybin <-- Disable xee (default)
mybin --xee <-- Enable xee, use default
mybin --xee x <-- enable xee using x
mybin --xee y <-- enable xee using y
mybin --xee z <-- enable xee using z
You could achieve this with the following parameter definition:
const args = Command.create().parameter('xee', z.union([z.boolean(), z.enum(['x', 'y', 'z'])]).default(false))
args.xee // type: false | true | 'x' | 'y' | 'z'
Molt Command is composed from multiple distinct layers that execute in a flow:
Copyright 2013 - present © cnpmjs.org | Home |