1. Overview

In this article, we’ll explore how to create user-friendly command-line applications in Kotlin using the experimental kotlinx-cli library. The library is responsible for parsing and validating arguments from the command line to our application.

2. Adding the Maven Dependency

Let’s add the required Maven dependency in pom.xml:

<dependency>
    <groupId>org.jetbrains.kotlinx</groupId>
    <artifactId>kotlinx-cli-jvm</artifactId>
    <version>0.3.5</version>
</dependency>

We can find the latest version of kotlinx-cli dependencies on Maven Central.

3. Handling Arguments

CLI arguments are values that start with the prefix (-/- -).

To parse the arguments from the CLI, we need ArgParser, which allows us to configure, validate, and consume the arguments.

Let’s create this parser object using ArgParser with a program name. The parsing of arguments is done by passing args to the parse() method:

fun main(args: Array<String>) {
    val parser = ArgParser("example")
    parser.parse(args)
}

The library creates the helper utility out of the box, which we can verify by running the example -h command:

Usage: example options_list
Options:
    --help, -h -> Usage info

3.1. Optional Arguments

To make the app useful, we need to define arguments and consume those arguments in the application. Let’s do this by calling the option() method on our parser instance:

fun main(args: Array<String>) {
    val parser = ArgParser("example")

    val name by parser.option(
        ArgType.String,
        shortName = "n",
        description = "User name"
    )

    parser.parse(args)

    println("Hello, $name!")
}

We consume the argument in the name variable using Kotlin’s delegation feature.

By default, the name argument is optional; hence, it’s nullable. We can check all the other details we pass to option() by running example -h:

Usage: example options_list
Options:
    --name, -n -> User name { String }
    --help, -h -> Usage info

The { String } is the option’s type, which is mandatory to create an option. The library supports ArgType.String, ArgType.Boolean, ArgType.Int, ArgType.Double, and ArgType.Choice.

To print the name, we run example -n John, where -n is the shortName command for name:

Hello, John!

3.2. Required Arguments

To make the option mandatory, we use the required() extension on the option’s type:

val name by parser.option(
    ArgType.String,
    shortName = "n",
    description = "User name"
).required()

Now, name becomes non-nullable, and running the command without the -n option will throw an error with helper info:

Value for option --name should always be provided on the command line.
Usage: example options_list
Options:
    --name, -n -> User name (always required) { String }
    --help, -h -> Usage info

3.3. Default Arguments

To set a default value for an option, we pass the default value to the default() extension on the option’s type:

val name by parser.option(
    ArgType.String,
    shortName = "n",
    description = "User name"
).default("N/A")

This default value is type-safe and will also appear in the helper info:

Usage: example options_list
Options:
    --name, -n [N/A] -> User name { String }
    --help, -h -> Usage info

If we run the command without the -n option, it will print Hello, N/A!

Remember, since required() and default() are contradictory, we cannot use them together.

3.4. Multiple Arguments

To consume multiple arguments, we use the multiple() extension on the option’s type. This will return a list of names instead of a single value:

fun main(args: Array<String>) {
    val parser = ArgParser("example")

    val names by parser.option(
        ArgType.String,
        shortName = "n",
        description = "User name"
    ).multiple()

    parser.parse(args)
    names.forEach {
        println("Hello, $it!")
    }
}

We pass multiple arguments using -n multiple times in the command line, as in example -n John -n Smith. The application prints names by iterating over each name:

Hello, John!
Hello, Smith!

4. Choice

The library also supports the Choice type, which can be defined in two ways.

4.1. Enum Choice

To provide choices from multiple options, we can use an enum directly, which will automatically convert its string representation as an argument:

enum class Format {
    HTML,
    CSV,
    PDF
}

fun main(args: Array<String>) {
    val parser = ArgParser("example")
    val format by parser.option(
        ArgType.Choice<Format>(),
        shortName = "f",
        description = "Format for the output file"
    )
    parser.parse(args)
    println("Hello, $format")
}

If we run example -f csv, where “csv” is the string representation of the enum, it will print Hello, CSV.

If we pass an incorrect string, such as example -f cs, then it will throw an error with helper info:

Option format is expected to be one of [html, csv, pdf]. cs is provided.
Usage: example options_list
Options:
    --name, -n -> User name { String }
    --format, -f -> Format for the output file { Value should be one of [html, csv, pdf] }
    --help, -h -> Usage info

4.2. Custom Choice

We can define a custom list of choices. However, we need to manually provide the string representation of those choices:

fun main(args: Array<String>) {
    val parser = ArgParser("example")

    val typeFormat by parser.option(
        ArgType.Choice(listOf("html", "csv", "pdf"), { it }),
        shortName = "sf",
        description = "Format as a string for the output file"
    )

    parser.parse(args)

    println("Hello, $format")
}

Here, the { it } is a transformation function and is used to convert the custom choice to a string representation. Since we’re using a list of strings, it simply returns the same value.

We can also mix up the choice with the multiple(), default(), or required() extensions:

val typeFormats by parser.option(
    ArgType.Choice(listOf("html", "csv", "pdf"), { it }),
    shortName = "sf",
    description = "Format as a string for the output file"
).default("csv").multiple()

5. Subcommands

Subcommands can prove useful when an application needs a robust command-line interface and executes distinct actions with varying arguments.

To create a subcommand, we extend the Subcommand class. To use it, we submit it to the subCommands() method:

class Multiply : Subcommand("mul", "Multiply") {

   val numbers by argument(ArgType.Int, description = "Numbers").multiple(3)
	 val result = 0
   override fun execute() {
       result = numbers.reduce { acc, it -> acc * it }
   }
 }

fun main(args: Array<String>) {
    val parser = ArgParser("example")
    val multiple = Multiply()
    parser.subcommands(multiple)
    parser.parse(args)
    println("Result ${multiple.result}")
}

The above Multiply subcommand is responsible for multiplying all its arguments and saving the result.

We define the arguments of the subcommand using the argument property, which is part of the Subcommand class hierarchy.

The execute() method is called when all outer options have been validated by parse(args), and then when a subcommand is matched, it calls execute().

To add the result with the outer option, we can simply do:

fun main(args: Array<String>) {
    val parser = ArgParser("example")
    val outNumber by parser.option(ArgType.Int, "outer number", "o").default(0)
    val multiple = Multiply()
    parser.subcommands(multiple)
    parser.parse(args)
    println("Total ${outNumber.plus(multiple.result)}")
}

If we run example -o 4 mul 1 2 3, then it will print Total 10.

6. Conclusion

In this article, we learned how to use the kotlinx-cli library to parse and validate arguments from the command line using various rules. These rules include making an argument optional or required, allowing multiple options, and implementing multiple choices using various subcommands.

All the examples in this article can be found over on GitHub.

Comments are closed on this article!