This is an introduction to creating user-friendly command-line interfaces to Java applications with the picocli framework.

The world of applications may seem dominated by fancy web applications and mobile apps but the need for running applications directly from a command line still exists. Command-line applications are generally not famous for their user-friendliness and for Java-based applications this seems to be much worse than the general.

It doesn’t have to be this way though. The Apache Commons CLI project has been fairly well-known for years and several other more or less well-known alternatives exists to create command-line interfaces for Java applications. Recently a new contestant has emerged. Being light-weight yet with a powerful and flexible API along with a few other interesting features, picocli is on a strong trajectory to become the go-to framework for providing Java applications with modern, user-friendly command-line interfaces.

Adding picocli to your application

First you will need to add picocli to your application. There are several options but for this tutorial the Maven dependency is displayed.

Add the following dependency to your pom.xml.

<dependency>
  <groupId>info.picocli</groupId>
  <artifactId>picocli</artifactId>
  <version>4.7.1</version>
</dependency>

If you are using Gradle or another dependency management system supporting the Maven central repository, it should be relatively straight-forward to adopt. It is also possible to include picocli from the source file, which is a single Java file, directly into your program. This is explained in the online documentation.

Executing the main program

As with all good introductions, let’s start with a Hello World style program. This program will simply write Hello, Picocli to the console.

import picocli.CommandLine;
import picocli.CommandLine.Command;

@Command
public class HelloPicocli implements Runnable {

  public void run() {
    System.out.println("Hello, Picocli");
  }

  public static void main(String[] args) {
    new CommandLine(new HelloPicocli()).execute(args);
  }

}

This is a very basic example but it does contain several important parts.

The class we wish to use as the application entry-point implements the public static void main(String[]) method as usual. The main method invokes the picocli framework through the picocli.CommandLine class.

The class implements the java.lang.Runnable interface and the required public void run() method. Inside this method is where the main application logic is implemented. The class has also been annotated with the picocli.CommandLine.Command (@Command) annotation.

The implementation of the main method creates a new instance of picocli.CommandLine and passes a new instance of our application class to the constructor. We then invoke the execute method on the new CommandLine instance, passing in the command-line arguments. This will parse the command-line arguments and call the run() method on our application instance.

Accepting parameters and options

The vast majority of command-line applications will accept one or more parameters and/or options in order to instruct them what to work on and potentially modify the processing. Before we continue, we should briefly discuss the difference between parameters and options.

Parameters are often used to specify the data (e.g. files or directories) which are to be worked on. The options modify the application behaviour, changing how the data is being processed.

Options are generally either binary (their presence enable or disable some behaviour) or they act as a key/value pair. In the latter case, the presence as well as it’s assigned value, modifies the behaviour of the program.

For example, the command ls -l /var/log has the option -l, instructing the ls program to use “long listing” (which displays more information for each entry). The last part, the /var/log, is a parameter and instructs the ls command which directory or file to display the information for.

The following program extends the HelloPicocli example to accept one or more parameters with messages, which are to be displayed instead of the Hello, Picocli message.

import java.util.List;
import picocli.CommandLine;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;

public class HelloCustomMessage implements Runnable {

  @Parameters
  private List<String> messages;

  @Option(names = { "-p", "--prefix" })
  private String prefix;

  public void run() {
    if (messages == null || messages.size() < 1) {
      System.out.printf("%sHello, Picocli%n", prefix == null ? "" : prefix);
    } else {
      for (String msg : messages) {
        System.out.printf("%s%s%n", prefix == null ? "" : prefix, msg);
      }
    }
  }

  public static void main(String[] args) {
    new CommandLine(new HelloCustomMessage()).execute(args);
  }

}

The @Parameters annotation specify the variable, which will hold the messages passed in as parameters. Here we use a java.util.List<String> to hold the parameters.

The @Option annotation specify that the value passed with either -p (short form) or --prefix (long form) is to be stored in the variable prefix.

If we wanted to make it mandatory to provide at least one parameter, then we can pass the arity parameter to the @Parameters annotation

  @Parameters(arity="1..*")

With this, picocli will check that at least one parameter has been specified. As a consequence, we can simplify our program to only handle the case of parameters. If the program is invoked without parameters, our run() method will not get invoked and instead a message similar to the following will be displayed to the user

Missing required parameter: <messages>
Usage: <main class> [-p=<prefix>] <messages>...
      <messages>...
  -p, --prefix=<prefix>

Accepting other types of parameters and options

So far our parameter and option have been passed as Strings. What if we wanted our option to be an integer or point to a file? The command-line parameters are passed to our Java program as Strings and it seems we would be required to parse these to our desired data types. Luckily picocli already contains a lot of functionality to do this for us automatically.

