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.

Console output and prompts

The com.codename1.cli package has what a tool needs on a terminal: styled text, tables, trees, progress bars, spinners and interactive questions.

Console.print(new Table("Service", "Status", "Replicas")
        .align(2, Table.Align.RIGHT)
        .addRow("api", Console.style("running", Console.Style.GREEN), 3)
        .addRow("worker", Console.style("degraded", Console.Style.YELLOW), 2)
        .render());

try (ProgressBar bar = new ProgressBar("upload", 40).unit("files")) {
    for (int i = 0; i < 40; i++) {
        upload(i);
        bar.step();
    }
}

Spinner spinner = Spinner.start("waiting for health checks");
waitForHealthy();
spinner.succeed("healthy");

Console answers questions about the terminal. width() and height() come from COLUMNS and LINES when they’re set, then from the terminal itself. isTerminal(), isErrorTerminal() and isInputTerminal() say which standard streams are attached to one. colorEnabled() decides whether escape codes are written: never under NO_COLOR, always under CLICOLOR_FORCE, never on a dumb terminal or when standard output goes to a file or a pipe. colorLevel() reports 16, 256 or true color from COLORTERM and TERM, and Ansi builds styled text with any of them, converting a color the terminal can’t show to the nearest one it can. Ansi also moves the cursor, erases lines, switches to the alternate screen and writes hyperlinks.

A table with green, yellow and red status cells, a colored file tree, a 24-bit color ramp and the text attributes

Table sizes its columns to the terminal and wraps cells that don’t fit; Tree draws a hierarchy. Both measure text by what it looks like on screen, so wide characters, emoji and styled text line up. Without a UTF-8 locale they draw with ASCII.

ProgressBar shows a count, a percentage, the rate and the time left, or a moving bar when the total isn’t known; ProgressBar.track(items, "label") wraps a loop. MultiProgress shows several bars at once, and its println writes a log line above them. A Spinner shows that work is under way and ends with a success, failure, warning or information line. They all redraw in place on a terminal. When the output goes elsewhere they print a few plain lines instead, so a log file or a build server shows what happened without escape codes.

Spinner results marked with a check, a warning and an information sign above three progress bars: one finished in green and two under way in cyan

Prompt asks questions:

String env = Prompt.select("Environment", Arrays.asList("staging", "production"));
List<String> regions = Prompt.multiSelect("Regions", Arrays.asList("us-east", "eu-west", "ap-south"));
String token = Prompt.password("API token");
if (!Prompt.confirm("Deploy to " + env + "?", false)) {
    return 1;
}

On a terminal the questions are interactive. Arrow keys move through a list, typing filters it, space checks an item in a multiple choice, and a text answer can be edited with the usual keys. Prompt.Text, Prompt.Select and Prompt.MultiSelect add defaults, validation, masks and page sizes. When standard input isn’t a terminal, each question reads a line instead: a number or a label for a choice, a comma separated list for a multiple choice. That keeps a tool scriptable with printf 'staging\n1,2\n' | tool. Ctrl-C at a question throws Prompt.Cancelled, and the command exits with 130, as a shell reports an interrupted program. The terminal is restored however the program ends, including on Ctrl-C and kill.

A choice from a list, with the arrow on the second item:

A select prompt after an answered text question, with the arrow and highlight on staging

A multiple choice, with two regions checked:

A multiple choice prompt with two regions checked in green and the arrow on the last region

A masked password, an answered confirmation and a number that failed validation:

A masked token, a confirmation answered Yes and a number prompt showing a red error for 12

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. On a terminal that accepts color, the usage line, program name and section headings are colored as in CPython 3.14. parse_intermixed_args isn’t supported.

The console API is available to Python as the cn1console module:

import cn1console as console


def main():
    console.print_table([["api", 3], ["worker", 2]],
                        headers=["Service", "Replicas"], align={1: "right"})
    for name in console.progress(["a.txt", "b.txt", "c.txt"], label="upload"):
        pass  # upload the file
    with console.spinner("waiting for health checks"):
        pass  # poll until healthy
    env = console.select("Environment", ["staging", "production"])
    regions = console.checkbox("Regions", ["us-east", "eu-west", "ap-south"])
    if console.confirm("Deploy to %s?" % env):
        console.echo("deploying to", ", ".join(regions), style="bold")

The standard modules read the same terminal: input(), getpass.getpass(), os.get_terminal_size(), shutil.get_terminal_size() and sys.stdout.isatty() behave as they do under CPython. A question cancelled with Ctrl-C raises KeyboardInterrupt, which ends the program with status 130.

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.