Building a CLI arguments parser with TypeScript

One of the things I like to do in my spare time is play World of Warcraft; it’s the game I met my spouse in over 10 years ago (she was a protection paladin in need of a healer, and I was a resto shaman — love at first sight). I’ve been leading raid teams since early Cataclysm, and one of the most irritating things about the “job” is playing human resources simulator and managing a roster.

To this end, I’ve cooked up an idea for a Discord bot that can hopefully help me manage my team with a little bit more efficiency. The idea is that I’ll have a Discord bot sitting in my raid team’s channel, and I should be able to pass it commands to manage the roster; add a member, delete a member, show the team’s current healer/tank/dps lineup, track attendance, track the critical buffs/debuffs brought by certain classes, and so on.

Yes, an Excel sheet would probably be more than adequate for this situation. But I’m a programmer, and I like to overengineer solutions to my problems!

The Discord bot is coming along slowly but surely (and you can even check it out or fork it on Github), but it’s a subject for another post. What I really want to write about is a realization I’ve come to while building this bot and using other bots: it would be so much nicer if you could just treat the bot like a CLI tool sitting in your Discord channel. That is to say, I want to be able to pass arguments and flags to the bot.

Most Discord bots (not all) try to get you to “talk” to them as if they’re a human. You need to pass arguments and values in a specific order, and if you don’t get the order correct you’ll goof it up. For example, one bot we use for our guild tracks members streaming on Twitch. To list all members who are currently marked as streamers, you need to write !botName streamer list twitch all. If you write !botName twitch list streamer all or !botName streamer twitch list all, you’ll get an error.

Wouldn’t it be better if we could treat it like a CLI instead, and tell it which value is which? I’d rather write !botName streamer --service twitch --list --all. That’s much more specific, and easier to remember because you don’t need to remember a specific and seemingly arbitrary order.

So I set out to modify my bot to parse Discord messages into CLI-y arguments, flags and values. Ideally I could just install a package that would take a string and parse it into an object or dictionary of key/value pairs. I’ve built a fair few CLI tools in the past in C#, F#, and TypeScript, and when it comes to TS, my go-to package is Microsoft’s ts-command-line. It’s easy to understand, easy to use, and it even documents the commands, args and flags for you.

Sadly it doesn’t quite fit the use-case. It was nearly perfect, too! The only problem with the package was that you can’t control where it outputs help and error messages. It would execute commands with no problem, but if the user got something wrong or asked for help (with --help), it would just log the message to the console where nobody is listening. After spelunking through the code on GitHub, I had my confirmation that there was no way to change that behavior, and I was forced to look for an alternative.

After a good while spent searching for and experimenting with alternatives — including yargs, minimist, and so on — there was no package that met all of my criteria. Don’t get me wrong, they’re all great, especially the really popular ones that most people are using today, but all of them fell short in one area or another. For example, the TypeScript support kinda sucks for a lot of the popular ones.

In the case of yargs, they do have TypeScript types, but they don’t work at all with the object you get back after parsing input. Instead you get an interface that looks like { $_: string[] }, which is largely useless. There’s no way to modify that interface, it doesn’t accept a generic type, so you can’t actually tell the compiler what the result should look like.

As a TypeScript “purist”, if a package doesn’t work and I have to modify the types or write my own, I’d rather just look for a different package, or just build my own. That way there are no situations where the types wrong or the package doesn’t quite behave as expected.

Building a custom arguments parser instead

And that’s the topic of this post! Since I couldn’t find a suitable package that met all of my requirements, I set out to build my own argument parser. The requirements I had (that other packages didn’t fully meet) were the following:

  1. Each “command” should be its own class, just like the ts-command-line package from Microsoft. This helps separate logic and prevent a ton of intermingling spaghetti code.
  2. Each command must declare the arguments, flags and values it needs or uses, which makes them self documenting.
  3. There should be an overarching “command parser” class that parses a command from a string and decides which command to execute.
  4. The command parser class should automatically handle help requests (e.g. help or -h or --help), and it should use the self-documenting nature of the commands to format those messages.
  5. The command parser and the commands themselves should all be asynchronous, as (in my case) they may be communicating with a user over Discord. If commands are asynchronous, they could even ask for more input from a user without ending the “call”, so to speak.
  6. The parser and the commands need to have some kind of hook that they’ll use to pass messages back to the package consumer. So instead of assuming that the parser is running in a terminal and just dumping output to console.log, the consumer (i.e. developer) decides what to do with help or error messages.