Let’s start by adding a boolean flag to include a standard greeting before the messages.

  @Option(names = { "-g", "--greeting"})
  private boolean displayGreeting;

And in our run() method we add the following:

  public void run() {
    if (displayGreeting) {
      System.out.println("Welcome! Here are your messages:");
    }
    .....
  }

We could also add a parameter, which allows us to repeat each message a specified number of times. If the parameter is not specified, we want the default behaviour to only print each message once.

  @Option(names = { "-r", "--repeat"})
  private int repeat = 1;

If we want to know whether the parameter was specifed or not, then we can use the wrapper classes, in our example that would be java.lang.Integer.

  @Option(names = { "-r", "--repeat"})
  private Integer repeat;

With this change, we can check if repeat is null which would be the case if the parameter is not specified.

As a final demonstration of option types, we will add an option to read the messages from a text file.

  @Option(names = { "-i", "--input"})
  private Path messagesFile;

Now we can work with the messagesFile as a java.nio.file.Path. Note that this will simply be a Path object. There is no guarantee that the path points to a valid file. If you want to provide such checks, you can use the functionality of java.nio.file.Files.

These are just some of the basic conversions picocli is capable of performing. More information can be found in the Built-in Types section of the picocli documentation.

Error handling and return code

Your program may be called directly from an interactive terminal but a lot of command-line applications are intended to be called from scripts. This may be simple shell scripts or perhaps being part of job scheduling and workload management systems such as Slurm.

For this reason it is important to provide a return code from our application. For normal execution, we want our program to return with an exit code 0. This normally indicates success. For error conditions, we want to return a non-zero value. All return codes are program dependent.

It is highly recommend to keep all your return codes in a specific place, such as an enum or static final fields of a single class. Make sure that you properly document the return codes and what their consequences are. Some things to consider is whether the return code is just a warning or an error and whether it is a permanent sitution (file unreadable) or may succeed when attempted again (network issue). If there is any chance that any part of the processing may not have been undone (rolled back), then be sure to make this very clear as well. If you think it makes sense, you may want to segment your return codes. For example 1 - 20 are warnings and above 20 are errors.

As stated above, make sure you document all return codes which may be returned. Speaking from experience (especially with certain closed-source commercial vendors), it is highly frustrating when a program aborts without any meaningful message and returns an undocumented return code.

So back to our application. In order to easily return a code from our application, we will change it to implement java.util.concurrent.Callable<Integer> instead of java.lang.Runnable. As a consequence, we also need to replace the public void run() method with public Integer call().

The value returned from call() will then be the return value from picocli.CommandLine.execute(String...) in our main method. Do note that picocli does not automatically return this value from the Java process to the caller. For this you need to explicitly call java.lang.System.exit(int) with the return code to return. It is recommended to only call System.exit in your main method as this will exit from the JVM. Most static code analyzers will also warn, if System.exit is called anywhere else than main.

Final application

So, let take a look at the final application. This implements the return code. If the program was successful then zero is returned. If there was an error, in this case the optional file with messages could not be read, then the value 1 is returned instead.

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.Callable;

import picocli.CommandLine;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;

public class HelloFinal implements Callable<Integer> {

  @Parameters
  private List<String> messages;

  @Option(names = { "-p", "--prefix" })
  private String prefix;

  @Option(names = { "-g", "--greeting" })
  private boolean displayGreeting;

  @Option(names = { "-r", "--repeat" })
  private int repeat = 1;

  @Option(names = { "-i", "--input" })
  private Path messagesFile;

  public Integer call() {
    if (displayGreeting) {
      System.out.println("Welcome! Here are your messages:");
    }

    if (messagesFile != null) {
      try {
        if (messages == null) {
          messages = new ArrayList<>();
        }
        messages.addAll(Files.readAllLines(messagesFile));
      } catch (IOException e) {
        System.err.printf("Error reading file %s%n", messagesFile);
        return 1;
      }
    }

    if (messages == null || messages.size() < 1) {
      messages = Collections.singletonList("Hello, Picocli");
    }

    for (String msg : messages) {
      for (int i = 0; i < repeat; i++) {
        System.out.printf("%s%s%n", prefix == null ? "" : prefix, msg);
      }
    }

    return 0;
  }

  public static void main(String[] args) {
    int rc = new CommandLine(new HelloFinal()).execute(args);
    System.exit(rc);
  }

}

Standard options

Which options and parameters to expose is strongly application dependent. There are however some standard parameters, which are often used with Linux / GNU commands. For example -h or --help for displaying some information on command usage in the console. Another common parameter is -v or --verbose for increasing the logging information written by the application. Some applications also support -vv and --very-verbose to increase the logging information further. If you are using a logging framework, then you could use the -v to set the application logger to level debug and -vv to set it to trace.

Some standards for command-line interfaces: