Introduction
cmd-ts
is a type-driven command line argument parser written in TypeScript. Let's break it down:
A command line argument parser written in TypeScript
Much like commander
and similar Node.js tools, the goal of cmd-ts
is to provide your users a superior experience while using your app from the terminal.
cmd-ts
is built with TypeScript and tries to bring soundness and ease of use to CLI apps. It is fully typed and allows custom types as CLI arguments. More on that on the next paragraph.
cmd-ts
API is built with small, composable "parsers" that are easily extensible
cmd-ts
has a wonderful error output, which preserves the parsing context, allowing the users to know what they've mistyped and where, instead of playing a guessing game
Type-driven command line argument parser
cmd-ts
is essentially an adapter between the user's shell and the code. For some reason, most command line argument parsers only accept strings as arguments, and provide no typechecking that the value makes sense in the context of your app:
- Some arguments may be a number; so providing a string should result in an error
- Some arguments may be an integer; so providing a float should result in an error
- Some arguments may be readable files; so providing a missing path should result in an error
These types of concerns are mostly implemented in userland right now. cmd-ts
has a different way of thinking about it using the Type
construct, which provides both static (TypeScript) and runtime typechecking. The power of Type
lets us have a strongly-typed commands that provide us autocomplete for our implementation and confidence in our codebase, while providing an awesome experience for the users, when they provide a wrong argument. More on that on the Custom Types guide
Getting Started
Install the package using npm:
npm install --save cmd-ts
or if you use Yarn:
yarn add cmd-ts
Using cmd-ts
All the interesting stuff is exported from the main module. Try writing the following app:
import { command, run, string, positional } from 'cmd-ts';
const app = command({
name: 'my-first-app',
args: {
someArg: positional({ type: string, displayName: 'some arg' }),
},
handler: ({ someArg }) => {
console.log({ someArg });
},
});
run(app, process.argv.slice(2));
This app is taking one string positional argument and prints it to the screen. Read more about the different parsers and combinators in Parsers and Combinators.
Note:
string
is one type that comes included incmd-ts
. There are more of these bundled in the included types guide. You can define your own types using the custom types guide
Included Types
string
A simple string => string
type. Useful for option
and positional
arguments
boolean
A simple boolean => boolean
type. Useful for flag
number
A string => number
type. Checks that the input is indeed a number or fails with a descriptive error message.
optional(type)
Takes a type and makes it nullable by providing a default value of undefined
array(type)
Takes a type and turns it into an array of type, useful for multioption
and multiflag
.
union([types])
Tries to decode the types provided until it succeeds, or throws all the errors combined. There's an optional configuration to this function:
combineErrors
: function that takes a list of strings (the error messages) and returns a string which is the combined error message. The default value for it is to join with a newline:xs => xs.join("\n")
.
oneOf(["string1", "string2", ...])
Takes a closed set of string values to decode from. An exact enum.
Custom Types
Not all command line arguments are strings. You sometimes want integers, UUIDs, file paths, directories, globs...
Note: this section describes the
ReadStream
type, implemented in./example/test-types.ts
Let's say we're about to write a cat
clone. We want to accept a file to read into stdout. A simple example would be something like:
// my-app.ts
import { command, run, positional, string } from 'cmd-ts';
const app = command({
/// name: ...,
args: {
file: positional({ type: string, displayName: 'file' }),
},
handler: ({ file }) => {
// read the file to the screen
fs.createReadStream(file).pipe(stdout);
},
});
// parse arguments
run(app, process.argv.slice(2));
That works well! We already get autocomplete from TypeScript and we're making progress towards developer experience. Still, we can do better. In which ways, you might think?
- Error handling is non existent, and if we'd implement it in our handler it'll be out of the command line argument parser context, making things less consistent and pretty.
- It shows we lack composability and encapsulation — we miss a way to share and distribute "command line" behavior.
💡 What if we had a way to get a
Stream
out of the parser, instead of a plain string?
This is where cmd-ts
gets its power from,
Custom Type Decoding
Exported from cmd-ts
, the construct Type<A, B>
is a way to declare a type that can be converted from A
into B
, in a safe manner. cmd-ts
uses it to decode the arguments provided. You might've seen the string
type, which is Type<string, string>
, or, the identity: because every string is a string. Constructing our own types let us have all the implementation we need in an isolated and easily composable.
So in our app, we need to implement a Type<string, Stream>
, or — a type that reads a string
and outputs a Stream
:
// ReadStream.ts
import { Type } from 'cmd-ts';
import fs from 'fs';
// Type<string, Stream> reads as "A type from `string` to `Stream`"
const ReadStream: Type<string, Stream> = {
async from(str) {
if (!fs.existsSync(str)) {
// Here is our error handling!
throw new Error('File not found');
}
return fs.createReadStream(str);
},
};
from
is the only required key inType<A, B>
. It's an async operation that getsA
and returns aB
, or throws an error with some message.- Other than
from
, we can provide more metadata about the type:description
to provide a default description for this typedisplayName
is a short way to describe the type in the helpdefaultValue(): B
to allow the type to be optional and have a default value
Using the type we've just created is no different that using string
:
// my-app.ts
import { command, run, positional } from 'cmd-ts';
const app = command({
// name: ...,
args: {
stream: positional({ type: ReadStream, displayName: 'file' }),
},
handler: ({ stream }) => stream.pipe(process.stdout),
});
// parse arguments
run(app, process.argv.slice(2));
Our handler
function now takes a stream
which has a type of Stream
. This is amazing: we've pushed the logic of encoding a string
into a Stream
outside of our implementation, which free us from having lots of guards and checks inside our handler
function, making it less readable and harder to test.
Now, we can add more features to our ReadStream
type and stop touching our code which expects a Stream
:
- We can throw a detailed error when the file is not found
- We can try to parse the string as a URI and check if the protocol is HTTP, if so - make an HTTP request and return the body stream
- We can see if the string is
-
, and when it happens, returnprocess.stdin
like many Unix applications
And the best thing about it — everything is encapsulated to an easily tested type definition, which can be easily shared and reused. Take a look at io-ts-types, for instance, which has types like DateFromISOString, NumberFromString and more, which is something we can totally do.
Battery Packs
Batteries, from the term "batteries included", are optional imports you can use in your application but aren't needed in every application. They might have dependencies of their own peer dependencies or run only in a specific runtime (browser, Node.js).
Here are some battery packs:
File System Battery Pack
The file system battery pack contains the following types:
ExistingPath
import { ExistingPath } from 'cmd-ts/batteries/fs';
Resolves into a path that exists. Fails if the path does not exist.
If a relative path is provided (../file
), it will expand by using the current working directory.
Directory
import { Directory } from 'cmd-ts/batteries/fs';
Resolves into a path of an existing directory. If an existing file was given, it'll use its dirname
.
File
import { File } from 'cmd-ts/batteries/fs';
Resolves into an existing file. Fails if the provided path is not a file.
URL Battery Pack
The URL battery pack contains the following types:
Url
import { Url } from 'cmd-ts/batteries/url';
Resolves into a URL
class. Fails if there is no host
or protocol
.
HttpUrl
import { HttpUrl } from 'cmd-ts/batteries/url';
Resolves into a URL
class. Fails if the protocol is not http
or https
Parsers and Combinators
cmd-ts
can help you build a full command line application, with nested commands, options, arguments, and whatever you want. One of the secret sauces baked into cmd-ts
is the ability to compose parsers.
Argument Parser
An argument parser is a simple struct with a parse
function and an optional register
function.
cmd-ts
is shipped with a couple of parsers and combinators to help you build your dream command-line app. subcommands
are built using nested command
s. Every command
is built with flag
, option
and positional
arguments. Here is a short parser description:
positional
andrestPositionals
to read arguments by positionoption
andmultioption
to read binary--key value
argumentsflag
andmultiflag
to read unary--key
argumentscommand
to compose multiple arguments into a command line appsubcommands
to compose multiple command line apps into one command line appbinary
to make a command line app a UNIX-executable-ready command
Positional Arguments
Read positional arguments. Positional arguments are all the arguments that are not an option or a flag. So in the following command line invocation for the my-app
command:
my-app greet --greeting Hello Joe
^^^^^ ^^^ - positional arguments
positional
Fetch a single positional argument
This parser will fail to parse if:
- Decoding the user input fails
Config:
displayName
(required): a display name for the named argument. This is required so it'll be understandable what the argument is fortype
(required): a Type fromstring
that will help decoding the value provided by the userdescription
: a short text describing what this argument is for
restPositionals
Fetch all the rest positionals
Note: this will swallaow all the other positionals, so you can't use
positional
to fetch a positional afterwards.
This parser will fail to parse if:
- Decoding the user input fails
Config:
displayName
: a display name for the named argument.type
(required): a Type fromstring
that will help decoding the value provided by the user. Each argument will go through this.description
: a short text describing what these arguments are for
Options
A command line option is an argument or arguments in the following formats:
--long-key value
--long-key=value
-s value
-s=value
where long-key
is "the long form key" and s
is "a short form key".
There are two ways to parse options:
- The
option
parser which parses one and only one option - The
multioption
parser which parser none or multiple options
option
Parses one and only one option. Accepts a Type
from string
to any value to decode the users' intent.
In order to make this optional, either the type provided or a defaultValue
function should be provided. In order to make a certain type optional, you can take a look at optional
This parser will fail to parse if:
- There are zero options that match the long form key or the short form key
- There are more than one option that match the long form key or the short form key
- No value was provided (if it was treated like a flag)
- Decoding the user input fails
Usage
import { command, number, option } from 'cmd-ts';
const myNumber = option({
type: number,
long: 'my-number',
short: 'n',
});
const cmd = command({
name: 'my number',
args: { myNumber },
});
Config
type
(required): A type fromstring
to any valuelong
(required): The long form keyshort
: The short form keydescription
: A short description regarding the optiondisplayName
: A short description regarding the optiondefaultValue
: A function that returns a default value for the optiondefaultValueIsSerializable
: Whether to print the defaultValue as a string in the help docs.
multioption
Parses multiple or zero options. Accepts a Type
from string[]
to any value, letting you do the conversion yourself.
Note: using
multioption
will drop all the contextual errors. Every error on the type conversion will show up as if all of the options were errored. This is a higher level with less granularity.
This parser will fail to parse if:
- No value was provided (if it was treated like a flag)
- Decoding the user input fails
Config
type
(required): A type fromstring[]
to any valuelong
(required): The long form keyshort
: The short form keydescription
: A short description regarding the optiondisplayName
: A short description regarding the optiondefaultValue
: A function that returns a default value for the option array in case no options were provided. If not provided, the default value will be an empty array.defaultValueIsSerializable
: Whether to print the defaultValue as a string in the help docs.
Flags
A command line flag is an argument or arguments in the following formats:
--long-key
--long-key=true
or--long-key=false
-s
-s=true
or--long-key=false
where long-key
is "the long form key" and s
is "a short form key".
Flags can also be stacked using their short form. Let's assume we have flags with the short form keys of a
, b
and c
: -abc
will be parsed the same as -a -b -c
.
There are two ways to parse flags:
- The
flag
parser which parses one and only one flag - The
multiflag
parser which parser none or multiple flags
flag
Parses one and only one flag. Accepts a Type
from boolean
to any value to decode the users' intent.
In order to make this optional, either the type provided or a defaultValue
function should be provided. In order to make a certain type optional, you can take a look at optional
This parser will fail to parse if:
- There are zero flags that match the long form key or the short form key
- There are more than one flag that match the long form key or the short form key
- A value other than
true
orfalse
was provided (if it was treated like an option) - Decoding the user input fails
Usage
import { command, boolean, flag } from 'cmd-ts';
const myFlag = flag({
type: boolean,
long: 'my-flag',
short: 'f',
});
const cmd = command({
name: 'my flag',
args: { myFlag },
});
Config
type
(required): A type fromboolean
to any valuelong
(required): The long form keyshort
: The short form keydescription
: A short description regarding the optiondisplayName
: A short description regarding the optiondefaultValue
: A function that returns a default value for the optiondefaultValueIsSerializable
: Whether to print the defaultValue as a string in the help docs.
multiflag
Parses multiple or zero flags. Accepts a Type
from boolean[]
to any value, letting you do the conversion yourself.
Note: using
multiflag
will drop all the contextual errors. Every error on the type conversion will show up as if all of the options were errored. This is a higher level with less granularity.
This parser will fail to parse if:
- A value other than
true
orfalse
was provided (if it was treated like an option) - Decoding the user input fails
Config
type
(required): A type fromboolean[]
to any valuelong
(required): The long form keyshort
: The short form keydescription
: A short description regarding the flagdisplayName
: A short description regarding the flag
command
This is what we call "a combinator": command
takes multiple parsers and combine them into one parser that can also take raw user input using its run
function.
Config
name
(required): A name for the commandversion
: A version for the commandhandler
(required): A function that takes all the arguments and do something with itargs
(required): An object where the keys are the argument names (how they'll be treated in code) and the values are parsersaliases
: A list of other names this command can be called with. Useful withsubcommands
Usage
#!/usr/bin/env YARN_SILENT=1 yarn ts-node
import {
run,
boolean,
option,
Type,
flag,
extendType,
command,
string,
} from '../src';
const PrNumber = extendType(string, {
async from(branchName) {
const prNumber = branchName === 'master' ? '10' : undefined;
if (!prNumber) {
throw new Error(`There is no PR associated with branch '${branchName}'`);
}
return prNumber;
},
defaultValue: () => 'Hello',
});
const Repo: Type<string, string> = {
...string,
defaultValue: () => {
throw new Error("Can't infer repo from git");
},
description: 'repository uri',
displayName: 'uri',
};
const app = command({
name: 'build',
args: {
user: option({
type: string,
env: 'APP_USER',
long: 'user',
short: 'u',
}),
password: option({
type: string,
env: 'APP_PASS',
long: 'password',
short: 'p',
}),
repo: option({
type: Repo,
long: 'repo',
short: 'r',
}),
prNumber: option({
type: PrNumber,
short: 'b',
long: 'pr-number',
env: 'APP_BRANCH',
}),
dev: flag({
type: boolean,
long: 'dev',
short: 'D',
}),
},
handler: ({ repo, user, password, prNumber, dev }) => {
console.log({ repo, user, password, prNumber, dev });
},
});
run(app, process.argv.slice(2));
subcommands
This is yet another combinator, which takes a couple of command
s and produce a new command that the first argument will choose between them.
Config
name
(required): A name for the containerversion
: The container versioncmds
: An object where the keys are the names of the subcommands to use, and the values arecommand
instances. You can also providesubcommands
instances to nest a nested subcommand!
Usage
import { command, subcommands, run } from 'cmd-ts';
const cmd1 = command({
/* ... */
});
const cmd2 = command({
/* ... */
});
const subcmd1 = subcommands({
name: 'my subcmd1',
cmds: { cmd1, cmd2 },
});
const nestingSubcommands = subcommands({
name: 'nesting subcommands',
cmds: { subcmd1 },
});
run(nestingSubcommands, process.argv.slice(2));
binary
A standard Node executable will receive two additional arguments that are often omitted:
- the node executable path
- the command path
cmd-ts
provides a small helper that ignores the first two positional arguments that a command receives:
import { binary, command, run } from 'cmd-ts';
const myCommand = command({
/* ... */
});
const binaryCommand = binary(myCommand);
run(binaryCommand, process.argv);
Building a Custom Parser
... wip ...