With those requirements in mind, we’re going to write a package that contains five separate classes:

  1. An abstract Parameter class, which will be used to parse arguments, values and flags from a string. This abstract class is going to be extended by the next two parameter classes, it’s only responsibility is to ensure they define certain methods and values.
  2. A FlagParameter class, which parses flags to booleans (e.g. --help or --force).
  3. A StringParameter class, which parses named arguments and their values (e.g. --foo bar or --foo=bar).
  4. A Command class, which will use the FlagParameter and StringParameter classes to define the parameters it needs. This class will be extended by the package consumer (i.e. the developer using the package) to create commands for their tool (e.g. tool foo or tool bar).
  5. A CommandParser class, which the package consumer will extend and add their custom commands tool. This class is responsible for deciding which command to execute, along with formatting help messages and relaying command output back to the package consumer.

The abstract Parameter class

The Parameter class is going to be entirely abstract, it’s purpose is just to ensure that the FlagParser and ArgumentParser classes both implement a parse function, and describe themselves in a certain way. Its constructor will take a configuration object which includes the parameter’s name (e.g. “—foo”), short name (e.g. “-f”), a default value, and a boolean to indicate whether the parameter is required or optional.

It’s also going to require a generic type, which will be used to constrain the default value and the parsed value to that type. So if we have a StringParameter class that extends this abstract Parameter (and we will), the compiler will know that the default value and parsed value will both be strings.

First let’s define that config object’s type:

type ParameterConfig<T> = 
    | { 
        name: string;
        shortName: string;
        description: string;
        defaultValue: T;
        required: false;
    }
    | {
        name: string;
        shortName: string;
        description: string;
        required: true;
    }

Since the ParameterConfig<T> is a union type, the compiler will also know that if required is false, then you need to pass in a default value. If it’s true, then there is no default value at all — the parse will either succeed and a value will be found, or it will fail and an error will be returned.

This class has one single function, which is parse. The function receives a string array of arguments and values — the input string split by spaces — then parses its own value from the array. If the operation succeeds, it should return an object containing the parsed value, and any strings (i.e. arguments and values) that remain after removing the parameter’s own argument name and value.

type ParameterParseOperation<T> =
    | {
        ok: true;
        value: T;
        remaining: string[]
    } 
    | {
        ok: false;
    }

With those two types defined, we can write the Parameter class. Its constructor is going to receive that ParameterConfig<T> type, and its abstract parse function is going to return a ParameterParseOperation<T>.

export abstract class Parameter<T> {
    constructor(public config: ParameterConfig<T>) { }

    /**
     * Parses an array of arguments, values and commands to find the Parameter's own value. 
     * If found, the array will be returned with the Parameter's argument and value removed.
     */
    abstract parse(input: string[]): ParameterParseOperation<T>;
}

Because the parse function is abstract, we don’t actually implement it on this class. Instead, it’s the responsibility of any class extending this one to implement that function.

Writing the FlagParameter class

  • The first “real” parser class we’re going to right is the FlagParser. In its simplest form, this class just looks for a string like --flag and converts it to a boolean — true if the flag is found, and false if it’s absent. That’s the simplest form, though; users may want to specifically set a flag’s value to false, so it will need to parse things like --flag=false and --flag false.

If the FlagParameter class is going to extend the abstract Parameter class, then it will need to call super() in its constructor and pass in a ParameterConfig<T> object. In this case, the FlagParameter’s going to use boolean as its value type.

export class FlagParameter extends Parameter<boolean> {
    constructor(config: ParameterConfig<boolean>) {
        super(config);
    }

