A command-line tool is a backend with no server in it. It uses the same runtime, the same build and the same packaging, so a tool written in Java starts in milliseconds as a single native executable, with no JVM to install beside it. The arguments are declared with annotations whose names follow picocli, the most widely used Java library for the job, so a picocli command usually needs nothing more than its imports changed.

The Initializr’s Command-line tool project type creates one, for Maven or for Gradle.

A command

A command is a class annotated @Command that implements Runnable, or Callable<Integer> when it decides its own exit code. Each field annotated @Option or @Parameters receives an argument:

import com.codename1.cli.CommandLine.Command;
import com.codename1.cli.CommandLine.Option;
import com.codename1.cli.CommandLine.Parameters;

import java.util.concurrent.Callable;

@Command(name = "greet", mixinStandardHelpOptions = true, version = "greet 1.0",
        description = "Prints a greeting.")
public class Greet implements Callable<Integer> {
    @Option(names = {"-c", "--count"}, description = "How many times.", defaultValue = "1")
    private int count;

    @Option(names = {"-l", "--loud"}, description = "Shout it.")
    private boolean loud;

    @Parameters(paramLabel = "<name>", description = "Who to greet.")
    private String name;

    @Override
    public Integer call() {
        String line = loud ? "HELLO, " + name + "!" : "Hello, " + name;
        for (int i = 0; i < count; i++) {
            System.out.println(line);
        }
        return 0;
    }
}

Run it on the JVM while you work on it, with the arguments in a property:

./mvnw cn1:backend -Dcn1.backend.args="--count 2 Ada"
./gradlew runBackend -Pcn1.backend.args="--count 2 Ada"

mixinStandardHelpOptions adds -h/--help and -V/--version, and the help is written from the annotations:

Usage: greet [-hlV] [--count=<count>] <name>
Prints a greeting.
      <name>                  Who to greet.
  -c, --count=<count>         How many times. Default: 1
  -l, --loud                  Shout it.
  -h, --help                  Show this help message and exit.
  -V, --version               Print version information and exit.

Nothing is read by reflection. The build reads the annotations and generates the code that fills each field and the application’s main, so a mistake in a declaration is a build error rather than a surprise at run time: two options with the same name, a field type there is no conversion for, positional indexes that overlap, a command with nothing to run, or a static or final field.

Options and parameters

The command line is parsed the way picocli and the POSIX conventions parse it:

  • Short options cluster: -lc 2 is -l -c 2, and -c2 is -c 2.

  • A long option takes its value after a space or an equals sign: --count 2, --count=2.

  • -- ends the options; every argument that follows is a positional parameter, even one that starts with a dash.

  • @file reads arguments from a file, one or more per line.

  • A boolean option is a flag. With negatable = true it also has a --no- form, and the help shows --[no-]color.

A field’s type decides how its value is converted: the primitives and their wrappers, String, File, and any enum (an unknown value names the ones it accepts). An array, List, Collection or Set collects every occurrence of an option, and split = "," divides one value into several. Any other type needs a converter, and anything the converter throws is reported as an invalid value:

@Command(name = "listen", description = "Opens a port range.")
public class Ports implements Runnable {
    /// A first and last port.
    public static class Range {
        final int first;
        final int last;

        Range(int first, int last) {
            this.first = first;
            this.last = last;
        }
    }

    /// Turns "8000-8010" into a range; anything it throws becomes a usage error.
    public static class RangeConverter implements ITypeConverter<Range> {
        @Override
        public Range convert(String value) {
            int dash = value.indexOf('-');
            if (dash < 0) {
                int port = Integer.parseInt(value);
                return new Range(port, port);
            }
            return new Range(Integer.parseInt(value.substring(0, dash)),
                    Integer.parseInt(value.substring(dash + 1)));
        }
    }

    @Option(names = "--ports", converter = RangeConverter.class, required = true,
            description = "A port, or a range such as 8000-8010.")
    Range ports;

    @Override
    public void run() {
        System.out.println("ports " + ports.first + " to " + ports.last);
    }
}

defaultValue applies when the argument is absent, and is converted like an argument. It can name an environment variable, with a fallback after :-: defaultValue = "${env:NOTES_FILE:-notes.txt}".

@Parameters takes the arguments that aren’t options. With index it takes one position or a range ("0", "1.."); a collection field with no index takes them all. arity sets how many values an option or parameter takes, as in picocli: "1", "0..1", "2..".

Subcommands

A tool with several verbs, as in git commit, lists them in subcommands. Each is a command of its own; @ParentCommand hands it the command above it, so options given before the verb are shared, and @Spec hands it its own model:

@Command(name = "notes", mixinStandardHelpOptions = true, version = "notes 1.0",
        description = "Keeps notes in a file.",
        subcommands = {Notes.Add.class, Notes.Show.class})
public class Notes {
    @Option(names = {"-f", "--file"}, paramLabel = "<file>",
            description = "Where the notes live.", defaultValue = "${env:NOTES_FILE:-notes.txt}")
    String file;

    @Command(name = "add", description = "Adds a note.")
    public static class Add implements Runnable {
        @ParentCommand
        Notes parent;

        @Option(names = "--tag", split = ",", description = "Tags, comma separated.")
        List<String> tags;

        @Parameters(paramLabel = "<text>", description = "The words of the note.")
        List<String> words;

        @Override
        public void run() {
            System.out.println("adding to " + parent.file + ": " + String.join(" ", words));
        }
    }

    @Command(name = "show", aliases = "ls", description = "Lists the notes.")
    public static class Show implements Runnable {
        @Option(names = "--sort", description = "NEWEST or OLDEST.",
                defaultValue = "NEWEST")
        Order sort;

        @Spec
        CommandSpec spec;

        @Override
        public void run() {
            System.out.println(spec.qualifiedName() + " sorted " + sort);
        }
    }

    public enum Order { NEWEST, OLDEST }
}

notes -f work.txt add --tag home,urgent buy milk runs add; notes ls runs show by its alias. A subcommand may come anywhere after the parent’s options, and notes --help lists the subcommands.

A module may also declare several top-level commands without a group. They become the subcommands of one command named after the application, which is the cn1.cli.name setting in application.properties, or the project’s name.

Exit codes and output

The process exits with:

  • 0 when the command finishes;

  • the value call() returns, for a Callable<Integer>, or getExitCode() for a command that implements IExitCodeGenerator;

  • 1 when the command throws; the exception’s message is printed to standard error;

  • 2 when the command line doesn’t parse; the error and the usage are printed to standard error.

com.codename1.cli.Console helps with terminal output. Console.width() is the width help is wrapped to, from COLUMNS. Console.style(text, Style.BOLD, Style.RED) adds ANSI colour only when colour is wanted: never under NO_COLOR, always under CLICOLOR_FORCE, never on a terminal that declares itself dumb, and never when standard output is redirected to a file or a pipe. Console.isTerminal() answers that last question.

Testing

A command is an ordinary class. The build generates CliApplication, in the package of your top-level command, and its commands() lists the models of every top-level command. The generated class appears after the main sources compile, so your tests can use it. A test runs the command line exactly as the binary would: CommandLine.run("greet", CliApplication.commands(), new String[] {"--count", "2", "Ada"}) returns the exit code, here 0.

Building the executable

./mvnw cn1:backend-package, or ./gradlew backendPackage, translates the tool to C and compiles it with clang into one executable for the machine it runs on, Linux or macOS. Build on each platform you ship to; a continuous integration matrix with one runner per platform is the usual way. The executable needs no JVM and no other files.

The tool compiles against the backend’s class library, the subset of the JDK that the native runtime provides: collections, strings, java.io files and streams, java.time, and the backend’s own networking, database and cryptography APIs. Packaging the tool reports any class outside that subset.

Command-line tools in Python

A tool written in Python uses argparse, as it would under CPython. Put the modules in src/main/python of a command-line project, in place of the Java command; main.py runs as __main__, with the command line in sys.argv:

import argparse
import sys


def main(argv=None):
    parser = argparse.ArgumentParser(description="Prints a greeting.")
    parser.add_argument("name", help="who to greet")
    parser.add_argument("-c", "--count", type=int, default=1, help="how many times")
    parser.add_argument("-l", "--loud", action="store_true", help="shout it")
    parser.add_argument("--version", action="version", version="%(prog)s 1.0")
    args = parser.parse_args(argv)
    greeting = "Hello, %s" % args.name
    for _ in range(args.count):
        print(greeting.upper() if args.loud else greeting)
    return 0


if __name__ == "__main__":
    sys.exit(main())

The build transpiles the program to Java and generates the entry point that runs it, so runBackend, cn1:backend and the native executable all work as they do for a Java tool. Usage, help and error messages read as CPython’s do, with the project’s name as the program name, or cn1.cli.name from application.properties. The process exits with the status CPython would give it:

  • 0 when the main module finishes;

  • the value passed to sys.exit;

  • 1 when an exception escapes, after its traceback is printed to standard error;

  • 2 when argparse rejects the command line, after the usage and the error are printed to standard error.

The Python is transpiled to Java 17, so the build runs on JDK 17 or newer; the Maven project switches to that level by itself once src/main/python exists. argparse covers the module as programs use it: positional and optional arguments, every action and nargs, type, choices, defaults, subcommands with add_subparsers, argument groups, mutually exclusive groups, fromfile_prefix_chars and the help formatters. parse_intermixed_args and colored help aren’t supported.

Commands in a backend

The annotations work in a server too, with no setting to turn them on. A backend whose module also declares @Command classes gets a command line: its administration tasks, such as a data import, a user reset or a report, sit next to the server and share its code.

  • With no arguments the binary starts the server, exactly as it did before.

  • serve starts it as well, and --help lists serve and every command.

  • Any other command runs and exits, and the server never starts.

A backend with no commands still accepts --help, and ignores any other argument, so existing deployments are unaffected.