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 2is-l -c 2, and-c2is-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.@filereads arguments from a file, one or more per line.A boolean option is a flag. With
negatable = trueit 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 aCallable<Integer>, orgetExitCode()for a command that implementsIExitCodeGenerator;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
argparserejects 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.
servestarts it as well, and--helplistsserveand 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.