    parse(input: string[]): ParameterParseOperation<boolean> {
        throw new Error("Method not implemented");
    }
}

And now’s our chance to do some actual parsing! The parse function is going to look at the string array it receives and try to find instances of --flag, --flag=false, and --flag false. Remember, the strings in this string array are just the words passed in by the user. So if you’re looking at the dotnet run -c Release command, there would be three strings in the array: run, -c and release.

The tool name itself is not going to be included in the string array, which is why there are only three strings in the array — dotnet should be removed automatically.

The parse function needs to not only look at those strings to determine its own value, it must also remove the argument (and potential argument value) from the input array. That will ensure no other parameters accidentally use this parameter’s value. To do this, we’ll use a reducer function which will loop over each string in the array to find the FlagParameter’s long argument name (--foo) or short argument name (-f).

We’ll build this parse function in a couple different steps. First, let’s start by running a reducer function on the string array. It will start with a failed parse operation as the default value, which means if the reducer is unable to find the parameter’s value, the default failed operation gets returned instead. In most cases (not all), a reducer function should “short circuit” itself if the value has already been found, so we just check if state.ok is true, and if so, return the state. If it’s false, that’s when we check if the string is the argument we need.

parse(input: string[]): ParameterParseOperation<boolean> {
    // Start with a failed parse operation as the default value. If the reducer function is unable to 
    // find this parameter's value, the default value will be returned instead.
    const defaultValue: ParameterParseOperation<boolean> = { ok: false };

    return input.reduce<ParameterParseOperation<boolean>((state, str, index, input) => {
        // If the value has already been parsed, short circuit and return it
        if (state.ok) {
            return state;
        }

        // TODO: check if the current string is the argument we're looking for
        return state;
    }, defaultValue);
}

Alright, we’re “short circuiting” the reducer function to return the value once it’s been found, so now we need to find that value. There are three specific cases we need to check:

  1. Is the string equal to the long argument name?
  2. Is the string equal to the short argument name?
  3. Does the string start with the long argument name and an =, i.e. does it look like --foo=value?
return input.reduce<ParameterParseOperation<boolean>((state, str, index, input) => {
    // If the value has already been parsed, short circuit and return it
    if (state.ok) {
        return state;
    }

    // Check if the current string is the argument name or argument name + value
    if (str === this.config.name || str === this.config.shortName) {
        // String looks like --foo or -f
        // TODO: Look ahead and see if the next string is "true" or "false", otherwise default the value to true
    } else if (str.indexOf(`${this.config.name}=`) === 0) {
        // String looks like `--foo=value` 
        // TODO: Check if the value is "true" or "false", otherwise throw an error
    }

    return state;
}, defaultValue);

Now our reducer is looking at whether the string is equal to the long or short name of the parameter, or if the string looks like --foo=value. The very last step is to parse the actual value. That’s easy to do if we have a --foo=value, we just check if it’s true or false; but if the string is --foo or -f, then we need to actually look ahead and see if the next value is true or false, or if it’s another argment. If it is another argument, we just assume that the value should be true.

return input.reduce<ParameterParseOperation<boolean>((state, str, index, input) => {
    // If the value has already been parsed, short circuit and return it
    if (state.ok) {
        return state;
    }

    // Check if the current string is the argument name or argument name + value
    if (str === this.config.name || str === this.config.shortName) {
        // String looks like --foo or -f. Look ahead to see if the next string is an argument or a true/false value
        const nextStr: string | undefined = input[index + 1];
    
        switch (nextStr) {
            case "true":
            case "false":
                // The user entered an explicit value for this flag
                return {
                    ok: true,
                    value: nextStr === "true",
                    remaining: [
                        // Remove this current string and the next string from the input array
                        ...input.slice(0, index),
                        ...input.slice(index + 2)
                    ]
                }

            default:
                // The next string is not true or false, so we assume no explicit value was specified
                // Default the flag's value to true
                return {
                    ok: true,
                    value: true,
                    remaining: [
                        ...input.slice(0, index),
                        ...input.slice(index + 1)
                    ]
                }
        }
    } else if (str.indexOf(`${this.config.name}=`) === 0) {
        // String looks like `--foo=value`. Check if the value is true/false, and throw an error if not
        const [split, value] = str.split(`${this.config.name}=`);

        switch (value) {
            case "true":
            case "false":
                // The user entered an explicit value for this flag
                return {
                    ok: true,
                    value: value === "true",
                    remaining: [
                        // Remove this current string from the input array
                        ...input.slice(0, index),
                        ...input.slice(index + 1)
                    ]
                }

            default:
                // Value is not "true" or "false", so it is invalid for a flag parameter. Throw an error.
                throw new Error(`Value ${value} is not valid for ${this.config.name}. Expected true or false.`);
        }
    }

    return state;
}, defaultValue);

And now the FlagParameter should be parsing true/false values from an input string! There are a couple edge cases that I’ve left out of this example for brevity, chiefly that you should check if --foo someValue is passed and throw if it is not true or false. In this example, we’re just assuming that if the next string is not “true” or “false” then it must be another argument — in reality it may be an invalid value. Leaving it in the string array might goof up other Parameters!

The source code for this entire article is available on GitHub.

Writing the StringParameter class

With the FlagParameter built, the next (and last) parameter class we’ll write for this tutorial is the StringParameter.This one is responsible for taking that input string array and parsing out arguments that have some kind of value. For example, if you try to run a dotnet project in release mode with dotnet run -c Release, then -c is the name of the argument and Release is it string value.

Once again, this class is just going to extend the abstract Parameter class and implement its required parse function. It needs to call super() in the constructor and pass in a ParameterConfig<string> object.

export class StringParameter extends Parameter<string> {
    constructor(config: ParameterConfig<string>) {
        super(config);
    }

    parse(input: string[]): ParameterParseOperation<string> {
        throw new Error("Method not implemented");
    }
}

Just like the FlagParser’s extra “modes”, this one needs to parse strings in two different formats: -c Release (where the argument and the value are two different entries in the string array), and -c=Release (where the argument and value are just one single entry in the string array).

Let’s start off by setting up another reducer function to iterate over those strings and return a default “failed” operation. If the reducer can’t parse a value, the “failed” operation gets returned instead. Inside the reducer, we’ll copy the FlagParameter and “short circuit” once the value has been parsed and just return that value.

parse(input: string[]): ParameterParseOperation<string> {
    // Start with a failed parse operation as the default value. If the reducer function is unable to 
    // find this parameter's value, the default value will be returned instead.
    const defaultValue: ParameterParseOperation<string> = { ok: false };

    return input.reduce<ParameterParseOperation<string>>((state, str, index, input) => {
        // If the value has already been parsed, short circuit and return it
        if (state.ok) {
            return state;
        }

        // TODO: check if the current string is the argument we're looking for
        return state;
    }, defaultValue);
}

Assuming the value has not yet been found, we need to check the current string and see if it’s our argument. Just like the FlagParameter, we need to see if the string is equal to the long argument name (--foo), the short argument name (-f), or if the string is the long argument name plus a value (--foo=value). Assuming the long name or short name are found, we’ll parse out the argument’s value. Remember, we need to remove both the argument and the value from the list of strings, or else another parameter may accidentally use them when parsing.

return input.reduce<ParameterParseOperation<boolean>((state, str, index, input) => {
    // If the value has already been parsed, short circuit and return it
    if (state.ok) {
        return state;
    }

    // Check if the current string is the argument name or argument name + value
    if (str === this.config.name || str === this.config.shortName) {
        // String looks like --foo or -f
        // TODO: Look ahead and see if the next string is a value or an argument 
        const nextStr: string | undefined = input[index + 1];

        if (!nextStr) {
            throw new Error(`Value not found for ${this.config.name}`);
        }

        return {
            ok: true,
            value: nextStr,
            remaining: [
                ...input.slice(0, index),
                ...input.slice(index + 2)
            ]
        }
    } else if (str.indexOf(`${this.config.name}=`) === 0) {
        // String looks like `--foo=value` 
        const [split, value] = str.split(`${this.config.name}=`);

        if (!value) {
            throw new Error(`Value not found for ${this.config.name}`);
        }

        return {
            ok: true,
            value: nextStr,
            remaining: [
                ...input.slice(0, index),
                ...input.slice(index + 1)
            ]
        }
    }

    return state;
}, defaultValue);

Although it’s beyond the scope of this article, you could improve the StringParameter class even further by only accepting certain strings. Then you can handle situations where the string value must be one of e.g. ["hello", "world", "foo", "bar"]. It could also be improved by specifying a default value in the ParameterConfig<T> interface.

Writing the Command class

With the FlagParameter and StringParameter classes written, we’re ready to write the Command class which will utilize those classes when defining the parameters it needs to run. Like the abstract Parameter class, the Command class is also going to be abstract, and it’s going to define two functions plus two properties that the consumer (i.e. developer) need to implement:

  1. Command.name: the name of the command or action. Reusing the dotnet example, if you run dotnet run -c Release, then run is the command’s name.
  2. Command.description: a string summarizing what the command does. This string will be used in help messages.
  3. Command.onDefineParameters: a function where the extending class defines the parameters it needs to run. It will return an object where each property is a StringParameter or a FlagParameter.
  4. Command.execute an async function (one that returns a promise) where the mat of the command is actually executed. It will receive an object containing the values of parameters defined in Command.onDefineParameters.

The two functions, onDefineParameters and execute have a special interaction with the value returned and the value received, respectively. The onDefineParameters function is expected to return an object where each property is a StringParameter or a FlagParameter. The package itself is going to look at each of those parameters and try to parse the values from the input string.

Assuming all of the values are parsed correctly, it’s going to pass that object to the execute function, but instead of the StringParameters and FlagParameters, it’s going to have the parsed values:

onDefineParameters() {
    return {
        foo: new StringParameter(...),
        bar: new FlagParameter(...)
    }
}

execute(parameters) {
    console.log(parameters); // { foo: "some string", bar: true }
}

This is exactly where the argument parsing packages I had tried previously would fail. I may have received an object after the input was parsed, but there was no intellisense or compiler protection for the properties on the object. Thankfully, since we’re building our own package, we can fix that right now! We need to create a TypeScript type that receives an object, looks at the properties on it, and morphs them to a StringParameter or a BooleanParameter.

type CommandParameters<T extends object> = { 
    [K in keyof T]: T[K] extends string ? StringParameter
        : T[K] extends boolean ? FlagParameter
        : Parameter<unknown>
}

Now we can use this type to refer to the value that gets returned by onDefineParameters. If T is the type/interface that the developer expects to receive in the execute command, then CommandParameters<T> is that same type but with its values changed to one of the Parameter classes. The type looks at each property on the object and says “if it’s a string property, it needs to be a StringParameter; if it’s a boolean property, it needs to be a FlagParameter; if it’s anything else, I don’t know what it should be, so it’s going to be a Parameter with an unknown value type.”

I detail this technique a little bit more in this post on TypeScript’s conditional types.

With that type good to go, let’s write the abstract Command class itself. It’s going to take a generic type — that object that the developer expected to receive in the execute function.

export abstract class Command<T extends object> {
    abstract get name(): string;
    abstract get description(): string;
    /**
     * Defines the parameters needed/supported by this Command. 
     * @returns An object where each value is a StringParameter or FlagParameter.
     */
    abstract onDefineParameters(): CommandParameters<T>;
    /**
     * Executes the "meat" of the command.
     * @param parsedParams The object returned by @see onDefineParameters, but each StringParameter property is a string and each FlagParameter is a boolean.
     * @returns A promise that resolves once the command is finished executing.
     */
    abstract execute(parsedParams: T): Promise<void>;
}

The Command class is largely finished, except for one important feature: it needs a way to pass messages back to the caller! Most CLI or arg parser packages just assume that you’re running your tool in the terminal and will gladly spit out their error/help messages to the console, but at least in this case, we want to have more control over that. Instead, our Command class should receive some kind of function that will take a string (the message) and return a promise that resolves once the message has been handled.

We could make that another abstract property that the caller must implement, but since it’s only going to be used “internally” by the commands, I think it makes more sense for it to be passed in with the constructor.

type MessageHandler = (message: string) => Promise<void>;

export abstract class Command<T extends object> {
    constructor(protected sendMessage: MessageHandler) { }

    ...
}

The message handler in this example is protected, which means it can only be seen by the Command class and any implementations of the Command class; it cannot be accessed from outside of the instance. To use it during command execution, the developer simply calls this.sendMessage("my message here").

Writing the CommandParser class

So we’ve got a StringParameter class, a FlagParameter class, and a Command class. The final piece of the puzzle is the CommandParser class. This class is responsible for parsing which command to run from the input string. Its constructor is going to receive two things:

  1. The same MessageHandler function that Commands receive, but in this case it will be used to send help or error messages.
  2. An array of supported Commands, which will be used to both format help messages and execute commands when necessary.
export class CommandParser {
    constructor(private sendMessage: MessageHandler, private commands: Command[]) { }
}

There will be two functions on the CommandParser class:

  1. formatHelpMessage(command?): this will be called whenever the CommandParser decides a user is trying to get help for a command or for the tool itself (e.g. dotnet help or dotnet run --help). It receives an optional argument — a command — and should format the help message accordingly. If a command is received, show a help message for that specific command; if no command is received, show a help message for all commands.
  2. execute(input): this will be called by the developer/consumer and will receive either a string array or a full string (which it will split into a string array). This function decides which command to run and runs it, and it will display a help message if the asks for one or if a matching command cannot be found.

Let’s start off with the function for formatting a help message. One of the nicer things about Microsoft’s ts-command-line package is that all of the commands are self documenting, which means the developer doesn’t need to implement a formatHelpMessage function on each command. Instead the command parser itself can access the command’s description, and it access all of the parameters the command expects by calling command.onDefineParameters() to get the list. With the description and list of expected parameters, the command parser can easily stitch together a help message.

export class CommandParser {
    constructor(...) { }

    /**
     * Formats and returns a help message, detailing usage instructions.
     * @param command An optional command that can be used to get a help message just for the command. If absent, a help message is created for all commands instead.
     */
    formatHelpMessage(command?: Command): string {
        // If a command was passed in, just get a help message for that command. Else, get a message for all commands.
        const commands: Command[] = command ? [command] : this.commands;
        const commandHelpMessages: string[] = commands.map<string>(command => {
            // Get this command's parameters
            const params: Parameter<any>[] = command.onDefineParameters();
            const paramMessages: string[] = params.map(c => `\t${c.name}|${c.shortName}: ${c.description}`);

            return `${command.name}: ${command.description}\n\n${params.join("\n")}` 
        });

        return commandHelpMessages.join("\n\n");
    }
}

The code we’ve written just looks at each command in the parser, then formats their names and descriptions. If you were to call parserInstance.formatHelpMessage() you’d receive a string that looks something like this:

fooCommand: does something with foos
    --fooArg: some argument for fooCommand
    --barArg: some argument for fooCommand

barCommand: does something with bars
    --fooArg: some argument for barCommand
    --barArg: some argument for barCommand

There are a few ways this could be improved, like adding a description of the tool itself, its version and how to get help for a specific command. All of that should be relatively simple, but its beyond the scope of this article.

Instead, let’s get into the meat of the CommandParser, the execute function. This function needs to do several things:

  1. Receive a string or a string array. If it receives a string, it should manually parse it into a string array. This will support situations where you already have a string array (e.g. from process.argv in Node), or situations where you only have a string (e.g. the Discord message scenario).
  2. Parse the name of the command being run, and run that command if it’s supported. If we use dotnet run -c Release, then run is the name of the command.
  3. If the user is trying to get help for a specific command (dotnet run --help), format the help message and call the MessageHandler.
  4. If the user is trying to get help for using the tool itself (dotnet help or dotnet --help), format a help message and call the MessageHandler.
  5. If the user entered nothing at all (dotnet) or they entered an invalid command, format a help message and call the MessageHandler.

We’ll start by checking whether the input value is a string or a string array. If it’s a string, we’ll split it up into a string array by splitting on spaces.

export class CommandParser<T> {
    constructor(...) {}

    formatHelpMessage(...) {...}

    /**
     * Attempts to find a command and execute it. Will emit a help message if no matching command is found.
     */
    async execute(input: string | string[]): Promise<void> {
        // Check if the input is a string, and if so, split it into a string array
        const strings: string[] = input = typeof input === "string" ? input.split(" ").map(s => s.trim()) : input;

        // TODO: Parse the name of the command
    }
}

Once we know for sure we have an array of strings, we can try to figure out which command is being run. If no matching command is found, we’ll have to display a help message, as the user must have entered an incorrect command. We should also look for cases where the user is specifically trying to get help, e.g. dotnet or dotnet --help or dotnet help.

async execute(input: string | string[]): Promise<void> {
    // Check if the input is a string, and if so, split it into a string array
    const strings: string[] = input = typeof input === "string" ? input.split(" ").map(s => s.trim()) : input;

    // Parse the name of the command, which should be the very first word in the array
    const command: string | undefined = strings[0];
    const matchingCommand = this.commands.find(c => c.name === command);

    if (!command || !matchingCommand || command === "--help" || command === "help") {
        return this.sendMessage(this.formatHelpMessage());
    }

    // TODO: A matching command was found. Try to parse its arguments.
}

In this example, we’re assuming that the command is going to be the very first word in the string array. Note that some CLI parsers let you specify the command anywhere in the argument — not necessarily directly after the toolname. This can be difficult to do, as you need to search the string array for a matching command name, and then figure out if the string is supposed to be a command name, or if it’s just the value of a parameter. Consider dotnet --myparam run test — is run the command or is test the command, and how do you reliably figure that out?

All of that is, again, beyond the scope of this article. We’ll just keep things super simple and assume that the command name is the very first string in the array. Assuming we have a matching command, we need to get the command’s list of parameters, then have them parse their values. If all parameters parse successfully, we execute the command.

async execute(input: string | string[]): Promise<void> {
    ...
    
    // A matching command was found. Try to parse its arguments.
    const parameters = matchingCommand.onDefineParameters();
    // Get the property name for each property on the parameters object
    const keys = Object.keys(parameters);
    // Remember to skip the first string since we've used it as the name of a command
    const remainingStrings = strings.slice(1);
    // Reduce over each defined parameter and have it parse its value from the string array
    const result = keys.reduce((state, key, index, keys) => {
        if (!state.ok) return state;

        const parameter: Parameter<any> = parameters[key];
        const parseResult = parameter.parse(state.strings);

        if (parseResult.ok) {
            return {
                ok: true,
                strings: parseResult.remaining,
                value: {
                    ...state.value,
                    [key]: parseResult.value
                }
            }
        }

        // TODO: Send error message about this parameter
    }, {ok: true, strings: remainingStrings, value: {}});
}
  • Should also parse a version command or —version flag.
  • Must connect the string read callback to the actions being executed.
  • Should know about the commands or parameters used by Commands, so it can format a help message.
  • This package is not parsing things like dotnet run --something "a really long value with spaces that should be treated as a single string in the string array". This is a very common situation in CLI applications, and something that you should definitely support if you’re building a real CLI argument parser.

Using the package to write a command line application


Learn how to build rock solid Shopify apps with C# and ASP.NET!

Did you enjoy this article? I wrote a premium course for C# and ASP.NET developers, and it's all about building rock-solid Shopify apps from day one.

Enter your email here and I'll send you a free sample from The Shopify Development Handbook. It'll help you get started with integrating your users' Shopify stores and charging them with the Shopify billing API.

We won't send you spam. Unsubscribe at any time.